Fix SameFileError in batch sweeps; guard self-copy in runner
run_parameter_sweep/run_monte_carlo write each variant .cir into a work_dir, then call run_netlist with that same work_dir -- which tried to shutil.copy2 the file onto itself (SameFileError), so every batch run failed before simulating. Add _safe_copy() that skips when src and dst resolve to the same path, used at all four staging-copy sites in run_simulation/run_netlist. Caught by headless claude-cli testing of the batch tools (no batch test existed). Adds test_runner.py (_safe_copy unit tests) and a TestBatchSweeps integration class.
This commit is contained in:
parent
32a7a2a0dc
commit
9c252606f7
@ -16,6 +16,18 @@ from .config import (
|
||||
from .raw_parser import RawFile, parse_raw_file
|
||||
|
||||
|
||||
def _safe_copy(src: Path, dst: Path) -> None:
|
||||
"""Copy src to dst, skipping when they are the same file.
|
||||
|
||||
Batch runs write each variant netlist directly into the work_dir and then
|
||||
pass that same work_dir to run_simulation/run_netlist, so the staging copy
|
||||
would otherwise be a file-onto-itself (shutil raises SameFileError).
|
||||
"""
|
||||
if Path(src).resolve() == Path(dst).resolve():
|
||||
return
|
||||
shutil.copy2(src, dst)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SimulationResult:
|
||||
"""Result of a simulation run."""
|
||||
@ -90,13 +102,13 @@ async def run_simulation(
|
||||
try:
|
||||
# Copy schematic to work directory
|
||||
work_schematic = work_dir / schematic_path.name
|
||||
shutil.copy2(schematic_path, work_schematic)
|
||||
_safe_copy(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)
|
||||
_safe_copy(f, work_dir / f.name)
|
||||
|
||||
# Convert path to Windows format for Wine
|
||||
# Wine maps Z: to root filesystem
|
||||
@ -267,13 +279,13 @@ async def run_netlist(
|
||||
try:
|
||||
# Copy netlist to work directory
|
||||
work_netlist = work_dir / netlist_path.name
|
||||
shutil.copy2(netlist_path, work_netlist)
|
||||
_safe_copy(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)
|
||||
_safe_copy(f, work_dir / f.name)
|
||||
|
||||
win_path = "Z:" + str(work_netlist).replace("/", "\\")
|
||||
|
||||
|
||||
@ -207,3 +207,34 @@ class TestColpittsOscillator:
|
||||
# With L=1u, C1=C2=100p: Cseries = 50p
|
||||
# f = 1/(2*pi*sqrt(1e-6 * 50e-12)) ~ 22.5 MHz
|
||||
# This is quite high, but we just verify oscillation exists
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestBatchSweeps:
|
||||
"""End-to-end batch runs (regression for the SameFileError staging bug)."""
|
||||
|
||||
_RC = (
|
||||
"* RC lowpass sweep\n"
|
||||
".param Rval=1k\n"
|
||||
"V1 in 0 AC 1\n"
|
||||
"R1 in out {Rval}\n"
|
||||
"C1 out 0 159n\n"
|
||||
".ac dec 10 1 1meg\n"
|
||||
".end\n"
|
||||
)
|
||||
|
||||
async def test_parameter_sweep_runs_all(self, ltspice_available):
|
||||
from mcltspice.batch import run_parameter_sweep
|
||||
|
||||
result = await run_parameter_sweep(
|
||||
self._RC, "Rval", [500.0, 1000.0, 2000.0], timeout=90
|
||||
)
|
||||
assert result.failure_count == 0, f"sweep failures: {result.failure_count}"
|
||||
assert result.success_count == 3
|
||||
|
||||
async def test_monte_carlo_runs_all(self, ltspice_available):
|
||||
from mcltspice.batch import run_monte_carlo
|
||||
|
||||
result = await run_monte_carlo(self._RC, 4, {"R1": 0.05}, timeout=90, seed=1)
|
||||
assert result.failure_count == 0, f"mc failures: {result.failure_count}"
|
||||
assert result.success_count == 4
|
||||
|
||||
27
tests/test_runner.py
Normal file
27
tests/test_runner.py
Normal file
@ -0,0 +1,27 @@
|
||||
"""Unit tests for runner helpers (no LTspice needed)."""
|
||||
|
||||
from mcltspice.runner import _safe_copy
|
||||
|
||||
|
||||
class TestSafeCopy:
|
||||
def test_skips_same_file(self, tmp_path):
|
||||
# Copying a file onto itself must not raise (was SameFileError in batch runs).
|
||||
f = tmp_path / "a.cir"
|
||||
f.write_text("hello")
|
||||
_safe_copy(f, f)
|
||||
assert f.read_text() == "hello"
|
||||
|
||||
def test_skips_same_file_via_unnormalized_path(self, tmp_path):
|
||||
# Same file referenced through a "." segment -> resolve() equal -> skip.
|
||||
f = tmp_path / "a.cir"
|
||||
f.write_text("x")
|
||||
alias = tmp_path / "." / "a.cir"
|
||||
_safe_copy(f, alias) # must not raise
|
||||
assert f.read_text() == "x"
|
||||
|
||||
def test_copies_distinct_files(self, tmp_path):
|
||||
src = tmp_path / "a.cir"
|
||||
src.write_text("data")
|
||||
dst = tmp_path / "b.cir"
|
||||
_safe_copy(src, dst)
|
||||
assert dst.read_text() == "data"
|
||||
Loading…
x
Reference in New Issue
Block a user