What this is

AtmoSense is a startup in San Diego building an ozone sensor. I spent the summer as their hardware intern redesigning the development board that carries the sensing circuit: pulling the ATSAMD21G18A-A off an Adafruit ItsyBitsy M0 module and onto the board itself, bare, while leaving the analog sensing circuit untouched.

The result is V5.0: 41.91 × 48.26 mm, 4-layer, 83 footprints, zero unconnected pads, fabricated and machine-assembled at JLCPCB. Five boards came back, about $30 each, all five powered up.

Top-down render of the board

V4.1 and V5.0 side by side, same hand

The measurement problem

The sensor is a chemiresistor whose resistance swings from a few kΩ to around 800 kΩ with ozone concentration — too wide for one fixed divider. The board’s DAC biases the sensor, an 8:1 analog mux picks from a bank of reference resistors, and a 16-bit ADC reads the result, auto-ranging through the eight channels for resolution:

$$R_{gas} = \frac{R_{ref}}{\dfrac{V_{out}}{V_{bias}} - 1}$$

A DMG3415 PMOS switches the heater at 732 Hz PWM, with a 1 Ω shunt so the MCU can back out heater resistance and temperature. An SHT4x covers humidity/temp; a W25Q32 SPI flash logs onboard, with a microSD header for the enclosure.

Power rails and a regulator mistake

VDDCORE is the output of the SAMD21’s internal 1.2 V regulator — tying it to 3.3 V destroys the chip. One cap to ground, nothing else. Everything on the board is 3.3 V native: no USB series resistors, no level shifters.

The real mistake was sizing the LDO against the heater’s average current instead of its peak. Measured peak is 550–580 mA per pulse, because the heater is coldest right when a pulse starts — a 250 mA part would have browned out every cycle. I moved to a 1 A TLV75733 in a 2×2 mm WSON-6, which forced the thermal math:

$$T_J = T_A + P \cdot \theta_{JA}$$

At 0.3 A average, \(T_J \approx 58\,°C\) — comfortable under the 125 °C limit. At worst case (100% heater duty, 85 °C ambient), it comes out around 137 °C, over the limit. That’s the number that explained the firmware’s 50% heater duty cap: not an efficiency choice, thermal protection for the regulator.

Layout and SPICE

Placement got redone twice as the board outline changed, and went from miserable to tractable once I started placing functional blocks before individual parts. The real bug: the 576 mA heater pulse was returning through 16.6 mm of surface copper past the analog cluster before finding a ground via. One via on the shunt’s ground pad fixed it.

Two questions got SPICE testbenches instead of guesses. Does the heater pulse sag the 3.3 V rail? About 77 mV droop, fully recovered before the next pulse — fine. Does that droop reach the measurement? The bias path rejects 33 dB of it at 732 Hz, and the ADC’s internal reference doesn’t care about rail ripple at all.

The reference ladder

Originally 1 kΩ–330 kΩ, which left almost no resolution against an 800 kΩ sensor. Revalued to a geometric sweep, ~2.15× per rung:

ChannelValueChannelValue
S14.7 kΩS5100 kΩ
S210 kΩS6220 kΩ
S322 kΩS7470 kΩ
S447 kΩS81 MΩ

Every value change has to land in three places — schematic, BOM, firmware constants — and I caught a stale firmware table that still had the old values before it shipped.

Designing for machine assembly

JLCPCB’s real gate isn’t warehouse stock or the “Basic” library flag — it’s “Idle Parts Stock,” a number only visible in their web UI, not the API. Zero idle stock forces a full-reel pre-order even with a Basic part and 700,000 pieces warehoused. It’s also package-specific: 10k/4.7k pull-ups had zero idle stock in 0402/0603 but plenty in 0805, so I moved them. A chunk of the last week was nudging values and packages until everything was Basic and actually orderable.

That $150-for-five figure was the prototype cost. I later built a fuller model separating fab/parts/assembly from shipping and a 35% duty assumption, swept across order quantity:

