board
RITHESH LED Display
Public Unreviewedby Rithesh03
A 300 x 70 mm PCB that spells RITHESH with 107 WS2812B RGB LEDs, controlled over Wi-Fi.
main
Rithesh03
Update 3: PCB design completed - routed board, manufacturing package (prototype, do not order yet), WLED guide, final checklist, Hydrogen feedback
1ed072d
13d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
"""Voltage drop on the 5 V supply and ground copper, solved on the real filled copper.
The filled +5V copper (front) and GND copper (back plane + front fill + tracks) are rasterised on a
0.4 mm grid. Each cell is a square of copper: sheet resistance of 1 oz (35 um) copper is 0.49 mOhm per
square at 25 C (0.57 at 65 C). Every LED draws the same current from its VDD pad and returns it through
its GND pad; the whole current comes in at the eFuse output (+5V) and returns to the USB-C ground.
The resulting sparse network is solved exactly (Kirchhoff), so narrow necks and gaps are included.
Usage: python3 power_analysis.py [total LED current in A ...] (default: 1.88 and 2.9)
"""
import math
import os
import sys
import numpy as np
import pcbnew
from scipy.sparse import coo_matrix
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import make_board as mb # noqa: E402
TO = pcbnew.ToMM
G = 0.4 # grid, mm
RS = 0.49e-3 # ohm per square, 1 oz copper, 25 C
RS_HOT = 0.57e-3 # at 65 C
def xy(v):
return (TO(v.x) - mb.X0, TO(v.y) - mb.Y0)
def raster(board, netname, layers):
"""Boolean grid of cells covered by copper of `netname` on the given layers (zones + tracks + pads)."""
nx, ny = int(mb.W / G) + 1, int(mb.H / G) + 1
grid = np.zeros((len(layers), ny, nx), bool)
xs = (np.arange(nx) + 0.5) * G
ys = (np.arange(ny) + 0.5) * G
for li, layer in enumerate(layers):
polys = []
for z in board.Zones():
if z.GetIsRuleArea() or z.GetNetname() != netname or not z.IsOnLayer(layer):
continue
fp = z.GetFilledPolysList(layer)
for i in range(fp.OutlineCount()):
o = fp.Outline(i)
outer = [xy(o.CPoint(k)) for k in range(o.PointCount())]
holes = []
for h in range(fp.HoleCount(i)):
hh = fp.Hole(i, h)
holes.append([xy(hh.CPoint(k)) for k in range(hh.PointCount())])
polys.append((outer, holes))
import shapely
from shapely.geometry import Polygon
X, Y = np.meshgrid(xs, ys)
m = np.zeros(X.shape, bool)
for outer, holes in polys:
m |= shapely.contains_xy(Polygon(outer, holes), X, Y)
grid[li] = m
for t in board.GetTracks():
if t.GetNetname() != netname or isinstance(t, pcbnew.PCB_VIA) or t.GetLayer() != layer:
continue
(x0, y0), (x1, y1) = xy(t.GetStart()), xy(t.GetEnd())
w = TO(t.GetWidth())
L = max(math.hypot(x1 - x0, y1 - y0), 1e-6)
for s in np.linspace(0, 1, int(L / (G / 2)) + 2):
cx, cy = x0 + (x1 - x0) * s, y0 + (y1 - y0) * s
r = w / 2
i0, i1 = int((cy - r) / G), int((cy + r) / G)
j0, j1 = int((cx - r) / G), int((cx + r) / G)
grid[li, max(i0, 0):i1 + 1, max(j0, 0):j1 + 1] = True
return grid
def vias_of(board, netname):
return [xy(t.GetPosition()) for t in board.GetTracks() if isinstance(t, pcbnew.PCB_VIA) and t.GetNetname() == netname]
def solve(grid, vias, sources, sinks, rs):
"""grid: (L, ny, nx) bool. vias join layers (0.8 mOhm each). sources: [(layer, x, y)] held at 0 V;
sinks: [(layer, x, y, amps)]. Returns potential map (V, negative = drop) and per-sink voltage."""
L, ny, nx = grid.shape
idx = -np.ones(grid.shape, int)
idx[grid] = np.arange(grid.sum())
n = int(grid.sum())
rows, cols, vals = [], [], []
g = 1.0 / rs
for li in range(L):
a = idx[li]
for (da, db) in ((0, 1), (1, 0)):
p = a[:ny - da, :nx - db]
q = a[da:, db:]
ok = (p >= 0) & (q >= 0)
pp, qq = p[ok], q[ok]
rows += [pp, qq, pp, qq]
cols += [pp, qq, qq, pp]
vals += [np.full(pp.size, g)] * 2 + [np.full(pp.size, -g)] * 2
def cell(li, x, y):
i, j = int(y / G), int(x / G)
best = None
for r in range(0, 8):
for di in range(-r, r + 1):
for dj in range(-r, r + 1):
ii, jj = i + di, j + dj
if 0 <= ii < ny and 0 <= jj < nx and idx[li, ii, jj] >= 0:
return idx[li, ii, jj]
return best
if L > 1:
gv = 1.0 / 0.8e-3
for (x, y) in vias:
a, b = cell(0, x, y), cell(1, x, y)
if a is None or b is None:
continue
rows += [np.array([a, b, a, b])]
cols += [np.array([a, b, b, a])]
vals += [np.array([gv, gv, -gv, -gv])]
A = coo_matrix((np.concatenate(vals), (np.concatenate(rows), np.concatenate(cols))), shape=(n, n)).tocsr()
rhs = np.zeros(n)
sink_cells = []
for (li, x, y, amps) in sinks:
c = cell(li, x, y)
sink_cells.append(c)
if c is not None:
rhs[c] -= amps
fixed = [cell(li, x, y) for (li, x, y) in sources]
fixed = [f for f in fixed if f is not None]
# the source cells are tied to 0 V through a very small resistance (1 micro-ohm)
from scipy.sparse import diags
from scipy.sparse.linalg import cg
d = np.full(n, 1e-6) # tiny leak so isolated specks of copper cannot make the system singular
for f in fixed:
d[f] += 1e6
A = (A + diags(d)).tocsr()
M = diags(1.0 / A.diagonal())
v, info = cg(A, rhs, tol=1e-10, maxiter=40000, M=M)
if info != 0:
print("warning: solver did not fully converge", info)
return v, [v[c] if c is not None else float("nan") for c in sink_cells], idx
# Power-entry copper, section by section (widths / lengths as routed in route_board.py).
# (name, width mm, length mm, share of the input current: (low, high))
RIGHT, LEFT = 1.55 / 0.5, 0.75 / 0.3 + 9.1 / 0.6 # squares: right-pad stub vs left-pad path
SECTIONS = [
("VBUS right pad stub (A9/B4)", 0.5, 1.55, None),
("VBUS left pad stub (A4/B9)", 0.3, 0.75, "left"),
("VBUS left pad, back-layer link", 0.6, 9.1, "left"),
("VBUS main, 0.8 mm part", 0.8, 1.1, 1.0),
("VBUS main, 1.2 mm to TVS", 1.2, 9.4, 1.0),
("TVS -> input capacitor", 0.8, 1.6, 1.0),
("input capacitor -> eFuse IN", 0.6, 1.45, 1.0),
("eFuse OUT pin neck", 0.25, 1.2, 1.0),
("5 V out -> +5V fill", 1.2, 3.05, 1.0),
]
def section_table(currents=(2.2, 2.9), contact_mohm=(0.0, 20.0)):
"""Per-section drop, dissipation and IPC-2221 temperature rise (outer 1 oz, long-track rule: short
sections run cooler because heat spreads into pads and wide copper)."""
def rise(i, w):
a = w / 0.0254 * 1.378 # cross-section in mil^2
return (i / (0.048 * a ** 0.725)) ** (1 / 0.44)
# how the input splits between the two VBUS pads: without and with ~20 mOhm contact resistance per pad
splits = []
for rc in contact_mohm:
rr, rl = RIGHT * 0.49 + rc, LEFT * 0.49 + 1.6 + rc # mOhm (two vias on the left path, 0.8 each)
splits.append(rl / (rr + rl))
lo, hi = min(splits), max(splits)
print(f"VBUS current share: right pad {lo * 100:.0f}-{hi * 100:.0f} %, left pad {100 - hi * 100:.0f}-{100 - lo * 100:.0f} %")
for I in currents:
print(f"-- input current {I:.1f} A --")
for name, w, L, share in SECTIONS:
if share is None:
i = I * hi
elif share == "left":
i = I * (1 - lo)
else:
i = I * share
r = RS * L / w
p = i * i * r
# short neck held at both ends by pads / wide copper: peak rise = P * L / (8 k A), copper k = 390
neck = p * (L * 1e-3) / (8 * 390 * (w * 1e-3) * 35e-6)
est = f"short-neck est. {neck:4.1f} C" if L < 5 else "long track: use IPC"
print(f" {name:32s} {w:.2f} x {L:4.2f} mm I {i:.2f} A drop {i * r * 1000:5.1f} mV "
f"heat {p * 1000:5.1f} mW IPC rise {rise(i, w):5.1f} C {est}")
print(f" (eFuse switch 34 mOhm typ: drop {I * 34:.0f} mV, heat {I * I * 34:.0f} mW; "
f"USB-C VBUS contacts rated 5 A for the connector = 2.5 A per pad pair)")
def main():
if len(sys.argv) > 1 and sys.argv[1] == "sections":
return section_table()
currents = [float(a) for a in sys.argv[1:]] or [1.88, 2.9]
board = pcbnew.LoadBoard(mb.PCB)
fps = {f.GetReference(): f for f in board.GetFootprints()}
leds = [f"LED{n}" for n in range(1, 108)]
def pad(ref, num):
return xy([p for p in fps[ref].Pads() if p.GetNumber() == num][0].GetPosition())
v5 = raster(board, "+5V", [pcbnew.F_Cu])
gnd = raster(board, "GND", [pcbnew.F_Cu, pcbnew.B_Cu])
gvias = vias_of(board, "GND")
src5 = [(0,) + pad("U2", "5")]
srcg = [(0,) + pad("J1", "A1"), (0,) + pad("J1", "A12"), (1,) + pad("J1", "A1"), (1,) + pad("J1", "A12")]
print(f"+5V copper: {v5.sum() * G * G:.0f} mm^2 on the front; GND copper: front {gnd[0].sum() * G * G:.0f} "
f"mm^2, back {gnd[1].sum() * G * G:.0f} mm^2, {len(gvias)} ground vias")
results = {}
for I in currents:
per = I / len(leds)
_, vv, _ = solve(v5, [], src5, [(0,) + pad(r, "1") + (per,) for r in leds], RS)
_, vg, _ = solve(gnd, gvias, srcg, [(0,) + pad(r, "3") + (per,) for r in leds], RS)
worst = max(range(len(leds)), key=lambda k: (-vv[k]) + (-vg[k]))
tot = [(-vv[k]) + (-vg[k]) for k in range(len(leds))]
results[I] = (max(-x for x in vv), max(-x for x in vg), max(tot), leds[worst])
print(f"total LED current {I:.2f} A ({per * 1000:.1f} mA per LED): worst +5V drop {max(-x for x in vv) * 1000:.0f} mV, "
f"worst GND rise {max(-x for x in vg) * 1000:.0f} mV, worst total {max(tot) * 1000:.0f} mV at {leds[worst]} "
f"(at 65 C copper: {max(tot) * 1000 * RS_HOT / RS:.0f} mV); average {1000 * sum(tot) / len(tot):.0f} mV")
return results
if __name__ == "__main__":
main()