Bottleneck tutorial — jupedsim-scenarios#

A guided tour of the library using the classic bottleneck scenario (examples/assets/bottleneck.zip, exported from the Web-Based JuPedSim editor).

Five short steps, each adding one new capability:

  1. Load + run a scenario, inspect the ScenarioResult object.

  2. Visualize the trajectory with pedpy.

  3. Sweep N (agents) with seeds — stochasticity is real.

  4. Sweep model at fixed N — swap models in one line.

  5. 2D sweep: model × bottleneck width — reproduce known physics.

Run top-to-bottom. Steps 3–5 take a few minutes each (tune SEEDS_PER_COND).

# Shared imports + constants
from datetime import datetime
from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np

from jupedsim_scenarios import (
    load_scenario,
    run_scenario,
    run_sweep,
    run_sweep_from_factory,
)

# Resolve the bottleneck zip whether the notebook is launched from
# examples/ (default) or from the repository root.
ASSET = Path("assets/bottleneck.zip")
if not ASSET.exists():
    ASSET = Path("examples/assets/bottleneck.zip")

SEEDS_PER_COND = 2          # bump to 5+ for publication-quality variance
WORKERS = 4                 # parallel trial workers
plt.rcParams["figure.dpi"] = 110

print(f"Executed on {datetime.now().strftime('%d.%m.%Y, %H:%M')}")
Executed on 24.05.2026, 15:44

Step 1 — Load, run, inspect#

The two API calls you’ll use most: load_scenario(path) reads a zip exported from the web editor, run_scenario(scenario) runs JuPedSim and returns a ScenarioResult. Everything else in the tutorial builds on this.

scenario = load_scenario(str(ASSET))
result = run_scenario(scenario, seed=42)

print(f"type:               {type(result).__name__}")
print(f"evacuation_time:    {result.evacuation_time:.2f} s")
print(f"agents_evacuated:   {result.agents_evacuated} / {result.total_agents}")
print(f"frame_rate:         {result.frame_rate} fps")
print(f"sqlite trajectory:  {result.sqlite_file}")
print()
print("metrics dict keys:", sorted(result.metrics.keys()))
Using fallback logic: No journeys defined
Processing with parameters: {'number': 50, 'radius': 0.2, 'v0': 1.3, 'flow_start_time': 0, 'flow_end_time': 10, 'percentage': None, 'distribution_mode': 'by_number', 'use_flow_spawning': False, 'use_premovement': False, 'premovement_distribution': 'gamma', 'premovement_param_a': None, 'premovement_param_b': None, 'premovement_seed': None, 'radius_distribution': 'constant', 'v0_distribution': 'constant'}
Using default parameters: v0=1.3, radius=0.2, n_agents=50

Distribution jps-distributions_0: {'number': 50, 'radius': 0.2, 'v0': 1.3, 'distribution_mode': 'by_number', 'percentage': None, 'use_flow_spawning': False, 'flow_start_time': 0, 'flow_end_time': 10, 'use_premovement': False, 'premovement_distribution': 'gamma', 'premovement_param_a': None, 'premovement_param_b': None, 'premovement_seed': None, 'radius_distribution': 'constant', 'radius_std': None, 'v0_distribution': 'constant', 'v0_std': None}
Added 50 agents using fallback logic (immediate), prepared 0 flow sources
type:               ScenarioResult
evacuation_time:    52.16 s
agents_evacuated:   50 / 50
frame_rate:         10.0 fps
sqlite trajectory:  /tmp/tmpsl4w4tl1.sqlite

metrics dict keys: ['agents_evacuated', 'agents_remaining', 'all_evacuated', 'dt', 'evacuation_time', 'frame_rate', 'message', 'seed', 'status', 'success', 'total_agents', 'walkable_polygon']

Step 2 — Visualize with pedpy#

result.sqlite_file is a standard JuPedSim trajectory database, so it plugs straight into pedpy’s loaders and plotters.

from pedpy import WalkableArea, load_trajectory_from_jupedsim_sqlite, plot_trajectories

