Skip to content

Commit bcfdca4

Browse files
committed
WIP: parametric sweeps and redo generate and submit
1 parent e25a09c commit bcfdca4

25 files changed

Lines changed: 825 additions & 715 deletions

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,14 +79,14 @@ client = DSClient()
7979
client.systems.establish_credentials("frontera")
8080

8181
# Submit a job
82-
job_request = client.jobs.generate_request(
82+
job_request = client.jobs.generate(
8383
app_id="matlab-r2023a",
8484
input_dir_uri="/MyData/analysis/input/",
8585
script_filename="run_analysis.m",
8686
max_minutes=30,
8787
allocation="your_allocation"
8888
)
89-
job = client.jobs.submit_request(job_request)
89+
job = client.jobs.submit(job_request)
9090
final_status = job.monitor()
9191
```
9292

_toc.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,15 @@ parts:
2020
- file: docs/examples/mpm
2121
- file: docs/examples/opensees
2222
- file: docs/examples/openfoam
23+
- file: docs/examples/pylauncher
2324
- file: docs/examples/tms_credentials
2425
- file: docs/examples/database
2526
- caption: API Reference
2627
chapters:
2728
- file: docs/api/index
2829
- file: docs/api/client
2930
- file: docs/api/jobs
31+
- file: docs/api/launcher
3032
- file: docs/api/files
3133
- file: docs/api/apps
3234
- file: docs/api/systems

dapi/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,12 @@
3535
>>> files = client.files.list("/MyData/uploads/")
3636
3737
>>> # Job submission and monitoring
38-
>>> job_request = client.jobs.generate_request(
38+
>>> job_request = client.jobs.generate(
3939
... app_id="matlab-r2023a",
4040
... input_dir_uri="/MyData/analysis/input/",
4141
... script_filename="run_analysis.m"
4242
... )
43-
>>> job = client.jobs.submit_request(job_request)
43+
>>> job = client.jobs.submit(job_request)
4444
>>> final_status = job.monitor()
4545
4646
>>> # Database access

dapi/client.py

Lines changed: 114 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from . import files as files_module
66
from . import jobs as jobs_module
77
from . import systems as systems_module
8+
from . import launcher as launcher_module
89
from .db.accessor import DatabaseAccessor
910

1011
# Import only the necessary classes/functions from jobs
@@ -366,6 +367,103 @@ def revoke_credentials(
366367
)
367368

368369

370+
class ParametricSweepMethods:
371+
"""Interface for PyLauncher parameter sweeps.
372+
373+
- ``generate`` — preview (``preview=True``) or write sweep files.
374+
- ``submit`` — submit the sweep job to TACC.
375+
"""
376+
377+
def __init__(self, tapis_client):
378+
self._tapis = tapis_client
379+
380+
def generate(
381+
self,
382+
base_command: str,
383+
sweep: Dict[str, Any],
384+
directory: str = None,
385+
*,
386+
placeholder_style: str = "token",
387+
debug: str = None,
388+
preview: bool = False,
389+
):
390+
"""Generate PyLauncher sweep files or preview the parameter grid.
391+
392+
With ``preview=True``, returns a DataFrame of all parameter
393+
combinations — no files are written.
394+
395+
Otherwise, expands *base_command* into one command per combination
396+
and writes ``runsList.txt`` and ``call_pylauncher.py`` into
397+
*directory*. Returns the list of generated commands.
398+
399+
Args:
400+
base_command: Command template with placeholders matching sweep keys.
401+
sweep: Mapping of placeholder name to sequence of values.
402+
directory: Directory to write files into (created if needed).
403+
Required when *preview* is ``False``.
404+
placeholder_style: ``"token"`` (default) for bare ``ALPHA``,
405+
or ``"braces"`` for ``{ALPHA}``.
406+
debug: Optional debug string (e.g. ``"host+job"``).
407+
preview: If ``True``, return a DataFrame (dry run).
408+
409+
Returns:
410+
``List[str]`` of commands, or ``pandas.DataFrame`` when
411+
*preview* is ``True``.
412+
"""
413+
return launcher_module.generate_sweep(
414+
base_command, sweep, directory,
415+
placeholder_style=placeholder_style, debug=debug, preview=preview,
416+
)
417+
418+
def submit(
419+
self,
420+
directory: str,
421+
app_id: str,
422+
allocation: str,
423+
*,
424+
node_count: Optional[int] = None,
425+
cores_per_node: Optional[int] = None,
426+
max_minutes: Optional[int] = None,
427+
queue: Optional[str] = None,
428+
**kwargs,
429+
):
430+
"""Submit a PyLauncher sweep job.
431+
432+
Translates *directory* to a Tapis URI, builds a job request with
433+
``call_pylauncher.py`` as the script, and submits it.
434+
435+
Args:
436+
directory: Path to the input directory containing
437+
``runsList.txt`` and ``call_pylauncher.py``
438+
(e.g. ``"/MyData/sweep/"``).
439+
app_id: Tapis application ID (e.g. ``"openseespy-s3"``).
440+
allocation: TACC allocation to charge.
441+
node_count: Number of compute nodes.
442+
cores_per_node: Cores per node.
443+
max_minutes: Maximum runtime in minutes.
444+
queue: Execution queue name.
445+
**kwargs: Additional arguments passed to
446+
``ds.jobs.generate()``.
447+
448+
Returns:
449+
SubmittedJob: A job object for monitoring via ``.monitor()``.
450+
"""
451+
input_uri = files_module.get_ds_path_uri(self._tapis, directory)
452+
job_request = jobs_module.generate_job_request(
453+
tapis_client=self._tapis,
454+
app_id=app_id,
455+
input_dir_uri=input_uri,
456+
script_filename="call_pylauncher.py",
457+
node_count=node_count,
458+
cores_per_node=cores_per_node,
459+
max_minutes=max_minutes,
460+
queue=queue,
461+
allocation=allocation,
462+
**kwargs,
463+
)
464+
return jobs_module.submit_job_request(self._tapis, job_request)
465+
466+
369467
class JobMethods:
370468
"""Interface for Tapis job submission, monitoring, and management.
371469
@@ -374,6 +472,10 @@ class JobMethods:
374472
375473
Args:
376474
tapis_client (Tapis): Authenticated Tapis client instance.
475+
476+
Attributes:
477+
parametric_sweep (ParametricSweepMethods): Interface for PyLauncher
478+
parameter sweep generation.
377479
"""
378480

379481
def __init__(self, tapis_client: Tapis):
@@ -383,9 +485,10 @@ def __init__(self, tapis_client: Tapis):
383485
tapis_client (Tapis): Authenticated Tapis client instance.
384486
"""
385487
self._tapis = tapis_client
488+
self.parametric_sweep = ParametricSweepMethods(tapis_client)
386489

387490
# Method to generate the request dictionary
388-
def generate_request(
491+
def generate(
389492
self,
390493
app_id: str,
391494
input_dir_uri: str,
@@ -456,7 +559,7 @@ def generate_request(
456559
JobSubmissionError: If job request generation fails.
457560
458561
Example:
459-
>>> job_request = ds.jobs.generate_request(
562+
>>> job_request = ds.jobs.generate(
460563
... app_id="matlab-r2023a",
461564
... input_dir_uri="tapis://designsafe.storage.default/username/input/",
462565
... script_filename="run_analysis.m",
@@ -491,27 +594,26 @@ def generate_request(
491594
)
492595

493596
# Method to submit the generated request dictionary
494-
def submit_request(self, job_request: Dict[str, Any]) -> SubmittedJob:
495-
"""Submit a pre-generated job request dictionary to Tapis.
597+
def submit(self, job_request: Dict[str, Any]) -> SubmittedJob:
598+
"""Submit a job request dictionary to Tapis.
496599
497-
This method takes a complete job request dictionary (typically generated
498-
by generate_request) and submits it to Tapis for execution.
600+
Takes a job request dictionary (typically from ``generate()``) and
601+
submits it to Tapis for execution.
499602
500603
Args:
501-
job_request (Dict[str, Any]): Complete job request dictionary containing
502-
all necessary job parameters and configuration.
604+
job_request (Dict[str, Any]): Complete job request dictionary.
503605
504606
Returns:
505607
SubmittedJob: A SubmittedJob object for monitoring and managing the job.
506608
507609
Raises:
508610
ValueError: If job_request is not a dictionary.
509-
JobSubmissionError: If the Tapis submission fails or encounters an error.
611+
JobSubmissionError: If the Tapis submission fails.
510612
511613
Example:
512-
>>> job_request = ds.jobs.generate_request(...)
513-
>>> submitted_job = ds.jobs.submit_request(job_request)
514-
>>> print(f"Job submitted with UUID: {submitted_job.uuid}")
614+
>>> job_request = ds.jobs.generate(...)
615+
>>> job = ds.jobs.submit(job_request)
616+
>>> print(f"Job submitted with UUID: {job.uuid}")
515617
"""
516618
return jobs_module.submit_job_request(self._tapis, job_request)
517619

dapi/exceptions.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ class JobSubmissionError(DapiException):
155155
156156
Example:
157157
>>> try:
158-
... job = client.jobs.submit_request(invalid_job_request)
158+
... job = client.jobs.submit(invalid_job_request)
159159
... except JobSubmissionError as e:
160160
... print(f"Job submission failed: {e}")
161161
... if e.response:

dapi/launcher.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
"""PyLauncher parameter sweep utilities for DesignSafe.
2+
3+
This module provides functions for generating parameter sweeps and writing
4+
PyLauncher input files. These are pure local operations — PyLauncher itself
5+
runs on TACC compute nodes, not locally.
6+
7+
Functions:
8+
generate_sweep: Generate sweep commands and optionally write PyLauncher input files.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
from itertools import product
14+
from pathlib import Path
15+
from typing import Any, List, Mapping, Sequence, Union
16+
17+
import pandas as pd
18+
19+
20+
def _validate_sweep(sweep: Mapping[str, Sequence[Any]]) -> None:
21+
"""Validate sweep values are non-empty, non-string sequences."""
22+
for k, vals in sweep.items():
23+
if not isinstance(vals, Sequence) or isinstance(vals, (str, bytes)):
24+
raise TypeError(f"sweep[{k!r}] must be a non-string sequence of values.")
25+
if len(vals) == 0:
26+
raise ValueError(f"sweep[{k!r}] is empty; provide at least one value.")
27+
28+
29+
def _expand_commands(
30+
base_command: str,
31+
sweep: Mapping[str, Sequence[Any]],
32+
placeholder_style: str,
33+
) -> List[str]:
34+
"""Expand a command template into all parameter combinations."""
35+
if not sweep:
36+
return [base_command]
37+
38+
_validate_sweep(sweep)
39+
40+
if placeholder_style not in ("token", "braces"):
41+
raise ValueError("placeholder_style must be 'token' or 'braces'.")
42+
43+
keys = list(sweep.keys())
44+
commands: List[str] = []
45+
for combo in product(*[sweep[k] for k in keys]):
46+
cmd = base_command
47+
for k, v in zip(keys, combo):
48+
if placeholder_style == "token":
49+
cmd = cmd.replace(k, str(v))
50+
else:
51+
cmd = cmd.replace("{" + k + "}", str(v))
52+
commands.append(cmd)
53+
54+
return commands
55+
56+
57+
def generate_sweep(
58+
base_command: str,
59+
sweep: Mapping[str, Sequence[Any]],
60+
directory: Union[str, Path, None] = None,
61+
*,
62+
placeholder_style: str = "token",
63+
debug: str | None = None,
64+
preview: bool = False,
65+
) -> Union[List[str], pd.DataFrame]:
66+
"""Generate sweep commands and write PyLauncher input files.
67+
68+
When *preview* is ``True``, returns a DataFrame of all parameter
69+
combinations without writing any files — useful for inspecting the
70+
sweep in a notebook before committing.
71+
72+
When *preview* is ``False`` (default), expands *base_command* into one
73+
command per parameter combination and writes ``runsList.txt`` and
74+
``call_pylauncher.py`` into *directory*.
75+
76+
Args:
77+
base_command: Command template containing placeholders that match
78+
keys in *sweep*. Environment variables like ``$WORK`` or
79+
``$SLURM_JOB_ID`` are left untouched.
80+
sweep: Mapping of placeholder name to a sequence of values.
81+
Example: ``{"ALPHA": [0.3, 0.5], "BETA": [1, 2]}``.
82+
directory: Directory to write files into. Created if it doesn't
83+
exist. Required when *preview* is ``False``.
84+
placeholder_style: How placeholders appear in *base_command*:
85+
86+
- ``"token"`` (default): bare tokens, e.g. ``ALPHA``
87+
- ``"braces"``: brace-wrapped, e.g. ``{ALPHA}``
88+
89+
debug: Optional debug string passed to ``ClassicLauncher``
90+
(e.g. ``"host+job"``). Ignored when *preview* is ``True``.
91+
preview: If ``True``, return a DataFrame of parameter combinations
92+
without writing files.
93+
94+
Returns:
95+
``List[str]`` of generated commands when *preview* is ``False``,
96+
or a ``pandas.DataFrame`` of parameter combinations when ``True``.
97+
98+
Raises:
99+
TypeError: If a sweep value is not a non-string sequence.
100+
ValueError: If a sweep value is empty, *placeholder_style* is
101+
invalid, or *directory* is missing when *preview* is ``False``.
102+
"""
103+
if sweep:
104+
_validate_sweep(sweep)
105+
106+
if preview:
107+
if not sweep:
108+
return pd.DataFrame()
109+
keys = list(sweep.keys())
110+
rows = [dict(zip(keys, combo)) for combo in product(*[sweep[k] for k in keys])]
111+
return pd.DataFrame(rows)
112+
113+
if directory is None:
114+
raise ValueError("directory is required when preview=False.")
115+
116+
commands = _expand_commands(base_command, sweep, placeholder_style)
117+
118+
dirpath = Path(directory)
119+
dirpath.mkdir(parents=True, exist_ok=True)
120+
121+
# Write runsList.txt
122+
(dirpath / "runsList.txt").write_text(
123+
"\n".join(commands) + "\n", encoding="utf-8"
124+
)
125+
126+
# Write call_pylauncher.py
127+
if debug is not None:
128+
script = (
129+
"import pylauncher\n"
130+
f'pylauncher.ClassicLauncher("runsList.txt", debug="{debug}")\n'
131+
)
132+
else:
133+
script = (
134+
"import pylauncher\n"
135+
'pylauncher.ClassicLauncher("runsList.txt")\n'
136+
)
137+
(dirpath / "call_pylauncher.py").write_text(script, encoding="utf-8")
138+
139+
return commands

0 commit comments

Comments
 (0)