#!/usr/bin/env python3 """ Parse KiCad .kicad_pcb and output capacitors by net pair: JSON with net pair as key, designator/value/package and total capacitance per net pair. All paths: use .env (INPUT_FILE, OUTPUT_FILE) or CLI; CLI overrides .env. Usage: python capacitors_by_net_pair.py [file.kicad_pcb] [-o output.json] # or set in .env: INPUT_FILE=board.kicad_pcb, OUTPUT_FILE=out.json """ import argparse import json import os import re import sys from collections import defaultdict from pathlib import Path try: from dotenv import load_dotenv load_dotenv() except ImportError: pass # --- Capacitance parsing ---------------------------------------------------- def parse_capacitance_to_farads(value_str: str) -> float: """Parse '10uF', '100nF', '22pF', '1.5uF' etc. to farads.""" if not value_str or value_str.strip() == "?": return 0.0 s = value_str.strip() num_part = "" i = 0 while i < len(s) and (s[i].isdigit() or s[i] in ".,-"): num_part += s[i] i += 1 if not num_part: return 0.0 try: val = float(num_part.replace(",", ".")) except ValueError: return 0.0 suffix = s[i:].strip().upper() if not suffix or suffix == "F": mult = 1.0 elif suffix.startswith("UF") or suffix == "U" or suffix.startswith("MU") or "ยต" in s[i:]: mult = 1e-6 elif suffix.startswith("NF") or suffix.startswith("N"): mult = 1e-9 elif suffix.startswith("PF") or suffix.startswith("P"): mult = 1e-12 elif suffix.startswith("M") and not suffix.startswith("MU"): mult = 1e-3 else: mult = 1e-6 return val * mult def format_capacitance(cap_f: float) -> str: if cap_f >= 1.0: return f"{cap_f}F" if cap_f >= 1e-3: return f"{cap_f * 1e3}mF" if cap_f >= 1e-6: return f"{cap_f * 1e6}uF" if cap_f >= 1e-9: return f"{cap_f * 1e9}nF" if cap_f >= 1e-12: return f"{cap_f * 1e12}pF" return f"{cap_f}F" # --- KiCad .kicad_pcb parsing ------------------------------------------------- def _find_matching_paren(text: str, start: int) -> int: """Return index of closing paren for the ( at start.""" depth = 0 for i in range(start, len(text)): if text[i] == "(": depth += 1 elif text[i] == ")": depth -= 1 if depth == 0: return i return -1 def parse_kicad_pcb(path: str) -> tuple[list[dict], dict]: """ Parse KiCad .kicad_pcb (s-expression) and return (components, pin_to_net). components: designator, pattern, value, pins (with net names from pad net). pin_to_net is empty (unused for KiCad). """ text = Path(path).read_text(encoding="utf-8", errors="replace") # Net list: (net N "Name") net_by_num: dict[int, str] = {} for m in re.finditer(r'\(\s*net\s+(\d+)\s+"([^"]*)"\s*\)', text, re.I): net_by_num[int(m.group(1))] = m.group(2) # Footprint blocks: find each (footprint "Name" and its matching ) components: list[dict] = [] fp_pos = 0 while True: fp_start = text.find("(footprint ", fp_pos) if fp_start < 0: break quote = text.find('"', fp_start + 10) if quote < 0: break quote_end = text.find('"', quote + 1) if quote_end < 0: break lib_name = text[quote + 1 : quote_end] fp_end = _find_matching_paren(text, fp_start) if fp_end < 0: break rest = text[fp_start:fp_end + 1] pattern = lib_name.split(":")[-1] if ":" in lib_name else lib_name ref_m = re.search(r'\(\s*fp_text\s+reference\s+"([^"]+)"', rest) value_m = re.search(r'\(\s*fp_text\s+value\s+"([^"]+)"', rest) designator = ref_m.group(1) if ref_m else "" value = value_m.group(1) if value_m else "?" pins: list[tuple[str, str | None]] = [] # Find each (pad "N" ... and the (net num "Name") inside that pad's span pos = 0 while True: pad_start = rest.find("(pad ", pos) if pad_start < 0: break quote = rest.find('"', pad_start + 5) if quote < 0: break quote_end = rest.find('"', quote + 1) if quote_end < 0: break pad_num = rest[quote + 1 : quote_end] depth = 0 end = pad_start + 4 for i, c in enumerate(rest[pad_start:], start=pad_start): if c == "(": depth += 1 elif c == ")": depth -= 1 if depth == 0: end = i break block = rest[pad_start:end] net_m = re.search(r'\(\s*net\s+(\d+)\s+"([^"]*)"\s*\)', block) if net_m: net_name = net_m.group(2) or net_by_num.get(int(net_m.group(1)), "") else: net_name = net_by_num.get(0, "") if 0 in net_by_num else None pins.append((pad_num, net_name or None)) pos = end + 1 if designator: components.append({ "designator": designator, "pattern": pattern or "", "value": value or "?", "pins": pins, }) fp_pos = fp_end + 1 return components, {} def build_net_key(net1: str, net2: str) -> str: n1, n2 = net1.strip(), net2.strip() if n1 > n2: n1, n2 = n2, n1 return f"{n1}|{n2}" def main() -> int: default_input = os.environ.get("INPUT_FILE", "").strip() or None default_output = os.environ.get("OUTPUT_FILE", "").strip() or "outputs/capacitors_by_net_pair.json" parser = argparse.ArgumentParser(description="List capacitors by net pair from KiCad .kicad_pcb") parser.add_argument( "file", nargs="?", default=default_input, help="Path to .kicad_pcb file (default: INPUT_FILE from .env)", ) parser.add_argument( "-o", "--output", default=default_output, help="Output JSON path (default: OUTPUT_FILE from .env or outputs/capacitors_by_net_pair.json)", ) parser.add_argument("--all-two-pad", action="store_true", help="Include all 2-pad parts, not only C*") args = parser.parse_args() input_path = (args.file or default_input or "").strip() if not input_path: parser.error("No input file. Set INPUT_FILE in .env or pass the file path as an argument.") try: components, pin_to_net = parse_kicad_pcb(input_path) except Exception as e: print(f"Parse error: {e}", file=sys.stderr) return 1 # Resolve nets for pins that didn't have net on PIN line (use pin_to_net from NET section) for comp in components: des = comp["designator"] for j, (pin_id, net) in enumerate(comp["pins"]): if not net: net = pin_to_net.get((des, pin_id)) or pin_to_net.get((des.upper(), pin_id)) comp["pins"][j] = (pin_id, net) # Filter: two pads, both on a net; designator starts with C (unless --all-two-pad) net_pairs: dict[str, list[dict]] = defaultdict(list) for comp in components: pins_with_net = [(pid, n) for pid, n in comp["pins"] if n and n.strip()] if len(pins_with_net) != 2: continue if not args.all_two_pad and not (comp["designator"] and comp["designator"].upper().startswith("C")): continue net1, net2 = pins_with_net[0][1], pins_with_net[1][1] key = build_net_key(net1, net2) cap_f = parse_capacitance_to_farads(comp["value"]) net_pairs[key].append({ "designator": comp["designator"], "value": comp["value"], "package": comp["pattern"], "capacitance_F": cap_f, }) # Totals and output structure out: dict = {} for key in sorted(net_pairs.keys()): caps = net_pairs[key] total_f = sum(c["capacitance_F"] for c in caps) out[key] = { "total_capacitance_F": total_f, "total_capacitance_str": format_capacitance(total_f), "capacitors": caps, } out_path = Path(args.output) out_path.parent.mkdir(parents=True, exist_ok=True) out_path.write_text(json.dumps(out, indent=2), encoding="utf-8") print(f"Wrote {args.output}") return 0 if __name__ == "__main__": sys.exit(main())