traj = load_trajectory_from_jupedsim_sqlite(Path(result.sqlite_file))
walkable = WalkableArea(result.walkable_polygon)

fig, ax = plt.subplots(figsize=(9, 4))
plot_trajectories(traj=traj, walkable_area=walkable, axes=ax)
ax.set_title(f"Bottleneck — evacuation time: {result.evacuation_time:.2f} s "
             f"(N={result.total_agents})")
ax.set_aspect("equal")
plt.tight_layout()
plt.show()

result.cleanup()   # delete the temp sqlite now that we're done with it
../_images/162be626da1ad0e4c67c379c1d268d1c9c029c99bd26ead1ddfb97eec1d16930.png

Step 3 — Sweep N with seeds#

run_sweep walks a cartesian product of axes and runs each combination across all seeds. One axis here (num_agents), three values, two seeds each = 6 trials. Same scenario, just mutated per-trial via apply.

Stochastic variance is real: even at fixed N, evac times scatter — that’s why we plot mean ± std, not a single number.

base = load_scenario(str(ASSET))

sweep_n = run_sweep(
    base,
    axes={"num_agents": [30, 40, 50]},
    apply={"num_agents": lambda s, n: s.set_agent_count("jps-distributions_0", n)},
    seeds=range(100, 100 + SEEDS_PER_COND),
    workers=WORKERS,
)

df_n = sweep_n.to_dataframe()
agg = df_n.groupby("num_agents")["evacuation_time"].agg(["mean", "std", "count"])
print(agg)

fig, ax = plt.subplots(figsize=(6, 4))
ax.errorbar(agg.index, agg["mean"], yerr=agg["std"].fillna(0),
            fmt="o-", capsize=4, color="#1f6feb")
ax.set_xlabel("Number of agents")
ax.set_ylabel("Evacuation time [s]")
ax.set_title(f"Bottleneck — evacuation time vs N  ({SEEDS_PER_COND} seeds/condition)")
ax.grid(alpha=0.3)
plt.tight_layout()
plt.show()

sweep_n.cleanup()
              mean         std  count
num_agents                           
30           34.49    2.983991      2
40           44.12    0.296985      2
50          177.03  173.905842      2
../_images/e6a6a158a9caa6fa456666fcacd332201ae7789a3b739120772714bab0afbe11.png

Step 4 — Sweep models at N=50#

Same run_sweep, different axis. set_model_type is a one-liner; the rest of the scenario (geometry, distribution, exit) is untouched.

Macro outcomes (evacuation time) often agree across models — the microscopic dynamics differ. We show both: a bar chart of evac times and a side-by-side trajectory snapshot per model.

MODELS = [
    "SocialForceModel",
    "CollisionFreeSpeedModel",
    "WarpDriverModel",
]

base = load_scenario(str(ASSET))

sweep_m = run_sweep(
    base,
    axes={"model": MODELS},
    apply={"model": lambda s, m: s.set_model_type(m)},
    seeds=range(200, 200 + SEEDS_PER_COND),
    workers=WORKERS,
)

df_m = sweep_m.to_dataframe()
agg_m = df_m.groupby("model")["evacuation_time"].agg(["mean", "std"]).reindex(MODELS)
print(agg_m)

fig, ax = plt.subplots(figsize=(7, 4))
ax.bar(range(len(MODELS)), agg_m["mean"], yerr=agg_m["std"].fillna(0),
       capsize=5, color=["#1f6feb", "#2da44e", "#bf8700"])
ax.set_xticks(range(len(MODELS)))
ax.set_xticklabels([m.replace("Model", "") for m in MODELS], rotation=15, ha="right")
ax.set_ylabel("Evacuation time [s]")
ax.set_title(f"Bottleneck — evacuation time by model  (N=50, {SEEDS_PER_COND} seeds)")
ax.grid(axis="y", alpha=0.3)
plt.tight_layout()
plt.show()
                           mean       std
