Coming from vanilla JuPedSim? Same study, two ways#

If you already write JuPedSim simulations by hand — shapely geometry, jps.Simulation, journeys, the iteration loop — this notebook is for you. It runs the same bottleneck study twice: once with vanilla jupedsim, once with jupedsim-scenarios, so you can judge the difference on identical physics.

jupedsim-scenarios is not a replacement for JuPedSim — it wraps it. The solver, the models, and the results are the same. What changes is how much code sits between you and your research question:

Task

vanilla jupedsim

jupedsim-scenarios

Build + run one bottleneck

~45 lines (Part 1)

~10 lines (Part 2)

Trajectory output

manage SqliteTrajectoryWriter yourself

temp SQLite + trajectory_dataframe()

Parameters × seeds study

hand-rolled loops + bookkeeping (bottleneck_sweep.py, ~190 lines)

one run_sweep(...) call (Part 3)

Scenarios from the web editor

parse the JSON yourself

load_scenario("export.zip")

pip install jupedsim-scenarios
import logging
from datetime import datetime

logging.getLogger("jupedsim_scenarios").setLevel(logging.WARNING)
print(f"Executed on {datetime.now().strftime('%d.%m.%Y, %H:%M')}")
Executed on 21.07.2026, 09:46

Part 1 — the bottleneck in vanilla JuPedSim#

A square room, a 1.2 m door through a dividing wall, a downstream room with an exit. 50 agents evacuate under the Collision-Free Speed Model. This is the canonical vanilla recipe: build the walkable polygon, place agents, wire stage → journey → agents, then drive the loop yourself.

import tempfile
from pathlib import Path

import jupedsim as jps
from shapely.geometry import Polygon
from shapely.ops import unary_union

# --- geometry: room + door channel + downstream room ---
DOOR_WIDTH = 1.2
room = Polygon([(0, 0), (10, 0), (10, 10), (0, 10)])
half = DOOR_WIDTH / 2
door_channel = Polygon([(10, 5 - half), (10.4, 5 - half), (10.4, 5 + half), (10, 5 + half)])
downstream = Polygon([(10.4, 0), (16, 0), (16, 10), (10.4, 10)])
walkable = unary_union([room, door_channel, downstream])
exit_polygon = Polygon([(15.5, 4), (16, 4), (16, 6), (15.5, 6)])
spawn_polygon = Polygon([(0.5, 0.5), (9, 0.5), (9, 9.5), (0.5, 9.5)])

# --- agent placement: clearances are on you ---
NUM_AGENTS, RADIUS = 50, 0.2
positions = jps.distribute_by_number(
    polygon=spawn_polygon,
    number_of_agents=NUM_AGENTS,
    distance_to_agents=2 * RADIUS + 0.1,
    distance_to_polygon=RADIUS + 0.05,
    seed=42,
)

# --- simulation + trajectory writer lifecycle: also on you ---
DT = 0.05
trajectory_file = Path(tempfile.mkdtemp()) / "vanilla_bottleneck.sqlite"
simulation = jps.Simulation(
    model=jps.CollisionFreeSpeedModel(),
    geometry=walkable,
    dt=DT,
    trajectory_writer=jps.SqliteTrajectoryWriter(
        output_file=trajectory_file, every_nth_frame=4
    ),
)

# --- stage -> journey -> per-agent parameter plumbing ---
exit_id = simulation.add_exit_stage(exit_polygon)
journey_id = simulation.add_journey(jps.JourneyDescription([exit_id]))
for position in positions:
    simulation.add_agent(
        jps.CollisionFreeSpeedModelAgentParameters(
            position=position,
            journey_id=journey_id,
            stage_id=exit_id,
            desired_speed=1.2,
            radius=RADIUS,
        )
    )

# --- the loop: stop condition and iteration budget are yours to get right ---
MAX_ITERATIONS = 6000
while simulation.agent_count() > 0 and simulation.iteration_count() < MAX_ITERATIONS:
    simulation.iterate()

