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
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>
545 lines
19 KiB
Python
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()
|