250 lines
8.3 KiB
Python
250 lines
8.3 KiB
Python
#!/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())
|