Solar MPPT Power System
Public Unreviewedby TylerK
A 30 W solar battery charger with regulated 5 V and 3.3 V rails: an STM32G031 running perturb & observe MPPT and a CC/CV charge state machine, steering a TI LM5175 4-switch buck-boost through a single analog control input. Four-layer KiCad board, schematic and PCB generated from Python, with an electrothermal simulation run against the published board file.
3D Model
Open in new tab ↗
Loads on click. Unloads only if this page sits in the background.
Datasheet
Open in new tab ↗Contents
- The board
- How the control loop works
- Verification
- Electrothermal simulation
- What the model changed about the copper
- Why revision 5 was called final (and what that missed)
- Reproducibility
- Firmware
- What running it found
- Revision 8 — the copper was thin for a reason nobody had looked at
- What that recovered
- Revision 7 — paying back what revision 6 borrowed, on a smaller board
- What it bought, and what it cost
- A bug the resize exposed
- Revision 6 — placing the board against a model instead of by hand
- Scoring a placement before routing it
- Three things the optimiser got wrong first
- What it bought
- The five cases that would not solve — a bug in the model, not the board
- The defect that fix exposed
- Also worth knowing
- Key design values
- Generated, not drawn
- Three KiCad behaviours worth knowing
- Revision history
- Known limitations
- Design record
- What's in this repo
README
markdownSolar MPPT Power System
A 30 W solar battery charger with regulated 5 V and 3.3 V rails. A TI LM5175 4-switch synchronous buck-boost controller moves power from the panel to the battery; an STM32G031 runs perturb & observe MPPT and a CC/CV charge state machine in firmware and steers the converter through a single analog control input. Charges a 12 V SLA/AGM battery to a 14.2 V absorption setpoint across the panel's full 6–24 V range.
Four-layer KiCad board, 173 × 118 mm. The schematic and the PCB are both emitted by Python — no part of either was drawn by hand. The copper went through five revisions, each one driven by what an electrothermal model and a first-build audit measured on the revision before it, both run against the board file itself.
Revision 8 is the current board. Revision 5 was called final because the three remaining ways to improve its copper were each measured and found closed — which was true, and missed the point: all three were routing questions, and the thing actually limiting the board was where the parts were. Revision 6 re-places them, scored against a model of loss and temperature rather than by hand, and the high-current path gets much better: the raw battery path drops from 31.1 to 2.7 mΩ and peak board temperature from 46.1 to 39.8 °C. It was not a clean win — it bought the charge path by spending the low-current output rails, leaving each regulator's filter on the far side of the board from the connector it feeds. Revision 7 fixed that and shrank the board 17% while it was there — but it paid in local current density, because a smaller board left the widening pass nowhere to grow. Revision 8 recovers all of it without moving a single part: total copper loss 259 → 200 mW, worst case 108 → 69 mW, peak current density 78.8 → 47.2 A/mm², every one of the fourteen cases improved. It turned out the copper was thin for a reason that had nothing to do with the placement. All of it is below, along with the six-revision-old simulator bug that revision 6 turned up.
The board

| Outline | 173 × 118 mm, 4-layer |
| Stack | F.Cu / In1.Cu (GND) / In2.Cu / B.Cu (GND) |
| Components | 102 parts, 106 footprints (incl. 4 mounting holes) |
| Copper | 7,098 track segments, 378 vias |
| Nets | 60 signal/power, plus the GND planes |
| Source | KiCad 10.0.2 |
| SHA-256 | bf1394cbc902f460376c5ec1d0044933eb1f08278029f553eedad360bfe1290f |
Nine functional blocks: solar input and protection (fuse, LM74700 active ideal-diode reverse-polarity stage, TVS), housekeeping bias (LM5165 wide-Vin buck, self-starting straight off raw panel input so the board wakes with a flat battery), the LM5175 MPPT/charge power stage, current and voltage sensing (two INA180 shunt amplifiers), the MCU, two POL buck stages (TPS62130 5 V/2 A, TLV62569 3.3 V/1 A), output filtering, and connectors plus test points.

Full four-layer copper plot: docs/routing.svg.
Schematic: hardware/schematic.pdf.
Block-by-block walkthrough: docs/03-schematic-summary.md.
How the control loop works
The LM5175 gives firmware exactly one analog input to push on: its FB pin, which sets the regulated battery-bus voltage. The MCU drives it with a filtered PWM injected into the FB summing node.
That single knob has to serve three constraints at once — the MPPT operating point, the charge-current limit, and the battery's CV ceiling. Each is expressed as "what VBATT setpoint would this constraint want?", and an arbitrator commands the lowest (most restrictive) of the three every cycle. MPPT tracks by perturbing that setpoint and watching charge current, which is a good proxy for extracted panel power as long as battery voltage moves slowly compared to the perturbation interval.
The charge state machine runs BULK (CC at 2.0 A) → ABSORPTION (CV at 14.2 V until
current tapers below 0.1 A for ten minutes) → FLOAT (CV at 13.6 V), returning to BULK if
the battery sags. Firmware detail and the exact CubeMX peripheral setup are in
firmware/README.md; the FB summing-node derivation is in
docs/02-detailed-design-calculations.md.
One bug this analysis caught before hardware: working the FB network's node equation during firmware bring-up showed the original divider (R13 = 46.4 k, R15 = 10 k) could only ever command a 10.28 V ceiling on the battery bus — physically incapable of reaching the 14.2 V CV target under any firmware command. Corrected to R13 = 120 k, R15 = 68 k, which brackets the target at 8.94 V / 14.76 V.
Verification
Everything below was measured against the published .kicad_pcb, not asserted.
| Check | Result |
|---|---|
| ERC | 0 errors, 10 cosmetic warnings |
| DRC — shorts, clearance, hole clearance | 0 |
| DRC — dangling track, hole-to-hole, courtyard overlap, isolated copper, starved thermal | 0 |
| DRC — unconnected items | 0 |
| DRC — cosmetic (silkscreen, footprint metadata) | 7 |
| Independent exact-geometry clearance check | 0 |
| Independent net-continuity check (exact geometry) | 0 split nets — every net is one continuous piece of copper |
| Fab capability (track, drill, annular ring, edge clearance) | clears a conservative prototype process |
| Every IC has a bypass capacitor within 10 mm | yes |
| Exposed pads stitched to the planes | yes |
| Pads overhanging the board outline | 0 |
| First-build audit, total problems needing attention | 0 |
The last row is a second opinion, not a restatement of the first. The autorouter reasons
on a 0.1 mm raster, and a raster can only approximate copper, so a separate checker
(geomcheck.py) recomputes every different-net
clearance from real shapes — oriented-rectangle pads, capsule tracks, annular vias,
drilled holes — with no grid anywhere. The router runs it as a rip-up loop: any link it
condemns is torn out and re-routed until the board is clean.
The bottom block is a separate audit (audit.py) of the things DRC does not check at
all: whether every schematic net actually reached the copper, whether each net is one
continuous piece measured with exact geometry rather than taken on trust, whether the
drills and annular rings are inside a fab's capability, whether each IC has a bypass
capacitor near enough to do anything, whether exposed pads are stitched to the planes,
and whether any pad hangs over the board edge. Those are the failures that survive a
clean DRC and show up on the bench. It currently reports nothing.
Electrothermal simulation
A DC current-density solve plus a steady-state thermal solve, run directly against the
board's own .kicad_pcb. The report's recorded board SHA-256 matches the file published
here, so the numbers and the copper cannot drift apart.

