ISO 20414:2020 — Test 12: Stair flow rates¶
100 occupants funnel through a centred stair opening of width 1.0, 1.2, 1.4, 1.6, 1.8 m (ISO 20414:2020 Table 13). The aim is to confirm that an increase in stair width leads to an increase in agent flow rate. The stair strip carries a speed_factor = 0.5 zone (agents cross it at 0.5 m/s) — the speed-factor convention ISO Test 3/12 describe for stairs. The factor is constant across runs, so width is the only driver of the difference in emergent flow.
from datetime import datetime
print(f"Executed on {datetime.now().astimezone().strftime('%d %B %Y, %H:%M %Z')}")
Executed on 16 October 2026, 11:37 UTC
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from jupedsim_scenarios import load_scenario, run_scenario
plt.rcParams.update({
"figure.facecolor": "white",
"axes.facecolor": "#f7f7f5",
"axes.edgecolor": "#3a3a3a",
"axes.labelcolor": "#1d1d1d",
"axes.titleweight": "bold",
"axes.grid": True,
"grid.alpha": 0.3,
"font.size": 11,
"figure.figsize": (8, 5),
})
import json
import tempfile
from scenario_builders.iso12_stair_flow import (
STAIR_WIDTHS, build_raw_scenario, build_geometry_wkt, measure_flow)
Scenario check — geometry and trajectories¶
Confirm the simulated layout: a feeding room with a centred stair (here width 1.4 m) carrying a speed_factor = 0.5 zone and an exit at the top. Occupants funnel from the room up the stair.
_raw = build_raw_scenario(1.4)
with tempfile.TemporaryDirectory() as _d:
_p = Path(_d)
(_p / 'config.json').write_text(json.dumps(_raw, indent=2), encoding='utf-8')
(_p / 'geometry.wkt').write_text(build_geometry_wkt(1.4), encoding='utf-8')
scen = load_scenario(str(_p))
_res = run_scenario(scen, seed=42)
tdf = _res.trajectory_dataframe()
poly = scen.walkable_polygon
fig, ax = plt.subplots(figsize=(6, 7))
gx, gy = poly.exterior.xy
ax.fill(gx, gy, facecolor='#eef2f7', edgecolor='k', lw=1.5, zorder=0)
for interior in poly.interiors:
ix, iy = interior.xy
ax.fill(ix, iy, facecolor='white', edgecolor='k', lw=1.0, zorder=1)
for z in scen.zones.values():
zx = [c[0] for c in z['coordinates']]
zy = [c[1] for c in z['coordinates']]
ax.fill(zx, zy, facecolor='tab:orange', alpha=0.18, edgecolor='tab:orange',
zorder=1, label=f"speed-factor {z.get('speed_factor')} zone")
for e in scen.exits.values():
ex = [c[0] for c in e['coordinates']]
ey = [c[1] for c in e['coordinates']]
ax.fill(ex, ey, facecolor='tab:green', alpha=0.6, zorder=2, label='exit')
for _, sub in tdf.sort_values(['id', 'frame']).groupby('id'):
ax.plot(sub.x, sub.y, color='tab:blue', lw=0.6, alpha=0.4, zorder=3)
starts = tdf.sort_values(['id', 'frame']).groupby('id').first()
ax.scatter(starts.x, starts.y, s=8, color='tab:blue', zorder=4, label='start')
handles, labels = ax.get_legend_handles_labels()
uniq = dict(zip(labels, handles))
ax.legend(uniq.values(), uniq.keys(), fontsize=8, loc='upper center',
bbox_to_anchor=(0.5, -0.18), ncol=3, frameon=False)
ax.set_aspect('equal'); ax.set_xlabel('x [m]'); ax.set_ylabel('y [m]')
plt.tight_layout(); plt.show()
_res.cleanup()
1
Sweep stair widths¶
Each width is materialised as a scenario directory (config.json + geometry.wkt) and loaded with load_scenario.
rows = []
for w in STAIR_WIDTHS:
raw = build_raw_scenario(w)
with tempfile.TemporaryDirectory() as d:
p = Path(d)
(p / 'config.json').write_text(json.dumps(raw, indent=2), encoding='utf-8')
(p / 'geometry.wkt').write_text(build_geometry_wkt(w), encoding='utf-8')
scenario = load_scenario(str(p))
r = run_scenario(scenario, seed=42)
rows.append(measure_flow(r.trajectory_dataframe(), r.frame_rate, w))
r.cleanup()
flow = pd.DataFrame(rows)
flow
| width | crossed | flow_p_s | specific_flow_p_m_s | |
|---|---|---|---|---|
| 0 | 1.0 | 100 | 1.174377 | 1.174377 |
| 1 | 1.2 | 100 | 1.544462 | 1.287051 |
| 2 | 1.4 | 100 | 1.911197 | 1.365141 |
| 3 | 1.6 | 100 | 2.020408 | 1.262755 |
| 4 | 1.8 | 100 | 2.462687 | 1.368159 |
Plot flow rate vs stair width¶
fig, ax = plt.subplots()
ax.plot(flow['width'], flow['flow_p_s'], 'o-', label='absolute flow')
ax.set_xlabel('stair width [m]'); ax.set_ylabel('flow at mid-stair [p/s]')
ax.legend(); plt.show()
Acceptance¶
Flow rate should increase with stair width: the widest stair must exceed the narrowest, and the linear trend must have positive slope.
f = flow.dropna(subset=['flow_p_s'])
slope = np.polyfit(f['width'], f['flow_p_s'], 1)[0]
print(f'flow(1.8 m) = {f.flow_p_s.iloc[-1]:.3f} p/s, flow(1.0 m) = {f.flow_p_s.iloc[0]:.3f} p/s, '
f'slope = {slope:.3f} p/s per m')
assert slope > 0, flow
assert f['flow_p_s'].iloc[-1] > f['flow_p_s'].iloc[0], flow
flow(1.8 m) = 2.463 p/s, flow(1.0 m) = 1.174 p/s, slope = 1.526 p/s per m