model                                    
SocialForceModel         40.560  2.064752
CollisionFreeSpeedModel  55.475  0.459619
WarpDriverModel          33.605  0.756604
../_images/99e363e76ded0fceb009f91356f897f52032f9e5e979a85ced80ab5985d95cb4.png
# Side-by-side trajectory snapshots — one representative seed per model
fig, axes = plt.subplots(1, len(MODELS), figsize=(15, 4), sharey=True)
for ax, model in zip(axes, MODELS, strict=False):
    trial = next(t for t in sweep_m.trials if t.axis_values["model"] == model)
    traj = load_trajectory_from_jupedsim_sqlite(Path(trial.result.sqlite_file))
    walkable = WalkableArea(trial.result.walkable_polygon)
    plot_trajectories(traj=traj, walkable_area=walkable, axes=ax)
    ax.set_title(f"{model.replace('Model', '')}\n"
                 f"evac = {trial.result.evacuation_time:.1f} s")
    ax.set_aspect("equal")
plt.tight_layout()
plt.show()

sweep_m.cleanup()
../_images/34fa23391b5eb0ea22290d6030381567c9026a785cd04743cdb830a2967b3b32.png

Step 5 — 2D sweep: model × bottleneck width#

Here the geometry itself depends on the trial parameter (width), so we switch to run_sweep_from_factory: each trial gets a freshly-built scenario from our factory function.

The bottleneck opening in the default scenario sits between y = 2.5 and y = 3.5 (width 1.0 m). We rewrite the WKT to vary it, centred on y = 3.0.

We plot two curves per model:

Watch the CollisionFreeSpeedModel point at b = 0.8 m: agents clog and only a fraction evacuates before the (raised) simulation cap fires. Different models cope with the same narrow geometry differently — a useful reminder that “which model” is itself a parameter worth sweeping.

WIDTHS = [0.8, 1.0, 1.2, 1.4, 1.6]
MODELS_2D = [
    "SocialForceModel",
    "CollisionFreeSpeedModel",
    "WarpDriverModel",
]

# Load the base scenario once; the factory copies it per trial.
# Reloading the zip inside the factory would re-parse it for every
# (trial, seed) combination.
BASE_2D = load_scenario(str(ASSET))

def bottleneck_factory(params):
    w = params["width"]
    y_lo, y_hi = 3.0 - w / 2, 3.0 + w / 2
    # Start from an independent copy of the base scenario, then mutate
    # in place. Rewriting the inner ring of the walkable polygon moves
    # the bottleneck opening (the gap at x in [15, 15.2]) to the
    # requested width; reassigning .walkable_area_wkt re-parses the
    # shapely cache automatically.
    s = BASE_2D.copy()
    s.walkable_area_wkt = (
        "POLYGON((20 7, 20 -1, -1 -1, -1 7, 20 7), "
        f"(15.2 {y_hi}, 15.2 6.199999999999999, -0.2 6.199999999999999, "
        f"-0.2 -0.2, 15.2 -0.2, 15.2 {y_lo}, 15 {y_lo}, "
        f"15 2.0000000000000002e-16, 0 0, 0 6, 15 6, 15 {y_hi}, 15.2 {y_hi}))"
    )
    s.set_model_type(params["model"])
    # Narrow widths can clog hard (especially CFM at b = 0.8 m, which
    # still hits the cap — see plot). 900 s gives the better-behaved
    # models room to finish even at the narrow end.
    s.set_max_time(900)
    return s, None

trials = [{"model": m, "width": w} for m in MODELS_2D for w in WIDTHS]

sweep_2d = run_sweep_from_factory(
    bottleneck_factory,
    trials=trials,
    seeds=range(300, 300 + SEEDS_PER_COND),
    workers=WORKERS,
)

df = sweep_2d.to_dataframe()
df["flow"] = df["total_agents"] / df["evacuation_time"]
agg2 = (df.groupby(["model", "width"])
          [["evacuation_time", "flow"]]
          .agg(["mean", "std"]))
print(agg2)
Using fallback logic: No journeys defined
Processing with parameters: {'number': 30, 'radius': 0.2, 'v0': 1.3, 'flow_start_time': 0, 'flow_end_time': 10, 'percentage': None, 'distribution_mode': 'by_number', 'use_flow_spawning': False, 'use_premovement': False, 'premovement_distribution': 'gamma', 'premovement_param_a': None, 'premovement_param_b': None, 'premovement_seed': None, 'radius_distribution': 'constant', 'v0_distribution': 'constant'}
Using default parameters: v0=1.3, radius=0.2, n_agents=30