All fourteen cases solve — the first revision for which that is true.
| Case | Net | Load | R | Drop | Loss | J p99 |
|---|---|---|---|---|---|---|
| Panel input | Net-(J1-Pin_1) |
5.00 A | 3.87 mΩ | 19.3 mV | 97 mW | 40.5 A/mm² |
| Buck-side switch node | Net-(Q2-S) |
5.00 A | 6.59 mΩ | 32.9 mV | 165 mW | 67.0 A/mm² |
| Raw VBATT (pre-shunt) | Net-(Q4-D) |
2.50 A | 31.09 mΩ | 77.7 mV | 194 mW | 61.3 A/mm² |
| Battery bus → battery | /VBATT |
2.50 A | 8.65 mΩ | 21.6 mV | 54 mW | 14.0 A/mm² |
| Battery bus → 5 V POL | /VBATT |
0.90 A | 60.19 mΩ | 54.2 mV | 49 mW | 13.5 A/mm² |
| Battery bus → 3.3 V POL | /VBATT |
0.30 A | 17.09 mΩ | 5.1 mV | 2 mW | 4.5 A/mm² |
| 5 V POL switch node | Net-(L3-Pad1) |
2.00 A | 11.38 mΩ | 22.8 mV | 46 mW | 49.3 A/mm² |
| 5 V rail | Net-(U8-VOS) |
2.00 A | 5.92 mΩ | 11.8 mV | 24 mW | 13.8 A/mm² |
| 5 V output | /V5V_OUT |
2.00 A | 5.81 mΩ | 11.6 mV | 23 mW | 16.7 A/mm² |
| 3.3 V POL switch node | Net-(U9-SW) |
1.00 A | 10.89 mΩ | 10.9 mV | 11 mW | 5.3 A/mm² |
| 3.3 V rail | Net-(C32-Pad1) |
1.00 A | 6.99 mΩ | 7.0 mV | 7 mW | 17.4 A/mm² |
| 3.3 V output | /V3V3_OUT |
1.00 A | 10.91 mΩ | 10.9 mV | 11 mW | 18.3 A/mm² |
| Load ground return | GND |
3.00 A | 0.05 mΩ | 0.16 mV | 0.5 mW | 0.3 A/mm² |
| Charge ground return | GND |
2.50 A | 0.22 mΩ | 0.55 mV | 1.4 mW | 0.3 A/mm² |
The bolded row is new. It is not a new measurement technique — it is the same case that previous revisions had to record as unsolved, because the 3.3 V buck's switch node was one of the three links the autorouter could not close, and a net that is in two pieces has no resistance to report. Closing it (revision 5, below) is what let the solver see it at all.
Worst case anywhere on the board: 69 mW copper loss, 47.2 A/mm², with 200 mW of copper loss across all fourteen cases combined. The ground return is two to three orders of magnitude stiffer than the rails, which is the whole point of spending two of four layers on plane.

