pm5_battery_test: merge nieldk version and add test scenarios

This commit is contained in:
Philippe Teuwen
2026-08-30 13:44:46 +02:00
parent 1d87a6c192
commit bf32513d1b
2 changed files with 151 additions and 58 deletions
+150 -57
View File
@@ -1,76 +1,169 @@
#!/usr/bin/env -S uv run pm3 -y
import re
import time
import csv
"""
PM5 / BWM battery discharge (or charge) logger.
Polls `hw status` at a fixed interval and logs the fuel-gauge readings to CSV
(plot with pm5_battery_test_graph.py).
If not yet done, you should set up the gauge first for meaningful numbers:
hw bwmsetcap --cap 500 # your cell's mAh
then fully charge
Usage:
pm5 --> script run pm5_battery_test.py
pm5 --> script run pm5_battery_test.py --cutoff 3200 --interval 10 --scenario idle
To trigger firmware auto-shutdown: keep the load on and let it run past the
firmware floor for the full debounce window, e.g.
pm5 --> script run pm5_battery_test.py --cutoff 0 --scenario hf_field_on
(do this on battery, not USB - the shutdown bails while USB power is good.)
NOTE: this deliberately runs a Li-ion cell down under load. Don't leave it
unattended, and don't push --cutoff below ~3000 mV.
"""
import os
import re
import csv
import time
import argparse
import pm3
# Limit the number of loops
DEBUG_LIMIT = 0
# DEBUG_LIMIT = 5
scenarios = {
'idle': {
"on_start": lambda _: None,
"in_loop": lambda _, s: time.sleep(s),
"on_stop": lambda _: None
},
'hf_field_on': {
"on_start": lambda p: p.console("hf 14a raw -ak 00"),
"in_loop": lambda _, s: time.sleep(s),
"on_stop": lambda p: p.console("hf 14a read --drop"),
},
'hf_14a_polling': {
"on_start": lambda _: None,
# one poll takes about 0.5s when a card is present
"in_loop": lambda p, s: p.console(f"hf 14a read -n {2*s}"),
"on_stop": lambda _: None,
}
}
# To test fast discharge, you can activate the HF field before running this script
# hf 14a raw -ak 00
def parse_args():
ap = argparse.ArgumentParser(
description="PM5/BWM battery discharge/charge logger.")
ap.add_argument("--cutoff", type=float, default=3200.0,
help="stop below this battery voltage in mV "
"(0 disables the voltage stop). default: 3200")
ap.add_argument("--interval", type=float, default=10.0,
help="seconds between polls. default: 10")
ap.add_argument("--scenario", default="idle", choices=scenarios.keys(),
help="scenario to run. default: idle")
ap.add_argument("--limit", type=int, default=0,
help="stop after N samples (0 = unlimited). default: 0")
ap.add_argument("--csv", default=None,
help="output CSV path "
"(default: battery_log_<timestamp>.csv)")
# ignore anything the pm3 wrapper may inject into argv
args, _ = ap.parse_known_args()
return args
# `hw status` prints one "Label..... <num> <unit>" line per field. Pull the
# first number out, tolerating a missing/garbled line (comms hiccup) by
# returning None instead of crashing the run.
def grab(output, label, unit):
for line in output.split("\n"):
if label in line:
m = re.search(r"([-\d.]+)\s*" + re.escape(unit), line)
if m:
return float(m.group(1)), line
return None, None
def fmt(v):
return f"{v:g}" if v is not None else "?"
def main():
args = parse_args()
scenario = scenarios['idle']
csv_path = args.csv or f"battery_log_{time.strftime('%Y-%m-%d_%H-%M-%S')}.csv"
p = pm3.pm3()
voltage = current = temp = capacity = soc = 0
stop = False
print(f"Logging to {csv_path}")
print(f"cutoff={args.cutoff:g} mV interval={args.interval:g}s "
f"scenario={args.scenario} limit={args.limit or 'none'}")
csv_path = f"battery_log_{time.strftime('%Y-%m-%d_%H-%M-%S')}.csv"
csv_file = open(csv_path, "w", newline="")
writer = csv.writer(csv_file)
writer.writerow(["timestamp", "voltage_mV", "current_mA", "temp_C", "capacity_mAh", "soc_percent"])
try:
# `with` + per-row flush() keeps the CSV valid even if the run is
# interrupted or the device drops off mid-log.
with open(csv_path, "w", newline="") as csv_file:
writer = csv.writer(csv_file)
writer.writerow(["timestamp", "elapsed_s", "voltage_mV", "current_mA", "temp_C", "capacity_mAh", "soc_percent"])
n = 0
while DEBUG_LIMIT == 0 or n < DEBUG_LIMIT:
p.console("hw status")
for line in p.grabbed_output.split('\n'):
if "Battery voltage" in line:
# print(line)
voltage = float(re.search(r"([\d.]+)\s*mV", line).group(1))
if voltage < 3200:
print(f"Voltage {voltage} mV is below 3200 mV, stopping test.")
stop = True
if "Battery current" in line:
# print(line)
current = float(re.search(r"([\d.]+)\s*mA", line).group(1))
if "idle" in line:
stop = True
if "Battery SoC" in line:
# print(line)
soc = float(re.search(r"([\d.]+)\s*%", line).group(1))
if "Temp (gauge)" in line:
# print(line)
temp = float(re.search(r"([\d.]+)\s*C", line).group(1))
if "Remaining capacity" in line:
# print(line)
capacity = float(re.search(r"([\d.]+)\s*mAh", line).group(1))
timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
print(f"[{timestamp}] Voltage: {voltage} mV, Current: {current} mA, "
f"Temp: {temp} C, Capacity: {capacity} mAh, SoC: {soc} %")
writer.writerow([timestamp, voltage, current, temp, capacity, soc])
csv_file.flush()
os.fsync(csv_file.fileno())
if stop or (DEBUG_LIMIT != 0 and n >= DEBUG_LIMIT):
break
scenario["on_start"](p)
t0 = time.monotonic()
n = 0
while args.limit == 0 or n < args.limit:
p.console("hw status --ms 0")
out = p.grabbed_output
time.sleep(10)
# if current > 0:
# # Charging
# time.sleep(10)
# else:
# # Discharging
# p.console("hf 14a read -n 20")
# p.console("hf 14a raw -ak 00")
n += 1
voltage, _ = grab(out, "Battery voltage", "mV")
current, cline = grab(out, "Battery current", "mA")
soc, _ = grab(out, "Battery SoC", "%")
temp, _ = grab(out, "Temp (gauge)", "C")
capacity, _ = grab(out, "Remaining capacity", "mAh")
csv_file.close()
p.console("hf 14a read --drop")
elapsed = time.monotonic() - t0
ts = time.strftime("%Y-%m-%d %H:%M:%S")
print(f"[{ts}] +{elapsed:8.1f}s "
f"V={fmt(voltage)} mV I={fmt(current)} mA "
f"T={fmt(temp)} C Cap={fmt(capacity)} mAh "
f"SoC={fmt(soc)} %")
writer.writerow([
ts, f"{elapsed:.1f}",
voltage if voltage is not None else "",
current if current is not None else "",
temp if temp is not None else "",
capacity if capacity is not None else "",
soc if soc is not None else "",
])
csv_file.flush()
os.fsync(csv_file.fileno())
# --- stop conditions ---
if args.cutoff > 0 and voltage is not None and voltage < args.cutoff:
print(f"Voltage {voltage:g} mV below cutoff "
f"{args.cutoff:g} mV, stopping.")
break
# gauge marks the current line "idle" when neither charging nor
# discharging - nothing more to log
if cline is not None and "idle" in cline:
print("Gauge current idle, stopping.")
break
scenario["in_loop"](p)
n += 1
except KeyboardInterrupt:
print("\nInterrupted, stopping.")
except Exception as e: # e.g. device powered off / comms dropped mid-run
print(f"\nStopped: {e}")
finally:
try:
scenario["on_stop"](p)
except Exception as e:
print(f"(Error: {e})")
print(f"CSV saved: {csv_path}")
if __name__ == '__main__':
+1 -1
View File
@@ -82,7 +82,7 @@ def plot(rows, output_path):
axes[-1].set_xlabel("Elapsed time (minutes)")
fig.suptitle("Battery status over time")
fig.tight_layout(rect=[0, 0, 1, 0.97])
fig.tight_layout(rect=(0.0, 0.0, 1.0, 0.97))
fig.savefig(output_path, dpi=150)
print(f"Saved plot to {output_path}")
return output_path