kicad-mcp/kicad_mcp/utils/symbol_library.py
Ryan Malloy bc0f3db97c
Some checks are pending
CI / Lint and Format (push) Waiting to run
CI / Test Python 3.11 on macos-latest (push) Waiting to run
CI / Test Python 3.12 on macos-latest (push) Waiting to run
CI / Test Python 3.13 on macos-latest (push) Waiting to run
CI / Test Python 3.10 on ubuntu-latest (push) Waiting to run
CI / Test Python 3.11 on ubuntu-latest (push) Waiting to run
CI / Test Python 3.12 on ubuntu-latest (push) Waiting to run
CI / Test Python 3.13 on ubuntu-latest (push) Waiting to run
CI / Security Scan (push) Waiting to run
CI / Build Package (push) Blocked by required conditions
Implement comprehensive AI/LLM integration for KiCad MCP server
Add intelligent analysis and recommendation tools for KiCad designs:

## New AI Tools (kicad_mcp/tools/ai_tools.py)
- suggest_components_for_circuit: Smart component suggestions based on circuit analysis
- recommend_design_rules: Automated design rule recommendations for different technologies
- optimize_pcb_layout: PCB layout optimization for signal integrity, thermal, and cost
- analyze_design_completeness: Comprehensive design completeness analysis

## Enhanced Utilities
- component_utils.py: Add ComponentType enum and component classification functions
- pattern_recognition.py: Enhanced circuit pattern analysis and recommendations
- netlist_parser.py: Implement missing parse_netlist_file function for AI tools

## Key Features
- Circuit pattern recognition for power supplies, amplifiers, microcontrollers
- Technology-specific design rules (standard, HDI, RF, automotive)
- Layout optimization suggestions with implementation steps
- Component suggestion system with standard values and examples
- Design completeness scoring with actionable recommendations

## Server Integration
- Register AI tools in FastMCP server
- Integrate with existing KiCad utilities and file parsers
- Error handling and graceful fallbacks for missing data

Fixes ImportError that prevented server startup and enables advanced
AI-powered design assistance for KiCad projects.

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

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

545 lines
19 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.
"""
from dataclasses import dataclass
import logging
import os
import re
from typing import Any
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: str | None = 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, 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) -> Symbol | None:
"""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) -> str | None:
"""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()