#!/usr/bin/env python3 """Generate the coupon test rig wiring diagram page (HTML + inline SVG).""" import sys OUT = sys.argv[1] def f(v): return f"{v:.1f}".rstrip("0").rstrip(".") class S: def __init__(self): self.p = [] def add(self, s): self.p.append(s) def wire(self, *pts, cls="w"): self.add(f'') def dot(self, x, y): self.add(f'') def term(self, x, y): self.add(f'') def t(self, x, y, s, cls="lbl", a="start"): self.add(f'{s}') def rect(self, x, y, w, h, cls="box"): self.add(f'') def res_h(self, x1, x2, y, name, val, above=True): mid = (x1 + x2) / 2 self.wire((x1, y), (mid - 20, y)); self.wire((mid + 20, y), (x2, y)) self.rect(mid - 20, y - 7, 40, 14, "part") dy = -12 if above else 24 self.t(mid, y + dy, f"{name} {val}", "pl", "middle") def res_v(self, x, y1, y2, name, val, right=True, trim=False): mid = (y1 + y2) / 2 self.wire((x, y1), (x, mid - 20)); self.wire((x, mid + 20), (x, y2)) self.rect(x - 7, mid - 20, 14, 40, "part") if trim: self.add(f'') self.t(x + (14 if right else -14), mid - 2, name, "pl", "start" if right else "end") self.t(x + (14 if right else -14), mid + 12, val, "pv", "start" if right else "end") def gnd(self, x, y): self.wire((x, y), (x, y + 8)) for i, w in enumerate((14, 9, 4)): self.add(f'') def svg(self, w, h, label, minw): defs = ('' '') return f'{defs}{"".join(self.p)}' # ---------------------------------------------------------------- channel def channel(): s = S() # +12 V and fuse s.term(60, 110); s.t(52, 114, "+12 V", "lblb", "end") s.wire((60, 110), (82, 110)) s.rect(82, 103, 44, 14, "part"); s.add('') s.t(104, 96, "F1 100 mA PTC", "pl", "middle") s.wire((126, 110), (170, 110)) # U1 LM317 constant current s.rect(170, 85, 80, 50, "ic"); s.t(210, 102, "U1 LM317", "icl", "middle"); s.t(210, 115, "CC", "pv", "middle") s.t(174, 130, "IN", "pin"); s.t(246, 130, "OUT", "pin", "end"); s.t(210, 130, "ADJ", "pin", "middle") s.res_h(250, 340, 110, "Rcc", "32.4 Ω") s.wire((210, 135), (210, 165), (340, 165), (340, 110)); s.dot(340, 110) s.t(275, 182, "38.4 mA = 28 mA cell + 10.4 mA U2 divider", "note", "middle") s.wire((340, 110), (380, 110)) # U2 LM317 constant voltage s.rect(380, 85, 80, 50, "ic"); s.t(420, 102, "U2 LM317", "icl", "middle"); s.t(420, 115, "CV limit", "pv", "middle") s.t(384, 130, "IN", "pin"); s.t(456, 130, "OUT", "pin", "end"); s.t(420, 130, "ADJ", "pin", "middle") s.wire((460, 110), (650, 110)); s.dot(500, 110); s.dot(560, 110) s.res_v(500, 110, 200, "R1", "120 Ω") s.wire((500, 200), (420, 200)); s.wire((420, 135), (420, 200)); s.dot(420, 200) s.res_v(420, 200, 290, "R2", "100 Ω trim", right=False, trim=True) s.gnd(420, 290) s.t(372, 316, "set 1.50 V → R2 ≈ 24 Ω · 1.80 V → R2 ≈ 53 Ω", "note", "middle") # C1 s.wire((560, 110), (560, 150)) s.add('') s.wire((560, 158), (560, 180)); s.gnd(560, 180) s.t(560, 214, "C1 10 µF", "pl", "middle") # K2 mode relay (SPDT) s.wire((650, 110), (650, 132)); s.term(650, 136) s.term(650, 214); s.term(706, 175) s.add('') s.t(640, 140, "NO", "pin", "end"); s.t(640, 218, "NC", "pin", "end"); s.t(712, 168, "COM", "pin") s.add('') s.t(675, 246, "K2 MODE", "lblb", "middle"); s.t(675, 260, "SPDT signal relay", "pv", "middle") s.t(675, 274, "off = discharge · on = charge", "pv", "middle") # K1 connect relay (SPST-NO) s.wire((706, 175), (770, 175)); s.term(774, 175); s.term(826, 175) s.add('') s.add('') s.t(800, 212, "K1 CONNECT", "lblb", "middle"); s.t(800, 226, "SPST-NO reed relay", "pv", "middle") s.t(800, 240, "off = rest (open circuit)", "pv", "middle") # node P, shunt, node B, cell s.wire((826, 175), (870, 175)); s.dot(870, 175); s.t(862, 196, "P", "node", "end") s.res_h(870, 960, 175, "Rsh", "1 Ω 0.1%") s.dot(960, 175); s.t(968, 196, "B", "node") s.wire((960, 175), (1040, 175), (1040, 214)) # cell symbol s.add('') s.add('') s.wire((1040, 226), (1040, 300)); s.gnd(1040, 300) s.t(1070, 210, "+ cathode (Ti tab)", "pl"); s.t(1070, 232, "− zinc (2 tabs joined)", "pl") s.t(1070, 260, "COUPON CELL", "lblb"); s.t(1070, 274, "C1 … C6", "pv") s.t(1070, 288, "keep − lead short", "pv") # ADC taps s.wire((870, 175), (870, 440)); s.wire((960, 175), (960, 440)) s.rect(830, 440, 230, 86, "ic") s.t(945, 462, "ADS1115 (shared, ±2.048 V)", "icl", "middle") s.t(870, 482, "Ax ← P", "pin", "middle"); s.t(960, 482, "Ay ← B", "pin", "middle") s.t(945, 502, "V cell = B", "pv", "middle"); s.t(945, 517, "I = (P − B) / 1 Ω (+ = charging)", "pv", "middle") s.t(1070, 470, "sense wires go", "note"); s.t(1070, 484, "straight to the", "note"); s.t(1070, 498, "tab clips (no", "note"); s.t(1070, 512, "load current)", "note") # discharge sink s.wire((650, 214), (650, 330)) s.rect(620, 330, 60, 60, "ic"); s.t(650, 356, "Q1", "icl", "middle"); s.t(650, 371, "2N7000", "pv", "middle") s.t(656, 343, "D", "pin"); s.t(656, 386, "S", "pin"); s.t(626, 364, "G", "pin") s.wire((650, 390), (650, 405)); s.dot(650, 405) s.res_v(650, 405, 485, "Rs", "10 Ω 0.1%") s.gnd(650, 485) s.add('') s.t(470, 344, "+", "pinb"); s.t(470, 386, "−", "pinb"); s.t(498, 364, "U3", "icl", "middle") s.t(498, 414, "½ MCP6002 · 5 V", "pv", "middle") s.res_h(545, 620, 360, "Rg", "100 Ω", above=False) s.wire((460, 340), (400, 340)); s.t(394, 344, "Vref 0.284 V", "lblb", "end") s.wire((460, 380), (440, 380), (440, 430), (600, 430), (600, 405), (650, 405)) s.t(300, 456, "holds 0.284 V across Rs → 28.4 mA sink", "note") # relay coil drive notes s.add(''); s.t(675, 52, "K2 coil ← MCU D(2n+1) via ULN2803", "ctlt", "middle") s.add(''); s.t(800, 76, "K1 coil ← MCU D(2n) via ULN2803", "ctlt", "middle") return s.svg(1260, 560, "Schematic of one coupon test channel", 980) # ---------------------------------------------------------------- vref def vref(): s = S() s.term(40, 60); s.t(32, 64, "+5 V", "lblb", "end") s.res_h(40, 150, 60, "R3", "2.2 kΩ") s.dot(150, 60); s.t(158, 84, "2.500 V", "node") # LM4040 shunt reference (zener symbol) s.wire((150, 60), (150, 92)) s.add('') s.add('') s.wire((150, 110), (150, 140)); s.gnd(150, 140) s.t(172, 104, "U4 LM4040-2.5", "pl") s.res_h(150, 290, 60, "R4", "7.87 kΩ 0.1%") s.dot(290, 60) s.res_v(290, 60, 150, "R5", "1.00 kΩ 0.1%") s.gnd(290, 150) s.wire((290, 60), (420, 60), cls="w") s.add('') s.t(448, 56, "Vref = 0.2838 V", "lblb"); s.t(448, 72, "to U3 + input of all 6 channels", "pv") return s.svg(720, 190, "Shared reference voltage for the discharge sinks", 600) # ---------------------------------------------------------------- overview def overview(): s = S() def blk(x, y, w, h, title, sub=None, cls="ic"): s.rect(x, y, w, h, cls) s.t(x + w/2, y + (h/2 if not sub else h/2 - 4), title, "icl", "middle") if sub: for i, line in enumerate(sub if isinstance(sub, list) else [sub]): s.t(x + w/2, y + h/2 + 12 + i*13, line, "pv", "middle") blk(30, 40, 150, 60, "PC", "CSV logging script") blk(250, 30, 180, 140, "Arduino Uno", ["5 V from USB", "A4 SDA · A5 SCL", "D2–D13 relay outputs"]) s.wire((180, 70), (250, 70)); s.t(215, 62, "USB", "pin", "middle") adr = [("0x48", "CH1–CH2", "ADDR → GND"), ("0x49", "CH3–CH4", "ADDR → VDD"), ("0x4A", "CH5–CH6", "ADDR → SDA")] for i, (a, c, pin) in enumerate(adr): x = 520 + i*190 blk(x, 30, 150, 70, f"ADS1115 {a}", [c, pin]) s.wire((x + 75, 100), (x + 75, 200)); s.dot(x + 75, 200) s.wire((430, 50), (470, 50), (470, 16)); s.wire((470, 16), (1100, 16), cls="bus") for i in range(3): s.wire((520 + i*190 + 75, 16), (520 + i*190 + 75, 30)); s.dot(520 + i*190 + 75, 16) s.t(1108, 20, "I²C + 5 V / GND", "busl") s.t(1108, 204, "SENSE (P, B)", "busl") s.wire((255, 200), (1100, 200), cls="bus") blk(30, 200, 150, 60, "Relay driver", ["2 × ULN2803 + 12 relays"]) s.wire((340, 170), (340, 182), (105, 182), (105, 200)); s.t(240, 177, "D2–D13", "pin", "middle") s.wire((180, 230), (230, 230), (230, 330)); s.wire((230, 330), (1100, 330), cls="bus") s.t(1108, 334, "RELAYS (K1, K2)", "busl") blk(30, 320, 150, 50, "12 V 1 A adapter", None) s.wire((180, 345), (1100, 345), cls="bus"); s.t(1108, 349, "+12 V", "busl") blk(30, 420, 150, 56, "Vref 0.284 V", ["LM4040 + divider"]) s.wire((180, 448), (205, 448), (205, 360)); s.wire((205, 360), (1100, 360), cls="bus") s.t(1108, 364, "Vref", "busl") mat = [("C1", "Lab MnO₂", "CV 1.50 V"), ("C2", "Lab MnO₂", "CV 1.80 V"), ("C3", "MTI γ", "CV 1.50 V"), ("C4", "MTI γ", "CV 1.80 V"), ("C5", "Pottery", "CV 1.80 V"), ("C6", "Lab + cello.", "CV 1.80 V")] for i in range(6): x = 250 + i*140 s.rect(x, 390, 120, 62, "ch") s.t(x + 60, 410, f"CH{i+1}", "icl", "middle"); s.t(x + 60, 425, "charger · sink", "pv", "middle"); s.t(x + 60, 439, "K1 · K2 · Rsh", "pv", "middle") for dx, yb in ((15, 200), (40, 330), (65, 345), (90, 360)): s.wire((x + dx, yb), (x + dx, 390)); s.dot(x + dx, yb) s.wire((x + 60, 452), (x + 60, 480)) s.rect(x + 10, 480, 100, 58, "cell") s.t(x + 60, 498, mat[i][0], "icl", "middle"); s.t(x + 60, 513, mat[i][1], "pv", "middle"); s.t(x + 60, 527, mat[i][2], "pv", "middle") s.t(250, 562, "All grounds return to one star point at the ADS1115 GND. Cell − leads go only to that point.", "note") return s.svg(1220, 580, "Overview of the six-channel coupon test rig", 960) CH, VR, OV = channel(), vref(), overview() pins = [(1, "C1", "D2", "D3", "0x48", "A0", "A1", "1.50"), (2, "C2", "D4", "D5", "0x48", "A2", "A3", "1.80"), (3, "C3", "D6", "D7", "0x49", "A0", "A1", "1.50"), (4, "C4", "D8", "D9", "0x49", "A2", "A3", "1.80"), (5, "C5", "D10", "D11", "0x4A", "A0", "A1", "1.80"), (6, "C6", "D12", "D13", "0x4A", "A2", "A3", "1.80")] pin_rows = "".join(f"CH{a}{b}{c}{d}{e}{g}{h}{i} V" for a, b, c, d, e, g, h, i in pins) bom = [ ("Arduino Uno (or Nano) + USB cable", "1", "Controller and serial logging"), ("ADS1115 16-bit ADC breakout", "3", "Addresses 0x48, 0x49, 0x4A"), ("LM317 (TO-220)", "12", "2 per channel: CC + CV"), ("Rcc 32.4 Ω 1% ¼ W", "6", "Sets 38.4 mA"), ("R1 120 Ω 1%", "6", "CV divider top"), ("R2 100 Ω multi-turn trimmer", "6", "Sets CV 1.50 / 1.80 V"), ("C1 10 µF 25 V", "6", "LM317 output stability"), ("F1 100 mA resettable PTC fuse", "6", "Per-channel protection"), ("K1 5 V SPST-NO reed relay (e.g. SIP reed)", "6", "Low-current dry switching"), ("K2 5 V SPDT signal relay, gold contacts", "6", "Charge / discharge select"), ("ULN2803 Darlington driver", "2", "Drives 12 relay coils (has flyback diodes)"), ("Q1 2N7000 N-MOSFET", "6", "Discharge sink pass device"), ("U3 MCP6002 dual rail-to-rail op-amp", "3", "One half per channel"), ("Rs 10 Ω 0.1% ¼ W", "6", "Sink current sense"), ("Rg 100 Ω", "6", "Gate resistor"), ("Rsh 1 Ω 0.1% ¼ W", "6", "Cell current shunt"), ("LM4040-2.5 + 2.2 kΩ, 7.87 kΩ 0.1%, 1.00 kΩ 0.1%", "1 set", "Shared Vref"), ("12 V 1 A DC adapter", "1", "Charger supply"), ("Small alligator / tab clips + silicone wire", "18", "3 per coupon (2 Zn joined, 1 Ti)"), ("Perfboard or 2 × half-size breadboards, terminal blocks", "—", ""), ] bom_rows = "".join(f"{a}{b}{c}" for a, b, c in bom) HTML = f"""Coupon Test Rig Wiring
Drawing ZM-RIG-01 · Rev A · for build plan phase 2E

