kicad-mcp/src/mckicad/tools/analysis.py
Ryan Malloy 4ae38fed59 Rebuild on FastMCP 3 with src-layout and kicad-sch-api integration
Migrate from FastMCP 2.14.5 to 3.1.0 with complete architectural
overhaul. Adopt src-layout packaging, lazy config functions to
eliminate .env race condition, and decorator-based tool registration.

Consolidate 14 tool modules into 8 focused modules (33 tools total).
Add 9 new schematic tools via kicad-sch-api for creating and
manipulating .kicad_sch files. Drop pandas dependency (BOM uses
stdlib csv). Remove ~17k lines of stubs, over-engineering, and
dead code.

All checks pass: ruff clean, mypy 0 errors, 17/17 tests green.
2026-03-03 18:26:54 -07:00

446 lines
15 KiB
Python

"""
Project analysis and validation tools.
Combines project validation, real-time board analysis, and component
detail retrieval into a single module. Uses KiCad IPC when available
for live data, falling back to file-based checks otherwise.
"""
import json
import logging
import os
from typing import Any
from mckicad.server import mcp
from mckicad.utils.file_utils import get_project_files
from mckicad.utils.ipc_client import check_kicad_availability, kicad_ipc_session
logger = logging.getLogger(__name__)
@mcp.tool()
def validate_project(project_path: str) -> dict[str, Any]:
"""Validate a KiCad project's structure and essential files.
Accepts either a path to a .kicad_pro file or a directory containing
exactly one .kicad_pro file. Checks that the project JSON parses
correctly, that schematic and PCB files exist, and -- when KiCad is
running -- performs a live IPC check for component count, routing
completion, and unrouted nets.
Args:
project_path: Path to a .kicad_pro file or a directory that
contains one.
Returns:
Dictionary with validation result, list of issues found, files
discovered, and optional real-time IPC analysis.
"""
# Resolve directory to .kicad_pro file
if os.path.isdir(project_path):
kicad_pro_files = [
f for f in os.listdir(project_path) if f.endswith(".kicad_pro")
]
if not kicad_pro_files:
return {
"valid": False,
"error": f"No .kicad_pro file found in directory: {project_path}",
}
if len(kicad_pro_files) > 1:
return {
"valid": False,
"error": (
f"Multiple .kicad_pro files in directory: {project_path}. "
"Specify the exact file."
),
}
project_path = os.path.join(project_path, kicad_pro_files[0])
if not os.path.exists(project_path):
return {"valid": False, "error": f"Project file not found: {project_path}"}
if not project_path.endswith(".kicad_pro"):
return {
"valid": False,
"error": f"Expected .kicad_pro file, got: {project_path}",
}
issues: list[str] = []
# Discover associated files
try:
files = get_project_files(project_path)
except Exception as e:
return {
"valid": False,
"error": f"Error analysing project files: {e}",
}
if "pcb" not in files:
issues.append("Missing PCB layout file (.kicad_pcb)")
if "schematic" not in files:
issues.append("Missing schematic file (.kicad_sch)")
# Validate JSON integrity of the project file
try:
with open(project_path) as f:
json.load(f)
except json.JSONDecodeError as e:
issues.append(f"Invalid project file JSON: {e}")
except Exception as e:
issues.append(f"Error reading project file: {e}")
# Optional live analysis via KiCad IPC
ipc_analysis: dict[str, Any] = {}
ipc_status = check_kicad_availability()
if ipc_status["available"] and "pcb" in files:
try:
with kicad_ipc_session(board_path=files["pcb"]) as client:
board_stats = client.get_board_statistics()
connectivity = client.check_connectivity()
ipc_analysis = {
"real_time_analysis": True,
"board_statistics": board_stats,
"connectivity_status": connectivity,
"routing_completion": connectivity.get("routing_completion", 0),
"component_count": board_stats.get("footprint_count", 0),
"net_count": board_stats.get("net_count", 0),
}
if connectivity.get("unrouted_nets", 0) > 0:
issues.append(
f"{connectivity['unrouted_nets']} net(s) are not routed"
)
if board_stats.get("footprint_count", 0) == 0:
issues.append("No components found on PCB")
except Exception as e:
logger.debug(f"IPC analysis unavailable: {e}")
ipc_analysis = {
"real_time_analysis": False,
"ipc_error": str(e),
}
else:
ipc_analysis = {
"real_time_analysis": False,
"reason": ipc_status.get(
"message", "KiCad IPC not available or PCB file missing"
),
}
return {
"valid": len(issues) == 0,
"path": project_path,
"issues": issues if issues else None,
"files_found": list(files.keys()),
"ipc_analysis": ipc_analysis,
"validation_mode": (
"enhanced_with_ipc"
if ipc_analysis.get("real_time_analysis")
else "file_based"
),
}
@mcp.tool()
def analyze_board_real_time(project_path: str) -> dict[str, Any]:
"""Live board analysis via KiCad IPC.
Connects to a running KiCad instance and pulls footprint, net,
track, and connectivity data to build a comprehensive snapshot of
the board state. Covers placement density, routing completion,
design quality scoring, and manufacturability assessment.
Requires KiCad to be running with the target project open.
Args:
project_path: Path to the KiCad project (.kicad_pro) or its
parent directory.
Returns:
Dictionary with placement analysis, routing analysis, quality
scores, and recommendations.
"""
try:
files = get_project_files(project_path)
if "pcb" not in files:
return {
"success": False,
"error": "PCB file not found in project",
}
ipc_status = check_kicad_availability()
if not ipc_status["available"]:
return {
"success": False,
"error": f"KiCad IPC not available: {ipc_status['message']}",
}
board_path = files["pcb"]
with kicad_ipc_session(board_path=board_path) as client:
footprints = client.get_footprints()
nets = client.get_nets()
tracks = client.get_tracks()
board_stats = client.get_board_statistics()
connectivity = client.check_connectivity()
# --- placement ---
placement_analysis = {
"total_components": len(footprints),
"component_types": board_stats.get("component_types", {}),
"placement_density": _placement_density(footprints),
"component_distribution": _component_distribution(footprints),
}
# --- routing ---
trace_items = [t for t in tracks if not hasattr(t, "drill")]
via_items = [t for t in tracks if hasattr(t, "drill")]
routing_analysis = {
"total_nets": len(nets),
"routed_nets": connectivity.get("routed_nets", 0),
"unrouted_nets": connectivity.get("unrouted_nets", 0),
"routing_completion": connectivity.get("routing_completion", 0),
"track_count": len(trace_items),
"via_count": len(via_items),
"routing_efficiency": _routing_efficiency(tracks, nets),
}
# --- quality ---
design_score = _design_score(placement_analysis, routing_analysis)
critical_issues = _critical_issues(footprints, tracks, nets)
optimization_opps = _optimization_opportunities(
placement_analysis, routing_analysis
)
mfg_score = _manufacturability_score(tracks, footprints)
quality_analysis = {
"design_score": design_score,
"critical_issues": critical_issues,
"optimization_opportunities": optimization_opps,
"manufacturability_score": mfg_score,
}
recommendations = _board_recommendations(
placement_analysis, routing_analysis, quality_analysis
)
return {
"success": True,
"project_path": project_path,
"board_path": board_path,
"analysis_timestamp": os.path.getmtime(board_path),
"placement_analysis": placement_analysis,
"routing_analysis": routing_analysis,
"quality_analysis": quality_analysis,
"recommendations": recommendations,
"board_statistics": board_stats,
"analysis_mode": "real_time_ipc",
}
except Exception as e:
logger.error(f"Error in real-time board analysis: {e}")
return {
"success": False,
"error": str(e),
"project_path": project_path,
}
@mcp.tool()
def get_component_details(
project_path: str,
component_reference: str | None = None,
) -> dict[str, Any]:
"""Retrieve component details from a live KiCad board via IPC.
When *component_reference* is given (e.g. ``"R1"``, ``"U3"``),
returns position, rotation, layer, value, and footprint name for
that single component. When omitted, returns the same information
for every component on the board.
Requires KiCad to be running with the target project open.
Args:
project_path: Path to the KiCad project (.kicad_pro) or its
parent directory.
component_reference: Reference designator of a specific
component, or None to list all.
Returns:
Dictionary with component detail(s) or an error message.
"""
try:
files = get_project_files(project_path)
if "pcb" not in files:
return {
"success": False,
"error": "PCB file not found in project",
}
ipc_status = check_kicad_availability()
if not ipc_status["available"]:
return {
"success": False,
"error": f"KiCad IPC not available: {ipc_status['message']}",
}
board_path = files["pcb"]
with kicad_ipc_session(board_path=board_path) as client:
if component_reference:
fp = client.get_footprint_by_reference(component_reference)
if not fp:
return {
"success": False,
"error": f"Component '{component_reference}' not found",
}
return {
"success": True,
"project_path": project_path,
"component_reference": component_reference,
"component_details": _extract_component_details(fp),
}
# All components
footprints = client.get_footprints()
all_components = {}
for fp in footprints:
ref = getattr(fp, "reference", None)
if ref:
all_components[ref] = _extract_component_details(fp)
return {
"success": True,
"project_path": project_path,
"total_components": len(all_components),
"components": all_components,
}
except Exception as e:
logger.error(f"Error getting component details: {e}")
return {
"success": False,
"error": str(e),
"project_path": project_path,
}
# ---------------------------------------------------------------------------
# Private helpers
# ---------------------------------------------------------------------------
def _extract_component_details(footprint: Any) -> dict[str, Any]:
"""Pull key attributes from a FootprintInstance into a plain dict."""
pos = getattr(footprint, "position", None)
return {
"reference": getattr(footprint, "reference", "Unknown"),
"value": getattr(footprint, "value", "Unknown"),
"position": {
"x": getattr(pos, "x", 0) if pos else 0,
"y": getattr(pos, "y", 0) if pos else 0,
},
"rotation": getattr(footprint, "rotation", 0),
"layer": getattr(footprint, "layer", "F.Cu"),
"footprint_name": getattr(footprint, "footprint", "Unknown"),
}
def _placement_density(footprints: list) -> float:
"""Estimate placement density (simplified, 0.0 -- 1.0)."""
if not footprints:
return 0.0
return min(len(footprints) / 100.0, 1.0)
def _component_distribution(footprints: list) -> dict[str, str]:
"""Simplified distribution characterisation."""
if not footprints:
return {"distribution": "empty"}
return {
"distribution": "distributed",
"clustering": "moderate",
"edge_utilization": "good",
}
def _routing_efficiency(tracks: list, nets: list) -> float:
"""Track-to-net ratio as a percentage (0 -- 100)."""
net_count = len(nets)
if net_count == 0:
return 0.0
return round(min(len(tracks) / (net_count * 2), 1.0) * 100, 1)
def _design_score(
placement: dict[str, Any], routing: dict[str, Any]
) -> int:
"""Composite design quality score (0 -- 100)."""
base = 70
density_bonus = placement.get("placement_density", 0) * 15
completion_bonus = routing.get("routing_completion", 0) * 0.15
return min(int(base + density_bonus + completion_bonus), 100)
def _critical_issues(
footprints: list, tracks: list, nets: list
) -> list[str]:
"""Return a list of blocking design issues."""
issues: list[str] = []
if not footprints:
issues.append("No components placed on board")
if not tracks and nets:
issues.append("No routing present despite having nets defined")
return issues
def _optimization_opportunities(
placement: dict[str, Any], routing: dict[str, Any]
) -> list[str]:
"""Suggest areas where the design could be improved."""
opps: list[str] = []
if placement.get("placement_density", 0) < 0.3:
opps.append("Board area could be reduced for better cost efficiency")
if routing.get("routing_completion", 0) < 100:
opps.append("Complete remaining routing for full functionality")
return opps
def _manufacturability_score(tracks: list, footprints: list) -> int:
"""Heuristic manufacturability score (0 -- 100)."""
score = 85
if len(tracks) > 1000:
score -= 10
if len(footprints) > 100:
score -= 5
return max(score, 0)
def _board_recommendations(
placement: dict[str, Any],
routing: dict[str, Any],
quality: dict[str, Any],
) -> list[str]:
"""Compile a prioritised list of recommendations."""
recs: list[str] = []
if quality.get("design_score", 0) < 80:
recs.append("Design score is below 80 -- consider optimisation")
unrouted = routing.get("unrouted_nets", 0)
if unrouted > 0:
recs.append(f"Complete routing for {unrouted} unrouted net(s)")
if placement.get("total_components", 0) > 0:
recs.append("Review thermal management for power components")
recs.append("Run DRC check to validate design rules")
return recs