Phase 3 features bringing the server to 27 tools: - Stepped/multi-run .raw file parsing (.step, .mc, .temp) - Stability analysis (gain/phase margin from AC loop gain) - Power analysis (average, RMS, efficiency, power factor) - Safe waveform expression evaluator (recursive-descent parser) - Component value optimizer (binary search + coordinate descent) - Batch simulation: parameter sweep, temperature sweep, Monte Carlo - .asc schematic generation from templates (RC filter, divider, inverting amp) - Touchstone .s1p/.s2p/.snp S-parameter file parsing - 7 new netlist templates (diff amp, common emitter, buck, LDO, oscillator, H-bridge) - Full ruff lint and format compliance across all modules
354 lines
10 KiB
Python
354 lines
10 KiB
Python
"""Run LTspice simulations via Wine batch mode."""
|
|
|
|
import asyncio
|
|
import shutil
|
|
import tempfile
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from .config import (
|
|
DEFAULT_TIMEOUT,
|
|
LTSPICE_EXE,
|
|
MAX_RAW_FILE_SIZE,
|
|
get_wine_env,
|
|
validate_installation,
|
|
)
|
|
from .raw_parser import RawFile, parse_raw_file
|
|
|
|
|
|
@dataclass
|
|
class SimulationResult:
|
|
"""Result of a simulation run."""
|
|
|
|
success: bool
|
|
raw_file: Path | None
|
|
log_file: Path | None
|
|
raw_data: RawFile | None
|
|
error: str | None
|
|
stdout: str
|
|
stderr: str
|
|
elapsed_seconds: float
|
|
|
|
|
|
async def run_simulation(
|
|
schematic_path: Path | str,
|
|
timeout: float = DEFAULT_TIMEOUT,
|
|
work_dir: Path | str | None = None,
|
|
parse_results: bool = True,
|
|
) -> SimulationResult:
|
|
"""Run an LTspice simulation in batch mode.
|
|
|
|
Args:
|
|
schematic_path: Path to .asc schematic file
|
|
timeout: Maximum simulation time in seconds
|
|
work_dir: Working directory for output files (temp dir if None)
|
|
parse_results: Whether to parse the .raw file
|
|
|
|
Returns:
|
|
SimulationResult with status and data
|
|
"""
|
|
import time
|
|
|
|
start_time = time.monotonic()
|
|
|
|
# Validate installation
|
|
ok, msg = validate_installation()
|
|
if not ok:
|
|
return SimulationResult(
|
|
success=False,
|
|
raw_file=None,
|
|
log_file=None,
|
|
raw_data=None,
|
|
error=msg,
|
|
stdout="",
|
|
stderr="",
|
|
elapsed_seconds=0,
|
|
)
|
|
|
|
schematic_path = Path(schematic_path).resolve()
|
|
if not schematic_path.exists():
|
|
return SimulationResult(
|
|
success=False,
|
|
raw_file=None,
|
|
log_file=None,
|
|
raw_data=None,
|
|
error=f"Schematic not found: {schematic_path}",
|
|
stdout="",
|
|
stderr="",
|
|
elapsed_seconds=0,
|
|
)
|
|
|
|
# Set up working directory
|
|
temp_dir = None
|
|
if work_dir:
|
|
work_dir = Path(work_dir)
|
|
work_dir.mkdir(parents=True, exist_ok=True)
|
|
else:
|
|
temp_dir = tempfile.mkdtemp(prefix="ltspice_")
|
|
work_dir = Path(temp_dir)
|
|
|
|
try:
|
|
# Copy schematic to work directory
|
|
work_schematic = work_dir / schematic_path.name
|
|
shutil.copy2(schematic_path, work_schematic)
|
|
|
|
# Copy any .lib, .sub files from the same directory
|
|
src_dir = schematic_path.parent
|
|
for ext in [".lib", ".sub", ".inc", ".model"]:
|
|
for f in src_dir.glob(f"*{ext}"):
|
|
shutil.copy2(f, work_dir / f.name)
|
|
|
|
# Convert path to Windows format for Wine
|
|
# Wine maps Z: to root filesystem
|
|
win_path = "Z:" + str(work_schematic).replace("/", "\\")
|
|
|
|
# Build command
|
|
cmd = [
|
|
"wine",
|
|
str(LTSPICE_EXE),
|
|
"-b", # Batch mode
|
|
win_path,
|
|
]
|
|
|
|
# Run simulation
|
|
env = get_wine_env()
|
|
|
|
process = await asyncio.create_subprocess_exec(
|
|
*cmd,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
env=env,
|
|
cwd=str(work_dir),
|
|
)
|
|
|
|
try:
|
|
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
|
process.communicate(), timeout=timeout
|
|
)
|
|
except TimeoutError:
|
|
process.kill()
|
|
await process.wait()
|
|
return SimulationResult(
|
|
success=False,
|
|
raw_file=None,
|
|
log_file=None,
|
|
raw_data=None,
|
|
error=f"Simulation timed out after {timeout} seconds",
|
|
stdout="",
|
|
stderr="",
|
|
elapsed_seconds=time.monotonic() - start_time,
|
|
)
|
|
|
|
stdout = stdout_bytes.decode("utf-8", errors="replace")
|
|
stderr = stderr_bytes.decode("utf-8", errors="replace")
|
|
|
|
elapsed = time.monotonic() - start_time
|
|
|
|
# Look for output files
|
|
raw_file = work_schematic.with_suffix(".raw")
|
|
log_file = work_schematic.with_suffix(".log")
|
|
|
|
if not raw_file.exists():
|
|
# Check for error in log
|
|
error_msg = "Simulation failed - no .raw file produced"
|
|
if log_file.exists():
|
|
log_content = log_file.read_text(errors="replace")
|
|
if "Error" in log_content or "error" in log_content:
|
|
error_msg = f"Simulation error: {log_content[:500]}"
|
|
|
|
return SimulationResult(
|
|
success=False,
|
|
raw_file=None,
|
|
log_file=log_file if log_file.exists() else None,
|
|
raw_data=None,
|
|
error=error_msg,
|
|
stdout=stdout,
|
|
stderr=stderr,
|
|
elapsed_seconds=elapsed,
|
|
)
|
|
|
|
# Check file size
|
|
if raw_file.stat().st_size > MAX_RAW_FILE_SIZE:
|
|
return SimulationResult(
|
|
success=True,
|
|
raw_file=raw_file,
|
|
log_file=log_file if log_file.exists() else None,
|
|
raw_data=None,
|
|
error=f"Raw file too large to parse ({raw_file.stat().st_size} bytes)",
|
|
stdout=stdout,
|
|
stderr=stderr,
|
|
elapsed_seconds=elapsed,
|
|
)
|
|
|
|
# Parse results
|
|
raw_data = None
|
|
parse_error = None
|
|
if parse_results:
|
|
try:
|
|
raw_data = parse_raw_file(raw_file)
|
|
except Exception as e:
|
|
parse_error = f"Failed to parse .raw file: {e}"
|
|
|
|
return SimulationResult(
|
|
success=True,
|
|
raw_file=raw_file,
|
|
log_file=log_file if log_file.exists() else None,
|
|
raw_data=raw_data,
|
|
error=parse_error,
|
|
stdout=stdout,
|
|
stderr=stderr,
|
|
elapsed_seconds=elapsed,
|
|
)
|
|
|
|
finally:
|
|
# Clean up temp directory (but not user-specified work_dir)
|
|
if temp_dir:
|
|
# Keep files for debugging if simulation failed
|
|
pass # Let user specify cleanup behavior
|
|
|
|
|
|
async def run_netlist(
|
|
netlist_path: Path | str,
|
|
timeout: float = DEFAULT_TIMEOUT,
|
|
work_dir: Path | str | None = None,
|
|
parse_results: bool = True,
|
|
) -> SimulationResult:
|
|
"""Run an LTspice simulation from a netlist (.cir/.net) file.
|
|
|
|
This is similar to run_simulation but for netlist files.
|
|
|
|
Args:
|
|
netlist_path: Path to .cir or .net netlist file
|
|
timeout: Maximum simulation time in seconds
|
|
work_dir: Working directory for output files
|
|
parse_results: Whether to parse the .raw file
|
|
|
|
Returns:
|
|
SimulationResult with status and data
|
|
"""
|
|
import time
|
|
|
|
start_time = time.monotonic()
|
|
|
|
ok, msg = validate_installation()
|
|
if not ok:
|
|
return SimulationResult(
|
|
success=False,
|
|
raw_file=None,
|
|
log_file=None,
|
|
raw_data=None,
|
|
error=msg,
|
|
stdout="",
|
|
stderr="",
|
|
elapsed_seconds=0,
|
|
)
|
|
|
|
netlist_path = Path(netlist_path).resolve()
|
|
if not netlist_path.exists():
|
|
return SimulationResult(
|
|
success=False,
|
|
raw_file=None,
|
|
log_file=None,
|
|
raw_data=None,
|
|
error=f"Netlist not found: {netlist_path}",
|
|
stdout="",
|
|
stderr="",
|
|
elapsed_seconds=0,
|
|
)
|
|
|
|
temp_dir = None
|
|
if work_dir:
|
|
work_dir = Path(work_dir)
|
|
work_dir.mkdir(parents=True, exist_ok=True)
|
|
else:
|
|
temp_dir = tempfile.mkdtemp(prefix="ltspice_")
|
|
work_dir = Path(temp_dir)
|
|
|
|
try:
|
|
# Copy netlist to work directory
|
|
work_netlist = work_dir / netlist_path.name
|
|
shutil.copy2(netlist_path, work_netlist)
|
|
|
|
# Copy included files
|
|
src_dir = netlist_path.parent
|
|
for ext in [".lib", ".sub", ".inc", ".model"]:
|
|
for f in src_dir.glob(f"*{ext}"):
|
|
shutil.copy2(f, work_dir / f.name)
|
|
|
|
win_path = "Z:" + str(work_netlist).replace("/", "\\")
|
|
|
|
cmd = ["wine", str(LTSPICE_EXE), "-b", win_path]
|
|
env = get_wine_env()
|
|
|
|
process = await asyncio.create_subprocess_exec(
|
|
*cmd,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
env=env,
|
|
cwd=str(work_dir),
|
|
)
|
|
|
|
try:
|
|
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
|
process.communicate(), timeout=timeout
|
|
)
|
|
except TimeoutError:
|
|
process.kill()
|
|
await process.wait()
|
|
return SimulationResult(
|
|
success=False,
|
|
raw_file=None,
|
|
log_file=None,
|
|
raw_data=None,
|
|
error=f"Simulation timed out after {timeout} seconds",
|
|
stdout="",
|
|
stderr="",
|
|
elapsed_seconds=time.monotonic() - start_time,
|
|
)
|
|
|
|
stdout = stdout_bytes.decode("utf-8", errors="replace")
|
|
stderr = stderr_bytes.decode("utf-8", errors="replace")
|
|
elapsed = time.monotonic() - start_time
|
|
|
|
raw_file = work_netlist.with_suffix(".raw")
|
|
log_file = work_netlist.with_suffix(".log")
|
|
|
|
if not raw_file.exists():
|
|
error_msg = "Simulation failed - no .raw file produced"
|
|
if log_file.exists():
|
|
log_content = log_file.read_text(errors="replace")
|
|
if "error" in log_content.lower():
|
|
error_msg = f"Simulation error: {log_content[:500]}"
|
|
|
|
return SimulationResult(
|
|
success=False,
|
|
raw_file=None,
|
|
log_file=log_file if log_file.exists() else None,
|
|
raw_data=None,
|
|
error=error_msg,
|
|
stdout=stdout,
|
|
stderr=stderr,
|
|
elapsed_seconds=elapsed,
|
|
)
|
|
|
|
raw_data = None
|
|
parse_error = None
|
|
if parse_results and raw_file.stat().st_size <= MAX_RAW_FILE_SIZE:
|
|
try:
|
|
raw_data = parse_raw_file(raw_file)
|
|
except Exception as e:
|
|
parse_error = f"Failed to parse .raw file: {e}"
|
|
|
|
return SimulationResult(
|
|
success=True,
|
|
raw_file=raw_file,
|
|
log_file=log_file if log_file.exists() else None,
|
|
raw_data=raw_data,
|
|
error=parse_error,
|
|
stdout=stdout,
|
|
stderr=stderr,
|
|
elapsed_seconds=elapsed,
|
|
)
|
|
finally:
|
|
pass # Keep temp files for debugging
|