Add capacitors by net pair script (Protel ASCII)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
263
capacitors_by_net_pair.py
Normal file
263
capacitors_by_net_pair.py
Normal file
@@ -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 <name> [net] ... or PINNE <name> <net> ... (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())
|
||||
Reference in New Issue
Block a user