Source code for jupedsim_scenarios.cli

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

    jps-scenarios run scenario.json --seed 42 --out trajectory.sqlite

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 shutil
import sys

try:
    from importlib.metadata import version as _pkg_version

    _VERSION = _pkg_version("jupedsim-scenarios")
except Exception:  # pragma: no cover - importlib.metadata failure is benign for --version
    _VERSION = "0.0.0"

from .runner import Scenario, run_scenario


def _build_scenario_from_json(path: pathlib.Path) -> Scenario:
    """Construct a Scenario from a single self-contained JSON file.

    Mirrors the constructor flow used in tests and the web app — the JSON
    is expected to embed `walkable_area_wkt` and a `config` block. For
    zipped exports (separate JSON + WKT) use `load_scenario` from Python
    instead.
    """
    data = json.loads(path.read_text(encoding="utf-8"))
    if "walkable_area_wkt" not in data:
        raise SystemExit(
            f"{path}: missing 'walkable_area_wkt' — the CLI only accepts "
            "self-contained JSON. Use the Python API's load_scenario() "
            "for zipped exports."
        )
    sim_settings = data.get("config", {}).get("simulation_settings", {})
    sim_params = dict(sim_settings.get("simulationParams", {}))
    sim_params.setdefault("max_simulation_time", 300)
    return Scenario(
        raw=data,
        walkable_area_wkt=data["walkable_area_wkt"],
        model_type=sim_params.get(
            "model_type", data.get("model_type", "CollisionFreeSpeedModel")
        ),
        seed=data.get("seed", sim_settings.get("baseSeed", 42)),
        sim_params=sim_params,
        source_path=str(path),
    )


def _cmd_run(args: argparse.Namespace) -> int:
    scenario_path = pathlib.Path(args.scenario).resolve()
    if not scenario_path.exists():
        print(f"error: scenario file not found: {scenario_path}", file=sys.stderr)
        return 2

    scenario = _build_scenario_from_json(scenario_path)
    result = run_scenario(scenario, seed=args.seed)
    # `keep_sqlite` is only true once the trajectory has been moved to --out.
    # On the failure path the temp sqlite always gets cleaned, even if --out
    # was requested — otherwise we'd leak a tempfile the caller can't find.
    keep_sqlite = False
    try:
        if not result.success:
            print(
                f"error: simulation failed: {result.metrics.get('message', 'unknown')}",
                file=sys.stderr,
            )
            return 1

        if args.out and result.sqlite_file:
            target = pathlib.Path(args.out).resolve()
            target.parent.mkdir(parents=True, exist_ok=True)
            shutil.move(result.sqlite_file, target)
            result.sqlite_file = str(target)
            keep_sqlite = True

        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 _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}")
    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="Path to scenario JSON.")
    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.set_defaults(func=_cmd_run)
    return parser


[docs] def main(argv: list[str] | None = None) -> int: parser = _build_parser() args = parser.parse_args(argv) return args.func(args)
if __name__ == "__main__": # pragma: no cover - argparse entrypoint raise SystemExit(main())