Rename scripts to capacitors, locations, bottom_termination; update README and .env

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-12 12:50:16 +13:00
parent 758991d75d
commit bce950c381
5 changed files with 160 additions and 178 deletions

View File

@@ -1,12 +1,12 @@
# All scripts: set vars in .env or pass via CLI; CLI overrides .env.
# Capacitors by net pair
INPUT_FILE=board.pcb
# Capacitors by net pair (KiCad .kicad_pcb only)
INPUT_FILE=board.kicad_pcb
OUTPUT_FILE=outputs/capacitors_by_net_pair.json
# Compare Protel locations
FILE1=board_v1.pcb
FILE2=board_v2.pcb
# Compare KiCad locations (.kicad_pcb)
FILE1=board_v1.kicad_pcb
FILE2=board_v2.kicad_pcb
COMPARE_OUTPUT=outputs/compare_locations.json
THRESHOLD=1.0

View File

@@ -59,35 +59,27 @@ Finds all **two-pad components** on the PCB that share the same two nets (e.g. d
}
```
### Protel PCB 2.8 ASCII — easier (Python, no Altium)
### KiCad .kicad_pcb (Python script)
**Yes — Protel PCB 2.8 ASCII is easier.** Its plain text, so you can parse it with Python and no OLE/binary handling. You dont need Altium running.
**Script:** `capacitors_by_net_pair.py`**KiCad only.** Reads a `.kicad_pcb` file and outputs the same JSON (net pair → capacitors with designator, value, package, total capacitance).
1. **Export from Altium:** Open your PcbDoc → **File → Save As** (or **Export**) → choose **PCB 2.8 ASCII** or **Protel PCB ASCII** if your version offers it. Some versions use **File → Save Copy As** with format “PCB Binary/ASCII” or similar.
2. **Run the Python script** on the exported `.pcb` / `.PcbDoc` (ASCII) file:
```bash
python3 capacitors_by_net_pair.py board.PcbDoc
python3 capacitors_by_net_pair.py board.PcbDoc -o out.json
```
**Input/output from .env:** Copy `.env.example` to `.env` and set `INPUT_FILE` and `OUTPUT_FILE`. The script reads these when the optional `python-dotenv` package is installed; CLI arguments override them. Without `.env`, you can still pass the input file and `-o` on the command line. By default the JSON is written to **`outputs/capacitors_by_net_pair.json`** (the `outputs/` directory is created if needed).
See **`capacitors_by_net_pair.py`** for the script. It parses COMP/PATTERN/VALUE and NET/PIN data from the ASCII file and produces the same JSON shape as the DelphiScript.
**Test file:** `tests/sample_protel_ascii.pcb` is a minimal Protel PCB 2.8 ASCII sample. Run:
**Usage:**
```bash
python3 capacitors_by_net_pair.py tests/sample_protel_ascii.pcb -o tests/out.json
python3 capacitors_by_net_pair.py board.kicad_pcb
python3 capacitors_by_net_pair.py board.kicad_pcb -o outputs/capacitors_by_net_pair.json
```
**Input/output from .env:** Set `INPUT_FILE` and `OUTPUT_FILE`; CLI overrides. Default output: **`outputs/capacitors_by_net_pair.json`**.
---
## Compare component locations (two Protel files)
## Compare component locations (two KiCad files)
**Script:** `compare_protel_locations.py`
Loads two Protel PCB 2.8 ASCII files and reports **which components have moved** between them. Component position is the centroid of pin coordinates. Output is written to `outputs/compare_locations.json` by default.
Loads two KiCad `.kicad_pcb` files and reports **which components have moved** between them. Component position is the centroid of pad `(at x y)` coordinates. Output is written to `outputs/compare_locations.json` by default.
- **Moved:** designators with different (x, y) in file2, with old position, new position, and distance.
- **Only in file1 / only in file2:** components that appear in just one file.
@@ -95,18 +87,12 @@ Loads two Protel PCB 2.8 ASCII files and reports **which components have moved**
**Usage:**
```bash
python3 compare_protel_locations.py board_v1.pcb board_v2.pcb
python3 compare_protel_locations.py board_v1.pcb board_v2.pcb -o outputs/compare_locations.json
python3 compare_protel_locations.py board_v1.kicad_pcb board_v2.kicad_pcb
python3 compare_protel_locations.py board_v1.kicad_pcb board_v2.kicad_pcb -o outputs/compare_locations.json
```
Use **.env** (optional): set `FILE1`, `FILE2`, and `COMPARE_OUTPUT`; CLI arguments override them. Use `--threshold N` to set the minimum position change to count as moved (default 1.0).
**Test:** `tests/sample_protel_ascii.pcb` and `tests/sample_protel_ascii_rev2.pcb` (C1 and C2 moved in rev2):
```bash
python3 compare_protel_locations.py tests/sample_protel_ascii.pcb tests/sample_protel_ascii_rev2.pcb
```
---
## Spreadsheet diff by designator

View File

@@ -1,14 +1,13 @@
#!/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.
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.pcb] [-o output.json]
# or set in .env: INPUT_FILE=board.pcb, OUTPUT_FILE=out.json
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
@@ -74,100 +73,87 @@ def format_capacitance(cap_f: float) -> str:
return f"{cap_f}F"
# --- Protel PCB 2.8 ASCII parsing --------------------------------------------
# --- KiCad .kicad_pcb parsing -------------------------------------------------
def parse_protel_ascii(path: str) -> tuple[list[dict], dict]:
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 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).
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")
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
# 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] = []
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
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
# 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))
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:
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
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,
@@ -175,7 +161,8 @@ def parse_protel_ascii(path: str) -> tuple[list[dict], dict]:
"value": value or "?",
"pins": pins,
})
return components, pin_to_net
fp_pos = fp_end + 1
return components, {}
def build_net_key(net1: str, net2: str) -> str:
@@ -189,12 +176,12 @@ 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 Protel PCB 2.8 ASCII")
parser = argparse.ArgumentParser(description="List capacitors by net pair from KiCad .kicad_pcb")
parser.add_argument(
"file",
nargs="?",
default=default_input,
help="Path to .pcb / .PcbDoc (ASCII) file (default: INPUT_FILE from .env)",
help="Path to .kicad_pcb file (default: INPUT_FILE from .env)",
)
parser.add_argument(
"-o", "--output",
@@ -209,7 +196,7 @@ def main() -> int:
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)
components, pin_to_net = parse_kicad_pcb(input_path)
except Exception as e:
print(f"Parse error: {e}", file=sys.stderr)
return 1

View File

@@ -1,12 +1,12 @@
#!/usr/bin/env python3
"""
Load two Protel PCB 2.8 ASCII files and report which components have moved
between them. Component position is taken as the centroid of pin coordinates.
Load two KiCad .kicad_pcb files and report which components have moved
between them. Component position is the centroid of pad (at x y) coordinates.
All paths: use .env (FILE1, FILE2, COMPARE_OUTPUT) or CLI; CLI overrides .env.
Usage:
python3 compare_protel_locations.py file1.pcb file2.pcb [-o report.json]
python3 compare_protel_locations.py file1.kicad_pcb file2.kicad_pcb [-o report.json]
python3 compare_protel_locations.py # uses FILE1, FILE2 from .env
"""
@@ -25,58 +25,66 @@ except ImportError:
pass
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_components_with_positions(path: str) -> dict[str, dict]:
"""
Parse a Protel PCB 2.8 ASCII file and return a dict:
Parse a KiCad .kicad_pcb file and return a dict:
designator -> { "x": float, "y": float, "pins": [(x,y), ...], "pattern": str, "value": str }
Position is the centroid of all pin coordinates (from the numeric line after each PIN line).
Position is the centroid of all pad (at x y) coordinates.
"""
text = Path(path).read_text(encoding="utf-8", errors="replace")
lines = [line.rstrip() for line in text.splitlines()]
components: dict[str, dict] = {}
i = 0
while i < len(lines):
if lines[i].strip().upper() != "COMP":
i += 1
continue
i += 1
designator = ""
pattern = ""
value = ""
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 "?"
pin_coords: list[tuple[float, float]] = []
while i < len(lines):
ln = lines[i]
if ln.strip().upper() == "ENDCOMP":
i += 1
pos = 0
while True:
pad_start = rest.find("(pad ", pos)
if pad_start < 0:
break
if not designator and ln.strip():
parts = ln.strip().split()
if parts and not re.match(
r"^(PATTERN|VALUE|PIN|PINNE|ENDCOMP)$", parts[0], re.I
):
designator = parts[0]
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('"')
if re.match(r"^\s*(?:PINNE|PIN)\s+", ln, re.I):
# Next line often has coordinates: first two numbers are X Y
if i + 1 < len(lines):
next_ln = lines[i + 1].strip().split()
nums = [t for t in next_ln if re.match(r"^-?\d+$", t)]
if len(nums) >= 2:
try:
x, y = int(nums[0]), int(nums[1])
pin_coords.append((float(x), float(y)))
except ValueError:
pass
i += 1
i += 1
pad_end = _find_matching_paren(rest, pad_start)
if pad_end < 0:
break
block = rest[pad_start:pad_end + 1]
at_m = re.search(r'\(\s*at\s+([-\d.]+)\s+([-\d.]+)', block)
if at_m:
try:
x, y = float(at_m.group(1)), float(at_m.group(2))
pin_coords.append((x, y))
except ValueError:
pass
pos = pad_end + 1
if designator and pin_coords:
cx = sum(p[0] for p in pin_coords) / len(pin_coords)
cy = sum(p[1] for p in pin_coords) / len(pin_coords)
@@ -87,6 +95,7 @@ def parse_components_with_positions(path: str) -> dict[str, dict]:
"pattern": pattern or "",
"value": value or "?",
}
fp_pos = fp_end + 1
return components
@@ -108,19 +117,19 @@ def main() -> int:
default_threshold = 1.0
parser = argparse.ArgumentParser(
description="Compare two Protel PCB ASCII files and list components that moved"
description="Compare two KiCad .kicad_pcb files and list components that moved"
)
parser.add_argument(
"file1",
nargs="?",
default=default_file1,
help="First PCB file (default: FILE1 from .env)",
help="First .kicad_pcb file (default: FILE1 from .env)",
)
parser.add_argument(
"file2",
nargs="?",
default=default_file2,
help="Second PCB file (default: FILE2 from .env)",
help="Second .kicad_pcb file (default: FILE2 from .env)",
)
parser.add_argument(
"-o",
@@ -140,7 +149,7 @@ def main() -> int:
path2 = (args.file2 or default_file2 or "").strip()
if not path1 or not path2:
parser.error(
"Need two PCB files. Set FILE1 and FILE2 in .env or pass two paths."
"Need two .kicad_pcb files. Set FILE1 and FILE2 in .env or pass two paths."
)
if not Path(path1).exists():