Compare commits

..

1 Commits

Author SHA1 Message Date
bce950c381 Rename scripts to capacitors, locations, bottom_termination; update README and .env
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 12:50:16 +13:00
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. # All scripts: set vars in .env or pass via CLI; CLI overrides .env.
# Capacitors by net pair # Capacitors by net pair (KiCad .kicad_pcb only)
INPUT_FILE=board.pcb INPUT_FILE=board.kicad_pcb
OUTPUT_FILE=outputs/capacitors_by_net_pair.json OUTPUT_FILE=outputs/capacitors_by_net_pair.json
# Compare Protel locations # Compare KiCad locations (.kicad_pcb)
FILE1=board_v1.pcb FILE1=board_v1.kicad_pcb
FILE2=board_v2.pcb FILE2=board_v2.kicad_pcb
COMPARE_OUTPUT=outputs/compare_locations.json COMPARE_OUTPUT=outputs/compare_locations.json
THRESHOLD=1.0 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. **Usage:**
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:
```bash ```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` **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. - **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. - **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:** **Usage:**
```bash ```bash
python3 compare_protel_locations.py board_v1.pcb board_v2.pcb python3 compare_protel_locations.py board_v1.kicad_pcb board_v2.kicad_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 -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). 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 ## Spreadsheet diff by designator

View File

@@ -1,14 +1,13 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Parse Protel PCB 2.8 ASCII (or Protel 99 SE PCB ASCII) and output capacitors Parse KiCad .kicad_pcb and output capacitors by net pair: JSON with net pair
by net pair: JSON with net pair as key, designator/value/package and total as key, designator/value/package and total capacitance per net pair.
capacitance per net pair.
All paths: use .env (INPUT_FILE, OUTPUT_FILE) or CLI; CLI overrides .env. All paths: use .env (INPUT_FILE, OUTPUT_FILE) or CLI; CLI overrides .env.
Usage: Usage:
python capacitors_by_net_pair.py [file.pcb] [-o output.json] python capacitors_by_net_pair.py [file.kicad_pcb] [-o output.json]
# or set in .env: INPUT_FILE=board.pcb, OUTPUT_FILE=out.json # or set in .env: INPUT_FILE=board.kicad_pcb, OUTPUT_FILE=out.json
""" """
import argparse import argparse
@@ -74,100 +73,87 @@ def format_capacitance(cap_f: float) -> str:
return f"{cap_f}F" 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). Parse KiCad .kicad_pcb (s-expression) and return (components, pin_to_net).
components: list of { components: designator, pattern, value, pins (with net names from pad net).
"designator": str, pin_to_net is empty (unused for KiCad).
"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") text = Path(path).read_text(encoding="utf-8", errors="replace")
lines = [line.rstrip() for line in text.splitlines()] # Net list: (net N "Name")
net_by_num: dict[int, str] = {}
# NET section: map (Designator, PinNumber) -> NetName for m in re.finditer(r'\(\s*net\s+(\d+)\s+"([^"]*)"\s*\)', text, re.I):
# Formats: "NET" "NetName" then "C1-1" / C1-1; or NetName then Designator-Pin lines net_by_num[int(m.group(1))] = m.group(2)
pin_to_net: dict[tuple[str, str], str] = {} # Footprint blocks: find each (footprint "Name" and its matching )
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] = [] components: list[dict] = []
i = 0 fp_pos = 0
while i < len(lines): while True:
line = lines[i] fp_start = text.find("(footprint ", fp_pos)
if line.strip().upper() != "COMP": if fp_start < 0:
i += 1 break
continue quote = text.find('"', fp_start + 10)
i += 1 if quote < 0:
designator = "" break
pattern = "" quote_end = text.find('"', quote + 1)
value = "" if quote_end < 0:
pins: list[tuple[str, str | None]] = [] # (pin_id, net_name) break
lib_name = text[quote + 1 : quote_end]
while i < len(lines): fp_end = _find_matching_paren(text, fp_start)
ln = lines[i] if fp_end < 0:
if ln.strip().upper() == "ENDCOMP": break
i += 1 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 break
# Designator: first token on first line after COMP that isn't PATTERN/VALUE/PIN/PINNE quote = rest.find('"', pad_start + 5)
if not designator and ln.strip(): if quote < 0:
parts = ln.strip().split() break
if parts: quote_end = rest.find('"', quote + 1)
candidate = parts[0] if quote_end < 0:
if not re.match(r"^(PATTERN|VALUE|PIN|PINNE|ENDCOMP)$", candidate, re.I): break
designator = candidate pad_num = rest[quote + 1 : quote_end]
# PATTERN = value or PATTERN value depth = 0
m = re.match(r"^\s*PATTERN\s+(.+)$", ln, re.I) end = pad_start + 4
if m: for i, c in enumerate(rest[pad_start:], start=pad_start):
pattern = m.group(1).strip().strip('"') if c == "(":
m = re.match(r"^\s*VALUE\s+(.+)$", ln, re.I) depth += 1
if m: elif c == ")":
value = m.group(1).strip().strip('"') depth -= 1
# PIN: PIN <name> [net] ... or PINNE <name> <net> ... (numbers follow) if depth == 0:
pin_match = re.match(r"^\s*(?:PINNE|PIN)\s+(\S+)\s+(\S+)", ln, re.I) end = i
if pin_match: break
pin_name, second = pin_match.groups() block = rest[pad_start:end]
net_name = second if not second.replace(".", "").isdigit() else None net_m = re.search(r'\(\s*net\s+(\d+)\s+"([^"]*)"\s*\)', block)
if net_name and net_name.upper() in ("PINNE", "PIN"): if net_m:
net_name = None net_name = net_m.group(2) or net_by_num.get(int(net_m.group(1)), "")
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: else:
pin_simple = re.match(r"^\s*(?:PINNE|PIN)\s+(\S+)", ln, re.I) net_name = net_by_num.get(0, "") if 0 in net_by_num else None
if pin_simple: pins.append((pad_num, net_name or None))
pn = pin_simple.group(1) pos = end + 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: if designator:
components.append({ components.append({
"designator": designator, "designator": designator,
@@ -175,7 +161,8 @@ def parse_protel_ascii(path: str) -> tuple[list[dict], dict]:
"value": value or "?", "value": value or "?",
"pins": pins, "pins": pins,
}) })
return components, pin_to_net fp_pos = fp_end + 1
return components, {}
def build_net_key(net1: str, net2: str) -> str: 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_input = os.environ.get("INPUT_FILE", "").strip() or None
default_output = os.environ.get("OUTPUT_FILE", "").strip() or "outputs/capacitors_by_net_pair.json" 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( parser.add_argument(
"file", "file",
nargs="?", nargs="?",
default=default_input, 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( parser.add_argument(
"-o", "--output", "-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.") parser.error("No input file. Set INPUT_FILE in .env or pass the file path as an argument.")
try: try:
components, pin_to_net = parse_protel_ascii(input_path) components, pin_to_net = parse_kicad_pcb(input_path)
except Exception as e: except Exception as e:
print(f"Parse error: {e}", file=sys.stderr) print(f"Parse error: {e}", file=sys.stderr)
return 1 return 1

View File

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