pixalynx/nrf9151-gps-tracker
A compact 44×32 mm, 4-layer nRF9151 LTE-M/NB-IoT/GNSS tracker PCB with nPM1300 LiPo/USB-C power, nano-SIM/eSIM switching, nRF7002 Wi‑Fi scanning, accelerometer, antennas, buzzer, LEDs, SOS button, and SWD/debug connectors.
- Version
- 0.1.4
- License
- unset
- Stars
- 0
scripts/solve-sch-boxes.py
#!/usr/bin/env python3
"""Size every chip's schematic box so `tsci check schematic-placement` has nothing to say about it.
Re-implements the three box rules of @tscircuit/circuit-json-schematic-placement-analysis on the built circuit JSON:
- SchematicPinPaddingToEdgeTooLarge: on a side with >= 2 pins the outer pins sit at most one pin pitch from the
box corners (label-aware allowance when the chip has pins on all four sides);
- SchematicBoxInnerLabelCollision: pin-name labels (0.095 per char, 0.15 high, 0.1 in from the edge; top/bottom
labels run vertically) of different sides must not overlap (0.02 padding);
- GenericSchematicBoxTooWide: at most 1.0 of empty space between the left and right label columns (or beside a
single column of >= 4 labels).
Pins are centred along each side at the chip's pin pitch whatever the box size, so only W and H are free. The
smallest box that passes is printed per chip; `--json` writes {name: [W, H]} for the part files.
Usage: python3 scripts/solve-sch-boxes.py [dist/index/circuit.json] [--json out]
"""
import json, sys
PAD_EDGE, TEXT_H, CH_NAME, CH_OTHER, COLL_PAD, EPS = 0.1, 0.15, 0.095, 0.13, 0.02, 1e-9
_out = sys.argv[sys.argv.index('--json') + 1] if '--json' in sys.argv else None
path = next((a for a in sys.argv[1:] if a.endswith('.json') and a != _out), 'dist/index/circuit.json')
d = json.load(open(path))
src = {c['source_component_id']: c for c in d if c['type'] == 'source_component'}
sp = {p['source_port_id']: p for p in d if p['type'] == 'source_port'}
ports = {}
for p in d:
if p['type'] == 'schematic_port' and p.get('schematic_component_id') and p.get('side_of_component') in ('left', 'right', 'top', 'bottom'):
ports.setdefault(p['schematic_component_id'], []).append(p)
def label_len(p):
lab = p.get('display_pin_label')
if not lab: return None
s = sp.get(p.get('source_port_id'), {})
name_like = lab == s.get('name') or lab == str(s.get('pin_number')) or lab in (s.get('port_hints') or [])
return len(lab) * (CH_NAME if name_like else CH_OTHER)
def check(W, H, pins, spacing):
"""pins: [(side, along, labelLen|None)] -> list of failed rule names"""
fails, by = [], {}
for side, a, L in pins: by.setdefault(side, []).append((a, L))
maxlab = {s: max([L for _, L in by.get(s, []) if L] or [0]) for s in ('left', 'right', 'top', 'bottom')}
all4 = all(s in by for s in ('left', 'right', 'top', 'bottom'))
for side, lst in by.items():
ext = W if side in ('top', 'bottom') else H
amax, amin = max(a for a, _ in lst), min(a for a, _ in lst)
if amax > ext / 2 - 0.05 or amin < -ext / 2 + 0.05: fails.append('fit')
if len(lst) < 2: continue
perp = ('left', 'right') if side in ('top', 'bottom') else ('top', 'bottom')
allow = (maxlab[perp[0]] + maxlab[perp[1]] + spacing) / 2 if all4 else spacing
if (ext / 2 - amax) - allow > EPS or (amin + ext / 2) - allow > EPS: fails.append('padding')
rects = []
for side, a, L in pins:
if not L: continue
if side == 'left': rects.append((side, -W / 2 + PAD_EDGE, -W / 2 + PAD_EDGE + L, a - TEXT_H / 2, a + TEXT_H / 2))
elif side == 'right': rects.append((side, W / 2 - PAD_EDGE - L, W / 2 - PAD_EDGE, a - TEXT_H / 2, a + TEXT_H / 2))
elif side == 'top': rects.append((side, a - TEXT_H / 2, a + TEXT_H / 2, H / 2 - PAD_EDGE - L, H / 2 - PAD_EDGE))
else: rects.append((side, a - TEXT_H / 2, a + TEXT_H / 2, -H / 2 + PAD_EDGE, -H / 2 + PAD_EDGE + L))
for i in range(len(rects)):
for j in range(i + 1, len(rects)):
a, b = rects[i], rects[j]
if a[0] == b[0]: continue
if min(a[2], b[2]) - max(a[1], b[1]) + COLL_PAD > EPS and min(a[4], b[4]) - max(a[3], b[3]) + COLL_PAD > EPS:
fails.append('labels'); break
lcol = [L for s, a, L in pins if s == 'left' and L]; rcol = [L for s, a, L in pins if s == 'right' and L]
gap = None
if lcol and rcol: gap = (W / 2 - PAD_EDGE - max(rcol)) - (-W / 2 + PAD_EDGE + max(lcol))
elif len(lcol) >= 4: gap = W / 2 - (-W / 2 + PAD_EDGE + max(lcol))
elif len(rcol) >= 4: gap = (W / 2 - PAD_EDGE - max(rcol)) + W / 2
if gap is not None and gap - 1 > EPS: fails.append('too-wide')
# not a tsci rule, just legibility: every pin label fits inside the box
for side, a, L in pins:
if L and PAD_EDGE + L > (W if side in ('left', 'right') else H) - 0.05: fails.append('label-fit'); break
return sorted(set(fails))
out, infeasible = {}, []
for c in d:
if c['type'] != 'schematic_component' or c['schematic_component_id'] not in ports: continue
s = src[c['source_component_id']]
if s.get('ftype') not in (None, 'simple_chip', 'simple_connector', 'simple_push_button', 'simple_crystal'): continue
cx, cy = c['center']['x'], c['center']['y']
pins = []
for p in ports[c['schematic_component_id']]:
side = p['side_of_component']
a = (p['center']['y'] - cy) if side in ('left', 'right') else (p['center']['x'] - cx)
pins.append((side, round(a, 4), label_len(p)))
spacing = c.get('pin_spacing') or 0.2
W0, H0 = c['size']['width'], c['size']['height']
now = check(W0, H0, pins, spacing)
best = None
for wi in range(4, 241):
W = wi * 0.05
for hi in range(4, 301):
H = hi * 0.05
if not check(W, H, pins, spacing):
if best is None or W * H < best[0] - 1e-9: best = (W * H, round(W, 2), round(H, 2))
break
name = s['name']
if best: out[name] = [best[1], best[2]]
else: infeasible.append(name)
print(f"{name:5} now {W0:5.2f} x {H0:5.2f} {','.join(now) or 'ok':22} -> {('%.2f x %.2f' % (best[1], best[2])) if best else 'NO SIZE WORKS (change pin sides)'}")
if '--json' in sys.argv: json.dump(out, open(sys.argv[sys.argv.index('--json') + 1], 'w'), indent=1)
if infeasible: print('infeasible:', infeasible)