Source code for jupedsim_scenarios.cli

"""Command-line entry point for jupedsim-scenarios.

    jps-scenarios run scenario.json --seed 42 --out trajectory.sqlite
    jps-scenarios run scenario.zip --out trajectory.sqlite
    jps-scenarios run scenario_dir/ --out trajectory.sqlite
    jps-scenarios sweep scenario.zip --seeds 20 --scale 10 --workers 4 --out results/
    jps-scenarios report results/ --out report.html

Accepts the same inputs as ``load_scenario`` — single self-contained
JSON, ZIP archive, or a directory holding ``*.json`` + ``*.wkt``.
Designed for CI smoke tests and scripted pipelines. Notebook use should
go through the Python API (``run_scenario`` / ``run_sweep``).
"""

from __future__ import annotations

import argparse
import json
import pathlib
import sys

from ._quiet import request_quiet
from .local import _VERSION, run_local
from .runner import CapacityError, load_scenario, run_scenario

[docs] SCENARIO_HELP = ( "Scenario source: a self-contained JSON file, a ZIP archive, " "or a directory holding one JSON + one WKT." )
def _cmd_run(args: argparse.Namespace) -> int: scenario_path = pathlib.Path(args.scenario).resolve() if not scenario_path.exists(): print(f"error: scenario path not found: {scenario_path}", file=sys.stderr) return 2 try: scenario = load_scenario(str(scenario_path)) except (ValueError, OSError) as exc: print(f"error: {exc}", file=sys.stderr) return 2 try: result = run_scenario( scenario, seed=args.seed, dt=args.dt, every_nth_frame=args.every_nth_frame, output_path=args.out, ) # Either invalid args (ValueError) or filesystem trouble writing the # trajectory (OSError / PermissionError on parent.mkdir / sqlite open) # surface as a friendly exit-2 instead of a traceback. except (ValueError, OSError) as exc: print(f"error: {exc}", file=sys.stderr) return 2 # Keep the sqlite only when --out was given AND the run succeeded. # On the failure path with --out, clean up so we don't leave a # partial / misleading trajectory at a known location. keep_sqlite = args.out is not None and result.success try: if not result.success: print( f"error: simulation failed: {result.metrics.get('message', 'unknown')}", file=sys.stderr, ) return 1 summary = { "scenario": str(scenario_path), "seed": result.seed, "model_type": scenario.model_type, "evacuation_time": result.evacuation_time, "total_agents": result.total_agents, "agents_evacuated": result.agents_evacuated, "agents_remaining": result.agents_remaining, # Only report the sqlite path when we're actually keeping the file # (i.e. --out was given). Otherwise it's about to be unlinked. "sqlite_file": result.sqlite_file if keep_sqlite else None, } # Single-line JSON so callers (CI, scripts) can grep the last line of # stdout without colliding with the simulation engine's DEBUG prints. print(json.dumps(summary)) return 0 finally: if not keep_sqlite: result.cleanup() def _sweep_seeds(args: argparse.Namespace) -> list[int]: if args.seed_start is not None or args.seed_end is not None: if args.seed_start is None or args.seed_end is None: raise ValueError("--seed-start and --seed-end must be given together") if args.seed_end < args.seed_start: raise ValueError("--seed-end must be >= --seed-start") return list(range(args.seed_start, args.seed_end + 1)) if args.seeds < 1: raise ValueError("--seeds must be >= 1") return list(range(args.seeds)) def _cmd_sweep(args: argparse.Namespace) -> int: scenario_path = pathlib.Path(args.scenario).resolve() if not scenario_path.exists(): print(f"error: scenario path not found: {scenario_path}", file=sys.stderr) return 2 out_dir = pathlib.Path(args.out) def _progress(done: int, total: int, payload: dict) -> None: print(f"trial {done}/{total} seed={payload.get('seed')}", flush=True) try: seeds = _sweep_seeds(args) sweep = run_local( str(scenario_path), out_dir=out_dir, seeds=seeds, workers=args.workers, dt=args.dt, every_nth_frame=args.every_nth_frame, scale=args.scale, scale_mode=args.scale_mode, progress=_progress, ) except CapacityError as exc: print(f"error: {exc}", file=sys.stderr) return 3 except (ValueError, OSError) as exc: print(f"error: {exc}", file=sys.stderr) return 2 n_failed = sum(not t.result.success for t in sweep.trials) summary = { "n_trials": len(sweep.trials), "n_failed": n_failed, "scale": args.scale, "mode": args.scale_mode, "seeds": seeds, "wall_clock_s": sweep.meta["wall_clock_s"], "out": str(out_dir), } print(json.dumps(summary)) return 1 if n_failed == len(sweep.trials) else 0 def _cmd_report(args: argparse.Namespace) -> int: from .report import build_report results_dir = pathlib.Path(args.results_dir) if not results_dir.exists(): print(f"error: results dir not found: {results_dir}", file=sys.stderr) return 2 try: out = build_report(results_dir, args.out, playback=args.playback) except (ImportError, FileNotFoundError, ValueError, OSError) as exc: print(f"error: {exc}", file=sys.stderr) return 2 print(json.dumps({"report": str(out)})) return 0
[docs] def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="jps-scenarios", description="Run JuPedSim scenarios authored in the web app.", ) parser.add_argument("--version", action="version", version=f"%(prog)s {_VERSION}") parser.add_argument( "--verbose", action="store_true", help="Show the library's INFO log lines (hidden by default, also in sweep workers).", ) sub = parser.add_subparsers(dest="command", required=True) run = sub.add_parser("run", help="Run a single scenario and emit a trajectory sqlite.") run.add_argument("scenario", help=SCENARIO_HELP) run.add_argument( "--seed", type=int, default=None, help="Override the scenario's seed (default: use the value in the JSON).", ) run.add_argument( "--out", default=None, help="Where to write the trajectory sqlite. If omitted, the file is " "created in a tempdir and deleted on exit (metrics are still printed).", ) run.add_argument( "--dt", type=float, default=None, help="Iteration step in seconds (default: the scenario's simulationParams.dt, " "else jupedsim's built-in 0.01).", ) run.add_argument( "--every-nth-frame", type=int, default=10, help="Trajectory writer stride. Default 10 (≈ 10 fps at dt=0.01); " "set to 1 to capture every iteration.", ) run.set_defaults(func=_cmd_run) sweep = sub.add_parser( "sweep", help="Run a scenario over many seeds, optionally scaled up, into an output directory.", ) sweep.add_argument("scenario", help=SCENARIO_HELP) seed_group = sweep.add_mutually_exclusive_group() seed_group.add_argument( "--seeds", type=int, default=1, help="Number of seeds, 0..N-1 (default 1)." ) seed_group.add_argument( "--seed-start", type=int, default=None, help="First seed (use with --seed-end)." ) sweep.add_argument( "--seed-end", type=int, default=None, help="Last seed, inclusive (use with --seed-start)." ) sweep.add_argument( "--scale", type=float, default=1.0, help="Agent scaling factor (default 1.0)." ) sweep.add_argument( "--scale-mode", choices=("count", "flow"), default="count", help="count: multiply start-area counts (refuses on capacity overflow, checked " "with the scenario's base seed; a seed that still cannot place the agents is " "recorded as a failed trial); " "flow: keep the spawn rate and stretch flow windows (needs flow spawning).", ) sweep.add_argument( "--workers", type=int, default=1, help="Parallel worker processes (0 = all CPUs)." ) sweep.add_argument("--out", required=True, help="Output directory for sqlites and sweep.json.") sweep.add_argument( "--dt", type=float, default=None, help="Iteration step in seconds (default: the scenario's simulationParams.dt, " "else jupedsim's built-in).", ) sweep.add_argument( "--every-nth-frame", type=int, default=10, help="Trajectory writer stride. Default 10, matching `run`.", ) sweep.set_defaults(func=_cmd_sweep) report = sub.add_parser("report", help="Render a saved sweep as a self-contained HTML report.") report.add_argument("results_dir", help="Directory written by `jps-scenarios sweep --out`.") report.add_argument("--out", default=None, help="Report path (default <results_dir>/report.html).") report.add_argument( "--playback", action="store_true", help="Also write per-trial plotly playback files next to the report (large).", ) report.set_defaults(func=_cmd_report) return parser
[docs] def main(argv: list[str] | None = None) -> int: parser = build_parser() args = parser.parse_args(argv) request_quiet(not args.verbose) return args.func(args)
if __name__ == "__main__": # pragma: no cover - argparse entrypoint raise SystemExit(main())