Cost per board vs. order quantityEx-works and landed cost per board drop as order quantity increases from 5 to 1000 boards: from $25.03 ex-works and $42.50 landed at 5 boards, down to $9.32 ex-works and $12.93 landed at 1000 boards. Full numbers are in the table below the chart.$0$10$20$30$405101520501005001,000Order quantity (boards, log scale)Hover or tab through a point ↓5 boardsex-works $25.03landed $42.510 boardsex-works $17.67landed $27.0915 boardsex-works $17.4landed $25.820 boardsex-works $15.85landed $23.1950 boardsex-works $12.48landed $17.75100 boardsex-works $11.85landed $17.33500 boardsex-works $9.6landed $13.311000 boardsex-works $9.32landed $12.93$12.93$9.32
Ex-works ($/board) Landed, with duties ($/board)
I pulled these from JLCPCB's PCB assembly quote calculator and swept them across order quantity by hand. “Landed” adds shipping and a 35% import-duty assumption on top of the ex-works quote, a snapshot at the time of the quote, not a fixed number. Hover or tab through a point for exact figures; full table below.
Full numbers
Order qtyEx-works $/boardLanded $/board
5$25.03$42.5
10$17.67$27.09
15$17.4$25.8
20$15.85$23.19
50$12.48$17.75
100$11.85$17.33
500$9.6$13.31
1,000$9.32$12.93

At 5 boards, shipping and duty add 70% on top of ex-works cost; at 1000 boards, that’s down to 39% — the prototype run pays the steepest tax on both fronts.

Pre-fab review

Full schematic/PCB/EMC/thermal review before release, plus KiCad’s own ERC/DRC. Electrically clean; the real problems were manufacturing ones: 7 vias sitting inside pads (fatal in reflow — paste wicks down an unplugged barrel), and zero fiducials. Both fixed. Everything the review flagged that KiCad’s own DRC didn’t confirm — a claimed ground-plane island, a “missing” LED resistor that was plainly in series — got dropped from the final report. Files win over any tool’s opinion of the files.

Lessons

  • Size power against peak current and worst-case ambient, not the bench.
  • Do the thermal math early — it changes part choices.
  • Manufacturing constraints (package, fiducials, via placement) belong in schematic phase, not after routing.
  • When a value changes, grep for it everywhere: schematic, BOM, firmware, docs.

Bring-up

Bootloader went on over SWD from a second dev board acting as a programmer. One trap: SWD can appear to work on a completely unpowered target, because the programmer’s 3.3 V leaks in through the ESD clamp diodes — a Found Target and a passing verify at a sagging voltage that fails later under real load. Power the target from its own USB-C, always.

Assembled AtmoSense Devboard V5.0, powered over USB-C with the ozone sensor plugged in

Porting firmware surfaced two silent bugs, same root cause: the stock Arduino board definition I compile against was written for a different Adafruit product on the same chip, so its pin capabilities are wrong for mine. The heater PWM pin is a real timer output in silicon, but the definition marks it as non-PWM — analogWrite() silently falls back to on/off, no error, and the heater just looks broken. Fixed by driving the timer registers directly. The SPI flash chip failed the same way: the definition’s data-in pin is wired to microSD chip-select on this board. Fixed by giving the flash chip its own SPIClass instance on the correct pin.

What real ozone found

Real exposure found the edges of the ladder redesign: baseline ran 1.2 kΩ–45 kΩ across four sensors, and one sensor hit 16 MΩ under ozone — past the top rung. The auto-ranging logic also couldn’t keep up with a fast transient, sitting stuck on the 4.7 kΩ rung through an entire excursion. Eight firmware fixes closed the gap; the one that matters most is a per-row data-quality flag, since \(R_{gas}\)’s denominator collapses near full-scale (58 mV signal against ±4.5 mV op-amp offset at the 16 MΩ peak). Trust below ~200 kΩ, treat above ~2 MΩ as “detected something,” not a precise number.

Separately, the SHT4x reads its own self-heating (+14.7 °C at steady state), not the room. A single exponential didn’t fit; two thermal time constants (90 s / 900 s) do, reaching ±0.2 °C — the sensor’s own spec — within 2 minutes instead of 15.

Validation

