ISO 20414:2020 — Test 5: Assigned occupant demographics¶
100 occupants evenly distributed in a 100 m x 100 m room with desired speeds sampled from a distribution (here Gaussian mu=1.2, sigma=0.2) (ISO 20414:2020 Table 6). The verification target is consistency between the assigned and the represented walking-speed distribution, so we run the scenario, measure each agent's free-flow speed from its trajectory, and compare it back to the assigned Gaussian.
from datetime import datetime
print(f"Executed on {datetime.now().astimezone().strftime('%d %B %Y, %H:%M %Z')}")
Executed on 16 October 2026, 11:35 UTC
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from scipy import stats
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),
})
Load and inspect the scenario¶
SCENARIO_ZIP = Path("scenario_files") / "Iso-05-demographics.zip"
scenario = load_scenario(str(SCENARIO_ZIP))
params = scenario.distributions['jps-distributions_0']['parameters']
print(f'v0 = {params["v0"]} m/s, v0_distribution = {params["v0_distribution"]}, '
f'v0_std = {params.get("v0_std", "n/a")}, n = {params["number"]}')
v0 = 1.2 m/s, v0_distribution = gaussian, v0_std = 0.2, n = 100
Scenario check — geometry and trajectories¶
Confirm the simulated layout: a 100 m x 100 m room with 100 occupants. A far-wall exit is added (the same one used to measure speeds below) so the sparse crowd walks across the room in free flow.
scen = load_scenario(str(SCENARIO_ZIP))
scen.add_exit([(100, 0), (100, 100), (99.7, 100), (99.7, 0)])
_res = run_scenario(scen, seed=42)
tdf = _res.trajectory_dataframe()
poly = scen.walkable_polygon
fig, ax = plt.subplots(figsize=(6, 6))
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
Run and measure the represented walking speeds¶
The base scenario has no exit, so agents never move. We add a full-width exit on the far wall and let the crowd walk across the room. At the ISO density (100 agents in 100 m x 100 m = 0.01 p/m²) every agent is in free flow, so its measured speed equals its assigned desired speed. Per ISO the test is repeated over several runs; for each agent we take the median instantaneous speed over the frames where it is moving.
MU, SIGMA = params['v0'], params['v0_std']
N_RUNS = 5
SEEDS = [42 + i for i in range(N_RUNS)]
assigned = np.random.default_rng(0).normal(MU, SIGMA, 100000) # the assigned distribution
def measure_free_flow_speeds(seed):
s = load_scenario(str(SCENARIO_ZIP))
# No exit in the base scenario -> add a full-width exit on the far (x=100)
# wall so the sparse crowd walks across the room in free flow.
s.add_exit([(100, 0), (100, 100), (99.7, 100), (99.7, 0)])
result = run_scenario(s, seed=seed)
traj = result.trajectory_dataframe().sort_values(['id', 'frame'])
fr = result.frame_rate
speeds = []
for _, sub in traj.groupby('id'):
step = np.hypot(np.diff(sub.x), np.diff(sub.y)) * fr # m/s per frame
step = step[step > 0.05] # frames where moving
if len(step):
speeds.append(np.median(step))
result.cleanup()
return np.array(speeds)
rows, all_speeds = [], []
for seed in SEEDS:
sp = measure_free_flow_speeds(seed)
all_speeds.append(sp)
rows.append({'seed': seed, 'n': len(sp), 'mean': sp.mean(),
'std': sp.std(), 'ks_p': stats.ks_2samp(sp, assigned).pvalue})
fit = pd.DataFrame(rows)
all_speeds = np.concatenate(all_speeds)
fit
| seed | n | mean | std | ks_p | |
|---|---|---|---|---|---|
| 0 | 42 | 100 | 1.178506 | 0.181167 | 0.205073 |
| 1 | 43 | 100 | 1.212917 | 0.192058 | 0.942699 |
| 2 | 44 | 100 | 1.182059 | 0.200427 | 0.924108 |
| 3 | 45 | 100 | 1.172164 | 0.186192 | 0.067124 |
| 4 | 46 | 100 | 1.186644 | 0.183767 | 0.213334 |
Represented speeds vs the assigned Gaussian¶
fig, ax = plt.subplots()
ax.hist(all_speeds, bins=25, density=True, alpha=0.6, label=f'measured ({N_RUNS} runs)')
xs = np.linspace(0.4, 2.0, 200)
ax.plot(xs, (1 / (SIGMA * np.sqrt(2 * np.pi)))
* np.exp(-0.5 * ((xs - MU) / SIGMA) ** 2), 'r-', label=f'assigned Gaussian({MU}, {SIGMA})')
ax.set_xlabel('walking speed [m/s]'); ax.legend()
plt.show()
Acceptance¶
The represented walking-speed distribution must be consistent with the assigned Gaussian(1.2, 0.2): the pooled mean and standard deviation match the assigned parameters within tolerance, and the per-run shape is Gaussian-consistent (median two-sample KS p > 0.05). A small (~1%) downward offset in the measured mean is a model-level free-flow characteristic, not a distribution mismatch.
ALPHA = 0.05
assert params['v0_distribution'] == 'gaussian'
assert abs(all_speeds.mean() - MU) < 0.05, all_speeds.mean()
assert abs(all_speeds.std() - SIGMA) < 0.05, all_speeds.std()
assert fit['ks_p'].median() > ALPHA, fit
print(f'PASS - represented speeds match assigned Gaussian({MU}, {SIGMA}) over {N_RUNS} runs: '
f'mean={all_speeds.mean():.3f}, std={all_speeds.std():.3f}, median KS p={fit["ks_p"].median():.3f}')
PASS - represented speeds match assigned Gaussian(1.2, 0.2) over 5 runs: mean=1.186, std=0.189, median KS p=0.213