"""Apply the reviewed C737 solid-tantalum substitution, preserving pin 2 as positive.""" import argparse import hashlib import json from pathlib import Path import re import shutil import subprocess import xml.etree.ElementTree as ET import pcbnew as pcb from check_hardware import Board from schematic_maintenance import ROOT, child, children, parse, properties OUT = ROOT / 'out/c737-tantalum' SHEET = ROOT / 'PhoneRail-Board2/C1-CONN.kicad_sch' BOARD = ROOT / 'PhoneRail-Board2/PhoneRail-Board2.kicad_pcb' LIB = ROOT / 'lib/PhoneRail.kicad_sym' FP_TABLE = ROOT / 'PhoneRail-Board2/fp-lib-table' FP_DIR = ROOT / 'lib/PhoneRail-Qualified.pretty' FP_NAME = 'CP_EIA-3216-18_Kemet-A_Pos2' SYMBOL = 'T491A226K010AT_Pos2' META = {'Value': '22uF / 10V Ta', 'Footprint': 'PhoneRail-Qualified:' + FP_NAME, 'Datasheet': 'https://item.szlcsc.com/datasheet/T491A226K010AT/117829.html', 'Description': '22uF +/-10% 10V solid tantalum MnO2; pin 2 positive, pin 1 negative; KEMET A 3216-18', 'Manufacturer': 'KEMET', 'MPN': 'T491A226K010AT', 'LCSC': 'C116575'} CLI = 'D:/KiCad/bin/kicad-cli.exe' def update_spans(source, edits): for start, end, text in sorted(edits, reverse=True): source = source[:start] + text + source[end:] return source def net_signature(root): return {n.attrib['name']: {(p.attrib['ref'], p.attrib['pin']) for p in n.findall('node')} for n in root.findall('nets/net')} def approved_metadata_transition(before, after): old, new = ET.fromstring(before), ET.fromstring(after) if old.attrib.get('ref') != 'C737' or old.findtext('footprint') != 'PhoneRail:C1206': return False if old.find('libsource').attrib.get('part') != 'CL31A226KAHNNNE': return False for tag, key in (('value', 'Value'), ('footprint', 'Footprint'), ('datasheet', 'Datasheet'), ('description', 'Description')): old.find(tag).text = META[key] for field in old.findall('fields/field'): if field.attrib['name'] in META: field.text = META[field.attrib['name']] for prop in old.findall('property'): if prop.attrib['name'] in META: prop.attrib['value'] = META[prop.attrib['name']] old.find('libsource').attrib['part'] = SYMBOL return ET.tostring(old) == ET.tostring(new) def field_style(footprint, template): for field in footprint.GetFields(): if field.GetName() not in ('Reference', 'Value'): field.SetVisible(False) field.SetMirrored(footprint.IsFlipped()) for current, previous in ((footprint.Reference(), template.Reference()), (footprint.Value(), template.Value())): current.SetPosition(previous.GetPosition()) current.SetTextAngle(previous.GetTextAngle()) current.SetTextSize(previous.GetTextSize()) current.SetVisible(previous.IsVisible()) current.SetMirrored(previous.IsMirrored()) def verify(): after = OUT / 'after' after.mkdir(exist_ok=True) b = Board(2) b.compile(CLI, after) assert all(b.parts['C737'][k] == v for k, v in META.items()) assert b.net('C737', 1) == 'GND' and b.net('C737', 2) == '+3V3_HUB' old = ET.parse(OUT / 'before/Board2.xml').getroot() new = ET.parse(after / 'Board2.xml').getroot() assert net_signature(old) == net_signature(new), 'Unexpected connectivity change' comps = lambda r: {c.attrib['ref']: ET.tostring(c) for c in r.findall('components/comp') if c.attrib['ref'] != 'C737'} assert comps(old) == comps(new), 'Unrelated component metadata changed' board = pcb.LoadBoard(str(BOARD)) before = pcb.LoadBoard(str(OUT / 'before' / BOARD.name)) fps = {f.GetReference(): f for f in board.GetFootprints()} for f in before.GetFootprints(): if f.GetReference() == 'C737': continue current = fps[f.GetReference()] assert current.GetPosition() == f.GetPosition() and current.GetLayer() == f.GetLayer() assert current.GetOrientationDegrees() == f.GetOrientationDegrees() assert current.GetFPIDAsString() == f.GetFPIDAsString() assert [(p.GetNumber(), p.GetPosition(), p.GetNetname()) for p in current.Pads()] == [ (p.GetNumber(), p.GetPosition(), p.GetNetname()) for p in f.Pads()] f = fps['C737'] assert f.GetFPIDAsString() == META['Footprint'] and f.GetValue() == META['Value'] pads = {p.GetNumber(): p for p in f.Pads()} assert pads['2'].GetNetname() == '+3V3_HUB' and pads['1'].GetNetname() == 'GND' assert f.GetLayer() == pcb.B_Cu and pads['2'].GetPosition().y < pads['1'].GetPosition().y assert len(list(board.GetTracks())) == len(list(before.GetTracks())) drc_path = OUT / 'Board2-drc.json' drc_path.unlink(missing_ok=True) result = subprocess.run([CLI, 'pcb', 'drc', '--format', 'json', '--schematic-parity', '-o', str(drc_path), str(BOARD)], capture_output=True, encoding='utf-8', errors='replace', timeout=180) drc_path.with_suffix('.log').write_text(result.stdout + result.stderr, encoding='utf-8') if result.returncode or not drc_path.exists(): raise RuntimeError('Native DRC failed to run') drc = json.loads(drc_path.read_text(encoding='utf-8')) assert not drc.get('violations') and not drc.get('schematic_parity'), 'Native DRC or parity failed' report = {'status': 'SUBSTITUTION_VERIFIED_NOT_PRODUCTION_RELEASE', 'reference': 'C737', **META, 'positive_pin': '2', 'positive_net': '+3V3_HUB', 'negative_pin': '1', 'negative_net': 'GND', 'unchanged_networks': len(net_signature(new)), 'unrelated_components_unchanged': len(comps(new)), 'position_mm': [pcb.ToMM(f.GetPosition().x), pcb.ToMM(f.GetPosition().y)], 'orientation_degrees': f.GetOrientationDegrees(), 'erc': b.erc, 'body_max_height_mm': 1.8, 'voltage_rating_v': 10, 'nominal_rail_v': 3.3, 'native_geometry_drc': 0, 'native_schematic_parity': 0, 'unconnected_pcb_items': len(drc.get('unconnected_items', []))} (OUT / 'verification.json').write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8') print(json.dumps(report)) def apply(): backup = OUT / 'before' if backup.exists(): for path in (SHEET, BOARD, LIB, FP_TABLE): if path.read_bytes() != (backup / path.name).read_bytes(): raise RuntimeError('C737 baseline differs; use verification, do not overwrite later work') pdf = ROOT / 'datasheets/ic/KEMET-T491.pdf' assert hashlib.sha256(pdf.read_bytes()).hexdigest() == '47bd290c07415015937bb42a5998eb0c6ccf5760c539ab9d180233ed37dd6649' source = SHEET.read_text(encoding='utf-8') root = parse(source) instance = next(s for s in children(root, 'symbol') if properties(s)['Reference'][2] == 'C737') props = properties(instance) assert props['MPN'][2] == 'CL31A226KAHNNNE' and props['Footprint'][2] == 'PhoneRail:C1206' old_lib_id = child(instance, 'lib_id')[1] template = next(s for s in children(child(root, 'lib_symbols'), 'symbol') if s[1] == old_lib_id) symbol = source[template.start:template.end].replace('CL31A226KAHNNNE', SYMBOL) symbol_root = parse(symbol) graphics = children(symbol_root, 'symbol')[0] plus = '\n(text "+" (at 2 2.8 0) (effects (font (size 1.27 1.27))))\n' symbol = symbol[:graphics.end-1] + plus + symbol[graphics.end-1:] edits = [(child(instance, 'lib_id').start, child(instance, 'lib_id').end, f'(lib_id "PhoneRail:{SYMBOL}")')] for key, value in META.items(): p = props[key] raw = source[p.start:p.end] updated = re.sub(r'^(\(property\s+"[^\"]*"\s+)"(?:\\.|[^\"\\])*"', lambda m: m[1] + json.dumps(value), raw) edits.append((p.start, p.end, updated)) pos = child(root, 'lib_symbols').end - 1 edits.append((pos, pos, '\n' + symbol + '\n')) updated_sheet = update_spans(source, edits) library = LIB.read_text(encoding='utf-8') lib_root = parse(library) updated_lib = library[:lib_root.end-1] + '\n' + symbol.replace('"PhoneRail:' + SYMBOL + '"', '"' + SYMBOL + '"', 1) + '\n' + library[lib_root.end-1:] standard = Path('D:/KiCad/share/kicad/footprints/Capacitor_Tantalum_SMD.pretty/CP_EIA-3216-18_Kemet-A.kicad_mod') footprint = standard.read_text(encoding='utf-8') fp_root = parse(footprint) fp_edits = [] for pad in children(fp_root, 'pad'): raw = footprint[pad.start:pad.end] fp_edits.append((pad.start, pad.end, raw.replace('(pad "' + pad[1] + '"', '(pad "' + {'1': '2', '2': '1'}[pad[1]] + '"', 1))) footprint = update_spans(footprint, fp_edits).replace('(footprint "CP_EIA-3216-18_Kemet-A"', '(footprint "' + FP_NAME + '"', 1) table = FP_TABLE.read_text(encoding='utf-8') table_root = parse(table) entry = '\n(lib (name "PhoneRail-Qualified") (type "KiCad") (uri "${KIPRJMOD}/../lib/PhoneRail-Qualified.pretty") (options "") (descr "Datasheet-qualified replacements"))\n' updated_table = table[:table_root.end-1] + entry + table[table_root.end-1:] OUT.mkdir(parents=True, exist_ok=True) if not backup.exists(): backup.mkdir() Board(2).compile(CLI, backup) for path in (SHEET, BOARD, LIB, FP_TABLE): shutil.copy2(path, backup / path.name) FP_DIR.mkdir(exist_ok=True) (FP_DIR / (FP_NAME + '.kicad_mod')).write_text(footprint, encoding='utf-8') board = pcb.LoadBoard(str(BOARD)) old = next(f for f in board.GetFootprints() if f.GetReference() == 'C737') original_pads = {p.GetNumber(): p for p in old.Pads()} f = pcb.FootprintLoad(str(FP_DIR), FP_NAME) board.Add(f) f.SetPosition(old.GetPosition()) if old.GetLayer() == pcb.B_Cu: f.Flip(f.GetPosition(), False) scores = [] for angle in (0, 90, 180, 270): f.SetOrientationDegrees(angle) scores.append((sum((p.GetPosition() - original_pads[p.GetNumber()].GetPosition()).EuclideanNorm() for p in f.Pads()), angle)) f.SetOrientationDegrees(min(scores)[1]) f.SetFPIDAsString(META['Footprint']) f.SetReference('C737') f.SetValue(META['Value']) f.SetPath(old.GetPath()) f.SetSheetfile(old.GetSheetfile()) f.SetSheetname(old.GetSheetname()) board.Remove(old) f.SetUuid(pcb.KIID(old.m_Uuid.AsString())) for field in old.GetFields(): if field.GetName() not in ('Reference', 'Value', 'Footprint'): f.SetField(field.GetName(), field.GetText()) for key, value in META.items(): if key not in ('Value', 'Footprint'): f.SetField(key, value) field_style(f, old) for p in f.Pads(): p.SetNet(original_pads[p.GetNumber()].GetNet()) pcb.SaveBoard(str(BOARD), board) SHEET.write_text(updated_sheet, encoding='utf-8') LIB.write_text(updated_lib, encoding='utf-8') FP_TABLE.write_text(updated_table, encoding='utf-8') (OUT / 'selection.json').write_text(json.dumps({**META, 'symbol': SYMBOL, 'positive_pin': '2', 'negative_pin': '1', 'footprint_source': str(standard), 'footprint_changes': 'Only root name and pad numbers swapped; standard positive marking remains on positive terminal', 'catalog_record': 'out/fan-catalog/20260907T070741439087-detail.json'}, indent=2) + '\n', encoding='utf-8') if __name__ == '__main__': parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--apply', action='store_true') args = parser.parse_args() if args.apply: apply() verify()