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 |
|
|---|---|---|
Build + run one bottleneck |
~45 lines (Part 1) |
~10 lines (Part 2) |
Trajectory output |
manage |
temp SQLite + |
Parameters × seeds study |
hand-rolled loops + bookkeeping ( |
one |
Scenarios from the web editor |
parse the JSON yourself |
|
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:
resultcarries 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 withsave_scenario.The same
run_scenarioaccepts 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();
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