Distribution jps-distributions_0: {'number': 30, 'radius': 0.2, 'v0': 1.3, 'distribution_mode': 'by_number', 'percentage': None, 'use_flow_spawning': False, 'flow_start_time': 0, 'flow_end_time': 10, 'use_premovement': False, 'premovement_distribution': 'gamma', 'premovement_param_a': None, 'premovement_param_b': None, 'premovement_seed': None, 'radius_distribution': 'constant', 'radius_std': None, 'v0_distribution': 'constant', 'v0_std': None}
Added 30 agents using fallback logic (immediate), prepared 0 flow sources
Using fallback logic: No journeys defined
Processing with parameters: {'number': 50, 'radius': 0.2, 'v0': 1.3, 'flow_start_time': 0, 'flow_end_time': 10, 'percentage': None, 'distribution_mode': 'by_number', 'use_flow_spawning': False, 'use_premovement': False, 'premovement_distribution': 'gamma', 'premovement_param_a': None, 'premovement_param_b': None, 'premovement_seed': None, 'radius_distribution': 'constant', 'v0_distribution': 'constant'}
Using default parameters: v0=1.3, radius=0.2, n_agents=50

Distribution jps-distributions_0: {'number': 50, 'radius': 0.2, 'v0': 1.3, 'distribution_mode': 'by_number', 'percentage': None, 'use_flow_spawning': False, 'flow_start_time': 0, 'flow_end_time': 10, 'use_premovement': False, 'premovement_distribution': 'gamma', 'premovement_param_a': None, 'premovement_param_b': None, 'premovement_seed': None, 'radius_distribution': 'constant', 'radius_std': None, 'v0_distribution': 'constant', 'v0_std': None}
Added 50 agents using fallback logic (immediate), prepared 0 flow sources
Using fallback logic: No journeys defined
Processing with parameters: {'number': 50, 'radius': 0.2, 'v0': 1.3, 'flow_start_time': 0, 'flow_end_time': 10, 'percentage': None, 'distribution_mode': 'by_number', 'use_flow_spawning': False, 'use_premovement': False, 'premovement_distribution': 'gamma', 'premovement_param_a': None, 'premovement_param_b': None, 'premovement_seed': None, 'radius_distribution': 'constant', 'v0_distribution': 'constant'}
Using default parameters: v0=1.3, radius=0.2, n_agents=50

Distribution jps-distributions_0: {'number': 50, 'radius': 0.2, 'v0': 1.3, 'distribution_mode': 'by_number', 'percentage': None, 'use_flow_spawning': False, 'flow_start_time': 0, 'flow_end_time': 10, 'use_premovement': False, 'premovement_distribution': 'gamma', 'premovement_param_a': None, 'premovement_param_b': None, 'premovement_seed': None, 'radius_distribution': 'constant', 'radius_std': None, 'v0_distribution': 'constant', 'v0_std': None}
Added 50 agents using fallback logic (immediate), prepared 0 flow sources
Using fallback logic: No journeys defined
Processing with parameters: {'number': 50, 'radius': 0.2, 'v0': 1.3, 'flow_start_time': 0, 'flow_end_time': 10, 'percentage': None, 'distribution_mode': 'by_number', 'use_flow_spawning': False, 'use_premovement': False, 'premovement_distribution': 'gamma', 'premovement_param_a': None, 'premovement_param_b': None, 'premovement_seed': None, 'radius_distribution': 'constant', 'v0_distribution': 'constant'}
Using default parameters: v0=1.3, radius=0.2, n_agents=50

