Skip to the content
PCBKitNew board
2 layers63.2 × 54.4 mmMosfet fan switch, the printed circuit board in 3D

KiCad 10 board: 19V->12V buck for two 2-pin 12V fans, 12V->5V->3.3V for a 3.3V Pro Mini, follows the NUC ITE's PWM duty, and simulates the tach signal. ERC/DRC clean.

Size
63.2 × 54.4 mm
Copper layers
2
Thickness
1.6 mm
Parts
28
Nets
39
Pads
105
Tracks, vias
189, 2
Pours
1
Routed
Yes
KiCad
KiCad 10.0 (file 20260206)
Created
19 Oct 2026
Updated
4 Nov 2026

Overview

Mosfet fan switch is an open-source printed circuit board by kavehs87, from the kavehs87/nuc-fan-controller repository on GitHub. It is a 2-layer board of 63.2 × 54.4 mm with 28 parts on 39 nets, routed with 189 tracks and 2 vias.

It is built around the BC547, IRF7416T and IRFZ44N, with the MBR10100CT among its other parts. Its parts include 8 resistors, 4 capacitors, 4 transistors, 4 connectors and 3 diodes. It belongs with the AVR (ATmega, ATtiny) boards of this library.

Main components

4

README

fan-switch-booster/README.md

12 V fan switched by 3.3 V logic — a gate booster built from my own parts

A KiCad schematic, a real ngspice simulation, and the same circuit actually built and running on perfboard — of the thing every 3D printer needs: an Arduino pin turning 12 V fans on and off with a MOSFET. And since the design grew a NUC fan-header socket, the same Arduino can read the NUC's own fan request and answer on its tachometer line.

What is finished. The perfboard build is the circuit: it runs, it is quiet across the duty range, and it needs no replacement board. The KiCad board is routed by hand and is being etched at home from fab/ — the same circuit made permanent, not a repair for anything the perfboard got wrong. The part of this project that actually changed something is the measurement work under Firmware, and why 0 % was humming: why 0 % hummed, and why the carrier is 100 Hz.

Every real component on this sheet is a part that actually sits in my parts cabinets (L1 and R1). Nothing here is a catalogue placeholder. The fan model is sized to the fans I actually use: 100 mA each at 12 V.

text
Arduino D9 (3.3 V PWM)
      |
     R1 1k ---- Q2  2N7002   level shifter: 3.3 V logic pulls /BOOT down to ~0 V
      |                |
     R2 10k          /BOOT ---- Q3  IRF7416 (P-ch)  gate booster: switches +12 V onto the gate
      |                |               |
     GND            R3 10k          R4 1k
                                     |
                                   /GATE ---- Q1  IRFZ44N  the switch that carries the fans
                                     |             |
                                    R5 10k       /DRAIN --- 12 V fan(s) --- +12 V
                                    GND             |
                                                 D1 1N5819 (flyback, cathode to +12 V)

NUC fan header J4
  PWM request 5 V --R7 10k--.  R8 10k to GND  -> /DEMAND 2.50 V at full request -> A0
  tach answer  <--Q4 BC547--  base via R9 1k from /TACH_PWM (D10, 100 Hz carrier)
  +12 V pin    not connected            the fans are powered by this board's own brick

Run everything with one command:

bash
./sim/run.sh

It exports the netlist from the schematic, runs ngspice, writes five plots and two comparison tables, and prints the numbers that matter. Output lands in sim/out/ (git-ignored).


The stages, and the nets they live on

netwhat it iswho drives it
/PWMthe Arduino pin (D9 = OC1A, timer 1 channel A)Arduino Pro Mini 3V3
/LGATE2N7002 gate, held at 3.0 V by the R1/R2 divider/PWM through R1 1k
/BOOTbooster node: +12 V when the pin is low, ~0 V when it is highR3 10k up, Q2 down
/HIGATE+12 V handed to the power FET by Q3Q3 IRF7416
/GATEIRFZ44N gate, settles at 10.91 VR4 1k from /HIGATE, pulled down by R5 10k
/DRAINthe switching node under the fan(s)Q1 IRFZ44N, clamped by D1
/FAN_Wbetween a fan's winding and its drag resistancefan model L1 + R6
/NUC_PWMthe NUC's own fan request, 5 V at J4 pin 1the NUC (V3 in simulation)
/DEMANDthat request halved and smoothed for a 3.3 V ADC inputR7/R8 divider, C5 + C6
/TACH_PWMthe Pro Mini's answer, D10 = OC1B, a 100 Hz carrierArduino Pro Mini 3V3
/TBASE/TACH_PWM after R9 1k, into Q4's baseR9 1k
/NUC_TACHthe tach line the NUC reads, pulled up inside the NUCQ4 as an open collector

Why /GATE is 10.9 V and not 12 V. A MOSFET gate draws no steady current, so R4 and R5 are simply a divider: 12 V × 10 k / (1 k + 10 k) = 10.91 V. That is still deep inside the region where the IRFZ44N's 17.5 mΩ is specified. R5 exists to keep the gate firmly at 0 V when the pin is low, so the fan cannot drift on.

