KiCad Project Analysis Skill Related Skills | Skill | Purpose | |-------|---------| | | BOM extraction, enrichment, ordering, and export workflows | | | Search DigiKey for parts (prototype sourcing) | | | Search Mouser for parts (secondary prototype source) | | | Search LCSC for parts (production sourcing, JLCPCB) | | | Search Newark/Farnell/element14 (international sourcing, reliable datasheets) | | | PCB fabrication & assembly ordering | | | Alternative PCB fabrication & assembly | | | SPICE simulation verification of detected subcircuits | | | EMC pre-compliance risk analysis — consumes sc…

, line)\n if m and int(m.group(1)) >= 10:\n current_aperture = int(m.group(1))\n continue\n\n # Coordinate + operation: X76687500Y-150250000D03*\n m = re.match(r'(?:X(-?\\d+))?(?:Y(-?\\d+))?D0([123])\\*', line)\n if m:\n if m.group(1): current_x = int(m.group(1))\n if m.group(2): current_y = int(m.group(2))\n op = int(m.group(3))\n # D01=draw, D02=move, D03=flash\n```\n\n**Coordinate format:** KiCad gerbers use `%FSLAX46Y46*%` — 4 digits integer, 6 digits decimal, in mm. So `X76687500` = 76.687500 mm. Divide by 1,000,000 to get mm.\n\n### Drill File Parsing\n\nExcellon drill files have a header section (tool definitions) and a body (drill hits):\n\n```python\ntools = {} # T-code → diameter\ncurrent_tool = None\ndrill_hits = [] # (x, y, tool, diameter)\nunits_mm = True # False for INCH\n\nfor line in drill_lines:\n # Tool definition: T1C0.300 or T01C0.300000\n m = re.match(r'T(\\d+)C([\\d.]+)', line)\n if m:\n tools[int(m.group(1))] = float(m.group(2))\n continue\n\n # Tool select: T1 or T01\n m = re.match(r'T(\\d+)

KiCad Project Analysis Skill Related Skills | Skill | Purpose | |-------|---------| | | BOM extraction, enrichment, ordering, and export workflows | | | Search DigiKey for parts (prototype sourcing) | | | Search Mouser for parts (secondary prototype source) | | | Search LCSC for parts (production sourcing, JLCPCB) | | | Search Newark/Farnell/element14 (international sourcing, reliable datasheets) | | | PCB fabrication & assembly ordering | | | Alternative PCB fabrication & assembly | | | SPICE simulation verification of detected subcircuits | | | EMC pre-compliance risk analysis — consumes sc…

, line)\n if m:\n current_tool = int(m.group(1))\n continue\n\n # Drill hit: X156100Y148800 (METRIC) or X5.4075Y3.0591 (INCH)\n m = re.match(r'X(-?[\\d.]+)Y(-?[\\d.]+)', line)\n if m:\n x, y = float(m.group(1)), float(m.group(2))\n if not units_mm:\n x, y = x * 25.4, y * 25.4 # Convert inches to mm\n elif x > 1000:\n x, y = x / 1000, y / 1000 # METRIC integer microns\n drill_hits.append((x, y, current_tool, tools.get(current_tool, 0)))\n```\n\n**METRIC vs INCH:** KiCad 5 uses `INCH` with decimal coordinates (e.g., `X5.4075`). KiCad 6+ uses `METRIC` with integer micron coordinates (e.g., `X156100` = 156.100 mm). Check for `METRIC` or `INCH` in the header. If coordinates have decimal points, they're inches; if integer-only, divide by 1000 for mm.\n\n### Script Output\n\nFormat analysis results as structured data for clear reporting:\n- Print summary statistics first (counts, min/max/avg)\n- Group findings by severity (critical → warning → info)\n- Include specific coordinates and net names so findings can be located in KiCad\n- When cross-referencing gerber vs PCB, print both values side-by-side for easy comparison\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":29976,"content_sha256":"d737087b59a219830615e29673537671f5263ac2373d6b63d44f79ba97c47d03"},{"filename":"references/manual-gerber-parsing.md","content":"# Manual Gerber & Drill Parsing (Script Fallback)\n\nWhen `analyze_gerbers.py` fails (unsupported format, newer KiCad version, non-KiCad gerbers), fall back to direct file parsing. Gerber and Excellon are simpler line-oriented text formats compared to KiCad S-expressions, but correct coordinate handling and X2 attribute state tracking require care.\n\n## Table of Contents\n\n1. [When to Use Manual Parsing](#when-to-use-manual-parsing)\n2. [Gerber RS-274X Parsing](#gerber-rs-274x-parsing)\n3. [X2 Attribute Extraction](#x2-attribute-extraction)\n4. [Excellon Drill Parsing](#excellon-drill-parsing)\n5. [Layer Identification](#layer-identification)\n6. [Gerber Job File (.gbrjob)](#gerber-job-file-gbrjob)\n7. [Cross-Reference with KiCad Source](#cross-reference-with-kicad-source)\n8. [Validation Methodology](#validation-methodology)\n\n---\n\n## When to Use Manual Parsing\n\nUse manual parsing when:\n- `analyze_gerbers.py` crashes or returns unexpected results\n- The gerbers are from non-KiCad EDA tools (Altium, Eagle, OrCAD)\n- You need to validate script output against raw file data\n- You need specific data the script doesn't extract (arc geometry, region vertices)\n- The file is partially corrupt but still readable\n\nAlways try the script first — it handles coordinate conversion, X2 attribute state tracking, drill classification, and layer identification automatically.\n\n---\n\n## Gerber RS-274X Parsing\n\nGerber files are line-oriented text. Each file represents one PCB layer. Parse line by line maintaining state.\n\n### Step 1: Extract Format and Units\n\nThese appear in the file header and are required for coordinate conversion.\n\n```python\nimport re\n\nwith open(gerber_path) as f:\n content = f.read()\n lines = content.splitlines()\n\n# Format specification: %FSLAX46Y46*%\nfs_match = re.search(r'%FS([LT])([AI])X(\\d)(\\d)Y(\\d)(\\d)\\*%', content)\nif fs_match:\n x_decimals = int(fs_match.group(4)) # typically 6\n y_decimals = int(fs_match.group(6))\n\n# Units: %MOMM*% or %MOIN*%\nunits_mm = '%MOMM*%' in content # True for mm, False for inch\n```\n\n**Coordinate conversion:** Raw integer coordinates divide by `10^decimals` to get the value in the declared unit. With `%FSLAX46Y46*%` and `%MOMM*%`:\n- `X150000000` = 150000000 / 10^6 = 150.0 mm\n- `X76687500Y-150250000` = (76.6875 mm, -150.25 mm)\n\nWith `%MOIN*%`, divide by 10^decimals to get inches, then multiply by 25.4 for mm.\n\n### Step 2: Parse Aperture Definitions\n\nApertures define the \"pen\" shape for drawing and flashing. They appear in the header as `%AD` commands.\n\n```python\napertures = {}\nfor line in lines:\n s = line.strip()\n m = re.match(r'%AD(D\\d+)(\\w+),?([^*]*)\\*%', s)\n if m:\n d_code = m.group(1) # e.g., \"D10\"\n shape = m.group(2) # C (circle), R (rect), O (obround), RoundRect (macro)\n params = m.group(3) # e.g., \"0.200000\" or \"1.000000X0.600000\"\n apertures[d_code] = {'shape': shape, 'params': params}\n```\n\n**Aperture shapes and dimensions:**\n\n| Shape | Params | Dimension extraction |\n|-------|--------|---------------------|\n| `C` | `diameter` | Trace width = diameter |\n| `R` | `widthXheight` | Pad size (split on X) |\n| `O` | `widthXheight` | Obround pad size |\n| `RoundRect` | `radiusX...coords...` | Complex — 2x radius is a lower bound |\n\nFor trace width analysis, focus on `C` (circle) apertures used with D01 (draw) commands — the diameter directly gives the trace width.\n\n### Step 3: Stateful Command Parsing\n\nParse draw/flash/move operations maintaining current position and aperture state.\n\n```python\ncurrent_aperture = None\ncurrent_x, current_y = 0, 0\nflash_count = 0\ndraw_count = 0\nregion_count = 0\nx_min = y_min = float('inf')\nx_max = y_max = float('-inf')\n\nx_div = 10 ** x_decimals\ny_div = 10 ** y_decimals\n\nfor line in lines:\n s = line.strip()\n\n # Aperture select: D10*\n m = re.match(r'D(\\d+)\\*

KiCad Project Analysis Skill Related Skills | Skill | Purpose | |-------|---------| | | BOM extraction, enrichment, ordering, and export workflows | | | Search DigiKey for parts (prototype sourcing) | | | Search Mouser for parts (secondary prototype source) | | | Search LCSC for parts (production sourcing, JLCPCB) | | | Search Newark/Farnell/element14 (international sourcing, reliable datasheets) | | | PCB fabrication & assembly ordering | | | Alternative PCB fabrication & assembly | | | SPICE simulation verification of detected subcircuits | | | EMC pre-compliance risk analysis — consumes sc…

, s)\n if m and int(m.group(1)) >= 10:\n current_aperture = f\"D{m.group(1)}\"\n continue\n\n # Region start/end\n if s == 'G36*':\n region_count += 1\n\n # Coordinate + operation\n m = re.match(r'(?:X(-?\\d+))?(?:Y(-?\\d+))?D0([123])\\*', s)\n if m:\n if m.group(1):\n current_x = int(m.group(1)) / x_div\n if m.group(2):\n current_y = int(m.group(2)) / y_div\n op = int(m.group(3))\n\n if op == 3: # Flash\n flash_count += 1\n elif op == 1: # Draw\n draw_count += 1\n # op == 2 is move (pen up)\n\n # Track extents\n x_min = min(x_min, current_x)\n x_max = max(x_max, current_x)\n y_min = min(y_min, current_y)\n y_max = max(y_max, current_y)\n```\n\n**Key operation codes:**\n\n| Code | Name | Action |\n|------|------|--------|\n| `D01` | Draw | Draw line from current position to coordinates |\n| `D02` | Move | Move without drawing (pen up) |\n| `D03` | Flash | Stamp aperture shape at coordinates |\n| `D10+` | Select | Switch to aperture N |\n| `G01` | Linear | Straight line interpolation (default) |\n| `G02` | CW arc | Clockwise circular arc |\n| `G03` | CCW arc | Counter-clockwise circular arc |\n| `G36` | Region start | Begin filled polygon |\n| `G37` | Region end | End filled polygon |\n| `G75` | Multi-quadrant | Arc mode (usually set once) |\n\n**Coordinates may omit X or Y** if unchanged from the previous command. `Y-150250000D03*` means flash at (previous_X, -150.25).\n\n### Step 4: Arc Parsing\n\nArc commands use I/J offsets from the current position to the arc center:\n\n```\nG75* ; Multi-quadrant mode\nG02* ; Clockwise\nX160000000Y100000000I5000000J0D01* ; Arc to (160,100) with center offset (5,0)\n```\n\n- `I` and `J` are offsets (not absolute coords) — arc center = (current_x + I, current_y + J)\n- Arc radius = sqrt(I^2 + J^2)\n- Arc appears in Edge.Cuts for rounded board corners and occasionally in copper for curved traces\n\nFor board outline analysis, you mainly need arc endpoints for bounding box calculation. For precise geometry (closed polygon verification), compute the arc center and trace the path.\n\n---\n\n## X2 Attribute Extraction\n\nX2 attributes are the most valuable data in modern gerber files. They come in three levels: file (`TF`), aperture (`TA`), and object (`TO`).\n\n### File Attributes (TF) — All KiCad Versions\n\nFile attributes identify the layer and provide metadata. **KiCad 5 and 6+ both emit them**, but in different syntax:\n\n```python\nx2_attrs = {}\n\n# Modern format (KiCad 6+): %TF.Key,Value*%\nfor m in re.finditer(r'%TF\\.(\\w+),([^*]*)\\*%', content):\n x2_attrs[m.group(1)] = m.group(2)\n\n# KiCad 5 comment format: G04 #@! TF.Key,Value*\nfor m in re.finditer(r'G04 #@! TF\\.(\\w+),([^*]*)\\*', content):\n key = m.group(1)\n if key not in x2_attrs: # Don't override modern format\n x2_attrs[key] = m.group(2)\n```\n\n**Critical TF attributes:**\n\n| Attribute | Example | Purpose |\n|-----------|---------|---------|\n| `FileFunction` | `Copper,L1,Top` | Layer identification |\n| `FilePolarity` | `Positive` / `Negative` | Mask layers are Negative |\n| `GenerationSoftware` | `KiCad,Pcbnew,9.0.7` | KiCad version detection |\n| `CreationDate` | `2026-02-24T01:31:01-08:00` | File generation timestamp |\n| `SameCoordinates` | `Original` | Alignment verification |\n\n### Aperture Attributes (TA) — KiCad 6+ Only\n\nAperture attributes classify aperture function. They appear **before** the `%AD` definition they apply to:\n\n```python\npending_aper_function = None\naperture_functions = {} # D-code -> function string\n\nfor line in lines:\n s = line.strip()\n\n # TA sets pending function\n m = re.match(r'%TA\\.AperFunction,([^*]*)\\*%', s)\n if m:\n pending_aper_function = m.group(1)\n continue\n\n # AD consumes pending function\n m = re.match(r'%AD(D\\d+)', s)\n if m and pending_aper_function:\n aperture_functions[m.group(1)] = pending_aper_function\n continue\n\n # TD clears pending\n if s == '%TD*%':\n pending_aper_function = None\n```\n\n**TA.AperFunction values and meaning:**\n\n| AperFunction | Description | Analysis use |\n|-------------|-------------|-------------|\n| `SMDPad,CuDef` | SMD pad copper | Count unique apertures = pad variety |\n| `ViaPad` | Via pad | Usually 1-2 apertures; count flashes = via count |\n| `ComponentPad` | Through-hole pad | Cross-ref with drill ComponentDrill |\n| `HeatsinkPad` | Thermal/exposed pad | QFN ground slugs, power pads |\n| `Conductor` | Traces | Circle diameter = trace width |\n| `NonConductor` | Non-electrical | Fiducials, logos |\n\n**KiCad 5 has no TA attributes.** Classify heuristically: small circle apertures used with D01 = traces; apertures used with D03 only = pads.\n\n### Object Attributes (TO) — KiCad 6+ Only\n\nObject attributes map copper features to schematic components and nets. This is the most powerful X2 feature — it enables reverse-engineering the netlist from gerber files alone.\n\n**TO attributes are stateful:** once set, they apply to all subsequent D01/D02/D03 commands until cleared by `%TD*%` or overwritten by a new `%TO*%`.\n\n```python\ncurrent_component = None\ncurrent_net = None\ncomponents = {} # ref -> {pads, nets}\npin_mappings = [] # [{ref, pin, pin_name, net}]\n\nfor line in lines:\n s = line.strip()\n\n # TO.C sets current component reference\n m = re.match(r'%TO\\.C,([^*]*)\\*%', s)\n if m:\n current_component = m.group(1)\n if current_component not in components:\n components[current_component] = {'pads': 0, 'nets': set()}\n continue\n\n # TO.N sets current net name\n m = re.match(r'%TO\\.N,([^*]*)\\*%', s)\n if m:\n current_net = m.group(1)\n if current_component and current_component in components:\n components[current_component]['nets'].add(current_net)\n continue\n\n # TO.P records pin mapping (ref, pin_number, pin_name)\n m = re.match(r'%TO\\.P,([^,]*),([^,*]*)(?:,([^*]*))?\\*%', s)\n if m:\n pin_mappings.append({\n 'ref': m.group(1),\n 'pin': m.group(2),\n 'pin_name': m.group(3) or '',\n 'net': current_net or '',\n })\n continue\n\n # TD clears all object attributes\n if s == '%TD*%':\n current_component = None\n current_net = None\n continue\n\n # On flash (D03), count pad for current component\n if 'D03' in s and current_component and current_component in components:\n components[current_component]['pads'] += 1\n```\n\n**Important state management rules:**\n- `%TO.C,R1*%` sets component context — all subsequent features belong to R1\n- `%TO.N,GND*%` sets net context — often changes within the same component\n- `%TO.P,R1,1,PAD*%` records a pin mapping — pin 1 of R1 is named \"PAD\"\n- `%TD*%` clears ALL TO attributes — resets component, net, and pin\n- The same component may appear multiple times (e.g., different pads on different draw passes)\n- TO attributes appear on **copper layers only** — mask/paste/silk layers don't have them\n\n**KiCad 5 has no TO attributes.** Component and net mapping requires the `.kicad_pcb` source file.\n\n### Component Side Detection\n\nComponents that appear only on B.Cu (back copper) TO.C attributes but not F.Cu are back-side components. Those appearing on F.Cu are front-side. Through-hole components appear on both layers (front pad + back pad).\n\n```python\nfront_components = set()\nback_components = set()\n\nfor gerber in parsed_gerbers:\n layer = gerber['layer_type']\n to_components = gerber.get('x2_objects', {}).get('component_refs', [])\n if layer == 'F.Cu':\n front_components.update(to_components)\n elif layer == 'B.Cu':\n back_components.update(to_components)\n\nback_only = back_components - front_components # True back-side SMD\n```\n\n---\n\n## Excellon Drill Parsing\n\nDrill files have a header (tool definitions) and body (drill hits). The coordinate format differs significantly between KiCad versions.\n\n### Step 1: Detect Units\n\n```python\nunits_mm = True # default assumption\n\nfor line in lines:\n s = line.strip()\n if 'METRIC' in s:\n units_mm = True\n elif 'INCH' in s:\n units_mm = False\n```\n\n### Step 2: Parse Tool Definitions\n\nTools are defined in the header section (before `%` end-of-header marker):\n\n```python\ntools = {}\npending_aper_function = None\n\nfor line in lines:\n s = line.strip()\n\n # Per-tool TA function (KiCad 6+ only)\n ta_match = re.match(r';\\s*#@!\\s*TA\\.AperFunction,(.*)', s)\n if ta_match:\n pending_aper_function = ta_match.group(1).strip()\n continue\n\n # Tool definition: T1C0.300 or T01C0.800000\n m = re.match(r'T(\\d+)C([\\d.]+)', s)\n if m:\n tool_num = int(m.group(1))\n diameter = float(m.group(2))\n if not units_mm:\n diameter *= 25.4 # Convert inches to mm\n tools[tool_num] = {\n 'diameter_mm': diameter,\n 'function': pending_aper_function, # None for KiCad 5\n 'hits': [],\n }\n pending_aper_function = None\n```\n\n### Step 3: Parse Drill Hits\n\n```python\ncurrent_tool = None\n\nfor line in lines:\n s = line.strip()\n\n # Tool select: T1 or T01\n m = re.match(r'^T(\\d+)

KiCad Project Analysis Skill Related Skills | Skill | Purpose | |-------|---------| | | BOM extraction, enrichment, ordering, and export workflows | | | Search DigiKey for parts (prototype sourcing) | | | Search Mouser for parts (secondary prototype source) | | | Search LCSC for parts (production sourcing, JLCPCB) | | | Search Newark/Farnell/element14 (international sourcing, reliable datasheets) | | | PCB fabrication & assembly ordering | | | Alternative PCB fabrication & assembly | | | SPICE simulation verification of detected subcircuits | | | EMC pre-compliance risk analysis — consumes sc…

, s)\n if m:\n current_tool = int(m.group(1))\n continue\n\n # Drill hit coordinate\n m = re.match(r'X(-?[\\d.]+)Y(-?[\\d.]+)', s)\n if m and current_tool:\n x, y = float(m.group(1)), float(m.group(2))\n if not units_mm:\n x, y = x * 25.4, y * 25.4\n elif x > 1000: # METRIC integer microns (no decimal point)\n x, y = x / 1000, y / 1000\n tools[current_tool]['hits'].append((x, y))\n```\n\n### KiCad 5 vs 6+ Coordinate Differences\n\n| Aspect | KiCad 5 | KiCad 6+ |\n|--------|---------|----------|\n| Units header | `INCH` | `METRIC` or `METRIC,TZ` |\n| Format hint | `; FORMAT={-:-/ absolute / inch / decimal}` | `; FORMAT={-:-/ absolute / metric / decimal}` |\n| Coordinate format | Decimal inches: `X1.3875Y-2.77` | Integer microns: `X150000Y100000` |\n| Decimal point | Present | Absent |\n| Negative Y values | Common (inverted Y-axis) | Rare |\n| Tool size | Inches: `T1C0.0157` (=0.399mm) | mm: `T1C0.300` |\n\n**Reliable detection:** If coordinates contain a decimal point (`.`), they're decimal inches/mm. If they're large integers without decimals, divide by 1000 for mm.\n\n### Drill Classification\n\n**With TA.AperFunction (KiCad 6+):**\n- `Plated,PTH,ViaDrill` — via\n- `Plated,PTH,ComponentDrill` — through-hole component pad\n- `NonPlated,NPTH,BoardEdge` — board cutout or slot\n\n**Without TA.AperFunction (KiCad 5) — use heuristics:**\n\n| Diameter | Likely function |\n|----------|----------------|\n| \u003c= 0.45mm | Via drill |\n| 0.45 - 1.3mm | Component hole (THT pads) |\n| > 1.3mm | Mounting hole or connector |\n| NPTH file | All holes are mounting/mechanical |\n\n**Layer span** from `TF.FileFunction`:\n- `Plated,1,2,PTH` — 2-layer board, holes span layers 1-2\n- `Plated,1,4,PTH` — 4-layer board, through-holes span all layers\n\n---\n\n## Layer Identification\n\n### From X2 FileFunction (Preferred)\n\nParse `TF.FileFunction` from file attributes (works for both KiCad 5 and 6+):\n\n```python\nfile_function = x2_attrs.get('FileFunction', '').lower()\n\nif 'copper' in file_function:\n if 'top' in file_function:\n layer = 'F.Cu'\n elif 'bot' in file_function:\n layer = 'B.Cu'\n else:\n # Inner copper: \"copper,l2,inr\" → In1.Cu\n m = re.search(r'copper,l(\\d+),inr', file_function)\n if m:\n abs_pos = int(m.group(1))\n inner_idx = abs_pos - 1 # L2→In1, L3→In2\n layer = f'In{inner_idx}.Cu'\n```\n\n**Inner layer naming pitfall:** X2 FileFunction uses absolute copper position (`L2` = second copper layer from top), but KiCad names inner layers starting from `In1.Cu`. For a 4-layer board: L1=F.Cu, **L2=In1.Cu**, **L3=In2.Cu**, L4=B.Cu. Subtract 1 from the absolute position to get the KiCad inner layer index.\n\n### From Filename Patterns (Fallback)\n\nWhen X2 attributes are missing or unparseable:\n\n```python\nname = filename.lower()\n\n# Check inner layers first (avoid false positive on \"in\" substring)\nm = re.search(r'in(\\d+)[_.]cu', name)\nif m:\n layer = f'In{m.group(1)}.Cu'\n\n# Outer layers and non-copper\npatterns = {\n 'f_cu': 'F.Cu', 'f.cu': 'F.Cu',\n 'b_cu': 'B.Cu', 'b.cu': 'B.Cu',\n 'f_mask': 'F.Mask', 'b_mask': 'B.Mask',\n 'f_paste': 'F.Paste', 'b_paste': 'B.Paste',\n 'f_silkscreen': 'F.SilkS', 'f_silks': 'F.SilkS',\n 'b_silkscreen': 'B.SilkS', 'b_silks': 'B.SilkS',\n 'edge_cuts': 'Edge.Cuts',\n}\n```\n\n**KiCad version from filenames:** `_SilkS` suffix = KiCad 5, `_Silkscreen` suffix = KiCad 6+.\n\n### Protel Extension Mapping\n\nSome fabs prefer Protel-style extensions:\n\n| Extension | Layer |\n|-----------|-------|\n| `.GTL` | F.Cu |\n| `.GBL` | B.Cu |\n| `.G1`-`.G4` | Inner layers |\n| `.GTS` | F.Mask |\n| `.GBS` | B.Mask |\n| `.GTP` | F.Paste |\n| `.GBP` | B.Paste |\n| `.GTO` | F.SilkS |\n| `.GBO` | B.SilkS |\n| `.GKO` / `.GM1` | Edge.Cuts |\n\n---\n\n## Gerber Job File (.gbrjob)\n\n**KiCad 6+ only.** JSON format with board metadata. Parse before individual gerbers — it's the most reliable source for board dimensions, layer count, and design rules.\n\n```python\nimport json\n\nwith open(gbrjob_path) as f:\n job = json.load(f)\n\nspecs = job.get('GeneralSpecs', {})\nsize = specs.get('Size', {})\nboard_width = size.get('X', 0) # mm\nboard_height = size.get('Y', 0) # mm\nlayer_count = specs.get('LayerNumber', 0)\nthickness = specs.get('BoardThickness', 0) # mm\n\n# Design rules\nfor rule in job.get('DesignRules', []):\n min_trace = rule.get('MinLineWidth', 0) # mm\n min_clearance = rule.get('PadToPad', 0) # mm\n\n# Expected files list\nfor f_attr in job.get('FilesAttributes', []):\n path = f_attr.get('Path', '')\n function = f_attr.get('FileFunction', '')\n polarity = f_attr.get('FilePolarity', '')\n\n# Stackup\nfor layer in job.get('MaterialStackup', []):\n layer_type = layer.get('Type', '') # \"Copper\" or \"Dielectric\"\n thickness = layer.get('Thickness', 0) # mm (0.035 = 1oz copper)\n material = layer.get('Material', '') # \"FR4\", etc.\n```\n\n**When .gbrjob is absent (KiCad 5):**\n- Board dimensions: compute from Edge.Cuts gerber coordinate bounding box\n- Layer count: count inner copper gerber files + 2 (F.Cu + B.Cu), or check drill `TF.FileFunction` layer span\n- Design rules: not available from gerber files; check `.kicad_pro` source\n\n---\n\n## Cross-Reference with KiCad Source\n\n### What Can Be Verified from Gerbers Alone\n\n| Check | KiCad 5 | KiCad 6+ |\n|-------|---------|----------|\n| Board dimensions | Edge.Cuts extents | .gbrjob or Edge.Cuts |\n| Layer count | Inner copper file count + drill span | .gbrjob or same |\n| Layer completeness | Filename matching | .gbrjob expected list |\n| Drill sizes | Tool definitions | Same + TA classification |\n| Trace widths | Aperture dimensions (heuristic) | TA.AperFunction Conductor |\n| Component list | Not available | TO.C attributes |\n| Net list | Not available | TO.N attributes |\n| Pin-to-net map | Not available | TO.P + TO.N attributes |\n| Pad count | Flash count (heuristic) | TA.AperFunction classification |\n\n### Cross-Reference Against PCB Analyzer\n\nWhen both gerber and PCB analysis outputs are available:\n\n1. **Component count**: Gerber `component_analysis.total_unique` vs PCB footprint count. Difference = non-electrical footprints (logos, mounting holes without copper)\n2. **Net count**: Gerber `net_analysis.total_unique` vs PCB net count. Should match closely (gerber may miss nets that are zone-only with no pads/traces)\n3. **Via count**: Gerber drill `vias.count` vs PCB via count\n4. **Trace widths**: Gerber `trace_widths.unique_widths_mm` vs PCB track width distribution\n5. **Board dimensions**: Gerber `board_dimensions` vs PCB Edge.Cuts extents\n6. **THT vs SMD ratio**: Gerber `pad_summary.smd_ratio` vs PCB component `attr` counts\n\n### Cross-Reference Against Schematic Analyzer\n\n1. **Component list**: Gerber component refs (from TO.C) should be a subset of schematic BOM. Missing = DNP components or power symbols (expected). Extra = fabrication-only components\n2. **Net names**: Named nets from gerber TO.N should match schematic net names. Unnamed gerber nets (`Net-(...)`) are auto-generated and may differ\n3. **Pin count per component**: Gerber pad count should match schematic pin count for each reference designator\n\n---\n\n## Validation Methodology\n\n### Quick Sanity Checks\n\n1. **File count**: Typical 2-layer board = 9 gerbers + 2 drills + 1 gbrjob. 4-layer = 11 gerbers + 2 drills + 1 gbrjob\n2. **Coordinate alignment**: All `TF.SameCoordinates` values should be `Original`\n3. **Date consistency**: All `TF.CreationDate` values should match — different dates = risk of misaligned files\n4. **Software consistency**: All `TF.GenerationSoftware` should match\n5. **Solder mask polarity**: Must be `Negative` (`TF.FilePolarity,Negative`)\n\n### Layer Consistency Checks\n\n- **Paste \u003c= Mask**: F.Paste flash count should be \u003c= F.Mask flash count (no paste on vias)\n- **Empty B.Paste**: Correct for single-side assembly\n- **B.Cu flashes ~ via count**: Back copper pad flashes should roughly equal PTH via drill count (plus any back-side SMD)\n- **Copper balance**: F.Cu and B.Cu draw counts within ~10x of each other (extreme imbalance = potential warping)\n- **Edge.Cuts non-empty**: Must have draws (board outline)\n\n### Drill Verification\n\n- **PTH minimum**: >= 0.2mm (JLCPCB standard)\n- **NPTH minimum**: >= 0.5mm (JLCPCB standard)\n- **Via count cross-check**: Drill via count should match B.Cu via pad flash count (when TA.AperFunction is available)\n- **Layer span**: Drill `TF.FileFunction` span should match copper layer count (e.g., `Plated,1,4,PTH` for 4-layer)\n\n### Known Edge Cases\n\n- **KiCad 5 mask/paste uses regions**: D03 flash count may be 0 on mask/paste layers — count G36/G37 region pairs instead\n- **Large B.Mask file size**: Normal when back has ground plane — mask must define tenting pattern over entire zone fill\n- **Negative Y in KiCad 5 drills**: KiCad 5 used inverted Y-axis for drill coordinates\n- **Non-KiCad gerbers**: May lack X2 attributes entirely; rely on filename patterns for layer identification\n- **Merged drill files**: Some workflows produce a single drill file with both PTH and NPTH — check `TF.FileFunction` for `MixedPlating`\n- **Protel extensions**: Some fabs require `.GTL`/`.GBL` extensions instead of KiCad's `-F_Cu.gbr` naming\n- **Inner layer L2 != In2.Cu**: X2 FileFunction uses absolute position (L2 = second physical copper), KiCad uses inner-relative naming (In1.Cu = first inner copper). L2 maps to In1.Cu, L3 maps to In2.Cu\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":22525,"content_sha256":"ca95542a02a6cd51d04a5e2be3b155a001ed28a7a4059b728284565a3c427b95"},{"filename":"references/manual-pcb-parsing.md","content":"# Manual PCB Parsing (Script Fallback)\n\nWhen `analyze_pcb.py` fails (unsupported format, newer KiCad version, corrupted file), fall back to direct file parsing. This is more expensive (reading raw S-expressions) but always works as long as the file is valid KiCad.\n\n## Table of Contents\n\n1. [When to Use Manual Parsing](#when-to-use-manual-parsing)\n2. [Performance: Line-by-Line Parsing](#performance-line-by-line-parsing)\n3. [Net Extraction](#net-extraction)\n4. [Footprint Extraction](#footprint-extraction)\n5. [Track and Via Extraction](#track-and-via-extraction)\n6. [Zone Extraction](#zone-extraction)\n7. [Board Outline](#board-outline)\n8. [Connectivity Analysis](#connectivity-analysis)\n9. [KiCad 5 Legacy Format](#kicad-5-legacy-format)\n10. [Validation Methodology](#validation-methodology)\n\n---\n\n## When to Use Manual Parsing\n\nUse manual parsing when:\n- `analyze_pcb.py` crashes or returns unexpected results on a file you know is valid\n- The PCB is from a KiCad version newer than the script supports\n- You need to validate script output against raw file data\n- You need to extract data the script doesn't provide (e.g., specific filled_polygon vertices)\n- The file is partially corrupt but still readable\n\nAlways try the script first — it handles coordinate transforms, via classification, connectivity analysis, and 25+ analysis stages automatically.\n\n---\n\n## Performance: Line-by-Line Parsing\n\nKiCad PCB files can be 20K-70K+ lines, with zones containing thousands of polygon vertices. **Never use full-content regex with `re.DOTALL` on the entire file** — it causes catastrophic backtracking on large files. Use line-by-line state-machine parsing instead for top-level extraction. `re.DOTALL` is acceptable on small, pre-extracted blocks (e.g., a single footprint or pad block) where the input size is bounded.\n\n### Buffer Accumulation Pattern\n\nFor simple blocks (segments, vias):\n\n```python\nimport re\n\nwith open(pcb_file) as f:\n lines = f.readlines()\n\nsegments = []\nbuf = None\nfor line in lines:\n if '\\t(segment' in line:\n buf = line\n elif buf:\n buf += line\n if line.strip() == ')':\n m_s = re.search(r'\\(start\\s+([\\d.-]+)\\s+([\\d.-]+)\\)', buf)\n m_e = re.search(r'\\(end\\s+([\\d.-]+)\\s+([\\d.-]+)\\)', buf)\n m_w = re.search(r'\\(width\\s+([\\d.]+)\\)', buf)\n m_l = re.search(r'\\(layer\\s+\"([^\"]+)\"\\)', buf)\n # KiCad ≤9: (net 7), KiCad 10: (net \"NetName\")\n m_n = re.search(r'\\(net\\s+(\\d+)\\)', buf) or re.search(r'\\(net\\s+\"([^\"]+)\"\\)', buf)\n if all([m_s, m_e, m_w, m_l, m_n]):\n net_val = m_n.group(1)\n segments.append({\n 'sx': float(m_s.group(1)), 'sy': float(m_s.group(2)),\n 'ex': float(m_e.group(1)), 'ey': float(m_e.group(2)),\n 'w': float(m_w.group(1)), 'layer': m_l.group(1),\n 'net': int(net_val) if net_val.isdigit() else net_val\n })\n buf = None\n```\n\n### Depth-Tracked Parsing\n\nFor nested blocks (footprints, zones), track parenthesis depth:\n\n```python\nfootprints = {}\ncurrent_fp = None\nfp_text = []\ndepth = 0\n\nfor line in lines:\n if line.startswith('\\t(footprint '):\n current_fp = True\n fp_text = [line]\n depth = line.count('(') - line.count(')')\n elif current_fp:\n fp_text.append(line)\n depth += line.count('(') - line.count(')')\n if depth \u003c= 0:\n block = ''.join(fp_text)\n # Extract data from the bounded block (regex is safe here)\n m_ref = re.search(r'\\(property\\s+\"Reference\"\\s+\"([^\"]+)\"', block)\n m_at = re.search(r'\\n\\t\\t\\(at\\s+([\\d.-]+)\\s+([\\d.-]+)(?:\\s+([\\d.-]+))?\\)', block)\n if m_ref and m_at:\n ref = m_ref.group(1)\n footprints[ref] = {\n 'x': float(m_at.group(1)),\n 'y': float(m_at.group(2)),\n 'angle': float(m_at.group(3)) if m_at.group(3) else 0,\n 'block': block\n }\n current_fp = None\n```\n\n---\n\n## Net Extraction\n\n**KiCad ≤9:** Net declarations are single-line entries near the top of the file:\n\n```python\nnets = {}\nfor line in lines:\n m = re.match(r'\\s*\\(net\\s+(\\d+)\\s+\"([^\"]*)\"\\)', line)\n if m:\n nets[int(m.group(1))] = m.group(2)\n```\n\nNet 0 is always the unconnected net (empty name).\n\n**KiCad 10:** No net declarations section. Nets are referenced by name string directly in pads, tracks, vias, and zones. Collect unique net names from those elements instead.\n\nPower nets typically have names like `GND`, `+3V3`, `+5V`, `VBUS`.\n\n---\n\n## Footprint Extraction\n\nAfter extracting footprint blocks with depth-tracking (see above), extract pads from each block:\n\n```python\nfor ref, fp in footprints.items():\n # KiCad ≤9: (net 5 \"+3V3\"), KiCad 10: (net \"+3V3\")\n pads = re.findall(\n r'\\(pad\\s+\"([^\"]+)\"\\s+(\\w+)\\s+\\w+\\s+'\n r'\\(at\\s+([\\d.-]+)\\s+([\\d.-]+).*?\\)'\n r'\\s+\\(size\\s+([\\d.-]+)\\s+([\\d.-]+)\\).*?'\n r'\\(net\\s+(?:(\\d+)\\s+)?\"([^\"]*)\"',\n fp['block'], re.DOTALL)\n # pads: list of (pad_num, type, rel_x, rel_y, size_w, size_h, net_id_or_empty, net_name)\n```\n\n### Absolute Pad Positions\n\nPad coordinates are relative to footprint origin. Transform to board coordinates:\n\n```python\nimport math\n\ndef pad_to_absolute(fp_x, fp_y, fp_angle_deg, pad_rx, pad_ry):\n rad = math.radians(-fp_angle_deg) # KiCad: CW positive in layout\n abs_x = fp_x + pad_rx * math.cos(rad) - pad_ry * math.sin(rad)\n abs_y = fp_y + pad_rx * math.sin(rad) + pad_ry * math.cos(rad)\n return abs_x, abs_y\n```\n\n### Key Footprint Fields\n\n| Field | Where | Purpose |\n|-------|-------|---------|\n| `(property \"Reference\" \"U1\")` | Footprint block | Component designator |\n| `(property \"Value\" \"STM32F407\")` | Footprint block | Component value |\n| `(at X Y ANGLE)` | 2nd-level child | Position and rotation |\n| `(layer \"F.Cu\")` | 1st-level child | Board side (F.Cu = front, B.Cu = back) |\n| `(attr smd)` | 1st-level child | SMD vs through-hole |\n| `(path \"/uuid\")` | 1st-level child | Link to schematic symbol |\n| `(sheetname \"Power\")` | 1st-level child | Source schematic sheet |\n| `(pad ...)` | Nested children | Pads with net assignments |\n\n### Courtyard Extraction\n\nCourtyard shapes define the component's keep-out area:\n\n```python\n# From footprint block:\ncrtyd_lines = re.findall(\n r'\\(fp_(?:line|rect)\\s+\\(start\\s+([\\d.-]+)\\s+([\\d.-]+)\\)\\s+'\n r'\\(end\\s+([\\d.-]+)\\s+([\\d.-]+)\\).*?'\n r'\\(layer\\s+\"[FB]\\.CrtYd\"\\)',\n fp['block'], re.DOTALL)\n```\n\nBuild a bounding box from all courtyard primitives, then transform to absolute coordinates.\n\n---\n\n## Track and Via Extraction\n\n### Tracks (Segments)\n\nSee the buffer accumulation pattern above. KiCad 7+ also has `(arc ...)` blocks with `(start)`, `(mid)`, `(end)` for curved tracks.\n\nFor arc length calculation:\n```python\nimport math\n\ndef arc_length(sx, sy, mx, my, ex, ey):\n \"\"\"Calculate arc length from start/mid/end points.\"\"\"\n # Find circle center from 3 points\n ax, ay = sx - mx, sy - my\n bx, by = ex - mx, ey - my\n D = 2 * (ax * by - ay * bx)\n if abs(D) \u003c 1e-10:\n return math.hypot(ex - sx, ey - sy) # Degenerate: straight line\n ux = (by * (ax*ax + ay*ay) - ay * (bx*bx + by*by)) / D + mx\n uy = (ax * (bx*bx + by*by) - bx * (ax*ax + ay*ay)) / D + my\n radius = math.hypot(sx - ux, sy - uy)\n # Angle subtended\n a1 = math.atan2(sy - uy, sx - ux)\n a2 = math.atan2(ey - uy, ex - ux)\n angle = abs(a2 - a1)\n if angle > math.pi:\n angle = 2 * math.pi - angle\n return radius * angle\n```\n\n### Vias\n\n```python\nvias = []\nbuf = None\nfor line in lines:\n if '\\t(via' in line and '(via' in line:\n buf = line\n elif buf:\n buf += line\n if line.strip() == ')':\n m_at = re.search(r'\\(at\\s+([\\d.-]+)\\s+([\\d.-]+)\\)', buf)\n m_sz = re.search(r'\\(size\\s+([\\d.]+)\\)', buf)\n m_dr = re.search(r'\\(drill\\s+([\\d.]+)\\)', buf)\n m_ly = re.search(r'\\(layers\\s+\"([^\"]+)\"\\s+\"([^\"]+)\"\\)', buf)\n # KiCad ≤9: (net 5) with numeric ID; KiCad 10: (net \"NetName\") with string\n m_n = re.search(r'\\(net\\s+(\\d+)\\)', buf)\n m_nn = re.search(r'\\(net\\s+\"([^\"]+)\"\\)', buf)\n via_type = 'through'\n if '(via blind' in buf: via_type = 'blind'\n elif '(via buried' in buf: via_type = 'buried'\n elif '(via micro' in buf: via_type = 'micro'\n if all([m_at, m_sz, m_dr]) and (m_n or m_nn):\n vias.append({\n 'x': float(m_at.group(1)), 'y': float(m_at.group(2)),\n 'size': float(m_sz.group(1)), 'drill': float(m_dr.group(1)),\n 'type': via_type,\n 'layers': (m_ly.group(1), m_ly.group(2)) if m_ly else ('F.Cu', 'B.Cu'),\n 'net': int(m_n.group(1)) if m_n else m_nn.group(1),\n 'free': '(free yes)' in buf\n })\n buf = None\n```\n\n### Annular Ring Check\n\n```python\nfor via in vias:\n annular_ring = (via['size'] - via['drill']) / 2\n if annular_ring \u003c 0.125: # JLCPCB standard minimum\n print(f\"Annular ring violation: {annular_ring:.3f}mm at ({via['x']}, {via['y']})\")\n```\n\n---\n\n## Zone Extraction\n\nZones are the trickiest part — they contain massive `filled_polygon` blocks. For most analyses, extract only the header:\n\n```python\nzones = []\nin_zone = False\nzone_info = {}\nfor line in lines:\n stripped = line.strip()\n if re.match(r'\\(zone\\s*

KiCad Project Analysis Skill Related Skills | Skill | Purpose | |-------|---------| | | BOM extraction, enrichment, ordering, and export workflows | | | Search DigiKey for parts (prototype sourcing) | | | Search Mouser for parts (secondary prototype source) | | | Search LCSC for parts (production sourcing, JLCPCB) | | | Search Newark/Farnell/element14 (international sourcing, reliable datasheets) | | | PCB fabrication & assembly ordering | | | Alternative PCB fabrication & assembly | | | SPICE simulation verification of detected subcircuits | | | EMC pre-compliance risk analysis — consumes sc…

, stripped) or re.match(r'\\(zone\\s+\\(', stripped):\n in_zone = True\n zone_info = {}\n elif in_zone:\n m = re.search(r'\\(net\\s+(\\d+)\\)', line)\n if m: zone_info['net'] = int(m.group(1))\n m = re.search(r'\\(net_name\\s+\"([^\"]*)\"', line)\n if m: zone_info['net_name'] = m.group(1)\n m = re.search(r'\\(layer\\s+\"([^\"]+)\"', line)\n if m: zone_info['layer'] = m.group(1)\n m = re.search(r'\\(priority\\s+(\\d+)\\)', line)\n if m: zone_info['priority'] = int(m.group(1))\n if '(keepout' in line:\n zone_info['is_keepout'] = True\n if '(polygon' in line or '(filled_polygon' in line:\n zones.append(zone_info)\n in_zone = False\n```\n\n### Zone Fill Polygon Extraction\n\nOnly extract filled polygon data when you actually need zone containment tests:\n\n```python\ndef extract_zone_polygon(lines, zone_net, zone_layer):\n \"\"\"Extract filled_polygon vertices for a specific zone.\"\"\"\n in_target_zone = False\n in_filled_poly = False\n points = []\n depth = 0\n\n for line in lines:\n if '(zone' in line:\n in_target_zone = False\n # Check if this is our target zone\n if in_target_zone and '(filled_polygon' in line:\n if f'(layer \"{zone_layer}\")' in line:\n in_filled_poly = True\n depth = line.count('(') - line.count(')')\n continue\n if in_filled_poly:\n for m in re.finditer(r'\\(xy\\s+([\\d.-]+)\\s+([\\d.-]+)\\)', line):\n points.append((float(m.group(1)), float(m.group(2))))\n depth += line.count('(') - line.count(')')\n if depth \u003c= 0:\n return points\n return points\n```\n\n---\n\n## Board Outline\n\nExtract graphical primitives on the `Edge.Cuts` layer:\n\n```python\noutline_segments = []\nfor line in lines:\n if 'Edge.Cuts' in line:\n # Check parent block for gr_line, gr_arc, gr_rect, gr_circle\n pass\n\n# Simpler: use buffer accumulation for gr_line blocks\nbuf = None\nfor line in lines:\n if '(gr_line' in line or '(gr_arc' in line or '(gr_rect' in line:\n buf = line\n elif buf:\n buf += line\n if line.strip().endswith(')'):\n if 'Edge.Cuts' in buf:\n if '(gr_line' in buf:\n m = re.search(r'\\(start\\s+([\\d.-]+)\\s+([\\d.-]+)\\).*?\\(end\\s+([\\d.-]+)\\s+([\\d.-]+)\\)', buf, re.DOTALL)\n if m:\n outline_segments.append({\n 'type': 'line',\n 'start': (float(m.group(1)), float(m.group(2))),\n 'end': (float(m.group(3)), float(m.group(4)))\n })\n buf = None\n\n# Bounding box from all outline segments\nif outline_segments:\n all_x = [s['start'][0] for s in outline_segments] + [s['end'][0] for s in outline_segments]\n all_y = [s['start'][1] for s in outline_segments] + [s['end'][1] for s in outline_segments]\n width = max(all_x) - min(all_x)\n height = max(all_y) - min(all_y)\n```\n\n---\n\n## Connectivity Analysis\n\n### Unrouted Net Detection\n\nChecking whether a net has *any* routing is insufficient — nets can be partially routed with breaks. A proper connectivity analysis builds a graph per net and checks if all pads are in a single connected component.\n\n#### Algorithm\n\n1. **Extract pad positions** (absolute coordinates) — transform relative pad coords using footprint position/angle\n2. **Extract routing elements per net** — segments, vias, and zone filled polygons\n3. **Build union-find graph** for each net:\n - Union segment endpoints (each segment connects its start and end)\n - Union coincident points within ~50µm tolerance on the same copper layer\n - Union pad-to-trace (segment endpoint within pad area)\n - Union via connections (vias exist on multiple layers)\n - Union zone connections (point-in-polygon for filled areas)\n4. **Count connected components** among pad points:\n - All pads in same component → fully routed\n - Multiple components → net has breaks (ratsnest lines)\n\n#### Common False Positives\n\n- ESP32/QFN ground slug pads may appear disconnected if they're just outside the zone fill boundary\n- Zone fills may need to be re-poured after component moves\n- Nets named `unconnected-(...)` are explicitly marked no-connect — skip these\n\n---\n\n## KiCad 5 Legacy Format\n\nKiCad 5 PCB files use `(module ...)` instead of `(footprint ...)`, and `(fp_text reference ...)` instead of `(property \"Reference\" ...)`.\n\n### Key Differences\n\n| Modern (KiCad 6+) | Legacy (KiCad 5) |\n|--------------------|------------------|\n| `(footprint \"Lib:Name\" ...)` | `(module \"Lib:Name\" ...)` |\n| `(property \"Reference\" \"U1\" ...)` | `(fp_text reference \"U1\" ...)` |\n| `(property \"Value\" \"STM32\" ...)` | `(fp_text value \"STM32\" ...)` |\n| `(uuid \"...\")` | `(tstamp HEXID)` |\n| Layer numbers: F.Cu=0, B.Cu=2 | Layer numbers: F.Cu=0, B.Cu=31 |\n\n### Net Classes (KiCad 5 only)\n\nKiCad 5 stores net classes directly in the PCB file:\n\n```\n(net_class Default \"Default net class\"\n (clearance 0.2)\n (trace_width 0.25)\n (via_dia 0.6)\n (via_drill 0.3)\n (uvia_dia 0.3)\n (uvia_drill 0.1)\n (add_net \"GND\")\n (add_net \"+3V3\")\n)\n```\n\n### Dimension Annotations (KiCad 5)\n\n```\n(dimension 50.0\n (width 0.12)\n (layer \"F.SilkS\")\n (gr_text \"50 mm\" (at X Y ANGLE) ...)\n (feature1 (pts (xy X1 Y1) (xy X2 Y2)))\n (feature2 (pts (xy X3 Y3) (xy X4 Y4)))\n (crossbar (pts (xy X5 Y5) (xy X6 Y6)))\n)\n```\n\n---\n\n## Validation Methodology\n\nWhen verifying analyzer output (or your own manual parse):\n\n### Component Count Validation\n\n1. Count all `(footprint ...)` or `(module ...)` top-level blocks\n2. Compare against the analyzer's footprint count — should match exactly\n3. Check for `(attr board_only)` or `(attr virtual)` components that may be excluded from counts\n\n### Net Count Validation\n\n1. Count all `(net N \"name\")` declarations (subtract net 0)\n2. Compare against analyzer's net count\n3. Spot-check 3-5 nets by finding all pads/segments/vias with that net ID\n\n### Routing Completeness\n\n1. Collect all unique net IDs from pads\n2. For each net, check if it has at least one segment, via, or zone\n3. Nets with multiple pads but no routing elements are unrouted\n4. Nets with routing but disconnected islands need the full union-find analysis\n\n### Known Edge Cases\n\n- **Test points**: Single-pad footprints appear as \"unrouted\" nets — they only have one endpoint, which is correct\n- **Mounting holes**: Non-plated holes (`np_thru_hole`) have no net and should be excluded\n- **Board-only components**: Logos, fiducials with `(attr board_only)` may not have nets\n- **Zone-only routing**: Some nets (especially GND) are routed entirely through copper pours with no tracks — check zone net assignments\n- **Multi-layer zones**: A zone on F.Cu doesn't connect to the same zone on B.Cu unless there are vias between them\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":16453,"content_sha256":"54c7453b3a7689b5b9ab09153b60756ff6cbd2b2d8750986ef3756886aa17825"},{"filename":"references/manual-schematic-parsing.md","content":"# Manual Schematic Parsing (Script Fallback)\n\nWhen `analyze_schematic.py` fails (unsupported format, newer KiCad version, corrupted file), fall back to direct file parsing. This is more expensive (reading raw S-expressions) but always works as long as the file is valid KiCad.\n\n## Table of Contents\n\n1. [When to Use Manual Parsing](#when-to-use-manual-parsing)\n2. [File Format Quick Reference](#file-format-quick-reference)\n3. [Component Extraction](#component-extraction)\n4. [Net Building](#net-building)\n5. [Signal Analysis Patterns](#signal-analysis-patterns)\n6. [Legacy .sch Format](#legacy-sch-format)\n7. [Validation Methodology](#validation-methodology)\n\n---\n\n## When to Use Manual Parsing\n\nUse manual parsing when:\n- `analyze_schematic.py` crashes or returns 0 components on a file you know has content\n- The schematic is from a KiCad version newer than the script supports\n- You need to validate script output against raw file data\n- The file is partially corrupt but still readable\n\nAlways try the script first — it handles coordinate transforms, multi-unit symbols, hierarchical sheets, and net building automatically.\n\n---\n\n## File Format Quick Reference\n\n### Modern `.kicad_sch` (KiCad 6+)\n\nS-expression format. Key sections in order:\n\n```\n(kicad_sch (version N) (generator ...) (uuid ...)\n (lib_symbols ...) ; Library symbol definitions (pin data, shapes)\n (junction ...) ; Wire junction points\n (no_connect ...) ; Explicit no-connect markers\n (wire ...) ; Wire segments (coordinate pairs)\n (label ...) ; Local net labels\n (global_label ...) ; Cross-sheet net labels\n (hierarchical_label ...) ; Sheet-to-sheet pin labels\n (symbol ...) ; Placed component instances\n (sheet ...) ; Sub-sheet references (hierarchical designs)\n)\n```\n\n### Legacy `.sch` (KiCad 4/5)\n\nLine-based format. Key block types:\n\n```\nEESchema Schematic File Version N\n$Comp / $EndComp ; Component blocks\nWire Wire Line / x1 y1 x2 y2 ; Wire segments\nText Label / Text GLabel ; Labels\nNoConn ~ x y ; No-connect markers\n$Sheet / $EndSheet ; Sub-sheet references\n```\n\n---\n\n## Component Extraction\n\n### Modern Format\n\nEach placed component is a `(symbol ...)` block after the `(lib_symbols)` section:\n\n```lisp\n(symbol (lib_id \"Device:R\") (at 152.4 176.53 90) (unit 1)\n (property \"Reference\" \"R13\" ...)\n (property \"Value\" \"10k\" ...)\n (property \"Footprint\" \"Resistor_SMD:R_0402_1005Metric\" ...)\n (property \"Datasheet\" \"~\" ...)\n (pin \"1\" (uuid ...))\n (pin \"2\" (uuid ...))\n)\n```\n\n**Extract for each component:**\n- `lib_id` — library:symbol name\n- `at` — placement position (X, Y, rotation angle)\n- `unit` — which unit of a multi-unit symbol (1-based, e.g., LM324 unit 1-4 + power unit 5)\n- Properties: Reference, Value, Footprint, Datasheet, MPN, Manufacturer, etc.\n\n**Filtering:**\n- Skip power symbols: `lib_id` contains `:power:` or the lib_symbol has a `(power)` flag\n- Skip power flag markers: Reference starts with `#PWR` or `#FLG`\n- Respect DNP: check for `(dnp yes)` attribute or `\"DNP\"` property\n\n**Multi-unit symbols (critical):**\nSymbols like LM324 (quad op-amp), STM32 (multi-bank MCU), dual inductors, relays — each unit is a separate `(symbol ...)` placement sharing the same Reference. Count unique References for BOM, not placements.\n\nThe `lib_symbols` section contains sub-symbols named `SymName_U_V` where U = unit number. `_0_1` sub-symbols contain pins shared by ALL units (typically power pins).\n\n### Legacy Format\n\nComponents are in `$Comp`/`$EndComp` blocks:\n\n```\n$Comp\nL library:SymbolName Reference\nU unit_number convert_num timestamp\nP x y\nF 0 \"R1\" ... ; Reference\nF 1 \"10k\" ... ; Value\nF 2 \"footprint\" ... ; Footprint\nF 3 \"datasheet\" ... ; Datasheet\nF 4 \"custom\" ... ; Custom field (MPN, Manufacturer, etc.)\n 1 x y\n$EndComp\n```\n\n**Custom fields (F4+):** May contain MPN (`manf#`, `MPN`, `MFG Part`), Manufacturer (`Manufacturer`, `MFG`), distributor part numbers (`DigiKey`, `Mouser`, `LCSC`), or DNP flag.\n\n---\n\n## Net Building\n\n### Coordinate-Based Union-Find (Modern Format)\n\nKiCad schematics don't store netlists — connectivity is implicit through coordinate matching. Build nets by:\n\n1. **Extract all wire endpoints** from `(wire (pts (xy X1 Y1) (xy X2 Y2)))` blocks\n2. **Compute absolute pin positions** for each component (see `net-tracing.md` for transforms)\n3. **Union-find**: merge coordinate groups connected by wires, junctions, and shared endpoints\n4. **Assign net names** from labels (local, global, hierarchical) and power symbols at group endpoints\n\n**Critical rules:**\n- **Y-axis inversion**: `absolute_Y = symbol_Y - pin_Y` (not `+`)\n- **Sheet isolation**: Each sheet has a separate coordinate space. Only global labels and power symbols connect across sheets. Local labels are scoped to their sheet.\n- **Junctions**: Wires crossing at a point only connect if there's an explicit `(junction (at X Y))`. T-junctions (wire endpoint touching mid-wire) also connect.\n- **Power symbols connect globally**: All instances of `GND`, `+3V3`, etc. are the same net regardless of sheet.\n\n### Net Names\n\nNets are named by (priority order):\n1. Power symbol name (e.g., `GND`, `+3V3`, `+5V`)\n2. Global label name\n3. Local label name\n4. Hierarchical label name\n5. Unnamed (auto-generated `__unnamed_N`)\n\n### Legacy Format\n\nWires: `Wire Wire Line` followed by `X1 Y1 X2 Y2` on next line.\nLabels: `Text Label X Y orientation 0 ~ 0 \"NetName\"` or `Text GLabel ...`.\nNo-connects: `NoConn ~ X Y`.\n\n---\n\n## Signal Analysis Patterns\n\nWhen scripts can't detect subcircuits, look for these patterns manually in the component/net data.\n\n### Power Regulators\n\n**LDO pattern:** IC with pins named VIN, VOUT, GND (and optionally EN, PG, ADJ/FB). VIN and VOUT connect to different named power nets.\n\n**Switching regulator pattern:** IC with SW/LX/PH pin connected to an inductor. May also have FB pin with voltage divider, BOOT/BST pin with bootstrap capacitor.\n\n**Pin name variants:**\n| Function | Pin names |\n|----------|-----------|\n| Input | VIN, VI, IN, PVIN, AVIN, INPUT |\n| Output | VOUT, VO, OUT, OUTPUT |\n| Feedback | FB, VFB, ADJ, VADJ (may have numeric suffix: FB1, ADJ2) |\n| Switch | SW, PH, LX (may have numeric suffix: SW1, SW2) |\n| Enable | EN, ENABLE, ON, ~{SHDN}, SHDN, ~{EN} |\n| Bootstrap | BOOT, BST, BOOTSTRAP, CBST |\n\n**Custom library detection:** If the IC has both VIN and VOUT connected to distinct recognized power nets (e.g., +5V and +3V3), it's almost certainly a regulator even without keyword matches in the library name.\n\n### Voltage Dividers\n\nTwo resistors in series: R1_pin1→Net_top, R1_pin2→R2_pin1→Mid_net, R2_pin2→Net_bottom. The mid-point net should NOT be a power rail with many connections (that's pull-ups sharing a bus, not a divider).\n\n`ratio = R_bottom / (R_top + R_bottom)`\n\n### Op-Amp Circuits\n\nLook for ICs with `+IN`/`IN+`, `-IN`/`IN-`, `OUT` pins (or bare `+`, `-`, `~` pin names for KiCad standard library op-amps).\n\n**Multi-unit op-amps (LM324, TL082, etc.):** Each unit has its own +IN/-IN/OUT pins with different pin numbers. When analyzing manually, check the `lib_symbols` section for `SymName_N_1` sub-symbols to identify which pins belong to which unit.\n\n**Configurations:**\n- **Buffer**: OUT connected directly to -IN\n- **Inverting**: Feedback R from OUT to -IN, input R to -IN, +IN to reference/ground\n- **Non-inverting**: Feedback R from OUT to -IN, +IN to signal, -IN to ground via R\n- **Comparator/open-loop**: No feedback resistor from OUT to -IN\n\n**Common false positives:**\n- Current sense amps (INA180/181/185/186/190/199): Have IN+/IN- pins but are fixed-gain, not user-configurable op-amps\n- Digital power monitors (INA219/226/229): Have I2C interface, not analog op-amp pins\n- Analog front-ends (AD8233): Complex ICs with internal op-amps that don't follow standard topology\n\n### Transistor Circuits\n\n**N-channel MOSFET:** Look for Q references with `NMOS`/`N-Channel` in lib_id or ki_keywords. Gate→drive signal, Drain→load, Source→GND (low-side switch).\n\n**P-channel MOSFET:** `PMOS`/`P-Channel` in lib_id or ki_keywords. Source→power rail, Drain→load, Gate→control (inverted logic). Used as high-side switches.\n\n**Reliable P-channel detection (priority order):**\n1. `ki_keywords` from lib_symbol containing \"P-Channel\" — most reliable\n2. lib_id containing `pmos`, `p-channel`, `q_pmos`\n3. Value containing unambiguous P-channel family names (DMP series from Diodes Inc)\n\n**Bridge circuits:** Look for transistor pairs where one drain connects to another's source (half-bridge mid-point). Two such pairs = H-bridge. Three = 3-phase.\n\n### Protection Devices\n\nTVS/ESD diodes: Keywords `TVS`, `ESD`, `PESD`, `PRTR`, `USBLC`, `SMAJ`, `SMBJ`, `LESD` in value or lib_id. Connected between signal line and ground/power.\n\nESD protection ICs: `USBLC6`, `PRTR5V`, `SP0502`, `TPD4E05` etc. Multi-channel protection arrays.\n\n### Bus Detection\n\n**I2C:** Nets named `SDA`/`SCL` (or containing these substrings), or IC pins named `SDA`/`SCL`. Look for pull-up resistors (2.2k-10k) to VCC. Exclude `SCLK`/`SCK` pins (SPI, not I2C).\n\n**SPI:** Nets named `MOSI`/`MISO`/`SCK`/`CS` or `COPI`/`CIPO`/`SCK`/`CS`.\n\n**UART:** Nets named `TX`/`RX`/`TXD`/`RXD` (exclude nets also containing `CAN`, `SPI`, `I2C`).\n\n**CAN:** Nets named `CANH`/`CANL` or CAN transceiver ICs (MCP2551, SN65HVD230, TJA1050, etc.). Don't confuse with RS-485 (SN65HVD75 is RS-485, not CAN).\n\n---\n\n## Legacy .sch Format\n\n### What the Analyzer Provides\n\nThe analyzer now parses `.lib` files (cache libraries and project libs) to populate pin data for legacy schematics. When `.lib` files are available:\n\n- All component references, values, footprints, lib_ids\n- Pin positions, pin names, and pin types (from `.lib` files)\n- Pin-to-net mapping via wire connectivity + pin positions\n- Signal analysis (voltage dividers, regulators, op-amp circuits, etc.)\n- Subcircuit detection (IC + 1-hop neighbors)\n- Net names from labels, power symbols, and pin associations\n- Custom properties (F4+ fields: MPN, manufacturer, distributor PNs)\n\n### Remaining Limitations\n\n- **Pin coverage depends on `.lib` availability** — components whose `.lib` files aren't in the repo (standard KiCad system libs like `power`, `device`, `conn`) use built-in fallbacks for common symbols (R, C, L, D, LED, transistors). Uncommon standard library symbols may lack pin data.\n- **No ki_keywords** — P-channel detection relies on lib_id and value only\n\n### Hierarchical Legacy Designs\n\nTop-level `.sch` has `$Sheet` blocks with `F1 \"subsheet.sch\"` pointing to sub-sheet files. Parse all sub-sheets. Hierarchical labels in sub-sheets connect to pins on the sheet block in the parent.\n\n---\n\n## Validation Methodology\n\nWhen verifying analyzer output (or your own manual parse) against the raw schematic:\n\n### Component Count Validation\n\n1. Count all `(symbol (lib_id ...))` blocks after `(lib_symbols)` section\n2. Subtract power symbols (`#PWR`, `#FLG` references)\n3. Result should exactly match the analyzer's `component_count`\n\n### Net Count Validation\n\n1. Count all `(wire ...)` blocks to verify wire count\n2. The number of unique named nets should match approximately (unnamed nets may differ in grouping)\n3. Spot-check 3-5 specific nets by tracing pins → wires → labels manually\n\n### Signal Analysis Validation\n\nFor each detected subcircuit:\n1. Verify the component IS what the analyzer says (check lib_id, value)\n2. Verify the pin connections are as reported (trace through nets)\n3. Check for false positives: is this detection actually correct?\n4. Check for false negatives: are there obvious subcircuits the analyzer missed?\n\n**Severity guide:**\n- **HIGH**: Wrong component data (extraction bug) or grossly incorrect detection\n- **MEDIUM**: Misleading detection (regulator classified wrong topology, wrong gain)\n- **LOW**: Minor cosmetic issue (missing unit number, suboptimal configuration label)\n\n### Known Edge Cases\n\n- **Custom libraries**: Components from project-specific libraries may lack keywords that standard KiCad libraries have. Regulators, op-amps, and transistors from custom libs may not be detected.\n- **Multi-unit symbols**: LM324 (quad op-amp), dual inductors, relays — each unit needs separate analysis. Pin numbers are unit-specific.\n- **Unit-0 shared pins**: In KiCad lib_symbols, `_0_1` sub-symbols contain pins shared by all units (typically power: VCC, GND). These must be included with every placed unit.\n- **Rescue libraries**: KiCad creates `*-rescue` libraries during migration. Check for `lib_prefix == \"power\"` exactly, not substring match (e.g., `dc-power-supply-rescue` is NOT a power library).\n- **Eagle .sch files**: Not KiCad format — will output 0 components. These are XML or binary and require separate tools.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":12834,"content_sha256":"bf2de339cebfb4e8d0fe4b93e302a9ef60d1bce4468e8cd3d6d90ffedb0a92eb"},{"filename":"references/net-tracing.md","content":"# Tracing Net Connectivity in Raw `.kicad_sch` Files\n\nKiCad schematics don't store explicit netlists — connectivity is implicit via coordinate matching. To verify a connection between two pins:\n\n## CRITICAL: Y-axis inversion\n\n**This is the single most common source of errors when tracing nets programmatically.** KiCad symbol library coordinates use math convention (Y-up), but schematic placement coordinates use screen convention (Y-down). You MUST subtract pin Y from symbol Y:\n\n```\nabsolute = (symbol_X + pin_X, symbol_Y - pin_Y)\n```\n\n**Getting this wrong inverts the entire pin map** — pin 1 appears where pin N should be, and vice versa. Every \"missing connection\" or \"wrong pin\" finding that comes from coordinate math should be double-checked for this error. If a script reports that a pin is unconnected but the user says the schematic is correct, the Y-axis transform is almost certainly wrong.\n\n## Step 1: Find pin positions in the symbol library definition\n\nEach symbol has pins defined with relative offsets in the `lib_symbols` section:\n```\n(symbol \"BSS84_1_1\"\n (pin input line (at -5.08 0 0) ... (number \"1\")) ; Gate\n (pin passive line (at 2.54 5.08 270) ... (number \"3\")) ; Drain\n (pin passive line (at 2.54 -5.08 90) ... (number \"2\")) ; Source\n)\n```\n\n## Step 2: Calculate absolute pin positions\n\nApply the symbol's placement transform: `(at X Y ANGLE)`.\n\n**No rotation (0 deg):**\n- Pin at relative (px, py) -> absolute **(X + px, Y - py)**\n- The Y subtraction is mandatory — symbol pin coordinates use math convention (up = positive Y), while schematic coordinates use screen convention (down = positive Y).\n\n**With rotation:** Apply rotation matrix to pin offset BEFORE the Y inversion, then add to symbol position.\n- 90 deg CW: (px, py) -> (py, -px) -> absolute (X + py, Y - (-px)) = (X + py, Y + px)\n- 180 deg: (px, py) -> (-px, -py) -> absolute (X + (-px), Y - (-py)) = (X - px, Y + py)\n- 270 deg CW: (px, py) -> (-py, px) -> absolute (X + (-py), Y - px) = (X - py, Y - px)\n\nExample: Symbol at (161.29, 176.53), pin at relative (-5.08, 0), no rotation:\n- Absolute: (161.29 + (-5.08), 176.53 - 0) = (156.21, 176.53)\n\nExample: Resistor at (152.4, 176.53) rotated 90 deg, pin 1 at relative (0, 3.81):\n- Rotated offset: (3.81, 0) -> absolute: (152.4 + 3.81, 176.53 - 0) = (156.21, 176.53)\n- Same point as the gate pin above -> directly connected!\n\n## Important: Multi-sided IC symbols have pins on different sides at the same Y-coordinate\n\nLarge IC symbols (e.g., ESP32 modules) have pins on the left **and** right sides. Two different pins can share the same Y-coordinate but have very different X-coordinates. A `no_connect` or wire at `(91.44, 77.47)` is NOT the same pin as a wire endpoint at `(60.96, 77.47)` — they are on opposite sides of the symbol.\n\n**Always verify BOTH X and Y** when matching coordinates. Use exact floating-point comparison — KiCad stores coordinates with nanometer precision internally, and properly connected elements share the exact same coordinate values. If you see any difference (even 0.001mm), the coordinates are NOT the same point; they indicate a wiring error or coordinate transform bug. To determine which side a pin exits from:\n1. Find the pin's relative offset in the `lib_symbols` definition — negative X = left side, positive X = right side\n2. Apply the symbol's placement transform to get the absolute position\n3. Match against wires/labels/no_connects using the **exact** (X, Y) pair\n\nDo not assume a pin exits on a particular side based on the pin name or number alone.\n\n## Step 3: Trace wires from pin positions\n\nSearch for `(wire (pts (xy X1 Y1) (xy X2 Y2)))` where one endpoint matches the pin position. Follow the wire chain endpoint-to-endpoint.\n\n**KiCad 9 wire format note:** The `(wire` keyword, `(pts` keyword, and coordinate data may be on separate lines:\n```\n(wire\n (pts\n (xy 41.91 77.47) (xy 60.96 77.47)\n )\n```\nWhen extracting wires programmatically, search up to 4-5 lines ahead from `(wire` to find the `(xy ...)` coordinates.\n\n## Step 4: Identify net names at wire endpoints\n\nLook for:\n- **Power symbols**: `(lib_id \"power:GND\")` or `(lib_id \"power:+BATT\")` placed at a wire endpoint — the symbol's Value property is the net name\n- **Labels**: `(label \"NET_NAME\" (at X Y ...))` at a wire endpoint\n- **Global labels**: `(global_label \"NET_NAME\" (at X Y ...))` for cross-sheet nets\n- **Junctions**: `(junction (at X Y))` marks where crossing wires connect\n- **Other component pins**: Another symbol's pin at the same coordinate\n\n**Global label parsing note (KiCad 9):** The `(at ...)` is NOT on the line immediately after `(global_label \"...\")`. There is a `(shape ...)` line in between:\n```\n(global_label \"EN_5V\"\n (shape input)\n (at 43.18 128.27 180)\n```\nWhen extracting labels, search 2-3 lines ahead for the `(at ...)` coordinates, not just the next line.\n\n**Labels connect via wires, not just at pin endpoints.** A global label is typically placed at the far end of a short wire stub extending from the pin. To find which label connects to which pin, you must trace the wire chain from the pin endpoint to the label position — checking only the exact pin coordinate will miss most connections.\n\n## Step 5: Verify with junctions\n\nIf a wire passes through a point where another wire starts, they only connect if there's a `(junction ...)` at that point, OR if the wire endpoint exactly matches.\n\n## Common False Positive Patterns\n\n### Reference designator reuse\nWhen a component is removed from a schematic (e.g., R13 was a GPIO pullup), its reference designator may be reused for a completely different component (e.g., R13 becomes a gate pulldown for a FET). Do not assume a reference designator has the same function across schematic revisions. Always check what the component actually connects to in the current schematic.\n\n### Connector naming conventions\nNot all \"programming\" connectors are JTAG. A 2x3 header (J2) with TX, RX, GND, 3V3, EN, and BOOT pins is a **serial programming header** (UART), not a JTAG header. JTAG requires TCK, TMS, TDI, TDO signals. Check the actual pin assignments before labeling a connector.\n\n## Multi-Unit Symbols\n\nComponents like LM324 (4 opamps), CD4066 (4 switches), or STM32 (multi-bank pin assignments) contain multiple units in one package. Each unit is a separate `_U_1` / `_U_2` / etc. sub-symbol in the `lib_symbols` section. When tracing nets:\n\n1. **Identify which unit is placed** — the placed symbol's `lib_id` suffix (e.g., `LM324_1_1` = unit 1) tells you which sub-symbol provides the pin offsets\n2. **Each unit has its own pin set** — unit 1 of an LM324 has pins 1/2/3 (IN+/IN-/OUT), unit 2 has pins 5/6/7, etc. Power pins (VCC/GND) are typically in a shared sub-symbol `_0_1`\n3. **Power pins may not be placed** — the `_0_1` sub-symbol with VCC/GND is often placed on only one instance (or a dedicated power sheet). Don't flag missing power connections on other units — check if any unit of the same component has them connected\n4. **Reference designator is shared** — all units share the same reference (e.g., U1A, U1B, U1C, U1D are all U1). The analyzer reports them as separate symbol instances with the same `reference` field\n\nTo find all units of a component: search for placed symbols where the `lib_id` base name matches (ignoring the `_U_V` suffix) and the `reference` property is the same.\n\n## Complete Example\n\nTo verify Q4 (P-FET) gate connects to R13 -> GND:\n1. Q4 at (161.29, 176.53), BSS84 Gate pin at (-5.08, 0) -> absolute **(156.21, 176.53)**\n2. R13 at (152.4, 176.53) rotated 90 deg, Pin 1 at (0, 3.81) -> rotated (3.81, 0) -> absolute **(156.21, 176.53)** — same point, direct connection\n3. R13 Pin 2 at (0, -3.81) -> rotated (-3.81, 0) -> absolute **(148.59, 176.53)**\n4. Wire from (147.32, 176.53) to (148.59, 176.53) connects to R13 Pin 2\n5. Junction at (147.32, 176.53), wire to (147.32, 177.8)\n6. `power:GND` symbol at (147.32, 177.8) -> **GND net**\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":7965,"content_sha256":"c242402dd1f1a0114e4a2586a79b1505ceb6ffd5f84beffc4cbae9679fa7d34f"},{"filename":"references/output-schema.md","content":"# Analyzer JSON Output Schema\n\nQuick reference for the JSON output of the three analysis scripts. Use `--schema` on any script for the authoritative, always-in-sync version. This document provides additional context and common extraction patterns.\n\n## analyze_schematic.py\n\n| Key | Type | Description |\n|-----|------|-------------|\n| `analyzer_type` | string | Always `\"schematic\"` |\n| `schema_version` | string | Semver (currently `\"1.3.0\"`) |\n| `summary` | object | `{total_findings: int, by_severity: {error, warning, info}}` |\n| `trust_summary` | object | Trust posture (see below) |\n| `findings` | array | All findings — subcircuit detections, validation checks, design observations (see below) |\n| `file` | string | Input file path |\n| `kicad_version` | string | Generator version |\n| `file_version` | string | KiCad file format version |\n| `title_block` | object | `{title, date, rev, company, comments: {n: string}}` |\n| `statistics` | object | Counts and summaries (see below) |\n| `bom` | array | Deduplicated BOM with quantities |\n| `components` | array | Every component with full properties |\n| `nets` | object | Net connectivity map keyed by net name |\n| `subcircuits` | array | Hierarchical sub-sheets |\n| `ic_pin_analysis` | array | Per-IC pin mapping (list of IC records) |\n| `rail_voltages` | object | `{net_name: voltage_float}` — auto-detected from regulator outputs and power symbol names |\n| `net_classifications` | object | Per-net class (power/data/analog/output_drive/etc.) |\n| `design_analysis` | object | Buses, power domains, ERC warnings |\n| `connectivity_issues` | object | Single-pin nets, multi-driver nets, floating nets |\n\n### trust_summary\n\n```\ntotal_findings: int,\ntrust_level: \"high\" | \"mixed\" | \"low\",\nby_confidence: {deterministic: int, heuristic: int, \"datasheet-backed\": int},\nby_evidence_source: {datasheet|topology|heuristic_rule|symbol_footprint|bom|geometry|api_lookup: int},\nprovenance_coverage_pct: float,\nbom_coverage: {mpn_pct: float, datasheet_pct: float} // schematic only\n```\n\nEmitted by all 6 analyzers (schematic, PCB, gerber, thermal, EMC, cross_analysis).\n\n### statistics\n\n```\ntotal_components: int, unique_parts: int, dnp_parts: int,\ntotal_nets: int, total_wires: int, total_no_connects: int,\ncomponent_types: {type_name: count},\npower_rails: [string],\nmissing_mpn: [reference], missing_footprint: [reference]\n```\n\n### bom entries\n\n```\n{value, footprint, mpn, manufacturer, digikey, mouser, lcsc, element14,\n datasheet, description, references: [string], quantity: int, dnp: bool, type}\n```\n\n### components entries\n\n```\n{reference, value, lib_id, footprint, datasheet, description,\n mpn, manufacturer, digikey, mouser, lcsc, element14,\n x: float, y: float, angle: float, mirror_x: bool, mirror_y: bool,\n unit: int|null, uuid, in_bom: bool, dnp: bool, on_board: bool,\n type, keywords, pins: [{number, name, type}],\n parsed_value: {value: float, unit: string}}\n```\n\n### nets entries\n\nKeyed by net name:\n```\n{name, pins: [{component, pin_number, pin_name, pin_type}], point_count: int}\n```\n\n### findings entries\n\nAll subcircuit detections, validation checks, and design observations are in the flat `findings[]` array. Each finding has a common envelope:\n\n```\n{detector, rule_id, category, severity, confidence, summary, recommendation,\n evidence_source, fix_params, report_context, ...detector-specific fields}\n```\n\nUse `detector` to filter by type. The `finding_schema.py` module provides `get_findings(data, detector)` and `group_findings(data)` helpers, plus `Det` constants for all detector names. `group_findings_legacy(data)` reconstructs the old dict-of-lists layout — it remains for backward compatibility but is scheduled for removal in v1.4 alongside the consumer rewrite of `what_if.py` and `diff_analysis.py`.\n\n**Detector types and their key fields:**\n\n| Detector | Key Fields |\n|----------|------------|\n| `voltage_dividers` | `top_ref, bottom_ref, ratio, estimated_vout, input_net, output_net` |\n| `rc_filters` | `resistor, capacitor, cutoff_frequency_hz, type: lowpass\\|highpass` |\n| `lc_filters` | `inductor, capacitors, resonant_formatted` |\n| `power_regulators` | `ref, value, lib_id, topology, input_rail, output_rail, estimated_vout, vref_source` |\n| `crystal_circuits` | `reference, value, frequency, type: passive\\|active_oscillator, load_caps` |\n| `opamp_circuits` | `reference, configuration, gain` |\n| `transistor_circuits` | `reference, type, load_classification` |\n| `bridge_circuits` | `topology, fet_refs` |\n| `protection_devices` | `type, reference, protected_net` |\n| `current_sense` | `shunt: {ref, value, ohms}, sense_ic: {ref, value, type}, high_net, low_net, max_current_50mV_A, max_current_100mV_A` |\n| `decoupling` | `capacitor_ref, ic_ref, distance` |\n| `rf_matching` | `antenna, topology: pi_match\\|L_match\\|T_match, components: [{ref, type, value}], target_ic` |\n| `key_matrices` | `rows, cols, diodes` |\n| `isolation_barriers` | `isolator_ref, side_a_nets, side_b_nets` |\n| `ethernet_interfaces` | `phy_ref, magnetics_ref, connector_ref` |\n| `memory_interfaces` | `type, bus_signals` |\n| `rf_chains` | `components_in_chain` |\n| `bms_systems` | `ic_ref, cell_count` |\n| `battery_chargers` | `ref, value, charger_type, charge_current` |\n| `motor_drivers` | `ref, value, driver_type: stepper\\|dc_brushed_h_bridge` |\n| `esd_coverage_audit` | `connector_ref, interface_type, risk_level, coverage: full\\|partial\\|none, unprotected_nets` |\n| `debug_interfaces` | `connector_ref, interface_type: swd\\|jtag, pins_found, target_ic, status` |\n| `power_path` | `ref, type: load_switch\\|ideal_diode\\|power_mux\\|usb_pd_controller, input_rail, output_rail, enable_net` |\n| `adc_circuits` | `ref, type: external_adc\\|voltage_reference, resolution_bits, interface, input_channels, vref_source` |\n| `reset_supervisors` | `ref, type: voltage_supervisor\\|watchdog\\|rc_reset, monitored_rail, threshold_voltage, target_ic` |\n| `clock_distribution` | `ref, type: clock_generator\\|pll\\|oscillator_output, outputs, consumers, series_termination` |\n| `display_interfaces` | `ref, type: display\\|touch_controller, display_type, interface, backlight` |\n| `sensor_interfaces` | `ref, type: motion\\|environmental\\|magnetic, interface, interrupt_pins, bus_peers` |\n| `level_shifters` | `ref, type: level_shifter_ic\\|discrete_level_shifter, side_a, side_b, shifted_nets` |\n| `audio_circuits` | `ref, type: audio_amplifier\\|audio_codec, amplifier_class, interface, output_nets` |\n| `led_driver_ics` | `ref, type: pwm_led_driver\\|matrix_led_driver\\|constant_current_led_driver, channels, current_set` |\n| `rtc_circuits` | `ref, type: rtc, interface, has_internal_oscillator, external_crystal, battery_backup` |\n| `led_audit` | `ref, drive_method: resistor_limited\\|direct_drive\\|ic_direct, series_resistor, estimated_current_mA` |\n| `thermocouple_rtd` | `ref, type: thermocouple_amplifier\\|rtd_interface, interface, reference_resistor` |\n| `power_sequencing_validation` | `power_tree, enable_chains, issues` |\n\n### design_analysis\n\n```\nnet_classification: {net: {type: 'power'|'ground'|'data'|...}}\npower_domains: {ic_power_rails: {ref: {voltage, rail_net}}, ...}\ncross_domain_signals: [signals crossing voltage domains]\nbus_analysis: {i2c|spi|uart|can|sdio|usb: [bus_instances]}\ndifferential_pairs: [{positive, negative, type, shared_ics, has_esd, ...}]\nerc_warnings: [string]\npassive_warnings: [string]\n```\n\n### Optional sections (included when applicable)\n\n`power_budget`, `power_sequencing`, `pdn_impedance`, `sleep_current_audit`, `usb_compliance`, `inrush_analysis`, `bom_optimization`, `test_coverage`, `assembly_complexity`, `sheets` (multi-sheet only)\n\n---\n\n## analyze_pcb.py\n\n| Key | Type | Description |\n|-----|------|-------------|\n| `analyzer_type` | string | Always `\"pcb\"` |\n| `schema_version` | string | Semver (currently `\"1.3.0\"`) |\n| `summary` | object | `{total_findings: int, by_severity: {error, warning, info}}` |\n| `trust_summary` | object | Trust posture (see schematic section) |\n| `findings` | array | All PCB findings — DFM, thermal, placement, assembly checks |\n| `file` | string | Input file path |\n| `kicad_version` | string | Generator version |\n| `file_version` | string | Format version |\n| `statistics` | object | Board-level counts and metrics |\n| `layers` | array | `[{name, type, index}]` |\n| `setup` | object | Design rules, clearances |\n| `nets` | object | `{str(net_id): net_name}` (net-ID-keyed; use `net_name_to_id` for reverse) |\n| `net_name_to_id` | object | `{net_name: int}` — reverse of `nets` |\n| `board_outline` | object | Bounding box, outline type, edge segments |\n| `component_groups` | object | `{prefix: {count, type, examples}}` |\n| `footprints` | array | Component placements with pad-net mapping |\n| `tracks` | object | Segment/arc counts, width/layer distribution |\n| `vias` | object | Count, size distribution, analysis |\n| `zones` | array | Copper zone definitions |\n| `connectivity` | object | Routing completeness, unconnected pads |\n| `net_lengths` | object | Per-net trace length, via count, layer transitions |\n\n### statistics\n\n```\nfootprint_count: int, front_side: int, back_side: int,\nsmd_count: int, tht_count: int, copper_layers_used: int,\ncopper_layer_names: [string], track_segments: int, via_count: int,\nzone_count: int, total_track_length_mm: float,\nboard_width_mm: float|null, board_height_mm: float|null,\nnet_count: int, routing_complete: bool, unrouted_net_count: int\n```\n\n### footprints entries\n\n```\n{reference, value, lib_id, layer, x: float, y: float, angle: float,\n type: smd|through_hole|mixed, mpn, manufacturer, description,\n exclude_from_bom: bool, exclude_from_pos: bool, dnp: bool,\n pad_nets: {pad_number: {net, pin}}, connected_nets: [string]}\n```\n\n### tracks (--full adds segments/arcs arrays)\n\n```\nsegment_count: int, arc_count: int,\nwidth_distribution: {width_mm: count}, layer_distribution: {layer: count}\n```\n\n### vias (--full adds vias array)\n\n```\ncount: int, size_distribution: {size: count}\nvias (--full): [{x, y: float, layers: [string], size, drill: float,\n net: int|null, type: 'through|blind|buried|micro'}]\nvia_in_pad: [{component, pad, via_x, via_y, via_drill, same_net,\n via_type: 'through|blind|buried|micro'}]\nvia_fanout: {ref: {via_count, fanout_traces}}\n```\n\n### Optional sections\n\n`power_net_routing`, `decoupling_placement`, `ground_domains`, `trace_proximity` (with `--proximity`), `layer_transitions`, `silkscreen`, `board_metadata`, `dfm_summary`, `placement_density`, `copper_presence_summary`, `board_thickness_mm`. Sections previously at top level (`thermal_analysis`, `thermal_pad_vias`, `tombstoning_risk`, `placement_analysis`, `current_capacity`, `copper_presence`, `dfm`) are now flattened into `findings[]`.\n\n---\n\n## analyze_gerbers.py\n\n| Key | Type | Description |\n|-----|------|-------------|\n| `analyzer_type` | string | Always `\"gerber\"` |\n| `schema_version` | string | Semver (currently `\"1.3.0\"`) |\n| `summary` | object | `{total_findings: int, by_severity: {error, warning, info}}` |\n| `trust_summary` | object | Trust posture (see schematic section) |\n| `findings` | array | All Gerber findings — missing layers, alignment, drill, paste, board outline |\n| `directory` | string | Scan directory path |\n| `generator` | string | KiCad/other/unknown |\n| `layer_count` | int | Detected copper layer count |\n| `board_dimensions` | object | `{x_min, x_max, y_min, y_max, width_mm, height_mm}` |\n| `statistics` | object | `{gerber_files, drill_files, total_holes, total_flashes, total_draws}` |\n| `completeness` | object | Expected vs found layers, coverage percent |\n| `alignment` | object | Per-layer coordinate ranges for alignment check |\n| `drill_classification` | object | Via/component/mounting hole breakdown |\n| `pad_summary` | object | `{smd_apertures, via_apertures, component_holes, tht}` |\n| `gerbers` | array | Parsed Gerber files with apertures and attributes |\n| `drills` | array | Parsed drill files with tools and hole counts |\n\n### gerbers entries\n\n```\n{file, filename, layer_type, format: {zero_omit, notation, x/y_integer, x/y_decimal},\n units: mm|inch, flash_count: int, draw_count: int, region_count: int,\n apertures: {d_code: {type, params, function}}, x2_attributes: {FileFunction, ...}}\n```\n\n### drills entries\n\n```\n{file, filename, units: mm|inch|null, type: PTH|NPTH|unknown,\n hole_count: int, coordinate_range, tools: {tool_id: {diameter_mm, hole_count}},\n x2_attributes}\n```\n\n### Optional sections\n\n`component_analysis`, `net_analysis`, `trace_widths`, `job_file`, `zip_archives`, `connectivity` (with `--full`)\n\n---\n\n## Common extraction patterns\n\n```python\nimport json\ndata = json.load(open('analysis.json'))\n\n# Component references\n[c['reference'] for c in data['components']]\n\n# BOM summary\nfor b in data['bom']:\n print(f\"{b['quantity']}x {b['value']} ({b['references']})\")\n\n# Power regulators with output voltage\nfor r in data['findings']:\n if r.get('detector') == 'power_regulators':\n print(f\"{r['ref']}: {r.get('estimated_vout', '?')}V ({r['topology']})\")\n\n# Net pin list\nfor p in data['nets']['NET_NAME']['pins']:\n print(f\" {p['component']}.{p['pin_number']} ({p['pin_name']})\")\n\n# Footprint pad-to-net mapping\nfor f in data['footprints']:\n print(f\"{f['reference']}: {f['pad_nets']}\")\n\n# Statistics\nprint(json.dumps(data['statistics'], indent=2))\n\n# All ICs with their pin counts\nfor ic in data['ic_pin_analysis']:\n print(f\"{ic['reference']} ({ic['value']}): {len(ic['pin_summary'])} pins — {ic['function']}\")\n```\n\n**Formatting tip**: Use f-strings or `json.dumps()` for output — never `%s` or `format()` with lists or dicts, as these raise `TypeError`.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":13644,"content_sha256":"29b86858adae359e65532f5b1843c5e0b3e52854ae75c3ee43ad482e891de21b"},{"filename":"references/pcb-layout-analysis.md","content":"# Deep PCB Layout Analysis (`.kicad_pcb`)\n\nThis reference covers in-depth PCB analysis techniques beyond what the `analyze_pcb.py` script provides automatically. For routine analysis, run the script first — it handles via classification, annular ring checks, connectivity, placement, thermal vias, current capacity, and signal integrity automatically.\n\nUse this reference for:\n- **Impedance calculations** from stackup parameters\n- **DRC/net class auditing** against manufacturer capabilities\n- **Power electronics design review** (trace current, sense routing, thermal management)\n- **Differential pair validation** (impedance, length matching)\n- **Manual script-writing patterns** when building custom analysis tools\n\nFor the PCB file format details (S-expression structure, fields, layer definitions), see `file-formats.md`.\n\n## Table of Contents\n\n1. [Setup and Stackup](#setup-and-stackup)\n2. [Net Classes and Design Rules](#net-classes-and-design-rules)\n3. [Differential Pairs](#differential-pairs)\n4. [PCB Review Techniques](#pcb-review-techniques) (includes Power Electronics Design Review)\n5. [Return Path Analysis](#return-path-analysis) — Reference plane continuity for high-speed signals\n6. [Copper Balance Assessment](#copper-balance-assessment) — Layer symmetry for warp prevention\n7. [Board Edge Clearance](#board-edge-clearance) — DFM clearance from board edges and depaneling features\n8. [Writing Analysis Scripts](#writing-analysis-scripts) (S-expr parsing, coordinate transforms, spatial queries)\n\n---\n\n## Setup and Stackup\n\nThe `(setup ...)` section contains board-level configuration. The analyzer extracts layer count, thickness, copper finish, and paste ratios automatically. Use this section for **impedance calculations** which require the full stackup detail.\n\n### Stackup Structure\n```\n(setup\n (stackup\n (layer \"F.Cu\" (type \"copper\") (thickness 0.035))\n (layer \"dielectric 1\" (type \"core\") (thickness 1.51) (material \"FR4\") (epsilon_r 4.5) (loss_tangent 0.02))\n (layer \"B.Cu\" (type \"copper\") (thickness 0.035))\n (copper_finish \"None\")\n (dielectric_constraints no)\n )\n)\n```\n\n### Key Stackup Fields\n\n| Field | Description |\n|-------|-------------|\n| `thickness` | Layer thickness in mm (copper: typically 0.035 = 1oz) |\n| `material` | Dielectric material (FR4, Rogers, etc.) |\n| `epsilon_r` | Relative permittivity (affects impedance calculations) |\n| `loss_tangent` | Dielectric loss (affects high-frequency signal integrity) |\n| `copper_finish` | Surface finish: `None`, `HASL`, `ENIG`, `OSP`, etc. |\n\n### Impedance Analysis\n\n- Total board thickness = sum of all layer thicknesses\n- Copper weight: 0.035mm = 1oz, 0.070mm = 2oz\n- For impedance control, you need `epsilon_r` and dielectric thickness between signal and reference layers\n- Compare stackup to manufacturer capabilities (e.g., JLCPCB standard 4-layer stackup)\n- Use an impedance calculator with the board's specific `epsilon_r` and dielectric thickness\n\n---\n\n## Net Classes and Design Rules\n\nNet classes define per-net routing constraints. They appear in the `.kicad_pro` file (JSON) rather than the `.kicad_pcb` file, but the PCB enforces them.\n\nIn `.kicad_pro`:\n```json\n\"net_settings\": {\n \"classes\": [\n {\n \"name\": \"Default\",\n \"clearance\": 0.2,\n \"track_width\": 0.2,\n \"via_diameter\": 0.6,\n \"via_drill\": 0.3,\n \"microvia_diameter\": 0.3,\n \"microvia_drill\": 0.1,\n \"diff_pair_width\": 0.2,\n \"diff_pair_gap\": 0.15\n },\n {\n \"name\": \"Power\",\n \"clearance\": 0.25,\n \"track_width\": 0.5,\n \"via_diameter\": 0.8,\n \"via_drill\": 0.4,\n \"nets\": [\"+3V3\", \"+5V\", \"+VBAT\", \"GND\"]\n }\n ]\n}\n```\n\n### Verifying Track Widths Against Net Classes\n\nTo check if all tracks comply with their net class:\n1. Read net class definitions from `.kicad_pro`\n2. Build a mapping: net name -> net class -> min track width\n3. Read all `(net N \"name\")` declarations in the PCB\n4. For each `(segment ... (width W) ... (net N) ...)`, verify W >= net class minimum\n5. Flag violations\n\n### Netclass Audit for Power Electronics\n\nPower electronics designs should have multiple netclasses. A single \"Default\" netclass is a red flag for any design with high-current paths. Expected netclasses: Power (wide traces), GateDrive (controlled routing), Sense (guarded/matched).\n\n---\n\n## Differential Pairs\n\nDifferential pairs in KiCad use a naming convention: nets ending in `+` and `-` (e.g., `USB_D+`/`USB_D-`) or `_P`/`_N`.\n\n### Identifying Differential Pairs\n\n1. Scan all net names for matching +/- or _P/_N pairs\n2. For each pair, collect all segments on both nets\n3. Verify routing:\n - Both traces should be on the same layer\n - Widths should match (from diff_pair_width in net class)\n - Gap should be consistent (from diff_pair_gap in net class)\n - Length should be matched (within tolerance, typically \u003c 0.1mm)\n\n### Common Differential Pairs\n\n| Interface | Impedance | Typical Width/Gap (FR4 1.6mm) |\n|-----------|-----------|-------------------------------|\n| USB 2.0 | 90 ohm diff | 0.2mm / 0.15mm |\n| USB 3.0 | 85 ohm diff | varies by stackup |\n| HDMI | 100 ohm diff | varies by stackup |\n| Ethernet | 100 ohm diff | varies by stackup |\n| LVDS | 100 ohm diff | varies by stackup |\n\nActual dimensions depend on stackup — use an impedance calculator with the board's `epsilon_r` and dielectric thickness.\n\n---\n\n## PCB Review Techniques\n\n### Power Electronics Design Review\n\nFor motor controllers, power supplies, and other high-current designs, check these additional items beyond what the analyzer provides.\n\n#### Net Function Verification\n\n**Critical — do this before flagging trace widths.** Net names alone are unreliable indicators of current level. In power electronics, sense/feedback nets often run parallel to power nets with similar names. Before flagging a net for insufficient trace width:\n\n1. Check what components the net connects to (from analyzer output). Resistors, capacitors, and op-amp inputs indicate a sense/filter network carrying microamps — 0.2mm is fine.\n2. Check the schematic: trace the net from its label to the actual pins. Power nets connect directly to MOSFET drain/source, connector pins, or inductor terminals. Sense nets connect through resistor dividers to ADC/comparator inputs.\n3. Common sense net patterns: resistor dividers off motor phases (back-EMF sensing), Kelvin sense traces from shunt resistors, voltage monitor taps.\n\n#### Trace Width vs Current Capacity\n\nThe analyzer provides `current_capacity` data with min/max track widths per net. Cross-reference against net function:\n\n| Net Function | Minimum Width (1oz Cu, 10°C rise) | Notes |\n|---|---|---|\n| Signal (\u003c 100mA) | 0.15-0.2mm | Default netclass is fine |\n| Low power (100mA-1A) | 0.3-0.5mm | LED drives, logic power |\n| Medium power (1-5A) | 0.5-1.0mm | Motor phases (small motors) |\n| High power (5-20A) | 1.0-3.0mm or copper pour | Motor phases, battery, VM |\n| Very high power (>20A) | Copper pour + via stitching | Power MOSFETs, motor outputs |\n\nInternal layers carry ~50% of external layer current. These are rough estimates — use an IPC-2221 calculator for precise values.\n\n#### Voltage Derating\n\nCross-reference capacitor voltage ratings (from schematic Value field) against the power rail they connect to:\n- Electrolytic: derate to 80% of rated voltage (63V cap → max 50V rail)\n- Ceramic (Class II): derate to 80% or less (DC bias causes capacitance drop)\n- Film: derate to 70-80%\n- Flag any cap at >80% of its voltage rating on a power rail\n\n#### Current Sense Routing\n\nFor shunt resistor current sensing (common in motor control):\n- Sense traces (to op-amp inputs) should be Kelvin-connected — routed directly from the resistor pads, not from the power trace\n- Check for zones on sense nets — these provide low-impedance Kelvin sensing\n- Sense traces should be routed as a pair, away from switching nodes\n\n### Signal Integrity Checks\n\nThe analyzer provides trace proximity data (with `--proximity`) and layer transition tracking. For deeper analysis:\n\n1. **Return path continuity**: For high-speed signals, check that the reference plane (usually GND) is continuous under the signal trace. Look for zone splits or cutouts on the GND layer beneath high-speed traces.\n\n2. **Via stubs**: Through-hole vias used for inner-layer connections have stubs that can cause signal reflections above ~3 GHz. Check if any high-speed signals use through vias to inner layers.\n\n3. **Trace length matching**: For parallel buses (DDR, RGMII), traces should be length-matched. Use the analyzer's `net_lengths` data to compare.\n\n4. **90-degree corners**: Sharp 90-degree bends are acceptable for most designs but should be avoided for high-speed signals (>1 GHz).\n\n### Copper Balance Analysis\n\nCheck that copper is roughly balanced between layers — imbalanced copper can cause board warping during manufacturing. Use the analyzer's per-layer segment counts and zone data.\n\n---\n\n## Return Path Analysis\n\nFor high-speed signals, the return current flows on the nearest reference plane directly beneath the trace. Any discontinuity in this return path creates a loop antenna that radiates EMI and degrades signal integrity.\n\n### Procedure\n\n1. **Identify high-speed nets**: USB data pairs, SPI CLK/MOSI/MISO (>10 MHz), UART at high baud rates (>1 Mbaud), DDR signals, clock distribution nets, RMII/RGMII\n2. **Determine trace layer**: from the analyzer output or PCB file, identify which copper layer each high-speed signal is routed on\n3. **Check reference plane coverage**: verify that the adjacent plane layer (usually GND) has continuous copper fill beneath the entire trace path. Look for:\n - Zone cutouts or keepout areas under the signal trace\n - Splits in the ground plane (caused by routing power traces on the ground layer)\n - Missing zone fill (unfilled areas due to clearance rules around other pads/vias)\n4. **Flag violations**: any high-speed trace that crosses a plane split or gap\n\n### Layer Transitions (Via Return Path)\n\nEvery via that moves a signal to a different layer changes its reference plane. If the reference planes on the two layers are different nets (e.g., signal moves from a layer referenced to GND to a layer referenced to VCC), the return current has no low-impedance path between the planes at the via location.\n\n**Check for each high-speed signal via:**\n- What is the reference plane on the departure layer?\n- What is the reference plane on the arrival layer?\n- If they differ, is there a stitching capacitor (100nF between the two planes) near the via?\n\nFor 2-layer boards, this is less applicable since both layers reference the same planes (or have no continuous plane at all — which is itself a concern for signals >10 MHz).\n\n### High-Risk Patterns\n\n| Pattern | Risk | Mitigation |\n|---------|------|------------|\n| Signal crossing a ground plane split | Return current detours around the split, creating a large loop | Route signal around the split, or bridge the split with a stitching via |\n| Signal via between GND-referenced and VCC-referenced layers | Return current has no path between planes | Place 100nF stitching cap between GND and VCC near the via |\n| High-speed trace on a layer with no adjacent plane | No defined return path, uncontrolled impedance | Route high-speed signals only on layers adjacent to continuous planes |\n| Multiple high-speed signals sharing a narrow ground corridor | Return currents overlap, causing crosstalk | Widen the ground area or separate the signals |\n\n---\n\n## Copper Balance Assessment\n\nImbalanced copper distribution between layers causes board warping during reflow soldering due to differential thermal expansion. This is a manufacturing concern, not an electrical one, but warped boards can cause assembly defects.\n\n### Procedure\n\n1. **Estimate per-layer copper coverage**: Use the analyzer's zone data and segment counts. Approximate copper fill percentage as: (total zone area + total trace area) / board area. Exact calculation requires zone polygon analysis, but a rough comparison between layers is usually sufficient.\n2. **Compare corresponding layer pairs**:\n - 2-layer board: F.Cu vs B.Cu\n - 4-layer board: F.Cu vs B.Cu (outer pair), In1.Cu vs In2.Cu (inner pair)\n3. **Flag imbalances**: if one layer in a pair has significantly more copper than the other\n\n### Guidelines\n\n| Board Type | Acceptable Imbalance | Common Issue |\n|------------|---------------------|--------------|\n| 2-layer | \u003c15% difference between F.Cu and B.Cu | One side has large ground pour, other side has only traces |\n| 4-layer | Inner layers usually balanced (full planes); check outer layers \u003c15% | Component-heavy front with sparse back |\n\n### Mitigation\n\n- Add copper fill (ground pour) to the sparse layer — this is the most common fix\n- For 2-layer boards, add a ground pour on the component side as well as the solder side\n- Thieving (non-functional copper patterns) can be added in empty areas but is less common in hobby designs\n\n### Severity\n\nFlag as **Suggestion** for \u003c20% imbalance, **Warning** for >20% imbalance. Boards with large power planes on one side and minimal copper on the other are most at risk.\n\n---\n\n## Board Edge Clearance\n\nComponents placed too close to the board edge risk damage during depaneling (V-score, tab routing, or saw cutting). This is a DFM (Design for Manufacturing) check.\n\n### Minimum Clearances\n\n| Component Type | Min Distance from Board Edge | Rationale |\n|----------------|----------------------------|-----------|\n| SMD (low profile) | 1 mm | Mechanical stress during handling |\n| SMD (tall, e.g., electrolytic caps) | 3 mm | Leverage from tall components amplifies stress |\n| Through-hole | 2 mm | Leads extend through board, vulnerable to flex |\n| BGA | 3 mm | Solder joints are stress-sensitive |\n| Connectors (edge-mounted) | 0 mm (intentionally at edge) | Verify they extend to/past edge as designed |\n| Mounting holes | 3 mm to nearest component | Board flexes around mounting points |\n\n### Depaneling Method Considerations\n\n| Method | Keep-out from Score/Tab | Notes |\n|--------|------------------------|-------|\n| V-score | 2 mm from V-score line | 0.3mm residual web can crack nearby joints during snap |\n| Tab routing (breakaway tabs) | 3 mm from tab connections | Mechanical stress during break-out radiates outward |\n| Saw cutting | 1 mm from cut line | Clean cut, minimal stress |\n| Laser cutting | 0.5 mm from cut line | Precision cut, heat-affected zone is small |\n\n### Procedure\n\n1. Get the board outline from the Edge.Cuts layer (the analyzer provides board dimensions)\n2. For each component, compute the minimum distance from any pad to the nearest board edge\n3. Compare against the clearance table above\n4. For panelized designs, also check clearance from panel rails and mouse bites\n\n### Severity\n\nFlag as **Warning** if components violate the minimums above. Flag as **Info** for edge-mounted connectors (intentional placement) — just verify they're oriented correctly.\n\n---\n\n## Writing Analysis Scripts\n\nWhen the analyzer doesn't cover a specific check, build a custom script. The `analyze_pcb.py` script uses the `sexp_parser.py` shared parser — import it directly rather than writing regex-based parsing.\n\n### Using the Shared Parser\n\n```python\nimport sys\nsys.path.insert(0, '\u003cskill-path>/scripts')\nfrom sexp_parser import parse_file, find_all, find_first, get_value, get_property, get_at\nfrom analyze_pcb import extract_footprints, extract_tracks, extract_vias, extract_nets\n\n# Parse and extract in one shot\ntree = parse_file('board.kicad_pcb')\nfootprints = extract_footprints(tree)\ntracks = extract_tracks(tree)\n```\n\nIf you can't import the shared parser (e.g., standalone script), see `manual-pcb-parsing.md` for regex-based patterns.\n\n### Coordinate Transforms\n\nPad positions in footprint definitions are **relative to the footprint origin**. To get absolute board coordinates:\n\n```python\nimport math\n\ndef pad_to_absolute(fp_x, fp_y, fp_angle_deg, pad_rx, pad_ry):\n \"\"\"Transform pad-relative coords to absolute board coords.\"\"\"\n rad = math.radians(-fp_angle_deg) # KiCad angles: CW positive in layout\n abs_x = fp_x + pad_rx * math.cos(rad) - pad_ry * math.sin(rad)\n abs_y = fp_y + pad_rx * math.sin(rad) + pad_ry * math.cos(rad)\n return abs_x, abs_y\n```\n\n### Net Function Classification\n\nBefore making current-capacity claims about a net, verify what the net actually does:\n\n```python\ndef classify_net(net_name, connected_refs):\n \"\"\"Classify a net as power, sense, signal, or ground.\"\"\"\n ref_prefixes = {ref[0] for ref in connected_refs}\n passive_only = ref_prefixes \u003c= {'R', 'C', 'L'}\n has_mosfets = 'Q' in ref_prefixes\n has_connectors = 'J' in ref_prefixes\n\n if passive_only:\n return 'sense' # Likely voltage divider / filter, microamp current\n if has_mosfets and has_connectors:\n return 'power' # Motor phase, power output\n return 'signal'\n```\n\n### Spatial Queries\n\n**Point-in-polygon** (for zone containment checks):\n```python\ndef point_in_polygon(x, y, polygon_pts):\n \"\"\"Ray-casting algorithm for point-in-polygon test.\"\"\"\n n = len(polygon_pts)\n inside = False\n j = n - 1\n for i in range(n):\n xi, yi = polygon_pts[i]\n xj, yj = polygon_pts[j]\n if ((yi > y) != (yj > y)) and (x \u003c (xj - xi) * (y - yi) / (yj - yi) + xi):\n inside = not inside\n j = i\n return inside\n```\n\n**Bounding box containment** (faster pre-filter):\n```python\ndef point_in_bbox(x, y, cx, cy, half_w, half_h, angle_deg=0):\n \"\"\"Check if point is within a rotated rectangle (pad bounding box).\"\"\"\n dx, dy = x - cx, y - cy\n if angle_deg != 0:\n rad = math.radians(angle_deg)\n dx, dy = dx*math.cos(rad) + dy*math.sin(rad), -dx*math.sin(rad) + dy*math.cos(rad)\n return abs(dx) \u003c= half_w and abs(dy) \u003c= half_h\n```\n\n### Common Pitfalls\n\n1. **Confusing pad-relative and absolute coordinates** — pad `(at ...)` inside a footprint is relative; segment/via `(start/at ...)` is absolute. Always transform pads before comparing.\n2. **Ignoring footprint rotation** — a pad at `(at 3 0)` in a footprint rotated 90° is actually at a different absolute position. The transform is not optional.\n3. **Net name vs net ID** — in KiCad ≤9, segments reference nets by numeric ID; build the ID→name map from `(net N \"name\")` declarations. In KiCad 10, nets are referenced by name string directly (no declarations section). The analyzer handles both formats transparently.\n4. **Zone polygon vs filled polygon** — `(polygon ...)` is the user-drawn boundary; `(filled_polygon ...)` is the actual copper after DRC clearance carving. Always use filled polygons for containment tests. The PCB analyzer extracts both: `outline_bbox`/`outline_area_mm2` for the boundary, `filled_bbox`/`filled_area_mm2`/`fill_ratio` for actual copper. The `copper_presence` section reports which components have zone copper on the opposite layer — use this instead of inferring from zone outlines. Zone fills can go stale if the board was edited after the last Fill All Zones (shortcut `B`).\n5. **Assuming net function from name** — net names like VPH*, VSENSE*, etc. can look like power nets but may be sense lines. Always verify by checking connected component types.\n6. **Measuring decoupling distance to IC center** — large modules (ESP32, etc.) can be 18+ mm long with power pins at one edge. Always measure to the IC's actual power pin positions.\n\n### Copper-Sensitive Components\n\nSome components require careful copper management on both layers. Use the analyzer's `copper_presence` data to verify these — don't infer from zone outlines.\n\n**Capacitive touch pads** (TP prefix, or pad-only footprints on touch nets):\n- Need NO copper on the opposite layer — ground planes under touch pads drastically reduce sensitivity by adding parasitic capacitance. But confirming copper absence isn't enough: check that **keepout zones** (rule areas) enforce this on the opposite layer. Without a keepout zone, the copper absence is accidental and one zone refill after a routing change could break touch sensitivity. If no keepout zones exist, flag as WARNING.\n- Need controlled clearance in same-layer ground pour (typically ≥1mm, check the controller's app note). Measure the actual clearance and compare against the spec minimum — if it's at the exact minimum, note the sensitivity margin concern and recommend increasing to 1.5× the minimum.\n- Trace to the controller should be thin (narrow reduces parasitic capacitance) and direct (no unnecessary length). Compare trace lengths across ALL touch pads — asymmetry >1.5× means different parasitic capacitance per channel, shifting baseline readings even with firmware calibration. Report the ratio.\n- Hatched ground pour around the pad is sometimes used instead of solid clearance — check the fill type\n- Report physical details for each pad: diameter/size, position, GND clearance (measured vs spec), trace width, trace length to controller\n\n**Antennas** (ANT prefix, antenna footprints, or wireless modules with PCB/integrated antennas like ESP32, nRF):\n- PCB trace antennas and module antennas need copper keep-out on ALL relevant layers for the antenna area — verify keepout zones exist and report their coordinates and layer coverage (e.g., \"Keepout zone on F.Cu+B.Cu: (8.49, 98.05) to (29.49, 146.05)\")\n- Ground plane should end at the antenna feed point, not extend under the radiating element\n- Check manufacturer's reference design for ground plane requirements — the module vendor's layout guide is the authoritative source for keepout dimensions. Always cite the reference when verifying: \"Correct per Espressif guidelines\"\n\n**RF components** (matching networks, baluns near antenna):\n- Controlled impedance traces need consistent ground reference\n- Ground plane voids under matching components can detune the network\n\nIn all cases, the `copper_presence.no_opposite_layer_copper` list in the analyzer output identifies components without opposite-layer zone copper — these are the isolation points to verify against the design intent.\n\n---\n\n## Datasheet-Driven PCB Validation\n\nThe schematic analysis methodology already prompts for datasheet cross-referencing (Vref lookup, pin verification, component values). PCB layout review needs the same rigor — many layout bugs are only visible when checked against the IC's datasheet recommendations.\n\n### Thermal Management\n\n- **Thermal vias**: Compare the number, size, and pattern of thermal vias under QFN/DFN/PowerPAD packages against the IC datasheet's recommended layout. Many datasheets specify exact via count, diameter, and grid pattern (e.g., TI's PowerPAD guidelines: 4×4 array of 0.3mm vias on 1.2mm pitch).\n- **Thermal via effective count methodology**: The `thermal_pad_vias` analyzer output includes an `effective_via_count` that weights each via by its plated barrel cross-section area relative to a 0.3mm reference drill: `(drill_diameter / 0.3)² per via`. Examples: 0.3mm via = 1.0 effective, 0.2mm = 0.44, 0.5mm = 2.78, 1.0mm = 11.1. The `recommended_min_vias` and `recommended_ideal_vias` thresholds are calibrated for 0.3mm reference vias and scale by pad area (pad \u003c10mm²: min 5/ideal 9; 10-25mm²: min 9/ideal 16; >25mm²: 0.5×area/0.8×area). When interpreting the adequacy rating, note that designs intentionally using smaller vias (e.g., 0.2mm to prevent solder wicking through vias during reflow, common in module footprints like ESP32) may appear \"insufficient\" despite adequate thermal performance. Always cross-reference the via count and drill size against the component datasheet's specific recommendations before flagging as a concern.\n- **θJA validation**: The datasheet's θJA is measured on a specific test board (usually JEDEC 2s2p for 4-layer). If the actual design has fewer layers or smaller copper area, θJA will be worse — note this when assessing thermal adequacy.\n- **Power dissipation check**: Calculate actual power dissipation from the circuit operating conditions (Vin, Vout, Iload for regulators; RDS(on) × I² for MOSFETs) and verify the thermal design can handle it. Flag when junction temperature exceeds the datasheet's maximum rating with margin.\n\n### Decoupling Requirements\n\n- **Capacitor values**: Many ICs specify minimum and maximum input/output capacitance, ESR range, and capacitor type (ceramic vs tantalum). Verify the schematic values match and that the PCB places them within the datasheet's maximum allowed distance.\n- **Placement distance**: Some datasheets specify \"place within X mm of pin Y\" — check the PCB analyzer's `decoupling_placement` distances against these requirements. LDOs and high-speed switching regulators are particularly sensitive.\n- **Capacitor type**: Datasheets that specify \"low-ESR ceramic\" or \"X5R/X7R minimum\" should be cross-checked against the schematic's capacitor specifications. Class II ceramics (Y5V/Z5U) lose significant capacitance under DC bias and may not meet minimum requirements.\n\n### Keepout Zones\n\n- **Antenna keepout**: Check the antenna manufacturer's datasheet for required copper-free area dimensions. The keepout must cover both the antenna element and a margin around it (often 5-10mm beyond the radiating element). Verify on all layers, not just the opposite layer.\n- **Touch controller keepout**: Capacitive touch controller datasheets specify clearance requirements for sensor pads, guard rings, and routing. Cross-reference pad layout against the controller's application note.\n- **Sensitive analog**: High-resolution ADCs and precision references often specify keepout zones or restricted routing areas near analog input pins. Check for digital traces routed under or near these components.\n\n### Component-Specific Layout Rules\n\n- **Crystal oscillator**: Datasheet specifies load capacitance; the PCB layout affects stray capacitance (typically 1-5pF). Route crystal traces short and direct, with ground guard if specified. Some crystals require no traces routed under the crystal body.\n- **Switching regulator power loop**: The hot loop (input cap → high-side switch → inductor → output cap → input cap return) must be minimized. Measure the loop area from the PCB layout and flag if the input capacitor is placed far from the IC or the inductor return path is indirect.\n- **USB impedance**: USB 2.0 requires 90Ω differential impedance; USB 3.x requires 85Ω. Verify trace width and spacing against the board stackup using the impedance parameters from the setup section. Check that D+/D- traces are length-matched per the USB spec tolerance.\n- **Exposed pad connection**: ICs with exposed thermal/ground pads (QFN, DFN, QFP-EP) require the pad to be soldered to the PCB. Verify the footprint has the pad connected to the correct net (usually GND) and has adequate thermal vias. A floating or poorly-connected exposed pad is both a thermal and electrical failure.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":26939,"content_sha256":"722a78e4edbcf4ccb70bb299d95c5ea5699d9bc60773ec911ad00ab87fc47c44"},{"filename":"references/pdf-schematic-extraction.md","content":"# PDF Schematic Analysis & Extraction\n\nHow to analyze schematics provided as PDF files — reference designs, dev board schematics, eval board docs, application notes — and extract useful information for incorporation into KiCad projects.\n\n## Table of Contents\n\n1. [Common Sources of PDF Schematics](#common-sources-of-pdf-schematics)\n2. [Reading PDF Schematics](#reading-pdf-schematics)\n3. [Extraction Workflow](#extraction-workflow)\n4. [Component Extraction](#component-extraction)\n5. [Net and Connectivity Extraction](#net-and-connectivity-extraction)\n6. [Subcircuit Extraction](#subcircuit-extraction)\n7. [Translating to KiCad](#translating-to-kicad)\n8. [Validation Against Datasheets](#validation-against-datasheets)\n9. [Common Pitfalls](#common-pitfalls)\n\n---\n\n## Common Sources of PDF Schematics\n\n| Source | What You Get | Example |\n|--------|-------------|---------|\n| **Dev board schematics** | Complete board design — power, MCU, peripherals, connectors | ESP32-DevKitC, STM32 Nucleo, Arduino, Raspberry Pi Pico |\n| **Eval board / reference designs** | Manufacturer's recommended circuit for a specific IC | TI EVM boards, Analog Devices eval boards |\n| **Application notes** | Focused subcircuits solving specific problems | AN-XXX from TI, Maxim, NXP, etc. |\n| **Chip datasheets** | Typical application circuit (usually 1-2 pages) | \"Typical Application\" section of any IC datasheet |\n| **Open-source hardware** | Full designs shared as PDFs (when source files aren't available) | Adafruit, SparkFun, community projects |\n\nThese PDFs are invaluable because they represent tested, working circuits — often designed by the IC manufacturer's own application engineers.\n\n---\n\n## Reading PDF Schematics\n\nRead specific pages of the PDF using page range selection. Schematics are visual — multimodal LLMs can interpret the circuit diagrams directly from the rendered PDF pages.\n\n### Strategy for multi-page schematics\n\n1. **Read the first page** — usually a title block or table of contents. Note the page count and sheet names.\n2. **Read the table of contents or sheet index** — many reference designs list sheets by function (Power, MCU, Connectivity, IO, etc.)\n3. **Read pages by functional area** — focus on the subcircuits relevant to your design goal.\n4. **Read the BOM page** (if included) — some PDFs include a BOM table on the last pages.\n\n### Tips for reading\n\n- Request 2-4 pages at a time to keep context manageable\n- For complex pages, focus on one area at a time (e.g., \"the voltage regulator in the top-left quadrant\")\n- If text is small or blurry, note component values you can't read clearly and cross-reference with the BOM or datasheet\n- Schematic PDFs from manufacturers are usually high-quality vector graphics — they render well\n\n---\n\n## Extraction Workflow\n\n### Step 1: Understand the purpose\n\nBefore extracting, clarify what you're taking from the PDF and why:\n- **Whole design**: recreating the entire board in KiCad (e.g., cloning a dev board)\n- **Specific subcircuit**: borrowing a power supply, USB interface, or sensor circuit\n- **Component selection**: seeing what parts the manufacturer chose and why\n- **Value verification**: checking your own design against a known-good reference\n\n### Step 2: Read and catalog\n\nRead through the schematic pages. For each page/sheet, note:\n- Sheet title and function\n- Key ICs and their part numbers\n- Power rails and voltages\n- Connectors and interfaces\n- Any notes or comments on the schematic (designers often annotate important decisions)\n\n### Step 3: Extract what you need\n\nDepending on your goal, extract:\n- Full BOM (all components with designators, values, footprints, part numbers)\n- Subcircuit topology (which components connect to which, net names)\n- Component values and their rationale\n- Design decisions (why certain values/parts were chosen)\n\n### Step 4: Translate to KiCad\n\nRecreate the extracted circuit in your KiCad schematic with proper symbols, values, and properties.\n\n### Step 5: Validate\n\nCross-reference the extracted circuit against the IC datasheets to confirm correctness. PDF schematics can contain errors (even from manufacturers), and your application conditions may differ.\n\n---\n\n## Component Extraction\n\nWhen reading a PDF schematic, extract a structured BOM.\n\n### What to capture per component\n\n| Field | Source in PDF | Notes |\n|-------|--------------|-------|\n| Reference | Printed next to symbol (R1, C5, U3) | May follow different convention than your project |\n| Value | Printed on or near symbol | \"100n\", \"10K\", \"4.7u\" — note the notation style |\n| Part number / MPN | In the BOM table, or printed on the symbol | Not always visible on the schematic itself |\n| Package / Footprint | Sometimes in BOM, sometimes from context | \"0402\", \"0603\", \"SOT-23-5\" |\n| Voltage rating | Sometimes annotated, often only in BOM | Critical for caps — \"16V\", \"25V\" |\n| Tolerance | Rarely on schematic, usually in BOM | \"1%\", \"5%\", \"10%\" |\n| Notes | Designer annotations near the component | \"DNP\", \"Optional\", \"Select for 3.3V\" |\n\n### Notation conventions in PDF schematics\n\nDifferent manufacturers use different shorthand:\n\n| PDF Notation | Meaning |\n|-------------|---------|\n| `100n`, `0.1u`, `100nF` | 100 nanofarads |\n| `4R7`, `4.7R` | 4.7 ohms (R marks decimal point) |\n| `10K`, `10k` | 10 kilohms |\n| `2M2` | 2.2 megohms |\n| `4u7`, `4.7u` | 4.7 microfarads |\n| `22p` | 22 picofarads |\n| `NF`, `NP`, `NC` | Not fitted / Not populated / No connect |\n| `DNP`, `DNS` | Do Not Populate / Do Not Stuff |\n\n### Handling missing information\n\nPDF schematics often omit details that KiCad needs:\n- **No MPN shown**: search the value + package on DigiKey/Mouser to find a suitable part\n- **No footprint shown**: infer from context (dev boards typically use 0402 or 0603 for passives) or check the BOM if included\n- **No voltage rating on caps**: check the rail voltage and select appropriate rating (1.5-2x)\n- **Generic part numbers**: \"100nF\" without MPN — select a specific part during your own BOM enrichment\n\n---\n\n## Net and Connectivity Extraction\n\n### Reading connections from PDF schematics\n\nPDF schematics show connectivity through:\n- **Wires** — lines connecting component pins\n- **Net labels** — text labels on wires (same label = same net, even across pages)\n- **Power symbols** — VCC, GND, +3V3, +5V, VBAT, etc.\n- **Port/off-page connectors** — arrows or symbols indicating connections to other sheets\n- **Bus notation** — thick lines with slash labels (D[0:7], A[0:15])\n\n### Multi-page connectivity\n\nFor multi-page schematics, track inter-sheet connections:\n1. Note all port/off-page connector labels on each page\n2. Match labels across pages — same label = same net\n3. Power rails (VCC, GND, etc.) are typically global across all pages\n4. Some designs use hierarchical labels — note the hierarchy\n\n### Creating a net map\n\nFor complex extractions, build a net map:\n\n```\nNet Name: USB_DP\n Page 2: U1 pin 33 (MCU USB_DP)\n Page 2: R5 pin 1 (22R series resistor)\n Page 3: J1 pin A6/B6 (USB-C connector D+)\n Page 3: U4 pin 3 (ESD protection)\n\nNet Name: +3V3\n Page 1: U2 VOUT (3.3V LDO output)\n Page 1: C3, C4 (output decoupling)\n Page 2: U1 VDD pins (MCU power)\n Page 3: R8, R9 (I2C pull-ups)\n```\n\nThis map becomes the basis for recreating the schematic in KiCad.\n\n---\n\n## Subcircuit Extraction\n\nThe most common use case — extracting a specific subcircuit from a reference design to use in your own project.\n\n### What makes a good subcircuit to extract\n\n- **Power supply circuits** — LDO, buck, boost, battery charger. These are the most commonly borrowed subcircuits because getting them wrong is consequential and the reference design is known to work.\n- **Interface circuits** — USB, Ethernet, CAN, RS-485. Protocol-specific circuits with precise component requirements.\n- **Sensor front-ends** — amplifier, filter, and ADC input stages from eval boards.\n- **Wireless module circuits** — antenna matching, crystal, bypass caps for WiFi/BT/LoRa modules.\n- **Protection circuits** — ESD, reverse polarity, overcurrent. Safety-critical, better to copy a proven design.\n\n### Extraction checklist\n\nFor each subcircuit you extract:\n\n- [ ] All components identified with values\n- [ ] All connections traced (including power and ground)\n- [ ] Net names recorded (use the PDF's names or create your own)\n- [ ] Any notes or annotations from the original designer captured\n- [ ] IC datasheet cross-referenced to verify the subcircuit\n- [ ] Any components shared with other subcircuits identified (e.g., bulk cap shared between regulators)\n- [ ] Board-specific components identified and excluded (e.g., test points, debug headers you don't need)\n- [ ] Voltage rails and current requirements documented\n\n### Adapting extracted subcircuits\n\nRarely can you copy a subcircuit verbatim. Common adaptations:\n\n| Adaptation | When Needed | How |\n|-----------|-------------|-----|\n| **Different input voltage** | Your power source differs from the reference | Recalculate input caps, voltage ratings, feedback dividers |\n| **Different output current** | Your load is lighter or heavier | Check regulator rating, adjust inductor/cap sizing |\n| **Different package** | You want a different footprint (e.g., larger for hand soldering) | Find same MPN in different package, or equivalent part |\n| **Removing unused features** | Reference has features you don't need | Identify which components are optional (check datasheet) |\n| **Adding features** | Reference is minimal, you want power-good or soft-start | Add components per datasheet |\n| **Different component availability** | Original parts hard to source | Find equivalents using DigiKey/Mouser/LCSC (match key specs) |\n\n---\n\n## Translating to KiCad\n\n### Mapping PDF components to KiCad symbols\n\n| PDF Symbol | KiCad Library | Notes |\n|-----------|--------------|-------|\n| Resistor (rectangle or zigzag) | `Device:R` | Add MPN, value, footprint |\n| Capacitor (two lines) | `Device:C` or `Device:C_Polarized` | Polarized for electrolytic/tantalum |\n| Inductor (coil) | `Device:L` | Check if shielded version needed |\n| Diode (triangle) | `Device:D` or `Device:D_Schottky` or `Device:D_Zener` | Match type to function |\n| LED | `Device:LED` | Note color for correct VF |\n| N-FET | `Device:Q_NMOS_GDS` | Check pin order matches |\n| P-FET | `Device:Q_PMOS_GDS` | Check pin order matches |\n| NPN/PNP | `Device:Q_NPN_BCE` / `Device:Q_PNP_BCE` | Check pin order |\n| Generic IC | Search KiCad library by MPN | If not in library, create custom symbol |\n| Connector | `Connector_Generic:Conn_01xNN` or specific | Match pin count and type |\n| Crystal | `Device:Crystal` | Two-pin or four-pin (with ground) |\n| TVS diode | `Device:D_TVS` or `Device:D_TVS_bidir` | Uni vs bidirectional |\n| Ferrite bead | `Device:FerriteBead` | Value in ohms at 100MHz |\n\n### Symbol not in KiCad library?\n\nIf the IC from the PDF isn't in KiCad's default libraries:\n1. **Check manufacturer libraries** — many vendors provide KiCad symbols (TI, STMicro, Espressif, etc.)\n2. **Search online** — SnapEDA, Ultra Librarian, Component Search Engine offer free KiCad symbols\n3. **Create a custom symbol** — use the pin descriptions from the IC datasheet to create a new symbol in KiCad's Symbol Editor\n\n### Recreating the schematic\n\n1. **Place ICs first** — the main active components anchor the layout\n2. **Add passive components** — resistors, caps, inductors connected to each IC\n3. **Add power symbols** — VCC, GND, named power nets\n4. **Wire everything** — follow the PDF's connectivity\n5. **Add net labels** — for connections between subcircuits or across pages\n6. **Annotate** — set reference designators (can reuse PDF's or use KiCad's auto-annotate)\n7. **Fill in symbol properties** — Value, Footprint, MPN, Datasheet URL\n8. **Run ERC** — KiCad's Electrical Rules Check catches basic wiring errors\n\n### Organizing multi-sheet schematics\n\nIf the PDF has multiple pages, consider mirroring that structure in KiCad with hierarchical sheets:\n- One sheet per functional block (Power, MCU, Connectivity, IO)\n- Use hierarchical labels for inter-sheet connections\n- Keep power rails as global power symbols (they connect across all sheets automatically)\n\n---\n\n## Validation Against Datasheets\n\nNever blindly trust a PDF schematic. Always cross-reference against datasheets.\n\n### Why PDF schematics can be wrong\n\n- **Errata not applied** — the PDF may predate a silicon revision or app note correction\n- **Transcription errors** — someone redrew the schematic and made mistakes\n- **Version mismatch** — the PDF shows rev A but the datasheet has been updated for rev C\n- **Board-specific workarounds** — the reference design may include bodge fixes that aren't appropriate for your design\n- **Different operating conditions** — the reference design may target conditions different from yours (temperature, voltage range, load)\n\n### Validation steps\n\n1. **Get the current datasheet** for every IC — don't rely on the PDF's embedded info\n2. **Compare pin connections** — verify every pin in the PDF matches the datasheet's pin table\n3. **Check the \"Typical Application\" circuit** in the datasheet — compare against the PDF\n4. **Verify component values** against datasheet recommendations (use `schematic-analysis.md` methodology)\n5. **Check for errata** — look for errata documents or app notes that supersede the reference design\n6. **Verify footprints** — the PDF's component packages may not match what's currently available\n\n### Red flags in PDF schematics\n\n- Component values that seem unusual (e.g., a 47nF where you'd expect 100nF)\n- Pins connected that the datasheet says should be left floating (or vice versa)\n- Missing decoupling caps on IC power pins\n- Net labels that don't match the IC datasheet pin names\n- Annotations like \"TBD\", \"check\", \"verify\" — the design may not be finalized\n- Very old revision dates — circuit may predate important errata\n\n---\n\n## Common Pitfalls\n\n### Pin numbering differences\n\nDifferent schematic tools number pins differently. A PDF from Altium, OrCAD, or Eagle may show pin numbers or names that don't match KiCad's symbol. Always cross-reference against the IC datasheet — the datasheet is the authority on pin numbering.\n\n### Passive component notation\n\nPDF schematics may use inconsistent notation. On the same page you might see \"100n\", \"0.1uF\", and \"100nF\" — these are all the same value. Normalize when entering into KiCad (pick one convention: \"100nF\" is clearest).\n\n### Ground symbol variants\n\nPDFs may use multiple ground symbols (chassis ground, digital ground, analog ground, signal ground). Understand which are connected and which are separate in the original design. In KiCad, each distinct ground net needs its own power symbol.\n\n### Copied circuits need adaptation\n\nThe reference design's operating conditions may differ from yours:\n- Different input voltage → recalculate feedback dividers, check voltage ratings\n- Different load current → verify regulator capacity, adjust inductor sizing\n- Different temperature range → check component ratings\n- Different PCB stackup → impedance-controlled traces may need different widths\n\n### Don't copy what you don't understand\n\nIf you can't explain why a component is there and what value it should be, research it before including it. Copying cargo-cult components from reference designs is a common source of problems — you'll have mysterious parts on your board that you can't debug because you don't know their purpose.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":15537,"content_sha256":"57e345f7c960cbcc1ea11ff81a5e078651331e8e9871c5ea67f4ec0c848501c1"},{"filename":"references/report-generation.md","content":"# Design Review Report Generation\n\nGuide for producing comprehensive design review reports from analyzer output + raw file cross-referencing. These reports help EE designers validate their designs before committing to fabrication.\n\n## Minimum Review Contract\n\nThis reference is not optional reading for full design reviews. If the user asks for a complete review, ready-to-fab assessment, or comprehensive report, the resulting report must satisfy this minimum contract:\n\n1. Name the analyzers that were actually run.\n2. Name the applicable analyzers that were not run, and why.\n3. Separate verified findings from inference-only findings.\n4. Put blockers and warnings near the top.\n5. Triage obvious false positives instead of repeating analyzer output unfiltered.\n6. State verification gaps explicitly when datasheets, thermal, lifecycle, gerbers, or prior-review delta were not available.\n\nIf the review does not meet this contract, it is an incomplete review, not a full one.\n\n## Required Sections for Full Reviews\n\nThese sections are expected in a complete design review unless genuinely not applicable:\n\n- Overview\n- Previous Review Delta, when a prior review or prior runs exist\n- Critical Findings\n- Component Summary\n- Power Tree\n- Analyzer Verification\n- Signal Analysis Review\n- Power Analysis\n- PCB Layout Analysis, when a PCB exists\n- Thermal Analysis or an explicit note that thermal analysis was not performed\n- EMC / Cross-Domain Analysis when schematic and PCB both exist\n- Component Lifecycle or an explicit note that lifecycle audit was not performed\n- Manufacturing / DFM / testability\n- False Positives / reviewer overrides\n- Not Performed / Review Limits\n- Final verdict / readiness statement\n\n## Evidence Basis Rules\n\nEvery substantive finding should make its evidence basis clear. Use these categories consistently:\n\n- **Datasheet-verified** — checked against the manufacturer PDF, with page / figure / table / equation citation\n- **Extraction-verified** — checked against structured datasheet extraction in `datasheets/extracted/`\n- **Raw-file verified** — checked against the raw `.kicad_sch`, `.kicad_pcb`, or fabrication files\n- **Analyzer-derived** — came from analyzer output and was accepted after review\n- **Inference-only** — plausible engineering reasoning, but not directly verified against datasheet or raw file\n\nDo not use the word \"verified\" for analyzer-only or inference-only claims.\n\n## Skipped Analysis Disclosure\n\nIf any applicable analysis was not run, include a short section in the report that says so explicitly. Do not bury omissions in prose or leave them unstated.\n\nRecommended format:\n\n```markdown\n## Not Performed / Review Limits\n\n- Thermal analysis not performed — reason.\n- Lifecycle audit not performed — reason.\n- Gerber analysis not performed — no fabrication outputs present.\n- Datasheet extraction not available — pin-level checks are datasheet-manual or inference-only.\n```\n\n## False-Positive Triage\n\nA good design review is not a transcript of analyzer output. For findings likely to be layout artifacts, intentional keepouts, expected RF-module courtyard behavior, or known heuristic overreach:\n\n- either dismiss them explicitly with reasoning\n- or downgrade them and explain the residual risk\n\nIf a finding was reviewed and judged benign, keep it in a \"False Positives / Reviewer Overrides\" section so the reader can see that it was considered rather than missed.\n\n## Contents\n\n| Section | Line | Purpose |\n|---------|------|---------|\n| Report Structure | ~18 | Full report template (copy and fill in) |\n| Analyzer Output Field Reference | ~364 | Maps every JSON output field to its report section — use as checklist |\n| Severity Definitions | ~468 | CRITICAL / WARNING / SUGGESTION criteria |\n| Writing Principles | ~476 | How to write actionable findings |\n| Handling Different Design Domains | ~512 | Domain-specific focus areas (IoT, motor, RF, analog, industrial) |\n| Cross-Referencing with Raw Schematic | ~529 | Mandatory verification steps |\n| Known Analyzer Limitations | ~540 | What the tool can and can't catch |\n| Report Length Guidelines | ~563 | Target report sizes by complexity |\n\n## Report Structure\n\nUse this template. Include sections that are relevant to the design — skip sections that genuinely don't apply (a battery-powered sensor board doesn't need an isolation barrier section). For sections where the analyzer returned empty data, briefly assess whether that's expected (\"no mains input, creepage N/A\") or a gap worth noting (\"no ESD protection detected on external USB connector\").\n\n```markdown\n# [Project Name] Design Review\n\n**Project:** [name] ([KiCad version], [single sheet | N hierarchical sheets], [N-layer PCB | no PCB])\n**Date:** [analysis date]\n**Analyzers:** [list scripts run: analyze_schematic.py, analyze_pcb.py, analyze_gerbers.py] ([modern/legacy format], [full signal analysis | legacy mode])\n\n## Overview\n[2-4 sentence description of the board: MCU, power architecture, key peripherals, domain (IoT/motor control/RF/instrumentation/etc.), form factor context]\n\n## Previous Review Delta\n[**Include when prior review files or analyzer JSON exist in the project directory.** Scan for `*review*.md`, `*design-review*.md`, and prior `*_analysis.json` files.\n\nIf prior analyzer JSON exists, run `diff_analysis.py old.json new.json` to generate a structured component/signal/EMC diff. Present as:]\n\n| Status | Count |\n|--------|-------|\n| Fixed since last review | N |\n| Still open | N |\n| New findings | N |\n\n[For each fixed item, note it as a positive finding (\"Thermal via count on U3 increased from 14 to 18 — now meets IPC recommendation\"). For still-open items, carry forward the original severity. For new findings, integrate into their normal sections below.\n\nIf prior analyzer JSON does not exist but a prior review markdown does, manually compare findings and track issue status (fixed/open/new) by reading both documents.\n\nIf no prior review exists, omit this section entirely.]\n\n## Critical Findings\n[**This section comes first** so the designer sees the most important issues immediately. Move here after completing the full analysis.]\n\n| Severity | Issue | Section |\n|----------|-------|---------|\n| CRITICAL | [Board won't function, safety hazard, or fabrication failure] | [link to section with details] |\n| WARNING | [Suboptimal but board may work, potential reliability issue] | [link to section with details] |\n\n[Only CRITICAL and WARNING items here. SUGGESTION-level items go in the Issues Found section later. If no CRITICAL or WARNING issues: \"No critical or warning-level issues found.\" — this itself is a valuable finding.]\n\n## Component Summary\n[Table: Type | Count, broken down by resistors, capacitors, ICs, connectors, etc.]\n[One-line stats: Nets, Wires, No-connects, Power rails, Sheets]\n[Sourcing audit: MPN coverage %, missing distributor part numbers — do not recommend any specific distributor by name]\n\n## Power Tree\n[ASCII art showing the power distribution hierarchy]\n[Include: input source, each regulator with type (LDO/buck/boost/inverting), input->output voltages, enable conditions, key caps with values, feedback divider calculations]\n[Note vref_source for each regulator: \"lookup\" (datasheet-verified) or \"heuristic\" (needs manual verification)]\n\n## Analyzer Verification\n[Spot-checks proving the analyzer data is trustworthy]\n### Component Count — [N/N match status]\n### Component Pinout Verification — [Table: ALL components verified against raw schematic + **manufacturer PDF datasheets** (not KiCad library symbols): Ref | Value | Pins | Datasheet Verified | Verification Status | Match. Every component must be checked — not just ICs. Include connectors, transistors, diodes, and critical passives. The \"Datasheet Verified\" column must reference the actual PDF datasheet with page/section number — not the `.kicad_sym` library file. Use the following **Verification Status** categories:\n- **Verified (datasheet)** — cross-checked against manufacturer PDF datasheet (cite page/section)\n- **Verified (extraction)** — cross-checked against pre-extracted specs from `datasheets/extracted/` (extraction score >= 6.0)\n- **Unverified** — no datasheet or extraction available; plausibility assessment only. State what was assessed and confidence level.\n- **Skipped** — passive/mechanical component where pinout verification is not meaningful (2-pin passives, mounting holes)\n\nCustom library symbols (e.g., `sacmap:TPS61023`) are highest priority for datasheet verification because there's no upstream KiCad library as a secondary check. When pre-extracted datasheet specs are available, use them for faster verification. Fall back to direct PDF reading when extraction score \u003c 6.0 or when pin-level detail is insufficient. Each finding in the report should also indicate its evidence basis: datasheet-verified, extraction-verified, or inference-only.]\n### Pinout Ambiguity & Plausibility — [Components where the symbol's pin assignment depends on the specific MPN. Table: Ref | lib_id | Footprint | MPN | Assumed Pinout | Datasheet Pinout | Plausibility | Status. When verification is possible (MPN + datasheet), verify directly. When it isn't, assess plausibility: does the assumed pinout match the dominant convention for this device type and package? Report confidence: \"matches most common convention,\" \"plausible but multiple variants exist,\" or \"unusual — most parts in this category use a different pinout.\" Flag CRITICAL when no MPN is specified AND the assumed pinout is uncommon or genuinely ambiguous.]\n### Connector Pin Tables — [For connectors with >2 pins (debug headers, programming ports, I/O connectors): table of Pin | Net | Function. The `ic_pin_analysis` section includes connector data — present it as a quick-reference table. Particularly valuable for debug/programming headers (EN, 3V3, TX, GND, RX, BOOT) where pin order matters for cable orientation.]\n### Net Tracing — [All power rails + critical signal nets traced end-to-end: list all pins, verify connectivity, confirm correctness]\n### PCB Verification — [If PCB analyzed: footprint count match, pad-net spot-check, board dimensions confirmed]\n### Gerber Verification — [If gerbers analyzed: layer completeness, drill count, alignment check]\n\n## Signal Analysis Review\n[Walk through each detected subcircuit category, validate calculations, note any false positives]\n\n### Power Regulators\n[Each regulator: topology (LDO/buck/boost/inverting), input/output rails, Vout estimate, vref_source (lookup vs heuristic), vout_net_mismatch flag. Verify heuristic Vref values against datasheets.]\n\n### Voltage Dividers & Feedback Networks\n[Table: R_top | R_bottom | Ratio | Output voltage | Purpose | Verified status. For feedback dividers: Vref verification against datasheet lookup table. Flag any vout_net_mismatch where estimated Vout differs >15% from the output rail name voltage.]\n\n### RC/LC Filters\n[Cutoff frequencies, filter type (low-pass/high-pass), component values, purpose, verified status]\n\n### Op-Amp Circuits\n[Configuration (inverting/non-inverting/buffer/differential), gain from Rf/Ri, topology identification accuracy]\n\n### Protection Devices\n[ESD, TVS, varistors — placement coverage on external connectors, voltage ratings vs rail voltages]\n\n### LED Circuits\n[For each LED: verify current limiting resistor value provides correct forward current. Calculate: I_LED = (V_supply - V_f) / R_limit. Check against LED datasheet for typical V_f, V_f tolerance range, and maximum current rating. Flag cases where current margin is tight (operating near max or where V_f variation could push current out of spec). Consider supply voltage tolerance as well.]\n\n### Transistor Circuits\n[Type (N-ch/P-ch MOSFET, NPN/PNP BJT), load type classification (inductive/led/resistive/motor/heater/fan/solenoid/connector), gate/base drive analysis, protection (flyback diode, snubber)]\n\n### Bridge Circuits\n[Topology (half_bridge/h_bridge/three_phase), FET references, output nets, gate driver ICs. Note: cross-sheet bridge detection works through unified hierarchical nets.]\n\n### Crystal Circuits\n[Load cap analysis, frequency, Cload vs recommended values]\n\n### Current Sense\n[Shunt values, sense amplifier, measurement range]\n\n### Simulation Verification\n[**Include when ngspice is available.** Run `simulate_subcircuits.py` on the analyzer JSON output (from the `spice` skill). The output JSON has top-level key `simulation_results` (list). Each entry has `reference` (e.g. \"R5/C3\"), `components` (list), `subcircuit_type`, `status`, `expected` (dict of metric values like `cutoff_hz`), `simulated` (dict of measured values), and `delta` (dict of error percentages). Use `summary` for totals. Present results as a summary table grouped by status:]\n\n[Summary line: \"ngspice verified N subcircuits in X.Xs. N pass, N warn, N fail, N skip.\"]\n\n[**Pass** — one line each, grouped: \"RC filter R5/C3 (fc=15.9kHz): confirmed, \u003c0.3% error.\" Build this from `result['reference']`, `result['expected']['cutoff_hz']`, and `result['delta']`.]\n\n[**Warn** — explain context: \"Opamp U4A (inverting, gain=-10): gain confirmed at 20.0dB. Bandwidth 98.8kHz (ideal model). Note: LM358 GBW is ~1MHz — actual bandwidth ~100kHz.\" Opamp and transistor results always carry model fidelity caveats.]\n\n[**Fail** — investigate and explain: \"RC filter R12/C8: simulated fc=3.2kHz vs expected 15.9kHz (80% deviation). Likely topology misdetection — verify R12's role in the circuit.\" Failures in passive circuits indicate analyzer bugs; in active circuits they may indicate real design issues.]\n\n[**Skip** — note the gap: \"Crystal Y1 (32.768kHz): active oscillator, no external load caps to validate.\" Skips are expected for unsimulatable configurations.]\n\n[Model fidelity notes: passive circuit simulations (RC, LC, dividers, current sense) use ideal components and are mathematically exact. Active circuit simulations (opamps, transistors) use generic behavioral models — qualify bandwidth and threshold results with the actual part's specifications.]\n\n### Decoupling Analysis\n[Table: Rail | Cap Count | Total uF | Bulk | Bypass — one row per rail. Flag rails with inadequate decoupling.]\n\n### Buzzer/Speaker Circuits\n[Driver topology, frequency if applicable]\n\n### Domain-Specific Detections\n[Include subsections only when detected:]\n- **RF Chains** — component chain, frequency bands, switch matrix\n- **BMS Systems** — cell count, balance topology, protection\n- **Ethernet Interfaces** — magnetics, PHY, termination\n- **Memory Interfaces** — type, data bus width, address lines\n- **Key Matrices** — row/column count, diode matrix\n- **Isolation Barriers** — isolation voltage, optocoupler/digital isolator\n\n### Design Observations\n[Automated observations from the analyzer: decoupling coverage, I2C pull-ups, crystal load caps, regulator details, etc. Validate each against the raw schematic.]\n\n## Power Analysis\n\n### PDN Impedance\n[Per-rail impedance profile (1kHz–1GHz), key impedance points, anti-resonances, SRF gaps, MLCC parasitic modeling]\n\n### Power Budget\n[Per-rail load estimates vs regulator capacity, flag any overloaded rails, regulator headroom]\n\n### Power Sequencing\n[Enable chains (EN/PG dependencies), startup order, missing PG feedback]\n\n### Sleep Current Audit\n[Per-rail estimated sleep current, dominant leakage paths (pull-up/pull-down resistors), regulator Iq estimates with EN pin detection. Present both the analyzer's worst-case figure (`total_estimated_sleep_uA`) and realistic estimate (`realistic_total_uA`). Use `realistic_total_uA` for expected battery life; use the worst-case for absolute-maximum calculations. Each current path has `likely_state` explaining whether it's active during sleep (e.g., \"can be disabled via EN\", \"GPIO off during sleep\", \"rail disabled during sleep\") and `realistic_uA` (0 for inactive paths). Explain which paths are inactive and why.]\n\n### Inrush Analysis\n[Power-on current analysis — not limited to regulators. Consider ALL current paths at power-on:]\n- Per-regulator inrush (input capacitance, soft-start adequacy)\n- IC supply pin absolute maximum ratings vs capacitor charging current (e.g., 74HC-series ±50mA VCC/GND limit, small MCUs with low abs max supply current)\n- Bulk/decoupling capacitor charging through connectors (hot-plug scenarios produce fast voltage steps → high dI/dt)\n- Source impedance: connector resistance, wire gauge, trace resistance — these limit peak inrush naturally\n- Series resistance or soft-start mechanisms (or lack thereof)\n- Multiple rails energizing simultaneously (total system inrush vs supply capability)\n[Even designs with no regulators need this section — external supply rails still charge decoupling caps through IC supply pins at power-on. Check each IC's datasheet for absolute maximum continuous current through VCC/GND pins.]\n\n### Voltage Derating\n[Component voltage ratings vs applied voltages, capacitor derating at operating voltage]\n\n## Standards Compliance\n[Include when applicable — see `references/standards-compliance.md` for auto-trigger conditions and tables. Consider for all boards: even low-voltage designs benefit from a brief conductor spacing and current capacity check. For mains-connected or safety-isolated designs, this section is mandatory.]\n\n### Product Classification\n[Class 1/2/3 determination with rationale from BOM indicators]\n\n### Conductor Spacing (IPC-2221A Table 6-1)\n[High-voltage net pairs with required vs actual spacing. Skip for designs where all nets are ≤15V and traces meet minimums.]\n\n### Current Capacity (IPC-2221A / IPC-2152)\n[Power traces: expected current, trace width, copper weight, calculated capacity, margin. Flag \u003c50% margin as WARNING, \u003c20% as CRITICAL.]\n\n### Creepage/Clearance (ECMA-287 / IEC 60664-1)\n[Only for mains-connected or safety-isolated designs. Working voltage, OVC, pollution degree, material group, required vs actual distances.]\n\n### Annular Ring (IPC-2221A Table 9-2)\n[Via annular ring analysis, fab capability vs IPC minimums]\n\n### Via Protection (IPC-4761)\n[Only for via-in-pad designs: protection type, BGA/QFN thermal pad vias. The `thermal_pad_vias` output uses an `effective_via_count` that weights each via by `(drill/0.3mm)²` — see pcb-layout-analysis.md \"Thermal via effective count methodology\" for the full formula and thresholds. Designs using 0.2mm vias by intent (module footprints) may show \"insufficient\" adequacy despite adequate thermal performance — always cross-reference the datasheet before flagging.]\n\n## Design Analysis\n\n### Net Classification\n[Power/ground/high_speed/data/analog/control/chip_select/interrupt/output_drive/debug/config/signal categorization. Verify output_drive nets (motor, heater, fan, solenoid, relay, lamp, LED, PWM, buzzer).]\n\n### Cross-Domain Signals\n[Signals crossing voltage domains, level shifter assessment. Uses voltage equivalence from rail name parsing to reduce false positives. Rails without parseable voltages may trigger false warnings.]\n\n### ERC Warnings\n[Total count, categorize: genuine issues vs benign/false positives. Covers: multi-driver nets, unconnected power pins, pin type conflicts.]\n\n### Bus Topology\n[I2C detection (SDA/SCL + pull-ups), SPI (SCK/MOSI/MISO/COPI/CIPO/SDI/SDO/CS), UART (TX/RX), CAN bus grouping accuracy]\n\n### Differential Pairs\n[Suffix-pair detection for USB (D+/D-), LVDS, Ethernet (TX+/TX-), HDMI, MIPI, PCIe, SATA, CAN, RS-485. Protocol guessing from net name keywords.]\n\n### Connectivity Issues\n[Unconnected pins, single-pin nets, multi-driver conflicts, power net summary]\n\n### Label/Annotation Warnings\n[Label shape warnings (input/output direction), PWR_FLAG coverage, annotation gaps, hierarchical label validation]\n\n### Passive Warnings\n[Unusual passive values, tolerance concerns]\n\n### Footprint Filter Warnings\n[Custom library vs standard filter mismatches]\n\n## PCB Layout Analysis\n[Include when a .kicad_pcb file was analyzed — skip for schematic-only reviews]\n\n### Board Overview\n[Dimensions, layer count (from tracks + vias + zones), copper layer names, stackup summary from .kicad_pro, board thickness]\n\n### Footprint Placement\n[Front/back side counts, SMD/THT ratio, placement density, courtyard overlaps, edge clearance warnings]\n\n### Via Analysis\n[Type breakdown (through/blind/micro), size distribution, annular ring checks, via-in-pad detection, BGA/QFN fanout patterns, current capacity, stitching via identification, tenting assessment]\n\n### Trace Routing\n[Per-net trace lengths, width distribution, layer distribution, power trace widths vs current requirements (IPC-2221)]\n\n### Signal Integrity\n[Layer transitions per net, ground return path assessment, trace proximity/crosstalk (with --proximity flag)]\n\nDifferential pair length matching: For each detected differential pair (USB D+/D-, Ethernet TX+/TX-, etc.), compute the length delta between the two traces and cite the protocol-specific tolerance. The delta and tolerance are more useful than the raw lengths alone — they tell the designer whether there's margin or a problem. Example format: \"D+=75.8mm, D-=75.2mm (delta=0.6mm — within USB 2.0 FS tolerance of ±25mm).\" For interfaces with tighter requirements: USB 3.x ±3mm, HDMI ±2mm, DDR ±0.5mm.\n\n### Power & Ground\n[Power net routing summary (width, length, current capacity), ground domain identification (AGND/DGND/PGND), zone stitching via density]\n\n### Thermal Analysis\n[Thermal pad detection, via counting and adequacy for QFN/DFN packages, zone stitching density, thermal relief settings, tombstoning risk assessment (0201/0402 thermal asymmetry). Cross-reference thermal via count and pad area against each IC's datasheet thermal management section — check recommended via count, via diameter, and exposed pad connection. Verify θJA assumptions match the datasheet's specified board conditions (e.g., JEDEC 2s2p vs actual layer count).\n\nWhen `analyze_thermal.py` was run, include its junction temperature estimates (Tj per component, margin to Tj_max, thermal score). Cross-reference against datasheet Tj_max values. When the thermal script was not run, estimate manually from power dissipation and package θJA for components with significant power draw (regulators, drivers, power FETs).]\n\nFor every IC with an exposed/thermal pad, explicitly report the via count and adequacy in this format: \"[Ref] pad [N] ([net]) connected through [count] thermal vias (recommended range: [min]–[max] per datasheet) — [adequate/insufficient].\" Example: \"U1 pad 41 (GND thermal pad) connected through 12 thermal vias (recommended range: 9–16) — adequate.\" The thermal via count is one of the most common QFN/DFN layout errors and is always worth calling out with a specific number, even when adequate — it confirms the designer got it right.\n\n### Copper Presence\n[Zone copper at component pad locations — from `copper_presence` section. Focus on `no_opposite_layer_copper` list: which components lack zone copper on the opposite layer? Verify this is intentional for capacitive touch pads and antennas (need isolation) vs unexpected for other components (might indicate a zone gap). Also note `same_layer_foreign_zones` — pads sitting on zones they're not connected to, which is normal for tightly-packed power island zones but worth flagging if unexpected.]\n\n### Capacitive Touch Pads\n[Include when TP-prefixed components or pad-only footprints appear in `copper_presence.no_opposite_layer_copper`, or when touch controller ICs are detected]\n\n| Pad | Diameter/Size | Position | Opposite-Layer Copper | Keepout Zone? | GND Pour Clearance | Trace Width | Trace Length to Controller |\n|-----|--------------|----------|----------------------|---------------|-------------------|-------------|--------------------------|\n| [ref] | [mm] | [x, y] | [none / present (CRITICAL)] | [yes — (x1,y1) to (x2,y2) / NO — flag WARNING] | [distance mm vs app note min] | [mm] | [mm — compare across pads] |\n\nCopper absence vs keepout enforcement: Confirming \"no copper\" under a touch pad is necessary but not sufficient. That absence could be accidental — a routing change or zone adjustment could fill in copper and kill touch sensitivity. A keepout zone is a DRC rule that prevents this permanently. Check the PCB file for explicit keepout/rule-area objects on the opposite layer under each touch pad. If none exist, flag as WARNING: \"no explicit keepout zone under [ref] — copper absence is not enforced by a DRC rule.\"\n\nTrace length asymmetry: Compute the trace length from each touch pad to the controller IC and compare across all pads. Significant asymmetry (>1.5×) means different parasitic capacitance per channel, which shifts baseline readings and may reduce dynamic range even with firmware calibration. Report the ratio: \"TOUCH_2 (41.6mm) is 1.75× longer than TOUCH_1 (23.7mm).\"\n\nGND pour clearance: Use the `touch_pad_gnd_clearance` section in `copper_presence` for measured clearance values (`gnd_clearance_mm` per touch pad). Compare against the touch controller's recommended minimum (typically 1.0mm for Espressif, check the specific controller's app note). If the clearance is exactly at the minimum, note this: \"GND clearance is 1.0mm — exactly the Espressif minimum. Consider increasing to 1.5mm if sensitivity is marginal.\" If `touch_pad_gnd_clearance` is not present, measure manually from the PCB layout.\n\n### Antenna Layout\n[Include when ANT-prefixed footprints, antenna lib_id patterns, or RF antenna footprints are detected, OR when wireless modules (ESP32, nRF, etc.) with integrated/PCB antennas are present]\n- Keepout zone verification: check the `keepout_zones` section in the PCB analyzer output for restriction areas near the antenna footprint. Report the keepout zone coordinates, layer coverage, and restriction types explicitly: \"Keepout zone on F.Cu+B.Cu: (x1, y1) to (x2, y2), restrictions: no copper pour, no tracks.\" The `nearby_components` field shows which components are near each keepout zone. Cross-reference dimensions against the manufacturer's reference layout — many antenna datasheets/app notes specify exact keepout areas.\n- Ground plane termination: verify the ground plane ends at the antenna feed point and does not extend under the radiating element\n- Matching network placement: components between antenna and RF IC should be close to the antenna with controlled-impedance traces\n[If no keepout zones are defined around the antenna, flag as WARNING. For wireless modules (ESP32, nRF, etc.), the module vendor's reference design is the authoritative source for keepout dimensions — these are often the single most important layout constraint for RF performance. Always cite the specific antenna/module reference when verifying keepout adequacy: \"Correct per Espressif guidelines\" or \"Matches nRF52840 reference layout.\"]\n\n### Decoupling Placement\n[Cap-to-IC distances for critical components, flag caps too far from IC power pins. Verify capacitor values and placement distances against each IC's datasheet requirements — many ICs specify maximum distance, minimum capacitance, and ESR limits for input/output decoupling. Flag any deviation from datasheet recommendations.\n\nESD protection ICs (entries with `category: \"esd_bypass\"` in the decoupling output) require a low-impedance bypass path for clamping. Their bypass cap should be within 3mm — flag WARNING if >5mm, SUGGESTION if 3-5mm. Common ESD ICs: USBLC6-2SC6, TPD4E05U06, PRTR5V0U2X, IP4220CZ6.]\n\n### Current Capacity\n[Per-net trace/via current capacity vs estimated load, narrow signal net warnings]\n\n### DFM Assessment\n[JLCPCB standard/advanced tier determination, DFM metrics (min trace width, min clearance, min drill, min annular ring), violation list. All threshold values MUST come from the \"Fab House Capabilities\" table in standards-compliance.md — do not substitute from memory.]\n\n### Silkscreen\n[Board text count, reference designator visibility, documentation warnings, values on silk]\n\n### Connectivity\n[Routing completeness, unrouted net count and list]\n\n## Schematic ↔ PCB Cross-Reference\n[Include when both schematic and PCB were analyzed — this catches the most dangerous bugs. Use `statistics.total_nets` from both analyzers for net count comparison — do not mix net counts from different sections or manual counting methods, as the schematic may include internal unnamed nets that the PCB doesn't.]\n### Component Count Match — [Schematic (excl. power symbols) vs PCB footprint count]\n### Pin-Net Verification — [ALL components: schematic pin mapping vs PCB pad mapping. Table: Ref | Pins | All Match | Mismatches. Do not sample — verify every component including connectors, transistors, diodes.]\nThis verification must happen at the PCB pad level, not just the schematic pin level. The schematic tells you pin 1 connects to net X; the PCB tells you pad 1 connects to net X. If the library footprint has pad numbering that doesn't match the symbol's pin numbering, the schematic and PCB will be internally consistent but the board will be wrong. For each IC, transistor, and connector, verify both directions: schematic pin N → net X, AND PCB pad N → net X, AND the physical pad position matches the datasheet's pin diagram for that specific package. Example format: \"Q1: 1=G(MAP_RED), 2=S(GND), 3=D(+5V).\" This catches the most dangerous class of bug — a library footprint with wrong pad numbering passes all consistency checks but produces a non-functional board.\n### Connector Pinout Tables — [For connectors with >2 pins (debug headers, programming headers, multi-pin interfaces), include a pin mapping table]\n\n| Connector | Pin | Net | Function |\n|-----------|-----|-----|----------|\n| J1 | 1 | RESET | MCU reset |\n| ... | ... | ... | ... |\n\n[This is especially important for programming/debug headers, USB connectors, and board-to-board interfaces where miswiring is common and consequences are severe.]\n\n### Footprint Match — [Schematic Footprint property vs actual PCB footprint]\n### Value/MPN Consistency — [Spot-check values and MPNs between schematic and PCB]\n### DNP Consistency — [Components marked DNP in schematic should not have routing on PCB]\n\n## Gerber Analysis\n[Include when gerber directory was analyzed]\n### Layer Completeness — [Found vs missing required/recommended layers, source identification]\n### Drill Classification — [Via count, component holes, mounting holes, classification method]\n### Alignment Verification — [Layer alignment check, extent comparison]\n### Pad Summary — [SMD vs THT aperture counts, via apertures, heatsink apertures]\n### Board Dimensions — [From gerber extents, compare against PCB if both analyzed]\n\n## Interface Summary\n[One-line summary of each external interface: connector type, protection, protocol, pin mapping]\n[Examples: USB-C (ESD: yes, CC: 5.1kΩ), SWD (J1, no ESD), CAN (120Ω term, PESD2CAN)]\n\n## Quality & Manufacturing\n\n### Assembly Complexity\n[Score, SMD/THT ratio, difficulty breakdown (fine-pitch, BGA, QFN), dominant package, unique footprint count]\n\n### Sourcing Audit\n[MPN coverage %, missing MPNs list, missing distributor part numbers. Do not recommend or prefer any specific distributor by name — keep sourcing observations neutral.]\n\n### Component Lifecycle Status\n[**Include when `--lifecycle` flag was used on the schematic analyzer.** Report any NRND (not recommended for new designs), EOL (end of life), or obsolete components from the `lifecycle_audit` output. For each flagged part: MPN, current status, last-buy date if known, and suggested action (find alternate, stock up, redesign). If lifecycle audit was not run, note: \"Lifecycle audit not performed — [reason: no API keys / no network / no MPNs].\"]\n\n### BOM Optimization\n[Unique passive value counts per type, total unique footprints, single-use passive values, consolidation opportunities]\n\n### Test Coverage\n[Test points found and nets covered, debug connectors, uncovered critical nets]\n\n### USB Compliance\n[If applicable: connector type, ESD protection, CC resistors, VBUS protection, D+/D- impedance]\n\n### Simulation Readiness\n[Components likely simulatable vs needing SPICE models, coverage percentage]\n\n### Ordering Notes\n[Practical manufacturing summary for ordering — this section bridges the design review and the fabrication order. Extract surface finish from the PCB stackup (`copper_finish` field in setup section), solder mask color from board setup, and board thickness from the stackup layer sum. Designers use this to configure their PCB order, so always include it when a PCB was analyzed.]\n- Layer count: [N] layers, surface finish: [HASL/ENIG/OSP — from PCB stackup `copper_finish`], solder mask color: [green/black/etc. — from board setup]\n- Stencil: [recommend if SMD components present, note if fine-pitch requires frameless stencil]\n- Board thickness: [standard 1.6mm or custom — from stackup total thickness]\n- DFM tier: [standard vs advanced capability requirements based on min trace/space/drill from DFM section]\n- Copper weight: [1oz/2oz based on current requirements — from stackup copper layer thickness, 0.035mm = 1oz]\n- Assembly notes: [reflow profile considerations, mixed SMD/THT implications]\n- Special requirements: [impedance control, via-in-pad, castellated edges, etc. if applicable]\n\n## All Issues & Suggestions\n[Complete list of all findings. CRITICAL and WARNING items were already shown in the Critical Findings section at the top — repeat them here with full detail and context. SUGGESTION items appear here only.]\n\n| Severity | Issue | Detail |\n|----------|-------|--------|\n| CRITICAL | [Board won't function, safety hazard, or fabrication failure] | [Full explanation, datasheet citation, affected components] |\n| WARNING | [Suboptimal but board may work, potential reliability issue] | [Full explanation, recommendation] |\n| SUGGESTION | [Improvements, best practices, documentation gaps] | [Rationale, optional fix] |\n\n## Positive Findings\n[Numbered list of things the design does well — builds designer confidence and validates good practices. Examples:]\n1. All ICs have local decoupling capacitors within 3mm — good EMC practice\n2. USB differential pairs are length-matched within 0.2mm — well within USB 2.0 spec (25ps)\n3. Feedback divider values for U3 (TPS61023) match the datasheet application circuit exactly (590K/200K → 2.37V)\n4. Thermal vias under QFN packages: U1 has 9 vias (TI recommends 6-9) — adequate thermal path\n\n## Analyzer Gaps\n[Numbered list of things the analyzer missed, got wrong, or couldn't detect — transparency about tool limitations. Examples:]\n1. Crystal Y1 (32.768 kHz) load capacitor validation skipped — no CL spec in analyzer output for this crystal\n2. Connector J3 pinout could not be verified — no datasheet found for this custom connector\n3. Analog ground (AGND) to digital ground (DGND) connection point not analyzed — single-point connection must be verified visually\n4. U5 (custom library symbol from `mylib:XYZ123`) — pin mapping not verified against datasheet due to missing MPN\n```\n\n## Analyzer Output Field Reference\n\nQuick reference for what each analyzer produces, to ensure no analysis dimension is missed in the report.\n\n### Schematic Analyzer (`analyze_schematic.py`)\n\n| Output Section | Report Section | Key Fields |\n|---|---|---|\n| `statistics` | Component Summary | component_types, power_rails, missing_mpn |\n| `bom` | Component Summary, BOM Optimization | deduplicated parts with quantities |\n| `components` | IC Spot-Check, throughout | full component details with pin_uuids, parsed_value |\n| `nets` | Net Tracing, throughout | per-net pin lists with pin_type |\n| `subcircuits` | Power Tree | auto-detected power/signal subcircuits |\n| `ic_pin_analysis` | MCU pin audit | per-IC pin utilization summary |\n| `findings[] (detector: power_regulators)` | Power Regulators | topology, vref_source, vout_estimate, vout_net_mismatch, inverting |\n| `findings[] (detector: feedback_networks)` | Feedback Networks | R_top, R_bottom, vref, vout, vref_source |\n| `findings[] (detector: voltage_dividers)` | Voltage Dividers | ratio, output_voltage |\n| `findings[] (detector: rc_filters)` | RC/LC Filters | cutoff_hz, filter_type |\n| `findings[] (detector: lc_filters)` | RC/LC Filters | resonant_freq_hz |\n| `findings[] (detector: opamp_circuits)` | Op-Amp Circuits | configuration, gain |\n| `findings[] (detector: protection_devices)` | Protection Devices | type, placement |\n| `findings[] (detector: transistor_circuits)` | Transistor Circuits | load_type (motor/heater/fan/solenoid/etc.), is_pchannel, gate_drive |\n| `findings[] (detector: bridge_circuits)` | Bridge Circuits | topology, half_bridges, driver_ics |\n| `findings[] (detector: crystal_circuits)` | Crystal Circuits | cload, frequency |\n| `findings[] (detector: current_sense)` | Current Sense | shunt_value, gain |\n| `findings[] (detector: decoupling_analysis)` | Decoupling Analysis | per-rail cap inventory |\n| `findings[] (detector: buzzer_speaker_circuits)` | Buzzer/Speaker | driver topology |\n| `findings[] (detector: rf_chains)` | RF Chains | component chain |\n| `findings[] (detector: bms_systems)` | BMS Systems | cell monitoring |\n| `findings[] (detector: ethernet_interfaces)` | Ethernet | magnetics, PHY |\n| `findings[] (detector: memory_interfaces)` | Memory | bus width |\n| `findings[] (detector: key_matrices)` | Key Matrices | row/col count |\n| `findings[] (detector: isolation_barriers)` | Isolation | isolation type |\n| `findings[] (detector: battery_chargers)` | Battery Chargers | charger_type, charge_current |\n| `findings[] (detector: motor_drivers)` | Motor Drivers | driver_type (stepper/dc_brushed) |\n| `findings[] (detector: esd_coverage_audit)` | ESD Coverage | per-connector coverage, risk_level |\n| `findings[] (detector: debug_interfaces)` | Debug Interfaces | SWD/JTAG, target_ic |\n| `findings[] (detector: power_path)` | Power Path | load switches, ideal diodes, USB PD |\n| `findings[] (detector: adc_circuits)` | ADC Circuits | external ADCs, voltage references |\n| `findings[] (detector: reset_supervisors)` | Reset/Supervisor | supervisors, watchdogs, RC reset |\n| `findings[] (detector: clock_distribution)` | Clock Distribution | generators, PLLs, oscillator outputs |\n| `findings[] (detector: display_interfaces)` | Display/Touch | display type, touch controller |\n| `findings[] (detector: sensor_interfaces)` | Sensor Fusion | motion/environmental/magnetic, interrupt pins |\n| `findings[] (detector: level_shifters)` | Level Shifters | IC + discrete, supply domains |\n| `findings[] (detector: audio_circuits)` | Audio Circuits | amplifiers, codecs, I2S |\n| `findings[] (detector: led_driver_ics)` | LED Driver ICs | PWM/matrix/constant-current |\n| `findings[] (detector: rtc_circuits)` | RTC Circuits | battery backup, crystal pairing |\n| `findings[] (detector: led_audit)` | LED Audit | current limiting validation |\n| `findings[] (detector: thermocouple_rtd)` | Thermocouple/RTD | amplifiers, RTD interfaces |\n| `findings[] (detector: power_sequencing_validation)` | Power Sequencing | power tree, enable chains, issues |\n| `findings[] (detector: design_observations)` | Design Observations | automated findings |\n| `design_analysis.net_classification` | Net Classification | per-net class (power/data/analog/output_drive/etc.) |\n| `design_analysis.power_domains` | Power Domains | per-IC rail mapping with IO rails |\n| `design_analysis.cross_domain_signals` | Cross-Domain Signals | voltage equivalence filtering |\n| `design_analysis.bus_analysis` | Bus Topology | I2C/SPI/UART/CAN with COPI/CIPO support |\n| `design_analysis.differential_pairs` | Differential Pairs | suffix-pair matching, protocol guessing |\n| `design_analysis.erc_warnings` | ERC Warnings | type, severity |\n| `design_analysis.passive_warnings` | Passive Warnings | unusual values |\n| `connectivity_issues` | Connectivity Issues | unconnected, single-pin, multi-driver |\n| `pdn_impedance` | PDN Impedance | per-rail impedance with MLCC parasitics |\n| `power_budget` | Power Budget | load vs capacity |\n| `power_sequencing` | Power Sequencing | EN/PG chains |\n| `sleep_current_audit` | Sleep Current | per-rail with regulator Iq, EN detection |\n| `inrush_analysis` | Inrush Analysis | per-regulator inrush (automated); also manually consider IC supply pin abs max ratings and capacitor charging through connectors |\n| `ground_domains` | Power & Ground | AGND/DGND/PGND separation |\n| `sourcing_audit` | Sourcing Audit | MPN and distributor PN coverage (report neutrally, no distributor preference) |\n| `bom_optimization` | BOM Optimization | value consolidation |\n| `test_coverage` | Test Coverage | test points, debug connectors |\n| `assembly_complexity` | Assembly Complexity | score, difficulty breakdown |\n| `usb_compliance` | USB Compliance | connector, ESD, CC |\n| `simulation_readiness` | Simulation Readiness | SPICE model coverage |\n| `label_shape_warnings` | Label Warnings | direction mismatches |\n| `pwr_flag_warnings` | PWR_FLAG | missing flags |\n| `footprint_filter_warnings` | Footprint Filters | library mismatches |\n| `annotation_issues` | Label Warnings | duplicate/missing refs |\n| `property_issues` | Quality | property pattern issues |\n| `placement_analysis` | (spatial context) | component clusters, grid |\n| `alternate_pin_summary` | MCU pin audit | alt function usage |\n\n### PCB Analyzer (`analyze_pcb.py`)\n\n| Output Section | Report Section | Key Fields |\n|---|---|---|\n| `statistics` | Board Overview | copper_layers_used (tracks+vias+zones), layer names, SMD/THT counts |\n| `layers` | Board Overview | full layer stack |\n| `setup` | Board Overview | thickness, mask clearance |\n| `board_outline` | Board Overview | bounding box, edge geometry |\n| `board_metadata` | Board Overview | title block, paper |\n| `footprints` | Footprint Placement | per-footprint pads, nets, schematic cross-ref (sch_path, sch_sheetname) |\n| `component_groups` | Footprint Placement | grouped by reference prefix |\n| `tracks` | Trace Routing | width/layer distribution |\n| `vias` | Via Analysis | size distribution, via_analysis (types, annular ring, via-in-pad, tenting) |\n| `zones` | Power & Ground | per-zone net, layers, outline/filled bbox, fill area, fill_ratio, thermal settings |\n| `connectivity` | Connectivity | routing completeness, unrouted list |\n| `net_lengths` | Signal Integrity | per-net trace length and layer transitions |\n| `power_net_routing` | Power & Ground | power net width/length/current capacity |\n| `ground_domains` | Power & Ground | AGND/DGND domains, multi-domain components |\n| `findings[] (detector: analyze_current_capacity)` | Current Capacity | per-net capacity vs load |\n| `findings[] (detector: analyze_thermal_vias)` | Thermal Analysis | zone stitching density |\n| `findings[] (detector: analyze_thermal_pad_vias)` | Thermal Analysis | per-footprint thermal pad via count and adequacy; `effective_via_count` weights by `(drill/0.3)²` — see pcb-layout-analysis.md for methodology |\n| `decoupling_placement` | Decoupling Placement | cap-to-IC distances |\n| `findings[] (detector: analyze_placement)` | Footprint Placement | courtyard overlaps, edge clearance |\n| `placement_density` | Footprint Placement | density metrics |\n| `layer_transitions` | Signal Integrity | per-net layer change tracking |\n| `silkscreen` | Silkscreen | ref visibility, documentation warnings |\n| `dfm_summary` | DFM Assessment | tier, metrics, violation_count |\n| `findings[] (category: dfm)` | DFM Assessment | individual violations |\n| `findings[] (detector: analyze_tombstoning_risk)` | Manufacturing | at-risk 0201/0402 components, thermal asymmetry reasons |\n| `findings[] (detector: analyze_copper_presence)` | Copper Presence | no_opposite_layer_copper, same_layer_foreign_zones, touch_pad_gnd_clearance |\n| `copper_presence_summary` | Copper Presence | opposite_layer_summary |\n\n### Gerber Analyzer (`analyze_gerbers.py`)\n\n| Output Section | Report Section | Key Fields |\n|---|---|---|\n| `statistics` | Gerber Analysis | file counts, total holes/flashes/draws |\n| `completeness` | Layer Completeness | found/missing layers, source |\n| `alignment` | Alignment Verification | aligned status, layer extents |\n| `drill_classification` | Drill Classification | vias, component holes, mounting holes |\n| `pad_summary` | Pad Summary | SMD/THT/via/heatsink apertures |\n| `board_dimensions` | Board Dimensions | from gerber extents |\n| `gerbers` | (per-layer detail) | aperture functions, trace widths, X2 attributes |\n| `drills` | (per-file detail) | tool list, hole counts |\n\n## Severity Definitions\n\nUse these consistently across all reports:\n\n- **CRITICAL**: The board will not function as designed, or there is a safety/damage risk. Examples: swapped pins in symbol library, output contention from shorted op-amp outputs, regulator can't start, missing power path, thermal pad without ground vias on QFN.\n- **WARNING**: The board may work but has a reliability, performance, or compliance concern. Examples: floating digital inputs, missing flyback diode on inductive load, cross-domain signal without level shifter, inadequate decoupling, regulator thermal margin tight.\n- **SUGGESTION**: Best-practice improvement or documentation gap that doesn't affect functionality. Examples: missing MPNs, no test points on power rails, DNP components not marked, footprint filter mismatch with custom library.\n\n## Writing Principles\n\n### Be specific and actionable\nBad: \"Power supply may have issues\"\nGood: \"LMR51450 (U7) feedback divider R12/R13 gives Vout = 0.6*(1+100k/47k) = 1.88V, but target rail is 3.3V. Vref for LMR51450 is 1.0V (not 0.6V assumed by analyzer), giving actual Vout = 1.0*(1+100k/47k) = 3.13V — still 5% below target.\"\n\n### Show your work\nInclude the calculation, the datasheet reference, and the conclusion. EE designers want to verify your reasoning, not just trust a pass/fail label.\n\n### Distinguish analyzer findings from manual findings\nMake it clear what came from the analyzer JSON vs what you found by reading the raw schematic. This helps designers understand the tool's coverage.\n\n### Call out false positives explicitly\nWhen the analyzer flags something that isn't actually a problem, explain why it's a false positive. This prevents designers from wasting time investigating non-issues and helps calibrate trust in the analyzer.\n\n### Validate calculations, don't just echo them\nWhen the analyzer reports a voltage divider ratio or filter cutoff, verify the calculation: check the formula, confirm the component values against the schematic, and validate the result against the datasheet's expected values (Vref, recommended output voltage, etc.).\n\n### Cross-check against datasheets\nThe highest-value findings come from verifying component connections and values against datasheets. Check IC pin assignments against the datasheet pinout, feedback divider values against regulator recommendations, capacitor selections against min/max requirements, and pull-up/pull-down values against acceptable ranges. These checks catch the bugs that internal consistency checks miss.\n\n### Assess plausibility when verification isn't possible\nWhen a component can't be fully verified (missing MPN, missing datasheet), don't just report \"unverified\" and move on — assess how likely the design choice is to be correct. Use domain knowledge: typical pinouts for that device/package combination, standard passive values for the application, common circuit topologies. Report the assessment alongside the ambiguity. \"Q1 uses Q_NPN_BEC (SOT-23) with no MPN — BCE is the most common SOT-23 NPN pinout, so this is likely correct but should be confirmed\" is far more useful than \"Q1 pinout is unverified.\" The principle: ambiguity is not uniform risk. Some unverified choices align with strong conventions and are low risk; others are genuinely ambiguous or go against convention and deserve higher suspicion.\n\n### Verify battery and power source configurations\nDon't assume a battery holder is a single cell. Check the footprint, part number, and trace connectivity to determine the actual battery configuration (series vs parallel, cell count). A 2×AA holder provides ~3V nominal, not 1.5V — getting this wrong invalidates the entire power tree analysis.\n\n### Check thermal vias in footprints\nThermal vias for QFN/BGA/module packages may be embedded in the footprint definition as thru_hole pads (sharing the thermal pad's net and number) rather than standalone vias. The PCB analyzer counts both types. When reviewing thermal via adequacy, confirm you're counting footprint-embedded via pads in addition to standalone vias.\n\n### Distributor neutrality\nNever recommend or prefer any specific component distributor (DigiKey, LCSC, Mouser, etc.) by name in the report. Keep sourcing observations neutral — report MPN coverage and missing part numbers without directing the user toward a particular source.\n\n### Power tree is king\nFor almost every board, the power tree section is the most valuable. Draw the complete hierarchy from input to every rail, including regulator types, enable conditions, and output capacitance. This single diagram often reveals the most critical issues (missing paths, wrong sequencing, inadequate capacitance).\n\n## Handling Different Design Domains\n\n### IoT / Battery-Powered\nFocus on: sleep current budget (validate against analyzer's worst-case estimates), startup voltage thresholds, power path (USB vs battery), regulator quiescent current (check Iq estimates), deep sleep GPIO configuration.\n\n### Motor Control\nFocus on: H-bridge/3-phase topology detection, gate driver bootstrap, current sense chain, MOSFET load classification (motor/heater/fan/solenoid), ground domain separation (analog/power), bulk capacitance adequacy, reverse polarity protection, thermal considerations.\n\n### RF / SDR\nFocus on: RF chain path tracing through switches, LNA/mixer/filter identification, frequency planning, decoupling tier analysis (bulk + bypass + HF), reference clock distribution, ground plane continuity.\n\n### Precision Analog / Instrumentation\nFocus on: op-amp configurations and gain accuracy, reference voltage chain, input protection on measurement channels, ADC/DAC interface verification, PGA detection, bipolar supply generation, guard traces.\n\n### Industrial / Multi-rail\nFocus on: power sequencing (EN/PG chains from analyzer), input protection (TVS, MOV, fuses), communication buses (CAN, RS485, Ethernet — check bus analysis), isolation barriers, creepage/clearance for high voltage. The Standards Compliance section in the report template is especially important here — fill in all subsections including creepage/clearance.\n\n## Cross-Referencing with Raw Schematic\n\nThe analyzer can silently produce plausible but incorrect results. Cross-reference against the raw `.kicad_sch` AND manufacturer PDF datasheets to catch these. Internal consistency checks (schematic matches PCB matches analyzer) are necessary but not sufficient — they only prove the design agrees with itself, not that it matches the real-world parts. The full verification procedure is in SKILL.md — the key checks are:\n\n1. **Component count**: Analyzer total vs `grep -c '(lib_id' file.kicad_sch` (subtract power symbols)\n2. **Pin-to-net mapping**: Verify against raw schematic for each component. Cross-reference IC pin assignments against **manufacturer PDF datasheets** (not KiCad library symbols — the library is the potential source of error). Cite datasheet page/section numbers.\n3. **Physical correctness**: For components with package-dependent pinouts (transistors in SOT-23 etc.), verify symbol assumptions against the MPN's datasheet — consistency checks alone don't catch wrong pinout assumptions. Custom/community library symbols are highest risk.\n4. **Net connectivity**: Trace power rails and critical signal nets end-to-end.\n5. **Signal analysis**: Confirm detected subcircuit topologies against the raw schematic.\n6. **Hierarchical sheets**: Verify all sub-sheets were parsed (`grep -c '(sheet ' file.kicad_sch`).\n\n## Known Analyzer Limitations\n\nDocument these when they affect the report — it helps the designer understand what the tool can and can't catch:\n\n- **Vref coverage**: Feedback divider Vout calculations use a lookup table (~60 regulator families) with heuristic fallback. When `vref_source` is `\"heuristic\"`, the assumed Vref may be wrong — always verify against the datasheet. The `vout_net_mismatch` field flags cases where estimated Vout differs >15% from the output rail name voltage.\n- **Legacy format**: KiCad 5 `.sch` files get full analysis when `.lib` files are available in the repo (92–100% typical coverage). Components whose `.lib` files are missing will lack pin data and won't participate in signal analysis or subcircuit detection.\n- **Sleep current model**: Reports both worst-case (`total_estimated_sleep_uA`) and realistic (`realistic_total_uA`) estimates. Worst-case assumes all pull-ups driven low simultaneously; realistic uses topology-aware state estimation (LEDs off, disableable regulators off, rails from EN-equipped regulators disabled). The realistic estimate may still overcount if the design has additional sleep-mode controls not visible in the schematic topology.\n- **Cross-domain analysis**: Uses voltage equivalence (parsing voltage from rail names) to reduce false positives, but rails without parseable voltages in their names may still trigger false cross-domain warnings.\n- **MOSFET load classification**: Net name keyword detection covers common patterns (motor, heater, fan, solenoid, valve, pump, relay, speaker, buzzer, lamp) but may miss unusual naming conventions.\n- **Bridge circuits**: Cross-sheet detection works through unified hierarchical nets. Topology classification is based on half-bridge count (1=half, 2=H-bridge, 3+=3-phase) which may misclassify independent half-bridges as an H-bridge.\n\n## Fabrication Notes (Optional)\n\n[Include when DFM analysis was performed and the user is preparing for manufacturing. Practical guidance specific to the board:]\n\n- **Fab tier**: Standard vs advanced process capability (based on DFM scoring from PCB analyzer)\n- **Recommended settings**: Copper weight, surface finish, impedance control (if controlled-impedance traces detected)\n- **Stencil guidance**: If fine-pitch components detected (QFN, BGA), note stencil thickness recommendations\n- **Assembly notes**: Component placement order, reflow considerations, hand-solder items\n- **Specific assembler notes**: JLCPCB basic vs extended parts count, PCBWay turnkey vs consigned\n\nThis section bridges the design review into the ordering workflow — the `jlcpcb` and `pcbway` skills handle the ordering specifics, but the report should flag anything the designer needs to address before ordering.\n\n## Report Length Guidelines\n\nTypical report lengths by design complexity:\n\n| Design | Components | Typical Report |\n|--------|-----------|---------------|\n| Simple (IoT, single sheet) | 30-100 | 150-250 lines |\n| Medium (motor control, few sheets) | 100-300 | 200-350 lines |\n| Complex (DAQ, RF, many sheets) | 300-700 | 300-450 lines |\n\nKeep it thorough but avoid padding. Every line should provide information the designer can act on or use to build confidence in the design.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":55362,"content_sha256":"63c1aeecdc7af17d623bde37b1dc4206da4c0635a523b99cb940585a778e3777"},{"filename":"references/schematic-analysis.md","content":"# Deep Schematic Analysis\n\nMethodology for validating KiCad schematics against datasheets, common design patterns, and electrical engineering best practices. This goes far beyond ERC — it catches design errors that only a human reviewer (or a thorough AI analysis) would find.\n\n## Table of Contents\n\n1. [Analysis Workflow](#analysis-workflow) — Steps 1-9 including script verification and manual fallback\n2. [Subcircuit Identification](#subcircuit-identification)\n3. [Datasheet-Driven Validation](#datasheet-driven-validation)\n4. [Design Pattern Library](#design-pattern-library)\n5. [Value Computation Verification](#value-computation-verification)\n6. [Error Taxonomy](#error-taxonomy)\n7. [Manufacturing & Sourcing Review](#manufacturing--sourcing-review)\n8. [Battery-Powered Design Considerations](#battery-powered-design-considerations)\n9. [Worst-Case Tolerance Stack Analysis](#worst-case-tolerance-stack-analysis) — Combined tolerance effects on critical values\n10. [GPIO Multiplexing Audit](#gpio-multiplexing-audit) — MCU pin assignment conflicts\n11. [Connector Pinout Verification](#connector-pinout-verification) — Standard pinout checks\n12. [Clock Tree Analysis](#clock-tree-analysis) — Clock distribution and integrity\n13. [Motor Control Design Review](#motor-control-design-review) — H-bridge, bootstrap, current sense\n14. [Battery Life Estimation](#battery-life-estimation) — Power budget and runtime calculation\n15. [Supply Chain Risk Assessment](#supply-chain-risk-assessment) — Sole-source and obsolescence checks\n16. [Report Format](#report-format)\n\n**Fallback methodology**: If `analyze_schematic.py` fails, see [`manual-schematic-parsing.md`](manual-schematic-parsing.md) for direct file parsing instructions.\n\n**For full design reviews:** read `report-generation.md` before writing conclusions. The report format, evidence-basis labeling, skipped-analysis disclosure, and false-positive triage expectations are part of the review method, not post-processing.\n\n---\n\n## Analysis Workflow\n\nFollow this sequence for a thorough schematic review. Each step builds on the previous.\n\n### Step 1: Run the schematic analyzer\n\nRun `analyze_schematic.py` on the schematic file (see SKILL.md for the command). The JSON output provides:\n- Component inventory grouped by type, with values, footprints, MPNs\n- Full net connectivity map with pin-to-net mapping\n- **Automated subcircuit detection** (in `findings[]`, filtered by `detector` field): power regulators, voltage dividers, RC/LC filters, op-amp circuits, transistor circuits, bridge circuits, protection devices, current sense, crystal circuits, feedback networks, decoupling analysis, plus domain-specific detections (RF chains/matching, BMS, Ethernet, HDMI/DVI, memory interfaces, key matrices, isolation barriers, addressable LEDs, battery chargers, motor drivers, ESD protection audit, debug interfaces, power path/load switches, ADC signal conditioning, reset/supervisor circuits, clock distribution, display/touch interfaces, sensor fusion, level shifters, audio circuits, LED driver ICs, RTC circuits, LED lighting audit, thermocouple/RTD, power sequencing validation)\n- Design observations (decoupling coverage, I2C pull-ups, crystal load caps, etc.)\n\nUse this structured data as the starting point — it replaces manual component extraction and most subcircuit identification.\n\n**If the script fails or returns unexpected results** (0 components, crash, etc.), fall back to manual parsing. See `manual-schematic-parsing.md` for the complete fallback methodology.\n\n**If the script returns incomplete data** (some components missing pins — typically due to `.lib` files not being available in the repo for legacy KiCad 5 projects), use supplementary project files to recover the missing data. See `supplementary-data-sources.md` for the netlist parsing and PCB cross-reference workflow.\n\n### Step 2: Verify script output against the raw schematic\n\nPerform thorough verification of the analyzer output against the raw schematic. This is not a quick spot-check — it's the primary defense against silent misparsing that leads to incorrect analysis.\n\n1. **Component count**: Read the raw `.kicad_sch` file and count `(symbol (lib_id ...))` blocks in the placed symbols section (after `(lib_symbols)`). Subtract power symbols (`#PWR`, `#FLG`). Compare against the analyzer's component count — must match exactly.\n\n2. **Complete pinout verification for ALL components**: For **every** component in the design (ICs, connectors, transistors, diodes, multi-pin passives), verify:\n - Value, lib_id, and footprint match the raw file\n - **Every pin's net assignment** matches the raw schematic (trace wires/labels from each pin position). This is the most critical check — a single swapped pin produces a non-functional board and passes DRC/ERC silently.\n - For ICs: cross-reference pin assignments against the manufacturer's datasheet pin table (not just the KiCad library). Library symbols can have wrong pin mappings.\n - Multi-unit symbols (op-amps, MCUs) list each unit separately with correct pin assignments\n - Pin count matches between the library symbol and the analyzer output\n - For transistors: verify pinout matches datasheet (SOT-23 pinout varies: BCE vs BEC vs CBE)\n - For polarized components: verify anode/cathode and +/- orientation\n - For 2-pin passives in critical positions (voltage dividers, feedback networks, filter caps): verify they connect between the correct nets\n\n Do not sample or limit this to \"key\" components. The part you skip is the one with the problem. Verify all of them.\n\n3. **Full net tracing**: Trace all power rails and critical signal nets end-to-end through the raw file — follow wires from pin coordinates through labels and junctions. Verify the analyzer's pin list for each net is complete. Don't limit to 2-3 nets; trace every power rail and every bus.\n\n4. **Regulator sanity**: For each detected power regulator, verify in the raw file that the component actually has VIN/VOUT (or FB/SW) pins and connects to the reported power rails. Custom-library regulators without standard keywords are a known edge case — check that the analyzer didn't miss any obvious LDOs or converters.\n\n5. **Connector pinout verification**: For every connector, verify pin-to-net mapping against the relevant standard or mating connector. Connector pinout errors are among the most common mistakes (see Connector Pinout Verification section below).\n\nThis thorough verification catches the cases where the analyzer silently drops components, misidentifies subcircuits, or — most dangerously — reports wrong pin-to-net mappings.\n\n**Evidence classification during Step 2:** as you verify items, keep track of which bucket each conclusion belongs to:\n- datasheet-verified\n- extraction-verified\n- raw-file verified\n- analyzer-derived\n- inference-only\n\nDo not collapse these into a generic \"verified\" label in the final report.\n\n### Step 3: Review and augment subcircuit identification\n\nThe analyzer's `findings[]` array automatically identifies most subcircuits (filter by `detector` field). Review its output and augment with any subcircuits it may have missed. Spot-check a few detected subcircuits against the raw schematic — verify the components and nets are correct. Common subcircuit boundaries:\n- Each voltage regulator + its input/output caps + feedback resistors = one block\n- Each IC + its decoupling caps + supporting passives = one block\n- Each connector + its ESD protection + filtering = one block\n- Crystal/oscillator + load caps = one block\n- Each LED + current-limiting resistor = one block\n\n### Step 4: Fetch and analyze datasheets\n\n**Datasheets are mandatory for verification — not optional reference material.** Without datasheets, you cannot confirm that the schematic's pin assignments match reality. Every IC pinout verification in Step 2 requires the datasheet's pin table as ground truth.\n\n**Automated sync (preferred):** If the `digikey` skill is installed, run `sync_datasheets.py` on the schematic. This should have been done in the workflow's Step 3 (see SKILL.md). If not done yet, run it now:\n\n```bash\npython3 \u003cdigikey-skill-path>/scripts/sync_datasheets.py \u003cfile.kicad_sch>\n```\n\n**Check for existing datasheets:** Before downloading, look for:\n- `\u003cproject>/datasheets/` with `manifest.json` (legacy name `index.json`) from a previous sync\n- `\u003cproject>/docs/` or `\u003cproject>/documentation/`\n- PDF files in the project directory whose names contain MPNs\n- `Datasheet` property URLs embedded in the KiCad symbols (the digikey skill names them as `\u003cMPN>_\u003cDescription>.pdf`)\n\n**If datasheets are missing for any component:** Use these fallback methods in order:\n1. Use the `Datasheet` property URL from the schematic symbol\n2. Use the `digikey` skill to search by MPN and download\n3. Use web search to find the manufacturer's datasheet page\n4. **Ask the user** — do not silently skip verification. Tell them: \"I need datasheets for [list of parts] to verify the pinout and application circuit. Can you provide them or point me to a datasheets directory?\"\n\nFor each IC and active component, extract and **note the page/section numbers** for later citation:\n- **Pin function table** (pin number → name → function) — this is the ground truth for pinout verification\n- Absolute maximum ratings (voltage, current, temperature)\n- Recommended operating conditions\n- Typical/reference application circuit (note the figure number, e.g., \"Figure 8-1\")\n- Required external components (with recommended values and the equation number, e.g., \"Equation 4\")\n- Thermal characteristics (junction-to-ambient, power dissipation limits)\n\n**For passives:** Individual resistor/capacitor datasheets aren't usually needed, but verify passive values against the IC datasheets that specify them. If an IC datasheet says \"use 10µF X5R on VIN\" and the schematic has 1µF or Y5V, that's a bug.\n\nThese references are essential for the report — every design validation claim should cite the specific datasheet section, page, figure, or equation it was checked against.\n\n### Step 5: Validate each subcircuit\n\nCompare the actual schematic against the datasheet's reference design. Check:\n- Are all required external components present?\n- Do component values match datasheet recommendations?\n- Are pins connected correctly (no swaps)?\n- Are optional features (enable, power-good, soft-start) handled appropriately?\n- Are absolute maximum ratings respected with margin?\n\nWhen a datasheet recommendation is absent or ambiguous, say so. Do not overstate confidence just because the topology looks conventional.\n\n### Step 6: Verify computed values\n\nFor every value that derives from a formula (resistor dividers, RC filters, current limits, etc.), compute the expected result and compare to the design intent. Flag discrepancies.\n\n### Step 7: Check cross-cutting concerns\n\nAfter subcircuit validation, check system-level issues:\n- Power sequencing across all regulators\n- Signal level compatibility between ICs (3.3V vs 5V logic). Note: the analyzer's `cross_domain_signals` detects these, but `needs_level_shifter: False` when the only cross-domain IC is an ESD protection device (e.g., USBLC6 on USB lines — USB signaling is 3.3V regardless of VBUS rail)\n- Decoupling strategy completeness\n- ESD protection on all external interfaces\n- Thermal budget (total power dissipation vs cooling)\n- Inductive loads driven from GPIOs: buzzers/speakers/relays driven directly from GPIO without a transistor driver or flyback diode. The analyzer's `buzzer_speaker_circuits` flags `direct_gpio_drive: true` for these.\n- LED driver completeness: the analyzer's transistor circuits include `led_driver` when a MOSFET drives an LED through a current-limiting resistor. Verify current levels are within LED and GPIO limits.\n- Battery-powered considerations:\n - Verify battery voltage range covers the regulator's input range (including UVLO startup threshold vs minimum battery voltage). Note: the battery component type alone doesn't tell you the cell configuration (single cell vs multi-cell series) — check the footprint and schematic context.\n - Check if USB or external power can operate the device when the battery is dead (look for power-path ORing or a charging circuit)\n - Verify shutdown/quiescent current is acceptable for battery life\n - Check that EN pins on unused regulators have proper pull-down/pull-up for safe default state\n\n### Step 8: Validate coordinate-based findings before reporting\n\n**Any finding that relies on coordinate math (pin positions, wire tracing, no-connect matching) is error-prone and must be validated before reporting as Critical or Warning.** The most common errors:\n\n1. **Y-axis inversion bug**: Forgetting that `absolute_Y = symbol_Y - pin_Y` (not `+`). This inverts the entire pin map and causes every pin to appear connected to the wrong net. See `net-tracing.md` for the correct transform.\n2. **Label offset**: Global labels connect to pins via wires, not at pin endpoints. A label placed 5mm from a pin along a wire stub is still connected — checking only the pin coordinate will miss it.\n3. **Wire extraction bugs**: KiCad 9 spreads `(wire`, `(pts`, and coordinates across multiple lines. A regex that only checks the next line will miss coordinates on line +2 or +3.\n4. **Reference designator reuse**: A reference (e.g., R13) may be reused for a completely different component between schematic revisions. Always check the actual circuit context, not just the designator name.\n\nIf your coordinate-based analysis finds \"critical\" issues (floating pins, wrong connections) but the user says the schematic is correct, **assume your coordinate math is wrong** and re-derive from scratch with the Y-axis formula from `net-tracing.md`.\n\n### Step 8b: Triage analyzer false positives before ranking severity\n\nBefore turning analyzer output into blockers, explicitly classify each notable finding as one of:\n- real issue\n- likely false positive\n- expected-by-design tradeoff\n- unresolved / needs more evidence\n\nCommon sources of false positives:\n- RF module courtyard / antenna keepout overlaps\n- intentional copper under bypass caps or local power islands\n- general USB resistor heuristics that do not apply to the specific PHY\n- board-edge warnings triggered by intentional antenna placement\n- inferred lifecycle or sourcing warnings based on partial distributor coverage\n\nA strong review explains why a finding was accepted, downgraded, or dismissed.\n\n### Step 9: Produce the report\n\nOrganize findings by severity (Critical / Warning / Suggestion / Info) and by subcircuit. For each finding, show the reasoning — not just the conclusion:\n\n- **Cite datasheet sources**: Reference the specific datasheet section, page, figure, table, or equation that supports the finding (e.g., \"per TPS61023 datasheet Table 6.3, page 4: VREF = 595mV typical\").\n- **Show formulas**: When validating computed values (feedback dividers, RC filters, current limits), write out the equation with the actual component values substituted in (e.g., \"VOUT = VREF × (1 + R_top/R_bottom) = 0.595V × (1 + 732k/100k) = 4.95V\").\n- **Compare against spec**: Show the design's value alongside the datasheet's recommended range so the reader can see the margin (e.g., \"L = 1µH, datasheet recommends 0.37-2.9µH ✓\").\n- **Explain the chain of reasoning** for non-obvious issues: if something looks wrong, explain how you traced the net, what you expected to find, and what you found instead.\n- **Disclose review limits**: If thermal, lifecycle, gerber, prior-review delta, or datasheet extraction work was not performed, state that explicitly in the report.\n- **Separate evidence from judgment**: A finding can be well-supported and still not be a blocker. Distinguish the factual observation from the severity judgment.\n\n---\n\n## Subcircuit Identification\n\n### How to identify subcircuit boundaries\n\nTrace nets outward from each IC. The IC plus everything directly connected to its pins (within 1-2 hops) typically forms a subcircuit. Shared nets like power rails and ground are boundaries — they connect subcircuits but don't belong to any single one.\n\n### Common subcircuit types\n\n| Type | Key Components | Identifying Features |\n|------|---------------|---------------------|\n| **Linear regulator (LDO)** | Regulator IC, Cin, Cout, feedback divider (if adjustable) | IC with VIN, VOUT, GND pins; caps on input and output |\n| **Switching regulator (buck/boost)** | Controller IC, inductor, diode/FET, Cin, Cout, feedback divider | Inductor in the power path, SW/LX pin |\n| **Crystal oscillator** | Crystal (Y), 2 load caps | Connected to XIN/XOUT or OSC1/OSC2 pins of an MCU |\n| **USB interface** | Connector, ESD diode, series resistors, decoupling | D+/D- nets, VBUS, shield/shell ground |\n| **I2C bus** | Pull-up resistors, bus devices | SDA/SCL nets with pull-ups to VCC |\n| **SPI bus** | Chip select resistors, bus devices | MOSI/MISO/SCK/CS nets |\n| **UART** | Level shifter (if needed), connector | TX/RX nets |\n| **LED indicator** | LED, current-limiting resistor | LED symbol with series resistor to GPIO or power |\n| **Reset circuit** | Pull-up resistor, cap, optional supervisor IC | Connected to RESET/nRST pin |\n| **Decoupling** | Ceramic cap (100nF typical), bulk cap | Connected between VCC and GND near IC |\n| **ESD protection** | TVS diode array | Connected to external-facing signal lines |\n| **Motor/relay driver** | MOSFET or driver IC, flyback diode | Inductive load with freewheeling diode |\n| **Analog sensing** | Op-amp or ADC input, voltage divider, filter cap | Signal conditioning into ADC pin |\n| **Battery management** | Charge controller IC, sense resistor, protection FET | Battery connector, charge/discharge paths |\n| **Level shifting** | Level shifter IC or MOSFET + pull-ups | Bridges two different voltage domains |\n\n---\n\n## Using Pre-Extracted Datasheet Specs\n\nWhen `datasheets/extracted/\u003cMPN>.json` files are available (produced by the `datasheets` skill — see its `references/extraction-schema.md` for the canonical field layout), use them to accelerate pin-by-pin verification:\n\n1. **Load the extraction** for each IC alongside the analyzer's `ic_pin_analysis` output\n2. **Join on pin number** — the extraction's `pins[].number` matches the analyzer's `pins[].pin_number`\n3. **For each pin, check:**\n - **Voltage compatibility:** Is the net voltage within the pin's `voltage_operating_min`/`voltage_operating_max`?\n - **Required externals:** Does the extraction's `required_external` field match what's actually connected?\n - **Power pins:** Does every VDD pin have a decoupling cap?\n - **Digital thresholds:** For digital inputs, are `threshold_high_v`/`threshold_low_v` met?\n - **NC pins:** Are pins marked as no-connect actually unconnected?\n4. **Cite extraction data** in findings\n\nPre-extracted data is especially valuable for large designs (10+ ICs). For small designs, direct PDF reading is equally effective.\n\n## Datasheet-Driven Validation\n\nFor each component type, here is what to extract from the datasheet and what to validate.\n\n### Voltage Regulators (LDO and Switching)\n\n**Extract from datasheet:**\n- Input voltage range (VIN min/max)\n- Output voltage (fixed or adjustable)\n- Maximum output current\n- Dropout voltage (LDO) or duty cycle limits (switching)\n- Required input capacitor: value, ESR range, type (ceramic OK? tantalum needed?)\n- Required output capacitor: value, ESR range, stability requirements\n- Feedback divider formula (adjustable types): `VOUT = VREF * (1 + R_TOP/R_BOTTOM)` or similar\n- Enable pin behavior (active high/low, threshold, internal pull-up/down)\n- Soft-start capacitor (if applicable)\n- Thermal shutdown temperature\n- Power-good output (if present)\n\n**Validate:**\n- Input voltage from upstream supply is within VIN min/max range\n- Output voltage matches design intent (compute from feedback divider if adjustable)\n- Load current (sum of all downstream consumers) is within rated maximum, with margin\n- Input cap meets datasheet requirements (value, type, voltage rating >= VIN_max * 1.5)\n- Output cap meets datasheet requirements (value, ESR, voltage rating >= VOUT * 1.5)\n- Enable pin is properly driven or tied (not floating)\n- Power dissipation is within package thermal limits: `P = (VIN - VOUT) * ILOAD` for LDO\n- For switching regulators: inductor value and saturation current meet requirements\n\n**Common errors:**\n- Output cap ESR too low or too high for regulator stability (some LDOs need ESR > 0.1 ohm)\n- Input cap missing or too small — causes input voltage ringing\n- Feedback divider resistors swapped (output voltage way off)\n- Dropout not accounted for — LDO can't regulate when VIN - VOUT \u003c dropout\n- VOUT cap voltage rating too close to VOUT (no margin for transients)\n\n### Microcontrollers / SoCs\n\n**Extract from datasheet:**\n- Power supply pins: all VDD/VSS pairs, analog supply (VDDA), USB supply, etc.\n- Decoupling requirements per pin group\n- Bulk capacitance requirements\n- Crystal/oscillator requirements: frequency range, load capacitance (CL), ESR max, drive level\n- Boot mode pin configuration\n- Reset circuit requirements (external cap, pull-up value, minimum pulse width)\n- I/O voltage levels (VIH, VIL, VOH, VOL) for each GPIO bank\n- Maximum GPIO source/sink current (per pin and total)\n- Special pin requirements (USB D+/D- bias, ADC reference, etc.)\n\n**Validate:**\n- Every VDD/VSS pair has a 100nF ceramic cap placed close to the pins\n- VDDA has its own filtering (ferrite bead + cap, or LC filter)\n- Bulk cap present on main supply (4.7uF-10uF typical)\n- Crystal load caps are correct: `CL_cap = 2 * (CL_crystal - C_stray)` where C_stray ~ 2-5pF\n- Boot pins are configured for the desired boot mode (not floating)\n- Reset pin has proper pull-up (typically 10k) and optional 100nF cap to ground\n- Unused GPIO pins are set to a known state (not floating) — either pulled up/down or marked no-connect\n- Total GPIO current draw doesn't exceed chip maximum\n- Signal voltage levels are compatible with connected ICs\n\n**Common errors:**\n- Missing decoupling on one VDD/VSS pair (especially on large BGA packages)\n- Crystal load caps computed wrong (using CL directly instead of the formula)\n- VDDA connected directly to VDD without filtering\n- Boot pins floating — MCU enters wrong boot mode randomly\n- ADC reference pin left unfiltered\n- USB VBUS not properly handled (missing 5V tolerance, or no decoupling)\n\n### Passive Components (Resistors, Capacitors, Inductors)\n\n**Validate for resistors:**\n- Power rating: `P = V^2 / R` or `P = I^2 * R` — must be within component's rated power with 50% derating\n- Voltage rating: voltage across resistor must not exceed maximum working voltage (relevant for high-value resistors in voltage dividers off high-voltage rails)\n- Tolerance is appropriate for the application (1% for feedback dividers, 5% OK for pull-ups)\n\n**Validate for capacitors:**\n- Voltage rating >= 1.5x maximum applied voltage (accounts for DC bias derating in ceramics)\n- DC bias derating: MLCC capacitance drops significantly at applied voltage (a 10uF 6.3V X5R cap at 5V may only provide 3-4uF actual). For critical applications, check the manufacturer's DC bias curves.\n- Dielectric type is appropriate: C0G/NP0 for precision/timing, X7R for general bypass, X5R for bulk (never Y5V for anything critical)\n- Temperature range matches application\n- ESR is appropriate (low ESR for bypass, may need higher ESR for LDO stability)\n\n**Validate for inductors:**\n- Saturation current >= peak current * 1.3 (derating)\n- DC resistance acceptable for power loss budget\n- Inductance value matches switching regulator requirements\n- Shielded vs unshielded appropriate for the application (shielded preferred near sensitive circuits)\n\n### Diodes and TVS\n\n**Validate:**\n- Forward voltage drop at operating current doesn't cause problems\n- Reverse voltage rating exceeds maximum reverse voltage with margin\n- For TVS: clamping voltage at peak pulse current is within protected IC's absolute max\n- For Schottky (power supply): reverse leakage at temperature is acceptable\n- For Zener: power dissipation = (VIN - VZ) * IZ is within rated power\n- For flyback diodes: reverse voltage rating > supply voltage, forward current rating > load current\n\n### Connectors\n\n**Validate:**\n- Pin mapping matches the cable/mating connector pinout (this is a very common error source)\n- ESD protection on all external-facing signal pins\n- Proper filtering on power input (bulk cap + ceramic)\n- USB connectors: CC resistors for type-C (5.1k to GND for UFP/sink), D+/D- series resistors if required\n- Power connectors: reverse polarity protection (Schottky, P-FET, or ideal diode)\n- Debug/programming headers: confirm pinout matches the programmer (JTAG/SWD pin order varies!)\n\n### MOSFETs\n\n**Validate:**\n- VDS rating >= maximum drain-source voltage * 1.5\n- VGS rating: gate drive voltage is within VGS max and above VGS(th) with margin\n- RDS(on) at actual VGS drive voltage (not the datasheet minimum test condition)\n- ID rating at operating temperature (derate from 25C spec)\n- Gate resistor present if needed (prevents ringing, limits di/dt)\n- For P-channel high-side: gate is pulled to VCC when off, driven to GND (or lower) when on\n- Gate-source pull-down/pull-up resistor to prevent floating during power-up\n\n---\n\n## Design Pattern Library\n\nThese are reference patterns for common subcircuits. Compare the schematic against these to detect deviations.\n\n### LDO Voltage Regulator (Fixed Output)\n\n```\nVIN ──┬── [Cin 1-10uF] ──┬── GND\n │ │\n └── VIN [REG] VOUT ─┬── [Cout 1-22uF] ──┬── GND\n │ │ │\n GND ─────────┤ │\n EN ── VIN or GPIO │\n PG ── pull-up to VOUT (optional) │\n └── VOUT rail\n```\n\n**Expected values:**\n- Cin: 1uF minimum ceramic (often 4.7-10uF), voltage rating > VIN\n- Cout: per datasheet (1-22uF typical), ESR within specified range\n- EN: tied to VIN (always on) or driven by sequencing logic; never floating\n- Feedback divider (adjustable): 1% resistors, bottom resistor typically 10k-100k\n\n### Buck Converter\n\n```\nVIN ──┬── [Cin 10-22uF] ──┬── GND\n │ │\n └── VIN [CTRL] SW ───┬── [L 1-47uH] ──┬── VOUT\n │ │ │\n GND [Boot cap] ├── [Cout 22-100uF] ── GND\n FB ── divider ┘ │\n EN └── VOUT rail\n COMP ── RC network (if external)\n```\n\n**Expected values:**\n- L: per datasheet, saturation current > peak load current * 1.3\n- Cin: low ESR ceramic, voltage rating > VIN, value per datasheet\n- Cout: low ESR ceramic or polymer, value per datasheet\n- Bootstrap cap: typically 100nF ceramic (for integrated FET controllers)\n- Feedback divider: `VOUT = VREF * (1 + RTOP/RBOT)`, 1% resistors\n\n### Crystal Oscillator\n\n```\nMCU_XIN ──┬── [Y1 crystal] ──┬── MCU_XOUT\n │ │\n [CL1] ── GND [CL2] ── GND\n```\n\n**Expected values:**\n- Load cap formula: `CL1 = CL2 = 2 * (CL - Cstray)` where:\n - CL = crystal's rated load capacitance (from crystal datasheet, typically 8-20pF)\n - Cstray = stray/parasitic capacitance (typically 2-5pF for PCB + MCU pin)\n- Example: crystal CL = 12pF, Cstray = 3pF → CL1 = CL2 = 2 * (12 - 3) = 18pF\n- Feedback resistor (1M, optional): some MCUs require it across XIN/XOUT for startup\n- Series resistor on XOUT (optional): limits drive level for low-power crystals\n\n**Common mistakes:**\n- Using the crystal CL value directly as the cap value (should be ~2x CL minus stray)\n- Missing load caps entirely (oscillator won't start or runs at wrong frequency)\n- Routing long traces to crystal (adds stray capacitance, picks up noise)\n\n### USB Type-C (Device/UFP)\n\n```\nVBUS ──┬── [ESD/TVS] ──┬── 5V rail\n │ │\n [Cin 10uF] [100nF]\n │ │\n GND GND\n\nCC1 ──── [5.1k] ──── GND (identifies as UFP/sink)\nCC2 ──── [5.1k] ──── GND\n\nD+ ──── [series R 22-27 ohm] ──── MCU_DP\nD- ──── [series R 22-27 ohm] ──── MCU_DN\n\nShield/Shell ──── GND (via 1M + 4.7nF to GND, or direct)\n```\n\n**Key checks:**\n- CC1 and CC2 each have 5.1k pull-down to GND (mandatory for device mode)\n- ESD protection on VBUS, D+, D-, CC lines\n- Series resistors on D+/D- (some MCU USB PHYs include these internally — check datasheet)\n- VBUS decoupling close to connector\n\n### I2C Bus\n\n```\nVCC ──┬── [Rp1 2.2-10k] ──── SDA bus\n │\n └── [Rp2 2.2-10k] ──── SCL bus\n```\n\n**Expected values:**\n- Pull-up resistor: `Rp_min = (VCC - VOL) / IOL` and `Rp_max = tr / (0.8473 * Cb)`\n - VOL = 0.4V, IOL = 3mA (standard), Cb = bus capacitance\n- Typical values: 2.2k (400kHz fast mode), 4.7k (100kHz standard), 10k (low power)\n- Only ONE set of pull-ups per bus (not per device!)\n- Bus capacitance limit: 400pF (standard mode), affects maximum pull-up resistance\n\n**Common errors:**\n- Multiple pull-up pairs on the same bus (each device adding its own)\n- Pull-ups to wrong voltage rail (3.3V pull-up on a 5V bus or vice versa)\n- Pull-up value too high for the bus speed (rise time too slow)\n- Missing pull-ups entirely (bus floats, random data)\n\n### LED Indicator\n\n```\nGPIO ──── [R] ──── [LED] ──── GND (active high, sourcing)\n or\nVCC ──── [R] ──── [LED] ──── GPIO (active low, sinking)\n```\n\n**Expected values:**\n- `R = (VSUPPLY - VLED - VOL_or_VOH) / ILED`\n- Typical: VLED ≈ 2.0V (red), 2.1V (yellow), 3.0V (green/blue/white)\n- Typical: ILED = 2-5mA for indicators (not full 20mA unless brightness needed)\n- Check GPIO source/sink current limit\n\n**Worst-case overcurrent check (critical for high-brightness LEDs):**\nWhen the supply comes from a switching regulator with tolerance, you must check LED current at the combined worst case: maximum supply voltage AND minimum LED forward voltage. This is the maximum current the LED will ever see.\n\n1. Compute worst-case high supply: use VREF_max and resistor tolerances (see \"Tolerance Stacking\" below)\n2. Get Vf_min from the LED datasheet (often significantly lower than typical — e.g., 2.7V min vs 3.3V typ for blue/green)\n3. `I_worst = (Vsupply_max - Vf_min) / R`\n4. This must be below the LED's absolute maximum current rating\n5. If not, increase R until `R >= (Vsupply_max - Vf_min) / I_abs_max`, then round up to next E24 value\n6. Re-verify typical current is still acceptable for desired brightness\n\n### Reset Circuit\n\n```\nVCC ──── [R 10k] ──┬──── MCU_RESET\n │\n [C 100nF] ──── GND (optional, delays reset release)\n │\n [Switch] ──── GND (optional manual reset)\n```\n\n**Key checks:**\n- Pull-up resistor present (10k typical, check MCU datasheet)\n- Filter cap if environment is noisy (100nF typical, creates RC delay)\n- If supervisor IC is used: threshold voltage matches supply rail, reset pulse width meets MCU requirement\n- Open-drain reset outputs from multiple sources can be wire-OR'd\n\n### Voltage Divider for ADC\n\n```\nVIN ──── [R_TOP] ──┬── ADC_INPUT\n │\n [R_BOT] ──── GND\n │\n [C_FILTER 100nF] ──── GND (optional anti-alias)\n```\n\n**Expected values:**\n- `V_ADC = VIN * R_BOT / (R_TOP + R_BOT)`\n- V_ADC must be \u003c= ADC reference voltage (usually VDDA)\n- Total impedance (R_TOP + R_BOT) should be reasonable:\n - Too low (\u003c 1k): wastes power, loads the source\n - Too high (> 1M): ADC sampling capacitor can't charge fast enough\n - Typical: 10k-100k total\n- Filter cap: `f_cutoff = 1 / (2 * pi * R_parallel * C)` where R_parallel = R_TOP * R_BOT / (R_TOP + R_BOT)\n\n**Source impedance and ADC accuracy:**\nMCU ADCs have a sample-and-hold capacitor (typically a few pF) that must charge through the source impedance during the sampling window. If source impedance is too high, the capacitor doesn't fully settle and readings are inaccurate.\n\n- Source impedance = `R_TOP × R_BOT / (R_TOP + R_BOT)` (parallel combination)\n- Check the MCU datasheet for maximum recommended source impedance (ESP32-S3: ~13kΩ)\n- If source impedance exceeds the limit, a filter capacitor (e.g., 100nF) at the ADC input helps: the cap pre-charges to the correct voltage, and the ADC samples from the cap instead of through the resistors\n- Verify the RC settling time (`τ = R_parallel × C_filter`) allows sufficient settling between readings\n- For battery-powered designs, balance accuracy vs sleep current: 100K/100K (50kΩ source, 15µA at 3V) is a reasonable compromise with a 100nF filter cap providing ~5ms settling\n\n### Power Input with Reverse Polarity Protection\n\n```\nVIN ──── [F1 fuse or PTC] ──┬── [D1 Schottky] ──── VCC_PROTECTED\n │\n [C_BULK 10-100uF]\n │\n GND\n```\nor (lower loss, P-FET method):\n```\nVIN ──── [F1] ──── S [Q1 P-FET] D ──── VCC_PROTECTED\n │\n G ── GND (via R, optional TVS across G-S)\n```\n\n**Key checks:**\n- Fuse/PTC rating matches maximum expected current with margin\n- Schottky drop is acceptable at max current (or use P-FET for lower drop)\n- Bulk cap voltage rating > VIN max\n- P-FET VGS is sufficient to fully enhance with the given VIN\n\n---\n\n## Value Computation Verification\n\nFor every computed value in the schematic, verify the math. Show your work so the user can check it.\n\n### Resistor Divider (General)\n\n```\nVOUT = VIN * R_BOTTOM / (R_TOP + R_BOTTOM)\n```\nOr equivalently: `R_TOP / R_BOTTOM = (VIN / VOUT) - 1`\n\n### Regulator Feedback Divider\n\nDifferent ICs use different formulas. Common patterns:\n- `VOUT = VREF * (1 + R1/R2)` — R1 from VOUT to FB, R2 from FB to GND\n- `VOUT = VREF * (R1 + R2) / R2` — same thing, different notation\n- Always check which resistor is \"top\" (VOUT to FB) and which is \"bottom\" (FB to GND)\n- VREF is from the regulator datasheet (commonly 0.6V, 0.8V, or 1.25V)\n\n**Feedforward capacitor** (boost/buck converters): Some switching regulators recommend a small capacitor across the upper feedback resistor to add a zero that improves transient response. Formula: `C_FF = 1 / (2π × f_FFZ × R_upper)`, where f_FFZ is the target zero frequency (typically ~1kHz, per datasheet). Always verify this against the specific regulator's datasheet — not all converters benefit from feedforward.\n\n### Tolerance Stacking for Regulator Output\n\nRegulator output voltage has combined tolerance from VREF accuracy and feedback resistor tolerance. Always compute the full range:\n\n```\nVout_max = VREF_max × (1 + R_upper×(1+tol) / (R_lower×(1-tol)))\nVout_min = VREF_min × (1 + R_upper×(1-tol) / (R_lower×(1+tol)))\n```\n\nWhere `tol` is the resistor tolerance (0.01 for 1%). This matters because:\n- Downstream components (LEDs, ICs) must tolerate the full Vout range\n- The high-side voltage determines worst-case overcurrent through current-limited loads\n- The low-side voltage determines whether downstream regulators have enough headroom\n\nExample: VREF = 595mV ±2.5%, R_upper = 820K ±1%, R_lower = 110K ±1%:\n- Vout_nom = 0.595 × (1 + 820/110) = 5.03V\n- Vout_max = 0.610 × (1 + 828.2/108.9) = 5.26V\n- Vout_min = 0.580 × (1 + 811.8/111.1) = 4.82V\n\nUse Vout_max when checking downstream current limits. Use Vout_min when checking regulator headroom.\n\n### Current Limiting Resistor\n\n```\nR = (V_SOURCE - V_LOAD) / I_TARGET\nP_RESISTOR = (V_SOURCE - V_LOAD) * I_TARGET = I_TARGET^2 * R\n```\n\n### RC Filter Cutoff\n\n```\nf_cutoff = 1 / (2 * pi * R * C)\n```\n- Low-pass: R in series, C to ground\n- High-pass: C in series, R to ground\n\n### Crystal Load Capacitors\n\n```\nCL_each = 2 * (CL_crystal - C_stray)\n```\nWhere C_stray includes PCB trace capacitance (~1-2pF) and MCU pin capacitance (~1-3pF from MCU datasheet).\n\n### Pull-up Resistor for Open-Drain\n\n```\nR_min = (VCC - VOL_max) / IOL_max\nR_max = VCC / (I_leakage * N_devices) (rough guide)\n```\nFor timing-critical buses (I2C), rise time constraint:\n```\nR_max = t_rise / (0.8473 * C_bus)\n```\n\n### MOSFET Gate Drive\n\nVerify VGS at the actual drive voltage exceeds VGS(th) with margin:\n- For logic-level FETs: VGS(th) max should be well below drive voltage\n- Check RDS(on) at the actual VGS, not the minimum datasheet value\n- Gate charge (Qg) determines switching speed and driver current requirement\n\n### Voltage Divider Power Dissipation\n\n```\nP_total = VIN^2 / (R_TOP + R_BOTTOM)\nP_R_TOP = P_total * R_TOP / (R_TOP + R_BOTTOM)\nP_R_BOTTOM = P_total * R_BOTTOM / (R_TOP + R_BOTTOM)\n```\n\n---\n\n## Error Taxonomy\n\nCategorize findings by severity to help the user prioritize.\n\n### Critical (design will not work or is unsafe)\n\n- Absolute maximum rating exceeded (voltage, current, temperature)\n- Missing essential component (no output cap on regulator, no decoupling on IC)\n- Wrong pin connections (swapped pins, connected to wrong net)\n- Short circuit path (power rail shorted to ground through missing component)\n- Reversed polarity on polarized components without protection\n- Floating power pins on ICs\n- Missing ground connections\n- Feedback divider gives dangerously wrong voltage (overvoltage on downstream IC)\n\n### Warning (design may work but has significant risk)\n\n- Component values outside datasheet recommendations\n- Insufficient voltage/current rating margins (\u003c 20% margin)\n- Missing but recommended components (e.g., input cap on LDO where output is close to input)\n- Pull-up/pull-down values outside optimal range (will work but may be unreliable)\n- DC bias derating not accounted for (ceramic cap actual capacitance much lower than nominal)\n- Thermal margin tight (power dissipation close to package limit)\n- Missing ESD protection on external interfaces\n- Crystal load caps slightly off (oscillator will start but frequency accuracy suffers)\n\n### Suggestion (improvements that aren't required)\n\n- Better component selection available (lower cost, better specs, more common)\n- Value optimization (pull-up could be lower for faster bus speed)\n- Additional filtering would improve performance (e.g., pi filter on analog supply)\n- Test points recommended for debugging\n- Consider adding power-good monitoring\n- LED current could be reduced (2mA is sufficient for most indicators, saves power)\n- Consider adding second-source alternatives for sole-source components\n\n### Info (observations, not actionable)\n\n- Component identification notes (what each subcircuit does)\n- Design pattern recognition (this is a standard buck converter topology)\n- Cross-references to datasheets and application notes\n- Notes on KiCad-specific issues (symbol doesn't match pinout, footprint mismatch)\n\n---\n\n## Manufacturing & Sourcing Review\n\nBeyond electrical correctness, check for practical manufacturing issues.\n\n### Component Availability\n\n- Are all parts currently in production? (check for obsolete/NRND status)\n- Are any parts sole-source with long lead times?\n- For JLCPCB assembly: are LCSC equivalents available for all parts?\n- Are any parts unusually expensive? (suggest alternatives)\n\n### Footprint Concerns\n\n- Do all footprints match the actual component package?\n- Are any footprints hand-solder unfriendly? (0201, QFN with no exposed pads, fine-pitch BGA)\n- For prototype hand assembly: are there through-hole alternatives for difficult SMD parts?\n- Are thermal pads properly handled (vias under QFN exposed pads)?\n\n### Design for Assembly\n\n- Are designators and polarity markers on silkscreen?\n- Are pin-1 indicators consistent and visible?\n- Are test points accessible?\n- For mixed-voltage designs: are voltage domains clearly marked on the schematic?\n\n### Consolidation Opportunities\n\n- Can multiple resistor values be consolidated? (e.g., 9.8k and 10k → both 10k if tolerance allows)\n- Can different cap values be consolidated to reduce BOM line count?\n- Are there parts that differ only by value where a single value would work for all?\n- Fewer unique parts = lower assembly cost and simpler sourcing\n\n---\n\n## Battery-Powered Design Considerations\n\nFor battery-powered designs, check these additional concerns beyond basic electrical correctness.\n\n### Sleep Current Budget\n\nEnumerate all current draws during deep sleep / low-power mode:\n- MCU sleep current (from datasheet, at actual voltage and temperature)\n- Voltage dividers (always-on resistive paths): `I = Vbatt / (R_top + R_bot)`\n- Regulator quiescent current (if always enabled)\n- Pull-up/pull-down resistors that create current paths\n- Leakage through protection diodes, FETs, ESD devices\n- LED indicator leakage (if any)\n\nSum all contributions and compute battery life:\n```\nLife (hours) = Battery_capacity_mAh / Total_sleep_current_mA\n```\nFor AA alkaline: ~2500-3000 mAh usable (derate from nominal depending on drain rate and cutoff voltage).\n\nFlag any single contributor that is >10% of the total sleep budget — it may be worth optimizing (e.g., higher-value divider resistors, FET-gated sensing circuits, lower-Iq regulator).\n\n### Minimum Battery Voltage (Low-Battery Threshold)\n\nCompute the minimum battery voltage at which the system can still function under peak load:\n\n1. Identify peak current events (WiFi TX, motor drive, LED animation)\n2. Look up battery internal resistance at end-of-life (AA alkaline: ~1-2Ω per cell at 1.0V)\n3. Calculate voltage sag: `V_sag = I_peak × R_internal_total`\n4. Check regulator minimum input voltage at peak output current (from efficiency curves, not just Vin_min spec — boost converters lose regulation when input current exceeds capability)\n5. Minimum battery voltage = regulator Vin_min + V_sag + margin\n\nThis threshold should be checked in firmware before high-current operations. Going below it risks brownout, corrupted flash writes, or incomplete WiFi transmissions.\n\n### Active Power Sequencing\n\nBattery-powered designs often power-gate subsystems to save energy:\n- Verify enable pins have pull-downs to keep regulators off during boot\n- Check for inrush current when multiple regulators enable simultaneously\n- Verify USB host power sequencing (if applicable — host must install before VBUS powers the device)\n- Check that GPIO states during deep sleep don't create parasitic current paths (unused GPIOs should be parked as outputs driven low, with `gpio_deep_sleep_hold_en()` or equivalent)\n\n---\n\n## Worst-Case Tolerance Stack Analysis\n\nBeyond individual component validation, compute how combined tolerances affect critical circuit parameters. This catches designs where each component is individually \"in spec\" but the combined worst case exceeds safe limits.\n\n### General Methodology\n\nFor any computed value that depends on multiple components, substitute worst-case values simultaneously:\n- Maximum output: use all component tolerances that increase the result\n- Minimum output: use all component tolerances that decrease the result\n- Include the IC's internal reference tolerance (VREF min/max from datasheet electrical characteristics table)\n\n### Voltage Divider with Tolerance Stacking\n\nThe regulator feedback divider tolerance stacking formula is covered in [Tolerance Stacking for Regulator Output](#tolerance-stacking-for-regulator-output). Apply the same approach to any voltage divider — ADC scaling, level detection thresholds, comparator references:\n\n```\nV_max = VIN_max × R_bot×(1+tol) / (R_top×(1-tol) + R_bot×(1+tol))\nV_min = VIN_min × R_bot×(1-tol) / (R_top×(1+tol) + R_bot×(1-tol))\n```\n\n**When to worry:** Compare the tolerance-stacked output range against downstream absolute maximum ratings. If Vout_max approaches an abs max, the design needs tighter-tolerance components or a wider safety margin.\n\n### RC Filter Cutoff with Tolerance\n\nBoth R and C have manufacturing tolerances. The cutoff frequency range is:\n\n```\nf_max = 1 / (2π × R_min × C_min) = 1 / (2π × R×(1-tol_R) × C×(1-tol_C))\nf_min = 1 / (2π × R_max × C_max) = 1 / (2π × R×(1+tol_R) × C×(1+tol_C))\n```\n\nExample: 10k (5%) + 100nF (10%) low-pass filter:\n- f_nom = 1/(2π × 10k × 100n) = 159 Hz\n- f_max = 1/(2π × 9.5k × 90n) = 186 Hz (+17%)\n- f_min = 1/(2π × 10.5k × 110n) = 138 Hz (-13%)\n\nFor anti-alias filters before ADCs, ensure f_min is still above the Nyquist frequency. For EMI filters, ensure f_max still attenuates the target frequency.\n\n### Crystal Load Capacitor Tolerance Effects\n\nCrystal frequency accuracy depends on correct load capacitance. With cap tolerance:\n\n```\nCL_actual_max = CL_cap×(1+tol)/2 + C_stray\nCL_actual_min = CL_cap×(1-tol)/2 + C_stray\n```\n\nA 10% tolerance on 18pF caps (16.2-19.8pF) with 3pF stray yields CL_actual = 11.1-12.9pF vs target 12pF. Frequency error is roughly ±(CL_delta/CL) × crystal trim sensitivity (typically 5-20 ppm/pF). For most applications this is acceptable; for precision timing (GPS, RF), use C0G/NP0 caps with 1-2% tolerance.\n\n### When to Escalate\n\nFlag tolerance stacking as a **Warning** or **Critical** when:\n- The worst-case output exceeds a downstream component's absolute maximum rating\n- The worst-case output falls below a minimum operating threshold (regulator dropout, logic VIH)\n- Safety margins shrink below 10% at worst case\n- The application requires precision (current limiting, battery charging voltage)\n\n---\n\n## GPIO Multiplexing Audit\n\nMCU pins serve multiple functions (alternate function muxing). A pin assigned to SPI_CLK cannot simultaneously serve as UART_TX. This analysis catches pin conflicts that ERC won't flag because both peripherals are electrically valid on the pin.\n\n### Procedure\n\n1. **Get the MCU's pin mux table** from the datasheet (usually titled \"Alternate Function Mapping\" or \"Pin Multiplexing\"). This table lists which alternate function (AF0-AF15 on STM32, IO_MUX on ESP32, etc.) maps each peripheral signal to each pin.\n\n2. **Extract all used pins from the schematic**: From the analyzer output, list every net connected to the MCU. Map each net name to its peripheral function (e.g., `SPI1_MOSI`, `UART2_TX`, `I2C1_SDA`, `ADC1_CH3`).\n\n3. **Check for conflicts**: For each pin, verify the assigned peripheral signal is available on that pin's alternate function list. Flag:\n - Two peripherals that require the same pin (e.g., SPI1_SCK and TIM2_CH1 both need PA5)\n - A peripheral signal routed to a pin that doesn't support it (e.g., UART_TX on a pin that only has SPI alternates)\n - ADC channels that conflict with digital peripherals (ADC is typically on AF0/analog mode — using the pin for SPI disables ADC)\n\n4. **Check boot-mode pin conflicts**: Some MCU pins have special behavior during reset/boot (STM32 BOOT0, ESP32 strapping pins). Verify that peripherals connected to these pins don't interfere with boot.\n\n### Common Conflict Patterns\n\n| Conflict | Example | Risk |\n|----------|---------|------|\n| SPI + UART on same pin | SPI1_MISO and USART1_RX both on PA10 | Only one works at a time |\n| I2C + ADC on same pin | I2C1_SDA on a pin also used for ADC input | Can't do both |\n| Timer PWM + SPI | TIM1_CH1 and SPI1_NSS on same pin | PWM output conflicts with chip select |\n| JTAG/SWD + GPIO | JTAG pins used as regular GPIO | Debugging no longer possible |\n| Boot strapping + peripheral | ESP32 GPIO0 used for SPI CS | Must be high during boot, SPI may pull low |\n\n---\n\n## Connector Pinout Verification\n\nConnector pinout errors are among the most common schematic mistakes and often aren't caught until board bring-up. Verify every connector's pin-to-net mapping against the relevant standard.\n\n### Procedure\n\n1. **Identify connector type** from the footprint or symbol library name (e.g., `USB_C_Receptacle`, `Conn_ARM_JTAG_SWD_10`, `Conn_01x06_FTDI`)\n2. **Extract pin-to-net mapping** from the analyzer output for that connector's reference designator\n3. **Compare against the standard pinout** (see table below)\n4. **Check orientation**: KiCad symbols may show the pinout from the connector side or the PCB side — verify which convention the library uses\n\n### Standard Pinouts\n\n| Connector | Key Pins | Common Errors |\n|-----------|----------|---------------|\n| **USB Type-A** | 1=VBUS, 2=D-, 3=D+, 4=GND | D+/D- swap |\n| **USB Type-B** | Same as Type-A | D+/D- swap |\n| **USB Micro-B** | 1=VBUS, 2=D-, 3=D+, 4=ID, 5=GND | D+/D- swap, ID left floating (should be NC for device) |\n| **USB Type-C** | A6/B6=D+, A7/B7=D-, A5=CC1, B5=CC2 | Missing CC resistors (5.1k to GND for UFP), TX/RX lane swap for USB3 |\n| **ARM JTAG 20-pin** | 1=VTref, 3=nTRST, 5=TDI, 7=TMS, 9=TCK, 13=TDO, 15=nRST | Pin numbering varies between ARM standard and legacy |\n| **ARM SWD 10-pin** (Cortex Debug) | 1=VTref, 2=SWDIO, 4=SWCLK, 6=SWO, 10=nRST | SWDIO/SWCLK swap, missing VTref connection |\n| **FTDI 6-pin** | 1=GND, 2=CTS, 3=VCC, 4=TXD, 5=RXD, 6=DTR | TX/RX labeled from FTDI cable perspective — board TX connects to cable RXD (pin 5) |\n| **Qwiic/STEMMA QT** (I2C) | 1=GND, 2=VCC(3.3V), 3=SDA, 4=SCL | VCC/GND swap, SDA/SCL swap |\n| **SPI header** | No universal standard | MOSI/MISO naming ambiguity (use SDI/SDO relative to device) |\n| **CAN bus** | CANH, CANL (2-wire) | H/L swap, missing 120 ohm termination resistor |\n| **RS-485** | A(+), B(-), GND | A/B polarity swap (varies between manufacturers) |\n\n### FTDI TX/RX Convention\n\nThis is the single most common connector pinout error. The FTDI cable labels pins from its own perspective:\n- Cable pin \"TXD\" (pin 4) = data the cable **transmits** = connect to **board RX**\n- Cable pin \"RXD\" (pin 5) = data the cable **receives** = connect to **board TX**\n\nIf the schematic labels match the FTDI cable labeling, the connections are **crossed** (correct). If the schematic connects board TX to cable TXD, the connections are **straight** (wrong — both sides transmit on the same wire).\n\n---\n\n## Clock Tree Analysis\n\nClock integrity is critical for reliable digital operation. Marginal clock circuits can cause intermittent failures that are extremely difficult to debug.\n\n### Procedure\n\n1. **Identify all clock sources**: crystals, crystal oscillators (packaged), MEMS oscillators, PLL outputs, clock buffers/distributors, RC oscillators\n2. **Trace clock distribution**: from each source, follow the clock net to all consumers (MCU, FPGA, ADC, communication ICs)\n3. **Check fan-out loading**: each clock output has a maximum number of inputs it can drive. Sum input capacitance of all loads and compare against the source's drive capability\n4. **Check AC coupling**: some clock inputs require AC coupling (series cap) — particularly LVDS and LVPECL clock inputs\n5. **Check termination**: series termination at the source (22-33 ohm typical) damps reflections for traces longer than λ/10\n\n### Transmission Line Threshold\n\nA clock trace must be treated as a transmission line (requiring impedance control and termination) when trace length exceeds λ/10:\n\n```\nλ = c / (f × √εr)\n```\n\nFor FR4 (εr ≈ 4.5):\n```\nλ ≈ 141mm / f_GHz\n```\n\n| Clock Frequency | λ (FR4) | λ/10 (trace threshold) |\n|----------------|---------|----------------------|\n| 8 MHz | 17.7 m | 1.77 m (never an issue) |\n| 25 MHz | 5.65 m | 565 mm (rarely an issue) |\n| 48 MHz | 2.94 m | 294 mm (rarely an issue) |\n| 100 MHz | 1.41 m | 141 mm (possible on large boards) |\n| 500 MHz | 283 mm | 28.3 mm (likely needs controlled impedance) |\n| 1 GHz | 141 mm | 14.1 mm (always needs controlled impedance) |\n\nMost hobby/prototype boards with clocks ≤25 MHz don't need transmission line treatment. Flag it as a **Suggestion** for 25–100 MHz clocks and a **Warning** for >100 MHz.\n\n### Common Clock Issues\n\n- Missing series termination on oscillator output driving a long trace\n- Crystal traces routed too far from MCU (adds stray capacitance, picks up noise)\n- Clock buffer powered from noisy rail (use filtered/dedicated supply)\n- Multiple clock frequencies creating beat interference (route apart, use ground guard traces)\n\n---\n\n## Motor Control Design Review\n\nMotor control circuits combine high-current power switching with precision analog sensing. This section covers the key validation checks beyond basic component ratings.\n\n### Dead-Time Verification (H-Bridge / Half-Bridge)\n\nShoot-through (both high-side and low-side FETs on simultaneously) destroys the bridge. Dead-time must exceed the slower FET's turn-off time:\n\n1. Get turn-off delay (`td(off)`) and fall time (`tf`) from the MOSFET datasheet\n2. Total turn-off time = `td(off) + tf` (at the actual gate drive voltage and load current)\n3. Dead-time (from gate driver datasheet or PWM controller config) must exceed total turn-off time with margin\n4. **Check at worst case**: turn-off time increases at higher temperature and higher load current\n\nFlag as **Critical** if the dead-time is less than the worst-case turn-off time.\n\n### Bootstrap Circuit Validation\n\nFor high-side N-channel MOSFET gate drive with a bootstrap circuit:\n\n1. **Bootstrap capacitor sizing**: `C_boot >= Q_gate / ΔV_boot`, where ΔV_boot is the acceptable voltage droop (typically 0.5-1V). Use 10× minimum as a rule of thumb.\n2. **Bootstrap diode**: reverse recovery time must be fast enough that the diode doesn't conduct during the switch node transition. Use Schottky or ultrafast recovery.\n3. **Startup**: the bootstrap cap charges during low-side on-time. If the first PWM cycle starts with high-side on, the cap is uncharged — verify the controller forces a low-side pulse at startup.\n4. **100% duty cycle limitation**: bootstrap circuits cannot sustain 100% high-side on-time (cap voltage decays). Check if the application requires this.\n\n### Current Sense Validation\n\nFor shunt-resistor current sensing:\n\n1. **Shunt power rating**: `P = I_peak² × R_shunt`. Derate to 50% of rated power for reliability. A 10mΩ shunt at 10A dissipates 1W — needs ≥2W rating.\n2. **Sense amplifier CMRR**: the common-mode voltage on the shunt equals the motor voltage (potentially tens of volts). Verify the sense amp's CMRR is adequate and its common-mode input range covers the full swing.\n3. **Kelvin routing**: sense traces must connect directly to the shunt resistor pads, not tap off the power trace. This is a PCB layout concern but should be noted in schematic review as a requirement.\n4. **Sense voltage at full scale**: `V_sense = I_max × R_shunt`. This should match the sense amplifier's input range and the ADC's resolution requirements.\n\n### Gate Drive Verification\n\n1. **VGS vs VGS(th)**: gate drive voltage must exceed VGS(th)_max (worst case, not typical) with margin. For logic-level FETs with 3.3V drive: verify VGS(th)_max \u003c 2.5V.\n2. **Gate resistor**: limits di/dt to reduce ringing. Typical 2.2-10Ω. Missing gate resistors cause EMI and voltage spikes on the gate.\n3. **Gate pull-down**: 10k-100k pull-down resistor on the gate to prevent floating during power-up (before the driver IC initializes). The gate driver's internal pull-down may not be active during power-up.\n\n### Protection Circuits\n\nVerify the presence and correctness of:\n- **Overcurrent shutdown**: comparator or driver IC feature, threshold set by reference resistor. Verify threshold matches the current limit requirement.\n- **Undervoltage lockout (UVLO)**: prevents operating the bridge with insufficient gate drive voltage (partial enhancement = high RDS(on) = thermal runaway). Usually built into the gate driver.\n- **Thermal shutdown**: either in the driver IC or via an NTC + comparator. Verify NTC placement is thermally coupled to the FETs.\n- **Flyback/freewheeling diodes**: for inductive loads, verify diodes across each switch or across the load. Body diodes in MOSFETs provide this, but external Schottky diodes may be needed for faster recovery.\n\n### Common Motor Control Errors\n\n| Error | Consequence | Check |\n|-------|-------------|-------|\n| Bootstrap cap too small | High-side gate voltage sags, FET partially on, overheats | C_boot >> Q_gate / ΔV |\n| Shunt resistor under-rated for power | Resistor overheats, value drifts, possible open circuit | P_shunt = I² × R at peak current |\n| Missing gate resistor | Gate ringing, EMI, false triggering | Every gate should have series R |\n| Gate pull-down missing | FETs turn on uncontrolled during power-up | 10k-100k to source on each gate |\n| Sense traces not Kelvin routed | Current measurement error from IR drop in power trace | Note for PCB layout review |\n\n---\n\n## Battery Life Estimation\n\nFor battery-powered designs, estimate operational lifetime to validate the design is practical. This builds on the sleep current audit in [Battery-Powered Design Considerations](#battery-powered-design-considerations).\n\n### Step-by-Step Procedure\n\n1. **Enumerate active mode current**: Sum the typical operating current for all ICs from their datasheets (at the actual supply voltage and clock frequency). Include:\n - MCU at operating frequency (e.g., ESP32-S3 at 240MHz: ~40-80mA)\n - Radio (WiFi TX: 180-380mA peak for ESP32; BLE TX: 20-30mA)\n - Sensors, ADCs, displays, LEDs\n - Regulator efficiency losses: `I_battery = I_load / η_regulator`\n\n2. **Enumerate sleep mode current**: Use the sleep current audit from the analyzer output, plus datasheet sleep/shutdown currents for each IC. Don't forget:\n - Voltage divider quiescent current (always-on resistive paths)\n - Regulator quiescent current\n - Pull-up/pull-down leakage paths\n\n3. **Estimate duty cycle**: What fraction of time is the device active vs sleeping? This is application-dependent — ask the user if not documented. Example: sensor that wakes every 60s, takes 2s to measure and transmit → duty = 2/60 = 3.3%.\n\n4. **Compute weighted average current**:\n ```\n I_avg = I_active × duty + I_sleep × (1 - duty)\n ```\n\n5. **Compute battery life**:\n ```\n Life_hours = Capacity_mAh / I_avg_mA\n Life_days = Life_hours / 24\n ```\n\n### Battery Capacity Derating\n\nNominal capacity is measured under ideal conditions. Actual usable capacity depends on discharge rate, temperature, and cutoff voltage:\n\n| Chemistry | Nominal | Usable Capacity | Notes |\n|-----------|---------|-----------------|-------|\n| LiPo 3.7V single cell | rated mAh | 90-95% (3.0V cutoff) | Linear down to ~3.4V, then drops fast |\n| AA alkaline 1.5V | ~2500 mAh | 60-80% depending on drain | Voltage sags under load; 1.0V cutoff typical |\n| CR2032 coin cell | ~220 mAh | ~200 mAh at \u003c2mA | Capacity drops sharply above 2mA continuous draw |\n| 18650 Li-ion | rated mAh | ~90% (2.5-3.0V cutoff) | High pulse capability |\n| AAA alkaline 1.5V | ~1000 mAh | 50-70% | Less capacity than AA, same derating factors |\n| LiFePO4 3.2V | rated mAh | ~95% (2.5V cutoff) | Very flat discharge curve, excellent for regulated supplies |\n\n### Peak Current Considerations\n\nEven if average current is low, peak current events can cause problems:\n- **WiFi TX bursts**: 180-380mA for 100-500ms. Battery internal resistance causes voltage sag. Verify the regulator input voltage stays above minimum during peaks (see [Minimum Battery Voltage](#minimum-battery-voltage-low-battery-threshold)).\n- **Motor start**: inrush current can be 5-10× running current. May need a bulk capacitor to supply the peak.\n- **LED animations**: WS2812B strips draw 60mA/LED at full white. 10 LEDs = 600mA peak.\n- **Bulk capacitor sizing**: for short peaks, `C = I_peak × t_peak / ΔV_allowed`. A 100µF cap supplies 200mA for 1ms with 2V droop.\n\n### Report Format for Battery Life\n\nInclude a power budget table in the analysis report:\n\n| Mode | Current | Duty Cycle | Weighted |\n|------|---------|-----------|----------|\n| Active (MCU + WiFi TX) | 250 mA | 3% | 7.5 mA |\n| Active (MCU only) | 50 mA | 2% | 1.0 mA |\n| Deep sleep | 15 µA | 95% | 14.3 µA |\n| **Weighted average** | | | **8.5 mA** |\n| **Battery life** (1000mAh LiPo) | | | **~5 days** |\n\n---\n\n## Supply Chain Risk Assessment\n\nIdentify components that pose sourcing risks — sole-source parts, obsolete components, or parts with historically constrained supply.\n\n### Procedure\n\n1. **For each IC in the BOM**, determine:\n - How many manufacturers make pin-compatible alternatives?\n - Is the part listed as active, NRND (Not Recommended for New Designs), or obsolete?\n - Are there recent stock-out or allocation events? (check DigiKey/Mouser stock levels and lead times)\n\n2. **Flag sole-source components**: if only one manufacturer makes a part with no pin-compatible alternative, it's a supply chain risk. Common sole-source categories:\n - Specialized sensor ICs (IMU, environmental sensors)\n - Application-specific PMICs\n - Wireless SoCs (ESP32, nRF52 — popular but single-source)\n - Specialized motor drivers\n\n3. **Suggest alternatives where available**:\n - Voltage regulators: many LDO/buck families have pin-compatible options across manufacturers (e.g., AP2112 ↔ ME6211 ↔ XC6220)\n - MOSFETs: generally interchangeable if VDS, ID, RDS(on), and package match\n - Passives: resistors, capacitors, inductors are multi-source by nature (use generic values, not custom)\n - Connectors: specify by mechanical standard (USB-C, JST-PH, etc.) rather than single MPN\n\n4. **Check for obsolescence indicators**:\n - Datasheet marked \"Not for new designs\" or \"End of life\"\n - Last datasheet revision date >5 years ago with no updates\n - Distributor listing shows \"Last Time Buy\" or zero stock across all distributors\n - Part has been superseded by a newer version (check manufacturer's cross-reference)\n\n### Severity Levels\n\n| Situation | Severity | Action |\n|-----------|----------|--------|\n| Obsolete/EOL part | **Warning** | Must find replacement before production |\n| NRND part | **Suggestion** | Plan migration, current stock usually available |\n| Sole-source, active, good stock | **Info** | Note the risk, suggest monitoring |\n| Sole-source, constrained supply | **Warning** | Identify alternatives or stock buffer |\n| Generic passive (0402 10k 1%) | No flag | Multi-source by nature |\n\n---\n\n## Report Format\n\nStructure the analysis report as follows:\n\n```markdown\n# Schematic Analysis Report: [Project Name]\n\n## Summary\n- Components: [N] unique parts, [M] total placements, [D] DNP\n- Subcircuits identified: [list]\n- Findings: [X] critical, [Y] warnings, [Z] suggestions\n\n## Subcircuit Analysis\n\n### [Subcircuit Name] (e.g., \"3.3V LDO — U2, C3, C4, R5, R6\")\n\n**Function:** [what it does]\n**Datasheet:** [URL or MPN]\n**Reference circuit comparison:** [matches/deviates from datasheet Fig. N]\n\n**Findings:**\n- [CRITICAL] ... (cite: datasheet §X.Y, page Z, Figure/Table N)\n- [WARNING] ...\n- [SUGGESTION] ...\n\n**Value verification** (show the work, cite the source equation):\n- VOUT = VREF × (1 + R_top/R_bottom) = 0.595V × (1 + 732k/100k) = 4.95V\n Target: 5.0V. Per TPS61023 datasheet Eq. 4 (page 13), VREF = 595mV typ (Table 6.5, page 5: 580-610mV range). ✓\n- Feedforward cap: C = 1/(2π × f_FFZ × R1) = 1/(2π × 1kHz × 732kΩ) = 218pF → 220pF (std value)\n Per TPS61023 datasheet Eq. 10 (page 15), TI recommends f_FFZ ≈ 1kHz. ✓\n- Cout ESR: X7R ceramic, ESR \u003c 10mΩ — datasheet requires ESR > 100mΩ for stability ✗\n\n[repeat for each subcircuit]\n\n## Cross-Cutting Issues\n\n### Power Budget\n| Rail | Regulator | Max Output | Estimated Load | Margin |\n|------|-----------|-----------|----------------|--------|\n| 3.3V | U2 (AP2112) | 600mA | ~120mA | 80% ✓ |\n\n### Signal Level Compatibility\n[any cross-domain voltage issues]\n\n### Missing Protection\n[ESD, reverse polarity, overcurrent gaps]\n\n## BOM Observations\n[consolidation opportunities, sourcing risks, cost notes]\n```\n\nAdapt the depth and detail to the complexity of the design. A simple LED blinker doesn't need a 10-page report. A battery-powered IoT sensor with multiple regulators, wireless, and analog sensing does.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":64240,"content_sha256":"0fc8ce180d85c49e7bd7760d795c5c23703a089d54c52503e8c19c041c8d150d"},{"filename":"references/standards-compliance.md","content":"# IPC/IEC Standards Compliance Reference\n\nReference tables and formulas for checking PCB designs against industry standards. All values are verified from the actual standard documents (source noted for each table). Values marked **[UNVERIFIED]** are from secondary sources and need primary document confirmation.\n\n**When to use this reference:** Automatically for professional/industrial designs — projects with: 4+ layer boards, controlled impedance, high voltage (>50V), safety-critical functions, CE/UL certification targets, medical/automotive/aerospace applications, or any project where the user mentions standards compliance. For hobby/prototype boards, reference only when the user asks or when a specific concern (e.g., high voltage spacing) warrants it.\n\n## Verification Status\n\n| Section | Standard | Status |\n|---|---|---|\n| Product Classification | IPC-A-600G, IPC-2221A | VERIFIED |\n| Conductor Spacing | IPC-2221A Table 6-1 | VERIFIED |\n| Current Capacity (classic) | IPC-2221A §6.2 | VERIFIED |\n| Current Capacity caveats | IPC-2221A / IPC-2152 history | VERIFIED |\n| Annular Ring | IPC-2221A Tables 9-1/9-2 | VERIFIED |\n| Hole Sizes | IPC-2221A Tables 9-3/9-4/9-5 | VERIFIED |\n| Impedance Calculations | IPC-2221A §6.4 | VERIFIED |\n| Dielectric Properties | IPC-2221A Table 6-2 | VERIFIED |\n| Via Protection Types | IPC-4761 | VERIFIED |\n| Mains Transient Voltages | ECMA-287 Table 3.3 | VERIFIED |\n| Minimum Clearances | ECMA-287 Table 3.4 | VERIFIED |\n| Minimum Creepage | ECMA-287 Table 3.5 | VERIFIED |\n| Coated PCB Separations | ECMA-287 Table 3.9 | VERIFIED |\n| Safety Definitions | ECMA-287 §2–3 | VERIFIED |\n| Current Capacity (updated) | IPC-2152 | **PARTIALLY VERIFIED** |\n| Safety Standard (modern) | IEC 62368-1 | **UNVERIFIED** |\n| Land Pattern Density | IPC-7351B | **UNVERIFIED** |\n\n### Remaining Gaps\n\n1. **IPC-2152 formula** — The approximate formula `A = (117.555 × ΔT^(-0.913) + 1.15) × I^(0.84 × ΔT^(-0.108) + 1.159)` and correction factors for copper planes are from secondary sources (online calculators). The Jouppi article confirms the standard's methodology, test scope (≤25A), and limitations but does not reprint the formula. Impact: **Low** — the IPC-2221A formula is adequate for most reviews, and the caveats about when IPC-2152 matters are well documented.\n\n2. **IEC 62368-1** — Energy source classification thresholds (ES1/ES2/ES3 current and voltage limits) and the hazard-based safety engineering approach are described from secondary sources. Impact: **Low** — the creepage/clearance tables from ECMA-287 (which derives from the same IEC 60664-1 framework) cover the PCB-relevant requirements. IEC 62368-1 mainly adds the energy-source classification layer on top.\n\n3. **IPC-7351B** — Land pattern density levels (A/B/C) and courtyard excess values (0.50/0.25/0.10 mm) are from secondary sources. Impact: **Low** — these values are widely cited and unlikely to be wrong, but haven't been confirmed against the actual standard document.\n\n## Contents\n\n| Section | Line | Standard |\n|---------|------|----------|\n| Product Classification | ~56 | IPC-A-600G, IPC-2221A |\n| Conductor Spacing | ~73 | IPC-2221A Table 6-1 |\n| Current Carrying Capacity | ~109 | IPC-2221A Section 6.2 |\n| Annular Ring Requirements | ~158 | IPC-2221A Tables 9-1, 9-2 |\n| Hole Size Requirements | ~194 | IPC-2221A Tables 9-3, 9-4, 9-5 |\n| Impedance Calculations | ~224 | IPC-2221A Section 6.4 |\n| Dielectric Properties | ~262 | IPC-2221A Table 6-2 |\n| Via Protection Types | ~279 | IPC-4761 |\n| Creepage and Clearance | ~313 | ECMA-287 (derived from IEC 60664-1) |\n| Safety Standards | ~446 | ECMA-287, IEC 62368-1 |\n| Land Pattern Density | ~481 | IPC-7351B |\n| Current Capacity (Updated) | ~501 | IPC-2152 |\n\n---\n\n## Product Classification\n\nThree product classes determine acceptable imperfection levels. Source: IPC-A-600G Section 1.4, IPC-2221A.\n\n| Class | Name | Description | Examples |\n|-------|------|-------------|----------|\n| 1 | General Electronic Products | Consumer products; cosmetic imperfections not important; function is primary requirement | Consumer electronics, toys, non-critical appliances |\n| 2 | Dedicated Service Electronic Products | High performance and extended life required; uninterrupted service desired but not critical; cosmetic imperfections allowed | Communications equipment, business machines, instruments |\n| 3 | High Reliability Electronic Products | Continued performance or on-demand performance critical; equipment downtime not tolerable; must function when required | Life support, flight control, military, medical implants |\n\n**How to determine class from a design:** Look for indicators in the schematic/BOM:\n- Medical ICs, MIL-spec parts, automotive-grade parts → likely Class 3\n- Industrial MCUs, commercial-grade with redundancy → likely Class 2\n- ESP32/Arduino, consumer-grade parts, 2-layer hobby boards → likely Class 1\n\n---\n\n## Conductor Spacing (Electrical Clearance)\n\nSource: **IPC-2221A Table 6-1** (page 43), verified from PDF.\n\nMinimum spacing in mm between uninsulated conductors. Columns B1-B4 are bare board conditions; A5-A7 are assembly conditions.\n\n| Voltage (DC or AC peak) | B1: Internal | B2: External, uncoated, sea level | B3: External, uncoated, >3050m | B4: External, polymer coated, sea level | A5: External, conformal coated over assembly | A6: External, uncoated, sea level | A7: External, uncoated, >3050m |\n|---|---|---|---|---|---|---|---|\n| 0–15 V | 0.05 | 0.1 | 0.1 | 0.05 | 0.13 | 0.13 | 0.13 |\n| 16–30 V | 0.05 | 0.1 | 0.1 | 0.05 | 0.13 | 0.25 | 0.13 |\n| 31–50 V | 0.1 | 0.6 | 0.6 | 0.13 | 0.13 | 0.4 | 0.13 |\n| 51–100 V | 0.1 | 0.6 | 1.5 | 0.13 | 0.13 | 0.5 | 0.13 |\n| 101–150 V | 0.2 | 0.6 | 3.2 | 0.4 | 0.4 | 0.8 | 0.4 |\n| 151–170 V | 0.2 | 1.25 | 3.2 | 0.4 | 0.4 | 0.8 | 0.4 |\n| 171–250 V | 0.2 | 1.25 | 6.4 | 0.4 | 0.4 | 0.8 | 0.4 |\n| 251–300 V | 0.2 | 1.25 | 12.5 | 0.8 | 0.8 | 1.5 | 0.8 |\n| 301–500 V | 0.25 | 2.5 | 12.5 | 0.8 | 0.8 | 1.5 | 0.8 |\n| >500 V | 0.0005/V | 0.005/V | 0.025/V | 0.00167/V | 0.00167/V | 0.003/V | 0.00167/V |\n\n**Column selection guide:**\n- **B1** (Internal): Traces on inner layers of multilayer boards\n- **B2** (External, uncoated, sea level): Bare board traces at normal altitude, no coating\n- **B3** (External, uncoated, >3050m): High altitude operation — much larger spacing required\n- **B4** (External, polymer coated): Bare board with conformal coating (before assembly)\n- **A5** (Conformal coated assembly): Assembled board with conformal coating applied\n- **A6** (External, uncoated assembly): Assembled board, no coating — the common case for most designs\n- **A7** (External, uncoated assembly, >3050m): Assembled, no coating, high altitude\n\n**Usage in design review:** Extract the maximum voltage on each net from the schematic's power analysis. For each pair of adjacent nets at different potentials, verify the PCB track spacing meets the appropriate column. Pay special attention to:\n- Power supply input (often highest voltage)\n- Switch-node nets on DC-DC converters\n- Any net with voltage >50V\n- Mains-referenced circuits (require IEC 60664-1 creepage/clearance instead — see below)\n\n---\n\n## Current Carrying Capacity\n\nSource: **IPC-2221A Section 6.2** (page 40), verified from PDF. These are the classic \"IPC-2221 charts\" widely used in PCB design.\n\n### Formula (from IPC-2221A Section 6.2)\n\n```\nI = k × ΔT^0.44 × A^0.725\n```\n\nWhere:\n- `I` = current capacity (Amperes)\n- `k` = 0.048 for external (outer) layers; 0.024 for internal layers\n- `ΔT` = temperature rise above ambient (°C)\n- `A` = cross-sectional area of the conductor (square mils; 1 mil = 0.0254 mm)\n\n**Converting trace width to cross-sectional area:**\n```\nA (sq. mils) = width_mils × thickness_mils\n```\nFor 1 oz copper: thickness = 1.37 mils (0.035 mm = 35 µm)\nFor 2 oz copper: thickness = 2.74 mils (0.070 mm = 70 µm)\n\n### Quick Reference Table (1 oz copper, 10°C rise, external layer)\n\n| Trace Width (mm) | Trace Width (mils) | Area (sq. mils) | Current (A) |\n|---|---|---|---|\n| 0.15 | 5.9 | 8.1 | 0.3 |\n| 0.25 | 9.8 | 13.5 | 0.5 |\n| 0.5 | 19.7 | 27.0 | 0.8 |\n| 1.0 | 39.4 | 54.0 | 1.4 |\n| 2.0 | 78.7 | 107.8 | 2.3 |\n| 3.0 | 118.1 | 161.8 | 3.1 |\n| 5.0 | 196.9 | 269.7 | 4.5 |\n\nNote: Internal layer capacity is approximately half of external (k=0.024 vs k=0.048).\n\n### Important Caveats\n\n- The external trace chart originates from **NBS (National Bureau of Standards) test data from 1954–1956** (NBS Report 4283). The test boards were phenolic, epoxy, and G-5 materials in thicknesses from 1/32\" to 1/8\", tested in still air. (Source: Mike Jouppi, IPC 1-10b task group chairman, PCDFCA July 2022.)\n- The **internal trace chart was NOT derived from test data** — it was created by halving the current from the external chart. This arbitrary factor was never documented or justified. (Verified from same source.)\n- These charts are for **single isolated conductors** in still air. Real-world conditions (adjacent traces, enclosed housing, elevated ambient, copper planes) significantly affect results.\n- **IPC-2152** is the successor standard with updated test methodology. See the IPC-2152 section below.\n- The formula assumes steady-state DC current. For pulsed/transient currents, the RMS equivalent should be used.\n\n**Usage in design review:** Extract power net trace widths from the PCB analyzer's `power_net_routing` list. Calculate current capacity using the formula and compare against expected current from the schematic's power analysis. Flag any traces with \u003c50% margin as WARNING, \u003c20% margin as CRITICAL.\n\n---\n\n## Annular Ring Requirements\n\nSource: **IPC-2221A Tables 9-1 and 9-2** (page 74), verified from PDF.\n\n### Table 9-1: Fabrication Allowance\n\n| Producibility Level | Min Fabrication Allowance |\n|---|---|\n| Level A (Preferred/Standard) | 0.4 mm |\n| Level B (Standard/Moderate) | 0.25 mm |\n| Level C (Reduced/Advanced) | 0.2 mm |\n\n### Table 9-2: Minimum Annular Ring\n\n| Feature | Minimum Annular Ring |\n|---|---|\n| External, Supported (plated through) | 0.050 mm |\n| External, Unsupported (non-plated) | 0.150 mm |\n| Internal, Supported | 0.025 mm |\n\n**\"Supported\"** means the hole has plating connecting to the land (plated-through hole). **\"Unsupported\"** means no plating support (non-plated through hole).\n\n**Annular ring calculation:**\n```\nAnnular ring = (pad diameter - drill diameter) / 2\n```\n\nThe fabrication allowance must be added to account for drill registration tolerance:\n```\nMinimum pad diameter = drill diameter + (2 × annular ring) + fabrication allowance\n```\n\n**Usage in design review:** The PCB analyzer reports annular ring values in the via analysis section. Compare against these minimums. For JLCPCB and similar budget fabs, their actual capability is typically Level B or C. Check the fab's DFM specs against these IPC minimums.\n\n---\n\n## Hole Size Requirements\n\nSource: **IPC-2221A Tables 9-3, 9-4, 9-5** (page 76), verified from PDF.\n\n### Table 9-3: Min Drilled Hole Size — Buried Vias\n\n| Board Thickness at Via | Level A | Level B | Level C |\n|---|---|---|---|\n| ≤1.0 mm | 0.25 mm | 0.20 mm | 0.15 mm |\n| >1.0 mm to ≤2.0 mm | 0.30 mm | 0.25 mm | 0.20 mm |\n| >2.0 mm | 0.35 mm | 0.30 mm | 0.25 mm |\n\n### Table 9-4: Min Drilled Hole Size — Blind Vias\n\n| Board Thickness at Via | Level A | Level B | Level C |\n|---|---|---|---|\n| ≤1.0 mm | 0.25 mm | 0.20 mm | 0.15 mm |\n| >1.0 mm to ≤2.0 mm | 0.30 mm | 0.25 mm | 0.20 mm |\n| >2.0 mm | 0.35 mm | 0.30 mm | 0.25 mm |\n\n### Table 9-5: Hole Location Tolerance\n\n| Producibility Level | Hole Location Tolerance |\n|---|---|\n| Level A (Preferred) | ±0.25 mm |\n| Level B (Standard) | ±0.20 mm |\n| Level C (Reduced) | ±0.15 mm |\n\n---\n\n## Impedance Calculations\n\nSource: **IPC-2221A Section 6.4** (pages 43-48), verified from PDF. These are first-order approximations — use a field solver for production designs.\n\n### Microstrip (outer layer trace over ground plane)\n\n```\nZ₀ = (87 / √(εᵣ + 1.41)) × ln(5.98h / (0.8w + t))\n```\n\nWhere:\n- `Z₀` = characteristic impedance (Ω)\n- `εᵣ` = relative dielectric constant of substrate\n- `h` = height of trace above ground plane (dielectric thickness)\n- `w` = trace width\n- `t` = trace thickness\n- All dimensions in the same units\n\nValid for: `w/h \u003c 1` (narrow trace relative to dielectric height)\n\n### Embedded Microstrip (inner trace with reference plane)\n\n```\nZ₀ = (60 / √εᵣ) × ln(4h / (0.67(0.8w + t)))\n```\n\n### Stripline (inner trace between two ground planes)\n\n```\nZ₀ = (60 / √εᵣ) × ln(4b / (0.67π(0.8w + t)))\n```\n\nWhere `b` = distance between the two reference planes.\n\n**Usage in design review:** For USB (90Ω differential), DDR, LVDS, and other controlled-impedance signals, verify the trace width against the board stackup using these formulas. The PCB analyzer doesn't calculate impedance directly — use the track widths from the analyzer and the stackup from the `.kicad_pro` or fab stackup notes.\n\n---\n\n## Dielectric Properties\n\nSource: **IPC-2221A Table 6-2** (page 45), verified from PDF.\n\n| Material | Dielectric Constant (εᵣ) at 1 MHz |\n|---|---|\n| FR-4 (glass epoxy) | 4.2–4.9 |\n| Polyimide (Kapton) | 3.2–3.5 |\n| BT/Epoxy | 3.9–4.2 |\n| PTFE (Teflon) | 2.0–2.3 |\n| Ceramic (alumina) | 8.0–10.5 |\n| Cyanate Ester | 3.5–3.8 |\n\nNote: Dielectric constant varies with frequency. At GHz frequencies, FR-4 εᵣ is typically 4.2-4.4 (lower than the 1 MHz value). For high-frequency designs (>1 GHz), use frequency-dependent data from the laminate manufacturer's datasheet (e.g., Isola, Rogers).\n\n---\n\n## Via Protection Types\n\nSource: **IPC-4761** (July 2006), verified from PDF. Complete document (12 pages).\n\nSeven via protection types identified by the IPC D-33d Via Protection Task Group:\n\n| Type | Name | Description | Via-in-Pad? |\n|---|---|---|---|\n| I | Tented | Dry film mask bridging over via, no fill material | No |\n| II | Tented and Covered | Type I + secondary mask covering | No |\n| III | Plugged | Material partially penetrates via (screened/roller coated) | No |\n| IV | Plugged and Covered | Type III + secondary mask covering | No |\n| V | Filled | Full penetration with conductive or non-conductive material | Precursor |\n| VI | Filled and Covered | Type V + secondary mask (liquid or dry film) | Yes |\n| VII | Filled and Capped | Type V + metallized coating (plated over) | Yes (preferred) |\n\n**Key rules from IPC-4761:**\n- **Single-sided** types (Ia, IIa, IIIa, IVa) are **NOT RECOMMENDED** — they leave bare copper exposed on the unprotected side, leading to corrosion\n- **Via-in-Pad** designs should use **Type VII** (filled and capped with metallization)\n- Bump height from via fill must be ≤0.076 mm (0.003 in) to avoid stencil contact issues\n- Dry film thickness for tenting: 0.058 mm as applied, 0.046 mm cured\n- LPI (Liquid Photo-Imageable) solder mask thickness: 0.018–0.030 mm\n\n**Application guidelines** (from Table 5-1):\n- Preventing solder ball blowout → Types Ib, IIb, IIIb, IVb, V, VI, VII\n- Via-in-pad for BGA → Type VII (filled and capped)\n- Keeping chemistry from passing through via → Types Ib, IIb, IIIb, IVb, V, VI, VII\n- Best thermal conductivity → Types V, VII (use thermally conductive fill ink)\n- Preventing migration of adhesives → Types Ib, IIb, IVb, VII\n\n**Usage in design review:** Check the PCB analyzer's via analysis for via-in-pad usage. If BGA/QFN components have vias in their pads, verify the fab notes specify Type VII. Flag unprotected vias under components as a manufacturing risk.\n\n---\n\n## Creepage and Clearance (Insulation Coordination)\n\nSources: **ECMA-287** (1st edition, June 1999) — Safety of electronic equipment, Tables 3.3–3.5, 3.9. Verified from PDF. ECMA-287 references and derives its values from the **IEC 60664 series** framework (overvoltage categories, pollution degrees, material groups are defined in IEC 60664-1).\n\nThese requirements apply to mains-connected equipment and safety-critical insulation. IPC-2221A Table 6-1 is sufficient for most low-voltage PCB designs; creepage/clearance from this section applies when the design involves:\n- Mains voltage (AC line input)\n- Safety isolation barriers (primary-to-secondary)\n- Medical equipment (IEC 60601)\n- IT/AV equipment (IEC 62368-1 / ECMA-287)\n\n### Key Concepts\n\n- **Clearance**: Shortest distance in air between two conductive parts (ECMA-287 §2.4)\n- **Creepage**: Shortest distance along the surface of insulating material between two conductive parts (ECMA-287 §2.5)\n- **Pollution Degree (PD)**: Environmental contamination level (ECMA-287 §2.25–2.28)\n - PD1: No pollution or only dry, non-conductive pollution (sealed components/assemblies)\n - PD2: Normally only non-conductive pollution; occasional temporary conductivity from condensation (default for equipment within scope of ECMA-287)\n - PD3: Conductive pollution or dry non-conductive pollution that becomes conductive due to condensation\n- **Overvoltage Category (OVC)**: Position in the power distribution system (defined in IEC 60664-1)\n - OVC I: Equipment with transient protection (signal-level circuits)\n - OVC II: Energy-consuming equipment (appliances, portable tools) — **default for most equipment**\n - OVC III: Equipment in fixed installations (distribution panels)\n - OVC IV: Equipment at the origin of installation (meters, primary overcurrent protection)\n- **Material Group**: Based on Comparative Tracking Index (CTI) per IEC 60112 (ECMA-287 §3.2.2)\n - Group I: CTI ≥ 600V\n - Group II: 400V ≤ CTI \u003c 600V\n - Group IIIa: 175V ≤ CTI \u003c 400V\n - Group IIIb: 100V ≤ CTI \u003c 175V\n - FR-4 is typically **Material Group IIIb** (CTI 100-175V) unless high-CTI grade is specified\n - If material group is unknown, assume IIIb (worst case)\n\n### Mains Transient Voltages (for Clearance Determination)\n\nSource: **ECMA-287 Table 3.3**, verified from PDF.\n\n| Nominal AC Mains (line-to-neutral) | OVC I | OVC II | OVC III | OVC IV |\n|---|---|---|---|---|\n| ≤50 V rms | 330 V pk | 500 V pk | 800 V pk | 1500 V pk |\n| ≤100 V rms | 500 V pk | 800 V pk | 1500 V pk | 2500 V pk |\n| ≤150 V rms ¹ | 800 V pk | 1500 V pk | 2500 V pk | 4000 V pk |\n| ≤300 V rms ² | 1500 V pk | 2500 V pk | 4000 V pk | 6000 V pk |\n| ≤600 V rms ³ | 2500 V pk | 4000 V pk | 6000 V pk | 8000 V pk |\n\n¹ Including 120/208 or 120/240 V. ² Including 230/400 or 277/480 V. ³ Including 400/690 V.\n\nUse this table to determine the **required withstand voltage** for clearance lookup (Table 3.4 below).\n\n### Minimum Clearances (up to 2000m altitude)\n\nSource: **ECMA-287 Table 3.4**, verified from PDF.\n\n| Required Withstand Voltage | Basic/Supplementary Insulation | Reinforced Insulation |\n|---|---|---|\n| ≤400 V peak/dc | 0.2 mm (0.1 mm) | 0.4 mm (0.2 mm) |\n| ≤800 V | 0.2 mm | 0.4 mm |\n| ≤1000 V | 0.3 mm | 0.6 mm |\n| ≤1200 V | 0.4 mm | 0.8 mm |\n| ≤1500 V | 0.8 mm (0.5 mm) | 1.6 mm (1 mm) |\n| ≤2000 V | 1.3 mm (1 mm) | 2.6 mm (2 mm) |\n| ≤2500 V | 2 mm (1.5 mm) | 4 mm (3 mm) |\n| ≤3000 V | 2.6 mm (2 mm) | 5.2 mm (4 mm) |\n| ≤4000 V | 4 mm (3 mm) | 6 mm |\n| ≤6000 V | 7.5 mm | 11 mm |\n| ≤8000 V | 11 mm | 16 mm |\n| ≤10000 V | 15 mm | 22 mm |\n| ≤12000 V | 19 mm | 28 mm |\n| ≤15000 V | 24 mm | 36 mm |\n\nValues in parentheses apply only with routine dielectric strength testing under a quality control programme. Linear interpolation permitted between table entries (round up to 0.1 mm).\n\n**Example:** 120V AC mains, OVC II → Table 3.3 gives 1500V peak withstand → Table 3.4 gives 0.8 mm basic, 1.6 mm reinforced clearance.\n\n### Minimum Creepage Distance\n\nSource: **ECMA-287 Table 3.5**, verified from PDF. Values for basic and supplementary insulation. For **reinforced insulation**, use **2× the basic insulation values**.\n\n| Working Voltage (rms/dc) | PD1 (all groups) | PD2, Grp I | PD2, Grp II | PD2, Grp IIIa/IIIb | PD3, Grp I | PD3, Grp II | PD3, Grp IIIa/IIIb |\n|---|---|---|---|---|---|---|---|\n| 50 V | Use clearance | 0.6 mm | 0.9 mm | 1.2 mm | 1.5 mm | 1.7 mm | 1.9 mm |\n| 100 V | value from | 0.7 mm | 1.0 mm | 1.4 mm | 1.8 mm | 2.0 mm | 2.2 mm |\n| 125 V | appropriate | 0.8 mm | 1.1 mm | 1.5 mm | 1.9 mm | 2.1 mm | 2.4 mm |\n| 150 V | table | 0.8 mm | 1.1 mm | 1.6 mm | 2.0 mm | 2.2 mm | 2.5 mm |\n| 200 V | | 1.0 mm | 1.4 mm | 2.0 mm | 2.5 mm | 2.8 mm | 3.2 mm |\n| 250 V | | 1.3 mm | 1.8 mm | 2.5 mm | 3.2 mm | 3.6 mm | 4.0 mm |\n| 300 V | | 1.6 mm | 2.2 mm | 3.2 mm | 4.0 mm | 4.5 mm | 5.0 mm |\n| 400 V | | 2.0 mm | 2.8 mm | 4.0 mm | 5.0 mm | 5.6 mm | 6.3 mm |\n| 600 V | | 3.2 mm | 4.5 mm | 6.3 mm | 8.0 mm | 9.0 mm | 10.0 mm |\n| 800 V | | 4.0 mm | 5.6 mm | 8.0 mm | 10.0 mm | 11.0 mm | 12.5 mm |\n| 1000 V | | 5.0 mm | 7.1 mm | 10.0 mm | 12.5 mm | 14.0 mm | 16.0 mm |\n\nLinear interpolation between entries is permitted (round up to 0.1 mm).\n\n**Common case for FR-4 PCB at 120V AC mains:** Use the 125V row (closest standard voltage). PD2, Material Group IIIb → creepage = 1.5 mm basic, 3.0 mm reinforced. At 230V AC (use 250V row): 2.5 mm basic, 5.0 mm reinforced.\n\n### Minimum Separation for Coated Printed Boards\n\nSource: **ECMA-287 Table 3.9**, verified from PDF. Applies to Type II coated boards (section 3.2.4.2) where ≥80% of the distance between conductive parts is coated. Requires routine dielectric strength testing for double/reinforced insulation.\n\n| Working Voltage (rms/dc) | Basic/Supplementary | Reinforced |\n|---|---|---|\n| ≤63 V | 0.1 mm | 0.2 mm |\n| ≤125 V | 0.2 mm | 0.4 mm |\n| ≤160 V | 0.3 mm | 0.6 mm |\n| ≤200 V | 0.4 mm | 0.8 mm |\n| ≤250 V | 0.6 mm | 1.2 mm |\n| ≤320 V | 0.8 mm | 1.6 mm |\n| ≤400 V | 1.0 mm | 2.0 mm |\n| ≤500 V | 1.3 mm | 2.6 mm |\n| ≤630 V | 1.8 mm | 3.6 mm |\n| ≤800 V | 2.4 mm | 3.8 mm |\n| ≤1000 V | 2.8 mm | 4.0 mm |\n\nThree coating types (ECMA-287 §3.2.4):\n- **Type I**: Coating only improves pollution degree to PD1 (use PD1 clearance/creepage)\n- **Type II**: Uses reduced separation distances from table above (requires quality control)\n- **Type III**: Solid insulation enclosing conductors — no minimum separation distances (requires dielectric testing)\n\n### Determining Required Clearance/Creepage\n\n1. Identify the **working voltage** (highest RMS or DC voltage across the insulation)\n2. Determine the **overvoltage category** (typically OVC II for most equipment)\n3. Look up the **mains transient voltage** from Table 3.3 (this is the required withstand voltage for clearance)\n4. Look up **minimum clearance** from Table 3.4 using the required withstand voltage\n5. Determine **pollution degree** (PD2 for most indoor electronics)\n6. Determine **material group** (IIIb for standard FR-4)\n7. Look up **minimum creepage** from Table 3.5 using working voltage, pollution degree, and material group\n8. For reinforced insulation (safety isolation barriers): double the basic creepage values\n9. The creepage distance must always be ≥ the applicable clearance distance\n\n**Usage in design review:** For mains-connected or safety-isolated designs, identify the primary-to-secondary boundary on the schematic (usually at the transformer, optocoupler, or isolated DC-DC). Measure the minimum spacing in the PCB layout across this boundary. Compare against both creepage and clearance requirements. Common failure: slot/groove in the PCB at the isolation boundary is present but too narrow, or components bridge the gap.\n\n---\n\n## Safety Standards\n\n### ECMA-287 (verified)\n\nSource: **ECMA-287, 1st edition, June 1999** — Safety of electronic equipment. Verified from PDF.\n\nECMA-287 is a freely available safety standard for electronic equipment with rated voltage ≤600V RMS, covering office equipment, consumer electronics, and telecom terminal equipment. It addresses:\n- Electric shock hazards (§3) — clearance, creepage, solid insulation, coated PCBs\n- Mechanical hazards (§4)\n- Fire hazards (§5)\n- Burn hazards (§6)\n- Chemical hazards (§7)\n- Radiation (§8)\n\nKey definitions for PCB design review (from §2):\n- **Hazardous voltage**: Exceeds ELV criteria (>42.4V AC peak or >60V DC) AND exceeds limited current criteria\n- **ELV circuit**: Secondary circuit ≤42.4V AC peak or ≤60V DC, separated from hazardous voltage by basic insulation\n- **SELV circuit**: ELV circuit designed so voltage doesn't exceed safe value under normal AND single fault conditions\n- **Class I**: Protection by basic insulation + protective earthing\n- **Class II**: Protection by double or reinforced insulation (no earth required)\n\n### IEC 62368-1 [UNVERIFIED]\n\nIEC 62368-1 is the safety standard for audio/video, information, and communication technology equipment. It replaces IEC 60950-1 (IT equipment) and IEC 60065 (audio/video equipment). It is the modern successor to standards like ECMA-287/IEC 60950.\n\nKey concepts relevant to PCB design review:\n- **Energy source classification**: ES1 (safe), ES2 (limited), ES3 (hazardous)\n- **Primary circuit**: Connected to mains (ES3)\n- **Secondary circuit**: Isolated from mains via a safety barrier\n- **Safeguards**: Basic insulation (1× barrier), supplementary (1× redundant), reinforced (2× single barrier equivalent), double (basic + supplementary)\n\n**Insulation requirements** determine the creepage/clearance at isolation barriers. Reinforced insulation requires 2× the basic insulation clearance. The creepage/clearance tables in ECMA-287 (derived from IEC 60664-1) provide verified reference values that are consistent with IEC 62368-1 requirements.\n\n---\n\n## Land Pattern Density Levels [UNVERIFIED]\n\nSource: IPC-7351B — Generic Requirements for Surface Mount Design and Land Pattern Standard.\n\n**[UNVERIFIED — need IPC-7351B PDF to confirm these values.]**\n\nThree density levels for component footprint land patterns:\n\n| Level | Name | Courtyard Excess | Application |\n|---|---|---|---|\n| A | Most (Maximum) | 0.50 mm | Hand soldering, prototyping, maximum reliability |\n| B | Nominal | 0.25 mm | Typical production, wave or reflow |\n| C | Least (Minimum) | 0.10 mm | High-density, miniaturized products |\n\n\"Courtyard excess\" is the additional clearance around the component body and pads that defines the component's keep-out zone.\n\n**Usage in design review:** The PCB analyzer reports courtyard overlaps. When violations are found, determine the target density level and compare. Level C designs may intentionally have tighter courtyards but require more precise placement equipment.\n\n---\n\n## Current Carrying Capacity (Updated) — IPC-2152\n\nSource: IPC-2152 — Standard for Determining Current Carrying Capacity in Printed Board Design. Context verified from article by **Mike Jouppi** (IPC 1-10b task group chairman, 1999–2016) in *Printed Circuit Design & Fab*, July 2022.\n\n**[PARTIALLY VERIFIED — the formula below is from secondary sources. The article confirms the methodology and limitations but does not reprint the exact formula. The IPC-2152 PDF with full charts/appendix would be needed for complete verification.]**\n\nIPC-2152 supersedes the current capacity charts in IPC-2221A. The IPC-2221A charts originated from NBS (National Bureau of Standards) test data from **1954–1956** (documented in NBS Report 4283). Key historical facts (verified from article):\n\n- The IPC-2221A **external trace chart** was derived from NBS test data on bare conductors\n- The IPC-2221A **internal trace chart was NOT based on test data** — it was created by halving the current from the external chart. This was an arbitrary choice whose logic was never documented.\n- IPC-2152 test data was collected on boards up to **25A** — calculations above 25A are extrapolations\n- Board material, thickness, width, copper weight, and **copper planes** all significantly affect trace temperature rise\n- A copper plane 0.005\" from the trace causes a **significant drop** in trace temperature rise\n- The IPC-2152 design charts are technology-specific: \"A single design chart cannot be expected to describe the temperature rise of traces in all printed circuit board applications\"\n- Mounting configuration (bolted, wedgelocks) also impacts thermal performance but was not tested\n\nApproximate formula (from secondary sources, commonly used in online calculators):\n\n```\nA = (117.555 × ΔT^(-0.913) + 1.15) × I^(0.84 × ΔT^(-0.108) + 1.159)\n```\n\nWhere:\n- `A` = cross-sectional area (sq. mils)\n- `ΔT` = temperature rise (°C)\n- `I` = current (Amperes)\n\nThis solves for the required area given current and acceptable temperature rise (the inverse of the IPC-2221A formula which solves for current given area).\n\nKey differences from IPC-2221A:\n- Generally more conservative results (larger traces required for the same current)\n- Based on modern test data (FR-4, polyimide, and BT boards in multiple thicknesses)\n- The IPC-2152 Appendix includes charts for various configurations (Table 1 in article lists 60+ test configurations across FR-4, polyimide, and BT materials, with board sizes from 3×3\" to 14×1\")\n- Correction factors for copper planes: 1oz plane 0.005\" from trace, 2oz plane 0.005\" from trace\n- For **parallel conductors** (multiple traces carrying current side by side), IPC-2221A dramatically overpredicts temperature rise; IPC-2152 provides better guidance\n\n**When to use IPC-2152 vs IPC-2221A:**\n- For currents **≤5A** on standard FR-4: IPC-2221A formula is adequate (minor differences)\n- For currents **5–25A**: IPC-2152 gives more accurate results, especially with copper planes\n- For currents **>25A**: Both are extrapolations — thermal modeling recommended\n- For **flex circuits** without copper planes: IPC-2221A internal chart (the one derived by halving) accidentally gives reasonable results\n- For safety-critical designs: always use IPC-2152 or thermal modeling\n\n**Usage in design review:** When the IPC-2221A calculation shows a trace is marginal (less than 2× safety margin), note that IPC-2152 should be consulted for a more accurate assessment. If copper planes are present adjacent to the trace, the actual temperature rise may be significantly lower than IPC-2221A predicts.\n\n---\n\n## How to Apply Standards in Design Review\n\n### Automatic Triggers\n\nInclude a standards compliance section in the design review report when any of these conditions are met:\n\n1. **High voltage present** (any net >50V DC or >30V AC peak) → Check IPC-2221A Table 6-1 conductor spacing\n2. **Mains input** (AC line connection detected) → Check IEC 60664-1 creepage/clearance, IEC 62368-1 insulation requirements\n3. **Power traces** (>1A expected on any net) → Verify current capacity per IPC-2221A Section 6.2 or IPC-2152\n4. **Safety isolation** (transformer, optocoupler, or isolated DC-DC in schematic) → Check creepage/clearance at barrier per IEC 60664-1\n5. **Class 2/3 indicators** (industrial MCU, automotive parts, MIL-spec components, medical ICs) → Apply tighter tolerances per product class\n6. **Impedance-controlled signals** (USB, DDR, LVDS, Ethernet) → Verify trace geometry per IPC-2221A Section 6.4\n7. **Via-in-pad** (BGA/QFN with vias in pads) → Verify via protection type per IPC-4761\n\n### Report Section Template\n\nWhen standards checking is triggered, add this section to the report (after the power analysis section):\n\n```markdown\n## Standards Compliance\n\n### Product Classification\n[Class 1/2/3 determination and rationale]\n\n### Conductor Spacing (IPC-2221A Table 6-1)\n| Net Pair | Voltage Difference | Required Spacing (column) | Actual Spacing | Status |\n|---|---|---|---|---|\n| [net1] / [net2] | [V] | [mm] ([column]) | [mm] | PASS/FAIL |\n\n### Current Capacity (IPC-2221A / IPC-2152)\n| Net | Expected Current | Trace Width | Copper Weight | Layer | Calculated Capacity | Margin | Status |\n|---|---|---|---|---|---|---|---|\n| [net] | [A] | [mm] | [oz] | [ext/int] | [A] | [%] | PASS/FAIL |\n\n### Annular Ring (IPC-2221A Table 9-2)\n[Via annular ring analysis results]\n\n### Creepage/Clearance (IEC 60664-1) — if applicable\n[Only for mains/safety isolation designs]\n\n### Via Protection (IPC-4761) — if applicable\n[Only for via-in-pad designs]\n```\n\n### What NOT to Check\n\n- Do not apply IEC 60664-1 creepage/clearance to low-voltage battery/USB-powered designs — IPC-2221A Table 6-1 is sufficient\n- Do not require Class 3 tolerances on hobby/prototype boards unless specifically requested\n- Do not flag conductor spacing on low-voltage designs (\u003c15V) unless traces are below the absolute minimum (0.05mm internal, 0.1mm external)\n- Do not apply IPC-2152 for low-current digital signals — the classic IPC-2221A formula is adequate for non-critical traces\n\n---\n\n## Fab House Capabilities (DFM Tier Classification)\n\nCanonical reference for DFM tier determination. The analyzer (`analyze_pcb.py`) uses these values in its `LIMITS_STD` and `LIMITS_ADV` constants. Report generation must cite values from this table — do not substitute values from training data.\n\n**Source:** JLCPCB capabilities page (verified 2025-01), PCBWay capabilities page (verified 2025-01). Fab capabilities change periodically — check the fab's website for the latest values before making DFM decisions.\n\n### JLCPCB\n\n| Parameter | Standard Tier | Advanced Tier |\n|-----------|---------------|---------------|\n| Min trace width | 0.127 mm (5 mil) | 0.1 mm (4 mil) |\n| Min trace spacing | 0.127 mm (5 mil) | 0.1 mm (4 mil) |\n| Min PTH drill | 0.2 mm | 0.15 mm |\n| Min via annular ring | 0.125 mm | 0.1 mm |\n| Min NPTH drill | 0.5 mm | 0.5 mm |\n| Min via diameter (drill+ring) | 0.45 mm | 0.35 mm |\n| Max copper weight | 2 oz | 2 oz |\n| Max layers | 20 | 20 |\n| Min solder mask bridge | 0.1 mm | 0.075 mm |\n| Min silkscreen width | 0.15 mm | 0.1 mm |\n| Board size (no surcharge) | ≤100×100 mm | ≤100×100 mm |\n| Min board dimension | 10 mm | 10 mm |\n\n**Tier determination:** If any metric falls below the standard tier limit, classify as \"advanced\". If any metric falls below the advanced tier limit, classify as \"challenging\" (may require manual review or alternative fab).\n\n### PCBWay\n\n| Parameter | Standard |\n|-----------|----------|\n| Min trace width | 0.1 mm (4 mil) |\n| Min trace spacing | 0.1 mm (4 mil) |\n| Min PTH drill | 0.2 mm |\n| Min annular ring | 0.1 mm |\n| Min NPTH drill | 0.8 mm |\n| Max copper weight | 6 oz |\n| Max layers | 14 |\n| Min solder mask bridge | 0.1 mm |\n| Board size (no surcharge) | ≤100×100 mm |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":34217,"content_sha256":"1b228649a57523e2642db4b1a91d92faf53632d2f93806e54814d218b8e8b6d9"},{"filename":"references/supplementary-data-sources.md","content":"# Supplementary Data Sources\n\nWhen `analyze_schematic.py` returns incomplete data — typically for legacy KiCad 5 `.sch` projects where some `.lib` files are missing from the repo — use these additional project files to recover full analysis capability.\n\nFor analyzing external PDF schematics (manufacturer reference designs, eval boards, application notes) as a primary input source, see `pdf-schematic-extraction.md`.\n\n## Table of Contents\n\n1. [When You Need Supplementary Data](#when-you-need-supplementary-data)\n2. [Netlist File (.net)](#netlist-file-net)\n3. [Cache Library (-cache.lib)](#cache-library--cachelib)\n4. [PCB Cross-Reference](#pcb-cross-reference)\n5. [PDF Schematic Exports](#pdf-schematic-exports)\n6. [Combined Workflow for Legacy Designs](#combined-workflow-for-legacy-designs)\n\n---\n\n## When You Need Supplementary Data\n\nThe schematic analyzer supports both formats with pin-level analysis:\n\n| Format | Components | Nets | Pin-to-Net | Signal Analysis | Subcircuits |\n|--------|-----------|------|-----------|----------------|-------------|\n| Modern `.kicad_sch` (KiCad 6+) | Full | Full | Full | Full | Full |\n| Legacy `.sch` (KiCad 4/5) | Full | Full | Near-full* | Near-full* | Near-full* |\n\n\\* The legacy analyzer parses `.lib` files (cache libraries, project libs, and built-in fallbacks for common symbols) to populate pin data. Coverage is typically 92–100% depending on which `.lib` files are available in the repo. Components whose `.lib` files are missing won't have pin data.\n\n**Indicators that you may need supplementary sources:**\n- Some components in the output have empty `pins` arrays\n- Standard KiCad system library symbols (uncommon symbols from `power`, `device`, `conn` beyond R/C/L/D/LED/transistors) lack pin data\n- Component inventory looks complete but specific ICs are missing from signal analysis\n\n---\n\n## Netlist File (`.net`)\n\n**The most valuable supplementary source when `.lib` files are incomplete.** A KiCad 5 netlist export provides explicit pin-to-net mapping for all components, filling gaps where `.lib` files are missing from the repo.\n\n### Finding the Netlist\n\nLook for a `.net` file in the project directory, named after the project (e.g., `myboard.net`). Generated via KiCad 5's `Tools → Generate Netlist`. Not all projects have one — it's an optional export step.\n\n### Netlist Structure\n\n```\n(export (version D)\n (components\n (comp (ref U1)\n (value STM32F407ZGTx)\n (footprint Package_QFP:LQFP-144_20x20mm_P0.5mm)\n (libsource (lib MCU_ST_STM32F4) (part STM32F407ZGTx) (description \"...\"))\n (sheetpath (names /) (tstamps /))\n (tstamp HEXID)\n )\n ...\n )\n (nets\n (net (code 1) (name GND)\n (node (ref U1) (pin 12))\n (node (ref C1) (pin 2))\n (node (ref R5) (pin 1))\n )\n (net (code 2) (name +3V3)\n (node (ref U1) (pin 100))\n (node (ref U1) (pin 28))\n (node (ref C3) (pin 1))\n )\n ...\n )\n)\n```\n\n### What the Netlist Provides\n\n| Data | How to Extract | Analysis Use |\n|------|---------------|-------------|\n| Pin-to-net mapping | `(node (ref U1) (pin 12))` in each `(net ...)` | Which pin of which component connects to which net |\n| Complete net list | All `(net (code N) (name \"...\"))` entries | Every named and unnamed net in the design |\n| Pin-level connectivity | All `(node ...)` entries per net | Full connectivity graph for subcircuit detection |\n| Component verification | `(comp ...)` entries with ref, value, footprint | Cross-check against schematic analyzer output |\n\n### Parsing the Netlist\n\nThe netlist is S-expression format — use the skill's `sexp_parser.py`:\n\n```python\nfrom sexp_parser import parse_file, find_all, find_first, get_value\n\ntree = parse_file('project.net')\nexport = tree # Root is the (export ...) node\n\n# Extract component list\ncomponents = find_all(find_first(export, 'components'), 'comp')\nfor comp in components:\n ref = get_value(comp, 'ref')\n value = get_value(comp, 'value')\n footprint = get_value(comp, 'footprint')\n\n# Build pin-to-net map\nnets = find_all(find_first(export, 'nets'), 'net')\npin_net_map = {} # (ref, pin) -> net_name\nfor net in nets:\n net_name = get_value(net, 'name')\n for node in find_all(net, 'node'):\n ref = get_value(node, 'ref')\n pin = get_value(node, 'pin')\n pin_net_map[(ref, pin)] = net_name\n```\n\nWith this map, you can perform the same subcircuit detection as the modern analyzer: find voltage dividers by checking shared nets between resistor pairs, identify regulator topologies by tracing VIN/VOUT/FB pin connections, etc.\n\n### Limitations\n\n- Netlist files are snapshots — they may be stale if the schematic was edited after the last export\n- Pin numbers in the netlist use the **symbol** pin numbering, which may not match physical pad numbering if the symbol library has errors\n- Unnamed nets get auto-generated names like `Net-(U1-Pad12)` that differ between exports\n\n---\n\n## Cache Library (`-cache.lib`)\n\nKiCad 5 projects include a `-cache.lib` file containing embedded copies of all symbol definitions used in the project. **The analyzer now parses cache libraries automatically** — this section documents the format for cases where you need to manually inspect or supplement the data.\n\n### Finding the Cache Library\n\nLook for `\u003cproject-name>-cache.lib` in the project directory. It's auto-generated by KiCad 5 and should always exist alongside a `.sch` file. The analyzer checks for this file first and uses it as the preferred source of pin data.\n\n### Cache Library Structure\n\n```\nEESchema-LIBRARY Version 2.4\n#\n# STM32F407ZGTx\n#\nDEF STM32F407ZGTx U 0 20 Y Y 9 L N\nF0 \"U\" ... reference\nF1 \"STM32F407ZGTx\" ... value\nF2 \"Package_QFP:LQFP-144_20x20mm_P0.5mm\" ... footprint\nDRAW\n...graphics...\nX PA0 34 -1600 900 200 R 50 50 1 1 B ; Pin definition\nX PA1 35 -1600 800 200 R 50 50 1 1 B ; X name number x y length orient sizeN sizeP unit convert type\n...\nENDDRAW\nENDDEF\n```\n\n### Pin Definition Fields\n\nThe `X` lines define each pin:\n\n```\nX \u003cname> \u003cnumber> \u003cx> \u003cy> \u003clength> \u003corientation> \u003csizeN> \u003csizeP> \u003cunit> \u003cconvert> \u003ctype>\n```\n\n| Field | Example | Meaning |\n|-------|---------|---------|\n| name | `PA0`, `VDD`, `BOOT0` | Pin function name |\n| number | `34`, `100` | Physical pin number (should match footprint pad) |\n| unit | `1`-`N` | Which unit of a multi-unit symbol |\n| type | `B`, `I`, `O`, `P`, `W`, `w`, `U` | Electrical type |\n\n**Pin electrical types:**\n| Code | Type | Description |\n|------|------|-------------|\n| `I` | Input | Logic input |\n| `O` | Output | Logic output |\n| `B` | Bidirectional | I/O, GPIO |\n| `T` | Tri-state | Tri-state output |\n| `P` | Passive | Resistor, capacitor |\n| `W` | Power input | VDD, GND (power consumer) |\n| `w` | Power output | Regulator output |\n| `U` | Unspecified | Unknown function |\n| `C` | Open collector | Requires pull-up |\n| `E` | Open emitter | Requires pull-down |\n| `N` | Not connected | NC pin |\n\n### Analysis Uses\n\n1. **Verify pin definitions**: Cross-reference pin numbers and names against the IC datasheet. Library pin mapping errors (symbol pin numbers not matching physical footprint pads) are a common source of manufacturing defects.\n\n2. **Identify pin functions**: Pin names like `VIN`, `VOUT`, `FB`, `EN`, `SW`, `SDA`, `SCL` help classify component functions when the analyzer can't auto-detect them.\n\n3. **Multi-unit verification**: Count `X` lines per unit number to verify all units of multi-unit symbols (quad op-amps, MCU pin banks) have the correct pins.\n\n4. **Power pin identification**: Pins with type `W` (power input) tell you which pins need decoupling caps. Pins with type `w` (power output) identify regulator output pins.\n\n---\n\n## PCB Cross-Reference\n\nWhen schematic analysis is incomplete, the PCB file provides an independent source of truth for net assignments — because PCB footprints use the physical pad numbering from the datasheet.\n\n### Cross-Reference Workflow\n\n1. Run `analyze_pcb.py` on the `.kicad_pcb` file\n2. For each component, compare the PCB's pad-to-net assignments against:\n - The schematic analyzer's component list (same refs, same values?)\n - The netlist file's pin-to-net map (same pin→net associations?)\n - The cache library's pin definitions (pin numbers match pad numbers?)\n\n### What Mismatches Reveal\n\n| PCB Data | Schematic Data | Problem |\n|----------|---------------|---------|\n| Pad 3 = `+3V3` | Pin 3 = `GND` | **Library pin mapping error** — symbol pin numbers don't match footprint pads |\n| Pad exists, net assigned | Component missing from schematic | Schematic out of sync with PCB |\n| Component on PCB | Component has `(dnp yes)` in schematic | DNP not cleaned up, or intentional |\n| Pad has net | Pin appears unconnected in netlist | Netlist is stale |\n\n### Key PCB Fields for Cross-Reference\n\nFrom `analyze_pcb.py` output, each footprint includes:\n- `reference` — component designator (should match schematic)\n- `value` — component value (should match schematic)\n- `pads[].net_name` — net assigned to each pad\n- `pads[].number` — pad number (physical pin number from datasheet)\n- `sch_path` — UUID linking back to the schematic symbol\n- `sheetname` / `sheetfile` — source schematic sheet\n\n### Library Error Detection Pattern\n\nThe most dangerous bugs are library pin mapping errors. Detect them by:\n\n1. From the cache library: build map of `(pin_name, pin_number)` for each component\n2. From the netlist: build map of `(ref, pin_number) → net_name`\n3. From the PCB: build map of `(ref, pad_number) → net_name`\n4. For each component, verify that the netlist's pin_number→net matches the PCB's pad_number→net for the same physical pin\n\nIf pin N in the netlist connects to net A, but pad N in the PCB connects to net B, the symbol library has a pin mapping error.\n\n---\n\n## PDF Schematic Exports\n\nIf the project includes a PDF export of the schematic (or if you can visually compare against KiCad's schematic viewer), use it for visual verification of circuit topology.\n\n### When PDF Helps\n\n- **Confirming connections**: When the analyzer output is ambiguous about how components connect, the visual schematic makes it clear\n- **Tracing signal paths**: Follow wires visually from IC pin to passive component to connector\n- **Verifying design intent**: Designer annotations, notes, and layout on the schematic reveal what the circuit is supposed to do\n- **Catching parser errors**: If the analyzer reports something that looks wrong, the PDF shows whether the schematic actually has that connection\n\n### How to Use\n\nRead the PDF pages (using page range selection if available), then correlate visual observations with the analyzer output. Focus on:\n1. Key IC connections (power, signal, control pins)\n2. Subcircuit boundaries (which passives belong to which IC)\n3. Net labels and power symbols\n4. Any designer annotations or notes\n\nFor comprehensive PDF schematic analysis techniques (when the PDF is the primary input, not just a supplement), see `pdf-schematic-extraction.md`.\n\n---\n\n## Combined Workflow for Legacy Designs\n\nWhen working with a KiCad 5 legacy project, the analyzer handles most of the work automatically:\n\n### Step 1: Run the schematic analyzer\n\n```bash\npython3 \u003cskill-path>/scripts/analyze_schematic.py project.sch\n```\n\nThe analyzer automatically parses `.lib` files (cache libraries and project libs), populates pin data, builds pin-to-net mapping, runs signal analysis, and detects subcircuits. Check the output for components with empty `pins` arrays — these are the ones missing `.lib` data.\n\n### Step 2: Parse the netlist (if needed)\n\nIf some components lack pin data (their `.lib` files aren't in the repo), look for `project.net` in the project directory. Parse it to get explicit pin-to-net mapping for those components (see parsing instructions above).\n\n### Step 3: Cross-reference with PCB\n\nRun `analyze_pcb.py` on the `.kicad_pcb` file. Compare pad-to-net assignments against the analyzer's pin-to-net data to catch library pin mapping errors.\n\n### Step 4: Visual verification (optional)\n\nIf a PDF export exists, use it to spot-check critical connections and verify your understanding of the circuit topology.\n\n### Data Recovery Matrix\n\n| Supplementary Source | What It Recovers | Priority |\n|---------------------|-----------------|----------|\n| Netlist (`.net`) | Pin-to-net mapping for components missing `.lib` data | **Highest** — fills remaining gaps |\n| PCB (`.kicad_pcb`) | Pad-to-net verification, library error detection | Medium — cross-check, not primary source |\n| PDF export | Visual circuit topology verification | Low — supplement, not data source |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":12619,"content_sha256":"63a1da271a8c9f017614579c6ad0e885324f50a4223cea9d6a8f34944a5b0e4c"},{"filename":"references/what-if.md","content":"# What-If Parameter Sweep Reference\n\nInteractive parameter sweep for KiCad designs. Patches component values in analyzer JSON, recalculates affected subcircuit fields, and shows before/after impact. Supports single changes, multi-point sweeps, tolerance corner analysis, inverse fix suggestions, EMC impact preview, and PCB parasitic awareness.\n\n## Table of Contents\n\n1. [Overview](#overview)\n2. [CLI Reference](#cli-reference)\n3. [Value Formats](#value-formats)\n4. [Fix Suggestions](#fix-suggestions)\n5. [E-Series Snapping](#e-series-snapping)\n6. [EMC Impact Preview](#emc-impact-preview)\n7. [PCB Parasitic Awareness](#pcb-parasitic-awareness)\n8. [Recalculable Fields](#recalculable-fields)\n9. [JSON Output Schema](#json-output-schema)\n10. [Common User Intents](#common-user-intents)\n11. [Combinability](#combinability)\n\n---\n\n## Overview\n\nThe what-if pipeline operates in three stages:\n\n1. **Patch** -- Locate all subcircuit detections in `findings[]` (grouped by detector) that reference the changed component(s) and replace their stored values.\n2. **Recalculate** -- Re-derive dependent fields (cutoff frequency, divider ratio, opamp gain, etc.) using the formulas in `_recalc_derived()`.\n3. **Compare** -- Diff the original and patched detections, report before/after values with percentage deltas.\n\nThe tool operates on analyzer JSON produced by `analyze_schematic.py`. It never re-parses the schematic file -- it works entirely on the pre-analyzed data.\n\n**When to use it:**\n- Exploring component value trade-offs before committing to a design change.\n- Answering \"what if I change R5 to 4.7k\" style questions instantly.\n- Finding the right component value to hit a target spec (--fix mode).\n- Evaluating tolerance spread impact on derived parameters.\n- Previewing EMC consequences of a component change before re-running the full EMC suite.\n\n---\n\n## CLI Reference\n\n```\npython3 what_if.py \u003cinput> [changes...] [options]\n```\n\n### Positional Arguments\n\n| Argument | Description |\n|----------|-------------|\n| `input` | Analyzer JSON file (from `analyze_schematic.py`) |\n| `changes` | Zero or more `REF=VALUE` pairs (e.g., `R5=4.7k C3=22n`) |\n\n### Options\n\n| Flag | Description |\n|------|-------------|\n| `--spice` | Re-run SPICE simulations on affected subcircuits (requires ngspice/LTspice/Xyce) |\n| `--output FILE`, `-o FILE` | Write patched analysis JSON to file (for downstream EMC, thermal, or diff analysis) |\n| `--text` | Human-readable text output instead of JSON |\n| `--emc` | Show EMC impact preview (runs `analyze_emc.py` on original and patched JSON) |\n| `--pcb FILE` | PCB analysis JSON for parasitic awareness (auto-discovered if omitted) |\n| `--fix TYPE[INDEX]` | Inverse-solve for component values to hit a target (e.g., `--fix voltage_dividers[0]`) |\n| `--target VALUE` | Target value for `--fix` mode (e.g., `3.3` for ratio, `1000` for Hz) |\n\n### Exit Codes\n\n| Code | Meaning |\n|------|---------|\n| 0 | Success |\n| 1 | Invalid input, parse error, or missing data |\n\n---\n\n## Value Formats\n\n### Single Value\n\n```\nR5=4.7k\nC3=22n\nL1=10u\n```\n\nStandard engineering notation. The parser uses `parse_value()` from `kicad_utils.py` with automatic component type detection based on the reference prefix (`C` -> capacitor, `L` -> inductor, everything else -> resistor by default).\n\n### Comma Sweep\n\n```\nR5=1k,2.2k,4.7k,10k\n```\n\nEvaluates the circuit at each listed value. Results are formatted as a markdown table in `--text` mode. Only one component may use sweep syntax per invocation.\n\n### Log-Range Sweep\n\n```\nR5=1k..100k:10\n```\n\nGenerates `N` logarithmically spaced values between start and stop (inclusive). The step count is capped at 50.\n\n**Syntax:** `START..STOP:N`\n\nThe log distribution is computed as: `v[i] = start * (stop/start)^(i/(N-1))`\n\n### Tolerance Suffix\n\n```\nR5=4.7k+-5%\nR5=4.7k±5%\n```\n\nBoth `+-` and the Unicode `±` character are accepted. The tolerance triggers worst-case corner analysis: all 2^N combinations of each toleranced component at its +tol and -tol extremes. Capped at 6 components (64 corners).\n\nDefault tolerances when the suffix is omitted but tolerance mode is active:\n\n| Prefix | Default Tolerance |\n|--------|-------------------|\n| `C`, `VC` | 10% |\n| `L` | 20% |\n| All others | 5% |\n\n### Combined Formats\n\nSweep and tolerance can be combined on a single component:\n\n```\nR5=1k,2.2k,4.7k+-5%\n```\n\nThis sweeps through the listed values and also computes tolerance corners at each step.\n\nMultiple non-sweep changes can be specified alongside a single sweep:\n\n```\nR5=1k,2.2k,4.7k C3=22n\n```\n\nHere `C3` is held fixed at 22nF while `R5` sweeps.\n\n---\n\n## Fix Suggestions\n\nThe `--fix` mode runs an inverse solver to find component values that achieve a target specification.\n\n### Syntax\n\n```\npython3 what_if.py analysis.json --fix TYPE[INDEX] --target VALUE\n```\n\nWhere `TYPE[INDEX]` references a detection type and index (e.g., `voltage_dividers[0]`, `rc_filters[2]`). Internally, findings are grouped by detector name.\n\n### Target Inference\n\nWhen `--target` is omitted, the solver attempts to infer the target from the detection context:\n\n| Detection Type | Inferred Target |\n|---------------|-----------------|\n| `voltage_dividers`, `feedback_networks` | `ratio = regulator_vref / target_vout` (from detection metadata) |\n| `crystal_circuits` | `effective_load_pF = target_load_pF` (from detection metadata) |\n| All others | Error -- `--target` is required |\n\n### Inverse Solver Formulas\n\nFor each detection type, the solver holds one component fixed and computes the ideal value for the other. Both directions are reported as separate suggestions.\n\n**voltage_dividers / feedback_networks** (target: `ratio`)\n\n| Solve For | Formula | Equation ID |\n|-----------|---------|-------------|\n| R_bottom (fix R_top) | `R_bot = R_top * ratio / (1 - ratio)` | EQ-WI-001 |\n| R_top (fix R_bottom) | `R_top = R_bot * (1 - ratio) / ratio` | EQ-WI-002 |\n\n**rc_filters** (target: `cutoff_hz`)\n\n| Solve For | Formula | Equation ID |\n|-----------|---------|-------------|\n| C (fix R) | `C = 1 / (2*pi*R*f_c)` | EQ-WI-003 |\n| R (fix C) | `R = 1 / (2*pi*C*f_c)` | EQ-WI-004 |\n\n**lc_filters** (target: `resonant_hz`)\n\n| Solve For | Formula | Equation ID |\n|-----------|---------|-------------|\n| C (fix L) | `C = 1 / ((2*pi*f_0)^2 * L)` | EQ-WI-005 |\n| L (fix C) | `L = 1 / ((2*pi*f_0)^2 * C)` | EQ-WI-006 |\n\n**opamp_circuits** (target: `gain` or `gain_dB`)\n\nWhen `gain_dB` is the target field, it is converted to linear gain first: `gain = 10^(gain_dB/20)`.\n\n| Configuration | Formula | Equation ID |\n|--------------|---------|-------------|\n| Non-inverting | `R_f = R_i * (|gain| - 1)` | EQ-WI-007 |\n| Inverting / default | `R_f = R_i * |gain|` | EQ-WI-008 |\n\n**crystal_circuits** (target: `effective_load_pF`)\n\n| Solve For | Formula | Equation ID |\n|-----------|---------|-------------|\n| Each load cap (symmetric) | `C_load = 2 * (target_pF - C_stray)` | EQ-WI-009 |\n\nDefault stray capacitance: 3.0 pF.\n\n**current_sense** (target: `max_current_100mV_A` or `max_current_50mV_A`)\n\n| Target | Formula | Equation ID |\n|--------|---------|-------------|\n| `max_current_100mV_A` | `R_shunt = 0.100 / I_target` | EQ-WI-010 |\n| `max_current_50mV_A` | `R_shunt = 0.050 / I_target` | EQ-WI-011 |\n\n### Output\n\nEach suggestion includes the ideal (exact) value plus E-series snapped alternatives at E12, E24, and E96 with error percentage. If PCB analysis is available, footprint compatibility warnings are generated for capacitor values that may exceed the package size limit.\n\n---\n\n## E-Series Snapping\n\nAll fix suggestions are snapped to standard E-series values using `snap_to_e_series()` from `kicad_utils.py`.\n\n**Algorithm:**\n1. Extract the decade: `decade = 10^floor(log10(value))`\n2. Normalize: `normalized = value / decade`\n3. Find the closest value in the series decade list.\n4. Reconstruct: `snapped = best * decade`\n5. Compute error: `error_pct = (snapped - value) / value * 100`\n\n**Available series:**\n\n| Series | Values per Decade | Typical Tolerance |\n|--------|-------------------|-------------------|\n| E12 | 12 | 10% |\n| E24 | 24 | 5% |\n| E96 | 96 | 1% |\n\nAll three series are reported for every fix suggestion, allowing the user to choose based on availability and precision requirements.\n\n---\n\n## EMC Impact Preview\n\nThe `--emc` flag runs the full EMC analyzer (`analyze_emc.py`) on both the original and patched analysis JSON, then diffs the results.\n\n### Protocol\n\n1. The patched analysis JSON is written to a temporary file.\n2. `analyze_emc.py` is invoked as a subprocess with `--schematic` pointing to each temporary file.\n3. If `--pcb` is specified, it is passed through as well.\n4. A 30-second timeout is enforced per invocation.\n5. Temporary files are cleaned up regardless of outcome.\n\n### Output Fields\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `before_risk` | string | Overall risk level before change |\n| `after_risk` | string | Overall risk level after change |\n| `resolved` | array | Findings that disappeared after the change |\n| `improved` | array | Findings whose risk level decreased |\n| `new_findings` | array | Findings that appeared after the change |\n| `unchanged` | integer | Count of findings with no change |\n\nText mode renders this as a summary with per-finding detail.\n\n---\n\n## PCB Parasitic Awareness\n\nWhen PCB analysis data is available, the tool annotates each affected subcircuit with trace resistance and inductance estimates.\n\n### Providing PCB Data\n\n1. **Explicit:** `--pcb pcb_analysis.json`\n2. **Auto-discovery:** If the schematic JSON is at `analysis/schematic/foo.json`, the tool looks for `analysis/pcb/*.json` automatically.\n\n### Trace Parasitic Formulas\n\nTrace resistance (EQ-WI-012):\n\n```\nR_trace = rho * length / (width * thickness)\n```\n\nWhere `rho` = 1.72e-8 ohm-m (copper), `thickness` = 35e-6 m (1 oz copper).\n\nTrace inductance (EQ-WI-013, valid when length > width):\n\n```\nL_trace = 2e-7 * length * ln(2 * length / width)\n```\n\nBoth are computed per net segment and summed for all track segments connected to the component.\n\n### Footprint Compatibility\n\nFor capacitor fix suggestions, the tool checks the suggested value against typical maximum capacitance for common package sizes:\n\n| Package | Typical Max (ceramic MLCC) |\n|---------|---------------------------|\n| 0402 | 100 nF |\n| 0603 | 1 uF |\n| 0805 | 10 uF |\n| 1206 | 22 uF |\n| 1210 | 47 uF |\n\nA warning is emitted when a suggested E-series value exceeds the package limit.\n\n---\n\n## Recalculable Fields\n\nThe recalculation engine (`_recalc_derived` in `spice_tolerance.py`) updates these fields after patching component values:\n\n| Detection Type | Field | Formula | Equation ID |\n|---------------|-------|---------|-------------|\n| `rc_filters` | `cutoff_hz` | `1 / (2*pi*R*C)` | EQ-RC-001 |\n| `voltage_dividers`, `feedback_networks` | `ratio` | `R_bot / (R_top + R_bot)` | EQ-VD-001 |\n| `lc_filters` | `resonant_hz` | `1 / (2*pi*sqrt(L*C))` | EQ-LC-001 |\n| `lc_filters` | `impedance_ohms` | `sqrt(L/C)` | EQ-LC-002 |\n| `crystal_circuits` | `effective_load_pF` | `(C1*C2)/(C1+C2) * 1e12 + C_stray` | EQ-XL-001 |\n| `opamp_circuits` (inverting) | `gain` | `-R_f / R_i` | EQ-OA-001 |\n| `opamp_circuits` (non-inverting) | `gain` | `1 + R_f / R_i` | EQ-OA-002 |\n| `opamp_circuits` | `gain_dB` | `20 * log10(|gain|)` | EQ-OA-003 |\n| `current_sense` | `max_current_50mV_A` | `0.050 / R_shunt` | EQ-CS-001 |\n| `current_sense` | `max_current_100mV_A` | `0.100 / R_shunt` | EQ-CS-002 |\n| `power_regulators` (feedback divider) | `ratio` | `R_bot / (R_top + R_bot)` | EQ-VD-001 |\n\nThe comparison engine also checks for any additional fields present in the detection that were not explicitly registered (e.g., `estimated_vout`).\n\n---\n\n## JSON Output Schema\n\n### Single-Value Mode\n\n```json\n{\n \"changes\": {\n \"R5\": {\n \"before\": 10000.0,\n \"after\": 4700.0,\n \"before_str\": \"10k\",\n \"after_str\": \"4.7k\",\n \"unit\": \"ohms\"\n }\n },\n \"affected_subcircuits\": [\n {\n \"type\": \"voltage_dividers\",\n \"label\": \"voltage divider R5/R6\",\n \"components\": [\"R5\", \"R6\"],\n \"delta\": [\n {\"field\": \"ratio\", \"before\": 0.5, \"after\": 0.6808, \"delta_pct\": 36.2}\n ],\n \"before\": {\"ratio\": 0.5},\n \"after\": {\"ratio\": 0.6808},\n \"parasitics\": {},\n \"tolerance\": [],\n \"spice_delta\": {}\n }\n ],\n \"summary\": {\n \"components_changed\": 1,\n \"subcircuits_affected\": 1,\n \"spice_verified\": false\n },\n \"emc_delta\": null\n}\n```\n\nThe `parasitics`, `tolerance`, `spice_delta`, and `emc_delta` fields are only present when the corresponding options are active.\n\n### Sweep Mode\n\n```json\n{\n \"ref\": \"R5\",\n \"values\": [1000.0, 2200.0, 4700.0, 10000.0],\n \"value_strs\": [\"1k\", \"2.2k\", \"4.7k\", \"10k\"],\n \"results\": [\n {\n \"value\": 1000.0,\n \"value_str\": \"1k\",\n \"affected_subcircuits\": [\n {\n \"type\": \"voltage_dividers\",\n \"label\": \"voltage divider R5/R6\",\n \"delta\": [{\"field\": \"ratio\", \"before\": 0.5, \"after\": 0.909}],\n \"after\": {\"ratio\": 0.909}\n }\n ]\n }\n ]\n}\n```\n\n### Fix Mode\n\n```json\n{\n \"fix_suggestions\": [\n {\n \"detection_type\": \"voltage_dividers\",\n \"detection_index\": 0,\n \"target_field\": \"ratio\",\n \"target_value\": 0.3,\n \"suggestions\": [\n {\n \"ref\": \"R6\",\n \"field\": \"ohms\",\n \"current\": 10000.0,\n \"ideal\": 4285.7,\n \"anchor_ref\": \"R5\",\n \"anchor_value\": 10000.0,\n \"e_series\": {\n \"E12\": {\"value\": 3900.0, \"error_pct\": -9.0},\n \"E24\": {\"value\": 4300.0, \"error_pct\": 0.3},\n \"E96\": {\"value\": 4320.0, \"error_pct\": 0.8}\n }\n }\n ],\n \"footprint_warnings\": []\n }\n ]\n}\n```\n\n### Tolerance Fields (within affected_subcircuits)\n\n```json\n{\n \"tolerance\": [\n {\n \"field\": \"cutoff_hz\",\n \"nominal\": 1591.55,\n \"worst_low\": 1447.77,\n \"worst_high\": 1768.39,\n \"spread_pct\": 20.1\n }\n ]\n}\n```\n\n---\n\n## Common User Intents\n\nNatural-language queries and their corresponding command invocations.\n\n| User Says | Command |\n|-----------|---------|\n| \"What if I change R5 to 4.7k\" | `what_if.py analysis.json R5=4.7k --text` |\n| \"Sweep R5 through some standard values\" | `what_if.py analysis.json R5=1k,2.2k,4.7k,10k --text` |\n| \"Sweep R5 from 1k to 100k\" | `what_if.py analysis.json R5=1k..100k:10 --text` |\n| \"What's the tolerance spread on this filter\" | `what_if.py analysis.json R5=10k+-5% C3=100n+-10% --text` |\n| \"What value gives me 3.3V on this divider\" | `what_if.py analysis.json --fix voltage_dividers[0] --target 3.3 --text` |\n| \"Fix the crystal load capacitance\" | `what_if.py analysis.json --fix crystal_circuits[0] --text` (target inferred) |\n| \"How does changing C3 affect EMC\" | `what_if.py analysis.json C3=1u --emc --text` |\n| \"What if I use a 4.7k with 1% tolerance instead\" | `what_if.py analysis.json R5=4.7k+-1% --text` |\n| \"Change R5 and C3 together, show me the filter response\" | `what_if.py analysis.json R5=4.7k C3=22n --text` |\n| \"Export the patched design for EMC analysis\" | `what_if.py analysis.json R5=4.7k --output patched.json` |\n| \"Verify with SPICE\" | `what_if.py analysis.json R5=4.7k --spice --text` |\n| \"What's the best R value for 1kHz cutoff\" | `what_if.py analysis.json --fix rc_filters[0] --target 1000 --text` |\n| \"Set the opamp gain to 20 dB\" | `what_if.py analysis.json --fix opamp_circuits[0] --target 20 --text` (target field = `gain_dB`) |\n| \"What shunt resistor for 5A max\" | `what_if.py analysis.json --fix current_sense[0] --target 5 --text` |\n\nFor fix mode, the `--target` value is in the natural unit of the first derived field for that detection type (ratio for dividers, Hz for filters, linear gain or dB for opamps, pF for crystals, amps for current sense).\n\n---\n\n## Combinability\n\nWhich flags and modes work together:\n\n| Combination | Supported | Notes |\n|-------------|-----------|-------|\n| Single change + `--text` | Yes | Primary use case |\n| Single change + `--spice` | Yes | Runs SPICE on original and patched |\n| Single change + `--emc` | Yes | Full EMC diff |\n| Single change + `--pcb` | Yes | Adds parasitic annotations |\n| Single change + `--output` | Yes | Exports patched JSON |\n| Single change + tolerance | Yes | Corner analysis on toleranced components |\n| Sweep + `--text` | Yes | Markdown table output |\n| Sweep + tolerance | Yes | Tolerance corners at each sweep point |\n| Sweep + fixed changes | Yes | Other components held at specified values |\n| Sweep + `--spice` | No | Sweep mode does not run SPICE |\n| Sweep + `--emc` | No | Sweep mode exits before EMC |\n| Sweep + `--output` | No | Sweep mode exits before export |\n| `--fix` + `--target` | Yes | Primary fix use case |\n| `--fix` (no `--target`) | Partial | Only works for detection types with inferrable targets |\n| `--fix` + `--pcb` | Yes | Adds footprint compatibility warnings for capacitors |\n| `--fix` + changes | No | Fix mode ignores positional changes |\n| `--fix` + `--spice` | No | Fix mode exits before SPICE |\n| `--fix` + `--emc` | No | Fix mode exits before EMC |\n| `--emc` + `--pcb` | Yes | PCB data passed through to EMC analyzer |\n| Multiple changes (no sweep) | Yes | All changed components patched simultaneously |\n| Multiple sweeps | No | Only one component may use sweep syntax |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":17261,"content_sha256":"e2166a088473f8a0cab401a345952e88dc5adbfe205a9a7326acac2722d879af"},{"filename":"scripts/analysis_cache.py","content":"\"\"\"\nShared analysis cache management for kicad-happy.\n\nManages the analysis/ directory convention: timestamped run folders,\nmanifest.json for freshness tracking, source file hashing, retention\npruning, and .gitignore generation.\n\nConsumed by:\n - kicad skill (writer): creates runs, updates manifest\n - kidoc skill (reader): loads current run data via manifest\n\nZero external dependencies -- stdlib only.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport hashlib\nimport json\nimport os\nimport shutil\nimport sys\nimport tempfile\nfrom datetime import datetime, timezone\nfrom typing import Any, Dict, List, Optional, Tuple\n\n\n# ---------------------------------------------------------------------------\n# Constants\n# ---------------------------------------------------------------------------\n\nMANIFEST_FILENAME = 'manifest.json'\n\nCANONICAL_OUTPUTS = {\n 'schematic': 'schematic.json',\n 'pcb': 'pcb.json',\n 'gerber': 'gerber.json',\n 'spice': 'spice.json',\n 'emc': 'emc.json',\n 'thermal': 'thermal.json',\n 'lifecycle': 'lifecycle.json',\n 'cross_analysis': 'cross_analysis.json',\n}\n\n# File extensions that each output's contents depend on. Used by create_run\n# to decide per-file whether a carried-forward output is stale vs safe to\n# copy. Outputs not listed are treated as derived (never blocked from copy).\n_OUTPUT_SOURCE_EXT = {\n 'schematic.json': ('.kicad_sch', '.sch'),\n 'pcb.json': ('.kicad_pcb',),\n 'gerber.json': ('.gbr', '.gtl', '.gbl', '.gts', '.gbs', '.gto', '.gbo',\n '.gm1', '.drl', '.txt'),\n}\n\nGITIGNORE_CONTENT = \"\"\"\\\n# Analysis output -- regenerable from source files\n*\n!.gitignore\n!manifest.json\n\"\"\"\n\ndef _empty_manifest() -> Dict[str, Any]:\n \"\"\"Return a fresh empty manifest (avoids mutable default sharing).\"\"\"\n return {\n 'version': 1,\n 'project': '',\n 'current': None,\n 'runs': {},\n }\n\n\n# ---------------------------------------------------------------------------\n# Directory initialization\n# ---------------------------------------------------------------------------\n\ndef ensure_analysis_dir(project_dir: str,\n project_file: str = '',\n config: Optional[Dict[str, Any]] = None) -> str:\n \"\"\"Create analysis/ directory with manifest and .gitignore if needed.\n\n Args:\n project_dir: Root directory of the KiCad project.\n project_file: Name of the .kicad_pro file (for manifest).\n config: Merged project config dict. If None, uses defaults.\n\n Returns:\n Absolute path to the analysis directory.\n \"\"\"\n if config is None:\n config = {}\n analysis_cfg = config.get('analysis', {})\n output_dir = analysis_cfg.get('output_dir', 'analysis')\n track_in_git = analysis_cfg.get('track_in_git', False)\n\n analysis_dir = os.path.join(os.path.abspath(project_dir), output_dir)\n os.makedirs(analysis_dir, exist_ok=True)\n\n # Create manifest if it doesn't exist\n manifest_path = os.path.join(analysis_dir, MANIFEST_FILENAME)\n if not os.path.isfile(manifest_path):\n manifest = _empty_manifest()\n manifest['project'] = project_file\n save_manifest(analysis_dir, manifest)\n\n # Create .gitignore if needed\n gitignore_path = os.path.join(analysis_dir, '.gitignore')\n if not track_in_git and not os.path.isfile(gitignore_path):\n with open(gitignore_path, 'w', encoding='utf-8') as f:\n f.write(GITIGNORE_CONTENT)\n\n return analysis_dir\n\n\ndef resolve_analysis_dir(path: str) -> str:\n \"\"\"Return a canonical analysis-dir path.\n\n Command-line ``--analysis-dir`` paths should be interpreted the same way\n across all analyzers:\n - absolute paths stay absolute\n - relative paths are resolved from the current working directory\n\n Do not anchor ``path`` to the input schematic/PCB file's directory. When\n callers already pass a project-relative path like ``hardware/foo/analysis``\n alongside a project-relative input file like ``hardware/foo/design.kicad_*``,\n joining them would incorrectly duplicate the prefix as\n ``hardware/foo/hardware/foo/analysis``.\n \"\"\"\n return os.path.abspath(path)\n\n\n# ---------------------------------------------------------------------------\n# Manifest I/O\n# ---------------------------------------------------------------------------\n\ndef load_manifest(analysis_dir: str) -> Dict[str, Any]:\n \"\"\"Read manifest.json from analysis directory.\n\n Returns empty manifest structure if file is missing or corrupt.\n \"\"\"\n manifest_path = os.path.join(analysis_dir, MANIFEST_FILENAME)\n if not os.path.isfile(manifest_path):\n return _empty_manifest()\n try:\n with open(manifest_path, 'r', encoding='utf-8') as f:\n return json.load(f)\n except (json.JSONDecodeError, OSError):\n # Corrupt manifest -- back up and return empty\n backup = manifest_path + '.bak'\n try:\n shutil.copy2(manifest_path, backup)\n except OSError:\n pass\n print(f'Warning: corrupt manifest at {manifest_path}, '\n f'backed up to .bak', file=sys.stderr)\n return _empty_manifest()\n\n\ndef save_manifest(analysis_dir: str, manifest: Dict[str, Any]) -> None:\n \"\"\"Write manifest.json atomically (write-to-temp then rename).\"\"\"\n os.makedirs(analysis_dir, exist_ok=True)\n manifest_path = os.path.join(analysis_dir, MANIFEST_FILENAME)\n with tempfile.NamedTemporaryFile(\n mode='w',\n encoding='utf-8',\n dir=analysis_dir,\n prefix=MANIFEST_FILENAME + '.',\n suffix='.tmp',\n delete=False,\n ) as f:\n json.dump(manifest, f, indent=2)\n f.write('\\n')\n tmp_path = f.name\n os.replace(tmp_path, manifest_path)\n\n\n# ---------------------------------------------------------------------------\n# Source file hashing\n# ---------------------------------------------------------------------------\n\ndef hash_source_file(filepath: str) -> Optional[str]:\n \"\"\"SHA-256 hash of a file, returned as 'sha256:\u003chex>'.\n\n Returns None if the file doesn't exist.\n \"\"\"\n if not os.path.isfile(filepath):\n return None\n h = hashlib.sha256()\n with open(filepath, 'rb') as f:\n for chunk in iter(lambda: f.read(65536), b''):\n h.update(chunk)\n return f'sha256:{h.hexdigest()}'\n\n\ndef hash_source_files(project_dir: str,\n source_files: List[str]) -> Dict[str, str]:\n \"\"\"Hash multiple source files relative to project_dir.\n\n Args:\n project_dir: Root directory of the KiCad project.\n source_files: List of paths relative to project_dir.\n\n Returns:\n Dict of {relative_path: \"sha256:\u003chex>\"} for files that exist.\n \"\"\"\n hashes = {}\n for relpath in source_files:\n abspath = os.path.join(project_dir, relpath)\n h = hash_source_file(abspath)\n if h is not None:\n hashes[relpath] = h\n return hashes\n\n\ndef sources_changed(old_hashes: Dict[str, str],\n project_dir: str) -> bool:\n \"\"\"Check if any source file has changed since the hashes were recorded.\n\n Args:\n old_hashes: Dict of {relative_path: \"sha256:\u003chex>\"} from manifest.\n project_dir: Root directory of the KiCad project.\n\n Returns:\n True if any file's current hash differs from old_hashes.\n \"\"\"\n for relpath, old_hash in old_hashes.items():\n abspath = os.path.join(project_dir, relpath)\n current_hash = hash_source_file(abspath)\n if current_hash != old_hash:\n return True\n return False\n\n\n# ---------------------------------------------------------------------------\n# Run ID generation\n# ---------------------------------------------------------------------------\n\ndef generate_run_id(analysis_dir: Optional[str] = None) -> str:\n \"\"\"Generate a timestamped run ID in YYYY-MM-DD_HHMM format.\n\n If analysis_dir is provided and a folder with that name already exists,\n appends a suffix: 2026-04-08_1919-2, 2026-04-08_1919-3, etc.\n \"\"\"\n now = datetime.now().astimezone()\n base_id = now.strftime('%Y-%m-%d_%H%M')\n\n if analysis_dir is None or not os.path.isdir(analysis_dir):\n return base_id\n\n if not os.path.exists(os.path.join(analysis_dir, base_id)):\n return base_id\n\n # Deduplicate\n for suffix in range(2, 100):\n candidate = f'{base_id}-{suffix}'\n if not os.path.exists(os.path.join(analysis_dir, candidate)):\n return candidate\n\n return base_id # fallback (should never happen)\n\n\n# ---------------------------------------------------------------------------\n# Run creation\n# ---------------------------------------------------------------------------\n\ndef create_run(analysis_dir: str,\n outputs_dir: str,\n source_hashes: Dict[str, str],\n scripts: Dict[str, str],\n run_id: Optional[str] = None) -> str:\n \"\"\"Create a new timestamped run folder with outputs.\n\n Copies all files from outputs_dir into the new run folder.\n Copies forward any outputs from the previous current run that\n are not present in outputs_dir (partial run support).\n Updates the manifest: adds the run entry, sets current pointer.\n\n Args:\n analysis_dir: Path to the analysis/ directory.\n outputs_dir: Temp directory containing the new output files.\n source_hashes: Dict of source file hashes for this run.\n scripts: Dict of analysis_type -> script command used.\n run_id: Override the auto-generated run ID (for testing).\n\n Returns:\n The run ID (folder name) of the created run.\n \"\"\"\n manifest = load_manifest(analysis_dir)\n if run_id is None:\n run_id = generate_run_id(analysis_dir)\n\n run_dir = os.path.join(analysis_dir, run_id)\n os.makedirs(run_dir, exist_ok=True)\n\n # Copy forward outputs from previous current run. Per-file staleness\n # check: a carried-forward output is stale only if *its* source files\n # (by extension) appear in cur_hashes with a different hash than in\n # prev_hashes. Source files that only appear on one side (e.g.\n # re-running pcb leaves the schematic hash untouched) do not poison\n # unrelated outputs. (KH-281 follow-up: original guard was too blunt.)\n prev_run_id = manifest.get('current')\n prev_outputs = {}\n if prev_run_id and prev_run_id in manifest.get('runs', {}):\n prev_run_dir = os.path.join(analysis_dir, prev_run_id)\n prev_run_info = manifest['runs'][prev_run_id]\n prev_outputs = prev_run_info.get('outputs', {})\n prev_hashes = prev_run_info.get('source_hashes', {}) or {}\n cur_hashes = source_hashes or {}\n for analysis_type, filename in prev_outputs.items():\n exts = _OUTPUT_SOURCE_EXT.get(filename, ())\n stale = False\n if exts:\n for key, old_hash in prev_hashes.items():\n if not any(key.lower().endswith(e) for e in exts):\n continue\n new_hash = cur_hashes.get(key)\n if new_hash is not None and new_hash != old_hash:\n stale = True\n break\n if stale:\n continue\n prev_file = os.path.join(prev_run_dir, filename)\n new_file = os.path.join(run_dir, filename)\n if os.path.isfile(prev_file) and not os.path.isfile(new_file):\n shutil.copy2(prev_file, new_file)\n\n # Copy new outputs (overwrites any copied-forward files)\n new_outputs = {}\n for filename in os.listdir(outputs_dir):\n src = os.path.join(outputs_dir, filename)\n if os.path.isfile(src) and filename.endswith('.json'):\n shutil.copy2(src, os.path.join(run_dir, filename))\n # Map filename back to analysis type\n for atype, canonical in CANONICAL_OUTPUTS.items():\n if filename == canonical:\n new_outputs[atype] = filename\n break\n\n # Merge output maps: previous + new (new wins)\n merged_outputs = dict(prev_outputs)\n merged_outputs.update(new_outputs)\n\n # Update manifest\n manifest['current'] = run_id\n manifest.setdefault('runs', {})[run_id] = {\n 'source_hashes': source_hashes,\n 'outputs': merged_outputs,\n 'scripts': scripts,\n 'generated': datetime.now().astimezone().isoformat(timespec='seconds'),\n 'pinned': False,\n }\n save_manifest(analysis_dir, manifest)\n\n return run_id\n\n\ndef overwrite_current(analysis_dir: str,\n outputs_dir: str,\n source_hashes: Optional[Dict[str, str]] = None) -> None:\n \"\"\"Overwrite the current run folder with new outputs.\n\n Used when sources changed but analysis results didn't differ\n meaningfully. Updates source hashes and timestamp in the manifest\n without creating a new folder.\n\n Args:\n analysis_dir: Path to the analysis/ directory.\n outputs_dir: Temp directory containing the new output files.\n source_hashes: Updated source hashes (None = keep existing).\n \"\"\"\n manifest = load_manifest(analysis_dir)\n current_id = manifest.get('current')\n if not current_id or current_id not in manifest.get('runs', {}):\n # No current run -- fall back to creating a new run\n create_run(analysis_dir, outputs_dir,\n source_hashes=source_hashes or {},\n scripts={})\n return\n\n current_dir = os.path.join(analysis_dir, current_id)\n os.makedirs(current_dir, exist_ok=True)\n\n # Overwrite files\n for filename in os.listdir(outputs_dir):\n src = os.path.join(outputs_dir, filename)\n if os.path.isfile(src) and filename.endswith('.json'):\n shutil.copy2(src, os.path.join(current_dir, filename))\n\n # Update manifest entry\n run_entry = manifest['runs'][current_id]\n if source_hashes is not None:\n # Merge, don't replace — running pcb after schematic should keep\n # the schematic's hash alongside the pcb's, so staleness detection\n # still works against every source file contributing to the run.\n existing = run_entry.get('source_hashes', {}) or {}\n existing.update(source_hashes)\n run_entry['source_hashes'] = existing\n run_entry['generated'] = datetime.now().astimezone().isoformat(timespec='seconds')\n\n # Update outputs map for any new output types written\n outputs = run_entry.setdefault('outputs', {})\n inv_canonical = {v: k for k, v in CANONICAL_OUTPUTS.items()}\n for filename in os.listdir(current_dir):\n if filename.endswith('.json') and filename in inv_canonical:\n atype = inv_canonical[filename]\n if atype not in outputs:\n outputs[atype] = filename\n\n save_manifest(analysis_dir, manifest)\n\n\n# ---------------------------------------------------------------------------\n# Retention pruning\n# ---------------------------------------------------------------------------\n\ndef prune_runs(analysis_dir: str, retention: int = 5) -> List[str]:\n \"\"\"Delete oldest unpinned runs exceeding the retention limit.\n\n Args:\n analysis_dir: Path to the analysis/ directory.\n retention: Max unpinned runs to keep. 0 = unlimited.\n\n Returns:\n List of pruned run IDs.\n \"\"\"\n if retention \u003c= 0:\n return []\n\n manifest = load_manifest(analysis_dir)\n current_id = manifest.get('current')\n runs = manifest.get('runs', {})\n\n # Separate pinned and unpinned, sorted by generated timestamp\n unpinned = [\n (rid, meta) for rid, meta in sorted(\n runs.items(), key=lambda x: x[1].get('generated', ''))\n if not meta.get('pinned', False) and rid != current_id\n ]\n\n # Always keep current in the unpinned count\n unpinned_count = len(unpinned) + (1 if current_id and current_id in runs\n and not runs[current_id].get('pinned')\n else 0)\n\n pruned = []\n while unpinned_count > retention and unpinned:\n rid, _meta = unpinned.pop(0) # oldest first\n # Delete folder\n run_dir = os.path.join(analysis_dir, rid)\n if os.path.isdir(run_dir):\n shutil.rmtree(run_dir)\n # Remove from manifest\n del manifest['runs'][rid]\n pruned.append(rid)\n unpinned_count -= 1\n\n if pruned:\n save_manifest(analysis_dir, manifest)\n\n return pruned\n\n\n# ---------------------------------------------------------------------------\n# Pinning\n# ---------------------------------------------------------------------------\n\ndef pin_run(analysis_dir: str, run_id: str) -> None:\n \"\"\"Mark a run as pinned (survives retention pruning).\"\"\"\n manifest = load_manifest(analysis_dir)\n if run_id in manifest.get('runs', {}):\n manifest['runs'][run_id]['pinned'] = True\n save_manifest(analysis_dir, manifest)\n\n\ndef unpin_run(analysis_dir: str, run_id: str) -> None:\n \"\"\"Mark a run as unpinned.\"\"\"\n manifest = load_manifest(analysis_dir)\n if run_id in manifest.get('runs', {}):\n manifest['runs'][run_id]['pinned'] = False\n save_manifest(analysis_dir, manifest)\n\n\n# ---------------------------------------------------------------------------\n# Current run accessor\n# ---------------------------------------------------------------------------\n\ndef get_current_run(analysis_dir: str\n ) -> Optional[Tuple[str, Dict[str, Any]]]:\n \"\"\"Return (folder_path, run_metadata) for the current run.\n\n Returns None if no current run exists.\n \"\"\"\n manifest = load_manifest(analysis_dir)\n current_id = manifest.get('current')\n if not current_id or current_id not in manifest.get('runs', {}):\n return None\n run_dir = os.path.join(analysis_dir, current_id)\n return run_dir, manifest['runs'][current_id]\n\n\ndef list_runs(analysis_dir: str, limit: int = 0) -> list:\n \"\"\"Return run entries sorted newest-first.\n\n Each entry is (run_id, run_metadata_dict). If limit > 0, return at most\n that many. Excludes runs whose folders no longer exist on disk.\n \"\"\"\n manifest = load_manifest(analysis_dir)\n runs = manifest.get('runs', {})\n sorted_ids = sorted(runs.keys(), reverse=True)\n result = []\n for run_id in sorted_ids:\n run_dir = os.path.join(analysis_dir, run_id)\n if os.path.isdir(run_dir):\n result.append((run_id, runs[run_id]))\n if limit > 0 and len(result) >= limit:\n break\n return result\n\n\n# ---------------------------------------------------------------------------\n# New-run decision logic\n# ---------------------------------------------------------------------------\n\n# Severity ordering for threshold comparison\n_SEVERITY_ORDER = {'none': 0, 'minor': 1, 'major': 2, 'breaking': 3}\n\n\ndef should_create_new_run(analysis_dir: str,\n new_outputs_dir: str,\n diff_threshold: str = 'major') -> bool:\n \"\"\"Decide whether new outputs warrant a new timestamped folder.\n\n Runs diff_analysis.py on each matching output type between the\n current run and the new outputs. If any diff severity meets or\n exceeds the threshold, returns True.\n\n Returns True if:\n - No current run exists (first run)\n - diff_analysis.py finds changes at or above the threshold\n Returns False if all diffs are below the threshold.\n\n Args:\n analysis_dir: Path to the analysis/ directory.\n new_outputs_dir: Directory containing the new output JSONs.\n diff_threshold: Minimum severity to trigger a new folder.\n One of: 'minor', 'major', 'breaking'.\n \"\"\"\n current = get_current_run(analysis_dir)\n if current is None:\n return True\n\n current_dir, current_meta = current\n threshold_level = _SEVERITY_ORDER.get(diff_threshold, 2)\n\n # Try to import diff_analysis for programmatic comparison\n try:\n import diff_analysis\n except ImportError:\n # diff_analysis.py not on sys.path -- try adding our directory\n import sys\n scripts_dir = os.path.dirname(os.path.abspath(__file__))\n if scripts_dir not in sys.path:\n sys.path.insert(0, scripts_dir)\n try:\n import diff_analysis\n except ImportError:\n # Can't diff -- default to creating a new run\n return True\n\n # Compare each output type\n for filename in os.listdir(new_outputs_dir):\n if not filename.endswith('.json'):\n continue\n new_path = os.path.join(new_outputs_dir, filename)\n current_path = os.path.join(current_dir, filename)\n if not os.path.isfile(current_path):\n # New output type (e.g., pcb.json landing after a schematic-only\n # run). Extend the current run instead of spawning a duplicate\n # folder — only actual diffs vs existing outputs warrant a new run.\n continue\n\n try:\n with open(current_path) as f:\n base_data = json.load(f)\n with open(new_path) as f:\n head_data = json.load(f)\n except (json.JSONDecodeError, OSError):\n return True # Can't compare -- treat as changed\n\n # Detect analyzer type and diff\n analyzer_type = base_data.get('analyzer_type', '')\n diff_func = {\n 'schematic': getattr(diff_analysis, 'diff_schematic', None),\n 'pcb': getattr(diff_analysis, 'diff_pcb', None),\n 'emc': getattr(diff_analysis, 'diff_emc', None),\n 'spice': getattr(diff_analysis, 'diff_spice', None),\n }.get(analyzer_type)\n\n if diff_func is None:\n continue # Unknown type -- skip\n\n diff_result = diff_func(base_data, head_data, threshold=1.0)\n severity = diff_analysis.classify_severity(analyzer_type, diff_result)\n if _SEVERITY_ORDER.get(severity, 0) >= threshold_level:\n return True\n\n return False\n","content_type":"text/x-python; charset=utf-8","language":"python","size":21993,"content_sha256":"5ff86031c4a14c3a823b0ed8549148f37fb491b606e0c76e8e548a03288899e5"},{"filename":"scripts/analyze_gerbers.py","content":"#!/usr/bin/env python3\n\"\"\"\nKiCad Gerber & Drill File Analyzer — comprehensive single-pass extraction.\n\nParses Gerber RS-274X files and Excellon drill files to extract:\n- Layer identification (X2 attributes, KiCad 5 comment format, filename patterns)\n- Component list, net list, and pin-to-net connectivity (from X2 TO attributes)\n- Aperture definitions, function classification, and trace width distribution\n- Board dimensions (from Edge.Cuts extents or .gbrjob)\n- Drill hole sizes, counts, and classification (via / component / mounting)\n- Layer completeness verification (against .gbrjob expected list or defaults)\n- Layer alignment (coordinate range consistency)\n- Inner copper layer detection (4+ layer boards)\n- .gbrjob metadata (stackup, design rules, board specs)\n- SMD vs THT pad analysis, minimum feature size\n\nUsage:\n python analyze_gerbers.py \u003cgerber_directory> [--output file.json] [--compact] [--full]\n\"\"\"\n\nimport json\nimport os\nimport re\nimport sys\nimport zipfile\nfrom datetime import datetime, timezone\nfrom pathlib import Path\n\n\n_POWER_KEYWORDS_GERBER = {\"vcc\", \"vdd\", \"gnd\", \"agnd\", \"dgnd\", \"gndref\",\n \"vss\", \"avdd\", \"dvdd\", \"vbat\", \"vbus\", \"vin\"}\n\n# ---------------------------------------------------------------------------\n# Gerber parser\n# ---------------------------------------------------------------------------\n\ndef parse_gerber(path: str) -> dict:\n \"\"\"Parse a single Gerber RS-274X file with full X2 object attribute extraction.\n\n Single stateful pass extracts:\n - Format, units, coordinate range, operation counts (flash/draw/region)\n - Aperture definitions with per-aperture TA function tags\n - X2 file attributes (TF) — modern %TF...% and KiCad 5 G04 comment format\n - X2 object attributes (TO) — component refs, net names, pin mappings\n - Trace width distribution from conductor apertures\n \"\"\"\n with open(path, \"r\", encoding=\"utf-8\", errors=\"replace\") as f:\n content = f.read()\n lines = content.splitlines()\n\n result = {\n \"file\": str(path),\n \"filename\": Path(path).name,\n \"format\": None,\n \"units\": None,\n \"apertures\": {},\n \"x2_attributes\": {},\n \"flash_count\": 0,\n \"draw_count\": 0,\n \"region_count\": 0,\n \"coordinate_range\": {\"x_min\": float(\"inf\"), \"x_max\": float(\"-inf\"),\n \"y_min\": float(\"inf\"), \"y_max\": float(\"-inf\")},\n \"polarity_changes\": 0,\n \"line_count\": len(lines),\n }\n\n # --- Phase 1: regex extraction for format and units (need these for coords) ---\n\n fs_match = re.search(r\"%FS([LT])([AI])X(\\d)(\\d)Y(\\d)(\\d)\\*%\", content)\n if fs_match:\n result[\"format\"] = {\n \"zero_omit\": \"leading\" if fs_match.group(1) == \"L\" else \"trailing\",\n \"notation\": \"absolute\" if fs_match.group(2) == \"A\" else \"incremental\",\n \"x_integer\": int(fs_match.group(3)),\n \"x_decimal\": int(fs_match.group(4)),\n \"y_integer\": int(fs_match.group(5)),\n \"y_decimal\": int(fs_match.group(6)),\n }\n\n if \"%MOIN*%\" in content:\n result[\"units\"] = \"inch\"\n elif \"%MOMM*%\" in content:\n result[\"units\"] = \"mm\"\n\n # X2 file attributes — modern format: %TF.Key,Value*%\n for match in re.finditer(r\"%TF\\.(\\w+),([^*]*)\\*%\", content):\n result[\"x2_attributes\"][match.group(1)] = match.group(2)\n\n # X2 file attributes — KiCad 5 comment format: G04 #@! TF.Key,Value*\n for match in re.finditer(r\"G04 #@! TF\\.(\\w+),([^*]*)\\*\", content):\n key = match.group(1)\n if key not in result[\"x2_attributes\"]:\n result[\"x2_attributes\"][key] = match.group(2)\n\n # --- Phase 2: stateful line-by-line pass ---\n\n x_div = 10 ** result[\"format\"][\"x_decimal\"] if result[\"format\"] else 1e6\n y_div = 10 ** result[\"format\"][\"y_decimal\"] if result[\"format\"] else 1e6\n\n # Aperture function tracking (TA precedes AD, TD clears)\n pending_aper_function = None\n aperture_functions = {} # D-code -> function string\n current_aperture = None # currently selected D-code\n aperture_flash_counts = {} # D-code -> flash instance count\n\n # X2 object attribute state\n current_component = None\n current_net = None\n component_pads = {} # ref -> pad flash count\n component_nets = {} # ref -> set of net names\n net_names = set()\n net_draw_counts = {} # net_name -> draw count (D01 operations)\n net_flash_counts = {} # net_name -> flash count (D03 operations)\n pin_mappings = [] # [{ref, pin, pin_name, net}]\n\n # Aperture dimension tracking for trace width / min feature analysis\n aperture_dims = {} # D-code -> parsed dimension info\n\n for line in lines:\n s = line.strip()\n\n # -- Aperture attribute (TA) --\n m = re.match(r\"%TA\\.AperFunction,([^*]*)\\*%\", s)\n if m:\n pending_aper_function = m.group(1)\n continue\n\n # -- Aperture definition (AD) --\n m = re.match(r\"%AD(D\\d+)(\\w+),?([^*]*)\\*%\", s)\n if m:\n ap_id = m.group(1)\n ap_type = m.group(2)\n ap_params = m.group(3) or \"\"\n result[\"apertures\"][ap_id] = {\n \"type\": ap_type,\n \"params\": ap_params if ap_params else None,\n }\n if pending_aper_function:\n aperture_functions[ap_id] = pending_aper_function\n result[\"apertures\"][ap_id][\"function\"] = pending_aper_function\n # Parse dimension for trace width / feature size analysis\n dim = _parse_aperture_dimension(ap_type, ap_params, result[\"units\"])\n if dim is not None:\n aperture_dims[ap_id] = {\n \"width_mm\": dim,\n \"function\": pending_aper_function or \"\",\n }\n continue\n\n # -- Clear attributes (TD) — per X2 spec, clears ALL object attributes --\n if s == \"%TD*%\":\n pending_aper_function = None\n current_component = None\n current_net = None\n continue\n\n # -- Object attributes (TO) — component, net, pin --\n m = re.match(r\"%TO\\.C,([^*]*)\\*%\", s)\n if m:\n current_component = m.group(1)\n if current_component not in component_pads:\n component_pads[current_component] = 0\n component_nets[current_component] = set()\n continue\n\n m = re.match(r\"%TO\\.N,([^*]*)\\*%\", s)\n if m:\n current_net = m.group(1)\n net_names.add(current_net)\n if current_component and current_component in component_nets:\n component_nets[current_component].add(current_net)\n continue\n\n m = re.match(r\"%TO\\.P,([^,]*),([^,*]*)(?:,([^*]*))?\\*%\", s)\n if m:\n pin_mappings.append({\n \"ref\": m.group(1),\n \"pin\": m.group(2),\n \"pin_name\": m.group(3) or \"\",\n \"net\": current_net or \"\",\n })\n continue\n\n # -- Polarity --\n if s.startswith(\"%LP\"):\n result[\"polarity_changes\"] += 1\n continue\n\n # -- Region start --\n if s == \"G36*\":\n result[\"region_count\"] += 1\n\n # -- Aperture selection (Dnn* where nn >= 10) --\n ap_sel = re.match(r\"^(D[1-9]\\d+)\\*$\", s)\n if ap_sel:\n current_aperture = ap_sel.group(1)\n\n # -- Operations: flash (D03), draw (D01) --\n if \"D03\" in s:\n result[\"flash_count\"] += 1\n if current_aperture:\n aperture_flash_counts[current_aperture] = aperture_flash_counts.get(current_aperture, 0) + 1\n if current_component and current_component in component_pads:\n component_pads[current_component] += 1\n if current_net:\n net_flash_counts[current_net] = net_flash_counts.get(current_net, 0) + 1\n elif \"D01\" in s:\n result[\"draw_count\"] += 1\n if current_net:\n net_draw_counts[current_net] = net_draw_counts.get(current_net, 0) + 1\n\n # -- Coordinate extraction --\n cm = re.match(r\"X(-?\\d+)Y(-?\\d+)\", s)\n if cm:\n x = int(cm.group(1)) / x_div\n y = int(cm.group(2)) / y_div\n cr = result[\"coordinate_range\"]\n if x \u003c cr[\"x_min\"]: cr[\"x_min\"] = x\n if x > cr[\"x_max\"]: cr[\"x_max\"] = x\n if y \u003c cr[\"y_min\"]: cr[\"y_min\"] = y\n if y > cr[\"y_max\"]: cr[\"y_max\"] = y\n\n # Fix infinite values if no coordinates found\n for key in result[\"coordinate_range\"]:\n if result[\"coordinate_range\"][key] in (float(\"inf\"), float(\"-inf\")):\n result[\"coordinate_range\"][key] = 0\n\n # Identify layer type\n result[\"layer_type\"] = identify_layer_type(Path(path).name, result[\"x2_attributes\"])\n\n # --- Build X2 object summary ---\n has_x2_objects = bool(component_pads or net_names or pin_mappings)\n if has_x2_objects:\n # Per-net copper usage (draws = traces, flashes = pads)\n net_copper_usage = {}\n for net in net_names:\n draws = net_draw_counts.get(net, 0)\n flashes = net_flash_counts.get(net, 0)\n if draws > 0 or flashes > 0:\n net_copper_usage[net] = {\n \"draw_operations\": draws,\n \"flash_operations\": flashes,\n \"total_operations\": draws + flashes,\n }\n\n result[\"x2_objects\"] = {\n \"component_refs\": sorted(component_pads.keys()),\n \"component_pads\": {r: c for r, c in sorted(component_pads.items()) if c > 0},\n \"component_nets\": {r: sorted(ns) for r, ns in sorted(component_nets.items()) if ns},\n \"net_names\": sorted(net_names),\n \"pin_mappings\": pin_mappings,\n \"net_copper_usage\": net_copper_usage,\n }\n\n # --- Aperture analysis ---\n func_counts = {}\n conductor_widths = set()\n all_dims = []\n for ap_id, info in aperture_dims.items():\n func = info.get(\"function\", \"\")\n if func:\n base_func = func.split(\",\")[0] # \"SMDPad,CuDef\" → \"SMDPad\"\n func_counts[base_func] = func_counts.get(base_func, 0) + 1\n if \"Conductor\" in func and info[\"width_mm\"] > 0:\n conductor_widths.add(round(info[\"width_mm\"], 4))\n if info[\"width_mm\"] > 0:\n all_dims.append(info[\"width_mm\"])\n\n # Count flash instances per aperture function (KH-173: instance counts, not unique defs)\n func_flash_counts = {}\n for ap_id, info in aperture_dims.items():\n func = info.get(\"function\", \"\")\n if func:\n base_func = func.split(\",\")[0]\n flashes = aperture_flash_counts.get(ap_id, 0)\n func_flash_counts[base_func] = func_flash_counts.get(base_func, 0) + flashes\n\n if func_counts or conductor_widths or all_dims:\n result[\"aperture_analysis\"] = {}\n if func_counts:\n result[\"aperture_analysis\"][\"by_function\"] = func_counts\n if func_flash_counts:\n result[\"aperture_analysis\"][\"by_function_flashes\"] = func_flash_counts\n if conductor_widths:\n result[\"aperture_analysis\"][\"conductor_widths_mm\"] = sorted(conductor_widths)\n if all_dims:\n result[\"aperture_analysis\"][\"min_feature_mm\"] = round(min(all_dims), 4)\n\n return result\n\n\ndef _parse_aperture_dimension(ap_type: str, ap_params: str, units: str | None) -> float | None:\n \"\"\"Extract the primary dimension (mm) from an aperture definition.\n\n Returns the smallest relevant dimension (e.g., diameter for circle,\n smaller side for rectangle). Returns None if unparseable.\n \"\"\"\n if not ap_params:\n return None\n\n try:\n if ap_type == \"C\":\n # Circle: C,diameter\n dim = float(ap_params.split(\"X\")[0])\n elif ap_type == \"R\":\n # Rectangle: R,widthXheight\n parts = ap_params.split(\"X\")\n dim = min(float(parts[0]), float(parts[1]))\n elif ap_type == \"O\":\n # Obround/Oval: O,widthXheight\n parts = ap_params.split(\"X\")\n dim = min(float(parts[0]), float(parts[1]))\n elif ap_type == \"RoundRect\":\n # RoundRect macro: first param is corner radius, then 8 corner coords\n # The overall pad size requires geometry — use 2× corner radius as\n # a conservative minimum feature estimate\n parts = ap_params.split(\"X\")\n radius = float(parts[0])\n dim = radius * 2\n else:\n return None\n except (ValueError, IndexError):\n return None\n\n if dim \u003c= 0:\n return None\n\n # Convert to mm if needed\n if units == \"inch\":\n dim *= 25.4\n\n return dim\n\n\n# ---------------------------------------------------------------------------\n# Drill parser\n# ---------------------------------------------------------------------------\n\ndef parse_drill(path: str) -> dict:\n \"\"\"Parse an Excellon drill file.\"\"\"\n with open(path, \"r\", encoding=\"utf-8\", errors=\"replace\") as f:\n lines = f.readlines()\n\n result = {\n \"file\": str(path),\n \"filename\": Path(path).name,\n \"units\": None,\n \"tools\": {},\n \"hole_count\": 0,\n \"coordinate_range\": {\"x_min\": float(\"inf\"), \"x_max\": float(\"-inf\"),\n \"y_min\": float(\"inf\"), \"y_max\": float(\"-inf\")},\n \"x2_attributes\": {},\n }\n\n current_tool = None\n pending_aper_function = None\n fmt_decimals = None # from ; FORMAT={3:3/...} comment\n coord_divisor = None # set on first coordinate line\n\n for line in lines:\n line = line.strip()\n\n # Units\n if \"METRIC\" in line or \"MOMM\" in line.upper():\n result[\"units\"] = \"mm\"\n elif \"INCH\" in line:\n result[\"units\"] = \"inch\"\n\n # KiCad FORMAT comment: ; FORMAT={3:3/ absolute / metric / ...}\n fmt_match = re.match(r\";\\s*FORMAT=\\{(\\d+):(\\d+)/\", line)\n if fmt_match:\n fmt_decimals = int(fmt_match.group(2))\n\n # X2 attributes from comments: ; #@! TF.Key,Value\n tf_match = re.match(r\";\\s*#@!\\s*TF\\.(\\w+),(.*)\", line)\n if tf_match:\n result[\"x2_attributes\"][tf_match.group(1)] = tf_match.group(2).strip()\n\n # Per-tool aperture function: ; #@! TA.AperFunction,Plated,PTH,ViaDrill\n ta_match = re.match(r\";\\s*#@!\\s*TA\\.AperFunction,(.*)\", line)\n if ta_match:\n pending_aper_function = ta_match.group(1).strip()\n\n # Tool definition in header\n tool_match = re.match(r\"T(\\d+)C(\\d+\\.?\\d*)\", line)\n if tool_match:\n tool_num = f\"T{tool_match.group(1)}\"\n diameter = float(tool_match.group(2))\n if result[\"units\"] == \"inch\":\n diameter *= 25.4\n result[\"tools\"][tool_num] = {\n \"diameter_mm\": round(diameter, 4),\n \"hole_count\": 0,\n }\n if pending_aper_function:\n result[\"tools\"][tool_num][\"aper_function\"] = pending_aper_function\n pending_aper_function = None\n\n # End of header\n if line == \"%\":\n continue\n\n # Tool selection\n tool_sel = re.match(r\"^(T\\d+)$\", line)\n if tool_sel:\n current_tool = tool_sel.group(1)\n continue\n\n # Drill hit\n coord_match = re.match(r\"X(-?\\d+\\.?\\d*)Y(-?\\d+\\.?\\d*)\", line)\n if coord_match and current_tool:\n x_str = coord_match.group(1)\n y_str = coord_match.group(2)\n x = float(x_str)\n y = float(y_str)\n\n # Detect integer vs decimal format on first coordinate\n if coord_divisor is None:\n if \".\" in x_str or \".\" in y_str:\n coord_divisor = 1 # explicit decimal — no conversion\n elif fmt_decimals is not None:\n coord_divisor = 10 ** fmt_decimals\n elif result[\"units\"] == \"inch\":\n coord_divisor = 10000 # standard 2:4 format\n else:\n coord_divisor = 1000 # standard metric 3:3 format\n\n if coord_divisor > 1:\n x /= coord_divisor\n y /= coord_divisor\n\n if result[\"units\"] == \"inch\":\n x *= 25.4\n y *= 25.4\n\n result[\"hole_count\"] += 1\n if current_tool in result[\"tools\"]:\n result[\"tools\"][current_tool][\"hole_count\"] += 1\n\n cr = result[\"coordinate_range\"]\n if x \u003c cr[\"x_min\"]: cr[\"x_min\"] = x\n if x > cr[\"x_max\"]: cr[\"x_max\"] = x\n if y \u003c cr[\"y_min\"]: cr[\"y_min\"] = y\n if y > cr[\"y_max\"]: cr[\"y_max\"] = y\n\n # Fix infinite values\n for key in result[\"coordinate_range\"]:\n if result[\"coordinate_range\"][key] in (float(\"inf\"), float(\"-inf\")):\n result[\"coordinate_range\"][key] = 0\n\n # Determine PTH vs NPTH from filename or X2 FileFunction\n file_func = result[\"x2_attributes\"].get(\"FileFunction\", \"\")\n name_lower = Path(path).name.lower()\n if \"NonPlated\" in file_func or \"npth\" in name_lower:\n result[\"type\"] = \"NPTH\"\n elif \"MixedPlating\" in file_func:\n result[\"type\"] = \"mixed\"\n elif \"Plated\" in file_func or \"pth\" in name_lower:\n result[\"type\"] = \"PTH\"\n else:\n result[\"type\"] = \"unknown\"\n\n # Extract layer span from FileFunction (e.g., \"Plated,1,4,PTH\" → layers 1-4)\n ff_match = re.match(r\"(?:(?:Non)?Plated|MixedPlating),(\\d+),(\\d+)\", file_func)\n if ff_match:\n result[\"layer_span\"] = [int(ff_match.group(1)), int(ff_match.group(2))]\n\n return result\n\n\n# ---------------------------------------------------------------------------\n# Layer identification\n# ---------------------------------------------------------------------------\n\ndef identify_layer_type(filename: str, x2_attrs: dict) -> str:\n \"\"\"Identify layer type from X2 attributes or filename patterns.\"\"\"\n file_function = x2_attrs.get(\"FileFunction\", \"\").lower()\n if file_function:\n if \"copper\" in file_function:\n # Cross-check: .gko extension always means board outline, even if\n # X2 FileFunction incorrectly says copper (KiCad 8 export bug)\n if Path(filename).suffix.lower() == \".gko\":\n return \"Edge.Cuts\"\n if \"top\" in file_function:\n return \"F.Cu\"\n if \"bot\" in file_function:\n return \"B.Cu\"\n # Inner copper layers: \"copper,l2,inr\" or \"copper,l3,inr\"\n # X2 uses absolute layer position (L2 = second copper layer), but KiCad\n # names inner layers starting at In1.Cu. For a 4-layer board, L2→In1, L3→In2.\n inr_match = re.search(r\"copper,l(\\d+),inr\", file_function)\n if inr_match:\n abs_pos = int(inr_match.group(1))\n inner_idx = abs_pos - 1 # L2→In1, L3→In2, etc.\n return f\"In{inner_idx}.Cu\"\n if \"soldermask\" in file_function:\n return \"F.Mask\" if \"top\" in file_function else \"B.Mask\"\n if \"paste\" in file_function or \"solderpaste\" in file_function:\n return \"F.Paste\" if \"top\" in file_function else \"B.Paste\"\n if \"legend\" in file_function or \"silkscreen\" in file_function:\n return \"F.SilkS\" if \"top\" in file_function else \"B.SilkS\"\n if \"profile\" in file_function:\n return \"Edge.Cuts\"\n\n # Fall back to filename patterns\n name = filename.lower()\n\n # Inner copper layers (check before outer to avoid false matches)\n inner_match = re.search(r\"in(\\d+)[_.]cu\", name)\n if inner_match:\n return f\"In{inner_match.group(1)}.Cu\"\n # Protel-style inner layers: .g1, .g2, .g3, .g4, .g2l, .g3l, .g4l\n protel_inner = re.search(r\"\\.g(\\d+)l?$\", name)\n if protel_inner:\n layer_num = int(protel_inner.group(1))\n if layer_num >= 1:\n return f\"In{layer_num}.Cu\"\n\n patterns = {\n \"f_cu\": \"F.Cu\", \"f.cu\": \"F.Cu\", \"front_cu\": \"F.Cu\",\n \"b_cu\": \"B.Cu\", \"b.cu\": \"B.Cu\", \"back_cu\": \"B.Cu\",\n \"f_mask\": \"F.Mask\", \"f.mask\": \"F.Mask\",\n \"b_mask\": \"B.Mask\", \"b.mask\": \"B.Mask\",\n \"f_paste\": \"F.Paste\", \"f.paste\": \"F.Paste\",\n \"b_paste\": \"B.Paste\", \"b.paste\": \"B.Paste\",\n \"f_silkscreen\": \"F.SilkS\", \"f_silks\": \"F.SilkS\", \"f.silks\": \"F.SilkS\",\n \"b_silkscreen\": \"B.SilkS\", \"b_silks\": \"B.SilkS\", \"b.silks\": \"B.SilkS\",\n \"edge_cuts\": \"Edge.Cuts\", \"edge.cuts\": \"Edge.Cuts\",\n }\n for pattern, layer in patterns.items():\n if pattern in name:\n return layer\n\n # Protel-style outer extensions\n ext = Path(filename).suffix.lower()\n ext_map = {\n \".gtl\": \"F.Cu\", \".gbl\": \"B.Cu\",\n \".gts\": \"F.Mask\", \".gbs\": \"B.Mask\",\n \".gtp\": \"F.Paste\", \".gbp\": \"B.Paste\",\n \".gto\": \"F.SilkS\", \".gbo\": \"B.SilkS\",\n \".gm1\": \"Edge.Cuts\", \".gko\": \"Edge.Cuts\",\n }\n if ext in ext_map:\n return ext_map[ext]\n\n return \"unknown\"\n\n\n# ---------------------------------------------------------------------------\n# Job file parser\n# ---------------------------------------------------------------------------\n\ndef parse_job_file(path: str) -> dict | None:\n \"\"\"Parse a .gbrjob file and extract structured metadata.\"\"\"\n try:\n with open(path, \"r\") as f:\n data = json.load(f)\n except (json.JSONDecodeError, OSError):\n return None\n\n result = {}\n\n header = data.get(\"Header\", {})\n gen_sw = header.get(\"GenerationSoftware\", {})\n if gen_sw:\n result[\"generator\"] = f\"{gen_sw.get('Application', '')} {gen_sw.get('Version', '')}\".strip()\n result[\"vendor\"] = gen_sw.get(\"Vendor\", \"\")\n result[\"creation_date\"] = header.get(\"CreationDate\", \"\")\n\n specs = data.get(\"GeneralSpecs\", {})\n if specs:\n size = specs.get(\"Size\", {})\n result[\"board_width_mm\"] = size.get(\"X\", 0)\n result[\"board_height_mm\"] = size.get(\"Y\", 0)\n result[\"layer_count\"] = specs.get(\"LayerNumber\", 0)\n result[\"board_thickness_mm\"] = specs.get(\"BoardThickness\", 0)\n result[\"finish\"] = specs.get(\"Finish\", \"\")\n project = specs.get(\"ProjectId\", {})\n if project:\n result[\"project_name\"] = project.get(\"Name\", \"\")\n\n rules = data.get(\"DesignRules\", [])\n if rules:\n result[\"design_rules\"] = []\n for rule in rules:\n entry = {\"layers\": rule.get(\"Layers\", \"\")}\n for key in (\"PadToPad\", \"PadToTrack\", \"TrackToTrack\",\n \"MinLineWidth\", \"TrackToRegion\", \"RegionToRegion\"):\n if key in rule:\n entry[key] = rule[key]\n result[\"design_rules\"].append(entry)\n\n files = data.get(\"FilesAttributes\", [])\n if files:\n result[\"expected_files\"] = []\n for f in files:\n result[\"expected_files\"].append({\n \"path\": f.get(\"Path\", \"\"),\n \"function\": f.get(\"FileFunction\", \"\"),\n \"polarity\": f.get(\"FilePolarity\", \"\"),\n })\n\n stackup = data.get(\"MaterialStackup\", [])\n if stackup:\n result[\"stackup\"] = []\n for layer in stackup:\n entry = {\"type\": layer.get(\"Type\", \"\"), \"name\": layer.get(\"Name\", \"\")}\n if \"Thickness\" in layer:\n entry[\"thickness_mm\"] = layer[\"Thickness\"]\n if \"Material\" in layer:\n entry[\"material\"] = layer[\"Material\"]\n result[\"stackup\"].append(entry)\n\n return result\n\n\n# ---------------------------------------------------------------------------\n# Analysis functions\n# ---------------------------------------------------------------------------\n\ndef classify_drill_tools(drills: list[dict]) -> dict:\n \"\"\"Classify drill holes by function: vias, component pins, mounting holes.\"\"\"\n via_count = 0\n component_count = 0\n mounting_count = 0\n\n via_tools = []\n component_tools = []\n mounting_tools = []\n\n for d in drills:\n for tool_name, tool_info in d.get(\"tools\", {}).items():\n count = tool_info[\"hole_count\"]\n dia = tool_info[\"diameter_mm\"]\n aper = tool_info.get(\"aper_function\", \"\")\n\n if d.get(\"type\") == \"NPTH\":\n # Check per-tool X2 aper_function first\n if \"ViaDrill\" in aper:\n via_count += count\n if count > 0:\n via_tools.append({\"diameter_mm\": dia, \"count\": count})\n elif \"ComponentDrill\" in aper:\n # KH-186: KiCad labels all NPTH as ComponentDrill;\n # large NPTH holes (>= 2.5mm) are mounting/standoff holes\n if dia >= 2.5:\n mounting_count += count\n if count > 0:\n mounting_tools.append({\"diameter_mm\": dia, \"count\": count, \"type\": \"NPTH\"})\n else:\n component_count += count\n if count > 0:\n component_tools.append({\"diameter_mm\": dia, \"count\": count, \"type\": \"NPTH\"})\n else:\n # NPTH heuristic: small holes are component alignment pins\n if dia \u003c= 2.0:\n component_count += count\n if count > 0:\n component_tools.append({\"diameter_mm\": dia, \"count\": count, \"type\": \"NPTH\"})\n else:\n mounting_count += count\n if count > 0:\n mounting_tools.append({\"diameter_mm\": dia, \"count\": count, \"type\": \"NPTH\"})\n continue\n\n if \"ViaDrill\" in aper:\n via_count += count\n if count > 0:\n via_tools.append({\"diameter_mm\": dia, \"count\": count})\n elif \"ComponentDrill\" in aper:\n # KH-186: large non-plated holes are mounting regardless of X2\n if \"NonPlated\" in aper and dia >= 2.5:\n mounting_count += count\n if count > 0:\n mounting_tools.append({\"diameter_mm\": dia, \"count\": count, \"type\": \"NPTH\"})\n else:\n component_count += count\n if count > 0:\n component_tools.append({\"diameter_mm\": dia, \"count\": count})\n else:\n # Heuristic fallback\n if dia \u003c= 0.45:\n via_count += count\n if count > 0:\n via_tools.append({\"diameter_mm\": dia, \"count\": count})\n elif dia \u003c= 1.3:\n component_count += count\n if count > 0:\n component_tools.append({\"diameter_mm\": dia, \"count\": count})\n else:\n mounting_count += count\n if count > 0:\n mounting_tools.append({\"diameter_mm\": dia, \"count\": count, \"type\": \"PTH\"})\n\n return {\n \"vias\": {\"count\": via_count, \"tools\": sorted(via_tools, key=lambda t: t[\"diameter_mm\"])},\n \"component_holes\": {\"count\": component_count, \"tools\": sorted(component_tools, key=lambda t: t[\"diameter_mm\"])},\n \"mounting_holes\": {\"count\": mounting_count, \"tools\": sorted(mounting_tools, key=lambda t: t[\"diameter_mm\"])},\n \"classification_method\": \"x2_attributes\" if any(\n \"aper_function\" in ti\n for d in drills for ti in d.get(\"tools\", {}).values()\n ) else \"diameter_heuristic\",\n }\n\n\ndef check_completeness(gerbers: list[dict], drills: list[dict],\n job_info: dict | None = None) -> dict:\n \"\"\"Check if all expected layers are present.\"\"\"\n found_layers = set()\n for g in gerbers:\n lt = g.get(\"layer_type\")\n if lt and lt != \"unknown\":\n found_layers.add(lt)\n\n expected_from_job = set()\n if job_info and \"expected_files\" in job_info:\n for ef in job_info[\"expected_files\"]:\n lt = identify_layer_type(ef[\"path\"], {\"FileFunction\": ef[\"function\"]})\n if lt != \"unknown\":\n expected_from_job.add(lt)\n\n if expected_from_job:\n missing = expected_from_job - found_layers\n extra = found_layers - expected_from_job\n return {\n \"found_layers\": sorted(found_layers),\n \"expected_layers\": sorted(expected_from_job),\n \"missing\": sorted(missing),\n \"extra\": sorted(extra),\n \"has_pth_drill\": any(d.get(\"type\") in (\"PTH\", \"mixed\") for d in drills),\n \"has_npth_drill\": any(d.get(\"type\") in (\"NPTH\", \"mixed\") for d in drills),\n \"complete\": len(missing) == 0,\n \"source\": \"gbrjob\",\n }\n\n inner_layers = {lt for lt in found_layers if lt.startswith(\"In\") and lt.endswith(\".Cu\")}\n required = {\"F.Cu\", \"B.Cu\", \"F.Mask\", \"B.Mask\", \"Edge.Cuts\"} | inner_layers\n recommended = {\"F.SilkS\", \"F.Paste\"}\n\n return {\n \"found_layers\": sorted(found_layers),\n \"missing_required\": sorted(required - found_layers),\n \"missing_recommended\": sorted(recommended - found_layers),\n \"has_pth_drill\": any(d.get(\"type\") in (\"PTH\", \"mixed\") for d in drills),\n \"has_npth_drill\": any(d.get(\"type\") in (\"NPTH\", \"mixed\") for d in drills),\n \"complete\": len(required - found_layers) == 0 and any(\n d.get(\"type\") in (\"PTH\", \"mixed\", \"unknown\") for d in drills),\n \"source\": \"defaults\",\n }\n\n\ndef check_alignment(gerbers: list[dict], drills: list[dict]) -> dict:\n \"\"\"Check that all layers have consistent coordinate ranges.\"\"\"\n ranges = {}\n for g in gerbers:\n lt = g.get(\"layer_type\", \"unknown\")\n if lt != \"unknown\":\n cr = g[\"coordinate_range\"]\n ranges[lt] = {\n \"width\": round(cr[\"x_max\"] - cr[\"x_min\"], 3),\n \"height\": round(cr[\"y_max\"] - cr[\"y_min\"], 3),\n }\n for d in drills:\n cr = d[\"coordinate_range\"]\n ranges[f\"drill_{d.get('type', 'unknown')}\"] = {\n \"width\": round(cr[\"x_max\"] - cr[\"x_min\"], 3),\n \"height\": round(cr[\"y_max\"] - cr[\"y_min\"], 3),\n }\n\n alignment_layers = {k: v for k, v in ranges.items()\n if k in (\"F.Cu\", \"B.Cu\", \"Edge.Cuts\") or\n (k.startswith(\"In\") and k.endswith(\".Cu\"))}\n widths = [r[\"width\"] for r in alignment_layers.values() if r[\"width\"] > 0]\n heights = [r[\"height\"] for r in alignment_layers.values() if r[\"height\"] > 0]\n\n # Use relative threshold: 5% of Edge.Cuts dimension, minimum 2.0mm\n edge = alignment_layers.get(\"Edge.Cuts\")\n threshold_w = max(2.0, edge[\"width\"] * 0.05) if edge and edge[\"width\"] > 0 else 2.0\n threshold_h = max(2.0, edge[\"height\"] * 0.05) if edge and edge[\"height\"] > 0 else 2.0\n\n aligned = True\n issues = []\n if widths and max(widths) - min(widths) > threshold_w:\n aligned = False\n issues.append(f\"Width varies by {max(widths) - min(widths):.1f}mm across copper/edge layers\")\n if heights and max(heights) - min(heights) > threshold_h:\n aligned = False\n issues.append(f\"Height varies by {max(heights) - min(heights):.1f}mm across copper/edge layers\")\n\n return {\"aligned\": aligned, \"issues\": issues, \"layer_extents\": ranges}\n\n\ndef compute_board_dimensions(gerbers: list[dict], job_info: dict | None) -> dict:\n \"\"\"Compute board dimensions from .gbrjob or Edge.Cuts extents.\"\"\"\n if job_info:\n w = job_info.get(\"board_width_mm\", 0)\n h = job_info.get(\"board_height_mm\", 0)\n if w > 0 and h > 0:\n return {\"width_mm\": round(w, 2), \"height_mm\": round(h, 2),\n \"area_mm2\": round(w * h, 1), \"source\": \"gbrjob\"}\n\n for g in gerbers:\n if g.get(\"layer_type\") == \"Edge.Cuts\":\n cr = g[\"coordinate_range\"]\n w = cr[\"x_max\"] - cr[\"x_min\"]\n h = cr[\"y_max\"] - cr[\"y_min\"]\n # Convert inches to mm if gerber uses MOIN units\n if g.get(\"units\") == \"inch\":\n w *= 25.4\n h *= 25.4\n if w > 0 and h > 0:\n return {\"width_mm\": round(w, 2), \"height_mm\": round(h, 2),\n \"area_mm2\": round(w * h, 1), \"source\": \"edge_cuts_extents\"}\n return {}\n\n\ndef build_component_analysis(gerbers: list[dict], drills: list[dict]) -> dict | None:\n \"\"\"Merge component/net/pin data across all gerber layers.\n\n Only produces output when X2 TO attributes are present (KiCad 6+).\n \"\"\"\n all_refs = set()\n front_refs = set()\n back_refs = set()\n all_nets = set()\n all_pins = []\n component_pads = {}\n component_nets = {}\n seen_pins = set() # dedup (ref, pin) across layers\n\n for g in gerbers:\n x2o = g.get(\"x2_objects\")\n if not x2o:\n continue\n\n layer_type = g.get(\"layer_type\", \"\")\n refs = set(x2o.get(\"component_refs\", []))\n all_refs |= refs\n\n # Determine component side from mask/silk/paste/copper layers\n if layer_type in (\"F.Cu\", \"F.Mask\", \"F.SilkS\", \"F.Paste\"):\n front_refs |= refs\n elif layer_type in (\"B.Cu\", \"B.Mask\", \"B.SilkS\", \"B.Paste\"):\n back_refs |= refs\n\n all_nets |= set(x2o.get(\"net_names\", []))\n\n # Merge pads (take max per ref since same pad appears on mask/paste too)\n for ref, count in x2o.get(\"component_pads\", {}).items():\n component_pads[ref] = max(component_pads.get(ref, 0), count)\n\n # Merge nets per component\n for ref, nets in x2o.get(\"component_nets\", {}).items():\n if ref not in component_nets:\n component_nets[ref] = set()\n component_nets[ref].update(nets)\n\n # Merge pin mappings (dedup by ref+pin)\n for pm in x2o.get(\"pin_mappings\", []):\n key = (pm[\"ref\"], pm[\"pin\"])\n if key not in seen_pins:\n seen_pins.add(key)\n all_pins.append(pm)\n\n if not all_refs:\n return None\n\n # Classify nets\n power_prefixes = (\"+\", \"-\")\n power_nets = set()\n signal_nets = set()\n unnamed_nets = 0\n for n in all_nets:\n nl = n.lower()\n if nl in _POWER_KEYWORDS_GERBER or n.startswith(power_prefixes) or nl.startswith(\"vcc\") or nl.startswith(\"vdd\"):\n power_nets.add(n)\n elif n.startswith(\"Net-(\") or n.startswith(\"unconnected-(\"):\n unnamed_nets += 1\n else:\n signal_nets.add(n)\n\n # Components that are only on back\n back_only = back_refs - front_refs\n\n result = {\n \"total_unique\": len(all_refs),\n \"front_side\": len(front_refs),\n \"back_side\": len(back_only),\n \"component_refs\": sorted(all_refs),\n \"has_x2_data\": True,\n }\n\n if component_pads:\n result[\"pads_per_component\"] = {r: c for r, c in sorted(component_pads.items())}\n result[\"total_pads\"] = sum(component_pads.values())\n\n return result\n\n\ndef build_net_analysis(gerbers: list[dict]) -> dict | None:\n \"\"\"Merge net data across copper layers.\"\"\"\n all_nets = set()\n all_pins = []\n seen_pins = set()\n\n for g in gerbers:\n x2o = g.get(\"x2_objects\")\n if not x2o:\n continue\n lt = g.get(\"layer_type\", \"\")\n if not lt.endswith(\".Cu\"):\n continue # nets only meaningful from copper layers\n all_nets |= set(x2o.get(\"net_names\", []))\n for pm in x2o.get(\"pin_mappings\", []):\n key = (pm[\"ref\"], pm[\"pin\"])\n if key not in seen_pins:\n seen_pins.add(key)\n all_pins.append(pm)\n\n if not all_nets:\n return None\n\n # Classify\n power_nets = []\n signal_nets = []\n unnamed_count = 0\n for n in sorted(all_nets):\n nl = n.lower()\n if (nl in _POWER_KEYWORDS_GERBER or n.startswith((\"+\", \"-\"))\n or nl.startswith((\"vcc\", \"vdd\", \"vss\"))):\n power_nets.append(n)\n elif n.startswith(\"Net-(\") or n.startswith(\"unconnected-(\"):\n unnamed_count += 1\n else:\n signal_nets.append(n)\n\n return {\n \"total_unique\": len(all_nets),\n \"named_nets\": len(all_nets) - unnamed_count,\n \"unnamed_nets\": unnamed_count,\n \"power_nets\": power_nets,\n \"signal_nets\": signal_nets,\n \"total_pins\": len(all_pins),\n }\n\n\ndef build_trace_analysis(gerbers: list[dict]) -> dict | None:\n \"\"\"Extract trace width distribution and minimum feature size across copper layers.\"\"\"\n conductor_widths = set()\n min_feature = float(\"inf\")\n\n for g in gerbers:\n aa = g.get(\"aperture_analysis\")\n if not aa:\n continue\n lt = g.get(\"layer_type\", \"\")\n if not lt.endswith(\".Cu\"):\n continue\n for w in aa.get(\"conductor_widths_mm\", []):\n conductor_widths.add(w)\n mf = aa.get(\"min_feature_mm\")\n if mf is not None and mf \u003c min_feature:\n min_feature = mf\n\n if not conductor_widths:\n return None\n\n widths = sorted(conductor_widths)\n return {\n \"unique_widths_mm\": widths,\n \"min_trace_mm\": widths[0],\n \"max_trace_mm\": widths[-1],\n \"width_count\": len(widths),\n \"min_feature_mm\": round(min_feature, 4) if min_feature \u003c float(\"inf\") else None,\n }\n\n\ndef build_pad_summary(gerbers: list[dict], drill_class: dict) -> dict:\n \"\"\"Summarize pad types across layers: SMD, via, heatsink, THT.\"\"\"\n smd = 0\n via = 0\n heatsink = 0\n\n for g in gerbers:\n aa = g.get(\"aperture_analysis\", {})\n # KH-173: prefer by_function_flashes (instance counts) over by_function (unique defs)\n bf = aa.get(\"by_function_flashes\") or aa.get(\"by_function\", {})\n lt = g.get(\"layer_type\", \"\")\n if not lt.endswith(\".Cu\"):\n continue\n smd += bf.get(\"SMDPad\", 0)\n via += bf.get(\"ViaPad\", 0)\n heatsink += bf.get(\"HeatsinkPad\", 0)\n\n # Fallback: when no X2 aperture functions, estimate SMD pad count from\n # paste layer flashes (paste layers only contain SMD pad openings)\n smd_source = \"x2_aperture_function\"\n if smd == 0:\n paste_flashes = 0\n for g in gerbers:\n lt = g.get(\"layer_type\", \"\")\n if lt in (\"F.Paste\", \"B.Paste\"):\n paste_flashes += g.get(\"flash_count\", 0)\n if paste_flashes > 0:\n smd = paste_flashes\n smd_source = \"paste_layer_flashes\"\n\n tht = drill_class.get(\"component_holes\", {}).get(\"count\", 0)\n\n result = {\n \"smd_apertures\": smd,\n \"via_apertures\": via,\n \"heatsink_apertures\": heatsink,\n \"tht_holes\": tht,\n \"smd_source\": smd_source,\n }\n if smd + tht > 0:\n result[\"smd_ratio\"] = round(smd / (smd + tht), 2)\n\n return result\n\n\n# ---------------------------------------------------------------------------\n# Zip archive scanning\n# ---------------------------------------------------------------------------\n\n_GERBER_EXTENSIONS = frozenset({\n \".gbr\", \".gtl\", \".gbl\", \".gts\", \".gbs\", \".gtp\", \".gbp\",\n \".gto\", \".gbo\", \".gko\", \".gm1\",\n \".g1\", \".g2\", \".g3\", \".g4\",\n \".drl\", \".gbrjob\",\n})\n\n\ndef scan_zip_archives(gerber_dir: Path, loose_gerber_files: list[Path],\n loose_drill_files: list[Path]) -> list[dict] | None:\n \"\"\"Scan zip files for gerber/drill contents and compare against loose files.\n\n Reports each zip's contents, modification time, and how its timestamp\n compares to the loose (unzipped) gerber files. This helps catch stale\n archives that don't reflect the current design, or stale loose files\n left over from an old unzip.\n \"\"\"\n zip_files = sorted(gerber_dir.glob(\"*.zip\")) + sorted(gerber_dir.glob(\"*.ZIP\"))\n zip_files = sorted(set(zip_files))\n if not zip_files:\n return None\n\n # Latest mtime among loose gerber/drill files\n loose_files = list(loose_gerber_files) + list(loose_drill_files)\n loose_mtimes = []\n for f in loose_files:\n try:\n loose_mtimes.append(f.stat().st_mtime)\n except OSError:\n pass\n latest_loose_mtime = max(loose_mtimes) if loose_mtimes else None\n\n results = []\n for zf_path in zip_files:\n entry: dict = {\n \"filename\": zf_path.name,\n \"size_bytes\": zf_path.stat().st_size,\n }\n\n try:\n zf_mtime = zf_path.stat().st_mtime\n entry[\"modified\"] = datetime.fromtimestamp(\n zf_mtime, tz=timezone.utc\n ).strftime(\"%Y-%m-%dT%H:%M:%SZ\")\n except OSError:\n zf_mtime = None\n\n try:\n with zipfile.ZipFile(zf_path, \"r\") as zf:\n members = zf.namelist()\n gerber_members = []\n drill_members = []\n other_members = []\n for name in members:\n ext = Path(name).suffix.lower()\n if ext == \".drl\":\n drill_members.append(name)\n elif ext in _GERBER_EXTENSIONS:\n gerber_members.append(name)\n else:\n other_members.append(name)\n\n entry[\"total_files\"] = len(members)\n entry[\"gerber_files\"] = len(gerber_members)\n entry[\"drill_files\"] = len(drill_members)\n entry[\"other_files\"] = len(other_members)\n\n # Latest file date inside the zip (from zip directory entries)\n latest_inside = None\n for info in zf.infolist():\n try:\n dt = datetime(*info.date_time, tzinfo=timezone.utc)\n ts = dt.timestamp()\n if latest_inside is None or ts > latest_inside:\n latest_inside = ts\n except (ValueError, OSError):\n pass\n\n if latest_inside is not None:\n entry[\"newest_member_date\"] = datetime.fromtimestamp(\n latest_inside, tz=timezone.utc\n ).strftime(\"%Y-%m-%dT%H:%M:%SZ\")\n\n # Compare zip contents date vs loose files\n compare_ts = latest_inside if latest_inside is not None else zf_mtime\n if compare_ts is not None and latest_loose_mtime is not None:\n delta_seconds = latest_loose_mtime - compare_ts\n if delta_seconds > 60:\n entry[\"vs_loose_files\"] = \"loose_files_newer\"\n entry[\"age_delta_hours\"] = round(delta_seconds / 3600, 1)\n elif delta_seconds \u003c -60:\n entry[\"vs_loose_files\"] = \"archive_newer\"\n entry[\"age_delta_hours\"] = round(-delta_seconds / 3600, 1)\n else:\n entry[\"vs_loose_files\"] = \"same_age\"\n\n except zipfile.BadZipFile:\n entry[\"error\"] = \"not a valid zip file\"\n except (OSError, ValueError, KeyError, TypeError) as e:\n entry[\"error\"] = str(e)\n\n results.append(entry)\n\n return results\n\n\n# ---------------------------------------------------------------------------\n# Main analysis\n# ---------------------------------------------------------------------------\n\ndef _is_excellon_file(path: Path) -> bool:\n \"\"\"Check if a file is likely an Excellon drill file by header inspection.\"\"\"\n try:\n with open(path, \"r\", encoding=\"utf-8\", errors=\"replace\") as f:\n head = f.read(1024)\n return \"M48\" in head or re.search(r\"T\\d+C\\d\", head) is not None\n except (OSError, UnicodeDecodeError):\n return False\n\n\ndef analyze_gerbers(directory: str, full: bool = False) -> dict:\n \"\"\"Main analysis function for a gerber directory.\"\"\"\n gerber_dir = Path(directory)\n\n # Find all gerber and drill files\n gerber_files = sorted(gerber_dir.glob(\"*.gbr\")) + sorted(gerber_dir.glob(\"*.g*\"))\n drill_files = sorted(gerber_dir.glob(\"*.drl\"))\n job_files = sorted(gerber_dir.glob(\"*.gbrjob\"))\n\n # Also pick up uppercase extensions\n for ext in (\"*.GBR\", \"*.GTL\", \"*.GBL\", \"*.GTS\", \"*.GBS\", \"*.GTO\", \"*.GBO\",\n \"*.GKO\", \"*.GM1\", \"*.G1\", \"*.G2\", \"*.G3\", \"*.G4\",\n \"*.G2L\", \"*.G3L\", \"*.G4L\", \"*.G5L\", \"*.G6L\",\n \"*.GTP\", \"*.GBP\", \"*.DRL\"):\n if ext == \"*.DRL\":\n drill_files.extend(sorted(gerber_dir.glob(ext)))\n else:\n gerber_files.extend(sorted(gerber_dir.glob(ext)))\n\n # Eagle outputs drill files with .TXT extension — validate header before including\n drill_set = set(drill_files)\n for txt_ext in (\"*.TXT\", \"*.txt\"):\n for txt_path in gerber_dir.glob(txt_ext):\n if txt_path not in drill_set and _is_excellon_file(txt_path):\n drill_files.append(txt_path)\n\n gerber_files = sorted(set(gerber_files))\n gerber_files = [f for f in gerber_files\n if f.suffix.lower() not in (\".drl\", \".gbrjob\", \".zip\", \".pos\", \".txt\")]\n drill_files = sorted(set(drill_files))\n\n # Scan zip archives before parsing (uses file lists for timestamp comparison)\n zip_scan = scan_zip_archives(gerber_dir, gerber_files, drill_files)\n\n # Parse\n gerbers = []\n for gf in gerber_files:\n try:\n gerbers.append(parse_gerber(str(gf)))\n except (OSError, ValueError, UnicodeDecodeError) as e:\n gerbers.append({\"file\": str(gf), \"filename\": gf.name, \"error\": str(e),\n \"layer_type\": \"unknown\"})\n\n drills = []\n for df in drill_files:\n try:\n drills.append(parse_drill(str(df)))\n except (OSError, ValueError, UnicodeDecodeError) as e:\n drills.append({\"file\": str(df), \"filename\": df.name, \"error\": str(e)})\n\n job_info = None\n if job_files:\n job_info = parse_job_file(str(job_files[0]))\n\n # Layer count\n copper_layers = [g for g in gerbers\n if g.get(\"layer_type\", \"\").endswith(\".Cu\") and \"error\" not in g]\n layer_count = len(copper_layers)\n if job_info and job_info.get(\"layer_count\"):\n layer_count = max(layer_count, job_info[\"layer_count\"])\n for d in drills:\n span = d.get(\"layer_span\")\n if span:\n layer_count = max(layer_count, span[1])\n # Infer layer count from X2 FileFunction Ln designation (e.g., \"Copper,L4,Bot\")\n for g in gerbers:\n ff = g.get(\"x2_attributes\", {}).get(\"FileFunction\", \"\")\n ln_match = re.search(r\"Copper,L(\\d+)\", ff, re.IGNORECASE)\n if ln_match:\n layer_count = max(layer_count, int(ln_match.group(1)))\n\n # Classify drills first — needed by completeness check (KH-184)\n drill_classification = classify_drill_tools(drills)\n\n # KH-184: Infer PTH from drill classification when type is unknown\n if drill_classification.get(\"vias\", {}).get(\"count\", 0) > 0:\n for d in drills:\n if d.get(\"type\") == \"unknown\":\n d[\"type\"] = \"PTH\"\n\n # Run checks\n completeness = check_completeness(gerbers, drills, job_info)\n alignment = check_alignment(gerbers, drills)\n board_dims = compute_board_dimensions(gerbers, job_info)\n\n # Higher-level analyses\n component_analysis = build_component_analysis(gerbers, drills)\n net_analysis = build_net_analysis(gerbers)\n trace_analysis = build_trace_analysis(gerbers)\n pad_summary = build_pad_summary(gerbers, drill_classification)\n\n # Statistics\n total_holes = sum(d.get(\"hole_count\", 0) for d in drills)\n total_flashes = sum(g.get(\"flash_count\", 0) for g in gerbers)\n total_draws = sum(g.get(\"draw_count\", 0) for g in gerbers)\n\n # Drill tools summary\n all_tools = {}\n for d in drills:\n for tool_name, tool_info in d.get(\"tools\", {}).items():\n key = f\"{tool_info['diameter_mm']}mm\"\n if key not in all_tools:\n all_tools[key] = {\"diameter_mm\": tool_info[\"diameter_mm\"],\n \"count\": 0, \"type\": d.get(\"type\", \"\")}\n all_tools[key][\"count\"] += tool_info[\"hole_count\"]\n\n # Gerber summary (compact — strip raw data)\n gerber_summary = []\n for g in gerbers:\n entry = {\n \"filename\": g.get(\"filename\", \"\"),\n \"layer_type\": g.get(\"layer_type\", \"unknown\"),\n \"units\": g.get(\"units\", \"\"),\n \"aperture_count\": len(g.get(\"apertures\", {})),\n \"flash_count\": g.get(\"flash_count\", 0),\n \"draw_count\": g.get(\"draw_count\", 0),\n \"region_count\": g.get(\"region_count\", 0),\n }\n x2 = g.get(\"x2_attributes\", {})\n if x2:\n entry[\"x2_attributes\"] = x2\n aa = g.get(\"aperture_analysis\")\n if aa:\n entry[\"aperture_analysis\"] = aa\n # Component/net counts per layer (compact summary)\n x2o = g.get(\"x2_objects\")\n if x2o:\n entry[\"x2_component_count\"] = len(x2o.get(\"component_refs\", []))\n entry[\"x2_net_count\"] = len(x2o.get(\"net_names\", []))\n entry[\"x2_pin_count\"] = len(x2o.get(\"pin_mappings\", []))\n if \"error\" in g:\n entry[\"error\"] = g[\"error\"]\n gerber_summary.append(entry)\n\n # Drill summary\n drill_summary = []\n for d in drills:\n entry = {\n \"filename\": d.get(\"filename\", \"\"),\n \"type\": d.get(\"type\", \"unknown\"),\n \"units\": d.get(\"units\", \"\"),\n \"hole_count\": d.get(\"hole_count\", 0),\n \"tools\": d.get(\"tools\", {}),\n }\n if d.get(\"layer_span\"):\n entry[\"layer_span\"] = d[\"layer_span\"]\n x2 = d.get(\"x2_attributes\", {})\n if x2:\n entry[\"x2_attributes\"] = x2\n if \"error\" in d:\n entry[\"error\"] = d[\"error\"]\n drill_summary.append(entry)\n\n # Generator\n generator = None\n if job_info and job_info.get(\"generator\"):\n generator = job_info[\"generator\"]\n else:\n for g in gerbers:\n gen = g.get(\"x2_attributes\", {}).get(\"GenerationSoftware\", \"\")\n if gen:\n generator = gen\n break\n\n result = {\n \"analyzer_type\": \"gerber\",\n \"schema_version\": \"1.3.0\",\n \"directory\": str(directory),\n \"generator\": generator,\n \"layer_count\": layer_count,\n \"board_dimensions\": board_dims,\n \"statistics\": {\n \"gerber_files\": len(gerbers),\n \"drill_files\": len(drills),\n \"total_holes\": total_holes,\n \"total_flashes\": total_flashes,\n \"total_draws\": total_draws,\n },\n \"completeness\": completeness,\n \"alignment\": alignment,\n \"drill_classification\": drill_classification,\n \"pad_summary\": pad_summary,\n }\n\n if trace_analysis:\n result[\"trace_widths\"] = trace_analysis\n if component_analysis:\n result[\"component_analysis\"] = component_analysis\n if net_analysis:\n result[\"net_analysis\"] = net_analysis\n\n result[\"gerbers\"] = gerber_summary\n result[\"drills\"] = drill_summary\n result[\"drill_tools\"] = all_tools\n\n if job_info:\n result[\"job_file\"] = job_info\n\n if zip_scan:\n result[\"zip_archives\"] = zip_scan\n\n findings = _build_gerber_findings(\n completeness, alignment, drill_classification,\n gerber_summary, drill_summary, result[\"statistics\"])\n result[\"findings\"] = findings\n\n sev_counts = {}\n for f in findings:\n sev = f.get(\"severity\", \"info\")\n sev_counts[sev] = sev_counts.get(sev, 0) + 1\n result[\"summary\"] = {\n \"total_findings\": len(findings),\n \"by_severity\": {\n \"error\": sev_counts.get(\"error\", 0),\n \"warning\": sev_counts.get(\"warning\", 0),\n \"info\": sev_counts.get(\"info\", 0),\n },\n }\n from finding_schema import compute_trust_summary\n result[\"trust_summary\"] = compute_trust_summary(findings)\n\n # Full mode: include raw pin-to-net connectivity\n if full and any(g.get(\"x2_objects\") for g in gerbers):\n all_pins = []\n seen = set()\n for g in gerbers:\n x2o = g.get(\"x2_objects\")\n if not x2o:\n continue\n for pm in x2o.get(\"pin_mappings\", []):\n key = (pm[\"ref\"], pm[\"pin\"])\n if key not in seen:\n seen.add(key)\n all_pins.append(pm)\n if all_pins:\n result[\"connectivity\"] = sorted(all_pins, key=lambda p: (p[\"ref\"], p[\"pin\"]))\n\n return result\n\n\ndef _build_gerber_findings(completeness, alignment, drill_classification,\n gerber_summary, drills, statistics) -> list:\n \"\"\"Build rich findings from gerber analysis data.\"\"\"\n findings = []\n\n # GR-001: Missing layers\n required_layers = {'F.Cu', 'B.Cu', 'Edge.Cuts'}\n if completeness.get('source') == 'gbrjob':\n missing = completeness.get('missing', [])\n else:\n missing = completeness.get('missing_required', []) + completeness.get('missing_recommended', [])\n required_layers.update(completeness.get('missing_required', []))\n\n for layer in missing:\n is_required = layer in required_layers\n findings.append({\n 'detector': 'analyze_gerbers',\n 'rule_id': 'GR-001',\n 'category': 'gerber_completeness',\n 'severity': 'warning' if is_required else 'info',\n 'confidence': 'deterministic',\n 'evidence_source': 'topology',\n 'summary': f'Missing layer: {layer}',\n 'description': f'Expected {\"required\" if is_required else \"recommended\"} layer {layer} not found in gerber set.',\n 'components': [],\n 'nets': [],\n 'pins': [],\n 'recommendation': f'Add {layer} to gerber export.',\n 'report_context': {'section': 'Gerber Completeness', 'impact': 'Fab may reject' if is_required else 'Assembly quality', 'standard_ref': ''},\n })\n\n # GR-003: Missing or empty drill file\n has_drill = statistics.get('drill_files', 0) > 0\n if not has_drill:\n findings.append({\n 'detector': 'analyze_gerbers',\n 'rule_id': 'GR-003',\n 'category': 'gerber_completeness',\n 'severity': 'warning',\n 'confidence': 'deterministic',\n 'evidence_source': 'topology',\n 'summary': 'No drill file in gerber set',\n 'description': 'No Excellon drill file found. PCB fabrication requires drill data.',\n 'components': [],\n 'nets': [],\n 'pins': [],\n 'recommendation': 'Include drill file(s) in gerber export.',\n 'report_context': {'section': 'Gerber Completeness', 'impact': 'Fab will reject', 'standard_ref': ''},\n })\n elif statistics.get('total_holes', 0) == 0:\n findings.append({\n 'detector': 'analyze_gerbers',\n 'rule_id': 'GR-003',\n 'category': 'gerber_completeness',\n 'severity': 'info',\n 'confidence': 'deterministic',\n 'evidence_source': 'topology',\n 'summary': 'Drill file has 0 holes',\n 'description': 'Drill file present but contains no hole definitions.',\n 'components': [],\n 'nets': [],\n 'pins': [],\n 'recommendation': 'Verify drill file was exported correctly.',\n 'report_context': {'section': 'Gerber Completeness', 'impact': '', 'standard_ref': ''},\n })\n\n # GR-002: Alignment issues\n for issue in alignment.get('issues', []):\n findings.append({\n 'detector': 'analyze_gerbers',\n 'rule_id': 'GR-002',\n 'category': 'gerber_alignment',\n 'severity': 'warning',\n 'confidence': 'deterministic',\n 'evidence_source': 'topology',\n 'summary': f'Alignment: {issue}',\n 'description': f'Layer alignment issue: {issue}',\n 'components': [],\n 'nets': [],\n 'pins': [],\n 'recommendation': 'Re-export gerbers to ensure consistent layer alignment.',\n 'report_context': {'section': 'Gerber Alignment', 'impact': 'Layer misregistration', 'standard_ref': ''},\n })\n\n # GR-004: Solder paste aperture mismatch\n # Compare flash counts between paste and copper layers\n paste_flashes = {}\n copper_flashes = {}\n for g in gerber_summary:\n lt = g.get('layer_type', '')\n flashes = g.get('flash_count', 0)\n if 'Paste' in lt and flashes > 0:\n side = 'F' if lt.startswith('F') else 'B'\n paste_flashes[side] = paste_flashes.get(side, 0) + flashes\n elif '.Cu' in lt and (lt.startswith('F.') or lt.startswith('B.')):\n side = 'F' if lt.startswith('F') else 'B'\n copper_flashes[side] = copper_flashes.get(side, 0) + flashes\n\n for side in paste_flashes:\n p_count = paste_flashes[side]\n c_count = copper_flashes.get(side, 0)\n if c_count > 0 and p_count \u003c c_count * 0.5:\n ratio = p_count / c_count * 100\n findings.append({\n 'detector': 'analyze_gerbers',\n 'rule_id': 'GR-004',\n 'category': 'gerber_completeness',\n 'severity': 'warning',\n 'confidence': 'heuristic',\n 'evidence_source': 'topology',\n 'summary': f'{side} paste layer: {p_count} flashes vs {c_count} copper ({ratio:.0f}%)',\n 'description': f'{side}-side paste layer has significantly fewer flashes ({p_count}) than copper layer ({c_count}). This may indicate missing solder paste apertures.',\n 'components': [],\n 'nets': [],\n 'pins': [],\n 'recommendation': 'Check solder paste aperture generation — missing apertures cause assembly defects.',\n 'report_context': {'section': 'Gerber Completeness', 'impact': 'Assembly solder joint quality', 'standard_ref': ''},\n })\n\n # GR-005: Board outline not closed (heuristic)\n # Check if Edge.Cuts layer exists and has reasonable draw count\n edge_layer = None\n for g in gerber_summary:\n if g.get('layer_type', '') == 'Edge.Cuts':\n edge_layer = g\n break\n if edge_layer:\n draws = edge_layer.get('draw_count', 0)\n if draws > 0 and draws \u003c 4:\n findings.append({\n 'detector': 'analyze_gerbers',\n 'rule_id': 'GR-005',\n 'category': 'gerber_completeness',\n 'severity': 'error',\n 'confidence': 'heuristic',\n 'evidence_source': 'topology',\n 'summary': f'Board outline may not be closed — only {draws} draw(s)',\n 'description': f'Edge.Cuts layer has only {draws} draw command(s). A closed rectangular outline needs at least 4. The outline may be incomplete.',\n 'components': [],\n 'nets': [],\n 'pins': [],\n 'recommendation': 'Verify board outline forms a closed shape before submitting to fab.',\n 'report_context': {'section': 'Gerber Completeness', 'impact': 'Fab will reject open outlines', 'standard_ref': ''},\n })\n elif completeness.get('complete', True) is False:\n # Edge.Cuts missing entirely — already covered by GR-001\n pass\n\n return findings\n\n\ndef _get_schema():\n \"\"\"Return JSON output schema description for --schema flag.\"\"\"\n return {\n \"analyzer_type\": \"string — always 'gerber'\",\n \"schema_version\": \"string — semver (currently '1.3.0')\",\n \"summary\": {\"total_findings\": \"int\", \"by_severity\": {\"error\": \"int\", \"warning\": \"int\", \"info\": \"int\"}},\n \"findings\": \"[{detector, rule_id, category, severity, confidence, evidence_source, summary, description, components, nets, pins, recommendation, report_context}] — GR-001..005 (missing layers, alignment, drill, paste apertures, outline closure)\",\n \"trust_summary\": {\n \"total_findings\": \"int\",\n \"trust_level\": \"'high' | 'mixed' | 'low'\",\n \"by_confidence\": \"{deterministic: int, heuristic: int, datasheet-backed: int}\",\n \"by_evidence_source\": \"{datasheet|topology|heuristic_rule|symbol_footprint|bom|geometry|api_lookup: int}\",\n \"provenance_coverage_pct\": \"float\",\n },\n \"directory\": \"string — scan directory path\",\n \"generator\": \"string (KiCad|other|unknown)\",\n \"layer_count\": \"int\",\n \"board_dimensions\": {\"x_min\": \"float\", \"x_max\": \"float\", \"y_min\": \"float\",\n \"y_max\": \"float\", \"width_mm\": \"float\", \"height_mm\": \"float\"},\n \"statistics\": {\"gerber_files\": \"int\", \"drill_files\": \"int\", \"total_holes\": \"int\",\n \"total_flashes\": \"int\", \"total_draws\": \"int\"},\n \"completeness\": {\"expected_layers\": \"[string]\", \"found_layers\": \"[string]\",\n \"missing\": \"[string]\", \"extra\": \"[string]\",\n \"complete\": \"bool\",\n \"has_pth_drill\": \"bool\", \"has_npth_drill\": \"bool\",\n \"source\": \"string — 'gbrjob' | 'filename_heuristic'\"},\n \"alignment\": \"{layer_name: {coord_range: {x_min, x_max, y_min, y_max: float}}}\",\n \"drill_classification\": {\"total_unique\": \"int\", \"via_apertures\": \"int\",\n \"component_holes\": \"int\", \"front_side\": \"int\",\n \"back_side\": \"int\", \"both_sides\": \"int\",\n \"smd_apertures\": \"int\"},\n \"pad_summary\": {\"smd_apertures\": \"int\", \"via_apertures\": \"int\",\n \"component_holes\": \"int\", \"tht\": \"int\"},\n \"gerbers\": \"[{file, filename, layer_type, format: {zero_omit, notation, x_integer, x_decimal, y_integer, y_decimal}, units: mm|inch, flash_count: int, draw_count: int, region_count: int, apertures: {d_code: {type, params, function}}, x2_attributes: {FileFunction, ...}}]\",\n \"drills\": \"[{file, filename, units: mm|inch|null, type: PTH|NPTH|unknown, hole_count: int, coordinate_range, tools: {tool_id: {diameter_mm: float, hole_count: int}}, x2_attributes}]\",\n \"_optional_sections\": \"component_analysis, net_analysis, trace_widths, job_file, zip_archives, connectivity (--full)\",\n }\n\n\ndef main():\n import argparse\n parser = argparse.ArgumentParser(description=\"KiCad Gerber & Drill File Analyzer\")\n parser.add_argument(\"directory\", nargs=\"?\", help=\"Path to gerber/drill file directory\")\n parser.add_argument(\"--output\", \"-o\", help=\"Output JSON file (default: stdout)\")\n parser.add_argument(\"--analysis-dir\",\n help=\"Write gerbers.json to this directory (analysis folder convention)\")\n parser.add_argument(\"--compact\", action=\"store_true\", help=\"Compact JSON output\")\n parser.add_argument(\"--full\", action=\"store_true\",\n help=\"Include full pin-to-net connectivity data\")\n parser.add_argument(\"--text\", action=\"store_true\",\n help=\"Print human-readable text summary\")\n parser.add_argument(\"--schema\", action=\"store_true\",\n help=\"Print JSON output schema and exit\")\n parser.add_argument('--stage', default=None,\n choices=['schematic', 'layout', 'pre_fab', 'bring_up'],\n help='Filter findings by review stage')\n parser.add_argument('--audience', default=None,\n choices=['designer', 'reviewer', 'manager'],\n help='Audience level for summaries and --text output')\n args = parser.parse_args()\n\n if args.schema:\n print(json.dumps(_get_schema(), indent=2))\n sys.exit(0)\n\n if not args.directory:\n parser.error(\"the following arguments are required: directory\")\n\n result = analyze_gerbers(args.directory, full=args.full)\n\n from output_filters import apply_output_filters\n apply_output_filters(result, args.stage, args.audience)\n\n # Determine output path\n output_path = args.output\n indent = None if args.compact else 2\n output_json = json.dumps(result, indent=indent, default=str)\n\n if not output_path and args.analysis_dir:\n # Route into the current run folder via the manifest. Use the\n # canonical filename (gerber.json) so the manifest tracks it.\n import tempfile\n from analysis_cache import overwrite_current, CANONICAL_OUTPUTS, get_current_run\n analysis_dir = args.analysis_dir\n if not os.path.isabs(analysis_dir):\n analysis_dir = os.path.abspath(analysis_dir)\n filename = CANONICAL_OUTPUTS.get('gerber', 'gerber.json')\n with tempfile.TemporaryDirectory() as tmp_dir:\n Path(os.path.join(tmp_dir, filename)).write_text(output_json)\n overwrite_current(analysis_dir, tmp_dir, source_hashes=None)\n current = get_current_run(analysis_dir)\n if current:\n out_path = os.path.join(current[0], filename)\n else:\n out_path = os.path.join(analysis_dir, filename)\n print(f\"Written to {out_path}\", file=sys.stderr)\n elif output_path:\n Path(output_path).write_text(output_json)\n print(f\"Written to {output_path}\", file=sys.stderr)\n elif args.text:\n from output_filters import format_text\n print(format_text(result.get('findings', []), args.audience or 'designer', args.stage))\n else:\n print(output_json)\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":65682,"content_sha256":"55bd3e9bec93e032500c788a74f4cdb5eb07f42c6e1f9569dee04f2ada1395fc"},{"filename":"scripts/analyze_thermal.py","content":"#!/usr/bin/env python3\n\"\"\"\nThermal hotspot estimator for KiCad designs.\n\nConsumes schematic and PCB analyzer JSON outputs, models each power-dissipating\ncomponent as a point heat source, estimates junction temperatures, and flags\ncomponents approaching or exceeding rated limits.\n\nUsage:\n python3 analyze_thermal.py --schematic analysis.json --pcb pcb.json\n python3 analyze_thermal.py -s analysis.json -p pcb.json --output thermal.json\n python3 analyze_thermal.py -s analysis.json -p pcb.json --text\n python3 analyze_thermal.py -s analysis.json -p pcb.json --ambient 40\n\nRequires both schematic and PCB JSON (schematic for power data, PCB for copper\nand thermal via data). Zero dependencies — Python 3.8+ stdlib only.\n\"\"\"\n\nimport argparse\nimport json\nimport math\nimport os\nimport re\nimport sys\nimport time\n\n\n# ---------------------------------------------------------------------------\n# Constants\n# ---------------------------------------------------------------------------\n\nDEFAULT_AMBIENT_C = 25.0\nDEFAULT_TJ_MAX_C = 125.0\nDEFAULT_RTHETA_JA = 150.0 # Conservative fallback °C/W\nMIN_PDISS_W = 0.01 # 10mW threshold — below this, thermal is negligible\n\nSEVERITY_WEIGHTS = {\n 'error': 15, 'warning': 3, 'info': 0,\n}\nMAX_FINDINGS_PER_RULE = 5\n\n# Switching regulator efficiency defaults by topology\nSWITCHING_EFFICIENCY = {\n 'buck': 0.87,\n 'boost': 0.85,\n 'buck-boost': 0.83,\n 'switching': 0.85, # generic\n}\n\n# ---------------------------------------------------------------------------\n# Package thermal resistance lookup (Rθ_JA in °C/W)\n# Values are JEDEC still-air conditions (no enhanced copper pour).\n# PCB corrections applied separately.\n# ---------------------------------------------------------------------------\n\nPACKAGE_THERMAL_RESISTANCE = [\n # (regex pattern on footprint library string, Rθ_JA °C/W)\n # Order matters — first match wins. More specific patterns first.\n # Discrete power packages\n (r\"TO-263|D2PAK\", 30.0),\n (r\"TO-252|DPAK\", 40.0),\n (r\"TO-220\", 25.0),\n (r\"TO-92\", 200.0),\n (r\"SOT-223\", 60.0),\n (r\"SOT-89\", 100.0),\n (r\"SOT-23\", 250.0), # SOT-23-3, SOT-23-5, SOT-23-6\n (r\"SOT-363|SC-70\", 300.0),\n # QFN/DFN — size matters\n (r\"(?:QFN|DFN).*7[xX×]7\", 20.0),\n (r\"(?:QFN|DFN).*6[xX×]6\", 22.0),\n (r\"(?:QFN|DFN).*5[xX×]5\", 25.0),\n (r\"(?:QFN|DFN).*4[xX×]4\", 35.0),\n (r\"(?:QFN|DFN).*3[xX×]3\", 50.0),\n (r\"(?:QFN|DFN).*2[xX×]2\", 70.0),\n (r\"QFN|DFN\", 40.0), # generic QFN\n # TQFP/LQFP — pin count\n (r\"[TL]QFP.*144\", 30.0),\n (r\"[TL]QFP.*100\", 35.0),\n (r\"[TL]QFP.*(?:64|80)\", 40.0),\n (r\"[TL]QFP.*48\", 50.0),\n (r\"[TL]QFP.*32\", 60.0),\n (r\"[TL]QFP\", 50.0),\n # SOIC/SOP\n (r\"SOIC.*16|SOP.*16\", 80.0),\n (r\"SOIC.*8|SOP.*8\", 120.0),\n (r\"SOIC|SOP\", 100.0),\n # TSSOP/MSOP\n (r\"TSSOP.*(?:20|24|28)\", 80.0),\n (r\"TSSOP.*(?:14|16)\", 100.0),\n (r\"TSSOP.*8\", 150.0),\n (r\"MSOP\", 200.0),\n # BGA\n (r\"BGA.*(?:256|324|400)\", 20.0),\n (r\"BGA\", 25.0),\n # Passives — resistor packages\n (r\"2512\", 40.0),\n (r\"2010\", 60.0),\n (r\"1210\", 80.0),\n (r\"1206\", 100.0),\n (r\"0805\", 150.0),\n (r\"0603\", 200.0),\n (r\"0402\", 250.0),\n (r\"0201\", 350.0),\n]\n\n# Compiled patterns for performance\n_COMPILED_PATTERNS = [(re.compile(pat, re.IGNORECASE), rtheta)\n for pat, rtheta in PACKAGE_THERMAL_RESISTANCE]\n\n\n# ---------------------------------------------------------------------------\n# Package classification\n# ---------------------------------------------------------------------------\n\ndef _classify_package(library_str: str) -> tuple:\n \"\"\"Extract package type and Rθ_JA from PCB footprint library string.\n\n Returns (package_name, rtheta_ja). Falls back to (\"unknown\", DEFAULT_RTHETA_JA).\n \"\"\"\n if not library_str:\n return (\"unknown\", DEFAULT_RTHETA_JA)\n\n # Use the footprint name (after the colon)\n name = library_str.split(\":\")[-1] if \":\" in library_str else library_str\n\n for pattern, rtheta in _COMPILED_PATTERNS:\n if pattern.search(name):\n # Extract the matched portion as the package name\n m = pattern.search(name)\n return (m.group(0), rtheta)\n\n return (\"unknown\", DEFAULT_RTHETA_JA)\n\n\n# ---------------------------------------------------------------------------\n# Datasheet thermal data lookup\n# ---------------------------------------------------------------------------\n\ndef _sanitize_mpn(mpn: str) -> str:\n \"\"\"Sanitize MPN for filesystem lookup.\"\"\"\n return re.sub(r'[^\\w\\-.]', '_', mpn.strip())\n\n\ndef _get_datasheet_thermal(mpn: str, extract_dir: str) -> dict:\n \"\"\"Look up thermal data from datasheet extraction cache.\n\n Returns dict with optional keys: tj_max_c, temp_max_c.\n Rejects extractions with quality score \u003c 6.0 (matches the trust\n gate used by datasheet_verify.py and spice_spec_fetcher.py).\n \"\"\"\n if not mpn or not extract_dir:\n return {}\n\n safe = _sanitize_mpn(mpn)\n path = os.path.join(extract_dir, f\"{safe}.json\")\n if not os.path.isfile(path):\n return {}\n\n try:\n with open(path) as f:\n data = json.load(f)\n except (json.JSONDecodeError, OSError):\n return {}\n\n # Trust gate: reject low-quality extractions (matches\n # datasheet_verify.py and spice_spec_fetcher.py threshold)\n meta = data.get(\"meta\", {})\n if meta.get(\"extraction_score\", 0) \u003c 6.0:\n return {}\n\n result = {}\n abs_max = data.get(\"absolute_maximum_ratings\", {})\n if isinstance(abs_max, dict):\n tj = abs_max.get(\"junction_temp_max_c\")\n if isinstance(tj, (int, float)) and tj > 0:\n result[\"tj_max_c\"] = float(tj)\n\n rec_op = data.get(\"recommended_operating_conditions\", {})\n if isinstance(rec_op, dict):\n tmax = rec_op.get(\"temp_max_c\")\n if isinstance(tmax, (int, float)) and tmax > 0:\n result[\"temp_max_c\"] = float(tmax)\n\n return result\n\n\n# ---------------------------------------------------------------------------\n# Power dissipation estimators\n# ---------------------------------------------------------------------------\n\ndef _estimate_all_power_dissipation(schematic: dict) -> list:\n \"\"\"Build list of all components with estimated power dissipation.\n\n Returns list of dicts with ref, value, type, pdiss_w, pdiss_source, etc.\n Only includes components with P > MIN_PDISS_W.\n \"\"\"\n results = []\n regulators = [f for f in schematic.get(\"findings\", [])\n if f.get(\"detector\") == \"detect_power_regulators\"]\n power_budget = schematic.get(\"power_budget\", {})\n seen_refs = set()\n\n # 1. Linear regulators (LDOs) — use pre-computed power_dissipation\n for reg in regulators:\n ref = reg.get(\"ref\", \"\")\n topology = reg.get(\"topology\", \"\").lower()\n pdiss = reg.get(\"power_dissipation\", {})\n\n if topology in (\"ldo\", \"linear\") and isinstance(pdiss, dict):\n p_w = pdiss.get(\"estimated_pdiss_W\", 0)\n if p_w and p_w > MIN_PDISS_W:\n results.append({\n \"ref\": ref,\n \"value\": reg.get(\"value\", \"\"),\n \"type\": \"ldo\",\n \"pdiss_w\": round(p_w, 4),\n \"pdiss_source\": (f\"({pdiss.get('vin_estimated_V', '?')}V - \"\n f\"{pdiss.get('vout_V', '?')}V) × \"\n f\"{pdiss.get('estimated_iout_A', '?')}A\"),\n \"pdiss_confidence\": \"heuristic\",\n \"vin_v\": pdiss.get(\"vin_estimated_V\"),\n \"vout_v\": pdiss.get(\"vout_V\"),\n \"iout_a\": pdiss.get(\"estimated_iout_A\"),\n })\n seen_refs.add(ref)\n\n # 2. Switching regulators — estimate from efficiency\n for reg in regulators:\n ref = reg.get(\"ref\", \"\")\n if ref in seen_refs:\n continue\n topology = reg.get(\"topology\", \"\").lower()\n if topology in (\"ldo\", \"linear\", \"\"):\n continue\n\n # Get output current estimate from power_budget\n output_rail = reg.get(\"output_rail\", \"\")\n iout_a = 0\n for rail_name, rail_data in power_budget.get(\"rails\", {}).items():\n if isinstance(rail_data, dict) and rail_name == output_rail:\n iout_a = rail_data.get(\"estimated_load_mA\", 0) / 1000.0\n break\n\n vout = reg.get(\"estimated_vout\")\n if not vout or not iout_a or iout_a \u003c= 0:\n continue\n\n eta = SWITCHING_EFFICIENCY.get(topology, 0.85)\n p_out = vout * iout_a\n p_in = p_out / eta\n p_loss = p_in - p_out\n\n if p_loss > MIN_PDISS_W:\n results.append({\n \"ref\": ref,\n \"value\": reg.get(\"value\", \"\"),\n \"type\": \"switching_reg\",\n \"pdiss_w\": round(p_loss, 4),\n \"pdiss_source\": f\"{vout}V × {iout_a:.3f}A × (1/{eta:.0%} - 1)\",\n \"pdiss_confidence\": \"heuristic\",\n \"vout_v\": vout,\n \"iout_a\": iout_a,\n })\n seen_refs.add(ref)\n\n # 3. Current sense shunt resistors — P = I²R\n current_sense = [f for f in schematic.get(\"findings\", [])\n if f.get(\"detector\") == \"detect_current_sense\"]\n for cs in current_sense:\n shunt = cs.get(\"shunt\", {})\n if not isinstance(shunt, dict):\n continue\n ref = shunt.get(\"ref\", \"\")\n if ref in seen_refs:\n continue\n r_ohms = shunt.get(\"ohms\", 0)\n i_max = cs.get(\"max_current_100mV_A\", 0)\n if not r_ohms or not i_max:\n continue\n\n p_w = i_max * i_max * r_ohms\n if p_w > MIN_PDISS_W:\n results.append({\n \"ref\": ref,\n \"value\": shunt.get(\"value\", \"\"),\n \"type\": \"shunt_resistor\",\n \"pdiss_w\": round(p_w, 4),\n \"pdiss_source\": f\"{i_max:.3f}A² × {r_ohms}Ω\",\n \"pdiss_confidence\": \"deterministic\",\n })\n seen_refs.add(ref)\n\n return results\n\n\n# ---------------------------------------------------------------------------\n# PCB thermal correction\n# ---------------------------------------------------------------------------\n\ndef _get_pcb_thermal_correction(ref: str, pcb: dict) -> dict:\n \"\"\"Compute PCB-based correction factor for a component's Rθ_JA.\n\n Returns dict with correction_factor (0.4-1.0), has_thermal_pad, etc.\n \"\"\"\n result = {\n \"correction_factor\": 1.0,\n \"has_thermal_pad\": False,\n \"thermal_vias\": 0,\n \"notes\": [],\n }\n\n # Check thermal_pad_vias findings for this component\n # (entries live in findings[] with detector=\"analyze_thermal_pad_vias\")\n for tpv in pcb.get(\"findings\", []):\n if not isinstance(tpv, dict):\n continue\n if tpv.get(\"detector\") != \"analyze_thermal_pad_vias\":\n continue\n if tpv.get(\"component\") != ref:\n continue\n\n result[\"has_thermal_pad\"] = True\n result[\"thermal_vias\"] = tpv.get(\"via_count\", 0)\n adequacy = tpv.get(\"adequacy\", \"none\")\n\n if adequacy == \"good\":\n result[\"correction_factor\"] *= 0.50\n result[\"notes\"].append(\n f\"thermal pad with {tpv.get('via_count', 0)} vias (good)\")\n elif adequacy == \"adequate\":\n result[\"correction_factor\"] *= 0.65\n result[\"notes\"].append(\n f\"thermal pad with {tpv.get('via_count', 0)} vias (adequate)\")\n elif adequacy == \"insufficient\":\n result[\"correction_factor\"] *= 0.80\n result[\"notes\"].append(\n f\"thermal pad with {tpv.get('via_count', 0)} vias (insufficient)\")\n else:\n result[\"notes\"].append(\"thermal pad but no vias\")\n break\n\n # Check thermal_pads findings for nearby via info\n # (entries from thermal_analysis also live in findings[])\n for tp in pcb.get(\"findings\", []):\n if not isinstance(tp, dict):\n continue\n if tp.get(\"component\") != ref:\n continue\n nearby = tp.get(\"nearby_thermal_vias\", 0)\n if nearby > 4 and not result[\"has_thermal_pad\"]:\n result[\"correction_factor\"] *= 0.70\n result[\"notes\"].append(f\"{nearby} nearby thermal vias\")\n break\n\n # Clamp to reasonable range\n result[\"correction_factor\"] = max(0.40, min(1.0, result[\"correction_factor\"]))\n return result\n\n\n# ---------------------------------------------------------------------------\n# Junction temperature computation\n# ---------------------------------------------------------------------------\n\ndef _get_footprint_map(pcb: dict) -> dict:\n \"\"\"Build ref -> footprint dict from PCB data.\"\"\"\n fp_map = {}\n for fp in pcb.get(\"footprints\", []):\n if isinstance(fp, dict) and \"reference\" in fp:\n fp_map[fp[\"reference\"]] = fp\n return fp_map\n\n\ndef _compute_junction_temps(power_comps: list, pcb: dict,\n extract_dir: str, ambient_c: float) -> list:\n \"\"\"Compute estimated junction temperature for each power component.\"\"\"\n fp_map = _get_footprint_map(pcb)\n assessments = []\n\n for comp in power_comps:\n ref = comp[\"ref\"]\n fp = fp_map.get(ref, {})\n\n # Package Rθ_JA\n library = fp.get(\"library\", fp.get(\"lib_id\", \"\"))\n pkg_name, pkg_rtheta = _classify_package(library)\n rtheta_source = \"package_table\" if pkg_name != \"unknown\" else \"default\"\n\n # Datasheet lookup for Tj_max\n mpn = \"\"\n for c in pcb.get(\"footprints\", []):\n if isinstance(c, dict) and c.get(\"reference\") == ref:\n mpn = c.get(\"mpn\", \"\") or c.get(\"value\", \"\")\n break\n ds_thermal = _get_datasheet_thermal(mpn, extract_dir) if extract_dir else {}\n\n tj_max = ds_thermal.get(\"tj_max_c\", DEFAULT_TJ_MAX_C)\n tj_max_source = \"datasheet\" if \"tj_max_c\" in ds_thermal else \"default_125\"\n\n # PCB correction\n pcb_corr = _get_pcb_thermal_correction(ref, pcb)\n rtheta_effective = pkg_rtheta * pcb_corr[\"correction_factor\"]\n\n # Junction temperature: Tj = Ta + P × Rθ_JA\n pdiss = comp[\"pdiss_w\"]\n tj = ambient_c + pdiss * rtheta_effective\n margin = tj_max - tj\n\n # Position from PCB\n position = None\n if \"x\" in fp and \"y\" in fp:\n position = {\"x\": fp[\"x\"], \"y\": fp[\"y\"]}\n\n assessments.append({\n \"ref\": ref,\n \"value\": comp.get(\"value\", \"\"),\n \"component_type\": comp[\"type\"],\n \"pdiss_w\": pdiss,\n \"pdiss_source\": comp.get(\"pdiss_source\", \"\"),\n \"pdiss_confidence\": comp.get(\"pdiss_confidence\", \"heuristic\"),\n \"package\": pkg_name,\n \"rtheta_ja_raw\": round(pkg_rtheta, 1),\n \"rtheta_ja_source\": rtheta_source,\n \"pcb_correction\": round(pcb_corr[\"correction_factor\"], 2),\n \"pcb_correction_notes\": pcb_corr[\"notes\"],\n \"rtheta_ja_effective\": round(rtheta_effective, 1),\n \"ambient_c\": ambient_c,\n \"tj_estimated_c\": round(tj, 1),\n \"tj_max_c\": tj_max,\n \"tj_max_source\": tj_max_source,\n \"margin_c\": round(margin, 1),\n \"position\": position,\n \"detector\": \"analyze_thermal\",\n \"rule_id\": \"TH-DET\",\n \"category\": \"thermal\",\n \"severity\": \"info\",\n \"confidence\": \"heuristic\" if rtheta_source == \"default\" else \"deterministic\",\n \"evidence_source\": \"datasheet\" if rtheta_source == \"package_table\" else \"heuristic_rule\",\n \"summary\": f\"Thermal: {ref} Tj={round(tj, 1)}C (margin {round(margin, 1)}C)\",\n \"description\": f\"Component {ref} in {pkg_name} package: Tj={round(tj, 1)}C, margin {round(margin, 1)}C to Tj_max ({tj_max}C).\",\n \"components\": [ref],\n \"nets\": [],\n \"pins\": [],\n \"recommendation\": \"\",\n \"report_context\": {\"section\": \"Thermal\", \"impact\": \"\", \"standard_ref\": \"\"},\n })\n\n # Sort by Tj descending (hottest first)\n assessments.sort(key=lambda a: -a[\"tj_estimated_c\"])\n return assessments\n\n\n# ---------------------------------------------------------------------------\n# Finding generation\n# ---------------------------------------------------------------------------\n\ndef _thermal_confidence(assessment: dict) -> str:\n \"\"\"Determine confidence level from assessment data sources.\"\"\"\n if assessment.get(\"rtheta_ja_source\") == \"default\":\n return \"heuristic\"\n if assessment.get(\"tj_max_source\") == \"default_125\":\n return \"heuristic\"\n return \"datasheet-backed\"\n\n\ndef _generate_findings(assessments: list) -> list:\n \"\"\"Generate thermal findings from assessments.\"\"\"\n findings = []\n\n for a in assessments:\n ref = a[\"ref\"]\n val = a[\"value\"]\n tj = a[\"tj_estimated_c\"]\n tj_max = a[\"tj_max_c\"]\n margin = a[\"margin_c\"]\n pdiss = a[\"pdiss_w\"]\n pkg = a[\"package\"]\n confidence = _thermal_confidence(a)\n ev_source = (\"datasheet\" if a.get(\"rtheta_ja_source\") == \"package_table\"\n else \"heuristic_rule\")\n\n label = f\"{ref} ({val})\" if val else ref\n\n if tj > tj_max:\n findings.append({\n \"category\": \"thermal_safety\",\n \"severity\": \"error\",\n \"rule_id\": \"TS-001\",\n \"confidence\": confidence,\n \"title\": f\"{label} estimated Tj {tj:.0f}°C exceeds abs max {tj_max:.0f}°C\",\n \"description\": (\n f\"{a['component_type']} dissipates {pdiss:.3f}W in {pkg} package \"\n f\"(Rθ_JA={a['rtheta_ja_effective']:.0f}°C/W). \"\n f\"Source: {a['pdiss_source']}.\"\n ),\n \"components\": [ref],\n \"recommendation\": (\n \"Reduce power dissipation (lower Vin, reduce load), improve \"\n \"thermal path (add thermal vias, larger copper pour), or use \"\n \"a more efficient topology (switching regulator instead of LDO).\"\n ),\n \"detector\": \"analyze_thermal\",\n \"summary\": f\"{label} estimated Tj {tj:.0f}°C exceeds abs max {tj_max:.0f}°C\",\n \"nets\": [],\n \"pins\": [],\n \"evidence_source\": ev_source,\n \"report_context\": {\"section\": \"Thermal Safety\", \"impact\": \"Component reliability\", \"standard_ref\": \"\"},\n })\n elif margin \u003c 15:\n findings.append({\n \"category\": \"thermal_safety\",\n \"severity\": \"error\",\n \"rule_id\": \"TS-002\",\n \"confidence\": confidence,\n \"title\": f\"{label} estimated Tj {tj:.0f}°C — only {margin:.0f}°C margin to abs max\",\n \"description\": (\n f\"{a['component_type']} dissipates {pdiss:.3f}W in {pkg} package \"\n f\"(Rθ_JA={a['rtheta_ja_effective']:.0f}°C/W). \"\n f\"Margin to {tj_max:.0f}°C abs max is {margin:.0f}°C — \"\n f\"may exceed limit at elevated ambient.\"\n ),\n \"components\": [ref],\n \"recommendation\": (\n \"Verify thermal design at worst-case ambient temperature. \"\n \"Consider improving thermal path or reducing input-output \"\n \"voltage differential.\"\n ),\n \"detector\": \"analyze_thermal\",\n \"summary\": f\"{label} estimated Tj {tj:.0f}°C — only {margin:.0f}°C margin to abs max\",\n \"nets\": [],\n \"pins\": [],\n \"evidence_source\": ev_source,\n \"report_context\": {\"section\": \"Thermal Safety\", \"impact\": \"Component reliability\", \"standard_ref\": \"\"},\n })\n elif tj > 85:\n findings.append({\n \"category\": \"thermal_safety\",\n \"severity\": \"warning\",\n \"rule_id\": \"TS-003\",\n \"confidence\": confidence,\n \"title\": f\"{label} estimated Tj {tj:.0f}°C may affect nearby components\",\n \"description\": (\n f\"{a['component_type']} runs hot at {tj:.0f}°C \"\n f\"({pdiss:.3f}W in {pkg}). \"\n f\"Nearby MLCCs may lose capacitance due to temperature \"\n f\"coefficient effects.\"\n ),\n \"components\": [ref],\n \"recommendation\": (\n \"Verify nearby ceramic capacitors maintain adequate \"\n \"capacitance at this temperature. Consider spacing \"\n \"temperature-sensitive components away from heat source.\"\n ),\n \"detector\": \"analyze_thermal\",\n \"summary\": f\"{label} estimated Tj {tj:.0f}°C may affect nearby components\",\n \"nets\": [],\n \"pins\": [],\n \"evidence_source\": ev_source,\n \"report_context\": {\"section\": \"Thermal Safety\", \"impact\": \"Component reliability\", \"standard_ref\": \"\"},\n })\n elif pdiss > 0.1:\n findings.append({\n \"category\": \"thermal_safety\",\n \"severity\": \"info\",\n \"rule_id\": \"TS-005\",\n \"confidence\": confidence,\n \"title\": f\"{label} Tj {tj:.0f}°C, margin {margin:.0f}°C\",\n \"description\": (\n f\"{a['component_type']} dissipates {pdiss:.3f}W in {pkg} — \"\n f\"within safe thermal limits.\"\n ),\n \"components\": [ref],\n \"recommendation\": \"\",\n \"detector\": \"analyze_thermal\",\n \"summary\": f\"{label} Tj {tj:.0f}°C, margin {margin:.0f}°C\",\n \"nets\": [],\n \"pins\": [],\n \"evidence_source\": ev_source,\n \"report_context\": {\"section\": \"Thermal Safety\", \"impact\": \"Component reliability\", \"standard_ref\": \"\"},\n })\n\n # TS-004: High-power component with no thermal vias\n for a in assessments:\n if a[\"pdiss_w\"] > 0.5 and a[\"pcb_correction\"] >= 0.95:\n ref = a[\"ref\"]\n val = a[\"value\"]\n label = f\"{ref} ({val})\" if val else ref\n findings.append({\n \"category\": \"thermal_safety\",\n \"severity\": \"warning\",\n \"rule_id\": \"TS-004\",\n \"confidence\": \"deterministic\",\n \"title\": f\"{label} dissipates {a['pdiss_w']:.2f}W with no thermal vias\",\n \"description\": (\n f\"{a['component_type']} in {a['package']} package dissipates \"\n f\"{a['pdiss_w']:.3f}W but no thermal pad vias were detected. \"\n f\"Heat removal relies entirely on surface copper.\"\n ),\n \"components\": [ref],\n \"recommendation\": (\n \"Add thermal vias under the component's thermal pad or \"\n \"exposed pad. Minimum 5 vias for QFN, more for larger pads.\"\n ),\n \"detector\": \"analyze_thermal\",\n \"summary\": f\"{label} dissipates {a['pdiss_w']:.2f}W with no thermal vias\",\n \"nets\": [],\n \"pins\": [],\n \"evidence_source\": \"geometry\",\n \"report_context\": {\"section\": \"Thermal Safety\", \"impact\": \"Component reliability\", \"standard_ref\": \"\"},\n })\n\n return findings\n\n\n# ---------------------------------------------------------------------------\n# Thermal proximity warnings\n# ---------------------------------------------------------------------------\n\ndef _check_thermal_proximity(assessments: list, pcb: dict) -> list:\n \"\"\"Check for temperature-sensitive components near hot spots.\"\"\"\n findings = []\n fp_map = _get_footprint_map(pcb)\n\n # Find hot components (Tj > 70°C) with known positions\n hot_comps = [a for a in assessments\n if a[\"tj_estimated_c\"] > 70 and a.get(\"position\")]\n\n if not hot_comps:\n return findings\n\n # Find capacitors in PCB footprints\n caps = []\n for fp in pcb.get(\"footprints\", []):\n if not isinstance(fp, dict):\n continue\n ref = fp.get(\"reference\", \"\")\n if not ref.startswith(\"C\"):\n continue\n if \"x\" not in fp or \"y\" not in fp:\n continue\n value = fp.get(\"value\", \"\").lower()\n is_elec = any(k in value for k in (\"elec\", \"tant\", \"polar\", \"aluminum\"))\n caps.append({\n \"ref\": ref, \"x\": fp[\"x\"], \"y\": fp[\"y\"],\n \"value\": fp.get(\"value\", \"\"), \"is_electrolytic\": is_elec,\n })\n\n # Check proximity\n for hot in hot_comps:\n hx, hy = hot[\"position\"][\"x\"], hot[\"position\"][\"y\"]\n for cap in caps:\n dx = cap[\"x\"] - hx\n dy = cap[\"y\"] - hy\n dist = math.sqrt(dx * dx + dy * dy)\n if dist > 10.0: # 10mm threshold\n continue\n\n hot_label = f\"{hot['ref']} ({hot.get('value', '')})\"\n if cap[\"is_electrolytic\"]:\n findings.append({\n \"category\": \"thermal_proximity\",\n \"severity\": \"warning\",\n \"rule_id\": \"TP-002\",\n \"confidence\": \"deterministic\",\n \"title\": (f\"Electrolytic {cap['ref']} ({cap['value']}) \"\n f\"is {dist:.1f}mm from {hot_label} \"\n f\"(Tj={hot['tj_estimated_c']:.0f}°C)\"),\n \"description\": (\n \"Electrolytic and tantalum capacitors have reduced \"\n \"lifetime at elevated temperatures. Every 10°C above \"\n \"rated temperature halves expected lifetime.\"\n ),\n \"components\": [cap[\"ref\"], hot[\"ref\"]],\n \"recommendation\": (\n \"Move capacitor away from heat source or use a \"\n \"ceramic capacitor rated for higher temperature.\"\n ),\n \"detector\": \"analyze_thermal\",\n \"summary\": (f\"Electrolytic {cap['ref']} ({cap['value']}) \"\n f\"is {dist:.1f}mm from {hot_label} \"\n f\"(Tj={hot['tj_estimated_c']:.0f}°C)\"),\n \"nets\": [],\n \"pins\": [],\n \"evidence_source\": \"geometry\",\n \"report_context\": {\"section\": \"Thermal Proximity\", \"impact\": \"Component reliability\", \"standard_ref\": \"\"},\n })\n else:\n findings.append({\n \"category\": \"thermal_proximity\",\n \"severity\": \"info\",\n \"rule_id\": \"TP-001\",\n \"confidence\": \"deterministic\",\n \"title\": (f\"MLCC {cap['ref']} is {dist:.1f}mm from \"\n f\"{hot_label} (Tj={hot['tj_estimated_c']:.0f}°C)\"),\n \"description\": (\n \"Ceramic capacitors lose effective capacitance at \"\n \"elevated temperatures. X7R loses ~15% at 85°C, \"\n \"X5R loses ~30%.\"\n ),\n \"components\": [cap[\"ref\"], hot[\"ref\"]],\n \"recommendation\": (\n \"Verify capacitor maintains adequate capacitance \"\n \"at the elevated temperature, or use C0G/NP0 \"\n \"dielectric for temperature-critical applications.\"\n ),\n \"detector\": \"analyze_thermal\",\n \"summary\": (f\"MLCC {cap['ref']} is {dist:.1f}mm from \"\n f\"{hot_label} (Tj={hot['tj_estimated_c']:.0f}°C)\"),\n \"nets\": [],\n \"pins\": [],\n \"evidence_source\": \"geometry\",\n \"report_context\": {\"section\": \"Thermal Proximity\", \"impact\": \"Component reliability\", \"standard_ref\": \"\"},\n })\n\n return findings\n\n\n# ---------------------------------------------------------------------------\n# Scoring\n# ---------------------------------------------------------------------------\n\ndef compute_thermal_score(findings: list) -> int:\n \"\"\"Compute thermal safety score from 0 (worst) to 100 (best).\"\"\"\n by_rule = {}\n for f in findings:\n rule = f.get(\"rule_id\", \"\")\n by_rule.setdefault(rule, []).append(f)\n\n penalty = 0\n sev_order = {'error': 0, 'warning': 1, 'info': 2}\n for rule, rule_findings in by_rule.items():\n rule_findings.sort(key=lambda f: sev_order.get(f.get('severity', 'info'), 3))\n for f in rule_findings[:MAX_FINDINGS_PER_RULE]:\n penalty += SEVERITY_WEIGHTS.get(f.get('severity', 'info'), 0)\n\n return max(0, min(100, 100 - penalty))\n\n\n# ---------------------------------------------------------------------------\n# Board thermal summary\n# ---------------------------------------------------------------------------\n\ndef _board_summary(assessments: list, ambient_c: float) -> dict:\n \"\"\"Generate board-level thermal statistics.\"\"\"\n if not assessments:\n return {\n \"total_board_dissipation_w\": 0,\n \"components_analyzed\": 0,\n \"components_above_85c\": 0,\n \"components_above_tjmax\": 0,\n \"ambient_c\": ambient_c,\n }\n\n total_p = sum(a[\"pdiss_w\"] for a in assessments)\n above_85 = sum(1 for a in assessments if a[\"tj_estimated_c\"] > 85)\n above_max = sum(1 for a in assessments if a[\"margin_c\"] \u003c 0)\n hottest = max(assessments, key=lambda a: a[\"tj_estimated_c\"])\n\n return {\n \"total_board_dissipation_w\": round(total_p, 3),\n \"hottest_component\": {\n \"ref\": hottest[\"ref\"],\n \"tj_estimated_c\": hottest[\"tj_estimated_c\"],\n },\n \"components_analyzed\": len(assessments),\n \"components_above_85c\": above_85,\n \"components_above_tjmax\": above_max,\n \"ambient_c\": ambient_c,\n }\n\n\n# ---------------------------------------------------------------------------\n# Text report formatter\n# ---------------------------------------------------------------------------\n\ndef format_text_report(result: dict) -> str:\n \"\"\"Format thermal analysis as human-readable text.\"\"\"\n lines = []\n summary = result.get(\"summary\", {})\n findings = result.get(\"findings\", [])\n assessments = result.get(\"thermal_assessments\", [])\n\n lines.append(\"=\" * 60)\n lines.append(\"THERMAL HOTSPOT ANALYSIS\")\n lines.append(\"=\" * 60)\n lines.append(\"\")\n\n score = summary.get(\"thermal_score\", 0)\n lines.append(f\"Thermal score: {score}/100\")\n lines.append(f\"Ambient temp: {summary.get('ambient_c', 25)}°C\")\n total_p = summary.get(\"total_board_dissipation_w\", 0)\n lines.append(f\"Total dissipation: {total_p:.3f}W\")\n lines.append(\"\")\n\n lines.append(f\"Total findings: {summary.get('total_findings', 0)}\")\n lines.append(f\"Components assessed: {summary.get('components_assessed', 0)}\")\n lines.append(f\" CRITICAL: {summary.get('critical', 0)}\")\n lines.append(f\" HIGH: {summary.get('high', 0)}\")\n lines.append(f\" MEDIUM: {summary.get('medium', 0)}\")\n lines.append(f\" LOW: {summary.get('low', 0)}\")\n lines.append(f\" INFO: {summary.get('info', 0)}\")\n lines.append(\"\")\n\n # Component thermal table\n if assessments:\n lines.append(\"-\" * 60)\n lines.append(\"Component Thermal Summary\")\n lines.append(\"-\" * 60)\n lines.append(f\"{'Ref':\u003c8} {'Type':\u003c14} {'P(W)':\u003c8} {'Rθ_JA':\u003c8} \"\n f\"{'Tj(°C)':\u003c8} {'Tj_max':\u003c8} {'Margin':\u003c8}\")\n lines.append(\"-\" * 60)\n for a in assessments:\n lines.append(\n f\"{a['ref']:\u003c8} {a['component_type']:\u003c14} \"\n f\"{a['pdiss_w']:\u003c8.3f} {a['rtheta_ja_effective']:\u003c8.1f} \"\n f\"{a['tj_estimated_c']:\u003c8.1f} {a['tj_max_c']:\u003c8.0f} \"\n f\"{a['margin_c']:\u003c8.1f}\"\n )\n lines.append(\"\")\n\n # Findings by severity\n if findings:\n lines.append(\"-\" * 60)\n lines.append(\"Findings\")\n lines.append(\"-\" * 60)\n\n for f in findings:\n sev = f[\"severity\"]\n lines.append(f\" [{sev}] {f['rule_id']}: {f['title']}\")\n desc = f.get(\"description\", \"\")\n for i in range(0, len(desc), 70):\n prefix = \" \" if i == 0 else \" \"\n lines.append(prefix + desc[i:i + 70])\n if f.get(\"recommendation\"):\n lines.append(f\" -> {f['recommendation']}\")\n lines.append(\"\")\n else:\n lines.append(\"No thermal findings.\")\n\n return \"\\n\".join(lines)\n\n\n# ---------------------------------------------------------------------------\n# Main\n# ---------------------------------------------------------------------------\n\ndef main():\n parser = argparse.ArgumentParser(\n description=\"Thermal hotspot estimator for KiCad designs\"\n )\n parser.add_argument(\"--schematic\", \"-s\",\n help=\"Schematic analyzer JSON (from analyze_schematic.py)\")\n parser.add_argument(\"--pcb\", \"-p\",\n help=\"PCB analyzer JSON (from analyze_pcb.py)\")\n parser.add_argument(\"--output\", \"-o\",\n help=\"Output JSON file path (default: stdout)\")\n parser.add_argument(\"--text\", action=\"store_true\",\n help=\"Print human-readable text report\")\n parser.add_argument(\"--ambient\", type=float, default=DEFAULT_AMBIENT_C,\n help=f\"Ambient temperature in °C (default: {DEFAULT_AMBIENT_C})\")\n parser.add_argument(\"--datasheets\", \"-d\",\n help=\"Path to datasheets/extracted/ directory\")\n parser.add_argument(\"--config\", default=None,\n help=\"Path to .kicad-happy.json project config file\")\n parser.add_argument(\"--analysis-dir\",\n help=\"Write thermal.json to this directory (analysis folder convention)\")\n parser.add_argument(\"--schema\", action=\"store_true\",\n help=\"Print JSON output schema and exit\")\n parser.add_argument('--stage', default=None,\n choices=['schematic', 'layout', 'pre_fab', 'bring_up'],\n help='Filter findings by review stage')\n parser.add_argument('--audience', default=None,\n choices=['designer', 'reviewer', 'manager'],\n help='Audience level for summaries and --text output')\n args = parser.parse_args()\n\n if args.schema:\n schema = {\n \"analyzer_type\": \"string — always 'thermal'\",\n \"schema_version\": \"string — semver (currently '1.3.0')\",\n \"summary\": {\n \"total_findings\": \"int\",\n \"components_assessed\": \"int\",\n \"active\": \"int — non-suppressed findings\",\n \"suppressed\": \"int\",\n \"critical\": \"int — deprecated, retained for consumer compat\",\n \"high\": \"int — deprecated, retained for consumer compat\",\n \"medium\": \"int — deprecated, retained for consumer compat\",\n \"low\": \"int — deprecated, retained for consumer compat\",\n \"info\": \"int — deprecated, retained for consumer compat\",\n \"by_severity\": \"{error: int, warning: int, info: int}\",\n \"thermal_score\": \"float (0-100)\",\n },\n \"findings\": \"[{detector, rule_id, category, severity, confidence, evidence_source, summary, description, components, nets, pins, recommendation, report_context}] — TS-001..005, TP-001..002, TH-DET assessments\",\n \"trust_summary\": {\n \"total_findings\": \"int\",\n \"trust_level\": \"'high' | 'mixed' | 'low'\",\n \"by_confidence\": \"{deterministic: int, heuristic: int, datasheet-backed: int}\",\n \"by_evidence_source\": \"{datasheet|topology|heuristic_rule|symbol_footprint|bom|geometry|api_lookup: int}\",\n \"provenance_coverage_pct\": \"float\",\n },\n \"elapsed_s\": \"float — analysis wall-clock time\",\n \"missing_info\": \"OPTIONAL — {default_rtheta_ja: [ref], default_tj_max: [ref]} when any component used default thermal parameters\",\n }\n print(json.dumps(schema, indent=2))\n sys.exit(0)\n\n if not args.schematic or not args.pcb:\n parser.error(\"the --schematic and --pcb arguments are required (except with --schema)\")\n\n # Load inputs\n try:\n with open(args.schematic) as f:\n schematic = json.load(f)\n except (json.JSONDecodeError, OSError) as e:\n print(f\"Error reading schematic: {e}\", file=sys.stderr)\n sys.exit(1)\n\n if 'signal_analysis' in schematic and 'findings' not in schematic:\n print(f'Error: {args.schematic} uses the pre-v1.3 '\n f'signal_analysis wrapper format.\\n'\n f'Re-run analyze_schematic.py to produce the current '\n f'findings[] format.', file=sys.stderr)\n sys.exit(1)\n\n try:\n with open(args.pcb) as f:\n pcb = json.load(f)\n except (json.JSONDecodeError, OSError) as e:\n print(f\"Error reading PCB: {e}\", file=sys.stderr)\n sys.exit(1)\n\n # Resolve datasheets directory\n extract_dir = args.datasheets\n if not extract_dir:\n # Prefer directory of actual input file over path stored inside JSON\n input_path = args.schematic if hasattr(args, 'schematic') and args.schematic else None\n search_base = os.path.dirname(os.path.abspath(input_path)) if input_path else None\n if not search_base:\n sch_file = schematic.get(\"file\", \"\")\n search_base = os.path.dirname(sch_file) if sch_file else None\n if search_base:\n candidate = os.path.join(search_base, \"datasheets\", \"extracted\")\n if os.path.isdir(candidate):\n extract_dir = candidate\n\n # Load project config\n try:\n from project_config import load_config_from_path, load_config, apply_suppressions\n if args.config:\n config = load_config_from_path(args.config)\n else:\n input_path = args.schematic if hasattr(args, 'schematic') and args.schematic else None\n if input_path:\n search = os.path.dirname(os.path.abspath(input_path))\n else:\n sch_file = schematic.get(\"file\", \"\")\n search = os.path.dirname(sch_file) if sch_file else \".\"\n config = load_config(search)\n except ImportError:\n config = {\"version\": 1, \"project\": {}, \"suppressions\": []}\n\n # Apply config defaults\n project = config.get(\"project\", {})\n if args.ambient == DEFAULT_AMBIENT_C and project.get(\"ambient_temperature_c\"):\n args.ambient = project[\"ambient_temperature_c\"]\n\n t0 = time.monotonic()\n\n # Estimate power dissipation\n power_comps = _estimate_all_power_dissipation(schematic)\n\n # Compute junction temperatures\n assessments = _compute_junction_temps(\n power_comps, pcb, extract_dir, args.ambient)\n\n # Generate findings\n findings = _generate_findings(assessments)\n findings.extend(_check_thermal_proximity(assessments, pcb))\n\n # Apply suppressions\n try:\n apply_suppressions(findings, config.get(\"suppressions\", []))\n except NameError:\n pass # project_config not available\n\n # Score (only active findings)\n score = compute_thermal_score(\n [f for f in findings if not f.get(\"suppressed\")])\n\n # Severity counts over the rule findings (thermal_assessments are\n # merged into findings[] further down and contribute info-level\n # entries — a final recompute below keeps summary in sync with the\n # merged list).\n counts = {\"error\": 0, \"warning\": 0, \"info\": 0}\n suppressed_count = 0\n for f in findings:\n sev = str(f.get(\"severity\", \"info\")).lower()\n if sev in counts:\n counts[sev] += 1\n else:\n counts[\"info\"] += 1\n if f.get(\"suppressed\"):\n suppressed_count += 1\n\n # Board summary\n board = _board_summary(assessments, args.ambient)\n\n elapsed = time.monotonic() - t0\n\n # Missing information — components with default thermal parameters\n missing_info = {}\n default_rtheta = [a[\"ref\"] for a in assessments\n if a.get(\"rtheta_ja_source\") == \"default\"]\n if default_rtheta:\n missing_info[\"default_rtheta_ja\"] = default_rtheta\n default_tjmax = [a[\"ref\"] for a in assessments\n if a.get(\"tj_max_source\") == \"default_125\"]\n if default_tjmax:\n missing_info[\"default_tj_max\"] = default_tjmax\n\n result = {\n \"analyzer_type\": \"thermal\",\n \"schema_version\": \"1.3.0\",\n \"summary\": {\n \"total_findings\": len(findings),\n \"components_assessed\": len(assessments),\n \"active\": len(findings) - suppressed_count,\n \"suppressed\": suppressed_count,\n # Standardized severity rollup (single source — raw\n # per-severity aliases were removed in v1.3 Batch 20).\n \"by_severity\": dict(counts),\n \"thermal_score\": score,\n **board,\n },\n \"findings\": findings,\n \"thermal_assessments\": assessments,\n \"elapsed_s\": round(elapsed, 3),\n }\n if missing_info:\n result[\"missing_info\"] = missing_info\n\n # Merge thermal_assessments into findings (TH-DET entries)\n result[\"findings\"] = result.get(\"findings\", []) + result.pop(\"thermal_assessments\", [])\n\n # Recompute summary from the merged findings list — the earlier\n # `counts` block only saw rule findings, not the TH-DET assessments\n # we just appended. Keep the envelope consistent with findings[].\n _merged = result.get(\"findings\", [])\n _merged_counts = {\"error\": 0, \"warning\": 0, \"info\": 0}\n _merged_suppressed = 0\n for _f in _merged:\n if not isinstance(_f, dict):\n continue\n _s = str(_f.get(\"severity\", \"info\")).lower()\n if _s in _merged_counts:\n _merged_counts[_s] += 1\n else:\n _merged_counts[\"info\"] += 1\n if _f.get(\"suppressed\"):\n _merged_suppressed += 1\n result[\"summary\"][\"total_findings\"] = len(_merged)\n result[\"summary\"][\"by_severity\"] = _merged_counts\n result[\"summary\"][\"active\"] = len(_merged) - _merged_suppressed\n result[\"summary\"][\"suppressed\"] = _merged_suppressed\n\n from finding_schema import compute_trust_summary\n result[\"trust_summary\"] = compute_trust_summary(result[\"findings\"])\n\n from output_filters import apply_output_filters\n apply_output_filters(result, args.stage, args.audience)\n\n # Determine output path\n output_path = args.output\n analysis_dir_mode = (not output_path\n and hasattr(args, \"analysis_dir\")\n and args.analysis_dir)\n\n # Output\n if analysis_dir_mode:\n # Route into the current run folder via the manifest. Falls back to\n # writing at the analysis-dir root if nothing is tracked yet (first\n # ever run — shouldn't happen since schematic/pcb precede thermal,\n # but be defensive).\n import tempfile\n from analysis_cache import overwrite_current, CANONICAL_OUTPUTS, get_current_run\n analysis_dir = args.analysis_dir\n if not os.path.isabs(analysis_dir):\n analysis_dir = os.path.abspath(analysis_dir)\n filename = CANONICAL_OUTPUTS.get(\"thermal\", \"thermal.json\")\n with tempfile.TemporaryDirectory() as tmp_dir:\n tmp_out = os.path.join(tmp_dir, filename)\n with open(tmp_out, \"w\") as f:\n json.dump(result, f, indent=2)\n overwrite_current(analysis_dir, tmp_dir, source_hashes=None)\n current = get_current_run(analysis_dir)\n if current:\n out_path = os.path.join(current[0], filename)\n else:\n out_path = os.path.join(analysis_dir, filename)\n print(f\"Thermal analysis complete: {len(findings)} findings \"\n f\"(score {score}/100) -> {out_path}\", file=sys.stderr)\n elif output_path:\n with open(output_path, \"w\") as f:\n json.dump(result, f, indent=2)\n print(f\"Thermal analysis complete: {len(findings)} findings \"\n f\"(score {score}/100) -> {output_path}\", file=sys.stderr)\n elif args.text:\n if args.audience:\n from output_filters import format_text\n print(format_text(result.get('findings', []), args.audience, args.stage))\n else:\n print(format_text_report(result))\n else:\n json.dump(result, sys.stdout, indent=2)\n print(file=sys.stdout)\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":45044,"content_sha256":"5ec7ecdfd66a27d3dace9c9fc1b14d31689a12e6b385f4aa8d1c7fa97ea21644"},{"filename":"scripts/cross_analysis.py","content":"#!/usr/bin/env python3\n\"\"\"Cross-domain analysis — checks requiring both schematic and PCB data.\n\nConsumes schematic and PCB analyzer JSON outputs. Produces rich findings\nfor checks that span the schematic-PCB boundary: connector current capacity,\nESD coverage gaps, decoupling adequacy, and schematic/PCB cross-validation.\n\nUsage:\n python3 cross_analysis.py --schematic sch.json --pcb pcb.json [--output cross.json]\n python3 cross_analysis.py --schematic sch.json # PCB-less mode (limited checks)\n python3 cross_analysis.py --schema # Print output schema\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport json\nimport math\nimport os\nimport re\nimport sys\nimport time\n\nsys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))\n\nfrom finding_schema import make_finding, compute_trust_summary\nfrom kicad_utils import build_net_id_map as _build_net_id_map\n\n\n# ---------------------------------------------------------------------------\n# Helpers\n# ---------------------------------------------------------------------------\n\ndef _is_ground_net(name: str) -> bool:\n if not name:\n return False\n n = name.upper().replace('/', '').replace('-', '').replace('_', '')\n return n in ('GND', 'VSS', 'DGND', 'AGND', 'PGND', 'GNDD', 'GNDA',\n 'GND_D', 'GND_A', 'EARTH', 'CHASSIS', '0V')\n\n\ndef _is_power_net(name: str) -> bool:\n if not name:\n return False\n n = name.upper()\n if _is_ground_net(name):\n return True\n if n.startswith(('+', 'VCC', 'VDD', 'VBUS', 'VIN', 'VBAT', 'VSYS')):\n return True\n if re.match(r'^\\+?\\d+V\\d*', n):\n return True\n return False\n\n\ndef _parse_voltage_from_name(name: str) -> float | None:\n if not name:\n return None\n m = re.search(r'(\\d+)V(\\d+)', name.upper())\n if m:\n return float(m.group(1)) + float(m.group(2)) / (10 ** len(m.group(2)))\n m = re.search(r'(\\d+\\.?\\d*)V', name.upper())\n if m:\n return float(m.group(1))\n return None\n\n\ndef _flagged_return_path_entries(pcb: dict, threshold_pct: float = 95.0) -> dict[str, dict]:\n \"\"\"Return nets with measured return-plane coverage below threshold.\"\"\"\n flagged: dict[str, dict] = {}\n for entry in pcb.get('return_path_continuity', []) or []:\n net_name = entry.get('net', '')\n coverage = entry.get('reference_plane_coverage_pct', 100)\n if net_name and isinstance(coverage, (int, float)) and coverage \u003c threshold_pct:\n flagged[net_name] = entry\n return flagged\n\n\ndef _island_size_map(graph: dict) -> dict[int, int]:\n \"\"\"Return {island_id: member_count} for a connectivity graph.\"\"\"\n sizes: dict[int, int] = {}\n for island_id in (graph.get('components', {}) or {}).values():\n if isinstance(island_id, int):\n sizes[island_id] = sizes.get(island_id, 0) + 1\n return sizes\n\n\n# ---------------------------------------------------------------------------\n# CC-001: Connector current capacity\n# ---------------------------------------------------------------------------\n\n_IPC2152_1OZ_10C = {\n 0.5: 0.25, 1.0: 0.50, 2.0: 1.10, 3.0: 1.80,\n 5.0: 3.50, 7.0: 5.50, 10.0: 9.0,\n}\n\n\ndef _min_trace_width_for_current(current_a: float) -> float:\n prev_i, prev_w = 0.0, 0.0\n for i, w in sorted(_IPC2152_1OZ_10C.items()):\n if current_a \u003c= i:\n if prev_i == 0:\n return w\n frac = (current_a - prev_i) / (i - prev_i)\n return prev_w + frac * (w - prev_w)\n prev_i, prev_w = i, w\n return prev_w\n\n\n_NET_NAME_HEURISTICS = [\n (re.compile(r'(?i)CLK|CLOCK|XTAL|OSC'), 'clock'),\n (re.compile(r'(?i)USB_D[PM]|USB_D\\+|USB_D.|USBDP|USBDM'), 'usb'),\n (re.compile(r'(?i)ETH_|MDIO|MDC|TX[PN]|RX[PN]'), 'ethernet'),\n (re.compile(r'(?i)DDR_|DQ\\d|DQS|DM\\d|CKE|ODT'), 'memory'),\n (re.compile(r'(?i)^SDA$|^SCL$|I2C'), 'i2c'),\n (re.compile(r'(?i)CAN[HL]|CAN_[HL]'), 'can'),\n (re.compile(r'(?i)MISO|MOSI|SCK|SPI'), 'spi'),\n (re.compile(r'(?i)\\bSW$|SW_NODE|^LX

KiCad Project Analysis Skill Related Skills | Skill | Purpose | |-------|---------| | | BOM extraction, enrichment, ordering, and export workflows | | | Search DigiKey for parts (prototype sourcing) | | | Search Mouser for parts (secondary prototype source) | | | Search LCSC for parts (production sourcing, JLCPCB) | | | Search Newark/Farnell/element14 (international sourcing, reliable datasheets) | | | PCB fabrication & assembly ordering | | | Alternative PCB fabrication & assembly | | | SPICE simulation verification of detected subcircuits | | | EMC pre-compliance risk analysis — consumes sc…

), 'switching_node'),\n (re.compile(r'(?i)LVDS|MIPI'), 'lvds'),\n]\n\n_HIGH_SPEED_TYPES = {'clock', 'usb', 'ethernet', 'memory', 'hdmi', 'lvds', 'rf'}\n\n\ndef _get_net_classification(net_name, schematic):\n if schematic:\n classifications = schematic.get('net_classifications', {})\n if net_name in classifications:\n return classifications[net_name]\n for pattern, net_type in _NET_NAME_HEURISTICS:\n if pattern.search(net_name):\n return {'type': net_type, 'source': 'name_heuristic'}\n return None\n\n\ndef _get_highest_frequency(schematic):\n highest = 0.0\n if not schematic:\n return highest\n findings = schematic.get('findings', [])\n for f in findings:\n det = f.get('detector', '')\n if det == 'detect_crystal_circuits':\n freq = f.get('frequency')\n if isinstance(freq, (int, float)) and freq > highest:\n highest = freq\n elif det == 'detect_power_regulators':\n freq = f.get('switching_frequency_hz')\n if isinstance(freq, (int, float)) and freq > highest:\n highest = freq\n return highest\n\n\ndef _point_to_segment_distance(px, py, x1, y1, x2, y2):\n dx, dy = x2 - x1, y2 - y1\n length_sq = dx * dx + dy * dy\n if length_sq == 0:\n return math.sqrt((px - x1) ** 2 + (py - y1) ** 2)\n t = max(0, min(1, ((px - x1) * dx + (py - y1) * dy) / length_sq))\n proj_x = x1 + t * dx\n proj_y = y1 + t * dy\n return math.sqrt((px - proj_x) ** 2 + (py - proj_y) ** 2)\n\n\ndef check_connector_current(schematic: dict, pcb: dict | None) -> list[dict]:\n \"\"\"CC-001: Check connector pin current capacity vs trace width.\"\"\"\n findings: list[dict] = []\n if not pcb:\n return findings\n\n footprints = pcb.get('footprints', [])\n fp_map = {fp.get('reference', ''): fp for fp in footprints}\n\n segments = pcb.get('tracks', {}).get('segments', [])\n net_id_map = _build_net_id_map(pcb)\n\n net_min_width: dict[str, float] = {}\n for seg in segments:\n net_id = seg.get('net', 0)\n net_name = net_id_map.get(net_id, '') if isinstance(net_id, int) else str(net_id)\n w = seg.get('width', 0) or 0\n if net_name and w > 0:\n if net_name not in net_min_width or w \u003c net_min_width[net_name]:\n net_min_width[net_name] = w\n\n components = schematic.get('components', [])\n connectors = [c for c in components if c.get('type') == 'connector']\n regulators = [f for f in schematic.get('findings', [])\n if f.get('detector') == 'detect_power_regulators']\n\n for conn in connectors:\n ref = conn['reference']\n fp = fp_map.get(ref)\n if not fp:\n continue\n for pad in fp.get('pads', []):\n net_name = pad.get('net_name', '')\n if not net_name or _is_ground_net(net_name) or not _is_power_net(net_name):\n continue\n voltage = _parse_voltage_from_name(net_name)\n if voltage is None:\n continue\n total_current = sum(\n reg.get('estimated_iout_A', 0) or 0\n for reg in regulators\n if reg.get('input_rail') == net_name\n )\n if total_current \u003c= 0:\n continue\n trace_w = net_min_width.get(net_name)\n if trace_w is None:\n continue\n min_w = _min_trace_width_for_current(total_current)\n if trace_w \u003c min_w * 0.8:\n findings.append(make_finding(\n detector='check_connector_current', rule_id='CC-001',\n category='current_capacity',\n summary=f'Connector {ref}: trace on {net_name} too narrow for ~{total_current:.1f}A',\n description=(\n f'Power net {net_name} at connector {ref} carries estimated '\n f'{total_current:.1f}A but narrowest trace is {trace_w:.2f}mm. '\n f'IPC-2152 recommends >= {min_w:.2f}mm (1oz Cu, 10C rise).'\n ),\n severity='warning', confidence='heuristic', evidence_source='topology',\n components=[ref], nets=[net_name],\n recommendation=f'Widen trace on {net_name} to >= {min_w:.1f}mm or use copper pour.',\n standard_ref='IPC-2152', impact='Trace overheating and voltage drop',\n ))\n return findings\n\n\n# ---------------------------------------------------------------------------\n# EG-001: ESD coverage gap analysis\n# ---------------------------------------------------------------------------\n\n_EXTERNAL_CONNECTOR_KEYWORDS = (\n 'usb', 'rj45', 'rj11', 'ethernet', 'hdmi', 'displayport',\n 'barrel', 'dc_jack', 'bnc', 'sma', 'din', 'dsub', 'db9', 'db25',\n 'screw_terminal', 'phoenix', 'molex',\n)\n\n\ndef check_esd_coverage_gaps(schematic: dict, pcb: dict | None) -> list[dict]:\n \"\"\"EG-001: Check for external connector pins missing ESD protection.\"\"\"\n findings: list[dict] = []\n protection = [f for f in schematic.get('findings', [])\n if f.get('detector') == 'detect_protection_devices']\n\n protected_nets: set[str] = set()\n for pd in protection:\n pnet = pd.get('protected_net', '')\n if pnet:\n protected_nets.add(pnet)\n for pn in pd.get('protected_nets', []):\n protected_nets.add(pn)\n\n components = schematic.get('components', [])\n connectors = [c for c in components if c.get('type') == 'connector']\n\n # Build pin_net lookup from schematic components\n pin_net_data = schematic.get('pin_net', {})\n\n for conn in connectors:\n val_lib = (conn.get('value', '') + ' ' + conn.get('lib_id', '')).lower()\n if not any(k in val_lib for k in _EXTERNAL_CONNECTOR_KEYWORDS):\n continue\n ref = conn['reference']\n\n unprotected_nets = []\n # Check via pin_net data (keys are \"ref:pin_number\" strings or tuples)\n if isinstance(pin_net_data, dict):\n for key, val in pin_net_data.items():\n key_str = str(key)\n if not key_str.startswith(ref + ':') and not key_str.startswith(f\"('{ref}'\"):\n continue\n net = val[0] if isinstance(val, (list, tuple)) else val\n if not net or _is_power_net(net) or _is_ground_net(net):\n continue\n if net not in protected_nets:\n unprotected_nets.append(net)\n\n # Deduplicate\n unprotected_nets = list(dict.fromkeys(unprotected_nets))\n\n if unprotected_nets:\n findings.append(make_finding(\n detector='check_esd_coverage_gaps', rule_id='EG-001',\n category='esd_protection',\n summary=f'Connector {ref}: {len(unprotected_nets)} unprotected signal pin(s)',\n description=(\n f'External connector {ref} ({conn.get(\"value\", \"\")}) has '\n f'{len(unprotected_nets)} unprotected signal net(s): '\n f'{\", \".join(unprotected_nets[:5])}{\"...\" if len(unprotected_nets) > 5 else \"\"}.'\n ),\n severity='warning', confidence='heuristic', evidence_source='topology',\n components=[ref], nets=unprotected_nets[:10],\n recommendation='Add TVS or ESD clamp diodes on unprotected external nets.',\n fix_params={\n 'type': 'add_protection',\n 'components': [{'type': 'tvs_diode', 'nets': unprotected_nets[:5]}],\n 'basis': 'IEC 61000-4-2 requires ESD protection on accessible pins',\n },\n standard_ref='IEC 61000-4-2', impact='ESD damage on unprotected pins',\n ))\n return findings\n\n\n# ---------------------------------------------------------------------------\n# DA-001: Decoupling strategy adequacy\n# ---------------------------------------------------------------------------\n\ndef check_decoupling_adequacy(schematic: dict, pcb: dict | None) -> list[dict]:\n \"\"\"DA-001: Per-IC decoupling assessment — count, value, and placement.\"\"\"\n findings: list[dict] = []\n decoupling_list = [f for f in schematic.get('findings', [])\n if f.get('detector') == 'detect_decoupling']\n decoupling = decoupling_list[0] if decoupling_list else {}\n if not decoupling:\n return findings\n\n rails = decoupling.get('per_rail', decoupling.get('rails', []))\n if isinstance(rails, dict):\n rails = list(rails.values())\n\n for rail in rails:\n rail_name = rail.get('rail', rail.get('name', ''))\n caps = rail.get('capacitors', [])\n ics = rail.get('ics', rail.get('ic_count', 0))\n ic_count = ics if isinstance(ics, int) else len(ics) if isinstance(ics, list) else 0\n if ic_count == 0:\n continue\n cap_count = len(caps)\n if cap_count \u003c ic_count:\n findings.append(make_finding(\n detector='check_decoupling_adequacy', rule_id='DA-001',\n category='power_integrity',\n summary=f'Rail {rail_name}: {cap_count} caps for {ic_count} ICs',\n description=(\n f'Power rail {rail_name} has {cap_count} decoupling cap(s) for '\n f'{ic_count} IC(s). Best practice: at least one 100nF per IC.'\n ),\n severity='warning' if cap_count == 0 else 'info',\n confidence='heuristic', evidence_source='topology',\n nets=[rail_name],\n recommendation=f'Add {ic_count - cap_count} more 100nF caps on {rail_name}.',\n fix_params={\n 'type': 'add_component',\n 'components': [{'type': 'capacitor', 'value': '100n',\n 'net_from': rail_name, 'net_to': 'GND'}] * min(ic_count - cap_count, 5),\n 'basis': 'One 100nF per IC power pin pair minimum',\n },\n impact='Increased power supply noise and EMI',\n ))\n return findings\n\n\n# ---------------------------------------------------------------------------\n# XV-001..003: Schematic/PCB cross-validation\n# ---------------------------------------------------------------------------\n\ndef check_cross_validation(schematic: dict, pcb: dict | None) -> list[dict]:\n \"\"\"XV-001..003: Cross-validate schematic and PCB data consistency.\"\"\"\n findings: list[dict] = []\n if not pcb:\n return findings\n\n sch_refs = {c.get('reference', '') for c in schematic.get('components', [])\n if c.get('reference', '') and not c['reference'].startswith('#')}\n pcb_refs = {fp.get('reference', '') for fp in pcb.get('footprints', [])\n if fp.get('reference', '') and not fp['reference'].startswith('#')}\n\n in_sch_not_pcb = sch_refs - pcb_refs\n in_pcb_not_sch = pcb_refs - sch_refs\n\n # XV-001: Components in schematic but not PCB\n real_missing = {r for r in in_sch_not_pcb if not r.startswith(('TP', 'MH', 'NT', 'FID'))}\n if real_missing:\n findings.append(make_finding(\n detector='check_cross_validation', rule_id='XV-001', category='design_sync',\n summary=f'{len(real_missing)} component(s) in schematic but not PCB',\n description=f'Missing from PCB: {\", \".join(sorted(real_missing)[:20])}{\"...\" if len(real_missing) > 20 else \"\"}.',\n severity='warning', confidence='deterministic', evidence_source='topology',\n components=sorted(real_missing)[:20],\n recommendation='Update PCB from schematic (Tools > Update PCB from Schematic).',\n impact='Missing components on manufactured board',\n ))\n\n # XV-001: Components in PCB but not schematic\n real_extra = {r for r in in_pcb_not_sch if not r.startswith(('TP', 'MH', 'NT', 'FID', 'H', 'G'))}\n if real_extra:\n findings.append(make_finding(\n detector='check_cross_validation', rule_id='XV-001', category='design_sync',\n summary=f'{len(real_extra)} component(s) in PCB but not schematic',\n description=f'Extra in PCB: {\", \".join(sorted(real_extra)[:20])}{\"...\" if len(real_extra) > 20 else \"\"}.',\n severity='info', confidence='deterministic', evidence_source='topology',\n components=sorted(real_extra)[:20],\n recommendation='Verify these are intentional (mounting holes, test points, fiducials).',\n ))\n\n # XV-002: Value consistency\n pcb_fp_map = {fp.get('reference', ''): fp for fp in pcb.get('footprints', [])}\n sch_comp_map = {c.get('reference', ''): c for c in schematic.get('components', [])}\n for ref in sch_refs & pcb_refs:\n sch_val = sch_comp_map.get(ref, {}).get('value', '')\n pcb_val = pcb_fp_map.get(ref, {}).get('value', '')\n if sch_val and pcb_val and sch_val != pcb_val:\n if sch_val.replace(' ', '') == pcb_val.replace(' ', ''):\n continue\n findings.append(make_finding(\n detector='check_cross_validation', rule_id='XV-002', category='design_sync',\n summary=f'{ref}: value mismatch — \"{sch_val}\" vs \"{pcb_val}\"',\n description=f'{ref} has \"{sch_val}\" in schematic but \"{pcb_val}\" in PCB.',\n severity='warning', confidence='deterministic', evidence_source='topology',\n components=[ref],\n recommendation='Sync PCB with schematic to resolve value differences.',\n impact='Wrong component may be placed during assembly',\n ))\n\n return findings\n\n\n# ---------------------------------------------------------------------------\n# NR-001: Critical net routing near board edges\n# ---------------------------------------------------------------------------\n\n_EDGE_DISTANCE_ERROR_MM = 1.0\n_EDGE_DISTANCE_WARN_MM = 2.0\n\n\ndef check_critical_net_routing(schematic, pcb):\n \"\"\"NR-001: Flag high-speed/clock signal traces routed near board edges.\"\"\"\n findings = []\n if not pcb:\n return findings\n segments = pcb.get('tracks', {}).get('segments', [])\n if not segments:\n return findings\n outline = pcb.get('board_outline', {})\n outline_segs = outline.get('segments', [])\n if not outline_segs:\n return findings\n net_id_map = _build_net_id_map(pcb)\n flagged_nets = {}\n for seg in segments:\n net_id = seg.get('net', 0)\n net_name = net_id_map.get(net_id, '') if isinstance(net_id, int) else str(net_id)\n if not net_name or _is_power_net(net_name) or _is_ground_net(net_name):\n continue\n classification = _get_net_classification(net_name, schematic)\n if not classification or classification.get('type') not in _HIGH_SPEED_TYPES:\n continue\n mx = (seg.get('x1', 0) + seg.get('x2', 0)) / 2\n my = (seg.get('y1', 0) + seg.get('y2', 0)) / 2\n min_dist = float('inf')\n for edge in outline_segs:\n ex1 = edge.get('x1', edge.get('start_x', 0))\n ey1 = edge.get('y1', edge.get('start_y', 0))\n ex2 = edge.get('x2', edge.get('end_x', 0))\n ey2 = edge.get('y2', edge.get('end_y', 0))\n d = _point_to_segment_distance(mx, my, ex1, ey1, ex2, ey2)\n if d \u003c min_dist:\n min_dist = d\n if min_dist \u003c _EDGE_DISTANCE_WARN_MM:\n if net_name not in flagged_nets or min_dist \u003c flagged_nets[net_name]:\n flagged_nets[net_name] = min_dist\n for net_name, dist in flagged_nets.items():\n classification = _get_net_classification(net_name, schematic)\n net_type = classification.get('type', 'signal') if classification else 'signal'\n severity = 'error' if dist \u003c _EDGE_DISTANCE_ERROR_MM else 'warning'\n findings.append(make_finding(\n detector='check_critical_net_routing', rule_id='NR-001',\n category='signal_routing',\n summary=f'{net_type} net {net_name}: {dist:.1f}mm from board edge',\n description=f'High-speed {net_type} net {net_name} is routed {dist:.1f}mm from the board edge. Signals near edges radiate more effectively.',\n severity=severity, confidence='deterministic', evidence_source='topology',\n nets=[net_name],\n recommendation=f'Re-route {net_name} at least {_EDGE_DISTANCE_WARN_MM}mm from board edges.',\n impact='Increased EMI radiation and susceptibility',\n ))\n return findings\n\n\n# ---------------------------------------------------------------------------\n# RP-002: Enhanced return path validation\n# ---------------------------------------------------------------------------\n\ndef check_return_path_enhanced(schematic, pcb):\n \"\"\"RP-002: Check for reference plane gaps under classified signal nets.\"\"\"\n findings = []\n if not pcb:\n return findings\n segments = pcb.get('tracks', {}).get('segments', [])\n if not segments:\n return findings\n conn_graph = pcb.get('connectivity_graph', {})\n net_id_map = _build_net_id_map(pcb)\n rpc_flagged = _flagged_return_path_entries(pcb, threshold_pct=95.0)\n has_rpc_data = len(pcb.get('return_path_continuity', []) or []) > 0\n plane_gaps = []\n for net_name, graph in conn_graph.items():\n if not (_is_ground_net(net_name) or _is_power_net(net_name)):\n continue\n for gap in graph.get('gaps', []):\n plane_gaps.append({**gap, 'net': net_name})\n if not plane_gaps:\n rpc = pcb.get('return_path_continuity', [])\n for entry in rpc:\n coverage = entry.get('reference_plane_coverage_pct', 100)\n if coverage \u003c 90:\n net_name = entry.get('net', '')\n classification = _get_net_classification(net_name, schematic)\n if classification and classification.get('type') in _HIGH_SPEED_TYPES:\n findings.append(make_finding(\n detector='check_return_path_enhanced', rule_id='RP-002',\n category='return_path',\n summary=f'Net {net_name}: {coverage:.0f}% reference plane coverage',\n description=f'High-speed net {net_name} has only {coverage:.0f}% reference plane coverage.',\n severity='warning', confidence='heuristic', evidence_source='topology',\n nets=[net_name],\n recommendation='Re-route signal to avoid reference plane gaps.',\n impact='Increased loop area and EMI',\n ))\n return findings\n flagged = set()\n for seg in segments:\n net_id = seg.get('net', 0)\n net_name = net_id_map.get(net_id, '') if isinstance(net_id, int) else str(net_id)\n if not net_name or _is_power_net(net_name) or _is_ground_net(net_name):\n continue\n if net_name in flagged:\n continue\n if has_rpc_data and net_name not in rpc_flagged:\n continue\n classification = _get_net_classification(net_name, schematic)\n if not classification:\n continue\n sx1, sy1 = seg.get('x1', 0), seg.get('y1', 0)\n sx2, sy2 = seg.get('x2', 0), seg.get('y2', 0)\n seg_min_x, seg_max_x = min(sx1, sx2), max(sx1, sx2)\n seg_min_y, seg_max_y = min(sy1, sy2), max(sy1, sy2)\n for gap in plane_gaps:\n bbox = gap.get('bbox', [0, 0, 0, 0])\n if (seg_max_x >= bbox[0] and seg_min_x \u003c= bbox[2] and\n seg_max_y >= bbox[1] and seg_min_y \u003c= bbox[3]):\n net_type = classification.get('type', 'signal')\n severity = 'error' if net_type in _HIGH_SPEED_TYPES else 'warning'\n findings.append(make_finding(\n detector='check_return_path_enhanced', rule_id='RP-002',\n category='return_path',\n summary=f'{net_type} net {net_name} crosses {gap[\"net\"]} plane gap',\n description=f'{net_type} signal {net_name} crosses a gap in {gap[\"net\"]} plane on layer {gap.get(\"layer\", \"?\")}.',\n severity=severity, confidence='deterministic', evidence_source='topology',\n nets=[net_name, gap['net']],\n recommendation=f'Re-route {net_name} to avoid the {gap[\"net\"]} plane gap, or bridge with a stitching capacitor.',\n impact='Increased EMI from enlarged return path loop',\n ))\n flagged.add(net_name)\n break\n return findings\n\n\n# ---------------------------------------------------------------------------\n# TW-001: Trace width validation\n# ---------------------------------------------------------------------------\n\ndef check_trace_width_power(schematic, pcb):\n \"\"\"TW-001: Check all power net trace widths against IPC-2152.\"\"\"\n findings = []\n if not pcb or not schematic:\n return findings\n segments = pcb.get('tracks', {}).get('segments', [])\n if not segments:\n return findings\n net_id_map = _build_net_id_map(pcb)\n regulators = [f for f in schematic.get('findings', [])\n if f.get('detector') == 'detect_power_regulators']\n net_current = {}\n for reg in regulators:\n output_rail = reg.get('output_rail', '')\n iout = reg.get('estimated_iout_A', 0) or 0\n if output_rail and iout > 0:\n net_current[output_rail] = max(net_current.get(output_rail, 0), iout)\n input_rail = reg.get('input_rail', '')\n if input_rail and iout > 0:\n net_current[input_rail] = net_current.get(input_rail, 0) + iout\n if not net_current:\n return findings\n net_min_width = {}\n for seg in segments:\n net_id = seg.get('net', 0)\n net_name = net_id_map.get(net_id, '') if isinstance(net_id, int) else str(net_id)\n w = seg.get('width', 0) or 0\n if net_name in net_current and w > 0:\n if net_name not in net_min_width or w \u003c net_min_width[net_name]:\n net_min_width[net_name] = w\n for net_name, current in net_current.items():\n trace_w = net_min_width.get(net_name)\n if trace_w is None:\n continue\n min_w = _min_trace_width_for_current(current)\n if trace_w \u003c min_w * 0.8:\n findings.append(make_finding(\n detector='check_trace_width_power', rule_id='TW-001',\n category='current_capacity',\n summary=f'Power net {net_name}: trace {trace_w:.2f}mm too narrow for ~{current:.1f}A',\n description=f'Power net {net_name} carries ~{current:.1f}A but narrowest trace is {trace_w:.2f}mm. IPC-2152 recommends >= {min_w:.2f}mm.',\n severity='warning', confidence='heuristic', evidence_source='topology',\n nets=[net_name],\n recommendation=f'Widen {net_name} traces to >= {min_w:.1f}mm or use copper pour.',\n fix_params={'type': 'resistor_value_change', 'change': f'trace width -> {min_w:.1f}mm', 'basis': f'IPC-2152: {current:.1f}A'},\n standard_ref='IPC-2152', impact='Trace overheating and voltage drop',\n ))\n return findings\n\n\n# ---------------------------------------------------------------------------\n# PS-002: Plane split detection\n# ---------------------------------------------------------------------------\n\ndef check_plane_splits(schematic, pcb):\n \"\"\"PS-002: Detect ground/power plane splits and signal traces crossing them.\"\"\"\n findings = []\n if not pcb:\n return findings\n conn_graph = pcb.get('connectivity_graph', {})\n if not conn_graph:\n return findings\n segments = pcb.get('tracks', {}).get('segments', [])\n net_id_map = _build_net_id_map(pcb)\n rpc_flagged = _flagged_return_path_entries(pcb, threshold_pct=95.0)\n for plane_net, graph in conn_graph.items():\n if not (_is_ground_net(plane_net) or _is_power_net(plane_net)):\n continue\n if graph.get('islands', 1) \u003c= 1:\n continue\n island_sizes = _island_size_map(graph)\n significant_islands = [size for size in island_sizes.values() if size >= 2]\n is_intentional = any(plane_net.upper().startswith(p) for p in ('AGND', 'DGND', 'PGND', 'GNDA', 'GNDD'))\n gaps = graph.get('gaps', [])\n if not gaps:\n continue\n crossing_signals = []\n for seg in segments:\n seg_net_id = seg.get('net', 0)\n seg_net = net_id_map.get(seg_net_id, '') if isinstance(seg_net_id, int) else str(seg_net_id)\n if not seg_net or _is_power_net(seg_net) or _is_ground_net(seg_net):\n continue\n sx1, sy1 = seg.get('x1', 0), seg.get('y1', 0)\n sx2, sy2 = seg.get('x2', 0), seg.get('y2', 0)\n for gap in gaps:\n bbox = gap.get('bbox', [0, 0, 0, 0])\n if (max(sx1, sx2) >= bbox[0] and min(sx1, sx2) \u003c= bbox[2] and\n max(sy1, sy2) >= bbox[1] and min(sy1, sy2) \u003c= bbox[3]):\n if seg_net not in crossing_signals:\n crossing_signals.append(seg_net)\n break\n crossing_signals_rpc = [s for s in crossing_signals if s in rpc_flagged]\n if len(significant_islands) \u003c= 1 and not crossing_signals_rpc:\n continue\n if is_intentional:\n severity = 'info'\n elif crossing_signals_rpc:\n has_hs = any(_get_net_classification(s, schematic) and\n _get_net_classification(s, schematic).get('type') in _HIGH_SPEED_TYPES\n for s in crossing_signals_rpc)\n severity = 'error' if has_hs else 'warning'\n else:\n severity = 'info'\n desc_signals = f' Signals crossing: {\", \".join(crossing_signals_rpc[:5])}.' if crossing_signals_rpc else ''\n findings.append(make_finding(\n detector='check_plane_splits', rule_id='PS-002',\n category='plane_integrity',\n summary=f'{plane_net} plane split: {graph[\"islands\"]} islands{\", \" + str(len(crossing_signals_rpc)) + \" signals crossing\" if crossing_signals_rpc else \"\"}',\n description=f'{plane_net} plane has {graph[\"islands\"]} disconnected islands.{desc_signals}',\n severity=severity, confidence='deterministic', evidence_source='topology',\n nets=[plane_net] + crossing_signals_rpc[:5],\n recommendation='Bridge the plane gap with copper pour or stitching vias.',\n impact='Return path discontinuity increases EMI',\n ))\n return findings\n\n\n# ---------------------------------------------------------------------------\n# VS-002: Via stitching density\n# ---------------------------------------------------------------------------\n\n_SPEED_OF_LIGHT = 3e8\n_EFFECTIVE_ER = 4.2\n\n\ndef check_via_stitching_density(schematic, pcb):\n \"\"\"VS-002: Check ground via stitching density against frequency requirements.\"\"\"\n findings = []\n if not pcb:\n return findings\n via_list = pcb.get('vias', {}).get('vias', [])\n if not via_list:\n return findings\n net_id_map = _build_net_id_map(pcb)\n outline = pcb.get('board_outline', {})\n bbox = outline.get('bounding_box', {})\n board_w = bbox.get('width', 0)\n board_h = bbox.get('height', 0)\n if board_w \u003c= 0 or board_h \u003c= 0:\n return findings\n highest_freq = _get_highest_frequency(schematic)\n if highest_freq \u003c= 0:\n highest_freq = 100e6\n wavelength_mm = (_SPEED_OF_LIGHT / math.sqrt(_EFFECTIVE_ER) / highest_freq) * 1000\n max_spacing_mm = wavelength_mm / 20\n board_x0 = bbox.get('x', bbox.get('min_x', 0))\n board_y0 = bbox.get('y', bbox.get('min_y', 0))\n gnd_vias = []\n for via in via_list:\n net_id = via.get('net', 0)\n net_name = net_id_map.get(net_id, '') if isinstance(net_id, int) else str(net_id)\n if _is_ground_net(net_name):\n gnd_vias.append((via.get('x', 0), via.get('y', 0)))\n if not gnd_vias:\n findings.append(make_finding(\n detector='check_via_stitching_density', rule_id='VS-002',\n category='via_stitching',\n summary='No ground stitching vias found',\n description='The board has no ground vias. Ground stitching vias are important for EMC.',\n severity='warning', confidence='deterministic', evidence_source='topology',\n recommendation=f'Add ground stitching vias at \u003c= {max_spacing_mm:.0f}mm spacing.',\n impact='Poor ground plane connectivity between layers',\n ))\n return findings\n cell_size = max_spacing_mm\n if cell_size \u003c= 0:\n return findings\n cells_x = max(1, int(math.ceil(board_w / cell_size)))\n cells_y = max(1, int(math.ceil(board_h / cell_size)))\n cell_counts = {}\n total_cells = cells_x * cells_y\n for vx, vy in gnd_vias:\n cx = max(0, min(int((vx - board_x0) / cell_size), cells_x - 1))\n cy = max(0, min(int((vy - board_y0) / cell_size), cells_y - 1))\n cell_counts[(cx, cy)] = cell_counts.get((cx, cy), 0) + 1\n empty_cells = total_cells - len(cell_counts)\n empty_pct = (empty_cells / total_cells * 100) if total_cells > 0 else 0\n if empty_pct > 30:\n findings.append(make_finding(\n detector='check_via_stitching_density', rule_id='VS-002',\n category='via_stitching',\n summary=f'Via stitching sparse: {empty_pct:.0f}% of board lacks ground vias',\n description=f'{empty_pct:.0f}% of board area (at {cell_size:.1f}mm grid) has no ground stitching vias. For {highest_freq/1e6:.0f}MHz, lambda/20 = {max_spacing_mm:.1f}mm.',\n severity='warning' if empty_pct > 50 else 'info',\n confidence='heuristic', evidence_source='topology',\n recommendation=f'Add ground stitching vias at \u003c= {max_spacing_mm:.0f}mm intervals.',\n impact='Degraded ground plane connectivity at high frequencies',\n ))\n return findings\n\n\n# ---------------------------------------------------------------------------\n# DP-005: Differential pair routing quality\n# ---------------------------------------------------------------------------\n\n_DIFF_PAIR_SUFFIXES = [\n (re.compile(r'(.+)[_]?P

KiCad Project Analysis Skill Related Skills | Skill | Purpose | |-------|---------| | | BOM extraction, enrichment, ordering, and export workflows | | | Search DigiKey for parts (prototype sourcing) | | | Search Mouser for parts (secondary prototype source) | | | Search LCSC for parts (production sourcing, JLCPCB) | | | Search Newark/Farnell/element14 (international sourcing, reliable datasheets) | | | PCB fabrication & assembly ordering | | | Alternative PCB fabrication & assembly | | | SPICE simulation verification of detected subcircuits | | | EMC pre-compliance risk analysis — consumes sc…

), re.compile(r'(.+)[_]?N

KiCad Project Analysis Skill Related Skills | Skill | Purpose | |-------|---------| | | BOM extraction, enrichment, ordering, and export workflows | | | Search DigiKey for parts (prototype sourcing) | | | Search Mouser for parts (secondary prototype source) | | | Search LCSC for parts (production sourcing, JLCPCB) | | | Search Newark/Farnell/element14 (international sourcing, reliable datasheets) | | | PCB fabrication & assembly ordering | | | Alternative PCB fabrication & assembly | | | SPICE simulation verification of detected subcircuits | | | EMC pre-compliance risk analysis — consumes sc…

)),\n (re.compile(r'(.+)\\+

KiCad Project Analysis Skill Related Skills | Skill | Purpose | |-------|---------| | | BOM extraction, enrichment, ordering, and export workflows | | | Search DigiKey for parts (prototype sourcing) | | | Search Mouser for parts (secondary prototype source) | | | Search LCSC for parts (production sourcing, JLCPCB) | | | Search Newark/Farnell/element14 (international sourcing, reliable datasheets) | | | PCB fabrication & assembly ordering | | | Alternative PCB fabrication & assembly | | | SPICE simulation verification of detected subcircuits | | | EMC pre-compliance risk analysis — consumes sc…

), re.compile(r'(.+)-

KiCad Project Analysis Skill Related Skills | Skill | Purpose | |-------|---------| | | BOM extraction, enrichment, ordering, and export workflows | | | Search DigiKey for parts (prototype sourcing) | | | Search Mouser for parts (secondary prototype source) | | | Search LCSC for parts (production sourcing, JLCPCB) | | | Search Newark/Farnell/element14 (international sourcing, reliable datasheets) | | | PCB fabrication & assembly ordering | | | Alternative PCB fabrication & assembly | | | SPICE simulation verification of detected subcircuits | | | EMC pre-compliance risk analysis — consumes sc…

)),\n (re.compile(r'(.+)_DP

KiCad Project Analysis Skill Related Skills | Skill | Purpose | |-------|---------| | | BOM extraction, enrichment, ordering, and export workflows | | | Search DigiKey for parts (prototype sourcing) | | | Search Mouser for parts (secondary prototype source) | | | Search LCSC for parts (production sourcing, JLCPCB) | | | Search Newark/Farnell/element14 (international sourcing, reliable datasheets) | | | PCB fabrication & assembly ordering | | | Alternative PCB fabrication & assembly | | | SPICE simulation verification of detected subcircuits | | | EMC pre-compliance risk analysis — consumes sc…

), re.compile(r'(.+)_DN

KiCad Project Analysis Skill Related Skills | Skill | Purpose | |-------|---------| | | BOM extraction, enrichment, ordering, and export workflows | | | Search DigiKey for parts (prototype sourcing) | | | Search Mouser for parts (secondary prototype source) | | | Search LCSC for parts (production sourcing, JLCPCB) | | | Search Newark/Farnell/element14 (international sourcing, reliable datasheets) | | | PCB fabrication & assembly ordering | | | Alternative PCB fabrication & assembly | | | SPICE simulation verification of detected subcircuits | | | EMC pre-compliance risk analysis — consumes sc…

)),\n (re.compile(r'(.+)_TXP

KiCad Project Analysis Skill Related Skills | Skill | Purpose | |-------|---------| | | BOM extraction, enrichment, ordering, and export workflows | | | Search DigiKey for parts (prototype sourcing) | | | Search Mouser for parts (secondary prototype source) | | | Search LCSC for parts (production sourcing, JLCPCB) | | | Search Newark/Farnell/element14 (international sourcing, reliable datasheets) | | | PCB fabrication & assembly ordering | | | Alternative PCB fabrication & assembly | | | SPICE simulation verification of detected subcircuits | | | EMC pre-compliance risk analysis — consumes sc…

), re.compile(r'(.+)_TXN

KiCad Project Analysis Skill Related Skills | Skill | Purpose | |-------|---------| | | BOM extraction, enrichment, ordering, and export workflows | | | Search DigiKey for parts (prototype sourcing) | | | Search Mouser for parts (secondary prototype source) | | | Search LCSC for parts (production sourcing, JLCPCB) | | | Search Newark/Farnell/element14 (international sourcing, reliable datasheets) | | | PCB fabrication & assembly ordering | | | Alternative PCB fabrication & assembly | | | SPICE simulation verification of detected subcircuits | | | EMC pre-compliance risk analysis — consumes sc…

)),\n (re.compile(r'(.+)_RXP

KiCad Project Analysis Skill Related Skills | Skill | Purpose | |-------|---------| | | BOM extraction, enrichment, ordering, and export workflows | | | Search DigiKey for parts (prototype sourcing) | | | Search Mouser for parts (secondary prototype source) | | | Search LCSC for parts (production sourcing, JLCPCB) | | | Search Newark/Farnell/element14 (international sourcing, reliable datasheets) | | | PCB fabrication & assembly ordering | | | Alternative PCB fabrication & assembly | | | SPICE simulation verification of detected subcircuits | | | EMC pre-compliance risk analysis — consumes sc…

), re.compile(r'(.+)_RXN

KiCad Project Analysis Skill Related Skills | Skill | Purpose | |-------|---------| | | BOM extraction, enrichment, ordering, and export workflows | | | Search DigiKey for parts (prototype sourcing) | | | Search Mouser for parts (secondary prototype source) | | | Search LCSC for parts (production sourcing, JLCPCB) | | | Search Newark/Farnell/element14 (international sourcing, reliable datasheets) | | | PCB fabrication & assembly ordering | | | Alternative PCB fabrication & assembly | | | SPICE simulation verification of detected subcircuits | | | EMC pre-compliance risk analysis — consumes sc…

)),\n]\n\n\ndef _find_diff_pairs(net_names, schematic):\n pairs = []\n seen = set()\n if schematic:\n classifications = schematic.get('net_classifications', {})\n diff_nets = [n for n, c in classifications.items() if c.get('differential')]\n for p_pat, n_pat in _DIFF_PAIR_SUFFIXES:\n for net in diff_nets:\n if net in seen:\n continue\n m = p_pat.match(net)\n if m:\n base = m.group(1)\n for net2 in diff_nets:\n if net2 in seen:\n continue\n m2 = n_pat.match(net2)\n if m2 and m2.group(1) == base:\n pairs.append((net, net2))\n seen.add(net)\n seen.add(net2)\n break\n for p_pat, n_pat in _DIFF_PAIR_SUFFIXES:\n for net in net_names:\n if net in seen:\n continue\n m = p_pat.match(net)\n if m:\n base = m.group(1)\n for net2 in net_names:\n if net2 in seen or net2 == net:\n continue\n m2 = n_pat.match(net2)\n if m2 and m2.group(1) == base:\n pairs.append((net, net2))\n seen.add(net)\n seen.add(net2)\n break\n return pairs\n\n\ndef check_diff_pair_quality(schematic, pcb):\n \"\"\"DP-005: Check differential pair routing quality.\"\"\"\n findings = []\n if not pcb:\n return findings\n segments = pcb.get('tracks', {}).get('segments', [])\n via_list = pcb.get('vias', {}).get('vias', [])\n if not segments:\n return findings\n net_id_map = _build_net_id_map(pcb)\n all_net_names = list(set(net_id_map.values()))\n pairs = _find_diff_pairs(all_net_names, schematic)\n if not pairs:\n return findings\n net_stats = {}\n for seg in segments:\n net_id = seg.get('net', 0)\n net_name = net_id_map.get(net_id, '') if isinstance(net_id, int) else str(net_id)\n if not net_name:\n continue\n x1, y1 = seg.get('x1', 0), seg.get('y1', 0)\n x2, y2 = seg.get('x2', 0), seg.get('y2', 0)\n length = math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2)\n stats = net_stats.setdefault(net_name, {'length_mm': 0, 'via_count': 0, 'layers': set()})\n stats['length_mm'] += length\n stats['layers'].add(seg.get('layer', ''))\n for via in (via_list or []):\n net_id = via.get('net', 0)\n net_name = net_id_map.get(net_id, '') if isinstance(net_id, int) else str(net_id)\n if net_name in net_stats:\n net_stats[net_name]['via_count'] = net_stats[net_name].get('via_count', 0) + 1\n for p_net, n_net in pairs:\n p_stats = net_stats.get(p_net)\n n_stats = net_stats.get(n_net)\n if not p_stats or not n_stats:\n continue\n issues = []\n p_vias = p_stats.get('via_count', 0)\n n_vias = n_stats.get('via_count', 0)\n if p_vias != n_vias:\n issues.append(f'via asymmetry ({p_vias} vs {n_vias})')\n p_layers = len(p_stats.get('layers', set()))\n n_layers = len(n_stats.get('layers', set()))\n if p_layers != n_layers:\n issues.append(f'layer transition asymmetry ({p_layers} vs {n_layers} layers)')\n p_len = p_stats.get('length_mm', 0)\n n_len = n_stats.get('length_mm', 0)\n avg_len = (p_len + n_len) / 2\n if avg_len > 0:\n mismatch_pct = abs(p_len - n_len) / avg_len * 100\n if mismatch_pct > 5:\n issues.append(f'length mismatch {abs(p_len - n_len):.1f}mm ({mismatch_pct:.0f}%)')\n if issues:\n findings.append(make_finding(\n detector='check_diff_pair_quality', rule_id='DP-005',\n category='differential_pair',\n summary=f'Diff pair {p_net}/{n_net}: {\", \".join(issues)}',\n description=f'Differential pair {p_net}/{n_net} has routing issues: {\"; \".join(issues)}.',\n severity='warning', confidence='deterministic', evidence_source='topology',\n nets=[p_net, n_net],\n recommendation='Match via counts, layer transitions, and trace lengths between P and N.',\n impact='Degraded signal integrity and increased common-mode EMI',\n ))\n return findings\n\n\n# ---------------------------------------------------------------------------\n# Main\n# ---------------------------------------------------------------------------\n\ndef run_all_checks(schematic: dict, pcb: dict | None) -> list[dict]:\n findings: list[dict] = []\n findings.extend(check_connector_current(schematic, pcb))\n findings.extend(check_esd_coverage_gaps(schematic, pcb))\n findings.extend(check_decoupling_adequacy(schematic, pcb))\n findings.extend(check_cross_validation(schematic, pcb))\n # PCB intelligence checks\n findings.extend(check_critical_net_routing(schematic, pcb))\n findings.extend(check_return_path_enhanced(schematic, pcb))\n findings.extend(check_trace_width_power(schematic, pcb))\n findings.extend(check_plane_splits(schematic, pcb))\n findings.extend(check_via_stitching_density(schematic, pcb))\n findings.extend(check_diff_pair_quality(schematic, pcb))\n return findings\n\n\ndef main():\n parser = argparse.ArgumentParser(\n description='Cross-domain analysis — schematic + PCB combined checks')\n parser.add_argument('--schematic', '-s', default=None, help='Schematic analyzer JSON')\n parser.add_argument('--pcb', '-p', default=None, help='PCB analyzer JSON (optional)')\n parser.add_argument('--output', '-o', default=None, help='Output JSON file path')\n parser.add_argument('--schema', action='store_true', help='Print output schema and exit')\n parser.add_argument('--text', action='store_true', help='Print human-readable text report')\n parser.add_argument('--analysis-dir', default=None, help='Write into analysis cache directory')\n parser.add_argument('--stage', default=None,\n choices=['schematic', 'layout', 'pre_fab', 'bring_up'],\n help='Filter findings by review stage')\n parser.add_argument('--audience', default=None,\n choices=['designer', 'reviewer', 'manager'],\n help='Audience level for summaries and --text output')\n\n args = parser.parse_args()\n\n if args.schema:\n schema = {\n 'analyzer_type': \"string — always 'cross_analysis'\",\n 'schema_version': \"string — semver (currently '1.3.0')\",\n 'elapsed_s': 'float — analysis wall-clock time',\n 'summary': {'total_findings': 'int', 'by_severity': {'error': 'int', 'warning': 'int', 'info': 'int'}},\n 'findings': '[{detector, rule_id, category, severity, confidence, evidence_source, summary, description, components, nets, pins, recommendation, fix_params, report_context}]',\n 'trust_summary': {\n 'total_findings': 'int',\n 'trust_level': \"'high' | 'mixed' | 'low'\",\n 'by_confidence': '{deterministic: int, heuristic: int, datasheet-backed: int}',\n 'by_evidence_source': '{datasheet|topology|heuristic_rule|symbol_footprint|bom|geometry|api_lookup: int}',\n 'provenance_coverage_pct': 'float',\n },\n }\n print(json.dumps(schema, indent=2))\n sys.exit(0)\n\n if not args.schematic:\n parser.error('--schematic is required')\n\n t0 = time.time()\n\n with open(args.schematic, 'r') as f:\n schematic = json.load(f)\n\n if 'signal_analysis' in schematic and 'findings' not in schematic:\n print(f'Error: {args.schematic} uses the pre-v1.3 '\n f'signal_analysis wrapper format.\\n'\n f'Re-run analyze_schematic.py to produce the current '\n f'findings[] format.', file=sys.stderr)\n sys.exit(1)\n\n pcb = None\n if args.pcb:\n with open(args.pcb, 'r') as f:\n pcb = json.load(f)\n\n findings = run_all_checks(schematic, pcb)\n elapsed = time.time() - t0\n\n sev_counts = {'error': 0, 'warning': 0, 'info': 0}\n for f_item in findings:\n sev = f_item.get('severity', 'info')\n sev_counts[sev] = sev_counts.get(sev, 0) + 1\n\n result = {\n 'analyzer_type': 'cross_analysis',\n 'schema_version': '1.3.0',\n 'elapsed_s': round(elapsed, 3),\n 'summary': {'total_findings': len(findings), 'by_severity': sev_counts},\n 'findings': findings,\n 'trust_summary': compute_trust_summary(findings),\n }\n\n from output_filters import apply_output_filters\n apply_output_filters(result, args.stage, args.audience)\n\n if args.text:\n from output_filters import format_text\n print(format_text(result.get('findings', []), args.audience or 'designer', args.stage))\n sys.exit(0)\n\n if args.output:\n with open(args.output, 'w') as f:\n json.dump(result, f, indent=2)\n print(f'Cross-analysis: {len(findings)} findings -> {args.output}', file=sys.stderr)\n elif args.analysis_dir:\n # Route into the current run folder via the manifest so that\n # cross_analysis.json co-locates with schematic.json + pcb.json\n # instead of landing at the analysis-dir root.\n import tempfile\n from analysis_cache import overwrite_current, CANONICAL_OUTPUTS, get_current_run\n analysis_dir = args.analysis_dir\n if not os.path.isabs(analysis_dir):\n analysis_dir = os.path.abspath(analysis_dir)\n filename = CANONICAL_OUTPUTS.get('cross_analysis', 'cross_analysis.json')\n with tempfile.TemporaryDirectory() as tmp_dir:\n tmp_out = os.path.join(tmp_dir, filename)\n with open(tmp_out, 'w') as f:\n json.dump(result, f, indent=2)\n overwrite_current(analysis_dir, tmp_dir, source_hashes=None)\n current = get_current_run(analysis_dir)\n if current:\n out_path = os.path.join(current[0], filename)\n else:\n out_path = os.path.join(analysis_dir, filename)\n print(f'Cross-analysis: {len(findings)} findings -> {out_path}', file=sys.stderr)\n else:\n print(json.dumps(result, indent=2))\n\n return 0\n\n\nif __name__ == '__main__':\n sys.exit(main())\n","content_type":"text/x-python; charset=utf-8","language":"python","size":45037,"content_sha256":"14682f08252bafe9b140c8f567099e15859b25d4ba28c57c716da752d9dbb466"},{"filename":"scripts/cross_verify.py","content":"#!/usr/bin/env python3\n\"\"\"Schematic-to-PCB cross-verification.\n\nCorrelates schematic design intent with PCB physical implementation.\nDetects component mismatches, differential pair length issues, power\ntrace width concerns, decoupling placement gaps, bus routing skew,\nand thermal via adequacy.\n\nUsage:\n python3 cross_verify.py --schematic sch.json --pcb pcb.json\n python3 cross_verify.py --schematic sch.json --pcb pcb.json --thermal thermal.json\n python3 cross_verify.py --schematic sch.json --pcb pcb.json --output report.json\n\nZero external dependencies — Python 3.8+ stdlib only.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport json\nimport math\nimport os\nimport sys\nfrom pathlib import Path\n\n\ndef cross_verify(sch: dict, pcb: dict,\n thermal: dict | None = None) -> dict:\n \"\"\"Run all cross-verification checks.\n\n Args:\n sch: Schematic analysis JSON (from analyze_schematic.py).\n pcb: PCB analysis JSON (from analyze_pcb.py).\n thermal: Optional thermal analysis JSON (from analyze_thermal.py).\n\n Returns:\n Structured report with per-check results and summary.\n \"\"\"\n result = {\n \"cross_verify_version\": 1,\n \"schematic_file\": sch.get(\"file\", \"\"),\n \"pcb_file\": pcb.get(\"file\", \"\"),\n }\n\n checks_run = 0\n status_counts = {\"pass\": 0, \"warning\": 0, \"fail\": 0, \"info\": 0}\n\n # Check 1: Component reference matching\n comp_match = check_component_matching(sch, pcb)\n result[\"component_matching\"] = comp_match\n checks_run += 1\n\n # Check 2: Differential pair length matching\n diff_pairs = check_diff_pair_routing(sch, pcb)\n if diff_pairs:\n result[\"diff_pair_routing\"] = diff_pairs\n checks_run += 1\n\n # Check 3: Power trace width assessment\n power_traces = check_power_traces(sch, pcb)\n if power_traces:\n result[\"power_trace_analysis\"] = power_traces\n checks_run += 1\n\n # Check 4: Decoupling cap placement\n decoupling = check_decoupling_placement(sch, pcb)\n if decoupling:\n result[\"decoupling_placement\"] = decoupling\n checks_run += 1\n\n # Check 5: Bus routing advisory\n bus_routing = check_bus_routing(sch, pcb)\n if bus_routing:\n result[\"bus_routing\"] = bus_routing\n checks_run += 1\n\n # Check 6: Thermal via adequacy\n if thermal:\n thermal_vias = check_thermal_vias(thermal, pcb)\n if thermal_vias:\n result[\"thermal_via_check\"] = thermal_vias\n checks_run += 1\n\n # Count statuses across all checks\n for section in result.values():\n if isinstance(section, list):\n for item in section:\n if isinstance(item, dict) and \"status\" in item:\n status_counts[item[\"status\"]] = status_counts.get(item[\"status\"], 0) + 1\n elif isinstance(section, dict):\n for item in section.values():\n if isinstance(item, list):\n for entry in item:\n if isinstance(entry, dict) and \"status\" in entry:\n status_counts[entry[\"status\"]] = status_counts.get(entry[\"status\"], 0) + 1\n\n result[\"summary\"] = {\n \"total_checks\": checks_run,\n **status_counts,\n }\n\n return result\n\n\ndef check_component_matching(sch: dict, pcb: dict) -> dict:\n \"\"\"Check 1: Bidirectional component reference matching.\n\n Compares schematic component refs against PCB footprint refs.\n Detects orphans, missing components, value mismatches, and DNP conflicts.\n \"\"\"\n # Build schematic ref lookup (exclude power symbols and flags)\n sch_comps = {}\n dnp_refs = set()\n for c in sch.get(\"components\", []):\n ref = c.get(\"reference\", \"\")\n if not ref or ref.startswith(\"#\"):\n continue\n sch_comps[ref] = {\n \"value\": c.get(\"value\", \"\"),\n \"type\": c.get(\"type\", \"\"),\n \"footprint\": c.get(\"footprint\", \"\"),\n }\n if c.get(\"dnp\"):\n dnp_refs.add(ref)\n\n # Build PCB ref lookup\n pcb_fps = {}\n for fp in pcb.get(\"footprints\", []):\n ref = fp.get(\"reference\", \"\")\n if not ref or ref.startswith(\"#\"):\n continue\n pcb_fps[ref] = {\n \"value\": fp.get(\"value\", \"\"),\n \"lib_id\": fp.get(\"lib_id\", \"\"),\n }\n\n sch_refs = set(sch_comps.keys())\n pcb_refs = set(pcb_fps.keys())\n\n matched = sch_refs & pcb_refs\n orphans = [] # in PCB but not schematic\n missing = [] # in schematic but not PCB\n value_mismatches = []\n dnp_conflicts = []\n\n # Orphans: in PCB but not in schematic\n for ref in sorted(pcb_refs - sch_refs):\n orphans.append({\n \"ref\": ref,\n \"pcb_value\": pcb_fps[ref][\"value\"],\n \"status\": \"fail\",\n \"message\": f\"{ref} in PCB but not in schematic (stale placement?)\",\n })\n\n # Missing: in schematic but not in PCB\n for ref in sorted(sch_refs - pcb_refs):\n if ref in dnp_refs:\n continue # DNP components are expected to be absent from PCB\n sc = sch_comps[ref]\n missing.append({\n \"ref\": ref,\n \"sch_value\": sc[\"value\"],\n \"sch_type\": sc[\"type\"],\n \"status\": \"fail\",\n \"message\": f\"{ref} ({sc['value']}) in schematic but not placed in PCB\",\n })\n\n # Value mismatches on matched refs\n for ref in sorted(matched):\n sv = sch_comps[ref][\"value\"]\n pv = pcb_fps[ref][\"value\"]\n if sv and pv and sv.lower() != pv.lower():\n value_mismatches.append({\n \"ref\": ref,\n \"sch_value\": sv,\n \"pcb_value\": pv,\n \"status\": \"warning\",\n \"message\": f\"{ref}: schematic says '{sv}', PCB says '{pv}'\",\n })\n\n # DNP conflicts: marked DNP but placed in PCB\n for ref in sorted(dnp_refs & pcb_refs):\n dnp_conflicts.append({\n \"ref\": ref,\n \"status\": \"warning\",\n \"message\": f\"{ref} marked DNP in schematic but placed in PCB\",\n })\n\n return {\n \"schematic_count\": len(sch_comps),\n \"pcb_count\": len(pcb_fps),\n \"matched\": len(matched),\n \"orphans\": orphans,\n \"missing\": missing,\n \"value_mismatches\": value_mismatches,\n \"dnp_conflicts\": dnp_conflicts,\n }\n\n\ndef check_diff_pair_routing(sch: dict, pcb: dict) -> list[dict]:\n \"\"\"Check 2: Differential pair length matching.\n\n Matches schematic-detected diff pairs against PCB per-net length data.\n Flags length mismatch, width mismatch, and layer mismatch.\n \"\"\"\n diff_pairs = sch.get(\"design_analysis\", {}).get(\"differential_pairs\", [])\n if not diff_pairs:\n return []\n\n # Build net length lookup from PCB\n net_lengths = {}\n for nl in pcb.get(\"net_lengths\", []):\n net_lengths[nl[\"net\"]] = nl\n\n # Protocol-specific length tolerances (mm)\n _TOLERANCES = {\n \"USB\": 2.0,\n \"Ethernet\": 5.0,\n \"HDMI\": 1.0,\n \"LVDS\": 1.5,\n \"MIPI\": 1.5,\n \"PCIe\": 2.0,\n \"SATA\": 2.0,\n }\n\n results = []\n for dp in diff_pairs:\n pos_net = dp.get(\"positive\", \"\")\n neg_net = dp.get(\"negative\", \"\")\n protocol = dp.get(\"type\", \"differential\")\n tolerance = _TOLERANCES.get(protocol, 2.0)\n\n pos_data = net_lengths.get(pos_net)\n neg_data = net_lengths.get(neg_net)\n\n entry = {\n \"type\": protocol,\n \"positive\": pos_net,\n \"negative\": neg_net,\n \"tolerance_mm\": tolerance,\n }\n\n if not pos_data and not neg_data:\n entry[\"status\"] = \"info\"\n entry[\"message\"] = f\"Neither {pos_net} nor {neg_net} found in PCB routing\"\n results.append(entry)\n continue\n\n if not pos_data or not neg_data:\n missing = pos_net if not pos_data else neg_net\n entry[\"status\"] = \"fail\"\n entry[\"message\"] = f\"Only one net routed — {missing} not found in PCB\"\n results.append(entry)\n continue\n\n pos_len = pos_data.get(\"total_length_mm\", 0)\n neg_len = neg_data.get(\"total_length_mm\", 0)\n delta = abs(pos_len - neg_len)\n\n entry[\"pos_length_mm\"] = round(pos_len, 2)\n entry[\"neg_length_mm\"] = round(neg_len, 2)\n entry[\"delta_mm\"] = round(delta, 2)\n\n # Length matching check\n if delta > tolerance:\n entry[\"status\"] = \"fail\"\n entry[\"message\"] = (f\"{protocol} pair {pos_net}/{neg_net}: \"\n f\"{delta:.1f}mm length mismatch \"\n f\"(tolerance {tolerance}mm)\")\n elif delta > tolerance * 0.7:\n entry[\"status\"] = \"warning\"\n entry[\"message\"] = (f\"{protocol} pair: {delta:.1f}mm mismatch \"\n f\"approaching {tolerance}mm limit\")\n else:\n entry[\"status\"] = \"pass\"\n\n # Intra-pair skew: P vs N trace length within same pair\n # (tighter than the inter-pair length tolerance above)\n _intra_pair_tolerance = {\n \"USB\": 1.0,\n \"Ethernet\": 2.0,\n \"HDMI\": 0.5,\n \"LVDS\": 0.5,\n \"MIPI\": 0.5,\n \"PCIe\": 1.0,\n \"SATA\": 1.0,\n }\n\n intra_skew = delta # same as abs(pos_len - neg_len) computed above\n intra_tol = _intra_pair_tolerance.get(protocol, 1.0)\n if intra_skew > intra_tol:\n entry[\"intra_pair_skew\"] = {\n \"skew_mm\": round(intra_skew, 2),\n \"tolerance_mm\": intra_tol,\n \"severity\": \"HIGH\" if intra_skew > intra_tol * 2 else \"MEDIUM\",\n \"detail\": (f\"Differential pair {pos_net}/{neg_net} intra-pair \"\n f\"skew {intra_skew:.1f}mm exceeds {protocol} \"\n f\"tolerance ({intra_tol}mm)\"),\n }\n # Upgrade entry status if the skew check is more severe\n if entry[\"status\"] == \"pass\":\n entry[\"status\"] = \"warning\"\n entry[\"message\"] = (f\"{protocol} pair {pos_net}/{neg_net}: \"\n f\"intra-pair skew {intra_skew:.1f}mm \"\n f\"exceeds {intra_tol}mm tolerance\")\n elif (entry[\"status\"] == \"warning\"\n and intra_skew > intra_tol * 2):\n entry[\"status\"] = \"fail\"\n\n # Layer check\n pos_layers = set(pos_data.get(\"layers\", {}).keys())\n neg_layers = set(neg_data.get(\"layers\", {}).keys())\n if pos_layers and neg_layers and pos_layers != neg_layers:\n entry[\"layer_mismatch\"] = {\n \"positive_layers\": sorted(pos_layers),\n \"negative_layers\": sorted(neg_layers),\n }\n if entry[\"status\"] == \"pass\":\n entry[\"status\"] = \"warning\"\n entry[\"message\"] = (f\"{protocol} pair routes on different layers: \"\n f\"{sorted(pos_layers)} vs {sorted(neg_layers)}\")\n\n results.append(entry)\n\n return results\n\n\ndef check_power_traces(sch: dict, pcb: dict) -> list[dict]:\n \"\"\"Check 3: Power trace width assessment.\n\n Matches regulator output rails against PCB power net routing data.\n Surfaces trace widths and total lengths for reviewer assessment.\n \"\"\"\n regulators = [f for f in sch.get(\"findings\", [])\n if f.get(\"detector\") == \"detect_power_regulators\"]\n if not regulators:\n return []\n\n # Build power routing lookup from PCB\n power_routing = {}\n for pr in pcb.get(\"power_net_routing\", []):\n power_routing[pr[\"net\"]] = pr\n\n results = []\n for reg in regulators:\n rail = reg.get(\"output_rail\")\n if not rail:\n continue\n\n entry = {\n \"regulator_ref\": reg.get(\"ref\", \"\"),\n \"output_rail\": rail,\n \"topology\": reg.get(\"topology\", \"unknown\"),\n \"estimated_vout\": reg.get(\"estimated_vout\"),\n }\n\n pr = power_routing.get(rail)\n if not pr:\n entry[\"status\"] = \"info\"\n entry[\"message\"] = f\"Output rail {rail} not found in PCB power routing\"\n results.append(entry)\n continue\n\n min_w = pr.get(\"min_width_mm\", 0)\n max_w = pr.get(\"max_width_mm\", 0)\n total_len = pr.get(\"total_length_mm\", 0)\n\n entry[\"min_trace_width_mm\"] = round(min_w, 3)\n entry[\"max_trace_width_mm\"] = round(max_w, 3)\n entry[\"total_length_mm\"] = round(total_len, 1)\n entry[\"track_count\"] = pr.get(\"track_count\", 0)\n\n # Switching regulators need wider traces for same current\n # (higher di/dt, transient current demands)\n is_switching = reg.get(\"topology\") in (\"switching\", \"buck\", \"boost\",\n \"buck-boost\", \"inverting\")\n width_threshold = 0.3 if is_switching else 0.2\n\n if min_w \u003c width_threshold:\n entry[\"status\"] = \"warning\"\n topo_note = \" (switching — higher current demand)\" if is_switching else \"\"\n entry[\"message\"] = (f\"{rail}: minimum trace width {min_w:.2f}mm\"\n f\" is narrow for a power rail{topo_note}\")\n else:\n entry[\"status\"] = \"pass\"\n\n results.append(entry)\n\n return results\n\n\ndef check_decoupling_placement(sch: dict, pcb: dict) -> list[dict]:\n \"\"\"Check 4: Decoupling cap placement cross-check.\n\n Matches schematic decoupling analysis (which caps serve which ICs)\n against PCB footprint positions. Uses PCB decoupling_placement data\n when available, otherwise computes distances from footprint coordinates.\n \"\"\"\n sch_decoupling = [f for f in sch.get(\"findings\", [])\n if f.get(\"detector\") == \"detect_decoupling\"]\n if not sch_decoupling or not isinstance(sch_decoupling, list):\n return []\n\n # Use PCB's pre-computed decoupling placement if available\n pcb_decoupling = pcb.get(\"decoupling_placement\", [])\n pcb_decoup_lookup = {}\n for entry in pcb_decoupling:\n ic_ref = entry.get(\"ic\", \"\")\n for cap in entry.get(\"nearby_caps\", []):\n pcb_decoup_lookup[(ic_ref, cap.get(\"cap\", \"\"))] = cap.get(\"distance_mm\", 999)\n\n # Build PCB footprint position lookup\n fp_positions = {}\n for fp in pcb.get(\"footprints\", []):\n ref = fp.get(\"reference\", \"\")\n if ref:\n fp_positions[ref] = (fp.get(\"x\", 0), fp.get(\"y\", 0))\n\n results = []\n for group in sch_decoupling:\n if not isinstance(group, dict):\n continue\n ic_ref = group.get(\"ic_ref\") or group.get(\"ic\") or group.get(\"rail\", \"\")\n caps = group.get(\"capacitors\", [])\n if not ic_ref or not caps:\n continue\n\n ic_pos = fp_positions.get(ic_ref)\n\n for cap in caps:\n if not isinstance(cap, dict):\n continue\n cap_ref = cap.get(\"ref\", \"\")\n if not cap_ref:\n continue\n\n entry = {\n \"ic_ref\": ic_ref,\n \"cap_ref\": cap_ref,\n \"cap_value\": cap.get(\"value\", \"\"),\n }\n\n # Try pre-computed distance first\n dist = pcb_decoup_lookup.get((ic_ref, cap_ref))\n\n # Fall back to footprint position calculation\n if dist is None and ic_pos:\n cap_pos = fp_positions.get(cap_ref)\n if cap_pos:\n dist = math.sqrt(\n (ic_pos[0] - cap_pos[0]) ** 2 +\n (ic_pos[1] - cap_pos[1]) ** 2)\n\n if dist is not None:\n entry[\"distance_mm\"] = round(dist, 2)\n if dist > 5.0:\n entry[\"status\"] = \"warning\"\n entry[\"message\"] = (f\"{cap_ref} is {dist:.1f}mm from {ic_ref} \"\n f\"— should be within 5mm for effective decoupling\")\n else:\n entry[\"status\"] = \"pass\"\n else:\n entry[\"status\"] = \"info\"\n entry[\"message\"] = f\"{cap_ref} or {ic_ref} not found in PCB placement\"\n\n results.append(entry)\n\n return results\n\n\ndef check_bus_routing(sch: dict, pcb: dict) -> list[dict]:\n \"\"\"Check 5: High-speed bus signal routing advisory.\n\n Matches schematic-detected buses against PCB net lengths.\n Reports trace lengths and flags clock-to-data skew for SPI.\n \"\"\"\n bus_analysis = sch.get(\"design_analysis\", {}).get(\"bus_analysis\", {})\n if not bus_analysis:\n return []\n\n # Build net length lookup\n net_lengths = {}\n for nl in pcb.get(\"net_lengths\", []):\n net_lengths[nl[\"net\"]] = nl.get(\"total_length_mm\", 0)\n\n results = []\n\n for protocol, buses in bus_analysis.items():\n if not isinstance(buses, list):\n continue\n for bus in buses:\n signals = bus.get(\"signals\", {})\n if not signals:\n continue\n\n bus_id = bus.get(\"bus_id\", protocol)\n signal_lengths = {}\n missing_nets = []\n\n for sig_name, sig_data in signals.items():\n if isinstance(sig_data, dict):\n net_name = sig_data.get(\"net\", \"\")\n else:\n net_name = str(sig_data)\n if not net_name:\n continue\n length = net_lengths.get(net_name)\n if length is not None:\n signal_lengths[sig_name] = round(length, 2)\n else:\n missing_nets.append(net_name)\n\n if not signal_lengths:\n continue\n\n entry = {\n \"protocol\": protocol.upper(),\n \"bus_id\": bus_id,\n \"signals\": signal_lengths,\n }\n\n if missing_nets:\n entry[\"missing_nets\"] = missing_nets\n\n lengths = list(signal_lengths.values())\n if len(lengths) >= 2:\n max_delta = max(lengths) - min(lengths)\n entry[\"max_delta_mm\"] = round(max_delta, 2)\n\n # SPI clock-to-data skew check\n if protocol == \"spi\" and \"SCK\" in signal_lengths:\n clk_len = signal_lengths[\"SCK\"]\n data_sigs = {k: v for k, v in signal_lengths.items() if k != \"SCK\"}\n if data_sigs:\n max_data = max(data_sigs.values())\n clk_skew = abs(clk_len - max_data)\n if clk_skew > 10.0:\n entry[\"status\"] = \"warning\"\n entry[\"message\"] = (f\"SPI clock {clk_len:.1f}mm vs \"\n f\"longest data {max_data:.1f}mm — \"\n f\"{clk_skew:.1f}mm skew\")\n else:\n entry[\"status\"] = \"pass\"\n else:\n entry[\"status\"] = \"pass\"\n else:\n entry[\"status\"] = \"info\"\n else:\n entry[\"status\"] = \"info\"\n\n results.append(entry)\n\n return results\n\n\ndef check_thermal_vias(thermal: dict, pcb: dict) -> list[dict]:\n \"\"\"Check 6: Thermal via adequacy.\n\n Cross-references thermal margins with PCB thermal pad via counts.\n Only runs when thermal JSON is provided.\n \"\"\"\n assessments = thermal.get(\"thermal_assessments\", [])\n if not assessments:\n return []\n\n # Build thermal pad via lookup from PCB findings[].\n # Thermal pad via entries have detector=\"analyze_thermal_pad_vias\".\n # KH-234: Keys are \"component\" and \"via_count\".\n via_lookup = {}\n for tv in pcb.get(\"findings\", []):\n if not isinstance(tv, dict):\n continue\n if tv.get(\"detector\") != \"analyze_thermal_pad_vias\":\n continue\n ref = tv.get(\"component\", \"\")\n if ref:\n via_lookup[ref] = tv.get(\"via_count\", 0)\n\n results = []\n for a in assessments:\n ref = a.get(\"ref\", \"\")\n tj = a.get(\"tj_estimated_c\", 0)\n margin = a.get(\"margin_c\", 999)\n pdiss = a.get(\"pdiss_w\", 0)\n\n if margin > 30 or pdiss \u003c 0.1:\n continue # Skip components with comfortable margins\n\n via_count = via_lookup.get(ref, 0)\n\n entry = {\n \"ref\": ref,\n \"value\": a.get(\"value\", \"\"),\n \"tj_estimated_c\": round(tj, 1),\n \"margin_c\": round(margin, 1),\n \"pdiss_w\": round(pdiss, 3),\n \"thermal_vias\": via_count,\n }\n\n if margin \u003c 10 and via_count \u003c 4:\n entry[\"status\"] = \"fail\"\n entry[\"message\"] = (f\"{ref}: {margin:.0f}°C margin with only \"\n f\"{via_count} thermal vias — insufficient cooling\")\n elif margin \u003c 20 and via_count \u003c 2:\n entry[\"status\"] = \"warning\"\n entry[\"message\"] = (f\"{ref}: {margin:.0f}°C margin with \"\n f\"{via_count} thermal vias — consider adding more\")\n elif margin \u003c 20:\n entry[\"status\"] = \"info\"\n entry[\"message\"] = (f\"{ref}: {margin:.0f}°C margin, \"\n f\"{via_count} thermal vias\")\n else:\n entry[\"status\"] = \"pass\"\n\n results.append(entry)\n\n return results\n\n\ndef main():\n parser = argparse.ArgumentParser(\n description=\"Cross-verify schematic design intent against PCB implementation\")\n parser.add_argument(\"--schematic\", \"-s\", required=True,\n help=\"Path to schematic analysis JSON\")\n parser.add_argument(\"--pcb\", \"-p\", required=True,\n help=\"Path to PCB analysis JSON\")\n parser.add_argument(\"--thermal\", \"-t\", default=None,\n help=\"Path to thermal analysis JSON (optional)\")\n parser.add_argument(\"--output\", \"-o\", default=None,\n help=\"Output JSON path (default: stdout)\")\n args = parser.parse_args()\n\n with open(args.schematic) as f:\n sch = json.load(f)\n with open(args.pcb) as f:\n pcb = json.load(f)\n\n thermal = None\n if args.thermal:\n with open(args.thermal) as f:\n thermal = json.load(f)\n\n report = cross_verify(sch, pcb, thermal)\n\n output = json.dumps(report, indent=2)\n if args.output:\n os.makedirs(os.path.dirname(os.path.abspath(args.output)) or \".\", exist_ok=True)\n with open(args.output, \"w\") as f:\n f.write(output)\n f.write(\"\\n\")\n print(f\"Written to {args.output}\", file=sys.stderr)\n else:\n print(output)\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":22721,"content_sha256":"ff13972a0c12e6d8ca5bb6eca60f3e52581412540f433a926cda9a7430277352"},{"filename":"scripts/detection_schema.py","content":"\"\"\"Unified detection type schema for kicad-happy signal analysis.\n\nSingle source of truth for per-detection-type metadata consumed by:\n- what_if.py (derived fields, recalculation, inverse solvers)\n- spice_tolerance.py (recalculation, primary metric)\n- diff_analysis.py (identity fields, value fields)\n\nAdding a new detection type: add a DetectionSchema entry to SCHEMAS.\n\"\"\"\n\nimport hashlib\nimport math\nimport os\nimport sys\nfrom dataclasses import dataclass, field\n\n# Allow importing kicad_utils from same directory\nsys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))\n\n\n# ---------------------------------------------------------------------------\n# Schema types\n# ---------------------------------------------------------------------------\n\n@dataclass\nclass DerivedField:\n \"\"\"A computed field on a detection dict.\"\"\"\n name: str # field key (e.g., \"cutoff_hz\")\n recalc: object # Callable[[dict], None] — mutates det in place\n inverse: object = None # Callable[[dict, str, float], list] or None\n\n\n@dataclass\nclass DetectionSchema:\n \"\"\"Metadata for one detection type.\"\"\"\n identity_fields: list # dotpath fields for diffing (e.g., [\"r_top.ref\"])\n value_fields: list # fields to compare in diffs (e.g., [\"ratio\"])\n derived: list = field(default_factory=list) # DerivedField instances\n primary_metric: str = None # for Monte Carlo sensitivity analysis\n\n\n# ---------------------------------------------------------------------------\n# Recalculation callables (relocated from spice_tolerance._recalc_derived)\n# ---------------------------------------------------------------------------\n\n_PI2 = 2.0 * math.pi\n\n\ndef _recalc_rc_cutoff(det: dict) -> None:\n \"\"\"RC filter: cutoff_hz = 1 / (2*pi*R*C).\"\"\"\n r = det.get(\"resistor\", {}).get(\"ohms\")\n c = det.get(\"capacitor\", {}).get(\"farads\")\n if r and c and r > 0 and c > 0:\n det[\"cutoff_hz\"] = round(1.0 / (_PI2 * r * c), 2)\n\n\ndef _recalc_divider_ratio(det: dict) -> None:\n \"\"\"Voltage divider / feedback: ratio = R_bot / (R_top + R_bot).\"\"\"\n r_top = det.get(\"r_top\", {}).get(\"ohms\")\n r_bot = det.get(\"r_bottom\", {}).get(\"ohms\")\n if r_top and r_bot and (r_top + r_bot) > 0:\n det[\"ratio\"] = round(r_bot / (r_top + r_bot), 6)\n\n\ndef _recalc_lc_filter(det: dict) -> None:\n \"\"\"LC filter: resonant_hz and impedance_ohms.\"\"\"\n l = det.get(\"inductor\", {}).get(\"henries\")\n c = det.get(\"capacitor\", {}).get(\"farads\")\n if l and c and l > 0 and c > 0:\n f0 = 1.0 / (_PI2 * math.sqrt(l * c))\n det[\"resonant_hz\"] = round(f0, 2)\n det[\"impedance_ohms\"] = round(math.sqrt(l / c), 2)\n\n\ndef _recalc_crystal_load(det: dict) -> None:\n \"\"\"Crystal: effective_load_pF = (C1*C2)/(C1+C2) + stray.\"\"\"\n caps = det.get(\"load_caps\")\n if isinstance(caps, list) and len(caps) >= 2:\n c1 = caps[0].get(\"farads\", 0)\n c2 = caps[1].get(\"farads\", 0)\n if c1 > 0 and c2 > 0:\n c_series = (c1 * c2) / (c1 + c2)\n stray_pf = det.get(\"stray_capacitance_pF\", 3.0)\n det[\"effective_load_pF\"] = round(c_series * 1e12 + stray_pf, 2)\n\n\ndef _recalc_regulator_feedback(det: dict) -> None:\n \"\"\"Regulator feedback divider: nested ratio.\"\"\"\n fd = det.get(\"feedback_divider\")\n if isinstance(fd, dict) and \"r_top\" in fd and \"r_bottom\" in fd:\n r_top = fd[\"r_top\"].get(\"ohms\")\n r_bot = fd[\"r_bottom\"].get(\"ohms\")\n if r_top and r_bot and (r_top + r_bot) > 0:\n fd[\"ratio\"] = round(r_bot / (r_top + r_bot), 6)\n\n\ndef _recalc_opamp_gain(det: dict) -> None:\n \"\"\"Opamp: gain and gain_dB from feedback/input resistors.\"\"\"\n rf = det.get(\"feedback_resistor\", {}).get(\"ohms\")\n ri = det.get(\"input_resistor\", {}).get(\"ohms\")\n if rf and ri and ri > 0:\n config = det.get(\"configuration\", \"\")\n if \"non-inverting\" in config or \"non_inverting\" in config:\n det[\"gain\"] = round(1.0 + rf / ri, 4)\n elif \"inverting\" in config:\n det[\"gain\"] = round(-rf / ri, 4)\n else:\n det[\"gain\"] = round(rf / ri, 4)\n gain = det[\"gain\"]\n if gain != 0:\n det[\"gain_dB\"] = round(20.0 * math.log10(abs(gain)), 2)\n\n\ndef _recalc_current_sense(det: dict) -> None:\n \"\"\"Current sense: max current at sense voltages.\"\"\"\n shunt = det.get(\"shunt\")\n if isinstance(shunt, dict):\n r = shunt.get(\"ohms\")\n if r and r > 0:\n det[\"max_current_50mV_A\"] = round(0.050 / r, 4)\n det[\"max_current_100mV_A\"] = round(0.100 / r, 4)\n\n\n# ---------------------------------------------------------------------------\n# Inverse solver callables (relocated from what_if._solve_fix)\n# ---------------------------------------------------------------------------\n\ndef _inverse_divider_ratio(det: dict, target_field: str, target_value: float) -> list:\n \"\"\"Solve for R_top or R_bottom given target ratio.\"\"\"\n suggestions = []\n r_top = det.get(\"r_top\", {})\n r_bot = det.get(\"r_bottom\", {})\n rt = r_top.get(\"ohms\", 0)\n rb = r_bot.get(\"ohms\", 0)\n if rt > 0 and 0 \u003c target_value \u003c 1:\n ideal_rb = rt * target_value / (1 - target_value)\n suggestions.append({\n \"ref\": r_bot.get(\"ref\", \"R_bottom\"), \"field\": \"ohms\",\n \"current\": rb, \"ideal\": ideal_rb,\n \"anchor_ref\": r_top.get(\"ref\", \"R_top\"), \"anchor_value\": rt,\n })\n if rb > 0 and 0 \u003c target_value \u003c 1:\n ideal_rt = rb * (1 - target_value) / target_value\n suggestions.append({\n \"ref\": r_top.get(\"ref\", \"R_top\"), \"field\": \"ohms\",\n \"current\": rt, \"ideal\": ideal_rt,\n \"anchor_ref\": r_bot.get(\"ref\", \"R_bottom\"), \"anchor_value\": rb,\n })\n return suggestions\n\n\ndef _inverse_rc_cutoff(det: dict, target_field: str, target_value: float) -> list:\n \"\"\"Solve for R or C given target cutoff_hz.\"\"\"\n suggestions = []\n r = det.get(\"resistor\", {})\n c = det.get(\"capacitor\", {})\n rv = r.get(\"ohms\", 0)\n cv = c.get(\"farads\", 0)\n if rv > 0 and target_value > 0:\n ideal_c = 1.0 / (_PI2 * rv * target_value)\n suggestions.append({\n \"ref\": c.get(\"ref\", \"C\"), \"field\": \"farads\",\n \"current\": cv, \"ideal\": ideal_c,\n \"anchor_ref\": r.get(\"ref\", \"R\"), \"anchor_value\": rv,\n })\n if cv > 0 and target_value > 0:\n ideal_r = 1.0 / (_PI2 * cv * target_value)\n suggestions.append({\n \"ref\": r.get(\"ref\", \"R\"), \"field\": \"ohms\",\n \"current\": rv, \"ideal\": ideal_r,\n \"anchor_ref\": c.get(\"ref\", \"C\"), \"anchor_value\": cv,\n })\n return suggestions\n\n\ndef _inverse_lc_resonant(det: dict, target_field: str, target_value: float) -> list:\n \"\"\"Solve for L or C given target resonant_hz.\"\"\"\n suggestions = []\n l = det.get(\"inductor\", {})\n c = det.get(\"capacitor\", {})\n lv = l.get(\"henries\", 0)\n cv = c.get(\"farads\", 0)\n if lv > 0 and target_value > 0:\n ideal_c = 1.0 / ((_PI2 * target_value) ** 2 * lv)\n suggestions.append({\n \"ref\": c.get(\"ref\", \"C\"), \"field\": \"farads\",\n \"current\": cv, \"ideal\": ideal_c,\n \"anchor_ref\": l.get(\"ref\", \"L\"), \"anchor_value\": lv,\n })\n if cv > 0 and target_value > 0:\n ideal_l = 1.0 / ((_PI2 * target_value) ** 2 * cv)\n suggestions.append({\n \"ref\": l.get(\"ref\", \"L\"), \"field\": \"henries\",\n \"current\": lv, \"ideal\": ideal_l,\n \"anchor_ref\": c.get(\"ref\", \"C\"), \"anchor_value\": cv,\n })\n return suggestions\n\n\ndef _inverse_opamp_gain(det: dict, target_field: str, target_value: float) -> list:\n \"\"\"Solve for Rf given target gain (or gain_dB).\"\"\"\n target_gain = target_value\n if target_field == \"gain_dB\":\n target_gain = 10 ** (target_value / 20.0)\n rf = det.get(\"feedback_resistor\", {})\n ri = det.get(\"input_resistor\", {})\n rfv = rf.get(\"ohms\", 0)\n riv = ri.get(\"ohms\", 0)\n config = det.get(\"configuration\", \"\")\n if riv > 0:\n if \"non-inverting\" in config or \"non_inverting\" in config:\n ideal_rf = riv * (abs(target_gain) - 1)\n else:\n ideal_rf = riv * abs(target_gain)\n if ideal_rf > 0:\n return [{\n \"ref\": rf.get(\"ref\", \"Rf\"), \"field\": \"ohms\",\n \"current\": rfv, \"ideal\": ideal_rf,\n \"anchor_ref\": ri.get(\"ref\", \"Ri\"), \"anchor_value\": riv,\n }]\n return []\n\n\ndef _inverse_crystal_load(det: dict, target_field: str, target_value: float) -> list:\n \"\"\"Solve for symmetric load caps given target effective_load_pF.\"\"\"\n caps = det.get(\"load_caps\", [])\n stray = det.get(\"stray_capacitance_pF\", 3.0)\n if len(caps) >= 2 and target_value > stray:\n ideal_pf = 2 * (target_value - stray)\n ideal_f = ideal_pf * 1e-12\n suggestions = []\n for cap in caps[:2]:\n suggestions.append({\n \"ref\": cap.get(\"ref\", \"C\"), \"field\": \"farads\",\n \"current\": cap.get(\"farads\", 0), \"ideal\": ideal_f,\n \"anchor_ref\": None, \"anchor_value\": None,\n })\n return suggestions\n return []\n\n\ndef _inverse_current_sense(det: dict, target_field: str, target_value: float) -> list:\n \"\"\"Solve for shunt R given target max current.\"\"\"\n shunt = det.get(\"shunt\", {})\n rv = shunt.get(\"ohms\", 0)\n if target_value > 0:\n if target_field == \"max_current_100mV_A\":\n ideal_r = 0.100 / target_value\n elif target_field == \"max_current_50mV_A\":\n ideal_r = 0.050 / target_value\n else:\n return []\n return [{\n \"ref\": shunt.get(\"ref\", \"R\"), \"field\": \"ohms\",\n \"current\": rv, \"ideal\": ideal_r,\n \"anchor_ref\": None, \"anchor_value\": None,\n }]\n return []\n\n\n# ---------------------------------------------------------------------------\n# Schema registry\n# ---------------------------------------------------------------------------\n\nSCHEMAS = {\n # --- Detections with derived fields (what-if, tolerance, fix) ---\n \"rc_filters\": DetectionSchema(\n identity_fields=[\"resistor.ref\", \"capacitor.ref\"],\n value_fields=[\"cutoff_hz\"],\n derived=[DerivedField(\"cutoff_hz\", _recalc_rc_cutoff, _inverse_rc_cutoff)],\n primary_metric=\"cutoff_hz\",\n ),\n \"lc_filters\": DetectionSchema(\n identity_fields=[\"inductor.ref\", \"capacitor.ref\"],\n value_fields=[\"resonant_hz\"],\n derived=[\n DerivedField(\"resonant_hz\", _recalc_lc_filter, _inverse_lc_resonant),\n DerivedField(\"impedance_ohms\", _recalc_lc_filter),\n ],\n primary_metric=\"resonant_hz\",\n ),\n \"voltage_dividers\": DetectionSchema(\n identity_fields=[\"r_top.ref\", \"r_bottom.ref\"],\n value_fields=[\"ratio\", \"vout_V\"],\n derived=[DerivedField(\"ratio\", _recalc_divider_ratio, _inverse_divider_ratio)],\n primary_metric=\"vout_V\",\n ),\n \"feedback_networks\": DetectionSchema(\n identity_fields=[\"r_top.ref\", \"r_bottom.ref\"],\n value_fields=[\"ratio\"],\n derived=[DerivedField(\"ratio\", _recalc_divider_ratio, _inverse_divider_ratio)],\n primary_metric=\"fb_voltage_V\",\n ),\n \"opamp_circuits\": DetectionSchema(\n identity_fields=[\"reference\"],\n value_fields=[\"gain\", \"gain_dB\", \"configuration\"],\n derived=[\n DerivedField(\"gain\", _recalc_opamp_gain, _inverse_opamp_gain),\n DerivedField(\"gain_dB\", _recalc_opamp_gain),\n ],\n primary_metric=\"gain_dB\",\n ),\n \"crystal_circuits\": DetectionSchema(\n identity_fields=[\"reference\"],\n value_fields=[\"frequency\", \"effective_load_pF\"],\n derived=[DerivedField(\"effective_load_pF\", _recalc_crystal_load, _inverse_crystal_load)],\n primary_metric=\"load_capacitance_pF\",\n ),\n \"current_sense\": DetectionSchema(\n identity_fields=[\"shunt.ref\"],\n value_fields=[\"max_current_50mV_A\", \"max_current_100mV_A\"],\n derived=[\n DerivedField(\"max_current_50mV_A\", _recalc_current_sense, _inverse_current_sense),\n DerivedField(\"max_current_100mV_A\", _recalc_current_sense),\n ],\n primary_metric=\"i_at_100mV_A\",\n ),\n \"power_regulators\": DetectionSchema(\n identity_fields=[\"ref\"],\n value_fields=[\"estimated_vout\", \"topology\"],\n derived=[DerivedField(\"estimated_vout\", _recalc_regulator_feedback)],\n primary_metric=None,\n ),\n # --- Detections without derived fields (diff/SPICE only) ---\n \"transistor_circuits\": DetectionSchema(\n identity_fields=[\"reference\"],\n value_fields=[\"type\"],\n primary_metric=\"vth_V\",\n ),\n \"protection_devices\": DetectionSchema(\n identity_fields=[\"reference\", \"type\"],\n value_fields=[\"protected_net\"],\n ),\n \"bridge_circuits\": DetectionSchema(\n identity_fields=[\"topology\"],\n value_fields=[],\n primary_metric=\"vth_low_side_V\",\n ),\n \"rf_matching\": DetectionSchema(\n identity_fields=[\"antenna_ref\"],\n value_fields=[],\n primary_metric=\"z_min_ohms\",\n ),\n \"bms_systems\": DetectionSchema(\n identity_fields=[\"bms_reference\"],\n value_fields=[\"cell_count\"],\n primary_metric=\"i_balance_mA\",\n ),\n \"decoupling_analysis\": DetectionSchema(\n identity_fields=[\"rail_net\"],\n value_fields=[],\n primary_metric=\"z_min_ohms\",\n ),\n \"rf_chains\": DetectionSchema(\n identity_fields=[],\n value_fields=[],\n primary_metric=\"z_min_ohms\",\n ),\n \"ethernet_interfaces\": DetectionSchema(\n identity_fields=[\"phy_ref\"],\n value_fields=[],\n ),\n \"memory_interfaces\": DetectionSchema(\n identity_fields=[\"type\"],\n value_fields=[],\n ),\n \"isolation_barriers\": DetectionSchema(\n identity_fields=[\"isolator_ref\"],\n value_fields=[],\n ),\n \"snubbers\": DetectionSchema(\n identity_fields=[],\n value_fields=[],\n primary_metric=\"z_min_ohms\",\n ),\n # --- Detections added for KH-233 (identity-only, no derived fields) ---\n \"rail_voltages\": DetectionSchema(\n identity_fields=[],\n value_fields=[],\n ),\n \"addressable_led_chains\": DetectionSchema(\n identity_fields=[\"first_led\"],\n value_fields=[\"chain_length\", \"protocol\"],\n ),\n \"adc_circuits\": DetectionSchema(\n identity_fields=[\"ref\"],\n value_fields=[\"type\", \"interface\"],\n ),\n \"audio_circuits\": DetectionSchema(\n identity_fields=[\"ref\"],\n value_fields=[\"type\", \"amplifier_class\"],\n ),\n \"battery_chargers\": DetectionSchema(\n identity_fields=[\"reference\"],\n value_fields=[\"charger_type\", \"charger_family\"],\n ),\n \"buzzer_speaker_circuits\": DetectionSchema(\n identity_fields=[\"reference\"],\n value_fields=[\"type\"],\n ),\n \"clock_distribution\": DetectionSchema(\n identity_fields=[\"ref\"],\n value_fields=[\"type\"],\n ),\n \"connector_ground_audit\": DetectionSchema(\n identity_fields=[\"ref\"],\n value_fields=[\"status\", \"signal_per_ground\"],\n ),\n \"debug_interfaces\": DetectionSchema(\n identity_fields=[\"connector\"],\n value_fields=[\"interface_type\"],\n ),\n \"display_interfaces\": DetectionSchema(\n identity_fields=[\"ref\"],\n value_fields=[\"display_type\", \"interface\"],\n ),\n \"hdmi_dvi_interfaces\": DetectionSchema(\n identity_fields=[\"reference\"],\n value_fields=[\"type\"],\n ),\n \"key_matrices\": DetectionSchema(\n identity_fields=[],\n value_fields=[\"rows\", \"columns\", \"estimated_keys\"],\n ),\n \"led_driver_ics\": DetectionSchema(\n identity_fields=[\"ref\"],\n value_fields=[\"type\", \"interface\", \"channels\"],\n ),\n \"level_shifters\": DetectionSchema(\n identity_fields=[\"ref\"],\n value_fields=[\"type\", \"direction\", \"channel_count\"],\n ),\n \"lvds_interfaces\": DetectionSchema(\n identity_fields=[\"reference\"],\n value_fields=[\"role\"],\n ),\n \"motor_drivers\": DetectionSchema(\n identity_fields=[\"reference\"],\n value_fields=[\"type\"],\n ),\n \"reset_supervisors\": DetectionSchema(\n identity_fields=[\"ref\"],\n value_fields=[\"type\", \"threshold_voltage\"],\n ),\n \"rtc_circuits\": DetectionSchema(\n identity_fields=[\"ref\"],\n value_fields=[\"type\", \"interface\"],\n ),\n \"sensor_interfaces\": DetectionSchema(\n identity_fields=[\"ref\"],\n value_fields=[\"type\", \"interface\"],\n ),\n \"suggested_certifications\": DetectionSchema(\n identity_fields=[\"standard\"],\n value_fields=[\"region\", \"reason\"],\n ),\n \"thermocouple_rtd\": DetectionSchema(\n identity_fields=[\"ref\"],\n value_fields=[\"type\", \"interface\"],\n ),\n \"validation_findings\": DetectionSchema(\n identity_fields=[\"rule_id\", \"components\"],\n value_fields=[\"severity\", \"summary\"],\n ),\n \"wireless_modules\": DetectionSchema(\n identity_fields=[\"reference\"],\n value_fields=[\"wireless_type\", \"antenna_net\"],\n ),\n \"transformer_feedback\": DetectionSchema(\n identity_fields=[\"reference\"],\n value_fields=[\"controller_type\", \"optocoupler\"],\n ),\n \"i2c_address_conflicts\": DetectionSchema(\n identity_fields=[\"rule_id\", \"components\"],\n value_fields=[\"severity\"],\n ),\n \"energy_harvesting\": DetectionSchema(\n identity_fields=[\"reference\"],\n value_fields=[\"harvester_type\"],\n ),\n \"pwm_led_dimming\": DetectionSchema(\n identity_fields=[\"reference\"],\n value_fields=[\"leds\", \"switch_type\"],\n ),\n \"headphone_jacks\": DetectionSchema(\n identity_fields=[\"reference\"],\n value_fields=[\"associated_codec\"],\n ),\n \"connectivity_graph\": DetectionSchema(\n identity_fields=[\"net_name\"],\n value_fields=[\"islands\", \"disconnected_pads\"],\n ),\n \"net_classifications\": DetectionSchema(\n identity_fields=[\"net_name\"],\n value_fields=[\"type\", \"frequency_hz\"],\n ),\n # --- PCB rich format + assembly/DFM checks (PCB-R8) ---\n \"dfm_violations\": DetectionSchema(\n identity_fields=[\"parameter\"],\n value_fields=[\"actual_mm\", \"tier_required\"],\n ),\n \"placement_overlaps\": DetectionSchema(\n identity_fields=[\"component_a\", \"component_b\"],\n value_fields=[\"overlap_mm2\"],\n ),\n \"tombstoning_risk\": DetectionSchema(\n identity_fields=[\"component\"],\n value_fields=[\"risk_level\", \"package\"],\n ),\n \"thermal_pad_vias\": DetectionSchema(\n identity_fields=[\"component\", \"pad_number\"],\n value_fields=[\"adequacy\", \"via_count\"],\n ),\n \"fiducial_check\": DetectionSchema(\n identity_fields=[\"side\"],\n value_fields=[\"fiducial_count\"],\n ),\n \"test_point_coverage\": DetectionSchema(\n identity_fields=[],\n value_fields=[\"coverage_pct\", \"nets_with_test_points\"],\n ),\n \"orientation_consistency\": DetectionSchema(\n identity_fields=[\"side\"],\n value_fields=[\"deviator_count\", \"majority_angle\"],\n ),\n \"silkscreen_pad_overlaps\": DetectionSchema(\n identity_fields=[\"component\"],\n value_fields=[\"silk_layer\"],\n ),\n \"via_in_pad_issues\": DetectionSchema(\n identity_fields=[\"component\", \"pad\"],\n value_fields=[\"tented\"],\n ),\n \"keepout_violations\": DetectionSchema(\n identity_fields=[\"component\", \"keepout_name\"],\n value_fields=[\"severity\"],\n ),\n \"board_edge_via_clearance\": DetectionSchema(\n identity_fields=[\"via_x\", \"via_y\"],\n value_fields=[\"edge_clearance_mm\"],\n ),\n # Batch 8: Remaining analyzer rich format\n \"thermal_assessments\": DetectionSchema(\n identity_fields=[\"ref\"],\n value_fields=[\"tj_estimated_c\", \"margin_c\"],\n ),\n \"gerber_findings\": DetectionSchema(\n identity_fields=[\"rule_id\", \"summary\"],\n value_fields=[\"severity\"],\n ),\n \"lifecycle_findings\": DetectionSchema(\n identity_fields=[\"mpn\"],\n value_fields=[\"status\", \"severity\"],\n ),\n \"temperature_findings\": DetectionSchema(\n identity_fields=[\"mpn\"],\n value_fields=[\"component_grade\"],\n ),\n}\n\n\n# ---------------------------------------------------------------------------\n# Convenience functions for consumers\n# ---------------------------------------------------------------------------\n\ndef recalc_derived(det: dict, det_type: str) -> None:\n \"\"\"Recalculate all derived fields for a detection of the given type.\n\n Drop-in replacement for spice_tolerance._recalc_derived(), but uses\n schema-driven dispatch instead of hard-coded if/elif chains.\n \"\"\"\n schema = SCHEMAS.get(det_type)\n if not schema:\n return\n seen = set()\n for df in schema.derived:\n # Avoid calling the same recalc twice (e.g., lc_filter has two fields\n # sharing _recalc_lc_filter)\n fn_id = id(df.recalc)\n if fn_id not in seen:\n df.recalc(det)\n seen.add(fn_id)\n\n\ndef get_derived_field_names(det_type: str) -> list:\n \"\"\"Return list of derived field names for a detection type.\"\"\"\n schema = SCHEMAS.get(det_type)\n if not schema:\n return []\n return [df.name for df in schema.derived]\n\n\ndef get_inverse_solver(det_type: str, field_name: str):\n \"\"\"Return the inverse solver callable for a field, or None.\"\"\"\n schema = SCHEMAS.get(det_type)\n if not schema:\n return None\n for df in schema.derived:\n if df.name == field_name and df.inverse is not None:\n return df.inverse\n # If exact field not found, try first derived field with an inverse\n for df in schema.derived:\n if df.inverse is not None:\n return df.inverse\n return None\n\n\ndef get_identity_and_value_fields(det_type: str) -> tuple:\n \"\"\"Return (identity_fields, value_fields) for a detection type.\n\n Returns ([\"reference\"], []) for unknown types (backward compat with\n diff_analysis.py fallback).\n \"\"\"\n schema = SCHEMAS.get(det_type)\n if not schema:\n return ([\"reference\"], [])\n return (schema.identity_fields, schema.value_fields)\n\n\ndef get_primary_metric(det_type: str) -> str:\n \"\"\"Return the primary metric name for Monte Carlo, or None.\"\"\"\n schema = SCHEMAS.get(det_type)\n return schema.primary_metric if schema else None\n\n\ndef compute_detection_id(det, det_type):\n \"\"\"Compute a stable hash ID for a detection based on identity fields.\n\n Deterministic: same detection -> same ID across runs. List-valued\n identity fields are sorted before hashing so upstream set/dict\n iteration order doesn't affect the ID (KH-316).\n\n Format: det_type:xxxxxxxxxxxx (12-char SHA-256 prefix).\n \"\"\"\n schema = SCHEMAS.get(det_type)\n if not schema:\n return \"\"\n\n parts = [det_type]\n for field in schema.identity_fields:\n val = det\n for key in field.split(\".\"):\n if isinstance(val, dict) and key in val:\n val = val[key]\n else:\n val = None\n break\n if isinstance(val, list):\n val = sorted(val, key=str)\n parts.append(str(val) if val is not None else \"\")\n\n raw = \"::\".join(parts)\n h = hashlib.sha256(raw.encode()).hexdigest()[:12]\n return f\"{det_type}:{h}\"\n","content_type":"text/x-python; charset=utf-8","language":"python","size":23291,"content_sha256":"f1f03c8e9b3ec143a5c4b924f5517df9e3c430da5157678d26cc969d0e0cc285"},{"filename":"scripts/detector_helpers.py","content":"\"\"\"Shared helper functions for signal and domain detectors.\n\nExtracted from repeated patterns across signal_detectors.py and\ndomain_detectors.py to eliminate boilerplate and reduce copy-paste risk.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom kicad_types import AnalysisContext\n\n\ndef index_two_pin_components(\n ctx: AnalysisContext,\n components: list[dict],\n) -> tuple[dict[str, tuple[str, str]], dict[str, list[str]]]:\n \"\"\"Index 2-pin components by their connected nets.\n\n Returns:\n comp_nets: ``{ref: (net1, net2)}`` for each valid component\n net_to_comps: ``{net_name: [ref, ...]}`` reverse index\n\n Skips components with missing nets, single-net connections, or\n same net on both pins (shorted).\n \"\"\"\n comp_nets: dict[str, tuple[str, str]] = {}\n net_to_comps: dict[str, list[str]] = {}\n for comp in components:\n ref = comp[\"reference\"]\n n1, n2 = ctx.get_two_pin_nets(ref)\n if not n1 or not n2 or n1 == n2:\n continue\n comp_nets[ref] = (n1, n2)\n net_to_comps.setdefault(n1, []).append(ref)\n net_to_comps.setdefault(n2, []).append(ref)\n return comp_nets, net_to_comps\n\n\ndef get_components_by_type(\n ctx: AnalysisContext,\n comp_type: str | tuple[str, ...],\n with_parsed_values: bool = False,\n) -> list[dict]:\n \"\"\"Filter ctx.components by type, optionally requiring a parsed value.\n\n Args:\n comp_type: Single type string or tuple of types.\n with_parsed_values: If True, only include components whose reference\n appears in ``ctx.parsed_values``.\n \"\"\"\n if isinstance(comp_type, str):\n comp_type = (comp_type,)\n result = [c for c in ctx.components if c[\"type\"] in comp_type]\n if with_parsed_values:\n result = [c for c in result if c[\"reference\"] in ctx.parsed_values]\n return result\n\n\ndef get_unique_ics(ctx) -> list:\n \"\"\"Return deduplicated list of IC components.\n\n Components with duplicate references are collapsed (keeps first seen).\n \"\"\"\n return list({c[\"reference\"]: c for c in ctx.components if c[\"type\"] == \"ic\"}.values())\n\n\ndef match_ic_keywords(component: dict, keywords: list[str] | tuple[str, ...]) -> bool:\n \"\"\"Check if an IC's value+lib_id contains any of the given keywords.\n\n Performs case-insensitive matching against the concatenation of the\n component's ``value`` and ``lib_id`` fields. Only matches components\n with ``type == 'ic'``.\n \"\"\"\n if component.get(\"type\") != \"ic\":\n return False\n val_lib = (component.get(\"value\", \"\") + \" \" + component.get(\"lib_id\", \"\")).lower()\n return any(k in val_lib for k in keywords)\n","content_type":"text/x-python; charset=utf-8","language":"python","size":2645,"content_sha256":"68fdc890755b53be1e25f5a7d07f72a2a8ca30dc5b0b67f4e2dfafebbe4ebc63"},{"filename":"scripts/diff_analysis.py","content":"#!/usr/bin/env python3\n\"\"\"\nDiff-aware design review for KiCad analysis outputs.\n\nCompares two analysis JSON files (base vs head) and reports changes:\nnew/removed/modified components, signal parameter shifts, EMC finding\ndeltas, and SPICE status transitions.\n\nUsage:\n python3 diff_analysis.py base.json head.json\n python3 diff_analysis.py base.json head.json --text\n python3 diff_analysis.py base.json head.json --output diff.json\n python3 diff_analysis.py base.json head.json --threshold 2.0\n\nSupports: schematic, PCB, EMC, and SPICE analyzer outputs (auto-detected).\nZero dependencies — Python 3.8+ stdlib only.\n\"\"\"\n\nimport argparse\nimport json\nimport os\nimport sys\n\n\n# ---------------------------------------------------------------------------\n# Signal analysis identity & value registry\n# ---------------------------------------------------------------------------\n# Maps detection type -> (identity_fields, value_fields)\n# identity_fields: dotpath fields that uniquely identify a detection\n# value_fields: numeric/string fields to compare for changes\n\nfrom detection_schema import SCHEMAS as _SCHEMAS\nfrom finding_schema import group_findings_legacy\n\n# SIGNAL_REGISTRY is derived from the unified detection schema.\n# Kept as a module-level name for backward compat (validate_signal_registry, _diff_items).\nSIGNAL_REGISTRY = {dt: (s.identity_fields, s.value_fields) for dt, s in _SCHEMAS.items()}\n\n\ndef validate_signal_registry(sample_output: dict) -> list[str]:\n \"\"\"Check SIGNAL_REGISTRY keys exist in a sample analyzer output.\n\n Returns list of warning strings for any registered detection type\n whose key is not found in the findings of the sample output.\n Useful for catching stale registry entries after schema changes.\n \"\"\"\n sa = group_findings_legacy(sample_output)\n warnings = []\n for key in SIGNAL_REGISTRY:\n if key not in sa:\n warnings.append(f\"SIGNAL_REGISTRY key '{key}' not in findings\")\n return warnings\n\n\n# ---------------------------------------------------------------------------\n# Shared primitives\n# ---------------------------------------------------------------------------\n\ndef _resolve(data, dotpath):\n \"\"\"Navigate a dotted path like 'statistics.total_components' to a value.\"\"\"\n obj = data\n for key in dotpath.split(\".\"):\n if isinstance(obj, dict) and key in obj:\n obj = obj[key]\n else:\n return None\n return obj\n\n\ndef _diff_counts(base, head, paths):\n \"\"\"Compare numeric values at dotted paths. Returns only changed paths.\"\"\"\n deltas = {}\n for path in paths:\n bv = _resolve(base, path)\n hv = _resolve(head, path)\n if bv is None and hv is None:\n continue\n bv = bv if bv is not None else 0\n hv = hv if hv is not None else 0\n if bv != hv:\n deltas[path] = {\"base\": bv, \"head\": hv, \"delta\": hv - bv}\n return deltas\n\n\ndef _identity_key(item, fields):\n \"\"\"Build a stable identity string from dotpath fields on a dict item.\"\"\"\n parts = []\n for field in fields:\n val = item\n for key in field.split(\".\"):\n if isinstance(val, dict) and key in val:\n val = val[key]\n else:\n val = None\n break\n if val is None:\n return None\n if isinstance(val, list):\n parts.append(\"|\".join(str(v) for v in sorted(val)))\n else:\n parts.append(str(val))\n return \"::\".join(parts) if parts else None\n\n\ndef _generic_identity(item):\n \"\"\"Fallback identity extraction for unknown detection types.\"\"\"\n for field in (\"reference\", \"ref\"):\n if field in item and isinstance(item[field], str):\n return item[field]\n # Try nested ref fields\n for key, val in item.items():\n if isinstance(val, dict) and \"ref\" in val:\n return val[\"ref\"]\n return None\n\n\ndef _diff_lists(base_items, head_items, id_fields, value_fields, threshold):\n \"\"\"Match items by identity, return added/removed/modified/unchanged.\n\n Returns:\n {added: [...], removed: [...], modified: [...], unchanged_count: int}\n \"\"\"\n result = {\"added\": [], \"removed\": [], \"modified\": [], \"unchanged_count\": 0}\n\n if not isinstance(base_items, list):\n base_items = []\n if not isinstance(head_items, list):\n head_items = []\n\n def _get_key(item):\n did = item.get(\"detection_id\") if isinstance(item, dict) else None\n if did:\n return did\n if id_fields:\n key = _identity_key(item, id_fields)\n if key:\n return key\n return _generic_identity(item)\n\n # Build identity maps\n base_map = {}\n for item in base_items:\n key = _get_key(item)\n if key:\n base_map[key] = item\n\n head_map = {}\n for item in head_items:\n key = _get_key(item)\n if key:\n head_map[key] = item\n\n # Find added, removed, modified\n for key, item in head_map.items():\n if key not in base_map:\n result[\"added\"].append(_summarize_detection(item, id_fields))\n else:\n changes = _compare_fields(base_map[key], item, value_fields, threshold)\n if changes:\n result[\"modified\"].append({\n \"identity\": key.replace(\"::\", \"/\"),\n \"changes\": changes,\n \"base_item\": base_map[key],\n \"head_item\": item,\n })\n else:\n result[\"unchanged_count\"] += 1\n\n for key in base_map:\n if key not in head_map:\n result[\"removed\"].append(_summarize_detection(base_map[key], id_fields))\n\n return result\n\n\ndef _compare_fields(base_item, head_item, fields, threshold):\n \"\"\"Compare specific fields between two matched items. Returns list of changes.\"\"\"\n changes = []\n for field in fields:\n bv = _resolve(base_item, field)\n hv = _resolve(head_item, field)\n if bv == hv:\n continue\n if bv is None or hv is None:\n changes.append({\"field\": field, \"base\": bv, \"head\": hv})\n continue\n if isinstance(bv, (int, float)) and isinstance(hv, (int, float)):\n if bv != 0:\n pct = (hv - bv) / abs(bv) * 100\n if abs(pct) \u003c threshold:\n continue\n changes.append({\n \"field\": field, \"base\": bv, \"head\": hv,\n \"delta_pct\": round(pct, 1),\n })\n elif hv != 0:\n changes.append({\"field\": field, \"base\": bv, \"head\": hv})\n else:\n changes.append({\"field\": field, \"base\": bv, \"head\": hv})\n return changes\n\n\ndef _summarize_detection(item, id_fields):\n \"\"\"Create a concise summary of a detection for added/removed lists.\"\"\"\n summary = {}\n # Include identity fields\n for field in (id_fields or []):\n val = _resolve(item, field)\n if val is not None:\n summary[field.split(\".\")[-1]] = val\n # Include common fields\n for key in (\"reference\", \"ref\", \"value\", \"type\", \"topology\"):\n if key in item and isinstance(item[key], str):\n summary[key] = item[key]\n # Include sub-dict refs\n for key, val in item.items():\n if isinstance(val, dict) and \"ref\" in val and key not in summary:\n summary[key + \"_ref\"] = val[\"ref\"]\n return summary\n\n\ndef _pct_delta(old, new):\n \"\"\"Calculate percentage change. Returns None if old is zero.\"\"\"\n if old == 0:\n return None\n return round((new - old) / abs(old) * 100, 1)\n\n\n# ---------------------------------------------------------------------------\n# Auto-detection\n# ---------------------------------------------------------------------------\n\ndef detect_type(data):\n \"\"\"Infer analyzer type from top-level JSON keys.\"\"\"\n # Prefer explicit analyzer_type field when present\n at = data.get(\"analyzer_type\")\n if at:\n return at\n # Fallback heuristic for older JSON files\n if \"findings\" in data and \"components\" in data:\n return \"schematic\"\n if \"footprints\" in data and \"tracks\" in data:\n return \"pcb\"\n summary = data.get(\"summary\", {})\n if \"findings\" in data and \"emc_risk_score\" in summary:\n return \"emc\"\n if \"simulation_results\" in data:\n return \"spice\"\n # Detect pre-v1.3 schematic format (signal_analysis wrapper, no findings[])\n if \"signal_analysis\" in data and \"components\" in data:\n return \"schematic_old\"\n return None\n\n\n# ---------------------------------------------------------------------------\n# Schematic diff\n# ---------------------------------------------------------------------------\n\ndef diff_schematic(base, head, threshold):\n \"\"\"Diff two schematic analysis JSONs.\"\"\"\n result = {}\n\n # Statistics\n stat_paths = [\n \"statistics.total_components\", \"statistics.total_nets\",\n \"statistics.unique_parts\", \"statistics.total_wires\",\n \"statistics.total_no_connects\",\n ]\n stats = _diff_counts(base, head, stat_paths)\n if stats:\n result[\"statistics\"] = stats\n\n # Components: match by reference\n base_comps = {c[\"reference\"]: c for c in base.get(\"components\", [])\n if isinstance(c, dict) and \"reference\" in c}\n head_comps = {c[\"reference\"]: c for c in head.get(\"components\", [])\n if isinstance(c, dict) and \"reference\" in c}\n\n comp_diff = {\"added\": [], \"removed\": [], \"modified\": []}\n for ref, comp in head_comps.items():\n if ref not in base_comps:\n comp_diff[\"added\"].append({\n \"reference\": ref, \"value\": comp.get(\"value\", \"\"),\n \"footprint\": comp.get(\"footprint\", \"\"),\n })\n else:\n bc = base_comps[ref]\n changes = []\n for field in (\"value\", \"footprint\", \"mpn\"):\n bv = bc.get(field, \"\")\n hv = comp.get(field, \"\")\n if bv != hv:\n changes.append({\"field\": field, \"base\": bv, \"head\": hv})\n if changes:\n comp_diff[\"modified\"].append({\"reference\": ref, \"changes\": changes})\n\n for ref in base_comps:\n if ref not in head_comps:\n bc = base_comps[ref]\n comp_diff[\"removed\"].append({\n \"reference\": ref, \"value\": bc.get(\"value\", \"\"),\n \"footprint\": bc.get(\"footprint\", \"\"),\n })\n\n if comp_diff[\"added\"] or comp_diff[\"removed\"] or comp_diff[\"modified\"]:\n result[\"components\"] = comp_diff\n\n # Signal analysis (grouped from flat findings[])\n base_sa = group_findings_legacy(base)\n head_sa = group_findings_legacy(head)\n all_keys = set(list(base_sa.keys()) + list(head_sa.keys()))\n sa_diff = {}\n\n for det_type in sorted(all_keys):\n base_items = base_sa.get(det_type, [])\n head_items = head_sa.get(det_type, [])\n\n if det_type in SIGNAL_REGISTRY:\n id_fields, val_fields = SIGNAL_REGISTRY[det_type]\n else:\n id_fields, val_fields = [\"reference\"], []\n\n diff = _diff_lists(base_items, head_items, id_fields, val_fields, threshold)\n if diff[\"added\"] or diff[\"removed\"] or diff[\"modified\"]:\n sa_diff[det_type] = diff\n\n if sa_diff:\n result[\"signal_analysis\"] = sa_diff\n\n # Attribution: correlate signal changes with component changes\n if \"signal_analysis\" in result and \"components\" in result:\n comp_changes = {}\n for m in result[\"components\"].get(\"modified\", []):\n for ch in m.get(\"changes\", []):\n if ch[\"field\"] == \"value\":\n comp_changes[m[\"reference\"]] = {\n \"base_value\": ch[\"base\"], \"head_value\": ch[\"head\"]}\n\n if comp_changes:\n for det_type, det_diff in result.get(\"signal_analysis\", {}).items():\n for mod in det_diff.get(\"modified\", []):\n # Find changed components referenced in this detection\n attributed = []\n # Check identity string for component refs\n identity = mod.get(\"identity\", \"\")\n for ref in comp_changes:\n if ref in identity:\n attributed.append(ref)\n # Also check the items themselves if available\n for item_key in (\"base_item\", \"head_item\"):\n item = mod.get(item_key, {})\n if isinstance(item, dict):\n for key, val in item.items():\n if isinstance(val, dict) and \"ref\" in val:\n r = val[\"ref\"]\n if r in comp_changes and r not in attributed:\n attributed.append(r)\n if attributed:\n mod[\"attributed_to\"] = [\n {\"ref\": ref, **comp_changes[ref]} for ref in sorted(attributed)\n ]\n\n # BOM changes\n base_bom = {(b.get(\"value\", \"\"), b.get(\"footprint\", \"\")): b\n for b in base.get(\"bom\", []) if isinstance(b, dict)}\n head_bom = {(b.get(\"value\", \"\"), b.get(\"footprint\", \"\")): b\n for b in head.get(\"bom\", []) if isinstance(b, dict)}\n\n bom_diff = {\"added\": [], \"removed\": [], \"quantity_changes\": []}\n for key, entry in head_bom.items():\n if key not in base_bom:\n bom_diff[\"added\"].append({\n \"value\": entry.get(\"value\", \"\"),\n \"footprint\": entry.get(\"footprint\", \"\"),\n \"quantity\": entry.get(\"quantity\", 0),\n })\n else:\n bq = base_bom[key].get(\"quantity\", 0)\n hq = entry.get(\"quantity\", 0)\n if bq != hq:\n bom_diff[\"quantity_changes\"].append({\n \"value\": entry.get(\"value\", \"\"),\n \"footprint\": entry.get(\"footprint\", \"\"),\n \"base_qty\": bq, \"head_qty\": hq, \"delta\": hq - bq,\n })\n for key in base_bom:\n if key not in head_bom:\n entry = base_bom[key]\n bom_diff[\"removed\"].append({\n \"value\": entry.get(\"value\", \"\"),\n \"footprint\": entry.get(\"footprint\", \"\"),\n \"quantity\": entry.get(\"quantity\", 0),\n })\n\n if bom_diff[\"added\"] or bom_diff[\"removed\"] or bom_diff[\"quantity_changes\"]:\n result[\"bom\"] = bom_diff\n\n # Connectivity issues (items may be strings or dicts)\n def _conn_key(item):\n if isinstance(item, dict):\n return json.dumps(item, sort_keys=True)\n return str(item)\n\n conn_diff = {}\n for section in (\"single_pin_nets\", \"floating_nets\", \"multi_driver_nets\"):\n base_items = base.get(\"connectivity_issues\", {}).get(section, [])\n head_items = head.get(\"connectivity_issues\", {}).get(section, [])\n base_map = {_conn_key(i): i for i in base_items}\n head_map = {_conn_key(i): i for i in head_items}\n new_keys = set(head_map) - set(base_map)\n resolved_keys = set(base_map) - set(head_map)\n if new_keys or resolved_keys:\n entry = {}\n if new_keys:\n entry[\"new\"] = [head_map[k] for k in sorted(new_keys)]\n if resolved_keys:\n entry[\"resolved\"] = [base_map[k] for k in sorted(resolved_keys)]\n conn_diff[section] = entry\n if conn_diff:\n result[\"connectivity\"] = conn_diff\n\n # ERC warnings (list of dicts — key on type/net/message for stable identity)\n def _erc_key(w):\n if isinstance(w, dict):\n return (w.get(\"type\", \"\"), w.get(\"net\", \"\"), w.get(\"message\", \"\"))\n return (str(w),)\n\n base_erc_list = base.get(\"design_analysis\", {}).get(\"erc_warnings\", [])\n head_erc_list = head.get(\"design_analysis\", {}).get(\"erc_warnings\", [])\n base_erc_map = {_erc_key(w): w for w in base_erc_list if isinstance(w, (dict, str))}\n head_erc_map = {_erc_key(w): w for w in head_erc_list if isinstance(w, (dict, str))}\n new_keys = set(head_erc_map) - set(base_erc_map)\n resolved_keys = set(base_erc_map) - set(head_erc_map)\n if new_keys or resolved_keys:\n erc = {}\n if new_keys:\n erc[\"new_warnings\"] = [head_erc_map[k] for k in sorted(new_keys)]\n if resolved_keys:\n erc[\"resolved_warnings\"] = [base_erc_map[k] for k in sorted(resolved_keys)]\n result[\"erc\"] = erc\n\n return result\n\n\n# ---------------------------------------------------------------------------\n# PCB diff\n# ---------------------------------------------------------------------------\n\ndef diff_pcb(base, head, threshold):\n \"\"\"Diff two PCB analysis JSONs.\"\"\"\n result = {}\n\n # Statistics\n stat_paths = [\n \"statistics.footprint_count\", \"statistics.track_segments\",\n \"statistics.via_count\", \"statistics.zone_count\",\n \"statistics.net_count\", \"statistics.copper_layers_used\",\n \"statistics.board_width_mm\", \"statistics.board_height_mm\",\n \"statistics.total_track_length_mm\",\n ]\n stats = _diff_counts(base, head, stat_paths)\n if stats:\n result[\"statistics\"] = stats\n\n # Routing completeness\n base_rc = _resolve(base, \"connectivity.routing_complete\")\n head_rc = _resolve(head, \"connectivity.routing_complete\")\n if base_rc != head_rc:\n result[\"routing_complete\"] = {\"base\": base_rc, \"head\": head_rc}\n\n unrouted = _diff_counts(base, head, [\"connectivity.unrouted_count\"])\n if unrouted:\n result[\"unrouted\"] = unrouted\n\n # Footprints: match by reference\n base_fps = {f[\"reference\"]: f for f in base.get(\"footprints\", [])\n if isinstance(f, dict) and \"reference\" in f}\n head_fps = {f[\"reference\"]: f for f in head.get(\"footprints\", [])\n if isinstance(f, dict) and \"reference\" in f}\n\n fp_diff = {\"added\": [], \"removed\": [], \"modified\": []}\n for ref, fp in head_fps.items():\n if ref not in base_fps:\n fp_diff[\"added\"].append({\n \"reference\": ref, \"value\": fp.get(\"value\", \"\"),\n \"lib_id\": fp.get(\"lib_id\", \"\"), \"layer\": fp.get(\"layer\", \"\"),\n })\n else:\n bfp = base_fps[ref]\n changes = []\n for field in (\"value\", \"lib_id\", \"layer\"):\n bv = bfp.get(field, \"\")\n hv = fp.get(field, \"\")\n if bv != hv:\n changes.append({\"field\": field, \"base\": bv, \"head\": hv})\n if changes:\n fp_diff[\"modified\"].append({\"reference\": ref, \"changes\": changes})\n\n for ref in base_fps:\n if ref not in head_fps:\n bfp = base_fps[ref]\n fp_diff[\"removed\"].append({\n \"reference\": ref, \"value\": bfp.get(\"value\", \"\"),\n \"layer\": bfp.get(\"layer\", \"\"),\n })\n\n if fp_diff[\"added\"] or fp_diff[\"removed\"] or fp_diff[\"modified\"]:\n result[\"footprints\"] = fp_diff\n\n return result\n\n\n# ---------------------------------------------------------------------------\n# EMC diff\n# ---------------------------------------------------------------------------\n\ndef diff_emc(base, head, threshold):\n \"\"\"Diff two EMC analysis JSONs.\"\"\"\n result = {}\n\n # Risk score\n base_score = _resolve(base, \"summary.emc_risk_score\")\n head_score = _resolve(head, \"summary.emc_risk_score\")\n if base_score is not None and head_score is not None and base_score != head_score:\n result[\"risk_score\"] = {\n \"base\": base_score, \"head\": head_score,\n \"delta\": head_score - base_score,\n }\n\n # Severity distribution\n sev_paths = [\"summary.critical\", \"summary.high\", \"summary.medium\",\n \"summary.low\", \"summary.info\"]\n sev_delta = _diff_counts(base, head, sev_paths)\n if sev_delta:\n result[\"by_severity\"] = sev_delta\n\n # Findings: match by (rule_id, sorted nets, sorted components)\n def _finding_key(f):\n rule = f.get(\"rule_id\", \"\")\n nets = \"|\".join(sorted(f.get(\"nets\", [])))\n comps = \"|\".join(sorted(f.get(\"components\", [])))\n return f\"{rule}::{nets}::{comps}\"\n\n base_findings = {_finding_key(f): f for f in base.get(\"findings\", [])\n if isinstance(f, dict)}\n head_findings = {_finding_key(f): f for f in head.get(\"findings\", [])\n if isinstance(f, dict)}\n\n findings_diff = {\"new\": [], \"resolved\": [], \"changed_severity\": []}\n\n for key, f in head_findings.items():\n if key not in base_findings:\n findings_diff[\"new\"].append({\n \"rule_id\": f.get(\"rule_id\", \"\"),\n \"severity\": f.get(\"severity\", \"\"),\n \"title\": f.get(\"title\", \"\"),\n \"category\": f.get(\"category\", \"\"),\n \"nets\": f.get(\"nets\", []),\n \"components\": f.get(\"components\", []),\n })\n else:\n bf = base_findings[key]\n if bf.get(\"severity\") != f.get(\"severity\"):\n findings_diff[\"changed_severity\"].append({\n \"rule_id\": f.get(\"rule_id\", \"\"),\n \"base_severity\": bf.get(\"severity\", \"\"),\n \"head_severity\": f.get(\"severity\", \"\"),\n \"title\": f.get(\"title\", \"\"),\n })\n\n for key, f in base_findings.items():\n if key not in head_findings:\n findings_diff[\"resolved\"].append({\n \"rule_id\": f.get(\"rule_id\", \"\"),\n \"severity\": f.get(\"severity\", \"\"),\n \"title\": f.get(\"title\", \"\"),\n \"category\": f.get(\"category\", \"\"),\n })\n\n if findings_diff[\"new\"] or findings_diff[\"resolved\"] or findings_diff[\"changed_severity\"]:\n result[\"findings\"] = findings_diff\n\n # Per-net score changes\n base_nets = {n[\"net\"]: n for n in base.get(\"per_net_scores\", [])\n if isinstance(n, dict) and \"net\" in n}\n head_nets = {n[\"net\"]: n for n in head.get(\"per_net_scores\", [])\n if isinstance(n, dict) and \"net\" in n}\n\n net_changes = []\n for net, entry in head_nets.items():\n if net in base_nets:\n bs = base_nets[net].get(\"score\", 0)\n hs = entry.get(\"score\", 0)\n if abs(hs - bs) >= threshold:\n net_changes.append({\n \"net\": net, \"base_score\": bs, \"head_score\": hs,\n \"delta\": hs - bs,\n })\n if net_changes:\n net_changes.sort(key=lambda n: -abs(n[\"delta\"]))\n result[\"per_net_scores\"] = net_changes\n\n return result\n\n\n# ---------------------------------------------------------------------------\n# SPICE diff\n# ---------------------------------------------------------------------------\n\ndef diff_spice(base, head, threshold):\n \"\"\"Diff two SPICE simulation JSONs.\"\"\"\n result = {}\n\n # Summary deltas\n sum_paths = [\"summary.pass\", \"summary.warn\", \"summary.fail\", \"summary.skip\",\n \"summary.total\"]\n sum_delta = _diff_counts(base, head, sum_paths)\n if sum_delta:\n result[\"summary\"] = sum_delta\n\n # Results: match by (subcircuit_type, sorted components)\n def _sim_key(r):\n stype = r.get(\"subcircuit_type\", \"\")\n comps = \"|\".join(sorted(r.get(\"components\", [])))\n return f\"{stype}::{comps}\"\n\n base_results = {_sim_key(r): r for r in base.get(\"simulation_results\", [])\n if isinstance(r, dict)}\n head_results = {_sim_key(r): r for r in head.get(\"simulation_results\", [])\n if isinstance(r, dict)}\n\n status_changes = []\n new_results = []\n removed_results = []\n\n for key, r in head_results.items():\n if key not in base_results:\n new_results.append({\n \"subcircuit_type\": r.get(\"subcircuit_type\", \"\"),\n \"components\": r.get(\"components\", []),\n \"status\": r.get(\"status\", \"\"),\n })\n else:\n br = base_results[key]\n bs = br.get(\"status\", \"\")\n hs = r.get(\"status\", \"\")\n if bs != hs:\n entry = {\n \"subcircuit_type\": r.get(\"subcircuit_type\", \"\"),\n \"components\": r.get(\"components\", []),\n \"base_status\": bs,\n \"head_status\": hs,\n }\n # Add note for regressions\n if bs == \"pass\" and hs in (\"fail\", \"warn\"):\n delta = r.get(\"delta\", {})\n notes = [f\"{k}={v}\" for k, v in delta.items()\n if isinstance(v, (int, float))]\n if notes:\n entry[\"note\"] = \", \".join(notes[:3])\n status_changes.append(entry)\n\n for key, r in base_results.items():\n if key not in head_results:\n removed_results.append({\n \"subcircuit_type\": r.get(\"subcircuit_type\", \"\"),\n \"components\": r.get(\"components\", []),\n \"status\": r.get(\"status\", \"\"),\n })\n\n if status_changes:\n result[\"status_changes\"] = status_changes\n if new_results:\n result[\"new_results\"] = new_results\n if removed_results:\n result[\"removed_results\"] = removed_results\n\n # Monte Carlo concerns diff\n base_mc = base.get(\"monte_carlo_summary\", {}).get(\"concerns\", [])\n head_mc = head.get(\"monte_carlo_summary\", {}).get(\"concerns\", [])\n if base_mc or head_mc:\n def _mc_key(c):\n return f\"{c.get('subcircuit_type', '')}::{c.get('metric', '')}\"\n base_mc_map = {_mc_key(c): c for c in base_mc}\n head_mc_map = {_mc_key(c): c for c in head_mc}\n new_concerns = [c for k, c in head_mc_map.items() if k not in base_mc_map]\n resolved_concerns = [c for k, c in base_mc_map.items() if k not in head_mc_map]\n if new_concerns or resolved_concerns:\n mc = {}\n if new_concerns:\n mc[\"new\"] = new_concerns\n if resolved_concerns:\n mc[\"resolved\"] = resolved_concerns\n result[\"monte_carlo\"] = mc\n\n return result\n\n\n# ---------------------------------------------------------------------------\n# Severity classification\n# ---------------------------------------------------------------------------\n\ndef classify_severity(analyzer_type, diff_result):\n \"\"\"Classify overall change severity.\"\"\"\n if not diff_result:\n return \"none\"\n\n # Breaking: SPICE pass->fail, new EMC CRITICAL, new ERC warnings\n if analyzer_type == \"spice\":\n for sc in diff_result.get(\"status_changes\", []):\n if sc.get(\"base_status\") == \"pass\" and sc.get(\"head_status\") == \"fail\":\n return \"breaking\"\n\n if analyzer_type == \"emc\":\n for f in diff_result.get(\"findings\", {}).get(\"new\", []):\n if f.get(\"severity\") == \"CRITICAL\":\n return \"breaking\"\n\n if analyzer_type == \"schematic\":\n if diff_result.get(\"erc\", {}).get(\"new_warnings\"):\n return \"breaking\"\n\n # Major: component changes, signal parameter shifts, new/removed detections\n if \"signal_analysis\" in diff_result or \"components\" in diff_result:\n return \"major\"\n if \"findings\" in diff_result:\n return \"major\"\n if \"status_changes\" in diff_result:\n return \"major\"\n if \"footprints\" in diff_result:\n fp = diff_result[\"footprints\"]\n if fp.get(\"added\") or fp.get(\"removed\") or fp.get(\"modified\"):\n return \"major\"\n\n # Minor: only statistics changes\n if \"statistics\" in diff_result:\n return \"minor\"\n\n return \"none\"\n\n\ndef classify_regressions(analyzer_type, diff_result):\n \"\"\"Identify specific regressions in the diff.\n\n Returns list of {category, severity, detail} for changes that\n made the design worse.\n \"\"\"\n regressions = []\n\n if analyzer_type == \"schematic\":\n # New ERC warnings\n for w in diff_result.get(\"erc\", {}).get(\"new_warnings\", []):\n regressions.append({\n \"category\": \"erc\",\n \"severity\": \"breaking\",\n \"detail\": f\"New ERC warning: {w.get('type', '?')} on {w.get('net', '?')}\",\n })\n # New connectivity issues\n for issue_type in (\"single_pin_nets\", \"floating_nets\", \"multi_driver_nets\"):\n conn = diff_result.get(\"connectivity\", {}).get(issue_type, {})\n new_items = conn.get(\"new\", [])\n for item in new_items:\n detail = item if isinstance(item, str) else str(item)\n regressions.append({\n \"category\": \"connectivity\",\n \"severity\": \"major\",\n \"detail\": f\"New {issue_type.replace('_', ' ')}: {detail}\",\n })\n # Removed protection devices\n for p in diff_result.get(\"signal_analysis\", {}).get(\"protection_devices\", {}).get(\"removed\", []):\n ident = p.get(\"identity\", p.get(\"reference\", \"?\"))\n regressions.append({\n \"category\": \"protection\",\n \"severity\": \"major\",\n \"detail\": f\"Protection device removed: {ident}\",\n })\n\n elif analyzer_type == \"emc\":\n # New critical/high findings\n for f in diff_result.get(\"findings\", {}).get(\"new\", []):\n sev = f.get(\"severity\", \"\").upper()\n if sev in (\"CRITICAL\", \"HIGH\"):\n regressions.append({\n \"category\": \"emc\",\n \"severity\": \"breaking\" if sev == \"CRITICAL\" else \"major\",\n \"detail\": f\"New EMC finding: {f.get('rule_id', '?')} ({sev})\",\n })\n # Risk score increase\n risk = diff_result.get(\"risk_score\", {})\n if isinstance(risk, dict) and risk.get(\"delta\", 0) > 0:\n regressions.append({\n \"category\": \"emc_risk\",\n \"severity\": \"major\",\n \"detail\": f\"EMC risk score increased: {risk.get('base', '?')} \\u2192 {risk.get('head', '?')}\",\n })\n\n elif analyzer_type == \"spice\":\n # Pass -> fail transitions\n for sc in diff_result.get(\"status_changes\", []):\n if sc.get(\"base_status\") == \"pass\" and sc.get(\"head_status\") == \"fail\":\n regressions.append({\n \"category\": \"spice\",\n \"severity\": \"breaking\",\n \"detail\": f\"SPICE regression: {sc.get('subcircuit_type', '?')} pass\\u2192fail\",\n })\n\n elif analyzer_type == \"pcb\":\n # Unrouted count increase\n stats = diff_result.get(\"statistics\", {})\n for path, info in stats.items():\n if \"unrouted\" in path and isinstance(info, dict) and info.get(\"delta\", 0) > 0:\n regressions.append({\n \"category\": \"routing\",\n \"severity\": \"major\",\n \"detail\": f\"Unrouted nets increased: {info.get('base', 0)} \\u2192 {info.get('head', 0)}\",\n })\n # Also check dedicated unrouted field\n unrouted = diff_result.get(\"unrouted\", {})\n for path, info in unrouted.items():\n if isinstance(info, dict) and info.get(\"delta\", 0) > 0:\n regressions.append({\n \"category\": \"routing\",\n \"severity\": \"major\",\n \"detail\": f\"Unrouted nets increased: {info.get('base', 0)} \\u2192 {info.get('head', 0)}\",\n })\n\n return regressions\n\n\n# ---------------------------------------------------------------------------\n# Summary builder\n# ---------------------------------------------------------------------------\n\ndef build_summary(analyzer_type, diff_result):\n \"\"\"Build a top-level summary of all changes.\"\"\"\n added = 0\n removed = 0\n modified = 0\n\n if analyzer_type == \"schematic\":\n comp = diff_result.get(\"components\", {})\n added += len(comp.get(\"added\", []))\n removed += len(comp.get(\"removed\", []))\n modified += len(comp.get(\"modified\", []))\n for det_type, det_diff in diff_result.get(\"signal_analysis\", {}).items():\n added += len(det_diff.get(\"added\", []))\n removed += len(det_diff.get(\"removed\", []))\n modified += len(det_diff.get(\"modified\", []))\n\n elif analyzer_type == \"pcb\":\n fp = diff_result.get(\"footprints\", {})\n added += len(fp.get(\"added\", []))\n removed += len(fp.get(\"removed\", []))\n modified += len(fp.get(\"modified\", []))\n\n elif analyzer_type == \"emc\":\n findings = diff_result.get(\"findings\", {})\n added += len(findings.get(\"new\", []))\n removed += len(findings.get(\"resolved\", []))\n modified += len(findings.get(\"changed_severity\", []))\n\n elif analyzer_type == \"spice\":\n added += len(diff_result.get(\"new_results\", []))\n removed += len(diff_result.get(\"removed_results\", []))\n modified += len(diff_result.get(\"status_changes\", []))\n\n total = added + removed + modified\n severity = classify_severity(analyzer_type, diff_result)\n\n return {\n \"total_changes\": total,\n \"added\": added,\n \"removed\": removed,\n \"modified\": modified,\n \"severity\": severity,\n }\n\n\n# ---------------------------------------------------------------------------\n# Text formatter\n# ---------------------------------------------------------------------------\n\nMAX_TEXT_ITEMS = 20\n\n\ndef format_text(output):\n \"\"\"Format diff output as human-readable text.\"\"\"\n lines = []\n atype = output.get(\"analyzer_type\", \"?\")\n summary = output.get(\"summary\", {})\n severity = summary.get(\"severity\", \"none\")\n total = summary.get(\"total_changes\", 0)\n\n lines.append(f\"Design Changes: {atype} ({severity}) — {total} changes\")\n\n regressions = output.get(\"regressions\", [])\n\n if total == 0 and not regressions:\n lines.append(\" No changes detected.\")\n return \"\\n\".join(lines)\n\n s = summary\n lines.append(f\" +{s['added']} added, -{s['removed']} removed, ~{s['modified']} modified\")\n\n if regressions:\n lines.append(\"\")\n lines.append(f\"\\u26a0 {len(regressions)} regression(s) detected:\")\n for r in regressions:\n lines.append(f\" [{r['severity'].upper()}] {r['detail']}\")\n\n lines.append(\"\")\n\n diff = output.get(\"diff\", {})\n shown = 0\n\n # Components (schematic)\n comp = diff.get(\"components\", {})\n if comp:\n lines.append(\"Components:\")\n for c in comp.get(\"added\", [])[:5]:\n lines.append(f\" + {c.get('reference', '?')} {c.get('value', '')} {c.get('footprint', '')}\")\n shown += 1\n for c in comp.get(\"removed\", [])[:5]:\n lines.append(f\" - {c.get('reference', '?')} {c.get('value', '')} {c.get('footprint', '')}\")\n shown += 1\n for c in comp.get(\"modified\", [])[:5]:\n ref = c.get(\"reference\", \"?\")\n for ch in c.get(\"changes\", []):\n lines.append(f\" ~ {ref}: {ch['field']} {ch.get('base', '?')} → {ch.get('head', '?')}\")\n shown += 1\n lines.append(\"\")\n\n # Signal analysis (schematic)\n sa = diff.get(\"signal_analysis\", {})\n if sa:\n lines.append(\"Signal Analysis:\")\n for det_type, det_diff in sa.items():\n label = det_type.replace(\"_\", \" \").title()\n for item in det_diff.get(\"added\", [])[:3]:\n desc = \" \".join(f\"{k}={v}\" for k, v in item.items())\n lines.append(f\" + New {label}: {desc}\")\n shown += 1\n for item in det_diff.get(\"removed\", [])[:3]:\n desc = \" \".join(f\"{k}={v}\" for k, v in item.items())\n lines.append(f\" - Removed {label}: {desc}\")\n shown += 1\n for item in det_diff.get(\"modified\", [])[:3]:\n identity = item.get(\"identity\", \"?\")\n changes = \", \".join(\n f\"{ch['field']} {ch.get('base', '?')} → {ch.get('head', '?')}\"\n for ch in item.get(\"changes\", [])\n )\n lines.append(f\" ~ {label} {identity}: {changes}\")\n attr = item.get(\"attributed_to\", [])\n if attr:\n causes = \", \".join(\n f\"{a['ref']} {a['base_value']}\\u2192{a['head_value']}\" for a in attr)\n lines.append(f\" caused by: {causes}\")\n shown += 1\n if shown >= MAX_TEXT_ITEMS:\n break\n lines.append(\"\")\n\n # Footprints (PCB)\n fp = diff.get(\"footprints\", {})\n if fp:\n lines.append(\"Footprints:\")\n for f in fp.get(\"added\", [])[:5]:\n lines.append(f\" + {f.get('reference', '?')} {f.get('value', '')} ({f.get('layer', '')})\")\n for f in fp.get(\"removed\", [])[:5]:\n lines.append(f\" - {f.get('reference', '?')} {f.get('value', '')} ({f.get('layer', '')})\")\n for f in fp.get(\"modified\", [])[:5]:\n ref = f.get(\"reference\", \"?\")\n for ch in f.get(\"changes\", []):\n lines.append(f\" ~ {ref}: {ch['field']} {ch.get('base', '?')} → {ch.get('head', '?')}\")\n lines.append(\"\")\n\n # EMC findings\n findings = diff.get(\"findings\", {})\n if findings:\n lines.append(\"EMC Findings:\")\n for f in findings.get(\"new\", [])[:5]:\n lines.append(f\" NEW: {f.get('rule_id', '?')} ({f.get('severity', '?')}) {f.get('title', '')}\")\n for f in findings.get(\"resolved\", [])[:5]:\n lines.append(f\" RESOLVED: {f.get('rule_id', '?')} ({f.get('severity', '?')}) {f.get('title', '')}\")\n for f in findings.get(\"changed_severity\", [])[:3]:\n lines.append(f\" CHANGED: {f.get('rule_id', '?')} {f.get('base_severity', '?')} → {f.get('head_severity', '?')}\")\n lines.append(\"\")\n\n # SPICE status changes\n status_changes = diff.get(\"status_changes\", [])\n if status_changes:\n lines.append(\"SPICE:\")\n for sc in status_changes[:5]:\n direction = \"REGRESSION\" if sc.get(\"head_status\") == \"fail\" else \"FIXED\" if sc.get(\"head_status\") == \"pass\" else \"CHANGED\"\n comps = \", \".join(sc.get(\"components\", []))\n lines.append(f\" {direction}: {sc.get('subcircuit_type', '?')} {comps}: \"\n f\"{sc.get('base_status', '?')} → {sc.get('head_status', '?')}\")\n lines.append(\"\")\n\n # Risk score\n risk = diff.get(\"risk_score\", {})\n if risk:\n lines.append(f\"EMC Risk Score: {risk.get('base', '?')} → {risk.get('head', '?')} \"\n f\"(delta {risk.get('delta', 0):+d})\")\n lines.append(\"\")\n\n remaining = total - shown\n if remaining > 0:\n lines.append(f\" ... and {remaining} more changes\")\n\n return \"\\n\".join(lines)\n\n\n# ---------------------------------------------------------------------------\n# Multi-run trend extraction\n# ---------------------------------------------------------------------------\n\ndef _extract_trends(analysis_dir, output_type, n_runs):\n \"\"\"Extract metric values across the last N runs.\n\n Returns {det_type: {identity: {field: [(run_id, value), ...]}}}\n \"\"\"\n from analysis_cache import list_runs, CANONICAL_OUTPUTS\n\n runs = list_runs(analysis_dir, limit=n_runs)\n if len(runs) \u003c 2:\n return {}\n\n filename = CANONICAL_OUTPUTS.get(output_type, f\"{output_type}.json\")\n trends = {}\n\n for run_id, _meta in runs:\n path = os.path.join(analysis_dir, run_id, filename)\n if not os.path.isfile(path):\n continue\n try:\n with open(path) as f:\n data = json.load(f)\n except (json.JSONDecodeError, OSError):\n continue\n\n sa = group_findings_legacy(data)\n for det_type, detections in sa.items():\n if not isinstance(detections, list):\n continue\n if det_type not in SIGNAL_REGISTRY:\n continue\n id_fields, val_fields = SIGNAL_REGISTRY[det_type]\n for det in detections:\n key = _identity_key(det, id_fields)\n if not key:\n key = _generic_identity(det)\n if not key:\n continue\n bucket = trends.setdefault(det_type, {}).setdefault(key, {})\n for field in val_fields:\n val = _resolve(det, field)\n if isinstance(val, (int, float)):\n bucket.setdefault(field, []).append((run_id, val))\n\n return trends\n\n\ndef _format_trends(trends, n_runs):\n \"\"\"Format trend data as human-readable text.\"\"\"\n lines = []\n lines.append(f\"Metric Trends (last {n_runs} runs)\")\n lines.append(\"\")\n\n if not trends:\n lines.append(\" No tracked metrics found across runs.\")\n return \"\\n\".join(lines)\n\n for det_type in sorted(trends):\n lines.append(f\" {det_type}:\")\n for identity in sorted(trends[det_type]):\n fields = trends[det_type][identity]\n for field, datapoints in sorted(fields.items()):\n if len(datapoints) \u003c 2:\n continue\n # Reverse to chronological (list_runs returns newest-first)\n datapoints = list(reversed(datapoints))\n vals = [f\"{v:.4g}\" for _, v in datapoints]\n first_val = datapoints[0][1]\n last_val = datapoints[-1][1]\n val_chain = \" \\u2192 \".join(vals)\n if first_val != 0:\n pct = (last_val - first_val) / abs(first_val) * 100\n arrow = \"\\u2191\" if pct > 1 else \"\\u2193\" if pct \u003c -1 else \"\\u2192\"\n lines.append(f\" {identity} {field}: {val_chain} ({arrow}{abs(pct):.1f}%)\")\n else:\n lines.append(f\" {identity} {field}: {val_chain}\")\n lines.append(\"\")\n\n return \"\\n\".join(lines)\n\n\n# ---------------------------------------------------------------------------\n# CLI\n# ---------------------------------------------------------------------------\n\ndef main():\n parser = argparse.ArgumentParser(\n description=\"Compare two KiCad analysis JSON files and report changes\"\n )\n parser.add_argument(\"base\", nargs=\"?\", help=\"Path to base (old) analysis JSON\")\n parser.add_argument(\"head\", nargs=\"?\", help=\"Path to head (new) analysis JSON\")\n parser.add_argument(\"--analysis-dir\", \"-d\",\n help=\"Analysis directory — auto-resolve runs from manifest\")\n parser.add_argument(\"--run\", action=\"append\", default=[],\n help=\"Run ID to compare (use twice: --run OLD --run NEW). \"\n \"Special: 'current', 'previous', or YYYY-MM-DD_HHMM\")\n parser.add_argument(\"--type\", dest=\"output_type\",\n help=\"Output type to diff (schematic, pcb, emc, spice). \"\n \"Default: auto-detect first common output\")\n parser.add_argument(\"--output\", \"-o\", help=\"Write output JSON to file (default: stdout)\")\n parser.add_argument(\"--text\", action=\"store_true\", help=\"Output human-readable text instead of JSON\")\n parser.add_argument(\"--threshold\", type=float, default=1.0,\n help=\"Ignore numeric deltas below this percentage (default: 1.0%%)\")\n parser.add_argument(\"--trend\", type=int, metavar=\"N\",\n help=\"Show metric trends across last N runs (requires --analysis-dir)\")\n args = parser.parse_args()\n\n # Resolve runs from analysis cache if --analysis-dir is provided\n if args.analysis_dir:\n sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))\n from analysis_cache import list_runs, CANONICAL_OUTPUTS\n\n analysis_dir = args.analysis_dir\n if not os.path.isdir(analysis_dir):\n print(f\"Error: analysis directory not found: {analysis_dir}\", file=sys.stderr)\n sys.exit(1)\n\n # Determine which runs to compare\n if len(args.run) == 2:\n run_ids = args.run\n elif len(args.run) == 1:\n run_ids = [args.run[0], \"current\"]\n else:\n run_ids = [\"previous\", \"current\"]\n\n # Resolve special names\n all_runs = list_runs(analysis_dir)\n if not all_runs:\n print(\"Error: no runs found in analysis directory\", file=sys.stderr)\n sys.exit(1)\n\n resolved = []\n for rid in run_ids:\n if rid == \"current\":\n resolved.append(all_runs[0][0])\n elif rid == \"previous\":\n if len(all_runs) \u003c 2:\n print(\"Error: only one run exists — nothing to diff\", file=sys.stderr)\n sys.exit(1)\n resolved.append(all_runs[1][0])\n else:\n # Exact run ID\n found = False\n for r_id, _ in all_runs:\n if r_id == rid:\n found = True\n break\n if not found:\n print(f\"Error: run '{rid}' not found in manifest\", file=sys.stderr)\n sys.exit(1)\n resolved.append(rid)\n\n # Determine output type\n out_type = args.output_type\n if not out_type:\n # Auto-detect: find first common output between the two runs\n for otype in (\"schematic\", \"pcb\", \"emc\", \"spice\"):\n fname = CANONICAL_OUTPUTS.get(otype, f\"{otype}.json\")\n if (os.path.isfile(os.path.join(analysis_dir, resolved[0], fname)) and\n os.path.isfile(os.path.join(analysis_dir, resolved[1], fname))):\n out_type = otype\n break\n if not out_type:\n print(\"Error: no common output files between runs\", file=sys.stderr)\n sys.exit(1)\n\n filename = CANONICAL_OUTPUTS.get(out_type, f\"{out_type}.json\")\n args.base = os.path.join(analysis_dir, resolved[0], filename)\n args.head = os.path.join(analysis_dir, resolved[1], filename)\n\n elif not args.base or not args.head:\n if not args.trend:\n parser.error(\"provide base and head paths, or use --analysis-dir\")\n\n if args.trend:\n if not args.analysis_dir:\n parser.error(\"--trend requires --analysis-dir\")\n out_type = args.output_type or \"schematic\"\n trends = _extract_trends(args.analysis_dir, out_type, args.trend)\n if args.text:\n print(_format_trends(trends, args.trend))\n else:\n json.dump({\"trends\": trends, \"n_runs\": args.trend}, sys.stdout, indent=2)\n print()\n sys.exit(0)\n\n # Load inputs\n try:\n with open(args.base) as f:\n base = json.load(f)\n except (json.JSONDecodeError, OSError) as e:\n print(f\"Error reading base file {args.base}: {e}\", file=sys.stderr)\n sys.exit(1)\n\n try:\n with open(args.head) as f:\n head = json.load(f)\n except (json.JSONDecodeError, OSError) as e:\n print(f\"Error reading head file {args.head}: {e}\", file=sys.stderr)\n sys.exit(1)\n\n # Detect types\n base_type = detect_type(base)\n head_type = detect_type(head)\n if not base_type or not head_type:\n print(\"Error: could not detect analyzer type from JSON\", file=sys.stderr)\n sys.exit(1)\n # Check for pre-v1.3 format\n old_files = []\n if base_type == \"schematic_old\":\n old_files.append(f\"base ({args.base})\")\n if head_type == \"schematic_old\":\n old_files.append(f\"head ({args.head})\")\n if old_files:\n print(f\"Error: {' and '.join(old_files)} use the pre-v1.3 \"\n f\"signal_analysis wrapper format.\\n\"\n f\"Re-run analyze_schematic.py to produce the current \"\n f\"findings[] format, then re-run this diff.\",\n file=sys.stderr)\n sys.exit(1)\n if base_type != head_type:\n print(f\"Error: type mismatch — base is {base_type}, head is {head_type}\", file=sys.stderr)\n sys.exit(1)\n\n # Run diff\n diff_funcs = {\n \"schematic\": diff_schematic,\n \"pcb\": diff_pcb,\n \"emc\": diff_emc,\n \"spice\": diff_spice,\n }\n diff_result = diff_funcs[base_type](base, head, args.threshold)\n summary = build_summary(base_type, diff_result)\n\n output = {\n \"diff_version\": \"1.0\",\n \"analyzer_type\": base_type,\n \"base_file\": args.base,\n \"head_file\": args.head,\n \"has_changes\": summary[\"total_changes\"] > 0,\n \"summary\": summary,\n \"diff\": diff_result,\n }\n\n regressions = classify_regressions(base_type, diff_result)\n if regressions:\n output[\"regressions\"] = regressions\n output[\"summary\"][\"regressions\"] = len(regressions)\n\n if args.text:\n text = format_text(output)\n if args.output:\n with open(args.output, \"w\") as f:\n f.write(text)\n else:\n print(text)\n else:\n output_json = json.dumps(output, indent=2)\n if args.output:\n with open(args.output, \"w\") as f:\n f.write(output_json)\n else:\n print(output_json)\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":49237,"content_sha256":"820df42ce17a6d84e788e9a7f32f2dc4d8d28fed47fe63d9a2e7439537d1b9e5"},{"filename":"scripts/domain_detectors.py","content":"\"\"\"\nDomain-specific detector functions for specialized circuit blocks.\n\nIdentifies functional blocks (Ethernet, HDMI, RF, BMS, battery chargers,\nmotor drivers, etc.) by IC keyword matching and pin tracing. Separated from\nsignal_detectors.py which handles core passive/active circuit analysis\n(filters, dividers, regulators, transistors).\n\nEach detector takes an AnalysisContext (ctx) and returns its detection results.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport re\nfrom collections import Counter\n\nfrom kicad_types import AnalysisContext\nfrom kicad_utils import lookup_regulator_vref, parse_value, parse_voltage_from_net_name\nfrom signal_detectors import _get_net_components\nfrom detector_helpers import get_components_by_type, index_two_pin_components, match_ic_keywords, get_unique_ics\nfrom finding_schema import make_finding, make_provenance\n\n\ndef detect_buzzer_speakers(ctx: AnalysisContext, transistor_circuits: list[dict]) -> list[dict]:\n \"\"\"Detect buzzer/speaker driver circuits.\"\"\"\n buzzer_speaker_circuits: list[dict] = []\n # Build index: net → transistor circuits that drive it\n tc_by_output_net: dict[str, list[dict]] = {}\n for tc in transistor_circuits:\n for key in (\"drain_net\", \"collector_net\"):\n n = tc.get(key)\n if n:\n tc_by_output_net.setdefault(n, []).append(tc)\n buzzer_speaker_types = (\"buzzer\", \"speaker\")\n for comp in ctx.components:\n if comp[\"type\"] not in buzzer_speaker_types:\n continue\n ref = comp[\"reference\"]\n # Find signal nets via direct pin lookup (buzzers/speakers are 2-pin)\n n1, n2 = ctx.get_two_pin_nets(ref)\n signal_net = None\n for net in (n1, n2):\n if net and not ctx.is_ground(net) and not ctx.is_power_net(net):\n signal_net = net\n break\n if not signal_net:\n continue\n net_comps = _get_net_components(ctx, signal_net, ref)\n driver_ic_ref = None\n series_resistor = None\n has_transistor_driver = False\n for nc in net_comps:\n if nc[\"type\"] == \"ic\":\n driver_ic_ref = nc[\"reference\"]\n elif nc[\"type\"] == \"resistor\":\n series_resistor = nc\n # Follow resistor to see if IC is on the other side\n r_n1, r_n2 = ctx.get_two_pin_nets(nc[\"reference\"])\n r_other = r_n2 if r_n1 == signal_net else r_n1\n if r_other:\n for rc in _get_net_components(ctx, r_other, nc[\"reference\"]):\n if rc[\"type\"] == \"ic\":\n driver_ic_ref = rc[\"reference\"]\n elif nc[\"type\"] == \"transistor\":\n has_transistor_driver = True\n # Check indexed transistor circuits for this net\n for tc in tc_by_output_net.get(signal_net, []):\n has_transistor_driver = True\n if not driver_ic_ref and tc.get(\"gate_driver_ics\"):\n driver_ic_ref = tc[\"gate_driver_ics\"][0].get(\"reference\", \"\")\n entry = {\n \"reference\": ref,\n \"value\": comp.get(\"value\", \"\"),\n \"type\": comp[\"type\"],\n \"signal_net\": signal_net,\n \"has_transistor_driver\": has_transistor_driver,\n \"detector\": \"detect_buzzer_speakers\",\n \"rule_id\": \"BZ-DET\",\n \"category\": \"audio\",\n \"severity\": \"info\",\n \"confidence\": \"deterministic\",\n \"evidence_source\": \"topology\",\n \"summary\": f\"{comp['type']} {ref} ({comp.get('value', '')}) on {signal_net}\",\n \"description\": f\"Detected {comp['type']} {ref} driven via {signal_net}.\",\n \"components\": [ref],\n \"nets\": [signal_net] if signal_net else [],\n \"pins\": [],\n \"recommendation\": \"\",\n \"report_context\": {\"section\": \"Audio\", \"impact\": \"\", \"standard_ref\": \"\"},\n }\n if driver_ic_ref:\n entry[\"driver_ic\"] = driver_ic_ref\n if series_resistor:\n entry[\"series_resistor\"] = {\n \"reference\": series_resistor[\"reference\"],\n \"value\": series_resistor.get(\"value\", \"\"),\n }\n if not has_transistor_driver and driver_ic_ref:\n entry[\"direct_gpio_drive\"] = True\n entry[\"provenance\"] = make_provenance(\"buzzer_topology\", \"deterministic\", claimed_components=[ref])\n buzzer_speaker_circuits.append(entry)\n return buzzer_speaker_circuits\n\n\ndef detect_key_matrices(ctx: AnalysisContext) -> list[dict]:\n \"\"\"Detect keyboard-style switch matrices.\"\"\"\n key_matrices: list[dict] = []\n row_nets = {}\n col_nets = {}\n for net_name in ctx.nets:\n nn = net_name.upper().replace(\"_\", \"\").replace(\"-\", \"\").replace(\" \", \"\")\n m_row = re.match(r'^ROW(\\d+)

KiCad Project Analysis Skill Related Skills | Skill | Purpose | |-------|---------| | | BOM extraction, enrichment, ordering, and export workflows | | | Search DigiKey for parts (prototype sourcing) | | | Search Mouser for parts (secondary prototype source) | | | Search LCSC for parts (production sourcing, JLCPCB) | | | Search Newark/Farnell/element14 (international sourcing, reliable datasheets) | | | PCB fabrication & assembly ordering | | | Alternative PCB fabrication & assembly | | | SPICE simulation verification of detected subcircuits | | | EMC pre-compliance risk analysis — consumes sc…

, nn)\n m_col = re.match(r'^COL(\\d+)

KiCad Project Analysis Skill Related Skills | Skill | Purpose | |-------|---------| | | BOM extraction, enrichment, ordering, and export workflows | | | Search DigiKey for parts (prototype sourcing) | | | Search Mouser for parts (secondary prototype source) | | | Search LCSC for parts (production sourcing, JLCPCB) | | | Search Newark/Farnell/element14 (international sourcing, reliable datasheets) | | | PCB fabrication & assembly ordering | | | Alternative PCB fabrication & assembly | | | SPICE simulation verification of detected subcircuits | | | EMC pre-compliance risk analysis — consumes sc…

, nn)\n if not m_row:\n m_row = re.match(r'^ROW(\\d+)

KiCad Project Analysis Skill Related Skills | Skill | Purpose | |-------|---------| | | BOM extraction, enrichment, ordering, and export workflows | | | Search DigiKey for parts (prototype sourcing) | | | Search Mouser for parts (secondary prototype source) | | | Search LCSC for parts (production sourcing, JLCPCB) | | | Search Newark/Farnell/element14 (international sourcing, reliable datasheets) | | | PCB fabrication & assembly ordering | | | Alternative PCB fabrication & assembly | | | SPICE simulation verification of detected subcircuits | | | EMC pre-compliance risk analysis — consumes sc…

, net_name.upper())\n if not m_col:\n m_col = re.match(r'^COL(?:UMN)?(\\d+)

KiCad Project Analysis Skill Related Skills | Skill | Purpose | |-------|---------| | | BOM extraction, enrichment, ordering, and export workflows | | | Search DigiKey for parts (prototype sourcing) | | | Search Mouser for parts (secondary prototype source) | | | Search LCSC for parts (production sourcing, JLCPCB) | | | Search Newark/Farnell/element14 (international sourcing, reliable datasheets) | | | PCB fabrication & assembly ordering | | | Alternative PCB fabrication & assembly | | | SPICE simulation verification of detected subcircuits | | | EMC pre-compliance risk analysis — consumes sc…

, net_name.upper())\n if m_row:\n row_nets[int(m_row.group(1))] = net_name\n elif m_col:\n col_nets[int(m_col.group(1))] = net_name\n\n if row_nets and col_nets:\n switch_count = 0\n diode_count = 0\n counted_refs: set[str] = set()\n for net_name in list(row_nets.values()) + list(col_nets.values()):\n if net_name in ctx.nets:\n for p in ctx.nets[net_name][\"pins\"]:\n ref = p[\"component\"]\n if ref in counted_refs:\n continue\n comp = ctx.comp_lookup.get(ref)\n if comp:\n if comp[\"type\"] == \"switch\":\n switch_count += 1\n counted_refs.add(ref)\n elif comp[\"type\"] == \"diode\":\n diode_count += 1\n counted_refs.add(ref)\n estimated_keys = max(switch_count, diode_count)\n if estimated_keys > 4 and row_nets and col_nets:\n key_matrices.append({\n \"rows\": len(row_nets),\n \"columns\": len(col_nets),\n \"row_nets\": list(row_nets.values()),\n \"col_nets\": list(col_nets.values()),\n \"estimated_keys\": estimated_keys,\n \"switches_on_matrix\": switch_count,\n \"diodes_on_matrix\": diode_count,\n \"detection_method\": \"net_name\",\n \"detector\": \"detect_key_matrices\",\n \"rule_id\": \"KM-DET\",\n \"category\": \"human_interface\",\n \"severity\": \"info\",\n \"confidence\": \"deterministic\",\n \"evidence_source\": \"topology\",\n \"summary\": f\"Key matrix {len(row_nets)}x{len(col_nets)} ({estimated_keys} keys)\",\n \"description\": f\"Detected {len(row_nets)}x{len(col_nets)} key matrix with {estimated_keys} estimated keys.\",\n \"components\": [],\n \"nets\": list(row_nets.values()) + list(col_nets.values()),\n \"pins\": [],\n \"recommendation\": \"\",\n \"report_context\": {\"section\": \"Human Interface\", \"impact\": \"\", \"standard_ref\": \"\"},\n \"provenance\": make_provenance(\"keymatrix_diode_grid\", \"deterministic\"),\n })\n\n # Topology-based detection: find switch-diode pairs and group by shared nets\n # to identify rows/columns regardless of net naming.\n if not key_matrices:\n # KH-152: Exclude solar cells and similar power-generation components\n switches = [c for c in ctx.components if c[\"type\"] == \"switch\"\n and \"solar\" not in c.get(\"lib_id\", \"\").lower()\n and \"solar_cell\" not in c.get(\"value\", \"\").lower()]\n if len(switches) >= 4:\n # For each switch, find if either net has a diode (switch-diode pair)\n # KH-197b: Track paired switches to avoid double-counting\n switch_diode_pairs = []\n paired_switches: set[str] = set()\n for sw in switches:\n if sw[\"reference\"] in paired_switches:\n continue\n sn1, sn2 = ctx.get_two_pin_nets(sw[\"reference\"])\n if not sn1 or not sn2:\n continue\n # Check both nets for connected diodes\n found_pair = False\n for sw_net, other_net in ((sn1, sn2), (sn2, sn1)):\n if found_pair:\n break\n if sw_net not in ctx.nets:\n continue\n for p in ctx.nets[sw_net][\"pins\"]:\n comp = ctx.comp_lookup.get(p[\"component\"])\n if comp and comp[\"type\"] == \"diode\" and p[\"component\"] != sw[\"reference\"]:\n # Found a switch-diode pair: diode's other net = row, sw's other net = col\n dn1, dn2 = ctx.get_two_pin_nets(p[\"component\"])\n diode_other = dn2 if dn1 == sw_net else dn1\n if diode_other and diode_other != other_net:\n switch_diode_pairs.append({\n \"switch\": sw[\"reference\"],\n \"diode\": p[\"component\"],\n \"row_net\": diode_other,\n \"col_net\": other_net,\n })\n paired_switches.add(sw[\"reference\"])\n found_pair = True\n break\n # Group by row/col nets\n if len(switch_diode_pairs) >= 4:\n topo_row_nets = set()\n topo_col_nets = set()\n for pair in switch_diode_pairs:\n topo_row_nets.add(pair[\"row_net\"])\n topo_col_nets.add(pair[\"col_net\"])\n # KH-152: Reject if row/col nets are power rails\n topo_row_nets = {n for n in topo_row_nets\n if not ctx.is_power_net(n) and not ctx.is_ground(n)}\n topo_col_nets = {n for n in topo_col_nets\n if not ctx.is_power_net(n) and not ctx.is_ground(n)}\n # KH-197c: Resolve ambiguous nets that appear in both sets\n ambiguous = topo_row_nets & topo_col_nets\n if ambiguous:\n row_votes = Counter(p[\"row_net\"] for p in switch_diode_pairs)\n col_votes = Counter(p[\"col_net\"] for p in switch_diode_pairs)\n for net in ambiguous:\n if row_votes.get(net, 0) > col_votes.get(net, 0):\n topo_col_nets.discard(net)\n elif col_votes.get(net, 0) > row_votes.get(net, 0):\n topo_row_nets.discard(net)\n else:\n # Tie — remove from both\n topo_row_nets.discard(net)\n topo_col_nets.discard(net)\n if len(topo_row_nets) >= 2 and len(topo_col_nets) >= 2:\n key_matrices.append({\n \"rows\": len(topo_row_nets),\n \"columns\": len(topo_col_nets),\n \"row_nets\": sorted(topo_row_nets),\n \"col_nets\": sorted(topo_col_nets),\n \"estimated_keys\": len(switch_diode_pairs),\n \"switches_on_matrix\": len(switch_diode_pairs),\n \"diodes_on_matrix\": len(switch_diode_pairs),\n \"detection_method\": \"topology\",\n \"detector\": \"detect_key_matrices\",\n \"rule_id\": \"KM-DET\",\n \"category\": \"human_interface\",\n \"severity\": \"info\",\n \"confidence\": \"deterministic\",\n \"evidence_source\": \"topology\",\n \"summary\": f\"Key matrix {len(topo_row_nets)}x{len(topo_col_nets)} ({len(switch_diode_pairs)} keys, topology)\",\n \"description\": f\"Detected {len(topo_row_nets)}x{len(topo_col_nets)} key matrix via topology analysis.\",\n \"components\": [],\n \"nets\": sorted(topo_row_nets) + sorted(topo_col_nets),\n \"pins\": [],\n \"recommendation\": \"\",\n \"report_context\": {\"section\": \"Human Interface\", \"impact\": \"\", \"standard_ref\": \"\"},\n \"provenance\": make_provenance(\"keymatrix_diode_grid\", \"deterministic\"),\n })\n\n return key_matrices\n\n\ndef detect_isolation_barriers(ctx: AnalysisContext) -> list[dict]:\n \"\"\"Detect galvanic isolation domains.\"\"\"\n isolation_barriers: list[dict] = []\n\n # Find ground domains (include PE/Earth for isolation detection)\n ground_nets = [n for n in ctx.nets if ctx.is_ground(n)\n or n.upper() in (\"PE\", \"EARTH\", \"CHASSIS\", \"SHIELD\")]\n if len(ground_nets) >= 2:\n ground_domains = {}\n for gn in ground_nets:\n gnu = gn.upper()\n if gnu in (\"PE\", \"EARTH\", \"CHASSIS\", \"SHIELD\"):\n domain = gnu.lower()\n else:\n domain = gnu.replace(\"GND\", \"\").replace(\"_\", \"\").replace(\"-\", \"\").strip()\n if not domain:\n domain = \"main\"\n ground_domains.setdefault(domain, []).append(gn)\n\n if len(ground_domains) >= 2:\n iso_keywords = (\n \"adum\", \"iso7\", \"iso15\", \"adm268\", \"adm248\",\n \"optocoupl\", \"opto_isolat\", \"pc817\", \"tlp\",\n \"isolated\", \"isol_dc\", \"traco\", \"recom\", \"murata\",\n \"dcdc_iso\", \"r1sx\", \"am1s\", \"tmu\", \"iec\",\n )\n\n isolation_components = []\n for c in ctx.components:\n val = (c.get(\"value\", \"\") + \" \" + c.get(\"lib_id\", \"\")).lower()\n if any(k in val for k in iso_keywords) or c[\"type\"] == \"optocoupler\":\n isolation_components.append({\n \"reference\": c[\"reference\"],\n \"value\": c[\"value\"],\n \"type\": c[\"type\"],\n \"lib_id\": c.get(\"lib_id\", \"\"),\n })\n\n ground_domain_map = {}\n for gn in ground_nets:\n domain = gn.upper().replace(\"GND\", \"\").replace(\"_\", \"\").replace(\"-\", \"\").strip()\n if not domain:\n domain = \"main\"\n ground_domain_map[gn] = domain\n\n isolated_power_rails = [\n n for n in ctx.nets\n if ctx.is_power_net(n) and any(\n k in n.upper() for k in (\"ISO\", \"ISOL\", \"_B\", \"_SEC\")\n )\n ]\n\n has_iso_evidence = (\n isolation_components\n or isolated_power_rails\n or any(\"ISO\" in d.upper() for d in ground_domains if d != \"main\")\n )\n if has_iso_evidence:\n # Shared ground detection: check if any ground net\n # appears on both sides of an isolation component\n shared_ground_warnings: list[dict] = []\n for iso_comp in isolation_components:\n iso_ref = iso_comp[\"reference\"]\n iso_pins = ctx.ref_pins.get(iso_ref, {})\n if len(iso_pins) \u003c 4:\n continue\n # Split pins into primary/secondary halves.\n # Prefer VDD/GND pin names to identify sides (works for\n # asymmetric parts like ADuM1201). Fall back to\n # pin-number bisection for parts without named power pins.\n pin_nums = sorted(iso_pins.keys(),\n key=lambda x: int(x) if x.isdigit() else 0)\n # Try name-based side detection: VDD1/GND1 = side A, VDD2/GND2 = side B\n side_a_pins: list[str] = []\n side_b_pins: list[str] = []\n comp_obj = ctx.comp_lookup.get(iso_ref, {})\n for pin in comp_obj.get(\"pins\", []):\n pname = pin.get(\"name\", \"\").upper()\n pnum = pin.get(\"number\", \"\")\n if any(pname.endswith(s) for s in (\"1\", \"A\", \"_A\", \"_IN\")):\n side_a_pins.append(pnum)\n elif any(pname.endswith(s) for s in (\"2\", \"B\", \"_B\", \"_OUT\")):\n side_b_pins.append(pnum)\n # Fall back to pin-number bisection if name detection didn't split\n if not side_a_pins or not side_b_pins:\n mid = len(pin_nums) // 2\n side_a_pins = pin_nums[:mid]\n side_b_pins = pin_nums[mid:]\n # Collect ground nets reachable from each side (1 hop)\n primary_grounds: set[str] = set()\n secondary_grounds: set[str] = set()\n for pnum in side_a_pins:\n net, _ = iso_pins.get(pnum, (None, None))\n if net and ctx.is_ground(net):\n primary_grounds.add(net)\n for pnum in side_b_pins:\n net, _ = iso_pins.get(pnum, (None, None))\n if net and ctx.is_ground(net):\n secondary_grounds.add(net)\n shared = primary_grounds & secondary_grounds\n if shared:\n shared_ground_warnings.append({\n \"isolation_component\": iso_ref,\n \"shared_ground_nets\": sorted(shared),\n })\n\n iso_refs = [c[\"reference\"] for c in isolation_components]\n entry = {\n \"ground_domains\": {d: gnets for d, gnets in ground_domains.items()},\n \"isolation_components\": isolation_components,\n \"isolated_power_rails\": isolated_power_rails,\n \"pcb_advisory\": (\n \"Isolation barrier detected — verify creepage/clearance \"\n \"on PCB layout (IEC 60664-1)\"\n ),\n \"detector\": \"detect_isolation_barriers\",\n \"rule_id\": \"IB-DET\",\n \"category\": \"isolation\",\n \"severity\": \"info\",\n \"confidence\": \"deterministic\",\n \"evidence_source\": \"topology\",\n \"summary\": f\"Isolation barrier: {len(ground_domains)} ground domains, {len(isolation_components)} isolation component(s)\",\n \"description\": f\"Detected galvanic isolation with {len(ground_domains)} ground domains.\",\n \"components\": iso_refs,\n \"nets\": isolated_power_rails,\n \"pins\": [],\n \"recommendation\": \"\",\n \"report_context\": {\"section\": \"Isolation\", \"impact\": \"\", \"standard_ref\": \"\"},\n }\n if shared_ground_warnings:\n entry[\"shared_ground_warnings\"] = shared_ground_warnings\n _iso_evidence = \"isolation_optocoupler\" if any(\n ic.get(\"type\") == \"optocoupler\" for ic in isolation_components\n ) else \"isolation_digital\"\n entry[\"provenance\"] = make_provenance(_iso_evidence, \"deterministic\", claimed_components=iso_refs)\n isolation_barriers.append(entry)\n return isolation_barriers\n\n\ndef detect_ethernet_interfaces(ctx: AnalysisContext) -> list[dict]:\n \"\"\"Detect Ethernet PHY + magnetics + connector chains.\"\"\"\n ethernet_interfaces: list[dict] = []\n\n eth_phy_keywords = (\n \"lan87\", \"lan91\", \"lan83\", \"dp838\", \"ksz8\", \"ksz9\",\n \"rtl81\", \"rtl83\", \"rtl88\", \"w5500\", \"w5100\", \"w5200\",\n \"enc28j60\", \"enc424\", \"dm9000\", \"ip101\", \"phy\",\n \"ethernet\", \"10base\", \"100base\", \"1000base\",\n )\n magnetics_keywords = (\n \"magnetics\", \"pulse\", \"transformer\", \"lan_tr\", \"rj45_mag\",\n \"hx1188\", \"hr601680\", \"g2406\", \"h5007\",\n )\n\n eth_phys = []\n eth_magnetics = []\n eth_connectors = []\n seen_eth_refs = set()\n\n for c in ctx.components:\n if c[\"reference\"] in seen_eth_refs:\n continue\n val_lib = (c.get(\"value\", \"\") + \" \" + c.get(\"lib_id\", \"\")).lower()\n if c[\"type\"] == \"ic\" and any(k in val_lib for k in eth_phy_keywords):\n eth_phys.append(c)\n seen_eth_refs.add(c[\"reference\"])\n elif c[\"type\"] == \"transformer\" and any(k in val_lib for k in magnetics_keywords):\n eth_magnetics.append(c)\n seen_eth_refs.add(c[\"reference\"])\n elif c[\"type\"] == \"connector\":\n # Also detect by LAN reference prefix or integrated magnetics RJ45 part numbers\n if (any(k in val_lib for k in (\"rj45\", \"8p8c\", \"ethernet\", \"magjack\",\n \"lpj4\", \"lpj0\", \"hr911\", \"hfj11\",\n \"arjp\", \"rjlbc\"))\n or c[\"reference\"].upper().startswith(\"LAN\")):\n eth_connectors.append(c)\n seen_eth_refs.add(c[\"reference\"])\n\n # BFS from each PHY's TX/RX pins through transformers/CMCs/\n # resistors/caps to find linked magnetics and connectors (max 4 hops).\n # Includes both MII differential pairs and RMII single-ended signals.\n _eth_tx_rx_re = re.compile(\n r'(TXP|TXN|TX\\+|TX-|TXD\\+|TXD-|RXP|RXN|RX\\+|RX-|RXD\\+|RXD-|'\n r'TD\\+|TD-|RD\\+|RD-|MDI\\d|'\n r'TXD\\d|RXD\\d|TXEN|TX_EN|CRS_DV|COL|REF_CLK|MDIO|MDC)', re.IGNORECASE)\n # Net name patterns for RMII/MII (fallback when PHY has no parsed pins)\n _eth_net_re = re.compile(\n r'(EMAC_TX|EMAC_RX|_TXD\\d|_RXD\\d|RMII|_MDIO|_MDC|TX_EN|CRS_DV|'\n r'TXP|TXN|RXP|RXN|MDI\\d|TD\\+|TD-|RD\\+|RD-)', re.IGNORECASE)\n if eth_phys:\n eth_mag_refs = {m[\"reference\"] for m in eth_magnetics}\n eth_conn_refs = {c[\"reference\"] for c in eth_connectors}\n for phy in eth_phys:\n # Gather PHY TX/RX pin nets\n phy_diff_nets = set()\n for pin in phy.get(\"pins\", []):\n pname = pin.get(\"name\", \"\")\n if _eth_tx_rx_re.match(pname):\n net_name, _ = ctx.pin_net.get(\n (phy[\"reference\"], pin[\"number\"]), (None, None))\n if net_name and not ctx.is_ground(net_name) and not ctx.is_power_net(net_name):\n phy_diff_nets.add(net_name)\n # Fallback: when PHY pins are empty, scan nets for the PHY ref\n # and match on pin name or net name patterns\n if not phy_diff_nets:\n phy_ref = phy[\"reference\"]\n for net_name, ndata in ctx.nets.items():\n if ctx.is_ground(net_name) or ctx.is_power_net(net_name):\n continue\n for p in ndata.get(\"pins\", []):\n if p.get(\"component\") == phy_ref:\n pname = p.get(\"pin_name\", \"\")\n if _eth_tx_rx_re.match(pname) or _eth_net_re.search(net_name):\n phy_diff_nets.add(net_name)\n break\n\n # BFS outward through passives, transformers, CMCs\n visited_nets = set(phy_diff_nets)\n visited_refs = {phy[\"reference\"]}\n found_magnetics = []\n found_connectors = []\n frontier = list(phy_diff_nets)\n\n for _ in range(4): # max 4 hops\n if not frontier:\n break\n next_frontier = []\n for net_name in frontier:\n if net_name not in ctx.nets:\n continue\n for p in ctx.nets[net_name][\"pins\"]:\n cref = p[\"component\"]\n if cref in visited_refs:\n continue\n comp = ctx.comp_lookup.get(cref)\n if not comp:\n continue\n visited_refs.add(cref)\n if cref in eth_mag_refs:\n found_magnetics.append(comp)\n elif cref in eth_conn_refs:\n found_connectors.append(comp)\n # Traverse through passives, transformers, ferrite beads\n if comp[\"type\"] in (\"resistor\", \"capacitor\", \"inductor\",\n \"ferrite_bead\", \"transformer\"):\n # Follow all nets this component touches\n for cpin in comp.get(\"pins\", []):\n cn, _ = ctx.pin_net.get(\n (cref, cpin[\"number\"]), (None, None))\n if cn and cn not in visited_nets:\n if not ctx.is_power_net(cn):\n visited_nets.add(cn)\n next_frontier.append(cn)\n # Also try 2-pin approach\n cn1, cn2 = ctx.get_two_pin_nets(cref)\n for cn in (cn1, cn2):\n if cn and cn not in visited_nets and not ctx.is_power_net(cn):\n visited_nets.add(cn)\n next_frontier.append(cn)\n elif comp[\"type\"] == \"connector\" and cref in eth_conn_refs:\n pass # already captured\n frontier = next_frontier\n\n # Fall back to global lists if BFS found nothing\n if not found_magnetics:\n found_magnetics = eth_magnetics\n if not found_connectors:\n found_connectors = eth_connectors\n\n # Bob Smith termination: ~75Ω from transformer center tap via cap to chassis GND\n # Reduces common-mode noise on Ethernet cable for EMC compliance\n bob_smith = None\n for mag in found_magnetics:\n if bob_smith:\n break\n mag_ref = mag[\"reference\"]\n # Check all nets this transformer connects to\n for (ref, pnum), (net_name, _) in ctx.pin_net.items():\n if ref != mag_ref or not net_name:\n continue\n if ctx.is_power_net(net_name) or ctx.is_ground(net_name):\n continue\n if net_name not in ctx.nets:\n continue\n # Look for a ~75Ω resistor on this net\n for p in ctx.nets[net_name][\"pins\"]:\n rc = ctx.comp_lookup.get(p[\"component\"])\n if not rc or rc[\"type\"] != \"resistor\":\n continue\n r_val = ctx.parsed_values.get(p[\"component\"])\n if not r_val or not (60 \u003c= r_val \u003c= 100): # 75Ω ±33%\n continue\n # Check if the other end goes to ground (direct or via cap)\n rn1, rn2 = ctx.get_two_pin_nets(p[\"component\"])\n r_other = rn2 if rn1 == net_name else rn1\n if not r_other:\n continue\n if ctx.is_ground(r_other):\n bob_smith = {\n \"resistor_ref\": p[\"component\"],\n \"resistor_value\": rc.get(\"value\", \"\"),\n \"ohms\": r_val,\n \"transformer_ref\": mag_ref,\n }\n break\n # Check for cap in between (R → cap → GND)\n if r_other in ctx.nets:\n for cp in ctx.nets[r_other][\"pins\"]:\n cc = ctx.comp_lookup.get(cp[\"component\"])\n if cc and cc[\"type\"] == \"capacitor\":\n cn1, cn2 = ctx.get_two_pin_nets(cp[\"component\"])\n c_other = cn2 if cn1 == r_other else cn1\n if c_other and ctx.is_ground(c_other):\n bob_smith = {\n \"resistor_ref\": p[\"component\"],\n \"resistor_value\": rc.get(\"value\", \"\"),\n \"ohms\": r_val,\n \"cap_ref\": cp[\"component\"],\n \"cap_value\": cc.get(\"value\", \"\"),\n \"transformer_ref\": mag_ref,\n }\n break\n if bob_smith:\n break\n if bob_smith:\n break\n\n # Impedance advisory: magnetics provide both isolation and 100Ω\n # differential impedance matching for Ethernet\n has_magnetics = bool(found_magnetics)\n if not has_magnetics:\n impedance_advisory = {\n \"status\": \"warning\",\n \"detail\": (\"No magnetics/transformer detected between PHY and connector. \"\n \"Ethernet requires magnetic isolation and impedance matching \"\n \"(100\\u03A9 differential).\"),\n }\n else:\n impedance_advisory = {\n \"status\": \"pass\",\n \"detail\": \"Magnetics present for impedance matching and isolation\",\n }\n\n eth_comps = [phy[\"reference\"]] + [m[\"reference\"] for m in found_magnetics] + [c[\"reference\"] for c in found_connectors]\n ethernet_interfaces.append({\n \"phy_reference\": phy[\"reference\"],\n \"phy_value\": phy[\"value\"],\n \"phy_lib_id\": phy.get(\"lib_id\", \"\"),\n \"magnetics\": [\n {\"reference\": m[\"reference\"], \"value\": m[\"value\"]}\n for m in found_magnetics\n ],\n \"connectors\": [\n {\"reference\": c[\"reference\"], \"value\": c[\"value\"]}\n for c in found_connectors\n ],\n \"bob_smith_termination\": bob_smith,\n \"impedance_advisory\": impedance_advisory,\n \"detector\": \"detect_ethernet_interfaces\",\n \"rule_id\": \"ET-DET\",\n \"category\": \"networking\",\n \"severity\": \"info\",\n \"confidence\": \"deterministic\",\n \"evidence_source\": \"topology\",\n \"summary\": f\"Ethernet PHY {phy['reference']} ({phy['value']})\",\n \"description\": f\"Detected Ethernet interface with PHY {phy['reference']}.\",\n \"components\": eth_comps,\n \"nets\": [],\n \"pins\": [],\n \"recommendation\": \"\",\n \"report_context\": {\"section\": \"Networking\", \"impact\": \"\", \"standard_ref\": \"\"},\n \"provenance\": make_provenance(\"eth_phy_magjack\", \"deterministic\", claimed_components=[phy[\"reference\"]]),\n })\n return ethernet_interfaces\n\n\ndef detect_hdmi_dvi_interfaces(ctx: AnalysisContext) -> list[dict]:\n \"\"\"Detect HDMI/DVI interfaces: bridge ICs, connectors, PIO-DVI patterns.\"\"\"\n hdmi_dvi: list[dict] = []\n\n # Bridge IC detection by part number\n _bridge_kw = (\n \"lt8912\", \"it6613\", \"it6616\", \"it6632\", \"it6635\",\n \"adv7533\", \"adv7511\", \"adv7513\", \"adv7612\",\n \"ch7033\", \"ch7034\", \"ch7055\",\n \"sii9022\", \"sii9024\", \"sil9022\", \"sil9024\",\n \"tfp410\", \"tfp401\",\n \"anx7580\", \"anx7688\",\n \"it68051\", \"it66121\",\n )\n for comp in ctx.components:\n if comp[\"type\"] != \"ic\":\n continue\n val_lib = (comp.get(\"value\", \"\") + \" \" + comp.get(\"lib_id\", \"\")).lower()\n if any(k in val_lib for k in _bridge_kw):\n hdmi_dvi.append({\n \"type\": \"bridge_ic\",\n \"reference\": comp[\"reference\"],\n \"value\": comp.get(\"value\", \"\"),\n \"detector\": \"detect_hdmi_dvi_interfaces\",\n \"rule_id\": \"HD-DET\",\n \"category\": \"video\",\n \"severity\": \"info\",\n \"confidence\": \"deterministic\",\n \"evidence_source\": \"topology\",\n \"summary\": f\"HDMI/DVI bridge IC {comp['reference']} ({comp.get('value', '')})\",\n \"description\": f\"Detected HDMI/DVI bridge IC {comp['reference']}.\",\n \"components\": [comp[\"reference\"]],\n \"nets\": [],\n \"pins\": [],\n \"recommendation\": \"\",\n \"report_context\": {\"section\": \"Video\", \"impact\": \"\", \"standard_ref\": \"\"},\n \"provenance\": make_provenance(\"hdmi_tmds_topology\", \"deterministic\", claimed_components=[comp[\"reference\"]]),\n })\n\n # HDMI/DVI connector detection\n _hdmi_conn_kw = (\"hdmi\", \"dvi\", \"tmds\")\n hdmi_connectors = []\n for comp in ctx.components:\n if comp[\"type\"] != \"connector\":\n continue\n val_lib = (comp.get(\"value\", \"\") + \" \" + comp.get(\"lib_id\", \"\")).lower()\n if any(k in val_lib for k in _hdmi_conn_kw):\n hdmi_connectors.append(comp)\n\n # PIO-DVI pattern: connector with 8+ series resistors (10-330R)\n # indicating RP2040/RP2350 PIO-driven DVI\n for conn in hdmi_connectors:\n # Count series resistors connected to connector pins\n series_resistors = []\n seen_refs = set()\n for pin in conn.get(\"pins\", []):\n net_name, _ = ctx.pin_net.get(\n (conn[\"reference\"], pin[\"number\"]), (None, None))\n if not net_name or ctx.is_ground(net_name) or ctx.is_power_net(net_name):\n continue\n if net_name not in ctx.nets:\n continue\n for p in ctx.nets[net_name][\"pins\"]:\n if p[\"component\"] == conn[\"reference\"]:\n continue\n comp = ctx.comp_lookup.get(p[\"component\"])\n if not comp or comp[\"type\"] != \"resistor\":\n continue\n if comp[\"reference\"] in seen_refs:\n continue\n rv = ctx.parsed_values.get(comp[\"reference\"])\n if rv and 10 \u003c= rv \u003c= 330:\n series_resistors.append(comp[\"reference\"])\n seen_refs.add(comp[\"reference\"])\n\n if len(series_resistors) >= 8:\n # Check if already captured by bridge IC detection\n already = any(e.get(\"connector\") == conn[\"reference\"] for e in hdmi_dvi)\n if not already:\n hdmi_dvi.append({\n \"type\": \"pio_dvi\",\n \"connector\": conn[\"reference\"],\n \"connector_value\": conn.get(\"value\", \"\"),\n \"series_resistors\": len(series_resistors),\n \"detector\": \"detect_hdmi_dvi_interfaces\",\n \"rule_id\": \"HD-DET\",\n \"category\": \"video\",\n \"severity\": \"info\",\n \"confidence\": \"deterministic\",\n \"evidence_source\": \"topology\",\n \"summary\": f\"PIO-DVI connector {conn['reference']} ({conn.get('value', '')})\",\n \"description\": f\"Detected PIO-DVI pattern on connector {conn['reference']}.\",\n \"components\": [conn[\"reference\"]],\n \"nets\": [],\n \"pins\": [],\n \"recommendation\": \"\",\n \"report_context\": {\"section\": \"Video\", \"impact\": \"\", \"standard_ref\": \"\"},\n \"provenance\": make_provenance(\"hdmi_tmds_topology\", \"deterministic\", claimed_components=[conn[\"reference\"]]),\n })\n elif not any(e.get(\"connector\") == conn[\"reference\"] for e in hdmi_dvi):\n hdmi_dvi.append({\n \"type\": \"hdmi_connector\",\n \"connector\": conn[\"reference\"],\n \"connector_value\": conn.get(\"value\", \"\"),\n \"detector\": \"detect_hdmi_dvi_interfaces\",\n \"rule_id\": \"HD-DET\",\n \"category\": \"video\",\n \"severity\": \"info\",\n \"confidence\": \"deterministic\",\n \"evidence_source\": \"topology\",\n \"summary\": f\"HDMI/DVI connector {conn['reference']} ({conn.get('value', '')})\",\n \"description\": f\"Detected HDMI/DVI connector {conn['reference']}.\",\n \"components\": [conn[\"reference\"]],\n \"nets\": [],\n \"pins\": [],\n \"recommendation\": \"\",\n \"report_context\": {\"section\": \"Video\", \"impact\": \"\", \"standard_ref\": \"\"},\n \"provenance\": make_provenance(\"hdmi_tmds_topology\", \"deterministic\", claimed_components=[conn[\"reference\"]]),\n })\n\n # TMDS differential termination check: look for ~100Ω resistors on\n # TMDS data/clock nets connected to HDMI connectors or bridge ICs\n _tmds_pin_re = re.compile(\n r'(TMDS|D\\d[+-]|CLK[+-]|CK[+-]|HPD|HDMI_D|HDMI_CLK|DVI_D|DVI_CLK)',\n re.IGNORECASE)\n for entry in hdmi_dvi:\n # Determine the component reference to scan\n comp_ref = entry.get(\"reference\") or entry.get(\"connector\")\n if not comp_ref:\n continue\n comp = ctx.comp_lookup.get(comp_ref)\n if not comp:\n continue\n\n # Gather signal nets from this component's TMDS-related pins\n tmds_nets: set[str] = set()\n for pin in comp.get(\"pins\", []):\n pname = pin.get(\"name\", \"\")\n if _tmds_pin_re.search(pname):\n net_name, _ = ctx.pin_net.get(\n (comp_ref, pin[\"number\"]), (None, None))\n if net_name and not ctx.is_ground(net_name) and not ctx.is_power_net(net_name):\n tmds_nets.add(net_name)\n # Fallback: scan nets for pin names or TMDS-related net names\n if not tmds_nets:\n _tmds_net_re = re.compile(r'(TMDS|HDMI_D|HDMI_CLK|DVI_D)', re.IGNORECASE)\n for net_name, ndata in ctx.nets.items():\n if ctx.is_ground(net_name) or ctx.is_power_net(net_name):\n continue\n for p in ndata.get(\"pins\", []):\n if p.get(\"component\") == comp_ref:\n pname = p.get(\"pin_name\", \"\")\n if _tmds_pin_re.search(pname) or _tmds_net_re.search(net_name):\n tmds_nets.add(net_name)\n break\n\n # Search for termination resistors (80–120Ω) on those nets\n term_resistors: list[dict] = []\n seen_term_refs: set[str] = set()\n for net_name in tmds_nets:\n if net_name not in ctx.nets:\n continue\n for p in ctx.nets[net_name][\"pins\"]:\n rc = ctx.comp_lookup.get(p[\"component\"])\n if not rc or rc[\"type\"] != \"resistor\":\n continue\n if rc[\"reference\"] in seen_term_refs:\n continue\n rv = ctx.parsed_values.get(rc[\"reference\"])\n if rv and 80 \u003c= rv \u003c= 120:\n term_resistors.append({\n \"reference\": rc[\"reference\"],\n \"value\": rc.get(\"value\", \"\"),\n \"ohms\": rv,\n })\n seen_term_refs.add(rc[\"reference\"])\n\n if term_resistors:\n entry[\"termination\"] = {\n \"status\": \"pass\",\n \"detail\": f\"{len(term_resistors)} termination resistor(s) found on TMDS nets\",\n \"resistors\": term_resistors,\n }\n elif tmds_nets:\n entry[\"termination\"] = {\n \"status\": \"warning\",\n \"detail\": (\"No ~100\\u03A9 differential termination resistors detected \"\n \"on TMDS nets. HDMI/DVI requires 100\\u03A9 differential \"\n \"impedance matching.\"),\n }\n # If no TMDS nets were identified, skip termination check silently\n\n return hdmi_dvi\n\n\ndef detect_lvds_interfaces(ctx: AnalysisContext) -> list[dict]:\n \"\"\"Detect LVDS serializer/deserializer ICs and flag impedance requirements.\"\"\"\n lvds_interfaces: list[dict] = []\n\n _lvds_keywords = (\n \"ds90\", \"fpdlink\", \"fpd-link\", \"fpd_link\", \"lvds\",\n \"sn65lvds\", \"thc63\", \"max9217\", \"max9247\",\n \"ub924\", \"ub925\",\n )\n _serializer_hints = (\"ser\", \"driver\", \"transmit\", \"tx\", \"encoder\", \"ub925\")\n _deserializer_hints = (\"des\", \"receiver\", \"rx\", \"decoder\", \"ub924\")\n\n for comp in ctx.components:\n if comp[\"type\"] != \"ic\":\n continue\n val_lib = (comp.get(\"value\", \"\") + \" \" + comp.get(\"lib_id\", \"\")).lower()\n if not any(k in val_lib for k in _lvds_keywords):\n continue\n\n # Classify role\n role = \"unknown\"\n if any(h in val_lib for h in _serializer_hints):\n role = \"serializer\"\n elif any(h in val_lib for h in _deserializer_hints):\n role = \"deserializer\"\n else:\n # Heuristic: DS90Cx8xx — even last digit = serializer, odd = deserializer\n # e.g., DS90C124 (ser), DS90C125 (des), DS90CR286 (ser), DS90CF386 (des)\n # Not perfectly reliable, so leave as \"unknown\" if no strong hint\n pass\n\n lvds_interfaces.append({\n \"reference\": comp[\"reference\"],\n \"value\": comp.get(\"value\", \"\"),\n \"lib_id\": comp.get(\"lib_id\", \"\"),\n \"role\": role,\n \"impedance_required\": \"100\\u03A9 differential (LVDS standard)\",\n \"detector\": \"detect_lvds_interfaces\",\n \"rule_id\": \"LV-DET\",\n \"category\": \"video\",\n \"severity\": \"info\",\n \"confidence\": \"deterministic\",\n \"evidence_source\": \"topology\",\n \"summary\": f\"LVDS {role} {comp['reference']} ({comp.get('value', '')})\",\n \"description\": f\"Detected LVDS {role} IC {comp['reference']}.\",\n \"components\": [comp[\"reference\"]],\n \"nets\": [],\n \"pins\": [],\n \"recommendation\": \"\",\n \"report_context\": {\"section\": \"Video\", \"impact\": \"\", \"standard_ref\": \"\"},\n \"provenance\": make_provenance(\"lvds_pair_topology\", \"deterministic\", claimed_components=[comp[\"reference\"]]),\n })\n\n return lvds_interfaces\n\n\ndef detect_memory_interfaces(ctx: AnalysisContext) -> list[dict]:\n \"\"\"Detect memory ICs paired with MCUs/FPGAs.\"\"\"\n memory_interfaces: list[dict] = []\n\n memory_keywords = (\n \"sram\", \"dram\", \"ddr\", \"sdram\", \"psram\", \"flash\", \"eeprom\",\n \"w25q\", \"at25\", \"mx25\", \"is62\", \"is66\", \"cy62\", \"as4c\",\n \"mt41\", \"mt48\", \"k4b\", \"hy57\", \"is42\", \"25lc\", \"24lc\",\n \"at24\", \"fram\", \"fm25\", \"mb85\", \"s27k\", \"hyperram\",\n \"aps6404\", \"aps1604\", \"ly68\",\n )\n processor_types = (\"ic\",)\n processor_keywords = (\n \"stm32\", \"esp32\", \"rp2040\", \"atmega\", \"atsamd\", \"pic\", \"nrf5\",\n \"ice40\", \"ecp5\", \"artix\", \"spartan\", \"cyclone\", \"max10\",\n \"fpga\", \"mcu\", \"cortex\", \"risc\",\n )\n\n memory_ics = []\n processor_ics = []\n seen_mem_refs = set()\n seen_proc_refs = set()\n for c in ctx.components:\n val_lib = (c.get(\"value\", \"\") + \" \" + c.get(\"lib_id\", \"\")).lower()\n if c[\"type\"] == \"ic\":\n if any(k in val_lib for k in memory_keywords):\n if c[\"reference\"] not in seen_mem_refs:\n memory_ics.append(c)\n seen_mem_refs.add(c[\"reference\"])\n elif any(k in val_lib for k in processor_keywords):\n if c[\"reference\"] not in seen_proc_refs:\n processor_ics.append(c)\n seen_proc_refs.add(c[\"reference\"])\n\n for mem in memory_ics:\n mem_nets = {net for net, _ in ctx.ref_pins.get(mem[\"reference\"], {}).values() if net}\n\n connected_processors = []\n for proc in processor_ics:\n proc_nets = {net for net, _ in ctx.ref_pins.get(proc[\"reference\"], {}).values() if net}\n shared = mem_nets & proc_nets\n signal_shared = [n for n in shared if not ctx.is_power_net(n) and not ctx.is_ground(n)]\n if signal_shared:\n connected_processors.append({\n \"reference\": proc[\"reference\"],\n \"value\": proc[\"value\"],\n \"shared_signal_nets\": len(signal_shared),\n })\n\n if connected_processors:\n proc_refs = [p[\"reference\"] for p in connected_processors]\n memory_interfaces.append({\n \"memory_reference\": mem[\"reference\"],\n \"memory_value\": mem[\"value\"],\n \"memory_lib_id\": mem.get(\"lib_id\", \"\"),\n \"connected_processors\": connected_processors,\n \"total_pins\": len(mem_nets),\n \"detector\": \"detect_memory_interfaces\",\n \"rule_id\": \"MI-DET\",\n \"category\": \"memory\",\n \"severity\": \"info\",\n \"confidence\": \"deterministic\",\n \"evidence_source\": \"topology\",\n \"summary\": f\"Memory {mem['reference']} ({mem['value']}) connected to {len(connected_processors)} processor(s)\",\n \"description\": f\"Detected memory IC {mem['reference']} paired with processor(s).\",\n \"components\": [mem[\"reference\"]] + proc_refs,\n \"nets\": [],\n \"pins\": [],\n \"recommendation\": \"\",\n \"report_context\": {\"section\": \"Memory\", \"impact\": \"\", \"standard_ref\": \"\"},\n \"provenance\": make_provenance(\"memory_ic_topology\", \"deterministic\", claimed_components=[mem[\"reference\"]]),\n })\n return memory_interfaces\n\n\n# RF IC frequency bands — maps lowercase keyword prefixes to operating frequency\nRF_IC_BANDS = {\n \"cc2500\": {\"freq_hz\": 2.4e9, \"band\": \"2.4GHz ISM\"},\n \"cc1101\": {\"freq_hz\": 868e6, \"band\": \"sub-GHz ISM\"},\n \"sx127\": {\"freq_hz\": 868e6, \"band\": \"LoRa sub-GHz\"},\n \"sx126\": {\"freq_hz\": 868e6, \"band\": \"LoRa sub-GHz\"},\n \"nrf24\": {\"freq_hz\": 2.4e9, \"band\": \"2.4GHz\"},\n \"nrf52\": {\"freq_hz\": 2.4e9, \"band\": \"2.4GHz BLE\"},\n \"nrf53\": {\"freq_hz\": 2.4e9, \"band\": \"2.4GHz BLE\"},\n \"esp32\": {\"freq_hz\": 2.4e9, \"band\": \"2.4GHz WiFi/BLE\"},\n \"at86rf\": {\"freq_hz\": 2.4e9, \"band\": \"802.15.4\"},\n \"si446\": {\"freq_hz\": 868e6, \"band\": \"sub-GHz\"},\n \"si4432\": {\"freq_hz\": 868e6, \"band\": \"sub-GHz\"},\n \"si4463\": {\"freq_hz\": 868e6, \"band\": \"sub-GHz\"},\n \"a7105\": {\"freq_hz\": 2.4e9, \"band\": \"2.4GHz\"},\n \"bk4819\": {\"freq_hz\": 430e6, \"band\": \"UHF\"},\n \"rfm9\": {\"freq_hz\": 868e6, \"band\": \"LoRa\"},\n \"rfm6\": {\"freq_hz\": 868e6, \"band\": \"FSK\"},\n}\n\n# Heuristic gain/loss per RF component role (dB)\nRF_ROLE_GAIN_DB = {\n \"amplifier\": 15.0,\n \"switch\": -0.5,\n \"filter\": -1.5,\n \"balun\": -0.5,\n \"mixer\": -7.0,\n \"attenuator\": -6.0,\n \"coupler\": -10.0,\n \"power_detector\": -20.0,\n \"freq_multiplier\": -10.0,\n \"transceiver\": 0.0,\n}\n\n\ndef detect_rf_chains(ctx: AnalysisContext) -> list[dict]:\n \"\"\"Detect RF signal chain components.\"\"\"\n rf_chains: list[dict] = []\n\n rf_switch_keywords = (\n \"sky134\", \"sky133\", \"sky131\", \"pe42\", \"as179\", \"as193\",\n \"hmc19\", \"hmc54\", \"hmc34\", \"bgrf\", \"rfsw\", \"spdt\", \"sp3t\", \"sp4t\",\n \"adrf\", \"hmc3\",\n )\n rf_mixer_keywords = (\n \"rffc50\", \"ltc5549\", \"lt5560\", \"hmc21\", \"sa612\", \"ade-\", \"tuf-\",\n \"mixer\",\n )\n rf_amp_keywords = (\n \"mga-\", \"bga-\", \"maal\", \"pga-\", \"gali-\", \"maa-\", \"bfp7\", \"bfr5\",\n \"hmc58\", \"hmc31\", \"lna\", \"mmic\",\n \"bgb7\", \"trf37\", \"sga-\", \"tqp3\", \"sky67\",\n \"maam\", \"admv\",\n )\n rf_transceiver_keywords = (\n \"max283\", \"at86rf\", \"cc1101\", \"cc2500\", \"sx127\", \"sx126\",\n \"rfm9\", \"rfm6\", \"nrf24\", \"si446\",\n # KH-120: Less common RF transceiver/front-end ICs\n \"bk4819\", \"cmx994\", \"cmx99\", \"si4463\", \"si4432\", \"a7105\",\n \"nrf52\", \"nrf53\", \"esp32\",\n )\n rf_filter_keywords = (\n \"saw\", \"baw\", \"fbar\", \"highpass\", \"lowpass\", \"bandpass\",\n \"fil-\", \"sf2\", \"ta0\", \"b39\",\n )\n # KH-085: New RF component categories\n rf_attenuator_keywords = (\n \"hmc47\", \"hmc54\", \"pe43\", \"pe44\", \"dat-\", \"rfsa\",\n )\n rf_coupler_keywords = (\n \"fpc0\", \"tcd-\", \"adc-\", \"bd-\", \"mdc-\",\n )\n rf_power_detector_keywords = (\n \"ltc559\", \"ad836\", \"hmc10\", \"hmc61\", \"hmc71\",\n )\n rf_freq_multiplier_keywords = (\n \"xx1000\", \"hmc57\", \"hmc20\",\n )\n\n rf_switches = []\n rf_mixers = []\n rf_amplifiers = []\n rf_transceivers = []\n rf_filters = []\n rf_baluns = []\n rf_attenuators = []\n rf_couplers = []\n rf_power_detectors = []\n rf_freq_multipliers = []\n seen_rf_refs = set()\n\n for c in ctx.components:\n if c[\"reference\"] in seen_rf_refs:\n continue\n val_lib = (c.get(\"value\", \"\") + \" \" + c.get(\"lib_id\", \"\")).lower()\n\n # KH-120: Also check \"other\" type — some RF ICs use non-standard\n # reference designators and get classified as \"other\"\n if c[\"type\"] in (\"ic\", \"other\"):\n if any(k in val_lib for k in rf_switch_keywords):\n rf_switches.append(c)\n seen_rf_refs.add(c[\"reference\"])\n elif any(k in val_lib for k in rf_mixer_keywords):\n rf_mixers.append(c)\n seen_rf_refs.add(c[\"reference\"])\n elif any(k in val_lib for k in rf_amp_keywords):\n rf_amplifiers.append(c)\n seen_rf_refs.add(c[\"reference\"])\n elif any(k in val_lib for k in rf_transceiver_keywords):\n rf_transceivers.append(c)\n seen_rf_refs.add(c[\"reference\"])\n elif any(k in val_lib for k in rf_filter_keywords):\n rf_filters.append(c)\n seen_rf_refs.add(c[\"reference\"])\n # KH-085: New RF categories\n elif any(k in val_lib for k in rf_attenuator_keywords):\n rf_attenuators.append(c)\n seen_rf_refs.add(c[\"reference\"])\n elif any(k in val_lib for k in rf_coupler_keywords):\n rf_couplers.append(c)\n seen_rf_refs.add(c[\"reference\"])\n elif any(k in val_lib for k in rf_power_detector_keywords):\n rf_power_detectors.append(c)\n seen_rf_refs.add(c[\"reference\"])\n elif any(k in val_lib for k in rf_freq_multiplier_keywords):\n rf_freq_multipliers.append(c)\n seen_rf_refs.add(c[\"reference\"])\n elif c[\"type\"] == \"transformer\":\n if any(k in val_lib for k in (\"balun\", \"bal-\", \"b0310\", \"bl14\")):\n rf_baluns.append(c)\n seen_rf_refs.add(c[\"reference\"])\n\n rf_component_count = (\n len(rf_switches) + len(rf_mixers) + len(rf_amplifiers)\n + len(rf_transceivers) + len(rf_filters) + len(rf_baluns)\n + len(rf_attenuators) + len(rf_couplers) + len(rf_power_detectors)\n + len(rf_freq_multipliers)\n )\n\n if rf_component_count >= 2:\n all_rf_refs = seen_rf_refs.copy()\n rf_nets_map = {}\n for ref in all_rf_refs:\n ref_nets = {net for net, _ in ctx.ref_pins.get(ref, {}).values()\n if net and not ctx.is_power_net(net) and not ctx.is_ground(net)}\n rf_nets_map[ref] = ref_nets\n\n connections = []\n rf_ref_list = sorted(all_rf_refs)\n for i, ref_a in enumerate(rf_ref_list):\n for ref_b in rf_ref_list[i+1:]:\n shared = rf_nets_map.get(ref_a, set()) & rf_nets_map.get(ref_b, set())\n signal_shared = [n for n in shared if not n.startswith(\"__unnamed_\")]\n if shared:\n connections.append({\n \"from\": ref_a,\n \"to\": ref_b,\n \"shared_nets\": len(shared),\n \"named_nets\": signal_shared,\n })\n\n def _rf_role(ref):\n comp = ctx.comp_lookup.get(ref)\n if not comp:\n return \"unknown\"\n val_lib = (comp.get(\"value\", \"\") + \" \" + comp.get(\"lib_id\", \"\")).lower()\n if any(k in val_lib for k in rf_switch_keywords):\n return \"switch\"\n if any(k in val_lib for k in rf_mixer_keywords):\n return \"mixer\"\n if any(k in val_lib for k in rf_amp_keywords):\n return \"amplifier\"\n if any(k in val_lib for k in rf_transceiver_keywords):\n return \"transceiver\"\n if any(k in val_lib for k in rf_filter_keywords):\n return \"filter\"\n # KH-085: New RF roles\n if any(k in val_lib for k in rf_attenuator_keywords):\n return \"attenuator\"\n if any(k in val_lib for k in rf_coupler_keywords):\n return \"coupler\"\n if any(k in val_lib for k in rf_power_detector_keywords):\n return \"power_detector\"\n if any(k in val_lib for k in rf_freq_multiplier_keywords):\n return \"freq_multiplier\"\n if comp[\"type\"] == \"transformer\":\n return \"balun\"\n return \"unknown\"\n\n all_rf_chain_refs = [c[\"reference\"] for c in (\n rf_switches + rf_mixers + rf_amplifiers + rf_transceivers +\n rf_filters + rf_baluns + rf_attenuators + rf_couplers +\n rf_power_detectors + rf_freq_multipliers\n )]\n rf_chains.append({\n \"switches\": [\n {\"reference\": c[\"reference\"], \"value\": c[\"value\"],\n \"lib_id\": c.get(\"lib_id\", \"\")}\n for c in rf_switches\n ],\n \"mixers\": [\n {\"reference\": c[\"reference\"], \"value\": c[\"value\"],\n \"lib_id\": c.get(\"lib_id\", \"\")}\n for c in rf_mixers\n ],\n \"amplifiers\": [\n {\"reference\": c[\"reference\"], \"value\": c[\"value\"],\n \"lib_id\": c.get(\"lib_id\", \"\")}\n for c in rf_amplifiers\n ],\n \"transceivers\": [\n {\"reference\": c[\"reference\"], \"value\": c[\"value\"],\n \"lib_id\": c.get(\"lib_id\", \"\")}\n for c in rf_transceivers\n ],\n \"filters\": [\n {\"reference\": c[\"reference\"], \"value\": c[\"value\"],\n \"lib_id\": c.get(\"lib_id\", \"\")}\n for c in rf_filters\n ],\n \"baluns\": [\n {\"reference\": c[\"reference\"], \"value\": c[\"value\"],\n \"lib_id\": c.get(\"lib_id\", \"\")}\n for c in rf_baluns\n ],\n # KH-085: New RF component categories\n \"attenuators\": [\n {\"reference\": c[\"reference\"], \"value\": c[\"value\"],\n \"lib_id\": c.get(\"lib_id\", \"\")}\n for c in rf_attenuators\n ],\n \"couplers\": [\n {\"reference\": c[\"reference\"], \"value\": c[\"value\"],\n \"lib_id\": c.get(\"lib_id\", \"\")}\n for c in rf_couplers\n ],\n \"power_detectors\": [\n {\"reference\": c[\"reference\"], \"value\": c[\"value\"],\n \"lib_id\": c.get(\"lib_id\", \"\")}\n for c in rf_power_detectors\n ],\n \"freq_multipliers\": [\n {\"reference\": c[\"reference\"], \"value\": c[\"value\"],\n \"lib_id\": c.get(\"lib_id\", \"\")}\n for c in rf_freq_multipliers\n ],\n \"total_rf_components\": rf_component_count,\n \"connections\": connections,\n \"component_roles\": {\n ref: _rf_role(ref) for ref in all_rf_refs\n },\n \"detector\": \"detect_rf_chains\",\n \"rule_id\": \"RF-DET\",\n \"category\": \"rf\",\n \"severity\": \"info\",\n \"confidence\": \"deterministic\",\n \"evidence_source\": \"topology\",\n \"summary\": f\"RF chain: {rf_component_count} components\",\n \"description\": f\"Detected RF signal chain with {rf_component_count} components.\",\n \"components\": all_rf_chain_refs,\n \"nets\": [],\n \"pins\": [],\n \"recommendation\": \"\",\n \"report_context\": {\"section\": \"RF\", \"impact\": \"\", \"standard_ref\": \"\"},\n \"provenance\": make_provenance(\"rf_chain_topology\", \"heuristic\", claimed_components=all_rf_chain_refs),\n })\n\n # Enrich with operating frequency and gain budget\n chain = rf_chains[-1]\n comp_roles = chain[\"component_roles\"]\n\n # Determine operating frequency from transceivers\n operating_freq = None\n freq_band = None\n for xcvr in rf_transceivers:\n val_lib = (xcvr.get(\"value\", \"\") + \" \" + xcvr.get(\"lib_id\", \"\")).lower()\n for prefix, band_info in RF_IC_BANDS.items():\n if prefix in val_lib:\n operating_freq = band_info[\"freq_hz\"]\n freq_band = band_info[\"band\"]\n break\n if operating_freq:\n break\n\n # Compute per-stage gain/loss budget\n stage_gains = {}\n total_gain_db = 0.0\n for ref, role in comp_roles.items():\n gain = RF_ROLE_GAIN_DB.get(role, 0.0)\n stage_gains[ref] = {\"role\": role, \"gain_dB\": gain}\n total_gain_db += gain\n\n chain[\"operating_frequency_hz\"] = operating_freq\n chain[\"frequency_band\"] = freq_band\n chain[\"gain_budget_dB\"] = round(total_gain_db, 1)\n chain[\"stage_gains\"] = stage_gains\n return rf_chains\n\n\n# Reference design matching value ranges (conservative to avoid false positives)\n_RF_REFERENCE_DESIGNS: dict[str, dict] = {\n \"esp32\": {\n \"keywords\": (\"esp32\", \"esp32-s\", \"esp32-c\", \"esp32-h\"),\n \"frequency_mhz\": 2400,\n \"inductor_range\": (1.0e-9, 5.6e-9), # 1.0-5.6 nH\n \"capacitor_range\": (0.5e-12, 3.3e-12), # 0.5-3.3 pF\n },\n \"cc1101\": {\n \"keywords\": (\"cc1101\", \"cc110\"),\n \"frequency_mhz\": 433,\n \"inductor_range\": (5.6e-9, 22e-9), # 5.6-22 nH\n \"capacitor_range\": (2.2e-12, 22e-12), # 2.2-22 pF\n },\n \"sx127x\": {\n \"keywords\": (\"sx127\", \"sx126\", \"rfm9\", \"ra-01\", \"ra01\"),\n \"frequency_mhz\": 868,\n \"inductor_range\": (2.7e-9, 15e-9), # 2.7-15 nH\n \"capacitor_range\": (0.8e-12, 10e-12), # 0.8-10 pF\n },\n \"nrf24\": {\n \"keywords\": (\"nrf24\", \"nrf52\", \"nrf53\"),\n \"frequency_mhz\": 2400,\n \"inductor_range\": (2.2e-9, 8.2e-9),\n \"capacitor_range\": (0.8e-12, 4.7e-12),\n },\n}\n\n\ndef _check_rf_reference_values(target_comp: dict,\n matching_components: list[dict],\n ) -> dict | None:\n \"\"\"Check matching component values against known reference designs.\"\"\"\n target_check = (target_comp.get(\"value\", \"\") + \" \" +\n target_comp.get(\"lib_id\", \"\") + \" \" +\n target_comp.get(\"description\", \"\")).lower()\n\n matched_family = None\n for family, info in _RF_REFERENCE_DESIGNS.items():\n if any(kw in target_check for kw in info[\"keywords\"]):\n matched_family = family\n break\n\n if not matched_family:\n return None\n\n info = _RF_REFERENCE_DESIGNS[matched_family]\n out_of_range: list[dict] = []\n\n for mc in matching_components:\n val = mc.get(\"henries\") or mc.get(\"farads\")\n if val is None:\n continue\n if mc[\"type\"] == \"inductor\":\n lo, hi = info[\"inductor_range\"]\n if val \u003c lo or val > hi:\n out_of_range.append({\n \"ref\": mc[\"ref\"],\n \"value\": mc.get(\"value\", \"\"),\n \"actual\": val,\n \"expected_range\": f\"{lo*1e9:.1f}-{hi*1e9:.1f} nH\",\n })\n elif mc[\"type\"] == \"capacitor\":\n lo, hi = info[\"capacitor_range\"]\n if val \u003c lo or val > hi:\n out_of_range.append({\n \"ref\": mc[\"ref\"],\n \"value\": mc.get(\"value\", \"\"),\n \"actual\": val,\n \"expected_range\": f\"{lo*1e12:.1f}-{hi*1e12:.1f} pF\",\n })\n\n return {\n \"target_ic_family\": matched_family,\n \"frequency_mhz\": info[\"frequency_mhz\"],\n \"values_in_range\": len(out_of_range) == 0,\n \"out_of_range_components\": out_of_range,\n }\n\n\ndef detect_rf_matching(ctx: AnalysisContext) -> list[dict]:\n \"\"\"Detect RF antenna matching networks (pi-match, L-match, T-match).\"\"\"\n rf_matching: list[dict] = []\n\n # Find antenna connectors\n _ant_prefixes = (\"AE\", \"ANT\")\n _ant_keywords = (\"antenna\", \"u.fl\", \"ufl\", \"ipex\", \"mhf\", \"rf_conn\")\n _ant_lib_keywords = (\"antenna\", \"u.fl\", \"ufl\", \"sma\", \"ipex\", \"mhf\", \"rf_conn\")\n antennas = []\n for comp in ctx.components:\n ref_prefix = \"\".join(c for c in comp[\"reference\"] if c.isalpha())\n val_lower = comp.get(\"value\", \"\").lower()\n lib_lower = comp.get(\"lib_id\", \"\").lower()\n if (ref_prefix in _ant_prefixes\n or any(kw in val_lower for kw in _ant_keywords)\n or any(kw in lib_lower for kw in _ant_lib_keywords)):\n antennas.append(comp)\n\n for ant in antennas:\n # BFS from antenna through L/C components\n ant_nets = set()\n for pin in ant.get(\"pins\", []):\n net_name, _ = ctx.pin_net.get((ant[\"reference\"], pin[\"number\"]), (None, None))\n if net_name and not ctx.is_ground(net_name) and not ctx.is_power_net(net_name):\n ant_nets.add(net_name)\n if not ant_nets:\n # Try 2-pin approach\n n1, n2 = ctx.get_two_pin_nets(ant[\"reference\"])\n for n in (n1, n2):\n if n and not ctx.is_ground(n) and not ctx.is_power_net(n):\n ant_nets.add(n)\n\n if not ant_nets:\n continue\n\n # BFS through passive matching components\n visited_nets = set(ant_nets)\n visited_refs = {ant[\"reference\"]}\n matching_components = []\n frontier = list(ant_nets)\n target_ic = None\n\n for _ in range(6): # Max 6 hops\n if not frontier:\n break\n next_frontier = []\n for net_name in frontier:\n if net_name not in ctx.nets:\n continue\n for p in ctx.nets[net_name][\"pins\"]:\n cref = p[\"component\"]\n if cref in visited_refs:\n continue\n comp = ctx.comp_lookup.get(cref)\n if not comp:\n continue\n if comp[\"type\"] in (\"capacitor\", \"inductor\", \"ferrite_bead\"):\n # KH-150: Skip ferrite beads (EMI filtering, not RF matching)\n _comp_desc = (comp.get(\"description\", \"\") + \" \" +\n comp.get(\"keywords\", \"\") + \" \" +\n comp.get(\"value\", \"\")).lower()\n if any(k in _comp_desc for k in (\"ferrite\", \"bead\", \"emi\")):\n visited_refs.add(cref)\n continue\n if comp[\"type\"] == \"ferrite_bead\":\n visited_refs.add(cref)\n continue\n visited_refs.add(cref)\n mc_parsed = ctx.parsed_values.get(cref)\n matching_components.append({\n \"ref\": cref,\n \"type\": comp[\"type\"],\n \"value\": comp.get(\"value\", \"\"),\n \"farads\": mc_parsed if comp[\"type\"] == \"capacitor\" and mc_parsed else None,\n \"henries\": mc_parsed if comp[\"type\"] == \"inductor\" and mc_parsed else None,\n \"ohms\": mc_parsed if comp[\"type\"] == \"resistor\" and mc_parsed else None,\n })\n # Follow through to other pin\n cn1, cn2 = ctx.get_two_pin_nets(cref)\n for cn in (cn1, cn2):\n if cn and cn not in visited_nets and not ctx.is_power_net(cn):\n # Allow ground as shunt element target\n if not ctx.is_ground(cn):\n visited_nets.add(cn)\n next_frontier.append(cn)\n elif comp[\"type\"] == \"ic\" and not target_ic:\n target_ic = cref\n visited_refs.add(cref)\n frontier = next_frontier\n\n if not matching_components:\n continue\n\n # KH-150: Require target IC to be RF-related\n if target_ic:\n _target_comp = ctx.comp_lookup.get(target_ic, {})\n _target_check = (_target_comp.get(\"value\", \"\") + \" \" +\n _target_comp.get(\"lib_id\", \"\") + \" \" +\n _target_comp.get(\"description\", \"\") + \" \" +\n _target_comp.get(\"keywords\", \"\")).lower()\n _rf_keywords = (\"rf\", \"transceiver\", \"mixer\", \"lna\", \"wireless\",\n \"radio\", \"bluetooth\", \"wifi\", \"zigbee\", \"lora\",\n \"sx127\", \"cc1101\", \"nrf\", \"esp32\", \"at86\",\n \"si446\", \"rfm\", \"ra0\", \"wl18\", \"antenna\",\n \"433\", \"868\", \"915\", \"2.4g\", \"uwb\", \"gps\",\n \"gnss\", \"amplifier_rf\", \"rf_amplifier\")\n if not any(kw in _target_check for kw in _rf_keywords):\n continue\n\n # RF matching networks require at least one inductor — pure C networks\n # are decoupling/filtering, not impedance matching\n has_inductor = any(mc[\"type\"] == \"inductor\" for mc in matching_components)\n if not has_inductor:\n continue\n\n # Value range filter: RF matching uses small-ish inductors and caps.\n # Thresholds set high enough for lower-frequency RF (433 MHz, HF/27 MHz)\n # where matching inductors can reach a few µH and caps tens of nF.\n # Very large values (power chokes, bulk caps) are still excluded.\n has_large_values = False\n for mc in matching_components:\n mc_val = parse_value(mc.get(\"value\", \"\"))\n if mc_val is not None:\n if mc[\"type\"] == \"inductor\" and mc_val > 10e-6: # > 10uH\n has_large_values = True\n break\n if mc[\"type\"] == \"capacitor\" and mc_val > 10e-9: # > 10nF\n has_large_values = True\n break\n if has_large_values:\n continue\n\n # Classify topology\n n_series_l = 0\n n_shunt_c = 0\n n_series_c = 0\n for mc in matching_components:\n if mc[\"type\"] == \"inductor\":\n n_series_l += 1\n elif mc[\"type\"] == \"capacitor\":\n # Check if cap has one terminal to ground (shunt) vs series\n cn1, cn2 = ctx.get_two_pin_nets(mc[\"ref\"])\n if ctx.is_ground(cn1) or ctx.is_ground(cn2):\n n_shunt_c += 1\n else:\n n_series_c += 1\n\n total = len(matching_components)\n if n_series_l >= 1 and n_shunt_c >= 2:\n topology = \"pi_match\"\n elif n_series_l >= 2 and n_shunt_c >= 1:\n topology = \"T_match\"\n elif total == 2 and (n_series_l + n_series_c) >= 1 and n_shunt_c >= 1:\n topology = \"L_match\"\n elif total >= 2:\n topology = \"matching_network\"\n else:\n topology = \"matching_network\"\n\n rm_comps = [ant[\"reference\"]] + ([target_ic] if target_ic else []) + [mc[\"ref\"] for mc in matching_components]\n entry = {\n \"antenna\": ant[\"reference\"],\n \"antenna_value\": ant.get(\"value\", \"\"),\n \"topology\": topology,\n \"components\": matching_components,\n \"detector\": \"detect_rf_matching\",\n \"rule_id\": \"RM-DET\",\n \"category\": \"rf\",\n \"severity\": \"info\",\n \"confidence\": \"deterministic\",\n \"evidence_source\": \"topology\",\n \"summary\": f\"RF {topology} matching network at {ant['reference']}\",\n \"description\": f\"Detected RF {topology} matching network near antenna {ant['reference']}.\",\n \"component_refs\": rm_comps,\n \"nets\": [],\n \"pins\": [],\n \"recommendation\": \"\",\n \"report_context\": {\"section\": \"RF\", \"impact\": \"\", \"standard_ref\": \"\"},\n }\n if target_ic:\n entry[\"target_ic\"] = target_ic\n entry[\"target_value\"] = ctx.comp_lookup.get(target_ic, {}).get(\"value\", \"\")\n\n # Reference design value validation\n if target_ic:\n ref_check = _check_rf_reference_values(\n ctx.comp_lookup.get(target_ic, {}), matching_components)\n if ref_check:\n entry[\"reference_design_check\"] = ref_check\n\n entry[\"advisory\"] = [\n \"RF ground stitching near antenna cannot be verified from \"\n \"schematic — check PCB layout\"\n ]\n\n entry[\"provenance\"] = make_provenance(\"rf_match_topology\", \"deterministic\", claimed_components=[ant[\"reference\"]])\n rf_matching.append(entry)\n\n return rf_matching\n\n\ndef detect_bms_systems(ctx: AnalysisContext) -> list[dict]:\n \"\"\"Detect Battery Management System ICs with cell monitoring.\"\"\"\n bms_systems: list[dict] = []\n\n # KH-123: Only include multi-cell BMS/AFE ICs, not single-cell chargers.\n # Single-cell chargers (TP4056, MCP73871, etc.) handle charging only,\n # not cell balancing or multi-cell monitoring.\n bms_ic_keywords = (\n \"bq769\", \"bq76920\", \"bq76930\", \"bq76940\", \"bq76952\", \"bq7694\",\n \"ltc681\", \"ltc682\", \"ltc683\", \"ltc680\",\n \"isl9420\", \"isl9421\", \"isl9424\", \"max1726\", \"max1730\",\n )\n # \"afe\" removed — too many false positives (matches \"safety\", \"cafe\", etc.)\n\n bms_ics = []\n seen_bms_refs = set()\n for c in ctx.components:\n if c[\"reference\"] in seen_bms_refs:\n continue\n val_lib = (c.get(\"value\", \"\") + \" \" + c.get(\"lib_id\", \"\")).lower()\n if c[\"type\"] == \"ic\" and any(k in val_lib for k in bms_ic_keywords):\n bms_ics.append(c)\n seen_bms_refs.add(c[\"reference\"])\n\n for bms_ic in bms_ics:\n ref = bms_ic[\"reference\"]\n\n cell_pins = []\n bms_nets = set()\n for pnum, (net, _) in ctx.ref_pins.get(ref, {}).items():\n if not net:\n continue\n bms_nets.add(net)\n # Match on PIN NAME (not net name) — cell voltage pins are\n # named VC0..VC16, CELL0..CELL16, C0..C16 on BMS ICs\n pin_name = None\n if net in ctx.nets:\n for p in ctx.nets[net][\"pins\"]:\n if p[\"component\"] == ref and p[\"pin_number\"] == pnum:\n pin_name = (p.get(\"pin_name\") or \"\").upper()\n break\n if pin_name:\n m = re.match(r'^VC(\\d+)[A-Z]?

KiCad Project Analysis Skill Related Skills | Skill | Purpose | |-------|---------| | | BOM extraction, enrichment, ordering, and export workflows | | | Search DigiKey for parts (prototype sourcing) | | | Search Mouser for parts (secondary prototype source) | | | Search LCSC for parts (production sourcing, JLCPCB) | | | Search Newark/Farnell/element14 (international sourcing, reliable datasheets) | | | PCB fabrication & assembly ordering | | | Alternative PCB fabrication & assembly | | | SPICE simulation verification of detected subcircuits | | | EMC pre-compliance risk analysis — consumes sc…

, pin_name)\n if not m:\n m = re.match(r'^CELL(\\d+)', pin_name)\n if not m:\n m = re.match(r'^C(\\d+)

KiCad Project Analysis Skill Related Skills | Skill | Purpose | |-------|---------| | | BOM extraction, enrichment, ordering, and export workflows | | | Search DigiKey for parts (prototype sourcing) | | | Search Mouser for parts (secondary prototype source) | | | Search LCSC for parts (production sourcing, JLCPCB) | | | Search Newark/Farnell/element14 (international sourcing, reliable datasheets) | | | PCB fabrication & assembly ordering | | | Alternative PCB fabrication & assembly | | | SPICE simulation verification of detected subcircuits | | | EMC pre-compliance risk analysis — consumes sc…

, pin_name)\n if m:\n cell_pins.append({\"pin\": pnum, \"pin_name\": pin_name, \"net\": net})\n\n # Determine cell count from pin names, filtering out repurposed pins.\n # BQ76920 reuses VC3-5 for I2C/enable on 3/4-cell configs — these\n # connect to GND, SDA, SCL etc. instead of cell voltage nets.\n # Only count VC pins that connect to non-power, non-I2C nets.\n cell_numbers = set()\n valid_cell_pins = []\n for cp in cell_pins:\n net = cp[\"net\"]\n net_upper = net.upper()\n # Skip pins connected to well-known non-cell nets\n if ctx.is_power_net(net) or ctx.is_ground(net):\n continue\n if any(k in net_upper for k in (\"SDA\", \"SCL\", \"I2C\", \"CHG_EN\",\n \"DSG_EN\", \"ALERT\", \"TS\", \"REGOUT\",\n \"REGSRC\")):\n continue\n valid_cell_pins.append(cp)\n m = re.match(r'^VC(\\d+)', cp[\"pin_name\"])\n if m:\n cell_numbers.add(int(m.group(1)))\n m = re.match(r'^CELL(\\d+)', cp[\"pin_name\"])\n if m:\n cell_numbers.add(int(m.group(1)))\n m = re.match(r'^C(\\d+)

KiCad Project Analysis Skill Related Skills | Skill | Purpose | |-------|---------| | | BOM extraction, enrichment, ordering, and export workflows | | | Search DigiKey for parts (prototype sourcing) | | | Search Mouser for parts (secondary prototype source) | | | Search LCSC for parts (production sourcing, JLCPCB) | | | Search Newark/Farnell/element14 (international sourcing, reliable datasheets) | | | PCB fabrication & assembly ordering | | | Alternative PCB fabrication & assembly | | | SPICE simulation verification of detected subcircuits | | | EMC pre-compliance risk analysis — consumes sc…

, cp[\"pin_name\"])\n if m:\n cell_numbers.add(int(m.group(1)))\n\n balance_resistors = []\n cell_net_names = {cp[\"net\"] for cp in valid_cell_pins}\n for net_name in cell_net_names:\n if net_name not in ctx.nets:\n continue\n for p in ctx.nets[net_name][\"pins\"]:\n comp = ctx.comp_lookup.get(p[\"component\"])\n if comp and comp[\"type\"] == \"resistor\" and p[\"component\"] != ref:\n r_ohms = ctx.parsed_values.get(p[\"component\"])\n balance_resistors.append({\n \"reference\": p[\"component\"],\n \"value\": comp[\"value\"],\n \"ohms\": r_ohms,\n \"cell_net\": net_name,\n })\n\n chg_dsg_fets = []\n seen_fet_refs = set()\n power_path_keywords = (\"BAT+\", \"BAT-\", \"PACK+\", \"PACK-\", \"CHG+\", \"DSG+\",\n \"BATT+\", \"BATT-\", \"VBAT+\", \"VBAT-\")\n for net_name in ctx.nets:\n if net_name.upper() not in power_path_keywords:\n continue\n for p in ctx.nets[net_name][\"pins\"]:\n comp = ctx.comp_lookup.get(p[\"component\"])\n if (comp and comp[\"type\"] == \"transistor\"\n and p[\"component\"] not in seen_fet_refs):\n chg_dsg_fets.append({\n \"reference\": p[\"component\"],\n \"value\": comp[\"value\"],\n \"power_net\": net_name,\n })\n seen_fet_refs.add(p[\"component\"])\n\n ntc_sensors = []\n for net_name in bms_nets:\n if not net_name or net_name not in ctx.nets:\n continue\n for p in ctx.nets[net_name][\"pins\"]:\n comp = ctx.comp_lookup.get(p[\"component\"])\n if comp and comp[\"type\"] == \"thermistor\":\n ntc_sensors.append({\n \"reference\": p[\"component\"],\n \"value\": comp[\"value\"],\n \"net\": net_name,\n })\n\n seen_ntc = set()\n unique_ntcs = []\n for ntc in ntc_sensors:\n if ntc[\"reference\"] not in seen_ntc:\n unique_ntcs.append(ntc)\n seen_ntc.add(ntc[\"reference\"])\n\n cell_count = max(cell_numbers) if cell_numbers else 0\n\n bms_comps = [ref] + [r[\"reference\"] for r in balance_resistors] + [f[\"reference\"] for f in chg_dsg_fets]\n bms_systems.append({\n \"bms_reference\": ref,\n \"bms_value\": bms_ic[\"value\"],\n \"bms_lib_id\": bms_ic.get(\"lib_id\", \"\"),\n \"cell_voltage_pins\": len(valid_cell_pins),\n \"cell_count\": cell_count,\n \"cell_nets\": sorted(cell_net_names),\n \"balance_resistors\": balance_resistors,\n \"balance_resistor_count\": len(balance_resistors),\n \"charge_discharge_fets\": chg_dsg_fets,\n \"ntc_sensors\": unique_ntcs,\n \"detector\": \"detect_bms_systems\",\n \"rule_id\": \"BM-DET\",\n \"category\": \"battery\",\n \"severity\": \"info\",\n \"confidence\": \"deterministic\",\n \"evidence_source\": \"topology\",\n \"summary\": f\"BMS {ref} ({bms_ic['value']}) {cell_count}-cell\",\n \"description\": f\"Detected Battery Management System IC {ref} monitoring {cell_count} cells.\",\n \"components\": bms_comps,\n \"nets\": sorted(cell_net_names),\n \"pins\": [],\n \"recommendation\": \"\",\n \"report_context\": {\"section\": \"Battery\", \"impact\": \"\", \"standard_ref\": \"\"},\n \"provenance\": make_provenance(\"bms_ic_topology\", \"deterministic\", claimed_components=[ref]),\n })\n return bms_systems\n\n\n# ---------------------------------------------------------------------------\n# Battery charger and cell protection detector\n# ---------------------------------------------------------------------------\n\n# Charge current formulas: I_charge = K / R_prog (mA, R in kohm)\n_CHARGER_PROG_FORMULAS: dict[str, tuple[float, str]] = {\n \"tp4056\": (1200.0, \"I = 1200 / R_prog\"),\n \"tp4057\": (1200.0, \"I = 1200 / R_prog\"),\n \"tp5400\": (1200.0, \"I = 1200 / R_prog\"),\n \"mcp73831\": (1000.0, \"I = 1000 / R_prog\"),\n \"mcp73832\": (1000.0, \"I = 1000 / R_prog\"),\n \"mcp73871\": (1000.0, \"I = 1000 / R_prog\"),\n \"mcp73811\": (1000.0, \"I = 1000 / R_prog\"),\n \"mcp73812\": (1000.0, \"I = 1000 / R_prog\"),\n \"bq24040\": (540.0, \"I = 540 / R_prog\"),\n \"bq24045\": (540.0, \"I = 540 / R_prog\"),\n \"bq24070\": (890.0, \"I = 890 / R_prog\"),\n \"bq24073\": (890.0, \"I = 890 / R_prog\"),\n \"bq24074\": (890.0, \"I = 890 / R_prog\"),\n \"bq24075\": (890.0, \"I = 890 / R_prog\"),\n \"ltc4054\": (1000.0, \"I = 1000 / R_prog\"),\n \"ltc4056\": (1000.0, \"I = 1000 / R_prog\"),\n \"ltc4065\": (1000.0, \"I = 1000 / R_prog\"),\n \"cn3052\": (1200.0, \"I = 1200 / R_prog\"),\n \"cn3058\": (1200.0, \"I = 1200 / R_prog\"),\n \"cn3063\": (1200.0, \"I = 1200 / R_prog\"),\n \"cn3065\": (1200.0, \"I = 1200 / R_prog\"),\n \"cn3791\": (1200.0, \"I = 1200 / R_prog\"),\n \"mp2615\": (1000.0, \"I = 1000 / R_prog\"),\n \"mp2624\": (1000.0, \"I = 1000 / R_prog\"),\n \"mp2639\": (1000.0, \"I = 1000 / R_prog\"),\n}\n\n_CHARGER_IC_KEYWORDS = tuple(_CHARGER_PROG_FORMULAS.keys()) + (\n \"sgm4105\", \"sgm4154\",\n \"max1551\", \"max1555\", \"max1811\",\n)\n\n_CELL_PROTECTION_KEYWORDS = (\n \"dw01\", \"fs8205\", \"s-8261\", \"s8261\", \"xb8089\",\n \"ap9101\", \"ht4936\", \"r5421\", \"r5426\",\n \"bq2970\", \"bq29700\", \"bq2980\",\n \"cw1054\", \"cw1084\",\n)\n\n_PROG_PIN_NAMES = {\"PROG\", \"RPROG\", \"IPROG\", \"ISET\", \"ICHG\", \"ITERM\"}\n_STATUS_PIN_NAMES = {\"STAT\", \"STAT1\", \"STAT2\", \"CHRG\", \"CHG\", \"DONE\",\n \"PG\", \"PGOOD\", \"nCHG\", \"nSTAT\"}\n_BAT_PIN_NAMES = {\"BAT\", \"VBAT\", \"BAT+\", \"BATT\", \"BATT+\"}\n_VIN_PIN_NAMES = {\"VIN\", \"VBUS\", \"IN\", \"VCC\", \"USB\"}\n\n\ndef detect_battery_chargers(ctx: AnalysisContext) -> list[dict]:\n \"\"\"Detect single-cell battery charger ICs and cell protection circuits.\n\n Complements detect_bms_systems() which handles multi-cell BMS/AFE ICs.\n This detector covers linear and switching single-cell chargers (TP4056,\n MCP73831, BQ2404x, etc.) and standalone cell protection ICs (DW01+FS8205).\n \"\"\"\n chargers: list[dict] = []\n protection_ics: list[dict] = []\n\n # --- Phase 1: Find charger ICs ---\n seen_refs: set[str] = set()\n for c in ctx.components:\n if c[\"type\"] != \"ic\" or c[\"reference\"] in seen_refs:\n continue\n val_lib = (c.get(\"value\", \"\") + \" \" + c.get(\"lib_id\", \"\")).lower()\n if any(k in val_lib for k in _CHARGER_IC_KEYWORDS):\n seen_refs.add(c[\"reference\"])\n ref = c[\"reference\"]\n\n # Identify the specific charger family for formula lookup\n charger_family = None\n for family_key in _CHARGER_PROG_FORMULAS:\n if family_key in val_lib:\n charger_family = family_key\n break\n\n # Classify charger type\n charger_type = \"single_cell_linear\"\n for sw_kw in (\"mp26\", \"cn3791\", \"bq2407\"):\n if sw_kw in val_lib:\n charger_type = \"single_cell_switching\"\n break\n\n # Scan pins for PROG, BAT, VIN, STATUS\n prog_info = None\n bat_net = None\n vin_net = None\n status_pins: list[dict] = []\n\n for pnum, (net, _) in ctx.ref_pins.get(ref, {}).items():\n if not net:\n continue\n # Get pin name from net data\n pin_name = \"\"\n if net in ctx.nets:\n for p in ctx.nets[net][\"pins\"]:\n if p[\"component\"] == ref and p[\"pin_number\"] == pnum:\n pin_name = (p.get(\"pin_name\") or \"\").upper()\n break\n\n # PROG pin — find connected resistor\n if pin_name in _PROG_PIN_NAMES:\n for p in ctx.nets.get(net, {}).get(\"pins\", []):\n comp = ctx.comp_lookup.get(p[\"component\"])\n if comp and comp[\"type\"] == \"resistor\" and p[\"component\"] != ref:\n r_ohms = ctx.parsed_values.get(p[\"component\"])\n if r_ohms and r_ohms > 0:\n r_kohm = r_ohms / 1000.0\n current_mA = None\n formula = None\n if charger_family and charger_family in _CHARGER_PROG_FORMULAS:\n k, formula = _CHARGER_PROG_FORMULAS[charger_family]\n current_mA = k / r_kohm\n prog_info = {\n \"prog_resistor\": p[\"component\"],\n \"prog_resistance_ohms\": r_ohms,\n \"programmed_current_mA\": round(current_mA, 1) if current_mA else None,\n \"formula\": formula,\n }\n break\n\n # Battery pin\n if pin_name in _BAT_PIN_NAMES and not bat_net:\n bat_net = net\n\n # Input pin\n if pin_name in _VIN_PIN_NAMES and not vin_net:\n vin_net = net\n\n # Status pins\n if pin_name in _STATUS_PIN_NAMES:\n # Check if an LED is connected\n has_led = False\n for p in ctx.nets.get(net, {}).get(\"pins\", []):\n comp = ctx.comp_lookup.get(p[\"component\"])\n if comp and comp[\"type\"] in (\"led\", \"diode\"):\n vl = (comp.get(\"value\", \"\") + \" \" + comp.get(\"lib_id\", \"\")).lower()\n if \"led\" in vl or comp[\"type\"] == \"led\":\n has_led = True\n break\n status_pins.append({\n \"pin\": pin_name,\n \"net\": net,\n \"has_led\": has_led,\n })\n\n # Fallback: if no pin-name match, try net-name heuristic for bat/vin\n if not bat_net:\n for pnum, (net, _) in ctx.ref_pins.get(ref, {}).items():\n if net and any(k in net.upper() for k in (\"VBAT\", \"BAT+\", \"BATT\")):\n bat_net = net\n break\n if not vin_net:\n for pnum, (net, _) in ctx.ref_pins.get(ref, {}).items():\n if net and any(k in net.upper() for k in (\"VBUS\", \"VIN\", \"USB\")):\n vin_net = net\n break\n\n entry: dict = {\n \"charger_reference\": ref,\n \"charger_value\": c.get(\"value\", \"\"),\n \"charger_lib_id\": c.get(\"lib_id\", \"\"),\n \"charger_type\": charger_type,\n \"input_rail\": vin_net,\n \"battery_net\": bat_net,\n \"detector\": \"detect_battery_chargers\",\n \"rule_id\": \"BC-DET\",\n \"category\": \"battery\",\n \"severity\": \"info\",\n \"confidence\": \"deterministic\",\n \"evidence_source\": \"topology\",\n \"summary\": f\"Battery charger {ref} ({c.get('value', '')}) [{charger_type}]\",\n \"description\": f\"Detected {charger_type} battery charger IC {ref}.\",\n \"components\": [ref],\n \"nets\": [n for n in (vin_net, bat_net) if n],\n \"pins\": [],\n \"recommendation\": \"\",\n \"report_context\": {\"section\": \"Battery\", \"impact\": \"\", \"standard_ref\": \"\"},\n }\n if prog_info:\n entry[\"charge_current\"] = prog_info\n if status_pins:\n entry[\"status_pins\"] = status_pins\n\n entry[\"provenance\"] = make_provenance(\"charger_ic_topology\", \"deterministic\", claimed_components=[ref])\n chargers.append(entry)\n\n # --- Phase 2: Find cell protection ICs ---\n for c in ctx.components:\n if c[\"reference\"] in seen_refs:\n continue\n val_lib = (c.get(\"value\", \"\") + \" \" + c.get(\"lib_id\", \"\")).lower()\n if c[\"type\"] == \"ic\" and any(k in val_lib for k in _CELL_PROTECTION_KEYWORDS):\n seen_refs.add(c[\"reference\"])\n prot_ref = c[\"reference\"]\n\n # Find protection FETs: BFS 2 hops from protection IC pins\n # looking for transistors (DW01 drives gate of FS8205 dual FET)\n protection_fets: list[dict] = []\n seen_fets: set[str] = set()\n for pnum, (net, _) in ctx.ref_pins.get(prot_ref, {}).items():\n if not net or net not in ctx.nets:\n continue\n for p in ctx.nets[net][\"pins\"]:\n comp = ctx.comp_lookup.get(p[\"component\"])\n if (comp and comp[\"type\"] == \"transistor\"\n and p[\"component\"] not in seen_fets\n and p[\"component\"] != prot_ref):\n protection_fets.append({\n \"reference\": p[\"component\"],\n \"value\": comp.get(\"value\", \"\"),\n })\n seen_fets.add(p[\"component\"])\n\n prot_entry = {\n \"protection_ic\": prot_ref,\n \"protection_value\": c.get(\"value\", \"\"),\n \"protection_lib_id\": c.get(\"lib_id\", \"\"),\n \"protection_fets\": protection_fets,\n }\n protection_ics.append(prot_entry)\n\n # --- Phase 3: Associate protection ICs with chargers ---\n # If a charger and protection IC share the battery net, link them\n for ch in chargers:\n ch[\"cell_protection\"] = None\n if ch.get(\"battery_net\"):\n for prot in protection_ics:\n prot_ref = prot[\"protection_ic\"]\n prot_nets = set()\n for pnum, (net, _) in ctx.ref_pins.get(prot_ref, {}).items():\n if net:\n prot_nets.add(net)\n if ch[\"battery_net\"] in prot_nets:\n ch[\"cell_protection\"] = prot\n break\n\n # Add unlinked protection ICs as standalone entries\n linked_prots = {ch[\"cell_protection\"][\"protection_ic\"]\n for ch in chargers if ch.get(\"cell_protection\")}\n for prot in protection_ics:\n if prot[\"protection_ic\"] not in linked_prots:\n prot_ref = prot[\"protection_ic\"]\n chargers.append({\n \"charger_reference\": None,\n \"charger_value\": None,\n \"charger_lib_id\": None,\n \"charger_type\": \"standalone_protection\",\n \"input_rail\": None,\n \"battery_net\": None,\n \"cell_protection\": prot,\n \"detector\": \"detect_battery_chargers\",\n \"rule_id\": \"BC-DET\",\n \"category\": \"battery\",\n \"severity\": \"info\",\n \"confidence\": \"deterministic\",\n \"evidence_source\": \"topology\",\n \"summary\": f\"Standalone cell protection IC {prot_ref}\",\n \"description\": f\"Detected standalone cell protection IC {prot_ref} without paired charger.\",\n \"components\": [prot_ref],\n \"nets\": [],\n \"pins\": [],\n \"recommendation\": \"\",\n \"report_context\": {\"section\": \"Battery\", \"impact\": \"\", \"standard_ref\": \"\"},\n \"provenance\": make_provenance(\"charger_ic_topology\", \"deterministic\", claimed_components=[prot_ref]),\n })\n\n return chargers\n\n\n# ---------------------------------------------------------------------------\n# Motor driver detector\n# ---------------------------------------------------------------------------\n\n_MOTOR_DRIVER_KEYWORDS = (\n \"drv8833\", \"drv8835\", \"drv8837\", \"drv8838\", \"drv8840\",\n \"drv8841\", \"drv8842\", \"drv8843\", \"drv8844\",\n \"drv8870\", \"drv8871\", \"drv8872\", \"drv8874\",\n \"drv8301\", \"drv8302\", \"drv8303\", \"drv8305\",\n \"l298\", \"l293\", \"l9110\", \"l6201\", \"l6202\",\n \"tb6612\", \"tb67h\", \"tb67s\",\n \"a4950\", \"a4988\", \"a4983\",\n \"tmc2100\", \"tmc2130\", \"tmc2208\", \"tmc2209\", \"tmc5160\",\n \"uln2003\", \"uln2803\",\n \"bd6211\", \"bd6220\", \"bd6231\",\n \"mp6513\", \"mp6515\", \"mp6522\", \"mp6530\",\n)\n\n_GATE_DRIVER_KEYWORDS = (\n \"ir2110\", \"ir2113\", \"ir2184\", \"ir2186\", \"ir2101\", \"ir2104\",\n \"ucc2152\", \"ucc2752\", \"ucc2150\",\n \"hip4086\", \"irs2186\",\n \"fan7388\", \"fan7390\",\n \"l6384\", \"l6387\", \"l6388\",\n \"ncp5106\", \"ncp5108\",\n \"fd6288\",\n)\n\n_STEPPER_PIN_NAMES = {\"STEP\", \"DIR\", \"MS1\", \"MS2\", \"MS3\", \"ENABLE\",\n \"nENABLE\", \"nSLEEP\", \"nRESET\", \"SPREAD\", \"INDEX\"}\n\n_MOTOR_OUTPUT_PIN_NAMES = {\"OUT1\", \"OUT2\", \"OUT3\", \"OUT4\",\n \"OUT1A\", \"OUT1B\", \"OUT2A\", \"OUT2B\",\n \"OUTA\", \"OUTB\", \"OUTC\",\n \"AO\", \"BO\", \"CO\",\n \"AOUT\", \"BOUT\", \"COUT\",\n \"PHASE_A\", \"PHASE_B\", \"PHASE_C\",\n \"MOT_A\", \"MOT_B\",\n \"OUT_A+\", \"OUT_A-\", \"OUT_B+\", \"OUT_B-\",\n # 3-phase UVW notation (BLDC/PMSM)\n \"U\", \"V\", \"W\", \"UH\", \"UL\", \"VH\", \"VL\", \"WH\", \"WL\",\n # Gate driver high/low side notation\n \"AH\", \"AL\", \"BH\", \"BL\", \"CH\", \"CL\"}\n\n_GATE_OUTPUT_PIN_NAMES = {\"HO\", \"LO\", \"HO1\", \"LO1\", \"HO2\", \"LO2\",\n \"HO3\", \"LO3\", \"HIN\", \"LIN\",\n \"OUTA\", \"OUTB\"}\n\n_BOOTSTRAP_PIN_NAMES = {\"VB\", \"VB1\", \"VB2\", \"VB3\", \"VBOOT\",\n \"VS\", \"VS1\", \"VS2\", \"VS3\"}\n\n_INDUCTIVE_LOAD_KEYWORDS = (\"MOTOR\", \"FAN\", \"PUMP\", \"SOLENOID\", \"VALVE\",\n \"COIL\", \"RELAY\", \"STEPPER\", \"ACTUATOR\")\n\n\ndef detect_motor_drivers(ctx: AnalysisContext) -> list[dict]:\n \"\"\"Detect motor driver ICs (H-bridge, stepper, BLDC) and gate drivers.\n\n Identifies integrated motor drivers and discrete gate driver + FET\n topologies. Checks for bootstrap capacitors, freewheeling diodes,\n and flags missing protection on inductive load nets.\n \"\"\"\n drivers: list[dict] = []\n seen_refs: set[str] = set()\n\n for c in ctx.components:\n if c[\"type\"] != \"ic\" or c[\"reference\"] in seen_refs:\n continue\n val_lib = (c.get(\"value\", \"\") + \" \" + c.get(\"lib_id\", \"\")).lower()\n\n is_motor_driver = any(k in val_lib for k in _MOTOR_DRIVER_KEYWORDS)\n is_gate_driver = any(k in val_lib for k in _GATE_DRIVER_KEYWORDS)\n if not is_motor_driver and not is_gate_driver:\n continue\n\n seen_refs.add(c[\"reference\"])\n ref = c[\"reference\"]\n\n # Collect pin info\n motor_outputs: list[dict] = []\n gate_outputs: list[dict] = []\n control_inputs: list[dict] = []\n bootstrap_pins: dict[str, str] = {} # pin_name → net\n power_supply = None\n has_stepper_pins = False\n\n for pnum, (net, _) in ctx.ref_pins.get(ref, {}).items():\n if not net:\n continue\n pin_name = \"\"\n if net in ctx.nets:\n for p in ctx.nets[net][\"pins\"]:\n if p[\"component\"] == ref and p[\"pin_number\"] == pnum:\n pin_name = (p.get(\"pin_name\") or \"\").upper()\n break\n\n if pin_name in _MOTOR_OUTPUT_PIN_NAMES:\n motor_outputs.append({\"pin\": pin_name, \"net\": net})\n elif pin_name in _GATE_OUTPUT_PIN_NAMES:\n gate_outputs.append({\"pin\": pin_name, \"net\": net})\n elif pin_name in _STEPPER_PIN_NAMES:\n has_stepper_pins = True\n control_inputs.append({\"pin\": pin_name, \"net\": net})\n elif pin_name in _BOOTSTRAP_PIN_NAMES:\n bootstrap_pins[pin_name] = net\n elif pin_name in (\"VM\", \"VCC\", \"VS\", \"VMOT\", \"VIN\") and not power_supply:\n if ctx.is_power_net(net) and not ctx.is_ground(net):\n power_supply = net\n\n # Classify driver type\n if is_gate_driver:\n driver_type = \"gate_driver\"\n elif has_stepper_pins:\n driver_type = \"stepper\"\n elif len(motor_outputs) >= 6 or any(\"PHASE\" in o[\"pin\"] or o[\"pin\"].endswith(\"C\") for o in motor_outputs):\n driver_type = \"brushless_3phase\"\n else:\n driver_type = \"dc_brushed_h_bridge\"\n\n # --- Gate driver: find external FETs connected to gate outputs ---\n external_fets: list[dict] = []\n if is_gate_driver:\n seen_fets: set[str] = set()\n for go in gate_outputs:\n go_net = go[\"net\"]\n if go_net not in ctx.nets:\n continue\n # BFS up to 4 hops from gate output to find FETs\n visited_nets = {go_net}\n frontier = [go_net]\n for _hop in range(4):\n next_frontier = []\n for fn in frontier:\n if fn not in ctx.nets:\n continue\n for p in ctx.nets[fn][\"pins\"]:\n comp = ctx.comp_lookup.get(p[\"component\"])\n if not comp or p[\"component\"] == ref:\n continue\n if (comp[\"type\"] == \"transistor\"\n and p[\"component\"] not in seen_fets):\n external_fets.append({\n \"reference\": p[\"component\"],\n \"value\": comp.get(\"value\", \"\"),\n \"gate_net\": go_net,\n })\n seen_fets.add(p[\"component\"])\n # Continue BFS through passives (gate resistors, etc.)\n if comp[\"type\"] in (\"resistor\", \"ferrite_bead\"):\n for pn2, (n2, _) in ctx.ref_pins.get(p[\"component\"], {}).items():\n if n2 and n2 not in visited_nets:\n visited_nets.add(n2)\n next_frontier.append(n2)\n frontier = next_frontier\n\n # --- Bootstrap capacitor detection ---\n bootstrap_caps: list[dict] = []\n vb_nets = {name: net for name, net in bootstrap_pins.items()\n if name.startswith(\"VB\")}\n vs_nets = {name: net for name, net in bootstrap_pins.items()\n if name.startswith(\"VS\")}\n for vb_name, vb_net in vb_nets.items():\n # Find matching VS pin (VB1↔VS1, VB↔VS)\n suffix = vb_name[2:] # \"\" or \"1\" or \"2\"\n vs_net = vs_nets.get(\"VS\" + suffix)\n if not vs_net or vb_net not in ctx.nets:\n continue\n # Find cap connected between VB and VS nets\n for p in ctx.nets[vb_net][\"pins\"]:\n comp = ctx.comp_lookup.get(p[\"component\"])\n if comp and comp[\"type\"] == \"capacitor\" and p[\"component\"] != ref:\n # Check if other pin connects to VS net\n n1, n2 = ctx.get_two_pin_nets(p[\"component\"])\n other = n2 if n1 == vb_net else n1\n if other == vs_net:\n bootstrap_caps.append({\n \"reference\": p[\"component\"],\n \"value\": comp.get(\"value\", \"\"),\n \"between\": [vb_name, \"VS\" + suffix],\n })\n\n # --- Freewheeling diode detection on motor output nets ---\n output_nets = [o[\"net\"] for o in motor_outputs]\n if is_gate_driver and external_fets:\n # For gate drivers, check FET drain/source nets instead\n for fet in external_fets:\n for pnum, (net, _) in ctx.ref_pins.get(fet[\"reference\"], {}).items():\n if net and net not in output_nets and not ctx.is_ground(net):\n if not ctx.is_power_net(net):\n output_nets.append(net)\n\n freewheeling_diodes: list[dict] = []\n missing_freewheeling: list[str] = []\n seen_diode_nets: set[str] = set()\n\n for out_net in output_nets:\n if out_net not in ctx.nets or out_net in seen_diode_nets:\n continue\n seen_diode_nets.add(out_net)\n\n # Check if any diode is on this net\n has_diode = False\n for p in ctx.nets[out_net][\"pins\"]:\n comp = ctx.comp_lookup.get(p[\"component\"])\n if comp and comp[\"type\"] == \"diode\" and p[\"component\"] != ref:\n freewheeling_diodes.append({\n \"reference\": p[\"component\"],\n \"value\": comp.get(\"value\", \"\"),\n \"net\": out_net,\n })\n has_diode = True\n\n # Flag missing diode if net name suggests inductive load\n if not has_diode:\n net_upper = out_net.upper()\n if any(kw in net_upper for kw in _INDUCTIVE_LOAD_KEYWORDS):\n missing_freewheeling.append(out_net)\n\n md_comps = [ref] + [f[\"reference\"] for f in external_fets] + [d[\"reference\"] for d in freewheeling_diodes]\n entry: dict = {\n \"driver_reference\": ref,\n \"driver_value\": c.get(\"value\", \"\"),\n \"driver_lib_id\": c.get(\"lib_id\", \"\"),\n \"driver_type\": driver_type,\n \"motor_outputs\": motor_outputs if not is_gate_driver else [],\n \"gate_outputs\": gate_outputs if is_gate_driver else [],\n \"control_inputs\": control_inputs,\n \"power_supply\": power_supply,\n \"bootstrap_caps\": bootstrap_caps,\n \"freewheeling_diodes\": freewheeling_diodes,\n \"external_fets\": external_fets,\n \"detector\": \"detect_motor_drivers\",\n \"rule_id\": \"MD-DET\",\n \"category\": \"motor_control\",\n \"severity\": \"info\",\n \"confidence\": \"deterministic\",\n \"evidence_source\": \"topology\",\n \"summary\": f\"Motor driver {ref} ({c.get('value', '')}) [{driver_type}]\",\n \"description\": f\"Detected {driver_type} motor driver IC {ref}.\",\n \"components\": md_comps,\n \"nets\": [power_supply] if power_supply else [],\n \"pins\": [],\n \"recommendation\": \"\",\n \"report_context\": {\"section\": \"Motor Control\", \"impact\": \"\", \"standard_ref\": \"\"},\n }\n if missing_freewheeling:\n entry[\"missing_freewheeling\"] = missing_freewheeling\n\n _motor_evidence = \"motor_hbridge\" if driver_type == \"dc_brushed_h_bridge\" else \"motor_driver_ic\"\n entry[\"provenance\"] = make_provenance(_motor_evidence, \"deterministic\", claimed_components=[ref])\n drivers.append(entry)\n\n return drivers\n\n\n\n\ndef detect_addressable_leds(ctx: AnalysisContext) -> list[dict]:\n \"\"\"Detect addressable LED chains (WS2812, SK6812, APA102, etc.).\"\"\"\n chains: list[dict] = []\n\n # Keywords that identify addressable LEDs\n addr_keywords = (\"ws2812\", \"ws2813\", \"ws2815\", \"sk6812\", \"apa102\", \"apa104\",\n \"sk9822\", \"ws2811\", \"tm1809\", \"tm1812\", \"sm16703\",\n \"neopixel\", \"dotstar\")\n\n # Find addressable LED components\n # KH-122: Also search \"diode\" type — D-prefix addressable LEDs may be\n # misclassified when using custom library symbols\n addr_leds = {}\n for c in ctx.components:\n if c[\"type\"] not in (\"led\", \"ic\", \"other\", \"diode\"):\n continue\n val_lower = c.get(\"value\", \"\").lower()\n lib_lower = c.get(\"lib_id\", \"\").lower()\n if any(k in val_lower or k in lib_lower for k in addr_keywords):\n addr_leds[c[\"reference\"]] = c\n\n if not addr_leds:\n return chains\n\n # Determine protocol\n def _get_protocol(comp):\n vl = comp.get(\"value\", \"\").lower()\n ll = comp.get(\"lib_id\", \"\").lower()\n txt = vl + \" \" + ll\n if any(k in txt for k in (\"apa102\", \"sk9822\", \"dotstar\")):\n return \"SPI (APA102)\"\n return \"single-wire (WS2812)\"\n\n # Find DIN/DOUT pin nets for each LED\n led_din_net = {} # ref -> net on DIN\n led_dout_net = {} # ref -> net on DOUT\n din_names = {\"DIN\", \"DI\", \"SDI\", \"DATAIN\", \"DATA_IN\", \"IN\", \"SDA\"}\n dout_names = {\"DOUT\", \"DO\", \"SDO\", \"DATAOUT\", \"DATA_OUT\", \"OUT\"}\n\n for led_ref, led_comp in addr_leds.items():\n for pnum, (net, _) in ctx.ref_pins.get(led_ref, {}).items():\n if not net:\n continue\n pin_name = \"\"\n if net in ctx.nets:\n for p in ctx.nets[net][\"pins\"]:\n if p[\"component\"] == led_ref and p[\"pin_number\"] == pnum:\n pin_name = p.get(\"pin_name\", \"\").upper().replace(\" \", \"\")\n break\n if pin_name in din_names:\n led_din_net[led_ref] = net\n elif pin_name in dout_names:\n led_dout_net[led_ref] = net\n\n # Build chain by tracing DOUT -> DIN connections\n used = set()\n for start_ref in addr_leds:\n if start_ref in used:\n continue\n # Walk backward to find chain start (LED whose DIN is not another LED's DOUT)\n head = start_ref\n visited_back = {head}\n while True:\n din = led_din_net.get(head)\n if not din or din not in ctx.nets:\n break\n found_prev = False\n for p in ctx.nets[din][\"pins\"]:\n pref = p[\"component\"]\n if pref != head and pref in addr_leds and pref not in visited_back:\n if led_dout_net.get(pref) == din:\n head = pref\n visited_back.add(head)\n found_prev = True\n break\n if not found_prev:\n break\n\n # Walk forward from head\n chain_refs = [head]\n used.add(head)\n cur = head\n while True:\n dout = led_dout_net.get(cur)\n if not dout or dout not in ctx.nets:\n break\n found_next = False\n for p in ctx.nets[dout][\"pins\"]:\n pref = p[\"component\"]\n if pref != cur and pref in addr_leds and pref not in used:\n if led_din_net.get(pref) == dout:\n chain_refs.append(pref)\n used.add(pref)\n cur = pref\n found_next = True\n break\n if not found_next:\n break\n\n first_comp = addr_leds[chain_refs[0]]\n protocol = _get_protocol(first_comp)\n # Estimate current: 60mA per LED at full white for WS2812/SK6812\n per_led_ma = 60 if \"APA102\" not in protocol else 40\n chains.append({\n \"chain_length\": len(chain_refs),\n \"first_led\": chain_refs[0],\n \"last_led\": chain_refs[-1],\n \"data_in_net\": led_din_net.get(chain_refs[0], \"\"),\n \"protocol\": protocol,\n \"led_type\": first_comp.get(\"value\", \"\"),\n \"estimated_current_mA\": len(chain_refs) * per_led_ma,\n \"components\": chain_refs,\n \"detector\": \"detect_addressable_leds\",\n \"rule_id\": \"AL-DET\",\n \"category\": \"led_control\",\n \"severity\": \"info\",\n \"confidence\": \"deterministic\",\n \"evidence_source\": \"topology\",\n \"summary\": f\"Addressable LED chain: {len(chain_refs)} LEDs ({protocol})\",\n \"description\": f\"Detected addressable LED chain of {len(chain_refs)} {first_comp.get('value', '')} LEDs.\",\n \"nets\": [led_din_net.get(chain_refs[0], \"\")] if led_din_net.get(chain_refs[0]) else [],\n \"pins\": [],\n \"recommendation\": \"\",\n \"report_context\": {\"section\": \"LED Control\", \"impact\": \"\", \"standard_ref\": \"\"},\n \"provenance\": make_provenance(\"aled_data_chain\", \"deterministic\", claimed_components=[chain_refs[0]]),\n })\n\n # Also pick up single LEDs not in a chain\n for led_ref in addr_leds:\n if led_ref not in used:\n comp = addr_leds[led_ref]\n protocol = _get_protocol(comp)\n per_led_ma = 60 if \"APA102\" not in protocol else 40\n chains.append({\n \"chain_length\": 1,\n \"first_led\": led_ref,\n \"last_led\": led_ref,\n \"data_in_net\": led_din_net.get(led_ref, \"\"),\n \"protocol\": protocol,\n \"led_type\": comp.get(\"value\", \"\"),\n \"estimated_current_mA\": per_led_ma,\n \"components\": [led_ref],\n \"detector\": \"detect_addressable_leds\",\n \"rule_id\": \"AL-DET\",\n \"category\": \"led_control\",\n \"severity\": \"info\",\n \"confidence\": \"deterministic\",\n \"evidence_source\": \"topology\",\n \"summary\": f\"Addressable LED {led_ref} ({comp.get('value', '')}) [{protocol}]\",\n \"description\": f\"Detected single addressable LED {led_ref} ({comp.get('value', '')}).\",\n \"nets\": [led_din_net.get(led_ref, \"\")] if led_din_net.get(led_ref) else [],\n \"pins\": [],\n \"recommendation\": \"\",\n \"report_context\": {\"section\": \"LED Control\", \"impact\": \"\", \"standard_ref\": \"\"},\n \"provenance\": make_provenance(\"aled_data_chain\", \"deterministic\", claimed_components=[led_ref]),\n })\n\n return chains\n\n\n# ---------------------------------------------------------------------------\n# ESD Protection Coverage Audit\n# ---------------------------------------------------------------------------\n\n# Connector interface classification for ESD risk level\n_ESD_HIGH_RISK_KEYWORDS = (\n \"usb\", \"hdmi\", \"ethernet\", \"rj45\", \"can\", \"rs485\", \"rs232\", \"rs-485\",\n \"rs-232\", \"displayport\", \"thunderbolt\", \"firewire\", \"ieee1394\",\n)\n_ESD_MEDIUM_RISK_KEYWORDS = (\n \"spi\", \"i2c\", \"iic\", \"uart\", \"serial\", \"header\", \"pin_header\",\n \"conn_01x\", \"conn_02x\",\n)\n_ESD_LOW_RISK_KEYWORDS = (\n \"debug\", \"swd\", \"jtag\", \"tag-connect\", \"st-link\", \"j-link\",\n \"programming\", \"isp\", \"icsp\", \"board_to_board\", \"b2b\", \"fpc\",\n)\n\n\ndef _classify_connector_interface(comp: dict) -> tuple[str, str]:\n \"\"\"Classify connector interface type and risk level.\n\n Returns (interface_type, risk_level).\n \"\"\"\n val = comp.get(\"value\", \"\").lower()\n lib = comp.get(\"lib_id\", \"\").lower()\n fp = comp.get(\"footprint\", \"\").lower()\n combined = val + \" \" + lib + \" \" + fp\n\n def _kw_match(kw: str) -> bool:\n \"\"\"Match keyword with word boundary for short keywords (\u003c=3 chars).\"\"\"\n if len(kw) \u003c= 3:\n return bool(re.search(r'\\b' + re.escape(kw) + r'\\b', combined))\n return kw in combined\n\n for kw in _ESD_HIGH_RISK_KEYWORDS:\n if _kw_match(kw):\n # More specific interface type\n if \"usb\" in combined:\n return \"usb\", \"high_risk\"\n if \"hdmi\" in combined:\n return \"hdmi\", \"high_risk\"\n if \"ethernet\" in combined or \"rj45\" in combined:\n return \"ethernet\", \"high_risk\"\n if _kw_match(\"can\"):\n return \"can\", \"high_risk\"\n if \"rs485\" in combined or \"rs-485\" in combined:\n return \"rs485\", \"high_risk\"\n if \"rs232\" in combined or \"rs-232\" in combined:\n return \"rs232\", \"high_risk\"\n if \"displayport\" in combined:\n return \"displayport\", \"high_risk\"\n return kw, \"high_risk\"\n\n for kw in _ESD_MEDIUM_RISK_KEYWORDS:\n if _kw_match(kw):\n if _kw_match(\"spi\"):\n return \"spi\", \"medium_risk\"\n if \"i2c\" in combined or _kw_match(\"iic\"):\n return \"i2c\", \"medium_risk\"\n if \"uart\" in combined or \"serial\" in combined:\n return \"uart\", \"medium_risk\"\n return \"header\", \"medium_risk\"\n\n for kw in _ESD_LOW_RISK_KEYWORDS:\n if _kw_match(kw):\n return \"debug\", \"low_risk\"\n\n return \"generic\", \"medium_risk\"\n\n\ndef audit_esd_protection(ctx: AnalysisContext,\n protection_devices: list[dict]) -> list[dict]:\n \"\"\"Audit ESD protection coverage on external connectors.\n\n Cross-references connector signal nets against protection_devices output\n to identify unprotected external-facing signal lines.\n \"\"\"\n # Build set of all protected nets\n protected_nets_set: set[str] = set()\n # Map net -> list of protection device refs\n net_to_esd: dict[str, list[str]] = {}\n for pd in protection_devices:\n pnets = pd.get(\"protected_nets\", [])\n if not pnets:\n pn = pd.get(\"protected_net\")\n if pn:\n pnets = [pn]\n for net in pnets:\n protected_nets_set.add(net)\n net_to_esd.setdefault(net, []).append(pd[\"ref\"])\n\n # Quick lookup from ref -> protection_device entry\n pd_lookup: dict[str, dict] = {pd[\"ref\"]: pd for pd in protection_devices}\n\n results: list[dict] = []\n\n for comp in ctx.components:\n if comp[\"type\"] != \"connector\":\n continue\n\n interface_type, risk_level = _classify_connector_interface(comp)\n\n # Collect signal nets on this connector (exclude GND and power rails)\n signal_nets: list[str] = []\n for pin_num, (net_name, _) in ctx.ref_pins.get(comp[\"reference\"], {}).items():\n if not net_name:\n continue\n if ctx.is_ground(net_name) or ctx.is_power_net(net_name):\n continue\n if net_name not in signal_nets:\n signal_nets.append(net_name)\n\n if not signal_nets:\n continue\n\n signal_nets.sort()\n prot = sorted(n for n in signal_nets if n in protected_nets_set)\n unprot = sorted(n for n in signal_nets if n not in protected_nets_set)\n\n if len(unprot) == 0:\n coverage = \"full\"\n elif len(prot) == 0:\n coverage = \"none\"\n else:\n coverage = \"partial\"\n\n # Collect ESD device refs covering this connector\n esd_refs: list[str] = sorted({\n ref for n in prot for ref in net_to_esd.get(n, [])\n })\n\n # Build detailed info for each ESD device\n esd_device_details: list[dict] = []\n for esd_ref in esd_refs:\n esd_comp = ctx.comp_lookup.get(esd_ref, {})\n detail: dict = {\n \"ref\": esd_ref,\n \"value\": esd_comp.get(\"value\", \"\"),\n \"lib_id\": esd_comp.get(\"lib_id\", \"\"),\n }\n # Pull protection_type from the protection_devices entry\n pd_entry = pd_lookup.get(esd_ref)\n if pd_entry:\n detail[\"protection_type\"] = pd_entry.get(\"type\", \"\")\n esd_device_details.append(detail)\n\n results.append({\n \"connector_ref\": comp[\"reference\"],\n \"connector_value\": comp.get(\"value\", \"\"),\n \"interface_type\": interface_type,\n \"risk_level\": risk_level,\n \"signal_nets\": signal_nets,\n \"protected_nets\": prot,\n \"unprotected_nets\": unprot,\n \"coverage\": coverage,\n \"esd_devices\": esd_refs,\n \"esd_device_details\": esd_device_details,\n \"detector\": \"audit_esd_protection\",\n \"rule_id\": \"EP-AUD\",\n \"category\": \"protection\",\n \"severity\": \"info\",\n \"confidence\": \"deterministic\",\n \"evidence_source\": \"topology\",\n \"summary\": f\"ESD audit {comp['reference']} ({interface_type}): {coverage} coverage\",\n \"description\": f\"ESD protection audit for {interface_type} connector {comp['reference']}: {coverage} coverage.\",\n \"components\": [comp[\"reference\"]] + esd_refs,\n \"nets\": signal_nets,\n \"pins\": [],\n \"recommendation\": \"\",\n \"report_context\": {\"section\": \"ESD Protection\", \"impact\": \"\", \"standard_ref\": \"\"},\n \"provenance\": make_provenance(\"esd_coverage_audit\", \"deterministic\", claimed_components=[comp[\"reference\"]]),\n })\n\n return results\n\n\n# ---------------------------------------------------------------------------\n# Debug Interface Verification\n# ---------------------------------------------------------------------------\n\n_SWD_ALIASES: dict[str, tuple[str, ...]] = {\n \"SWDIO\": (\"SWDIO\", \"SWD_IO\", \"TMS_SWDIO\", \"TMS/SWDIO\"),\n \"SWCLK\": (\"SWCLK\", \"SWD_CLK\", \"TCK_SWCLK\", \"TCK/SWCLK\"),\n}\n_JTAG_ALIASES: dict[str, tuple[str, ...]] = {\n \"TCK\": (\"TCK\", \"JTAG_TCK\"),\n \"TMS\": (\"TMS\", \"JTAG_TMS\"),\n \"TDI\": (\"TDI\", \"JTAG_TDI\"),\n \"TDO\": (\"TDO\", \"JTAG_TDO\", \"SWO\"),\n}\n_RESET_ALIASES = (\"NRESET\", \"NRST\", \"RESET\", \"RST\", \"SRST\")\n_DEBUG_CONNECTOR_KEYWORDS = (\n \"jtag\", \"swd\", \"debug\", \"tag-connect\", \"tag_connect\", \"cortex\",\n \"st-link\", \"st_link\", \"j-link\", \"j_link\", \"arm_jtag\", \"arm_swd\",\n)\n\n\ndef detect_debug_interfaces(ctx: AnalysisContext) -> list[dict]:\n \"\"\"Detect SWD/JTAG debug connectors and validate pin connections.\"\"\"\n results: list[dict] = []\n\n for comp in ctx.components:\n if comp[\"type\"] != \"connector\":\n continue\n\n val = comp.get(\"value\", \"\").lower()\n lib = comp.get(\"lib_id\", \"\").lower()\n combined = val + \" \" + lib\n\n # Stage 1: keyword match on value/lib_id\n is_debug_kw = any(kw in combined for kw in _DEBUG_CONNECTOR_KEYWORDS)\n\n # Gather pin names and nets for this connector\n pin_map: dict[str, tuple[str, str]] = {} # pin_name_upper -> (net, pin_num)\n for pin_num, (net_name, _) in ctx.ref_pins.get(comp[\"reference\"], {}).items():\n if not net_name or net_name not in ctx.nets:\n continue\n for p in ctx.nets[net_name][\"pins\"]:\n if p[\"component\"] == comp[\"reference\"] and p[\"pin_number\"] == pin_num:\n pname = p.get(\"pin_name\", \"\").upper().replace(\" \", \"\")\n if pname:\n pin_map[pname] = (net_name, pin_num)\n break\n\n # Stage 2: classify interface by pin names found\n swd_found: dict[str, dict] = {}\n jtag_found: dict[str, dict] = {}\n\n for canonical, aliases in _SWD_ALIASES.items():\n for alias in aliases:\n # Check pin name match\n if alias in pin_map:\n swd_found[canonical] = {\"net\": pin_map[alias][0], \"pin_num\": pin_map[alias][1]}\n break\n # Check net name match (for generic connectors)\n for pname, (net, pnum) in pin_map.items():\n if alias in net.upper():\n swd_found[canonical] = {\"net\": net, \"pin_num\": pnum}\n break\n if canonical in swd_found:\n break\n\n for canonical, aliases in _JTAG_ALIASES.items():\n for alias in aliases:\n if alias in pin_map:\n jtag_found[canonical] = {\"net\": pin_map[alias][0], \"pin_num\": pin_map[alias][1]}\n break\n for pname, (net, pnum) in pin_map.items():\n if alias in net.upper():\n jtag_found[canonical] = {\"net\": net, \"pin_num\": pnum}\n break\n if canonical in jtag_found:\n break\n\n # Check for reset pin\n reset_info = None\n for alias in _RESET_ALIASES:\n if alias in pin_map:\n reset_info = {\"net\": pin_map[alias][0], \"pin_num\": pin_map[alias][1]}\n break\n for pname, (net, pnum) in pin_map.items():\n if alias in net.upper():\n reset_info = {\"net\": net, \"pin_num\": pnum}\n break\n if reset_info:\n break\n\n # Determine interface type\n has_swd = len(swd_found) >= 2 # SWDIO + SWCLK\n has_jtag = len(jtag_found) >= 3 # TCK + TMS + TDI (TDO optional)\n\n if not has_swd and not has_jtag and not is_debug_kw:\n continue\n\n if has_jtag and len(jtag_found) >= 4:\n interface_type = \"jtag\"\n elif has_swd:\n interface_type = \"swd\"\n elif has_jtag:\n interface_type = \"jtag\"\n elif is_debug_kw:\n # Keyword match but can't determine exact interface\n interface_type = \"swd\" if \"swd\" in combined else \"jtag\" if \"jtag\" in combined else \"debug\"\n else:\n continue\n\n # Build pins_found map with IC tracing\n all_pins = swd_found.copy() if interface_type == \"swd\" else jtag_found.copy()\n if reset_info:\n all_pins[\"nRESET\"] = reset_info\n\n pins_found: dict[str, dict] = {}\n target_ics: list[str] = []\n for canonical, info in all_pins.items():\n net = info[\"net\"]\n entry: dict = {\"net\": net}\n # Trace to an IC (MCU, FPGA)\n if net in ctx.nets:\n for p in ctx.nets[net][\"pins\"]:\n if p[\"component\"] == comp[\"reference\"]:\n continue\n ic = ctx.comp_lookup.get(p[\"component\"])\n if ic and ic[\"type\"] == \"ic\":\n entry[\"connected_to_ic\"] = p[\"component\"]\n if p[\"component\"] not in target_ics:\n target_ics.append(p[\"component\"])\n break\n pins_found[canonical] = entry\n\n # Determine missing and floating pins\n if interface_type == \"swd\":\n required = {\"SWDIO\", \"SWCLK\"}\n elif interface_type == \"jtag\":\n required = {\"TCK\", \"TMS\", \"TDI\", \"TDO\"}\n else:\n required = set()\n\n missing_pins = sorted(required - set(pins_found.keys()))\n\n # Check for floating (no-connect) pins among optional pins\n floating_pins: list[str] = []\n optional_check = {\"SWO\", \"nRESET\"} if interface_type == \"swd\" else {\"TRST\", \"nRESET\"}\n for alias_set in (_SWD_ALIASES, _JTAG_ALIASES):\n for canonical, aliases in alias_set.items():\n if canonical in pins_found:\n continue\n if canonical not in optional_check:\n continue\n for alias in aliases:\n if alias in pin_map:\n # Pin exists but wasn't matched — check if NC\n net = pin_map[alias][0]\n if net in ctx.nets and len(ctx.nets[net][\"pins\"]) \u003c= 1:\n floating_pins.append(canonical)\n break\n\n # Determine target IC (most common)\n target_ic = target_ics[0] if target_ics else None\n\n status = \"pass\" if not missing_pins else \"incomplete\"\n\n di_comps = [comp[\"reference\"]] + ([target_ic] if target_ic else [])\n results.append({\n \"connector_ref\": comp[\"reference\"],\n \"connector_value\": comp.get(\"value\", \"\"),\n \"interface_type\": interface_type,\n \"pins_found\": pins_found,\n \"missing_pins\": missing_pins,\n \"floating_pins\": floating_pins,\n \"target_ic\": target_ic,\n \"status\": status,\n \"detector\": \"detect_debug_interfaces\",\n \"rule_id\": \"DI-DET\",\n \"category\": \"debug\",\n \"severity\": \"info\",\n \"confidence\": \"deterministic\",\n \"evidence_source\": \"topology\",\n \"summary\": f\"Debug interface {comp['reference']} ({interface_type}): {status}\",\n \"description\": f\"Detected {interface_type} debug interface on connector {comp['reference']}.\",\n \"components\": di_comps,\n \"nets\": [],\n \"pins\": [],\n \"recommendation\": \"\",\n \"report_context\": {\"section\": \"Debug\", \"impact\": \"\", \"standard_ref\": \"\"},\n \"provenance\": make_provenance(f\"debug_{interface_type}\", \"deterministic\", claimed_components=[comp[\"reference\"]]),\n })\n\n return results\n\n\n# ---------------------------------------------------------------------------\n# Power Path / Load Switch Detection\n# ---------------------------------------------------------------------------\n\n_LOAD_SWITCH_KEYWORDS = (\n \"tps229\", \"tps2291\", \"tps2293\", \"tps2295\",\n \"tps2281\", \"tps2283\", \"tps2285\",\n \"tps2211\", \"tps2219\", \"tps2221\",\n \"tps22918\", \"tps22919\", \"tps22810\",\n \"sy6280\", \"sy6282\", \"sy6288\",\n \"rt9742\", \"rt9701\", \"ap2281\", \"ap2191\",\n \"stmps2\", \"ncp380\", \"ncp381\",\n \"mic205\", \"mic209\",\n)\n\n_IDEAL_DIODE_KEYWORDS = (\n \"ltc4412\", \"ltc4413\", \"ltc4414\",\n \"tps2113\", \"tps2115\", \"tps2121\",\n \"lm66100\", \"lm66200\",\n \"sm74611\",\n)\n\n_USB_PD_KEYWORDS = (\n \"fusb302\", \"stusb4500\", \"cypd3177\", \"husb238\",\n \"tps65987\", \"tps65988\", \"max77958\",\n \"ccg3\", \"ccg6\",\n)\n\n# Pin name patterns for classification\n_POWER_IN_PINS = {\"VIN\", \"IN\", \"V_IN\", \"VINA\", \"VINB\", \"VIN1\", \"VIN2\", \"SUP\", \"VS\"}\n_POWER_OUT_PINS = {\"VOUT\", \"OUT\", \"V_OUT\", \"VOUTA\", \"VOUTB\", \"VOUT1\", \"VOUT2\"}\n_ENABLE_PINS = {\"EN\", \"ENABLE\", \"ON\", \"ON_OFF\", \"CTRL\", \"CE\", \"SHDN\", \"SHUTDOWN\"}\n_VBUS_PINS = {\"VBUS\", \"V_BUS\"}\n_CC_PINS = {\"CC1\", \"CC2\", \"CC\"}\n_SBU_PINS = {\"SBU1\", \"SBU2\", \"SBU\"}\n\n\ndef detect_power_path(ctx: AnalysisContext) -> list[dict]:\n \"\"\"Detect load switches, ideal diodes, power MUXes, and USB PD controllers.\"\"\"\n results: list[dict] = []\n matched_refs: set[str] = set()\n\n for comp in ctx.components:\n if comp[\"type\"] != \"ic\":\n continue\n ref = comp[\"reference\"]\n if ref in matched_refs:\n continue\n\n val = comp.get(\"value\", \"\").lower()\n lib = comp.get(\"lib_id\", \"\").lower()\n combined = val + \" \" + lib\n\n # Classify device type\n device_type = None\n if any(kw in combined for kw in _LOAD_SWITCH_KEYWORDS):\n device_type = \"load_switch\"\n elif any(kw in combined for kw in _IDEAL_DIODE_KEYWORDS):\n # Distinguish ideal diode from power MUX by pin count/keywords\n if any(kw in combined for kw in (\"tps2113\", \"tps2115\", \"tps2121\")):\n device_type = \"power_mux\"\n else:\n device_type = \"ideal_diode\"\n elif any(kw in combined for kw in _USB_PD_KEYWORDS):\n device_type = \"usb_pd_controller\"\n\n if not device_type:\n continue\n\n matched_refs.add(ref)\n\n # Map pin names to nets\n pin_nets: dict[str, str] = {} # pin_name_upper -> net\n for pin_num, (net_name, _) in ctx.ref_pins.get(ref, {}).items():\n if not net_name:\n continue\n if net_name in ctx.nets:\n for p in ctx.nets[net_name][\"pins\"]:\n if p[\"component\"] == ref and p[\"pin_number\"] == pin_num:\n pname = p.get(\"pin_name\", \"\").upper().replace(\" \", \"\").replace(\"/\", \"_\")\n if pname:\n pin_nets[pname] = net_name\n break\n\n entry: dict = {\n \"ref\": ref,\n \"value\": comp.get(\"value\", \"\"),\n \"type\": device_type,\n }\n\n if device_type == \"load_switch\":\n # Find input, output, enable nets\n input_rail = None\n output_rail = None\n enable_net = None\n\n for pname, net in pin_nets.items():\n if pname in _POWER_IN_PINS or pname.startswith(\"VIN\"):\n input_rail = net\n elif pname in _POWER_OUT_PINS or pname.startswith(\"VOUT\"):\n output_rail = net\n elif pname in _ENABLE_PINS or pname.startswith(\"EN\"):\n enable_net = net\n\n # Fallback: if no named pins found, infer from power nets\n if not input_rail and not output_rail:\n power_nets = [n for pn, n in pin_nets.items()\n if ctx.is_power_net(n) and not ctx.is_ground(n)]\n if len(power_nets) >= 2:\n input_rail = power_nets[0]\n output_rail = power_nets[1]\n elif len(power_nets) == 1:\n input_rail = power_nets[0]\n\n # Determine enable polarity from pin name\n enable_active_high = True\n for pname in pin_nets:\n if pname in (\"SHDN\", \"SHUTDOWN\"):\n enable_active_high = False\n if not enable_net:\n enable_net = pin_nets[pname]\n break\n\n entry[\"input_rail\"] = input_rail\n entry[\"output_rail\"] = output_rail\n entry[\"enable_net\"] = enable_net\n entry[\"enable_active_high\"] = enable_active_high\n\n elif device_type in (\"ideal_diode\", \"power_mux\"):\n # Find multiple inputs and single output\n inputs: list[str] = []\n output_rail = None\n\n for pname, net in pin_nets.items():\n if pname in _POWER_IN_PINS or pname.startswith(\"VIN\"):\n inputs.append(net)\n elif pname in _POWER_OUT_PINS or pname.startswith(\"VOUT\"):\n output_rail = net\n\n # Fallback for ideal diodes: anode is input, cathode is output\n if not inputs and not output_rail:\n power_nets = [n for pn, n in pin_nets.items()\n if ctx.is_power_net(n) and not ctx.is_ground(n)]\n if len(power_nets) >= 2:\n inputs = power_nets[:-1]\n output_rail = power_nets[-1]\n\n entry[\"input_rails\"] = inputs\n entry[\"output_rail\"] = output_rail\n\n elif device_type == \"usb_pd_controller\":\n vbus_net = None\n cc_nets: list[str] = []\n sbu_nets: list[str] = []\n\n for pname, net in pin_nets.items():\n if pname in _VBUS_PINS or pname.startswith(\"VBUS\"):\n vbus_net = net\n elif pname in _CC_PINS or pname.startswith(\"CC\"):\n cc_nets.append(net)\n elif pname in _SBU_PINS or pname.startswith(\"SBU\"):\n sbu_nets.append(net)\n\n entry[\"vbus_net\"] = vbus_net\n entry[\"cc_nets\"] = cc_nets\n entry[\"sbu_nets\"] = sbu_nets\n\n # USB-C CC resistor validation\n # UFP (sink) requires 5.1kΩ ±10% pull-down on each CC pin to GND\n # DFP (source) uses 56kΩ (default USB), 22kΩ (1.5A), or 10kΩ (3A)\n # PD controller IC on CC nets means discrete resistors not required\n cc_resistor_findings = []\n has_pd_controller = bool(entry.get(\"pd_controller\"))\n\n if cc_nets and not has_pd_controller:\n for cc_net in cc_nets:\n if cc_net not in ctx.nets:\n continue\n cc_resistors = []\n for pin in ctx.nets[cc_net].get(\"pins\", []):\n comp = ctx.comp_lookup.get(pin[\"component\"])\n if not comp or comp[\"type\"] != \"resistor\":\n continue\n r_val = ctx.parsed_values.get(pin[\"component\"])\n if r_val is None:\n continue\n # Check which net the other pin connects to\n n1, n2 = ctx.get_two_pin_nets(pin[\"component\"])\n other_net = n2 if n1 == cc_net else n1\n if other_net and ctx.is_ground(other_net):\n cc_resistors.append({\n \"ref\": pin[\"component\"],\n \"ohms\": r_val,\n \"value\": comp.get(\"value\", \"\"),\n \"to_net\": other_net,\n })\n\n if not cc_resistors:\n cc_resistor_findings.append({\n \"net\": cc_net,\n \"status\": \"missing\",\n \"message\": f\"No pull-down resistor on {cc_net} — \"\n f\"required for USB-C sink (UFP) role\",\n })\n else:\n for cr in cc_resistors:\n r = cr[\"ohms\"]\n # 5.1kΩ ±10% for sink (UFP)\n if 4590 \u003c= r \u003c= 5610:\n cc_resistor_findings.append({\n \"net\": cc_net,\n \"ref\": cr[\"ref\"],\n \"ohms\": r,\n \"status\": \"correct_sink\",\n \"message\": f\"{cr['ref']} ({cr['value']}) on {cc_net} — \"\n f\"correct for UFP/sink role\",\n })\n elif 50400 \u003c= r \u003c= 61600: # 56kΩ ±10% = default USB\n cc_resistor_findings.append({\n \"net\": cc_net, \"ref\": cr[\"ref\"], \"ohms\": r,\n \"status\": \"source_default\",\n \"message\": f\"{cr['ref']} ({cr['value']}) — \"\n f\"DFP source, default USB current\",\n })\n elif 19800 \u003c= r \u003c= 24200: # 22kΩ ±10% = 1.5A\n cc_resistor_findings.append({\n \"net\": cc_net, \"ref\": cr[\"ref\"], \"ohms\": r,\n \"status\": \"source_1_5A\",\n \"message\": f\"{cr['ref']} ({cr['value']}) — \"\n f\"DFP source, 1.5A advertisement\",\n })\n elif 9000 \u003c= r \u003c= 11000: # 10kΩ ±10% = 3A\n cc_resistor_findings.append({\n \"net\": cc_net, \"ref\": cr[\"ref\"], \"ohms\": r,\n \"status\": \"source_3A\",\n \"message\": f\"{cr['ref']} ({cr['value']}) — \"\n f\"DFP source, 3A advertisement\",\n })\n else:\n cc_resistor_findings.append({\n \"net\": cc_net, \"ref\": cr[\"ref\"], \"ohms\": r,\n \"status\": \"unexpected_value\",\n \"message\": f\"{cr['ref']} ({cr['value']}) = {r:.0f}Ω on {cc_net} — \"\n f\"not a standard USB-C CC value \"\n f\"(expected 5.1kΩ sink or 56k/22k/10k source)\",\n })\n\n if cc_resistor_findings:\n entry[\"cc_resistor_check\"] = cc_resistor_findings\n\n entry.setdefault(\"detector\", \"detect_power_path\")\n entry.setdefault(\"rule_id\", \"PP-DET\")\n entry.setdefault(\"category\", \"power_management\")\n entry.setdefault(\"severity\", \"info\")\n entry.setdefault(\"confidence\", \"deterministic\")\n entry.setdefault(\"evidence_source\", \"topology\")\n entry.setdefault(\"summary\", f\"Power path {ref} ({comp.get('value', '')}) [{entry.get('type', device_type)}]\")\n entry.setdefault(\"description\", f\"Detected {device_type} power path component {ref}.\")\n entry.setdefault(\"components\", [ref])\n entry.setdefault(\"nets\", [])\n entry.setdefault(\"pins\", [])\n entry.setdefault(\"recommendation\", \"\")\n entry.setdefault(\"report_context\", {\"section\": \"Power Management\", \"impact\": \"\", \"standard_ref\": \"\"})\n entry[\"provenance\"] = make_provenance(\"ppath_topology\", \"deterministic\", claimed_components=[ref])\n results.append(entry)\n\n return results\n\n\n# ---------------------------------------------------------------------------\n# ADC Signal Conditioning\n# ---------------------------------------------------------------------------\n\n_ADC_IC_KEYWORDS = (\n \"ads1\", \"ads8\", \"mcp33\", \"mcp34\", \"mcp35\",\n \"max114\", \"max119\", \"max11\", \"ltc24\", \"ltc23\",\n \"ad7\", \"adc08\", \"adc12\", \"ads12\", \"ads13\",\n \"mcp32\", \"nau7802\",\n)\n\n_VREF_IC_KEYWORDS = (\n \"ref20\", \"ref30\", \"ref31\", \"ref32\", \"ref33\", \"ref19\",\n \"lm431\", \"tl431\", \"lm4040\", \"lt1009\", \"ad584\",\n \"adr44\", \"adr36\", \"adr38\", \"max60\", \"lt6656\",\n \"mcp15\", \"lm385\",\n)\n\n_ADC_PIN_PREFIXES = (\"AIN\", \"ADC\", \"AN\", \"CH\", \"INP\", \"INN\", \"IN+\", \"IN-\",\n \"AINP\", \"AINN\", \"MUX\", \"VIN\")\n_VREF_PIN_NAMES = {\"VREF\", \"REFIN\", \"REFOUT\", \"REF\", \"REF+\", \"REF-\",\n \"VREF+\", \"VREF-\", \"VREFP\", \"VREFN\", \"REFP\", \"REFN\"}\n_SPI_PIN_NAMES = {\"SCLK\", \"SCK\", \"CLK\", \"MOSI\", \"SDI\", \"DIN\", \"MISO\",\n \"SDO\", \"DOUT\", \"CS\", \"CSN\", \"SS\", \"NSS\"}\n_I2C_PIN_NAMES = {\"SDA\", \"SCL\"}\n\n# ADC resolution inference from part number prefix\n_ADC_RESOLUTION_MAP = {\n \"ads111\": 16, \"ads101\": 12, \"ads131\": 24, \"ads126\": 24,\n \"ads861\": 16, \"ads868\": 16,\n \"mcp320\": 12, \"mcp330\": 10, \"mcp340\": 10, \"mcp342\": 18,\n \"mcp346\": 16, \"mcp356\": 24, \"mcp355\": 24,\n \"max1161\": 12, \"max1163\": 12, \"max1194\": 10, \"max1198\": 8,\n \"max1192\": 10, \"max1141\": 14,\n \"ltc24\": 24, \"ltc23\": 16,\n \"ad760\": 16, \"ad770\": 14, \"ad799\": 8,\n \"adc081\": 8, \"adc082\": 8, \"adc121\": 12, \"adc122\": 12,\n \"nau780\": 24,\n}\n\n\ndef _infer_adc_resolution(value: str, lib_id: str) -> int | None:\n \"\"\"Infer ADC resolution in bits from part number.\"\"\"\n combined = (value + \" \" + lib_id).lower()\n for prefix, bits in _ADC_RESOLUTION_MAP.items():\n if prefix in combined:\n return bits\n return None\n\n\ndef _infer_interface(pin_names: set[str]) -> str | None:\n \"\"\"Infer communication interface from pin names.\"\"\"\n if pin_names & _SPI_PIN_NAMES:\n return \"spi\"\n if pin_names & _I2C_PIN_NAMES:\n return \"i2c\"\n return None\n\n\ndef detect_adc_circuits(ctx: AnalysisContext,\n rc_filters: list[dict],\n protection_devices: list[dict]) -> list[dict]:\n \"\"\"Detect external ADC ICs and voltage reference ICs.\n\n Cross-references rc_filters for anti-aliasing and protection_devices\n for input protection on ADC input nets.\n \"\"\"\n results: list[dict] = []\n matched_refs: set[str] = set()\n\n # Build net → RC filter index for anti-aliasing cross-reference\n rc_by_net: dict[str, list[dict]] = {}\n for rcf in rc_filters:\n for key in (\"resistor\", \"capacitor\"):\n comp = rcf.get(key, {})\n ref = comp.get(\"reference\")\n if ref:\n for pin_num, (net, _) in ctx.ref_pins.get(ref, {}).items():\n if net:\n rc_by_net.setdefault(net, []).append(rcf)\n\n # Build net → protection device index\n prot_by_net: dict[str, list[dict]] = {}\n for pd in protection_devices:\n pnets = pd.get(\"protected_nets\", [])\n if not pnets:\n pn = pd.get(\"protected_net\")\n if pn:\n pnets = [pn]\n for net in pnets:\n prot_by_net.setdefault(net, []).append(pd)\n\n # Phase 1: Detect VREF ICs (needed before ADCs so we can link them)\n vref_entries: dict[str, dict] = {} # ref -> entry\n vref_output_nets: dict[str, str] = {} # net -> vref_ref\n\n for comp in ctx.components:\n if comp[\"type\"] != \"ic\":\n continue\n ref = comp[\"reference\"]\n val = comp.get(\"value\", \"\").lower()\n lib = comp.get(\"lib_id\", \"\").lower()\n combined = val + \" \" + lib\n\n if not any(kw in combined for kw in _VREF_IC_KEYWORDS):\n continue\n if ref in matched_refs:\n continue\n matched_refs.add(ref)\n\n # Infer output voltage\n vref_v, _ = lookup_regulator_vref(comp.get(\"value\", \"\"), comp.get(\"lib_id\", \"\"))\n\n # Find output net\n output_net = None\n for pin_num, (net_name, _) in ctx.ref_pins.get(ref, {}).items():\n if not net_name or ctx.is_ground(net_name):\n continue\n if net_name in ctx.nets:\n for p in ctx.nets[net_name][\"pins\"]:\n if p[\"component\"] == ref and p[\"pin_number\"] == pin_num:\n pname = p.get(\"pin_name\", \"\").upper()\n if pname in _VREF_PIN_NAMES or pname in (\"OUT\", \"VOUT\", \"OUTPUT\"):\n output_net = net_name\n break\n if output_net:\n break\n\n # Fallback: for 2-3 pin VREFs (e.g., TL431, LM4040), use non-power, non-ground net\n if not output_net:\n for pin_num, (net_name, _) in ctx.ref_pins.get(ref, {}).items():\n if net_name and not ctx.is_ground(net_name) and not ctx.is_power_net(net_name):\n output_net = net_name\n break\n\n entry = {\n \"ref\": ref,\n \"value\": comp.get(\"value\", \"\"),\n \"type\": \"voltage_reference\",\n \"output_voltage\": vref_v,\n \"output_net\": output_net,\n \"consumers\": [],\n \"detector\": \"detect_adc_circuits\",\n \"rule_id\": \"AD-DET\",\n \"category\": \"analog\",\n \"severity\": \"info\",\n \"confidence\": \"deterministic\",\n \"evidence_source\": \"topology\",\n \"summary\": f\"Voltage reference {ref} ({comp.get('value', '')})\" + (f\" {vref_v}V\" if vref_v else \"\"),\n \"description\": f\"Detected voltage reference IC {ref}.\",\n \"components\": [ref],\n \"nets\": [output_net] if output_net else [],\n \"pins\": [],\n \"recommendation\": \"\",\n \"report_context\": {\"section\": \"Analog\", \"impact\": \"\", \"standard_ref\": \"\"},\n }\n\n entry[\"provenance\"] = make_provenance(\"adc_ic_topology\", \"deterministic\", claimed_components=[ref])\n\n if output_net:\n vref_output_nets[output_net] = ref\n # Trace consumers on the output net\n if output_net in ctx.nets:\n for p in ctx.nets[output_net][\"pins\"]:\n if p[\"component\"] != ref:\n ic = ctx.comp_lookup.get(p[\"component\"])\n if ic and ic[\"type\"] == \"ic\" and p[\"component\"] not in entry[\"consumers\"]:\n entry[\"consumers\"].append(p[\"component\"])\n\n vref_entries[ref] = entry\n results.append(entry)\n\n # Phase 2: Detect ADC ICs\n for comp in ctx.components:\n if comp[\"type\"] != \"ic\":\n continue\n ref = comp[\"reference\"]\n if ref in matched_refs:\n continue\n val = comp.get(\"value\", \"\").lower()\n lib = comp.get(\"lib_id\", \"\").lower()\n combined = val + \" \" + lib\n\n if not any(kw in combined for kw in _ADC_IC_KEYWORDS):\n continue\n matched_refs.add(ref)\n\n # Map pin names to nets\n pin_nets: dict[str, str] = {}\n all_pin_names: set[str] = set()\n for pin_num, (net_name, _) in ctx.ref_pins.get(ref, {}).items():\n if not net_name or net_name not in ctx.nets:\n continue\n for p in ctx.nets[net_name][\"pins\"]:\n if p[\"component\"] == ref and p[\"pin_number\"] == pin_num:\n pname = p.get(\"pin_name\", \"\").upper().replace(\" \", \"\")\n if pname:\n pin_nets[pname] = net_name\n all_pin_names.add(pname)\n break\n\n # Find analog input channels\n input_channels: list[str] = []\n channel_nets: dict[str, str] = {} # channel_name -> net\n for pname, net in pin_nets.items():\n if any(pname.startswith(prefix) for prefix in _ADC_PIN_PREFIXES):\n if not ctx.is_power_net(net) and not ctx.is_ground(net):\n input_channels.append(pname)\n channel_nets[pname] = net\n input_channels.sort()\n\n # Infer resolution and interface\n resolution = _infer_adc_resolution(comp.get(\"value\", \"\"), comp.get(\"lib_id\", \"\"))\n interface = _infer_interface(all_pin_names)\n\n # Find VREF source\n vref_source = None\n for pname, net in pin_nets.items():\n if pname in _VREF_PIN_NAMES:\n if net in vref_output_nets:\n vref_ref = vref_output_nets[net]\n vref_entry = vref_entries.get(vref_ref, {})\n vref_source = {\n \"ref\": vref_ref,\n \"voltage\": vref_entry.get(\"output_voltage\"),\n }\n break\n\n # Cross-reference anti-aliasing filters on input channels\n anti_aliasing: list[dict] = []\n for ch_name, ch_net in channel_nets.items():\n for rcf in rc_by_net.get(ch_net, []):\n aa_entry = {\"channel\": ch_name}\n r_comp = rcf.get(\"resistor\", {})\n c_comp = rcf.get(\"capacitor\", {})\n if r_comp.get(\"reference\"):\n aa_entry[\"rc_ref_r\"] = r_comp[\"reference\"]\n if c_comp.get(\"reference\"):\n aa_entry[\"rc_ref_c\"] = c_comp[\"reference\"]\n if rcf.get(\"cutoff_hz\"):\n aa_entry[\"cutoff_hz\"] = rcf[\"cutoff_hz\"]\n anti_aliasing.append(aa_entry)\n break # one filter per channel\n\n # Cross-reference input protection\n input_protection: list[dict] = []\n for ch_name, ch_net in channel_nets.items():\n for pd in prot_by_net.get(ch_net, []):\n input_protection.append({\n \"channel\": ch_name,\n \"device\": pd[\"ref\"],\n })\n break # one protection device per channel\n\n adc_comps = [ref] + ([vref_source[\"ref\"]] if vref_source else [])\n entry = {\n \"ref\": ref,\n \"value\": comp.get(\"value\", \"\"),\n \"type\": \"external_adc\",\n \"resolution_bits\": resolution,\n \"interface\": interface,\n \"input_channels\": input_channels,\n \"detector\": \"detect_adc_circuits\",\n \"rule_id\": \"AD-DET\",\n \"category\": \"analog\",\n \"severity\": \"info\",\n \"confidence\": \"deterministic\",\n \"evidence_source\": \"topology\",\n \"summary\": f\"ADC {ref} ({comp.get('value', '')})\" + (f\" {resolution}-bit\" if resolution else \"\"),\n \"description\": f\"Detected external ADC {ref}\" + (f\" ({resolution}-bit)\" if resolution else \"\") + \".\",\n \"components\": adc_comps,\n \"nets\": [],\n \"pins\": [],\n \"recommendation\": \"\",\n \"report_context\": {\"section\": \"Analog\", \"impact\": \"\", \"standard_ref\": \"\"},\n }\n if vref_source:\n entry[\"vref_source\"] = vref_source\n if anti_aliasing:\n entry[\"anti_aliasing\"] = anti_aliasing\n if input_protection:\n entry[\"input_protection\"] = input_protection\n\n entry[\"provenance\"] = make_provenance(\"adc_ic_topology\", \"deterministic\", claimed_components=[ref])\n results.append(entry)\n\n return results\n\n\n# ---------------------------------------------------------------------------\n# Reset / Supervisor Circuits\n# ---------------------------------------------------------------------------\n\n_SUPERVISOR_IC_KEYWORDS = (\n \"tps38\", \"tps37\", \"max809\", \"max810\", \"max803\",\n \"stm6315\", \"stm690\", \"mcp120\", \"mcp130\",\n \"cat811\", \"cat810\", \"sgm809\", \"apx803\", \"bd48\",\n \"adm803\", \"adm809\", \"adm810\",\n)\n\n_WATCHDOG_IC_KEYWORDS = (\n \"max6369\", \"max6370\", \"max6381\",\n \"tps3431\", \"tps3813\", \"tps3823\", \"tps3824\",\n \"adm6316\", \"adm6320\", \"stwd100\",\n)\n\n_RESET_OUTPUT_PINS = {\"RST\", \"RESET\", \"NRST\", \"NRESET\", \"~{RST}\", \"~{RESET}\",\n \"~{NRST}\", \"~{NRESET}\", \"RSTN\", \"RESETN\", \"MR\", \"RESETOUT\",\n \"RESET_OUT\", \"RST_OUT\", \"RSTOUT\"}\n_RESET_INPUT_PINS = {\"NRST\", \"NRESET\", \"RST\", \"RESET\", \"~{RST}\", \"~{RESET}\",\n \"~{NRST}\", \"~{NRESET}\", \"RSTN\", \"RESETN\"}\n_WDI_PINS = {\"WDI\", \"WD\", \"WDT\", \"WDOG\"}\n_SUPERVISOR_VIN_PINS = {\"VIN\", \"SENSE\", \"VSS\", \"VS\", \"IN\", \"VCC\", \"VDD\"}\n\n\ndef _is_reset_net_name(net_name: str) -> bool:\n \"\"\"Check if a net name suggests it's a reset signal.\"\"\"\n nu = net_name.upper().replace(\"-\", \"\").replace(\"_\", \"\").replace(\" \", \"\")\n return any(k in nu for k in (\"NRST\", \"NRESET\", \"RESET\", \"RST\"))\n\n\ndef _find_target_ic_on_reset_net(ctx: AnalysisContext, reset_net: str,\n exclude_ref: str) -> str | None:\n \"\"\"Trace a reset net to find the target MCU/IC.\"\"\"\n if reset_net not in ctx.nets:\n return None\n for p in ctx.nets[reset_net][\"pins\"]:\n if p[\"component\"] == exclude_ref:\n continue\n ic = ctx.comp_lookup.get(p[\"component\"])\n if ic and ic[\"type\"] == \"ic\":\n pname = p.get(\"pin_name\", \"\").upper().replace(\" \", \"\")\n if pname in _RESET_INPUT_PINS or _is_reset_net_name(reset_net):\n return p[\"component\"]\n return None\n\n\ndef detect_reset_supervisors(ctx: AnalysisContext) -> list[dict]:\n \"\"\"Detect voltage supervisor ICs, watchdog ICs, and RC reset networks.\"\"\"\n results: list[dict] = []\n matched_refs: set[str] = set()\n\n for comp in ctx.components:\n if comp[\"type\"] != \"ic\":\n continue\n ref = comp[\"reference\"]\n if ref in matched_refs:\n continue\n val = comp.get(\"value\", \"\").lower()\n lib = comp.get(\"lib_id\", \"\").lower()\n combined = val + \" \" + lib\n\n # Classify\n is_supervisor = any(kw in combined for kw in _SUPERVISOR_IC_KEYWORDS)\n is_watchdog = any(kw in combined for kw in _WATCHDOG_IC_KEYWORDS)\n if not is_supervisor and not is_watchdog:\n continue\n matched_refs.add(ref)\n\n # Map pin names to nets\n pin_nets: dict[str, str] = {}\n for pin_num, (net_name, _) in ctx.ref_pins.get(ref, {}).items():\n if not net_name or net_name not in ctx.nets:\n continue\n for p in ctx.nets[net_name][\"pins\"]:\n if p[\"component\"] == ref and p[\"pin_number\"] == pin_num:\n pname = p.get(\"pin_name\", \"\").upper().replace(\" \", \"\")\n if pname:\n pin_nets[pname] = net_name\n break\n\n # Find reset output pin\n reset_net = None\n reset_active_low = True\n for pname, net in pin_nets.items():\n if pname in _RESET_OUTPUT_PINS:\n reset_net = net\n # Active-low if pin name has overbar or starts with N\n reset_active_low = (\"~{\" in pname or pname.startswith(\"N\")\n or pname.startswith(\"RESET\") and \"N\" not in pname)\n # Correction: plain \"RST\" or \"RESET\" is ambiguous — check for overbar\n if pname in (\"RST\", \"RESET\", \"RESETOUT\", \"RESET_OUT\", \"RST_OUT\", \"RSTOUT\"):\n reset_active_low = True # most supervisors are active-low\n break\n\n # Find target IC\n target_ic = _find_target_ic_on_reset_net(ctx, reset_net, ref) if reset_net else None\n\n if is_supervisor:\n # Find monitored rail (VIN/SENSE pin)\n monitored_rail = None\n for pname, net in pin_nets.items():\n if pname in _SUPERVISOR_VIN_PINS and ctx.is_power_net(net):\n monitored_rail = net\n break\n\n # Infer threshold voltage from part number\n threshold_v, _ = lookup_regulator_vref(comp.get(\"value\", \"\"), comp.get(\"lib_id\", \"\"))\n\n sup_comps = [ref] + ([target_ic] if target_ic else [])\n results.append({\n \"ref\": ref,\n \"value\": comp.get(\"value\", \"\"),\n \"type\": \"voltage_supervisor\",\n \"monitored_rail\": monitored_rail,\n \"threshold_voltage\": threshold_v,\n \"reset_net\": reset_net,\n \"target_ic\": target_ic,\n \"reset_active_low\": reset_active_low,\n \"detector\": \"detect_reset_supervisors\",\n \"rule_id\": \"RS-DET\",\n \"category\": \"power_management\",\n \"severity\": \"info\",\n \"confidence\": \"deterministic\",\n \"evidence_source\": \"topology\",\n \"summary\": f\"Voltage supervisor {ref} ({comp.get('value', '')})\" + (f\" monitoring {monitored_rail}\" if monitored_rail else \"\"),\n \"description\": f\"Detected voltage supervisor IC {ref}.\",\n \"components\": sup_comps,\n \"nets\": [n for n in (monitored_rail, reset_net) if n],\n \"pins\": [],\n \"recommendation\": \"\",\n \"report_context\": {\"section\": \"Power Management\", \"impact\": \"\", \"standard_ref\": \"\"},\n \"provenance\": make_provenance(\"reset_supervisor_ic\", \"deterministic\", claimed_components=[ref]),\n })\n\n elif is_watchdog:\n # Find WDI pin\n wdi_net = None\n for pname, net in pin_nets.items():\n if pname in _WDI_PINS:\n wdi_net = net\n break\n\n wd_comps = [ref] + ([target_ic] if target_ic else [])\n results.append({\n \"ref\": ref,\n \"value\": comp.get(\"value\", \"\"),\n \"type\": \"watchdog\",\n \"wdi_net\": wdi_net,\n \"reset_net\": reset_net,\n \"target_ic\": target_ic,\n \"detector\": \"detect_reset_supervisors\",\n \"rule_id\": \"RS-DET\",\n \"category\": \"power_management\",\n \"severity\": \"info\",\n \"confidence\": \"deterministic\",\n \"evidence_source\": \"topology\",\n \"summary\": f\"Watchdog {ref} ({comp.get('value', '')})\",\n \"description\": f\"Detected watchdog IC {ref}.\",\n \"components\": wd_comps,\n \"nets\": [n for n in (wdi_net, reset_net) if n],\n \"pins\": [],\n \"recommendation\": \"\",\n \"report_context\": {\"section\": \"Power Management\", \"impact\": \"\", \"standard_ref\": \"\"},\n \"provenance\": make_provenance(\"reset_supervisor_ic\", \"deterministic\", claimed_components=[ref]),\n })\n\n # Phase 2: Detect RC reset networks\n # Find nets that look like reset signals and have both a resistor and capacitor\n reset_nets_seen: set[str] = set()\n for net_name, net_info in ctx.nets.items():\n if not _is_reset_net_name(net_name):\n continue\n if net_name in reset_nets_seen:\n continue\n\n resistors: list[dict] = []\n capacitors: list[dict] = []\n target_ic = None\n\n for p in net_info[\"pins\"]:\n comp = ctx.comp_lookup.get(p[\"component\"])\n if not comp:\n continue\n if comp[\"type\"] == \"resistor\":\n r_val = parse_value(comp.get(\"value\", \"\"))\n if r_val and r_val >= 1000: # at least 1k for reset RC\n resistors.append({\"ref\": comp[\"reference\"], \"ohms\": r_val})\n elif comp[\"type\"] == \"capacitor\":\n c_val = parse_value(comp.get(\"value\", \"\"), component_type=\"capacitor\")\n if c_val and c_val >= 1e-9: # at least 1nF\n capacitors.append({\"ref\": comp[\"reference\"], \"farads\": c_val})\n elif comp[\"type\"] == \"ic\":\n pname = p.get(\"pin_name\", \"\").upper().replace(\" \", \"\")\n if pname in _RESET_INPUT_PINS:\n target_ic = p[\"component\"]\n\n if resistors and capacitors and target_ic:\n reset_nets_seen.add(net_name)\n r = resistors[0]\n c = capacitors[0]\n tau_s = r[\"ohms\"] * c[\"farads\"]\n results.append({\n \"ref_r\": r[\"ref\"],\n \"ref_c\": c[\"ref\"],\n \"type\": \"rc_reset\",\n \"reset_net\": net_name,\n \"time_constant_ms\": round(tau_s * 1000, 3),\n \"target_ic\": target_ic,\n \"detector\": \"detect_reset_supervisors\",\n \"rule_id\": \"RS-DET\",\n \"category\": \"power_management\",\n \"severity\": \"info\",\n \"confidence\": \"deterministic\",\n \"evidence_source\": \"topology\",\n \"summary\": f\"RC reset network on {net_name} ({round(tau_s * 1000, 3)} ms)\",\n \"description\": f\"Detected RC reset network on net {net_name}.\",\n \"components\": [r[\"ref\"], c[\"ref\"]] + ([target_ic] if target_ic else []),\n \"nets\": [net_name],\n \"pins\": [],\n \"recommendation\": \"\",\n \"report_context\": {\"section\": \"Power Management\", \"impact\": \"\", \"standard_ref\": \"\"},\n \"provenance\": make_provenance(\"reset_supervisor_ic\", \"deterministic\", claimed_components=[r[\"ref\"], c[\"ref\"]]),\n })\n\n return results\n\n\n# ---------------------------------------------------------------------------\n# Clock Distribution\n# ---------------------------------------------------------------------------\n\n_CLOCK_BUFFER_KEYWORDS = (\n \"si535\", \"si534\", \"si533\", \"cdce9\", \"cdcel9\", \"cdcvf\",\n \"sit9\", \"cy22\", \"cy23\", \"lmk0\", \"ics55\",\n)\n\n_PLL_KEYWORDS = (\n \"adf43\", \"adf45\", \"max287\", \"si544\", \"si546\",\n \"lmx25\", \"hmc83\",\n)\n\n_CLOCK_INPUT_PINS = {\"CLKIN\", \"CLK_IN\", \"XCLK\", \"MCLK\", \"SCLK\", \"BCLK\",\n \"FCLK\", \"REFCLK\", \"CLK\", \"CKIN\", \"XA\", \"XI\", \"XTAL_IN\",\n \"XTAL1\", \"OSC_IN\", \"OSCI\"}\n_CLOCK_OUTPUT_PINS = {\"CLKOUT\", \"CLK_OUT\", \"CLK0\", \"CLK1\", \"CLK2\", \"CLK3\",\n \"CLK4\", \"CLK5\", \"CLK6\", \"CLK7\", \"FOUT\", \"MCLK_OUT\",\n \"OUT0\", \"OUT1\", \"OUT2\", \"OUT3\", \"XB\", \"XO\", \"XTAL_OUT\",\n \"XTAL2\", \"OSC_OUT\", \"OSCO\"}\n\n\ndef _find_series_termination(ctx: AnalysisContext, net_name: str,\n source_ref: str) -> dict | None:\n \"\"\"Check for series termination resistor (22-100Ω) on a clock net.\"\"\"\n if net_name not in ctx.nets:\n return None\n for p in ctx.nets[net_name][\"pins\"]:\n comp = ctx.comp_lookup.get(p[\"component\"])\n if not comp or comp[\"type\"] != \"resistor\" or p[\"component\"] == source_ref:\n continue\n r_val = parse_value(comp.get(\"value\", \"\"))\n if r_val and 22 \u003c= r_val \u003c= 100:\n return {\"ref\": comp[\"reference\"], \"ohms\": r_val}\n return None\n\n\ndef _trace_clock_consumers(ctx: AnalysisContext, net_name: str,\n source_ref: str) -> list[str]:\n \"\"\"Find IC consumers on a clock net, excluding the source.\n\n When ctx.nq is available, traces through clock buffers to find\n downstream consumers (e.g., SI5351 → MCU, FPGA).\n \"\"\"\n consumers: list[str] = []\n if net_name not in ctx.nets:\n return consumers\n seen: set[str] = {source_ref}\n for p in ctx.nets[net_name][\"pins\"]:\n ref = p[\"component\"]\n if ref in seen:\n continue\n comp = ctx.comp_lookup.get(ref)\n if not comp or comp[\"type\"] != \"ic\":\n continue\n seen.add(ref)\n # Check if this IC is a clock buffer — trace through to outputs\n if ctx.nq:\n val_lib = (comp.get(\"value\", \"\") + \" \" + comp.get(\"lib_id\", \"\")).lower()\n if any(kw in val_lib for kw in _CLOCK_BUFFER_KEYWORDS):\n for out_net in ctx.nq.trace_through(net_name, ref):\n if ctx.is_ground(out_net) or ctx.is_power_net(out_net):\n continue\n for downstream in ctx.nq.ics_on_net(out_net, exclude_ref=ref):\n dref = downstream[\"reference\"]\n if dref not in seen:\n seen.add(dref)\n consumers.append(dref)\n continue\n consumers.append(ref)\n return consumers\n\n\ndef detect_clock_distribution(ctx: AnalysisContext,\n crystal_circuits: list[dict]) -> list[dict]:\n \"\"\"Detect clock buffer/PLL ICs and trace oscillator outputs to consumers.\"\"\"\n results: list[dict] = []\n matched_refs: set[str] = set()\n\n for comp in ctx.components:\n if comp[\"type\"] != \"ic\":\n continue\n ref = comp[\"reference\"]\n if ref in matched_refs:\n continue\n val = comp.get(\"value\", \"\").lower()\n lib = comp.get(\"lib_id\", \"\").lower()\n combined = val + \" \" + lib\n\n is_buffer = any(kw in combined for kw in _CLOCK_BUFFER_KEYWORDS)\n is_pll = any(kw in combined for kw in _PLL_KEYWORDS)\n if not is_buffer and not is_pll:\n continue\n matched_refs.add(ref)\n\n # Map pin names to nets\n pin_nets: dict[str, str] = {}\n for pin_num, (net_name, _) in ctx.ref_pins.get(ref, {}).items():\n if not net_name or net_name not in ctx.nets:\n continue\n for p in ctx.nets[net_name][\"pins\"]:\n if p[\"component\"] == ref and p[\"pin_number\"] == pin_num:\n pname = p.get(\"pin_name\", \"\").upper().replace(\" \", \"\")\n if pname:\n pin_nets[pname] = net_name\n break\n\n # Find reference/clock input\n ref_input = None\n ref_source = None\n for pname, net in pin_nets.items():\n if pname in _CLOCK_INPUT_PINS:\n ref_input = {\"net\": net}\n # Trace to find source (crystal or oscillator)\n if net in ctx.nets:\n for p in ctx.nets[net][\"pins\"]:\n if p[\"component\"] == ref:\n continue\n src = ctx.comp_lookup.get(p[\"component\"])\n if src and src[\"type\"] in (\"crystal\", \"oscillator\"):\n ref_source = p[\"component\"]\n break\n if ref_source:\n ref_input[\"source\"] = ref_source\n break\n\n # Find clock outputs\n outputs: list[dict] = []\n for pname, net in sorted(pin_nets.items()):\n if pname in _CLOCK_OUTPUT_PINS:\n consumers = _trace_clock_consumers(ctx, net, ref)\n term = _find_series_termination(ctx, net, ref)\n outputs.append({\n \"pin\": pname,\n \"net\": net,\n \"consumers\": consumers,\n \"series_termination\": term,\n })\n\n device_type = \"clock_generator\" if is_buffer else \"pll\"\n entry: dict = {\n \"ref\": ref,\n \"value\": comp.get(\"value\", \"\"),\n \"type\": device_type,\n \"detector\": \"detect_clock_distribution\",\n \"rule_id\": \"CD-DET\",\n \"category\": \"timing\",\n \"severity\": \"info\",\n \"confidence\": \"deterministic\",\n \"evidence_source\": \"topology\",\n \"summary\": f\"Clock {device_type} {ref} ({comp.get('value', '')})\",\n \"description\": f\"Detected {device_type} IC {ref}.\",\n \"components\": [ref],\n \"nets\": [],\n \"pins\": [],\n \"recommendation\": \"\",\n \"report_context\": {\"section\": \"Timing\", \"impact\": \"\", \"standard_ref\": \"\"},\n }\n if ref_input:\n entry[\"reference_input\"] = ref_input\n if outputs:\n entry[\"outputs\"] = outputs\n\n entry[\"provenance\"] = make_provenance(\"clock_buffer_topology\", \"deterministic\", claimed_components=[ref])\n results.append(entry)\n\n # Phase 2: Trace standalone oscillator outputs from crystal_circuits\n for xc in crystal_circuits:\n # Active oscillators have an output net but crystal_circuits doesn't trace consumers\n if xc.get(\"type\") != \"active_oscillator\":\n continue\n osc_ref = xc.get(\"reference\")\n if not osc_ref or osc_ref in matched_refs:\n continue\n\n output_net = xc.get(\"output_net\")\n if not output_net:\n continue\n\n consumers = _trace_clock_consumers(ctx, output_net, osc_ref)\n if not consumers:\n continue\n\n term = _find_series_termination(ctx, output_net, osc_ref)\n results.append({\n \"ref\": osc_ref,\n \"value\": xc.get(\"value\", \"\"),\n \"type\": \"oscillator_output\",\n \"frequency_hz\": xc.get(\"frequency_hz\"),\n \"output_net\": output_net,\n \"consumers\": consumers,\n \"series_termination\": term,\n \"detector\": \"detect_clock_distribution\",\n \"rule_id\": \"CD-DET\",\n \"category\": \"timing\",\n \"severity\": \"info\",\n \"confidence\": \"deterministic\",\n \"evidence_source\": \"topology\",\n \"summary\": f\"Oscillator output {osc_ref} ({xc.get('value', '')})\" + (f\" {xc.get('frequency_hz', '')/1e6:.3g} MHz\" if xc.get(\"frequency_hz\") else \"\"),\n \"description\": f\"Detected active oscillator {osc_ref} driving clock output.\",\n \"components\": [osc_ref] + consumers,\n \"nets\": [output_net] if output_net else [],\n \"pins\": [],\n \"recommendation\": \"\",\n \"report_context\": {\"section\": \"Timing\", \"impact\": \"\", \"standard_ref\": \"\"},\n \"provenance\": make_provenance(\"clock_buffer_topology\", \"deterministic\", claimed_components=[osc_ref]),\n })\n\n return results\n\n\n# ---------------------------------------------------------------------------\n# Shared helper: build pin name → net map for an IC\n# ---------------------------------------------------------------------------\n\ndef _build_pin_net_map(ctx: AnalysisContext, ref: str) -> dict[str, str]:\n \"\"\"Build pin_name_upper → net_name map for a component.\"\"\"\n pin_nets: dict[str, str] = {}\n for pin_num, (net_name, _) in ctx.ref_pins.get(ref, {}).items():\n if not net_name or net_name not in ctx.nets:\n continue\n for p in ctx.nets[net_name][\"pins\"]:\n if p[\"component\"] == ref and p[\"pin_number\"] == pin_num:\n pname = p.get(\"pin_name\", \"\").upper().replace(\" \", \"\")\n if pname:\n pin_nets[pname] = net_name\n break\n return pin_nets\n\n\n# ---------------------------------------------------------------------------\n# Display / Touch Interface Detection\n# ---------------------------------------------------------------------------\n\n_DISPLAY_IC_KEYWORDS = (\n \"ssd1306\", \"ssd1309\", \"ssd1327\", \"ssd1351\",\n \"st7735\", \"st7789\", \"st7796\", \"st7920\",\n \"ili9341\", \"ili9488\", \"ili9486\", \"ili9325\",\n \"hx8357\", \"uc1701\", \"sh1106\", \"sh1107\",\n \"nt35\", \"gc9a01\", \"rm68140\",\n \"ssd1681\", \"il0373\", \"gdew\",\n)\n\n_TOUCH_IC_KEYWORDS = (\n \"ft6236\", \"ft6336\", \"ft5436\", \"ft5x06\",\n \"gt911\", \"gt928\", \"gt9xx\",\n \"xpt2046\", \"tsc2046\", \"ads7843\",\n \"cst816\", \"cst328\",\n \"stmpe811\", \"stmpe610\",\n \"cap1188\", \"mpr121\",\n)\n\n_DISPLAY_CONTROL_PINS = {\"DC\", \"D/C\", \"A0\", \"RS\", \"CMD\"}\n_BACKLIGHT_PINS = {\"BL\", \"LED\", \"LEDA\", \"BACKLIGHT\", \"BLK\"}\n_DISPLAY_RESET_PINS = {\"RES\", \"RST\", \"RESET\", \"NRST\"}\n\n# Display type inference from IC keyword\n_OLED_KEYWORDS = (\"ssd1306\", \"ssd1309\", \"ssd1327\", \"ssd1351\", \"sh1106\", \"sh1107\")\n_EPAPER_KEYWORDS = (\"ssd1681\", \"il0373\", \"gdew\")\n\n\ndef detect_display_interfaces(ctx: AnalysisContext) -> list[dict]:\n \"\"\"Detect display controller and touch controller ICs.\"\"\"\n results: list[dict] = []\n matched_refs: set[str] = set()\n\n for comp in ctx.components:\n if comp[\"type\"] != \"ic\":\n continue\n ref = comp[\"reference\"]\n if ref in matched_refs:\n continue\n val = comp.get(\"value\", \"\").lower()\n lib = comp.get(\"lib_id\", \"\").lower()\n combined = val + \" \" + lib\n\n is_display = any(kw in combined for kw in _DISPLAY_IC_KEYWORDS)\n is_touch = any(kw in combined for kw in _TOUCH_IC_KEYWORDS)\n if not is_display and not is_touch:\n continue\n matched_refs.add(ref)\n\n pin_nets = _build_pin_net_map(ctx, ref)\n all_pins = set(pin_nets.keys())\n\n # Infer interface\n interface = None\n if all_pins & {\"SCK\", \"SCLK\", \"CLK\", \"MOSI\", \"SDI\", \"SDA\"} and all_pins & _DISPLAY_CONTROL_PINS:\n interface = \"spi\"\n elif all_pins & {\"SCK\", \"SCLK\", \"CLK\", \"MOSI\", \"SDI\", \"DIN\"}:\n interface = \"spi\"\n elif all_pins & {\"SDA\", \"SCL\"}:\n interface = \"i2c\"\n elif all_pins & {\"D0\", \"D1\", \"D2\", \"D3\"}:\n interface = \"parallel\"\n\n if is_display:\n # Classify display type\n display_type = \"lcd\"\n if any(kw in combined for kw in _OLED_KEYWORDS):\n display_type = \"oled\"\n elif any(kw in combined for kw in _EPAPER_KEYWORDS):\n display_type = \"e-paper\"\n\n # Find control pins\n dc_net = None\n for pname in _DISPLAY_CONTROL_PINS:\n if pname in pin_nets:\n dc_net = pin_nets[pname]\n break\n\n reset_net = None\n for pname in _DISPLAY_RESET_PINS:\n if pname in pin_nets:\n reset_net = pin_nets[pname]\n break\n\n # Find backlight\n backlight = None\n for pname in _BACKLIGHT_PINS:\n if pname in pin_nets:\n bl_net = pin_nets[pname]\n backlight = {\"pin\": pname, \"net\": bl_net}\n # Check if a resistor or driver is on the BL net\n if bl_net in ctx.nets:\n for p in ctx.nets[bl_net][\"pins\"]:\n if p[\"component\"] == ref:\n continue\n rc = ctx.comp_lookup.get(p[\"component\"])\n if rc and rc[\"type\"] == \"resistor\":\n backlight[\"resistor\"] = p[\"component\"]\n elif rc and rc[\"type\"] == \"ic\":\n backlight[\"driver_ic\"] = p[\"component\"]\n break\n\n results.append({\n \"ref\": ref,\n \"value\": comp.get(\"value\", \"\"),\n \"type\": \"display\",\n \"display_type\": display_type,\n \"interface\": interface,\n \"dc_pin_net\": dc_net,\n \"reset_net\": reset_net,\n \"backlight\": backlight,\n \"detector\": \"detect_display_interfaces\",\n \"rule_id\": \"DP-DET\",\n \"category\": \"display\",\n \"severity\": \"info\",\n \"confidence\": \"deterministic\",\n \"evidence_source\": \"topology\",\n \"summary\": f\"Display {ref} ({comp.get('value', '')}) [{display_type}/{interface}]\",\n \"description\": f\"Detected {display_type} display controller IC {ref}.\",\n \"components\": [ref],\n \"nets\": [n for n in (dc_net, reset_net) if n],\n \"pins\": [],\n \"recommendation\": \"\",\n \"report_context\": {\"section\": \"Display\", \"impact\": \"\", \"standard_ref\": \"\"},\n \"provenance\": make_provenance(\"display_controller\", \"deterministic\", claimed_components=[ref]),\n })\n\n elif is_touch:\n # Find interrupt pin\n interrupt_net = None\n interrupt_connected = False\n for pname in (\"INT\", \"IRQ\", \"PENIRQ\", \"nINT\", \"ALERT\"):\n if pname in pin_nets:\n interrupt_net = pin_nets[pname]\n # Check if connected to an IC\n if interrupt_net in ctx.nets:\n for p in ctx.nets[interrupt_net][\"pins\"]:\n if p[\"component\"] == ref:\n continue\n ic = ctx.comp_lookup.get(p[\"component\"])\n if ic and ic[\"type\"] == \"ic\":\n interrupt_connected = True\n break\n break\n\n reset_net = None\n for pname in _DISPLAY_RESET_PINS:\n if pname in pin_nets:\n reset_net = pin_nets[pname]\n break\n\n results.append({\n \"ref\": ref,\n \"value\": comp.get(\"value\", \"\"),\n \"type\": \"touch_controller\",\n \"interface\": interface,\n \"interrupt_net\": interrupt_net,\n \"interrupt_connected\": interrupt_connected,\n \"reset_net\": reset_net,\n \"detector\": \"detect_display_interfaces\",\n \"rule_id\": \"DP-DET\",\n \"category\": \"display\",\n \"severity\": \"info\",\n \"confidence\": \"deterministic\",\n \"evidence_source\": \"topology\",\n \"summary\": f\"Touch controller {ref} ({comp.get('value', '')}) [{interface}]\",\n \"description\": f\"Detected touch controller IC {ref}.\",\n \"components\": [ref],\n \"nets\": [n for n in (interrupt_net, reset_net) if n],\n \"pins\": [],\n \"recommendation\": \"\",\n \"report_context\": {\"section\": \"Display\", \"impact\": \"\", \"standard_ref\": \"\"},\n \"provenance\": make_provenance(\"display_controller\", \"deterministic\", claimed_components=[ref]),\n })\n\n return results\n\n\n# ---------------------------------------------------------------------------\n# Sensor Fusion Detection\n# ---------------------------------------------------------------------------\n\n_IMU_KEYWORDS = (\n \"mpu6\", \"mpu9\", \"icm20\", \"icm42\", \"lsm6\", \"lsm9\",\n \"bno0\", \"bmi1\", \"bmi2\", \"bmi3\",\n \"lis2\", \"lis3\", \"lsm3\", \"adxl3\",\n \"kxtj3\", \"mc3419\", \"mma845\",\n)\n_ENV_SENSOR_KEYWORDS = (\n \"bme28\", \"bme68\", \"bmp28\", \"bmp39\", \"bmp58\",\n \"sht3\", \"sht4\", \"hdc10\", \"hdc20\", \"si70\", \"aht\",\n \"lps22\", \"lps25\", \"ms56\", \"dps310\",\n)\n_MAG_KEYWORDS = (\n \"hmc58\", \"qmc58\", \"lis3m\", \"lis2m\", \"mmc56\",\n \"ak8963\", \"ak0991\", \"bmm150\", \"rm3100\",\n)\n\n_SENSOR_INT_PINS = {\"INT\", \"INT1\", \"INT2\", \"DRDY\", \"IRQ\", \"ALERT\", \"RDY\", \"BUSY\"}\n_SENSOR_SPI_PINS = {\"SCK\", \"SCLK\", \"CLK\", \"MOSI\", \"SDI\", \"MISO\", \"SDO\", \"CS\", \"CSN\", \"NCS\"}\n_SENSOR_I2C_PINS = {\"SDA\", \"SCL\"}\n\n\ndef detect_sensor_interfaces(ctx: AnalysisContext) -> list[dict]:\n \"\"\"Detect IMU, environmental, and magnetometer sensor ICs.\"\"\"\n results: list[dict] = []\n matched_refs: set[str] = set()\n\n # Collect all sensor entries with their bus nets for clustering\n sensor_bus_nets: dict[str, list[str]] = {} # ref -> list of bus net names\n\n for comp in ctx.components:\n if comp[\"type\"] != \"ic\":\n continue\n ref = comp[\"reference\"]\n if ref in matched_refs:\n continue\n val = comp.get(\"value\", \"\").lower()\n lib = comp.get(\"lib_id\", \"\").lower()\n combined = val + \" \" + lib\n\n # Classify sensor type\n sensor_type = None\n if any(kw in combined for kw in _IMU_KEYWORDS):\n sensor_type = \"motion\"\n elif any(kw in combined for kw in _ENV_SENSOR_KEYWORDS):\n sensor_type = \"environmental\"\n elif any(kw in combined for kw in _MAG_KEYWORDS):\n sensor_type = \"magnetic\"\n\n if not sensor_type:\n continue\n matched_refs.add(ref)\n\n pin_nets = _build_pin_net_map(ctx, ref)\n all_pins = set(pin_nets.keys())\n\n # Infer interface\n interface = None\n bus_nets: list[str] = []\n if all_pins & _SENSOR_SPI_PINS:\n interface = \"spi\"\n for pname in (\"SCK\", \"SCLK\", \"CLK\", \"MOSI\", \"SDI\", \"MISO\", \"SDO\"):\n if pname in pin_nets:\n bus_nets.append(pin_nets[pname])\n if all_pins & _SENSOR_I2C_PINS:\n # SPI takes precedence if CS pin present, else I2C\n if not (interface == \"spi\" and all_pins & {\"CS\", \"CSN\", \"NCS\"}):\n interface = \"i2c\"\n bus_nets = []\n for pname in (\"SDA\", \"SCL\"):\n if pname in pin_nets:\n bus_nets.append(pin_nets[pname])\n\n sensor_bus_nets[ref] = bus_nets\n\n # Check interrupt pins\n interrupt_pins: list[dict] = []\n for pname in sorted(all_pins & _SENSOR_INT_PINS):\n net = pin_nets[pname]\n connected_to = None\n if net in ctx.nets:\n for p in ctx.nets[net][\"pins\"]:\n if p[\"component\"] == ref:\n continue\n ic = ctx.comp_lookup.get(p[\"component\"])\n if ic and ic[\"type\"] == \"ic\":\n connected_to = p[\"component\"]\n break\n interrupt_pins.append({\n \"pin\": pname,\n \"net\": net,\n \"connected_to_ic\": connected_to,\n })\n\n results.append({\n \"ref\": ref,\n \"value\": comp.get(\"value\", \"\"),\n \"type\": sensor_type,\n \"interface\": interface,\n \"interrupt_pins\": interrupt_pins,\n \"bus_peers\": [], # filled in clustering pass\n \"detector\": \"detect_sensor_interfaces\",\n \"rule_id\": \"SI-DET\",\n \"category\": \"sensors\",\n \"severity\": \"info\",\n \"confidence\": \"deterministic\",\n \"evidence_source\": \"topology\",\n \"summary\": f\"Sensor {ref} ({comp.get('value', '')}) [{sensor_type}/{interface}]\",\n \"description\": f\"Detected {sensor_type} sensor IC {ref}.\",\n \"components\": [ref],\n \"nets\": bus_nets,\n \"pins\": [],\n \"recommendation\": \"\",\n \"report_context\": {\"section\": \"Sensors\", \"impact\": \"\", \"standard_ref\": \"\"},\n \"provenance\": make_provenance(\"sensor_ic_topology\", \"heuristic\", claimed_components=[ref]),\n })\n\n # Clustering pass: find sensors sharing bus nets\n if len(results) >= 2:\n for i, entry in enumerate(results):\n ref = entry[\"ref\"]\n my_nets = set(sensor_bus_nets.get(ref, []))\n if not my_nets:\n continue\n peers = []\n for j, other in enumerate(results):\n if i == j:\n continue\n other_nets = set(sensor_bus_nets.get(other[\"ref\"], []))\n if my_nets & other_nets:\n peers.append(other[\"ref\"])\n entry[\"bus_peers\"] = peers\n\n return results\n\n\n# ---------------------------------------------------------------------------\n# Level Shifter Detection\n# ---------------------------------------------------------------------------\n\n_LEVEL_SHIFTER_KEYWORDS = (\n \"txb0\", \"txs0\", \"tca9\", \"lsf0\", \"sn74lvc\", \"sn74avc\",\n \"sn74cb3\", \"sn74cbt\", \"nlsx\", \"nts0\", \"fxl\", \"adg320\",\n \"max395\", \"gtl2\", \"pca960\", \"tca641\", \"fxma\", \"txu0\",\n)\n\n_VCCA_PINS = {\"VCCA\", \"VA\", \"VCC_A\", \"VREF1\", \"VREF_A\", \"VIN_A\", \"VDDA\"}\n_VCCB_PINS = {\"VCCB\", \"VB\", \"VCC_B\", \"VREF2\", \"VREF_B\", \"VIN_B\", \"VDDB\"}\n_LS_ENABLE_PINS = {\"OE\", \"EN\", \"ENABLE\", \"DIR\"}\n\n# Bidirectional vs unidirectional inference\n_BIDIR_KEYWORDS = (\"txb0\", \"gtl2\", \"lsf0\", \"fxma\", \"nlsx\", \"sn74cb3\", \"sn74cbt\")\n_UNIDIR_KEYWORDS = (\"txs0\", \"sn74lvc\", \"sn74avc\", \"txu0\")\n\n\ndef _infer_voltage(ctx: AnalysisContext, net_name: str | None) -> float | None:\n \"\"\"Try to infer voltage from a power net name.\"\"\"\n if not net_name:\n return None\n from kicad_utils import parse_voltage_from_net_name\n return parse_voltage_from_net_name(net_name)\n\n\ndef detect_level_shifters(ctx: AnalysisContext) -> list[dict]:\n \"\"\"Detect level shifter ICs and discrete BSS138-based shifters.\"\"\"\n results: list[dict] = []\n matched_refs: set[str] = set()\n\n # Phase 1: IC-based level shifters\n for comp in ctx.components:\n if comp[\"type\"] != \"ic\":\n continue\n ref = comp[\"reference\"]\n if ref in matched_refs:\n continue\n val = comp.get(\"value\", \"\").lower()\n lib = comp.get(\"lib_id\", \"\").lower()\n combined = val + \" \" + lib\n\n if not any(kw in combined for kw in _LEVEL_SHIFTER_KEYWORDS):\n continue\n\n # KH-227: Exclude pure logic gates (not level shifters)\n _val_lib = (comp.get(\"value\", \"\") + \" \" + comp.get(\"lib_id\", \"\")).lower()\n _logic_gate_patterns = (\"1g00\", \"1g02\", \"1g04\", \"1g08\", \"1g14\", \"1g32\",\n \"1g86\", \"2g00\", \"2g02\", \"2g04\", \"2g08\", \"2g14\",\n \"2g32\", \"2g86\", \"3g14\",\n \"inverter\", \"nand_gate\", \"nor_gate\", \"and_gate\",\n \"or_gate\", \"xor_gate\", \"schmitt\")\n if any(g in _val_lib for g in _logic_gate_patterns):\n continue\n\n matched_refs.add(ref)\n\n pin_nets = _build_pin_net_map(ctx, ref)\n all_pins = set(pin_nets.keys())\n\n # Find supply pins\n vcca_net = None\n vccb_net = None\n for pname in _VCCA_PINS:\n if pname in pin_nets:\n vcca_net = pin_nets[pname]\n break\n for pname in _VCCB_PINS:\n if pname in pin_nets:\n vccb_net = pin_nets[pname]\n break\n\n # Fallback: if no VCCA/VCCB found, look for VCC pins\n if not vcca_net and not vccb_net:\n vcc_nets = []\n for pname, net in pin_nets.items():\n if pname.startswith(\"VCC\") or pname.startswith(\"VDD\"):\n if ctx.is_power_net(net) and not ctx.is_ground(net):\n vcc_nets.append(net)\n if len(vcc_nets) >= 2:\n vcca_net = vcc_nets[0]\n vccb_net = vcc_nets[1]\n elif len(vcc_nets) == 1:\n vcca_net = vcc_nets[0]\n\n # Find signal nets (non-power, non-ground, non-enable)\n shifted_nets: list[str] = []\n for pname, net in pin_nets.items():\n if pname in _VCCA_PINS | _VCCB_PINS | _LS_ENABLE_PINS:\n continue\n if ctx.is_ground(net) or ctx.is_power_net(net):\n continue\n if net not in shifted_nets:\n shifted_nets.append(net)\n\n # Infer direction\n direction = \"bidirectional\"\n if any(kw in combined for kw in _UNIDIR_KEYWORDS):\n direction = \"unidirectional\"\n elif any(kw in combined for kw in _BIDIR_KEYWORDS):\n direction = \"bidirectional\"\n\n side_a: dict = {\"supply_net\": vcca_net}\n side_b: dict = {\"supply_net\": vccb_net}\n va = _infer_voltage(ctx, vcca_net)\n vb = _infer_voltage(ctx, vccb_net)\n if va:\n side_a[\"voltage\"] = va\n if vb:\n side_b[\"voltage\"] = vb\n\n results.append({\n \"ref\": ref,\n \"value\": comp.get(\"value\", \"\"),\n \"type\": \"level_shifter_ic\",\n \"direction\": direction,\n \"side_a\": side_a,\n \"side_b\": side_b,\n \"shifted_nets\": sorted(shifted_nets),\n \"channel_count\": len(shifted_nets) // 2 or len(shifted_nets),\n \"detector\": \"detect_level_shifters\",\n \"rule_id\": \"LS-DET\",\n \"category\": \"signal_integrity\",\n \"severity\": \"info\",\n \"confidence\": \"deterministic\",\n \"evidence_source\": \"topology\",\n \"summary\": f\"Level shifter {ref} ({comp.get('value', '')}) [{direction}]\",\n \"description\": f\"Detected {direction} level shifter IC {ref}.\",\n \"components\": [ref],\n \"nets\": sorted(shifted_nets),\n \"pins\": [],\n \"recommendation\": \"\",\n \"report_context\": {\"section\": \"Signal Integrity\", \"impact\": \"\", \"standard_ref\": \"\"},\n \"provenance\": make_provenance(\"levelshift_topology\", \"deterministic\", claimed_components=[ref]),\n })\n\n # Phase 2: Discrete BSS138 level shifters\n # Pattern: N-channel MOSFET with gate to low-voltage rail,\n # drain and source each with pull-up resistors to different voltage rails\n for comp in ctx.components:\n if comp[\"type\"] != \"transistor\":\n continue\n ref = comp[\"reference\"]\n if ref in matched_refs:\n continue\n val = comp.get(\"value\", \"\").lower()\n lib = comp.get(\"lib_id\", \"\").lower()\n combined = val + \" \" + lib\n\n # Only look for common discrete shifter MOSFETs\n if not any(kw in combined for kw in (\"bss138\", \"2n7002\", \"bss84\")):\n continue\n\n pin_nets = _build_pin_net_map(ctx, ref)\n\n gate_net = pin_nets.get(\"G\") or pin_nets.get(\"GATE\")\n drain_net = pin_nets.get(\"D\") or pin_nets.get(\"DRAIN\")\n source_net = pin_nets.get(\"S\") or pin_nets.get(\"SOURCE\")\n\n if not gate_net or not drain_net or not source_net:\n continue\n\n # Gate should connect to a power rail (low-voltage side)\n if not ctx.is_power_net(gate_net):\n continue\n\n # Find pull-up resistors on drain and source nets\n def _find_pullup(net_name):\n if not net_name or net_name not in ctx.nets:\n return None\n for p in ctx.nets[net_name][\"pins\"]:\n if p[\"component\"] == ref:\n continue\n rc = ctx.comp_lookup.get(p[\"component\"])\n if not rc or rc[\"type\"] != \"resistor\":\n continue\n r_val = parse_value(rc.get(\"value\", \"\"))\n if r_val and 1000 \u003c= r_val \u003c= 100000: # 1k-100k typical pull-up\n # Check other side goes to a power rail\n rn1, _ = ctx.pin_net.get((rc[\"reference\"], \"1\"), (None, None))\n rn2, _ = ctx.pin_net.get((rc[\"reference\"], \"2\"), (None, None))\n other = rn2 if rn1 == net_name else rn1\n if ctx.is_power_net(other):\n return {\"ref\": rc[\"reference\"], \"supply_net\": other}\n return None\n\n drain_pullup = _find_pullup(drain_net)\n source_pullup = _find_pullup(source_net)\n\n if not drain_pullup or not source_pullup:\n continue\n # Must pull up to different rails for level shifting\n if drain_pullup[\"supply_net\"] == source_pullup[\"supply_net\"]:\n continue\n\n matched_refs.add(ref)\n\n # Determine which side is A (low) and B (high)\n va = _infer_voltage(ctx, source_pullup[\"supply_net\"])\n vb = _infer_voltage(ctx, drain_pullup[\"supply_net\"])\n if va and vb and va > vb:\n # Swap so side_a is lower voltage\n source_pullup, drain_pullup = drain_pullup, source_pullup\n source_net, drain_net = drain_net, source_net\n\n ls_sig_nets = sorted({drain_net, source_net} - {gate_net})\n results.append({\n \"ref\": ref,\n \"value\": comp.get(\"value\", \"\"),\n \"type\": \"discrete_level_shifter\",\n \"gate_net\": gate_net,\n \"side_a\": {\"pullup_ref\": source_pullup[\"ref\"], \"supply_net\": source_pullup[\"supply_net\"]},\n \"side_b\": {\"pullup_ref\": drain_pullup[\"ref\"], \"supply_net\": drain_pullup[\"supply_net\"]},\n \"signal_nets\": ls_sig_nets,\n \"detector\": \"detect_level_shifters\",\n \"rule_id\": \"LS-DET\",\n \"category\": \"signal_integrity\",\n \"severity\": \"info\",\n \"confidence\": \"deterministic\",\n \"evidence_source\": \"topology\",\n \"summary\": f\"Discrete level shifter {ref} ({comp.get('value', '')})\",\n \"description\": f\"Detected discrete BSS138-style level shifter using {ref}.\",\n \"components\": [ref, source_pullup[\"ref\"], drain_pullup[\"ref\"]],\n \"nets\": ls_sig_nets,\n \"pins\": [],\n \"recommendation\": \"\",\n \"report_context\": {\"section\": \"Signal Integrity\", \"impact\": \"\", \"standard_ref\": \"\"},\n \"provenance\": make_provenance(\"levelshift_topology\", \"deterministic\", claimed_components=[ref]),\n })\n\n return results\n\n\n# ---------------------------------------------------------------------------\n# Audio Circuit Detection\n# ---------------------------------------------------------------------------\n\n_AUDIO_AMP_KEYWORDS = (\n \"tpa31\", \"tpa32\", \"tpa62\", \"tpa60\",\n \"max983\", \"max984\",\n \"lm386\", \"lm48\",\n \"pam84\", \"pam83\",\n \"tas57\", \"tas21\", \"tas58\",\n \"tda7\", \"tda2\",\n \"ssm23\", \"ssm21\",\n \"sta3\",\n)\n\n_AUDIO_CODEC_KEYWORDS = (\n \"wm89\", \"wm87\",\n \"es83\", \"es81\",\n \"ak49\", \"ak45\",\n \"pcm51\", \"pcm17\", \"pcm29\", \"pcm30\",\n \"cs42\", \"cs43\", \"cs44\", \"cs47\",\n \"sgtl5\", \"tlv320\",\n \"nau88\", \"adau17\",\n)\n\n_I2S_PINS = {\"BCLK\", \"LRCK\", \"LRCLK\", \"WSEL\", \"WS\", \"SDIN\", \"SDOUT\",\n \"SDAT\", \"DIN\", \"DOUT\", \"DACDAT\", \"ADCDAT\", \"MCLK\"}\n_AUDIO_OUTPUT_PINS = {\"OUT+\", \"OUT-\", \"OUTP\", \"OUTN\", \"SPKR\", \"SPK+\", \"SPK-\",\n \"HP\", \"HPL\", \"HPR\", \"HPOUT\", \"LOUT\", \"ROUT\", \"LOUT1\",\n \"ROUT1\", \"LOUT2\", \"ROUT2\", \"LINEOUT\", \"SPKOUTP\", \"SPKOUTN\"}\n_AUDIO_INPUT_PINS = {\"IN+\", \"IN-\", \"INP\", \"INN\", \"LINEIN\", \"LIN\", \"RIN\",\n \"MIC\", \"MICIN\", \"MICP\", \"MICN\", \"LMICIN\", \"RMICIN\",\n \"LINPUT1\", \"RINPUT1\", \"LINPUT2\", \"RINPUT2\"}\n\n# Class-D amp keywords for amplifier class inference\n_CLASS_D_KEYWORDS = (\"tpa31\", \"tpa32\", \"max983\", \"tas57\", \"tas58\", \"sta3\", \"ssm23\")\n\n\ndef detect_audio_circuits(ctx: AnalysisContext) -> list[dict]:\n \"\"\"Detect audio amplifier and codec ICs.\"\"\"\n results: list[dict] = []\n matched_refs: set[str] = set()\n\n for comp in ctx.components:\n if comp[\"type\"] != \"ic\":\n continue\n ref = comp[\"reference\"]\n if ref in matched_refs:\n continue\n val = comp.get(\"value\", \"\").lower()\n lib = comp.get(\"lib_id\", \"\").lower()\n combined = val + \" \" + lib\n\n is_amp = any(kw in combined for kw in _AUDIO_AMP_KEYWORDS)\n is_codec = any(kw in combined for kw in _AUDIO_CODEC_KEYWORDS)\n if not is_amp and not is_codec:\n continue\n matched_refs.add(ref)\n\n pin_nets = _build_pin_net_map(ctx, ref)\n all_pins = set(pin_nets.keys())\n\n # Infer interface\n interface = None\n if all_pins & _I2S_PINS:\n interface = \"i2s\"\n elif all_pins & {\"SDA\", \"SCL\"}:\n interface = \"i2c\"\n else:\n interface = \"analog\"\n\n # Find output nets\n output_nets: list[str] = []\n for pname in sorted(all_pins & _AUDIO_OUTPUT_PINS):\n net = pin_nets[pname]\n if not ctx.is_ground(net) and not ctx.is_power_net(net):\n output_nets.append(net)\n\n # Trace output load\n output_load = None\n for net in output_nets:\n if net not in ctx.nets:\n continue\n for p in ctx.nets[net][\"pins\"]:\n if p[\"component\"] == ref:\n continue\n lc = ctx.comp_lookup.get(p[\"component\"])\n if not lc:\n continue\n if lc[\"type\"] in (\"speaker\", \"buzzer\"):\n output_load = \"speaker\"\n break\n if lc[\"type\"] == \"connector\":\n cv = (lc.get(\"value\", \"\") + \" \" + lc.get(\"lib_id\", \"\")).lower()\n if any(k in cv for k in (\"audio\", \"headphone\", \"hp\", \"jack\", \"phone\")):\n output_load = \"headphone\"\n else:\n output_load = \"connector\"\n break\n if output_load:\n break\n\n if is_amp:\n # Classify amplifier class\n amp_class = \"class_ab\"\n if any(kw in combined for kw in _CLASS_D_KEYWORDS):\n amp_class = \"class_d\"\n\n # Check for LC output filter (class-D amps)\n has_output_filter = False\n if amp_class == \"class_d\":\n for net in output_nets:\n if net not in ctx.nets:\n continue\n has_inductor = False\n has_cap = False\n for p in ctx.nets[net][\"pins\"]:\n lc = ctx.comp_lookup.get(p[\"component\"])\n if lc and lc[\"type\"] == \"inductor\":\n has_inductor = True\n elif lc and lc[\"type\"] == \"capacitor\":\n has_cap = True\n if has_inductor and has_cap:\n has_output_filter = True\n break\n\n entry: dict = {\n \"ref\": ref,\n \"value\": comp.get(\"value\", \"\"),\n \"type\": \"audio_amplifier\",\n \"amplifier_class\": amp_class,\n \"interface\": interface,\n \"output_nets\": output_nets,\n \"detector\": \"detect_audio_circuits\",\n \"rule_id\": \"AU-DET\",\n \"category\": \"audio\",\n \"severity\": \"info\",\n \"confidence\": \"deterministic\",\n \"evidence_source\": \"topology\",\n \"summary\": f\"Audio amplifier {ref} ({comp.get('value', '')}) [{amp_class}/{interface}]\",\n \"description\": f\"Detected {amp_class} audio amplifier IC {ref}.\",\n \"components\": [ref],\n \"nets\": output_nets,\n \"pins\": [],\n \"recommendation\": \"\",\n \"report_context\": {\"section\": \"Audio\", \"impact\": \"\", \"standard_ref\": \"\"},\n }\n if output_load:\n entry[\"output_load\"] = output_load\n if amp_class == \"class_d\":\n entry[\"has_output_filter\"] = has_output_filter\n entry[\"provenance\"] = make_provenance(\"audio_codec_topology\", \"deterministic\", claimed_components=[ref])\n results.append(entry)\n\n elif is_codec:\n has_adc = bool(all_pins & _AUDIO_INPUT_PINS)\n has_dac = bool(all_pins & _AUDIO_OUTPUT_PINS)\n\n entry = {\n \"ref\": ref,\n \"value\": comp.get(\"value\", \"\"),\n \"type\": \"audio_codec\",\n \"interface\": interface,\n \"has_adc\": has_adc,\n \"has_dac\": has_dac,\n \"output_nets\": output_nets,\n \"detector\": \"detect_audio_circuits\",\n \"rule_id\": \"AU-DET\",\n \"category\": \"audio\",\n \"severity\": \"info\",\n \"confidence\": \"deterministic\",\n \"evidence_source\": \"topology\",\n \"summary\": f\"Audio codec {ref} ({comp.get('value', '')}) [{interface}]\",\n \"description\": f\"Detected audio codec IC {ref}.\",\n \"components\": [ref],\n \"nets\": output_nets,\n \"pins\": [],\n \"recommendation\": \"\",\n \"report_context\": {\"section\": \"Audio\", \"impact\": \"\", \"standard_ref\": \"\"},\n }\n if output_load:\n entry[\"output_load\"] = output_load\n entry[\"provenance\"] = make_provenance(\"audio_codec_topology\", \"deterministic\", claimed_components=[ref])\n results.append(entry)\n\n return results\n\n\n# ---------------------------------------------------------------------------\n# LED Driver IC Detection\n# ---------------------------------------------------------------------------\n\n_LED_DRIVER_IC_KEYWORDS = (\n \"pca9685\", \"pca968\",\n \"tlc594\", \"tlc595\",\n \"is31fl\", \"is31\",\n \"lp556\", \"lp503\",\n \"al880\", \"al881\",\n \"cat410\", \"cat420\",\n \"bcr42\",\n \"ap303\", \"mp330\",\n \"tps611\",\n)\n\n_LED_PWM_KEYWORDS = (\"pca9685\", \"pca968\", \"tlc594\", \"tlc595\")\n_LED_MATRIX_KEYWORDS = (\"is31fl\", \"is31\")\n_LED_CC_KEYWORDS = (\"al880\", \"al881\", \"cat410\", \"cat420\", \"bcr42\", \"ap303\", \"mp330\", \"tps611\")\n_LED_RGB_KEYWORDS = (\"lp556\", \"lp503\")\n\n_LED_CURRENT_SET_PINS = {\"IREF\", \"REXT\", \"ISET\", \"RSET\", \"RIREF\"}\n_LED_OUTPUT_PREFIXES = (\"OUT\", \"LED\", \"CH\", \"PWM\", \"DRV\")\n\n\ndef detect_led_driver_ics(ctx: AnalysisContext) -> list[dict]:\n \"\"\"Detect dedicated LED driver ICs (PWM, matrix, constant-current).\"\"\"\n results: list[dict] = []\n matched_refs: set[str] = set()\n\n for comp in ctx.components:\n if comp[\"type\"] != \"ic\":\n continue\n ref = comp[\"reference\"]\n if ref in matched_refs:\n continue\n val = comp.get(\"value\", \"\").lower()\n lib = comp.get(\"lib_id\", \"\").lower()\n combined = val + \" \" + lib\n\n if not any(kw in combined for kw in _LED_DRIVER_IC_KEYWORDS):\n continue\n matched_refs.add(ref)\n\n pin_nets = _build_pin_net_map(ctx, ref)\n all_pins = set(pin_nets.keys())\n\n # Classify driver type\n if any(kw in combined for kw in _LED_PWM_KEYWORDS):\n driver_type = \"pwm_led_driver\"\n elif any(kw in combined for kw in _LED_MATRIX_KEYWORDS):\n driver_type = \"matrix_led_driver\"\n elif any(kw in combined for kw in _LED_CC_KEYWORDS):\n driver_type = \"constant_current_led_driver\"\n elif any(kw in combined for kw in _LED_RGB_KEYWORDS):\n driver_type = \"rgb_led_driver\"\n else:\n driver_type = \"led_driver\"\n\n # Infer interface\n interface = None\n if all_pins & {\"SDA\", \"SCL\"}:\n interface = \"i2c\"\n elif all_pins & {\"SCK\", \"SCLK\", \"CLK\", \"MOSI\", \"SDI\"}:\n interface = \"spi\"\n\n # Count output channels\n channels = 0\n for pname in all_pins:\n if any(pname.startswith(prefix) for prefix in _LED_OUTPUT_PREFIXES):\n if not ctx.is_power_net(pin_nets.get(pname, \"\")) and not ctx.is_ground(pin_nets.get(pname, \"\")):\n channels += 1\n\n # Find current set resistor\n current_set = None\n for pname in _LED_CURRENT_SET_PINS:\n if pname in pin_nets:\n net = pin_nets[pname]\n if net in ctx.nets:\n for p in ctx.nets[net][\"pins\"]:\n if p[\"component\"] == ref:\n continue\n rc = ctx.comp_lookup.get(p[\"component\"])\n if rc and rc[\"type\"] == \"resistor\":\n r_val = parse_value(rc.get(\"value\", \"\"))\n current_set = {\"ref\": rc[\"reference\"], \"net\": net}\n if r_val:\n current_set[\"ohms\"] = r_val\n break\n break\n\n entry: dict = {\n \"ref\": ref,\n \"value\": comp.get(\"value\", \"\"),\n \"type\": driver_type,\n \"interface\": interface,\n \"detector\": \"detect_led_driver_ics\",\n \"rule_id\": \"LI-DET\",\n \"category\": \"led_control\",\n \"severity\": \"info\",\n \"confidence\": \"deterministic\",\n \"evidence_source\": \"topology\",\n \"summary\": f\"LED driver {ref} ({comp.get('value', '')}) [{driver_type}]\",\n \"description\": f\"Detected {driver_type} LED driver IC {ref}.\",\n \"components\": [ref],\n \"nets\": [],\n \"pins\": [],\n \"recommendation\": \"\",\n \"report_context\": {\"section\": \"LED Control\", \"impact\": \"\", \"standard_ref\": \"\"},\n }\n if channels > 0:\n entry[\"channels\"] = channels\n if current_set:\n entry[\"current_set\"] = current_set\n\n entry[\"provenance\"] = make_provenance(\"led_resistor_topology\", \"deterministic\", claimed_components=[ref])\n results.append(entry)\n\n return results\n\n\n# ---------------------------------------------------------------------------\n# RTC Circuit Detection\n# ---------------------------------------------------------------------------\n\n_RTC_IC_KEYWORDS = (\n \"ds1307\", \"ds3231\", \"ds3232\", \"ds1302\",\n \"pcf8523\", \"pcf8563\", \"pcf2129\",\n \"rv3028\", \"rv3032\", \"rv8803\", \"rv1805\",\n \"mcp7940\", \"mcp7941\",\n \"isl1208\", \"isl1218\", \"isl12\",\n \"ab1805\", \"ab0805\", \"abx8\",\n \"m41t\", \"rx8025\", \"rx8900\",\n \"bq3285\", \"bq4802\",\n)\n\n_VBAT_PINS = {\"VBAT\", \"VBACK\", \"VBACKUP\", \"BAT\", \"BATT\"}\n_RTC_INT_PINS = {\"INT\", \"INTA\", \"INTB\", \"IRQ\", \"nINT\", \"~{INT}\"}\n_RTC_SQW_PINS = {\"SQW\", \"CLKOUT\", \"CLK_OUT\", \"FOUT\", \"32K\"}\n\n# RTCs with internal TCXO (no external crystal needed)\n_INTERNAL_OSC_KEYWORDS = (\"ds3231\", \"ds3232\", \"rv8803\", \"rx8900\")\n\n\ndef detect_rtc_circuits(ctx: AnalysisContext,\n crystal_circuits: list[dict]) -> list[dict]:\n \"\"\"Detect RTC ICs with battery backup and crystal pairing.\"\"\"\n results: list[dict] = []\n matched_refs: set[str] = set()\n\n # Build crystal-to-IC map from crystal_circuits\n crystal_by_ic: dict[str, str] = {} # ic_ref -> crystal_ref\n for xc in crystal_circuits:\n ic = xc.get(\"connected_to\")\n xref = xc.get(\"reference\")\n if ic and xref:\n crystal_by_ic[ic] = xref\n\n for comp in ctx.components:\n if comp[\"type\"] != \"ic\":\n continue\n ref = comp[\"reference\"]\n if ref in matched_refs:\n continue\n val = comp.get(\"value\", \"\").lower()\n lib = comp.get(\"lib_id\", \"\").lower()\n combined = val + \" \" + lib\n\n if not any(kw in combined for kw in _RTC_IC_KEYWORDS):\n continue\n matched_refs.add(ref)\n\n pin_nets = _build_pin_net_map(ctx, ref)\n all_pins = set(pin_nets.keys())\n\n # Interface\n interface = None\n if all_pins & {\"SDA\", \"SCL\"}:\n interface = \"i2c\"\n elif all_pins & {\"SCK\", \"SCLK\", \"CLK\", \"MOSI\", \"SDI\"}:\n interface = \"spi\"\n\n # Internal oscillator?\n has_internal_osc = any(kw in combined for kw in _INTERNAL_OSC_KEYWORDS)\n\n # External crystal — check crystal_circuits for a crystal connected to this IC\n external_crystal = crystal_by_ic.get(ref)\n # Also check by scanning crystal pins on this IC\n if not external_crystal:\n for pname in all_pins:\n if any(k in pname for k in (\"XTAL\", \"OSC\", \"X1\", \"X2\", \"XI\", \"XO\",\n \"X32K\", \"XT1\", \"XT2\")):\n net = pin_nets[pname]\n if net in ctx.nets:\n for p in ctx.nets[net][\"pins\"]:\n if p[\"component\"] == ref:\n continue\n xc = ctx.comp_lookup.get(p[\"component\"])\n if xc and xc[\"type\"] in (\"crystal\", \"oscillator\"):\n external_crystal = p[\"component\"]\n break\n if external_crystal:\n break\n\n # Battery backup\n battery_backup = None\n for pname in _VBAT_PINS:\n if pname in pin_nets:\n vbat_net = pin_nets[pname]\n if vbat_net in ctx.nets:\n for p in ctx.nets[vbat_net][\"pins\"]:\n if p[\"component\"] == ref:\n continue\n bc = ctx.comp_lookup.get(p[\"component\"])\n if bc and bc[\"type\"] in (\"battery\", \"capacitor\"):\n battery_backup = {\n \"pin\": pname,\n \"net\": vbat_net,\n \"battery_ref\": p[\"component\"],\n }\n break\n if not battery_backup:\n battery_backup = {\"pin\": pname, \"net\": vbat_net, \"battery_ref\": None}\n break\n\n # Interrupt pin\n interrupt_net = None\n interrupt_connected = False\n for pname in _RTC_INT_PINS:\n if pname in pin_nets:\n interrupt_net = pin_nets[pname]\n if interrupt_net in ctx.nets:\n for p in ctx.nets[interrupt_net][\"pins\"]:\n if p[\"component\"] == ref:\n continue\n ic = ctx.comp_lookup.get(p[\"component\"])\n if ic and ic[\"type\"] == \"ic\":\n interrupt_connected = True\n break\n break\n\n # SQW/CLKOUT pin\n sqw_net = None\n for pname in _RTC_SQW_PINS:\n if pname in pin_nets:\n sqw_net = pin_nets[pname]\n break\n\n rtc_comps = [ref] + ([external_crystal] if external_crystal else [])\n entry: dict = {\n \"ref\": ref,\n \"value\": comp.get(\"value\", \"\"),\n \"type\": \"rtc\",\n \"interface\": interface,\n \"has_internal_oscillator\": has_internal_osc,\n \"external_crystal\": external_crystal,\n \"detector\": \"detect_rtc_circuits\",\n \"rule_id\": \"RT-DET\",\n \"category\": \"timing\",\n \"severity\": \"info\",\n \"confidence\": \"deterministic\",\n \"evidence_source\": \"topology\",\n \"summary\": f\"RTC {ref} ({comp.get('value', '')}) [{interface}]\",\n \"description\": f\"Detected RTC IC {ref}.\",\n \"components\": rtc_comps,\n \"nets\": [],\n \"pins\": [],\n \"recommendation\": \"\",\n \"report_context\": {\"section\": \"Timing\", \"impact\": \"\", \"standard_ref\": \"\"},\n }\n if battery_backup:\n entry[\"battery_backup\"] = battery_backup\n if interrupt_net:\n entry[\"interrupt_net\"] = interrupt_net\n entry[\"interrupt_connected\"] = interrupt_connected\n if sqw_net:\n entry[\"sqw_net\"] = sqw_net\n\n entry[\"provenance\"] = make_provenance(\"rtc_crystal_topology\", \"deterministic\", claimed_components=[ref])\n results.append(entry)\n\n return results\n\n\n# ---------------------------------------------------------------------------\n# LED Lighting Audit\n# ---------------------------------------------------------------------------\n\n# Forward voltage estimates by LED color (for current calculation)\n_LED_VF = {\n \"red\": 2.0, \"orange\": 2.0, \"yellow\": 2.0, \"amber\": 2.0,\n \"green\": 3.2, \"blue\": 3.2, \"white\": 3.2, \"uv\": 3.5,\n}\n\n\ndef _estimate_led_vf(comp: dict) -> float:\n \"\"\"Estimate LED forward voltage from value/lib_id color hints.\"\"\"\n combined = (comp.get(\"value\", \"\") + \" \" + comp.get(\"lib_id\", \"\")).lower()\n for color, vf in _LED_VF.items():\n if color in combined:\n return vf\n return 2.0 # default to red/generic\n\n\ndef audit_led_circuits(ctx: AnalysisContext,\n transistor_circuits: list[dict]) -> list[dict]:\n \"\"\"Audit all LEDs for proper current limiting.\n\n Catches direct GPIO→resistor→LED circuits and flags LEDs with missing\n current limiting. Excludes LEDs already handled by transistor-based\n detect_led_drivers() and addressable LED detection.\n \"\"\"\n results: list[dict] = []\n\n # Build set of LED refs already claimed by transistor drivers\n driven_led_refs: set[str] = set()\n for tc in transistor_circuits:\n if tc.get(\"led_driver\"):\n led_ref = tc[\"led_driver\"].get(\"led_ref\")\n if led_ref:\n driven_led_refs.add(led_ref)\n\n # Also exclude addressable LEDs (type will be \"led\" but value matches WS2812 etc.)\n addr_keywords = (\"ws2812\", \"ws2813\", \"ws2815\", \"sk6812\", \"sk6805\", \"sk6803\",\n \"apa102\", \"apa104\", \"sk9822\", \"ws2811\", \"neopixel\", \"dotstar\")\n\n _seen_refs = set()\n for comp in ctx.components:\n if comp[\"type\"] != \"led\":\n continue\n ref = comp[\"reference\"]\n if ref in _seen_refs:\n continue\n _seen_refs.add(ref)\n if ref in driven_led_refs:\n continue\n\n # Skip addressable LEDs\n val_lower = comp.get(\"value\", \"\").lower()\n lib_lower = comp.get(\"lib_id\", \"\").lower()\n if any(k in val_lower or k in lib_lower for k in addr_keywords):\n continue\n\n # Skip multi-pin LEDs (RGB, RAGB) — they need per-channel analysis\n comp_pins = comp.get(\"pins\", [])\n if len(comp_pins) > 2:\n continue\n\n # Get both pins\n n1, n2 = ctx.get_two_pin_nets(ref)\n if not n1 or not n2:\n continue\n\n # Classify each net: power, ground, signal\n supply_net = None\n signal_net = None\n for net in (n1, n2):\n if ctx.is_ground(net):\n continue\n if ctx.is_power_net(net):\n supply_net = net\n else:\n signal_net = net\n\n # If both are signal nets, pick the one that's not ground-adjacent\n if not supply_net and not signal_net:\n continue\n\n # Look for series resistor on the non-ground net(s)\n series_resistor = None\n has_unparsed_resistor = False\n driver_source = None\n check_nets = [n for n in (n1, n2) if n and not ctx.is_ground(n)]\n\n def _scan_net_for_resistor(net, exclude_ref):\n \"\"\"Scan a net for a current-limiting resistor. Returns (resistor_dict, unparsed_flag).\"\"\"\n nonlocal supply_net, driver_source\n if net not in ctx.nets:\n return None, False\n found_unparsed = False\n for p in ctx.nets[net][\"pins\"]:\n if p[\"component\"] == exclude_ref:\n continue\n rc = ctx.comp_lookup.get(p[\"component\"])\n if not rc:\n continue\n if rc[\"type\"] == \"resistor\":\n r_val = parse_value(rc.get(\"value\", \"\"))\n if r_val is None:\n found_unparsed = True\n continue\n if r_val \u003c 10 or r_val > 100000:\n continue\n res = {\"ref\": rc[\"reference\"], \"ohms\": r_val}\n # Check what's on the resistor's other side\n rn1, rn2 = ctx.get_two_pin_nets(rc[\"reference\"])\n r_other = rn2 if rn1 == net else rn1\n if r_other and not supply_net and ctx.is_power_net(r_other):\n supply_net = r_other\n if r_other and r_other in ctx.nets:\n for rp in ctx.nets[r_other][\"pins\"]:\n if rp[\"component\"] == rc[\"reference\"]:\n continue\n src = ctx.comp_lookup.get(rp[\"component\"])\n if src and src[\"type\"] == \"ic\":\n driver_source = rp[\"component\"]\n break\n return res, False\n elif rc[\"type\"] == \"ic\" and not driver_source:\n driver_source = rc[\"reference\"]\n return None, found_unparsed\n\n for net in check_nets:\n series_resistor, unparsed = _scan_net_for_resistor(net, ref)\n if unparsed:\n has_unparsed_resistor = True\n if series_resistor:\n break\n\n # If no resistor found, trace through LED chains (LED→LED→...→resistor)\n if not series_resistor:\n visited_leds: set[str] = {ref}\n frontier_nets = list(check_nets)\n for _ in range(5): # max 5 hops through LED chain\n next_nets: list[str] = []\n for net in frontier_nets:\n if net not in ctx.nets:\n continue\n for p in ctx.nets[net][\"pins\"]:\n if p[\"component\"] in visited_leds:\n continue\n pc = ctx.comp_lookup.get(p[\"component\"])\n if not pc:\n continue\n if pc[\"type\"] == \"led\":\n # Follow through this LED to its other net\n visited_leds.add(p[\"component\"])\n ln1, ln2 = ctx.get_two_pin_nets(p[\"component\"])\n for ln in (ln1, ln2):\n if ln and ln != net and not ctx.is_ground(ln):\n next_nets.append(ln)\n if not next_nets:\n break\n # Check the next set of nets for a resistor\n for net in next_nets:\n series_resistor, unparsed = _scan_net_for_resistor(net, \"\")\n if unparsed:\n has_unparsed_resistor = True\n if series_resistor:\n break\n if series_resistor:\n break\n frontier_nets = next_nets\n\n # Determine drive method\n if series_resistor:\n drive_method = \"resistor_limited\"\n elif driver_source:\n drive_method = \"ic_direct\"\n elif has_unparsed_resistor:\n drive_method = \"resistor_unparsed\"\n else:\n drive_method = \"direct_drive\"\n\n la_comps = [ref] + ([series_resistor[\"ref\"]] if series_resistor else []) + ([driver_source] if driver_source else [])\n entry: dict = {\n \"ref\": ref,\n \"value\": comp.get(\"value\", \"\"),\n \"type\": \"indicator_led\",\n \"drive_method\": drive_method,\n \"detector\": \"audit_led_circuits\",\n \"rule_id\": \"LA-AUD\",\n \"category\": \"led_control\",\n \"severity\": \"info\",\n \"confidence\": \"deterministic\",\n \"evidence_source\": \"topology\",\n \"summary\": f\"LED {ref} ({comp.get('value', '')}) [{drive_method}]\",\n \"description\": f\"LED audit: {ref} using {drive_method} drive method.\",\n \"components\": la_comps,\n \"nets\": [supply_net] if supply_net else [],\n \"pins\": [],\n \"recommendation\": \"\",\n \"report_context\": {\"section\": \"LED Audit\", \"impact\": \"\", \"standard_ref\": \"\"},\n }\n\n if series_resistor:\n entry[\"series_resistor\"] = series_resistor\n if supply_net:\n entry[\"supply_net\"] = supply_net\n if driver_source:\n entry[\"driver_source\"] = driver_source\n\n # Estimate current for resistor-limited LEDs\n if series_resistor and supply_net:\n v_supply = parse_voltage_from_net_name(supply_net)\n if v_supply:\n vf = _estimate_led_vf(comp)\n if v_supply > vf:\n i_ma = (v_supply - vf) / series_resistor[\"ohms\"] * 1000\n entry[\"estimated_current_mA\"] = round(i_ma, 1)\n\n # Flag issues\n if drive_method == \"direct_drive\":\n entry[\"issue\"] = \"no_current_limiting_resistor\"\n elif drive_method == \"resistor_unparsed\":\n entry[\"issue\"] = \"has_resistor_unparsed_value\"\n\n entry[\"provenance\"] = make_provenance(\"led_audit\", \"deterministic\", claimed_components=[ref])\n results.append(entry)\n\n return results\n\n\n# ---------------------------------------------------------------------------\n# Thermocouple / RTD Interface Detection\n# ---------------------------------------------------------------------------\n\n_TC_IC_KEYWORDS = (\n \"max31855\", \"max31856\", \"max3185\",\n \"max6675\", \"max667\",\n \"ad8495\", \"ad8494\", \"ad8496\", \"ad8497\",\n \"ads1118\",\n \"mcp960\",\n)\n\n_RTD_IC_KEYWORDS = (\n \"max31865\", \"max3186\",\n \"ads124\",\n)\n\n_TC_INPUT_PINS = {\"T+\", \"T-\", \"TC+\", \"TC-\", \"INP\", \"INN\",\n \"THERMOCOUPLE+\", \"THERMOCOUPLE-\"}\n_RTD_REF_PINS = {\"RREF+\", \"RREF-\", \"REFIN+\", \"REFIN-\", \"RREF\"}\n_RTD_FORCE_PINS = {\"FORCE+\", \"FORCE-\", \"RTDIN+\", \"RTDIN-\", \"F+\", \"F-\"}\n\n# ICs with internal cold junction compensation\n_INTERNAL_CJC_KEYWORDS = (\"max31855\", \"max31856\", \"max3185\", \"max6675\", \"max667\", \"mcp960\")\n\n\ndef detect_thermocouple_rtd(ctx: AnalysisContext) -> list[dict]:\n \"\"\"Detect thermocouple amplifier and RTD interface ICs.\"\"\"\n results: list[dict] = []\n matched_refs: set[str] = set()\n\n for comp in ctx.components:\n if comp[\"type\"] != \"ic\":\n continue\n ref = comp[\"reference\"]\n if ref in matched_refs:\n continue\n val = comp.get(\"value\", \"\").lower()\n lib = comp.get(\"lib_id\", \"\").lower()\n combined = val + \" \" + lib\n\n is_tc = any(kw in combined for kw in _TC_IC_KEYWORDS)\n is_rtd = any(kw in combined for kw in _RTD_IC_KEYWORDS)\n if not is_tc and not is_rtd:\n continue\n matched_refs.add(ref)\n\n pin_nets = _build_pin_net_map(ctx, ref)\n all_pins = set(pin_nets.keys())\n\n # Infer interface\n interface = None\n if all_pins & {\"SCK\", \"SCLK\", \"CLK\", \"MISO\", \"SDO\", \"CS\", \"CSN\"}:\n interface = \"spi\"\n elif all_pins & {\"SDA\", \"SCL\"}:\n interface = \"i2c\"\n elif \"ad849\" in combined:\n interface = \"analog\"\n\n if is_tc and not is_rtd:\n # Thermocouple amplifier\n has_cjc = any(kw in combined for kw in _INTERNAL_CJC_KEYWORDS)\n\n # Find sensor input nets\n sensor_nets: list[str] = []\n for pname in sorted(all_pins & _TC_INPUT_PINS):\n sensor_nets.append(pin_nets[pname])\n\n results.append({\n \"ref\": ref,\n \"value\": comp.get(\"value\", \"\"),\n \"type\": \"thermocouple_amplifier\",\n \"interface\": interface,\n \"cold_junction_compensation\": \"internal\" if has_cjc else \"external\",\n \"sensor_input_nets\": sensor_nets,\n \"detector\": \"detect_thermocouple_rtd\",\n \"rule_id\": \"TC-DET\",\n \"category\": \"sensors\",\n \"severity\": \"info\",\n \"confidence\": \"deterministic\",\n \"evidence_source\": \"topology\",\n \"summary\": f\"Thermocouple amplifier {ref} ({comp.get('value', '')}) [{interface}]\",\n \"description\": f\"Detected thermocouple amplifier IC {ref}.\",\n \"components\": [ref],\n \"nets\": sensor_nets,\n \"pins\": [],\n \"recommendation\": \"\",\n \"report_context\": {\"section\": \"Sensors\", \"impact\": \"\", \"standard_ref\": \"\"},\n \"provenance\": make_provenance(\"thermo_ic_topology\", \"deterministic\", claimed_components=[ref]),\n })\n\n elif is_rtd:\n # RTD interface\n # Find reference resistor\n ref_resistor = None\n for pname in _RTD_REF_PINS:\n if pname in pin_nets:\n net = pin_nets[pname]\n if net in ctx.nets:\n for p in ctx.nets[net][\"pins\"]:\n if p[\"component\"] == ref:\n continue\n rc = ctx.comp_lookup.get(p[\"component\"])\n if rc and rc[\"type\"] == \"resistor\":\n r_val = parse_value(rc.get(\"value\", \"\"))\n ref_resistor = {\"ref\": rc[\"reference\"]}\n if r_val:\n ref_resistor[\"ohms\"] = r_val\n break\n if ref_resistor:\n break\n\n # Infer sensor type from reference resistor value\n sensor_type = None\n if ref_resistor and ref_resistor.get(\"ohms\"):\n ohms = ref_resistor[\"ohms\"]\n if 380 \u003c= ohms \u003c= 470:\n sensor_type = \"pt100\"\n elif 3800 \u003c= ohms \u003c= 4700:\n sensor_type = \"pt1000\"\n\n # Find sensor input nets\n sensor_nets = []\n for pname in sorted(all_pins & (_RTD_FORCE_PINS | _TC_INPUT_PINS)):\n sensor_nets.append(pin_nets[pname])\n\n rtd_comps = [ref] + ([ref_resistor[\"ref\"]] if ref_resistor else [])\n entry: dict = {\n \"ref\": ref,\n \"value\": comp.get(\"value\", \"\"),\n \"type\": \"rtd_interface\",\n \"interface\": interface,\n \"detector\": \"detect_thermocouple_rtd\",\n \"rule_id\": \"TC-DET\",\n \"category\": \"sensors\",\n \"severity\": \"info\",\n \"confidence\": \"deterministic\",\n \"evidence_source\": \"topology\",\n \"summary\": f\"RTD interface {ref} ({comp.get('value', '')})\" + (f\" [{sensor_type}]\" if sensor_type else \"\"),\n \"description\": f\"Detected RTD interface IC {ref}.\",\n \"components\": rtd_comps,\n \"nets\": sensor_nets,\n \"pins\": [],\n \"recommendation\": \"\",\n \"report_context\": {\"section\": \"Sensors\", \"impact\": \"\", \"standard_ref\": \"\"},\n }\n if ref_resistor:\n entry[\"reference_resistor\"] = ref_resistor\n if sensor_type:\n entry[\"sensor_type\"] = sensor_type\n if sensor_nets:\n entry[\"sensor_input_nets\"] = sensor_nets\n\n entry[\"provenance\"] = make_provenance(\"thermo_ic_topology\", \"deterministic\", claimed_components=[ref])\n results.append(entry)\n\n return results\n\n\n# ---------------------------------------------------------------------------\n# Power Sequencing Validation\n# ---------------------------------------------------------------------------\n\n_EN_PIN_NAMES = {\"EN\", \"ENABLE\", \"ON\", \"ON/OFF\", \"CE\", \"SHDN\", \"SHUTDOWN\",\n \"EN1\", \"EN2\", \"EN3\",\n \"~{EN}\", \"~{SHDN}\", \"~{ENABLE}\", \"~{CE}\"}\n_PG_PIN_NAMES = {\"PG\", \"PGOOD\", \"PG1\", \"PG2\", \"POWER_GOOD\", \"POK\", \"nPG\",\n \"~{PG}\", \"~{PGOOD}\", \"~{POWER_GOOD}\", \"~{POK}\"}\n\n\ndef validate_power_sequencing(ctx: AnalysisContext,\n power_regulators: list[dict],\n power_path: list[dict],\n reset_supervisors: list[dict]) -> dict:\n \"\"\"Cross-reference power regulators, load switches, and supervisors\n to build a power-up dependency graph and flag sequencing issues.\"\"\"\n\n power_tree: list[dict] = []\n enable_chains: list[dict] = []\n issues: list[dict] = []\n\n # Collect all power sources (regulators + load switches)\n all_sources: list[dict] = []\n for reg in power_regulators:\n all_sources.append({\n \"ref\": reg[\"ref\"],\n \"kind\": \"regulator\",\n \"input_rail\": reg.get(\"input_rail\"),\n \"output_rail\": reg.get(\"output_rail\"),\n \"voltage\": reg.get(\"estimated_vout\"),\n })\n for pp in power_path:\n if pp.get(\"type\") == \"load_switch\":\n all_sources.append({\n \"ref\": pp[\"ref\"],\n \"kind\": \"load_switch\",\n \"input_rail\": pp.get(\"input_rail\"),\n \"output_rail\": pp.get(\"output_rail\"),\n \"voltage\": None,\n \"enable_net\": pp.get(\"enable_net\"),\n })\n\n # For each source, find EN and PG pins by scanning IC pins\n source_en_nets: dict[str, str | None] = {} # ref -> en_net\n source_pg_nets: dict[str, str | None] = {} # ref -> pg_net\n\n def _match_pin(pin_nets: dict[str, str], names: set[str]) -> str | None:\n \"\"\"Match a pin name from *pin_nets* against a set of canonical names.\n\n KiCad 6+ overbar markup (``~{EN}``) is stripped before comparison so\n that both ``EN`` and ``~{EN}`` match the canonical ``\"EN\"`` entry.\n Trailing digits are also stripped for base-name matching (e.g.\n ``EN1`` matches if ``\"EN\"`` is in *names*).\n \"\"\"\n # First pass: direct lookup (fast path for exact matches)\n for pname in names:\n if pname in pin_nets:\n return pin_nets[pname]\n # Second pass: normalize raw pin names and match against base names\n # KH-223: strip ~{ } overbar wrappers and retry\n for raw_pname, net in pin_nets.items():\n norm = raw_pname.replace(\"~{\", \"\").replace(\"}\", \"\")\n if norm in names:\n return net\n # Also try with trailing digits stripped (EN1 → EN)\n base = norm.rstrip(\"0123456789\")\n if base and base in names:\n return net\n return None\n\n for src in all_sources:\n ref = src[\"ref\"]\n pin_nets = _build_pin_net_map(ctx, ref)\n\n # EN pin\n en_net = src.get(\"enable_net\") # load switches already have this\n if not en_net:\n en_net = _match_pin(pin_nets, _EN_PIN_NAMES)\n source_en_nets[ref] = en_net\n\n # PG pin\n pg_net = _match_pin(pin_nets, _PG_PIN_NAMES)\n source_pg_nets[ref] = pg_net\n\n # Build PG→EN cross-reference: which PG net drives which EN net\n pg_to_ref: dict[str, str] = {} # pg_net -> source ref that outputs it\n for ref, pg_net in source_pg_nets.items():\n if pg_net:\n pg_to_ref[pg_net] = ref\n\n # Trace enable chains and build power tree\n output_rails: dict[str, dict] = {} # rail_name -> source info\n for src in all_sources:\n ref = src[\"ref\"]\n rail = src.get(\"output_rail\")\n if rail:\n output_rails[rail] = src\n\n en_net = source_en_nets.get(ref)\n en_source = None\n en_type = \"always_on\"\n\n if en_net:\n if ctx.is_power_net(en_net):\n en_type = \"tied_to_rail\"\n en_source = en_net\n elif en_net in pg_to_ref:\n en_type = \"pg_daisy_chain\"\n en_source = pg_to_ref[en_net]\n enable_chains.append({\n \"regulator\": ref,\n \"en_net\": en_net,\n \"en_source\": en_source,\n \"type\": \"pg_daisy_chain\",\n })\n else:\n # Check if EN net has any driver (IC pin, connector)\n has_driver = False\n if en_net in ctx.nets:\n for p in ctx.nets[en_net][\"pins\"]:\n if p[\"component\"] == ref:\n continue\n ec = ctx.comp_lookup.get(p[\"component\"])\n if ec and ec[\"type\"] in (\"ic\", \"connector\"):\n has_driver = True\n en_source = p[\"component\"]\n en_type = \"gpio_controlled\"\n break\n if not has_driver:\n # Check if net has any pins at all beyond the EN pin itself\n pin_count = len(ctx.nets.get(en_net, {}).get(\"pins\", []))\n if pin_count \u003c= 1:\n en_type = \"floating\"\n issues.append({\n \"type\": \"floating_enable\",\n \"ref\": ref,\n \"rail\": rail,\n \"en_net\": en_net,\n })\n\n # Build power tree entry\n tree_entry: dict = {\n \"rail\": rail,\n \"source\": ref,\n \"source_type\": src[\"kind\"],\n }\n if src.get(\"voltage\"):\n tree_entry[\"voltage\"] = src[\"voltage\"]\n tree_entry[\"enable_type\"] = en_type\n if en_source:\n tree_entry[\"enabled_by\"] = en_source\n power_tree.append(tree_entry)\n\n # Check supervisors for issues\n for sup in reset_supervisors:\n if sup.get(\"type\") != \"voltage_supervisor\":\n continue\n rail = sup.get(\"monitored_rail\")\n threshold = sup.get(\"threshold_voltage\")\n if rail and threshold:\n # Find nominal voltage of monitored rail\n src = output_rails.get(rail)\n nominal = src.get(\"voltage\") if src else parse_voltage_from_net_name(rail)\n if nominal and threshold > nominal:\n issues.append({\n \"type\": \"supervisor_threshold_above_nominal\",\n \"ref\": sup[\"ref\"],\n \"monitored_rail\": rail,\n \"threshold\": threshold,\n \"nominal\": nominal,\n })\n\n # Topological sort for sequence order\n # Build adjacency: source ref → list of refs it enables\n deps: dict[str, list[str]] = {}\n for chain in enable_chains:\n src = chain[\"en_source\"]\n tgt = chain[\"regulator\"]\n deps.setdefault(src, []).append(tgt)\n\n # Assign sequence order via BFS\n visited: set[str] = set()\n order: dict[str, int] = {}\n queue: list[tuple[str, int]] = []\n\n # Start with sources that have no enable dependency (always-on)\n for entry in power_tree:\n if entry[\"enable_type\"] in (\"always_on\", \"tied_to_rail\"):\n ref = entry[\"source\"]\n if ref not in visited:\n queue.append((ref, 0))\n visited.add(ref)\n\n while queue:\n ref, seq = queue.pop(0)\n order[ref] = seq\n for child in deps.get(ref, []):\n if child not in visited:\n queue.append((child, seq + 1))\n visited.add(child)\n\n # Apply sequence order to power tree\n for entry in power_tree:\n ref = entry[\"source\"]\n if ref in order:\n entry[\"sequence_order\"] = order[ref]\n\n # Sort power tree by sequence order\n power_tree.sort(key=lambda e: (e.get(\"sequence_order\", 999), e.get(\"rail\") or \"\"))\n\n result: dict = {\n \"power_tree\": power_tree,\n \"provenance\": make_provenance(\"pseq_enable_chain\", \"heuristic\"),\n }\n if enable_chains:\n result[\"enable_chains\"] = enable_chains\n if issues:\n result[\"issues\"] = issues\n\n return result\n\n\n# ---------------------------------------------------------------------------\n# Connector Ground Pin Distribution Audit\n# ---------------------------------------------------------------------------\n\ndef audit_connector_ground_distribution(ctx: AnalysisContext) -> list[dict]:\n \"\"\"Check ground pin distribution on multi-pin connectors.\n\n Professional rule: ground pins every 2-3 signal pins for EMI control.\n Flags connectors with >4 signal pins per ground pin or no ground pins.\n \"\"\"\n results: list[dict] = []\n for comp in ctx.components:\n if comp.get(\"type\") != \"connector\":\n continue\n pin_nets = comp.get(\"pin_nets\", {})\n # Also build from ref_pins if pin_nets not populated on the component\n if not pin_nets:\n rp = ctx.ref_pins.get(comp[\"reference\"], {})\n pin_nets = {pn: net for pn, (net, _) in rp.items() if net}\n if len(pin_nets) \u003c 5:\n continue\n\n ref = comp[\"reference\"]\n gnd_count = 0\n signal_count = 0\n for pin_num, net in pin_nets.items():\n if isinstance(net, tuple):\n net = net[0] # handle (net_name, pin_info) tuples\n if not net:\n continue\n if ctx.is_ground(net):\n gnd_count += 1\n elif not ctx.is_power_net(net):\n signal_count += 1\n\n if signal_count == 0:\n continue\n\n if gnd_count == 0:\n results.append({\n \"ref\": ref, \"value\": comp.get(\"value\", \"\"),\n \"total_pins\": len(pin_nets), \"ground_pins\": 0,\n \"signal_pins\": signal_count,\n \"status\": \"warning\",\n \"detail\": f\"{ref}: {len(pin_nets)}-pin connector has no ground pins\",\n \"detector\": \"audit_connector_ground_distribution\",\n \"rule_id\": \"CG-AUD\",\n \"category\": \"connectors\",\n \"severity\": \"warning\",\n \"confidence\": \"deterministic\",\n \"evidence_source\": \"topology\",\n \"summary\": f\"Connector {ref} has no ground pins ({len(pin_nets)} pins)\",\n \"description\": f\"Connector {ref} has {signal_count} signal pins but no ground pins.\",\n \"components\": [ref],\n \"nets\": [],\n \"pins\": [],\n \"recommendation\": \"Add ground pin(s) for EMI control.\",\n \"report_context\": {\"section\": \"Connector Ground\", \"impact\": \"\", \"standard_ref\": \"\"},\n \"provenance\": make_provenance(\"cg_ground_audit\", \"deterministic\", claimed_components=[ref]),\n })\n else:\n ratio = signal_count / max(gnd_count, 1)\n if ratio > 4:\n results.append({\n \"ref\": ref, \"value\": comp.get(\"value\", \"\"),\n \"total_pins\": len(pin_nets), \"ground_pins\": gnd_count,\n \"signal_pins\": signal_count, \"signal_per_ground\": round(ratio, 1),\n \"status\": \"advisory\",\n \"detail\": (f\"{ref}: {ratio:.0f} signal pins per ground pin \"\n f\"(recommended: \\u22643 for EMI control)\"),\n \"detector\": \"audit_connector_ground_distribution\",\n \"rule_id\": \"CG-AUD\",\n \"category\": \"connectors\",\n \"severity\": \"info\",\n \"confidence\": \"deterministic\",\n \"evidence_source\": \"topology\",\n \"summary\": f\"Connector {ref}: {ratio:.0f}:1 signal-to-ground ratio\",\n \"description\": f\"Connector {ref} has {ratio:.1f} signal pins per ground pin.\",\n \"components\": [ref],\n \"nets\": [],\n \"pins\": [],\n \"recommendation\": \"Add more ground pins (target \\u22643 signal pins per ground).\",\n \"report_context\": {\"section\": \"Connector Ground\", \"impact\": \"\", \"standard_ref\": \"\"},\n \"provenance\": make_provenance(\"cg_ground_audit\", \"deterministic\", claimed_components=[ref]),\n })\n return results\n\n\n# ---------------------------------------------------------------------------\n# Certification Suggestion\n# ---------------------------------------------------------------------------\n\ndef suggest_certifications(ctx: AnalysisContext, signal_analysis: dict) -> list[dict]:\n \"\"\"Suggest applicable certifications based on design characteristics.\n\n Cross-references existing domain detections to identify likely\n required standards/certifications.\n \"\"\"\n suggestions: list[dict] = []\n\n # Wireless modules / RF → FCC/CE/IC\n rf_chains = signal_analysis.get(\"rf_chains\", [])\n if rf_chains:\n suggestions.append({\n \"standard\": \"FCC Part 15\",\n \"region\": \"US\",\n \"reason\": f\"RF transceiver detected ({len(rf_chains)} chain(s))\",\n \"components\": [c.get(\"reference\", c.get(\"ref\", \"\")) for c in rf_chains[:3]],\n })\n suggestions.append({\n \"standard\": \"CE RED (Radio Equipment Directive)\",\n \"region\": \"EU\",\n \"reason\": \"RF transceiver detected\",\n })\n\n # WiFi/BT modules\n for comp in ctx.components:\n val_lib = (comp.get(\"value\", \"\") + \" \" + comp.get(\"lib_id\", \"\")).lower()\n if any(k in val_lib for k in (\"esp32\", \"esp8266\", \"nrf52\", \"cc254\",\n \"wl18\", \"cyw43\", \"ap621\", \"bcm43\")):\n if not any(s[\"standard\"] == \"FCC Part 15\" for s in suggestions):\n suggestions.append({\n \"standard\": \"FCC Part 15\",\n \"region\": \"US\",\n \"reason\": f\"Wireless module detected: {comp['reference']}\",\n })\n break\n\n # Battery/charger → safety standards\n battery_chargers = signal_analysis.get(\"battery_chargers\", [])\n bms_systems = signal_analysis.get(\"bms_systems\", [])\n if battery_chargers or bms_systems:\n suggestions.append({\n \"standard\": \"IEC 62133 / UL 2054\",\n \"region\": \"International\",\n \"reason\": \"Battery charging/management circuitry detected\",\n })\n suggestions.append({\n \"standard\": \"UN 38.3\",\n \"region\": \"International (shipping)\",\n \"reason\": \"Lithium battery transport safety testing\",\n })\n\n # USB → USB-IF compliance\n usb_compliance = signal_analysis.get(\"usb_compliance\", [])\n if usb_compliance:\n suggestions.append({\n \"standard\": \"USB-IF Compliance\",\n \"region\": \"International\",\n \"reason\": \"USB interface detected\",\n })\n\n # Ethernet → EMC\n ethernet = signal_analysis.get(\"ethernet_interfaces\", [])\n if ethernet:\n suggestions.append({\n \"standard\": \"IEEE 802.3 / CISPR 32\",\n \"region\": \"International\",\n \"reason\": \"Ethernet interface detected — EMC compliance required\",\n })\n\n # High voltage detection (>60V)\n rail_voltages = signal_analysis.get(\"rail_voltages\", {})\n max_voltage = max((v for v in rail_voltages.values()\n if isinstance(v, (int, float))), default=0)\n if max_voltage > 60:\n suggestions.append({\n \"standard\": \"IEC 62368-1 (Safety)\",\n \"region\": \"International\",\n \"reason\": f\"High voltage detected: {max_voltage:.0f}V (>60V safety threshold)\",\n })\n\n # General EMC (all products)\n if not any(s[\"standard\"].startswith(\"FCC\") for s in suggestions):\n suggestions.append({\n \"standard\": \"FCC Part 15 Subpart B (Unintentional Radiator)\",\n \"region\": \"US\",\n \"reason\": \"All electronic devices require unintentional radiator compliance\",\n })\n if not any(\"CISPR\" in s[\"standard\"] or \"CE\" in s[\"standard\"]\n for s in suggestions):\n suggestions.append({\n \"standard\": \"CISPR 32 / CE EMC Directive\",\n \"region\": \"EU\",\n \"reason\": \"All electronic devices require EMC compliance for EU market\",\n })\n\n # Annotate each suggestion with finding metadata so the trust_summary\n # doesn't flag them as unknown confidence/evidence.\n for s in suggestions:\n s.setdefault('detector', 'suggest_certifications')\n s.setdefault('rule_id', 'CERT-001')\n s.setdefault('category', 'certification')\n s.setdefault('severity', 'info')\n s.setdefault('confidence', 'heuristic')\n s.setdefault('evidence_source', 'topology')\n s.setdefault('summary', s.get('reason', ''))\n s.setdefault('description', f\"{s.get('standard', '')}: {s.get('reason', '')}\")\n s.setdefault('components', s.get('components', []))\n s.setdefault('nets', [])\n s.setdefault('pins', [])\n s.setdefault('recommendation', '')\n s.setdefault('report_context', {\n 'section': 'Certifications', 'impact': '', 'standard_ref': ''})\n\n return suggestions\n\n\ndef _get_pin_net_domain(ctx: AnalysisContext, ref: str, pin_names: tuple) -> str | None:\n \"\"\"Find net connected to a pin matching names (domain_detectors version).\"\"\"\n pins = ctx.ref_pins.get(ref, {})\n for pnum, (net, _) in pins.items():\n comp = ctx.comp_lookup.get(ref)\n if not comp:\n continue\n for p in comp.get('pins', []):\n if p.get('number') == pnum and p.get('name', '').upper() in pin_names:\n return net\n for pnum, (net, _) in pins.items():\n if not net or net not in ctx.nets:\n continue\n for np in ctx.nets[net]['pins']:\n if np['component'] == ref and np.get('pin_name', '').upper() in pin_names:\n return net\n return None\n\n\n# ---------------------------------------------------------------------------\n# WL-001: Wireless module validation\n# ---------------------------------------------------------------------------\n\n_WIFI_BLE_KEYWORDS = (\n 'esp32', 'esp8266', 'esp32s', 'esp32c', 'esp32h',\n 'nrf52', 'nrf53', 'nrf91', 'nrf7002',\n 'cc2640', 'cc2652', 'cc1352', 'cc3220',\n 'cyw43', 'cyw20',\n 'da1469', 'da1458',\n 'stm32wb', 'stm32wl', 'stm32wba',\n 'rp2040w', 'pico_w',\n 'atecc', 'atwinc', 'atwilc',\n 'bg22', 'bg24', 'mg24',\n)\n\n_LORA_KEYWORDS = (\n 'sx1276', 'sx1278', 'sx1262', 'sx1261', 'sx1280',\n 'rfm95', 'rfm96', 'rfm98', 'rfm69',\n 'ra01', 'ra02',\n 'llcc68',\n 'lr1110', 'lr1120', 'lr1121',\n 'stm32wl',\n)\n\n_CELLULAR_KEYWORDS = (\n 'sim7000', 'sim7600', 'sim7080', 'sim800', 'sim900',\n 'bg96', 'bg77', 'bc66', 'bc95',\n 'sara', 'lara', 'toby',\n 'mc60', 'mc20',\n 'a7670', 'a7608',\n)\n\n_GPS_KEYWORDS = (\n 'neo6m', 'neo7m', 'neo8m', 'neom8', 'neom9', 'zed',\n 'l76', 'l86', 'l96',\n 'sam_m8q', 'sam_m10',\n 'gps', 'gnss',\n 'pa1010', 'pa1616',\n)\n\n_ANTENNA_PIN_NAMES = ('ANT', 'ANTENNA', 'RF', 'RF_OUT', 'RF_IN', 'RFIO', 'ANT_SW')\n_SIM_PIN_NAMES = ('SIM_VCC', 'SIM_RST', 'SIM_IO', 'SIM_CLK', 'SIM_DET')\n\n\ndef detect_wireless_modules(ctx: AnalysisContext) -> list[dict]:\n \"\"\"WL-001: Detect WiFi/BLE, LoRa, cellular, and GPS modules.\"\"\"\n results: list[dict] = []\n for ic in get_unique_ics(ctx):\n ref = ic['reference']\n val = ic.get('value', '')\n wl_type = None\n if match_ic_keywords(ic, _WIFI_BLE_KEYWORDS):\n wl_type = 'wifi_ble'\n elif match_ic_keywords(ic, _LORA_KEYWORDS):\n wl_type = 'lora'\n elif match_ic_keywords(ic, _CELLULAR_KEYWORDS):\n wl_type = 'cellular'\n elif match_ic_keywords(ic, _GPS_KEYWORDS):\n wl_type = 'gps'\n if not wl_type:\n continue\n\n ant_net = _get_pin_net_domain(ctx, ref, _ANTENNA_PIN_NAMES)\n entry = {\n 'detector': 'detect_wireless_modules', 'rule_id': 'WL-001',\n 'category': 'wireless', 'reference': ref, 'value': val,\n 'wireless_type': wl_type, 'antenna_net': ant_net,\n 'severity': 'info', 'confidence': 'deterministic',\n 'evidence_source': 'topology',\n 'summary': f'{wl_type.replace(\"_\", \"/\")} module {ref} ({val})',\n 'description': f'Detected {wl_type} wireless module {ref} ({val}).',\n 'components': [ref], 'nets': [ant_net] if ant_net else [],\n 'pins': [], 'recommendation': '',\n 'report_context': {'section': 'Wireless', 'impact': '', 'standard_ref': ''},\n }\n if wl_type == 'cellular':\n sim_nets = {}\n for sn in _SIM_PIN_NAMES:\n net = _get_pin_net_domain(ctx, ref, (sn,))\n if net:\n sim_nets[sn] = net\n entry['sim_pins'] = sim_nets\n if not sim_nets:\n entry['severity'] = 'warning'\n entry['recommendation'] = 'Verify SIM card connections for cellular module.'\n entry['provenance'] = make_provenance('wireless_antenna_match', 'heuristic', claimed_components=[ref])\n results.append(entry)\n return results\n\n\n# ---------------------------------------------------------------------------\n# TF-001: Transformer-coupled SMPS feedback\n# ---------------------------------------------------------------------------\n\n_FLYBACK_CONTROLLER_KEYWORDS = (\n 'uc3842', 'uc3843', 'uc3844', 'uc3845', 'uc284', 'uc384',\n 'lt3748', 'lt3573', 'lt3798',\n 'top2', 'top3', 'tnr', 'tny2', 'tny3',\n 'viper', 'viper22', 'viper53',\n 'lnk3', 'lnk6',\n 'ob2263', 'ob2269',\n 'mp15', 'mp17',\n 'ap393', 'fsd2',\n)\n\n_OPTOCOUPLER_KEYWORDS = (\n 'pc817', 'el817', 'tlp', 'sfh6', 'hcpl', 'acpl', 'cny17',\n 'ps2501', 'ps2561', '4n25', '4n35', 'moc3',\n 'fod8', 'fod3', 'vo618', 'vo3120',\n)\n\n_TL431_KEYWORDS = ('tl431', 'tlv431', 'lm431', 'ka431', 'az431', 'lmv431')\n\n\ndef detect_transformer_feedback(ctx: AnalysisContext) -> list[dict]:\n \"\"\"TF-001: Detect isolated SMPS feedback loops (optocoupler + TL431).\"\"\"\n results: list[dict] = []\n controllers = [ic for ic in get_unique_ics(ctx) if match_ic_keywords(ic, _FLYBACK_CONTROLLER_KEYWORDS)]\n optocouplers = [c for c in ctx.components\n if match_ic_keywords(c, _OPTOCOUPLER_KEYWORDS)\n or 'optocoupler' in (c.get('lib_id', '') + c.get('value', '')).lower()]\n tl431s = [c for c in ctx.components\n if match_ic_keywords(c, _TL431_KEYWORDS) or 'tl431' in c.get('value', '').lower()]\n\n if not controllers:\n return results\n\n for ctrl in controllers:\n ref = ctrl['reference']\n entry = {\n 'detector': 'detect_transformer_feedback', 'rule_id': 'TF-001',\n 'category': 'isolated_power', 'reference': ref, 'value': ctrl.get('value', ''),\n 'controller_type': 'flyback', 'optocoupler': None, 'shunt_reference': None,\n 'severity': 'info', 'confidence': 'heuristic', 'evidence_source': 'topology',\n 'summary': f'Isolated SMPS controller {ref} ({ctrl.get(\"value\", \"\")})',\n 'description': f'Detected flyback/isolated SMPS controller {ref}.',\n 'components': [ref], 'nets': [], 'pins': [], 'recommendation': '',\n 'report_context': {'section': 'Isolated Power', 'impact': '', 'standard_ref': ''},\n }\n fb_net = _get_pin_net_domain(ctx, ref, ('FB', 'COMP', 'VFB', 'OPTO'))\n if fb_net:\n for opto in optocouplers:\n opto_ref = opto['reference']\n for pnum, (net, _) in ctx.ref_pins.get(opto_ref, {}).items():\n if net == fb_net:\n entry['optocoupler'] = {'ref': opto_ref, 'value': opto.get('value', '')}\n entry['components'].append(opto_ref)\n break\n if entry['optocoupler']:\n opto_ref = entry['optocoupler']['ref']\n for pnum, (net, _) in ctx.ref_pins.get(opto_ref, {}).items():\n if not net:\n continue\n for tl in tl431s:\n tl_ref = tl['reference']\n for tp, (tnet, _) in ctx.ref_pins.get(tl_ref, {}).items():\n if tnet == net:\n entry['shunt_reference'] = {'ref': tl_ref, 'value': tl.get('value', '')}\n entry['components'].append(tl_ref)\n break\n entry['confidence'] = 'deterministic'\n entry['description'] = (\n f'Isolated SMPS {ref} with optocoupler feedback via {entry[\"optocoupler\"][\"ref\"]}'\n f'{\" and \" + entry[\"shunt_reference\"][\"ref\"] + \" shunt reference\" if entry[\"shunt_reference\"] else \"\"}.'\n )\n entry['provenance'] = make_provenance('smps_transformer_topology', 'deterministic', claimed_components=[ref])\n results.append(entry)\n return results\n\n\n# ---------------------------------------------------------------------------\n# IA-001: I2C address conflict detection\n# ---------------------------------------------------------------------------\n\n_I2C_DEVICE_ADDRESSES = {\n 'ina219': {'base': 0x40, 'pins': ('A0', 'A1')},\n 'ina226': {'base': 0x40, 'pins': ('A0', 'A1')},\n 'ina228': {'base': 0x40, 'pins': ('A0', 'A1')},\n 'pca9685': {'base': 0x40, 'pins': ('A0', 'A1', 'A2', 'A3', 'A4', 'A5')},\n 'ads1115': {'base': 0x48, 'pins': ('ADDR',)},\n 'ads1015': {'base': 0x48, 'pins': ('ADDR',)},\n 'bme280': {'base': 0x76, 'pins': ('SDO',)},\n 'bmp280': {'base': 0x76, 'pins': ('SDO',)},\n 'sht3': {'base': 0x44, 'pins': ('ADDR',)},\n 'mcp4725': {'base': 0x60, 'pins': ('A0', 'A1', 'A2')},\n 'at24c': {'base': 0x50, 'pins': ('A0', 'A1', 'A2')},\n 'pcf8574': {'base': 0x20, 'pins': ('A0', 'A1', 'A2')},\n 'pcf8575': {'base': 0x20, 'pins': ('A0', 'A1', 'A2')},\n 'mcp23017': {'base': 0x20, 'pins': ('A0', 'A1', 'A2')},\n 'ds1307': {'base': 0x68, 'pins': ()},\n 'ds3231': {'base': 0x68, 'pins': ()},\n}\n\n\ndef _resolve_addr_pin(ctx: AnalysisContext, ref: str, pin_name: str) -> str:\n net = _get_pin_net_domain(ctx, ref, (pin_name,))\n if not net:\n return 'unknown'\n if ctx.is_ground(net):\n return 'gnd'\n if ctx.is_power_net(net):\n return 'vcc'\n net_lower = net.lower()\n if 'sda' in net_lower:\n return 'sda'\n if 'scl' in net_lower:\n return 'scl'\n return 'unknown'\n\n\ndef detect_i2c_address_conflicts(ctx: AnalysisContext) -> list[dict]:\n \"\"\"IA-001: Detect I2C devices with potentially conflicting addresses.\"\"\"\n results: list[dict] = []\n buses: dict = {}\n\n for ic in get_unique_ics(ctx):\n ref = ic['reference']\n combined = (ic.get('value', '') + ' ' + ic.get('lib_id', '')).lower()\n device_type = None\n for keyword in _I2C_DEVICE_ADDRESSES:\n if keyword in combined:\n device_type = keyword\n break\n if not device_type:\n continue\n sda_net = _get_pin_net_domain(ctx, ref, ('SDA', 'I2C_SDA'))\n scl_net = _get_pin_net_domain(ctx, ref, ('SCL', 'I2C_SCL'))\n if not sda_net or not scl_net:\n continue\n dev_info = _I2C_DEVICE_ADDRESSES[device_type]\n addr_config = {pin: _resolve_addr_pin(ctx, ref, pin) for pin in dev_info['pins']}\n buses.setdefault((sda_net, scl_net), []).append({\n 'ref': ref, 'value': ic.get('value', ''), 'device_type': device_type,\n 'addr_config': addr_config, 'base_addr': dev_info['base'],\n })\n\n for (sda_net, scl_net), devices in buses.items():\n addr_groups: dict = {}\n for dev in devices:\n config_str = f\"{dev['device_type']}:{','.join(f'{k}={v}' for k, v in sorted(dev['addr_config'].items()))}\"\n addr_groups.setdefault(config_str, []).append(dev)\n for config_str, group in addr_groups.items():\n if len(group) \u003c 2:\n continue\n refs = [d['ref'] for d in group]\n dev_type = group[0]['device_type']\n base = group[0]['base_addr']\n results.append(make_finding(\n detector='detect_i2c_address_conflicts', rule_id='IA-001', category='protocol_integrity',\n summary=f'I2C address conflict: {len(refs)}x {dev_type} at same address on {sda_net}/{scl_net}',\n description=f'{len(refs)} instances of {dev_type} ({\", \".join(refs)}) have identical address configs ({config_str.split(\":\", 1)[1]}).',\n severity='error', confidence='deterministic', evidence_source='topology',\n components=refs, nets=[sda_net, scl_net],\n recommendation='Reconfigure address pins (A0/A1/A2) to assign unique addresses.',\n fix_params={'type': 'swap_connection', 'components': refs[1:], 'change': 'Rewire address pins', 'basis': f'{dev_type} base 0x{base:02X}'},\n impact='Bus corruption — multiple devices respond to same address',\n provenance=make_provenance('i2c_addr_conflict', 'deterministic', claimed_components=refs),\n ))\n return results\n\n\n# ---------------------------------------------------------------------------\n# SC-001: Supercapacitor / energy harvesting\n# ---------------------------------------------------------------------------\n\n_HARVESTER_KEYWORDS = (\n 'bq25570', 'bq25504', 'bq25505',\n 'ltc3108', 'ltc3109', 'ltc3588',\n 'spv1040', 'spv1050',\n 'adp5090', 'adp5091', 'adp5092',\n 'max20361',\n 'ab1815',\n 'mb39c', 'mb39d',\n 'e_peas', 'aem1094', 'aem3094',\n)\n\n_SUPERCAP_KEYWORDS = ('supercap', 'edlc', 'ultracap', 'gold_cap', 'super_cap')\n\n\ndef detect_energy_harvesting(ctx: AnalysisContext) -> list[dict]:\n \"\"\"SC-001: Detect energy harvesting ICs and supercapacitor circuits.\"\"\"\n results: list[dict] = []\n for ic in get_unique_ics(ctx):\n if not match_ic_keywords(ic, _HARVESTER_KEYWORDS):\n continue\n ref = ic['reference']\n results.append({\n 'detector': 'detect_energy_harvesting', 'rule_id': 'SC-001',\n 'category': 'energy_harvesting', 'reference': ref, 'value': ic.get('value', ''),\n 'harvester_type': 'energy_harvesting_ic',\n 'severity': 'info', 'confidence': 'deterministic', 'evidence_source': 'topology',\n 'summary': f'Energy harvesting IC {ref} ({ic.get(\"value\", \"\")})',\n 'description': f'Detected energy harvesting PMIC {ref} ({ic.get(\"value\", \"\")}).',\n 'components': [ref], 'nets': [], 'pins': [], 'recommendation': '',\n 'report_context': {'section': 'Energy Harvesting', 'impact': '', 'standard_ref': ''},\n 'provenance': make_provenance('eharvest_topology', 'heuristic', claimed_components=[ref]),\n })\n for comp in ctx.components:\n if comp['type'] != 'capacitor':\n continue\n val = comp.get('value', '').lower()\n lib = comp.get('lib_id', '').lower()\n combined = val + ' ' + lib\n farads = ctx.parsed_values.get(comp['reference'])\n is_supercap = any(k in combined for k in _SUPERCAP_KEYWORDS)\n if not is_supercap and farads is not None and farads >= 0.1:\n is_supercap = True\n if is_supercap:\n results.append({\n 'detector': 'detect_energy_harvesting', 'rule_id': 'SC-001',\n 'category': 'energy_harvesting', 'reference': comp['reference'],\n 'value': comp.get('value', ''), 'harvester_type': 'supercapacitor',\n 'capacitance_F': farads,\n 'severity': 'info', 'confidence': 'deterministic', 'evidence_source': 'topology',\n 'summary': f'Supercapacitor {comp[\"reference\"]} ({comp.get(\"value\", \"\")})',\n 'description': f'Detected supercapacitor {comp[\"reference\"]}.',\n 'components': [comp['reference']], 'nets': [], 'pins': [], 'recommendation': '',\n 'report_context': {'section': 'Energy Harvesting', 'impact': '', 'standard_ref': ''},\n 'provenance': make_provenance('eharvest_topology', 'heuristic', claimed_components=[comp['reference']]),\n })\n return results\n\n\n# ---------------------------------------------------------------------------\n# PL-001: PWM LED dimming topology\n# ---------------------------------------------------------------------------\n\ndef detect_pwm_led_dimming(ctx: AnalysisContext, transistor_circuits: list[dict]) -> list[dict]:\n \"\"\"PL-001: Detect transistor-driven LED circuits.\n\n Finds transistors whose load_type is 'led' or whose collector/drain net\n connects to LEDs. Covers both MOSFET and BJT LED drivers.\n \"\"\"\n results: list[dict] = []\n for tc in transistor_circuits:\n ref = tc.get('reference', '')\n leds_on_load: list[str] = []\n\n if tc.get('load_type') == 'led':\n for net_key in ('collector_net', 'drain_net'):\n load_net = tc.get(net_key, '')\n if load_net and load_net in ctx.nets:\n for p in ctx.nets[load_net]['pins']:\n comp = ctx.comp_lookup.get(p['component'])\n if comp and comp['type'] == 'led' and p['component'] not in leds_on_load:\n leds_on_load.append(p['component'])\n\n if not leds_on_load:\n continue\n\n sense_r = tc.get('emitter_resistor') or tc.get('source_resistor')\n load_net = tc.get('collector_net', tc.get('drain_net', ''))\n\n results.append({\n 'detector': 'detect_pwm_led_dimming', 'rule_id': 'PL-001',\n 'category': 'led_control', 'reference': ref,\n 'transistor_type': tc.get('type', ''),\n 'leds': leds_on_load,\n 'sense_resistor': sense_r,\n 'severity': 'info', 'confidence': 'deterministic', 'evidence_source': 'topology',\n 'summary': f'Transistor-driven LED: {ref} driving {\", \".join(leds_on_load)}',\n 'description': (\n f'Transistor {ref} ({tc.get(\"value\", \"\")}) drives LED(s) '\n f'{\", \".join(leds_on_load)} {\"with\" if sense_r else \"without\"} '\n f'current sense resistor.'\n ),\n 'components': [ref] + leds_on_load, 'nets': [load_net] if load_net else [],\n 'pins': [],\n 'recommendation': '' if sense_r else 'Consider adding a current sense resistor for LED current regulation.',\n 'report_context': {'section': 'LED Control', 'impact': '', 'standard_ref': ''},\n 'provenance': make_provenance('pwm_led_topology', 'deterministic', claimed_components=[ref]),\n })\n return results\n\n\n# ---------------------------------------------------------------------------\n# AH-001: Audio headphone jack switch detection\n# ---------------------------------------------------------------------------\n\n_AUDIO_CODEC_KEYWORDS = (\n 'wm8', 'wm8960', 'wm8978', 'wm8731', 'wm8994',\n 'sgtl5000', 'tlv320', 'cs42', 'cs43', 'cs47',\n 'es8388', 'es8311', 'es7210',\n 'max9', 'max98',\n 'ssm26', 'adau17',\n 'ak4', 'ak5',\n 'pcm51', 'pcm17', 'pcm29', 'pcm31',\n 'tas2', 'tas5', 'tas6',\n 'nau88',\n)\n\n_HP_JACK_KEYWORDS = ('headphone', 'hp_jack', 'audio_jack', 'phone_jack', 'trrs', 'trs')\n_HP_DET_PIN_NAMES = ('HP_DET', 'HPDET', 'JACKDET', 'JACK_DET', 'DETECT', 'DET', 'SENSE', 'SW')\n\n\ndef detect_headphone_jack(ctx: AnalysisContext) -> list[dict]:\n \"\"\"AH-001: Detect headphone jack insertion detection and codec wiring.\"\"\"\n results: list[dict] = []\n codecs = [ic for ic in get_unique_ics(ctx) if match_ic_keywords(ic, _AUDIO_CODEC_KEYWORDS)]\n hp_jacks = [c for c in ctx.components if c['type'] == 'connector'\n and any(k in (c.get('value', '') + ' ' + c.get('lib_id', '')).lower() for k in _HP_JACK_KEYWORDS)]\n if not hp_jacks:\n return results\n\n for jack in hp_jacks:\n ref = jack['reference']\n det_net = _get_pin_net_domain(ctx, ref, _HP_DET_PIN_NAMES)\n entry = {\n 'detector': 'detect_headphone_jack', 'rule_id': 'AH-001',\n 'category': 'audio', 'reference': ref, 'value': jack.get('value', ''),\n 'detection_pin_net': det_net, 'associated_codec': None,\n 'severity': 'info', 'confidence': 'heuristic', 'evidence_source': 'topology',\n 'summary': f'Headphone jack {ref} ({jack.get(\"value\", \"\")})',\n 'description': f'Detected headphone jack {ref}.',\n 'components': [ref], 'nets': [], 'pins': [], 'recommendation': '',\n 'report_context': {'section': 'Audio', 'impact': '', 'standard_ref': ''},\n }\n jack_pins = ctx.ref_pins.get(ref, {})\n for codec in codecs:\n codec_ref = codec['reference']\n codec_nets = set(net for net, _ in ctx.ref_pins.get(codec_ref, {}).values() if net)\n jack_nets = set(net for net, _ in jack_pins.values() if net)\n shared = {n for n in (codec_nets & jack_nets) if n and not ctx.is_power_net(n) and not ctx.is_ground(n)}\n if shared:\n entry['associated_codec'] = {'ref': codec_ref, 'value': codec.get('value', '')}\n entry['components'].append(codec_ref)\n entry['nets'] = list(shared)[:5]\n break\n if not det_net and entry['associated_codec']:\n entry['recommendation'] = (\n f'Headphone jack {ref} has no insertion detection pin connected. '\n f'Consider connecting switch pin to codec {entry[\"associated_codec\"][\"ref\"]} HP_DET input.'\n )\n entry['provenance'] = make_provenance('headphone_jack_topology', 'deterministic', claimed_components=[ref])\n results.append(entry)\n return results\n","content_type":"text/x-python; charset=utf-8","language":"python","size":257723,"content_sha256":"71bb59623886e37eb0e29aaddce14b4a2d7d3eeec4c4854921162da148973542"},{"filename":"scripts/export_issues.py","content":"#!/usr/bin/env python3\n\"\"\"Export analysis findings to GitHub Issues.\n\nReads analyzer JSON output and creates GitHub Issues via the ``gh`` CLI.\nDry-run by default — previews issues to stdout. Use ``--create`` to push.\nLabel-based dedup prevents duplicates.\n\nUsage:\n export_issues.py schematic.json --repo owner/repo\n export_issues.py schematic.json --repo owner/repo --severity warning\n export_issues.py schematic.json --repo owner/repo --rule-id RG-001,CP-001\n export_issues.py schematic.json --repo owner/repo --create\n\nPython 3.8+ stdlib only. Requires ``gh`` CLI (https://cli.github.com/).\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport json\nimport os\nimport subprocess\nimport sys\n\n# ---------------------------------------------------------------------------\n# Severity ranking: lower number = higher priority\n# ---------------------------------------------------------------------------\n\n_SEV_RANK: dict[str, int] = {\n \"critical\": 0,\n \"high\": 0,\n \"error\": 0,\n \"warning\": 1,\n \"medium\": 1,\n \"info\": 2,\n \"low\": 2,\n}\n\n\n# ---------------------------------------------------------------------------\n# Helpers\n# ---------------------------------------------------------------------------\n\ndef _norm_severity(s: str) -> str:\n \"\"\"Normalize a severity string to one of: high / warning / info.\"\"\"\n s = (s or \"\").lower().strip()\n if s in (\"critical\", \"high\", \"error\"):\n return \"high\"\n if s in (\"warning\", \"medium\", \"warn\"):\n return \"warning\"\n return \"info\"\n\n\ndef load_findings(path: str) -> list[dict]:\n \"\"\"Load an analyzer JSON file and return findings that have a rule_id.\"\"\"\n if not os.path.isfile(path):\n raise SystemExit(f\"error: file not found: {path!r}\")\n try:\n with open(path, \"r\", encoding=\"utf-8\") as fh:\n data = json.load(fh)\n except (OSError, json.JSONDecodeError) as exc:\n raise SystemExit(f\"error: cannot read {path!r}: {exc}\") from exc\n\n raw = data.get(\"findings\", [])\n if not isinstance(raw, list):\n raise SystemExit(\n f\"error: 'findings' key in {path!r} is not a list\"\n )\n return [f for f in raw if isinstance(f, dict) and f.get(\"rule_id\")]\n\n\ndef filter_findings(\n findings: list[dict],\n severity: \"str | None\",\n rule_ids: \"list[str] | None\",\n) -> list[dict]:\n \"\"\"Filter findings by severity threshold and/or explicit rule ID list.\n\n ``severity`` is the *minimum* severity — all findings at that level or\n more severe are included. ``rule_ids`` is an exact-match whitelist (any\n case).\n \"\"\"\n result = findings\n\n if severity:\n threshold_rank = _SEV_RANK.get(_norm_severity(severity), 2)\n result = [\n f for f in result\n if _SEV_RANK.get(_norm_severity(f.get(\"severity\", \"info\")), 2)\n \u003c= threshold_rank\n ]\n\n if rule_ids:\n upper = {r.upper() for r in rule_ids}\n result = [\n f for f in result\n if (f.get(\"rule_id\") or \"\").upper() in upper\n ]\n\n return result\n\n\ndef format_issue_title(finding: dict) -> str:\n \"\"\"Build an issue title: ``[{rule_id}] {summary}``.\n\n If the finding has exactly one component and its reference is not already\n mentioned in the summary, it is appended in parentheses.\n \"\"\"\n rule_id: str = finding.get(\"rule_id\", \"\")\n summary: str = (finding.get(\"summary\") or \"\").strip()\n title = f\"[{rule_id}] {summary}\"\n\n components: list = finding.get(\"components\") or []\n if len(components) == 1:\n ref = \"\"\n comp = components[0]\n if isinstance(comp, dict):\n ref = comp.get(\"ref\") or comp.get(\"reference\") or comp.get(\"name\") or \"\"\n elif isinstance(comp, str):\n ref = comp\n if ref and ref not in summary:\n title = f\"{title} ({ref})\"\n\n return title\n\n\ndef format_issue_body(finding: dict) -> str:\n \"\"\"Build a structured markdown body for a GitHub Issue.\n\n Sections included (in order):\n - Metadata table (Rule, Severity, Confidence, Evidence, Category, Detector)\n - ## Summary\n - ## Description\n - ## Components (omitted if empty)\n - ## Nets (omitted if empty)\n - ## Recommendation (omitted if empty)\n - Footer attribution line\n \"\"\"\n rule_id = finding.get(\"rule_id\", \"\")\n severity = _norm_severity(finding.get(\"severity\", \"info\"))\n confidence = finding.get(\"confidence\", \"\")\n evidence = finding.get(\"evidence_source\", \"\")\n category = finding.get(\"category\", \"\")\n detector = finding.get(\"detector\", \"\")\n\n lines: list[str] = []\n\n # --- Metadata table ---\n lines.append(\"| Field | Value |\")\n lines.append(\"|-------|-------|\")\n lines.append(f\"| **Rule** | `{rule_id}` |\")\n lines.append(f\"| **Severity** | {severity} |\")\n if confidence:\n lines.append(f\"| **Confidence** | {confidence} |\")\n if evidence:\n lines.append(f\"| **Evidence** | {evidence} |\")\n if category:\n lines.append(f\"| **Category** | {category} |\")\n if detector:\n lines.append(f\"| **Detector** | `{detector}` |\")\n lines.append(\"\")\n\n # --- Summary ---\n summary = (finding.get(\"summary\") or \"\").strip()\n if summary:\n lines.append(\"## Summary\")\n lines.append(\"\")\n lines.append(summary)\n lines.append(\"\")\n\n # --- Description ---\n description = (finding.get(\"description\") or \"\").strip()\n if description:\n lines.append(\"## Description\")\n lines.append(\"\")\n lines.append(description)\n lines.append(\"\")\n\n # --- Components ---\n components: list = finding.get(\"components\") or []\n if components:\n lines.append(\"## Components\")\n lines.append(\"\")\n for comp in components:\n if isinstance(comp, dict):\n ref = comp.get(\"ref\") or comp.get(\"reference\") or comp.get(\"name\") or \"\"\n val = comp.get(\"value\") or \"\"\n fp = comp.get(\"footprint\") or \"\"\n parts = [f\"`{ref}`\" if ref else \"\"]\n if val:\n parts.append(val)\n if fp:\n parts.append(f\"({fp})\")\n lines.append(f\"- {' '.join(p for p in parts if p)}\")\n elif isinstance(comp, str):\n lines.append(f\"- `{comp}`\")\n lines.append(\"\")\n\n # --- Nets ---\n nets: list = finding.get(\"nets\") or []\n if nets:\n lines.append(\"## Nets\")\n lines.append(\"\")\n for net in nets:\n if isinstance(net, str):\n lines.append(f\"- `{net}`\")\n elif isinstance(net, dict):\n name = net.get(\"name\") or net.get(\"net\") or str(net)\n lines.append(f\"- `{name}`\")\n lines.append(\"\")\n\n # --- Recommendation ---\n recommendation = (finding.get(\"recommendation\") or \"\").strip()\n if recommendation:\n lines.append(\"## Recommendation\")\n lines.append(\"\")\n lines.append(recommendation)\n lines.append(\"\")\n\n # --- Footer ---\n lines.append(\n \"*Generated by [kicad-happy](https://github.com/aklofas/kicad-happy)*\"\n )\n\n return \"\\n\".join(lines)\n\n\ndef issue_labels(finding: dict, extra_labels: \"list[str]\") -> list[str]:\n \"\"\"Return the label list for a finding.\n\n Always includes:\n - ``kicad-happy``\n - ``kicad-happy:{rule_id}``\n - ``severity:{severity}``\n - ``confidence:{confidence}`` (if present)\n - ``evidence:{evidence_source}`` (if present)\n\n Plus any caller-supplied extra labels.\n \"\"\"\n rule_id = finding.get(\"rule_id\", \"\")\n severity = _norm_severity(finding.get(\"severity\", \"info\"))\n labels = [\n \"kicad-happy\",\n f\"kicad-happy:{rule_id}\",\n f\"severity:{severity}\",\n ]\n confidence = finding.get(\"confidence\", \"\")\n if confidence:\n labels.append(f\"confidence:{confidence}\")\n evidence = finding.get(\"evidence_source\", \"\")\n if evidence:\n labels.append(f\"evidence:{evidence}\")\n labels.extend(extra_labels or [])\n return labels\n\n\n# ---------------------------------------------------------------------------\n# GitHub CLI integration\n# ---------------------------------------------------------------------------\n\ndef check_gh_available() -> bool:\n \"\"\"Return True if ``gh`` is installed and authenticated.\"\"\"\n try:\n result = subprocess.run(\n [\"gh\", \"auth\", \"status\"],\n capture_output=True,\n timeout=15,\n )\n return result.returncode == 0\n except (FileNotFoundError, subprocess.TimeoutExpired):\n return False\n\n\ndef find_existing_issues(repo: str, rule_id: str) -> list[dict]:\n \"\"\"Return open issues that carry the dedup label ``kicad-happy:{rule_id}``.\n\n Uses ``gh issue list --json number,title`` for machine-readable output.\n Returns an empty list on any error.\n \"\"\"\n label = f\"kicad-happy:{rule_id}\"\n try:\n result = subprocess.run(\n [\n \"gh\", \"issue\", \"list\",\n \"--repo\", repo,\n \"--label\", label,\n \"--state\", \"open\",\n \"--json\", \"number,title\",\n ],\n capture_output=True,\n text=True,\n timeout=30,\n )\n if result.returncode != 0:\n return []\n return json.loads(result.stdout) if result.stdout.strip() else []\n except (FileNotFoundError, subprocess.TimeoutExpired, json.JSONDecodeError):\n return []\n\n\ndef create_issue(\n repo: str,\n title: str,\n body: str,\n labels: list[str],\n assignee: \"str | None\",\n milestone: \"str | None\",\n) -> \"str | None\":\n \"\"\"Create a GitHub Issue via ``gh``.\n\n Returns the new issue URL on success, or None on failure.\n Stderr from ``gh`` is forwarded to our stderr so the caller can see errors.\n \"\"\"\n cmd = [\n \"gh\", \"issue\", \"create\",\n \"--repo\", repo,\n \"--title\", title,\n \"--body\", body,\n ]\n for label in labels:\n cmd += [\"--label\", label]\n if assignee:\n cmd += [\"--assignee\", assignee]\n if milestone:\n cmd += [\"--milestone\", milestone]\n\n try:\n result = subprocess.run(\n cmd,\n capture_output=True,\n text=True,\n timeout=60,\n )\n if result.returncode == 0:\n return result.stdout.strip() or None\n # Forward gh's error output so the user understands what went wrong\n if result.stderr:\n print(f\" gh error: {result.stderr.strip()}\", file=sys.stderr)\n return None\n except (FileNotFoundError, subprocess.TimeoutExpired) as exc:\n print(f\" subprocess error: {exc}\", file=sys.stderr)\n return None\n\n\n# ---------------------------------------------------------------------------\n# Main\n# ---------------------------------------------------------------------------\n\ndef main(argv: \"list[str] | None\" = None) -> int: # noqa: C901 (intentionally long)\n ap = argparse.ArgumentParser(\n description=\"Export analysis findings to GitHub Issues via gh CLI.\",\n formatter_class=argparse.RawDescriptionHelpFormatter,\n epilog=__doc__,\n )\n ap.add_argument(\n \"analysis_json\",\n help=\"Path to analyzer JSON (schematic/PCB/EMC/thermal)\",\n )\n ap.add_argument(\n \"--repo\",\n required=True,\n help=\"Target GitHub repository (owner/repo)\",\n )\n ap.add_argument(\n \"--create\",\n action=\"store_true\",\n help=\"Actually create issues (default: dry-run preview)\",\n )\n ap.add_argument(\n \"--severity\",\n help=\"Minimum severity filter (high/warning/info)\",\n )\n ap.add_argument(\n \"--rule-id\",\n dest=\"rule_id\",\n help=\"Export specific rule IDs only (comma-separated)\",\n )\n ap.add_argument(\n \"--label\",\n action=\"append\",\n default=[],\n dest=\"labels\",\n metavar=\"LABEL\",\n help=\"Additional labels to apply (repeatable)\",\n )\n ap.add_argument(\n \"--assignee\",\n help=\"Assign issues to this GitHub user\",\n )\n ap.add_argument(\n \"--milestone\",\n help=\"Add issues to this milestone\",\n )\n ap.add_argument(\n \"--json\",\n action=\"store_true\",\n help=\"Output dry-run preview as JSON\",\n )\n args = ap.parse_args(argv)\n\n # Validate --severity if provided\n if args.severity:\n known = {\"critical\", \"high\", \"error\", \"warning\", \"medium\", \"warn\", \"info\", \"low\"}\n if args.severity.lower() not in known:\n ap.error(\n f\"unknown --severity {args.severity!r} — \"\n \"accepted: high/critical/error, warning/medium/warn, info/low\"\n )\n\n rule_ids: \"list[str] | None\" = None\n if args.rule_id:\n rule_ids = [r.strip() for r in args.rule_id.split(\",\") if r.strip()]\n\n all_findings = load_findings(args.analysis_json)\n findings = filter_findings(all_findings, args.severity, rule_ids)\n\n if not findings:\n print(\n f\"No findings match the given filters \"\n f\"(loaded {len(all_findings)} total from {args.analysis_json!r}).\"\n )\n return 2\n\n # --create mode requires gh\n if args.create and not check_gh_available():\n print(\n \"error: gh CLI is not available or not authenticated.\\n\"\n \"Install: https://cli.github.com/\\n\"\n \"Authenticate: gh auth login\",\n file=sys.stderr,\n )\n return 1\n\n # Sort findings by severity rank, then rule_id for stable output\n findings.sort(\n key=lambda f: (\n _SEV_RANK.get(_norm_severity(f.get(\"severity\", \"info\")), 2),\n f.get(\"rule_id\", \"\"),\n )\n )\n\n # -----------------------------------------------------------------------\n # Dry-run paths\n # -----------------------------------------------------------------------\n if not args.create:\n if args.json:\n # Machine-readable dry-run\n issues_out = []\n for finding in findings:\n title = format_issue_title(finding)\n body = format_issue_body(finding)\n labels = issue_labels(finding, args.labels)\n issues_out.append({\n \"rule_id\": finding.get(\"rule_id\"),\n \"severity\": _norm_severity(finding.get(\"severity\", \"info\")),\n \"title\": title,\n \"body\": body,\n \"labels\": labels,\n \"assignee\": args.assignee,\n \"milestone\": args.milestone,\n })\n payload = {\n \"schema\": \"export_issues/1\",\n \"repo\": args.repo,\n \"source\": args.analysis_json,\n \"dry_run\": True,\n \"count\": len(issues_out),\n \"issues\": issues_out,\n \"skipped\": [],\n }\n json.dump(payload, sys.stdout, indent=2)\n sys.stdout.write(\"\\n\")\n return 0\n\n # Human-readable dry-run\n sep = \"=\" * 72\n print(sep)\n print(f\"DRY-RUN: {len(findings)} issue(s) would be created in {args.repo!r}\")\n print(f\"Source: {args.analysis_json}\")\n print(sep)\n for i, finding in enumerate(findings, 1):\n title = format_issue_title(finding)\n body = format_issue_body(finding)\n labels = issue_labels(finding, args.labels)\n print(f\"\\n[{i}/{len(findings)}] {title}\")\n print(f\"Labels: {', '.join(labels)}\")\n if args.assignee:\n print(f\"Assignee: {args.assignee}\")\n if args.milestone:\n print(f\"Milestone: {args.milestone}\")\n print(\"-\" * 72)\n print(body)\n print(sep)\n print(\n f\"\\nSummary: {len(findings)} issue(s) previewed. \"\n \"Run with --create to push.\"\n )\n return 0\n\n # -----------------------------------------------------------------------\n # --create mode\n # -----------------------------------------------------------------------\n\n # Batch dedup: query once per unique rule_id, not once per finding\n unique_rule_ids = {f.get(\"rule_id\", \"\") for f in findings}\n existing_by_rule = {\n rid: find_existing_issues(args.repo, rid) for rid in unique_rule_ids if rid\n }\n\n created = 0\n skipped = 0\n errors = 0\n\n for finding in findings:\n rule_id = finding.get(\"rule_id\", \"\")\n\n # Dedup check (uses pre-fetched results)\n existing = existing_by_rule.get(rule_id, [])\n if existing:\n nums = \", \".join(f\"#{e['number']}\" for e in existing)\n summary = (finding.get(\"summary\") or \"\")[:60]\n print(f\"SKIP [{rule_id}] {summary} (already open: {nums})\")\n skipped += 1\n continue\n\n title = format_issue_title(finding)\n body = format_issue_body(finding)\n labels = issue_labels(finding, args.labels)\n url = create_issue(\n args.repo, title, body, labels, args.assignee, args.milestone\n )\n if url:\n print(f\"CREATED {url}\")\n created += 1\n else:\n print(f\"ERROR [{rule_id}] {title[:60]}\", file=sys.stderr)\n errors += 1\n\n print(\n f\"\\nDone: {created} created, {skipped} skipped (duplicate), \"\n f\"{errors} error(s).\"\n )\n return 0 if errors == 0 else 1\n\n\nif __name__ == \"__main__\":\n sys.exit(main())\n","content_type":"text/x-python; charset=utf-8","language":"python","size":17497,"content_sha256":"a34c3aa214ef00d2547b2a0e16ff7fa53f66ce742a842c9ce13c6d3ba86ff01e"},{"filename":"scripts/fab_release_gate.py","content":"#!/usr/bin/env python3\n\"\"\"\nFabrication release gate for KiCad designs.\n\n\"Ready for fab?\" check that consumes existing analyzer JSON outputs and\nproduces a structured pass/fail gate with categorized checks.\n\nUsage:\n python3 fab_release_gate.py --schematic sch.json --pcb pcb.json\n python3 fab_release_gate.py --schematic sch.json --pcb pcb.json --gerbers gerbers.json\n python3 fab_release_gate.py --schematic sch.json --pcb pcb.json --text\n python3 fab_release_gate.py --schematic sch.json --pcb pcb.json --strict\n\nZero external dependencies — Python 3.8+ stdlib only.\n\"\"\"\n\nimport argparse\nimport json\nimport os\nimport sys\nimport time\nfrom typing import Any, Dict, List, Optional\n\n\nGATE_VERSION = \"1.0\"\n\n\n# ---------------------------------------------------------------------------\n# Check result structure\n# ---------------------------------------------------------------------------\n\ndef _check(category: str, check_id: str, status: str, message: str,\n details: Optional[Dict] = None) -> Dict[str, Any]:\n \"\"\"Build a gate check result.\"\"\"\n return {\n \"category\": category,\n \"check_id\": check_id,\n \"status\": status, # pass, warn, fail, skip\n \"message\": message,\n \"details\": details,\n }\n\n\n# ---------------------------------------------------------------------------\n# Individual checks\n# ---------------------------------------------------------------------------\n\ndef check_routing(pcb: Dict) -> List[Dict]:\n \"\"\"Check PCB routing completeness.\"\"\"\n conn = pcb.get(\"connectivity\", {})\n total = conn.get(\"total_nets_with_pads\", 0)\n unrouted = conn.get(\"unrouted_count\", 0)\n complete = conn.get(\"routing_complete\", False)\n\n if complete or unrouted == 0:\n return [_check(\"routing\", \"routing_completeness\", \"pass\",\n f\"All nets routed ({total}/{total})\")]\n\n unrouted_list = [u.get(\"net_name\", \"?\")\n for u in conn.get(\"unrouted\", [])[:10]]\n return [_check(\"routing\", \"routing_completeness\", \"fail\",\n f\"{unrouted} unrouted net(s) out of {total}\",\n {\"unrouted_count\": unrouted, \"unrouted_nets\": unrouted_list})]\n\n\ndef check_bom(sch: Dict) -> List[Dict]:\n \"\"\"Check BOM completeness — MPNs and footprints.\"\"\"\n checks = []\n stats = sch.get(\"statistics\", {})\n sourcing = sch.get(\"sourcing_audit\", {})\n\n # MPN coverage\n missing_mpn = (sourcing.get(\"missing_mpn\", [])\n or stats.get(\"missing_mpn\", []))\n total = stats.get(\"total_components\", 0)\n\n if not missing_mpn:\n coverage = sourcing.get(\"mpn_coverage\", f\"{total}/{total}\")\n checks.append(_check(\"bom\", \"mpn_coverage\", \"pass\",\n f\"All components have MPNs ({coverage})\"))\n else:\n checks.append(_check(\"bom\", \"mpn_coverage\", \"fail\",\n f\"{len(missing_mpn)} component(s) missing MPN\",\n {\"missing_mpn\": missing_mpn[:20],\n \"coverage\": sourcing.get(\"mpn_coverage\", \"?\")}))\n\n # Footprint assignment\n missing_fp = stats.get(\"missing_footprint\", [])\n if not missing_fp:\n checks.append(_check(\"bom\", \"footprint_assignment\", \"pass\",\n \"All components have footprints assigned\"))\n else:\n checks.append(_check(\"bom\", \"footprint_assignment\", \"fail\",\n f\"{len(missing_fp)} component(s) missing footprint\",\n {\"missing_footprint\": missing_fp[:20]}))\n\n return checks\n\n\ndef check_dfm(pcb: Dict) -> List[Dict]:\n \"\"\"Check DFM tier and violations.\"\"\"\n # dfm_summary holds tier/metrics; violations are in findings[]\n dfm = pcb.get(\"dfm_summary\", {})\n tier = dfm.get(\"dfm_tier\", \"unknown\")\n violations = [f for f in pcb.get(\"findings\", [])\n if isinstance(f, dict) and f.get(\"category\") == \"dfm\"]\n\n if tier == \"standard\" and not violations:\n return [_check(\"dfm\", \"fab_capability\", \"pass\",\n \"Design within standard fab capability\")]\n elif tier == \"advanced\":\n v_summary = [{\"parameter\": v.get(\"parameter\", v.get(\"rule_id\", \"?\")),\n \"actual_mm\": v.get(\"actual_mm\"),\n \"limit_mm\": v.get(\"standard_limit_mm\")}\n for v in violations[:5]]\n return [_check(\"dfm\", \"fab_capability\", \"warn\",\n f\"Design requires advanced process tier ({len(violations)} violation(s))\",\n {\"dfm_tier\": tier, \"violations\": v_summary})]\n elif tier in (\"challenging\", \"extreme\"):\n v_summary = [{\"parameter\": v.get(\"parameter\", v.get(\"rule_id\", \"?\")),\n \"actual_mm\": v.get(\"actual_mm\"),\n \"limit_mm\": v.get(\"advanced_limit_mm\")}\n for v in violations[:5]]\n return [_check(\"dfm\", \"fab_capability\", \"fail\",\n f\"Design requires {tier} process — verify fab house capability\",\n {\"dfm_tier\": tier, \"violations\": v_summary})]\n else:\n metrics = dfm.get(\"metrics\", {})\n if metrics:\n return [_check(\"dfm\", \"fab_capability\", \"pass\",\n f\"DFM tier: {tier}\")]\n return [_check(\"dfm\", \"fab_capability\", \"skip\",\n \"No DFM data available\")]\n\n\ndef check_documentation(pcb: Dict) -> List[Dict]:\n \"\"\"Check board documentation (revision, board name).\"\"\"\n checks = []\n silk = pcb.get(\"silkscreen\", {})\n doc_warnings = silk.get(\"documentation_warnings\", [])\n\n # Revision\n has_rev_warning = any(w.get(\"type\") == \"missing_revision\"\n for w in doc_warnings)\n if has_rev_warning:\n checks.append(_check(\"documentation\", \"revision_marking\", \"warn\",\n \"No revision marking found on silkscreen\"))\n else:\n checks.append(_check(\"documentation\", \"revision_marking\", \"pass\",\n \"Revision marking found\"))\n\n # Board name\n has_name_warning = any(w.get(\"type\") == \"missing_board_name\"\n for w in doc_warnings)\n if has_name_warning:\n checks.append(_check(\"documentation\", \"board_name\", \"warn\",\n \"No board name found on silkscreen\"))\n else:\n checks.append(_check(\"documentation\", \"board_name\", \"pass\",\n \"Board name found on silkscreen\"))\n\n return checks\n\n\ndef check_consistency(sch: Dict, pcb: Dict) -> List[Dict]:\n \"\"\"Check schematic ↔ PCB consistency.\"\"\"\n checks = []\n sch_stats = sch.get(\"statistics\", {})\n pcb_stats = pcb.get(\"statistics\", {})\n\n # Component count comparison\n # Schematic count: total minus power symbols, test points, mounting holes, DNP\n sch_total = sch_stats.get(\"total_components\", 0)\n types = sch_stats.get(\"component_types\", {})\n # These are already excluded from total_components in the schematic analyzer\n # (power_symbol, power_flag, flag are filtered out). DNP parts are counted\n # separately but included in total.\n dnp = sch_stats.get(\"dnp_parts\", 0)\n sch_placeable = sch_total - dnp\n\n pcb_fp = pcb_stats.get(\"footprint_count\",\n len(pcb.get(\"footprints\", [])))\n\n comp_diff = abs(sch_placeable - pcb_fp)\n pct_diff = (comp_diff / max(sch_placeable, 1)) * 100\n\n if comp_diff == 0:\n checks.append(_check(\"consistency\", \"component_count\", \"pass\",\n f\"Schematic ({sch_placeable} placeable) matches \"\n f\"PCB ({pcb_fp} footprints)\"))\n elif comp_diff \u003c= 3 or pct_diff \u003c= 5:\n checks.append(_check(\"consistency\", \"component_count\", \"warn\",\n f\"Small component count gap: schematic {sch_placeable} \"\n f\"vs PCB {pcb_fp} (diff {comp_diff})\",\n {\"schematic_placeable\": sch_placeable,\n \"pcb_footprints\": pcb_fp, \"difference\": comp_diff}))\n else:\n checks.append(_check(\"consistency\", \"component_count\", \"fail\",\n f\"Component count mismatch: schematic {sch_placeable} \"\n f\"vs PCB {pcb_fp} (diff {comp_diff}, {pct_diff:.0f}%)\",\n {\"schematic_placeable\": sch_placeable,\n \"pcb_footprints\": pcb_fp, \"difference\": comp_diff}))\n\n # Net count comparison\n sch_nets = sch_stats.get(\"total_nets\", 0)\n pcb_nets = pcb_stats.get(\"net_count\",\n pcb.get(\"connectivity\", {}).get(\"total_nets_with_pads\", 0))\n\n net_diff = abs(sch_nets - pcb_nets)\n if net_diff == 0:\n checks.append(_check(\"consistency\", \"net_count\", \"pass\",\n f\"Net counts match ({sch_nets})\"))\n elif net_diff \u003c= 5:\n checks.append(_check(\"consistency\", \"net_count\", \"warn\",\n f\"Small net count gap: schematic {sch_nets} \"\n f\"vs PCB {pcb_nets} (diff {net_diff})\",\n {\"schematic_nets\": sch_nets, \"pcb_nets\": pcb_nets}))\n else:\n checks.append(_check(\"consistency\", \"net_count\", \"fail\",\n f\"Net count mismatch: schematic {sch_nets} \"\n f\"vs PCB {pcb_nets} (diff {net_diff})\",\n {\"schematic_nets\": sch_nets, \"pcb_nets\": pcb_nets}))\n\n return checks\n\n\ndef check_gerbers(gerber_data: Optional[Dict]) -> List[Dict]:\n \"\"\"Check Gerber layer completeness and alignment.\"\"\"\n if not gerber_data:\n return [_check(\"gerbers\", \"layer_completeness\", \"skip\",\n \"Gerber analysis not provided\"),\n _check(\"gerbers\", \"layer_alignment\", \"skip\",\n \"Gerber analysis not provided\")]\n\n checks = []\n\n # Layer completeness\n completeness = gerber_data.get(\"completeness\", {})\n missing = completeness.get(\"missing_layers\", [])\n critical_missing = [l for l in missing\n if any(k in l.upper() for k in\n (\"F.CU\", \"B.CU\", \"EDGE\", \"F.MASK\", \"B.MASK\",\n \"FRONT_COPPER\", \"BACK_COPPER\", \"BOARD_OUTLINE\",\n \"FRONT_SOLDERMASK\", \"BACK_SOLDERMASK\"))]\n silk_missing = [l for l in missing\n if any(k in l.upper() for k in (\"SILK\", \"LEGEND\"))]\n\n if not missing:\n checks.append(_check(\"gerbers\", \"layer_completeness\", \"pass\",\n \"All expected layers present\"))\n elif critical_missing:\n checks.append(_check(\"gerbers\", \"layer_completeness\", \"fail\",\n f\"Critical layers missing: {', '.join(critical_missing)}\",\n {\"missing_layers\": missing}))\n elif silk_missing:\n checks.append(_check(\"gerbers\", \"layer_completeness\", \"warn\",\n f\"Non-critical layers missing: {', '.join(silk_missing)}\",\n {\"missing_layers\": missing}))\n else:\n checks.append(_check(\"gerbers\", \"layer_completeness\", \"warn\",\n f\"Some layers missing: {', '.join(missing[:5])}\",\n {\"missing_layers\": missing}))\n\n # Alignment\n alignment = gerber_data.get(\"alignment\", {})\n aligned = alignment.get(\"aligned\", True)\n if aligned:\n checks.append(_check(\"gerbers\", \"layer_alignment\", \"pass\",\n \"Layer coordinate ranges consistent\"))\n else:\n checks.append(_check(\"gerbers\", \"layer_alignment\", \"fail\",\n \"Layer alignment issue detected — coordinate ranges inconsistent\",\n {\"alignment\": alignment}))\n\n return checks\n\n\ndef check_thermal(thermal_data: Optional[Dict]) -> List[Dict]:\n \"\"\"Check for critical thermal findings.\"\"\"\n if not thermal_data:\n return [_check(\"thermal\", \"thermal_risk\", \"skip\",\n \"Thermal analysis not provided\")]\n\n findings = thermal_data.get(\"findings\", [])\n active = [f for f in findings if not f.get(\"suppressed\")]\n crits = [f for f in active if f.get(\"severity\") == \"CRITICAL\"]\n highs = [f for f in active if f.get(\"severity\") == \"HIGH\"]\n\n if crits:\n refs = [f.get(\"components\", [\"?\"])[0] for f in crits[:3]]\n return [_check(\"thermal\", \"thermal_risk\", \"fail\",\n f\"{len(crits)} CRITICAL thermal finding(s): {', '.join(refs)}\",\n {\"critical_count\": len(crits), \"high_count\": len(highs)})]\n elif highs:\n return [_check(\"thermal\", \"thermal_risk\", \"warn\",\n f\"{len(highs)} HIGH thermal finding(s)\",\n {\"high_count\": len(highs)})]\n else:\n score = thermal_data.get(\"summary\", {}).get(\"thermal_score\", \"?\")\n return [_check(\"thermal\", \"thermal_risk\", \"pass\",\n f\"Thermal score {score}/100 — no critical/high findings\")]\n\n\ndef check_emc(emc_data: Optional[Dict]) -> List[Dict]:\n \"\"\"Check EMC risk (advisory only — never FAIL).\"\"\"\n if not emc_data:\n return [_check(\"emc\", \"emc_risk\", \"skip\",\n \"EMC analysis not provided\")]\n\n summary = emc_data.get(\"summary\", {})\n score = summary.get(\"emc_risk_score\", 0)\n crits = summary.get(\"critical\", 0)\n\n if crits > 0:\n return [_check(\"emc\", \"emc_risk\", \"warn\",\n f\"EMC score {score}/100 — {crits} critical finding(s) (advisory)\",\n {\"emc_risk_score\": score, \"critical\": crits})]\n else:\n return [_check(\"emc\", \"emc_risk\", \"pass\",\n f\"EMC score {score}/100 — no critical findings\")]\n\n\ndef _compute_trust_posture(sch, pcb, thermal_data, emc_data):\n \"\"\"Aggregate trust_summary from all analyzer inputs into a gate posture.\n\n Returns a dict with overall trust_level, per-analyzer breakdown,\n aggregate confidence counts, and evidence blockers.\n \"\"\"\n sources = []\n if sch:\n sources.append(('schematic', sch.get('trust_summary')))\n if pcb:\n sources.append(('pcb', pcb.get('trust_summary')))\n if thermal_data:\n sources.append(('thermal', thermal_data.get('trust_summary')))\n if emc_data:\n sources.append(('emc', emc_data.get('trust_summary')))\n\n sources = [(name, ts) for name, ts in sources if ts]\n if not sources:\n return None\n\n total = 0\n det = 0\n heu = 0\n ds_backed = 0\n unknown = 0\n for _, ts in sources:\n total += ts.get('total_findings', 0)\n bc = ts.get('by_confidence', {})\n det += bc.get('deterministic', 0)\n heu += bc.get('heuristic', 0)\n ds_backed += bc.get('datasheet-backed', 0)\n unknown += ts.get('unknown_confidence', 0)\n\n levels = [ts.get('trust_level', 'high') for _, ts in sources]\n if 'low' in levels:\n overall = 'low'\n elif 'mixed' in levels:\n overall = 'mixed'\n else:\n overall = 'high'\n\n blockers = []\n if unknown > 0:\n blockers.append(f\"{unknown} findings with unknown confidence\")\n if sch:\n bom_cov = (sch.get('trust_summary') or {}).get('bom_coverage')\n if bom_cov and bom_cov.get('mpn_pct', 100) \u003c 50:\n blockers.append(f\"Low MPN coverage ({bom_cov['mpn_pct']:.0f}%)\")\n\n prov_values = [ts.get('provenance_coverage_pct', 0) for _, ts in sources\n if ts.get('total_findings', 0) > 0]\n avg_prov = round(sum(prov_values) / len(prov_values), 1) if prov_values else 0.0\n\n posture = {\n 'trust_level': overall,\n 'total_findings': total,\n 'by_confidence': {\n 'deterministic': det,\n 'heuristic': heu,\n 'datasheet-backed': ds_backed,\n },\n 'provenance_coverage_pct': avg_prov,\n 'per_analyzer': {name: ts.get('trust_level', '?') for name, ts in sources},\n }\n if unknown:\n posture['unknown_confidence'] = unknown\n if blockers:\n posture['evidence_blockers'] = blockers\n\n return posture\n\n\n# ---------------------------------------------------------------------------\n# Gate orchestrator\n# ---------------------------------------------------------------------------\n\ndef run_gate(sch: Dict, pcb: Dict,\n gerber_data: Optional[Dict] = None,\n thermal_data: Optional[Dict] = None,\n emc_data: Optional[Dict] = None,\n strict: bool = False,\n ) -> Dict[str, Any]:\n \"\"\"Run all gate checks and compute overall status.\"\"\"\n t0 = time.monotonic()\n\n all_checks: List[Dict] = []\n all_checks.extend(check_routing(pcb))\n all_checks.extend(check_bom(sch))\n all_checks.extend(check_dfm(pcb))\n all_checks.extend(check_documentation(pcb))\n all_checks.extend(check_consistency(sch, pcb))\n all_checks.extend(check_gerbers(gerber_data))\n all_checks.extend(check_thermal(thermal_data))\n all_checks.extend(check_emc(emc_data))\n\n # Apply strict mode\n if strict:\n for c in all_checks:\n if c[\"status\"] == \"warn\":\n c[\"status\"] = \"fail\"\n\n # Compute summary\n counts = {\"pass\": 0, \"warn\": 0, \"fail\": 0, \"skip\": 0}\n for c in all_checks:\n counts[c[\"status\"]] = counts.get(c[\"status\"], 0) + 1\n\n if counts[\"fail\"] > 0:\n overall = \"FAIL\"\n elif counts[\"warn\"] > 0:\n overall = \"WARN\"\n elif counts[\"pass\"] > 0:\n overall = \"PASS\"\n else:\n overall = \"INCOMPLETE\"\n\n elapsed = time.monotonic() - t0\n\n trust = _compute_trust_posture(sch, pcb, thermal_data, emc_data)\n\n result = {\n \"gate_version\": GATE_VERSION,\n \"overall_status\": overall,\n \"summary\": {\n \"total_checks\": len(all_checks),\n **counts,\n },\n \"checks\": all_checks,\n \"elapsed_s\": round(elapsed, 3),\n }\n if trust:\n result[\"trust_posture\"] = trust\n return result\n\n\n# ---------------------------------------------------------------------------\n# Text report\n# ---------------------------------------------------------------------------\n\n_STATUS_ICONS = {\n \"pass\": \"PASS\",\n \"warn\": \"WARN\",\n \"fail\": \"FAIL\",\n \"skip\": \"SKIP\",\n}\n\n_OVERALL_ICONS = {\n \"PASS\": \"PASS — Ready for fabrication\",\n \"WARN\": \"WARN — Review warnings before ordering\",\n \"FAIL\": \"FAIL — Issues must be resolved\",\n \"INCOMPLETE\": \"INCOMPLETE — Missing required inputs\",\n}\n\n\ndef format_text_report(result: Dict) -> str:\n \"\"\"Format gate result as human-readable text.\"\"\"\n lines = []\n overall = result[\"overall_status\"]\n summary = result[\"summary\"]\n\n lines.append(\"=\" * 60)\n lines.append(f\"FABRICATION RELEASE GATE — {_OVERALL_ICONS.get(overall, overall)}\")\n lines.append(\"=\" * 60)\n lines.append(\"\")\n lines.append(f\" {summary['pass']} pass {summary['warn']} warn \"\n f\"{summary['fail']} fail {summary['skip']} skip\")\n lines.append(\"\")\n\n trust = result.get(\"trust_posture\")\n if trust:\n level = trust[\"trust_level\"]\n bc = trust[\"by_confidence\"]\n total = trust[\"total_findings\"]\n if total > 0:\n det_pct = round(100 * bc[\"deterministic\"] / total)\n heu_pct = round(100 * bc[\"heuristic\"] / total)\n else:\n det_pct = heu_pct = 0\n lines.append(f\" Trust: {level.upper()} — \"\n f\"{det_pct}% deterministic, {heu_pct}% heuristic \"\n f\"({total} findings)\")\n prov = trust.get(\"provenance_coverage_pct\", 0)\n lines.append(f\" Provenance: {prov}% of findings carry detector provenance\")\n blockers = trust.get(\"evidence_blockers\", [])\n if blockers:\n for b in blockers:\n lines.append(f\" Evidence blocker: {b}\")\n lines.append(\"\")\n\n # Group by category\n categories: Dict[str, List] = {}\n for c in result[\"checks\"]:\n categories.setdefault(c[\"category\"], []).append(c)\n\n for cat, cat_checks in categories.items():\n lines.append(f\"--- {cat.upper()} ---\")\n for c in cat_checks:\n icon = _STATUS_ICONS.get(c[\"status\"], \"????\")\n lines.append(f\" [{icon}] {c['message']}\")\n if c.get(\"details\") and c[\"status\"] in (\"fail\", \"warn\"):\n for k, v in c[\"details\"].items():\n if isinstance(v, list):\n val = \", \".join(str(x) for x in v[:8])\n if len(v) > 8:\n val += f\" (+{len(v)-8} more)\"\n else:\n val = str(v)\n lines.append(f\" {k}: {val}\")\n lines.append(\"\")\n\n return \"\\n\".join(lines)\n\n\n# ---------------------------------------------------------------------------\n# CLI\n# ---------------------------------------------------------------------------\n\ndef main():\n parser = argparse.ArgumentParser(\n description=\"Fabrication release gate for KiCad designs\")\n parser.add_argument(\"--schematic\", \"-s\", required=True,\n help=\"Schematic analyzer JSON\")\n parser.add_argument(\"--pcb\", \"-p\", required=True,\n help=\"PCB analyzer JSON\")\n parser.add_argument(\"--gerbers\", \"-g\", default=None,\n help=\"Gerber analyzer JSON (optional)\")\n parser.add_argument(\"--thermal\", \"-t\", default=None,\n help=\"Thermal analyzer JSON (optional)\")\n parser.add_argument(\"--emc\", \"-e\", default=None,\n help=\"EMC analyzer JSON (optional)\")\n parser.add_argument(\"--output\", \"-o\",\n help=\"Output JSON file (default: stdout)\")\n parser.add_argument(\"--text\", action=\"store_true\",\n help=\"Output human-readable text report\")\n parser.add_argument(\"--compact\", action=\"store_true\",\n help=\"Compact JSON output\")\n parser.add_argument(\"--strict\", action=\"store_true\",\n help=\"Treat warnings as failures\")\n\n args = parser.parse_args()\n\n def _load(path):\n if not path or not os.path.isfile(path):\n return None\n with open(path) as f:\n return json.load(f)\n\n sch = _load(args.schematic)\n pcb = _load(args.pcb)\n if not sch or not pcb:\n print(\"Error: schematic and PCB JSON are required\", file=sys.stderr)\n sys.exit(1)\n\n result = run_gate(\n sch, pcb,\n gerber_data=_load(args.gerbers),\n thermal_data=_load(args.thermal),\n emc_data=_load(args.emc),\n strict=args.strict,\n )\n\n if args.text:\n print(format_text_report(result))\n elif args.output:\n indent = None if args.compact else 2\n with open(args.output, \"w\") as f:\n json.dump(result, f, indent=indent)\n overall = result[\"overall_status\"]\n total = result[\"summary\"][\"total_checks\"]\n print(f\"Gate: {overall} — {total} checks → {args.output}\",\n file=sys.stderr)\n else:\n indent = None if args.compact else 2\n json.dump(result, sys.stdout, indent=indent)\n print(file=sys.stdout)\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":23248,"content_sha256":"d7340f44e9b355f3dda60132a7f97d074e3e9060640dcba48c7416d887b6c46d"},{"filename":"scripts/finding_schema.py","content":"\"\"\"Rich finding schema shared by all detectors and validators.\n\nEvery detection and validation finding uses make_finding() to produce\na self-describing dict consumable by kidoc, suggest-fixes, and lighter LLMs.\n\"\"\"\n\nfrom __future__ import annotations\n\nVALID_SEVERITIES = ('error', 'warning', 'info')\nVALID_CONFIDENCES = ('deterministic', 'heuristic', 'datasheet-backed')\nVALID_EVIDENCE_SOURCES = (\n 'datasheet', 'topology', 'heuristic_rule', 'symbol_footprint',\n 'bom', 'geometry', 'api_lookup',\n)\nVALID_FIX_TYPES = (\n 'resistor_value_change', 'capacitor_value_change',\n 'add_component', 'remove_component', 'swap_connection', 'add_protection',\n)\n\n\ndef make_finding(\n detector: str,\n rule_id: str,\n category: str,\n summary: str,\n description: str,\n severity: str = 'warning',\n confidence: str = 'heuristic',\n evidence_source: str = 'heuristic_rule',\n components: list | None = None,\n nets: list | None = None,\n pins: list | None = None,\n recommendation: str = '',\n fix_params: dict | None = None,\n report_section: str | None = None,\n impact: str | None = None,\n standard_ref: str | None = None,\n **extra,\n) -> dict:\n \"\"\"Build a rich finding dict with consistent structure.\n\n Required fields: detector, rule_id, category, summary, description.\n All other fields have sensible defaults.\n\n Extra kwargs are merged into the finding (e.g., domain-specific data).\n \"\"\"\n if severity not in VALID_SEVERITIES:\n raise ValueError(\n f\"make_finding: invalid severity {severity!r} \"\n f\"(valid: {VALID_SEVERITIES})\")\n if confidence not in VALID_CONFIDENCES:\n raise ValueError(\n f\"make_finding: invalid confidence {confidence!r} \"\n f\"(valid: {VALID_CONFIDENCES})\")\n if evidence_source not in VALID_EVIDENCE_SOURCES:\n raise ValueError(\n f\"make_finding: invalid evidence_source {evidence_source!r} \"\n f\"(valid: {VALID_EVIDENCE_SOURCES})\")\n finding = {\n 'detector': detector,\n 'rule_id': rule_id,\n 'category': category,\n 'summary': summary,\n 'description': description,\n 'components': components if components is not None else [],\n 'nets': nets if nets is not None else [],\n 'pins': pins if pins is not None else [],\n 'severity': severity,\n 'confidence': confidence,\n 'evidence_source': evidence_source,\n 'recommendation': recommendation,\n }\n if fix_params is not None:\n finding['fix_params'] = fix_params\n finding['report_context'] = {\n 'section': report_section or category.replace('_', ' ').title(),\n 'impact': impact or '',\n 'standard_ref': standard_ref or '',\n }\n if extra:\n finding.update(extra)\n return finding\n\n\ndef make_provenance(evidence: str, confidence: str = 'heuristic',\n claimed_components: list | None = None) -> dict:\n \"\"\"Create a provenance dict for a detector output.\n\n Attaches to detection dicts to record how a detection was made.\n Part of the KH-263 detector provenance contract.\n\n Args:\n evidence: Detection method string. Convention: {detector_short}_{method}.\n confidence: One of VALID_CONFIDENCES (deterministic, heuristic,\n datasheet-backed).\n claimed_components: Component references this detection owns.\n\n Returns:\n Provenance dict with fields: evidence, confidence,\n claimed_components, excluded_by, suppressed_candidates.\n \"\"\"\n if confidence not in VALID_CONFIDENCES:\n raise ValueError(\n f\"make_provenance: invalid confidence {confidence!r} \"\n f\"(valid: {VALID_CONFIDENCES})\")\n return {\n 'evidence': evidence,\n 'confidence': confidence,\n 'claimed_components': claimed_components or [],\n 'excluded_by': [],\n 'suppressed_candidates': [],\n }\n\n\ndef compute_trust_summary(findings, bom=None):\n \"\"\"Compute a trust summary from a list of findings.\n\n Aggregates finding metadata into a single trust posture block that\n tells users how much of the report is solid, heuristic, or missing\n evidence.\n\n Args:\n findings: List of finding dicts (each should have confidence,\n evidence_source fields).\n bom: Optional BOM list from schematic analyzer. If provided,\n computes manufacturer evidence coverage.\n\n Returns:\n Dict with trust posture fields.\n \"\"\"\n total = len(findings)\n\n by_confidence = {}\n for c in VALID_CONFIDENCES:\n by_confidence[c] = 0\n by_evidence = {}\n for e in VALID_EVIDENCE_SOURCES:\n by_evidence[e] = 0\n\n has_provenance = 0\n unknown_confidence = 0\n unknown_evidence = 0\n\n for f in findings:\n if not isinstance(f, dict):\n continue\n conf = f.get('confidence', '')\n ev = f.get('evidence_source', '')\n if conf in by_confidence:\n by_confidence[conf] += 1\n else:\n unknown_confidence += 1\n if ev in by_evidence:\n by_evidence[ev] += 1\n else:\n unknown_evidence += 1\n if f.get('provenance') is not None:\n has_provenance += 1\n\n # BOM evidence coverage\n bom_coverage = None\n if bom is not None:\n bom_total = 0\n bom_with_mpn = 0\n bom_with_datasheet = 0\n for comp in bom:\n if not isinstance(comp, dict):\n continue\n if comp.get('type') in ('power_symbol', 'power_flag', 'flag'):\n continue\n bom_total += 1\n if comp.get('mpn') or comp.get('MPN'):\n bom_with_mpn += 1\n if comp.get('datasheet') and comp['datasheet'] not in ('', '~'):\n bom_with_datasheet += 1\n if bom_total > 0:\n bom_coverage = {\n 'total_components': bom_total,\n 'with_mpn': bom_with_mpn,\n 'with_datasheet': bom_with_datasheet,\n 'mpn_pct': round(100 * bom_with_mpn / bom_total, 1),\n 'datasheet_pct': round(100 * bom_with_datasheet / bom_total, 1),\n }\n\n # Determine trust level\n if total == 0:\n trust_level = 'high'\n else:\n heuristic_pct = 100 * by_confidence.get('heuristic', 0) / total\n if heuristic_pct > 50 or unknown_confidence > 0:\n trust_level = 'low'\n elif heuristic_pct > 20:\n trust_level = 'mixed'\n else:\n trust_level = 'high'\n\n result = {\n 'total_findings': total,\n 'trust_level': trust_level,\n 'by_confidence': by_confidence,\n 'by_evidence_source': by_evidence,\n # None when no findings — avoids \"100% coverage of nothing\"\n # misleading aggregates in downstream consumers.\n 'provenance_coverage_pct': round(100 * has_provenance / total, 1) if total else None,\n }\n if unknown_confidence:\n result['unknown_confidence'] = unknown_confidence\n if unknown_evidence:\n result['unknown_evidence_source'] = unknown_evidence\n if bom_coverage is not None:\n result['bom_coverage'] = bom_coverage\n return result\n\n\n# ---------------------------------------------------------------------------\n# Deterministic ordering for findings lists\n# ---------------------------------------------------------------------------\n\ndef sort_findings(findings):\n \"\"\"Sort a findings list in-place by a stable composite key.\n\n Produces deterministic output across runs so baseline snapshots stay\n byte-identical and git diffs stay minimal. Sort key:\n\n (rule_id, detector, sorted_components, sorted_nets, summary)\n\n Also sorts each finding's ``components``, ``nets``, and ``pins`` lists\n in place so upstream set/dict iteration order doesn't surface as drift\n in the output. Ties fall through to ``summary`` as the final\n disambiguator. Non-dict entries are tolerated (sorted to the end).\n\n Args:\n findings: List of finding dicts. Mutated in place.\n\n Returns:\n The same list (for chaining convenience).\n \"\"\"\n # First pass: canonicalize nested list fields within each finding so\n # set-iteration order doesn't leak into output.\n for f in findings:\n if not isinstance(f, dict):\n continue\n for key in ('components', 'nets', 'pins'):\n v = f.get(key)\n if isinstance(v, list) and all(not isinstance(x, (dict, list)) for x in v):\n f[key] = sorted(v, key=str)\n\n def _key(f):\n if not isinstance(f, dict):\n return (1, '', '', '', '', '')\n comps = f.get('components') or []\n nets = f.get('nets') or []\n first_comp = str(comps[0]) if comps else ''\n first_net = str(nets[0]) if nets else ''\n return (\n 0,\n str(f.get('rule_id') or ''),\n str(f.get('detector') or ''),\n first_comp,\n first_net,\n str(f.get('summary') or ''),\n )\n findings.sort(key=_key)\n return findings\n\n\n# ---------------------------------------------------------------------------\n# Detector name constants — avoids string typos across consumers\n# ---------------------------------------------------------------------------\n\nclass Det:\n \"\"\"Detector name constants for filtering findings.\"\"\"\n # Signal detectors\n VOLTAGE_DIVIDERS = 'detect_voltage_dividers'\n RC_FILTERS = 'detect_rc_filters'\n LC_FILTERS = 'detect_lc_filters'\n CRYSTAL_CIRCUITS = 'detect_crystal_circuits'\n OPAMP_CIRCUITS = 'detect_opamp_circuits'\n TRANSISTOR_CIRCUITS = 'detect_transistor_circuits'\n BRIDGE_CIRCUITS = 'detect_bridge_circuits'\n LED_DRIVERS = 'detect_led_drivers'\n POWER_REGULATORS = 'detect_power_regulators'\n INTEGRATED_LDOS = 'detect_integrated_ldos'\n DECOUPLING = 'detect_decoupling'\n CURRENT_SENSE = 'detect_current_sense'\n PROTECTION_DEVICES = 'detect_protection_devices'\n DESIGN_OBSERVATIONS = 'detect_design_observations'\n # Domain detectors\n BUZZER_SPEAKERS = 'detect_buzzer_speakers'\n KEY_MATRICES = 'detect_key_matrices'\n ISOLATION_BARRIERS = 'detect_isolation_barriers'\n ETHERNET_INTERFACES = 'detect_ethernet_interfaces'\n HDMI_DVI_INTERFACES = 'detect_hdmi_dvi_interfaces'\n LVDS_INTERFACES = 'detect_lvds_interfaces'\n MEMORY_INTERFACES = 'detect_memory_interfaces'\n RF_CHAINS = 'detect_rf_chains'\n RF_MATCHING = 'detect_rf_matching'\n BMS_SYSTEMS = 'detect_bms_systems'\n BATTERY_CHARGERS = 'detect_battery_chargers'\n MOTOR_DRIVERS = 'detect_motor_drivers'\n ADDRESSABLE_LEDS = 'detect_addressable_leds'\n DEBUG_INTERFACES = 'detect_debug_interfaces'\n POWER_PATH = 'detect_power_path'\n ADC_CIRCUITS = 'detect_adc_circuits'\n RESET_SUPERVISORS = 'detect_reset_supervisors'\n CLOCK_DISTRIBUTION = 'detect_clock_distribution'\n DISPLAY_INTERFACES = 'detect_display_interfaces'\n SENSOR_INTERFACES = 'detect_sensor_interfaces'\n LEVEL_SHIFTERS = 'detect_level_shifters'\n AUDIO_CIRCUITS = 'detect_audio_circuits'\n LED_DRIVER_ICS = 'detect_led_driver_ics'\n RTC_CIRCUITS = 'detect_rtc_circuits'\n THERMOCOUPLE_RTD = 'detect_thermocouple_rtd'\n WIRELESS_MODULES = 'detect_wireless_modules'\n TRANSFORMER_FEEDBACK = 'detect_transformer_feedback'\n I2C_ADDRESS_CONFLICTS = 'detect_i2c_address_conflicts'\n ENERGY_HARVESTING = 'detect_energy_harvesting'\n PWM_LED_DIMMING = 'detect_pwm_led_dimming'\n HEADPHONE_JACK = 'detect_headphone_jack'\n SOLDER_JUMPERS = 'detect_solder_jumpers'\n LABEL_ALIASES = 'detect_label_aliases'\n POWER_PIN_DC_PATH = 'audit_power_pin_dc_paths'\n # Audit detectors\n ESD_AUDIT = 'audit_esd_protection'\n LED_AUDIT = 'audit_led_circuits'\n CONNECTOR_GROUND_AUDIT = 'audit_connector_ground_distribution'\n RAIL_SOURCE_AUDIT = 'audit_rail_sources'\n SOURCING_GATE = 'audit_sourcing_gate'\n DATASHEET_COVERAGE = 'audit_datasheet_coverage'\n # Connectivity detectors\n CONNECTIVITY_SINGLE_PIN = 'analyze_connectivity'\n # Validation detectors\n PULLUPS = 'validate_pullups'\n VOLTAGE_LEVELS = 'validate_voltage_levels'\n I2C_BUS = 'validate_i2c_bus'\n SPI_BUS = 'validate_spi_bus'\n CAN_BUS = 'validate_can_bus'\n USB_BUS = 'validate_usb_bus'\n POWER_SEQUENCING = 'validate_power_sequencing'\n LED_RESISTORS = 'validate_led_resistors'\n FEEDBACK_STABILITY = 'validate_feedback_stability'\n\n\n# ---------------------------------------------------------------------------\n# Finding filter helpers — used by all consumers of analyzer JSON output\n# ---------------------------------------------------------------------------\n\ndef get_findings(data, detector=None,\n rule_prefix=None,\n category=None):\n \"\"\"Filter findings from an analyzer result dict.\n\n Args:\n data: Analyzer result dict with top-level 'findings' key.\n detector: Filter by detector name (e.g., Det.POWER_REGULATORS).\n rule_prefix: Filter by rule_id prefix (e.g., 'PU-').\n category: Filter by category (e.g., 'signal_integrity').\n\n Returns:\n List of matching finding dicts.\n \"\"\"\n findings = data.get('findings', [])\n if detector:\n return [f for f in findings if f.get('detector') == detector]\n if rule_prefix:\n return [f for f in findings if f.get('rule_id', '').startswith(rule_prefix)]\n if category:\n return [f for f in findings if f.get('category') == category]\n return list(findings)\n\n\ndef group_findings(data):\n \"\"\"Group findings by detector name.\n\n Returns:\n Dict mapping detector name to list of findings.\n Usage: group_findings(schematic).get(Det.POWER_REGULATORS, [])\n \"\"\"\n groups = {}\n for f in data.get('findings', []):\n groups.setdefault(f.get('detector', ''), []).append(f)\n return groups\n\n\n# ---------------------------------------------------------------------------\n# Legacy key mapping — used by detection_schema / what_if / diff_analysis\n# ---------------------------------------------------------------------------\n\nDETECTOR_TO_LEGACY_KEY = {\n \"detect_power_regulators\": \"power_regulators\",\n \"detect_integrated_ldos\": \"power_regulators\",\n \"detect_voltage_dividers\": \"voltage_dividers\",\n \"detect_rc_filters\": \"rc_filters\",\n \"detect_lc_filters\": \"lc_filters\",\n \"detect_crystal_circuits\": \"crystal_circuits\",\n \"detect_decoupling\": \"decoupling_analysis\",\n \"detect_current_sense\": \"current_sense\",\n \"detect_protection_devices\": \"protection_devices\",\n \"detect_opamp_circuits\": \"opamp_circuits\",\n \"detect_transistor_circuits\": \"transistor_circuits\",\n \"detect_bridge_circuits\": \"bridge_circuits\",\n \"detect_rf_matching\": \"rf_matching\",\n \"detect_rf_chains\": \"rf_chains\",\n \"detect_bms_systems\": \"bms_systems\",\n \"detect_battery_chargers\": \"battery_chargers\",\n \"detect_motor_drivers\": \"motor_drivers\",\n \"detect_ethernet_interfaces\": \"ethernet_interfaces\",\n \"detect_buzzer_speakers\": \"buzzer_speaker_circuits\",\n \"detect_key_matrices\": \"key_matrices\",\n \"detect_isolation_barriers\": \"isolation_barriers\",\n \"detect_hdmi_dvi_interfaces\": \"hdmi_dvi_interfaces\",\n \"detect_lvds_interfaces\": \"lvds_interfaces\",\n \"detect_memory_interfaces\": \"memory_interfaces\",\n \"detect_addressable_leds\": \"addressable_led_chains\",\n \"detect_debug_interfaces\": \"debug_interfaces\",\n \"detect_adc_circuits\": \"adc_circuits\",\n \"detect_reset_supervisors\": \"reset_supervisors\",\n \"detect_clock_distribution\": \"clock_distribution\",\n \"detect_display_interfaces\": \"display_interfaces\",\n \"detect_sensor_interfaces\": \"sensor_interfaces\",\n \"detect_level_shifters\": \"level_shifters\",\n \"detect_audio_circuits\": \"audio_circuits\",\n \"detect_led_driver_ics\": \"led_driver_ics\",\n \"detect_rtc_circuits\": \"rtc_circuits\",\n \"detect_thermocouple_rtd\": \"thermocouple_rtd\",\n \"detect_wireless_modules\": \"wireless_modules\",\n \"detect_transformer_feedback\": \"transformer_feedback\",\n \"detect_i2c_address_conflicts\": \"i2c_address_conflicts\",\n \"detect_energy_harvesting\": \"energy_harvesting\",\n \"detect_pwm_led_dimming\": \"pwm_led_dimming\",\n \"detect_headphone_jack\": \"headphone_jacks\",\n \"detect_power_path\": \"power_path\",\n \"detect_design_observations\": \"design_observations\",\n \"detect_led_drivers\": \"led_drivers\",\n \"audit_esd_protection\": \"esd_coverage_audit\",\n \"audit_led_circuits\": \"led_audit\",\n \"audit_connector_ground_distribution\": \"connector_ground_audit\",\n}\n\n\ndef group_findings_legacy(data):\n \"\"\"Group findings by legacy signal_analysis key names.\n\n Returns {legacy_key: [finding, ...]} dict compatible with the\n old signal_analysis dict-of-lists layout. Detector names are\n mapped via DETECTOR_TO_LEGACY_KEY so that downstream code (SCHEMAS,\n SPICE templates, --fix CLI) works unchanged.\n\n Detects pre-v1.3 JSON (signal_analysis wrapper, no findings[]) and\n emits a warning to stderr. Returns empty dict in that case — callers\n should check is_old_schema() first if they need to abort early.\n \"\"\"\n if \"signal_analysis\" in data and \"findings\" not in data:\n import sys\n print(\"Warning: this JSON uses the pre-v1.3 signal_analysis wrapper \"\n \"format. Re-run the analyzer to produce the current findings[] \"\n \"format.\", file=sys.stderr)\n return {}\n sa = {}\n for f in data.get(\"findings\", []):\n det = f.get(\"detector\", \"\")\n if det:\n key = DETECTOR_TO_LEGACY_KEY.get(det, det)\n sa.setdefault(key, []).append(f)\n return sa\n\n\ndef is_old_schema(data):\n \"\"\"Return True if data uses the pre-v1.3 signal_analysis wrapper format.\"\"\"\n return \"signal_analysis\" in data and \"findings\" not in data\n","content_type":"text/x-python; charset=utf-8","language":"python","size":17778,"content_sha256":"ad8ee5d5e5d01b15fa0e50319a4e8648426c448b16610957b27a35e3163fe0a3"},{"filename":"scripts/kicad_types.py","content":"\"\"\"\nTyped data structures for KiCad analysis.\n\nProvides AnalysisContext — the shared state object passed between all\nanalysis functions, replacing repeated comp_lookup/parsed_values/known_power_rails\nconstruction.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass, field\n\nfrom kicad_utils import is_ground_name, is_power_net_name, parse_value\n\n\n@dataclass\nclass AnalysisContext:\n \"\"\"Shared state passed to all 40+ signal and domain detector functions.\n\n Built once in ``analyze_schematic()`` after components, nets, and pin_net\n are fully resolved. Provides indexed lookups and helper methods so\n detectors don't rebuild these structures individually.\n\n Attributes:\n components: All placed components (excluding power symbols/flags).\n Each dict has: reference, value, type, lib_id, footprint,\n properties, pins, and optionally mpn, datasheet.\n nets: Net connectivity graph. ``{net_name: {\"pins\": [{\"component\": ref,\n \"pin_number\": num, \"pin_name\": name, \"x\": float, \"y\": float}], ...}}``.\n lib_symbols: Library symbol definitions extracted from the schematic's\n embedded ``lib_symbols`` section. ``{lib_id: {value, pins, ...}}``.\n pin_net: Pin-to-net mapping. ``{(ref, pin_number): (net_name, pin_info)}``.\n Covers every pin on every placed component.\n comp_lookup: Quick component lookup by reference. ``{ref: component_dict}``.\n Auto-built from *components* in ``__post_init__``.\n parsed_values: Parsed SI values (ohms, farads, henries) per component.\n ``{ref: float}``. Only components whose value string parses\n successfully are included. Auto-built in ``__post_init__``.\n known_power_rails: Net names connected to power symbols (``#PWR``, ``#FLG``).\n Used by ``is_power_net()`` to distinguish power rails from signal nets.\n ref_pins: Per-component pin map. ``{ref: {pin_number: (net_name, pin_info)}}``.\n Derived from *pin_net* for quick per-component pin enumeration.\n no_connects: List of no-connect markers from the schematic.\n generator_version: KiCad version string (e.g., ``\"9.0.1\"``).\n nq: Optional high-performance ``NetlistQueries`` object for multi-hop\n net tracing. Initialized separately when available.\n hierarchy_context: Cross-sheet context for sub-sheet analysis. None when\n analyzing a root schematic or when hierarchy discovery is disabled.\n When present, contains: root_schematic, target_sheet, sheets_in_project,\n cross_sheet_nets (per hierarchical label: external components, power rail\n status, connected sheets), project_power_rails, and\n reference_corrections_applied.\n\n Methods:\n is_power_net(name): True if *name* is a known power rail or matches\n common power net name patterns (VCC, +3V3, VBUS, etc.).\n is_ground(name): True if *name* matches ground patterns (GND, VSS, etc.).\n get_two_pin_nets(ref): Returns ``(net1, net2)`` for a 2-pin component.\n Handles standard \"1\"/\"2\" numbering and falls back to enumerating\n all pins for non-standard numbering (Eagle imports, diodes A/K).\n \"\"\"\n\n components: list[dict]\n nets: dict[str, dict]\n lib_symbols: dict\n pin_net: dict[tuple[str, str], tuple[str | None, str | None]]\n comp_lookup: dict[str, dict] = field(default_factory=dict)\n parsed_values: dict[str, float] = field(default_factory=dict)\n known_power_rails: set[str] = field(default_factory=set)\n ref_pins: dict[str, dict[str, tuple[str | None, str | None]]] = field(default_factory=dict)\n no_connects: list[dict] = field(default_factory=list)\n generator_version: str = \"unknown\"\n nq: 'NetlistQueries | None' = field(default=None, repr=False)\n hierarchy_context: dict | None = field(default=None, repr=False)\n\n def __post_init__(self) -> None:\n if not self.comp_lookup:\n self.comp_lookup = {c[\"reference\"]: c for c in self.components}\n if not self.parsed_values:\n for c in self.components:\n val = parse_value(c.get(\"value\", \"\"), component_type=c.get(\"type\"))\n if val is not None:\n self.parsed_values[c[\"reference\"]] = val\n if not self.known_power_rails:\n for net_name, net_info in self.nets.items():\n for p in net_info.get(\"pins\", []):\n if p[\"component\"].startswith(\"#PWR\") or p[\"component\"].startswith(\"#FLG\"):\n self.known_power_rails.add(net_name)\n break\n if not self.ref_pins:\n rp: dict[str, dict[str, tuple[str | None, str | None]]] = {}\n for (comp_ref, pin_num), val in self.pin_net.items():\n rp.setdefault(comp_ref, {})[pin_num] = val\n self.ref_pins = rp\n\n def is_power_net(self, name: str | None) -> bool:\n return is_power_net_name(name, self.known_power_rails)\n\n def is_ground(self, name: str | None) -> bool:\n return is_ground_name(name)\n\n def get_two_pin_nets(self, ref: str) -> tuple[str | None, str | None]:\n n1, _ = self.pin_net.get((ref, \"1\"), (None, None))\n n2, _ = self.pin_net.get((ref, \"2\"), (None, None))\n if n1 is not None and n2 is not None:\n return n1, n2\n # Fallback for non-\"1\"/\"2\" pin numbering (Eagle imports, diodes A/K, etc.)\n pins = self.ref_pins.get(ref, {})\n if len(pins) == 2:\n nets = [net for net, _ in pins.values()]\n return nets[0], nets[1]\n return n1, n2\n","content_type":"text/x-python; charset=utf-8","language":"python","size":5679,"content_sha256":"d4ed801611819296c4a45eeb84259f31ef545d1ee9ee2fcc4fd5ffb41f26d541"},{"filename":"scripts/methodology_gerbers.md","content":"# Gerber & Drill File Analyzer — Methodology\n\nThis document describes the analysis methodology used by `analyze_gerbers.py`. It covers Gerber RS-274X parsing, Excellon drill parsing, layer identification, X2 attribute extraction, and all higher-level analyses performed on a fabrication output directory.\n\n## Design Philosophy\n\nSame as the schematic and PCB analyzers — this is a **data extraction layer**. It outputs structured JSON containing neutral observations about the fabrication files. An LLM (or human reviewer) consumes this alongside the schematic and PCB layout analyses for design review.\n\nThe Gerber analyzer is the last line of defense before a board order — the fabrication files are what the manufacturer actually builds. Thorough detection matters: a missing layer, stale zip archive, or misaligned coordinate range that goes unreported can result in costly respins. Every reported fact (layer identification, drill classification, coordinate extents) must be accurate, since the reviewer is making go/no-go ordering decisions based on this data.\n\nThe analyzer operates on manufactured output files, not source design files. It answers the question \"what did the CAD tool actually produce?\" rather than \"what did the designer intend?\" This makes it useful for:\n- Verifying that exported Gerbers match the design (cross-reference with PCB analysis)\n- Checking fabrication file completeness before ordering\n- Extracting board specs and design rules from machine-readable metadata\n- Identifying layer alignment issues that would cause manufacturing defects\n\n---\n\n## 1. Input Discovery\n\n### File Collection\n\n`analyze_gerbers()` scans the target directory for three file categories:\n\n| Category | Extensions | Purpose |\n|---|---|---|\n| Gerber files | `.gbr`, `.g*` (including Protel: `.gtl`, `.gbl`, `.gts`, `.gbs`, `.gto`, `.gbo`, `.gko`, `.gm1`, `.g1`–`.g4`) | Copper, mask, paste, silk, edge layers |\n| Drill files | `.drl` | Excellon hole data (PTH and NPTH) |\n| Job files | `.gbrjob` | KiCad-generated JSON metadata |\n| Zip archives | `.zip` | Packaged Gerber sets (scanned for staleness, not extracted) |\n\nBoth lowercase and uppercase extensions are collected. Duplicates are removed, and non-Gerber files (`.drl`, `.gbrjob`, `.zip`, `.pos`) are filtered from the Gerber list.\n\n### Processing Order\n\n1. Parse all Gerber files → `gerbers[]`\n2. Parse all drill files → `drills[]`\n3. Parse job file (first `.gbrjob` found, if any) → `job_info`\n4. Run analysis functions over the parsed data\n\nIndividual file parse errors are caught and recorded as `{\"error\": \"...\"}` entries — a corrupt file doesn't abort the entire analysis.\n\n---\n\n## 2. Gerber RS-274X Parsing\n\n`parse_gerber()` performs a single stateful pass over each Gerber file, extracting format information, aperture definitions, X2 attributes, operation counts, and coordinate ranges.\n\n### 2.1 Format and Units\n\nExtracted via regex from the file content:\n\n- **Format specification** (`%FS...%`): Zero omission mode (leading/trailing), coordinate notation (absolute/incremental), and integer/decimal digit counts for X and Y axes. These are needed to interpret raw coordinate integers.\n- **Units** (`%MOIN*%` or `%MOMM*%`): Inch or millimeter. Determines whether aperture dimensions need conversion.\n\n### 2.2 Two-Phase Architecture\n\n**Phase 1** (regex over full content): Extracts format, units, and X2 file attributes (`%TF.*%` and `G04 #@! TF.*` comment format). These are needed before the line-by-line pass can interpret coordinates.\n\n**Phase 2** (stateful line-by-line): Tracks aperture state, object attributes, and operations:\n\n| State Machine | Tracked By | Purpose |\n|---|---|---|\n| Aperture attributes | `pending_aper_function` | TA.AperFunction preceding AD definition |\n| Current component | `current_component` | TO.C object attribute |\n| Current net | `current_net` | TO.N object attribute |\n| Component pad counts | `component_pads{}` | Flash count per component ref |\n| Component net sets | `component_nets{}` | Which nets each component connects to |\n| Pin mappings | `pin_mappings[]` | TO.P ref/pin/pin_name/net tuples |\n\n### 2.3 X2 File Attributes\n\nTwo formats are supported:\n\n- **Modern** (KiCad 6+): `%TF.Key,Value*%` — standard X2 extended attributes\n- **KiCad 5 comment format**: `G04 #@! TF.Key,Value*` — same data embedded in G-code comments\n\nThe comment format is checked second, so modern attributes take precedence if both exist.\n\nCommon file attributes extracted:\n- `FileFunction` — layer type (Copper, SolderMask, Legend, Profile, etc.)\n- `FilePolarity` — Positive or Negative\n- `GenerationSoftware` — CAD tool identification\n- `CreationDate` — when the file was generated\n\n### 2.4 X2 Object Attributes\n\nObject attributes (`TO.*`) track which component, net, and pin are associated with subsequent operations:\n\n- **`TO.C,RefDes`** — sets the current component reference (e.g., `TO.C,R1`)\n- **`TO.N,NetName`** — sets the current net name\n- **`TO.P,Ref,Pin[,PinName]`** — records a pin-to-net mapping\n\nThese are accumulated across all flashes and draws. When a flash (D03) occurs while `current_component` is set, the pad count for that component increments. This builds a component-level view of which pads exist on each layer.\n\n### 2.5 Aperture Definitions\n\nEach `%AD...%` definition is parsed for:\n- **D-code**: The aperture identifier (D10, D11, ...)\n- **Type**: C (circle), R (rectangle), O (obround), RoundRect (macro), or custom\n- **Parameters**: Dimensions in file units\n- **Function**: If a `TA.AperFunction` preceded the definition, it's attached (e.g., `Conductor`, `SMDPad,CuDef`, `ViaPad`, `HeatsinkPad`)\n\n### 2.6 Aperture Dimension Parsing\n\n`_parse_aperture_dimension()` extracts the primary dimension (in mm) from standard aperture types:\n\n| Aperture Type | Dimension Extracted |\n|---|---|\n| C (circle) | Diameter |\n| R (rectangle) | Smaller of width/height |\n| O (obround) | Smaller of width/height |\n| RoundRect | 2× corner radius (conservative estimate) |\n\nInch dimensions are converted to mm. These are used for trace width distribution and minimum feature size analysis.\n\n### 2.7 Operation Counting\n\nThree operation types are counted:\n- **Flashes** (D03): Pad/via placements — counted globally and per component\n- **Draws** (D01): Trace segments, arcs\n- **Regions** (G36): Copper fills/pours\n\n### 2.8 Coordinate Range\n\nEvery `X...Y...` coordinate in the file updates a running min/max bounding box. This is used later for layer alignment checking and board dimension estimation. Coordinates are divided by the format's decimal precision to get real-world values.\n\n### 2.9 Aperture Analysis Summary\n\nAfter the line-by-line pass, aperture data is aggregated:\n- **By function**: Count of apertures per function category (SMDPad, ViaPad, Conductor, HeatsinkPad, etc.)\n- **Conductor widths**: Set of unique trace widths (mm) from Conductor-tagged apertures\n- **Minimum feature**: Smallest aperture dimension across all types\n\n---\n\n## 3. Excellon Drill Parsing\n\n`parse_drill()` parses Excellon drill files in a single pass.\n\n### 3.1 Header Parsing\n\nThe header (before the `%` end-of-header marker) contains:\n- **Units**: Detected from `METRIC`/`MOMM` or `INCH` keywords\n- **Tool definitions**: `TnnCddd.ddd` — tool number and diameter. Diameters in inches are converted to mm.\n- **X2 attributes**: Same `; #@! TF.*` comment format as Gerber files\n- **Per-tool aperture functions**: `; #@! TA.AperFunction,Plated,PTH,ViaDrill` etc. — attached to the next tool definition\n\n### 3.2 Drill Hits\n\nAfter the header, tool selections (`Tnn`) and coordinate lines (`Xnnn.nnnYnnn.nnn`) are tracked. Each coordinate line increments the hole count for the current tool and updates the coordinate bounding box.\n\n### 3.3 PTH/NPTH Classification\n\nEach drill file is classified as PTH (plated through-hole) or NPTH (non-plated) using:\n1. **X2 FileFunction attribute** — `Plated` or `NonPlated` (authoritative)\n2. **Filename pattern** — `pth`/`npth` in the filename (fallback)\n3. **Unknown** — if neither source provides classification\n\n### 3.4 Layer Span\n\nFrom `FileFunction` values like `Plated,1,4,PTH`, the layer span is extracted (e.g., layers 1–4). This indicates which copper layers the drill connects and is used to determine total layer count.\n\n---\n\n## 4. Layer Identification\n\n`identify_layer_type()` maps each Gerber file to a KiCad-style layer name. Three identification methods are tried in priority order:\n\n### 4.1 X2 FileFunction (Highest Priority)\n\nThe `FileFunction` attribute provides authoritative layer identification:\n\n| FileFunction Contains | Mapped Layer |\n|---|---|\n| `copper` + `top` | F.Cu |\n| `copper` + `bot` | B.Cu |\n| `copper,Ln,inr` | In(n-1).Cu (L2→In1, L3→In2, etc.) |\n| `soldermask` + `top`/`bot` | F.Mask / B.Mask |\n| `paste`/`solderpaste` + `top`/`bot` | F.Paste / B.Paste |\n| `legend`/`silkscreen` + `top`/`bot` | F.SilkS / B.SilkS |\n| `profile` | Edge.Cuts |\n\nInner copper layer mapping: X2 uses absolute layer positions (L2 = second copper layer), while KiCad names inner layers starting at In1.Cu. The conversion is `In(L-1).Cu`.\n\n### 4.2 KiCad Filename Patterns (Second Priority)\n\nIf no X2 attributes are present, the filename is checked against KiCad-style patterns:\n- Inner copper: `In1_Cu`, `In1.Cu`, etc.\n- Outer layers: `F_Cu`, `F.Cu`, `Front_Cu`, `B_Cu`, etc.\n- Masks/paste/silk/edge: Similar patterns with layer prefixes\n\n### 4.3 Protel-Style Extensions (Lowest Priority)\n\nClassic Protel/Altium extensions as a final fallback:\n\n| Extension | Layer |\n|---|---|\n| `.gtl` / `.gbl` | F.Cu / B.Cu |\n| `.gts` / `.gbs` | F.Mask / B.Mask |\n| `.gtp` / `.gbp` | F.Paste / B.Paste |\n| `.gto` / `.gbo` | F.SilkS / B.SilkS |\n| `.gm1` / `.gko` | Edge.Cuts |\n| `.g1`–`.g4` | In1.Cu–In4.Cu |\n\nFiles that match none of these patterns get `\"unknown\"` and are still included in the output.\n\n---\n\n## 5. Job File Parsing\n\n`parse_job_file()` parses the `.gbrjob` file (JSON format, generated by KiCad alongside Gerbers).\n\n### Extracted Fields\n\n| JSON Path | Output Field | Purpose |\n|---|---|---|\n| `Header.GenerationSoftware` | `generator`, `vendor` | CAD tool identification |\n| `Header.CreationDate` | `creation_date` | Timestamp |\n| `GeneralSpecs.Size` | `board_width_mm`, `board_height_mm` | Authoritative board dimensions |\n| `GeneralSpecs.LayerNumber` | `layer_count` | Total copper layers |\n| `GeneralSpecs.BoardThickness` | `board_thickness_mm` | Stackup thickness |\n| `GeneralSpecs.Finish` | `finish` | Surface finish (HASL, ENIG, etc.) |\n| `GeneralSpecs.ProjectId` | `project_name` | KiCad project name |\n| `DesignRules[]` | `design_rules[]` | Pad-to-pad, track-to-track, min width, etc. |\n| `FilesAttributes[]` | `expected_files[]` | What files should exist (for completeness check) |\n| `MaterialStackup[]` | `stackup[]` | Layer types, thicknesses, materials |\n\n---\n\n## 6. Analysis Functions\n\n### 6.1 Drill Classification\n\n`classify_drill_tools()` categorizes every drill tool across all drill files into three groups:\n\n**Classification priority:**\n1. **NPTH file** → all tools classified as mounting holes (regardless of diameter)\n2. **X2 AperFunction** — `ViaDrill` → via, `ComponentDrill` → component hole\n3. **Diameter heuristic** (fallback when no X2 data):\n\n| Diameter Range | Classification | Rationale |\n|---|---|---|\n| ≤ 0.45 mm | Via | Standard via drill sizes |\n| 0.45–1.3 mm | Component hole | THT component pin sizes |\n| > 1.3 mm | Mounting hole | Screws, standoffs |\n\nThe output records which method was used (`x2_attributes` or `diameter_heuristic`), so the consumer knows confidence level.\n\n### 6.2 Layer Completeness\n\n`check_completeness()` verifies that all necessary layers are present.\n\n**With `.gbrjob`:** Compares found layers against the `expected_files` list from the job file. Reports missing and extra layers. Source is tagged as `\"gbrjob\"`.\n\n**Without `.gbrjob`:** Checks against a default required set:\n- **Required**: F.Cu, B.Cu, F.Mask, B.Mask, Edge.Cuts, plus any inner copper layers found\n- **Recommended**: F.SilkS, F.Paste\n- **Drill**: At least one PTH drill file\n\nA board is `\"complete\": true` only when all required layers are present and a PTH drill exists.\n\n### 6.3 Layer Alignment\n\n`check_alignment()` checks that copper and edge layers have consistent coordinate ranges.\n\n**Method:**\n1. Compute width and height from the coordinate bounding box of each identified layer\n2. Compare copper layers (F.Cu, B.Cu, inner copper) and Edge.Cuts\n3. Flag as misaligned if width or height varies by more than **2.0 mm** across these layers\n\nThe 2mm threshold is generous — any real misalignment would produce offsets much larger than normal coordinate range variation. Drill file extents are recorded but not included in the alignment check (drill coordinate ranges are often slightly different due to pad-center vs edge-of-trace differences).\n\n### 6.4 Board Dimensions\n\n`compute_board_dimensions()` determines board width, height, and area.\n\n**Priority:**\n1. **`.gbrjob`** — `GeneralSpecs.Size.X` and `.Y` (authoritative, computed by KiCad)\n2. **Edge.Cuts extents** — bounding box of the board outline Gerber file (fallback)\n\nThe source is tagged in the output so the consumer knows which method was used. The Edge.Cuts fallback gives bounding-box dimensions, which are correct for rectangular boards but overestimate for boards with cutouts or non-rectangular outlines.\n\n### 6.5 Component Analysis\n\n`build_component_analysis()` merges X2 object attribute data across all Gerber layers to build a board-level view of components.\n\n**Only produces output when X2 TO attributes are present** (KiCad 6+ exports). Without X2 data, no component analysis is possible from Gerbers alone.\n\n**Merging logic:**\n- Component references are collected from all layers that have `TO.C` attributes\n- Front/back side assignment: if a component's `TO.C` appears on F.Cu, it's front-side; if on B.Cu, it's back-side. Components appearing only on B.Cu are counted as back-only.\n- Pad counts: maximum pad count per component across all layers (same pad appears on copper, mask, and paste layers)\n- Nets per component: union of all nets associated with each component across layers\n\n**Net classification** uses keyword matching:\n- **Power nets**: Names matching `vcc`, `vdd`, `gnd`, `agnd`, `vss`, `vbat`, `vbus`, `vin`, or starting with `+`/`-`\n- **Unnamed nets**: Starting with `Net-(` or `unconnected-(`\n- **Signal nets**: Everything else\n\n### 6.6 Net Analysis\n\n`build_net_analysis()` merges net and pin data from copper layers only (mask/paste/silk layers don't carry meaningful net data).\n\nOutput includes:\n- Total unique nets, named vs unnamed count\n- Power and signal net lists (same classification as component analysis)\n- Total pin-to-net mappings (deduplicated across layers)\n\n### 6.7 Trace Width Analysis\n\n`build_trace_analysis()` aggregates conductor aperture data from copper layers:\n- **Unique widths**: Set of all trace widths used (from Conductor-tagged apertures)\n- **Min/max trace**: Smallest and largest trace widths\n- **Minimum feature**: Smallest aperture dimension of any type on copper layers\n\n### 6.8 Pad Summary\n\n`build_pad_summary()` counts pad types by aperture function across copper layers:\n\n| Counter | Source |\n|---|---|\n| SMD apertures | `SMDPad` function on copper layers |\n| Via apertures | `ViaPad` function on copper layers |\n| Heatsink apertures | `HeatsinkPad` function on copper layers |\n| THT holes | Component hole count from drill classification |\n\nWhen both SMD and THT counts are available, an `smd_ratio` is computed (0.0 = all THT, 1.0 = all SMD).\n\n### 6.9 Zip Archive Scanning\n\n`scan_zip_archives()` detects `.zip` files in the Gerber directory and reports metadata to help identify stale archives or stale loose files. Gerber directories commonly contain zip archives — manufacturers require zipped uploads, and designers often snapshot Gerbers at different design stages.\n\n**Per-archive data:**\n- Filename, file size, filesystem modification time\n- Total files inside, broken down by gerber/drill/other\n- Newest member date (from the zip directory entries, not filesystem mtime)\n- Comparison against loose Gerber files: `loose_files_newer`, `archive_newer`, or `same_age`\n- Time delta in hours when ages differ (threshold: 60 seconds to ignore trivial filesystem jitter)\n\n**Comparison logic:** The newest internal member date is preferred over the zip's filesystem mtime for comparison, since filesystem mtime can change from a copy/move without reflecting the actual export time. The loose file side uses the latest filesystem mtime across all gerber and drill files.\n\nThe analyzer does not extract or parse files from inside zip archives — it only inspects the zip directory. This is intentional: the loose files are what gets analyzed, and the zip scan exists to flag when those loose files may not match what was (or will be) uploaded to the manufacturer.\n\nOnly present in output when zip files exist in the directory.\n\n---\n\n## 7. Layer Count Determination\n\nThe total copper layer count is determined from the maximum of three sources:\n1. **Parsed Gerber files**: Count of files identified as copper layers (`*.Cu`)\n2. **Job file**: `GeneralSpecs.LayerNumber` from `.gbrjob`\n3. **Drill layer span**: Maximum layer number from drill file `FileFunction` (e.g., `Plated,1,4` → 4 layers)\n\nThis handles cases where inner layer Gerbers might be missing or misidentified — the drill span and job file still report the correct count.\n\n---\n\n## 8. Output Structure\n\n### Top-Level Keys\n\n| Key | Type | Description |\n|---|---|---|\n| `directory` | string | Input directory path |\n| `generator` | string\\|null | CAD tool that produced the files |\n| `layer_count` | int | Total copper layers |\n| `board_dimensions` | object | Width, height, area, source |\n| `statistics` | object | File counts, total holes/flashes/draws |\n| `completeness` | object | Missing/extra layers, complete flag |\n| `alignment` | object | Aligned flag, issues, per-layer extents |\n| `drill_classification` | object | Vias/component/mounting holes with tools |\n| `pad_summary` | object | SMD/via/heatsink/THT aperture counts |\n| `trace_widths` | object | Width distribution, min feature (if available) |\n| `component_analysis` | object | Component refs, front/back counts (X2 only) |\n| `net_analysis` | object | Net counts, power/signal lists (X2 only) |\n| `gerbers` | array | Per-file summary (compact) |\n| `drills` | array | Per-file summary with tool details |\n| `drill_tools` | object | Aggregated drill sizes and counts |\n| `job_file` | object | Full `.gbrjob` metadata (if present) |\n| `zip_archives` | array | Zip files with contents summary and staleness comparison (if present) |\n| `connectivity` | array | Pin-to-net mappings (`--full` mode only) |\n\n### Per-Gerber Summary\n\nEach entry in the `gerbers` array contains:\n- Filename, identified layer type, units\n- Aperture count, flash/draw/region counts\n- X2 file attributes (if present)\n- Aperture analysis (function counts, conductor widths, min feature)\n- X2 component/net/pin counts per layer (if present)\n\n### Per-Drill Summary\n\nEach entry in the `drills` array contains:\n- Filename, PTH/NPTH type, units\n- Total hole count\n- Tool definitions with diameters and per-tool hole counts\n- Layer span (if available from X2)\n- X2 attributes\n\n### Output Modes\n\n- **Default**: Compact per-file summaries with board-level analysis\n- **`--full`**: Adds raw pin-to-net connectivity data (every TO.P mapping)\n- **`--compact`**: Minified JSON (no indentation)\n\n---\n\n## 9. Known Limitations\n\n### Format Coverage\n\n- **RS-274X only**: Does not parse legacy RS-274D (no embedded aperture definitions). RS-274D requires external aperture files — virtually all modern CAD tools export RS-274X.\n- **Aperture macros**: Custom macro apertures (AM commands) beyond RoundRect are not dimension-parsed. They are still recorded as aperture definitions, but their dimensions don't contribute to trace width or min feature analysis.\n- **Step-and-repeat**: SR commands (array replication) are not interpreted. The coordinate range will reflect the base pattern, not the replicated extent.\n- **Block apertures**: AB (aperture block) commands are not interpreted.\n\n### Coordinate Interpretation\n\n- **Incremental mode**: The parser assumes absolute notation. Files using incremental coordinates (rare in modern output) will produce incorrect coordinate ranges.\n- **Bounding box only**: Coordinate ranges are axis-aligned bounding boxes, not actual board geometry. Non-rectangular boards and cutouts are not detected.\n\n### X2 Attribute Dependency\n\n- Component analysis, net analysis, and pin connectivity **require X2 object attributes** (TO.C, TO.N, TO.P). These are only present in KiCad 6+ and other modern CAD exports that support the X2 extension.\n- KiCad 5 exports include X2 file attributes (TF, via G04 comments) but not object attributes. For KiCad 5 Gerbers, the analyzer provides layer identification and statistics but no component or net data.\n\n### Drill Interpretation\n\n- **Routing commands**: G85 (routed slot), M15/M16 (routed drilling) are not interpreted. Routed slots are not counted as holes.\n- **Multiple drill files**: Some exports split PTH and NPTH into separate files, others combine them. The analyzer handles both — it parses all `.drl` files and merges results. But if a combined file has no X2 attributes and no filename hint, it defaults to `\"unknown\"` type.\n\n### Cross-Layer Analysis\n\n- The analyzer does not perform geometric cross-referencing between layers (e.g., checking that a drill hole aligns with pads on copper layers, or that solder mask openings match pad sizes). These checks require spatial correlation that the Gerber format does not natively support without full coordinate parsing and matching.\n\n---\n\n## 10. Verification\n\nThe analyzer can be verified by:\n1. **Round-trip comparison**: Run on Gerbers exported from a KiCad project, then compare component/net counts against the schematic and PCB analyses of the same project\n2. **Job file cross-check**: Board dimensions and layer count from the analyzer should match `.gbrjob` values\n3. **Completeness**: The completeness check itself verifies that the expected file list from `.gbrjob` matches what was found on disk\n4. **Alignment**: Running on known-good Gerber sets should always report `\"aligned\": true`\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":22380,"content_sha256":"bba75063a377aebf9acf8c16eb25fd83777e20433280671625293721c330529d"},{"filename":"scripts/README.md","content":"# KiCad Analysis Scripts — Developer Reference\n\nThis directory contains the core analysis scripts, shared utilities, the S-expression parser, and the rich-finding/trust-summary infrastructure. Each analyzer outputs a structured JSON envelope for the AI agent to consume during design reviews.\n\n| Script | Input | Size | Purpose |\n|--------|-------|------|---------|\n| `analyze_schematic.py` | `.kicad_sch` / `.sch` | ~9,300 LOC | Component extraction, net building, subcircuit detection, signal/power/BOM/DFM analysis, audit detectors |\n| `analyze_pcb.py` | `.kicad_pcb` | ~6,600 LOC | Footprint inventory, routing, signal integrity, power, thermal, placement, manufacturing, DFM, union-find connectivity graph, assembly/DFM checks |\n| `analyze_gerbers.py` | Gerber dir (`.gbr`/`.drl`) | ~1,400 LOC | Layer completeness, drill holes, apertures, coordinate alignment, X2 attributes |\n| `analyze_thermal.py` | schematic + PCB JSON | ~910 LOC | Junction-temperature estimator with package θJA, thermal via correction, proximity warnings |\n| `cross_analysis.py` | schematic + PCB JSON | ~430 LOC | Cross-domain checks: CC-001 connector current, EG-001 ESD gaps, DA-001 decoupling adequacy, XV-001..003, PCB intelligence (NR/RP/TW/PS/VS/DP-005) |\n| `lifecycle_audit.py` | schematic JSON + distributor API | ~855 LOC | Component obsolescence, temperature audit (LC-001..006, LT-001) |\n| `sexp_parser.py` | — | ~220 LOC | S-expression parser shared by schematic and PCB analyzers |\n| `kicad_utils.py` | — | ~860 LOC | Shared utilities: component classification, value parsing, net detection, switching-frequency table, Vref lookup |\n| `kicad_types.py` | — | ~110 LOC | Typed dataclass (`AnalysisContext`) shared across detectors |\n| `signal_detectors.py` | — | ~4,400 LOC | Core signal path detectors (regulators, filters, opamps, dividers, crystals, transistors, bridges, protection), plus v1.3 audit detectors (RS-001, LB-001, PP-001) |\n| `domain_detectors.py` | — | ~6,100 LOC | Domain-specific detectors (RF, Ethernet, HDMI, memory, BMS, battery chargers, motor drivers, wireless modules, etc.) |\n| `validation_detectors.py` | — | ~1,000 LOC | Validation detectors (PU-001, VM-001, PR-001..004, PS-001, LR-001, FS-001) |\n| `finding_schema.py` | — | ~330 LOC | `make_finding()` factory, `Det.*` constants, `get_findings()` / `group_findings()` helpers, `trust_summary` aggregation, `sort_findings()` determinism |\n| `output_filters.py` | — | ~460 LOC | Stage/audience filtering (`--stage schematic/layout/pre_fab/bring_up`, `--audience designer/reviewer/manager`) |\n| `pcb_connectivity.py` | — | ~300 LOC | Union-find over pads/tracks/vias/zone fills for per-net island detection (used by `analyze_pcb.py --full`) |\n| `project_config.py` | — | ~870 LOC | `.kicad-happy.json` loader, suppression matching, design intent resolution |\n| `analysis_cache.py` | — | ~510 LOC | Analysis-folder convention, manifest-based run tracking, SHA-256 staleness detection |\n| `diff_analysis.py` | two analyzer JSONs | ~950 LOC | Diff-aware design comparison (component/signal/EMC/SPICE) |\n| `what_if.py` | analyzer JSON + patch spec | ~1,500 LOC | Parameter sweep + automated fix suggestions (inverse solvers with E-series snapping) |\n| `summarize_findings.py` | analysis/manifest.json | ~200 LOC | Cross-run severity × count rollup |\n\nDetailed methodology documentation for each analyzer:\n- `methodology_schematic.md` — parsing pipeline, net building, component classification, detector inventory\n- `methodology_pcb.md` — extraction, union-find connectivity, DFM scoring, thermal/placement/SI analysis, assembly/DFM checks\n- `methodology_gerbers.md` — RS-274X/Excellon parsing, X2 attributes, layer identification, completeness/alignment checks\n\n---\n\n## sexp_parser.py\n\nParses KiCad's Lisp-like S-expression format into nested Python lists. Used by both `analyze_schematic.py` and `analyze_pcb.py`.\n\n### API\n\n| Function | Purpose |\n|----------|---------|\n| `parse_file(path)` | Parse a `.kicad_sch` or `.kicad_pcb` file → nested lists |\n| `find_all(node, keyword)` | Find direct children starting with keyword |\n| `find_first(node, keyword)` | Find first direct child starting with keyword |\n| `find_deep(node, keyword)` | Recursive search at any depth |\n| `get_value(node, keyword)` | Get value from `(keyword value)` pair |\n| `get_property(node, prop_name)` | Get value from `(property \"name\" \"value\")` |\n| `get_at(node)` | Get `(x, y, angle)` from `(at ...)` node |\n| `get_xy(node)` | Get `(x, y)` from `(xy ...)` node |\n\n**Design note**: The parser is intentionally simple — no schema validation, no type coercion beyond strings. All values come back as strings; callers convert to `float`/`int` as needed. This makes it robust against KiCad version differences.\n\n**Pitfall**: `find_all` and `find_first` only search direct children. For nested structures, use `find_deep` — but be aware it can return matches from unrelated subtrees.\n\n---\n\n## analyze_pcb.py\n\nParses `.kicad_pcb` files (KiCad 5 `module` and KiCad 6+ `footprint` formats).\n\n### Pipeline\n\n```\n.kicad_pcb file\n |\n v\n EXTRACTION (core data)\nextract_layers() -- Layer stack definitions (incl. jumper layers)\nextract_setup() -- Thickness, stackup, copper finish, paste ratio, teardrops\nextract_nets() -- Net number → name mapping\nextract_footprints() -- Footprints with pads, courtyards, attrs, sch cross-ref\nextract_tracks() -- Track segments and arcs with width/layer stats\nextract_vias() -- Vias with type, free flag, tenting\nextract_zones() -- Zones with fill areas, keepouts, priority, pad connection\nextract_board_outline() -- Edge.Cuts geometry, bounding box\nextract_board_metadata() -- Title block, properties, paper size\nextract_dimensions() -- Designer-placed dimension annotations\nextract_groups() -- Designer-defined component/routing groups\nextract_net_classes() -- Net class definitions (KiCad 5 legacy)\nextract_silkscreen() -- Board-level text on SilkS/Fab layers\n |\n v\n ANALYSIS (derived facts)\nanalyze_connectivity() -- Unrouted nets (zone-aware)\nanalyze_net_lengths() -- Per-net trace length (segments + arcs)\nanalyze_power_nets() -- Power net routing summary\nanalyze_decoupling_placement() -- Cap-to-IC distance\nanalyze_ground_domains() -- AGND/DGND split detection\nanalyze_current_capacity() -- Track widths per net for IPC-2221\nanalyze_vias() -- Type breakdown, annular ring, via-in-pad, fanout, current\nanalyze_thermal_vias() -- Zone stitching density, thermal pad detection\nanalyze_layer_transitions() -- Signal net layer changes (ground return paths)\nanalyze_placement() -- Courtyard overlaps, edge clearance, density\nanalyze_trace_proximity() -- Spatial grid crosstalk assessment (optional)\ncompute_statistics() -- Summary counts\n |\n v\nJSON output (~50-300KB depending on board complexity)\n```\n\n### Key Design Decisions\n\n- **Pad positions are absolute**: Pad `(at)` is relative to footprint; the code rotates by footprint angle and adds footprint position to compute absolute coordinates.\n- **Footprint summary omits raw pads by default**: The JSON output includes `connected_nets` per footprint instead of full pad arrays. Use `--full` for individual track/via data.\n- **Zone fill areas computed without storing coordinates**: Shoelace formula applied directly to parsed S-expression nodes — the parse tree is already in memory, we just iterate and accumulate. Avoids the massive memory cost of storing filled polygon coordinate arrays.\n- **Keepout zones distinguished from copper zones**: Zones with `(keepout ...)` blocks are flagged with `is_keepout: true` and their restriction types (tracks, vias, pads, copperpour, footprints).\n- **Extended footprint attributes**: Parses full `(attr ...)` node including `dnp`, `board_only`, `exclude_from_bom`, `exclude_from_pos_files`. Also extracts schematic cross-reference (`path`, `sheetname`, `sheetfile`), net ties, 3D model references, and manufacturer/MPN properties.\n- **Custom pad copper area**: Pads with `custom` shape have their `(primitives (gr_poly ...))` areas computed via shoelace, giving accurate copper area for power MOSFET pads.\n- **Free vias identified**: Vias with `(free yes)` are flagged — typically stitching or thermal vias not anchored to tracks.\n- **Pin function/type carried from schematic**: Pad-level `pinfunction` and `pintype` enable power-pin vs signal-pin differentiation without needing the schematic.\n- **KiCad 5 compatibility**: Handles `(module ...)`, `(fp_text reference ...)`, `(net_class ...)`, and `(dimension ...)` in addition to KiCad 6+ equivalents.\n- **Unrouted detection**: Zone-aware — nets routed only through copper pours are not flagged as unrouted.\n- **Facts over judgement**: Analysis functions provide raw facts (track widths, via counts, distances) rather than pass/fail verdicts, enabling flexible higher-level analysis.\n\n### Usage\n\n```bash\npython3 analyze_pcb.py board.kicad_pcb # JSON to stdout\npython3 analyze_pcb.py board.kicad_pcb --output out.json # JSON to file\npython3 analyze_pcb.py board.kicad_pcb --compact # Minified JSON\npython3 analyze_pcb.py board.kicad_pcb --full # Include individual tracks/vias\npython3 analyze_pcb.py board.kicad_pcb --proximity # Add crosstalk proximity analysis\n```\n\n---\n\n## analyze_gerbers.py\n\nParses a directory of Gerber RS-274X files and Excellon drill files. Does NOT render the gerbers — it extracts metadata, counts, and performs sanity checks.\n\n### Pipeline\n\n```\ngerber directory\n |\n v\nparse_gerber() -- Per-file: apertures, X2 attributes, flash/draw counts, coord range\nparse_drill() -- Per-file: tool definitions, hole counts, coord range, PTH/NPTH type\nscan_zip_archives() -- Zip contents inventory + timestamp comparison vs loose files\n |\n v\nidentify_layer_type() -- Map filename/X2 attributes to KiCad layer names (F.Cu, B.Mask, etc.)\ncheck_completeness() -- Verify required layers present (F.Cu, B.Cu, F.Mask, B.Mask, Edge.Cuts)\ncheck_alignment() -- Compare coordinate extents across copper/edge layers\n |\n v\nJSON output\n```\n\n### Layer Identification\n\nUses two strategies, in order:\n1. **X2 attributes**: `%TF.FileFunction,...*%` headers (modern gerbers from KiCad 6+)\n2. **Filename patterns**: Maps common suffixes/extensions to layers (e.g., `.gtl` → F.Cu, `.gbl` → B.Cu, `F_Cu.gbr` → F.Cu)\n\n**Pitfall**: The filename patterns dictionary is case-insensitive substring matching. Non-standard naming (e.g., a file called `top_copper.ger`) won't be identified. Add patterns to the `patterns` dict in `identify_layer_type()` as needed.\n\n### Alignment Check\n\nCompares bounding box extents across copper and edge layers. Only checks F.Cu, B.Cu, and Edge.Cuts — paste, silk, mask, and drill layers naturally have smaller extents. A >2mm difference flags an alignment issue.\n\n### Drill File Parsing\n\n- Handles both metric and inch formats (auto-detects from `METRIC`/`INCH` keywords)\n- Inch values are converted to mm internally\n- PTH vs NPTH is determined from filename (`-PTH.drl` vs `-NPTH.drl`)\n- Individual hole coordinates are parsed for coordinate range but not included in output (too verbose)\n\n### Usage\n\n```bash\npython3 analyze_gerbers.py ./gerbers/ # JSON to stdout\npython3 analyze_gerbers.py ./gerbers/ --output out.json # JSON to file\npython3 analyze_gerbers.py ./gerbers/ --compact # Minified JSON\n```\n\n---\n\n## analyze_schematic.py\n\nThe largest and most complex script. The rest of this document focuses on its architecture and pitfalls.\n\n### Pipeline\n\n```\n.kicad_sch file(s)\n |\n v\nparse_single_sheet() -- S-expression parsing, component/wire/label extraction\n |\n v\nanalyze_schematic() -- Multi-sheet orchestration, instance remapping\n | Builds: all_components, all_wires, all_labels, all_junctions\n |\n v\nbuild_net_map() -- Union-find net building (sheet-aware coordinates)\n | Produces: nets dict {name -> {pins, labels, ...}}\n |\n v\nanalyze_signal_paths() -- Subcircuit detection (VD, RC, regulators, bridges, etc.)\nanalyze_design_rules() -- Bus detection, diff pairs, power domains, ERC\nanalyze_ic_pinouts() -- Per-IC pin connectivity summary\ncompute_statistics() -- Counts, BOM dedup\n |\n v\nOutput harmonization -- All detections → flat findings[] with rich envelopes\n (detector, rule_id, severity, confidence, recommendation)\n rail_voltages/net_classifications promoted to top level\n |\n v\nJSON output -- {analyzer_type, summary, findings[], components, nets, ...}\n```\n\n### Key Data Structures\n\n- **`nets`**: `{net_name: {\"pins\": [{component, pin_number, pin_name, pin_type, x, y}], ...}}`\n- **`pin_net`**: `{(reference, pin_number): (net_name, pin_type)}` — reverse lookup from `build_pin_to_net_map()`\n- **`comp_lookup`**: `{reference: component_dict}` — built locally in analysis functions\n- **`parsed_values`**: `{reference: float}` — numeric values for passive components\n\n## File Format Support\n\n### Modern `.kicad_sch` (KiCad 6+)\nFull support. S-expression format parsed by `sexp_parser.py`.\n\n### Legacy `.sch` (KiCad 4/5)\nLine-based format. Components, wires, labels, power symbols parsed. `.lib` symbol libraries are parsed for pin definitions (`parse_legacy_lib()`), enabling pin-to-net mapping via geometric snapping. Library resolution searches cache-lib, sym-lib-table, LIBS: directives, and built-in defaults. Pin geometry uses a snapping radius (up to 12mm) when parsed symbols are incomplete — results are heuristic and carry reduced confidence compared to KiCad 6+ native pin data.\n\n### Eagle `.sch`\nNot supported (binary and XML formats). Returns 0 components gracefully.\n\n## Critical Concepts\n\n### Sheet-Aware Coordinate Keys\n\n**Problem**: Different hierarchical sheets can have wires at identical coordinates. Without sheet separation, the union-find merges nets across sheets (e.g., +3V3 and +5V merge because wires at (100,50) exist on both sheets).\n\n**Solution**: Every element (component, wire, label, junction) is tagged with `_sheet` index. All coordinate-based keys in `build_net_map()` include the sheet index: `(x, y, sheet)` not `(x, y)`.\n\n**Pitfall**: If you add new coordinate-based lookups, always include `_sheet` in the key. Forgetting this causes silent cross-sheet net merges that are extremely hard to debug.\n\n### Multi-Instance Hierarchical Sheets\n\n**Problem**: A parent sheet can reference the same sub-sheet file multiple times (e.g., 3 instances of `h_bridge.kicad_sch` for 3 motor phases). Each instance has different component references (Q1/Q2, Q3/Q4, Q5/Q6).\n\n**How it works**:\n1. `parse_single_sheet()` returns sub-sheet entries as `(path, uuid)` tuples\n2. The main loop tracks `(file_path, instance_uuid)` pairs — same file with different UUIDs gets parsed separately\n3. `extract_components()` reads the `(instances)` block in each symbol to remap the reference designator for the specific instance UUID\n4. Each instance gets its own `_sheet` index\n\n**KiCad storage format**: Each symbol in a sub-sheet has:\n```\n(instances\n (project \"project_name\"\n (path \"/root_uuid/sheet_instance_uuid\"\n (reference \"Q4\")\n (unit 1))\n (path \"/root_uuid/other_instance_uuid\"\n (reference \"Q6\")\n (unit 1))))\n```\n\nThe sheet's UUID comes from the parent's `(sheet ... (uuid \"xxx\"))` block.\n\n### Multi-Unit Symbols\n\n**Problem**: ICs like STM32 have multiple units (GPIO unit, power unit, etc.) placed as separate symbols on the schematic. Each unit has different pins, but they share the same reference (e.g., U1).\n\n**Solution**:\n- `extract_lib_symbols()` stores pins per unit in `unit_pins` dict\n- `extract_components()` reads `(unit N)` from each placed symbol\n- `compute_pin_positions()` filters pins by unit number\n- `generate_bom()` and `compute_statistics()` deduplicate by reference (count U1 once, not per unit)\n\n**Pitfall**: Multi-unit components appear multiple times in `all_components`. Always use reference-based dedup when counting unique components.\n\n### Label Scoping Rules\n\n- **Local labels** (`label`): Connect only within their sheet (`_sheet` index must match)\n- **Global labels** (`global_label`): Connect across all sheets\n- **Hierarchical labels** (`hierarchical_label`): Connect via parent sheet's hierarchical pin\n- **Power symbols**: Behave like global labels (connect across all sheets by name)\n\nIn `build_net_map()`, local labels use `(name, sheet)` as their union key, while global/hierarchical labels and power symbols use `(name,)` (no sheet).\n\n### Net Name Assignment\n\nNets are assigned names with this priority:\n1. Power symbol name (e.g., \"GND\", \"+3V3\")\n2. Global/hierarchical label name\n3. Local label name\n4. `__unnamed_N` for nets with no label\n\n**Duplicate name handling**: When multiple disconnected wire groups share the same net name (e.g., two separate \"GND\" connections via local labels), the second group's pins are merged into the first's net entry rather than overwriting it. This was a previous bug (commit f8ae22b).\n\n## Value Parser\n\n`parse_value()` converts component value strings to floats:\n\n| Input | Output | Notes |\n|-------|--------|-------|\n| `\"4.7k\"` | 4700.0 | SI prefix |\n| `\"4K7\"` | 4700.0 | Embedded multiplier |\n| `\"0R1\"` | 0.1 | R as decimal point |\n| `\"100n\"` | 1e-7 | |\n| `\"300µ\"` | 0.0003 | Unicode micro |\n| `\"0.3mOhm\"` | 0.0003 | Ohm suffix stripped |\n| `\"220k/R0402\"` | 220000.0 | Splits on \"/\" first |\n| `\"4.7k 1%\"` | 4700.0 | Tolerance stripped |\n| `\"DNP\"` | None | Not parseable |\n\n**Pitfall**: The parser is generous — it will parse the first numeric-looking thing it finds. Value fields like \"FDMT80080DC\" (a MOSFET part number) may parse to a number. Always check `c[\"type\"]` before using parsed values.\n\n## Signal Analysis Patterns\n\n### Detection Pattern: Two Resistors Sharing a Net (Voltage Dividers)\n\nIterates all resistor pairs, finds shared nets (mid-point), checks endpoints for power/ground.\n\n**Known pitfalls**:\n- **R_top/R_bottom assignment**: After swapping r1/r2 to fix orientation, must re-derive net membership from current r1/r2 (not stale `r1_n1` variables). Previous bug: stale variables caused ratio inversion.\n- **Power rail mid-point filter**: If the mid-point has >4 connections and is a power/ground net, reject — it's a bus, not a divider output.\n- **Solder jumper gaps**: Dividers gated by solder jumpers (SJ) break the direct R-R series topology. Accepted limitation.\n\n### Detection Pattern: IC Pin Matching (Regulators, Op-amps)\n\nScans IC pins by name (FB, SW, BOOT, VIN, VOUT, etc.) to classify function.\n\n**Key rule**: Strip trailing digits before matching (`pn_base = pname.rstrip(\"0123456789\")`). Multi-channel regulators have pins like FB1, SW2, ADJ2.\n\n**Regulator false positive prevention**: ICs without FB/SW/BOOT pins require regulator keywords in lib_id/value. ICs with SW pin but no inductor on the SW net also require keywords. This prevents analog ICs with \"SW\" pins (like AD8233 gain switch) from being classified as regulators.\n\n### Detection Pattern: Component on Both Sides (Current Sense, Bridges)\n\nFinds ICs connected to both nets of a 2-terminal component (shunt resistor for current sense, transistors for bridges).\n\n**4-pin Kelvin shunts**: Check for pin 3/4 presence *before* using `get_two_pin_nets()`. Kelvin shunts have pins 1,4 (current path) and pins 2,3 (sense). `get_two_pin_nets()` returns pins 1,2 which is wrong for Kelvin.\n\n**1-hop tracing**: For current sense, if no IC is found directly on both sides of the shunt, trace through resistors (filter resistors between shunt and sense IC are common in BMS designs).\n\n### Detection Pattern: Keyword Matching (ESD, Memory, RF)\n\nMany detectors use keyword lists to identify component types from value/lib_id strings. When adding new keywords:\n- Use lowercase matching (`val.lower()`)\n- Test against the batch suite to check false positive rates\n- Substring matching can be too broad (e.g., `\"power\"` matched `\"dc-power-supply-rescue\"` — fixed by requiring exact prefix match or `_power` suffix)\n\n### Component Type Classification\n\n`classify_component()` uses reference prefix → type mapping, then fallback keyword checks on value/lib_id.\n\n**X prefix ambiguity**: X can mean crystal (IEC standard) or connector (some designers). The code checks value/lib keywords. Active oscillators (MEMS, TCXO) with \"oscillator\" in lib but not \"crystal\"/\"xtal\" get typed as `\"oscillator\"` (an IC-like active device), not `\"crystal\"` (passive).\n\n**Power symbols**: Detected by `(power)` flag in lib_symbol definition, or `#PWR`/`#FLG` reference prefix, or `lib_prefix == \"power\"` / `lib_prefix.endswith(\"_power\")`. The substring check was previously too broad.\n\n## Adding New Detection Features\n\n1. **Start with the net graph**. Most detections work by finding components sharing nets with specific topologies.\n\n2. **Use `get_two_pin_nets()`** for passive 2-terminal components. For multi-pin ICs, iterate `pin_net.get((ref, pin_number))`.\n\n3. **Filter high-fanout nets**. Power rails (+3V3, GND) connect to many components. Most detection patterns should skip or special-case nets with >4-6 connections, or nets identified as power/ground by `is_power_net()`/`is_ground()`.\n\n4. **Test against the harness**. See `kicad-happy-testharness` repo — `run_tests.py --smoke` runs the 565-test PR-gate subset in ~30s with no corpus dependency, `run_tests.py --quick-sanity` runs 5-repo assertions, and `run/run_schematic.py --jobs 16` runs the full 5,829-repo corpus regression (~30 min).\n\n5. **Count detections across the corpus** to calibrate sensitivity. Too many detections (>1000 for a specific pattern across the 36,000+ schematic files) suggests false positives. Too few (\u003c5) might mean overly narrow keywords. Use `run/run_schematic.py --cross-section smoke` or `--cross-section quick_200` for faster calibration passes.\n\n6. **Validate manually** against 2-3 known schematics where the pattern definitely exists. Check that component references, net names, and computed values match what you see in the raw schematic.\n\n## Test Harness\n\nLocation: `kicad-happy-testharness` sibling repo.\n\n- 5,829 open-source KiCad projects spanning KiCad 5 through 10\n- ~36,500 schematic files, ~18,700 PCB files, ~5,500 gerber dirs\n- 2M+ regression assertions at 99.98%+ pass, 565-test smoke subset, 5-repo quick-sanity\n- Schema drift tests across all 8 analyzer types\n- Equation audit (107 tagged equations), constants audit (105+ switching freqs), bugfix guards\n\nThe harness is the authoritative validation layer. For the legacy `batchtest` directory some older scripts reference — the 1,053-file subset that lived under `~/Projects/sandbox/batchtest/` — is retired. All new detector work validates against the harness corpus.\n\n## Known Remaining Limitations\n\n- **Legacy pin mapping**: `.sch` pin-to-net mapping uses heuristic geometry snapping (up to 12mm radius) when `.lib` symbols are incomplete or resolved from fallback sources\n- **Vout estimation**: Feedback divider Vout uses hardcoded Vref guesses (0.6, 0.8, 1.0, 1.22, 1.25V) without a component database\n- **Regulator output_rail**: Switching regulators sometimes show null output_rail when the power net is on the inductor output side\n- **Eagle files**: Not parseable — output 0 components\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":23598,"content_sha256":"41fa29c3c3232ab4b02dc6d5dcba81fef265109eefe712a5cd67daa507dc6fbb"},{"filename":"scripts/sexp_parser.py","content":"\"\"\"\nS-expression parser for KiCad files.\n\nParses KiCad's Lisp-like S-expression format into nested Python lists.\nHandles quoted strings, multi-line expressions, and large files efficiently.\n\nUsage:\n from sexp_parser import parse_file, find_all, find_first, get_property\n\"\"\"\n\nimport re\nimport sys\nfrom typing import Any\n\n# KiCad brace-escape sequences (common/string_utils.cpp)\n_BRACE_ESCAPES = {\n \"{dblquote}\": '\"', \"{quote}\": \"'\", \"{lt}\": \"\u003c\", \"{gt}\": \">\",\n \"{backslash}\": \"\\\\\", \"{slash}\": \"/\", \"{bar}\": \"|\", \"{colon}\": \":\",\n \"{space}\": \" \", \"{amp}\": \"&\", \"{tab}\": \"\\t\", \"{newline}\": \"\\n\",\n \"{return}\": \"\\r\", \"{brace}\": \"{\",\n}\n_BRACE_RE = re.compile(r\"\\{[a-z]+\\}\")\n\n\ndef _unescape_braces(s: str) -> str:\n \"\"\"Replace KiCad {brace_escape} sequences with their characters.\"\"\"\n if \"{\" not in s:\n return s\n return _BRACE_RE.sub(lambda m: _BRACE_ESCAPES.get(m.group(0), m.group(0)), s)\n\n\ndef parse(text: str) -> list:\n \"\"\"Parse S-expression text into nested Python lists.\"\"\"\n tokens = _tokenize(text)\n result = _parse_tokens(tokens, 0)[0]\n return result\n\n\ndef parse_file(path: str) -> list:\n \"\"\"Parse a KiCad S-expression file.\"\"\"\n with open(path, \"r\", encoding=\"utf-8\", errors=\"replace\") as f:\n return parse(f.read())\n\n\ndef _tokenize(text: str) -> list[str]:\n \"\"\"Tokenize S-expression text into a flat list of tokens.\"\"\"\n tokens = []\n i = 0\n n = len(text)\n while i \u003c n:\n c = text[i]\n if c in \" \\t\\n\\r\":\n i += 1\n elif c == \"#\":\n # Line comment — skip to end of line\n while i \u003c n and text[i] != \"\\n\":\n i += 1\n elif c == \"(\":\n tokens.append(\"(\")\n i += 1\n elif c == \")\":\n tokens.append(\")\")\n i += 1\n elif c == '\"':\n # Quoted string\n j = i + 1\n while j \u003c n:\n if text[j] == \"\\\\\":\n j += 2\n elif text[j] == '\"':\n break\n else:\n j += 1\n raw = text[i + 1 : j]\n # Unescape: \\\\→placeholder first, then known sequences, then restore\n raw = raw.replace(\"\\\\\\\\\", \"\\x00\").replace('\\\\\"', '\"').replace(\"\\\\n\", \"\\n\").replace(\"\\\\r\", \"\\r\").replace(\"\\x00\", \"\\\\\")\n tokens.append(_unescape_braces(raw))\n i = j + 1\n else:\n # Unquoted atom\n j = i\n while j \u003c n and text[j] not in \" \\t\\n\\r()\\\"\":\n j += 1\n tokens.append(_unescape_braces(text[i:j]))\n i = j\n return tokens\n\n\ndef _parse_tokens(tokens: list[str], pos: int) -> tuple[Any, int]:\n \"\"\"Recursively parse tokens starting at pos. Returns (result, new_pos).\"\"\"\n # KH-101: Bounds check for truncated/malformed files with unbalanced parens\n if pos >= len(tokens):\n raise ValueError(\"Unexpected end of input at position %d\" % pos)\n if tokens[pos] == \"(\":\n lst = []\n pos += 1\n while pos \u003c len(tokens) and tokens[pos] != \")\":\n item, pos = _parse_tokens(tokens, pos)\n lst.append(item)\n return lst, pos + 1 # skip ')'\n else:\n return tokens[pos], pos + 1\n\n\ndef find_all(node: list, keyword: str) -> list[list]:\n \"\"\"Find all direct children of node that start with keyword.\n\n Example: find_all(root, \"symbol\") returns all (symbol ...) blocks.\n \"\"\"\n if not isinstance(node, list):\n return []\n return [child for child in node if isinstance(child, list) and len(child) > 0 and child[0] == keyword]\n\n\ndef find_first(node: list, keyword: str) -> list | None:\n \"\"\"Find first direct child of node that starts with keyword.\"\"\"\n if not isinstance(node, list):\n return None\n for child in node:\n if isinstance(child, list) and len(child) > 0 and child[0] == keyword:\n return child\n return None\n\n\ndef find_deep(node: list, keyword: str) -> list[list]:\n \"\"\"Recursively find all nodes starting with keyword at any depth.\"\"\"\n results = []\n if not isinstance(node, list):\n return results\n _find_deep_acc(node, keyword, results)\n return results\n\n\ndef _find_deep_acc(node: list, keyword: str, acc: list) -> None:\n \"\"\"Accumulator helper for find_deep — avoids intermediate list allocations.\"\"\"\n if len(node) > 0 and node[0] == keyword:\n acc.append(node)\n for child in node:\n if isinstance(child, list):\n _find_deep_acc(child, keyword, acc)\n\n\ndef get_value(node: list, keyword: str) -> str | None:\n \"\"\"Get the value of a simple (keyword value) pair.\n\n Example: get_value(symbol, \"lib_id\") -> \"Device:C\"\n \"\"\"\n child = find_first(node, keyword)\n if child and len(child) > 1:\n return str(child[1])\n return None\n\n\ndef get_property(node: list, prop_name: str) -> str | None:\n \"\"\"Get the value of a named property (exact case match).\n\n Handles KiCad 9+ ``(property private \"Name\" \"Value\" ...)`` format\n where ``private`` shifts the name/value indices by one.\n\n Example: get_property(symbol, \"Reference\") -> \"C7\"\n \"\"\"\n for child in node:\n if isinstance(child, list) and len(child) >= 3 and child[0] == \"property\":\n off = 1 if child[1] == \"private\" else 0\n if len(child) >= 3 + off and child[1 + off] == prop_name:\n return str(child[2 + off])\n return None\n\n\ndef get_properties(node: list) -> dict[str, str]:\n \"\"\"Return all properties of a node as a case-normalised dict.\n\n Handles KiCad 9+ ``(property private ...)`` format.\n\n Keys are lowercased so callers can do case-insensitive lookups without\n enumerating every possible capitalisation variant.\n\n Example:\n props = get_properties(sym)\n digikey = props.get(\"digikey\") or props.get(\"digi-key part number\") or \"\"\n \"\"\"\n result: dict[str, str] = {}\n for child in node:\n if isinstance(child, list) and len(child) >= 3 and child[0] == \"property\":\n off = 1 if child[1] == \"private\" else 0\n if len(child) >= 3 + off:\n result[child[1 + off].lower()] = str(child[2 + off])\n return result\n\n\ndef get_at(node: list) -> tuple[float, float, float] | None:\n \"\"\"Get (x, y, angle) from an (at x y [angle]) node.\"\"\"\n at = find_first(node, \"at\")\n if at and len(at) >= 3:\n x = float(at[1])\n y = float(at[2])\n angle = float(at[3]) if len(at) > 3 else 0.0\n return (x, y, angle)\n return None\n\n\ndef get_xy(node: list) -> tuple[float, float] | None:\n \"\"\"Get (x, y) from an (xy x y) node.\"\"\"\n if isinstance(node, list) and len(node) >= 3 and node[0] == \"xy\":\n return (float(node[1]), float(node[2]))\n return None\n\n\ndef has_flag(node: list, flag: str) -> bool:\n \"\"\"Check if a node contains a flag like 'hide' or 'yes'.\n\n Handles three KiCad forms:\n - Bare token: (pin ... hide ...) — legacy (KiCad 5/6/early 7)\n - Boolean yes: (pin ... (hide yes) ...) — post-20241004\n - Boolean no: (pin ... (hide no) ...) — post-20241004 (returns False)\n\n Absent flag returns False.\n \"\"\"\n if flag in node:\n return True\n for child in node:\n if isinstance(child, list) and len(child) >= 2 and child[0] == flag:\n return str(child[1]).lower() in (\"yes\", \"true\")\n return False\n\n\nif __name__ == \"__main__\":\n if len(sys.argv) \u003c 2:\n print(\"Usage: python sexp_parser.py \u003cfile.kicad_sch|.kicad_pcb>\")\n sys.exit(1)\n tree = parse_file(sys.argv[1])\n print(f\"Parsed {sys.argv[1]}: root node = {tree[0] if isinstance(tree, list) else tree}\")\n print(f\"Top-level children: {len(tree) - 1}\")\n","content_type":"text/x-python; charset=utf-8","language":"python","size":7679,"content_sha256":"4932e12f97827599f09c2e072749fcbfba8df58d43ecc2aa4cb9161211c59afa"}],"content_json":{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"text":"KiCad Project Analysis Skill","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Related Skills","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Skill","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Purpose","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"bom","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"BOM extraction, enrichment, ordering, and export workflows","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"digikey","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Search DigiKey for parts (prototype sourcing)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"mouser","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Search Mouser for parts (secondary prototype source)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"lcsc","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Search LCSC for parts (production sourcing, JLCPCB)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"element14","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Search Newark/Farnell/element14 (international sourcing, reliable datasheets)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"jlcpcb","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"PCB fabrication & assembly ordering","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"pcbway","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Alternative PCB fabrication & assembly","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"spice","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"SPICE simulation verification of detected subcircuits","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"emc","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"EMC pre-compliance risk analysis — consumes schematic + PCB analyzer output","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"Handoff guidance:","type":"text","marks":[{"type":"strong"}]},{"text":" Use this skill to parse schematics/PCBs and extract structured data. Hand off to ","type":"text"},{"text":"bom","type":"text","marks":[{"type":"code_inline"}]},{"text":" for BOM enrichment, pricing, and ordering. Hand off to ","type":"text"},{"text":"digikey","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"mouser","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"lcsc","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"element14","type":"text","marks":[{"type":"code_inline"}]},{"text":" for part searches and datasheet fetching. Hand off to ","type":"text"},{"text":"jlcpcb","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"pcbway","type":"text","marks":[{"type":"code_inline"}]},{"text":" for fabrication ordering and DFM rule validation. ","type":"text"},{"text":"Always run ","type":"text","marks":[{"type":"strong"}]},{"text":"spice","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" for simulation verification during design reviews when any SPICE simulator is installed (check with ","type":"text"},{"text":"which ngspice ltspice xyce","type":"text","marks":[{"type":"code_inline"}]},{"text":"). ","type":"text"},{"text":"Always run ","type":"text","marks":[{"type":"strong"}]},{"text":"emc","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" for EMC pre-compliance risk analysis during design reviews when both schematic and PCB analysis are available. These are not optional — skipping them leaves value-computation errors and EMC risks undetected.","type":"text"}]},{"type":"paragraph","content":[{"text":"Before analysis:","type":"text","marks":[{"type":"strong"}]},{"text":" When the user asks to analyze or review a KiCad project, check whether a ","type":"text"},{"text":"datasheets/","type":"text","marks":[{"type":"code_inline"}]},{"text":" directory exists in the project. If not, and DigiKey API keys are available (","type":"text"},{"text":"DIGIKEY_CLIENT_ID","type":"text","marks":[{"type":"code_inline"}]},{"text":"), offer to sync datasheets first: \"I can download datasheets for your components before analysis — this enables pin-level verification and decoupling validation against manufacturer specs. Want me to sync them?\" If the user declines or no API keys are set, proceed without datasheets — the analysis works without them but datasheet verification findings won't be available.","type":"text"}]},{"type":"paragraph","content":[{"text":"If you see a ","type":"text","marks":[{"type":"strong"}]},{"text":"DS-001","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" finding in the analyzer output","type":"text","marks":[{"type":"strong"}]},{"text":" (severity ","type":"text"},{"text":"high","type":"text","marks":[{"type":"code_inline"}]},{"text":", detector ","type":"text"},{"text":"audit_datasheet_coverage","type":"text","marks":[{"type":"code_inline"}]},{"text":"), the review cannot make any verified claim. Stop and either (a) run the datasheet sync via ","type":"text"},{"text":"digikey","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"mouser","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"lcsc","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"element14","type":"text","marks":[{"type":"code_inline"}]},{"text":" (whichever has credentials/stock), (b) populate MPNs on the BOM parts, or (c) state explicitly in the report that every pin-level, electrical, and regulator finding is ","type":"text"},{"text":"consistency only","type":"text","marks":[{"type":"em"}]},{"text":" — do not use the words \"verified\", \"confirmed\", or \"per datasheet\" anywhere. ","type":"text"},{"text":"DS-002","type":"text","marks":[{"type":"code_inline"}]},{"text":" (datasheets missing but MPNs set) and ","type":"text"},{"text":"DS-003","type":"text","marks":[{"type":"code_inline"}]},{"text":" (partial MPN coverage) are softer variants with the same implication for the parts they cite.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Design Review Contract","type":"text"}]},{"type":"paragraph","content":[{"text":"When the user asks for a ","type":"text"},{"text":"design review","type":"text","marks":[{"type":"strong"}]},{"text":", ","type":"text"},{"text":"complete report","type":"text","marks":[{"type":"strong"}]},{"text":", ","type":"text"},{"text":"ready-to-fab assessment","type":"text","marks":[{"type":"strong"}]},{"text":", or anything equivalent, do not stop at running one or two analyzers and summarizing their findings. A design review in this skill has a stricter contract:","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Read the full workflow in this ","type":"text"},{"text":"SKILL.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", not just the analyzer command sections.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Read ","type":"text"},{"text":"references/report-generation.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" before writing the report.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Run every applicable analyzer for the files present in the project, then say explicitly which ones were and were not run.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Perform raw-file and datasheet cross-verification before claiming anything is \"verified\".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Triage likely analyzer false positives before elevating them into blockers.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"If a required step could not be done, state it as a review gap, not as silent omission.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Treat this as the minimum bar. Analyzer JSON alone is not the final review.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Minimum Review Checklist","type":"text"}]},{"type":"paragraph","content":[{"text":"For a full design review, explicitly account for each item below in the report:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"datasheets/","type":"text","marks":[{"type":"code_inline"}]},{"text":" present, synced, or verification gap stated","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"analyze_schematic.py","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"analyze_pcb.py --full","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"cross_analysis.py","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"analyze_emc.py","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"SPICE simulation when any simulator is installed","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"analyze_thermal.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" when both schematic and PCB JSON exist","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"analyze_gerbers.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" when fabrication outputs exist","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"lifecycle audit when network access and MPN coverage allow it","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"prior review / prior run delta check","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"raw schematic/PCB spot-verification elevated to full verification for critical parts","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"explicit report sections for blockers, verification basis, false positives, and skipped analyses","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"If an item is not applicable, say why. If it was skipped, say why. If it failed, say how that limits confidence.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Common Review Failure Modes","type":"text"}]},{"type":"paragraph","content":[{"text":"These are the failure modes this contract is meant to prevent:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Stopping after schematic + PCB + EMC output and calling it a complete review","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Reporting analyzer findings without checking whether they are expected layout artifacts","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Claiming \"verified\" without direct datasheet evidence or structured extraction evidence","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Omitting thermal, lifecycle, prior-review delta, or gerber checks without disclosure","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Writing a report that lacks a verdict, blockers table, verification basis, or skipped-analysis notes","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Reading only the first part of this skill and missing the design-review workflow later in the file","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"PDF Schematic Analysis","type":"text"}]},{"type":"paragraph","content":[{"text":"This skill also handles ","type":"text"},{"text":"PDF schematics","type":"text","marks":[{"type":"strong"}]},{"text":" — reference designs, dev board schematics, eval board docs, application notes, and datasheet typical-application circuits. Common use cases:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Analyze a manufacturer's reference design to understand the circuit","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Extract a subcircuit (power supply, USB interface, sensor front-end) to incorporate into your own KiCad design","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Compare a PDF reference design against your own schematic","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Extract a full BOM from a PDF schematic","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Validate component values in a PDF against current datasheets","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Workflow:","type":"text","marks":[{"type":"strong"}]},{"text":" Read the PDF pages visually → identify components and connections → extract structured data → translate to KiCad symbols and nets → validate against datasheets.","type":"text"}]},{"type":"paragraph","content":[{"text":"For the full methodology — component extraction, notation conventions, net mapping, subcircuit extraction, KiCad translation, and validation — read ","type":"text"},{"text":"references/pdf-schematic-extraction.md","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"paragraph","content":[{"text":"For deep validation of extracted circuits against datasheets (verifying values, checking patterns, detecting errors), use the methodology in ","type":"text"},{"text":"references/schematic-analysis.md","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Analysis Scripts","type":"text"}]},{"type":"paragraph","content":[{"text":"This skill includes Python scripts that extract comprehensive structured JSON from KiCad files in a single pass. Run these first, then reason about the output.","type":"text"}]},{"type":"paragraph","content":[{"text":"Read analyzer JSON output directly rather than writing ad-hoc extraction scripts. The JSON schema has specific field names (documented below and in ","type":"text"},{"text":"references/output-schema.md","type":"text","marks":[{"type":"code_inline"}]},{"text":") that are easy to get wrong in custom code. To extract a specific section: ","type":"text"},{"text":"python3 -c \"import json; d=json.load(open('file.json')); print(json.dumps(d['key'], indent=2))\"","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"paragraph","content":[{"text":"When the JSON surprises you","type":"text","marks":[{"type":"strong"}]},{"text":" — an AttributeError, unexpected shape, field returning ","type":"text"},{"text":"None","type":"text","marks":[{"type":"code_inline"}]},{"text":" that \"should\" have a value — stop and run ","type":"text"},{"text":"--schema","type":"text","marks":[{"type":"code_inline"}]},{"text":" before writing a second extraction attempt. It prints the exact field names and types for every top-level key:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"python3 \u003cskill-path>/scripts/analyze_schematic.py --schema\npython3 \u003cskill-path>/scripts/analyze_pcb.py --schema\npython3 \u003cskill-path>/scripts/analyze_gerbers.py --schema","type":"text"}]},{"type":"paragraph","content":[{"text":"JSON field cheat sheet","type":"text","marks":[{"type":"strong"}]},{"text":" — the most common mistakes when reading analyzer output by hand:","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"What you want","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Correct path and field","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Common mistake","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Pins on a net","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"nets[\u003cname>].pins[].component / .pin_number / .pin_name / .pin_type","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"ref","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"pin","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"type","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"number","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Unnamed-net pretty display","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"nets[\u003cname>].display_name","type":"text","marks":[{"type":"code_inline"}]},{"text":" — when set, a ","type":"text"},{"text":"Ref.PinName","type":"text","marks":[{"type":"code_inline"}]},{"text":" hint for an ","type":"text"},{"text":"__unnamed_N","type":"text","marks":[{"type":"code_inline"}]},{"text":" net whose only named IC pin tells the story (e.g. ","type":"text"},{"text":"__unnamed_36 → U1.VBOOT","type":"text","marks":[{"type":"code_inline"}]},{"text":"). Absent means the analyzer couldn't disambiguate.","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Ignoring ","type":"text"},{"text":"display_name","type":"text","marks":[{"type":"code_inline"}]},{"text":" and pasting raw ","type":"text"},{"text":"__unnamed_36","type":"text","marks":[{"type":"code_inline"}]},{"text":" into the report","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"IC pin map","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"ic_pin_analysis[]","type":"text","marks":[{"type":"code_inline"}]},{"text":" is a ","type":"text"},{"text":"list","type":"text","marks":[{"type":"strong"}]},{"text":" of IC entries; each has ","type":"text"},{"text":".reference","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":".pins[]","type":"text","marks":[{"type":"code_inline"}]},{"text":" with ","type":"text"},{"text":".pin_number / .pin_name / .pin_type / .net / .connected_to[]","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Treating it as ","type":"text"},{"text":"{ref: {...}}","type":"text","marks":[{"type":"code_inline"}]},{"text":" or ","type":"text"},{"text":"pins[].number","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Detected circuits","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Every pattern-matched circuit (power regulators, RC filters, crystal oscillators, bridges, …) lives in ","type":"text"},{"text":"findings[]","type":"text","marks":[{"type":"code_inline"}]},{"text":" — filter with ","type":"text"},{"text":"finding_schema.get_findings(data, Det.POWER_REGULATORS)","type":"text","marks":[{"type":"code_inline"}]},{"text":" etc. ","type":"text"},{"text":"Do not read from ","type":"text","marks":[{"type":"strong"}]},{"text":"subcircuits[]","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":": that's an IC-neighborhood grouping (","type":"text"},{"text":"{center_ic, ic_value, neighbor_components, …}","type":"text","marks":[{"type":"code_inline"}]},{"text":"), not a categorized detection index","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Looking for ","type":"text"},{"text":"subcircuits.power_regulators","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"subcircuits.rc_filters","type":"text","marks":[{"type":"code_inline"}]},{"text":", or any ","type":"text"},{"text":"subcircuits[type]","type":"text","marks":[{"type":"code_inline"}]},{"text":" key — these never existed in v1.3 output","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Zone net","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"pcb.zones[].net","type":"text","marks":[{"type":"code_inline"}]},{"text":" is an ","type":"text"},{"text":"integer net ID","type":"text","marks":[{"type":"strong"}]},{"text":", not a string. Use ","type":"text"},{"text":"f\"{net!r}\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" or convert first","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"f\"{net:20s}\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" — crashes with ","type":"text"},{"text":"ValueError: Unknown format code 's' for object of type 'int'","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Footprint position","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"pcb.footprints[].x / .y","type":"text","marks":[{"type":"code_inline"}]},{"text":" at top level (no ","type":"text"},{"text":".position","type":"text","marks":[{"type":"code_inline"}]},{"text":" wrapper)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"footprints[].position.x","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Findings","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"findings[]","type":"text","marks":[{"type":"code_inline"}]},{"text":" flat list — each has ","type":"text"},{"text":"rule_id","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"detector","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"severity","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"summary","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"report_context","type":"text","marks":[{"type":"code_inline"}]},{"text":". Filter with ","type":"text"},{"text":"finding_schema.get_findings(data, Det.*)","type":"text","marks":[{"type":"code_inline"}]},{"text":" or ","type":"text"},{"text":"group_findings(data)","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Looking for keyed dicts like ","type":"text"},{"text":"signal_analysis.power_regulators[]","type":"text","marks":[{"type":"code_inline"}]},{"text":" (pre-v1.3 format, removed)","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"This prevents format-string bugs and wrong field names. Use f-strings or ","type":"text"},{"text":"json.dumps()","type":"text","marks":[{"type":"code_inline"}]},{"text":" for output formatting — never ","type":"text"},{"text":"%s","type":"text","marks":[{"type":"code_inline"}]},{"text":" with non-string types. See ","type":"text"},{"text":"references/output-schema.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" for the full schema with common extraction patterns.","type":"text"}]},{"type":"paragraph","content":[{"text":"In all commands below, ","type":"text"},{"text":"\u003cskill-path>","type":"text","marks":[{"type":"code_inline"}]},{"text":" refers to this skill's base directory (shown at the top of this file when loaded).","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Schematic Analyzer","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"python3 \u003cskill-path>/scripts/analyze_schematic.py \u003cfile.kicad_sch> --analysis-dir analysis/\npython3 \u003cskill-path>/scripts/analyze_schematic.py \u003cfile.kicad_sch> --analysis-dir analysis/ --compact\npython3 \u003cskill-path>/scripts/analyze_schematic.py \u003cfile.kicad_sch> --output analysis.json # one-off, no cache","type":"text"}]},{"type":"paragraph","content":[{"text":"Outputs structured JSON (~60-220KB depending on board complexity) with:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Components & BOM","type":"text","marks":[{"type":"strong"}]},{"text":": inventory with reference, value, footprint, lib_id, type classification, MPN, datasheet; deduplicated BOM with quantities","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Nets","type":"text","marks":[{"type":"strong"}]},{"text":": full connectivity map with pin-to-net mapping, wire counts, no-connects","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Detected subcircuits","type":"text","marks":[{"type":"strong"}]},{"text":" (pattern-matched circuits — all emitted as ","type":"text"},{"text":"findings[]","type":"text","marks":[{"type":"code_inline"}]},{"text":" entries with matching ","type":"text"},{"text":"Det.*","type":"text","marks":[{"type":"code_inline"}]},{"text":" detectors; use ","type":"text"},{"text":"get_findings(data, Det.POWER_REGULATORS)","type":"text","marks":[{"type":"code_inline"}]},{"text":" etc. to fetch):","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Power regulators — LDO/switching/inverting topology, Vout estimation via datasheet-verified Vref lookup (~60 families) with heuristic fallback and fixed-output suffix parsing, ","type":"text"},{"text":"vref_source","type":"text","marks":[{"type":"code_inline"}]},{"text":" (","type":"text"},{"text":"lookup","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"heuristic","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"fixed_suffix","type":"text","marks":[{"type":"code_inline"}]},{"text":") and ","type":"text"},{"text":"vout_net_mismatch","type":"text","marks":[{"type":"code_inline"}]},{"text":" fields","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Voltage dividers, RC/LC filters (cutoff frequency), feedback networks, crystal circuits (load cap analysis, IC pin-based detection)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Op-amp circuits (configuration, gain, integrator/compensator), transistor circuits (net-name-aware load classification: motor/heater/fan/solenoid/valve/pump/relay/speaker/buzzer/lamp; FET level shifter topology)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Bridge circuits (H-bridge, 3-phase, cross-sheet detection), protection devices (ESD/TVS), current sense, decoupling analysis","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Domain-specific: RF chains, RF matching networks, BMS, Ethernet (BFS PHY-to-connector tracing), HDMI/DVI interfaces, memory interfaces, key matrices (net-name and topology-based), isolation barriers, addressable LED chains (WS2812/SK6812/APA102), battery chargers (TP4056/MCP73831/BQ2404x), motor drivers (A4988/TMC2209/DRV8301), ESD protection coverage audit, debug interfaces (SWD/JTAG with MCU tracing), power path (load switches/ideal diodes/USB PD controllers), ADC signal conditioning (external ADCs + voltage references with anti-aliasing cross-ref), reset/supervisor circuits (voltage supervisors/watchdogs/RC reset networks), clock distribution (clock generators/PLLs/oscillator output tracing), display/touch interfaces (SSD1306/ILI9341/ST7789/FT6236/GT911), sensor fusion (IMU/environmental/magnetometer with interrupt validation and bus clustering), level shifters (IC-based + discrete BSS138 with supply domain mapping), audio circuits (amplifiers/codecs with I2S/class-D detection), LED driver ICs (PWM/matrix/constant-current), RTC circuits (battery backup/crystal pairing), LED lighting audit (current limiting validation), thermocouple/RTD interfaces (MAX31855/MAX31865), power sequencing validation (power tree/enable chain/PG daisy chain analysis)","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"IC pinout analysis","type":"text","marks":[{"type":"strong"}]},{"text":": pin-level connectivity, IC function classification (3-tier: library prefix, part number keywords, description fallback)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Power analysis","type":"text","marks":[{"type":"strong"}]},{"text":": PDN impedance (1kHz–1GHz with MLCC parasitics), power budget, power sequencing (EN/PG chains), sleep current audit (resistive paths + regulator Iq with EN detection), voltage derating, inrush estimation","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Design analysis","type":"text","marks":[{"type":"strong"}]},{"text":": ERC warnings, power domains, bus detection (I2C/SPI/UART/CAN/RS-485 with COPI/CIPO/SDI/SDO), differential pairs (suffix-pair matching for USB/LVDS/Ethernet/HDMI/MIPI/PCIe/SATA/CAN/RS-485), cross-domain signals (voltage equivalence), BOM optimization, test coverage, assembly complexity, USB compliance","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Quality checks","type":"text","marks":[{"type":"strong"}]},{"text":": annotation completeness, label validation, PWR_FLAG audit, footprint filter validation, sourcing audit, property pattern audit, generic transistor symbol detection (flags Q_NPN_","type":"text"},{"text":"/Q_PNP_","type":"text","marks":[{"type":"em"}]},{"text":"/Q_NMOS_","type":"text"},{"text":"/Q_PMOS_","type":"text","marks":[{"type":"em"}]},{"text":" symbols with datasheet availability check)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Structural","type":"text","marks":[{"type":"strong"}]},{"text":": MCU alternate pin summary, ground domain classification, bus topology, wire geometry, spatial clustering, pin coverage, hierarchical label validation","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Supports modern ","type":"text"},{"text":".kicad_sch","type":"text","marks":[{"type":"code_inline"}]},{"text":" (KiCad 6+) and legacy ","type":"text"},{"text":".sch","type":"text","marks":[{"type":"code_inline"}]},{"text":" (KiCad 4/5). Hierarchical designs parsed recursively.","type":"text"}]},{"type":"paragraph","content":[{"text":"Legacy format:","type":"text","marks":[{"type":"strong"}]},{"text":" For KiCad 5 legacy ","type":"text"},{"text":".sch","type":"text","marks":[{"type":"code_inline"}]},{"text":" files, the analyzer parses ","type":"text"},{"text":".lib","type":"text","marks":[{"type":"code_inline"}]},{"text":" files (cache libraries and project libs) to populate pin data. Pin-to-net mapping, signal analysis, and subcircuit detection all work when ","type":"text"},{"text":".lib","type":"text","marks":[{"type":"code_inline"}]},{"text":" files are available. Coverage is typically 92–100% — components whose ","type":"text"},{"text":".lib","type":"text","marks":[{"type":"code_inline"}]},{"text":" files are missing (standard KiCad system libs not in the repo) will lack pin data. Built-in fallbacks cover 40+ common symbols (R, C, L, D, LED, transistors, MOSFETs, crystals, switches, polarized caps, connectors up to 20-pin, resistor packs) with mil-based pin offsets and automatic wire-snap correction for version-mismatched pin positions.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Supplementary Data for Legacy Designs","type":"text"}]},{"type":"paragraph","content":[{"text":"When ","type":"text"},{"text":"analyze_schematic.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" returns incomplete data (components with missing pins due to unavailable ","type":"text"},{"text":".lib","type":"text","marks":[{"type":"code_inline"}]},{"text":" files), use additional project files to recover full analysis capability. The most valuable source is the ","type":"text"},{"text":".net","type":"text","marks":[{"type":"code_inline"}]},{"text":" netlist file, which provides explicit pin-to-net mapping that closes any remaining gaps.","type":"text"}]},{"type":"paragraph","content":[{"text":"For detailed parsing instructions, data recovery workflows, and a priority matrix of supplementary sources (netlist, cache library, PCB cross-reference, PDF exports), read ","type":"text"},{"text":"references/supplementary-data-sources.md","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"paragraph","content":[{"text":"Verify analyzer output against reality.","type":"text","marks":[{"type":"strong"}]},{"text":" The analyzer can silently produce plausible-looking but incorrect results — wrong voltage estimates, missing MPNs, wrong pin-to-net mappings. These don't cause script errors; they just produce bad data that flows into your report. In testing across multiple boards, every project had at least one misleading analyzer output. Cross-reference against the raw ","type":"text"},{"text":".kicad_sch","type":"text","marks":[{"type":"code_inline"}]},{"text":" file:","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Component count","type":"text","marks":[{"type":"strong"}]},{"text":" — grep for ","type":"text"},{"text":"(symbol (lib_id","type":"text","marks":[{"type":"code_inline"}]},{"text":" blocks, subtract power symbols. Must match analyzer count exactly.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Pin-to-net mapping","type":"text","marks":[{"type":"strong"}]},{"text":" — verify the analyzer's pin-to-net mapping against the raw schematic for each component. Read the symbol block, trace wires/labels to confirm connections. Cross-reference IC pin assignments against the manufacturer's datasheet pin table. This is the highest-value verification step — a wrong pin mapping produces a non-functional board and is invisible to DRC/ERC.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Physical correctness (not just consistency)","type":"text","marks":[{"type":"strong"}]},{"text":" — consistency checks (schematic=PCB=analyzer all agree) are necessary but not sufficient. They only confirm the design is internally coherent — not that it matches the real-world part. The most dangerous case: a transistor symbol encodes a pinout assumption (like ","type":"text"},{"text":"Q_NPN_BEC","type":"text","marks":[{"type":"code_inline"}]},{"text":" = pin 1=B, 2=E, 3=C) that doesn't match the actual part. Everything passes consistency checks, but the board is wrong. To catch this:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"For transistors (BJT/MOSFET) in SOT-23, SOT-223, TO-252 and similar packages, the KiCad ","type":"text"},{"text":"lib_id","type":"text","marks":[{"type":"code_inline"}]},{"text":" suffix encodes a pin ordering assumption. SOT-23 BJTs exist in at least 6 pinout variants (BEC, BCE, EBC, ECB, CBE, CEB); SOT-23 MOSFETs in GDS, GSD, SGD, DSG. If no MPN is specified, there's no way to verify the assumption — flag this as a critical ambiguity.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"When an MPN is specified, verify the symbol's pin-to-pad assignment against the datasheet's pinout diagram for that specific package.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"This principle extends beyond transistors — any component where multiple pin orderings exist for the same package (voltage regulators with different pin assignments, connectors with vendor-specific pinouts) needs MPN-level verification.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"When verification isn't possible, assess plausibility.","type":"text","marks":[{"type":"strong"}]},{"text":" Not all unverified choices carry equal risk. Some align with strong conventions (the most common SOT-23 NPN pinout is BCE; 2N2222 in SOT-23 is almost always BCE); others go against convention or are genuinely ambiguous (SOT-23 MOSFETs have no dominant standard). When an MPN is missing and you can't verify, use domain knowledge — typical pinouts for that device type and package, manufacturer conventions, what the majority of parts in that category do — to assess whether the assumed pinout is likely correct, unusual, or a coin flip. Report the confidence level: \"matches the most common convention\" is different from \"could go either way.\" This same reasoning applies to passive values (is 4.7kΩ a typical pull-up value for this bus?), circuit topologies (is this a standard application circuit?), and component selection (is this part commonly used for this purpose?).","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Net trace","type":"text","marks":[{"type":"strong"}]},{"text":" — trace power rails and critical signal nets end-to-end through wires/labels. Verify the analyzer's pin list is complete for each net.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Regulator Vout","type":"text","marks":[{"type":"strong"}]},{"text":" — check the ","type":"text"},{"text":"vref_source","type":"text","marks":[{"type":"code_inline"}]},{"text":" field. ","type":"text"},{"text":"\"lookup\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" means datasheet-verified (~60 families); ","type":"text"},{"text":"\"heuristic\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" means it's a guess that needs manual verification. The ","type":"text"},{"text":"vout_net_mismatch","type":"text","marks":[{"type":"code_inline"}]},{"text":" field flags estimated Vout differing >15% from the output rail name voltage.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Hierarchical connectivity","type":"text","marks":[{"type":"strong"}]},{"text":" — on multi-sheet designs, verify sub-sheet connections are reflected in the net data.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"See ","type":"text"},{"text":"references/schematic-analysis.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" Step 2 for the full verification checklist. If the script fails or returns unexpected results, see ","type":"text"},{"text":"references/manual-schematic-parsing.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" for the complete fallback methodology.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"PCB Layout Analyzer","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"python3 \u003cskill-path>/scripts/analyze_pcb.py \u003cfile.kicad_pcb> --analysis-dir analysis/\npython3 \u003cskill-path>/scripts/analyze_pcb.py \u003cfile.kicad_pcb> --analysis-dir analysis/ --proximity # add crosstalk analysis\npython3 \u003cskill-path>/scripts/analyze_pcb.py \u003cfile.kicad_pcb> --output pcb.json # one-off, no cache","type":"text"}]},{"type":"paragraph","content":[{"text":"Outputs structured JSON (~50-300KB depending on board complexity) with:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Core","type":"text","marks":[{"type":"strong"}]},{"text":": footprint inventory (pads, courtyards, net assignments, extended attrs, schematic cross-reference), track/via statistics, zone summaries, board outline/dimensions, routing completeness","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Zones & copper presence","type":"text","marks":[{"type":"strong"}]},{"text":": zone outline vs filled polygon bounding boxes, fill ratio, cross-layer copper presence at every pad (which components have zone copper on the opposite layer and which don't), same-layer foreign zone detection","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Via analysis","type":"text","marks":[{"type":"strong"}]},{"text":": type breakdown (through/blind/micro), annular ring checks, via-in-pad detection, BGA/QFN fanout patterns, current capacity, stitching via identification, tenting","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Signal integrity","type":"text","marks":[{"type":"strong"}]},{"text":": per-net trace length, layer transition tracking (ground return paths), trace proximity/crosstalk (with ","type":"text"},{"text":"--proximity","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Power & thermal","type":"text","marks":[{"type":"strong"}]},{"text":": current capacity per net, power net routing summary, ground domain identification (AGND/DGND), zone stitching via density, thermal pad detection and via counting","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Manufacturing","type":"text","marks":[{"type":"strong"}]},{"text":": placement analysis (courtyard overlaps, edge clearance), decoupling cap distances, DFM scoring (JLCPCB standard/advanced tier), tombstoning risk (0201/0402 thermal asymmetry), thermal pad via adequacy, silkscreen documentation audit","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Add ","type":"text"},{"text":"--full","type":"text","marks":[{"type":"code_inline"}]},{"text":" to include individual track/via coordinates, per-segment trace impedance (microstrip Z0 from stackup), pad-to-pad routed distances, return path continuity analysis, and via stub lengths. The ","type":"text"},{"text":"--full","type":"text","marks":[{"type":"code_inline"}]},{"text":" output feeds the ","type":"text"},{"text":"spice","type":"text","marks":[{"type":"code_inline"}]},{"text":" skill's parasitic extraction (","type":"text"},{"text":"extract_parasitics.py","type":"text","marks":[{"type":"code_inline"}]},{"text":") for PCB-aware simulation. Supports KiCad 5 legacy format.","type":"text"}]},{"type":"paragraph","content":[{"text":"Zone fills must be current.","type":"text","marks":[{"type":"strong"}]},{"text":" The copper presence analysis uses KiCad's filled polygon data, which is computed when the user runs Edit → Fill All Zones (shortcut ","type":"text"},{"text":"B","type":"text","marks":[{"type":"code_inline"}]},{"text":") and stored in the ","type":"text"},{"text":".kicad_pcb","type":"text","marks":[{"type":"code_inline"}]},{"text":" file. If the board was modified after the last fill, the filled polygon data may be stale and the copper presence results will be inaccurate. When reviewing copper presence data, note whether the ","type":"text"},{"text":"fill_ratio","type":"text","marks":[{"type":"code_inline"}]},{"text":" seems reasonable — a zone with 0 filled area or ","type":"text"},{"text":"is_filled: false","type":"text","marks":[{"type":"code_inline"}]},{"text":" likely hasn't been filled.","type":"text"}]},{"type":"paragraph","content":[{"text":"Zone outline ≠ actual copper.","type":"text","marks":[{"type":"strong"}]},{"text":" The zone ","type":"text"},{"text":"outline_bbox","type":"text","marks":[{"type":"code_inline"}]},{"text":" is the user-drawn boundary; ","type":"text"},{"text":"filled_bbox","type":"text","marks":[{"type":"code_inline"}]},{"text":" is where copper actually exists after clearances, keepouts, and priority cuts. The ","type":"text"},{"text":"copper_presence","type":"text","marks":[{"type":"code_inline"}]},{"text":" section shows which components have zone copper on the opposite layer — use this for capacitive touch pad isolation, antenna keep-out, and thermal analysis instead of inferring copper presence from zone outlines.","type":"text"}]},{"type":"paragraph","content":[{"text":"Copper-sensitive components need deeper checks.","type":"text","marks":[{"type":"strong"}]},{"text":" For capacitive touch pads and antennas, confirming \"no opposite-layer copper\" is necessary but not sufficient. The copper absence could be accidental — one zone refill after a routing change could add copper and kill touch sensitivity or detune the antenna. Check for explicit ","type":"text"},{"text":"keepout zones","type":"text","marks":[{"type":"strong"}]},{"text":" (rule areas) that enforce the copper-free area as a DRC rule. Also measure same-layer GND clearance around touch pads and compare against the controller's app note minimum. For touch pads, compare trace lengths across all pads — significant asymmetry shifts baseline readings per channel. Report physical details (pad size, position, clearance, trace width/length) for all copper-sensitive components. See ","type":"text"},{"text":"references/pcb-layout-analysis.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" → Copper-Sensitive Components for the full checklist.","type":"text"}]},{"type":"paragraph","content":[{"text":"Verify after every run:","type":"text","marks":[{"type":"strong"}]},{"text":" Confirm footprint count and board outline dimensions against the raw ","type":"text"},{"text":".kicad_pcb","type":"text","marks":[{"type":"code_inline"}]},{"text":" file. Verify pad-to-net assignments for IC footprints against the schematic's pin-to-net mapping — this catches library footprint errors where pad numbering doesn't match the symbol pinout. If the script fails, see ","type":"text"},{"text":"references/manual-pcb-parsing.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" for the fallback methodology.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"PCB Rich Format and Assembly Checks","type":"text"}]},{"type":"paragraph","content":[{"text":"All PCB analysis sections now produce findings with the rich format (detector, rule_id, category, severity, confidence, summary, recommendation, report_context). Additionally, 7 new assembly/DFM checks run automatically:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"FD-001","type":"text","marks":[{"type":"strong"}]},{"text":": Fiducial marker presence (>= 3 per SMD side)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"TE-001","type":"text","marks":[{"type":"strong"}]},{"text":": Test point coverage across signal nets","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"OR-001","type":"text","marks":[{"type":"strong"}]},{"text":": Passive component orientation consistency","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"SK-001","type":"text","marks":[{"type":"strong"}]},{"text":": Silkscreen text overlapping exposed pads","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"VP-001","type":"text","marks":[{"type":"strong"}]},{"text":": Via-in-pad without tenting (--full mode)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"BV-001","type":"text","marks":[{"type":"strong"}]},{"text":": Via clearance from board edges (--full mode)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"KO-001","type":"text","marks":[{"type":"strong"}]},{"text":": Keepout zone violations","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"CP-001","type":"text","marks":[{"type":"strong"}]},{"text":": Same-layer foreign zone under a component. Severity is ","type":"text"},{"text":"warning","type":"text","marks":[{"type":"code_inline"}]},{"text":" when the foreign zone is a non-ground net or the component has no GND pad; severity is ","type":"text"},{"text":"info","type":"text","marks":[{"type":"code_inline"}]},{"text":" when the foreign zone is GND and the component has a GND pad (the common case of a bypass cap sitting over the ground pour — expected layout, not a clearance issue).","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Cross-Domain Analysis","type":"text"}]},{"type":"paragraph","content":[{"text":"After running both schematic and PCB analyzers, run the cross-domain analyzer. Point ","type":"text"},{"text":"--schematic","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"--pcb","type":"text","marks":[{"type":"code_inline"}]},{"text":" at the current run's JSON files and pass ","type":"text"},{"text":"--analysis-dir analysis/","type":"text","marks":[{"type":"code_inline"}]},{"text":" so the result lands inside the same run folder and the manifest tracks it:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"# Recommended: integrate into the current run\npython3 \u003cskill-path>/scripts/cross_analysis.py \\\n --schematic analysis/\u003crun_id>/schematic.json \\\n --pcb analysis/\u003crun_id>/pcb.json \\\n --analysis-dir analysis/\n\n# One-off (bypasses the cache)\npython3 \u003cskill-path>/scripts/cross_analysis.py \\\n --schematic schematic.json --pcb pcb.json --output cross.json","type":"text"}]},{"type":"paragraph","content":[{"text":"Checks: CC-001 connector current capacity, EG-001 ESD protection gaps, DA-001 decoupling adequacy, XV-001..003 schematic/PCB sync. PCB JSON optional.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Connectivity Graph (--full mode)","type":"text"}]},{"type":"paragraph","content":[{"text":"When ","type":"text"},{"text":"--full","type":"text","marks":[{"type":"code_inline"}]},{"text":" is used with the PCB analyzer, the output includes a ","type":"text"},{"text":"connectivity_graph","type":"text","marks":[{"type":"code_inline"}]},{"text":" section with per-net copper connectivity analysis via union-find over pads, tracks, vias, and zone fills. This enables deterministic plane split detection and return path validation in cross_analysis.py. Each net entry shows island count, component-to-island mapping, gap locations, and disconnected pad pairs.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Gerber & Drill Analyzer","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# Recommended: integrate into the current run\npython3 \u003cskill-path>/scripts/analyze_gerbers.py \u003cgerber_directory/> --analysis-dir analysis/\n\n# One-off\npython3 \u003cskill-path>/scripts/analyze_gerbers.py \u003cgerber_directory/> --output gerber.json","type":"text"}]},{"type":"paragraph","content":[{"text":"Outputs: layer identification (X2 attributes), component/net/pin mapping (KiCad 6+ TO attributes), aperture function classification, trace width distribution, board dimensions, drill classification (via/component/mounting), layer completeness, alignment verification, pad type summary (SMD/THT ratio). Add ","type":"text"},{"text":"--full","type":"text","marks":[{"type":"code_inline"}]},{"text":" for complete pin-to-net connectivity dump. ~10KB JSON.","type":"text"}]},{"type":"paragraph","content":[{"text":"The gerber analyzer produces a ","type":"text"},{"text":"findings","type":"text","marks":[{"type":"code_inline"}]},{"text":" list with rich format findings: GR-001 missing layers, GR-002 alignment issues, GR-003 drill problems, GR-004 paste aperture mismatches, GR-005 open board outlines.","type":"text"}]},{"type":"paragraph","content":[{"text":"If the script fails or returns unexpected results, see ","type":"text"},{"text":"references/manual-gerber-parsing.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" for the complete fallback methodology for parsing raw Gerber/Excellon files directly.","type":"text"}]},{"type":"paragraph","content":[{"text":"All scripts output JSON to stdout by default. Prefer ","type":"text"},{"text":"--analysis-dir analysis/","type":"text","marks":[{"type":"code_inline"}]},{"text":" to integrate output into the run-folder convention described in \"Analysis Cache Convention\" below — every analyzer in a single session then co-locates inside the same ","type":"text"},{"text":"analysis/\u003crun_id>/","type":"text","marks":[{"type":"code_inline"}]},{"text":" folder and is tracked by the manifest. Use ","type":"text"},{"text":"--output file.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" only for one-off runs where you don't want the result cached. Add ","type":"text"},{"text":"--compact","type":"text","marks":[{"type":"code_inline"}]},{"text":" for single-line JSON.","type":"text"}]},{"type":"paragraph","content":[{"text":"Analyzer JSON is worth keeping","type":"text","marks":[{"type":"strong"}]},{"text":" — these are expensive to regenerate (large schematics take time). ","type":"text"},{"text":"--analysis-dir","type":"text","marks":[{"type":"code_inline"}]},{"text":" preserves every run and is the form downstream tools (kidoc, diff_analysis, what_if) expect. They're not worth committing to git, but don't delete them between analysis steps.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Harmonized Output Format","type":"text"}]},{"type":"paragraph","content":[{"text":"All analyzers produce a uniform output envelope:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"json"},"content":[{"text":"{\n \"analyzer_type\": \"schematic|pcb|emc|cross_analysis|thermal|gerber|lifecycle|spice\",\n \"schema_version\": \"1.3.0\",\n \"summary\": {\n \"total_findings\": 42,\n \"by_severity\": {\"error\": 3, \"warning\": 15, \"info\": 24}\n },\n \"findings\": [\n {\"rule_id\": \"...\", \"detector\": \"...\", \"severity\": \"...\", \"confidence\": \"...\", \"evidence_source\": \"...\", \"summary\": \"...\", ...}\n ],\n \"trust_summary\": {\n \"total_findings\": 42,\n \"trust_level\": \"high|mixed|low\",\n \"by_confidence\": {\"deterministic\": 20, \"heuristic\": 18, \"datasheet-backed\": 4},\n \"by_evidence_source\": {\"datasheet\": 4, \"topology\": 10, \"heuristic_rule\": 18, ...},\n \"provenance_coverage_pct\": 96.5\n }\n}","type":"text"}]},{"type":"paragraph","content":[{"text":"The ","type":"text"},{"text":"findings","type":"text","marks":[{"type":"code_inline"}]},{"text":" list is the single authoritative source for all findings. Use ","type":"text"},{"text":"finding_schema.get_findings()","type":"text","marks":[{"type":"code_inline"}]},{"text":" or ","type":"text"},{"text":"finding_schema.group_findings()","type":"text","marks":[{"type":"code_inline"}]},{"text":" to filter by detector, rule prefix, or category. Detector names are available as constants in ","type":"text"},{"text":"finding_schema.Det","type":"text","marks":[{"type":"code_inline"}]},{"text":". Severities are ","type":"text"},{"text":"error","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"warning","type":"text","marks":[{"type":"code_inline"}]},{"text":", or ","type":"text"},{"text":"info","type":"text","marks":[{"type":"code_inline"}]},{"text":"; confidence is ","type":"text"},{"text":"deterministic","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"heuristic","type":"text","marks":[{"type":"code_inline"}]},{"text":", or ","type":"text"},{"text":"datasheet-backed","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"paragraph","content":[{"text":"All analyzers support ","type":"text"},{"text":"--text","type":"text","marks":[{"type":"code_inline"}]},{"text":" for human-readable output, ","type":"text"},{"text":"--analysis-dir","type":"text","marks":[{"type":"code_inline"}]},{"text":" for integrated run-folder output (preferred), and ","type":"text"},{"text":"--output","type":"text","marks":[{"type":"code_inline"}]},{"text":" for writing to a specific file verbatim (one-off). When both are passed, the explicit ","type":"text"},{"text":"--output","type":"text","marks":[{"type":"code_inline"}]},{"text":" path wins — pick one form per invocation.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Stage and Audience Filtering","type":"text"}]},{"type":"paragraph","content":[{"text":"All analyzers support ","type":"text"},{"text":"--stage","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"--audience","type":"text","marks":[{"type":"code_inline"}]},{"text":" flags:","type":"text"}]},{"type":"paragraph","content":[{"text":"Stages:","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"schematic","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"layout","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"pre_fab","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"bring_up","type":"text","marks":[{"type":"code_inline"}]},{"text":" ","type":"text"},{"text":"Audiences:","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"designer","type":"text","marks":[{"type":"code_inline"}]},{"text":" (default), ","type":"text"},{"text":"reviewer","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"manager","type":"text","marks":[{"type":"code_inline"}]}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# Show only layout-relevant findings for a reviewer\npython3 \u003cskill-path>/scripts/analyze_pcb.py board.kicad_pcb --stage layout --audience reviewer --text\n\n# Manager summary of schematic review readiness\npython3 \u003cskill-path>/scripts/analyze_schematic.py design.kicad_sch --audience manager --text\n\n# Pre-fab checklist for cross-domain analysis\npython3 \u003cskill-path>/scripts/cross_analysis.py -s sch.json -p pcb.json --stage pre_fab --text","type":"text"}]},{"type":"paragraph","content":[{"text":"JSON output always includes all findings. ","type":"text"},{"text":"--stage","type":"text","marks":[{"type":"code_inline"}]},{"text":" adds ","type":"text"},{"text":"stages","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"in_active_stage","type":"text","marks":[{"type":"code_inline"}]},{"text":" fields to each finding plus a ","type":"text"},{"text":"stage_filter","type":"text","marks":[{"type":"code_inline"}]},{"text":" summary. ","type":"text"},{"text":"audience_summary","type":"text","marks":[{"type":"code_inline"}]},{"text":" is always computed with designer/reviewer/manager views. ","type":"text"},{"text":"--text","type":"text","marks":[{"type":"code_inline"}]},{"text":" output respects both flags.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Generated Files","type":"text"}]},{"type":"paragraph","content":[{"text":"Analysis outputs are stored in ","type":"text"},{"text":"analysis/","type":"text","marks":[{"type":"code_inline"}]},{"text":" with timestamped run folders managed by ","type":"text"},{"text":"analysis_cache.py","type":"text","marks":[{"type":"code_inline"}]},{"text":". The manifest (","type":"text"},{"text":"analysis/manifest.json","type":"text","marks":[{"type":"code_inline"}]},{"text":") tracks all runs.","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"File Type","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Location","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Regenerable?","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Commit to git?","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Analyzer JSON","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"analysis/\u003ctimestamp>/*.json","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Yes (expensive)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Configured by ","type":"text"},{"text":"track_in_git","type":"text","marks":[{"type":"code_inline"}]},{"text":" in ","type":"text"},{"text":".kicad-happy.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" (default: no)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Manifest","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"analysis/manifest.json","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Yes","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Always (tracked by default)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Design review report","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"User-chosen path","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Yes","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Optional","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"When creating design reviews, check the manifest for prior runs. If ","type":"text"},{"text":"auto_diff","type":"text","marks":[{"type":"code_inline"}]},{"text":" is enabled and prior runs exist, automatically diff current vs previous using ","type":"text"},{"text":"diff_analysis.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" and include the delta in the \"Previous Review Delta\" section.","type":"text"}]},{"type":"paragraph","content":[{"text":"See also the ","type":"text"},{"text":"bom","type":"text","marks":[{"type":"code_inline"}]},{"text":" skill's cleanup section for datasheets, order CSVs, and backups.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Analysis Cache Configuration","type":"text"}]},{"type":"paragraph","content":[{"text":"The ","type":"text"},{"text":"analysis","type":"text","marks":[{"type":"code_inline"}]},{"text":" section in ","type":"text"},{"text":".kicad-happy.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" controls the shared analysis output directory:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"json"},"content":[{"text":"{\n \"analysis\": {\n \"output_dir\": \"analysis\",\n \"retention\": 5,\n \"auto_diff\": true,\n \"track_in_git\": false,\n \"diff_threshold\": \"major\"\n }\n}","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Field","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Default","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Description","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"output_dir","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"\"analysis\"","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Analysis directory path, relative to project root","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"retention","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"5","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Max unpinned runs to keep. ","type":"text"},{"text":"0","type":"text","marks":[{"type":"code_inline"}]},{"text":" = unlimited","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"auto_diff","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"true","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Auto-include delta section in design reviews","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"track_in_git","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"false","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"When false, JSONs gitignored but manifest tracked","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"diff_threshold","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"\"major\"","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Severity that triggers new timestamped folder: ","type":"text"},{"text":"minor","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"major","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"breaking","type":"text","marks":[{"type":"code_inline"}]}]}]}]}]},{"type":"paragraph","content":[{"text":"All fields are optional. Missing fields use defaults.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Output JSON Schema Quick Reference","type":"text"}]},{"type":"paragraph","content":[{"text":"Schematic analyzer top-level keys:","type":"text","marks":[{"type":"strong"}]}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"analyzer_type, schema_version, summary, findings, trust_summary,\nfile, kicad_version, file_version, title_block, statistics,\nbom, components, nets, subcircuits, ic_pin_analysis, design_analysis,\nconnectivity_issues, hierarchy_context, hierarchy_warning,\nnet_classifications, rail_voltages","type":"text"}]},{"type":"paragraph","content":[{"text":"Optional (present when non-empty): ","type":"text"},{"text":"pdn_impedance","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"sleep_current_audit","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"voltage_derating","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"power_budget","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"power_sequencing","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"bom_optimization","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"test_coverage","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"assembly_complexity","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"usb_compliance","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"inrush_analysis","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"sheets","type":"text","marks":[{"type":"code_inline"}]},{"text":" (multi-sheet only), ","type":"text"},{"text":"missing_info","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"bom_lock","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"project_settings","type":"text","marks":[{"type":"code_inline"}]}]},{"type":"paragraph","content":[{"text":"Key nested structures:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"statistics","type":"text","marks":[{"type":"code_inline"}]},{"text":": ","type":"text"},{"text":"{total_components, unique_parts, dnp_parts, total_nets, total_wires, total_no_connects, component_types, power_rails, missing_mpn, ...}","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"bom[]","type":"text","marks":[{"type":"code_inline"}]},{"text":": ","type":"text"},{"text":"{reference, references[], value, footprint, mpn, manufacturer, datasheet, quantity, dnp, ...}","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"components[]","type":"text","marks":[{"type":"code_inline"}]},{"text":": ","type":"text"},{"text":"{reference, value, footprint, lib_id, lib_name, type, category, mpn, datasheet, dnp, in_bom, parsed_value, ...}","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"nets{net_name}","type":"text","marks":[{"type":"code_inline"}]},{"text":": ","type":"text"},{"text":"{pins[], wires, labels[], ...}","type":"text","marks":[{"type":"code_inline"}]},{"text":" — each pin: ","type":"text"},{"text":"{component, pin_number, pin_name, pin_type, ...}","type":"text","marks":[{"type":"code_inline"}]},{"text":" (NOT ","type":"text"},{"text":"ref","type":"text","marks":[{"type":"code_inline"}]},{"text":" or ","type":"text"},{"text":"pin","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"subcircuits[]","type":"text","marks":[{"type":"code_inline"}]},{"text":": IC-neighborhood groupings (","type":"text"},{"text":"{center_ic, ic_value, neighbor_components, ...}","type":"text","marks":[{"type":"code_inline"}]},{"text":"), NOT a categorized detection index — see the JSON field cheat sheet at the top of this file.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Detected subcircuits live in ","type":"text","marks":[{"type":"strong"}]},{"text":"findings[]","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" — power regulators, voltage dividers, RC/LC filters, feedback networks, opamp/transistor/bridge/crystal circuits, current sense, decoupling, protection, buzzer/speaker, Ethernet/HDMI/memory interfaces, RF chains/matching, BMS, key matrices, isolation barriers, addressable LED chains, and design observations all emit as findings with matching ","type":"text"},{"text":"Det.*","type":"text","marks":[{"type":"code_inline"}]},{"text":" detectors. Use ","type":"text"},{"text":"get_findings(data, Det.POWER_REGULATORS)","type":"text","marks":[{"type":"code_inline"}]},{"text":" etc. to fetch them. The pre-v1.3 ","type":"text"},{"text":"signal_analysis","type":"text","marks":[{"type":"code_inline"}]},{"text":" wrapper and its top-level detection lists are gone.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"PCB analyzer top-level keys:","type":"text","marks":[{"type":"strong"}]}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"analyzer_type, schema_version, summary, findings, trust_summary,\nfile, kicad_version, file_version, statistics, layers, setup,\nnets, net_name_to_id, board_outline, component_groups, footprints,\ntracks, vias, zones, keepout_zones, connectivity, net_lengths","type":"text"}]},{"type":"paragraph","content":[{"text":"Optional: ","type":"text"},{"text":"power_net_routing","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"decoupling_placement","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"ground_domains","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"layer_transitions","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"silkscreen","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"board_metadata","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"dimensions","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"groups","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"net_classes","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"dfm_summary","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"placement_density","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"copper_presence_summary","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"board_thickness_mm","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"trace_proximity","type":"text","marks":[{"type":"code_inline"}]},{"text":" (with ","type":"text"},{"text":"--proximity","type":"text","marks":[{"type":"code_inline"}]},{"text":"). Sections previously at top level (","type":"text"},{"text":"thermal_analysis","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"thermal_pad_vias","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"tombstoning_risk","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"placement_analysis","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"current_capacity","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"copper_presence","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"dfm","type":"text","marks":[{"type":"code_inline"}]},{"text":") are now in ","type":"text"},{"text":"findings[]","type":"text","marks":[{"type":"code_inline"}]},{"text":". With ","type":"text"},{"text":"--full","type":"text","marks":[{"type":"code_inline"}]},{"text":", the output also includes a ","type":"text"},{"text":"connectivity_graph","type":"text","marks":[{"type":"code_inline"}]},{"text":" section (see \"Connectivity Graph\" above).","type":"text"}]},{"type":"paragraph","content":[{"text":"Key nested structures:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"net_lengths","type":"text","marks":[{"type":"code_inline"}]},{"text":" is a ","type":"text"},{"text":"list","type":"text","marks":[{"type":"strong"}]},{"text":" (not dict): ","type":"text"},{"text":"[{net, net_number, total_length_mm, segment_count, via_count, layers{}}, ...]","type":"text","marks":[{"type":"code_inline"}]},{"text":" sorted by length descending","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"power_net_routing","type":"text","marks":[{"type":"code_inline"}]},{"text":" is a ","type":"text"},{"text":"list","type":"text","marks":[{"type":"strong"}]},{"text":": ","type":"text"},{"text":"[{net, track_count, total_length_mm, min_width_mm, max_width_mm, widths_used[]}, ...]","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"footprints[]","type":"text","marks":[{"type":"code_inline"}]},{"text":": ","type":"text"},{"text":"{reference, value, footprint, layer, pads[], sch_path, sch_sheetname, sch_sheetfile, connected_nets[], ...}","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"statistics","type":"text","marks":[{"type":"code_inline"}]},{"text":": ","type":"text"},{"text":"{footprint_count, copper_layers_used, smd_count, tht_count, zone_count, via_count, routing_complete, ...}","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"paragraph","content":[{"text":"Gerber analyzer top-level keys:","type":"text","marks":[{"type":"strong"}]}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"analyzer_type, schema_version, summary, findings, trust_summary,\ndirectory, generator, layer_count, statistics, completeness, alignment,\ndrill_classification, pad_summary, board_dimensions, gerbers, drills","type":"text"}]},{"type":"paragraph","content":[{"text":"Workflow:","type":"text","marks":[{"type":"strong"}]},{"text":" When analyzing a KiCad project, scan the project directory for all available file types and run ","type":"text"},{"text":"every applicable analyzer","type":"text","marks":[{"type":"strong"}]},{"text":" — not just the one the user mentioned. A complete analysis uses all the data available. Use ","type":"text"},{"text":"--analysis-dir analysis/","type":"text","marks":[{"type":"code_inline"}]},{"text":" on all analyzers to share a single run folder tracked by the manifest. For one-off runs without cache tracking, use ","type":"text"},{"text":"--output file.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" instead.","type":"text"}]},{"type":"paragraph","content":[{"text":"Before starting the workflow below for a design review:","type":"text","marks":[{"type":"strong"}]},{"text":" read ","type":"text"},{"text":"references/report-generation.md","type":"text","marks":[{"type":"code_inline"}]},{"text":". The report structure, verification basis rules, skipped-analysis disclosure, and false-positive triage expectations there are part of the review workflow, not optional polish added at the end.","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Scan the project directory","type":"text","marks":[{"type":"strong"}]},{"text":" for ","type":"text"},{"text":".kicad_sch","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":".kicad_pcb","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":".kicad_pro","type":"text","marks":[{"type":"code_inline"}]},{"text":", gerber directories, and ","type":"text"},{"text":".net","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":".xml","type":"text","marks":[{"type":"code_inline"}]},{"text":" netlist files.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Sync datasheets","type":"text","marks":[{"type":"strong"}]},{"text":" (see Datasheet Acquisition below) — this is a prerequisite for verification, not optional. Without datasheets, all subsequent verification is reduced to internal consistency checks — confirming the design agrees with itself, not that it's correct. Run the sync before reading any analyzer output. If sync fails or no API keys are available, use fallback methods (Datasheet property URLs, individual downloads via ","type":"text"},{"text":"digikey","type":"text","marks":[{"type":"code_inline"}]},{"text":" skill, ask the user). If critical IC datasheets can't be obtained, note this prominently in the report as a verification gap.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Run the core analyzers.","type":"text","marks":[{"type":"strong"}]},{"text":" If the schematic exists, run ","type":"text"},{"text":"analyze_schematic.py","type":"text","marks":[{"type":"code_inline"}]},{"text":". If the PCB exists, run ","type":"text"},{"text":"analyze_pcb.py --full","type":"text","marks":[{"type":"code_inline"}]},{"text":". If gerbers exist, run ","type":"text"},{"text":"analyze_gerbers.py","type":"text","marks":[{"type":"code_inline"}]},{"text":". Run them in parallel when possible.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Run cross-domain analysis","type":"text","marks":[{"type":"strong"}]},{"text":" — when both schematic and PCB analysis exist, run ","type":"text"},{"text":"cross_analysis.py --schematic sch.json --pcb pcb.json","type":"text","marks":[{"type":"code_inline"}]},{"text":". This catches dangerous cross-domain bugs (connector current vs trace width, ESD gaps, decoupling adequacy, schematic/PCB sync).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Run EMC pre-compliance","type":"text","marks":[{"type":"strong"}]},{"text":" — when both schematic and PCB analysis exist, run ","type":"text"},{"text":"analyze_emc.py --schematic sch.json --pcb pcb.json","type":"text","marks":[{"type":"code_inline"}]},{"text":". This is ","type":"text"},{"text":"required","type":"text","marks":[{"type":"strong"}]},{"text":" during design reviews, not optional. The EMC skill runs 44 rule checks covering ground plane integrity, decoupling, switching harmonics, PDN impedance, diff pair skew, ESD paths, and more. Include results in the EMC section of the report.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Run SPICE simulation","type":"text","marks":[{"type":"strong"}]},{"text":" — first run ","type":"text"},{"text":"which ngspice ltspice xyce","type":"text","marks":[{"type":"code_inline"}]},{"text":". If any simulator is installed, SPICE is ","type":"text"},{"text":"required","type":"text","marks":[{"type":"strong"}]},{"text":" before writing the report. Hand off to the ","type":"text"},{"text":"spice","type":"text","marks":[{"type":"code_inline"}]},{"text":" skill with the schematic analysis JSON. This validates filter frequencies, divider ratios, opamp gains, and more against actual simulation results. SPICE takes \u003c1 second on most boards and catches value-computation errors (wrong resistor ratio, wrong cap for cutoff frequency) that no static analyzer finds. If both schematic and PCB analysis exist, use ","type":"text"},{"text":"--parasitics","type":"text","marks":[{"type":"code_inline"}]},{"text":" for high-impedance circuits (>100K feedback dividers, LC filters, RF matching networks). Include results in the Simulation Verification section of the report. ","type":"text"},{"text":"Output schema:","type":"text","marks":[{"type":"strong"}]},{"text":" top-level keys are ","type":"text"},{"text":"summary","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"simulation_results","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"workdir","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"total_elapsed_s","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"simulator","type":"text","marks":[{"type":"code_inline"}]},{"text":". Each entry in ","type":"text"},{"text":"simulation_results[]","type":"text","marks":[{"type":"code_inline"}]},{"text":" has: ","type":"text"},{"text":"subcircuit_type","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"components","type":"text","marks":[{"type":"code_inline"}]},{"text":" (list of refs, e.g. ","type":"text"},{"text":"[\"R5\", \"C3\"]","type":"text","marks":[{"type":"code_inline"}]},{"text":"), ","type":"text"},{"text":"reference","type":"text","marks":[{"type":"code_inline"}]},{"text":" (joined refs, e.g. ","type":"text"},{"text":"\"R5/C3\"","type":"text","marks":[{"type":"code_inline"}]},{"text":"), ","type":"text"},{"text":"status","type":"text","marks":[{"type":"code_inline"}]},{"text":" (","type":"text"},{"text":"pass","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"warn","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"fail","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"skip","type":"text","marks":[{"type":"code_inline"}]},{"text":"), ","type":"text"},{"text":"expected","type":"text","marks":[{"type":"code_inline"}]},{"text":" (dict of metric values), ","type":"text"},{"text":"simulated","type":"text","marks":[{"type":"code_inline"}]},{"text":" (dict of measured values), ","type":"text"},{"text":"delta","type":"text","marks":[{"type":"code_inline"}]},{"text":" (dict of error percentages).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Run thermal analysis","type":"text","marks":[{"type":"strong"}]},{"text":" — when both schematic and PCB analysis exist, run ","type":"text"},{"text":"analyze_thermal.py --schematic schematic.json --pcb pcb.json","type":"text","marks":[{"type":"code_inline"}]},{"text":". Estimates junction temperatures from package θJA and board thermal via correction. Include results in the Thermal Hotspot section of the report.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Run lifecycle audit","type":"text","marks":[{"type":"strong"}]},{"text":" (when network access and MPNs are available) — invoke via ","type":"text"},{"text":"analyze_schematic.py --lifecycle","type":"text","marks":[{"type":"code_inline"}]},{"text":" flag. Checks component obsolescence status via distributor APIs. Include results in the Component Lifecycle section of the report, or note \"Lifecycle audit not performed — [reason: no API keys / no network / no MPNs].\"","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Read the ","type":"text","marks":[{"type":"strong"}]},{"text":".kicad_pro","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" project file directly (it's JSON) for design rules, net classes, and DRC/ERC settings.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Check for prior design reviews","type":"text","marks":[{"type":"strong"}]},{"text":" — scan the project directory for existing review files (","type":"text"},{"text":"*review*.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"*design-review*.md","type":"text","marks":[{"type":"code_inline"}]},{"text":"). If found, read the most recent one. If ","type":"text"},{"text":"auto_diff","type":"text","marks":[{"type":"code_inline"}]},{"text":" is enabled and prior runs exist, run ","type":"text"},{"text":"diff_analysis.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" on current vs previous run and include the delta in the \"Previous Review Delta\" section.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Verify each output","type":"text","marks":[{"type":"strong"}]},{"text":" against the raw files and datasheets before using the data in your report.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Produce a unified report","type":"text","marks":[{"type":"strong"}]},{"text":" covering schematic analysis, PCB layout analysis, cross-domain findings, EMC risk assessment, simulation verification, thermal hotspots, and cross-reference findings. See ","type":"text"},{"text":"references/report-generation.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" for the report template.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Disclose all review gaps explicitly","type":"text","marks":[{"type":"strong"}]},{"text":" — if thermal, lifecycle, gerber, datasheet extraction, or prior-review delta were not performed, add a short \"Not performed / limits\" section to the report instead of omitting them silently.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"The more data sources you combine, the more confident the analysis. A schematic-only review misses layout issues; a PCB-only review misses design intent. Always use everything available.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Analysis Depth","type":"text"}]},{"type":"paragraph","content":[{"text":"Default to thorough analysis unless the user asks for a quick review. The reason: the bugs that kill boards are the ones that look correct at a glance. A spot-check might confirm 5 ICs are correct while the 6th has pins 3 and 4 swapped — and that's the one that kills the board. Thoroughness principles:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Verify all components, not a sample.","type":"text","marks":[{"type":"strong"}]},{"text":" Pin-to-net errors on \"simple\" parts (reversed diode, wrong resistor in a divider, connector with wrong pin ordering) are just as fatal as swapped IC pins. Cover the full design.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Use datasheets as ground truth — not KiCad library symbols.","type":"text","marks":[{"type":"strong"}]},{"text":" The analyzer, raw schematic, and KiCad library files all tell you what the design ","type":"text"},{"text":"says","type":"text","marks":[{"type":"em"}]},{"text":" — only the manufacturer's PDF datasheet tells you what it ","type":"text"},{"text":"should","type":"text","marks":[{"type":"em"}]},{"text":" say. A library symbol with a wrong pin mapping is the most dangerous class of bug precisely because everything is internally consistent: schematic, PCB, and analyzer all agree, but the board doesn't work. Verifying a pin assignment against the ","type":"text"},{"text":".kicad_sym","type":"text","marks":[{"type":"code_inline"}]},{"text":" file is circular — it's the source of the potential error. Download datasheets before starting verification (see \"Datasheet Acquisition\" below), open the actual PDF for each IC, extract the pin function table, and cite page/section numbers when reporting verification results.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Assess plausibility, not just verifiability.","type":"text","marks":[{"type":"strong"}]},{"text":" When something can't be verified (missing MPN, missing datasheet), don't stop at \"unverified.\" Use domain knowledge to assess whether the design choice aligns with common conventions or looks unusual. A 10kΩ I2C pull-up is unremarkable; a 100Ω I2C pull-up warrants a closer look even without a datasheet to check against. An SOT-23 NPN with BCE pinout matches the most common convention; one with CEB is unusual enough to flag. The goal is to distinguish \"unverified but probably fine\" from \"unverified and suspicious.\" This applies to pinouts, passive values, circuit topologies, and component selection.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Think beyond what the analyzer detects.","type":"text","marks":[{"type":"strong"}]},{"text":" The analyzer only finds patterns it's programmed for. When a section has no automated data, consider whether that's because the design doesn't need it (fine — say so briefly) or because the analyzer can't detect it (reason about it manually). Not every section needs a paragraph — \"Not applicable: battery-powered, no mains input\" is sufficient. But don't let empty data create blind spots in areas that matter for the specific design.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Datasheet Acquisition","type":"text"}]},{"type":"paragraph","content":[{"text":"Datasheets are what separate a consistency check from a correctness check. Without them, you can confirm the design agrees with itself — but not that it matches the real-world parts. Obtain datasheets early in the workflow.","type":"text"}]},{"type":"paragraph","content":[{"text":"Automated sync (preferred):","type":"text","marks":[{"type":"strong"}]},{"text":" Run datasheet sync scripts early in the workflow. They download datasheets for all components with MPNs into a shared ","type":"text"},{"text":"datasheets/","type":"text","marks":[{"type":"code_inline"}]},{"text":" directory with an ","type":"text"},{"text":"manifest.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" manifest. Run the preferred source first; if some parts fail, try others — they share the same directory and skip already-downloaded files.","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"python3 \u003cdigikey-skill-path>/scripts/sync_datasheets_digikey.py \u003cfile.kicad_sch>\npython3 \u003clcsc-skill-path>/scripts/sync_datasheets_lcsc.py \u003cfile.kicad_sch>\npython3 \u003celement14-skill-path>/scripts/sync_datasheets_element14.py \u003cfile.kicad_sch>\npython3 \u003cmouser-skill-path>/scripts/sync_datasheets_mouser.py \u003cfile.kicad_sch>","type":"text"}]},{"type":"paragraph","content":[{"text":"DigiKey is best (direct PDF URLs). element14 is reliable (no bot protection). LCSC works for LCSC-only parts. Mouser is a last resort (often blocks downloads).","type":"text"}]},{"type":"paragraph","content":[{"text":"Check for existing datasheets:","type":"text","marks":[{"type":"strong"}]},{"text":" Before downloading, look for:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"\u003cproject>/datasheets/","type":"text","marks":[{"type":"code_inline"}]},{"text":" with ","type":"text"},{"text":"manifest.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" (from a previous sync)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"\u003cproject>/docs/","type":"text","marks":[{"type":"code_inline"}]},{"text":" or ","type":"text"},{"text":"\u003cproject>/documentation/","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"PDF files in the project directory whose names contain MPNs","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Datasheet","type":"text","marks":[{"type":"code_inline"}]},{"text":" property URLs embedded in the KiCad symbols","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Fallback methods when automated sync isn't available or misses parts:","type":"text","marks":[{"type":"strong"}]}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Use the ","type":"text"},{"text":"Datasheet","type":"text","marks":[{"type":"code_inline"}]},{"text":" property URL from the schematic symbol — many KiCad libraries include direct PDF links","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Use the ","type":"text"},{"text":"digikey","type":"text","marks":[{"type":"code_inline"}]},{"text":" skill to search by MPN and download individual datasheets","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Use web search to find the manufacturer's datasheet page","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Ask the user","type":"text","marks":[{"type":"strong"}]},{"text":" — if a critical component's datasheet can't be found automatically, tell the user which parts are missing and ask them to provide the datasheets. Don't silently skip verification because a datasheet wasn't available. Example: \"I couldn't find datasheets for U3 (XYZ1234) and U7 (ABC5678). Can you provide them? I need them to verify the pinout and application circuit.\"","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Structured datasheet extraction (for large designs or repeated reviews):","type":"text","marks":[{"type":"strong"}]},{"text":" Pre-extract datasheet specs into cached JSON for faster, more consistent pin verification. This is especially valuable for designs with 10+ ICs where re-reading PDFs from scratch each time is slow.","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"python3 \u003cskill-path>/scripts/datasheet_page_selector.py \u003cpdf_path> --mpn \u003cmpn> --category \u003ccategory>","type":"text"}]},{"type":"paragraph","content":[{"text":"After reading the selected pages and producing an extraction JSON, score and cache it using ","type":"text"},{"text":"datasheet_score","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"datasheet_extract_cache","type":"text","marks":[{"type":"code_inline"}]},{"text":" modules. Extractions are stored in ","type":"text"},{"text":"datasheets/extracted/\u003cMPN>.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" and reused across reviews. The ","type":"text"},{"text":"datasheets","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" skill","type":"text","marks":[{"type":"strong"}]},{"text":" owns the full extraction pipeline (schema, page selection, scoring rubric, consumer API) — see ","type":"text"},{"text":"skills/datasheets/SKILL.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" and its reference guides.","type":"text"}]},{"type":"paragraph","content":[{"text":"What to extract from each datasheet","type":"text","marks":[{"type":"strong"}]},{"text":" (note page/section/figure/equation numbers for citations):","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Pin function table (pin number → name → function)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Absolute maximum ratings (voltage, current, temperature — including max continuous current through VCC/GND pins, which constrains inrush)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Recommended application circuit and required external components","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Required component values (and the equations that derive them)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Thermal characteristics","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"For passives:","type":"text","marks":[{"type":"strong"}]},{"text":" While individual resistor/capacitor datasheets are rarely needed, verify the component values against the IC datasheets that specify them. The IC's datasheet says \"use a 10µF input cap\" — verify the schematic actually has 10µF there, not 1µF.","type":"text"}]},{"type":"paragraph","content":[{"text":"Anti-pattern: verification without datasheets.","type":"text","marks":[{"type":"strong"}]},{"text":" The most common failure mode in design review is verifying component connections against KiCad library symbols instead of manufacturer datasheets. This is circular — if the library symbol has a wrong pin mapping, the schematic, PCB, and analyzer output will all agree with each other (and with the wrong pinout). Only the datasheet reveals the error. This is especially dangerous for custom/community library symbols (e.g., ","type":"text"},{"text":"sacmap:TPS61023","type":"text","marks":[{"type":"code_inline"}]},{"text":") where there's no upstream KiCad library as a secondary check. If you find yourself verifying a pinout by reading the ","type":"text"},{"text":".kicad_sym","type":"text","marks":[{"type":"code_inline"}]},{"text":" file or the analyzer's pin data and confirming it matches the schematic — stop. That's a consistency check, not a correctness check. Open the actual PDF datasheet, find the pin function table, and verify against that. Cite the datasheet page/section/figure number in your report so the designer can confirm your work.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Schematic + PCB Cross-Reference","type":"text"}]},{"type":"paragraph","content":[{"text":"When both files exist, cross-reference them. This catches the most expensive bugs — swapped pins, missing nets, and footprint mismatches pass DRC/ERC but produce non-functional boards.","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Component count","type":"text","marks":[{"type":"strong"}]},{"text":": Schematic count (excluding power symbols) vs PCB footprint count.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Net consistency","type":"text","marks":[{"type":"strong"}]},{"text":": Verify schematic net names appear in PCB net declarations. Missing nets suggest incomplete routing or un-synced changes.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Pin-net assignments","type":"text","marks":[{"type":"strong"}]},{"text":": Compare schematic pin-to-net mapping against PCB pad-to-net mapping. Mismatches reveal swapped pins or library errors. Higher-risk areas:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Custom/community library symbols (may not match datasheet pinout)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Multi-unit symbols (op-amps, gate arrays) — unit-to-pin assignment errors","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"QFN/BGA packages — pad numbering mistakes","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Transistors without MPNs — pinout ambiguity (see verification step 3)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Polarized components — anode/cathode orientation","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Connectors — pin 1 orientation","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Footprint match","type":"text","marks":[{"type":"strong"}]},{"text":": Schematic ","type":"text"},{"text":"Footprint","type":"text","marks":[{"type":"code_inline"}]},{"text":" property vs actual PCB footprint (e.g., SOT-23 vs SOT-23-5).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"DNP consistency","type":"text","marks":[{"type":"strong"}]},{"text":": DNP components in schematic should not have routing on PCB.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Value/MPN consistency","type":"text","marks":[{"type":"strong"}]},{"text":": Values and MPNs match between schematic and PCB properties.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"The PCB analyzer's ","type":"text"},{"text":"sch_path","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"sch_sheetname","type":"text","marks":[{"type":"code_inline"}]},{"text":", and ","type":"text"},{"text":"sch_sheetfile","type":"text","marks":[{"type":"code_inline"}]},{"text":" fields in each footprint enable automated cross-referencing.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Diff-Aware Design Comparison","type":"text"}]},{"type":"paragraph","content":[{"text":"Compare two analysis JSON outputs to see what changed between design revisions (e.g., base branch vs PR, v1 vs v2). Use when the user says things like \"compare designs\", \"what changed\", \"diff my schematic\", \"show changes from main\", or \"diff base vs head\". Full reference: ","type":"text"},{"text":"references/diff-analysis.md","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# Compare two schematic analysis outputs (JSON to stdout)\npython3 \u003cskill-path>/scripts/diff_analysis.py base.json head.json\n\n# Human-readable text output\npython3 \u003cskill-path>/scripts/diff_analysis.py base.json head.json --text\n\n# Write to file, custom threshold (ignore \u003c2% deltas)\npython3 \u003cskill-path>/scripts/diff_analysis.py base.json head.json --output diff.json --threshold 2.0\n\n# Ignore small percentage changes (e.g., rounding noise)\npython3 \u003cskill-path>/scripts/diff_analysis.py base.json head.json --threshold 5.0 --text","type":"text"}]},{"type":"paragraph","content":[{"text":"Auto-detects analyzer type (schematic, PCB, EMC, SPICE). Reports:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Components","type":"text","marks":[{"type":"strong"}]},{"text":": new, removed, value/footprint/MPN changes","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Signal analysis","type":"text","marks":[{"type":"strong"}]},{"text":": parameter shifts per detection type, driven by ","type":"text"},{"text":"detection_schema.SCHEMAS","type":"text","marks":[{"type":"code_inline"}]},{"text":" identity and value fields","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"BOM","type":"text","marks":[{"type":"strong"}]},{"text":": added/removed line items, quantity changes","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Connectivity/ERC","type":"text","marks":[{"type":"strong"}]},{"text":": new/resolved single-pin nets, floating nets, multi-driver nets, ERC warnings","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"EMC findings","type":"text","marks":[{"type":"strong"}]},{"text":": new/resolved findings with severity, risk score delta, per-net score changes","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"SPICE results","type":"text","marks":[{"type":"strong"}]},{"text":": status transitions (pass->fail regressions, fail->pass fixes), Monte Carlo concern changes","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Severity classification","type":"text","marks":[{"type":"strong"}]},{"text":": ","type":"text"},{"text":"none","type":"text","marks":[{"type":"code_inline"}]},{"text":" (no changes), ","type":"text"},{"text":"minor","type":"text","marks":[{"type":"code_inline"}]},{"text":" (statistics only), ","type":"text"},{"text":"major","type":"text","marks":[{"type":"code_inline"}]},{"text":" (component/signal/finding changes), ","type":"text"},{"text":"breaking","type":"text","marks":[{"type":"code_inline"}]},{"text":" (SPICE regressions, new CRITICAL EMC findings, new ERC warnings)","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Also used programmatically by ","type":"text"},{"text":"analysis_cache.should_create_new_run()","type":"text","marks":[{"type":"code_inline"}]},{"text":" to decide whether new outputs warrant a new timestamped run folder.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Thermal Hotspot Estimation","type":"text"}]},{"type":"paragraph","content":[{"text":"Estimates junction temperatures of power-dissipating components by combining schematic power data with PCB thermal infrastructure (copper pour, thermal vias, package type). Use when the user says \"check thermals\", \"thermal analysis\", \"will this overheat\", \"junction temperature\", \"power dissipation\", or \"thermal design\".","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# Recommended: integrate into the current run\npython3 \u003cskill-path>/scripts/analyze_thermal.py \\\n -s analysis/\u003crun_id>/schematic.json \\\n -p analysis/\u003crun_id>/pcb.json \\\n --analysis-dir analysis/\n\n# Human-readable text report\npython3 \u003cskill-path>/scripts/analyze_thermal.py -s schematic.json -p pcb.json --text\n\n# Custom ambient temperature (default: 25°C), one-off output file\npython3 \u003cskill-path>/scripts/analyze_thermal.py -s schematic.json -p pcb.json --ambient 40 -o thermal.json","type":"text"}]},{"type":"paragraph","content":[{"text":"Models each power component (LDO, switching regulator, shunt resistor) as a point heat source. Computes Tj = T_ambient + P_diss × Rθ_JA_effective, where Rθ_JA comes from a package lookup table (SOT-223: 60°C/W, QFN-5x5: 25°C/W, etc.) and is corrected for PCB thermal vias and copper pour. Rules:","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Rule","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Condition","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Severity","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"TS-001","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Tj exceeds absolute maximum","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"CRITICAL","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"TS-002","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Tj within 15°C of absolute maximum","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"HIGH","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"TS-003","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Tj > 85°C (may affect nearby passives)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"MEDIUM","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"TS-004","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"P > 0.5W with no thermal vias","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"MEDIUM","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"TS-005","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Significant power, within safe limits","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"INFO","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"TP-001","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"MLCC within 10mm of hot component","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"LOW","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"TP-002","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Electrolytic cap within 10mm of hot component","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"MEDIUM","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"Thermal findings and assessments include the rich format envelope (detector, rule_id, summary, evidence_source, report_context). Rule IDs: TS-001..005 (safety), TP-001..002 (proximity), TH-DET (assessments).","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Interactive \"What-If\" Parameter Sweep","type":"text"}]},{"type":"paragraph","content":[{"text":"Instantly see the impact of component value changes on circuit behavior without re-running the full analyzer. Use when the user says \"what if I change\", \"what happens if\", \"try a different value\", \"swap R5 to 4.7k\", \"parameter sweep\", \"what value gives me X\", or wants to explore design trade-offs. Full reference: ","type":"text"},{"text":"references/what-if.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/what-if.md","title":null}},{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# Single value change\npython3 \u003cskill-path>/scripts/what_if.py analysis.json R5=4.7k --text\n\n# Sweep: comma list or log range\npython3 \u003cskill-path>/scripts/what_if.py analysis.json R5=1k,2.2k,4.7k,10k --text\npython3 \u003cskill-path>/scripts/what_if.py analysis.json R5=1k..100k:10 --text\n\n# Tolerance corner analysis (±5% worst-case)\npython3 \u003cskill-path>/scripts/what_if.py analysis.json R5=4.7k+-5% C3=100n+-10% --text\n\n# Find the right value: inverse solver with E-series snapping\npython3 \u003cskill-path>/scripts/what_if.py analysis.json --fix voltage_dividers[0] --target 3.3 --text\npython3 \u003cskill-path>/scripts/what_if.py analysis.json --fix rc_filters[0] --target 1000 --text\n\n# EMC impact preview\npython3 \u003cskill-path>/scripts/what_if.py analysis.json C3=1u --emc --text\n\n# SPICE re-simulation on affected subcircuits\npython3 \u003cskill-path>/scripts/what_if.py analysis.json R5=4.7k --spice --text\n\n# Export patched JSON for further analysis (EMC, thermal, diff)\npython3 \u003cskill-path>/scripts/what_if.py analysis.json R5=4.7k --output patched.json","type":"text"}]},{"type":"paragraph","content":[{"text":"Patches component values in the analyzer JSON, recalculates derived fields (filter cutoff, divider ratio, opamp gain, crystal load, current sense range, regulator Vout), and shows before/after comparison with percentage deltas. Supports single changes, multi-point sweeps (comma or log-range), tolerance corner analysis, inverse fix suggestions with E-series snapping, EMC impact preview, PCB parasitic awareness (auto-discovered or via ","type":"text"},{"text":"--pcb","type":"text","marks":[{"type":"code_inline"}]},{"text":"), and SPICE re-verification.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Findings Summary","type":"text"}]},{"type":"paragraph","content":[{"text":"Summarises findings across all analyzers in a run. Use when the user wants a top-N list, a severity-filtered view, or a machine-readable roll-up without reading individual JSON files. Reads the current run from ","type":"text"},{"text":"analysis/manifest.json","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# Top findings from the current run (default: top 20)\npython3 \u003cskill-path>/scripts/summarize_findings.py analysis/\n\n# Limit to top 10 high-severity findings\npython3 \u003cskill-path>/scripts/summarize_findings.py analysis/ --top 10 --severity high\n\n# JSON output for programmatic consumption\npython3 \u003cskill-path>/scripts/summarize_findings.py analysis/ --json\n\n# Summarise a specific run by ID\npython3 \u003cskill-path>/scripts/summarize_findings.py analysis/ --run \u003crun_id>","type":"text"}]},{"type":"paragraph","content":[{"text":"Flags: ","type":"text"},{"text":"--top N","type":"text","marks":[{"type":"code_inline"}]},{"text":" (default 20), ","type":"text"},{"text":"--severity","type":"text","marks":[{"type":"code_inline"}]},{"text":" (filter to ","type":"text"},{"text":"critical","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"high","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"warning","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"info","type":"text","marks":[{"type":"code_inline"}]},{"text":"), ","type":"text"},{"text":"--run","type":"text","marks":[{"type":"code_inline"}]},{"text":" (explicit run ID instead of latest), ","type":"text"},{"text":"--json","type":"text","marks":[{"type":"code_inline"}]},{"text":" (machine-readable output).","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Component Lifecycle & Temperature Audit","type":"text"}]},{"type":"paragraph","content":[{"text":"Queries distributor APIs to check component lifecycle status (active, NRND, EOL, obsolete) and operating temperature range coverage. Use when the user says \"check for obsolete parts\", \"lifecycle audit\", \"are any parts end of life\", \"temperature audit\", \"will this work at industrial temp range\", or during production readiness reviews.","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# Basic lifecycle check\npython3 \u003cskill-path>/scripts/lifecycle_audit.py analysis.json\n\n# With temperature range validation (preset or custom)\npython3 \u003cskill-path>/scripts/lifecycle_audit.py analysis.json --temp-range industrial\npython3 \u003cskill-path>/scripts/lifecycle_audit.py analysis.json --temp-range \"-40,105\"\n\n# Query specific distributors only\npython3 \u003cskill-path>/scripts/lifecycle_audit.py analysis.json --only digikey,lcsc\n\n# Search for replacement parts when EOL/NRND found\npython3 \u003cskill-path>/scripts/lifecycle_audit.py analysis.json --suggest-alternatives\n\n# Save results\npython3 \u003cskill-path>/scripts/lifecycle_audit.py analysis.json --output lifecycle.json","type":"text"}]},{"type":"paragraph","content":[{"text":"Reads the analyzer JSON BOM section, extracts unique MPNs, queries distributors (LCSC no-auth, DigiKey, element14, Mouser) for lifecycle status and operating temperature. Temperature presets: ","type":"text"},{"text":"commercial","type":"text","marks":[{"type":"code_inline"}]},{"text":" (0/70°C), ","type":"text"},{"text":"industrial","type":"text","marks":[{"type":"code_inline"}]},{"text":" (-40/85°C), ","type":"text"},{"text":"extended","type":"text","marks":[{"type":"code_inline"}]},{"text":" (-40/105°C), ","type":"text"},{"text":"automotive","type":"text","marks":[{"type":"code_inline"}]},{"text":" (-40/125°C), ","type":"text"},{"text":"military","type":"text","marks":[{"type":"code_inline"}]},{"text":" (-55/125°C). Also checks datasheet extraction cache for temperature data before making API calls.","type":"text"}]},{"type":"paragraph","content":[{"text":"The lifecycle audit produces rich format findings: LC-001 (obsolete/discontinued), LC-002 (last time buy), LC-003 (NRND), LC-004 (unknown status), LC-005 (single source), LC-006 (long lead time), LT-001 (temperature violation).","type":"text"}]},{"type":"paragraph","content":[{"text":"Requires network access","type":"text","marks":[{"type":"strong"}]},{"text":" — unlike the core analyzers, this script calls distributor APIs. Same environment variables as the distributor skills (DIGIKEY_CLIENT_ID/SECRET, MOUSER_SEARCH_API_KEY, ELEMENT14_API_KEY). LCSC requires no credentials.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Schematic Analyzer Rule IDs","type":"text"}]},{"type":"paragraph","content":[{"text":"All schematic rule findings appear in ","type":"text"},{"text":"findings[]","type":"text","marks":[{"type":"code_inline"}]},{"text":". The following rule IDs are produced by the schematic analyzer:","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Rule","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Detector","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Condition","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Severity","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"SS-001","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"audit_sourcing_gate","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"MPN coverage \u003c 50%","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"high","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"SS-002","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"audit_sourcing_gate","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"MPN coverage 50–80%","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"warning","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"SS-003","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"audit_sourcing_gate","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"MPN coverage 80–100%","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"info","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"NT-001","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"analyze_connectivity","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Single-pin net: signal pin","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"warning","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"NT-001","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"analyze_connectivity","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Single-pin net: power_out or passive pin","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"info","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"RS-001","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"audit_rail_sources","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Rail has a declared source (direct, PWR_FLAG, or bridged jumper)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"info or warning","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"RS-002","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"audit_rail_sources","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Rail depends on user closing an open jumper","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"high","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"LB-001","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"detect_label_aliases","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Net has >= 2 distinct global/hierarchical labels (power nets excluded)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"info","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"PP-001","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"audit_power_pin_dc_paths","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"IC power_in pin reaches a rail only through a capacitor (2-hop BFS)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"high","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"SS-001 is a pre-fab blocker — a ","type":"text"},{"text":"high","type":"text","marks":[{"type":"code_inline"}]},{"text":" finding that should be resolved before ordering. NT-001 severity depends on pin type: signal pins (digital I/O, bidirectional) are ","type":"text"},{"text":"warning","type":"text","marks":[{"type":"code_inline"}]},{"text":"; power_out and passive pins are ","type":"text"},{"text":"info","type":"text","marks":[{"type":"code_inline"}]},{"text":". RS-001 severity varies by confidence level in the detected source. PP-001 uses a 2-hop BFS over the net graph, rejecting capacitor edges, to confirm a direct DC path from a power rail to each IC power_in pin.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Reference Files","type":"text"}]},{"type":"paragraph","content":[{"text":"Detailed methodology and format documentation lives in reference files. Read these as needed — they provide deep-dive content beyond what the scripts output automatically.","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Reference","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Lines","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"When to Read","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"schematic-analysis.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"1133","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Deep schematic review: datasheet validation, design patterns, error taxonomy, tolerance stacking, GPIO audit, motor control, battery life, supply chain","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"pcb-layout-analysis.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"447","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Advanced PCB: impedance calculations, differential pairs, return paths, copper balance, edge clearance, copper-sensitive components (capacitive touch, antennas), custom analysis scripts","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"output-schema.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"293","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Full analyzer JSON schema with field names, types, and common extraction patterns","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"file-formats.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"379","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Manual file inspection: S-expression structure, field-by-field docs for all KiCad file types, version detection","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"gerber-parsing.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"729","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Gerber/Excellon format details, X2 attributes, analysis techniques","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"pdf-schematic-extraction.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"315","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"PDF schematic analysis: extraction workflow, notation conventions, KiCad translation","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"supplementary-data-sources.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"288","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Legacy KiCad 5 data recovery: netlist parsing, cache library, PCB cross-reference","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"net-tracing.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"120","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Manual net tracing: coordinate math, Y-axis inversion, rotation transforms","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"manual-schematic-parsing.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"289","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Fallback when schematic script fails","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"manual-pcb-parsing.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"467","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Fallback when PCB script fails","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"manual-gerber-parsing.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"621","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Fallback when Gerber script fails","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"report-generation.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"614","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Report template (critical findings at top), analyzer output field reference (schematic/PCB/gerber), severity definitions, writing principles, domain-specific focus areas, known analyzer limitations","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"standards-compliance.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"638","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"IPC/IEC standards tables: conductor spacing (IPC-2221A Table 6-1), current capacity (IPC-2221A/IPC-2152), annular rings, hole sizes, impedance, via protection (IPC-4761), creepage/clearance (ECMA-287/IEC 60664-1). Consider for all boards; auto-trigger for professional/industrial designs, high voltage, mains input, or safety isolation.","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"design-intent.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"—","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Design intent resolution, target market / certification / power constraints that gate findings by context","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"diff-analysis.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"—","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"How ","type":"text"},{"text":"diff_analysis.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" compares two analyzer runs and emits severity-ranked change reports","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"what-if.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"—","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"How ","type":"text"},{"text":"what_if.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" patches component values, recalculates derived fields, and suggests fixes for feedback dividers / crystal load caps / cap derating","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"config-reference.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"—","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":".kicad-happy.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" schema — project config for analysis cache, suppressions, design intent, risk scoring","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"datasheet-verification.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"—","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Automated cross-check of schematic connections against structured datasheet extractions (pin voltage, required externals, decoupling adequacy)","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"For script internals, data structures, signal analysis patterns, and batch test suite documentation, see ","type":"text"},{"text":"scripts/README.md","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"File Types Quick Reference","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Extension","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Format","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Purpose","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":".kicad_pro","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"JSON","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Project settings, net classes, DRC/ERC severity, BOM fields","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":".kicad_sch","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"S-expr","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Schematic sheet (symbols, wires, labels, hierarchy)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":".kicad_pcb","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"S-expr","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"PCB layout (footprints, tracks, vias, zones, board outline)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":".kicad_sym","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"S-expr","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Symbol library (schematic symbols with pins, graphics)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":".kicad_mod","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"S-expr","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Single footprint (in ","type":"text"},{"text":".pretty/","type":"text","marks":[{"type":"code_inline"}]},{"text":" directory)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":".kicad_dru","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Custom","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Custom design rules (DRC constraints)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"fp-lib-table","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"sym-lib-table","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"S-expr","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Library path tables","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":".sch","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":".lib","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":".dcm","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Legacy","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"KiCad 5 schematic, symbol library, descriptions","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":".net","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":".xml","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"S-expr/XML","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Netlist export, BOM export","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":".gbr","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":".g*","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":".drl","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Gerber/Excellon","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Manufacturing files (copper, mask, silk, outline, drill)","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"For version detection and detailed field-by-field format documentation, read ","type":"text"},{"text":"references/file-formats.md","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Analysis Strategies","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Deep Schematic Analysis","type":"text"}]},{"type":"paragraph","content":[{"text":"For a thorough datasheet-driven schematic review — identifying subcircuits, fetching datasheets, validating component values against manufacturer recommendations, comparing against common design patterns, detecting errors, and suggesting improvements — read ","type":"text"},{"text":"references/schematic-analysis.md","type":"text","marks":[{"type":"code_inline"}]},{"text":". Use this reference whenever the user asks to review, validate, or analyze a schematic in depth.","type":"text"}]},{"type":"paragraph","content":[{"text":"Fetching datasheets","type":"text","marks":[{"type":"strong"}]},{"text":": When the analysis requires datasheet data, use the DigiKey API as the preferred source (see the ","type":"text"},{"text":"digikey","type":"text","marks":[{"type":"code_inline"}]},{"text":" skill) — it returns direct PDF URLs via the ","type":"text"},{"text":"DatasheetUrl","type":"text","marks":[{"type":"code_inline"}]},{"text":" field without web scraping. Search by MPN from the schematic's component properties. Fall back to web search only for parts not on DigiKey.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Deep PCB Analysis","type":"text"}]},{"type":"paragraph","content":[{"text":"For advanced layout analysis beyond what the PCB analyzer script provides — impedance calculations from stackup parameters, DRC rule authoring, power electronics design review techniques, differential pair validation, return path analysis, copper balance assessment, board edge clearance rules, and manual script-writing patterns — read ","type":"text"},{"text":"references/pcb-layout-analysis.md","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"paragraph","content":[{"text":"Most routine PCB analysis (via types, annular ring, placement, connectivity, thermal vias, current capacity, signal integrity, DFM scoring, tombstoning risk, thermal pad vias) is handled automatically by ","type":"text"},{"text":"analyze_pcb.py","type":"text","marks":[{"type":"code_inline"}]},{"text":". Use the reference for deeper manual investigation.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Design Intent","type":"text"}]},{"type":"paragraph","content":[{"text":"For interpreting auto-detected design intent and calibrating review severity by product class and target market (hobby vs consumer vs industrial vs medical vs automotive vs aerospace), read ","type":"text"},{"text":"references/design-intent.md","type":"text","marks":[{"type":"code_inline"}]},{"text":". Check the ","type":"text"},{"text":"design_intent","type":"text","marks":[{"type":"code_inline"}]},{"text":" object in analysis output to understand the design context.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Probing Analyzer JSON","type":"text"}]},{"type":"paragraph","content":[{"text":"During a review you will often run one-off ","type":"text"},{"text":"python3 -c \"import json; ...\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" probes to inspect analyzer output (pin-nets, rail voltages, specific finding contents, etc.). Two practices that materially improve the user's ability to follow along:","type":"text"}]},{"type":"paragraph","content":[{"text":"Announce what you're checking before each probe.","type":"text","marks":[{"type":"strong"}]},{"text":" One concise sentence before the script — not after, not in a comment inside the script. The user should be able to read only the narrative and understand the review flow without opening every tool call.","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Bad: ","type":"text"},{"text":"[Bash] python3 -c \"import json; d = json.load(open('analysis/.../schematic.json')); print([...])\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" with no surrounding prose.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Good: \"Checking whether U3's EN pin is tied to +BATT directly or through a divider.\" then the probe.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Good: \"Verifying the detected TPS61023 topology matches the datasheet (buck-boost expected).\" then the probe.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"The narrative matters most for probes that investigate ","type":"text"},{"text":"why","type":"text","marks":[{"type":"em"}]},{"text":" something looks wrong — those are the moments a user loses context fastest.","type":"text"}]},{"type":"paragraph","content":[{"text":"Defensive patterns for JSON-shape uncertainty.","type":"text","marks":[{"type":"strong"}]},{"text":" Analyzer output has heterogeneous shapes (lists of dicts, dicts keyed by ref, optional sections, nested paths). Scripts that assume the wrong shape crash mid-probe.","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Before slicing, check type: ","type":"text"},{"text":"x[:3]","type":"text","marks":[{"type":"code_inline"}]},{"text":" on a dict raises ","type":"text"},{"text":"KeyError: slice(None, 3, None)","type":"text","marks":[{"type":"code_inline"}]},{"text":". Use ","type":"text"},{"text":"list(x.values())[:3]","type":"text","marks":[{"type":"code_inline"}]},{"text":" or ","type":"text"},{"text":"list(x.items())[:3]","type":"text","marks":[{"type":"code_inline"}]},{"text":" for dicts.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Before ","type":"text"},{"text":"min()","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"max()","type":"text","marks":[{"type":"code_inline"}]},{"text":", check non-empty: ","type":"text"},{"text":"min([])","type":"text","marks":[{"type":"code_inline"}]},{"text":" raises ","type":"text"},{"text":"ValueError: min() iterable argument is empty","type":"text","marks":[{"type":"code_inline"}]},{"text":". Use ","type":"text"},{"text":"min(items, default=None)","type":"text","marks":[{"type":"code_inline"}]},{"text":" or guard with ","type":"text"},{"text":"if items:","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Use ","type":"text"},{"text":".get(\"key\", default)","type":"text","marks":[{"type":"code_inline"}]},{"text":" not ","type":"text"},{"text":"[\"key\"]","type":"text","marks":[{"type":"code_inline"}]},{"text":" when a section may be absent (many sections are optional based on what the analyzer found).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"isinstance(x, list)","type":"text","marks":[{"type":"code_inline"}]},{"text":" vs ","type":"text"},{"text":"isinstance(x, dict)","type":"text","marks":[{"type":"code_inline"}]},{"text":" — ","type":"text"},{"text":"components","type":"text","marks":[{"type":"code_inline"}]},{"text":" is a list of dicts, ","type":"text"},{"text":"nets","type":"text","marks":[{"type":"code_inline"}]},{"text":" is a dict keyed by net name. Check the schema reference before iterating.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"When a finding field can be a list of strings OR a list of dicts (rare but happens in legacy-shape sections), handle both: ","type":"text"},{"text":"r = c if isinstance(c, str) else c.get(\"reference\", \"\")","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Pads/components with missing data: ","type":"text"},{"text":"pad.get(\"abs_x\")","type":"text","marks":[{"type":"code_inline"}]},{"text":" can be ","type":"text"},{"text":"None","type":"text","marks":[{"type":"code_inline"}]},{"text":"; guard before arithmetic.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Small investment, much lower friction.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Quick Review Checklists","type":"text"}]},{"type":"paragraph","content":[{"text":"Schematic","type":"text","marks":[{"type":"strong"}]},{"text":" — verify: decoupling caps on every IC VCC/GND pair, I2C pull-ups, reset pin circuits, unconnected pins have no-connect markers, consistent net naming across sheets, ESD protection on external connectors, power sequencing (EN/PG), adequate bulk capacitance.","type":"text"}]},{"type":"paragraph","content":[{"text":"PCB","type":"text","marks":[{"type":"strong"}]},{"text":" — verify: power trace widths for current (IPC-2221), via current capacity, creepage/clearance for high voltage, decoupling cap proximity to IC power pins, continuous ground plane (no splits under signals), controlled impedance traces (USB/DDR), board outline closed polygon, silkscreen readability, thermal via count for every exposed-pad IC (report the count and compare against the datasheet's recommended range — this is one of the most common QFN/DFN layout errors), keepout zone enforcement for copper-sensitive components (touch pads and antennas — confirming copper absence isn't enough because it could be accidental; check that keepout zones exist as DRC rules), differential pair length deltas with protocol-specific tolerance (compute the delta and cite the spec — raw lengths alone don't tell the designer if there's a problem), pad-to-net cross-reference at PCB level for all ICs/transistors/connectors (catches library footprint pin numbering errors that are invisible to DRC/ERC — the most dangerous class of PCB bug). Consider ","type":"text"},{"text":"references/standards-compliance.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" for IPC/IEC standard values — conductor spacing and current capacity are relevant for most boards; creepage/clearance and via protection apply to mains-connected or safety-isolated designs.","type":"text"}]},{"type":"paragraph","content":[{"text":"Common bugs (ranked by board-killing potential)","type":"text","marks":[{"type":"strong"}]},{"text":": swapped IC pins (library symbol vs datasheet pinout — invisible to DRC/ERC), transistor pinout ambiguity (SOT-23 without MPN — symbol assumes a pin ordering that may not match the real part; assess plausibility against common conventions when verification isn't possible), wrong footprint pad numbering, missing nets from un-synced schematic→PCB, wrong package variant (SOT-23 vs SOT-23-5), floating digital inputs, missing bulk caps, reversed polarity, incorrect feedback divider values, wrong crystal load caps, USB impedance mismatch, QFN thermal pad missing vias, connector pinout errors, unusual passive values (a value that's technically valid but uncommon for the application — e.g., a non-standard pull-up resistance, an unusual decoupling capacitor value).","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Report Generation","type":"text"}]},{"type":"paragraph","content":[{"text":"When producing a design review report, read ","type":"text"},{"text":"references/report-generation.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" for the standard report template, severity definitions, writing principles, and domain-specific focus areas. The report format covers: overview, component summary, power tree, analyzer verification (spot-checks), signal/power/design analysis review, quality & manufacturing, prioritized issues table, positive findings, and known analyzer gaps. Always cross-reference analyzer output against the raw schematic before reporting findings.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Design Comparison","type":"text"}]},{"type":"paragraph","content":[{"text":"When comparing two designs, diff: component counts/types, net classes/design rules, track widths/via sizes, board dimensions/layer count, power supply topology, KiCad version differences.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Security Architecture","type":"text"}]},{"type":"paragraph","content":[{"text":"All analysis scripts process untrusted input (user-provided KiCad files, third-party PDF datasheets, distributor API responses). The parsing architecture mitigates prompt injection and code execution risks:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Deterministic parsers","type":"text","marks":[{"type":"strong"}]},{"text":": S-expression files (","type":"text"},{"text":".kicad_sch","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":".kicad_pcb","type":"text","marks":[{"type":"code_inline"}]},{"text":") are parsed by a dedicated recursive-descent parser (","type":"text"},{"text":"sexp_parser.py","type":"text","marks":[{"type":"code_inline"}]},{"text":") — not ","type":"text"},{"text":"eval()","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"exec()","type":"text","marks":[{"type":"code_inline"}]},{"text":", or any code execution primitive. The parser produces Python lists/strings only.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Structured JSON boundary","type":"text","marks":[{"type":"strong"}]},{"text":": All analyzer output is structured JSON with a fixed schema. External content (component values, net names, datasheet text) is treated as data fields, never as instructions or code.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"PDF processing","type":"text","marks":[{"type":"strong"}]},{"text":": Datasheet PDFs are processed by ","type":"text"},{"text":"pdftotext","type":"text","marks":[{"type":"code_inline"}]},{"text":" (external binary, list-based args — no shell injection) and page content is passed to the LLM for structured extraction. Extracted data is validated against a 5-dimension quality rubric before caching.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"No shell commands from input","type":"text","marks":[{"type":"strong"}]},{"text":": No analyzer constructs shell commands from file content. Subprocess calls use list-based arguments exclusively.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Read-only by default","type":"text","marks":[{"type":"strong"}]},{"text":": Analysis scripts never modify input files. BOM write-back requires an explicit ","type":"text"},{"text":"--write","type":"text","marks":[{"type":"code_inline"}]},{"text":" flag.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Distributor API scope","type":"text","marks":[{"type":"strong"}]},{"text":": Network requests are limited to known distributor APIs (DigiKey, Mouser, LCSC, element14) for datasheet downloads and component lookups. Only MPNs are sent — no design data leaves the local machine.","type":"text"}]}]}]},{"type":"hr","attrs":{"markup":"---"}}]},"metadata":{"date":"2026-06-05","name":"kicad","author":"@skillopedia","source":{"stars":464,"repo_name":"kicad-happy","origin_url":"https://github.com/aklofas/kicad-happy/blob/HEAD/skills/kicad/SKILL.md","repo_owner":"aklofas","body_sha256":"080c2ded8f32e72985594a54436b234dae37482b9c54aeae12496a48d565c61a","cluster_key":"4a2548a510891baeb1fb086ccd67d56fd21e8c556bcfc7e87270a0c924469756","clean_bundle":{"format":"clean-skill-bundle-v1","source":"aklofas/kicad-happy/skills/kicad/SKILL.md","attachments":[{"id":"5f6557d7-8d64-55c0-b9bb-b80308e53848","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/5f6557d7-8d64-55c0-b9bb-b80308e53848/attachment.md","path":"references/config-reference.md","size":18722,"sha256":"ddfb9cdac2dc80130b7c5f947c45448c529349fe22e59918d6514bd77f8f0355","contentType":"text/markdown; charset=utf-8"},{"id":"6906de71-4ff3-5914-8960-cf93bacfb479","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/6906de71-4ff3-5914-8960-cf93bacfb479/attachment.md","path":"references/datasheet-verification.md","size":15401,"sha256":"d4f568fb6c3399783885e75d1ddbdfd8910092d826172d7dc0ccef14673a40eb","contentType":"text/markdown; charset=utf-8"},{"id":"e1cb2310-f757-5129-9476-bb925b4d89a6","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/e1cb2310-f757-5129-9476-bb925b4d89a6/attachment.md","path":"references/design-intent.md","size":5519,"sha256":"0398f976443aeee58fbf380c9fc36061a8ef5cef28e02acf87019734c63d2ebd","contentType":"text/markdown; charset=utf-8"},{"id":"9ed59e38-b61a-5057-88d5-59c57a4ff5d5","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/9ed59e38-b61a-5057-88d5-59c57a4ff5d5/attachment.md","path":"references/diff-analysis.md","size":14070,"sha256":"db3eded4e833e95687db47c6196d41d4872a7cdebd96f1cfd425a00de724cfbf","contentType":"text/markdown; charset=utf-8"},{"id":"4f24280d-4627-5c1d-983d-646629fd7caf","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/4f24280d-4627-5c1d-983d-646629fd7caf/attachment.md","path":"references/file-formats.md","size":13358,"sha256":"e049958ff532e474fe3f73dbf07c8fa2ada12df5a6983be2dee324aa49b4e877","contentType":"text/markdown; charset=utf-8"},{"id":"5206b67e-a9ee-51db-b7c9-f9d33618e1ff","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/5206b67e-a9ee-51db-b7c9-f9d33618e1ff/attachment.md","path":"references/gerber-parsing.md","size":29976,"sha256":"d737087b59a219830615e29673537671f5263ac2373d6b63d44f79ba97c47d03","contentType":"text/markdown; charset=utf-8"},{"id":"217f194d-fa6d-5b75-a227-f3fd85370c58","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/217f194d-fa6d-5b75-a227-f3fd85370c58/attachment.md","path":"references/manual-gerber-parsing.md","size":22525,"sha256":"ca95542a02a6cd51d04a5e2be3b155a001ed28a7a4059b728284565a3c427b95","contentType":"text/markdown; charset=utf-8"},{"id":"d1af3af0-06dc-5bb8-a357-421e75c7dbd9","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/d1af3af0-06dc-5bb8-a357-421e75c7dbd9/attachment.md","path":"references/manual-pcb-parsing.md","size":16453,"sha256":"54c7453b3a7689b5b9ab09153b60756ff6cbd2b2d8750986ef3756886aa17825","contentType":"text/markdown; charset=utf-8"},{"id":"6a6d525f-197f-5e16-9e5c-092c337aa68d","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/6a6d525f-197f-5e16-9e5c-092c337aa68d/attachment.md","path":"references/manual-schematic-parsing.md","size":12834,"sha256":"bf2de339cebfb4e8d0fe4b93e302a9ef60d1bce4468e8cd3d6d90ffedb0a92eb","contentType":"text/markdown; charset=utf-8"},{"id":"d3b53fec-9c92-5d41-afc4-a3b73cf7f56e","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/d3b53fec-9c92-5d41-afc4-a3b73cf7f56e/attachment.md","path":"references/net-tracing.md","size":7965,"sha256":"c242402dd1f1a0114e4a2586a79b1505ceb6ffd5f84beffc4cbae9679fa7d34f","contentType":"text/markdown; charset=utf-8"},{"id":"421a17c4-821d-5a5e-8f8b-fd479a4ea3ac","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/421a17c4-821d-5a5e-8f8b-fd479a4ea3ac/attachment.md","path":"references/output-schema.md","size":13644,"sha256":"29b86858adae359e65532f5b1843c5e0b3e52854ae75c3ee43ad482e891de21b","contentType":"text/markdown; charset=utf-8"},{"id":"62ee49d0-c134-5bf9-9b9d-78fff44091ad","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/62ee49d0-c134-5bf9-9b9d-78fff44091ad/attachment.md","path":"references/pcb-layout-analysis.md","size":26939,"sha256":"722a78e4edbcf4ccb70bb299d95c5ea5699d9bc60773ec911ad00ab87fc47c44","contentType":"text/markdown; charset=utf-8"},{"id":"978b6bc3-f55d-5646-9284-f213bb4c9d39","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/978b6bc3-f55d-5646-9284-f213bb4c9d39/attachment.md","path":"references/pdf-schematic-extraction.md","size":15537,"sha256":"57e345f7c960cbcc1ea11ff81a5e078651331e8e9871c5ea67f4ec0c848501c1","contentType":"text/markdown; charset=utf-8"},{"id":"e417b7bd-97bb-5718-b15b-6b0f9fcacb94","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/e417b7bd-97bb-5718-b15b-6b0f9fcacb94/attachment.md","path":"references/report-generation.md","size":55362,"sha256":"63c1aeecdc7af17d623bde37b1dc4206da4c0635a523b99cb940585a778e3777","contentType":"text/markdown; charset=utf-8"},{"id":"bad2abfd-8121-5e80-afb4-a7987aa7894c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/bad2abfd-8121-5e80-afb4-a7987aa7894c/attachment.md","path":"references/schematic-analysis.md","size":64240,"sha256":"0fc8ce180d85c49e7bd7760d795c5c23703a089d54c52503e8c19c041c8d150d","contentType":"text/markdown; charset=utf-8"},{"id":"d75875e1-2afa-5416-b9eb-27441c9c0ca3","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/d75875e1-2afa-5416-b9eb-27441c9c0ca3/attachment.md","path":"references/standards-compliance.md","size":34217,"sha256":"1b228649a57523e2642db4b1a91d92faf53632d2f93806e54814d218b8e8b6d9","contentType":"text/markdown; charset=utf-8"},{"id":"5d90cb68-8dd7-5e21-9c82-cd732af68b77","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/5d90cb68-8dd7-5e21-9c82-cd732af68b77/attachment.md","path":"references/supplementary-data-sources.md","size":12619,"sha256":"63a1da271a8c9f017614579c6ad0e885324f50a4223cea9d6a8f34944a5b0e4c","contentType":"text/markdown; charset=utf-8"},{"id":"dd738e53-093b-5f76-a87b-072d61b899d5","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/dd738e53-093b-5f76-a87b-072d61b899d5/attachment.md","path":"references/what-if.md","size":17261,"sha256":"e2166a088473f8a0cab401a345952e88dc5adbfe205a9a7326acac2722d879af","contentType":"text/markdown; charset=utf-8"},{"id":"af92776e-cf49-5344-baff-079c2902b256","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/af92776e-cf49-5344-baff-079c2902b256/attachment","path":"scripts/.gitignore","size":13,"sha256":"32dae3052f331ee34d628ef535709b301259a45df7c7522c4d35dcf49873f00b","contentType":"text/plain; charset=utf-8"},{"id":"ade832a8-9129-5e5b-8657-0d7d1f3dd0ff","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/ade832a8-9129-5e5b-8657-0d7d1f3dd0ff/attachment.md","path":"scripts/README.md","size":23598,"sha256":"41fa29c3c3232ab4b02dc6d5dcba81fef265109eefe712a5cd67daa507dc6fbb","contentType":"text/markdown; charset=utf-8"},{"id":"b5ee9ed7-7542-5e6e-99a3-706c1e81c630","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/b5ee9ed7-7542-5e6e-99a3-706c1e81c630/attachment.py","path":"scripts/analysis_cache.py","size":21993,"sha256":"5ff86031c4a14c3a823b0ed8549148f37fb491b606e0c76e8e548a03288899e5","contentType":"text/x-python; charset=utf-8"},{"id":"353efe24-6e19-5fb0-afb9-d521d999ea69","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/353efe24-6e19-5fb0-afb9-d521d999ea69/attachment.py","path":"scripts/analyze_gerbers.py","size":65682,"sha256":"55bd3e9bec93e032500c788a74f4cdb5eb07f42c6e1f9569dee04f2ada1395fc","contentType":"text/x-python; charset=utf-8"},{"id":"1f7599dc-782f-53a9-9a3f-55d874324a71","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/1f7599dc-782f-53a9-9a3f-55d874324a71/attachment.py","path":"scripts/analyze_pcb.py","size":277179,"sha256":"855c812e262305fda90f2f509759a0144082a508d346843332fde6ba0606b87a","contentType":"text/x-python; charset=utf-8"},{"id":"13d0c7c7-134e-5bf2-b354-648d967bc1d5","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/13d0c7c7-134e-5bf2-b354-648d967bc1d5/attachment.py","path":"scripts/analyze_schematic.py","size":400404,"sha256":"199d60a7a8081bb374acc1895bb8a0176da89e4c6f1bc2706947e59bec8b8c89","contentType":"text/x-python; charset=utf-8"},{"id":"19075068-ee4d-5368-874b-7d0ccca393a1","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/19075068-ee4d-5368-874b-7d0ccca393a1/attachment.py","path":"scripts/analyze_thermal.py","size":45044,"sha256":"5ec7ecdfd66a27d3dace9c9fc1b14d31689a12e6b385f4aa8d1c7fa97ea21644","contentType":"text/x-python; charset=utf-8"},{"id":"af93a86a-0617-54f4-8a10-9926a8615b03","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/af93a86a-0617-54f4-8a10-9926a8615b03/attachment.py","path":"scripts/cross_analysis.py","size":45037,"sha256":"14682f08252bafe9b140c8f567099e15859b25d4ba28c57c716da752d9dbb466","contentType":"text/x-python; charset=utf-8"},{"id":"2ea796c9-5dcf-5955-9510-9d5bdd15b6f3","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/2ea796c9-5dcf-5955-9510-9d5bdd15b6f3/attachment.py","path":"scripts/cross_verify.py","size":22721,"sha256":"ff13972a0c12e6d8ca5bb6eca60f3e52581412540f433a926cda9a7430277352","contentType":"text/x-python; charset=utf-8"},{"id":"9d050156-9717-50a4-bb7a-4880de95483a","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/9d050156-9717-50a4-bb7a-4880de95483a/attachment.py","path":"scripts/detection_schema.py","size":23291,"sha256":"f1f03c8e9b3ec143a5c4b924f5517df9e3c430da5157678d26cc969d0e0cc285","contentType":"text/x-python; charset=utf-8"},{"id":"8b0f05b2-e134-54fd-a9f4-393d6809b74e","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/8b0f05b2-e134-54fd-a9f4-393d6809b74e/attachment.py","path":"scripts/detector_helpers.py","size":2645,"sha256":"68fdc890755b53be1e25f5a7d07f72a2a8ca30dc5b0b67f4e2dfafebbe4ebc63","contentType":"text/x-python; charset=utf-8"},{"id":"d0b544a1-877d-538b-bd6d-f5d900810432","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/d0b544a1-877d-538b-bd6d-f5d900810432/attachment.py","path":"scripts/diff_analysis.py","size":49237,"sha256":"820df42ce17a6d84e788e9a7f32f2dc4d8d28fed47fe63d9a2e7439537d1b9e5","contentType":"text/x-python; charset=utf-8"},{"id":"dbe368e0-405b-5881-9d91-c93b9ee0c2ad","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/dbe368e0-405b-5881-9d91-c93b9ee0c2ad/attachment.py","path":"scripts/domain_detectors.py","size":257723,"sha256":"71bb59623886e37eb0e29aaddce14b4a2d7d3eeec4c4854921162da148973542","contentType":"text/x-python; charset=utf-8"},{"id":"08083538-87b2-5b27-a27d-9c8882acecce","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/08083538-87b2-5b27-a27d-9c8882acecce/attachment.py","path":"scripts/export_issues.py","size":17497,"sha256":"a34c3aa214ef00d2547b2a0e16ff7fa53f66ce742a842c9ce13c6d3ba86ff01e","contentType":"text/x-python; charset=utf-8"},{"id":"1a82cd58-1efd-5cd6-99b4-79cb2867abb9","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/1a82cd58-1efd-5cd6-99b4-79cb2867abb9/attachment.py","path":"scripts/fab_release_gate.py","size":23248,"sha256":"d7340f44e9b355f3dda60132a7f97d074e3e9060640dcba48c7416d887b6c46d","contentType":"text/x-python; charset=utf-8"},{"id":"6c752350-1a93-5a37-ae00-1ac267caa42e","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/6c752350-1a93-5a37-ae00-1ac267caa42e/attachment.py","path":"scripts/finding_schema.py","size":17778,"sha256":"ad8ee5d5e5d01b15fa0e50319a4e8648426c448b16610957b27a35e3163fe0a3","contentType":"text/x-python; charset=utf-8"},{"id":"630ed20e-5d1a-5504-9d18-f57cf2f5f58c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/630ed20e-5d1a-5504-9d18-f57cf2f5f58c/attachment.py","path":"scripts/kicad_types.py","size":5679,"sha256":"d4ed801611819296c4a45eeb84259f31ef545d1ee9ee2fcc4fd5ffb41f26d541","contentType":"text/x-python; charset=utf-8"},{"id":"a7c13ae0-e7bd-5e91-b080-53c7ae84026c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/a7c13ae0-e7bd-5e91-b080-53c7ae84026c/attachment.py","path":"scripts/kicad_utils.py","size":88577,"sha256":"8364d2e6598d200a483d81a3c12ed717d2f35b6e8bd2839d4127e09a94cc09df","contentType":"text/x-python; charset=utf-8"},{"id":"734c72f0-910c-5f48-b116-dac6fd1009d9","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/734c72f0-910c-5f48-b116-dac6fd1009d9/attachment.py","path":"scripts/lifecycle_audit.py","size":41752,"sha256":"b6f461c0ceb2185d8ff5720bec77a83135a15a452f9c9ce2ed6f7651704d8bb4","contentType":"text/x-python; charset=utf-8"},{"id":"170ad885-082e-576e-a7a4-371f86545aa7","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/170ad885-082e-576e-a7a4-371f86545aa7/attachment.md","path":"scripts/methodology_gerbers.md","size":22380,"sha256":"bba75063a377aebf9acf8c16eb25fd83777e20433280671625293721c330529d","contentType":"text/markdown; charset=utf-8"},{"id":"1ff691cc-5b77-5c65-91a8-a4ff362c75fb","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/1ff691cc-5b77-5c65-91a8-a4ff362c75fb/attachment.md","path":"scripts/methodology_pcb.md","size":20674,"sha256":"9653782242b7334e3c75e07c8e7df9e1f59b8c431453f75a185c91017b34121e","contentType":"text/markdown; charset=utf-8"},{"id":"20792f94-56f0-551a-933b-1cd0c0986572","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/20792f94-56f0-551a-933b-1cd0c0986572/attachment.md","path":"scripts/methodology_schematic.md","size":39189,"sha256":"eca93d2d0a141eba53ca1c206c220da2edaf174f6475243c888dfc317096e26a","contentType":"text/markdown; charset=utf-8"},{"id":"0a81f3d4-8d23-56e2-8ba2-900ee2f57dc2","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/0a81f3d4-8d23-56e2-8ba2-900ee2f57dc2/attachment.py","path":"scripts/netlist_queries.py","size":14939,"sha256":"abd66f2d33343b7cb97b63cc962c4e8a6eda119ed423af1b0bb246a247956eb9","contentType":"text/x-python; charset=utf-8"},{"id":"df065a99-aec0-5084-ac66-58b8414af875","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/df065a99-aec0-5084-ac66-58b8414af875/attachment.py","path":"scripts/output_filters.py","size":17410,"sha256":"1faf348f5eda22726436c1623ea68d9d54396748e5f4e4fd28eabcd70ca0468b","contentType":"text/x-python; charset=utf-8"},{"id":"46a04004-9399-58f3-9ceb-ada6ddb86940","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/46a04004-9399-58f3-9ceb-ada6ddb86940/attachment.py","path":"scripts/pcb_connectivity.py","size":18928,"sha256":"8882e57db828c8fdc559d076493f466dd622fae217ca9e7f706bd76c6dfaa64a","contentType":"text/x-python; charset=utf-8"},{"id":"5c45dec7-46f5-5deb-964d-df4fb26f9d7d","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/5c45dec7-46f5-5deb-964d-df4fb26f9d7d/attachment.py","path":"scripts/project_config.py","size":32701,"sha256":"1b60e56a73fa8dcd6ec0bba1197dbfbd430cbc26d50592a33289017918b68db5","contentType":"text/x-python; charset=utf-8"},{"id":"23675c05-cb96-590f-9ecf-92cd33b46d0a","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/23675c05-cb96-590f-9ecf-92cd33b46d0a/attachment.py","path":"scripts/sexp_parser.py","size":7679,"sha256":"4932e12f97827599f09c2e072749fcbfba8df58d43ecc2aa4cb9161211c59afa","contentType":"text/x-python; charset=utf-8"},{"id":"0eebae64-1807-54cc-ab00-f33be956648a","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/0eebae64-1807-54cc-ab00-f33be956648a/attachment.py","path":"scripts/signal_detectors.py","size":208594,"sha256":"08a787ac8a89597e7ca363897d8c92709cc08f02a7eb556aa94352f808c83f81","contentType":"text/x-python; charset=utf-8"},{"id":"5f118442-e080-5c34-bfff-7c4a0b911b90","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/5f118442-e080-5c34-bfff-7c4a0b911b90/attachment.py","path":"scripts/summarize_findings.py","size":10507,"sha256":"3d28bf83c31e2e1d5ff15dd779f14962245808d867a5585cc8060de204b1a755","contentType":"text/x-python; charset=utf-8"},{"id":"4b837da2-0427-5319-b56b-a5b754b0f4db","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/4b837da2-0427-5319-b56b-a5b754b0f4db/attachment.py","path":"scripts/validation_detectors.py","size":54551,"sha256":"b8b4729e4431b53439a05d8888849ff591d9c257fd6bfc204906b70b36209d9d","contentType":"text/x-python; charset=utf-8"},{"id":"5833f774-049d-59d8-82e4-b747e4609047","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/5833f774-049d-59d8-82e4-b747e4609047/attachment.py","path":"scripts/what_if.py","size":59559,"sha256":"0a05e2b5a2129efca552f6c8e64ecd741684a0f4d041f630f3ee48d0efe94185","contentType":"text/x-python; charset=utf-8"}],"bundle_sha256":"7783a536fdb4024a78879e73bf5b2dae27e7bbe2ee3073597601b7c0e7298f07","attachment_count":49,"text_attachments":48,"attachment_storage":"skillopedia-attachments-v1","binary_attachments":1,"excluded_attachments":[]},"cluster_size":1,"skill_md_path":"skills/kicad/SKILL.md","import_metadata":{"date":"2026-06-05","author":"@skillopedia","version":"v1","category":"web-development","category_label":"Web"},"exact_dupes_collapsed_into_this":0},"version":"v1","category":"web-development","import_tag":"clean-skills-v1","description":"Analyze KiCad projects and PDF schematics: schematics, PCB layouts, Gerbers, footprints, symbols, netlists, and design rules. Reviews designs for bugs, traces nets, cross-references schematic to PCB, extracts BOM data, checks DRC/ERC, DFM, power trees, and regulator circuits. Every finding carries a confidence label and evidence source with trust_summary rollup. Analyzes PDF schematics from dev boards, reference designs, eval kits, and datasheets. Supports KiCad 5–10. Use whenever the user mentions .kicad_sch, .kicad_pcb, .kicad_pro, PCB design review, schematic analysis, PDF schematics, reference designs, Gerber files, DRC/ERC, netlist issues, BOM extraction, signal tracing, power budget, DFM, or wants to understand, debug, compare, or review any hardware design. Also for \"check my board\", \"review before fab\", \"what's wrong with my schematic\", \"is this ready to order\", \"check my power supply\", \"verify this circuit\", or any electronics/PCB design question."}},"renderedAt":1782987518499}

KiCad Project Analysis Skill Related Skills | Skill | Purpose | |-------|---------| | | BOM extraction, enrichment, ordering, and export workflows | | | Search DigiKey for parts (prototype sourcing) | | | Search Mouser for parts (secondary prototype source) | | | Search LCSC for parts (production sourcing, JLCPCB) | | | Search Newark/Farnell/element14 (international sourcing, reliable datasheets) | | | PCB fabrication & assembly ordering | | | Alternative PCB fabrication & assembly | | | SPICE simulation verification of detected subcircuits | | | EMC pre-compliance risk analysis — consumes sc…