kicad-mcp/kicad_mcp/utils/symbol_library.py
Ryan Malloy 995dfd57c1 Add comprehensive advanced KiCad features and fix MCP compatibility issues
- Implement 3D model analysis and mechanical constraints checking
- Add advanced DRC rule customization for HDI, RF, and automotive applications
- Create symbol library management with analysis and validation tools
- Implement PCB layer stack-up analysis with impedance calculations
- Fix Context parameter validation errors causing client failures
- Add enhanced tool annotations with examples for better LLM compatibility
- Include comprehensive test coverage improvements (22.21% coverage)
- Add CLAUDE.md documentation for development guidance

New Advanced Tools:
• 3D model analysis: analyze_3d_models, check_mechanical_constraints
• Advanced DRC: create_drc_rule_set, analyze_pcb_drc_violations
• Symbol management: analyze_symbol_library, validate_symbol_library
• Layer analysis: analyze_pcb_stackup, calculate_trace_impedance

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-11 15:57:46 -06:00

545 lines
20 KiB
Python

"""
Symbol Library Management utilities for KiCad.
Provides functionality to analyze, manage, and manipulate KiCad symbol libraries
including library validation, symbol extraction, and library organization.
"""
import json
import os
import re
from dataclasses import dataclass
from typing import Dict, List, Optional, Any, Tuple
import logging
logger = logging.getLogger(__name__)
@dataclass
class SymbolPin:
"""Represents a symbol pin with electrical and geometric properties."""
number: str
name: str
position: Tuple[float, float]
orientation: str # "L", "R", "U", "D"
electrical_type: str # "input", "output", "bidirectional", "power_in", etc.
graphic_style: str # "line", "inverted", "clock", etc.
length: float = 2.54 # Default pin length in mm
@dataclass
class SymbolProperty:
"""Symbol property like reference, value, footprint, etc."""
name: str
value: str
position: Tuple[float, float]
rotation: float = 0.0
visible: bool = True
justify: str = "left"
@dataclass
class SymbolGraphics:
"""Graphical elements of a symbol."""
rectangles: List[Dict[str, Any]]
circles: List[Dict[str, Any]]
arcs: List[Dict[str, Any]]
polylines: List[Dict[str, Any]]
text: List[Dict[str, Any]]
@dataclass
class Symbol:
"""Represents a KiCad symbol with all its properties."""
name: str
library_id: str
description: str
keywords: List[str]
pins: List[SymbolPin]
properties: List[SymbolProperty]
graphics: SymbolGraphics
footprint_filters: List[str]
aliases: List[str] = None
power_symbol: bool = False
extends: Optional[str] = None # For derived symbols
@dataclass
class SymbolLibrary:
"""Represents a KiCad symbol library (.kicad_sym file)."""
name: str
file_path: str
version: str
symbols: List[Symbol]
metadata: Dict[str, Any]
class SymbolLibraryAnalyzer:
"""Analyzer for KiCad symbol libraries."""
def __init__(self):
"""Initialize the symbol library analyzer."""
self.libraries = {}
self.symbol_cache = {}
def load_library(self, library_path: str) -> SymbolLibrary:
"""Load a KiCad symbol library file."""
try:
with open(library_path, 'r', encoding='utf-8') as f:
content = f.read()
# Parse library header
library_name = os.path.basename(library_path).replace('.kicad_sym', '')
version = self._extract_version(content)
# Parse symbols
symbols = self._parse_symbols(content)
library = SymbolLibrary(
name=library_name,
file_path=library_path,
version=version,
symbols=symbols,
metadata=self._extract_metadata(content)
)
self.libraries[library_name] = library
logger.info(f"Loaded library '{library_name}' with {len(symbols)} symbols")
return library
except Exception as e:
logger.error(f"Failed to load library {library_path}: {e}")
raise
def _extract_version(self, content: str) -> str:
"""Extract version from library content."""
version_match = re.search(r'\(version\s+(\d+)\)', content)
return version_match.group(1) if version_match else "unknown"
def _extract_metadata(self, content: str) -> Dict[str, Any]:
"""Extract library metadata."""
metadata = {}
# Extract generator info
generator_match = re.search(r'\(generator\s+"([^"]+)"\)', content)
if generator_match:
metadata["generator"] = generator_match.group(1)
return metadata
def _parse_symbols(self, content: str) -> List[Symbol]:
"""Parse symbols from library content."""
symbols = []
# Find all symbol definitions
symbol_pattern = r'\(symbol\s+"([^"]+)"[^)]*\)'
symbol_matches = []
# Use a more sophisticated parser to handle nested parentheses
level = 0
current_symbol = None
symbol_start = 0
for i, char in enumerate(content):
if char == '(':
if level == 0 and content[i:i+8] == '(symbol ':
symbol_start = i
level += 1
elif char == ')':
level -= 1
if level == 0 and current_symbol is not None:
symbol_content = content[symbol_start:i+1]
symbol = self._parse_single_symbol(symbol_content)
if symbol:
symbols.append(symbol)
current_symbol = None
# Check if we're starting a symbol
if level == 1 and content[i:i+8] == '(symbol ' and current_symbol is None:
# Extract symbol name
name_match = re.search(r'\(symbol\s+"([^"]+)"', content[i:i+100])
if name_match:
current_symbol = name_match.group(1)
logger.info(f"Parsed {len(symbols)} symbols from library")
return symbols
def _parse_single_symbol(self, symbol_content: str) -> Optional[Symbol]:
"""Parse a single symbol definition."""
try:
# Extract symbol name
name_match = re.search(r'\(symbol\s+"([^"]+)"', symbol_content)
if not name_match:
return None
name = name_match.group(1)
# Parse basic properties
description = self._extract_property(symbol_content, "description") or ""
keywords = self._extract_keywords(symbol_content)
# Parse pins
pins = self._parse_pins(symbol_content)
# Parse properties
properties = self._parse_properties(symbol_content)
# Parse graphics
graphics = self._parse_graphics(symbol_content)
# Parse footprint filters
footprint_filters = self._parse_footprint_filters(symbol_content)
# Check if it's a power symbol
power_symbol = "(power)" in symbol_content
# Check for extends (derived symbols)
extends_match = re.search(r'\(extends\s+"([^"]+)"\)', symbol_content)
extends = extends_match.group(1) if extends_match else None
return Symbol(
name=name,
library_id=name, # Will be updated with library prefix
description=description,
keywords=keywords,
pins=pins,
properties=properties,
graphics=graphics,
footprint_filters=footprint_filters,
aliases=[],
power_symbol=power_symbol,
extends=extends
)
except Exception as e:
logger.error(f"Failed to parse symbol: {e}")
return None
def _extract_property(self, content: str, prop_name: str) -> Optional[str]:
"""Extract a property value from symbol content."""
pattern = f'\\(property\\s+"{prop_name}"\\s+"([^"]*)"'
match = re.search(pattern, content)
return match.group(1) if match else None
def _extract_keywords(self, content: str) -> List[str]:
"""Extract keywords from symbol content."""
keywords_match = re.search(r'\(keywords\s+"([^"]*)"\)', content)
if keywords_match:
return [k.strip() for k in keywords_match.group(1).split() if k.strip()]
return []
def _parse_pins(self, content: str) -> List[SymbolPin]:
"""Parse pins from symbol content."""
pins = []
# Pin pattern - matches KiCad 6+ format
pin_pattern = r'\(pin\s+(\w+)\s+(\w+)\s+\(at\s+([-\d.]+)\s+([-\d.]+)\s+(\d+)\)\s+\(length\s+([-\d.]+)\)[^)]*\(name\s+"([^"]*)"\s+[^)]*\)\s+\(number\s+"([^"]*)"\s+[^)]*\)'
for match in re.finditer(pin_pattern, content):
electrical_type = match.group(1)
graphic_style = match.group(2)
x = float(match.group(3))
y = float(match.group(4))
orientation_angle = int(match.group(5))
length = float(match.group(6))
pin_name = match.group(7)
pin_number = match.group(8)
# Convert angle to orientation
orientation_map = {0: "R", 90: "U", 180: "L", 270: "D"}
orientation = orientation_map.get(orientation_angle, "R")
pin = SymbolPin(
number=pin_number,
name=pin_name,
position=(x, y),
orientation=orientation,
electrical_type=electrical_type,
graphic_style=graphic_style,
length=length
)
pins.append(pin)
return pins
def _parse_properties(self, content: str) -> List[SymbolProperty]:
"""Parse symbol properties."""
properties = []
# Property pattern
prop_pattern = r'\(property\s+"([^"]+)"\s+"([^"]*)"\s+\(at\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\)'
for match in re.finditer(prop_pattern, content):
name = match.group(1)
value = match.group(2)
x = float(match.group(3))
y = float(match.group(4))
rotation = float(match.group(5))
prop = SymbolProperty(
name=name,
value=value,
position=(x, y),
rotation=rotation
)
properties.append(prop)
return properties
def _parse_graphics(self, content: str) -> SymbolGraphics:
"""Parse graphical elements from symbol."""
rectangles = []
circles = []
arcs = []
polylines = []
text = []
# Parse rectangles
rect_pattern = r'\(rectangle\s+\(start\s+([-\d.]+)\s+([-\d.]+)\)\s+\(end\s+([-\d.]+)\s+([-\d.]+)\)'
for match in re.finditer(rect_pattern, content):
rectangles.append({
"start": (float(match.group(1)), float(match.group(2))),
"end": (float(match.group(3)), float(match.group(4)))
})
# Parse circles
circle_pattern = r'\(circle\s+\(center\s+([-\d.]+)\s+([-\d.]+)\)\s+\(radius\s+([-\d.]+)\)'
for match in re.finditer(circle_pattern, content):
circles.append({
"center": (float(match.group(1)), float(match.group(2))),
"radius": float(match.group(3))
})
# Parse polylines (simplified)
poly_pattern = r'\(polyline[^)]*\(pts[^)]+\)'
polylines = [{"data": match.group(0)} for match in re.finditer(poly_pattern, content)]
return SymbolGraphics(
rectangles=rectangles,
circles=circles,
arcs=arcs,
polylines=polylines,
text=text
)
def _parse_footprint_filters(self, content: str) -> List[str]:
"""Parse footprint filters from symbol."""
filters = []
# Look for footprint filter section
fp_filter_match = re.search(r'\(fp_filters[^)]*\)', content, re.DOTALL)
if fp_filter_match:
filter_content = fp_filter_match.group(0)
filter_pattern = r'"([^"]+)"'
filters = [match.group(1) for match in re.finditer(filter_pattern, filter_content)]
return filters
def analyze_library_coverage(self, library: SymbolLibrary) -> Dict[str, Any]:
"""Analyze symbol library coverage and statistics."""
analysis = {
"total_symbols": len(library.symbols),
"categories": {},
"electrical_types": {},
"pin_counts": {},
"missing_properties": [],
"duplicate_symbols": [],
"unused_symbols": [],
"statistics": {}
}
# Analyze by categories (based on keywords/names)
categories = {}
electrical_types = {}
pin_counts = {}
for symbol in library.symbols:
# Categorize by keywords
for keyword in symbol.keywords:
categories[keyword] = categories.get(keyword, 0) + 1
# Count pin types
for pin in symbol.pins:
electrical_types[pin.electrical_type] = electrical_types.get(pin.electrical_type, 0) + 1
# Pin count distribution
pin_count = len(symbol.pins)
pin_counts[pin_count] = pin_counts.get(pin_count, 0) + 1
# Check for missing essential properties
essential_props = ["Reference", "Value", "Footprint"]
symbol_props = [p.name for p in symbol.properties]
for prop in essential_props:
if prop not in symbol_props:
analysis["missing_properties"].append({
"symbol": symbol.name,
"missing_property": prop
})
analysis.update({
"categories": categories,
"electrical_types": electrical_types,
"pin_counts": pin_counts,
"statistics": {
"avg_pins_per_symbol": sum(pin_counts.keys()) / len(library.symbols) if library.symbols else 0,
"most_common_category": max(categories.items(), key=lambda x: x[1])[0] if categories else None,
"symbols_with_footprint_filters": len([s for s in library.symbols if s.footprint_filters]),
"power_symbols": len([s for s in library.symbols if s.power_symbol])
}
})
return analysis
def find_similar_symbols(self, symbol: Symbol, library: SymbolLibrary,
threshold: float = 0.7) -> List[Tuple[Symbol, float]]:
"""Find symbols similar to the given symbol."""
similar = []
for candidate in library.symbols:
if candidate.name == symbol.name:
continue
similarity = self._calculate_symbol_similarity(symbol, candidate)
if similarity >= threshold:
similar.append((candidate, similarity))
return sorted(similar, key=lambda x: x[1], reverse=True)
def _calculate_symbol_similarity(self, symbol1: Symbol, symbol2: Symbol) -> float:
"""Calculate similarity score between two symbols."""
score = 0.0
factors = 0
# Pin count similarity
if symbol1.pins and symbol2.pins:
pin_diff = abs(len(symbol1.pins) - len(symbol2.pins))
max_pins = max(len(symbol1.pins), len(symbol2.pins))
pin_similarity = 1.0 - (pin_diff / max_pins) if max_pins > 0 else 1.0
score += pin_similarity * 0.4
factors += 0.4
# Keyword similarity
keywords1 = set(symbol1.keywords)
keywords2 = set(symbol2.keywords)
if keywords1 or keywords2:
keyword_intersection = len(keywords1.intersection(keywords2))
keyword_union = len(keywords1.union(keywords2))
keyword_similarity = keyword_intersection / keyword_union if keyword_union > 0 else 0.0
score += keyword_similarity * 0.3
factors += 0.3
# Name similarity (simple string comparison)
name_similarity = self._string_similarity(symbol1.name, symbol2.name)
score += name_similarity * 0.3
factors += 0.3
return score / factors if factors > 0 else 0.0
def _string_similarity(self, str1: str, str2: str) -> float:
"""Calculate string similarity using simple character overlap."""
if not str1 or not str2:
return 0.0
str1_lower = str1.lower()
str2_lower = str2.lower()
# Simple character-based similarity
intersection = len(set(str1_lower).intersection(set(str2_lower)))
union = len(set(str1_lower).union(set(str2_lower)))
return intersection / union if union > 0 else 0.0
def validate_symbol(self, symbol: Symbol) -> List[str]:
"""Validate a symbol and return list of issues."""
issues = []
# Check for essential properties
prop_names = [p.name for p in symbol.properties]
essential_props = ["Reference", "Value"]
for prop in essential_props:
if prop not in prop_names:
issues.append(f"Missing essential property: {prop}")
# Check pin consistency
pin_numbers = [p.number for p in symbol.pins]
if len(pin_numbers) != len(set(pin_numbers)):
issues.append("Duplicate pin numbers found")
# Check for pins without names
unnamed_pins = [p.number for p in symbol.pins if not p.name]
if unnamed_pins:
issues.append(f"Pins without names: {', '.join(unnamed_pins)}")
# Validate electrical types
valid_types = ["input", "output", "bidirectional", "tri_state", "passive",
"free", "unspecified", "power_in", "power_out", "open_collector",
"open_emitter", "no_connect"]
for pin in symbol.pins:
if pin.electrical_type not in valid_types:
issues.append(f"Invalid electrical type '{pin.electrical_type}' for pin {pin.number}")
return issues
def export_symbol_report(self, library: SymbolLibrary) -> Dict[str, Any]:
"""Export a comprehensive symbol library report."""
analysis = self.analyze_library_coverage(library)
# Add validation results
validation_results = []
for symbol in library.symbols:
issues = self.validate_symbol(symbol)
if issues:
validation_results.append({
"symbol": symbol.name,
"issues": issues
})
return {
"library_info": {
"name": library.name,
"file_path": library.file_path,
"version": library.version,
"total_symbols": len(library.symbols)
},
"analysis": analysis,
"validation": {
"total_issues": len(validation_results),
"symbols_with_issues": len(validation_results),
"issues_by_symbol": validation_results
},
"recommendations": self._generate_recommendations(library, analysis, validation_results)
}
def _generate_recommendations(self, library: SymbolLibrary,
analysis: Dict[str, Any],
validation_results: List[Dict[str, Any]]) -> List[str]:
"""Generate recommendations for library improvement."""
recommendations = []
# Check for missing footprint filters
no_filters = [s for s in library.symbols if not s.footprint_filters]
if len(no_filters) > len(library.symbols) * 0.5:
recommendations.append("Consider adding footprint filters to more symbols for better component matching")
# Check for validation issues
if validation_results:
recommendations.append(f"Address {len(validation_results)} symbols with validation issues")
# Check pin distribution
if analysis["statistics"]["avg_pins_per_symbol"] > 50:
recommendations.append("Library contains many high-pin-count symbols - consider splitting complex symbols")
# Check category distribution
if len(analysis["categories"]) < 5:
recommendations.append("Consider adding more keyword categories for better symbol organization")
return recommendations
def create_symbol_analyzer() -> SymbolLibraryAnalyzer:
"""Create and initialize a symbol library analyzer."""
return SymbolLibraryAnalyzer()