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>
339 lines
10 KiB
Python
339 lines
10 KiB
Python
#!/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)
|