Files
kicad-lib/scripts/gen_resistors_0402.py
Brent Perteet 278b2db158 Use descriptive names for generated resistor symbols (R0402_4.7K_1%)
Symbols are now named by value+tolerance instead of internal GLE P/N,
e.g. R0402_4.7K_1%, R0402_100_5%, R0402_10.0K_0.1%.

- Add make_symbol_name() to normalise value suffix (k→K) and extract
  tolerance via regex from the Description column
- Deduplicate by sym_name: where two GLE P/Ns share the same
  value+tolerance (alternate approved vendors), keep the first row
  and report the 10 skipped duplicates at runtime
- UMPN field still carries the GLE P/N of the primary vendor

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 15:40:44 +00:00

339 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
gen_resistors_0402.py
=====================
Reads the approved parts list spreadsheet and generates KiCad 9 symbols
for every 0402 resistor into kicad-lib/symbols/Res_0402.kicad_sym.
The existing R_temp template symbol is kept in the file. One new symbol
is added per unique value+tolerance combination, named descriptively, e.g.:
R0402_4.7K_1%
R0402_100_5%
R0402_10.0K_0.1%
Symbol fields:
Value ← Value1 (e.g. "10k", "4.7k", "100")
Description ← Description column
UMPN ← GLE P/N (internal part number of first approved vendor)
MFG ← Mfg.1
MPN ← Mfg.1 P/N
Where multiple approved vendors share the same value+tolerance, only the
first row is used (the duplicate rows are reported and skipped).
All geometry and pin definitions are copied verbatim from R_temp.
Usage:
python3 gen_resistors_0402.py <parts_list.xlsx> <Res_0402.kicad_sym>
"""
import re
import sys
import pandas as pd
from pathlib import Path
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def kicad_str(value: str) -> str:
"""Escape a string for use inside a KiCad S-expression quoted string."""
return str(value).replace('\\', '\\\\').replace('"', '\\"')
def make_symbol_name(value: str, description: str) -> str:
"""
Build a descriptive KiCad symbol name, e.g. 'R0402_4.7K_1%'.
value the Value1 column (e.g. '4.7k', '100', '10.0k')
description the Description column (e.g. 'Resistor, 0402, 1%, 4.7k')
The value suffix (k, m, g) is uppercased. Tolerance is extracted
with a regex that matches patterns like '1%', '0.1%', '5%'.
"""
# Uppercase trailing unit letter(s): 4.7k → 4.7K, 10.0k → 10.0K
value_norm = re.sub(r'([a-zA-Z]+)$', lambda m: m.group(1).upper(), value.strip())
# Extract tolerance from description
tol_match = re.search(r'(\d+(?:\.\d+)?%)', description)
tolerance = tol_match.group(1) if tol_match else 'X'
return f'R0402_{value_norm}_{tolerance}'
def make_symbol(name: str, value: str, description: str,
umpn: str, mfg: str, mpn: str,
geometry_body: str) -> str:
"""
Render a complete (symbol ...) block.
geometry_body is the text of the two inner sub-symbols
(originally named R_temp_0_1 and R_temp_1_1) with the name
already substituted.
"""
return f'''\t(symbol "{kicad_str(name)}"
\t\t(pin_numbers
\t\t\t(hide yes)
\t\t)
\t\t(pin_names
\t\t\t(offset 0)
\t\t)
\t\t(exclude_from_sim no)
\t\t(in_bom yes)
\t\t(on_board yes)
\t\t(property "Reference" "R"
\t\t\t(at 2.54 0 90)
\t\t\t(effects
\t\t\t\t(font
\t\t\t\t\t(size 1.27 1.27)
\t\t\t\t)
\t\t\t)
\t\t)
\t\t(property "Value" "{kicad_str(value)}"
\t\t\t(at -2.54 0 90)
\t\t\t(effects
\t\t\t\t(font
\t\t\t\t\t(size 1.27 1.27)
\t\t\t\t)
\t\t\t)
\t\t)
\t\t(property "Footprint" "Resistor_SMD:R_0402_1005Metric"
\t\t\t(at 1.016 -0.254 90)
\t\t\t(effects
\t\t\t\t(font
\t\t\t\t\t(size 1.27 1.27)
\t\t\t\t)
\t\t\t\t(hide yes)
\t\t\t)
\t\t)
\t\t(property "Datasheet" "~"
\t\t\t(at 0 0 0)
\t\t\t(effects
\t\t\t\t(font
\t\t\t\t\t(size 1.27 1.27)
\t\t\t\t)
\t\t\t\t(hide yes)
\t\t\t)
\t\t)
\t\t(property "Description" "{kicad_str(description)}"
\t\t\t(at 0 0 0)
\t\t\t(effects
\t\t\t\t(font
\t\t\t\t\t(size 1.27 1.27)
\t\t\t\t)
\t\t\t\t(hide yes)
\t\t\t)
\t\t)
\t\t(property "MPN" "{kicad_str(mpn)}"
\t\t\t(at 0 0 0)
\t\t\t(effects
\t\t\t\t(font
\t\t\t\t\t(size 1.27 1.27)
\t\t\t\t)
\t\t\t\t(hide yes)
\t\t\t)
\t\t)
\t\t(property "MFG" "{kicad_str(mfg)}"
\t\t\t(at 0 0 0)
\t\t\t(effects
\t\t\t\t(font
\t\t\t\t\t(size 1.27 1.27)
\t\t\t\t)
\t\t\t\t(hide yes)
\t\t\t)
\t\t)
\t\t(property "UMPN" "{kicad_str(umpn)}"
\t\t\t(at 0 0 0)
\t\t\t(effects
\t\t\t\t(font
\t\t\t\t\t(size 1.27 1.27)
\t\t\t\t)
\t\t\t\t(hide yes)
\t\t\t)
\t\t)
\t\t(property "ki_keywords" "R res resistor"
\t\t\t(at 0 0 0)
\t\t\t(effects
\t\t\t\t(font
\t\t\t\t\t(size 1.27 1.27)
\t\t\t\t)
\t\t\t\t(hide yes)
\t\t\t)
\t\t)
\t\t(property "ki_fp_filters" "R_*"
\t\t\t(at 0 0 0)
\t\t\t(effects
\t\t\t\t(font
\t\t\t\t\t(size 1.27 1.27)
\t\t\t\t)
\t\t\t\t(hide yes)
\t\t\t)
\t\t)
{geometry_body}
\t\t(embedded_fonts no)
\t)'''
# ---------------------------------------------------------------------------
# Extract geometry from R_temp
# ---------------------------------------------------------------------------
def extract_geometry(sym_text: str) -> str:
"""
Pull out the two inner sub-symbol blocks (R_temp_0_1 and R_temp_1_1)
from the raw file text and return them as a single string with the
'R_temp' name prefix replaced by a placeholder that callers substitute.
"""
# Match (symbol "R_temp_0_1" ...) and (symbol "R_temp_1_1" ...)
# by tracking brace depth after the opening paren of each sub-symbol.
results = []
for sub in ('R_temp_0_1', 'R_temp_1_1'):
pattern = f'(symbol "{sub}"'
start = sym_text.find(pattern)
if start == -1:
raise ValueError(f"Could not find sub-symbol '{sub}' in template")
# Walk forward to find the matching closing paren
depth = 0
i = start
while i < len(sym_text):
if sym_text[i] == '(':
depth += 1
elif sym_text[i] == ')':
depth -= 1
if depth == 0:
results.append(sym_text[start:i + 1])
break
i += 1
return '\n'.join(results)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main(xlsx_path: Path, sym_path: Path):
# ---- Load spreadsheet ----
df = pd.read_excel(xlsx_path, sheet_name='PCB', dtype=str)
df = df.fillna('')
# Filter to 0402 resistors
mask = (
df['Footprint'].str.contains('0402', na=False) &
df['Description'].str.contains('[Rr]es', na=False, regex=True)
)
resistors = df[mask].copy()
print(f"Found {len(resistors)} 0402 resistors in parts list")
# ---- Read existing symbol file ----
existing = sym_path.read_text(encoding='utf-8')
# Extract geometry from R_temp once
raw_geom = extract_geometry(existing)
# ---- Build new symbols ----
new_symbols = []
skipped = []
seen_names: dict[str, str] = {} # sym_name → GLE P/N of first occurrence
for _, row in resistors.iterrows():
gle_pn = str(row['GLE P/N']).strip()
value = str(row['Value1']).strip()
description = str(row['Description']).strip()
mfg = str(row['Mfg.1']).strip()
mpn = str(row['Mfg.1 P/N']).strip()
if not gle_pn:
skipped.append(('(no GLE P/N)', value))
continue
# Build descriptive symbol name, e.g. "R0402_4.7K_1%"
sym_name = make_symbol_name(value, description)
# Skip duplicate value+tolerance combinations (alternate approved vendors)
if sym_name in seen_names:
skipped.append((sym_name, f'dup of GLE {seen_names[sym_name]} (this: {gle_pn})'))
continue
seen_names[sym_name] = gle_pn
# Substitute R_temp → this symbol's name in the geometry block.
# Also re-indent so sub-symbol opening parens align with the template.
geom = raw_geom.replace('R_temp_0_1', f'{sym_name}_0_1') \
.replace('R_temp_1_1', f'{sym_name}_1_1')
# Each sub-symbol block starts at column 0 after extract; add two tabs.
geom = '\n'.join('\t\t' + line if line.startswith('(symbol ') else line
for line in geom.splitlines())
sym = make_symbol(
name=sym_name,
value=value,
description=description,
umpn=gle_pn,
mfg=mfg,
mpn=mpn,
geometry_body=geom,
)
new_symbols.append(sym)
dups = [(n, r) for n, r in skipped if n != '(no GLE P/N)']
no_pn = [(n, r) for n, r in skipped if n == '(no GLE P/N)']
print(f"Generated {len(new_symbols)} symbols "
f"({len(dups)} duplicate value/tol skipped, {len(no_pn)} missing GLE P/N)")
if dups:
print(" Skipped duplicates:")
for name, reason in dups:
print(f" {name}: {reason}")
# ---- Rebuild file from scratch (idempotent) ----
# Extract the library header lines (everything before the first symbol)
# and keep only the R_temp template symbol, then append generated symbols.
header_end = existing.find('\n\t(symbol "R_temp"')
if header_end == -1:
raise ValueError("Could not find R_temp template in symbol file")
header = existing[:header_end]
# Extract R_temp block by brace depth
rt_start = existing.index('\n\t(symbol "R_temp"')
depth, i = 0, rt_start + 1 # skip leading newline
while i < len(existing):
if existing[i] == '(':
depth += 1
elif existing[i] == ')':
depth -= 1
if depth == 0:
rt_block = existing[rt_start:i + 1]
break
i += 1
lib_body = header + rt_block + '\n' + '\n'.join(new_symbols) + '\n)'
# Back up original
backup = sym_path.with_suffix('.bak')
backup.write_text(existing, encoding='utf-8')
print(f"Backup written to {backup.name}")
sym_path.write_text(lib_body, encoding='utf-8')
print(f"Wrote {sym_path}")
# Quick sanity check
symbol_count = lib_body.count('\n\t(symbol "')
print(f"Total symbols in file (including R_temp): {symbol_count}")
if __name__ == '__main__':
if len(sys.argv) == 3:
xlsx = Path(sys.argv[1])
sym = Path(sys.argv[2])
else:
# Default paths relative to repo root
repo = Path(__file__).parent.parent
xlsx = repo.parent / 'parts_list_pcb.xlsx'
sym = repo / 'symbols' / 'Res_0402.kicad_sym'
if not xlsx.exists():
print(f"Error: spreadsheet not found at {xlsx}", file=sys.stderr)
sys.exit(1)
if not sym.exists():
print(f"Error: symbol file not found at {sym}", file=sys.stderr)
sys.exit(1)
main(xlsx, sym)