// ===================================================================== // Coupon Test Rig: six-channel Zn-MnO2 cycler // Matches coupon-test-rig-wiring.html (Rev A) and build_plan_test_cell.md // (phase 2, coupon cycling). // // Board: Arduino Uno // Library: Adafruit ADS1X15 v2.x (Library Manager; it also installs // Adafruit BusIO) // Serial: 115200 baud. Log with coupon_logger.py, or any serial // terminal. // // Per channel (n = 1..6): // K1 (connect) = D(2n), K2 (mode) = D(2n+1), both through ULN2803s. // K1 off -> rest (cell open circuit) // K1 on, K2 off -> discharge through the 28.4 mA sink // K1 on, K2 on -> charge (38.4 mA CC into the LM317 voltage limit) // ADS1115 0x48: CH1 A0=P A1=B, CH2 A2=P A3=B // 0x49: CH3, CH4 0x4A: CH5, CH6 (same pattern) // Cell voltage = B. Current = (P - B) / 1 ohm, + = charging. // // Test sequence per channel: // REST0 24 h -> [ DIS to 0.900 V -> REST_D 10 min -> CHG (CC, then // CV until I < 3.0 mA) -> REST_C 10 min ] x CYCLES -> optional // self-discharge rest + final discharge -> DONE. // The coupons are built charged (MnO2 + zinc metal), so each cycle // starts with a discharge. Coulombic efficiency for cycle n is // discharge(n) / charge(n-1), so cycle 1 has none. // // Fail-safes: B above 1.90 V or below 0.80 V, current during a rest // (K1 stuck), no current or reversed current 2 min into a step, or an // ADC fault -> relays off, channel FAULT. Other channels keep running. // // Shunt offset: auto-zeroed during every rest (no current flows then), // so calibration step 4 on the wiring sheet happens on its own during // the first 24 h rest. // // Power loss / reset: state is saved to EEPROM at every step change and // every 10 min. After a reset the test resumes where it was (up to // 10 min of mAh may be lost from the open step). Opening the serial // port resets an Uno, so this matters every time the logger starts. // Send NEW to start a fresh test. // // Serial commands (end with Enter): // STATUS one line per channel // NEW clear the saved state and start a new test on all channels // STOP relays off, all channels stop (NEW to start again) // CAL pause; K1 off and K2 on for every channel, so you can trim // each charger's voltage limit (R2) with a meter at K2 COM // RUN leave CAL and carry on // HELP // // Output lines (CSV, first field is the record type): // D,time_s,ch,state,cycle,V,mA,mAh,mWh every 60 s and at each // step change // S,time_s,ch,cycle,dis_mAh,dis_mWh,avg_dis_V,chg_mAh,chg_mWh,CE_pct,EE_pct // once per cycle, after the // charge // E,time_s,ch,message events and faults // # ... comments // // Note: the Uno bootloader blinks D13 (K2 of CH6) during a reset. K1 is // off then, so the cell is disconnected and nothing happens. // ===================================================================== #include #include #include // ------------------------- Test settings ------------------------------ const uint16_t CYCLES = 10; // 50-100 for a fade run const uint32_t REST0_S = 24UL * 3600; // rest after filling const uint32_t REST_S = 10UL * 60; // rest between steps const uint32_t SD_REST_S = 0; // self-discharge rest after // the last charge, e.g. // 7 days = 604800; 0 = skip const float V_DIS_END = 0.900; // discharge cutoff (V) const float I_CHG_END = 3.0; // end of CV hold (mA) const float V_MAX = 1.90; // fail-safe high (V) const float V_MIN = 0.80; // fail-safe low (V) const uint32_t MAX_STEP_S = 60UL * 3600; // step timeout (ends the step) const uint16_t LOG_EVERY_S = 60; const uint16_t SAVE_EVERY_S = 600; // ------------------------- Channel settings --------------------------- const uint8_t NCH = 6; const bool ENABLED[NCH] = { true, true, true, true, true, true }; // Voltage limits set on each charger's trimmer (logged only; the // hardware sets the real limit). Matches the test matrix. const float CV_SET[NCH] = { 1.50, 1.80, 1.50, 1.80, 1.80, 1.80 }; // Measured shunt values (ohms). 1.000 unless you measured otherwise. const float R_SHUNT[NCH] = { 1.000, 1.000, 1.000, 1.000, 1.000, 1.000 }; // ------------------------- Hardware ----------------------------------- Adafruit_ADS1115 ads[3]; const uint8_t ADS_ADDR[3] = { 0x48, 0x49, 0x4A }; bool adsOk[3]; inline uint8_t pinK1(uint8_t c) { return 2 + 2 * c; } inline uint8_t pinK2(uint8_t c) { return 3 + 2 * c; } // ------------------------- State -------------------------------------- enum : uint8_t { S_OFF, S_REST0, S_DIS, S_REST_D, S_CHG, S_REST_C, S_SD_REST, S_SD_DIS, S_DONE, S_FAULT, S_STOP }; const char* const SNAME[] = { "OFF", "REST0", "DIS", "REST_D", "CHG", "REST_C", "SD_REST", "SD_DIS", "DONE", "FAULT", "STOP" }; enum : uint8_t { M_REST, M_DIS, M_CHG }; struct Ch { // saved to EEPROM uint8_t state; uint16_t cycle; // 1-based uint32_t stepS; // seconds in the current step float mAh, mWh; // current step float disAh, disWh; // this cycle's discharge float prevChgAh, prevChgWh; // last completed charge float offset; // shunt offset (mA), auto-zeroed in rest }; Ch ch[NCH]; struct Hdr { uint32_t magic; uint32_t seconds; }; const uint32_t MAGIC = 0x5A4D4331UL; // "ZMC1"; change if Ch changes // runtime only float lastV[NCH], lastI[NCH]; uint8_t endCount[NCH], vBad[NCH], iBad[NCH]; uint32_t seconds = 0; // test clock (s); paused during CAL uint32_t lastTick; bool paused = false; char lineBuf[16]; uint8_t lineLen = 0; // ------------------------- Output helpers ----------------------------- void evHead(uint8_t c) { Serial.print(F("E,")); Serial.print(seconds); Serial.print(','); Serial.print(c + 1); Serial.print(','); } void event(uint8_t c, const __FlashStringHelper* msg) { evHead(c); Serial.println(msg); } void logData(uint8_t c) { const Ch& x = ch[c]; Serial.print(F("D,")); Serial.print(seconds); Serial.print(','); Serial.print(c + 1); Serial.print(','); Serial.print(SNAME[x.state]); Serial.print(','); Serial.print(x.cycle); Serial.print(','); Serial.print(lastV[c], 4); Serial.print(','); Serial.print(lastI[c], 3); Serial.print(','); Serial.print(x.mAh, 3); Serial.print(','); Serial.println(x.mWh, 3); } // ------------------------- EEPROM ------------------------------------- void saveAll() { Hdr h = { MAGIC, seconds }; EEPROM.put(0, h); for (uint8_t c = 0; c < NCH; c++) EEPROM.put(sizeof(Hdr) + c * sizeof(Ch), ch[c]); } bool loadAll() { Hdr h; EEPROM.get(0, h); if (h.magic != MAGIC) return false; seconds = h.seconds; for (uint8_t c = 0; c < NCH; c++) EEPROM.get(sizeof(Hdr) + c * sizeof(Ch), ch[c]); return true; } // ------------------------- Relays ------------------------------------- uint8_t modeFor(uint8_t s) { if (s == S_DIS || s == S_SD_DIS) return M_DIS; if (s == S_CHG) return M_CHG; return M_REST; } // Break before make: K1 always opens before K2 moves. void setMode(uint8_t c, uint8_t m) { digitalWrite(pinK1(c), LOW); delay(30); digitalWrite(pinK2(c), m == M_CHG ? HIGH : LOW); if (m == M_REST) return; delay(30); digitalWrite(pinK1(c), HIGH); } // ------------------------- Measurement -------------------------------- bool readChannel(uint8_t c, float& v, float& iraw) { uint8_t a = c / 2; if (!adsOk[a]) return false; bool second = c % 2; // second channel on an ADC: A2/A3 Adafruit_ADS1115& d = ads[a]; d.setGain(GAIN_TWO); // +/-2.048 V for the cell voltage int16_t rb = d.readADC_SingleEnded(second ? 3 : 1); v = d.computeVolts(rb); d.setGain(GAIN_SIXTEEN); // +/-0.256 V, 7.8 uA per count int16_t rd = second ? d.readADC_Differential_2_3() : d.readADC_Differential_0_1(); iraw = d.computeVolts(rd) / R_SHUNT[c] * 1000.0; return true; } // ------------------------- Steps -------------------------------------- void enterState(uint8_t c, uint8_t s) { Ch& x = ch[c]; logData(c); // last row of the old step x.state = s; x.stepS = 0; x.mAh = 0; x.mWh = 0; endCount[c] = vBad[c] = iBad[c] = 0; setMode(c, modeFor(s)); evHead(c); Serial.print(F("step ")); Serial.print(SNAME[s]); Serial.print(F(" cycle ")); Serial.println(x.cycle); saveAll(); } void fault(uint8_t c, const __FlashStringHelper* why) { setMode(c, M_REST); evHead(c); Serial.print(F("FAULT in ")); Serial.print(SNAME[ch[c].state]); Serial.print(F(": ")); Serial.print(why); Serial.print(F(" (V=")); Serial.print(lastV[c], 4); Serial.print(F(" mA=")); Serial.print(lastI[c], 3); Serial.println(')'); ch[c].state = S_FAULT; logData(c); saveAll(); } void endDischarge(uint8_t c) { Ch& x = ch[c]; if (x.state == S_SD_DIS) { evHead(c); Serial.print(F("self-discharge retention ")); Serial.print(x.disAh > 0 ? x.mAh / x.disAh * 100.0 : 0.0, 2); Serial.print(F(" % (")); Serial.print(x.mAh, 2); Serial.print(F(" vs ")); Serial.print(x.disAh, 2); Serial.println(F(" mAh)")); enterState(c, S_DONE); return; } x.disAh = x.mAh; x.disWh = x.mWh; enterState(c, S_REST_D); } void endCharge(uint8_t c) { Ch& x = ch[c]; Serial.print(F("S,")); Serial.print(seconds); Serial.print(','); Serial.print(c + 1); Serial.print(','); Serial.print(x.cycle); Serial.print(','); Serial.print(x.disAh, 3); Serial.print(','); Serial.print(x.disWh, 3); Serial.print(','); Serial.print(x.disAh > 0 ? x.disWh / x.disAh : 0.0, 4); Serial.print(','); Serial.print(x.mAh, 3); Serial.print(','); Serial.print(x.mWh, 3); Serial.print(','); if (x.prevChgAh > 0) { Serial.print(x.disAh / x.prevChgAh * 100.0, 2); Serial.print(','); Serial.println(x.disWh / x.prevChgWh * 100.0, 2); } else { Serial.println(F(",")); // cycle 1: no earlier charge } x.prevChgAh = x.mAh; x.prevChgWh = x.mWh; enterState(c, S_REST_C); } void nextCycle(uint8_t c) { Ch& x = ch[c]; if (x.cycle < CYCLES) { x.cycle++; enterState(c, S_DIS); } else if (SD_REST_S > 0) { enterState(c, S_SD_REST); } else { enterState(c, S_DONE); } } bool active(uint8_t s) { return s != S_OFF && s < S_DONE; } void tickChannel(uint8_t c) { Ch& x = ch[c]; if (x.state == S_OFF || x.state == S_FAULT) return; float v, iraw; if (!readChannel(c, v, iraw)) { if (active(x.state)) fault(c, F("ADC not responding")); return; } float i = iraw - x.offset; lastV[c] = v; lastI[c] = i; if (!active(x.state)) return; // DONE / STOP: log the voltage only x.stepS++; uint8_t m = modeFor(x.state); // fail-safes if (v > V_MAX || v < V_MIN) { if (++vBad[c] >= 2) { fault(c, v > V_MAX ? F("over-voltage") : F("under-voltage")); return; } } else { vBad[c] = 0; } if (m == M_REST) { if (x.stepS > 60) { // let the relay and cell settle if (fabs(i) > 2.0) { if (++iBad[c] >= 5) { fault(c, F("current during rest, K1 stuck?")); return; } } else { iBad[c] = 0; x.offset += 0.02 * (iraw - x.offset); } } } else { float a = fabs(i); x.mAh += a / 3600.0; x.mWh += a * v / 3600.0; if (x.stepS == 120) { if (a < 5.0) { fault(c, F("no current: check clips, relays, charger/sink")); return; } if ((m == M_CHG) != (i > 0)) { fault(c, F("current in the wrong direction")); return; } } } switch (x.state) { case S_REST0: if (x.stepS >= REST0_S) enterState(c, S_DIS); break; case S_DIS: case S_SD_DIS: if (x.stepS >= 10 && v <= V_DIS_END) { if (++endCount[c] >= 3) { endDischarge(c); return; } } else { endCount[c] = 0; } break; case S_REST_D: if (x.stepS >= REST_S) enterState(c, S_CHG); break; case S_CHG: if (x.stepS >= 300 && i < I_CHG_END) { if (++endCount[c] >= 10) { endCharge(c); return; } } else { endCount[c] = 0; } break; case S_REST_C: if (x.stepS >= REST_S) nextCycle(c); break; case S_SD_REST: if (x.stepS >= SD_REST_S) enterState(c, S_SD_DIS); break; } if (m != M_REST && x.stepS >= MAX_STEP_S) { event(c, F("step timeout, ending the step")); if (m == M_CHG) endCharge(c); else endDischarge(c); } } // ------------------------- Test control ------------------------------- void newTest() { seconds = 0; for (uint8_t c = 0; c < NCH; c++) { memset(&ch[c], 0, sizeof(Ch)); ch[c].cycle = 1; ch[c].state = ENABLED[c] ? S_REST0 : S_OFF; setMode(c, M_REST); evHead(c); Serial.print(F("new test, ")); Serial.print(CYCLES); Serial.print(F(" cycles, CV limit ")); Serial.print(CV_SET[c], 2); Serial.println(ENABLED[c] ? F(" V") : F(" V (channel disabled)")); } saveAll(); } void applyRelays() { for (uint8_t c = 0; c < NCH; c++) setMode(c, modeFor(ch[c].state)); } void printStatus() { Serial.print(F("# t=")); Serial.print(seconds); Serial.println(paused ? F(" s (PAUSED: CAL)") : F(" s")); for (uint8_t c = 0; c < NCH; c++) { const Ch& x = ch[c]; Serial.print(F("# CH")); Serial.print(c + 1); Serial.print(' '); Serial.print(SNAME[x.state]); Serial.print(F(" cycle ")); Serial.print(x.cycle); Serial.print(F(" step ")); Serial.print(x.stepS); Serial.print(F(" s V=")); Serial.print(lastV[c], 4); Serial.print(F(" mA=")); Serial.print(lastI[c], 3); Serial.print(F(" mAh=")); Serial.print(x.mAh, 2); Serial.print(F(" offset=")); Serial.print(x.offset, 3); Serial.println(F(" mA")); } } void command(char* s) { for (char* p = s; *p; p++) *p = toupper(*p); if (!strcmp(s, "STATUS")) { printStatus(); } else if (!strcmp(s, "NEW")) { paused = false; newTest(); } else if (!strcmp(s, "STOP")) { for (uint8_t c = 0; c < NCH; c++) { setMode(c, M_REST); if (ch[c].state != S_OFF) ch[c].state = S_STOP; } saveAll(); Serial.println(F("# stopped; send NEW to start a new test")); } else if (!strcmp(s, "CAL")) { paused = true; for (uint8_t c = 0; c < NCH; c++) { digitalWrite(pinK1(c), LOW); delay(30); digitalWrite(pinK2(c), HIGH); } Serial.println(F("# CAL: K1 off, K2 on. Trim each R2 at K2 COM, then send RUN")); } else if (!strcmp(s, "RUN")) { if (paused) { paused = false; applyRelays(); lastTick = millis(); Serial.println(F("# running")); } } else if (!strcmp(s, "HELP") || !strcmp(s, "?")) { Serial.println(F("# commands: STATUS NEW STOP CAL RUN")); } else if (*s) { Serial.println(F("# unknown command, try HELP")); } } void readSerial() { while (Serial.available()) { char k = Serial.read(); if (k == '\n' || k == '\r') { lineBuf[lineLen] = 0; command(lineBuf); lineLen = 0; } else if (lineLen < sizeof(lineBuf) - 1) { lineBuf[lineLen++] = k; } } } // ------------------------- Main --------------------------------------- void setup() { for (uint8_t p = 2; p <= 13; p++) { digitalWrite(p, LOW); pinMode(p, OUTPUT); } Serial.begin(115200); Wire.begin(); Serial.println(F("# Zn-MnO2 coupon rig v1")); Serial.println(F("#D,time_s,ch,state,cycle,V,mA,mAh,mWh")); Serial.println(F("#S,time_s,ch,cycle,dis_mAh,dis_mWh,avg_dis_V,chg_mAh,chg_mWh,CE_pct,EE_pct")); Serial.println(F("#E,time_s,ch,message")); for (uint8_t a = 0; a < 3; a++) { adsOk[a] = ads[a].begin(ADS_ADDR[a]); if (adsOk[a]) { ads[a].setDataRate(RATE_ADS1115_64SPS); } else { Serial.print(F("# ADS1115 at 0x")); Serial.print(ADS_ADDR[a], HEX); Serial.println(F(" not found")); } } if (loadAll()) { for (uint8_t c = 0; c < NCH; c++) if (!ENABLED[c]) ch[c].state = S_OFF; Serial.print(F("# resumed saved test at t=")); Serial.print(seconds); Serial.println(F(" s (send NEW to start over)")); applyRelays(); } else { newTest(); } lastTick = millis(); } void loop() { readSerial(); if (paused) return; if (millis() - lastTick < 1000) return; lastTick += 1000; seconds++; for (uint8_t c = 0; c < NCH; c++) if (ENABLED[c]) tickChannel(c); if (seconds % LOG_EVERY_S == 0) for (uint8_t c = 0; c < NCH; c++) if (ENABLED[c] && ch[c].state != S_FAULT) logData(c); if (seconds % SAVE_EVERY_S == 0) saveAll(); }