RiMEA 13: Fundamental Diagram on Stairs¶
This notebook approximates RiMEA Test 13 with a zone-based stair model. The stair segment is represented by a slowdown zone inside the corridor. Agents keep a shared flat-ground demographic around $1.2\,\mathrm{m/s}$, while the stair behavior is introduced through zone speed factors 0.5 for upwards and 0.7 for downwards.
The outputs focus on three questions:
- do the
upstairsanddownstairsspeed-density points stay within the coded band? - is the
downstairscurve faster than theupstairscurve at the same density? - what do the trajectories look like in the stair geometry?
Deviation from RiMEA¶
RiMEA asks for stair speeds within the spread of Abb. 16. The test only requires at least 25% of the points inside the coded band, so the inside-band fractions below are published rather than gated. The band (the suite's own, source pending; compare RiMEA Abb. 16) and the RiMEA standard population (section 3.2.4) are not yet checked against the standard.
In [1]:
from datetime import datetime
print(f"Executed on {datetime.now().astimezone().strftime('%d %B %Y, %H:%M %Z')}")
Executed on 16 October 2026, 12:31 UTC
In [2]:
import json
import tempfile
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import pedpy
from shapely.geometry import Point, Polygon
from scenario_builders.rimea13_stairs import (
EXIT_COORDINATES,
FRUIN_STAIR_SPEEDS,
RIEMA_STANDARD_DEMOGRAPHIC,
STAIR_WALKABLE_AREA_WKT,
STAIR_ZONE_COORDINATES,
STAIR_ZONE_SPEED_FACTORS,
build_raw_scenario,
corbetta_envelope_bounds,
)
from jupedsim_scenarios import load_scenario, run_sweep_from_factory
In [3]:
plt.rcParams.update(
{
"figure.facecolor": "white",
"axes.facecolor": "#f8fafc",
"axes.edgecolor": "#334155",
"axes.labelcolor": "#0f172a",
"axes.titleweight": "bold",
"xtick.color": "#0f172a",
"ytick.color": "#0f172a",
"font.size": 11,
}
)
STAIR_ZONE = Polygon(STAIR_ZONE_COORDINATES)
COLORS = {"up": "#2563EB", "down": "#DC2626"}
Build and Run Both Scenarios¶
In [4]:
def _build_direction(params):
"""Factory that constructs a Scenario for the given direction.
The scenario reference is paired into extras so downstream
post-processing can read it back off the Trial."""
direction = params["direction"]
raw = build_raw_scenario(direction=direction)
tmpdir = tempfile.TemporaryDirectory()
scenario_dir = Path(tmpdir.name)
(scenario_dir / "config.json").write_text(json.dumps(raw, indent=2), encoding="utf-8")
(scenario_dir / "geometry.wkt").write_text(STAIR_WALKABLE_AREA_WKT, encoding="utf-8")
scenario = load_scenario(str(scenario_dir))
return scenario, scenario
def _process_direction(trial):
"""Compute the per-direction pedpy artefacts from a Trial.
Replaces the post-processing tail that used to live inside
run_direction."""
result = trial.result
traj_df = result.trajectory_dataframe()[["id", "frame", "x", "y"]].copy()
traj = pedpy.TrajectoryData(traj_df, frame_rate=result.frame_rate)
speed_df = pedpy.compute_individual_speed(
traj_data=traj,
frame_step=10,
speed_calculation=pedpy.SpeedCalculation.BORDER_EXCLUDE,
)
merged = traj.data.merge(speed_df[["id", "frame", "speed"]], on=["id", "frame"], how="inner")
stair_points = merged[
merged.apply(lambda row: STAIR_ZONE.covers(Point(row["x"], row["y"])), axis=1)
].copy()
frame_counts = stair_points.groupby("frame")["id"].nunique().rename("count")
stair_points = stair_points.merge(frame_counts, on="frame", how="left")
stair_points["density"] = stair_points["count"] / STAIR_ZONE.area
stair_points = stair_points[(stair_points["density"] >= 0.6) & (stair_points["density"] <= 1.5)]
direction = trial.axis_values["direction"]
scenario = trial.extras
return {
"direction": direction,
"scenario": scenario,
"result": result,
"traj": traj,
"walkable_area": pedpy.WalkableArea(scenario.walkable_polygon),
"stair_points": stair_points,
}
In [5]:
%%capture
# Run both directions in one factory-driven sweep so they execute
# in parallel.
sweep = run_sweep_from_factory(
_build_direction,
trials=[{"direction": "up"}, {"direction": "down"}],
seeds=[42],
workers=2,
)
# Look up trials by direction so the assignment is independent of
# the order run_sweep_from_factory returns them in.
by_direction = {t.axis_values["direction"]: t for t in sweep.trials}
up = _process_direction(by_direction["up"])
down = _process_direction(by_direction["down"])
sweep.cleanup()
In [6]:
summary = pd.DataFrame(
[
{
"direction": run["direction"],
"zone_speed_factor": STAIR_ZONE_SPEED_FACTORS[run["direction"]],
"evacuation_time [s]": run["result"].evacuation_time,
"stair samples": len(run["stair_points"]),
"mean stair speed [m/s]": run["stair_points"]["speed"].mean(),
}
for run in [up, down]
]
)
summary
Out[6]:
| direction | zone_speed_factor | evacuation_time [s] | stair samples | mean stair speed [m/s] | |
|---|---|---|---|---|---|
| 0 | up | 0.5 | 52.62 | 918 | 0.553609 |
| 1 | down | 0.7 | 45.19 | 853 | 0.756988 |
Geometry, Stair Zone, and Trajectories¶
In [7]:
fig, axes = plt.subplots(1, 2, figsize=(15.5, 5.4), constrained_layout=True)
for ax, run in zip(axes, [up, down]):
pedpy.plot_trajectories(
traj=run["traj"],
walkable_area=run["walkable_area"],
axes=ax,
traj_alpha=0.18,
traj_width=0.35,
traj_color=COLORS[run["direction"]],
).set_aspect("equal")
ax.fill(
[xy[0] for xy in STAIR_ZONE_COORDINATES],
[xy[1] for xy in STAIR_ZONE_COORDINATES],
color="#22C55E",
alpha=0.22,
zorder=2,
)
ax.fill(
[xy[0] for xy in EXIT_COORDINATES],
[xy[1] for xy in EXIT_COORDINATES],
color="#15803D",
alpha=0.28,
zorder=2,
)
ax.text(14.5, 6.25, "stair zone", color="#166534", fontsize=10, ha="center")
ax.text(18.9, 6.25, "goal", color="#166534", fontsize=10, ha="center")
ax.set_title(f"{run['direction'].title()} scenario", pad=12)
ax.set_xlim(-0.5, 19.5)
ax.set_ylim(-0.5, 10.5)
ax.set_xlabel("x [m]")
ax.set_ylabel("y [m]")
ax.grid(True, linestyle="--", linewidth=0.7, alpha=0.35)
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
plt.show()
Fundamental Diagram Against the Coded Band¶
In [8]:
fig, axes = plt.subplots(1, 2, figsize=(15.5, 5.4), constrained_layout=True)
for ax, run in zip(axes, [up, down]):
rho = np.linspace(0.6, 1.5, 400)
low, high = corbetta_envelope_bounds(run["direction"], rho)
ax.fill_between(rho, low, high, color="#BFDBFE", alpha=0.7, label="Suite band (source pending)")
ax.scatter(
run["stair_points"]["density"],
run["stair_points"]["speed"],
s=16,
alpha=0.22,
color=COLORS[run["direction"]],
label="simulation points",
)
binned = run["stair_points"].groupby("count", observed=True)["speed"].mean()
ax.plot(binned.index.to_numpy(dtype=float) / STAIR_ZONE.area, binned.to_numpy(), color="#0F172A", linewidth=2.2, marker="o", label="binned mean")
ax.set_title(f"{run['direction'].title()} stairs", pad=12)
ax.set_xlabel("Density [1/m²]")
ax.set_ylabel("Speed [m/s]")
ax.set_xlim(0.55, 1.55)
ax.set_ylim(0.15, 1.0)
ax.grid(True, linestyle="--", linewidth=0.7, alpha=0.35)
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
ax.legend(frameon=False)
plt.show()
In [9]:
def inside_band_fraction(run):
points = run["stair_points"]
low, high = corbetta_envelope_bounds(run["direction"], points["density"].to_numpy())
speed = points["speed"].to_numpy()
return float(((speed >= low) & (speed <= high)).mean())
pd.DataFrame(
[
{
"direction": run["direction"],
"stair samples": len(run["stair_points"]),
"fraction inside coded band": round(inside_band_fraction(run), 3),
}
for run in [up, down]
]
)
Out[9]:
| direction | stair samples | fraction inside coded band | |
|---|---|---|---|
| 0 | up | 918 | 0.998 |
| 1 | down | 853 | 0.549 |
Density-Matched Comparison¶
In [10]:
up_binned = up["stair_points"].groupby("count", observed=True)["speed"].mean()
down_binned = down["stair_points"].groupby("count", observed=True)["speed"].mean()
common_counts = up_binned.index.intersection(down_binned.index)
area = STAIR_ZONE.area
comparison = pd.DataFrame(
{
"count": common_counts.astype(int),
"density": common_counts.astype(float) / area,
"upstairs_speed": up_binned.loc[common_counts].to_numpy(),
"downstairs_speed": down_binned.loc[common_counts].to_numpy(),
}
).sort_values("count")
comparison["difference"] = comparison["downstairs_speed"] - comparison["upstairs_speed"]
fig, ax = plt.subplots(figsize=(9, 4.8))
ax.plot(comparison["density"], comparison["upstairs_speed"], color=COLORS["up"], marker="o", linewidth=2.0, label="upstairs")
ax.plot(comparison["density"], comparison["downstairs_speed"], color=COLORS["down"], marker="o", linewidth=2.0, label="downstairs")
ax.fill_between(
comparison["density"],
comparison["upstairs_speed"],
comparison["downstairs_speed"],
color="#FDE68A",
alpha=0.35,
)
ax.set_title("Stair fundamental diagram: downstairs is faster across the FD range", pad=12)
ax.set_xlabel("Density [1/m²]")
ax.set_ylabel("Mean speed [m/s]")
ax.grid(True, linestyle="--", linewidth=0.7, alpha=0.35)
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
ax.legend(frameon=False)
plt.show()
comparison
Out[10]:
| count | density | upstairs_speed | downstairs_speed | difference | |
|---|---|---|---|---|---|
| 0 | 6 | 0.6 | 0.500578 | 0.689080 | 0.188503 |
| 1 | 7 | 0.7 | 0.597739 | 0.768939 | 0.171201 |
| 2 | 8 | 0.8 | 0.481706 | 0.625440 | 0.143735 |
| 3 | 9 | 0.9 | 0.611225 | 0.695758 | 0.084533 |
| 4 | 10 | 1.0 | 0.508669 | 0.612071 | 0.103402 |
| 5 | 11 | 1.1 | 0.575517 | 0.802071 | 0.226554 |
| 6 | 12 | 1.2 | 0.517502 | 0.725480 | 0.207978 |
| 7 | 13 | 1.3 | 0.489394 | 0.677406 | 0.188012 |
| 8 | 14 | 1.4 | 0.514621 | 0.711225 | 0.196604 |
| 9 | 15 | 1.5 | 0.594185 | 0.834138 | 0.239954 |
Flat-Ground Demographic v0 Used In Both Scenarios¶
In [11]:
pd.DataFrame(RIEMA_STANDARD_DEMOGRAPHIC)
Out[11]:
| label | count | v0 | |
|---|---|---|---|
| 0 | under_30_internal | 15 | 1.34 |
| 1 | under_30_external | 15 | 1.30 |
| 2 | age_30_50_internal | 15 | 1.24 |
| 3 | age_30_50_external | 15 | 1.20 |
| 4 | over_50_internal | 15 | 1.14 |
| 5 | over_50_external | 15 | 1.10 |
| 6 | impaired_mobility | 10 | 0.96 |
Fruin Stair-Speed Reference Used To Motivate The Zone Factors¶
In [12]:
pd.DataFrame(
[
{
"direction": direction,
"group": row["label"],
"stair_speed": row["speed"],
}
for direction, rows in FRUIN_STAIR_SPEEDS.items()
for row in rows
]
)
Out[12]:
| direction | group | stair_speed | |
|---|---|---|---|
| 0 | down | under_30_internal | 0.76 |
| 1 | down | under_30_external | 0.81 |
| 2 | down | age_30_50_internal | 0.65 |
| 3 | down | age_30_50_external | 0.78 |
| 4 | down | over_50_internal | 0.55 |
| 5 | down | over_50_external | 0.59 |
| 6 | down | impaired_mobility | 0.42 |
| 7 | up | under_30_internal | 0.55 |
| 8 | up | under_30_external | 0.58 |
| 9 | up | age_30_50_internal | 0.50 |
| 10 | up | age_30_50_external | 0.58 |
| 11 | up | over_50_internal | 0.42 |
| 12 | up | over_50_external | 0.42 |
| 13 | up | impaired_mobility | 0.32 |
In [13]:
# sweep.cleanup() above already released the per-trial result and
# temp scenario directories. Nothing extra needed here.