Why the logic is not inverted. Two inversions (Q2 inverts, Q3's P-channel orientation inverts again) cancel. Pin high → fan on. At power-up the pin is floating, R2 keeps Q2 off, so Q3 is off, so Q1 is off. The fans stay off.

Why Q3's source must be the +12 V terminal. A P-channel device's body diode conducts drain → source. Wire Q3 the other way round and that diode feeds +12 V straight into Q1's gate through R4 — the fans would be permanently on and no amount of code would fix it. This is the single easiest way to get this circuit wrong, so the sheet draws Q3 rotated 180° with its source on the rail.


The NUC fan-header link

The same board also talks to a PC. A NUC's 4-pin fan header wants to drive a fan itself: it puts out a PWM request, expects a tachometer answer on the second pin, and supplies the fan on the third. This board refuses both jobs and does them anyway:

text
NUC fan header J4                Pro Mini (on the board, U1)
  1  PWM request, 5 V ──R7 10k──┐
  2  tach answer   <──Q4 BC547──┼── D10 (OC1B)  /TACH_PWM  100 Hz carrier out
  3  fan supply    not connected│   (5 V on a 6th-gen NUC, 12 V on newer ones)
  4  GND ───────────────────────┴── GND (shared with the 12 V brick)
                                 A0  /DEMAND   2.50 V at full request, read as 775 counts
                                 D9  /PWM      the fan drive, unchanged
        R8 10k from /DEMAND to GND, with C5 1µ + C6 100n across it
  • The request has to be knocked from 5 V to 3.3 V before it touches an Arduino pin. R7 (10 k, in series at the header) and R8 (10 k, to ground) halve it; C5 + C6 turn the 23 kHz square wave into a DC level (τ = 5.49 ms, ≤ 5 mV ripple). Full request = 2.50 V = 775 counts, 0.80 V of headroom. The NUC's PWM output only has to source 250 µA.
  • The answer is an open collector, because the pull-up lives in the NUC: R9 (1 k) into a BC547's base, collector on the tach line. 2.58 mA of base drive sinks 4.96 mA of pull-up current; the line lands at 0.037 V low, 5.00 V high.
  • The tach carrier is a separate signal. An ITE counts two pulses per revolution, so RPM = 30 × f_tach, and the legal window (700–10 500 RPM) is 23–350 Hz on that wire. Copying the 1 kHz fan drive onto it would read 30 000 RPM and be rejected, so D10 generates its own 100 Hz carrier (which reads 3 000 RPM). "Same PWM being sent to the fans" is the right idea — the frequency is what has to differ.
  • J4 pin 3 (the NUC's own fan supply) is not connected: the fans are powered by this board's 12 V brick. Note that this supply is not 12 V on every NUC — a 6th-generation NUC drives its stock blower at 5 V, and the ITE's PWM wire tops out near 4.9 V, not the 5.5 V a header supply would suggest. Nothing here depends on that pin, which is the point of leaving it unconnected.

Measured on a real NUC — the record is at the root, not here

The ITE side of this design was assumed here and measured one directory up: 4 577 paired samples at 1 Hz on a 6th‑generation i3 NUC, logged in ../bench/data/, analysed by ../bench/analyze-*.py, and tabulated with their evidence in ../README.md, The facts both halves rest on. Read them there — they are deliberately not repeated here. Those numbers govern, not the assumptions below.

Three of them decide how this board is used:

  • the ITE's duty has a 46–48 % floor, so under ITE control the fan never stops, and "the NUC controls the speed" means a ladder of roughly 47 % → 85 %, not a knob. An actually stopped fan has to be a threshold in the sketch;
  • a 5.6× tach claim moved the demand 1.1 % — the tach answer exists to keep the BIOS happy, not to close a loop;
  • the counts in ../bench/data/ are on an Uno's 5 V ADC reference, where 512 counts is full duty. On this board's 3.3 V / 8 MHz Pro Mini the same 2.50 V tap is 775 counts. Carry the calibration as volts, or multiply counts by 5 / 3.3.

The enclosure board those measurements were designed for — nuc-fan-controller, with its 19 V → 12 V module, its BC547 gate stage and its 31.25 kHz drive on D11 — is retired: the perfboard here is the reference. That design is intact in git at the tag nuc-fan-controller-full-2026-10-23, and the sketch that carries the measured ITE constants, ../firmware/nuc-tach-modeB/nuc-tach-modeB.ino, is still here.

sim/nuc_link.py (run by ./sim/run.sh) is the check: duty-by-duty tap voltage, ripple, ADC counts, the divider's load, the tach levels and the carrier window. Numbers and caveats: docs/pcb-notes.md, The NUC link.


Parts — all from my cabinets

refpartpackagecabinet · drawerqty in bin
U1Arduino Pro Mini (3.3 V / 8 MHz, ATmega328P)35-hole module, on female socketsL1 · C51
Q1IRFZ44N N-channel power MOSFETTO-220R1 · B412
Q22N7002 N-channel MOSFETSOT-23R1 · A411
Q3IRF7416 P-channel MOSFETSO-8R1 · A46 (+1 already on a breakout board)
Q4BC547 NPN small-signal transistorTO-92R1 · D320
D11N5819 Schottky, 40 V / 1 ADO-41L1 · E130
D21N5822 Schottky, 40 V / 3 A — spare footprint, not solderedDO-27L1 · E19
D3MBR10100CT Schottky, 100 V / 10 A dual — spare footprint, not solderedTO-220L1 · E13
R1, R4, R91 kΩ through-holeaxialR1 · A220
R2, R3, R5, R7, R810 kΩ through-holeaxialR1 · B220
C3, C6100 n (104) ceramic discradialL1 · C930
C410 µ 16 V electrolyticradial, 6.3 mmL1 · C120
C51 µ 50 V electrolyticradial, 8 mmL1 · E78
V1Switching Power Supply Module, 12 V 450 mAmoduleL1 · B72

These items on the sheet are not bin parts. They carry dnp yes (do not populate) and have no footprint on the board:

  • C1 = 1.5 nF — the IRFZ44N's own input capacitance (Ciss ≈ 1489 pF), drawn explicitly.
  • C2 = 1.7 nF — the IRF7416's Ciss (≈ 1700 pF), drawn explicitly.
  • L1 = 10 mH + R6 = 120 Ω — the fan. My bins hold no 12 V fan, so the fan is a model: an inductor for the winding and a resistor for the mechanical load. 12 V / 120 Ω = 100 mA, which is what my real fans draw. Change R6 if yours are different: 80 Ω models a 150 mA fan.
  • V2, V3, V5 (VPULSE sources) stand in for the three pins: D9's fan drive, the NUC's 5 V / 23 kHz request at J4 pin 1, and D10's 100 Hz tach carrier. V4 + R10 stand in for the 5 V and 1 kΩ pull-up the NUC carries on its own tach line.
  • The KiCad symbol for U1 is mosfet-fan-switch:Arduino_Pro_Mini_328, generated by tools/pro_mini.py from the same hole map as the board's socket footprint: 35 passive pins, one per module hole, so a symbol pin number, a footprint pad number and the silkscreen name beside a hole are one number. U1 is excluded from the simulation (exclude_from_sim yes) because ngspice has no model for a microcontroller, and V2 and V5 (VPULSE sources) stand in for the two pins it drives.
  • D2 and D3 are on the board but not populated. They are the stronger flyback diodes the bins already hold, laid down as footprints in parallel with D1 so that growing this board to a dozen fans is a soldering job, not a new board. sim/fan_count.py says when to fit one (see How many fans one output can carry).

Why one transistor is not enough

This is the actual lesson. sim/variants.py runs the same fan, same diode, same resistors against every drive option the bin allows:

variantpingatefanV(drain)stateRds(on)in the FET
booster-3v3 (this sheet)3.3 V10.91 V102.3 mA1.6 mVohmic15.7 mΩ0.16 mW
booster-3v3-wc (Vgs(th) = 4.0 V unit)3.3 V10.91 V102.3 mA1.6 mVohmic15.5 mΩ0.16 mW
direct-3v3 (one transistor)3.3 V3.00 V99.6 mA51.9 mVohmic521 mΩ5.17 mW
direct-3v3-wc (worst-case unit)3.3 V3.00 V0 mA12.0 Voff—0
direct-5v (if you use the 5 V Pro Mini)5.0 V4.54 V100.0 mA5.7 mVohmic56.6 mΩ0.57 mW
2n7002-only (2N7002 as the pass element)3.3 V3.00 V52.0 mA5.76 Vsaturated—300 mW

Read the two bold rows:

  • The IRFZ44N is not a logic-level part. Its Rds(on) is specified at Vgs = 10 V, and its threshold is guaranteed anywhere between 2.0 V and 4.0 V. Wired straight to a 3.3 V pin the gate only reaches 3.0 V (R1 1k and R5 10k form a divider). A typical unit still runs the fan — which is exactly why "it worked on my bench" is not a design — but it wastes 32× more power, and a worst-case unit from the same drawer never turns on at all. The booster makes both units behave identically.
  • The 2N7002 cannot be the switch. At Vgs = 3.0 V it saturates at 52 mA, starves a 100 mA fan, drops 5.76 V across itself and burns 300 mW against a 200 mW rating. It is a level shifter here, nothing else.

Measured by the simulation

text
pin voltage where the fan starts                :  2.27 V
/BOOT when the pin is high (2N7002 sinks 1.2 mA):  11.6 mV
/GATE steady state (12 V x R5/(R4+R5))          :  10.91 V
fan current with the power FET on               :  102.3 mA
V(drain) with the power FET on                  :   1.6 mV
Rds(on) at that point                           :  15.7 mΩ
power FET dissipation while on                  :  0.16 mW
V(drain) spike at turn-off (clamped by D1)      : 12.36 V
Arduino pin current (max)                       :  0.30 mA
/GATE rise 5 tau (R4=1k, C1=1.5n)               :   7.5 µs
/BOOT rise 5 tau (R3=10k, C2=1.7n)              :  85.0 µs
/BOOT back at 12 V after the pin goes low       : 120.6 µs
fan L/R time constant                           :  83.3 µs
peak fan current during a PWM on-time           :  99.8 mA
average fan current at 1 kHz / 50 % duty        :  54.2 mA

A few of those deserve a comment:

  • The pin only ever sources 0.30 mA — that is 3.3 V / (R1 + R2). Charging the power MOSFET's 1.5 nF gate, which would be a 3 mA hammering if you connected the pin to it directly, is the booster's job. This is the whole point of the two extra transistors.
  • 12.36 V at turn-off. When Q1 opens, the fan's winding has ~100 mA flowing through it and that current has to go somewhere. The 1N5819 takes over and holds the node at 12 V + 0.36 V. Without D1 the node would fly to whatever voltage is needed to force that current through the parasitic capacitances — hundreds of volts. Delete D1 in KiCad and look at the transient; it is the most instructive ten seconds of this project.
  • The fan current is chopped, not dimmed. With 10 mH and 120 Ω the fan's time constant is 83 µs, so at 1 kHz it never reaches steady state: it ramps to ~100 mA and decays to zero every cycle. The average is 54 mA. The fan always sees either a full 12 V or 0 V — that is how PWM speed control works. At Klipper's default 100 Hz the effect is the same but with 5 ms pulses.
  • Turn-off is slower than turn-on, and the model understates it. /BOOT has to charge back up to 12 V through R3 10 kΩ. With the netlist's C2 = 1.7n — only the IRF7416's own Ciss — that is τ = 17 µs and the fan stays on ~55 µs after the pin goes low. The perfboard measured ~170 µs, because that node also carries its wiring: about 8 nF rather than 1.7 nF, see "Why the carrier is 100 Hz". At 100 Hz the difference is 3 % of the off half-period, which is why the board runs there. If you want several kHz, drop R3 to 1 kΩ and fit the Q5 gate helper described under that heading; R3 = 1k then sinks 12 mA, well inside the 2N7002's 115 mA rating, but has to dissipate about 145 mW.

How many fans one output can carry

sim/fan_count.py clones the sheet's fan branch as many times as you ask and re-runs both analyses. The gate drive does not change at all — the fans are on the drain side — so the only things to check are the parts that carry load current: Q1, D1 and the 12 V supply.

bash
python3 sim/fan_count.py                                   # 1..4 fans, the 450 mA bin module
python3 sim/fan_count.py --fans 1 2 4 8 16 32 64 --supply-ma 8000
fanseachtotalV(drain)Q1 lossspikeD1 peakD1 avgD1 losssupply avgsupply peak
1102.3 mA102.3 mA1.6 mV0.16 mW12.36 V99 mA8 mA2.5 mW48 mA102 mA
2101.1 mA202.2 mA3.2 mV0.65 mW12.38 V197 mA15 mA5.3 mW94 mA202 mA
3100.7 mA302.2 mA4.8 mV1.46 mW12.40 V295 mA22 mA8.2 mW141 mA301 mA
4100.5 mA402.1 mA6.4 mV2.59 mW12.41 V392 mA30 mA11.2 mW187 mA401 mA

Three fans fit, with room to spare. What the numbers say:

  • Q1 is not remotely stressed. Three fans put 302 mA through it and it drops 4.8 mV: 1.5 mW. The IRFZ44N is a 49 A / 94 W TO-220 part; it could switch twenty of these fans. Its gate drive is unchanged, because gate current is set by R4 and the gate capacitance, not by the load.
  • D1 is the part that actually feels the extra fans. At turn-off all 295 mA of fan current commutates into the 1N5819. Its rating is 1 A average; it averages 22 mA here and peaks at 295 mA, dissipating 8 mW. Four fans (392 mA peak) are still fine. The diode only ever has to carry the total fan current, so add diode current, not diode count.
  • The 12 V supply is the real limit. Three fans at full speed draw 301 mA from the Switching Power Supply Module in L1 · B7, which is rated 450 mA — that works. Four fans draw 401 mA, which technically fits but leaves almost nothing for the electronics. If your fans are the 150 mA kind instead, three of them need 452 mA and that module is too small; use the 5 V module's bigger sibling, a USB-C PD source triggered by the IP2721 module in L1 · A5, or a proper 12 V brick.
  • Everything shares equally because the branches are identical, and each fan keeps its own 83 µs L/R — the current waveforms just scale by three, they do not change shape.

Wiring three fans on one output also means one flyback diode serves all three, and one blown fan stalls the others only mechanically, not electrically.

With a bigger 12 V supply, the flyback diode becomes the limit

The 450 mA module in L1 · B7 is what makes "three fans" the answer above. Fed from a real 12 V brick (8 A, which is what this build actually uses), the same sheet and the same three transistors scale a long way:

fanstotalV(drain)Q1 lossQ1 risespikeD1 peakD1 avgD1 losssupply peak
1102 mA1.6 mV0.16 mW+0.0 °C12.36 V99 mA8 mA2.5 mW102 mA
4402 mA6.4 mV2.6 mW+0.2 °C12.41 V392 mA30 mA11 mW402 mA
8801 mA12.9 mV10.3 mW+0.6 °C12.45 V779 mA58 mA24 mW800 mA
161.60 A25.7 mV41 mW+2.5 °C12.51 V1.53 A104 mA47 mW1.60 A
323.19 A51.4 mV164 mW+10 °C12.60 V2.99 A201 mA102 mW3.18 A
646.35 A102 mV650 mW+40 °C12.77 V5.93 A399 mA242 mW6.34 A

Q1 rise is the junction temperature rise in free air, using the TO-220's 62 °C/W junction-to-ambient rating with no heatsink.

Read the two right-hand columns carefully, because a diode has three different ratings and only two of them apply here:

  • Q1 is never the problem. Sixty-four fans put 6.35 A through it and it dissipates 0.65 W, which is a 40 °C rise — the part is rated to 150 °C junction and 49 A continuous. The MOSFET is good for roughly eighty of these fans with no heatsink at all.
  • D1's average current and dissipation stay comfortable even at 64 fans: 399 mA average and 0.24 W in a DO-41 Schottky. That is not what limits it.
  • What limits D1 is the repetitive peak. At turn-off the entire fan current commutates through the diode every single cycle. Eight fans already push 779 mA through a 1 A part; sixteen push 1.53 A. The datasheet's 45 A surge figure is for a single pulse, not for 1000 pulses a second, so treat the peak as the working limit.

So the practical ladder, using only parts already in L1 · E1:

fanswhat to change
up to ~8nothing — the sheet is exactly right as drawn
~9 to ~30fit D2: the 1N5822 (3 A, DO-27 — a fatter body than D1's DO-41, and it already has a footprint on the board)
~30 to ~60fit D3: the MBR10100CT (100 V, 10 A dual, TO-220 — two anodes on /DRAIN, common cathode on +12 V), or two 1N5822
beyondQ1 starts wanting a heatsink around 8 A, and the wiring matters more than the semiconductors

Both spares are already drawn and already have copper on the board, in parallel with D1; dnp yes means nobody solders them until this table says to. See Spare flyback diodes in docs/pcb-notes.md for their pad → net table.

The turn-off spike creeps up with load — 12.36 V at one fan, 12.77 V at sixty-four — simply because the Schottky's forward drop grows with current. Still nowhere near the IRFZ44N's 55 V or the IRF7416's 30 V.

And the honest caveat, again: this fan model always limits itself to 100 mA. Real fans draw a surge when they start, and a stalled fan draws amps. Sixteen fans all starting on the same PWM edge can momentarily ask for 3–5 A, which an 8 A supply absorbs and a 450 mA module does not.


The plots

sim/run.sh writes these to sim/out/:

filewhat it shows
dc_transfer.pngsweeping the pin 0 → 3.3 V: /BOOT falls from 12 V to 12 mV while /GATE jumps from 0 to 10.9 V, and the fan starts at 2.27 V
transient.png1 kHz / 50 % PWM: all four node voltages, the fan current, and the 0.3 mA the pin actually supplies
switching_edge.pngone turn-on edge (gate charging through R4) and one turn-off edge (the 12.36 V clamp and the freewheeling decay)
drive_compare.pngthe variant table above as a chart: fan current and dissipation for each drive option
fan_count.pngfan counts side by side, and the current the flyback diode carries at the largest count
nuc_link.csvnot a plot: the NUC-link duty table (tap voltage, ripple, ADC counts) that sim/nuc_link.py prints

Running it

bash
./sim/run.sh              # export -> ngspice -> plots -> variant and fan-count tables

Requirements: KiCad 9 or 10 (kicad-cli), ngspice, Python 3 with numpy and matplotlib.

Files:

text
mosfet-fan-switch.kicad_sch     the sheet (A3, ERC clean: 0 errors / 0 warnings)
mosfet-fan-switch.kicad_sym     five local symbols: four package-accurate transistors, and
                                the Pro Mini itself - 35 pins, one per module hole
sym-lib-table                   registers that library for this project
footprints/mosfet-fan-switch.pretty/   the Pro Mini socket the board uses
fp-lib-table                    registers it for this project
mosfet-fan-switch.kicad_pcb     the board - 63.2 x 54.4 mm, routed by hand, being etched
fab/export.sh                   regenerate everything in fab/ from the board file
fab/gerber/                     F.Cu + B.Cu + Edge.Cuts gerbers, Excellon drill + 1:1 map
fab/preview/                    1:1 copper SVGs, and KiCad's board statistics
fab/README.md                   the fabrication record (the printer's LCD as the exposure lamp)
fab/test-card.gbr               exposure / trace-width / mirroring calibration card
tools/pro_mini.py               the module's hole map -> socket footprint AND schematic symbol
tools/embed_pro_mini_schematic.py  the one-shot migration that replaced the Nano with U1
tools/skeleton_board.py         strip a board down to placement + pad nets (historical)
tools/make_package_symbols.py   put the four package symbols on the sheet
tools/tidy_q3_labels.py         tie Q3's SO-8 legs together, label each node once
tools/shift_q3_clearance.py     move Q3 clear of the /BOOT branch it was crossing
tools/fix_schematic_junctions.py  put KiCad's junction dots back after any scripted edit
docs/mosfet-fan-switch.pdf      the sheet as a PDF
docs/pcb-notes.md               board rules, pad-to-net table, the NUC link, assembly notes
docs/pcb-etch-bottom.svg/.pdf   the earlier 56 x 92 plan: toner-transfer print (1:1, mirrored)
docs/pcb-assembly-bottom.pdf    that plan's copper-side assembly drawing, pads numbered
docs/pcb-assembly-top.pdf       its component-side view

You can also run the simulation inside KiCad: Simulator → Run Simulator. The sheet carries .op, .dc V2 0 3.3 0.01 and .tran 1u 5m cards as text, and Q1/Q2/Q3/D1 point at the .lib files through their Sim.Library / Sim.Name fields, so KiCad's built-in ngspice picks the real models up.

Things to try in KiCad

  • Run the DC sweep and watch /BOOT fall while /GATE rises. That step is the booster.
  • Delete Q2, Q3, R3, R4 and C2, wire R1 straight to /GATE, re-run: the fan still runs (100 mA) but Q1 now burns 5.2 mW. Then change Q1's Sim.Name to mos_irfz44n_wc: dead fan. Same drawer, same part number, different transistor.
  • Copy the L1 / R6 fan branch twice, tie all three drains to /DRAIN, and re-run the transient: that is the three-fan case above, drawn.
  • Shorten R3 to 1 kΩ and watch the turn-off delay shrink.
  • Remove D1 and look at the /DRAIN spike.
  • Change V2's tw from 500u to 250u (25 % duty): the average fan current falls, but the pulses the fans see are still a full 12 V.

About the models — read this before quoting the numbers

The five .lib files are level-1 fits to the spec records in my own parts cabinet, not vendor models. Each one is calibrated on the two numbers I actually recorded for the part:

partcabinet recordfit
IRFZ44NVgs(th) 2.0–4.0 V, Rds(on) ≤ 0.0175 Ω at Vgs = 10 V, 55 V / 49 A / 94 Wvto=2.8 kp=11.11 rd=5m → 17 mΩ at 10 V
IRFZ44N (worst case)same bin, threshold at the datasheet maximumvto=4.0 kp=13.33 rd=5m → 17.5 mΩ at 10 V
2N7002Vgs(th) ≤ 2.5 V, Rds(on) ≤ 1.2 Ω at Vgs = 10 V, Id max 115 mA, 0.2 Wvto=2.0 kp=0.104 → 1.2 Ω at 10 V
IRF7416Vgs(th) −2 V, Rds(on) ≤ 0.025 Ω at Vgs = −10 V, 30 V / 10 A, 2.5 Wvto=-2.0 kp=5.0 → 25 mΩ at −10 V
1N581940 V / 1 A Schottkyis=1e-7 rs=0.05 → 0.36 V at 100 mA

What a level-1 model gets right, and what this circuit depends on: the threshold voltage, the on-resistance at a properly driven gate, and the fact that at Vgs = 3 V the 2N7002 is comfortably on while the IRFZ44N is barely on at all.

What it gets wrong, honestly:

  • No subthreshold region. direct-3v3-wc reports exactly 0 mA. A real worst-case unit would trickle some milliamps. The point of the row is "not usable", not "literally zero".
  • Level-1's transconductance is too linear at low Vgs, so its Rds(on) at Vgs = 3 V is a rough guess. The datasheet curves are steeper.
  • The gate capacitances are drawn on the sheet as C1 and C2 rather than being inside the models, so the RC timing is visible and auditable. The models themselves carry no capacitance, otherwise Ciss would be counted twice.
  • The fan is a model, not a measurement. A real 12 V fan has back-EMF that dominates its behaviour; 10 mH + 120 Ω reproduces the electrical lesson (the winding current that makes the flyback diode necessary) and the right steady current, but not the aerodynamics. It also cannot show a stalled fan: a real stalled 12 V fan draws amps through its few-ohm winding, whereas this model always limits itself to 100 mA. The IRFZ44N and the 1N5819 are both rated far above any stall current a small fan can pull, so the design is safe even where the model is blind.

KiCad → SPICE gotchas that cost time

Kept here so the next schematic is faster:

  • A Sim.Library file that does not exist makes KiCad export a broken device card (Q1 Q1.irfz44n with no nodes at all) instead of MQ1 /DRAIN /GATE GND GND irfz44n. Write the .lib files before exporting.
  • KiCad 10 ignores Sim.Pins for .model cards and writes every device line in symbol pin order. So the symbol's pin numbers decide the SPICE terminal order, and the only reliable way to get ngspice's order is a symbol whose pins are the real package legs plus a .subckt wrapper that re-orders them (sim/models/*.lib). The old Simulation_SPICE:NMOS three-pin symbol left KiCad handing ngspice a dangling bulk node (NC-Q2-0) — with the package symbols and wrappers there is nothing left to repair, and sim/prepare_deck.py no longer tries.
  • ngspice wants four nodes on a MOSFET card: M d g s model is refused with not enough nodes; M d g s s model (bulk spelled out, tied to the source) runs.
  • A pin whose endpoint lands in the middle of a wire is not connected — KiCad calls it Pin not connected and the netlist names the terminal unconnected-_Q3-D-Pad7_. When a wire ties several package legs together, segment it so every pin lands on a segment end.
  • kicad-cli resolves a symbol from the sheet's own lib_symbols cache, not from the library. Insert an instance whose lib_id has no cached definition and the file still loads, but ERC sees a symbol with no pins: Pin not connected at the instance's anchor, Label not connected on every net label attached to it, and the pin printed as Pin 1 [???, ?]. Carry the cached copy in yourself — the library's (symbol "Name" …) block, renamed to (symbol "Library:Name" …) — which is what tools/embed_pro_mini_schematic.py does.
  • A .kicad_sch wire is exactly two points. Three points in one (wire (pts …)) and kicad-cli refuses the whole file with Failed to load schematic and no other clue. Draw an L as two wires.
  • ERC's connection grid is 1.27 mm. endpoint_off_grid fires on any pin or wire end that is not a multiple of it; symbol pin offsets are multiples of 1.27, so putting the anchors on the grid clears the whole batch at once.
  • kicad-cli needs the project context next to the schematic. Copy a .kicad_sch to a scratch directory and every command answers Failed to load schematic, because sym-lib-table, fp-lib-table and the .kicad_pro are not there. It looks exactly like a corrupt file. Copy those three along with it, or test in the project directory.
  • A VPULSE source needs instance-level Sim.Device=V, Sim.Type=PULSE, Sim.Pins=1=+ 2=- and a Value of VPULSE. Otherwise the export writes V2 /PWM GND dc="3.3V PWM" y1=0 ... which ngspice cannot parse.
  • KiCad writes schematic text directives before the device lines, so a .control … .endcontrol card on the sheet breaks ngspice. The sheet carries classic .op / .dc / .tran / .print cards; prepare_deck.py replaces them with a .control block at the end so wrdata can dump plottable vectors.
  • Multi-line (text "...") blocks in a .kicad_sch must contain escaped \n. Raw newlines parse for ERC but make kicad-cli sch export pdf fail with "Failed to load schematic".
  • Scripted wires need an explicit (junction …) at every T-junction or ERC reports "Unconnected wire endpoint". Crossings without a junction stay unconnected — the BOOT rail crossing Q3's source wire is one of those on purpose.
  • wrdata repeats the x column before every vector, so the value columns are 1, 3, 5, …
  • ERC checks ignored by KiCad's default settings here: "SPICE model issue" and "Assigned footprint doesn't match footprint filters" (the 4-pin substrate symbols do not match 8-pin MOSFET footprint filters, which is fine for a learning sheet).

The board

Routed, and being etched at home. 63.2 × 54.4 mm, GND pour on F.Cu, 166 F.Cu tracks, 23 B.Cu jumpers, 2 vias. fab/ is the export set and the process record for the route actually being used — the Mono 6K's LCD as the exposure lamp — and fab/export.sh regenerates it. The perfboard build is still where every measured number in this README came from; the board is the same circuit made permanent.

Routing this board is human work. Its copper was drawn by hand. pcb/build_board.py and pcb/router.py — the generator and the autorouter that produced the earlier layout — are out of the working tree, complete at the tag fan-switch-booster-pre-cleanup-2026-10-24, and nothing here regenerates the board. Do not re-route it, and do not run Update PCB from Schematic on it: one attempt split the net names and produced 84 DRC violations.

outline63.20 × 54.40 mm, 1.6 mm FR4, four M3 non-plated holes at the corners
clearance between nets0.50 mm — KiCad's reported minimum; DRC is clean at 0.50 and reports four violations at 0.55
tracks0.80 mm throughout
copperF.Cu 2807 mm² = 82 % of the board (pour + 166 tracks); B.Cu 369 mm² = 11 % (23 jumpers, no pour)
drills0.3 mm ×2 (the vias), 0.8 ×27, 1.0 ×53, 1.1 ×8, 1.2 ×2, 3.2 NPTH ×4 — 96 holes
pads90 through-hole, 11 SMD, 4 non-plated; 28 footprints

Those rules are the ones a home-etched board actually needs rather than the fab defaults KiCad ships with, and they live in the Default netclass of mosfet-fan-switch.kicad_pro.

B.Cu is not being etched. The bottom copper of the blank is stripped entirely — otherwise every through-hole pad's bottom annulus ties to a solid sheet and the links short through it — and the four nets those 23 jumpers carried are bridged by four pieces of insulated wire: /P3V3 12.0 mm, /RST 15.2 mm, /GATE 17.9 mm, /BOOT 52.3 mm. Those are the only splits; every other net is a single conductor on F.Cu alone. Pads, wire lengths and the reason are in fab/README.md.

Q2 (SOT-23) and Q3 (SO-8) are the only surface-mount parts, and they sit on the copper side. Everything else is through-hole.

The outline went 56 × 68 → 56 × 92 → 63.2 × 54.4: the current routing is wider and shorter than the generated plan, and it fits an M3 hole in each corner. The fan driver is the same arrangement as the first version (tag checkpoint-fan-driver-v1): the level shifter R1/R2/Q2 down the left edge, the booster Q3 with R3/R4/R5 on the right, the power FET Q1 above it, and the three fans out through the 2×03 header J3 at the right edge. The NUC interface band hugs the top edge — J4, the socket the NUC's own fan cable plugs into, the 5 V → 3.3 V request divider R7/R8 with C5/C6, and the open-collector tach answer R9 + Q4 at the left. The module deck is U1 on three female sockets through the middle-left, with J5 (the FTDI programming header, wired pin for pin to the module's own programming row) below it at the left corner. +12 V comes in at J1 near the middle, with D1 and the spares D2 and D3 along the bottom. There is no J6 on this board: the module is flashed through its own bootloader, so the ICSP header was removed.

The symbols on the sheet are the package pinout now. The MOSFET and transistor symbols used to be KiCad's SPICE symbols, which number their pins D-G-S because that is the order ngspice wants — while SOT-23 is 1 gate / 2 source / 3 drain, the IRF7416 SO-8 is 1-3 source / 4 gate / 5-8 drain, and the IRFZ44N TO-220 is 1 gate / 2 drain / 3 source. The sheet now carries four local symbols (mosfet-fan-switch.kicad_sym) whose pin numbers are the leg numbers, so the numbers printed beside each transistor leg match the footprint pad, and KiCad's Update PCB from Schematic agrees with the layout instead of fighting it. The ngspice order moved into .subckt wrappers in sim/models/. docs/pcb-notes.md has the pad-to-net table and the wrapper table.

filewhat it is for
fab/gerber/mosfet-fan-switch-F_Cu.gtlthe artwork to expose — the copper that survives
fab/gerber/mosfet-fan-switch.drl, -drl_map.pdfthe Excellon drill set (96 holes) and its 1:1 map
fab/preview/copper-top.svg1:1, page trimmed to the outline: the assembly sheet, and the pad-numbered view of the side that keeps its copper
fab/preview/copper-bottom.svgthe side that gets stripped — the corridor the four wire links follow
fab/README.mdthe process: panel and exposure calibration, the UVtools settings, stripping the bottom copper, the four links, drilling
fab/test-card.gbrthe calibration card — trace ladder, gap ladder, 10.000 mm square, drill crosshairs, mirroring check
docs/pcb-notes.mddesign rules, courtyard boxes, the pad→net table, programming the module. Its Drilling and Etching sections describe the earlier 56 × 92 plan and are out of date
docs/pcb-etch-bottom.svg/.pdf, docs/pcb-assembly-*.pdf, docs/pcb-render-*.pngplots of the earlier unrouted 56 × 92 placement, kept as the corridor plan and the copper-side assembly view. They carry no copper. fab/preview/ is the current artwork

fab/export.sh regenerates the current artwork from the board file, so it is the one to re-run after any change to the board. The docs/pcb-* plots were generated from the placement that predated the routing and are kept for the reasoning they carry, not as something to print.

The two surface-mount parts (Q2 SOT-23, Q3 IRF7416 SO-8) sit on the copper side, which is what keeps this a one-layer board. Their outlines and labels are therefore stored mirrored, and a top-side view of the board shows them reversed — Q3 reads as ƧQ and pads 1–4 run right-to-left, so that view cannot tell you which pad is pin 1. Read the copper-side drawing, not the top render: on the copper side the notch sits beside pad 1, and pads 1–3 are the three pads that run straight into the wide +12 V bus. Rotate the datasheet's top-view picture 90° counter-clockwise and it is the board. In KiCad, View → Flip Board View shows the same thing, and the footprints carry their 3D models so the 3D viewer shows both chips lying on the copper. docs/pcb-notes.md has the picture and the proof.

The end of docs/pcb-notes.md records the KiCad 10 behaviour that had to be pinned down before the DRC would go clean: a footprint's placement rotation rotates pad positions only — a pad's copper rotates only by that pad's own angle, so a hand-written rotated SO-8 has overlapping pads until the angle is baked into each pad; track ends are round, so a 2 mm bus bulges 1 mm past its endpoint and can short a pad 1.27 mm away; and text angles in the file are board-absolute, not relative to the footprint.


Building it for real

  • Q1 does not need a heatsink: at 100 mA it dissipates 0.16 mW, at 300 mA (three fans) 1.5 mW. What the TO-220 and the 17.5 mΩ are for is a stalled fan or a shorted output, where several amps flow.

  • The 12 V supply decides the fan count. The module in the bin (L1 · B7) is rated 450 mA: comfortable for three 100 mA fans, tight for four, not enough for three 150 mA fans. This build runs from an 8 A 12 V brick, which moves the limit to the flyback diode — see the ladder in With a bigger 12 V supply above.

  • Q3's SO-8 package is the awkward one. There is one IRF7416 already mounted on a breakout board in R1 · A4 — use that for the first build.

  • Keep R4 (1 k) in series with Q1's gate. It slows the edge just enough to keep switching noise down and limits the current Q3 has to source when charging 1.5 nF.

  • Keep R5 (10 k) from gate to ground. Without it a floating gate can sit at any voltage and the fans can half-turn-on while the Arduino is resetting.

  • Wire multiple fans in parallel on the drain side, all meeting at /DRAIN, with the one 1N5819 from /DRAIN to +12 V. Keep each fan's wiring short and equal so no single cable carries the sum of the switching edges.

  • If you drive this from Klipper, the pin needs to be a hardware PWM pin and Klipper wants an inverted PWM output for a low-side switch that turns on with a high pin:

    ini
    [heater_fan fan]
    pin: !PH6
    pwm_cycle_time: 0.010
    max_power: 1.0
    heater_temp: 45.0
    fan_speed: 0.6

    !PH6 is a hardware PWM pin on the Mega-class board that runs Klipper in my printer. If the Pro Mini on this board is the Klipper MCU, the pin wired to /PWM is D9 = OC1A, which Klipper spells !PB1.

    pwm_cycle_time: 0.010 is 100 Hz — well above the ~60 µs turn-off delay of this driver, so the delays are invisible. Do not use a plain [fan] section: a [heater_fan] only runs the fan while the heater is active, which is what you want on a printer. Klipper drives one fan per pin, so three fans on one output means wiring them together in parallel — which is exactly the case sim/fan_count.py just measured.

Wiring the NUC side

J4, J5 and U1's pad numbers are designators on this board — the pad→net table is in docs/pcb-notes.md and the pad-numbered 1:1 sheet is fab/preview/copper-top.svg. J6 (a 2×03 ICSP header) and JP1 (a 0 Ω link) belonged to the earlier 56 × 92 plan and are not on the routed board: the module is flashed through its own bootloader, so this board has no ISP route at all. The perfboard build uses the same Pro Mini in sockets and the same four signals; it just has no silk to point at.

  • U1 is the Pro Mini, and it is on the board — three female socket strips, 35 holes, with the four signals this circuit needs already assigned: pad 12 /PWM (D9 = OC1A), pad 24 /TACH_PWM (D10 = OC1B), pad 20 /DEMAND (read it on A0), and /GND on pads 4, 29 and 31. Pad 13 RAW takes /P12V, which is how the module powers itself; pads 16 and 28 are the module's 3.3 V output, and that rail is what the /DEMAND divider is referenced to. The board carries no regulator of its own, and the module's ground must be the same ground as J1's 12 V brick and J4's pin 4.
  • J4 is where the NUC's fan cable plugs in — and Intel publishes no pinout for these headers, so the numbering below is this project's own convention, not a datasheet. Identify the four wires with a meter before anything is spliced: which one is ground, which one the ITE drives at 23 kHz, which one is the header's own fan supply (5 V on a 6th-gen NUC, 12 V on newer ones). A mis-mapped header puts 5 V or 12 V on a 3.3 V input — the ATmega328P's absolute maximum on any pin is VCC + 0.5 V, so 5 V straight onto A0 is out of spec even though nothing burns while a 10 kΩ sits in front of it.
  • JP1 is a real part. A 0 Ω axial with its leads spread to the 7.62 mm pitch, or a piece of tinned wire, between the two pads at (2.00, 14) and (9.62, 14). Without it the PWM line is open-circuited and the fans never switch.
  • The tach carrier is firmware's job. Drive D10 between 23 and 350 Hz — 100 Hz reads 3 000 RPM on an ITE that counts two pulses per revolution. Above that window the tach stops being a faster reading and becomes a claim the NUC rejects: the 1 kHz this board ran at while the carrier was being investigated would read 30 000 RPM, and 25 kHz would read 750 000. Nothing on the board enforces the limit — the sketch does, which is why the carrier is a constant in the sketch rather than a coincidence.
  • Read /DEMAND on A0 and map it straight onto the fan duty: 2.50 V is a full request (775 counts), so duty = min(1.0, counts / 775) is the whole control law. The 5.5 ms time constant at the tap means the ADC reading is already the average, so a plain analogRead per 100 ms is enough — no oversampling needed.
  • /PWM and /TACH_PWM are Timer 1's two channels (OC1A and OC1B), so one TCCR1A / TCCR1B / ICR1 setup gives them the same frequency and two different duties — a 16-bit timer has one counter, not two. That works here, and only because of the numbers: the fan drive is 100 Hz and the tach carrier wants 100 Hz too, so a single period serves both. If the fan carrier ever moves — fitting Q5 and going to 25 kHz, say — the tach moves with it, and 25 kHz reads 750 000 RPM on the NUC's tach line and is rejected outright. Then the tach has to be generated somewhere else: Timer 2 (D11 = OC2B, fast PWM with prescaler 512 and OCR2A = 155 gives 100.16 Hz), or a software toggle from the Timer 1 overflow interrupt, which is what fan-switch-debug actually does. The Uno stays usable as the programmer, and it would also run the same four signals at 5 V logic, in which case R7/R8 should become a 10 k / 20 k divider (or simply go, if you feed /DEMAND from a 3.3 V reference instead of the NUC's 5 V).

Programming the module

  • J5 is the FTDI route: 1 DTR, 2 TX0, 3 RX1, 4 VCC, 5 GND, 6 BLK — the module's own programming row, repeated pin for pin 4.02 mm to its right. Plug the adapter with its black wire over the BLK end; that is the DTR line the bootloader waits for.
  • Use a 3.3 V FTDI or CH340 breakout. This is the 3.3 V / 8 MHz module. A 5 V adapter's TX into the module's RX is tolerated by the ATmega328P, but the module drives 3.3 V back out, and there is no reason to find out what your particular adapter makes of that.
  • J6 is the Uno-as-ISP route, and it is five wires, not six: Uno 10 → J6-1 (MISO), 11 → J6-3 (SCK), 12 → J6-4 (MOSI), 9 → J6-5 (RST), GND → J6-6 — and nothing to J6-2. Pin 2 of an ICSP header is the programmer's 5 V, and an Uno pushing 5 V into a 3.3 V target's VCC makes two boards fight over the supply. J6's pin 2 is a dead pad so that you cannot make this mistake by pulling a sixth wire. The module powers itself from /P12V while it is being programmed.
  • IDE settings: board Arduino Pro or Pro Mini, processor ATmega328P (3.3 V, 8 MHz). The 5 V / 16 MHz entry writes the wrong bootloader at the wrong baud rate, and the IDE then reports "programmer is not responding" while every wire is correct.
  • Measure the module before soldering the sockets. The 35-hole map in tools/pro_mini.py is the clone layout — 12 pads per row, 15.24 mm between the rows, six programming holes in their own row. A genuine SparkFun board is 13 pads per row at 12.70 mm with the programming holes in the end column. ROW_SPACING, PADS_PER_ROW and SPARE are at the top of that file for exactly this argument.
  • The bins hold 16/18/28-pin DIP sockets, and they do not fit: a DIP socket's rows are 7.62 mm apart. What the module wants is a 2.54 mm female strip snapped to 12 + 12 + 6, and the drawers do not have one.

Firmware, and why 0 % was humming

text
firmware/fan-switch-debug/     drive + tach blink, the first one that ran on the bench
firmware/fan-switch-ladder/    100 % -> 50 %, one step per minute, no serial needed
firmware/fan-switch-serial/    duty commanded over the UART: 0..100, ?, h
firmware/fan-switch-freq/      duty AND carrier over the UART: 0..100, f <Hz>, ?, h
firmware/bench/                the scripts that drive the bench: flash, set, sequence, verify

All four drive /PWM on D9 = OC1A, fast PWM with TOP from ICR1. Timer 1 is the only 16-bit timer on the module and D9 is the only pin carrying OC1A, which is why the sketches touch TCCR1A/B, ICR1 and OCR1A directly instead of calling analogWrite() — that would have moved the whole thing to Timer 2 at 980 Hz.

The two sketches the bench uses now — ladder and freq — default to ICR1 = 9999 with a prescaler of 8: 100 Hz, 5 000 µs high at 50 %. That is not the value they started on: both ran at 1 kHz until the carrier was measured, and serial and debug are left at 1 kHz as the record of what the bench ran before that measurement. The reason for the change is the next section.

The hum at 0 % was OCR1A = 0, and it is not 0 %

In fast PWM, OCR1A = 0 does not switch the output off. The datasheet calls it "a narrow spike for each TOP+1 timer clock cycle": one timer clock, whatever the carrier is. At 8 MHz that is a 125 ns pulse on D9, once every carrier period, forever — every 1 ms at the 1 kHz these sketches originally ran at, every 10 ms at the 100 Hz they run at now.

Through the Q2/Q3 booster that spike is not small. Measured in sim/out/mosfet-fan-switch.spice with the fan branch attached:

nodewhat the spike does
/LGATE3.00 V pulled low
/BOOT12 V → 8.055 V
/HIGATEheld at 12 V
/GATE10.905 V for 36.66 µs
i(L1)31.1 mA peak, 2.57 mA mean, every cycle

So the fan was being kicked with 10.9 V on its MOSFET gate a thousand times a second, at the 1 kHz carrier this board originally ran, while the sketch believed the output was off. 36.66 µs of a 1 ms period is 3.7 % — enough to make the winding and its own capacitance ring audibly. That is the hum.

The fix is not a smaller number, it is a different mode: clear COM1A1:0 to 00 so OC1A leaves the pin, then hold D9 low as ordinary GPIO. Timer 1 keeps running, but it is no longer connected to anything.

c
if (pct == 0) {
    TCCR1A &= (uint8_t)~(_BV(COM1A1) | _BV(COM1A0));   // OC1A off the pin
    digitalWrite(PWM_PIN, LOW);                          // held low as GPIO
    return;
}
TCCR1A = _BV(COM1A1) | _BV(WGM11);                       // back to fast PWM
OCR1A  = (uint16_t)((uint32_t)(PWM_TOP + 1) * pct / 100);

Confirmed on the bench. fan-switch-serial written through the module's own bootloader (-c arduino -b 57600, ATmegaBOOT_168_atmega328_pro_8MHz.hex) over J5 with DTR connected: at duty 0 % OCR1A 0/8000 the fan is silent, and 50 spins it up on command. Every sketch since carries the same fix, so 0 % is silent whatever the carrier is set to.

Why the carrier is 100 Hz

The sketches started at 1 kHz on the theory that the hum was the chopping, and that putting the carrier above hearing — the 4-pin fan PWM standard uses 25 kHz for exactly that reason — would fix it. On the bench the opposite happened: 25 kHz is not quieter, it is the fan running at full speed no matter what duty is commanded. A Hantek DSO2D10 with both channels on the board, CH1 on Q1 pin 1 (/GATE) and CH2 on Q1's tab (/DRAIN), ground clip on the board's input ground, says why.

At 1 kHz, 50 % commanded, OCR1A 4000/8000, so 500 µs high:

measurementvalue
/GATE high width652 µs of a 1000 µs period
/DRAIN low width680 µs
/GATE swing−0.2 V → 11 V, rise 12 µs
/DRAIN swing−1.2 V → 12 V
/DRAIN average2.99 V, so the fan's own average supply is 12 − 2.99 = 9.01 V

The two channels agree with each other, which is what makes the reading trustworthy: the gate is high for exactly as long as the drain is low. A commanded 500 µs of conduction became 650–680 µs. Turn-on is fast, 12 µs. Turn-off takes roughly 170 µs.

That single number explains everything the bench had already shown, including measurements taken before the scope existed. A fixed time error adds a fixed voltage offset: 12 V × 170 µs ÷ 1000 µs ≈ 2 V, and a multimeter sweep across the fan at 1 kHz had produced a straight line — 9.6 V at 60 %, 8.6 V at 50 %, 0.1 V per percent — sitting about 2 V above the ideal line at every duty. The scope derived the same 2 V from a time measurement, independently. It also explains why the hum appeared at every chopped duty from 60 % down to 10 % and at neither extreme: at 100 % there is no chopping, and at 0 % the pin is held low as GPIO.

And it explains 25 kHz. The excess is a constant ~170 µs, but at 25 kHz the off half-period is 20 µs. The gate is told to fall and never gets there, so the output is simply stuck on — which is exactly what the fan was doing.

Calibrating the model to the board, and the two ways out

The netlist models /BOOT with C2 = 1.7n, which is only the IRF7416's own Ciss. Re-running the transient at 1 kHz / 50 % against the measured 652 µs:

C2 in the model/GATE high, 500 µs commanded
1.7 nF (what the netlist says)538 µs
4.7 nF580 µs
6.8 nF616 µs
10 nF689 µs
the board652 µs

So the perfboard's /BOOT node carries roughly 8 nF, not 1.7 nF — the extra ~6 nF is the wiring. That is the difference between a model that reports 53 % duty and a board that delivers 68 %. It is worth remembering that a hand-built board is three times its own netlist at this node.

Two ways out, and they were both measured:

Slow the carrier. The error is a fixed time, so at a long period it stops mattering. At 100 Hz, 50 % the scope reads:

measurementvalue
period10.02 ms
/GATE high / low width4.82 ms / 5.20 ms
delivered duty48.10 % for 50 % commanded
/GATE average4.76 V

170 µs out of a 5 ms off-half is 3 %, and the delivered duty is honest. The fan is quiet — the noise the 1 kHz carrier produced is gone. This is the fix the board is running now, and it cost nothing but a constant in the sketch.

Or make the turn-off fast, which buys the high carriers back. A BC547 (Q5) with its emitter to ground, base to /BOOT through 4.7 kΩ, and collector to /GATE clamps the gate directly instead of waiting for /BOOT to charge back up. On the calibrated board:

variant1 kHz10 kHz25 kHz
as built, R3 = 10k580 µs (want 500)stuck onstuck on
Q5, R3 = 10k, Rb = 4.7k500 µs53 µs23 µs, fan 58 mA
Q5, R3 = 1k, Rb = 4.7k496 µs49 µs19 µs, fan 47.5 mA

Wanted values are 500, 50 and 20 µs. The helper with R3 dropped to 1 kΩ is essentially exact at every carrier from 1 kHz to 25 kHz. With R3 = 1k the off state also gets cheaper rather than more expensive: /BOOT settles near 10 V, which is high enough that Q3 genuinely turns off, so /HIGATE and R4 stop carrying anything and the off-state loss collapses to about 25 mW. The cost moves to the on state — R3 = 1k then carries about 12 mA while the fan runs, so R3 has to dissipate around 145 mW, fine for a quarter-watt through-hole part but a part to choose knowingly. That variant is modelled and not fitted: 100 Hz already solved the noise, so Q5 is on the shelf until a carrier above hearing is wanted.

One more thing visible in the 1 kHz trace: /DRAIN undershoots to −1.2 V, which means Q1's body diode conducts briefly at turn-on. The freewheel current circulating through D1 and the fan's own 10 mH has to collapse, and it does that by pulling the drain below ground. It is not the noise source, but it is why the drain edges look ragged.

The whole duty range at 100 Hz, listened to

With the carrier fixed at 100 Hz the board was run up the entire range, 10 seconds per rung, with OCR1A read back from the sketch at every step:

dutyOCR1A/GATE high per 10 msfan supplywhat the bench did
0 %00 — pin held low as GPIO0 Vsilent
10 %1000~1.0 ms~1.2 Vwould not spin the fan
20 %2000~2.0 ms~2.4 Vwould not spin the fan
30 % … 90 %3000 … 9000~3 … 9 ms~3.6 … 10.7 Vran
100 %10000always high~11.9 Vran, smooth

No humming at any rung. That is the result the 1 kHz carrier could not produce at any chopped duty, and it is the reason 100 Hz is the default.

The dead bottom of the ladder is the fan, not the switch. A 12 V brushless fan needs something like 4–7 V at its terminals before its own controller will start the rotor, so 10 % and 20 % are being delivered correctly and the fan is simply declining to start — expected behaviour on a 2-wire fan, and worth knowing before anyone reads "nothing happens at 10 %" as a board fault. The exact rung where it starts was not pinned down. The other half of the same fact: once spinning, a fan keeps turning at a terminal voltage well below the one it needed to start, so the speed at a given duty depends on whether the fan was already running.

Open item: a start kick, not written yet

A thermostat wants low duty means low speed, but a fan that will not start at 1.2 V makes the bottom of the range dead. The usual answer is a start kick — 100 % for a second or two to get the rotor going, then drop to the commanded duty. That belongs in fan-switch-freq as a k command and in the ladder as a kick before each rung settles. Not implemented; the bench is happy without it.

Do not put a capacitor across the fan terminals

That was the other theory, and the simulation killed it. A capacitor there does not absorb the spike, it becomes the other half of an LC tank with the fan's own 10 mH winding:

Cfanmean fan current at 0 %ring with L1 = 10 mH
none2.57 mA—
100 nF14.9 mAharmless
10 µF69.6 mA503 Hz, Q 3.8, ~2.4 ms
100 µF96.1 mA159 Hz, Q 12, ~24 ms

At 10 % duty it is worse still: 74.9 mA with 10 µF, 97.0 mA with 100 µF. A bulk capacitor across the terminals makes the fan hotter and louder, not quieter. The only capacitor on that side of the board worth having is 100 nF film at J3, and that is for EMI, not for the hum.

Programming it on the bench

Both routes in docs work; the FTDI route is the one that keeps the bench quiet:

sh
arduino-cli compile --fqbn arduino:avr:pro:cpu=8MHzatmega328 <sketch>
avrdude -C <avrdude.conf> -c arduino -P /dev/ttyUSB0 -b 57600 -p m328p \
        -U flash:w:<sketch>.ino.hex:i

firmware/bench/ wraps exactly this. flash-bootloader.sh compiles and writes, set.sh leaves the sketch in a commanded state so the board can be probed with the port released, seq.sh runs a timed sequence so a duty range can be listened to, and verify-flash.sh reads all 32 kB back over ISP and compares it against the compiled hex.

  • DTR must reach the module's RST. Without it the bootloader never gets its reset pulse and avrdude just times out. The FTDI breakout's DTR to J5-1 is the whole auto-reset circuit.
  • Cross the data lines. J5-2 is TX0, the module's output, so it goes to the breakout's RX; J5-3 is RX1, the module's input, so it goes to the breakout's TX. Matching the silkscreen names label-to-label is the wrong way round. If a breakout's labels are ambiguous, jumper its own TX to its RX with nothing else attached and send bytes at it — 20 out, 20 back identifies the pair by function.
  • 57600, not 9600. The 3.3 V / 8 MHz bootloader runs at 57600; the sketch runs at 9600. They are different baud rates on the same wire, which is why a failed upload looks like a dead link.
  • Opening the port resets the module, and that is both the feature and the trap. DTR to J5-1 is what gives the bootloader its reset pulse, so flashing needs the port to be opened. But the same pulse resets the sketch, which puts the duty back to 0 %, and ATmegaBOOT then sits listening for a sync byte for about a second — any command sent in that window is eaten by the bootloader instead of reaching the sketch. That is how f 100 once arrived as unknown: j. The bench uses a set.sh that opens the port, waits 3 s for the bootloader to give up, sends the command list, waits again, sends the list a second time, and then releases the port; the sketch holds whatever it was last commanded, so the bench stays in that state while it is probed with a scope or a meter.

Known bug, left in place

firmware/fan-switch-debug blinks the tach LED with PORTB ^= _BV(PB6), but Arduino D10 is PB2. The disassembly confirms it (in r24,0x05; ldi r25,0x40; out 0x05): it toggles a bit that is not the tach pin. It is left as written because that sketch is the record of the first bench run. Fix it to _BV(PB2) before reusing it on the embedded-Pro-Mini board.

Where the bench is left

  • The chip is running firmware/fan-switch-freq, flashed through its own bootloader, and boots on its own at duty 0 % OCR1A 0/10000 carrier 100 Hz. Nothing has to be sent for it to be in the quiet configuration.
  • Q5 and the 1 kΩ R3 are not fitted. The board is the perfboard build with R3 = 10k, and 100 Hz is what makes that work.
  • Carriers above roughly 5 kHz stay unusable on this board until Q5 is fitted: the ~170 µs turn-off is longer than the off half-period there, so the output is stuck on.
  • The fan is quiet at every chopped duty from 30 % to 100 %, silent at 0 %, and does not start below about 20 % because a 12 V brushless fan will not start on 2 V. That is the fan, not the switch.

Archive

archive/ keeps the first version of this project: a single logic-level IRLZ44N driven directly at 5 V, plus its placeholder models and its plots. It is kept because the reason it worked (a logic-level part, a 5 V pin) is exactly the reason the 3.3 V version does not.

Parts

28
QtyValueFootprintReferences
510kResistor_THT:R_Axial_DIN0207_L6.3mm_D2.5mm_P7.62mm_HorizontalR2, R3, R5, R7, R8
4M3 mounting holeMountingHole:MountingHole_3.2mm_M3MK1, MK2, MK3, MK4
31kResistor_THT:R_Axial_DIN0207_L6.3mm_D2.5mm_P7.62mm_HorizontalR1, R4, R9
1100nCapacitor_THT:C_Disc_D5.0mm_W2.5mm_P5.00mmC3
1100nCapacitor_THT:C_Disc_D3.8mm_W2.6mm_P2.50mmC6
110uCapacitor_THT:CP_Radial_D6.3mm_P2.50mmC4
112V INConnector_PinHeader_2.54mm:PinHeader_1x02_P2.54mm_VerticalJ1
11n5819Diode_THT:D_DO-41_SOD81_P10.16mm_HorizontalD1
11n5822Diode_THT:D_DO-27_P15.24mm_HorizontalD2
11uCapacitor_THT:CP_Radial_D8.0mm_P3.50mmC5
12n7002Package_TO_SOT_SMD:SOT-23Q2
1Arduino_Pro_Mini_3V3mosfet-fan-switch:Arduino_Pro_Mini_328_SocketU1
1BC547Package_TO_SOT_THT:TO-92_Inline_WideQ4
1FAN OUTConnector_PinHeader_2.54mm:PinHeader_2x03_P2.54mm_VerticalJ3
1FTDI HEADERConnector_PinHeader_2.54mm:PinHeader_1x06_P2.54mm_VerticalJ5
1NUC FAN HEADERConnector_PinHeader_2.54mm:PinHeader_1x04_P2.54mm_VerticalJ4
1irf7416Package_SO:SO-8_3.9x4.9mm_P1.27mmQ3
1irfz44nPackage_TO_SOT_THT:TO-220-3_VerticalQ1
1mbr10100ctPackage_TO_SOT_THT:TO-220-3_VerticalD3

Questions

How big is the Mosfet fan switch PCB?

It measures 63.2 × 54.4 mm and has 2 copper layers, 1.6 mm thick.

What parts does the Mosfet fan switch use?

28 parts, built around the BC547, IRF7416T, IRFZ44N and MBR10100CT. The full list of its parts is on this page, with links to each part's symbol and footprint where PCBKit's library has them.

Where can I download the Mosfet fan switch KiCad files?

From the kavehs87/nuc-fan-controller repository on GitHub, at the commit this page was made from: the board is fan-switch-booster/mosfet-fan-switch.kicad_pcb and its schematic fan-switch-booster/archive/v1-irlz44n-5v-low-side.kicad_sch. PCBKit opens the board in the browser, with nothing to install.

Can I use the Mosfet fan switch design in my own project?

Its repository states no licence, so its author keeps all rights: ask kavehs87 before reusing the design.

Similar boards

  • 2 layers114.3 × 40.6 mmKSGER Controller REV 2.0, the printed circuit board in 3D

    KSGER Controller REV 2.0

    omega9380/KSGER-Coverplates

    T12 Soldering Iron control board. A design based on schematics created by Mindstorm88 on the EEVBlog forums.

    54 parts5
  • 4 layers150 × 150 mmNLTL Lite, the printed circuit board in 3D

    NLTL Lite

    Javitax47/NLTL_Lite

    Implementación pasiva de cinemática relativista general Agujeros negros · Agujeros blancos · Arrastre de marco · Proceso de Penrose · Lensing gravitacional · Solitones PCB 150 × 150 mm · 857 componentes · 37…

    857 parts
  • 2 layers40.1 × 40.1 mmBATPOWER04, the printed circuit board in 3D

    BATPOWER04

    mlab-modules/BATPOWER04

    High-efficiency step-down converter based on the LM3485 PFET buck controller. Input voltage range is 4.5 V to 16 V.

    31 parts
  • 2 layers67.4 × 54.3 mmFTDI master, the printed circuit board in 3D

    FTDI master

    Phunkafizer/AllSerial

    An universal sophisticated USB-serial converter

    60 parts6MIT
  • 2 layers89.4 × 83.2 mmEbaja back pcb, the printed circuit board in 3D

    Ebaja back pcb

    sanoopsivanandan/ebaja_pcb_design

    design of the ebaja 2026 pcb design

    60 parts
  • 2 layers38.1 × 43.2 mmAddon, the printed circuit board in 3D

    Addon

    bullestock/chiller

    Lasersaur chiller

    12 parts
  • 2 layers153.5 × 125.2 mmPlaca Chute, the printed circuit board in 3D

    Placa Chute

    LuizAmaral19/PCBs_SSL-EL_2026

    34 parts