Distribution jps-distributions_0: {'number': 50, 'radius': 0.2, 'v0': 1.3, 'distribution_mode': 'by_number', 'percentage': None, 'use_flow_spawning': False, 'flow_start_time': 0, 'flow_end_time': 10, 'use_premovement': False, 'premovement_distribution': 'gamma', 'premovement_param_a': None, 'premovement_param_b': None, 'premovement_seed': None, 'radius_distribution': 'constant', 'radius_std': None, 'v0_distribution': 'constant', 'v0_std': None}
Added 50 agents using fallback logic (immediate), prepared 0 flow sources
Using fallback logic: No journeys defined
Processing with parameters: {'number': 50, 'radius': 0.2, 'v0': 1.3, 'flow_start_time': 0, 'flow_end_time': 10, 'percentage': None, 'distribution_mode': 'by_number', 'use_flow_spawning': False, 'use_premovement': False, 'premovement_distribution': 'gamma', 'premovement_param_a': None, 'premovement_param_b': None, 'premovement_seed': None, 'radius_distribution': 'constant', 'v0_distribution': 'constant'}
Using default parameters: v0=1.3, radius=0.2, n_agents=50

Distribution jps-distributions_0: {'number': 50, 'radius': 0.2, 'v0': 1.3, 'distribution_mode': 'by_number', 'percentage': None, 'use_flow_spawning': False, 'flow_start_time': 0, 'flow_end_time': 10, 'use_premovement': False, 'premovement_distribution': 'gamma', 'premovement_param_a': None, 'premovement_param_b': None, 'premovement_seed': None, 'radius_distribution': 'constant', 'radius_std': None, 'v0_distribution': 'constant', 'v0_std': None}
Added 50 agents using fallback logic (immediate), prepared 0 flow sources
Using fallback logic: No journeys defined
Processing with parameters: {'number': 50, 'radius': 0.2, 'v0': 1.3, 'flow_start_time': 0, 'flow_end_time': 10, 'percentage': None, 'distribution_mode': 'by_number', 'use_flow_spawning': False, 'use_premovement': False, 'premovement_distribution': 'gamma', 'premovement_param_a': None, 'premovement_param_b': None, 'premovement_seed': None, 'radius_distribution': 'constant', 'v0_distribution': 'constant'}
Using default parameters: v0=1.3, radius=0.2, n_agents=50

Distribution jps-distributions_0: {'number': 50, 'radius': 0.2, 'v0': 1.3, 'distribution_mode': 'by_number', 'percentage': None, 'use_flow_spawning': False, 'flow_start_time': 0, 'flow_end_time': 10, 'use_premovement': False, 'premovement_distribution': 'gamma', 'premovement_param_a': None, 'premovement_param_b': None, 'premovement_seed': None, 'radius_distribution': 'constant', 'radius_std': None, 'v0_distribution': 'constant', 'v0_std': None}
Added 50 agents using fallback logic (immediate), prepared 0 flow sources
Using fallback logic: No journeys defined
Processing with parameters: {'number': 50, 'radius': 0.2, 'v0': 1.3, 'flow_start_time': 0, 'flow_end_time': 10, 'percentage': None, 'distribution_mode': 'by_number', 'use_flow_spawning': False, 'use_premovement': False, 'premovement_distribution': 'gamma', 'premovement_param_a': None, 'premovement_param_b': None, 'premovement_seed': None, 'radius_distribution': 'constant', 'v0_distribution': 'constant'}
Using default parameters: v0=1.3, radius=0.2, n_agents=50

Distribution jps-distributions_0: {'number': 50, 'radius': 0.2, 'v0': 1.3, 'distribution_mode': 'by_number', 'percentage': None, 'use_flow_spawning': False, 'flow_start_time': 0, 'flow_end_time': 10, 'use_premovement': False, 'premovement_distribution': 'gamma', 'premovement_param_a': None, 'premovement_param_b': None, 'premovement_seed': None, 'radius_distribution': 'constant', 'radius_std': None, 'v0_distribution': 'constant', 'v0_std': None}
Added 50 agents using fallback logic (immediate), prepared 0 flow sources
Using fallback logic: No journeys defined
Processing with parameters: {'number': 50, 'radius': 0.2, 'v0': 1.3, 'flow_start_time': 0, 'flow_end_time': 10, 'percentage': None, 'distribution_mode': 'by_number', 'use_flow_spawning': False, 'use_premovement': False, 'premovement_distribution': 'gamma', 'premovement_param_a': None, 'premovement_param_b': None, 'premovement_seed': None, 'radius_distribution': 'constant', 'v0_distribution': 'constant'}
Using default parameters: v0=1.3, radius=0.2, n_agents=50