Coupon Test Rig Wiring

Six independent channels that charge, discharge and log six coupon cells at 28 mA. Each channel has a constant-current charger with a voltage limit, a constant-current discharge load, and a 1 Ω shunt read by a 16-bit ADC. An Arduino switches the relays and streams readings to a PC.

System overview

{OV}
TitleSix-channel rig · block wiring
Sheet1 of 3
Supply12 V + USB 5 V
RevA

One channel (build 6)

K1 open = rest. K1 closed with K2 off = discharge through the sink. K1 closed with K2 on = charge. Because K2 is a changeover contact, the charger and the sink can never be connected at the same time.

{CH}

Shared reference (build 1)

{VR}
TitleChannel schematic · Vref
Sheet2 of 3
Current28 mA each way
RevA

Pin map, parts and setup

{pin_rows}
ChannelCouponK1 pinK2 pinADS1115P inputB inputCV limit

Parts list (rig)

{bom_rows}
PartQtyNote

Setup and calibration

  1. Power the board with no coupons. Check Vref reads 0.282–0.286 V.
  2. Per channel, with K1 off, close K2 by hand (or from the sketch) and measure U2 output. Turn R2 to 1.500 V or 1.800 V as the pin map says.
  3. Put a 10 Ω resistor in place of the coupon. Charge mode: current should read about 28 mA, falling as the voltage limit is reached. Discharge mode: 28.4 mA.
  4. Shunt offset: the sketch measures and subtracts it automatically during every rest (no current flows then). Check it with STATUS after the first hour: it should be under 0.1 mA.
  5. Connect a coupon: red clip to the Ti tab (+), black clip to the joined zinc tabs (−). The sense wires to P/B are on the board side of the clips.

