// Native unit tests for the ohm tester logic. // g++ -std=c++17 -O2 -o test test_ohm_logic.cpp ohm_tester/ohm_logic.cpp && ./test // (no g++? python -m ziglang c++ ... works the same) // // Gear is modelled as a resistor network between the eight terminals plus // internal nodes; NetMeter solves it by nodal analysis, so a cord, a weapon, // a short or a dead lame patch reads exactly as the real muxes would see it. #include "ohm_tester/ohm_logic.h" #include #include #include #include static int passes = 0, failures = 0; static void check(const char* name, bool ok, const char* detail = "") { printf("%-6s %-58s %s\n", ok ? "PASS" : "FAIL", name, detail); ok ? ++passes : ++failures; } static bool near(float a, float b, float tol) { return std::fabs(a - b) <= tol; } // ------------------------------------------------------- network meter struct Edge { int a, b; double r; }; struct NetMeter : Meter { std::vector edges; int nodes = T_COUNT; int node() { return nodes++; } // a new internal node void add(int a, int b, double r) { edges.push_back({a, b, r}); } float ohms(Term x, Term y) override { int n = nodes; // Laplacian with node y grounded, 1 A injected at x. A 1 Tohm leak from // every node to y keeps the matrix solvable when x is isolated. std::vector G(n * n, 0.0), I(n, 0.0); auto stamp = [&](int a, int b, double g) { G[a * n + a] += g; G[b * n + b] += g; G[a * n + b] -= g; G[b * n + a] -= g; }; for (auto& e : edges) stamp(e.a, e.b, 1.0 / e.r); for (int i = 0; i < n; ++i) if (i != y) stamp(i, y, 1e-12); I[x] = 1.0; for (int i = 0; i < n; ++i) { G[y * n + i] = 0; G[i * n + y] = 0; } G[y * n + y] = 1; I[y] = 0; for (int c = 0; c < n; ++c) { // Gauss-Jordan, partial pivot int p = c; for (int r = c + 1; r < n; ++r) if (std::fabs(G[r * n + c]) > std::fabs(G[p * n + c])) p = r; for (int k = 0; k < n; ++k) std::swap(G[c * n + k], G[p * n + k]); std::swap(I[c], I[p]); for (int r = 0; r < n; ++r) { if (r == c) continue; double f = G[r * n + c] / G[c * n + c]; if (f == 0) continue; for (int k = c; k < n; ++k) G[r * n + k] -= f * G[c * n + k]; I[r] -= f * I[c]; } } double v = I[x] / G[x * n + x]; return v > 1e9 ? R_OPEN : (float)v; } }; // A cord plugged into J1 (reel end) and J2 or the clip (weapon end). static void epeeCord(NetMeter& m, double a, double b, double c) { m.add(T_J1A, T_J2A, a); m.add(T_J1B, T_J2B, b); m.add(T_J1C, T_J2C, c); } static void foilCord(NetMeter& m, double a, double b, double c) { m.add(T_J1A, T_CLIP, a); m.add(T_J1B, T_J2B, b); m.add(T_J1C, T_J2C, c); } // ------------------------------------------------------------ ADC math // bits = 16 for a real ADS1115, 12 for an ADS1015 sold as one: it returns its // 12-bit result left-justified, so counts stay on the 16-bit scale. static RawReading reading(double rDut, double ron, double fsrXY, int bits = 16) { // The same arithmetic as the Python front-end model: 3.3 V, 330 ohm // limit, two mux Ron in the current path, 10 ohm reference, 10 mohm of // contact per jack, ADS input impedance loading the sense muxes. const double vcc = 3.3, rlim = 330, rref = 10, contact = 0.01, zin = 710e3; double i = vcc / (rlim + ron + 2 * contact + rDut + ron + rref); double vxy = i * (rDut + 2 * contact) * zin / (zin + 2 * ron); double vrf = i * rref; RawReading r{}; int32_t step = 1 << (16 - bits); auto q = [&](double v, double fsr) { return (int32_t)std::lround(v / (fsr / 32768) / step) * step; }; r.xy = q(vxy, fsrXY); r.xyFsr = (float)fsrXY; r.ref = q(vrf, 0.256); r.refFsr = 0.256f; return r; } static void adc_math() { // Allowed: 1 mohm + 0.1 % of the reading (the ADS input impedance loading // the sense muxes is the 0.07 % part at 20 ohm). float worst = 0; bool ok = true; for (double rd : {0.05, 0.3, 1.0, 2.0, 5.0, 20.0}) for (double ron : {70.0, 160.0, 250.0}) { float want = (float)(rd + 0.02); float err = std::fabs(ratioToOhms(reading(rd, ron, 0.256), 10.0f) - want); if (err > 0.001f + 0.001f * want) ok = false; if (rd <= 2.0) worst = std::fmax(worst, err); } char d[64]; snprintf(d, sizeof d, "worst error up to 2 ohm: %.2f mohm", worst * 1000); check("ratiometric reading within 1 mohm + 0.1 %, Ron 70-250", ok, d); float r12 = ratioToOhms(reading(1.0, 160, 0.256, 12), 10.0f); snprintf(d, sizeof d, "reads %.3f", r12); check("fake ADS1115 (12-bit ADS1015) still reads 1 ohm to 0.05", near(r12, 1.02f, 0.05f), d); RawReading hi = reading(150.0, 160, 4.096); float r150 = ratioToOhms(hi, 10.0f); snprintf(d, sizeof d, "reads %.2f", r150); check("4.096 V range reads 150 ohm", near(r150, 150.02f, 0.5f), d); RawReading open{}; open.xy = 26000; open.xyFsr = 4.096f; open.ref = 2; open.refFsr = 0.256f; check("no reference current reads as open", ratioToOhms(open, 10.0f) >= R_OPEN); RawReading off = reading(1.0, 160, 0.256); off.xy += 3; off.xyOff = 3; off.ref += 2; off.refOff = 2; check("offset counts are subtracted", near(ratioToOhms(off, 10.0f), 1.02f, 0.002f)); } static void formatting() { struct { float v; const char* s; } cases[] = { {0.2144f, "0.214"}, {1.0f, "1.000"}, {9.9996f, "10.0"}, {12.34f, "12.3"}, {512.4f, "512"}, {12340.f, "12k"}, {250000.f, "OPEN"}, {R_OPEN, "OPEN"}, }; bool ok = true; char buf[16], d[96] = ""; for (auto& c : cases) { formatOhms(c.v, buf, sizeof buf); if (strcmp(buf, c.s)) { ok = false; snprintf(d, sizeof d, "%g -> %s, want %s", c.v, buf, c.s); } } check("formatOhms matches the screen format", ok, d); } // ----------------------------------------------------------- cord tests static void cord_good_epee() { NetMeter m; epeeCord(m, 0.21, 0.25, 0.19); CordReport r = testCord(m); check("good epee cord: 3-prong type, PASS", r.type == CORD_3PIN && r.pass && near(r.wire[1].ohms, 0.25f, 0.001f)); } static void cord_good_foil() { NetMeter m; foilCord(m, 0.30, 0.22, 0.24); CordReport r = testCord(m); check("good foil cord: clip type, A measured to the clip, PASS", r.type == CORD_CLIP && r.pass && r.wire[0].farEnd == T_CLIP && near(r.wire[0].ohms, 0.30f, 0.001f)); } static void cord_high_wire() { NetMeter m; epeeCord(m, 0.2, 1.4, 0.2); CordReport r = testCord(m); check("1.4 ohm B wire is HIGH and the cord fails", r.wire[1].v == V_HIGH && !r.pass); } static void cord_open_wire() { NetMeter m; m.add(T_J1A, T_J2A, 0.2); m.add(T_J1C, T_J2C, 0.2); CordReport r = testCord(m); check("broken B wire reads OPEN", r.wire[1].v == V_OPEN && !r.pass && r.type == CORD_3PIN); } static void cord_crossed() { NetMeter m; m.add(T_J1A, T_J2C, 0.2); m.add(T_J1B, T_J2B, 0.2); m.add(T_J1C, T_J2A, 0.2); CordReport r = testCord(m); check("A and C swapped in the plug: both flagged crossed", r.wire[0].crossed && r.wire[0].farEnd == T_J2C && r.wire[2].crossed && !r.pass); } static void cord_short_ab() { NetMeter m; int mid = m.node(); // A and B touching halfway down the cord m.add(T_J1A, mid, 0.1); m.add(mid, T_J2A, 0.1); int midB = m.node(); m.add(T_J1B, midB, 0.1); m.add(midB, T_J2B, 0.1); m.add(mid, midB, 2.0); m.add(T_J1C, T_J2C, 0.2); CordReport r = testCord(m); check("A-B short inside the cord is caught with both ends plugged", r.shortV[0] == V_SHORT && r.shortV[2] == V_PASS && !r.pass); } static void cord_short_ac() { NetMeter m; epeeCord(m, 0.2, 0.2, 0.2); m.add(T_J1A, T_J1C, 5.0); CordReport r = testCord(m); check("A-C short is caught (the lamp tester could not see it)", r.shortV[2] == V_SHORT && !r.pass); } static void cord_leaky() { NetMeter m; epeeCord(m, 0.2, 0.2, 0.2); m.add(T_J1B, T_J1C, 40000); CordReport r = testCord(m); check("40 k between B and C reads LEAKY", r.shortV[1] == V_LEAKY && !r.pass); } static void cord_none() { NetMeter m; CordReport r = testCord(m); check("nothing plugged in: no cord, not a pass", r.type == CORD_NONE && !r.pass); } // --------------------------------------------------------- weapon tests static CordMemory knownCord() { NetMeter m; epeeCord(m, 0.20, 0.25, 0.30); CordMemory c; c.remember(testCord(m)); return c; } static void cord_memory() { CordMemory c = knownCord(); NetMeter m; m.add(T_J1A, T_J2A, 0.2); // only one wire conducts CordMemory bad; bad.remember(testCord(m)); check("a continuous cord is remembered, a broken one is not", c.valid && near(c.b, 0.25f, 0.001f) && !bad.valid); } static void epee_good() { CordMemory c = knownCord(); EpeeTest e; e.update(R_OPEN, R_OPEN, R_OPEN, c); e.update(0.45f + 1.1f, R_OPEN, R_OPEN, c); e.update(0.45f + 1.1f, R_OPEN, R_OPEN, c); check("epee: open at rest, 1.1 ohm pressed -> PASS", e.verdict() == V_PASS && near(e.pressedOhms, 1.1f, 0.001f) && e.presses == 1); } static void epee_guard_short() { CordMemory c = knownCord(); EpeeTest e; e.update(R_OPEN, 600.0f, R_OPEN, c); e.update(1.0f, R_OPEN, R_OPEN, c); check("epee tip wire touching the guard reads SHORT", e.verdict() == V_SHORT); } static void epee_high() { CordMemory c = knownCord(); EpeeTest e; e.update(R_OPEN, R_OPEN, R_OPEN, c); e.update(0.45f + 2.5f, R_OPEN, R_OPEN, c); e.update(0.45f + 2.5f, R_OPEN, R_OPEN, c); check("epee 2.5 ohm pressed is HIGH", e.verdict() == V_HIGH); } static void epee_bounce() { CordMemory c = knownCord(); EpeeTest e; e.update(R_OPEN, R_OPEN, R_OPEN, c); e.update(0.45f + 9.0f, R_OPEN, R_OPEN, c); // half-made contact as the tip goes down e.update(0.45f + 1.0f, R_OPEN, R_OPEN, c); e.update(0.45f + 1.0f, R_OPEN, R_OPEN, c); check("epee: a bounce reading on the way down does not fail it", e.verdict() == V_PASS); } static void foilsabre_rules() { CordMemory c = knownCord(); FoilSabreTest f; f.update(0.55f + 0.8f, R_OPEN, c); f.update(0.55f + 0.8f, R_OPEN, c); bool rest = f.seenRest && near(f.worstRest, 0.8f, 0.001f) && !f.seenOpen; f.update(R_OPEN, R_OPEN, c); check("foil/sabre: 0.8 ohm alone at rest, a press is counted", rest && f.seenOpen && f.presses == 1); FoilSabreTest g; g.update(R_OPEN, R_OPEN, c); check("open before any closed reading is not a press", !g.seenOpen && g.presses == 0); } static void lame_sweep() { CordMemory c = knownCord(); LameTest l; float sweep[] = {R_OPEN, 2.1f, 2.3f, 300.0f, 2.2f, 2.4f, R_OPEN}; // one bounce at 300 for (float v : sweep) l.update(R_OPEN, v + 0.20f, c); Verdict clean = l.verdict(); float cleanWorst = l.worst; l.update(R_OPEN, 12.2f, c); l.update(R_OPEN, 12.4f, c); // a dead patch, held char d[64]; snprintf(d, sizeof d, "worst before patch %.2f", cleanWorst); check("lame: bounce ignored, cord subtracted, dead patch fails", clean == V_PASS && near(cleanWorst, 2.3f, 0.001f) && l.verdict() == V_HIGH, d); } static void wire_limit() { LameTest w; w.limit = LIM_WIRE; CordMemory none; w.update(0.6f, R_OPEN, none); w.update(0.6f, R_OPEN, none); Verdict ok = w.verdict(); w.reset(); w.update(1.3f, R_OPEN, none); w.update(1.3f, R_OPEN, none); check("wire mode: 0.6 ohm passes, 1.3 ohm fails, limit survives reset", ok == V_PASS && w.verdict() == V_HIGH && w.limit == LIM_WIRE); } // ------------------------------------------------------------ auto mode // Whole scenarios: build the gear as a network, scan it the way the sketch // does, step time forward, and check the light and sound a user would get. struct Run { AutoTester at; CordMemory mem; uint32_t t = 0; // One pass the way the sketch does it: scan when not in a cord session, // full cord report when in one. void pass(NetMeter& m, uint32_t dt = 150) { t += dt; if (at.sit == S_CORD) at.updateCord(testCord(m), mem, t); else { at.update(scanAll(m), mem, t); if (at.sit == S_CORD) at.updateCord(testCord(m), mem, t); } } }; static void weaponOnCord(NetMeter& m, int& b, int& c) { // A known 0.2/0.25/0.3 cord in J1 whose far end goes to nodes b and c // (the weapon's B and C), A hanging free. b = m.node(); c = m.node(); m.add(T_J1B, b, 0.25); m.add(T_J1C, c, 0.30); } static void auto_idle() { Run r; NetMeter m; r.pass(m); check("auto: nothing plugged in -> idle, light off", r.at.sit == S_IDLE && r.at.light == L_OFF); } static void auto_cord() { Run r; NetMeter m; epeeCord(m, 0.21, 0.25, 0.19); r.pass(m); Sound s1 = r.at.sound; r.pass(m); bool green = r.at.sit == S_CORD && r.at.light == L_GREEN; check("auto: good cord in both sockets -> green + pass chirp, cord remembered", green && (s1 == SND_PASS || r.at.sound == SND_PASS) && r.mem.valid); // Unplug: one wire lets go first, then the whole plug. NetMeter half; half.add(T_J1B, T_J2B, 0.25); half.add(T_J1C, T_J2C, 0.19); r.pass(half); bool quiet = r.at.light == L_GREEN && r.at.sound == SND_NONE; NetMeter none; r.pass(none); r.pass(none); check("auto: unplugging a good cord does not flash red or buzz, answer stays", quiet && r.at.sit == S_IDLE && r.at.light == L_GREEN && r.at.shown == S_CORD); } static void auto_cord_broken() { Run r; NetMeter m; m.add(T_J1A, T_J2A, 0.2); m.add(T_J1C, T_J2C, 0.2); r.pass(m); Light first = r.at.light; r.pass(m); check("auto: broken B wire -> red + fail buzz on the second pass", first == L_OFF && r.at.light == L_RED && r.at.sound == SND_FAIL && r.at.wireVerdict(1) == V_OPEN); } static void auto_cord_leaky_and_high() { Run r; NetMeter m; epeeCord(m, 0.2, 0.2, 0.2); m.add(T_J1B, T_J1C, 40000); r.pass(m); r.pass(m); Light leaky = r.at.light; Run q; NetMeter h; epeeCord(h, 0.2, 1.3, 0.2); q.pass(h); q.pass(h); check("auto: leaky cord -> yellow, 1.3 ohm wire -> red", leaky == L_YELLOW && q.at.light == L_RED); } static void auto_cord_flex() { Run r; NetMeter m; epeeCord(m, 0.2, 0.2, 0.2); r.pass(m); bool notYet = !r.at.flexArmed(r.t); r.pass(m, 600); bool armed = r.at.flexArmed(r.t); r.at.noteBreaks(1); check("auto: flex counting arms 0.5 s after the plug is in; a break -> red + click", notYet && armed && r.at.light == L_RED && r.at.sound == SND_BREAK); } static void auto_foil() { Run r; r.mem = knownCord(); NetMeter m; int b, c; weaponOnCord(m, b, c); m.add(b, c, 0.8); // foil point at rest r.pass(m); r.pass(m); bool green = r.at.sit == S_FOILSABRE && r.at.light == L_GREEN; NetMeter pressed; int b2, c2; weaponOnCord(pressed, b2, c2); r.pass(pressed); check("auto: foil 0.8 ohm at rest -> green; tip press -> tick, foil confirmed", green && r.at.sound == SND_TICK && r.at.fs.seenOpen && r.at.light == L_GREEN); } static void auto_foil_only() { Run r; r.mem = knownCord(); NetMeter m; int b, c; weaponOnCord(m, b, c); m.add(b, c, 1.5); r.pass(m); r.pass(m); bool waiting = r.at.light == L_YELLOW && r.at.blink; NetMeter pressed; int b2, c2; weaponOnCord(pressed, b2, c2); r.pass(pressed); check("auto: 1.5 ohm (foil yes, sabre no) -> blinking yellow, green once the tip opens it", waiting && r.at.light == L_GREEN); } static void auto_foil_high() { Run r; r.mem = knownCord(); NetMeter m; int b, c; weaponOnCord(m, b, c); m.add(b, c, 2.6); r.pass(m); r.pass(m); check("auto: 2.6 ohm foil/sabre -> red", r.at.light == L_RED); } static void auto_sabre_blade() { Run r; r.mem = knownCord(); NetMeter m; int b, c; weaponOnCord(m, b, c); m.add(b, c, 0.3); r.pass(m); r.pass(m); bool ok = r.at.light == L_GREEN; NetMeter p; int b2, c2; weaponOnCord(p, b2, c2); p.add(b2, c2, 0.3); p.add(c2, T_PROBE, 1.4); // probe on a taped blade r.pass(p); r.pass(p); check("auto: sabre guard 0.3 ohm green; probe finds 1.4 ohm on the blade -> red", ok && r.at.sit == S_FOILSABRE && r.at.light == L_RED); } static void auto_epee() { Run r; r.mem = knownCord(); NetMeter rest; int a0 = rest.node(); rest.add(T_J1A, a0, 0.2); int b0, c0; weaponOnCord(rest, b0, c0); r.pass(rest); r.pass(rest); bool idle = r.at.sit == S_IDLE; NetMeter down; int a = down.node(); down.add(T_J1A, a, 0.2); int b, c; weaponOnCord(down, b, c); down.add(a, b, 1.0); r.pass(down); Sound tick = r.at.sound; r.pass(down); bool green = r.at.sit == S_EPEE && r.at.light == L_GREEN; r.pass(rest); r.pass(rest); bool stays = r.at.sit == S_EPEE && r.at.light == L_GREEN; r.pass(down); check("auto: epee reads as idle at rest, press -> tick + green, stays epee between presses", idle && tick == SND_TICK && green && stays && r.at.sound == SND_TICK); } static void auto_epee_stuck() { Run r; r.mem = knownCord(); NetMeter down; int a = down.node(); down.add(T_J1A, a, 0.2); int b, c; weaponOnCord(down, b, c); down.add(a, b, 1.0); r.pass(down); r.pass(down); r.pass(down); check("auto: epee whose tip never opens -> blinking yellow, not green", r.at.sit == S_EPEE && r.at.light == L_YELLOW && r.at.blink); } static void auto_epee_guard_short() { Run r; r.mem = knownCord(); NetMeter rest; int b0, c0; weaponOnCord(rest, b0, c0); NetMeter down; int a = down.node(); down.add(T_J1A, a, 0.2); int b, c; weaponOnCord(down, b, c); down.add(a, b, 1.0); r.pass(rest); r.pass(down); r.pass(down); NetMeter shorted; int b2, c2; weaponOnCord(shorted, b2, c2); shorted.add(b2, c2, 0.4); // B wire touching the guard r.pass(shorted); check("auto: B touching the guard during an epee session -> red, stays epee", r.at.sit == S_EPEE && r.at.light == L_RED); } static void auto_swap_weapons() { Run r; r.mem = knownCord(); NetMeter down; int a = down.node(); down.add(T_J1A, a, 0.2); int b, c; weaponOnCord(down, b, c); down.add(a, b, 1.0); r.pass(down); r.pass(down); NetMeter none; for (int i = 0; i < 20; ++i) r.pass(none); // 3 s to unplug the epee and fit a foil NetMeter foil; int b2, c2; weaponOnCord(foil, b2, c2); foil.add(b2, c2, 0.8); r.pass(foil); r.pass(foil); check("auto: epee swapped for a foil after a pause is a new foil test, not a guard short", r.at.sit == S_FOILSABRE && r.at.light == L_GREEN); } static void auto_lame() { Run r; r.mem = knownCord(); // Cord in J1, its clip on the lame; a foil still on the cord (B-C closed). auto build = [](NetMeter& m, double spot) { int b, c; weaponOnCord(m, b, c); m.add(b, c, 0.8); int lame = m.node(); m.add(T_J1A, lame, 0.2); if (spot > 0) m.add(lame, T_PROBE, spot); }; NetMeter good; build(good, 2.0); r.pass(good); r.pass(good); bool probing = r.at.sit == S_PROBE && r.at.light == L_YELLOW && r.at.sound == SND_NONE; NetMeter dead; build(dead, 12.0); r.pass(dead); r.pass(dead); bool red = r.at.light == L_RED && r.at.sound == SND_FAIL; NetMeter lifted; build(lifted, 0); for (int i = 0; i < 30; ++i) r.pass(lifted); // 4.5 s lifted bool kept = r.at.sit == S_PROBE && near(r.at.probe.worst, 12.0f, 0.01f); for (int i = 0; i < 45; ++i) r.pass(lifted); // probe put down for good (another ~7 s) bool handsOver = r.at.sit == S_FOILSABRE; check("auto: probe on a lame wins over the foil, 2 ohm yellow, dead spot red + buzz, worst kept while lifted", probing && red && kept); check("auto: 10 s after the probe is put down, the foil on the cord is tested", handsOver); } static void auto_odd() { Run r; NetMeter m; m.add(T_J1A, T_J1C, 0.5); r.pass(m); check("auto: A-C closed alone -> red", r.at.sit == S_ODD && r.at.light == L_RED); } // ------------------------------------------------------------ flex test static void flex_tracking() { FlexTracker f; uint32_t t = 0; auto run = [&](int ln, bool on, int samples) { for (int i = 0; i < samples; ++i) { f.sample(ln, on, t); t += 100; } }; run(1, true, 50); run(1, false, 1); run(1, true, 10); // one dead sample: noise, ignored run(1, false, 30); run(1, true, 10); // 3 ms break run(1, false, 5); // still open now check("flex: single dead sample ignored, 3 ms break counted, open now", f.line[1].breaks == 1 && f.line[1].longestUs == 3000 && f.line[1].openNow, ""); FlexTracker g; t = 0; for (int i = 0; i < 10; ++i) { g.sample(0, false, t); t += 100; } check("flex: a wire that never conducted counts no breaks", g.line[0].breaks == 0 && g.line[0].openNow); } int main() { adc_math(); formatting(); cord_good_epee(); cord_good_foil(); cord_high_wire(); cord_open_wire(); cord_crossed(); cord_short_ab(); cord_short_ac(); cord_leaky(); cord_none(); cord_memory(); epee_good(); epee_guard_short(); epee_high(); epee_bounce(); foilsabre_rules(); lame_sweep(); wire_limit(); flex_tracking(); auto_idle(); auto_cord(); auto_cord_broken(); auto_cord_leaky_and_high(); auto_cord_flex(); auto_foil(); auto_foil_only(); auto_foil_high(); auto_sabre_blade(); auto_epee(); auto_epee_stuck(); auto_epee_guard_short(); auto_swap_weapons(); auto_lame(); auto_odd(); printf("\n%d passed, %d failed\n", passes, failures); return failures ? 1 : 0; }