From 1c02165b5ea0c2e955f426f54479c12e9348f925 Mon Sep 17 00:00:00 2001 From: Jimmy Date: Wed, 11 Feb 2026 12:10:12 +1300 Subject: [PATCH] Add capacitors by net pair script (Protel ASCII) Co-authored-by: Cursor --- capacitors_by_net_pair.py | 263 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 263 insertions(+) create mode 100644 capacitors_by_net_pair.py diff --git a/capacitors_by_net_pair.py b/capacitors_by_net_pair.py new file mode 100644 index 0000000..0994cb5 --- /dev/null +++ b/capacitors_by_net_pair.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +""" +Parse Protel PCB 2.8 ASCII (or Protel 99 SE PCB ASCII) and output capacitors +by net pair: JSON with net pair as key, designator/value/package and total +capacitance per net pair. + +Input/output paths are read from .env (INPUT_FILE, OUTPUT_FILE) if set; +CLI arguments override them. + +Usage: + python capacitors_by_net_pair.py [file.pcb] [-o output.json] + # or set in .env: INPUT_FILE=board.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" + + +# --- Protel PCB 2.8 ASCII parsing -------------------------------------------- + +def parse_protel_ascii(path: str) -> tuple[list[dict], dict]: + """ + Parse file and return (components, pin_to_net). + components: list of { + "designator": str, + "pattern": str, + "value": str, + "pins": list of (pin_id, net_name or None), + } + pin_to_net: (designator, pin_id) -> net_name (from NET section if present). + """ + text = Path(path).read_text(encoding="utf-8", errors="replace") + lines = [line.rstrip() for line in text.splitlines()] + + # NET section: map (Designator, PinNumber) -> NetName + # Formats: "NET" "NetName" then "C1-1" / C1-1; or NetName then Designator-Pin lines + pin_to_net: dict[tuple[str, str], str] = {} + i = 0 + while i < len(lines): + line = lines[i] + rest = line.strip() + # "NET" "NetName" or NET NetName + if re.match(r"^\s*NET\s+", line, re.I): + parts = re.split(r"\s+", rest, maxsplit=2) + current_net = (parts[1].strip('"') if len(parts) > 1 else "") or None + i += 1 + while i < len(lines): + conn = lines[i].strip().strip('"') + if re.match(r"^[A-Za-z0-9_]+-\d+$", conn) and current_net: + comp, pin = conn.split("-", 1) + pin_to_net[(comp.upper(), pin)] = current_net + i += 1 + elif conn.upper() in ("ENDNET", "END", ""): + break + else: + i += 1 + continue + i += 1 + + # COMP ... ENDCOMP blocks + components: list[dict] = [] + i = 0 + while i < len(lines): + line = lines[i] + if line.strip().upper() != "COMP": + i += 1 + continue + i += 1 + designator = "" + pattern = "" + value = "" + pins: list[tuple[str, str | None]] = [] # (pin_id, net_name) + + while i < len(lines): + ln = lines[i] + if ln.strip().upper() == "ENDCOMP": + i += 1 + break + # Designator: first token on first line after COMP that isn't PATTERN/VALUE/PIN/PINNE + if not designator and ln.strip(): + parts = ln.strip().split() + if parts: + candidate = parts[0] + if not re.match(r"^(PATTERN|VALUE|PIN|PINNE|ENDCOMP)$", candidate, re.I): + designator = candidate + # PATTERN = value or PATTERN value + m = re.match(r"^\s*PATTERN\s+(.+)$", ln, re.I) + if m: + pattern = m.group(1).strip().strip('"') + m = re.match(r"^\s*VALUE\s+(.+)$", ln, re.I) + if m: + value = m.group(1).strip().strip('"') + # PIN: PIN [net] ... or PINNE ... (numbers follow) + pin_match = re.match(r"^\s*(?:PINNE|PIN)\s+(\S+)\s+(\S+)", ln, re.I) + if pin_match: + pin_name, second = pin_match.groups() + net_name = second if not second.replace(".", "").isdigit() else None + if net_name and net_name.upper() in ("PINNE", "PIN"): + net_name = None + if not net_name and (designator, pin_name) in pin_to_net: + net_name = pin_to_net[(designator, pin_name)] + elif not net_name and (designator.upper(), pin_name) in pin_to_net: + net_name = pin_to_net[(designator.upper(), pin_name)] + pins.append((pin_name, net_name)) + else: + pin_simple = re.match(r"^\s*(?:PINNE|PIN)\s+(\S+)", ln, re.I) + if pin_simple: + pn = pin_simple.group(1) + net_name = pin_to_net.get((designator, pn)) or pin_to_net.get((designator.upper(), pn)) + pins.append((pn, net_name)) + i += 1 + + if designator: + components.append({ + "designator": designator, + "pattern": pattern or "", + "value": value or "?", + "pins": pins, + }) + return components, pin_to_net + + +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 "output/capacitors_by_net_pair.json" + + parser = argparse.ArgumentParser(description="List capacitors by net pair from Protel PCB 2.8 ASCII") + parser.add_argument( + "file", + nargs="?", + default=default_input, + help="Path to .pcb / .PcbDoc (ASCII) file (default: INPUT_FILE from .env)", + ) + parser.add_argument( + "-o", "--output", + default=default_output, + help="Output JSON path (default: OUTPUT_FILE from .env or output/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_protel_ascii(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())