vanilla_evac_time = simulation.elapsed_time()
print(f"evacuation time: {vanilla_evac_time:.1f} s "
      f"({simulation.iteration_count()} iterations)")
evacuation time: 47.0 s (939 iterations)

It works — but count what you had to own that is not your research question: placement clearances, the writer’s file and frame stride, the stage/journey wiring, per-agent parameter objects, the stop condition, and an iteration budget so a jammed door can’t hang the notebook.

Part 2 — the same study with jupedsim-scenarios#

Same walkable polygon, same exit, same spawn area, same model — the shapely objects from Part 1 are passed straight in. Everything from the “on you” list above is handled by run_scenario.

from jupedsim_scenarios import Scenario, run_scenario

scenario = Scenario(
    raw={},
    walkable_area_wkt=walkable.wkt,
    model_type="CollisionFreeSpeedModel",
    seed=42,
    sim_params={"max_simulation_time": 300},
)
scenario.add_distribution(spawn_polygon, number=NUM_AGENTS, desired_speed=1.2, radius=RADIUS)
scenario.add_exit(exit_polygon)

result = run_scenario(scenario, seed=42)
print(f"evacuation time: {result.evacuation_time:.1f} s  (vanilla: {vanilla_evac_time:.1f} s)")
evacuation time: 43.0 s  (vanilla: 47.0 s)

The two times agree closely but not exactly: both runs use the same solver, the same model defaults, and even the same seed value — but the two code paths implement agent placement independently, so the initial positions differ. Think of the runs as two independent draws of the same experiment, not bit-identical replays.

What the ~10 lines bought beyond brevity:

  • result carries the run: result.evacuation_time, result.trajectory_dataframe() (ready for pedpy analysis — see the visualisation how-to), result.cleanup().

  • The scenario is an object you can mutate (set_agent_count, set_agent_params, add_zone, …) and persist with save_scenario.

  • The same run_scenario accepts scenarios drawn in the web editor: load_scenario("export.zip") — no JSON parsing.

df = result.trajectory_dataframe()
print(f"trajectory: {len(df)} rows, columns: {list(df.columns)}")
result.cleanup();
trajectory: 12430 rows, columns: ['frame', 'id', 'x', 'y', 'ori_x', 'ori_y']

Part 3 — the payoff: a parameters × seeds study in one call#

In vanilla JuPedSim a sweep means loops, per-run output files, a results table, and censoring bookkeeping — bottleneck_sweep.py does it properly in ~190 lines. With run_sweep you declare what varies (axes) and how to inject it (apply); copies, seeds, parallel workers, and the results table are handled for you.

Here: does desired speed move the evacuation time? 3 speeds × 3 seeds = 9 runs (about a minute).

from jupedsim_scenarios import run_sweep

