# FILE: src/circuit_synth/core/circuit.py
import inspect
import re
from pathlib import Path # Import Path
from typing import Any, Dict, List, Optional
from ._logger import context_logger
from .exception import ValidationError
from .net import Net
from .netlist_exporter import NetlistExporter
from .reference_manager import ReferenceManager
[docs]
class Circuit:
from .component import Component # for type-hinting
[docs]
def __init__(self, name=None, description=None, auto_comments=True):
self.name = name or "UnnamedCircuit"
self.description = description
self.auto_comments = auto_comments
self._components = {}
self._nets = {}
# self._unnamed_net_counter = 1 # Removed: Counter is now managed by ReferenceManager
self._parent = None
self._subcircuits = []
self._component_list = []
self._reference_manager = ReferenceManager()
self._annotations = [] # Store TextProperty, TextBox, etc.
self._ref_mapping = (
{}
) # Track prefix -> final ref mappings for source rewriting
self._circuit_func = None # Store reference to the @circuit decorated function
[docs]
def validate_reference(self, ref: str) -> bool:
"""Check if reference is available in this circuit's scope"""
return self._reference_manager.validate_reference(ref)
[docs]
def register_reference(self, ref: str) -> None:
"""Register a new reference in this circuit's scope"""
self._reference_manager.register_reference(ref)
[docs]
def add_subcircuit(self, subcirc: "Circuit"):
"""Add a subcircuit and establish parent-child relationship"""
subcirc._parent = self
self._subcircuits.append(subcirc)
# NEW: Link reference managers
subcirc._reference_manager.set_parent(self._reference_manager)
@staticmethod
def _from_json(json_data):
circuit = Circuit(name=json_data.get("name", "unnamed"))
# Add components
if "components" in json_data:
for comp_data in json_data["components"]:
from .component import Component
comp = Component(
symbol=comp_data.get("symbol", "Device:R"),
ref=comp_data.get("reference", comp_data.get("ref", "U")),
value=comp_data.get("value", ""),
footprint=comp_data.get("footprint", ""),
)
circuit._components[comp.ref] = comp
circuit._component_list.append(comp)
# Add nets
if "nets" in json_data:
from .net import Net
for net_data in json_data["nets"]:
# Use from_dict to preserve all Net attributes
net = Net.from_dict(net_data)
circuit._nets[net.name] = net
# Add subcircuits recursively
if "subcircuits" in json_data:
for sub_data in json_data["subcircuits"]:
subcircuit = Circuit._from_json(sub_data)
circuit.add_subcircuit(subcircuit)
return circuit
@property
def components(self):
"""Get components dictionary for compatibility."""
return self._components
@property
def nets(self):
"""Get nets dictionary for compatibility."""
return self._nets
@property
def subcircuits(self):
"""Get subcircuits list."""
return self._subcircuits
[docs]
def add_subcircuit_old(self, subcirc: "Circuit"):
context_logger.debug(
"Added subcircuit to parent",
component="CIRCUIT",
subcircuit_name=subcirc.name,
parent_name=self.name,
)
[docs]
def add_component(self, comp: "Component"):
"""Register a Component with this Circuit."""
user_ref = comp._user_reference
# Handle components with no reference - generate a default prefix
if not user_ref:
# Generate a default prefix based on the component symbol
symbol_parts = comp.symbol.split(":")
if len(symbol_parts) >= 2:
# Use the symbol name as prefix (e.g., "Device:R" -> "R")
default_prefix = symbol_parts[1]
# Clean up the prefix to be a valid reference
default_prefix = re.sub(r"[^A-Za-z0-9_]", "", default_prefix)
if default_prefix and default_prefix[0].isalpha():
user_ref = default_prefix
else:
user_ref = "U" # Generic fallback
else:
user_ref = "U" # Generic fallback
# Update the component's reference
comp._user_reference = user_ref
comp._is_prefix = True
# Check if reference is final (has trailing digits)
has_trailing_digits = bool(re.search(r"\d+$", user_ref))
if has_trailing_digits:
# For final references, validate through hierarchy
if not self._reference_manager.validate_reference(user_ref):
existing = self._components.get(user_ref)
msg = (
f"Reference collision: final reference '{user_ref}' is already used in circuit hierarchy\n"
f"Existing component => {existing}\n"
f"New component => {comp}\n"
"Please specify a different final reference."
)
context_logger.error(msg, component="CIRCUIT")
raise ValidationError(msg)
# Register and store with final reference
self._reference_manager.register_reference(user_ref)
comp.ref = user_ref
comp._is_prefix = False # Mark as final reference
self._components[user_ref] = comp
self._component_list.append(comp)
context_logger.debug(
"Component with final reference registered",
component="CIRCUIT",
reference=user_ref,
symbol=comp.symbol,
circuit_name=self.name,
)
else:
# For prefix references, store with placeholder
placeholder_key = f"(prefix){id(comp)}"
context_logger.debug(
"Component with prefix reference stored as placeholder",
component="CIRCUIT",
prefix_reference=user_ref,
placeholder_key=placeholder_key,
symbol=comp.symbol,
circuit_name=self.name,
)
comp.ref = user_ref
comp._is_prefix = True # Mark as prefix reference
self._components[placeholder_key] = comp
self._component_list.append(comp)
[docs]
def finalize_references(self):
"""Auto-assign references for any components that only had a prefix."""
context_logger.debug(
"Starting reference finalization",
component="CIRCUIT",
circuit_name=self.name,
)
newly_assigned = {}
to_remove = []
for key, comp in self._components.items():
# Only process components with prefix references
if key.startswith("(prefix)") and comp._is_prefix:
prefix = comp._user_reference
final_ref = self._reference_manager.generate_next_reference(prefix)
comp.ref = final_ref
comp._is_prefix = False # Mark as final reference
newly_assigned[final_ref] = comp
to_remove.append(key)
# Capture ref mapping for source rewriting
# Use list to track multiple components with same prefix
if prefix not in self._ref_mapping:
self._ref_mapping[prefix] = []
self._ref_mapping[prefix].append(final_ref)
context_logger.debug(
"Assigned final reference for prefix",
component="CIRCUIT",
final_reference=final_ref,
prefix=prefix,
circuit_name=self.name,
)
# Update component dictionaries
for placeholder_key in to_remove:
del self._components[placeholder_key]
for final_key, comp in newly_assigned.items():
self._components[final_key] = comp
context_logger.debug(
"Finished reference finalization",
component="CIRCUIT",
circuit_name=self.name,
total_components=len(self._components),
)
# Auto-generate docstring annotation if enabled
if self.auto_comments and self.description and self.description.strip():
self._add_docstring_annotation()
# Recursively finalize subcircuits
for sc in self._subcircuits:
sc.finalize_references()
def _get_source_file(self) -> Optional[Path]:
"""Get the source file path for this circuit's function.
Uses Python's inspect module to find where the @circuit decorated
function was defined.
Returns:
Path to source file, or None if not available (REPL, exec, frozen app)
"""
if not self._circuit_func:
context_logger.debug(
"No circuit function reference available",
component="CIRCUIT",
circuit_name=self.name,
)
return None
try:
source_file = inspect.getfile(self._circuit_func)
# Check for special cases where source isn't available
if source_file == "<stdin>" or source_file == "<string>":
context_logger.warning(
"Circuit defined in REPL/exec environment, source file not available",
component="CIRCUIT",
circuit_name=self.name,
)
return None
# Check for frozen applications (PyInstaller, etc)
if getattr(inspect.sys, "frozen", False):
context_logger.warning(
"Running in frozen application, source file not available",
component="CIRCUIT",
circuit_name=self.name,
)
return None
path = Path(source_file).resolve()
if not path.exists():
context_logger.warning(
"Source file no longer exists",
component="CIRCUIT",
circuit_name=self.name,
source_file=str(path),
)
return None
return path
except (TypeError, OSError) as e:
context_logger.warning(
"Could not determine source file",
component="CIRCUIT",
circuit_name=self.name,
error=str(e),
)
return None
def _update_source_refs(self, source_file: Path) -> bool:
"""Update component refs in the source file.
Uses SourceRefRewriter to atomically update ref values in the
Python source file, preserving formatting and encoding.
Args:
source_file: Path to the Python source file to update
Returns:
True if update succeeded, False if skipped or failed
"""
if not self._ref_mapping:
context_logger.debug(
"No ref mapping to apply", component="CIRCUIT", circuit_name=self.name
)
return False
try:
from .source_ref_rewriter import SourceRefRewriter
context_logger.info(
"Updating source file with finalized refs",
component="CIRCUIT",
circuit_name=self.name,
source_file=str(source_file),
ref_mapping=self._ref_mapping,
)
rewriter = SourceRefRewriter(source_file, self._ref_mapping)
success = rewriter.update()
if success:
context_logger.info(
"Source file updated successfully",
component="CIRCUIT",
circuit_name=self.name,
refs_updated=len(self._ref_mapping),
)
else:
context_logger.debug(
"Source file unchanged (no modifications needed)",
component="CIRCUIT",
circuit_name=self.name,
)
return success
except Exception as e:
context_logger.error(
"Failed to update source file",
component="CIRCUIT",
circuit_name=self.name,
source_file=str(source_file),
error=str(e),
)
# Don't raise - source rewriting failure shouldn't break project generation
return False
def _add_docstring_annotation(self):
"""Add a TextBox annotation with the circuit's docstring."""
# Check if docstring annotation already exists to prevent duplicates
for annotation in self._annotations:
if (
hasattr(annotation, "text")
and annotation.text.strip() == self.description.strip()
):
return # Already added
elif (
isinstance(annotation, dict)
and annotation.get("text", "").strip() == self.description.strip()
):
return # Already added
from .annotations import TextBox
# Position the docstring in the top-left area of the schematic
# Using a reasonable default position and size
docstring_box = TextBox(
text=self.description.strip(),
position=(
184.0,
110.0,
), # Double the coordinates to account for KiCad scaling
size=(80.0, 30.0), # 80mm wide, 30mm tall
text_size=1.2,
bold=True,
background=True,
background_color="lightyellow",
border=True,
justify="center center",
)
self.add_annotation(docstring_box)
context_logger.debug(
"Added auto-generated docstring annotation",
component="CIRCUIT",
circuit_name=self.name,
text_length=len(self.description),
)
[docs]
def generate_text_netlist(self) -> str:
"""
Generate a textual netlist for display or debugging.
"""
exporter = NetlistExporter(self)
return exporter.generate_text_netlist()
[docs]
def generate_full_netlist(self) -> str:
"""
Print a hierarchical netlist showing:
1) Each circuit + subcircuit (name, components, and optional description)
2) A single combined net listing from all circuits
"""
exporter = NetlistExporter(self)
return exporter.generate_full_netlist()
[docs]
def add_net(self, net: Net):
if not net.name:
# Use the ReferenceManager to get a globally unique name
net.name = self._reference_manager.generate_next_unnamed_net_name()
if net.name not in self._nets:
context_logger.debug(
"Registering net in circuit",
component="CIRCUIT",
net_name=net.name,
circuit_name=self.name,
)
self._nets[net.name] = net
[docs]
def add_annotation(self, annotation):
"""Add a text annotation (TextProperty, TextBox, etc.) to this circuit."""
self._annotations.append(annotation)
context_logger.debug(
"Added annotation to circuit",
component="CIRCUIT",
annotation_type=type(annotation).__name__,
circuit_name=self.name,
)
[docs]
def add_image(self, image_path: str, position: tuple, scale: float = 1.0):
"""
Add an embedded image to the schematic.
Images are embedded as base64-encoded data in the KiCad schematic file.
The image file is read when the schematic is generated, so it only needs
to exist at generation time.
Args:
image_path: Path to image file (PNG, JPG, etc.)
position: Tuple of (x, y) position in mm
scale: Scale factor (1.0 = original size, 2.0 = 2x larger, etc.)
Returns:
Image: The created Image annotation object
Example:
>>> circuit.add_image("logo.png", position=(100, 50), scale=2.0)
"""
from .annotations import Image
img = Image(image_path=image_path, position=position, scale=scale)
self.add_annotation(img)
context_logger.debug(
"Added image to circuit",
component="CIRCUIT",
image_path=image_path,
position=position,
scale=scale,
circuit_name=self.name,
)
return img
[docs]
def to_dict(self) -> Dict[str, Any]:
"""
Return a hierarchical dictionary representation of this circuit,
including subcircuits, components (as a dictionary keyed by reference),
and net connections.
"""
exporter = NetlistExporter(self)
return exporter.to_dict()
[docs]
def generate_json_netlist(self, filename: str) -> None:
"""
Generate a JSON representation of this circuit and its hierarchy,
then write it out to 'filename'.
"""
exporter = NetlistExporter(self)
return exporter.generate_json_netlist(filename)
# --------------------------------------------------------------------------
# UPDATED FLATTENED JSON LOGIC (SHOWING ALL NETS USED BY LOCAL COMPONENTS)
# --------------------------------------------------------------------------
[docs]
def to_flattened_list(
self, parent_name: str = None, flattened: List[Dict[str, Any]] = None
) -> List[Dict[str, Any]]:
"""
Build or update a shared `flattened` list of circuit data dicts.
"""
exporter = NetlistExporter(self)
return exporter.to_flattened_list(parent_name, flattened)
[docs]
def generate_flattened_json_netlist(self, filename: str) -> None:
"""
Produce a flattened JSON representation for this circuit + subcircuits.
"""
exporter = NetlistExporter(self)
return exporter.generate_flattened_json_netlist(filename)
def _generate_hierarchical_json_netlist(self, filename: str) -> None:
"""
Generate JSON netlist with hierarchical components flattened into single component/net structure.
This creates the format expected by the netlist service: {"components": {...}, "nets": {...}}
"""
import json
# Collect all components and nets from this circuit and all subcircuits
all_components = {} # ref -> component_dict
all_nets = {} # net_name -> [connections]
all_net_objects = {} # net_name -> Net object (to collect connections from)
context_logger.info(
"Collecting hierarchical components and nets",
component="CIRCUIT",
circuit_name=self.name,
)
# Helper to recursively collect from circuit and subcircuits
def collect_from_circuit(circuit, prefix=""):
# Collect components
for comp_ref, comp in circuit._components.items():
if hasattr(comp, "ref") and comp.ref:
full_ref = f"{prefix}{comp.ref}" if prefix else comp.ref
# Convert component to dict format
all_components[full_ref] = comp.to_dict()
# Collect nets (the Net objects themselves, not connections yet)
for net_name, net in circuit._nets.items():
if net_name not in all_net_objects:
all_net_objects[net_name] = net
all_nets[net_name] = []
# Recursively collect from subcircuits
for subcircuit in circuit._subcircuits:
collect_from_circuit(subcircuit, f"{prefix}{subcircuit.name}_")
# Start collection from root circuit
collect_from_circuit(self)
# Now collect connections from each net's _pins set
for net_name, net in all_net_objects.items():
if hasattr(net, "_pins"):
for pin in net._pins:
if hasattr(pin, "_component") and pin._component:
component_ref = pin._component.ref
# Find the full reference (with prefix) for this component
full_ref = None
for ref, comp in all_components.items():
if comp.get("ref") == component_ref:
full_ref = ref
break
if full_ref:
connection = {
"component": full_ref,
"pin": {
"number": str(pin.num),
"name": getattr(pin, "name", "~"),
"type": str(getattr(pin, "func", "passive")),
},
}
all_nets[net_name].append(connection)
# Create the expected JSON structure
json_data = {
"name": self.name,
"description": getattr(self, "description", ""),
"tstamps": "",
"source_file": f"{self.name}.kicad_sch",
"components": all_components,
"nets": all_nets,
"subcircuits": [], # Flattened, so no subcircuits
"annotations": getattr(self, "_annotations", []),
}
context_logger.info(
"Hierarchical collection complete",
component="CIRCUIT",
total_components=len(all_components),
total_nets=len(all_nets),
component_refs=list(all_components.keys()),
net_names=list(all_nets.keys()),
)
# Write to file
try:
with open(filename, "w", encoding="utf-8") as f:
json.dump(json_data, f, indent=2, default=str)
context_logger.info(
f"Hierarchical JSON netlist written to {filename}", component="CIRCUIT"
)
except Exception as e:
context_logger.error(f"Failed to write hierarchical JSON: {e}")
raise
[docs]
def generate_kicad_netlist(self, filename: str) -> None:
"""
Generate a KiCad netlist (.net) file for this circuit and its hierarchy.
"""
exporter = NetlistExporter(self)
return exporter.generate_kicad_netlist(filename)
[docs]
def generate_kicad_project(
self,
project_name: str,
generate_pcb: bool = True,
force_regenerate: bool = False,
placement_algorithm: str = "hierarchical",
draw_bounding_boxes: bool = False,
generate_ratsnest: bool = True,
update_source_refs: Optional[bool] = None,
preserve_user_components: bool = False,
) -> Dict[str, Any]:
"""
Generate a complete KiCad project (schematic + PCB) from this circuit.
This method provides a simplified API that handles all the boilerplate setup
required for KiCad project generation, creating the project directory and
generating KiCad files with an automatic JSON netlist in the project directory.
Args:
project_name: Name of the KiCad project and directory to create
generate_pcb: Whether to generate PCB in addition to schematic (default: True)
force_regenerate: Force regeneration of existing files, losing manual edits (default: False)
placement_algorithm: Component placement algorithm to use (default: "hierarchical")
draw_bounding_boxes: Whether to draw visual bounding boxes around components (default: False)
generate_ratsnest: Whether to generate ratsnest connections in PCB (default: True)
update_source_refs: Whether to update source file with finalized refs.
None (default): Auto-update unless force_regenerate=True
True: Always update source file
False: Never update source file
preserve_user_components: Keep components in KiCad that don't exist in Python (default: False)
False: Python is source of truth - delete components not in Python
True: Preserve all components in KiCad, even if not in Python
Returns:
dict: Result dictionary containing:
- success (bool): Whether generation succeeded
- json_path (Path): Path to the canonical JSON netlist
- project_path (Path): Path to the KiCad project directory
- error (str, optional): Error message if generation failed
Example:
>>> circuit = esp32s3_simple()
>>> result = circuit.generate_kicad_project("esp32s3_simple")
>>> print(f"JSON netlist: {result['json_path']}")
>>> print(f"KiCad project: {result['project_path']}")
"""
try:
from .. import print_version_info
from ..kicad.config import get_recommended_generator
# Print version information for debugging
print_version_info()
print() # Blank line after version info
# Finalize references before generation
self.finalize_references()
# Determine if we should update source file
should_update_source = update_source_refs
if should_update_source is None:
# Auto mode: update unless force_regenerate
should_update_source = not force_regenerate
# Update source file with finalized refs if enabled
if should_update_source:
source_file = self._get_source_file()
if source_file:
self._update_source_refs(source_file)
else:
context_logger.debug(
"Source file not available, skipping ref update",
component="CIRCUIT",
circuit_name=self.name,
)
# Create output directory with the project name
output_path = Path(project_name).resolve()
output_path.mkdir(parents=True, exist_ok=True)
context_logger.info(
"Starting KiCad project generation",
component="CIRCUIT",
project_name=project_name,
output_dir=str(output_path),
generate_pcb=generate_pcb,
placement_algorithm=placement_algorithm,
generator_type=get_recommended_generator(),
)
# Use legacy system for positioning/hierarchy, modern API for file writing
from ..kicad.sch_gen.main_generator import SchematicGenerator
context_logger.info(
"Using hybrid approach: legacy positioning + modern kicad-sch-api file writing",
component="CIRCUIT",
)
# Determine project base name for file generation
# Check for existing .kicad_pro files in output directory
existing_projects = list(output_path.glob("*.kicad_pro"))
if len(existing_projects) > 1:
# Multiple projects - error out
project_names = [p.stem for p in existing_projects]
raise ValueError(
f"Multiple KiCad projects found in '{output_path}': {project_names}. "
f"Please specify which project to update or clean the directory."
)
elif len(existing_projects) == 1:
# Exactly one project - use its base name for regeneration
project_base_name = existing_projects[0].stem
context_logger.info(
f"Detected existing KiCad project: {project_base_name}",
component="CIRCUIT",
)
# Validate folder name matches project base name
if output_path.name != project_base_name:
context_logger.warning(
f"Folder name '{output_path.name}' doesn't match project '{project_base_name}'. "
f"Using circuit name for file generation.",
component="CIRCUIT",
)
# Use circuit name for file generation, not folder name
project_base_name = self.name
else:
# No existing project - use circuit name for KiCad files
project_base_name = self.name
context_logger.info(
f"No existing project found, using circuit name: {project_base_name}",
component="CIRCUIT",
)
# Create JSON netlist in project directory (canonical format)
json_path = output_path / f"{project_base_name}.json"
self.generate_json_netlist(str(json_path))
context_logger.info(
"Generated canonical JSON netlist",
component="CIRCUIT",
json_path=str(json_path),
)
# Create schematic generator
# Pass output_path.parent as output_dir and project_base_name as project_name
# But we need to change how SchematicGenerator uses project_dir
# For now, pass the full path and update how project_dir is computed
generator = SchematicGenerator(str(output_path), project_base_name)
# Override project_dir since output_path is already the full project directory
generator.project_dir = Path(str(output_path)).resolve()
# Generate the complete project using the JSON file
# Legacy system handles placement, modern API handles file writing via write_schematic_file
result = generator.generate_project(
json_file=str(json_path),
placement_algorithm=placement_algorithm, # PCB placement algorithm
schematic_placement="sequential", # Use simple sequential for schematic
generate_pcb=generate_pcb,
force_regenerate=force_regenerate,
draw_bounding_boxes=draw_bounding_boxes,
generate_ratsnest=generate_ratsnest,
preserve_user_components=preserve_user_components,
)
if result.get("success", True): # Default to success if not specified
context_logger.info(
"KiCad project generated successfully",
component="CIRCUIT",
project_name=project_name,
output_path=str(output_path),
)
# Return success result with JSON path
return {
"success": True,
"json_path": json_path,
"project_path": output_path,
}
else:
error_msg = result.get(
"error", "Unknown error occurred during project generation"
)
context_logger.error(
"KiCad project generation failed",
component="CIRCUIT",
project_name=project_name,
error=error_msg,
)
return {
"success": False,
"error": error_msg,
"json_path": json_path,
"project_path": output_path,
}
except ImportError as e:
error_msg = f"KiCad integration not available: {e}"
context_logger.error(error_msg, component="CIRCUIT")
return {
"success": False,
"error": error_msg,
}
except Exception as e:
error_msg = f"Failed to generate KiCad project '{project_name}': {e}"
context_logger.error(
error_msg, component="CIRCUIT", project_name=project_name
)
return {
"success": False,
"error": error_msg,
}
[docs]
def generate_bom(
self,
output_file: Optional[str] = None,
project_name: Optional[str] = None,
fields: Optional[str] = None,
labels: Optional[str] = None,
group_by: Optional[str] = None,
exclude_dnp: bool = False,
) -> Dict[str, Any]:
"""
Generate a Bill of Materials (BOM) from this circuit as a CSV file.
This method creates a KiCad project if one doesn't already exist, then
exports the schematic to a CSV BOM file using kicad-cli.
Args:
output_file: Path where CSV BOM should be written. If not provided,
defaults to {project_name}/{project_name}.csv
project_name: Name of the KiCad project directory. If not provided,
defaults to the circuit name. Required if output_file is provided
but needs a project.
fields: Comma-separated fields to export from schematic. If not specified,
KiCad will export default fields (Refs, Value, Footprint, etc.)
labels: Comma-separated column headers for the BOM. Must match the number
of fields if fields are specified.
group_by: Field to group references by when exporting. Common values:
"Value" (group by component value), "Footprint", etc.
exclude_dnp: If True, exclude "Do not populate" components from BOM
Returns:
dict: Result dictionary containing:
- success (bool): True if BOM was successfully generated
- file (Path): Path to the generated CSV file
- component_count (int): Number of components in the BOM
- project_path (Path): Path to the KiCad project directory
- error (str, optional): Error message if generation failed
Example:
>>> circuit = led_blinker()
>>> result = circuit.generate_bom(project_name="led_blinker")
>>> print(f"BOM exported to: {result['file']}")
>>> print(f"Component count: {result['component_count']}")
Raises:
FileNotFoundError: If kicad-cli is not available
RuntimeError: If BOM export fails
"""
from ..kicad.bom_exporter import BOMExporter
# Determine project name
if project_name is None:
project_name = self.name
# Determine output file
if output_file is None:
output_path = Path(project_name)
output_file = output_path / f"{project_name}.csv"
else:
output_file = Path(output_file)
try:
# Generate KiCad project if needed
project_path = Path(project_name).resolve()
project_base_name = self.name
sch_file = project_path / f"{project_base_name}.kicad_sch"
if not sch_file.exists():
context_logger.info(
"Generating KiCad project for BOM export",
component="CIRCUIT",
circuit_name=self.name,
project_name=project_name,
)
project_result = self.generate_kicad_project(
project_name=project_name,
generate_pcb=False, # Only need schematic for BOM
)
if not project_result.get("success"):
error_msg = f"Failed to generate KiCad project: {project_result.get('error')}"
context_logger.error(
error_msg, component="CIRCUIT", project_name=project_name
)
return {"success": False, "error": error_msg}
else:
context_logger.debug(
"Using existing KiCad project for BOM export",
component="CIRCUIT",
project_path=str(project_path),
)
# Export BOM using kicad-cli
context_logger.info(
"Exporting BOM",
component="CIRCUIT",
schematic_file=str(sch_file),
output_file=str(output_file),
)
bom_result = BOMExporter.export_csv(
schematic_file=sch_file,
output_file=output_file,
fields=fields,
labels=labels,
group_by=group_by,
exclude_dnp=exclude_dnp,
)
# Add project path to result
bom_result["project_path"] = project_path
return bom_result
except FileNotFoundError as e:
error_msg = f"Cannot export BOM: {e}"
context_logger.error(error_msg, component="CIRCUIT")
return {"success": False, "error": error_msg}
except Exception as e:
error_msg = f"Failed to generate BOM: {e}"
context_logger.error(
error_msg, component="CIRCUIT", exception=str(e)
)
return {"success": False, "error": error_msg}
[docs]
def generate_pdf_schematic(
self,
output_file: Optional[str] = None,
project_name: Optional[str] = None,
black_and_white: bool = False,
theme: Optional[str] = None,
exclude_drawing_sheet: bool = False,
pages: Optional[str] = None,
) -> Dict[str, Any]:
"""
Generate a PDF schematic from this circuit.
This method creates a KiCad project if one doesn't already exist, then
exports the schematic to a PDF file using kicad-cli.
Args:
output_file: Path where PDF should be written. If not provided,
defaults to {project_name}/{project_name}.pdf
project_name: Name of the KiCad project directory. If not provided,
defaults to the circuit name.
black_and_white: Export in black and white instead of color (default: False)
theme: Color theme to use for export (optional). Theme name depends on KiCad installation.
exclude_drawing_sheet: Exclude the drawing sheet/border from PDF (default: False)
pages: Page range to export (e.g., "1,3-5" for pages 1, 3, 4, 5).
If not specified, all pages are exported.
Returns:
dict: Result dictionary containing:
- success (bool): True if PDF was successfully generated
- file (Path): Path to the generated PDF file
- project_path (Path): Path to the KiCad project directory
- error (str, optional): Error message if generation failed
Example:
>>> circuit = led_blinker()
>>> result = circuit.generate_pdf_schematic(project_name="led_blinker")
>>> print(f"PDF exported to: {result['file']}")
Raises:
FileNotFoundError: If kicad-cli is not available
RuntimeError: If PDF export fails
"""
from ..kicad.pdf_exporter import PDFExporter
# Determine project name
if project_name is None:
project_name = self.name
# Determine output file
if output_file is None:
output_path = Path(project_name)
output_file = output_path / f"{project_name}.pdf"
else:
output_file = Path(output_file)
try:
# Generate KiCad project if needed
project_path = Path(project_name).resolve()
project_base_name = self.name
sch_file = project_path / f"{project_base_name}.kicad_sch"
if not sch_file.exists():
context_logger.info(
"Generating KiCad project for PDF export",
component="CIRCUIT",
circuit_name=self.name,
project_name=project_name,
)
project_result = self.generate_kicad_project(
project_name=project_name,
generate_pcb=False, # Only need schematic for PDF
)
if not project_result.get("success"):
error_msg = f"Failed to generate KiCad project: {project_result.get('error')}"
context_logger.error(
error_msg, component="CIRCUIT", project_name=project_name
)
return {"success": False, "error": error_msg}
else:
context_logger.debug(
"Using existing KiCad project for PDF export",
component="CIRCUIT",
project_path=str(project_path),
)
# Export PDF using kicad-cli
context_logger.info(
"Exporting PDF schematic",
component="CIRCUIT",
schematic_file=str(sch_file),
output_file=str(output_file),
)
pdf_result = PDFExporter.export_pdf(
schematic_file=sch_file,
output_file=output_file,
black_and_white=black_and_white,
theme=theme,
exclude_drawing_sheet=exclude_drawing_sheet,
pages=pages,
)
# Add project path to result
pdf_result["project_path"] = project_path
return pdf_result
except FileNotFoundError as e:
error_msg = f"Cannot export PDF: {e}"
context_logger.error(error_msg, component="CIRCUIT")
return {"success": False, "error": error_msg}
except Exception as e:
error_msg = f"Failed to generate PDF schematic: {e}"
context_logger.error(
error_msg, component="CIRCUIT", exception=str(e)
)
return {"success": False, "error": error_msg}
[docs]
def generate_gerbers(
self,
output_dir: Optional[str] = None,
project_name: Optional[str] = None,
include_drill: bool = True,
drill_format: str = "excellon",
) -> Dict[str, Any]:
"""
Generate Gerber files for PCB manufacturing from this circuit.
This method creates a complete KiCad project (including PCB) if one doesn't
already exist, then exports Gerber files for manufacturing using kicad-cli.
Gerbers can be submitted directly to manufacturers like JLCPCB, PCBWay, etc.
Args:
output_dir: Directory where Gerber files should be written. If not provided,
defaults to {project_name}/gerbers
project_name: Name of the KiCad project directory. If not provided,
defaults to the circuit name.
include_drill: Also export drill files along with Gerbers (default: True)
drill_format: Format for drill files: "excellon" (default) or "gerber"
Returns:
dict: Result dictionary containing:
- success (bool): True if Gerbers were successfully generated
- gerber_files (list): List of Path objects to generated .gbr files
- drill_files (tuple): Tuple of (plated_holes_file, non_plated_holes_file) or None
- project_path (Path): Path to the KiCad project directory
- output_dir (Path): Directory where Gerbers were exported
- error (str, optional): Error message if generation failed
Example:
>>> circuit = esp32_board()
>>> result = circuit.generate_gerbers(project_name="esp32_board")
>>> print(f"Gerbers exported to: {result['output_dir']}")
>>> print(f"Files: {len(result['gerber_files'])} Gerber files")
Requirements:
- KiCad 8.0 or later
- kicad-cli must be available in PATH
- Circuit must be complete with all components and connections
Notes:
- First run generates full KiCad project including PCB (slower)
- Subsequent runs reuse existing project (faster)
- Default layers: F.Cu, B.Cu, F.Mask, B.Mask, F.SilkS, B.SilkS, F.Paste, B.Paste, Edge.Cuts
- Gerbers use standard Protel file extension format (.gbr, .gbl, etc.)
- Compatible with JLCPCB, PCBWay, OSH Park, and most PCB manufacturers
"""
# Determine project name
if project_name is None:
project_name = self.name
# Determine output directory
if output_dir is None:
output_path = Path(project_name) / "gerbers"
else:
output_path = Path(output_dir)
try:
# Generate full KiCad project (including PCB) if needed
project_path = Path(project_name).resolve()
project_base_name = self.name
pcb_file = project_path / f"{project_base_name}.kicad_pcb"
if not pcb_file.exists():
context_logger.info(
"Generating complete KiCad project for Gerber export",
component="CIRCUIT",
circuit_name=self.name,
project_name=project_name,
)
project_result = self.generate_kicad_project(
project_name=project_name,
generate_pcb=True, # Need PCB for Gerber export
)
if not project_result.get("success"):
error_msg = f"Failed to generate KiCad project: {project_result.get('error')}"
context_logger.error(
error_msg, component="CIRCUIT", project_name=project_name
)
return {"success": False, "error": error_msg}
else:
context_logger.debug(
"Using existing KiCad project for Gerber export",
component="CIRCUIT",
project_path=str(project_path),
)
# Create output directory
output_path.mkdir(parents=True, exist_ok=True)
context_logger.info(
"Exporting Gerber files",
component="CIRCUIT",
pcb_file=str(pcb_file),
output_dir=str(output_path),
)
# Import PCB utilities for Gerber export
from ..pcb.kicad_cli import get_kicad_cli
cli = get_kicad_cli()
# Export Gerbers with standard layer set
standard_layers = [
"F.Cu", # Front copper
"B.Cu", # Back copper
"F.Mask", # Front solder mask
"B.Mask", # Back solder mask
"F.SilkS", # Front silkscreen
"B.SilkS", # Back silkscreen
"F.Paste", # Front solder paste
"B.Paste", # Back solder paste
"Edge.Cuts", # Board outline
]
gerber_files = cli.export_gerbers(
pcb_file=pcb_file,
output_dir=output_path,
layers=standard_layers,
protel_extensions=True, # Use .gbr, .gbl format for compatibility
)
context_logger.info(
"Gerber export successful",
component="CIRCUIT",
gerber_count=len(gerber_files),
output_dir=str(output_path),
)
# Export drill files if requested
drill_files = None
if include_drill:
context_logger.debug(
"Exporting drill files",
component="CIRCUIT",
format=drill_format,
)
drill_files = cli.export_drill(
pcb_file=pcb_file,
output_dir=output_path,
format=drill_format,
units="mm",
)
return {
"success": True,
"gerber_files": gerber_files,
"drill_files": drill_files,
"project_path": project_path,
"output_dir": output_path,
}
except Exception as e:
error_msg = f"Failed to generate Gerbers: {e}"
context_logger.error(
error_msg, component="CIRCUIT", exception=str(e)
)
return {"success": False, "error": error_msg}
[docs]
def simulate(self):
"""
Create a simulator instance for this circuit.
This method provides access to circuit simulation capabilities using
PySpice as the backend. The returned simulator object can be used to
run various analyses such as DC operating point, transient, and AC.
Returns:
CircuitSimulator: Simulator object for running analyses
Example:
>>> circuit = voltage_divider()
>>> sim = circuit.simulate()
>>> result = sim.operating_point()
Raises:
ImportError: If simulation dependencies are not installed
CircuitSynthError: If circuit cannot be converted for simulation
"""
try:
from ..simulation import CircuitSimulator
except ImportError as e:
context_logger.error(
"Simulation module not available", component="CIRCUIT", error=str(e)
)
raise ImportError(
"Circuit simulation requires PySpice and its dependencies. "
"Install with: pip install circuit-synth[simulation]"
) from e
context_logger.info(
"Creating simulator for circuit",
component="CIRCUIT",
circuit_name=self.name,
)
return CircuitSimulator(self)
[docs]
def simulator(self):
"""
Alias for simulate() method for backward compatibility.
Returns:
CircuitSimulator: Simulator object for running analyses
"""
return self.simulate()
@property
def nets(self):
"""
Return a dictionary of all nets in this circuit.
Returns:
Dict[str, Net]: Dictionary of nets keyed by net name
"""
return self._nets