Sketch logic (per channel, every 1 s)

Implemented in coupon_rig/coupon_rig.ino; log it with coupon_rig/coupon_logger.py.

  1. Rest 24 h: K1 off. Log the resting voltage.
  2. Discharge: K1 on, K2 off. Stop at 0.900 V. Integrate mAh.
  3. Charge: K1 on, K2 on. Constant current until the CV limit, then hold until I < 3.0 mA. Integrate mAh.
  4. Rest 10 min between steps. Repeat for 10 cycles.
  5. CSV over USB every 60 s: D,time_s,ch,state,cycle,V,mA,mAh,mWh. One S summary row per cycle (capacity, CE, average voltage) and E rows for events.
  6. Fail-safes: any cell above 1.90 V or below 0.80 V, current during a rest, or no current 2 min into a step → K1 off and flag the channel.
  7. Shunt offset auto-zeroes during rests; state is saved to EEPROM, so a reset resumes the test.

Notes

  • Self-discharge loading: while resting, the only load is the ADC input (well under 1 µA), so the 7-day rest test is not affected.
  • CV point: U2 holds node P at its set voltage, so the cell sees about 28 mV less until the current tapers. That is why the end-of-charge current check is at 3 mA, where the error is only 3 mV.
  • Relays: use reed or gold-contact signal relays. Large 10 A relay boards can give unreliable contact at 28 mA.
  • Hydrogen: coupons vent through the lid hole. Keep the rig in a ventilated spot, and the electronics above and away from the cups.
  • A commercial multi-channel battery analyzer can replace this whole rig if it supports custom voltage limits down to 0.9 V and currents of about 30 mA.
TitlePin map · parts · setup
Sheet3 of 3
LoggingCSV over USB
RevA
""" open(OUT, "w").write(HTML) print("ok", len(HTML))