kicad-mcp/kicad_mcp/utils/layer_stackup.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

559 lines
23 KiB
Python

"""
PCB Layer Stack-up Analysis utilities for KiCad.
Provides functionality to analyze PCB layer configurations, impedance calculations,
manufacturing constraints, and design rule validation for multi-layer boards.
"""
from dataclasses import dataclass
import logging
import math
import re
from typing import Any
logger = logging.getLogger(__name__)
@dataclass
class LayerDefinition:
"""Represents a single layer in the PCB stack-up."""
name: str
layer_type: str # "signal", "power", "ground", "dielectric", "soldermask", "silkscreen"
thickness: float # in mm
material: str
dielectric_constant: float | None = None
loss_tangent: float | None = None
copper_weight: float | None = None # in oz (for copper layers)
layer_number: int | None = None
kicad_layer_id: str | None = None
@dataclass
class ImpedanceCalculation:
"""Impedance calculation results for a trace configuration."""
trace_width: float
trace_spacing: float | None # For differential pairs
impedance_single: float | None
impedance_differential: float | None
layer_name: str
reference_layers: list[str]
calculation_method: str
@dataclass
class StackupConstraints:
"""Manufacturing and design constraints for the stack-up."""
min_trace_width: float
min_via_drill: float
min_annular_ring: float
aspect_ratio_limit: float
dielectric_thickness_limits: tuple[float, float]
copper_weight_options: list[float]
layer_count_limit: int
@dataclass
class LayerStackup:
"""Complete PCB layer stack-up definition."""
name: str
layers: list[LayerDefinition]
total_thickness: float
layer_count: int
impedance_calculations: list[ImpedanceCalculation]
constraints: StackupConstraints
manufacturing_notes: list[str]
class LayerStackupAnalyzer:
"""Analyzer for PCB layer stack-up configurations."""
def __init__(self):
"""Initialize the layer stack-up analyzer."""
self.standard_materials = self._load_standard_materials()
self.impedance_calculator = ImpedanceCalculator()
def _load_standard_materials(self) -> dict[str, dict[str, Any]]:
"""Load standard PCB materials database."""
return {
"FR4_Standard": {
"dielectric_constant": 4.35,
"loss_tangent": 0.02,
"description": "Standard FR4 epoxy fiberglass"
},
"FR4_High_Tg": {
"dielectric_constant": 4.2,
"loss_tangent": 0.015,
"description": "High Tg FR4 for lead-free soldering"
},
"Rogers_4003C": {
"dielectric_constant": 3.38,
"loss_tangent": 0.0027,
"description": "Rogers low-loss hydrocarbon ceramic"
},
"Rogers_4350B": {
"dielectric_constant": 3.48,
"loss_tangent": 0.0037,
"description": "Rogers woven glass reinforced hydrocarbon"
},
"Polyimide": {
"dielectric_constant": 3.5,
"loss_tangent": 0.002,
"description": "Flexible polyimide substrate"
},
"Prepreg_106": {
"dielectric_constant": 4.2,
"loss_tangent": 0.02,
"description": "Standard prepreg 106 glass style"
},
"Prepreg_1080": {
"dielectric_constant": 4.4,
"loss_tangent": 0.02,
"description": "Thick prepreg 1080 glass style"
}
}
def analyze_pcb_stackup(self, pcb_file_path: str) -> LayerStackup:
"""Analyze PCB file and extract layer stack-up information."""
try:
with open(pcb_file_path, encoding='utf-8') as f:
content = f.read()
# Extract layer definitions
layers = self._parse_layers(content)
# Calculate total thickness
total_thickness = sum(layer.thickness for layer in layers if layer.thickness)
# Extract manufacturing constraints
constraints = self._extract_constraints(content)
# Perform impedance calculations
impedance_calcs = self._calculate_impedances(layers, content)
# Generate manufacturing notes
notes = self._generate_manufacturing_notes(layers, total_thickness)
stackup = LayerStackup(
name=f"PCB_Stackup_{len(layers)}_layers",
layers=layers,
total_thickness=total_thickness,
layer_count=len([l for l in layers if l.layer_type in ["signal", "power", "ground"]]),
impedance_calculations=impedance_calcs,
constraints=constraints,
manufacturing_notes=notes
)
logger.info(f"Analyzed {len(layers)}-layer stack-up with {total_thickness:.3f}mm total thickness")
return stackup
except Exception as e:
logger.error(f"Failed to analyze PCB stack-up from {pcb_file_path}: {e}")
raise
def _parse_layers(self, content: str) -> list[LayerDefinition]:
"""Parse layer definitions from PCB content."""
layers = []
# Extract layer setup section
setup_match = re.search(r'\(setup[^)]*\(stackup[^)]*\)', content, re.DOTALL)
if not setup_match:
# Fallback to basic layer extraction
return self._parse_basic_layers(content)
stackup_content = setup_match.group(0)
# Parse individual layers
layer_pattern = r'\(layer\s+"([^"]+)"\s+\(type\s+(\w+)\)\s*(?:\(thickness\s+([\d.]+)\))?\s*(?:\(material\s+"([^"]+)"\))?'
for match in re.finditer(layer_pattern, stackup_content):
layer_name = match.group(1)
layer_type = match.group(2)
thickness = float(match.group(3)) if match.group(3) else None
material = match.group(4) or "Unknown"
# Get material properties
material_props = self.standard_materials.get(material, {})
layer = LayerDefinition(
name=layer_name,
layer_type=layer_type,
thickness=thickness or 0.0,
material=material,
dielectric_constant=material_props.get("dielectric_constant"),
loss_tangent=material_props.get("loss_tangent"),
copper_weight=1.0 if layer_type in ["signal", "power", "ground"] else None
)
layers.append(layer)
# If no stack-up found, create standard layers
if not layers:
layers = self._create_standard_stackup(content)
return layers
def _parse_basic_layers(self, content: str) -> list[LayerDefinition]:
"""Parse basic layer information when detailed stack-up is not available."""
layers = []
# Find layer definitions in PCB
layer_pattern = r'\((\d+)\s+"([^"]+)"\s+(signal|power|user)\)'
found_layers = []
for match in re.finditer(layer_pattern, content):
layer_num = int(match.group(1))
layer_name = match.group(2)
layer_type = match.group(3)
found_layers.append((layer_num, layer_name, layer_type))
found_layers.sort(key=lambda x: x[0]) # Sort by layer number
# Create layer definitions with estimated properties
for i, (layer_num, layer_name, layer_type) in enumerate(found_layers):
# Estimate thickness based on layer type and position
if i == 0 or i == len(found_layers) - 1: # Top/bottom layers
thickness = 0.035 # 35μm copper
else:
thickness = 0.017 # 17μm inner layers
layer = LayerDefinition(
name=layer_name,
layer_type="signal" if layer_type == "signal" else layer_type,
thickness=thickness,
material="Copper",
copper_weight=1.0,
layer_number=layer_num,
kicad_layer_id=str(layer_num)
)
layers.append(layer)
# Add dielectric layer between copper layers (except after last layer)
if i < len(found_layers) - 1:
dielectric_thickness = 0.2 if len(found_layers) <= 4 else 0.1
dielectric = LayerDefinition(
name=f"Dielectric_{i+1}",
layer_type="dielectric",
thickness=dielectric_thickness,
material="FR4_Standard",
dielectric_constant=4.35,
loss_tangent=0.02
)
layers.append(dielectric)
return layers
def _create_standard_stackup(self, content: str) -> list[LayerDefinition]:
"""Create a standard 4-layer stack-up when no stack-up is defined."""
return [
LayerDefinition("Top", "signal", 0.035, "Copper", copper_weight=1.0),
LayerDefinition("Prepreg_1", "dielectric", 0.2, "Prepreg_106",
dielectric_constant=4.2, loss_tangent=0.02),
LayerDefinition("Inner1", "power", 0.017, "Copper", copper_weight=0.5),
LayerDefinition("Core", "dielectric", 1.2, "FR4_Standard",
dielectric_constant=4.35, loss_tangent=0.02),
LayerDefinition("Inner2", "ground", 0.017, "Copper", copper_weight=0.5),
LayerDefinition("Prepreg_2", "dielectric", 0.2, "Prepreg_106",
dielectric_constant=4.2, loss_tangent=0.02),
LayerDefinition("Bottom", "signal", 0.035, "Copper", copper_weight=1.0)
]
def _extract_constraints(self, content: str) -> StackupConstraints:
"""Extract manufacturing constraints from PCB."""
# Default constraints - could be extracted from design rules
return StackupConstraints(
min_trace_width=0.1, # 100μm
min_via_drill=0.2, # 200μm
min_annular_ring=0.05, # 50μm
aspect_ratio_limit=8.0, # 8:1 drill depth to diameter
dielectric_thickness_limits=(0.05, 3.0), # 50μm to 3mm
copper_weight_options=[0.5, 1.0, 2.0], # oz
layer_count_limit=16
)
def _calculate_impedances(self, layers: list[LayerDefinition],
content: str) -> list[ImpedanceCalculation]:
"""Calculate characteristic impedances for signal layers."""
impedance_calcs = []
signal_layers = [l for l in layers if l.layer_type == "signal"]
for signal_layer in signal_layers:
# Find reference layers (adjacent power/ground planes)
ref_layers = self._find_reference_layers(signal_layer, layers)
# Calculate for standard trace widths
for trace_width in [0.1, 0.15, 0.2, 0.25]: # mm
single_ended = self.impedance_calculator.calculate_microstrip_impedance(
trace_width, signal_layer, layers
)
differential = self.impedance_calculator.calculate_differential_impedance(
trace_width, 0.15, signal_layer, layers # 0.15mm spacing
)
impedance_calcs.append(ImpedanceCalculation(
trace_width=trace_width,
trace_spacing=0.15,
impedance_single=single_ended,
impedance_differential=differential,
layer_name=signal_layer.name,
reference_layers=ref_layers,
calculation_method="microstrip"
))
return impedance_calcs
def _find_reference_layers(self, signal_layer: LayerDefinition,
layers: list[LayerDefinition]) -> list[str]:
"""Find reference planes for a signal layer."""
ref_layers = []
signal_idx = layers.index(signal_layer)
# Look for adjacent power/ground layers
for i in range(max(0, signal_idx - 2), min(len(layers), signal_idx + 3)):
if i != signal_idx and layers[i].layer_type in ["power", "ground"]:
ref_layers.append(layers[i].name)
return ref_layers
def _generate_manufacturing_notes(self, layers: list[LayerDefinition],
total_thickness: float) -> list[str]:
"""Generate manufacturing and assembly notes."""
notes = []
copper_layers = len([l for l in layers if l.layer_type in ["signal", "power", "ground"]])
if copper_layers > 8:
notes.append("High layer count may require specialized manufacturing")
if total_thickness > 3.0:
notes.append("Thick board may require extended drill programs")
elif total_thickness < 0.8:
notes.append("Thin board requires careful handling during assembly")
# Check for impedance control requirements
signal_layers = len([l for l in layers if l.layer_type == "signal"])
if signal_layers > 2:
notes.append("Multi-layer design - impedance control recommended")
# Material considerations
materials = set(l.material for l in layers if l.layer_type == "dielectric")
if len(materials) > 1:
notes.append("Mixed dielectric materials - verify thermal expansion compatibility")
return notes
def validate_stackup(self, stackup: LayerStackup) -> list[str]:
"""Validate stack-up for manufacturability and design rules."""
issues = []
# Check layer count
if stackup.layer_count > stackup.constraints.layer_count_limit:
issues.append(f"Layer count {stackup.layer_count} exceeds limit of {stackup.constraints.layer_count_limit}")
# Check total thickness
if stackup.total_thickness > 6.0:
issues.append(f"Total thickness {stackup.total_thickness:.2f}mm may be difficult to manufacture")
# Check for proper reference planes
signal_layers = [l for l in stackup.layers if l.layer_type == "signal"]
power_ground_layers = [l for l in stackup.layers if l.layer_type in ["power", "ground"]]
if len(signal_layers) > 2 and len(power_ground_layers) < 2:
issues.append("Multi-layer design should have dedicated power and ground planes")
# Check dielectric thickness
for layer in stackup.layers:
if layer.layer_type == "dielectric":
if layer.thickness < stackup.constraints.dielectric_thickness_limits[0]:
issues.append(f"Dielectric layer '{layer.name}' thickness {layer.thickness:.3f}mm is too thin")
elif layer.thickness > stackup.constraints.dielectric_thickness_limits[1]:
issues.append(f"Dielectric layer '{layer.name}' thickness {layer.thickness:.3f}mm is too thick")
# Check copper balance
top_copper = sum(l.thickness for l in stackup.layers[:len(stackup.layers)//2] if l.copper_weight)
bottom_copper = sum(l.thickness for l in stackup.layers[len(stackup.layers)//2:] if l.copper_weight)
if abs(top_copper - bottom_copper) / max(top_copper, bottom_copper) > 0.3:
issues.append("Copper distribution is unbalanced - may cause warpage")
return issues
def generate_stackup_report(self, stackup: LayerStackup) -> dict[str, Any]:
"""Generate comprehensive stack-up analysis report."""
validation_issues = self.validate_stackup(stackup)
# Calculate electrical properties
electrical_props = self._calculate_electrical_properties(stackup)
# Generate recommendations
recommendations = self._generate_stackup_recommendations(stackup, validation_issues)
return {
"stackup_info": {
"name": stackup.name,
"layer_count": stackup.layer_count,
"total_thickness_mm": stackup.total_thickness,
"copper_layers": len([l for l in stackup.layers if l.copper_weight]),
"dielectric_layers": len([l for l in stackup.layers if l.layer_type == "dielectric"])
},
"layer_details": [
{
"name": layer.name,
"type": layer.layer_type,
"thickness_mm": layer.thickness,
"material": layer.material,
"dielectric_constant": layer.dielectric_constant,
"loss_tangent": layer.loss_tangent,
"copper_weight_oz": layer.copper_weight
}
for layer in stackup.layers
],
"impedance_analysis": [
{
"layer": imp.layer_name,
"trace_width_mm": imp.trace_width,
"single_ended_ohm": imp.impedance_single,
"differential_ohm": imp.impedance_differential,
"reference_layers": imp.reference_layers
}
for imp in stackup.impedance_calculations
],
"electrical_properties": electrical_props,
"manufacturing": {
"constraints": {
"min_trace_width_mm": stackup.constraints.min_trace_width,
"min_via_drill_mm": stackup.constraints.min_via_drill,
"aspect_ratio_limit": stackup.constraints.aspect_ratio_limit
},
"notes": stackup.manufacturing_notes
},
"validation": {
"issues": validation_issues,
"passed": len(validation_issues) == 0
},
"recommendations": recommendations
}
def _calculate_electrical_properties(self, stackup: LayerStackup) -> dict[str, Any]:
"""Calculate overall electrical properties of the stack-up."""
# Calculate effective dielectric constant
dielectric_layers = [l for l in stackup.layers if l.layer_type == "dielectric" and l.dielectric_constant]
if dielectric_layers:
weighted_dk = sum(l.dielectric_constant * l.thickness for l in dielectric_layers) / sum(l.thickness for l in dielectric_layers)
avg_loss_tangent = sum(l.loss_tangent or 0 for l in dielectric_layers) / len(dielectric_layers)
else:
weighted_dk = 4.35 # Default FR4
avg_loss_tangent = 0.02
return {
"effective_dielectric_constant": weighted_dk,
"average_loss_tangent": avg_loss_tangent,
"total_copper_thickness_mm": sum(l.thickness for l in stackup.layers if l.copper_weight),
"total_dielectric_thickness_mm": sum(l.thickness for l in stackup.layers if l.layer_type == "dielectric")
}
def _generate_stackup_recommendations(self, stackup: LayerStackup,
issues: list[str]) -> list[str]:
"""Generate recommendations for stack-up optimization."""
recommendations = []
if issues:
recommendations.append("Address validation issues before manufacturing")
# Impedance recommendations
impedance_50ohm = [imp for imp in stackup.impedance_calculations if imp.impedance_single and abs(imp.impedance_single - 50) < 5]
if not impedance_50ohm and stackup.impedance_calculations:
recommendations.append("Consider adjusting trace widths to achieve 50Ω characteristic impedance")
# Layer count recommendations
if stackup.layer_count == 2:
recommendations.append("Consider 4-layer stack-up for better signal integrity and power distribution")
elif stackup.layer_count > 8:
recommendations.append("High layer count - ensure proper via management and signal routing")
# Material recommendations
materials = set(l.material for l in stackup.layers if l.layer_type == "dielectric")
if "Rogers" in str(materials) and "FR4" in str(materials):
recommendations.append("Mixed materials detected - verify thermal expansion compatibility")
return recommendations
class ImpedanceCalculator:
"""Calculator for transmission line impedance."""
def calculate_microstrip_impedance(self, trace_width: float, signal_layer: LayerDefinition,
layers: list[LayerDefinition]) -> float | None:
"""Calculate microstrip impedance for a trace."""
try:
# Find the dielectric layer below the signal layer
signal_idx = layers.index(signal_layer)
dielectric = None
for i in range(signal_idx + 1, len(layers)):
if layers[i].layer_type == "dielectric":
dielectric = layers[i]
break
if not dielectric or not dielectric.dielectric_constant:
return None
# Microstrip impedance calculation (simplified)
h = dielectric.thickness # dielectric height
w = trace_width # trace width
er = dielectric.dielectric_constant
# Wheeler's formula for microstrip impedance
if w/h > 1:
z0 = (120 * math.pi) / (math.sqrt(er) * (w/h + 1.393 + 0.667 * math.log(w/h + 1.444)))
else:
z0 = (60 * math.log(8*h/w + w/(4*h))) / math.sqrt(er)
return round(z0, 1)
except (ValueError, ZeroDivisionError, IndexError):
return None
def calculate_differential_impedance(self, trace_width: float, trace_spacing: float,
signal_layer: LayerDefinition,
layers: list[LayerDefinition]) -> float | None:
"""Calculate differential impedance for a trace pair."""
try:
single_ended = self.calculate_microstrip_impedance(trace_width, signal_layer, layers)
if not single_ended:
return None
# Find the dielectric layer below the signal layer
signal_idx = layers.index(signal_layer)
dielectric = None
for i in range(signal_idx + 1, len(layers)):
if layers[i].layer_type == "dielectric":
dielectric = layers[i]
break
if not dielectric:
return None
# Approximate differential impedance calculation
h = dielectric.thickness
w = trace_width
s = trace_spacing
# Coupling factor (simplified)
k = s / (s + 2*w)
# Differential impedance approximation
z_diff = 2 * single_ended * (1 - k)
return round(z_diff, 1)
except (ValueError, ZeroDivisionError):
return None
def create_stackup_analyzer() -> LayerStackupAnalyzer:
"""Create and initialize a layer stack-up analyzer."""
return LayerStackupAnalyzer()