""" 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. """ import json import re from dataclasses import dataclass from typing import Dict, List, Optional, Any, Tuple import logging import math 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: Optional[float] = None loss_tangent: Optional[float] = None copper_weight: Optional[float] = None # in oz (for copper layers) layer_number: Optional[int] = None kicad_layer_id: Optional[str] = None @dataclass class ImpedanceCalculation: """Impedance calculation results for a trace configuration.""" trace_width: float trace_spacing: Optional[float] # For differential pairs impedance_single: Optional[float] impedance_differential: Optional[float] 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, 'r', 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]) -> Optional[float]: """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]) -> Optional[float]: """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()