app
AI Flow
Public Made by Adomby adom
Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.
main
Colby Knox
Merge PR #279: 0.2.13: Altium library refuses a symbol spec without pin types
bc2975b
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502
"""altium-lane: the Altium lane's pure helpers (no bridge, no run state), run in front of altium-project.py
and tested on their own (tests/altium-lane.py).
Symbols and pin types (the Altium half of `schematic check`, issue #39 items 1 and 2):
check_symbols(placed, specs, prims, spec) judges the drawn sheet as Altium reads it back
(altium_schematic_data: every placed part's pins with their electrical type and place), each used
symbol's SchLib record (altium_symbol_prims: pins, rectangles, display modes, body rectangle) and the
symbol spec the library step imported (altium-symbol-modes output, kept in <run>/altium/symbols/):
- pins on the body: a pin's x,y is its BODY end and must sit on the body rectangle's edge, pointing
away from it, in every display mode (the spec) and on the sheet (the drawn mode, read back);
- pin electrical types on active devices (U/IC references, op-amps and comparators), with the KiCad
check's rules: an amplifier input or an output declared passive is an error, a passive power pin
an error on op-amps and comparators and a warning on other ICs;
- polarity drawings of the small parts drawn as their own symbol (the spec's native mode): the diode
triangle and cathode bar at the pin named K, a BJT's emitter arrow on the pin named E.
Project-library parts (issue #66 on the Altium lane):
part_origin(), single_symbol_lib(), stage_project_part(): a part with no wiki component page that the
board's project library gives (`parts --project-lib`) is staged as the lane's pages/<key>/ and goes
through the same converters as a wiki part.
"""
import math, re, shutil
from pathlib import Path
MIL = 0.0254 # mm per mil
# Altium's TPinElectrical ordinals (altium_schematic_data reports the ordinal) and the KiCad names the
# rules use; Altium has one Power type (no power_in / power_out)
ALTIUM_ETYPE = {0: 'input', 1: 'bidirectional', 2: 'output', 3: 'open_collector', 4: 'passive', 5: 'tri_state', 6: 'open_emitter', 7: 'power'}
# Altium's pin Orientation ordinal (eRotate0..eRotate270): the direction the pin runs away from the body
ALTIUM_ORIENT = {0: 'right', 1: 'up', 2: 'left', 3: 'down'}
DIRS = {'right': (1, 0), 'up': (0, 1), 'left': (-1, 0), 'down': (0, -1)}
LIMITS = ("Altium: pins checked on the body rectangle (every display mode from the imported spec, the drawn mode read back "
"from the sheet); pin types read from the placed parts (Altium's Power has no in/out); polarity only on small parts "
"drawn as their own symbol (diodes with pins named K/A, BJTs with pins named E/C), arcs ignored; op-amp +/- are the "
"box symbol's shown pin names (no drawn signs to contradict them); sheet readability not checked (altium_build_schematic "
"draws one wire stub and one net label per pin)")
# ---------------------------------------------------------------- symbol specs
def parse_symbol_spec(text):
"""An altium-symbol-modes spec (META, MODE, RECT, PIN, LINE, ARC lines; mils, y up) as
{libref, desc, designator, modes: {i: {name, rects, lines, arcs, pins: [{number, name, x, y, orient, len, hidden}]}}}."""
sym = {'libref': '', 'desc': '', 'designator': 'U', 'modes': {}}
def mode(i):
return sym['modes'].setdefault(i, {'name': '', 'rects': [], 'lines': [], 'arcs': [], 'pins': []})
for raw in (text or '').splitlines():
f = raw.rstrip('\r').split('|')
try:
if f[0] == 'META': sym['libref'], sym['desc'], sym['designator'] = (f + ['', '', ''])[1:4]
elif f[0] == 'MODE': mode(int(f[1]))['name'] = f[2] if len(f) > 2 else ''
elif f[0] == 'RECT': mode(int(f[1]))['rects'].append(tuple(float(v) for v in f[2:6]))
elif f[0] == 'LINE': mode(int(f[1]))['lines'].append(tuple(float(v) for v in f[2:6]))
elif f[0] == 'ARC': mode(int(f[1]))['arcs'].append(tuple(float(v) for v in f[2:7]))
elif f[0] == 'PIN':
mode(int(f[1]))['pins'].append({'number': f[2], 'name': f[3], 'x': float(f[4]), 'y': float(f[5]), 'orient': f[6],
'len': float(f[7]), 'hidden': len(f) > 8 and f[8] == 'hide', 'etype': f[9] if len(f) > 9 else None})
except (ValueError, IndexError):
continue
return sym
def _box(r):
return min(r[0], r[2]), min(r[1], r[3]), max(r[0], r[2]), max(r[1], r[3])
def pin_edge_problems(pins, rect, tol=1.0):
"""The pins whose body end (x, y) is not on the rectangle's edge, or that point into the body:
[(pin, 'off', distance in mil) | (pin, 'inward', 0)]."""
x0, y0, x1, y1 = _box(rect)
out = []
for p in pins:
x, y = p['x'], p['y']
on_l, on_r, on_b, on_t = abs(x - x0) <= tol, abs(x - x1) <= tol, abs(y - y0) <= tol, abs(y - y1) <= tol
in_y, in_x = y0 - tol <= y <= y1 + tol, x0 - tol <= x <= x1 + tol
if not (((on_l or on_r) and in_y) or ((on_b or on_t) and in_x)):
dx = max(x0 - x, 0, x - x1); dy = max(y0 - y, 0, y - y1)
d = math.hypot(dx, dy) if (dx or dy) else min(x - x0, x1 - x, y - y0, y1 - y)
out.append((p, 'off', round(d)))
continue
ux, uy = DIRS.get(p.get('orient'), (0, 0))
away = (on_l and in_y and ux < 0) or (on_r and in_y and ux > 0) or (on_b and in_x and uy < 0) or (on_t and in_x and uy > 0)
if not away: out.append((p, 'inward', 0))
return out
def _edge_line(who, what, probs, total, native):
eg = []
for p, kind, d in probs[:3]:
nm = ' (%s)' % p['name'] if p.get('name') else ''
eg.append('pin %s%s %s' % (p['number'], nm, ('%d mil off the body edge' % d) if kind == 'off' else 'points into the body'))
more = '' if len(probs) <= 3 else ', ...'
why = ("a small part's pin should end on its drawing" if native else
"a pin's x,y is its body end, on the body rectangle's edge (pins one pin length off the box, names in the gap, was the symbol_modes bug fixed 2026-10-19)")
return '%s %s: %d of %d pin(s) not on the body edge: %s%s; %s' % (who, what, len(probs), total, '; '.join(eg), more, why)
# ---------------------------------------------------------------- names (the KiCad check's rules)
def norm(name):
n = (name or '').strip().upper()
for ch in '{}~ _': n = n.replace(ch, '')
return n.replace('−', '-').replace('–', '-')
def amp_input(name):
"""'+' or '-' for a pin named as an amplifier input, else None."""
n = norm(name)
if n in ('+', '-'): return n
if 'IN' in n and not n.startswith('V'):
if '+' in n: return '+'
if '-' in n: return '-'
if n.endswith('INP') or n == 'NONINV': return '+'
if n.endswith('INN'): return '-'
return None
def is_output(name):
s = norm(name).lstrip('0123456789')
return s in ('OUT', 'VOUT') or (s.startswith('OUT') and len(s) == 4)
def is_power(name):
n = norm(name)
return (n in ('V+', 'V-', '+V', '-V', 'VS', 'VS+', 'VS-', '+VS', '-VS', 'AVDD', 'DVDD', 'AVSS', 'DVSS', 'AVCC')
or any(n.startswith(p) for p in ('VCC', 'VDD', 'VEE', 'VSS')) or (n.endswith('GND') and len(n) <= 5))
def ref_prefix(r):
return re.match(r'[A-Za-z]*', r or '').group(0).upper()
def amp_like(info, pin_names):
i = (info or '').lower()
return any(k in i for k in ('opamp', 'op amp', 'op-amp', 'operational', 'comparator')) or (
any(amp_input(n) == '+' for n in pin_names) and any(amp_input(n) == '-' for n in pin_names))
def pin_type_findings(pins, amp):
"""[(severity 'error'|'warn', pin, message)] for one symbol's pins ({number, name, etype})."""
out, seen = [], set()
ok_out = ('output', 'tri_state', 'open_collector', 'open_emitter', 'power', 'power_out', 'bidirectional')
for p in pins:
k = (p['number'], p['name'])
if k in seen: continue
seen.add(k)
et, s = p['etype'], amp_input(p['name'])
if s:
if et == 'passive': out.append(('error', p, 'amplifier input `%s` (%s) is passive; declare it Input' % (p['name'], s)))
elif et != 'input': out.append(('warn', p, 'amplifier input `%s` is %s; amplifier inputs are Input' % (p['name'], et)))
elif is_output(p['name']):
if et == 'passive': out.append(('error', p, 'output `%s` is passive; declare it Output (Power for a regulator output)' % p['name']))
elif et not in ok_out: out.append(('warn', p, 'output `%s` is %s' % (p['name'], et)))
elif is_power(p['name']):
if et == 'passive': out.append(('error' if amp else 'warn', p, 'power pin `%s` is passive; declare it Power' % p['name']))
elif et not in ('power', 'power_in', 'power_out'): out.append(('warn', p, 'power pin `%s` is %s; power pins are Power' % (p['name'], et)))
return out
def passive_allowed(spec, ref, pin):
"""spec.schematic.allowPassive [{ref, pin?, why}]: True (allowed with a datasheet why), False (named
without a why), None (not named)."""
for e in ((spec or {}).get('schematic') or {}).get('allowPassive') or []:
if e.get('ref') == ref and (e.get('pin') is None or e.get('pin') in (pin['number'], pin['name'])):
return len((e.get('why') or '').strip()) >= 8
return None
def polarity_reviewed(spec, libref, refs):
for e in ((spec or {}).get('schematic') or {}).get('polarityReviewed') or []:
if (e.get('symbol') == libref or e.get('symbol') in refs) and len((e.get('why') or '').strip()) >= 8: return True
return False
# ---------------------------------------------------------------- polarity (the KiCad check, on LINE segments)
def _d(a, b): return math.hypot(a[0] - b[0], a[1] - b[1])
def _same(a, b): return _d(a, b) < 0.01
def _triangles(segs):
"""Closed triangles drawn as three LINE segments (a KiCad closed polyline becomes segments in the spec)."""
tris, used = [], set()
n = len(segs)
for i in range(n):
for j in range(i + 1, n):
for k in range(j + 1, n):
ends = [segs[i][0], segs[i][1], segs[j][0], segs[j][1], segs[k][0], segs[k][1]]
pts = []
for e in ends:
if not any(_same(e, q) for q in pts): pts.append(e)
if len(pts) == 3 and all(sum(1 for e in ends if _same(e, q)) == 2 for q in pts) and all(not _same(s[0], s[1]) for s in (segs[i], segs[j], segs[k])):
tris.append(pts); used |= {i, j, k}
return tris, used
def _vees(segs, used, max_len=1.5):
"""Two short segments sharing an end (an arrow head whose fill the converter dropped): their 3 points."""
out = []
for i in range(len(segs)):
for j in range(i + 1, len(segs)):
if i in used or j in used: continue
a, b = segs[i], segs[j]
if _d(*a) > max_len or _d(*b) > max_len: continue
for p in a:
for q in b:
if _same(p, q): out.append([a[0] if a[1] is p else a[1], p, b[0] if b[1] is q else b[1]])
return out
def _named(pins, names):
return [p for p in pins if norm(p['name']) in names]
def diode_finding(pins, tris, bars_open, bars_closed):
"""The KiCad check's diode rule: (severity, message) or None. pins carry 'at' (outer end) in mm."""
k, a = _named(pins, ('K', 'CATHODE', 'KATHODE')), _named(pins, ('A', 'ANODE'))
if len(k) != 1 or len(a) != 1: return None
k, a = k[0], a[0]
l = _d(a['at'], k['at'])
if l < 0.1: return None
u = ((k['at'][0] - a['at'][0]) / l, (k['at'][1] - a['at'][1]) / l)
t = lambda p: ((p[0] - a['at'][0]) * u[0] + (p[1] - a['at'][1]) * u[1]) / l
s = lambda p: -(p[0] - a['at'][0]) * u[1] + (p[1] - a['at'][1]) * u[0]
who = 'pin %s (%s) is the cathode, pin %s (%s) the anode' % (k['number'], k['name'], a['number'], a['name'])
tri = None
for pts in tris:
for i in range(3):
ap, pj, pk = pts[i], pts[(i + 1) % 3], pts[(i + 2) % 3]
width = abs(s(pj) - s(pk))
if width < 0.3 or abs(t(pj) - t(pk)) * l > 0.25 * width or abs(s(ap)) > 0.25 * width or abs((s(pj) + s(pk)) / 2) > 0.25 * width: continue
if tri is None or width > tri[2]: tri = ((t(pj) + t(pk)) / 2, t(ap), width)
bars = []
for pts, closed in [(b, False) for b in bars_open] + [(b, True) for b in bars_closed]:
ts, ss = [t(p) for p in pts], [s(p) for p in pts]
along = (max(ts) - min(ts)) * l
smin, smax = min(ss), max(ss)
across = smax - smin
if across < 0.5 or across < (3.0 if closed else 1.5) * along or smin > -0.2 or smax < 0.2: continue
n = len(pts)
i = max(range(n if closed else n - 1), key=lambda i: _d(pts[i], pts[(i + 1) % n]))
bars.append((t(((pts[i][0] + pts[(i + 1) % n][0]) / 2, (pts[i][1] + pts[(i + 1) % n][1]) / 2)), across))
if tri: bar = min(bars, key=lambda b: abs(b[0] - tri[1]))[0] if bars else None
else: bar = max(bars, key=lambda b: b[1])[0] if bars else None
if tri:
base, apex = tri[0], tri[1]
if apex < base:
if bar is None or abs(bar - apex) < abs(bar - base):
return ('error', 'the diode is drawn pointing at the anode: the triangle and cathode bar are at the pin %s end, but %s' % (a['number'], who))
return ('warn', 'the diode triangle points at pin %s (named %s) while the bar is at the other end; %s' % (a['number'], a['name'], who))
if bar is not None and bar < base - 0.02: return ('warn', 'the cathode bar is on the anode side of the triangle; %s' % who)
return None
if bar is None: return ('warn', 'no cathode bar or triangle recognised in the drawing; check by eye that the bar is at the cathode (%s)' % who)
if bar < 0.4: return ('error', 'the cathode bar is drawn at the pin %s end, but %s' % (a['number'], who))
if bar < 0.6: return ('warn', 'the cathode bar is near the middle, so the drawing does not show which end is the cathode; %s' % who)
return None
def bjt_finding(pins, arrows):
"""The KiCad check's BJT rule on arrow heads (point lists, mm): (severity, message) or None."""
e, c = _named(pins, ('E', 'EMITTER')), _named(pins, ('C', 'COLLECTOR'))
if len(e) != 1 or len(c) != 1: return None
e, c = e[0], c[0]
small = [pts for pts in arrows if math.hypot(max(p[0] for p in pts) - min(p[0] for p in pts), max(p[1] for p in pts) - min(p[1] for p in pts)) < 2.5]
if not small: return ('warn', 'a transistor with E and C pins but no emitter arrow recognised in the drawing')
cen = [(sum(p[0] for p in pts) / len(pts), sum(p[1] for p in pts) / len(pts)) for pts in small]
best = min(cen, key=lambda x: min(_d(x, e['inner']), _d(x, c['inner'])))
de, dc = _d(best, e['inner']), _d(best, c['inner'])
who = 'pin %s is named %s and pin %s %s' % (e['number'], e['name'], c['number'], c['name'])
if dc * 1.5 < de: return ('error', 'the emitter arrow is drawn on the collector lead; %s' % who)
if dc < de: return ('warn', 'the emitter arrow is closer to the collector than to the emitter; %s' % who)
return None
def polarity_findings(mode):
"""Polarity of one spec mode (a small part drawn as its symbol): [(severity, message)]."""
mm = lambda v: v * MIL
pins = []
for p in mode['pins']:
ux, uy = DIRS.get(p['orient'], (0, 0))
inner = (mm(p['x']), mm(p['y']))
pins.append(dict(p, inner=inner, at=(inner[0] + ux * mm(p['len']), inner[1] + uy * mm(p['len']))))
segs = [((mm(l[0]), mm(l[1])), (mm(l[2]), mm(l[3]))) for l in mode['lines']]
tris, used = _triangles(segs)
bars_open = [[s[0], s[1]] for i, s in enumerate(segs) if i not in used]
bars_closed = []
for r in mode['rects']:
x0, y0, x1, y1 = (mm(v) for v in _box(r))
bars_closed.append([(x0, y0), (x1, y0), (x1, y1), (x0, y1)])
out = [f for f in (diode_finding(pins, tris, bars_open, bars_closed), bjt_finding(pins, tris + _vees(segs, used))) if f]
return out
# ---------------------------------------------------------------- the check
def _rel(pin, comp):
"""A placed pin's place in its symbol's frame (mils): the sheet position less the part's, rotated back."""
dx, dy = pin.get('xMil', 0) - comp.get('xMil', 0), pin.get('yMil', 0) - comp.get('yMil', 0)
q = int(comp.get('orientation') or 0) % 4
for _ in range(q): dx, dy = dy, -dx # undo one 90 degree counterclockwise turn
orient = DIRS_ROT(ALTIUM_ORIENT.get(int(pin.get('orientation') or 0) % 4, 'right'), -q)
return {'number': str(pin.get('number', '')), 'name': pin.get('name') or '', 'x': dx, 'y': dy, 'orient': orient}
def DIRS_ROT(orient, quarter_turns):
order = ['right', 'up', 'left', 'down']
return order[(order.index(orient) + quarter_turns) % 4] if orient in order else orient
def check_symbols(placed, specs, prims, spec=None):
"""The Altium schematic's symbols and pin types (see the module doc). placed: altium_schematic_data
components; specs: {libref: spec text or parse_symbol_spec()}; prims: {libref: altium_symbol_prims data};
spec: the run's spec (schematic.allowPassive, schematic.polarityReviewed). Returns the report the
`altium schematic` proof carries: {ok, errors, warnings, symbols, librarySymbols, checked, limits}."""
errors, warnings = [], []
by_lib = {}
for c in placed or []:
d = by_lib.setdefault(c.get('libReference') or '', {'refs': [], 'comps': []})
d['refs'].append(c.get('designator') or '?'); d['comps'].append(c)
spec_up = {k.upper(): v for k, v in (specs or {}).items()}
prim_up = {k.upper(): v for k, v in (prims or {}).items()}
checked = {'pinTypes': 0, 'bodyEdgeModes': 0, 'drawnSymbols': 0, 'polarity': 0, 'noSpec': []}
for lr, d in sorted(by_lib.items()):
refs, comp = d['refs'], d['comps'][0]
who = '%s (%s)' % (','.join(refs), lr)
sp = spec_up.get(lr.upper())
sp = parse_symbol_spec(sp) if isinstance(sp, str) else sp
pr = prim_up.get(lr.upper())
live = [_rel(p, comp) for p in comp.get('pins') or []]
# 1. the SchLib symbol is the one its imported spec describes (else the spec says nothing about it)
if sp and pr:
n_pins = sum(len(m['pins']) for m in sp['modes'].values()); n_rect = sum(len(m['rects']) for m in sp['modes'].values())
diffs = []
if pr.get('displayModeCount') is not None and int(pr['displayModeCount']) != len(sp['modes']): diffs.append('%s display modes, the spec %d' % (pr['displayModeCount'], len(sp['modes'])))
if pr.get('pins') is not None and int(pr['pins']) != n_pins: diffs.append('%s pins, the spec %d' % (pr['pins'], n_pins))
if pr.get('rectangles') is not None and int(pr['rectangles']) != n_rect: diffs.append('%s rectangles, the spec %d' % (pr['rectangles'], n_rect))
if diffs:
warnings.append('library: %s: the SchLib symbol is not the one its kept spec describes (%s); only the drawn mode was checked. `altium library --only <MPN>` imports it again' % (who, '; '.join(diffs)))
sp = None
if not sp:
checked['noSpec'].append(lr)
elif any(p['etype'] is None for m in sp['modes'].values() for p in m['pins']):
# an altium-symbol-modes older than altium-bridge 0.2.53 wrote no pin types: every pin went in Passive
warnings.append('library: %s: its kept spec carries no pin types (altium-symbol-modes older than altium-bridge 0.2.53), so its pins are Passive in Altium; `adom-wiki pkg install adom/altium-bridge`, then `altium library --only <MPN>` imports it again with them' % who)
# 2. pins on the body edge, every display mode of the spec
if sp:
for i, m in sorted(sp['modes'].items()):
if len(m['rects']) != 1 or not m['pins']: continue
checked['bodyEdgeModes'] += 1
native = m['name'] == 'native'
probs = pin_edge_problems(m['pins'], m['rects'][0])
if probs: (warnings if native else errors).append('pins on the body: ' + _edge_line(who, 'mode %d (%s)' % (i, m['name'] or '?'), probs, len(m['pins']), native))
# ... and the drawn mode, read back from the sheet
body = None
if sp and sp['modes'].get(0) and len(sp['modes'][0]['rects']) == 1: body = sp['modes'][0]['rects'][0]
elif not sp and pr and int(pr.get('rectangles') or 0) >= 1 and pr.get('bodyRectMil'): body = tuple(pr['bodyRectMil'])
if body and live:
checked['drawnSymbols'] += 1
native = (sp['modes'][0]['name'] == 'native') if sp else len(live) <= 3
probs = pin_edge_problems(live, body)
if probs: (warnings if native else errors).append('pins on the body: ' + _edge_line(who, 'as drawn on the sheet', probs, len(live), native))
if sp and sp['modes'].get(0) and live:
want = {p['number']: p for p in sp['modes'][0]['pins']}
moved = [p for p in live if p['number'] in want and (abs(p['x'] - want[p['number']]['x']) > 1 or abs(p['y'] - want[p['number']]['y']) > 1)]
if moved: warnings.append('library: %s: %d drawn pin(s) are not where the spec puts mode 0 (e.g. pin %s at %d,%d mil, the spec %d,%d)' % (
who, len(moved), moved[0]['number'], moved[0]['x'], moved[0]['y'], want[moved[0]['number']]['x'], want[moved[0]['number']]['y']))
# 3. polarity of a small part drawn as its own symbol
if sp and sp['modes'].get(0) and sp['modes'][0]['name'] == 'native':
checked['polarity'] += 1
reviewed = polarity_reviewed(spec, lr, refs)
for sev, msg in polarity_findings(sp['modes'][0]):
line = '%s: %s' % (who, msg)
if sev == 'error' and not reviewed: errors.append('polarity drawing: ' + line)
else: warnings.append('polarity drawing: ' + line + (' [reviewed in spec.schematic.polarityReviewed]' if sev == 'error' else ''))
# 4. pin electrical types on active devices, as Altium holds them on the placed parts
pins = [{'number': str(p.get('number', '')), 'name': p.get('name') or '', 'etype': ALTIUM_ETYPE.get(p.get('electrical'), str(p.get('electrical')))} for p in comp.get('pins') or []]
info = ' '.join([lr, comp.get('comment') or '', (sp or {}).get('desc') or ''])
amp = amp_like(info, [p['name'] for p in pins])
active = [r for r in refs if ref_prefix(r) in ('U', 'IC')] if not amp else list(refs)
if active:
checked['pinTypes'] += 1
for sev, pin, msg in pin_type_findings(pins, amp):
if sev == 'warn':
warnings.append('pin type: %s pin %s (%s): %s' % (','.join(active), pin['number'], lr, msg)); continue
blocked = []
for r in active:
a = passive_allowed(spec, r, pin)
if a is True: warnings.append('pin type: %s pin %s (%s): %s [allowed by spec.schematic.allowPassive]' % (r, pin['number'], lr, msg))
elif a is False: warnings.append('pin type: spec.schematic.allowPassive names %s pin %s without a datasheet `why`; it does not count' % (r, pin['number'])); blocked.append(r)
else: blocked.append(r)
if blocked: errors.append('pin type: %s pin %s (%s): %s' % (','.join(blocked), pin['number'], lr, msg))
if checked['noSpec']:
warnings.append('no kept symbol spec for %s (imported before AI Flow 0.2.10, or the SchLib differs): only the drawn mode was checked, not the other display modes or the polarity drawing; `altium library --only <MPN>` imports it again and keeps its spec' % ', '.join(checked['noSpec']))
return {'ok': not errors, 'errors': errors, 'warnings': warnings, 'symbols': len(placed or []), 'librarySymbols': len(by_lib),
'checked': checked, 'limits': LIMITS}
# ---------------------------------------------------------------- project-library parts
def mpn_key(m):
return (m or '').strip().upper()
def parts_rows(run_data):
"""The `parts` step's rows (each MPN's wiki page or project-library files) by MPN."""
rows = (((run_data or {}).get('outcomes') or {}).get('parts') or {}).get('parts') or []
return {mpn_key(r.get('mpn')): r for r in rows if isinstance(r, dict) and r.get('mpn')}
def part_origin(comp, row):
"""Where one netlist part's KiCad files come from: ('wiki', slug) or ('project-lib', projectLib).
A page named in the netlist wins, then the page `parts` matched, then the board's project library
(`parts --project-lib`, already checked complete there), then the MPN as the page slug."""
if comp.get('page'): return 'wiki', comp['page']
if (row or {}).get('wiki'): return 'wiki', row['wiki']
if isinstance((row or {}).get('projectLib'), dict): return 'project-lib', row['projectLib']
return 'wiki', (comp.get('mpn') or '').lower()
def _top_blocks(text):
"""The top-level child blocks of an s-expression file (strings with parentheses respected)."""
out, depth, start, in_str, i, n = [], 0, None, False, 0, len(text)
while i < n:
ch = text[i]
if in_str:
if ch == '\\': i += 2; continue
if ch == '"': in_str = False
elif ch == '"': in_str = True
elif ch == '(':
depth += 1
if depth == 2: start = i
elif ch == ')':
if depth == 2 and start is not None: out.append(text[start:i + 1]); start = None
depth -= 1
i += 1
return out
def _sym_name(block):
m = re.match(r'\(symbol\s+"((?:[^"\\]|\\.)*)"', block)
return m.group(1) if m else None
def single_symbol_lib(text, name):
"""A .kicad_sym with just the symbol `name` (and the symbol it extends): the converter reads every pin
in its file, and a project library holds many symbols. None when the library has no such symbol."""
syms = {}
for b in _top_blocks(text):
n = _sym_name(b)
if n is not None: syms[n] = b
key = name.split(':')[-1]
hit = syms.get(key) or next((b for n, b in syms.items() if n.upper() == key.upper()), None)
if hit is None: return None
want = [hit]
ext = re.search(r'\(extends\s+"((?:[^"\\]|\\.)*)"', hit)
if ext and ext.group(1) in syms: want.insert(0, syms[ext.group(1)])
v = re.search(r'\(version\s+(\d+)\)', text)
return '(kicad_symbol_lib (version %s) (generator "adom-aiflow")\n %s\n)\n' % (v.group(1) if v else '20241209', '\n '.join(want))
def is_step(path):
try:
return Path(path).read_bytes()[:64].decode('latin-1').lstrip().startswith('ISO-10303-21')
except OSError:
return False
def stage_project_part(key, pl, dest):
"""One project-library part's KiCad files staged where a wiki part's page is cloned (the lane's
pages/<key>/: the board render takes its courtyard from the footprint there): its one symbol, its
footprint and its STEP model. Returns ({sym, outline, fp, step}, []) or (None, [what is wrong])."""
sf, ff, mf = Path(pl.get('symbolFile') or ''), Path(pl.get('footprintFile') or ''), Path(pl.get('model') or '')
problems = ['%s %s is missing' % (what, f) for what, f in (('symbol file', sf), ('footprint', ff), ('STEP model', mf)) if not f.is_file()]
if problems: return None, problems
name = pl.get('symbol') or key
text = single_symbol_lib(sf.read_text(errors='ignore'), name)
if text is None: problems.append('no symbol %s in %s' % (name, sf))
if not is_step(mf): problems.append('%s is not a STEP file (no ISO-10303-21 header)' % mf)
if problems: return None, problems
dest = Path(dest)
shutil.rmtree(dest, ignore_errors=True); dest.mkdir(parents=True)
sym = dest / (re.sub(r'[^A-Za-z0-9_.-]', '_', name.split(':')[-1]) + '.kicad_sym')
sym.write_text(text)
fp = dest / ff.name; shutil.copy(ff, fp)
step = dest / mf.name; shutil.copy(mf, step)
return {'sym': sym, 'outline': None, 'fp': fp, 'step': step}, []
def project_part_facts(pl, row):
"""What a project-library part says about itself for the Altium parameters: description, manufacturer,
package and any distributor numbers named in its provenance.json entry."""
prov = pl.get('provenance') or {}
links = dict(prov.get('distributor_links') or {})
for k in ('mouser', 'digikey', 'lcsc'):
if prov.get(k) and not links.get(k): links[k] = prov[k]
desc = prov.get('description') or (row or {}).get('value') or pl.get('footprint') or ''
return {'desc': str(desc)[:120].replace('\n', ' '), 'mfr': prov.get('manufacturer') or (row or {}).get('manufacturer') or '',
'package': prov.get('package') or pl.get('footprint') or '', 'links': links, 'source': pl.get('source') or prov.get('source') or ''}