#!/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 """ 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)