#!/usr/bin/env python3 """Log the coupon test rig's serial output to CSV files. Usage: pip install pyserial python3 coupon_logger.py /dev/ttyACM0 # Linux (COM3 etc. on Windows) python3 coupon_logger.py /dev/ttyACM0 --new # start a fresh test Files in --out (default ./coupon_logs), appended across restarts: data.csv one row per channel per minute (D records) summary.csv one row per channel per cycle (S records) events.csv step changes, faults, resumes (E records) raw.txt every line from the rig, with the PC time Type rig commands (STATUS, STOP, CAL, RUN, NEW, HELP) and press Enter to send them. Ctrl+C quits; the rig keeps running without the PC. Opening the port resets the Arduino. The sketch saves its state to EEPROM and resumes, so restarting this logger does not restart the test. """ import argparse import csv import datetime import pathlib import sys import threading import time try: import serial except ImportError: sys.exit("pyserial is missing: pip install pyserial") HEADERS = { "D": ["pc_time", "time_s", "ch", "state", "cycle", "V", "mA", "mAh", "mWh"], "S": ["pc_time", "time_s", "ch", "cycle", "dis_mAh", "dis_mWh", "avg_dis_V", "chg_mAh", "chg_mWh", "CE_pct", "EE_pct"], "E": ["pc_time", "time_s", "ch", "message"], } NAMES = {"D": "data.csv", "S": "summary.csv", "E": "events.csv"} def open_csv(path, header): new = not path.exists() or path.stat().st_size == 0 f = open(path, "a", newline="") w = csv.writer(f) if new: w.writerow(header) return f, w def send_keyboard(port): for line in sys.stdin: port.write((line.strip() + "\n").encode()) def main(): ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) ap.add_argument("port", help="serial port, e.g. /dev/ttyACM0 or COM3") ap.add_argument("--out", default="coupon_logs", help="output folder") ap.add_argument("--new", action="store_true", help="send NEW: clear the saved state and start a new test") a = ap.parse_args() out = pathlib.Path(a.out) out.mkdir(parents=True, exist_ok=True) files = {k: open_csv(out / NAMES[k], HEADERS[k]) for k in NAMES} raw = open(out / "raw.txt", "a") with serial.Serial(a.port, 115200, timeout=2) as port: time.sleep(2.5) # the Uno resets when the port opens if a.new: port.write(b"NEW\n") threading.Thread(target=send_keyboard, args=(port,), daemon=True).start() print(f"logging to {out.resolve()} (Ctrl+C to quit)") try: while True: line = port.readline().decode(errors="replace").strip() if not line: continue pc = datetime.datetime.now().isoformat(timespec="seconds") raw.write(f"{pc} {line}\n") raw.flush() kind, _, rest = line.partition(",") if kind in files and rest: if kind == "E": # the message may contain commas t, ch, msg = (rest.split(",", 2) + ["", ""])[:3] fields = [t, ch, msg] else: fields = rest.split(",") f, w = files[kind] w.writerow([pc] + fields) f.flush() if kind == "S": h = HEADERS["S"][1:] r = dict(zip(h, fields)) print(f"{pc} CH{r['ch']} cycle {r['cycle']}: " f"dis {r['dis_mAh']} mAh, chg {r['chg_mAh']} mAh, " f"CE {r['CE_pct'] or '-'} %") elif kind == "E": print(f"{pc} CH{fields[1]} {fields[2]}") else: print(line) # comments, STATUS output except KeyboardInterrupt: print("\nstopped logging (the rig keeps running)") finally: for f, _ in files.values(): f.close() raw.close() if __name__ == "__main__": main()