Thermally, a deliberately conservative composite case — every documented worst-case component loss applied simultaneously, 4.29 W total — at 25 °C ambient predicts a 42.0 °C peak board temperature, over the LM5175 power stage. At 5 W/m²K convection per side it is 45.7 °C; at 20 W/m²K, 38.4 °C.
Board temperature barely moves between revisions, and that is the expected result rather than a disappointing one: the composite case injects 4.29 W of component loss, against roughly 0.4 W of copper loss across every rail combined. Copper work shows up in voltage drop and current density, not in board temperature — which is worth knowing before spending effort on the wrong metric.
Both are screening models: board-surface temperature, not junction temperature, and
DC copper resistance only — component, connector, fuse, shunt, inductor, contact and
solder-joint resistance are all excluded. Method, mesh convergence, sensitivities and the
full caveat list are in
docs/simulation/05-electrothermal-simulation.md.
What the model changed about the copper
The copper has been through five revisions, each one driven by what the model — or, from revision 4 on, the first-build audit — measured on the revision before it. Nothing here was chosen from a rule of thumb.
Revision 1 — current comes from topology, not from net names. The first routing pass
classified "power" nets by matching keywords against net names. KiCad auto-generates
names like Net-(Q4-D) for any net never explicitly labelled in the schematic, so
several genuinely high-current nets matched no keyword and were routed at signal width
purely because of how they happened to be named. The model caught one carrying its full
2.5 A on a 0.25 mm trace: 431 mV, 1.08 W. Per-net current is now derived from the
schematic's own topology — the same list the model uses to define its cases.
Revision 2 — width is a per-segment property. A link's width is capped by the narrowest pad it lands on. That is correct at the pad and wrong everywhere else: a 5 A net ran 0.4 mm along its entire length, about 357 A/mm², because it touches one small pad. A post-route pass now grows each segment as far as the surrounding copper actually allows, verified exactly, so a net reaches full width in open board and necks down only where it has to. This revision also added the first direct parallel path, on the charge run from the shunt RS2 to the battery connector J2, after the model measured that path as resistance-dominated by its length rather than its width.
Revision 3 — aim higher, and treat every measured case the same way. The widening targets were roughly doubled (5 A now aims at 2.0 mm, 2.5 A at 1.6 mm, 1 A at 0.8 mm), which costs nothing where there is no room because every increase is individually verified, and the router is untouched — it has already finished by the time this pass runs. Every resistance-dominated case also got its own parallel path, routed between that case's own terminal pads, rather than only the charge run.
Revision 4 — the things a simulation does not measure. This revision was aimed at first-build survival rather than at the numbers below, and it traded some of them away to get there. An audit of the things DRC does not check found four real defects:
- Every bypass capacitor was too far from the pin it bypasses — worst was the LM5175's, 62.5 mm away. At that distance the trace inductance is the circuit and the capacitor is decoration; it is a classic "does not start on the bench" fault. Cause: a decoupling cap's two nets are a supply rail and GND, both of which the placer treats as diffuse and ignores, so the cap had no attractor at all and stayed where its block's grid put it. Bypass caps are now assigned to a specific pin, round-robin so every IC gets one before any gets a second. Every IC now has one within 10 mm.
- 96 vias had a 0.100 mm annular ring, inside a fab's drill-registration tolerance — a fraction of them would have broken out of their own pad on a real panel. Signal vias went from 0.40 mm to 0.45 mm, giving the 0.125 mm ring a standard process guarantees, and still fitting the 0.65 mm fanout with more margin than before.
- Exposed pads had no vias. The LM5165's ground pad had none at all, so it had neither a ground connection to the planes nor a heat path. 27 thermal vias are now placed inside exposed ground pads, falling back to the smaller via where the larger one will not fit between 0.5 mm-pitch pins.
- Both POL inductors sat 14 mm from their buck's switch node. That loop is the one that radiates. They are now placed by the same net-driven placer, at 5.8 mm and 10.0 mm — close, without walling in the buck's other pins, which hand-placing them at 2.2 mm did.
Two smaller ones: no-connect pins were being given fanout copper that did nothing but obstruct real signals, and a bug in the router's own rip-up bookkeeping was silently deleting fanout from links it had restored, turning 3 unroutable links into 10 split nets. Both are fixed.
Revision 5 — closing the last three links, and one pour that would not fill. Four revisions in, three links were still open, and they had stopped being a routing problem worth throwing more routing at: they are escapes from 0.65 mm-pitch pins that are walled in on F.Cu, and giving the router a whole spare plane layer to work with placed none of them. They are a resolution problem. The legal corridor down the middle of such a pad is about 0.1 mm wider than the trace needs — real, but finer than a 0.1 mm raster can represent, so rounding the pad centre to the nearest cell closes it.
That is also, precisely, why a person closes these in the interactive editor in a couple
of minutes. They are not out-thinking the router; they are working at nanometre
resolution instead of on a 0.1 mm grid. So handroute.py does the same thing the same
way: it re-rasterises the board at 0.025 mm, four times finer, and routes only the
three named links at minimum width against every piece of copper already on the board.
Nothing else moves. All three closed on the first pass, and the result was checked
against exact geometry before it was written:
| Net | From → to | Width | Copper |
|---|---|---|---|
Net-(U9-SW) |
U9.4 → L4.1 | 0.15 mm | 6.6 mm, 51 segments + 2 vias |
Net-(U4-SS) |
U4.8 → C13.1 | 0.25 mm | 9.0 mm, 38 segments + 2 vias |
Net-(U4-SLOPE) |
U4.7 → R11.1 | 0.25 mm | 16.1 mm, 198 segments + 4 vias |
The fourth unconnected item was more interesting than it looked. DRC had been reporting
it as a ground-pour region the fill leaves separate — easy to read as a cosmetic
artefact of how the pour is modelled. It was not. Reading KiCad's actual
filled_polygon output rather than a reconstruction of it showed U9's ground pad sitting
in a 1.6 × 0.4 mm sliver of top pour connected to nothing: a switching regulator whose
ground pin had no path to the ground planes. The cause was one 0.60 mm /VBATT run
alongside U9 that the widening pass had grown into the gap, squeezing the corridor past
U9's ground pad to 0.22 mm — just under the zone's own 0.25 mm min_thickness, at which
point KiCad simply stops filling. Giving 0.35 mm of width back on one 1.4 mm segment
opens the corridor; the resistance that costs is immaterial next to a regulator that is
not grounded. GND is a plane net and is never routed, which is exactly why this hid
behind a clean-looking DRC line for two revisions.
The result is the first fully connected board in the series: 0 unconnected items, 0 split nets, 0 audit problems, and the 3.3 V switch-node simulation case solves instead of being skipped.
What that bought, and what it cost. The same model, run on the board at each revision:
| Measurement | Rev 1 | Rev 2 | Rev 3 | Rev 4 | Rev 5 (final) |
|---|---|---|---|---|---|
| Panel input path resistance | 11.578 mΩ | 5.454 mΩ | 3.866 mΩ | 3.866 mΩ | 3.866 mΩ |
| Panel input current density (p99) | 122.55 A/mm² | 55.24 A/mm² | 40.50 A/mm² | 40.50 A/mm² | 40.50 A/mm² |
| Buck-side switch node copper loss | 333.13 mW | 160.35 mW | 112.84 mW | 164.74 mW | 164.74 mW |
| Raw VBATT resistance | 39.370 mΩ | 30.167 mΩ | 21.155 mΩ | 31.094 mΩ | 31.094 mΩ |
| Battery bus → battery resistance | 29.591 mΩ | 20.180 mΩ | 11.121 mΩ | 8.645 mΩ | 8.645 mΩ |
| Battery bus → 5 V POL resistance | 76.856 mΩ | 30.297 mΩ | 21.143 mΩ | 60.186 mΩ | 60.189 mΩ |
| 5 V rail resistance | 25.502 mΩ | 19.973 mΩ | 8.755 mΩ | 5.919 mΩ | 5.919 mΩ |
| 5 V output resistance | 23.939 mΩ | 19.392 mΩ | 5.309 mΩ | 5.810 mΩ | 5.810 mΩ |
| 3.3 V rail resistance | 18.606 mΩ | 25.300 mΩ | 7.638 mΩ | 6.991 mΩ | 6.991 mΩ |
| 3.3 V output resistance | 24.754 mΩ | 31.069 mΩ | 10.913 mΩ | 10.913 mΩ | 10.913 mΩ |
| 3.3 V POL switch node resistance | — | — | — | unsolved | 10.887 mΩ |
| Worst copper loss anywhere | 333.13 mW | 188.54 mW | 132.22 mW | 194.34 mW | 194.34 mW |
| Peak current density, any case | 122.55 A/mm² | 61.29 A/mm² | 58.92 A/mm² | 67.03 A/mm² | 67.03 A/mm² |
| Peak board temperature | 48.5 °C | 45.7 °C | 45.6 °C | 46.1 °C | 46.1 °C |
| Worst IC bypass distance | — | — | 62.5 mm | 10.0 mm | 9.0 mm |
| Vias below fab annular ring | — | — | 96 | 0 | 0 |
| DRC unconnected items | 0 (with 86 shorts) | 13 | 4 | 4 | 0 |
| Split nets, exact geometry | — | — | 3 | 3 | 0 |
| Simulation cases solved | — | — | — | 13 / 14 | 14 / 14 |
Revision 4 is the first row set that goes both ways, and that is the honest summary of it: four cases improved, six got worse, and the three headline copper figures are among the worse ones. Moving the bypass capacitors and the POL inductors to where they belong re-shuffled the placement, and re-routing around the new positions cost some of the copper that revision 3 had won. The worst case, the battery bus feed to the 5 V POL, went from 21 to 60 mΩ — 54 mV of drop on a 0.9 A rail.
That trade is deliberate. A 54 mV drop is a performance number on a rail with plenty of headroom; a 62 mm bypass capacitor and a 0.100 mm annular ring are the kind of defect that produces a board which does not work and cannot be debugged from the schematic. Revision 3's copper is better. Revision 4 is more likely to work the first time it is built, and that is what that revision was for.
Revision 5 changes no copper numbers, and that is the correct outcome. Every resistance figure above is identical to revision 4 to three decimal places, because the three links it added are small-signal — a buck's switch-node escape, a soft-start pin, a slope-compensation pin — and the one segment it narrowed carries 2.5 A over 1.4 mm. What it changed is the bottom of the table: the board went from four unconnected items and three nets in pieces to none, so the last row set is the first one that describes a board which would actually function if it were built.
Two rows are worth reading carefully rather than skimming.
The 3.3 V rail and output rows went backwards in revision 2 — 18.6 → 25.3 mΩ and 24.8 → 31.1 mΩ. That revision re-placed and re-routed from scratch, discarding a parallel path the previous one had. The regression was visible in the numbers, and it is what prompted revision 3 to give every resistance-dominated case a parallel path instead of only the one that happened to be worst at the time.
Peak current density has nearly stopped responding to trace width — 122.55 → 61.29 → 58.92 → 67.03 A/mm². The first halving came from fixing genuinely thin copper. What is left sits in short runs right at pads, where the pad's own width sets the limit and no amount of widening in open board helps. Reducing it further is a placement and package problem, not a trace-width one.
Why revision 5 was called final (and what that missed)
Revision 5's measurements were worked for a revision 6 and did not yield one. Three avenues were examined and all three were closed — correctly. The conclusion drawn from that was wrong, and the reason is worth keeping: every one of these is a question about routing, and none of them asks whether the parts are in the right places. They are left here because they are still accurate about revision 5, and because "we examined three things and they were all shut" is exactly how a search gets mistaken for an exhaustive one. Revision 6 re-placed the board and the first two reopened immediately.
The two worst resistances cannot be improved with more copper. The battery-bus feed
to the 5 V POL (60.2 mΩ) and the raw VBATT path (31.1 mΩ) are both dominated by length
rather than width, and the established fix for that on this board is a direct parallel
run between the case's own terminal pads — the technique that took the charge path from
92 mΩ to 8.6 mΩ in revisions 2 and 3. Both candidates were retried against revision 5's
geometry. RS2.2 → U8.10 and RS2.2 → U9.3 find no legal path on any of the three
routing layers plus the escape layer, at every width from 1.2 mm down to 0.4 mm. They are
blocked, not short of search space, and that distinction was tested rather than
assumed: the router's search window is the endpoints' bounding box expanded by the
margin, so at the 60 mm margin used here — with endpoints 84 mm and 66 mm apart — the
window already covers essentially the whole board.
Every current-carrying net is already at its full width target. Re-measured against
the final geometry, widen.py reports /VBATT and Net-(Q4-D) at 1.60 mm and
Net-(Q2-S) and /PANEL_PROT at 2.00 mm — their targets — everywhere the surrounding
copper permits. The pass is at a fixed point. There is no segment left that geometry
would allow to grow, so raising the targets would change nothing except the margin
against neighbouring copper.
The peak current density is a package limit, not a layout one. The 67.0 A/mm² on the
buck-side switch node sits in short segments right at the pads, where Net-(Q2-S) runs
1.60 mm against its 2.00 mm target because the pad itself is narrower than the trace
wants to be. Widening in open board does not touch it. Moving that number means moving Q2
or changing its footprint — reopening the fanout and courtyard work of revision 4 to buy
a figure already down from 122.6 A/mm² at revision 1.
What remains on the table is precisely what revision 4 traded away on purpose: revision 3's copper was better on six of ten resistance cases, and recovering it means putting the bypass capacitors and POL inductors back where they were — 62 mm and 14 mm from the pins they serve. On a board whose remaining purpose is to work the first time it is built, that is not a trade worth reopening.
Reproducibility
The electrothermal run was executed twice against the same board file, in separate processes. Both runs recorded the same board SHA-256, solved all fourteen cases, and agreed to zero relative difference on every resistance, voltage drop, copper loss and current-density figure, with the thermal solution identical to six decimal places. The numbers on this page are a property of the board file, not of a particular run.
Firmware
The MCU runs the part of this system that cannot be done with resistors: deciding,
every 20 ms, what battery-bus voltage to ask the LM5175 for. Source, the CubeMX pin
configuration and the full design notes are in
firmware/README.md.
It is an application layer, so it has no main() of its own — CubeMX supplies the
startup code, clock setup, HAL and linker script, and app_main() replaces the body
of the generated while(1). That means no flashable image is produced here. Two
things can still be verified without hardware, and make runs both:
make arm |
Cross-compiles all six sources for Cortex-M0+ under -Wall -Wextra -Wconversion -Wshadow -Wdouble-promotion, against a stub main.h. Costs 3,096 bytes of flash and 0 bytes of RAM, and fails the build if double-precision soft-float appears — the STM32G031 has no FPU, so one stray double in the control maths would cost both flash and loop time. |
make test |
Compiles the control layer for the host and runs it against a simulated panel, converter and battery. 16 property checks: current limits, voltage ceiling, MPPT tracking, fault detection and recovery. |
The plant model is deliberately not a circuit simulator. Its job is to reproduce the one relationship the controller steers by — charge current against commanded bus voltage, with a real maximum in the middle — including the panel collapsing past that maximum, which is the entire reason MPPT exists. A model that pins the panel at its peak makes a broken tracker look fine.
What running it found
Nine defects, none of which were visible by reading the code, and two more that only
appeared while fixing the first nine. The full table is in
firmware/README.md; the three that mattered most:
Current limits existed only in the BULK branch. Reaching absorption or float silently switched off both the 2 A charge-current limit and the 5 A panel-side ceiling — measured at 2.86 A and 5.64 A against those limits. The panel ceiling is what protects RS1 and the bridge FETs, so this was a hardware-protection hole rather than a tuning problem.
Two independent paths led to holding 14.2 V on the battery indefinitely. The sensor plausibility checking the header documented was never implemented, and a timed-out ADC conversion was reported as a confident reading of 0 V. Either one makes a dead VBATT divider look like a flat battery, and sustained absorption voltage on an SLA is exactly what the absorption→float transition exists to prevent.
P&O steered by sensor noise. With no dead-band on the direction test, and a gradient that is zero at the peak by definition, the tracker reversed 597 times in a 600-perturbation run. Fixing that introduced a subtler failure worth recording: an adaptive step that shrank on every reversal eventually fell below the point where one perturbation moved the current more than the dead-band, at which point every reading said "no change", the direction froze, and the tracker walked steadily off the peak — 9.16 W down to 1.19 W of an available 9.18 W — without ever detecting a loss. The step now grows whenever the signal is too weak to steer by, which makes going blind structurally impossible.
One finding is architectural rather than a bug: MPPT holds about 93% of available panel power in simulation, not the ~99% a well-tuned tracker reaches. The only handle firmware has is the output setpoint, and a battery is a stiff voltage source, so the usable span between no charge current and a collapsed panel is a few hundred millivolts. Closing that gap needs a VIN sense channel — so power could be maximised directly instead of inferred from charge current — which is a future-board item.
Revision 8 — the copper was thin for a reason nobody had looked at
Revision 7 left three things on the table: peak current density had risen to 78.8 A/mm², the 3.3 V POL switch node sat at 4.1 mΩ, and the panel and battery-bus paths had both roughly doubled. The obvious diagnosis was the 17% smaller board — less room, thinner copper — and the obvious fix was to re-place or re-grow it.
That diagnosis was wrong, and measuring before acting is what showed it. Listing the copper on the worst net by width gave this:
| Net | Current | Copper at ≤0.6 mm |
|---|---|---|
Net-(Q2-S) buck switch node |
5.0 A | 44.8 mm of 0.6 mm |
/VBATT battery bus |
2.5 A | 13.5 mm of 0.25 mm |
Net-(U9-SW) 3.3 V switch node |
1.0 A | 4.4 mm of 4.6 mm total, at 0.3 mm |
44.8 mm of 0.6 mm copper on a 5 A net is not a board-space problem — 0.6 mm is a
suspiciously specific number. It came from stitch.py, which adds a direct parallel run
between a case's own terminals when the model says that case is length-dominated. Its
width ladder read:
ladder = [(w, 60.0) for w in (width, 0.6, 0.5, 0.4) if w <= width]
A 2.0 mm request that did not fit fell straight to 0.6 mm, with nothing in between. Every parallel path on a congested board was landing at the bottom of a ladder whose first rung was the floor. Filling the ladder in — 2.0, 1.6, 1.2, 1.0, 0.8, then down — took the buck switch node's parallel run from 0.6 mm to 1.6 mm on the same board, with the same parts in the same places.
Three smaller things went with it:
- The widening pass now runs again after stitching. It had only ever run before, so the parallel paths it most wanted to grow were added after it had finished and were never touched.
- The width targets went up — 5 A now aims at 2.5 mm rather than 2.0, 2.5 A at 2.0. Every increase is still verified against exact geometry individually, so aiming higher costs nothing where there is no room.
- The hand-router learned to add a parallel path to an already-connected net. The 0.1 mm stitcher had reported "no direct path U9.4 → L4.1, 4.5 mm apart" and given up on the 3.3 V switch node. At 0.025 mm the same corridor is a different question, and the answer was a direct 4.5 mm run.
What that recovered
Not one component moved. This is the same placement as revision 7.
| Case | Load | rev 7 | rev 8 | |
|---|---|---|---|---|
| Buck switch node | 5.00 A | 4.329 mΩ | 2.760 mΩ | −36% |
| 5 V rail | 2.00 A | 3.718 mΩ | 2.761 mΩ | −26% |
| 5 V output | 2.00 A | 2.028 mΩ | 1.506 mΩ | −26% |
| 5 V POL switch node | 2.00 A | 0.840 mΩ | 0.648 mΩ | −23% |
| Battery bus → battery | 2.50 A | 3.189 mΩ | 2.573 mΩ | −19% |
| 3.3 V rail | 1.00 A | 2.660 mΩ | 2.221 mΩ | −17% |
| 3.3 V output | 1.00 A | 0.786 mΩ | 0.661 mΩ | −16% |
| 3.3 V POL switch node | 1.00 A | 4.096 mΩ | 3.565 mΩ | −13% |
| Raw VBATT (pre-shunt) | 2.50 A | 2.069 mΩ | 1.806 mΩ | −13% |
| Battery bus → 3.3 V POL | 0.30 A | 11.655 mΩ | 10.246 mΩ | −12% |
| Battery bus → 5 V POL | 0.90 A | 7.493 mΩ | 6.811 mΩ | −9% |
| Panel input | 5.00 A | 2.955 mΩ | 2.719 mΩ | −8% |
| Load ground return | 3.00 A | 0.143 mΩ | 0.144 mΩ | +0.8% |
| Charge ground return | 2.50 A | 0.244 mΩ | 0.245 mΩ | +0.5% |
| rev 7 | rev 8 | ||
|---|---|---|---|
| Total copper loss, all 14 cases | 258.8 mW | 199.7 mW | −23% |
| Worst single case, copper loss | 108.2 mW | 69.0 mW | −36% |
| Peak current density (p99) | 78.8 A/mm² | 47.2 A/mm² | −40% |
| Peak board temperature | 41.99 °C | 41.95 °C | — |
Twelve cases improved, two ground returns moved by under 1%, none regressed. The two figures revision 7 had given up — peak current density and the 3.3 V switch node — are both back, and the 17% smaller board is kept.
The lesson is the one this project keeps relearning: measure the thing itself before theorising about it. A board-space explanation for thin copper was plausible, would have cost a full re-place and re-route, and was wrong. Listing the copper by width took one command and pointed straight at a ladder with a missing middle.
Revision 7 — paying back what revision 6 borrowed, on a smaller board
Revision 6's objective weighted every net by I². That is proportional to copper loss, so it is the right thing to minimise — and it means a 5 A path is worth twenty-five times a 1 A path per millimetre. The optimiser did exactly what it was told: it bought the charge path by spending the output rails, and left each POL's filter on the far side of the board from the connector it feeds. Measured as a chain walked in flow order — regulator → inductor → bead → connector — revision 6's output rails ran 161.7 mm and 165.5 mm.
The loss it traded away really was small: 26 mΩ on a 1 A rail is 26 mV. What the objective had no way to express is that a rail whose filter is nowhere near its connector is a bad layout for reasons the loss figure does not capture. So the chains are now scored directly, as their own term, alongside the I² one. Revision 7's output chains: 51.3 mm and 28.1 mm.
The same pass shrank the frame. At 9% footprint occupancy there was room to give up, and a sweep of the thermal model said how much: dropping from 189 × 129 mm to 173 × 118 mm — 17% less panel area — costs about 1.5 °C. That is a real fabrication saving for a temperature this board has margin to spend.
What it bought, and what it cost
| Case | Load | rev 6 | rev 7 | |
|---|---|---|---|---|
| 3.3 V rail | 1.00 A | 26.201 mΩ | 2.660 mΩ | −90% |
| 3.3 V output | 1.00 A | 5.101 mΩ | 0.786 mΩ | −85% |
| 5 V output | 2.00 A | 13.001 mΩ | 2.028 mΩ | −84% |
| Battery bus → 3.3 V POL | 0.30 A | 47.701 mΩ | 11.655 mΩ | −76% |
| Battery bus → 5 V POL | 0.90 A | 24.431 mΩ | 7.493 mΩ | −69% |
| 5 V POL switch node | 2.00 A | 1.406 mΩ | 0.840 mΩ | −40% |
| 5 V rail | 2.00 A | 6.173 mΩ | 3.718 mΩ | −40% |
| Buck switch node | 5.00 A | 4.665 mΩ | 4.329 mΩ | −7% |
| Raw VBATT (pre-shunt) | 2.50 A | 2.091 mΩ | 2.069 mΩ | −1% |
| Charge ground return | 2.50 A | 0.239 mΩ | 0.244 mΩ | +2% |
| Load ground return | 3.00 A | 0.108 mΩ | 0.143 mΩ | +32% |
| Battery bus → battery | 2.50 A | 1.487 mΩ | 3.189 mΩ | +114% |
| Panel input | 5.00 A | 1.303 mΩ | 2.955 mΩ | +127% |
| 3.3 V POL switch node | 1.00 A | 1.667 mΩ | 4.096 mΩ | +146% |
| rev 6 | rev 7 | ||
|---|---|---|---|
| Total copper loss, all 14 cases | 313.4 mW | 258.8 mW | −17% |
| Board area | 189 × 129 mm | 173 × 118 mm | −17% |
| Worst single case, copper loss | 116.6 mW | 108.2 mW | −7% |
| Peak current density (p99) | 61.7 A/mm² | 78.8 A/mm² | +28% |
| Peak board temperature | 39.81 °C | 41.99 °C | +2.2 °C |
The honest summary: the board dissipates 17% less in total copper on 17% less panel, and pays for it with a higher local current density and 2.2 °C. Nine cases improved, five regressed. Against revision 5 — the last board before any of this placement work — it is better on everything: total copper loss 491.6 → 258.8 mW, peak current density 120.4 → 78.8 A/mm², peak board temperature 46.0 → 42.0 °C.
The two regressions worth naming are the 3.3 V POL switch node (1.7 → 4.1 mΩ) and the
peak current density, which now sits on the buck switch node. Both are short runs right
at pads, where the pad's own width is the limit; the parallel-path pass that fixed the
other switch nodes could not find a route for U9.4 → L4.1 at all, 4.5 mm apart, because
that corner is congested. At 1 A and 4 mV neither is an electrical problem, but they are
where a revision 8 would start.
A bug the resize exposed
Shrinking the frame immediately produced a pad hanging over the board edge — J6, the UART header. The placement search had approved it, because its bounds check had the old frame's numbers written in as literals:
if not (bx[0] > 3.0 and bx[1] < 192.0 and bx[2] > 3.0 and bx[3] < 132.0):
A board dimension that appears as a literal in a legality test is a trap the first time
the board changes size, and it had been copied into three modules. The frame now lives in
one place (boardsize.py) that the generator, the
router and the placement scorer all import, and the bounds derive from it. The fab audit
caught the pad; the point is that the check which was supposed to prevent it did not.
Revision 6 — placing the board against a model instead of by hand
Revision 5's closing argument was that the copper could not be improved further. That was true and beside the point. Every avenue it examined was a routing avenue — a parallel path, a wider trace, a shorter escape — and routing was not the constraint. The constraint was the placement, which had been written by hand as a table of coordinates and never questioned.
One line of that table was doing most of the damage. RS2, the 2.5 A charge-current
shunt, sat in the "sensing" block beside the other INA180 instead of in the power path it
measures. The chain therefore ran Q5 → RS2 → J2 as a 60 mm hop followed by a 97 mm hop:
157 mm of a 338 mm power path, spent travelling to a sense resistor and back. That single
placement is why the raw-VBATT run measured 31 mΩ, why both /VBATT parallel paths were
blocked, and why revision 5 concluded they were unroutable. They were unroutable. They
should not have needed routing.
A second one had never been measured at all: RS1 and U5 — a Kelvin current-sense pair — were 48 mm apart on a switching board.
Scoring a placement before routing it
Routing, widening, stitching and simulating one candidate takes hours, so placements
cannot be chosen by trying them. But the two quantities revision 6 cares about are both
mostly decided by position, and both can be estimated from positions alone in about a
tenth of a second (placescore.py):
| Term | What it measures |
|---|---|
| I²·L | Copper loss a net will suffer at fixed width, using the minimum spanning tree over its pads as the best case for the length a router could achieve |
| Peak temperature | The same collapsed sheet-conduction model the electrothermal run uses, on a uniform-copper assumption — it lands within about a degree of the full solve |
| Power-chain length | The chain walked in flow order, because "short" and "in the right order" are different properties and revision 5's chain doubled back |
| Loop perimeter | The physical extent of each half-bridge commutation loop, which a DC simulation cannot see at all and fabrication cannot undo |
placeopt.py then anneals against that score from a
hand-written seed. The seed matters more than the search: "put the shunt in the power
path" is a topological idea, and every individual move toward it scores worse on its own,
so local search cannot find it. The search is good at the millimetres, and at the
trade-off between the two terms — current wants everything short and therefore clustered,
heat wants the watts spread apart.
Measured against revision 5 by that scorer: I²·L −27%, peak temperature −6.8 °C, power chain −41% (338 → 198 mm), switching-loop perimeter −58%.
Three things the optimiser got wrong first
Worth recording, because each one scored better while being worse:
- It stretched a half-bridge across the board. Scoring loops by enclosed area rewards collinearity — three points in a line enclose nothing — so it put Q4 and Q5, which share the highest-dV/dt node in the converter, 55 mm apart and reported the loop as 28 mm². The score is now the loop's perimeter, which a long thin loop cannot game.
- It stacked parts on top of each other. Anchors are placed at the coordinate they are given, with no collision test; the first run produced a beautiful score and fourteen overlapping footprints.
- It passed a placement that put a pad 0.125 mm from another pad. The keep-out was sized from the footprint's courtyard, but a TO-220's origin is on pad 1 and its other leads reach 5 mm past the drawn courtyard. The keep-out has to cover the copper, and the courtyard rule and the clearance rule have to be checked separately — conflating them let each hide the other.
What it bought
Both columns are measured on their own board file with the corrected model, so they are comparable to each other and not to anything published before revision 6.
| Case | Load | rev 5 | rev 6 | |
|---|---|---|---|---|
| Panel input | 5.00 A | 4.394 mΩ | 1.303 mΩ | −70% |
| Raw VBATT (pre-shunt) | 2.50 A | 8.551 mΩ | 2.091 mΩ | −76% |
| Battery bus → battery | 2.50 A | 9.185 mΩ | 1.487 mΩ | −84% |
| Battery bus → 5 V POL | 0.90 A | 61.981 mΩ | 24.431 mΩ | −61% |
| 5 V POL switch node | 2.00 A | 9.535 mΩ | 1.406 mΩ | −85% |
| 3.3 V POL switch node | 1.00 A | 11.661 mΩ | 1.667 mΩ | −86% |
| 3.3 V output | 1.00 A | 10.147 mΩ | 5.101 mΩ | −50% |
| Buck switch node | 5.00 A | 4.156 mΩ | 4.665 mΩ | +12% |
| 5 V rail | 2.00 A | 5.924 mΩ | 6.173 mΩ | +4% |
| Charge ground return | 2.50 A | 0.215 mΩ | 0.239 mΩ | +11% |
| Load ground return | 3.00 A | 0.056 mΩ | 0.108 mΩ | +94% |
| 5 V output | 2.00 A | 5.628 mΩ | 13.001 mΩ | +131% |
| Battery bus → 3.3 V POL | 0.30 A | 17.892 mΩ | 47.701 mΩ | +167% |
| 3.3 V rail | 1.00 A | 7.196 mΩ | 26.201 mΩ | +264% |
| rev 5 | rev 6 | ||
|---|---|---|---|
| Total copper loss, all 14 cases | 491.6 mW | 313.4 mW | −36% |
| Peak current density (p99) | 120.4 A/mm² | 61.7 A/mm² | −49% |
| Peak board temperature | 46.04 °C | 39.81 °C | −6.2 °C |
| Worst single case, copper loss | 109.9 mW | 116.6 mW | +6% |
The board dissipates a third less in copper, its worst current density halves, and it runs 6 °C cooler under the same composite 4.29 W case. The eight cases that improved are the high-current ones; the five that regressed are low-current rails, and that is the objective working exactly as written — it weights each net by I², so a 5 A path is worth twenty-five times a 1 A path per millimetre, and it spent the output rails to buy the charge path. On a 1 A rail with volts of headroom that is a performance number rather than a fault, but nothing in the objective represented an output filter's distance to its connector, and it shows. That is the first thing a revision 7 should reclaim.
The five cases that would not solve — a bug in the model, not the board
Five cases failed with "terminal is disconnected", including the buck switch node. Three tools were asked independently and two said the copper was fine: KiCad reported 0 unconnected items, the exact-geometry audit reported every net as one continuous piece of copper, and a union-find over the raw polygons put every failing terminal in the same component as its net. The model was the outlier, so the model was wrong.
It was wrong in a specific and long-standing way. It placed footprint pads with a counter-clockwise rotation:
px = fx + lx·cos θ − ly·sin θ py = fy + lx·sin θ + ly·cos θ
KiCad places them clockwise in board coordinates, because board Y points down. On U9 — a SOT-563 sitting at 90° — that mirrored the pad row end-for-end and put pad 4 where pad 6 belongs, 1.4 mm away. The pad's copper was therefore attributed to empty board, the net's geometry came out as two disjoint polygons with a 0.53 mm gap, and a perfectly continuous net was reported as broken. Unrotated parts were unaffected, which is why it survived six revisions: it only bites when a rotated footprint's pad is a terminal, and revision 6 rotates far more parts.
Cross-checked against the exact-geometry parser over all 292 pads, the fix takes disagreements from 66 to 16 and the worst from 32.7 mm to 0.4 mm (the residual are multi-element through-hole pads where the two parsers pick a different representative point). A second, smaller error was fixed alongside it: the model added the footprint's rotation to each pad's stored shape angle, but that angle is already absolute — KiCad does not re-rotate it — so every pad shape was being turned twice.
This changed revision 5's published numbers too. Everything quoted for revision 5 below is re-measured on the same board file with the corrected model. The most striking correction is revision 5's true peak current density: 120.4 A/mm² on the 5 V POL switch node, not the 67.0 A/mm² previously published. The old model could not see it.
The rasterizer was also given area-weighted cells while this was being chased — a cell is now a node if any copper touches it, with its conductance scaled by how much copper is actually in it, instead of counting a conductor as either a full cell wide or absent. That alone did not fix the connectivity, but it cut worst-case mesh convergence error from 11.7% to 2.8% and is strictly more accurate for any trace narrower than the grid.
The defect that fix exposed
With the model corrected, revision 6's buck switch node measured 53.9 mΩ, 245.7 A/mm² and 1.35 W — far and away the worst figure on either board, and invisible while the case could not be solved at all. The new placement had put Q2's source and L2 far enough apart that the router chained them through thin B.Cu: 15 mm of 0.25 mm copper carrying 5 A.
The fix is the technique this board already uses for resistance-dominated paths — a direct parallel run between the case's own terminals — extended to all three switch nodes. Afterwards: 4.665 mΩ, 61.7 A/mm², 117 mW.
Also worth knowing
The board still measures 189 × 129 mm but the new placement uses roughly 60% of it — the optimiser was never asked to spread out or to shrink the outline, only to shorten paths and separate heat sources. A smaller board would cost less to fabricate and shorten the output rails that regressed. That is the obvious next iteration, and it is a placement question again, not a routing one.
Key design values
| Parameter | Value | Basis |
|---|---|---|
| Panel | 30 W, 6–24 V | design brief |
| Battery | 12 V SLA/AGM, 14.2 V absorption | design brief |
| Panel-side current ceiling | 5.0 A (30 W ÷ 6 V floor) | conservative sizing convention |
| Bulk charge current | 2.0 A | firmware BATT_CC_BULK_LIMIT_A |
| Absorption taper | 0.10 A held 10 min | firmware charge state machine |
| Float voltage | 13.6 V | firmware BATT_CV_FLOAT_V |
| FB network ceiling / floor | 14.76 V / 8.94 V | R13/R14/R15, see above |
| Panel current shunt | RS1, 0.01 Ω, 2512 | ×20 V/V INA180 gain |
| Charge current shunt | RS2, 0.02 Ω, 2512 | ×20 V/V INA180 gain |
| Trace width, routing target | 5 A → 1.2 mm, 2.5 A → 0.8 mm, 2 A → 0.6 mm, 1 A → 0.4 mm | narrow enough that the link routes |
| Trace width, final target | 5 A → 2.0 mm, 2.5 A → 1.6 mm, 2 A → 1.2 mm, 1 A → 0.8 mm | grown per segment where geometry allows |
| Via, signal / power | 0.45 mm pad on 0.20 mm drill / 0.6 mm on 0.3 mm | 0.125 mm annular ring, a standard process minimum |
| Clearance / via | 0.2 mm; 0.6 mm via on power nets, 0.4 mm on signals | board netclass |
Generated, not drawn
Every pad, track, via and silkscreen item is emitted by the Python in
kicad/generator-scripts/:
| Script | Role |
|---|---|
build.py |
The schematic — every placement and net, block by block, as real drawn wires rather than net-label-only connectivity |
schgen2.py |
Schematic writer, plus an intended-vs-actual connectivity verifier (ERC does not catch wire-routing shorts) |
symlib.py / fplib.py |
Read real .kicad_sym / .kicad_mod libraries and emit placed instances |
pcbgen.py |
Placement — net-driven clustering, courtyard and fanout keep-outs, fine-pitch clearance overrides |
route2.py |
The autorouter — exact-geometry fanout, multi-layer A*, rip-up and re-route, GND stitching, connectivity repair |
geomcheck.py |
The clearance oracle — exact geometry, no grid; used by the router and standalone against a finished board |
widen.py |
Per-segment current-density widening, iterated to a fixed point |
stitch.py |
Adds a direct parallel path, per measured case, between that case's own terminal pads |
silk.py |
Places reference designators clear of pads and of each other |
check_board.py |
One-command verdict: runs KiCad DRC and classifies every finding as electrical, mechanical or cosmetic |
audit.py |
The first-build audit — net parity, exact-geometry net continuity, fab capability, bypass-capacitor distance, exposed-pad stitching, pads over the board edge |
handroute.py |
Closes named links on a 0.025 mm raster — the resolution a human has in the interactive editor, applied programmatically |
pourstitch.py |
Stitches every region of KiCad's actual ground fill to the planes, and relieves the pinch points that stop a region filling at all |
Routing a board this dense is mostly a fanout problem. At 0.65 mm pin pitch the legal escape corridor down the middle of a pad is about 0.1 mm wider than the trace needs — real, but finer than a 0.1 mm grid can express — so escapes are computed with exact geometry and staggered into a comb, each trace reaching open board where its neighbour has already stopped. At 0.5 mm pitch a via does not fit between the pins at all, so those parts get an L-shaped fanout that carries each trace clear of the package before the router takes over.
Three KiCad behaviours worth knowing
All three were confirmed against KiCad 10 on real boards rather than assumed, and all
three will bite any tool that writes .kicad_pcb files directly:
- A pad's stored angle is absolute. A footprint's rotation moves its pads' positions
but does not orient their shapes — a
(size 3 0.6)pad inside a footprint placed at 90° still plots, and DRCs, as 3.000 × 0.600 mm. The footprint'sfp_lineandfp_rectgraphics do rotate. A generator that copies pad blocks verbatim from a library therefore mis-orients every non-square pad on every rotated footprint, and DRC will not complain, because the file and the plot agree with each other. --refill-zones --save-boardrewrites the net format. Zone refill is required before DRC or connectivity is undercounted, but the re-serialised file has every(net N "name")collapsed to a bare(net "name"), with no numeric id and no top-level net table. Any parser that requires the leading number silently matches nothing after the first save.- A zone's
min_thicknessdecides where the pour simply stops. A corridor narrower than it — 0.22 mm against a 0.25 mm minimum, here — is not filled thin, it is not filled at all, and the copper beyond it becomes a region connected to nothing. Modelling the pour yourself will not show this; the board file's ownfilled_polygonblocks are the only description of the fill that is actually true.
Revision history
Each revision is a full re-run of the same pipeline — place, route, widen, verify, simulate — against the previous revision's measurements.
| Focus | Result | |
|---|---|---|
| Rev 1 | First fully routed board | 60/60 nets connected, but a programmatic audit of its DRC report found 86 genuine different-net shorts and 150 clearance violations behind what had been read as cosmetic warnings |
| Rev 2 | Correctness over completeness | 0 shorts, 0 clearance violations, 0 mechanical findings; connectivity traded down to 13 unconnected items. Peak current density halved. Silkscreen and pad-orientation work brought cosmetic findings from 86 to 14 and fixed 21 physically mis-oriented pads on the power FETs and diodes |
| Rev 3 | Copper, measured | Same verification results, unconnected items down to 4, every resistance case improved — several by more than half. 2,785 segments widened, six parallel paths added |
| Rev 4 | First-build survival | An audit of what DRC does not check found four real defects and fixed them: bypass capacitors up to 62.5 mm from their pin, 96 vias below the fab's annular-ring minimum, exposed ground pads with no vias, and both POL inductors 14 mm from their switch node. Cost some of rev 3's copper margin to do it |
| Rev 5 | Electrically complete | The three links the autorouter never closed were a resolution problem, not a congestion one, so they were routed on a 0.025 mm raster instead — all three closed, nothing else moved. Reading KiCad's actual fill polygons then found the fourth unconnected item was a switching regulator's ground pin stranded in an unfillable sliver of pour. 0 unconnected items, 0 split nets, 0 audit problems, and every simulation case solves |
| Rev 6 | Placement, scored | The hand-written placement table was the real constraint, not the routing. Re-placed against a model of loss and temperature: total copper loss −36%, peak current density −49%, peak board temperature −6.2 °C, power chain −41%, switching-loop perimeter −58%. Low-current output rails regressed as a deliberate I²-weighted trade. Chasing five unsolved simulation cases found a six-revision-old bug in the model that mis-placed every rotated footprint's pads, which re-measured rev 5 as well and exposed a 245 A/mm² switch node in rev 6 that is now fixed |
| Rev 7 | Paying back the trade | Revision 6's I²-weighted objective had spent the low-current output rails to buy the charge path. Their chains — regulator to inductor to bead to connector — are now scored directly: 161.7/165.5 mm becomes 51.3/28.1 mm, the 3.3 V rail drops 26.2 → 2.7 mΩ and the 5 V output 13.0 → 2.0 mΩ. The frame shrank 17% at the same time. Total copper loss −17%, at the cost of +28% peak current density and +2.2 °C |
| Rev 8 (current) | The ladder with a missing middle | Revision 7's thin copper looked like a board-space problem and was not. stitch.py's width ladder fell from the requested width straight to 0.6 mm, so every parallel path on a congested board landed on the floor — 44.8 mm of 0.6 mm copper on a 5 A net. Filling in the ladder, widening again after stitching, raising the width targets and teaching the 0.025 mm hand-router to add a parallel path to an already-connected net: total copper loss −23%, worst case −36%, peak current density −40%, with no component moved |
The audit that opened rev 1 is why check_board.py exists: it runs DRC and classifies
every finding as electrical, mechanical or cosmetic with the different-net test made
explicitly, because reading a DRC report by eye is exactly how a genuine short gets
filed as a harmless warning.
Known limitations
Not built, not bench-tested. This is the final hardware revision, so these are the items the first build should be prepared for rather than a list of things pending another layout pass.
- Nothing is left unrouted. Revision 6 has 0 unconnected items, 0 different-net electrical defects and 0 mechanical findings; every net is one continuous piece of copper by exact geometry.
- All fourteen electrothermal cases solve. The five that previously did not were a bug in the model, not the board — see above. Any figure for this project published before revision 6 was computed with that bug present.
- Firmware compiles for the target and passes its control tests, but has never
been flashed.
make armbuilds every application source for Cortex-M0+ andmake testruns the control layer against a simulated plant, but producing a flashable image still needs CubeMX's startup code, linker script and HAL. Loop stability on real hardware remains a bench item. - Confirm the real panel's Voc and temperature coefficient. Input protection ratings currently assume a 30 V worst case pending the actual panel datasheet.
- R16/R17 (LM5175 VOSNS overvoltage threshold) needs independent re-derivation against the real datasheet value.
- The LM5175 FB reference (1.0 V) is a datasheet-typical assumption; the whole FB network's ceiling/floor math depends on it.
- Control-loop stability across MPPT/CC/CV transitions is a bench item.
- The three hand-routed links run at 0.15–0.25 mm, the board minimum. They carry small-signal currents, so that is electrically fine, but they are the narrowest copper on the board and worth a look under magnification on the first article.
- Peak current density is 47.2 A/mm², on the 3.3 V POL switch node. That is a 1 A net, so the absolute current is small, and it is a short run right at a 0.5 mm-pitch pad where the pad's own width sets the limit. Reducing it further is a package and placement question, not a copper-width one.
- 11 cosmetic findings remain: 7 silkscreen overlaps in the densest clusters and 4 footprint-library metadata mismatches. Nothing on silkscreen sits over a pad.
Design record
| Document | Contents |
|---|---|
01-architecture-analysis.md |
Block diagram as built, control architecture |
02-detailed-design-calculations.md |
Shunt and gain sizing, the FB summing-node derivation, MPPT-via-ICHG rationale, POL selection |
03-schematic-summary.md |
Block-by-block schematic walkthrough and component count |
04-pcb-layout-review.md |
Layer stack, placement and routing strategy, verified state |
simulation/05-electrothermal-simulation.md |
Full electrothermal report, method and caveats |
What's in this repo
docs/ design record + board images
docs/simulation/ electrothermal run: report, figures, CSVs, reproducer script
firmware/ STM32G031 application firmware + build README
kicad/ .kicad_sch / .kicad_pcb / .kicad_pro / .kicad_dru
kicad/generator-scripts/ the Python that emits the schematic, places and routes the board
hardware/ STEP + GLB 3D models, schematic PDF, BOM CSV
adom-wiki repo clone TylerK/mppt-power-system