Two real comparisons. First: a Thermo 49iQ lab analyzer characterized an ozone appliance’s actual output (40–75 ppb, noisy plateau, hard cutoff). The board, run against the same appliance six days later, drew the identical shape — 9.1 kΩ to 2.37 MΩ, a 262× swing, 90% rise in 407 s, 90% recovery in 177 s.

Left: Thermo 49iQ reference analyzer trace of the ozone appliance. Right: the board’s own resistance trace against the same appliance, six days later

Second: a controlled ~10 ppb step from a Teledyne T753U calibrator, on the reworked heater cycle. Earlier runs on the old cycle climbed, peaked, and decayed while true concentration held flat — measuring a re-adsorption rate, not accumulated load. This run holds:

Sensor resistance response to a calibrated ozone stepR/R0 rises from a baseline near 1.0 to a peak of 15.32 times baseline about 39.22 minutes after a ~10 ppb ozone step begins, holds briefly, then falls back to baseline over the following 41.2 minutes. Full numbers are in the table below the chart.12×16×R / R0020406080Minutes since ozone step beganHover or tab through a point ↓t = 0 min43732 Ω0.97× baselinet = 10.05 min45104 Ω1.01× baselinet = 20.11 min45731 Ω1.02× baselinet = 21.12 min45025 Ω1× baselinet = 22.12 min71835 Ω1.6× baselinet = 23.13 min98492 Ω2.2× baselinet = 24.13 min152921 Ω3.41× baselinet = 25.14 min198204 Ω4.42× baselinet = 26.15 min316006 Ω7.04× baselinet = 27.15 min373747 Ω8.33× baselinet = 28.16 min569270 Ω12.69× baselinet = 29.17 min641594 Ω14.3× baselinet = 32.19 min629058 Ω14.02× baselinet = 35.2 min662292 Ω14.76× baselinet = 39.22 min687135 Ω15.32× baselinet = 43.25 min683570 Ω15.24× baselinet = 47.27 min674054 Ω15.03× baselinet = 50.28 min637182 Ω14.21× baselinet = 53.3 min379091 Ω8.45× baselinet = 56.32 min220830 Ω4.92× baselinet = 59.34 min113192 Ω2.52× baselinet = 62.36 min70271 Ω1.57× baselinet = 65.37 min63480 Ω1.42× baselinet = 68.39 min40642 Ω0.91× baselinet = 71.41 min39870 Ω0.89× baselinet = 75.43 min36780 Ω0.82× baselinet = 80.46 min39649 Ω0.88× baseline
Sensor resistance, R/R0
One full cycle from a real board: a 80.46-minute run against a Teledyne T753U calibrator putting out a ~10 ppb ozone step. Baseline R0 = 44856 Ω. Points are downsampled from the raw CSV log — dense through the rise and fall, sparse on the flat baseline. Hover or tab through a point for exact figures; full table below.
Full numbers
t (min)Rgas (Ω)R / R0
0437320.97×
10.05451041.01×
20.11457311.02×
21.1245025
22.12718351.6×
23.13984922.2×
24.131529213.41×
25.141982044.42×
26.153160067.04×
27.153737478.33×
28.1656927012.69×
29.1764159414.3×
32.1962905814.02×
35.266229214.76×
39.2268713515.32×
43.2568357015.24×
47.2767405415.03×
50.2863718214.21×
53.33790918.45×
56.322208304.92×
59.341131922.52×
62.36702711.57×
65.37634801.42×
68.39406420.91×
71.41398700.89×
75.43367800.82×
80.46396490.88×

Baseline near 1.0×, peak ~15×, back to baseline — held through the exposure instead of sagging mid-plateau. None of this compares in raw ohms across boards (baselines span 40× board to board), so every number here is a ratio against that board’s own baseline.

Where it landed

Bare chip to fabricated, assembled, flashed, and validated against a real ozone source, on both a lab reference analyzer and a calibrated gas step — that’s the whole arc, design through data. One caveat worth keeping attached to the numbers above: the temperature-calibration constants are bare-board, open-bench figures, so they’re specific to the conditions they were measured under, not a promise that holds in any enclosure.