sweep = run_sweep(
    scenario,
    axes={"desired_speed": [0.8, 1.2, 1.5]},
    apply={"desired_speed": lambda s, v: s.set_agent_params(0, desired_speed=v)},
    seeds=range(1, 4),
    workers=2,
)
df = sweep.to_dataframe()
df[["desired_speed", "seed", "evacuation_time"]]
INFO - Using fallback logic: No journeys defined
INFO - Processing with parameters: {'number': 50, 'distribution_mode': 'by_number', 'desired_speed': 0.8, 'radius': 0.2, 'v0': 0.8}
INFO - Using default parameters: v0=0.8, radius=0.2, n_agents=50
INFO - Distribution jps-distributions_0: {'number': 50, 'radius': 0.2, 'v0': 0.8, 'distribution_mode': 'by_number', 'percentage': None, 'use_flow_spawning': False, 'flow_start_time': 0, 'flow_end_time': 10, 'strict_spawning': False, '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}
INFO - Added 50 agents using fallback logic (immediate), prepared 0 flow sources
INFO - Using fallback logic: No journeys defined
INFO - Processing with parameters: {'number': 50, 'distribution_mode': 'by_number', 'desired_speed': 0.8, 'radius': 0.2, 'v0': 0.8}
INFO - Using default parameters: v0=0.8, radius=0.2, n_agents=50
INFO - Distribution jps-distributions_0: {'number': 50, 'radius': 0.2, 'v0': 0.8, 'distribution_mode': 'by_number', 'percentage': None, 'use_flow_spawning': False, 'flow_start_time': 0, 'flow_end_time': 10, 'strict_spawning': False, '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}
INFO - Added 50 agents using fallback logic (immediate), prepared 0 flow sources
INFO - Using fallback logic: No journeys defined
INFO - Processing with parameters: {'number': 50, 'distribution_mode': 'by_number', 'desired_speed': 0.8, 'radius': 0.2, 'v0': 0.8}
INFO - Using fallback logic: No journeys defined
INFO - Using default parameters: v0=0.8, radius=0.2, n_agents=50
INFO - Processing with parameters: {'number': 50, 'distribution_mode': 'by_number', 'desired_speed': 1.2, 'radius': 0.2, 'v0': 1.2}
INFO - Using default parameters: v0=1.2, radius=0.2, n_agents=50
INFO - Distribution jps-distributions_0: {'number': 50, 'radius': 0.2, 'v0': 1.2, 'distribution_mode': 'by_number', 'percentage': None, 'use_flow_spawning': False, 'flow_start_time': 0, 'flow_end_time': 10, 'strict_spawning': False, '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}
INFO - Distribution jps-distributions_0: {'number': 50, 'radius': 0.2, 'v0': 0.8, 'distribution_mode': 'by_number', 'percentage': None, 'use_flow_spawning': False, 'flow_start_time': 0, 'flow_end_time': 10, 'strict_spawning': False, '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}
INFO - Added 50 agents using fallback logic (immediate), prepared 0 flow sources
INFO - Added 50 agents using fallback logic (immediate), prepared 0 flow sources
INFO - Using fallback logic: No journeys defined
INFO - Processing with parameters: {'number': 50, 'distribution_mode': 'by_number', 'desired_speed': 1.2, 'radius': 0.2, 'v0': 1.2}
INFO - Using default parameters: v0=1.2, radius=0.2, n_agents=50
INFO - Distribution jps-distributions_0: {'number': 50, 'radius': 0.2, 'v0': 1.2, 'distribution_mode': 'by_number', 'percentage': None, 'use_flow_spawning': False, 'flow_start_time': 0, 'flow_end_time': 10, 'strict_spawning': False, '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}
INFO - Added 50 agents using fallback logic (immediate), prepared 0 flow sources
INFO - Using fallback logic: No journeys defined
INFO - Processing with parameters: {'number': 50, 'distribution_mode': 'by_number', 'desired_speed': 1.2, 'radius': 0.2, 'v0': 1.2}
INFO - Using default parameters: v0=1.2, radius=0.2, n_agents=50
INFO - Distribution jps-distributions_0: {'number': 50, 'radius': 0.2, 'v0': 1.2, 'distribution_mode': 'by_number', 'percentage': None, 'use_flow_spawning': False, 'flow_start_time': 0, 'flow_end_time': 10, 'strict_spawning': False, '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}
INFO - Added 50 agents using fallback logic (immediate), prepared 0 flow sources
INFO - Using fallback logic: No journeys defined
INFO - Processing with parameters: {'number': 50, 'distribution_mode': 'by_number', 'desired_speed': 1.5, 'radius': 0.2, 'v0': 1.5}
INFO - Using default parameters: v0=1.5, radius=0.2, n_agents=50
INFO - Distribution jps-distributions_0: {'number': 50, 'radius': 0.2, 'v0': 1.5, 'distribution_mode': 'by_number', 'percentage': None, 'use_flow_spawning': False, 'flow_start_time': 0, 'flow_end_time': 10, 'strict_spawning': False, '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}
INFO - Added 50 agents using fallback logic (immediate), prepared 0 flow sources
INFO - Using fallback logic: No journeys defined
INFO - Processing with parameters: {'number': 50, 'distribution_mode': 'by_number', 'desired_speed': 1.5, 'radius': 0.2, 'v0': 1.5}
INFO - Using default parameters: v0=1.5, radius=0.2, n_agents=50
INFO - Distribution jps-distributions_0: {'number': 50, 'radius': 0.2, 'v0': 1.5, 'distribution_mode': 'by_number', 'percentage': None, 'use_flow_spawning': False, 'flow_start_time': 0, 'flow_end_time': 10, 'strict_spawning': False, '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}
INFO - Added 50 agents using fallback logic (immediate), prepared 0 flow sources
INFO - Using fallback logic: No journeys defined
INFO - Processing with parameters: {'number': 50, 'distribution_mode': 'by_number', 'desired_speed': 1.5, 'radius': 0.2, 'v0': 1.5}
INFO - Using default parameters: v0=1.5, radius=0.2, n_agents=50
INFO - Distribution jps-distributions_0: {'number': 50, 'radius': 0.2, 'v0': 1.5, 'distribution_mode': 'by_number', 'percentage': None, 'use_flow_spawning': False, 'flow_start_time': 0, 'flow_end_time': 10, 'strict_spawning': False, '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}
INFO - Added 50 agents using fallback logic (immediate), prepared 0 flow sources
desired_speed seed evacuation_time
0 0.8 1 53.21
1 0.8 2 51.81
2 0.8 3 51.82
3 1.2 1 44.68
4 1.2 2 44.63
5 1.2 3 40.98
6 1.5 1 39.44
7 1.5 2 44.26
8 1.5 3 39.10
import matplotlib.pyplot as plt

stats = df.groupby("desired_speed")["evacuation_time"].agg(["mean", "std"])

fig, ax = plt.subplots(figsize=(5, 3.5))
ax.errorbar(stats.index, stats["mean"], yerr=stats["std"],
            marker="o", capsize=4)
ax.set_xlabel("desired speed (m/s)")
ax.set_ylabel("evacuation time (s)")
ax.set_title(f"Bottleneck ({DOOR_WIDTH} m door), {NUM_AGENTS} agents, 3 seeds")
fig.tight_layout()
plt.show()

sweep.cleanup();
../../_images/cc8d2c9e91a18100adf732383df0b9f763d8de0e4184d0f49ea9db6ae88cb181.png

To sweep something that changes the geometry itself — like the door width — build each scenario fresh with run_sweep_from_factory:

from jupedsim_scenarios import run_sweep_from_factory

def make(params):
    scenario = Scenario(
        raw={},
        walkable_area_wkt=build_geometry(params["door_width"]).wkt,  # your function
        model_type="CollisionFreeSpeedModel",
        seed=42,
        sim_params={"max_simulation_time": 300},
    )
    scenario.add_distribution(spawn_polygon, number=NUM_AGENTS, desired_speed=1.2)
    scenario.add_exit(exit_polygon)
    return scenario

sweep = run_sweep_from_factory(
    make,
    trials=[{"door_width": w} for w in (0.8, 1.2, 1.6, 2.4)],
    seeds=range(1, 4),
    workers=4,
)

The bottleneck tutorial runs exactly this kind of model × door-width grid and reproduces the known flow physics.

When vanilla JuPedSim is still the right tool#

Direct use of jupedsim remains the way to go when you need what the wrapper doesn’t expose: per-iteration control of the loop (rerouting agents mid-run, custom waiting logic, direct steering), custom trajectory serializers, or model features outside the scenario schema. For everything on the run–sweep–analyze path, start here and keep your code at the level of your research question.

Next steps: sweep basics · build a scenario from scratch · visualise trajectories