build steps
Three 4S16P banks: 192 cells spot-welded, busbars in, BMS wired, balanced and capacity-checked. At 14 kg the pack is half the vehicle's mass and the number every other number is derived from.
Before anything is welded, prove the cells are healthy and that you have enough.
192 cells present, undamaged, and every one between 3.4 V and 3.8 V.
⚠️ Any cell below 2.5 V is dead — do not charge it. A deeply discharged lithium cell can plate copper internally and short later, in a sealed tube, at depth. Set it aside permanently. ⚠️ Big spread in resting voltage (>100 mV) suggests mixed batches. That matters for the next step.
Cells in parallel must be matched. Mismatched cells fight each other — the strong ones dump current into the weak ones the moment they're joined, forever.
Twelve labeled groups of 16, each internally matched to within ~20 mV and similar IR.
⚠️ This is the step everyone skips and regrets. Parallel mismatch is invisible on day one and shows up as a bank that won't balance, months later, sealed inside a tube.
rib_tip_d = 103.8) on the top and bottom plates only — ribs on every part means four sets of interference fighting a 5 kg pack down a 400 mm tube.A full dry stack goes in and comes out with firm but manageable force, and you know the real spare length.
⚠️ Insertion force with 5 kg behind it is the number that matters, not how one ring felt by hand. If it's a fight, drop rib_tip_d to 103.5 and reprint before committing to twelve plates. ⚠️ A pack you can't extract from a blind 400 mm tube is a genuinely bad situation. Err loose.
Never learn to weld on the real pack. This step is entirely about scrap cells.
Spot welding is resistance welding. Both probes press on the nickel a few millimeters apart. A huge current pulse goes down one probe, through the nickel, dives into the cell can, and comes back up the other probe. The resistance where nickel meets can is where heat concentrates — that is what fuses them.
~~~ probe A probe B | | v v -----#------------------#----- nickel strip \ / \________________/ current dips through the joint =====================#======= cell can <- fusion happens HERE ~~~
Every technique rule below follows from that path.
5-8 mm apart.
~~~ TOO FAR (>10mm) CORRECT (5-8mm) A B A B | | | | -#------------#- -#----#- \__________/ \__/ current shortcuts current dives THROUGH the nickel INTO the can -> two burn marks, -> a real weld no weld ~~~
If your welder has a fixed head, this is already set. If the probes are handheld, make a jig - a block of wood with two holes at 6 mm - rather than eyeballing it each time.
Press until the probes stop moving, THEN trigger.
Light pressure leaves an air gap and the pulse arcs across it. You get a bright flash, spatter, and a black pit - not a weld, and the pit is damage to the can.
Start at the low end of the dial and work up until the pull test passes.
WARNING: Pure nickel needs MORE energy than nickel-plated steel, and nearly every tutorial online uses plated steel. Nickel is more conductive so less heat develops for the same pulse. Your welder's numbers will not match what you see in videos. Scrap testing is the only calibration that counts.
Weld a tab to a scrap cell, grab it with pliers, and peel it back hard.
~~~ GOOD BAD nickel TEARS tab PEELS OFF CLEAN small discs stay welded only discolouration left to the can on the can
___ ___ ____________ | \/ | <- torn | | <- intact # # . . ^^^^^^^^^^ ^^^^^^^^^^ discs remain nothing remains ~~~
WARNING: The dangerous result is the middle one - a tab that resists a little then releases. That is a marginal weld, and it looks perfect from above.
Ten consecutive tears, no peels, at one setting. Write the setting down. Do not touch the dial again.
WARNING: Never let the probes bridge a cell's two terminals - that is a dead short through your welder. WARNING: Eye protection. Bad welds throw sparks. WARNING: Do not dwell. A long pulse heats the cell rather than the joint. If a cell gets warm, stop. WARNING: If a test cell hisses or swells, take it outside immediately and leave it there.
One layer done carefully is the template for the other eleven.
Cut nickel tabs 8 mm wide x ~30 mm long from 0.15 mm strip. That length is deliberate:
~~~ |<-- 10mm -->|<- 6mm ->|<---- 14mm ---->| [ welded to ][ through ][ folds over to ] [ the cell ][ plate ][ the copper ] ~~~
The extra length is what lets you solder away from the cell later. Do not cut them short.
~~~ tab ___________ | o o | <- pair 1 | | | o o | <- pair 2 |___________| cell ~~~
Two pairs, spaced apart. Four, not two - one marginal weld out of four costs nothing; one out of two is a joint that fails under load inside a sealed tube.
Pull-testing destroys the joint, so on real cells you use:
A 16-cell layer at one stable voltage, every tab tug-tested, every weld visually clean.
WARNING: 16 cells in parallel is 85 Ah. A short across that is thousands of amps - it welds tools instantly and starts a fire faster than you can react. WARNING: Insulated tools only. No watch, no rings. Non-conductive work surface. One hand behind your back when probing. WARNING: Kapton over exposed metal as you go. Never leave a live layer open on the bench.
The nickel carries 3 A per cell. The collector carries all 48 A - but only at the two ends of the bank. That distinction is what makes this buildable.
~~~ pack + --- 48 A arrives at ONE point on the top collector | top disc +--> spreads RADIALLY to 16 cells <- 48 A. COPPER. | layer 4 16 columns x 3 A each | interface cell N sits directly over cell N <- 3 A per column. NICKEL. | ... same at every interior interface ... | bot disc 16 x 3 A converges to ONE point <- 48 A. COPPER. ~~~
| Position | Conductor | Joint | |---|---|---| | Pack + and pack - (2 per bank) | 0.5 mm copper disc | soldered | | 3 interior interfaces | 0.2 mm nickel disc, 100 mm | welded |
WARNING: Layers must be ROTATIONALLY ALIGNED - cell N directly above cell N. Index-mark every plate and check as you stack. A rotated layer forces current sideways at that interface and the whole 3-A-per-column argument collapses.
WARNING: At every interface, one face of the conductor becomes unreachable once the stack closes. No amount of hole-drilling, weld-through, or clever folding changes that - the inner cells have no access path. Every "assemble in place" scheme fails on them.
So each layer is finished as a module on the bench with both faces accessible, and modules are joined afterwards from outside the cell array.
That module is now sealed - 16 cells paralleled top and bottom, nothing left to reach inside.
WARNING: Nickel-to-nickel needs MORE energy than tab-to-can, and 0.2 mm sheet more again. Calibrate a second setting on scrap. Do not assume the cell setting transfers.
Stacked, module N's top disc lands flat against module N+1's bottom disc. They are already touching. The only problem is the plate rim blocking probe access - so cut it away.
6-8 notches in each plate rim, exposing the disc underneath:
~~~ PLAN VIEW, looking down at one interface
_..-'''''-.._ .-' ___ ___ -. .' | | | | . <- notch: plate cut away, / __| | | |__ \ DISC RIM EXPOSED | | o o o o | | |----| o o o o o o |----| <- probes go in here | | o o o o | | \ |__ | | __| / . | | | | .' -._|__| |__|_..-' ~~~
Both probes go on the top disc through the notch. The weld forms between the two discs. You never reach between them.
WARNING: The disc must NOT protrude past the plate. The rib is 103.8 mm in a 104 mm bore; anything proud of it touches the tube and puts a series node on the hull. Cut the PLATE back, never the disc out.
Bank 1 is done and this is the method the remaining two should follow.
Each layer is a self-contained module with a nickel disc welded to BOTH faces, done while the layer was open and reachable. The top and bottom of the whole stack get copper instead (they carry the concentrated 48 A).
At each interface, two nickel discs meet face to face and are welded together through the plate notches.
* THE KEY REFINEMENT: SPREAD THE WELDS OVER THE DISC FACE. * 4 welds at each notch, at every inter-cell gap around the disc - not 6-8 points at the rim. That is ~60-80 fused points distributed across the area, so current crosses the interface wherever it arrives instead of detouring out to the edge and back. It removes the current-concentration objection that killed the earlier edge-solder and rim-strap ideas.
Design as though ONLY THE WELDS CARRY CURRENT. The unclamped face contact between them is oxidized and unreliable - do not count it. With ~60-80 nuggets of fused metal at microohms each, that is effectively a continuous joint anyway.
WARNING: Do NOT use conductive paste between the discs. Three reasons: (1) resistance welding needs bare metal-to-metal contact, and a paste layer gives spatter and arcing instead of fusion; (2) conductive pastes are far MORE resistive than nickel - they exist to fill microscopic gaps in a CLAMPED joint, not a welded one; (3) residue between two conductors inside a sealed pack is the exact failure mode that has already cost this project a driver chip.
What actually helps: clean both nickel faces with isopropyl before mating, and make sure the discs are FLAT. A disc with a wave in it only touches where the probes press.
Bank 1 result: 14.28 V across the pack, 3.57 V per group, all four equal. Every tab and disc tugged during welding.
The only soldered joints in the bank, and both sit at the outside of the stack where an iron reaches.
2. Drill 16 holes, ~9 mm, aligned with the plate windows. The tab passes UP through the copper and folds flat on TOP - the only way the joint is reachable with an iron. - Use the printed plate as the drill guide: lay the copper under it, mark through the windows, drill the marks. - 9 mm not 12 mm - sixteen 12 mm holes remove ~23% of the disc against ~13% at 9 mm, and this disc carries the full 48 A. - WARNING: Thin copper grabs a drill. Back it with scrap wood, clamp it, step drill at low speed. - Deburr both faces. A sharp edge cuts a tab that flexes through it.
3. Preheat the bare disc to ~150 C (oven or hotplate), then flux and tin the 16 pads. A hotplate beats an oven because the disc stays hot while you work; out of an oven, 0.5 mm copper cools noticeably in about a minute. - WARNING: NEVER put a disc in an oven or on a hotplate with cells attached. Li-ion at 150 C is thermal runaway. Bare copper only. - Do not use a heat gun - it holds a large area hot in open air and grows the oxide that stops solder wetting.
WARNING: Clamp a hemostat on each tab between the joint and the cell. Not optional - soldering puts in far more heat than welding. WARNING: Nickel needs an active flux; it does not wet like copper. WARNING: You cannot spot-weld to bare copper - it shunts both the current and the heat, because copper is ~7x less resistive than nickel and conducts heat ~6x better. Weld to nickel, solder to copper.
A 4S16P bank at ~14.8 V: three welded nickel interfaces, two soldered copper end discs, every joint tugged, every layer index-aligned.
WARNING: Voltage climbs as you stack. Insulate each interface as you make it, not at the end. WARNING: Verify every weld BEFORE the next module goes on top. Once stacked you cannot see or reach them. WARNING: A nickel disc touching the aluminum tube puts a series node on the hull, and two of them shorts a group. Check rim clearance at every interface.
The bank stack is ~309 mm in 361.8 mm of usable tube. That 52 mm is not waste - it is where the bank's electronics live, and filling it is a safety requirement in its own right.
WARNING: A 4.5 kg pack with 52 mm of travel will slam into an end cap. Every bit of that shock lands on 0.2 mm nickel foil. Filling the gap removes the single largest mechanical threat to the welds.
The rule the whole pack is built to:
> The printed structure carries the load. The nickel carries only current.
* EVERYTHING GOES AT THE B+ END. * (Revised 2026-08-06 - an earlier version of this step said B-, which was wrong.)
The fuse (#110) and the high-side shunt (#134) both have to be at B+ regardless, so putting the protoboard there co-locates three of the four things - and it keeps the shunt's Kelvin sense wires short, which is what sets the accuracy of the entire coulomb count.
| Location | Contents | |---|---| | B+ end module (protoboard) | BMS (#36), MIDI fuse (#110), shunt (#134) on its copper carrier, ADS1115 (#132) + dividers (#133), INA228 (#72), balance connector, charge connector | | On the pack itself | the four DS18B20s (#129) - nothing else lives inside the layers |
The BMS mounts FLAT - 61 x 42 mm is a 74 mm diagonal in a 103 mm bore - so it costs only its thickness, not its length.
One 10 AWG run goes from the bottom disc up to the BMS's B- terminal, ~350 mm. That is ~1.1 mOhm: 2.6 W at the 48 A stall, 0.16 W at the real 12 A load. WARNING: give it its own rim channel, roughly opposite the signal harness - a 10 AWG silicone wire is ~4 mm and will not share a slot with five signal wires. And strain-relieve it within 30 mm of the BMS pad; those pads expect a short stub, not 350 mm of dangling wire in a vehicle that gets handled.
A 4S pack has five electrical levels, and in this stack each one is a disc:
~~~ =============== copper disc <- B+ (pack positive) [ layer 4 ] =============== nickel pair <- B3 [ layer 3 ] =============== nickel pair <- B2 [ layer 2 ] =============== nickel pair <- B1 [ layer 1 ] =============== copper disc <- B- (pack negative) ~~~
One wire per disc. Five wires. The BMS does not measure groups directly - it measures BETWEEN adjacent taps, so layer 1 is B1 minus B-, layer 2 is B2 minus B1, and so on. The same five feed the ADS1115, the cell checker (#131) and the balance charger (#15).
Attaching to them:
| Disc | Method | |---|---| | Nickel interfaces (B1, B2, B3) | WELD a short nickel tab into a notch, then solder the wire to the protruding end | | Copper end discs (B-, B+) | SOLDER directly |
Same rule as the whole pack: weld to nickel, solder to copper. WARNING: make the wire joint on the exposed tab end with a hemostat clamped between the joint and the disc.
Lead lengths (stack ~309 mm, module at B+):
| Tap | Cut to | |---|---| | B+ | 100 mm | | B3 | 150 mm | | B2 | 230 mm | | B1 | 300 mm | | B- | 380 mm |
22 AWG stranded silicone for all five - sized for mechanics, not amps (they carry ~100 mA of balance current at most). Do NOT go thinner: these run through a vehicle that vibrates, and a fatigued balance lead leaves the BMS reading a floating tap. Cut generous and trim.
WARNING: B- and B+ each get TWO wires - the 10 AWG power lead AND a 22 AWG sense lead. Do not make the heavy lead do both, or the sense reading includes the lug's voltage drop at 48 A. WARNING: The I2C signal ground is NOT a power return. During bench bring-up the INA228's GND pin is the only bond between pack B- and the Jetson, and that is fine - nothing is drawing, so nothing flows through it. The moment the pack FEEDS the Jetson, the heavy negative lead becomes the return path and this thin wire sits in PARALLEL with it. Current divides by resistance, not by intent: a 22 AWG signal ground bridging the same two nodes as a 10 AWG return will carry far more than it was ever sized for. Give the Jetson's return its own heavy conductor and keep the I2C ground as a REFERENCE only - or star both at a single point so no parallel path exists.
Down the OUTSIDE of the stack, in the ~10 mm band between the cell array (83 mm) and the plate rim (103.8 mm). The cells fill the center - there is no path through the middle.
Pick one clock position and run all five signal wires down that single channel. Check first whether the interface notches already line up vertically; if they do, no cutting is needed. If not, cut a 4-5 mm wide, 3-4 mm deep slot at the rim of each plate at the same clock position - an open channel you lay wires into, not a hole you fish them through. WARNING: do not cut into a rib; those set the 103.8 mm tube fit.
WARNING: Connect the balance leads B- first, then B1, B2, B3, B+ in that order. Each sits at a higher potential than the last, so bottom-up means you are never holding a live high-potential wire with nowhere defined to go. Insulate each individually before bundling, and label both ends AS YOU GO - five identical wires at five different potentials is how a good pack gets shorted.
WARNING: You cannot charge through the bus - the ORing diodes (#44) are unidirectional. Each bank is charged upstream of its own diode, which is why the charge connector (#116) is 6-pin: three B+/C- pairs.
The connector lives INSIDE the electronics tube in a dry volume - no sealing, no hull penetration. Workflow: open -> desiccant -> charge -> close -> vacuum test -> dive.
WARNING: The fuse is the one people forget. FUSING.md F1 puts a 60 A fuse in each battery tube, before the ORing diode. It is what stands between a shorted bank and a fire.
The BMS needs five taps - B-, B1, B2, B3, B+. Three come from the internal nickel discs, mid-stack.
Route them through the interface notches. Those are already cut for welding access, so make two of them wider and use them as the wire channel. Plan which notches carry wire and which you weld through.
The same channel carries the DS18B20 wiring (#129, four per bank).
Also needed: a balance connector facing the end cap that the cell checker (#131) can plug into. The selected BMS has no UART, so that connector is the only per-cell visibility on the bench. Design it in.
WARNING: The center cells are NOT reachable once a bank is assembled. Placement is constrained to disc rims mid-stack, and the disc face at the ends:
| # | Location | Watching for | |---|---|---| | 1 | Copper end disc, center | cell temperature — copper conducts well and the disc center sits over the center cells, so it is the best accessible proxy | | 2 | Nickel disc RIM, layer 2/3 interface | the thermally deepest point reachable. Pick the MIDDLE interface: layers 2 and 3 are surrounded axially as well as radially | | 3 | Fuse holder | a loose fuse joint — the classic hot spot | | 4 | Shunt carrier or B+ lug | the other high-current termination |
WARNING: Rim readings read LOW. The rim is both the furthest point from the hot center cells AND the closest to the tube wall, so it is the best heat-sunk part of the disc. Treat it as a TREND sensor, not an absolute one — a rim reading climbing over a mission is real information, but do not set an alarm threshold as though it were the hottest point in the pack. WARNING: Record each sensor's 64-bit ROM address against its location BEFORE bonding it. 1-Wire enumerates by ADDRESS, not by position on the bus - four sensors come back in arbitrary order. Bond them without a map and we get four anonymous temperatures: we would know something is at 45 C but not whether it is the fuse or a disc rim. And because rim readings deliberately read LOW, an unlabeled reading is worse than no reading. Use software/tools/ds18b20.py - wire all four, run it, then pinch each sensor in turn and watch which delta moves. That identifies AND proves out every sensor in one pass. The map persists in ds18b20-labels.json. Unrecoverable once a bank closes.
* DO THIS ON BANK 2 OR 3 — BANK 1 IS ALREADY SEALED. * While layer 2 is still open, tack a FIFTH sensor onto a CENTER cell and route its wire out through the interstitial gaps. The load test then gives the actual offset between what the rim reads and what the hottest cell is doing, which turns every rim sensor from "a trend" into "a trend with a known offset". That number is unrecoverable once a bank closes.
ONE 4.7 kOhm pull-up for the WHOLE 1-Wire bus, not one per sensor — all 12 sensors share three wires. Mount with thermal epoxy or a thermal pad plus Kapton (#115), and strain-relieve the leads: three thin TO-92 legs are the weak point, and a break can take out every sensor downstream of it on a shared bus. - BME280 x1 per battery tube (#71). In a battery tube this is a safety sensor: a venting cell spikes pressure and humidity before heat conducts through a can to a DS18B20. Same reading catches flooding. - INA228 (#72) + 0.5 mOhm shunt (#134) on the bank output, for coulomb counting.
| Setting | Value | |---|---| | R_SHUNT | 0.0005 ohm (#134) | | ADCRANGE | 1 — the +/-40.96 mV span | | Full scale | 81.92 A | | CURRENT_LSB | 156.25 uA | | SHUNT_CAL | 4096 |
The datasheet relation is SHUNT_CAL = 13107.2e6 x CURRENT_LSB x R_SHUNT, then x4 because ADCRANGE = 1. With CURRENT_LSB = 81.92 / 2^19 = 156.25 uA, that is 1024 x 4 = 4096.
Why the narrow range. Our worst case is 48 A x 0.5 mOhm = 24 mV, which fits +/-40.96 mV with room to spare and buys 4x the resolution of the wide span. It clips above 81.9 A, which is only reachable in a fault - and in a fault the exact number is not the point. WARNING: the coulomb count under-reports during a clipped fault. That is the accepted trade; do NOT "fix" it by widening the range and giving up resolution across the entire normal operating band.
Set it with `software/tools/ina228.py`, which writes CONFIG and SHUNT_CAL and then reads SHUNT_CAL back and refuses to continue if it does not match. Run it on the bench before this module is trusted with anything. * CORRECTION 2026-08-21 - the "wrong by 30x" warning above was aimed at the wrong register. * Verified on hardware: the INA228's power-on default for SHUNT_CAL is 0x1000 = 4096, which is EXACTLY the value our 0.5 mOhm shunt needs. The register write is belt-and-braces, not load-bearing.
The number that IS load-bearing is CURRENT_LSB in software = 156.25 uA. The chip returns a raw CURRENT count; our code multiplies it by CURRENT_LSB to get amps. Derive that constant from the wrong shunt - say the 15 mOhm part the breakout ships with - and every reading is off by 30x while SHUNT_CAL sits at a perfectly correct 4096. So the failure mode is real and still silent; it just lives in the host code, not the device.
Why 4096 lands the same either way: SHUNT_CAL calibrates to FULL SCALE, and CURRENT_LSB is defined as full-scale/2^19. Change ADCRANGE and both the span and the LSB move together, so the product is unchanged. ADCRANGE=1 gives 81.92 A full scale at 156.25 uA/LSB; ADCRANGE=0 gives 327.68 A at 625 uA/LSB. Same SHUNT_CAL, 4x the resolution on the narrow range.
Mount the shunt on a copper-sheet carrier cut from #130: two pads with a small gap, shunt bridged across, ring lugs bolted on. Pads 15-20 mm wide - 48 A through 0.5 mm x 10 mm is ~10 A/mm2 - and those pads are also the heatsink the 3 W rating assumes. Kelvin sense on the INNER pads only, never the current pads, or you are measuring your own solder joints.
The ADS1115 readings mean nothing without two numbers per divider, and neither can be reconstructed once the pack is sealed:
Twelve dividers across three banks. Do it here, with the modules open. The calibration is only as good as the meter used as the reference.
Both end modules fit, the stack has no axial travel, every lead is routed and strain-relieved, and the balance connector is reachable with the tube open.
WARNING: Do not seal the modules. Leave a vent path. A cell venting into a closed pocket is far worse than one venting into the tube. WARNING: Do not pot the pack. It traps heat and blocks venting. WARNING: Voltage is live across the whole stack by this point. Insulate as you go, and treat the balance harness as live - a tap shorted to the hull or to a neighbor is a shorted group.
The INA228 answered I2C, passed MEMSTAT, accepted every register write, and returned ZERO for every measurement. It was diagnosed as hot-air damaged from the R015 removal. It was not damaged. Its supply was 1.49 V.
WARNING: An INA228 on a dead rail still talks. The Jetson's I2C pull-ups push current through the SDA/SCL ESD diodes and onto the chip's supply, which is enough to run the digital core - registers read and write, MEMSTAT passes, DEVICE_ID is correct - while the ADC, which needs the full 2.7 V minimum, never converts. Every measurement comes back zero and the part looks dead rather than starved.
* THE TELL IS DIETEMP. * Die temperature needs NO external wiring. If DIETEMP reads 0x8000 (-256 C) the chip is not converting and the problem is POWER, not your sense wiring. A sane room temperature there means the analog side is alive and anything still wrong is external. Check it FIRST, before touching the shunt wiring or suspecting the part.
Root cause was two stacked faults in the LM2936 (#138) supplying it: 1. TO-92 pinout mirrored. The TI datasheet draws Figure 5 as a BOTTOM view while every other package in the document is Top view. Built flat-face-toward-viewer, IN and OUT swap. Symptom: a STEADY ~0.69 V diode drop on the output. 2. No output capacitor. Once the pins were corrected the output sat at 11.6 V of a 15 V input - the pass device wide open, because the loop has no compensation without C_OUT. WARNING: 11.6 V would have destroyed the INA228 (~6 V absolute max) had it been connected. With the pins swapped and a 10 uF tantalum plus a 0.1 uF ceramic fitted: 3.309 V, dead center of the datasheet's 3.234-3.366 V window. DIETEMP then read 21.19 C immediately.
WARNING: neither fault could be diagnosed by testing the other. Swapping pins with no capacitor fitted gives an invalid result in BOTH orientations. Fit the capacitor FIRST, then test orientation.
* MEASURE VS AT THE CHIP'S OWN PINS BEFORE SUSPECTING THE CHIP. * It is one probe placement and it would have saved an evening.
WARNING: * THE INA228's GROUND GOES TO B-, NEVER TO C-/P-. * On the BMS, B- is the pack's raw negative; C-/P- is the SWITCHED negative, downstream of the protection MOSFETs. Ground the monitor to C- and two things break: every bus reading picks up the FET voltage drop, which varies with current, so the error grows exactly when accuracy matters most - and when the BMS OPENS, the reference disappears entirely, so the monitor goes blind at the moment you most need to know what the pack just did. Diagnosed live on bank 1: grounded to C- the INA228 read 3.566 V instead of the pack; moved to B- it read 14.257 V against a 14.28 V meter. Same wire, same chip, one terminal over.
| Measurement | Result | | |---|---|---| | DIETEMP | 21.19 C | ADC converting | | VBUS | 14.257 V vs 14.28 V meter | 0.16% agreement | | Current offset, no load | -6 mA of 81.92 A full scale | 0.007% | | SHUNT_CAL | 4096, verified by readback | |
STILL UNPROVEN: the CURRENT path. -6 mA is an offset, not a measurement - nothing has drawn through the shunt yet. The load test is 3-5 A compared against a clamp meter, watching that shunt mV = amps / 2 at 0.5 mOhm. Until that passes, treat the amp column as unvalidated.
The INA228's coulomb counter is the state-of-charge instrument - Li-ion voltage is nearly flat from ~30% to ~80% SoC, so amp-hours are what actually say when to come home. Scaling verified against TI SLYS021A: Charge [C] = CURRENT_LSB x CHARGE (40-bit two's complement, register 0x0A) and Energy [J] = CURRENT_LSB x 3.2 x 16 x ENERGY (40-bit UNSIGNED, register 0x09).
* THE ACCUMULATOR IS RAW AND FREE-RUNNING. * It only resets when something writes RSTACC, and it rolls over to zero on overflow (~23,900 Ah at our 156.25 uA LSB - hundreds of full cycles, but real). Always read it as a DIFFERENCE between two readings, never as an absolute state of charge.
WARNING: A zero-current offset does not stay small. Bank 1 reads -6 to -8 mA with nothing drawing. That is 0.007% of the 81.92 A full scale and utterly negligible as an instantaneous number - but the counter integrates it:
| | | |---|---| | offset | ~ -7 mA | | per day | -0.17 Ah | | over a 5.5-day mission | -0.92 Ah | | as a share of an 88 Ah bank | ~1% |
Measured live 2026-08-23: -0.008884 Ah accumulated over ~1.3 h with the pack idle, exactly the rate the offset predicts. The coulomb count would quietly report a 1% deeper discharge than reality on every long mission, and it would look completely plausible.
* THE FIX: measure the zero-current offset and subtract it in software. * Do this AFTER the load test, so the zero is established against a shunt path that is known good rather than one that has never carried current. Record the per-bank figure here - it is a property of that shunt, that carrier and those Kelvin joints, not a constant to be shared between banks.
WARNING: * THE CHARGE ACCUMULATOR RESETS WHEN THE INA228 LOSES POWER. * Observed 2026-08-23: the counter read -0.009516 Ah, the bench was powered down and back up, and the next reading was -0.000894 Ah - about 13 minutes of accumulation at the idle offset, not an hour. The register is volatile; there is no non-volatile charge state in the chip.
This matters more than it looks. A reset does NOT announce itself, and the delta between the reading before and the reading after is a POSITIVE jump - which reads as the pack GAINING charge. At bench scale that is a fraction of a milliamp-hour and harmless. Mid-mission, a pack that had counted down -30 Ah and then briefly lost the 3.3 V rail would report +30 Ah of charge appearing from nowhere, and the state-of-charge estimate would be wrong in the optimistic direction - the dangerous one.
Consequences to design around: - The INA228 must stay powered continuously for state of charge to mean anything. It sits on the pack-side LDO, so in the vehicle it is only interrupted by service or a rail fault - but that makes the LDO's reliability a state-of-charge dependency, not just a telemetry one. - Any service that opens the module resets the count. Expect it, and re-establish the reference against a known state (full charge) rather than trusting the running total afterwards. - A reset is indistinguishable from a charge event by the numbers alone. Detecting it needs something outside the accumulator - the agent knowing the chip was just configured, or a plausibility check against measured current. NOT YET IMPLEMENTED; the /power delta guard only rejects deltas above 1000 Ah, which a realistic reset would slip straight under.
Method: the bench PSU (#93) in CONSTANT CURRENT as the reference, pushing charge current backward through the shunt, rather than a resistive load. In CC the supply IS the ammeter - it regulates to a set current and displays it - which beats a resistor whose value drifts as it heats, needs 60-70 W of dissipation, and can only be changed by swapping hardware.
WARNING: the whole vehicle was live on the pack during this test (Jetson, Pixhawk, regulators), so the PSU fed the vehicle AND the pack: shunt current = PSU current MINUS vehicle load. The saving grace is that stepping the PSU by a known amount changes the shunt current by that same amount, so the load term cancels in a DIFFERENCE between two PSU points. Differencing against PSU-off does NOT cancel it.
| PSU set | INA228 median | sample sd | bus V | |---|---|---|---| | 0.98 A | -0.38812 A | 0.01679 | 14.2706 | | 2.98 A | -2.37820 A | 0.01657 | 14.4579 | | 4.98 A | -4.34016 A | 0.00555 | 14.5990 |
Sign convention confirmed: POSITIVE = DISCHARGE, NEGATIVE = CHARGE. Bus voltage rose across the run, which is the independent check that current really was going into the pack.
RESULT: the amp column agrees with an independent source to within 1-2% across a 5:1 range. No gross error, no sign inversion, no scaling mistake. That was the purpose of the test and it passed.
* NO CORRECTION FACTOR WAS APPLIED, DELIBERATELY. * Two reasons, both about honesty: 1. A bench PSU's display carries roughly +/-0.5-1%, which is the same order as the 1-2% being measured. Trimming the INA228 to match a reference no better than itself could make it WORSE. 2. Pairwise slopes disagree - 0.995 (0.98->2.98 A), 0.988 (0.98->4.98 A), 0.981 (2.98->4.98 A). The residuals against a constant-power vehicle load shrink as the signal grows (-0.053, -0.035, +0.010 A), which is the signature of a DRIFTING BASELINE, not a nonlinear shunt. LINEARITY IS THEREFORE UNPROVEN, not proven-good. Any nonlinearity is smaller than the baseline drift, so this test cannot see it. Do not record it as linear.
WARNING: The 1 A point is not usable for calibration. At a 1 A step against a 0.65 A varying vehicle load, the load wobble is the same size as the answer. Note the sample sd: 0.0168 at 1 A vs 0.0056 at 5 A - a 3x improvement purely from signal size. Calibrate at the top of the range.
* STILL OUTSTANDING - needs the vehicle powered DOWN: * with the pack isolated (PSU -> shunt -> pack and nothing else on the node), PSU current IS shunt current, with no load term to cancel or drift. That run gives a real linearity check, a scale factor limited only by the PSU, and - most importantly - the TRUE ZERO OFFSET, which is the number that fixes the -0.17 Ah/day phantom drain in the coulomb count. Until then the offset remains uncorrected.
* THE PACK IS BALANCED TO 1 mV. * Measured directly as ADJACENT balance-lead pairs with a handheld meter - B- to B1, B1 to B2, B2 to B3, B3 to B+ - which gives each group as a DIRECT reading with no subtraction:
| group 1 | group 2 | group 3 | group 4 | spread | |---|---|---|---|---| | 3.577 V | 3.578 V | 3.577 V | 3.578 V | 1 mV |
WARNING: Measure adjacent pairs, not each tap to B-. Tap-to-B- readings are CUMULATIVE, so every group is the difference of two large numbers and a small percentage error on each becomes a large error on the difference. Group 4 is 14.3 minus 10.7 to extract 3.5 - a 1% channel error there is 250 mV on the answer. Direct adjacent-pair measurement has no subtraction in it at all, which is why it is the referee and the ADS1115 is not.
That distinction mattered: uncalibrated at the nominal 7.0 the ADS1115 reported a 250 mV spread, 5x the "investigate" threshold, and it was ENTIRELY divider error. * Do not calibrate before confirming the pack against a direct reference. * Calibrating against what looks like a drifting pack bakes a phantom imbalance into the correction factors permanently.
Per-channel ratios now in `software/tools/danu-agent.py` (DIVIDER_RATIOS):
| ch | true tap | ratio | err at nominal 7.0 | |---|---|---|---| | A0 (B1) | 3.577 V | 6.9322 | +0.98% | | A1 (B2) | 7.155 V | 7.0019 | -0.03% | | A2 (B3) | 10.732 V | 7.0276 | -0.39% | | A3 (B+) | 14.310 V | 7.1287 | -1.81% |
With these applied, the reconstructed groups match the meter to within 0.1 mV.
* TWO THINGS ARE UNRESOLVED AND SHOULD NOT BE FORGOTTEN: *
1. The ratios spread 2.81% (6.9322 to 7.1287), which 0.1% RN55E resistors CANNOT account for. A shared gain-plus-offset model was tested and fits WORSE (72 mV worst residual), so the per-channel differences look real. Candidates: the ADS1115's finite input impedance loading an 85.7 kOhm source (600k || 100k), or the fitted resistors not being the specified parts. MEASURING THE TWELVE RESISTORS SETTLES IT and is the next thing to do.
2. Calibrated at ONE pack state (14.31 V). One point cannot distinguish a per-channel GAIN error (which holds at any voltage) from a common OFFSET (which is only right near 14.3 V). If it is actually an offset, these ratios are wrong everywhere else on the discharge curve - by ~0.1 V per group at 12 V, which is exactly the size of the drift they exist to detect. RE-VERIFY AGAINST THE METER AT A MATERIALLY DIFFERENT PACK VOLTAGE before trusting group voltages below full charge. Until then: good near 14.3 V, unproven under it.
The per-channel-ratio calibration recorded earlier was WRONG IN FORM, not just in value. The correct model is gain AND offset:
V_tap = (V_adc - offset) * ratio bank A: ratio 7.0663, offset 8.57 mV
One ratio and one offset fit all four channels to under 0.12 mV. Fitting four independent ratios to the same data instead produces bogus per-channel values spanning 1.3%, which is the false trail this project spent an evening following.
WHERE THE OFFSET COMES FROM. The divider's source impedance is 600k || 100k = 85.71 kOhm, and the ADS1115 leaks about 100 nA into it. That develops 8.57 mV - identical on every channel, because every divider presents the same source impedance. A fixed voltage offset looks exactly like a ratio error if you assume the fit line passes through zero.
* DO NOT REPLACE THIS WITH PER-CHANNEL RATIOS. * A gain-only fit is correct ONLY at the voltage it was taken at. At 12 V it would be wrong by roughly 0.1 V per group - the same size as the drift the whole measurement exists to detect - and it would fail SILENTLY as the pack discharges. The offset form holds across the range.
WARNING: ADC noise is amplified twice before it reaches a group voltage. It is multiplied by the divider ratio (x7), and then two noisy taps are SUBTRACTED. A 1 mV wobble at the ADC lands as up to ~14 mV of apparent group spread - against a 20 mV "investigate" threshold, that is most of the budget spent on nothing.
Measured, on a pack the meter says is balanced to 1 mV:
| ADC config | reported spread | |---|---| | 128 SPS, single sample | 13.6 mV | | 8 SPS, median of 4 | 2.2 - 2.7 mV |
8 SPS is the ADS1115's quietest rate. The cost is 125 ms per conversion, ~2.4 s for a full four-channel sweep with four samples each - free on a five-minute interval. Use the MEDIAN, not the mean, so one glitched conversion cannot move the answer.
Three separate faults, found in this order, each masking the next: 1. A0's bottom leg open - input railed at VDD. The 600k top leg limited the fault to ~20 uA into the ADS1115's protection clamp, so the chip survived. That fail-safe property is a real reason not to be tempted by lower divider resistances. 2. A1 and A2 swapped at the header. 3. A3's midpoint-to-pin wire progressively failing - ~250 kOhm of series resistance. It read open, then floating, then stable-but-wrong across successive power cycles. WARNING: it was stable enough at one point to be CALIBRATED, and that produced a plausible, wrong ratio (7.1287) that went into the code. A calibration constant taken through a failing joint encodes the fault as if it were a property of the divider. Plus a solder bridge between A1 and A2 introduced during the repair - visible as both channels reading the AVERAGE of their two correct values.
* THE LESSON: rebuild, do not keep repairing. * Three attempts at A3's joint each moved the fault without fixing it. Rebuilding the whole divider circuit fixed it in one pass AND made the offset model visible, because clean data is what let a single line fit all four channels.
BMS reporting all four groups, balanced, with leads routed and strain-relieved.
⚠️ A balance lead that chafes through against a cell can is a dead short inside a sealed tube. Strain-relieve everything and check nothing is trapped where a plate meets the bundle.
Each bank gets its own fuse at the pack B+, inside the battery tube, with the shortest lead you can manage. 80-100A - it is short-circuit protection, not overcurrent, and a fuse near 48A would open during normal full thrust.
WARNING: Never put this fuse in the electronics tube. The run from pack terminal, through the tube, through a penetrator, across to the electronics tube would be completely unprotected - a chafe anywhere along it is a dead short with 85 Ah behind it inside a sealed hull.
Use a bolt-down MIDI/AMI fuse, not a blade holder - a blade holder is ~50 x 25 x 20mm and the tube has only ~53mm spare, 15mm of which the BMS takes.
Full plan for every fuse on the vehicle: docs/FUSING.md
Prove it before it goes in a tube you have to unseal to fix.
Measured capacity within 10% of nominal, balanced after a rest, and no joint warmer than its neighbors under load.
⚠️ Do this outside or in a fire-safe space, not on the bench beside everything else you've built. ⚠️ A bank that won't balance now will never balance. Find out here, not at 300 m.
Same process, twice more.
⚠️ Don't let familiarity erode the checks. Bank 3 is where polarity errors happen, because by then it feels routine. ⚠️ Keep the three banks matched to each other as well — they'll be ORed onto a common bus, and a bank that's meaningfully weaker will be back-fed by the other two.
Three banks built, tested, logged, and labeled — ~3.7 kWh total, ready for their tubes.