diff --git a/client/pyscripts/pm5_battery_test.py b/client/pyscripts/pm5_battery_test.py index e28b213d4..7c1c9899b 100755 --- a/client/pyscripts/pm5_battery_test.py +++ b/client/pyscripts/pm5_battery_test.py @@ -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_.csv)") + # ignore anything the pm3 wrapper may inject into argv + args, _ = ap.parse_known_args() + return args + + +# `hw status` prints one "Label..... " 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__': diff --git a/client/pyscripts/pm5_battery_test_graph.py b/client/pyscripts/pm5_battery_test_graph.py index e07dc96b8..50f972a41 100755 --- a/client/pyscripts/pm5_battery_test_graph.py +++ b/client/pyscripts/pm5_battery_test_graph.py @@ -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