Distribution jps-distributions_0: {'number': 50, 'radius': 0.2, 'v0': 1.3, 'distribution_mode': 'by_number', 'percentage': None, 'use_flow_spawning': False, 'flow_start_time': 0, 'flow_end_time': 10, 'use_premovement': False, 'premovement_distribution': 'gamma', 'premovement_param_a': None, 'premovement_param_b': None, 'premovement_seed': None, 'radius_distribution': 'constant', 'radius_std': None, 'v0_distribution': 'constant', 'v0_std': None}
Added 50 agents using fallback logic (immediate), prepared 0 flow sources
                              evacuation_time                flow          
                                         mean       std      mean       std
model                   width                                              
CollisionFreeSpeedModel 0.8           900.000  0.000000  0.055556  0.000000
                        1.0            55.630  2.446589  0.899666  0.039567
                        1.2            44.245  0.572756  1.130166  0.014630
                        1.4            37.210  2.474874  1.346704  0.089571
                        1.6            35.120  2.262742  1.426651  0.091918
SocialForceModel        0.8            60.890  0.806102  0.821225  0.010872
                        1.0            41.355  1.633417  1.209987  0.047791
                        1.2            31.540  0.777817  1.585771  0.039107
                        1.4            26.555  0.035355  1.882886  0.002507
                        1.6            23.820  0.777817  2.100196  0.068580
WarpDriverModel         0.8            40.125  0.388909  1.246164  0.012078
                        1.0            32.780  0.240416  1.525361  0.011187
                        1.2            28.210  0.353553  1.772560  0.022215
                        1.4            25.340  0.381838  1.973389  0.029736
                        1.6            23.260  0.183848  2.149680  0.016991
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(13, 4.5))
colors = {"SocialForceModel": "#1f6feb",
          "CollisionFreeSpeedModel": "#2da44e",
          "WarpDriverModel": "#bf8700"}

for model in MODELS_2D:
    sub = df[df.model == model].groupby("width")
    et = sub["evacuation_time"].agg(["mean", "std"])
    fl = sub["flow"].agg(["mean", "std"])
    ax1.errorbar(et.index, et["mean"], yerr=et["std"].fillna(0),
                 fmt="o-", capsize=4, label=model.replace("Model", ""),
                 color=colors[model])
    ax2.errorbar(fl.index, fl["mean"], yerr=fl["std"].fillna(0),
                 fmt="o-", capsize=4, label=model.replace("Model", ""),
                 color=colors[model])

ax1.set_xlabel("Bottleneck width $b$ [m]")
ax1.set_ylabel("Evacuation time [s]")
ax1.set_title("Evac time vs width")
ax1.grid(alpha=0.3); ax1.legend()

w_line = np.linspace(min(WIDTHS), max(WIDTHS), 50)
ax2.plot(w_line, 1.9 * w_line, color="gray", ls="--", lw=1,
         label=r"$J = 1.9\,b$  (Seyfried 2010, Fig. 4)")
ax2.set_xlabel("Bottleneck width $b$ [m]")
ax2.set_ylabel("Flow $J$ [1/s]")
ax2.set_title("Flow vs width")
ax2.grid(alpha=0.3); ax2.legend()

plt.tight_layout()
plt.show()

sweep_2d.cleanup()
../_images/a7a92516aef308df99bb2df786e2988542d34e2f1c654de929cd77236891d380.png

Where to go from here#

  • Pick any Scenario.set_* mutator and add it as a sweep axis.

  • For studies whose geometry depends on the parameter, use run_sweep_from_factory (as in step 5).

  • Trial results expose result.sqlite_file and result.trajectory_dataframe() — both feed directly into pedpy for density, flow, Voronoi, and fundamental-diagram analysis.

  • For long sweeps, persist sweep.to_dataframe() to CSV/parquet before calling sweep.cleanup().