ISO 20414:2020 — Test 2: Walking speed in a corridor¶
Single agent walks a 40 m corridor at 1.0 m/s and should cover it in 40 s (ISO 20414:2020 Table 3). The corridor is extended to 60 m with 10 m acceleration and 10 m isolation buffers; the 40 m measurement segment is unchanged.
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, 11:34 UTC
In [2]:
from pathlib import Path
import matplotlib.pyplot as plt
import pandas as pd
from jupedsim_scenarios import load_scenario, run_scenario
In [3]:
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),
})
In [4]:
import numpy as np
# House style for the V&V figures (plain matplotlib; applied per figure via rc_context).
from matplotlib.colors import to_rgba
from matplotlib.lines import Line2D
from matplotlib.patches import Patch, PathPatch
from matplotlib.patches import Polygon as MplPolygon
from matplotlib.path import Path as MplPath
VV_BLUE, VV_ORANGE, VV_RED, VV_GREEN = "#0072B2", "#E69F00", "#D55E00", "#009E73"
VV_RC = {
"figure.facecolor": "white", "axes.facecolor": "white", "figure.dpi": 150,
"font.family": "DejaVu Sans", "font.size": 10,
"axes.grid": True, "grid.color": "#e5e5e5", "grid.linewidth": 0.8, "axes.axisbelow": True,
"axes.edgecolor": "lightgrey", "axes.labelcolor": "dimgrey",
"axes.titlecolor": "#333333", "axes.titleweight": "normal", "axes.titlesize": 11,
"axes.spines.left": False, "axes.spines.bottom": False,
"axes.spines.top": False, "axes.spines.right": False,
"xtick.color": "dimgrey", "ytick.color": "dimgrey",
"xtick.major.size": 0, "ytick.major.size": 0,
"legend.frameon": True, "legend.facecolor": "white", "legend.framealpha": 0.8,
"legend.edgecolor": "lightgrey", "legend.labelcolor": "dimgrey", "legend.fontsize": 9,
}
def vv_polygon_patch(geom, **kwargs):
"""Matplotlib patch for a shapely (Multi)Polygon, holes included."""
verts, codes = [], []
for part in getattr(geom, "geoms", [geom]):
for ring in [part.exterior, *part.interiors]:
xy = np.asarray(ring.coords)
verts.extend(xy)
codes += [MplPath.MOVETO] + [MplPath.LINETO] * (len(xy) - 2) + [MplPath.CLOSEPOLY]
return PathPatch(MplPath(verts, codes), **kwargs)
def vv_draw_scenario(ax, scenario, starts=True, pad=0.04):
"""Draw walkable area, exits and start areas of a loaded scenario; return legend handles."""
ax.add_patch(vv_polygon_patch(scenario.walkable_polygon, facecolor="#eeeeee",
edgecolor="dimgrey", linewidth=1.4, zorder=1))
handles = [Patch(facecolor="#eeeeee", edgecolor="dimgrey", label="walkable area")]
if starts:
for dist in scenario.distributions.values():
ax.add_patch(MplPolygon(dist["coordinates"], closed=True,
facecolor=to_rgba(VV_BLUE, 0.15), edgecolor=VV_BLUE,
linestyle="--", linewidth=1.2, hatch="..", zorder=2))
handles.append(Patch(facecolor=to_rgba(VV_BLUE, 0.15), edgecolor=VV_BLUE,
linestyle="--", hatch="..", label="start area"))
for ex in scenario.exits.values():
ax.add_patch(MplPolygon(ex["coordinates"], closed=True,
facecolor=to_rgba(VV_RED, 0.35), edgecolor=VV_RED,
linewidth=1.2, hatch="///", zorder=3))
if scenario.exits:
handles.append(Patch(facecolor=to_rgba(VV_RED, 0.35), edgecolor=VV_RED,
hatch="///", label="exit"))
minx, miny, maxx, maxy = scenario.walkable_polygon.bounds
m = pad * max(maxx - minx, maxy - miny)
ax.set_xlim(minx - m, maxx + m)
ax.set_ylim(miny - m, maxy + m)
ax.set_aspect("equal")
ax.grid(False)
ax.set_xlabel("x [m]")
ax.set_ylabel("y [m]")
return handles
def vv_agent_count(scenario):
"""Total number of agents placed by the scenario's start areas."""
return sum(int(d.get("parameters", {}).get("number", 0)) for d in scenario.distributions.values())
Load and run the scenario¶
In [5]:
SCENARIO_ZIP = Path("scenario_files") / "Iso-02-corridor.zip"
scenario = load_scenario(str(SCENARIO_ZIP))
print(scenario.summary())
result = run_scenario(scenario, seed=42)
Scenario: standards/iso/scenario_files/Iso-02-corridor.zip
Model: CollisionFreeSpeedModel
Seed: 42
Max time: 100s
Exits: 1
Distributions: 1
Stages: 0
Zones: 0
Journeys: 1
Agents: ~1
Journey elems: 2
Route: 1 distribution, 0 checkpoint, 1 exit
Sequence: jps-distributions_0 -> jps-exits_0
jps-distributions_0: 1 agents
Measure walking speed across the 40 m segment¶
Expected transit time at 1 m/s is 40 s.
In [6]:
df = result.trajectory_dataframe().sort_values(['id', 'frame'])
MEAS_START_X = 10.0
MEAS_END_X = 50.0
DIRECTION = 'right'
rows = []
for agent_id, sub in df.groupby('id'):
if DIRECTION == 'right':
a = sub[sub.x >= MEAS_START_X]; b = sub[sub.x >= MEAS_END_X]
else:
a = sub[sub.x <= MEAS_START_X]; b = sub[sub.x <= MEAS_END_X]
if a.empty or b.empty:
continue
t_in = a.iloc[0].frame / result.frame_rate
t_out = b.iloc[0].frame / result.frame_rate
rows.append({'id': int(agent_id), 't_in_s': t_in, 't_out_s': t_out, 'transit_s': t_out - t_in})
transit = pd.DataFrame(rows)
transit['speed_m_s'] = abs(MEAS_END_X - MEAS_START_X) / transit['transit_s']
transit
Out[6]:
| id | t_in_s | t_out_s | transit_s | speed_m_s | |
|---|---|---|---|---|---|
| 0 | 1 | 8.8 | 48.8 | 40.0 | 1.0 |
Plot trajectory x vs t¶
In [7]:
fig, ax = plt.subplots()
for agent_id, sub in df.groupby('id'):
ax.plot(sub.frame / result.frame_rate, sub.x, label=f'agent {agent_id}')
ax.axhline(MEAS_START_X, color='k', ls='--', alpha=0.3, label='measure start')
ax.axhline(MEAS_END_X, color='k', ls=':', alpha=0.3, label='measure end')
ax.set_xlabel('time [s]'); ax.set_ylabel('x position [m]'); ax.legend()
plt.show()
Scenario check — geometry and trajectory¶
Confirm the simulated layout: a 60 m corridor (40 m measurement segment plus 10 m run-up and 10 m buffer) with a single occupant walking left to right to the exit.
In [8]:
scen = scenario
tdf = df
poly = scen.walkable_polygon
fig, ax = plt.subplots(figsize=(10, 2.2))
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()
Result figure¶
Mean speed over the measurement segment against the accepted band of the acceptance check.
In [9]:
# Mean speed over the measurement segment against the accepted band.
TARGET, TOL = 1.0, 0.05 # same values as the acceptance cell
observed = transit["speed_m_s"]
with plt.rc_context(VV_RC):
fig, ax = plt.subplots(figsize=(8, 2.8))
ax.axvspan(TARGET - TOL, TARGET + TOL, facecolor=to_rgba(VV_GREEN, 0.18), edgecolor=VV_GREEN,
lw=0, label=f"accepted: {TARGET:g} +/- {TOL:g} m/s")
ax.axvline(TARGET, color=VV_GREEN, ls="--", lw=1.4, label=f"target {TARGET:g} m/s")
ax.plot(observed, np.zeros(len(observed)), "o", color=VV_BLUE, ms=10, zorder=4, label="measured")
lo = min(TARGET - 3 * TOL, observed.min() - TOL)
hi = max(TARGET + 3 * TOL, observed.max() + TOL)
ax.set_xlim(lo, hi)
ax.set_ylim(-1, 1.7)
ax.set_yticks([])
ax.set_xlabel("walking speed over the measurement segment [m/s]")
ax.set_title("Measured speed against the acceptance band", loc="left")
ax.legend(loc="upper center", bbox_to_anchor=(0.5, -0.38), ncol=3)
ax.text(0.5, 0.78, f"measured {observed.mean():.3f} m/s (transit {transit['transit_s'].mean():.1f} s)",
transform=ax.transAxes, ha="center", color="dimgrey", fontweight="semibold", fontsize=9,
bbox={"facecolor": "white", "alpha": 0.85, "edgecolor": "none", "pad": 2})
ax.grid(False)
plt.show()
Acceptance¶
In [10]:
TARGET = 1.0
TOL = 0.05
observed = transit['speed_m_s'].mean()
print(f'observed mean speed = {observed:.3f} m/s; target = {TARGET} m/s; '
f'transit = {transit["transit_s"].mean():.2f} s; expected = 40 s')
assert abs(observed - TARGET) <= TOL, (observed, TARGET)
result.cleanup()
observed mean speed = 1.000 m/s; target = 1.0 m/s; transit = 40.00 s; expected = 40 s
Out[10]:
1