@@ -1354,13 +1354,19 @@ def run_local(
13541354
13551355 Args:
13561356 palace_sif_path: Path to Palace Apptainer SIF file.
1357- Only used when ``use_apptainer=True``.
1358- If None, uses PALACE_SIF environment variable.
1357+ Only used when ``use_apptainer=True`` and no executable is
1358+ selected.
1359+ If None, tries PALACE_SIF, then local defaults:
1360+ ``./Palace.sif``, ``./palace.sif``,
1361+ ``./bin/Palace.sif``, ``./bin/palace.sif``.
13591362 palace_executable: Path to Palace executable.
1360- Only used when ``use_apptainer=False``.
1361- If None, uses PALACE_EXECUTABLE environment variable or "palace".
1363+ If provided, runs Palace directly and overrides
1364+ ``use_apptainer``.
1365+ If None, tries PALACE_EXECUTABLE, then ``./bin/palace``,
1366+ then ``palace`` on PATH.
13621367 use_apptainer: If True (default), run via Apptainer using SIF file.
13631368 If False, run Palace executable directly.
1369+ Ignored when ``palace_executable`` is explicitly provided.
13641370 num_processes: Number of MPI processes. If None (default),
13651371 uses all available CPUs.
13661372 num_threads: Number of OpenMP threads to use for OpenMP builds, default is 1
@@ -1387,13 +1393,14 @@ def run_local(
13871393 >>> # Using Apptainer with explicit path
13881394 >>> results = sim.run_local(palace_sif_path="/path/to/Palace.sif")
13891395 >>>
1390- >>> # Using direct Palace installation
1391- >>> results = sim.run_local(use_apptainer=False)
1396+ >>> # Auto-discovery with no options:
1397+ >>> # tries local SIF first, then executable defaults
1398+ >>> results = sim.run_local()
13921399 >>>
13931400 >>> # Using direct Palace with custom executable path
1394- >>> results = sim.run_local(
1395- ... use_apptainer=False, palace_executable="/usr/local/bin/palace"
1396- ... )
1401+ >>> results = sim.run_local(palace_executable="/usr/local/bin/palace")
1402+ >>> # Using a locally built Palace binary
1403+ >>> results = sim.run_local(palace_executable="./bin/palace" )
13971404 >>> # For DrivenSim: `results` is SParams -> results.s21.db
13981405 >>> # For eigen / electrostatic: `results` is dict[str, Path]
13991406 """
@@ -1423,20 +1430,70 @@ def run_local(
14231430 f"Mesh file not found: { mesh_path } . Call mesh() first."
14241431 )
14251432
1426- # Determine Palace command based on use_apptainer flag
1427- if use_apptainer :
1433+ # Auto-discovery when arguments are omitted.
1434+ search_roots = [Path .cwd (), output_dir , output_dir .parent ]
1435+ dedup_roots : list [Path ] = []
1436+ for root in search_roots :
1437+ resolved = root .expanduser ().resolve ()
1438+ if resolved not in dedup_roots :
1439+ dedup_roots .append (resolved )
1440+
1441+ if palace_sif_path is None :
1442+ palace_sif_path = os .environ .get ("PALACE_SIF" )
1443+ if palace_sif_path is None :
1444+ sif_rel_candidates = [
1445+ Path ("Palace.sif" ),
1446+ Path ("palace.sif" ),
1447+ Path ("bin/Palace.sif" ),
1448+ Path ("bin/palace.sif" ),
1449+ ]
1450+ for root in dedup_roots :
1451+ for rel in sif_rel_candidates :
1452+ candidate = (root / rel ).resolve ()
1453+ if candidate .exists ():
1454+ palace_sif_path = str (candidate )
1455+ if verbose :
1456+ logger .info (
1457+ "Auto-discovered Palace SIF: %s" , palace_sif_path
1458+ )
1459+ break
1460+ if palace_sif_path is not None :
1461+ break
1462+
1463+ if palace_executable is None :
1464+ palace_executable = os .environ .get ("PALACE_EXECUTABLE" )
1465+
1466+ if palace_executable is None and palace_sif_path is None :
1467+ for root in dedup_roots :
1468+ candidate = (root / "bin" / "palace" ).resolve ()
1469+ if candidate .exists () and os .access (candidate , os .X_OK ):
1470+ palace_executable = str (candidate )
1471+ if verbose :
1472+ logger .info (
1473+ "Auto-discovered Palace executable: %s" , palace_executable
1474+ )
1475+ break
1476+
1477+ if palace_executable is None and palace_sif_path is None :
1478+ palace_executable = "palace"
1479+
1480+ # palace_executable takes precedence for a simpler API.
1481+ run_with_apptainer = use_apptainer and palace_executable is None
1482+
1483+ if palace_executable is not None and use_apptainer and verbose :
1484+ logger .info (
1485+ "palace_executable was provided; running directly and ignoring "
1486+ "use_apptainer=True."
1487+ )
1488+
1489+ # Determine Palace command based on effective execution mode.
1490+ if run_with_apptainer :
14281491 # Determine Palace SIF path from environment variable or parameter
14291492 if palace_sif_path is None :
1430- palace_sif_path = os .environ .get ("PALACE_SIF" )
1431- if palace_sif_path is None :
1432- raise ValueError (
1433- "Palace SIF path not specified. Either set PALACE_SIF "
1434- "environment variable or pass palace_sif_path parameter."
1435- )
1436- if verbose :
1437- logger .info (
1438- "Using PALACE_SIF from environment: %s" , palace_sif_path
1439- )
1493+ raise ValueError (
1494+ "Palace SIF path not specified. Either set PALACE_SIF, pass "
1495+ "palace_sif_path, or provide a Palace executable."
1496+ )
14401497
14411498 sif_path = Path (palace_sif_path ).expanduser ().resolve ()
14421499
@@ -1472,20 +1529,40 @@ def run_local(
14721529 palace_executable ,
14731530 )
14741531
1475- exe_path = Path (palace_executable ).expanduser ()
1532+ exe_candidate = Path (palace_executable ).expanduser ()
1533+ is_path_like = (
1534+ exe_candidate .is_absolute ()
1535+ or exe_candidate .parent != Path ()
1536+ or str (palace_executable ).startswith ("~" )
1537+ )
14761538
1477- # Check if executable exists
1478- if not exe_path . exists ():
1479- # Try resolving to see if it's in PATH
1480- resolved = shutil . which ( str ( exe_path ) )
1481- if resolved is None :
1539+ if is_path_like :
1540+ # Normalize local paths to absolute paths because subprocess
1541+ # runs with cwd=output_dir.
1542+ exe_path = exe_candidate . resolve ( )
1543+ if not exe_path . exists () :
14821544 raise FileNotFoundError (
14831545 f"Palace executable not found: { exe_path } . "
14841546 "Install Palace directly or provide correct path via "
14851547 "palace_executable parameter."
14861548 )
1549+ else :
1550+ # Resolve command names (e.g. "palace") via PATH.
1551+ resolved = shutil .which (str (exe_candidate ))
1552+ if resolved is None :
1553+ raise FileNotFoundError (
1554+ f"Palace executable not found: { exe_candidate } . "
1555+ "Install Palace directly or provide correct path via "
1556+ "palace_executable parameter."
1557+ )
14871558 exe_path = Path (resolved )
14881559
1560+ if not os .access (exe_path , os .X_OK ):
1561+ raise FileNotFoundError (
1562+ f"Palace executable is not executable: { exe_path } . "
1563+ "Update file permissions or provide a valid executable path."
1564+ )
1565+
14891566 cmd = [
14901567 str (exe_path ),
14911568 "-np" ,
@@ -1497,7 +1574,7 @@ def run_local(
14971574 cmd .extend (["config.json" ])
14981575
14991576 if verbose :
1500- if use_apptainer :
1577+ if run_with_apptainer :
15011578 logger .info ("Running Palace simulation in %s via Apptainer" , output_dir )
15021579 else :
15031580 logger .info ("Running Palace simulation in %s directly" , output_dir )
@@ -1528,7 +1605,7 @@ def run_local(
15281605 error_msg += f"\n \n Stderr:\n { e .stderr } "
15291606 raise RuntimeError (error_msg ) from e
15301607 except FileNotFoundError as e :
1531- if use_apptainer :
1608+ if run_with_apptainer :
15321609 raise RuntimeError (
15331610 "Apptainer not found. Install Apptainer to run local simulations "
15341611 "with use_apptainer=True."
0 commit comments