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
|
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
|
@dataclass
|
||||||
class SimulationResult:
|
class SimulationResult:
|
||||||
"""Result of a simulation run."""
|
"""Result of a simulation run."""
|
||||||
@ -90,13 +102,13 @@ async def run_simulation(
|
|||||||
try:
|
try:
|
||||||
# Copy schematic to work directory
|
# Copy schematic to work directory
|
||||||
work_schematic = work_dir / schematic_path.name
|
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
|
# Copy any .lib, .sub files from the same directory
|
||||||
src_dir = schematic_path.parent
|
src_dir = schematic_path.parent
|
||||||
for ext in [".lib", ".sub", ".inc", ".model"]:
|
for ext in [".lib", ".sub", ".inc", ".model"]:
|
||||||
for f in src_dir.glob(f"*{ext}"):
|
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
|
# Convert path to Windows format for Wine
|
||||||
# Wine maps Z: to root filesystem
|
# Wine maps Z: to root filesystem
|
||||||
@ -267,13 +279,13 @@ async def run_netlist(
|
|||||||
try:
|
try:
|
||||||
# Copy netlist to work directory
|
# Copy netlist to work directory
|
||||||
work_netlist = work_dir / netlist_path.name
|
work_netlist = work_dir / netlist_path.name
|
||||||
shutil.copy2(netlist_path, work_netlist)
|
_safe_copy(netlist_path, work_netlist)
|
||||||
|
|
||||||
# Copy included files
|
# Copy included files
|
||||||
src_dir = netlist_path.parent
|
src_dir = netlist_path.parent
|
||||||
for ext in [".lib", ".sub", ".inc", ".model"]:
|
for ext in [".lib", ".sub", ".inc", ".model"]:
|
||||||
for f in src_dir.glob(f"*{ext}"):
|
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("/", "\\")
|
win_path = "Z:" + str(work_netlist).replace("/", "\\")
|
||||||
|
|
||||||
|
|||||||
@ -207,3 +207,34 @@ class TestColpittsOscillator:
|
|||||||
# With L=1u, C1=C2=100p: Cseries = 50p
|
# With L=1u, C1=C2=100p: Cseries = 50p
|
||||||
# f = 1/(2*pi*sqrt(1e-6 * 50e-12)) ~ 22.5 MHz
|
# f = 1/(2*pi*sqrt(1e-6 * 50e-12)) ~ 22.5 MHz
|
||||||
# This is quite high, but we just verify oscillation exists
|
# 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