ISO 20414:2020 — Test 8: Exit route allocation¶

Corridor with rooms (ISO 20414:2020 Table 9 / Figure 5). 23 agents, pre-evacuation time 0, as Table 9's text states: 2 in each room and 1 in room 3, the population of RiMEA 4.1.1 Test 10 and NIST TN 1822 Verif.3.1. Figure 5 of ISO 20414 shows 13 (1 per room, 2 in room 3), which is inconsistent with its own text. Occupants in rooms 1,2,3,4,7,8,9,10 are allocated to the main exit; the rest to the secondary exit. Each agent must reach its allocated exit.

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:36 UTC
In [2]:
from pathlib import Path

import matplotlib.pyplot as plt
import pandas as pd
import pedpy
from shapely.geometry import Point, Polygon

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 the scenario and the encoded route allocation¶

In [5]:
SCENARIO_ZIP = Path("scenario_files") / "Iso-08-route-allocation.zip"
scenario = load_scenario(str(SCENARIO_ZIP))
print(scenario.summary())
raw = scenario.raw
if 'journeys' in raw:
    journey_map = {j['stages'][0]: j['stages'][-1] for j in raw['journeys']}
else:
    j_to_exit = {j['id']: j['sequence'][-1] for j in raw.get('journeys_v2', [])}
    journey_map = {did: j_to_exit.get((d.get('journey_weights') or [{}])[0].get('journey_id'), '')
                   for did, d in scenario.distributions.items()}
print('expected assignment:', journey_map)
Scenario: standards/iso/scenario_files/Iso-08-route-allocation.zip
  Model:         CollisionFreeSpeedModel
  Seed:          420
  Max time:      300s
  Exits:         2
  Distributions: 12
  Stages:        0
  Zones:         0
  Journeys:      0
  Agents:        ~23
    jps-distributions_0: 2 agents
    jps-distributions_1: 2 agents
    jps-distributions_2: 2 agents
    jps-distributions_3: 2 agents
    jps-distributions_4: 2 agents
    jps-distributions_5: 2 agents
    jps-distributions_6: 2 agents
    jps-distributions_7: 1 agents
    jps-distributions_8: 2 agents
    jps-distributions_9: 2 agents
    jps-distributions_10: 2 agents
    jps-distributions_11: 2 agents
expected assignment: {'jps-distributions_0': 'jps-exits_0', 'jps-distributions_1': 'jps-exits_0', 'jps-distributions_2': 'jps-exits_0', 'jps-distributions_3': 'jps-exits_0', 'jps-distributions_4': 'jps-exits_0', 'jps-distributions_5': 'jps-exits_0', 'jps-distributions_6': 'jps-exits_0', 'jps-distributions_7': 'jps-exits_0', 'jps-distributions_8': 'jps-exits_1', 'jps-distributions_9': 'jps-exits_1', 'jps-distributions_10': 'jps-exits_1', 'jps-distributions_11': 'jps-exits_1'}

Scenario figure¶

Rooms and exits from the loaded scenario; each room is coloured by the exit its journey assigns.

In [6]:
# Start areas coloured by the exit that the scenario journey assigns to them.
exit_ids = sorted(scenario.exits)
palette = dict(zip(exit_ids, [VV_BLUE, VV_ORANGE]))
hatches = dict(zip(exit_ids, ["..", "xx"]))
names = {}
with plt.rc_context(VV_RC):
    fig, ax = plt.subplots(figsize=(10, 6))
    handles = vv_draw_scenario(ax, scenario, starts=False)
    handles = handles[:1]
    for dist_id, dist in scenario.distributions.items():
        target = journey_map[dist_id]
        ax.add_patch(MplPolygon(dist["coordinates"], closed=True, facecolor=to_rgba(palette[target], 0.25),
                                edgecolor=palette[target], linestyle="--", linewidth=1.2,
                                hatch=hatches[target], zorder=2))
    for ex_id, ex in scenario.exits.items():
        ax.add_patch(MplPolygon(ex["coordinates"], closed=True, facecolor=to_rgba(palette.get(ex_id, VV_RED), 0.8),
                                edgecolor=palette.get(ex_id, VV_RED), linewidth=1.4, zorder=3))
    for t in exit_ids:
        handles.append(Patch(facecolor=to_rgba(palette[t], 0.8), edgecolor=palette[t], label=names.get(t, t)))
    for t in sorted(set(journey_map.values())):
        handles.append(Patch(facecolor=to_rgba(palette[t], 0.25), edgecolor=palette[t], linestyle="--",
                             hatch=hatches[t], label=f"start rooms assigned to {names.get(t, t)}"))
    ax.set_title("Route allocation: start rooms coloured by their assigned exit", loc="left")
    ax.legend(handles=handles, loc="center left", bbox_to_anchor=(1.02, 0.5))
    plt.show()
No description has been provided for this image

Helper functions¶

In [7]:
def agent_to_distribution(trajectory, distributions):
    first = trajectory.data.sort_values(['id', 'frame']).groupby('id').first().reset_index()
    polys = {k: Polygon(v['coordinates']) for k, v in distributions.items()}
    mapping = {}
    for row in first.itertuples():
        point = Point(row.x, row.y)
        for did, poly in polys.items():
            if poly.covers(point):
                mapping[row.id] = did; break
    return mapping

def agent_to_actual_exit(trajectory, exits):
    last = trajectory.data.sort_values(['id', 'frame']).groupby('id').last().reset_index()
    polys = {k: Polygon(v['coordinates']) for k, v in exits.items()}
    return {row.id: min(polys, key=lambda eid: polys[eid].distance(Point(row.x, row.y)))
            for row in last.itertuples()}

Run and map expected vs actual exit¶

In [8]:
result = run_scenario(scenario, seed=42)
trajectory = pedpy.TrajectoryData(
    result.trajectory_dataframe()[['id', 'frame', 'x', 'y']].copy(), frame_rate=result.frame_rate)
homes = agent_to_distribution(trajectory, scenario.distributions)
actuals = agent_to_actual_exit(trajectory, scenario.exits)
assignment = pd.DataFrame([
    {'agent': int(aid), 'home': homes.get(aid),
     'expected_exit': journey_map.get(homes.get(aid)), 'actual_exit': actuals[aid]}
    for aid in actuals])
assignment['matches'] = assignment['expected_exit'] == assignment['actual_exit']
assignment
Out[8]:
agent home expected_exit actual_exit matches
0 1 jps-distributions_0 jps-exits_0 jps-exits_0 True
1 2 jps-distributions_0 jps-exits_0 jps-exits_0 True
2 3 jps-distributions_1 jps-exits_0 jps-exits_0 True
3 4 jps-distributions_1 jps-exits_0 jps-exits_0 True
4 5 jps-distributions_2 jps-exits_0 jps-exits_0 True
5 6 jps-distributions_2 jps-exits_0 jps-exits_0 True
6 7 jps-distributions_3 jps-exits_0 jps-exits_0 True
7 8 jps-distributions_3 jps-exits_0 jps-exits_0 True
8 9 jps-distributions_4 jps-exits_0 jps-exits_0 True
9 10 jps-distributions_4 jps-exits_0 jps-exits_0 True
10 11 jps-distributions_5 jps-exits_0 jps-exits_0 True
11 12 jps-distributions_5 jps-exits_0 jps-exits_0 True
12 13 jps-distributions_6 jps-exits_0 jps-exits_0 True
13 14 jps-distributions_6 jps-exits_0 jps-exits_0 True
14 15 jps-distributions_7 jps-exits_0 jps-exits_0 True
15 16 jps-distributions_8 jps-exits_1 jps-exits_1 True
16 17 jps-distributions_8 jps-exits_1 jps-exits_1 True
17 18 jps-distributions_9 jps-exits_1 jps-exits_1 True
18 19 jps-distributions_9 jps-exits_1 jps-exits_1 True
19 20 jps-distributions_10 jps-exits_1 jps-exits_1 True
20 21 jps-distributions_10 jps-exits_1 jps-exits_1 True
21 22 jps-distributions_11 jps-exits_1 jps-exits_1 True
22 23 jps-distributions_11 jps-exits_1 jps-exits_1 True

Plot trajectories coloured by allocated exit¶

In [9]:
fig, ax = plt.subplots(figsize=(10, 7))
x, y = scenario.walkable_polygon.exterior.xy
ax.plot(x, y, 'k-')
for interior in scenario.walkable_polygon.interiors:
    ix, iy = interior.xy; ax.plot(ix, iy, 'k-', lw=0.8)
colors = {'jps-exits_0': 'tab:blue', 'jps-exits_1': 'tab:orange'}
for agent_id, sub in trajectory.data.sort_values('frame').groupby('id'):
    row = assignment[assignment.agent == agent_id]
    if len(row) == 0: continue
    ax.plot(sub.x, sub.y, color=colors.get(row.iloc[0].expected_exit, 'k'), alpha=0.6, lw=0.7)
ax.set_aspect('equal'); plt.show()
No description has been provided for this image

Result figure¶

Number of agents that reached the exit allocated to their start room. Acceptance: every agent.

In [10]:
# Agents per assigned exit: how many reached the exit that their route allocates.
tab = assignment.groupby("expected_exit")["matches"].agg(["sum", "count"])
tab["wrong"] = tab["count"] - tab["sum"]
with plt.rc_context(VV_RC):
    fig, ax = plt.subplots(figsize=(8, 1.6 + 0.8 * len(tab)))
    y = np.arange(len(tab))
    ax.barh(y, tab["sum"], color=to_rgba(VV_GREEN, 0.75), edgecolor=VV_GREEN, hatch="//", label="reached assigned exit")
    ax.barh(y, tab["wrong"], left=tab["sum"], color=to_rgba(VV_RED, 0.75), edgecolor=VV_RED, hatch="xx",
            label="reached another exit")
    ax.set_yticks(y, [str(e) for e in tab.index])
    ax.set_ylim(-0.6, len(tab) - 0.5 + 0.7)
    ax.set_xlabel("agents [-]")
    ax.set_ylabel("assigned exit")
    rate = tab["sum"].sum() / tab["count"].sum()
    ax.set_title("Exit reached against the allocated exit", loc="left")
    ax.legend(loc="upper center", bbox_to_anchor=(0.5, -0.22), ncol=2)
    ax.text(0.01, 0.97, f"match rate {rate:.0%} ({int(tab['sum'].sum())} of {int(tab['count'].sum())} agents), required 100%",
            transform=ax.transAxes, ha="left", va="top", color="dimgrey", fontweight="semibold", fontsize=9)
    ax.grid(axis="y", visible=False)
    plt.show()
No description has been provided for this image

Acceptance¶

In [11]:
match_rate = assignment['matches'].mean()
print(f'allocation match rate: {match_rate:.1%}')
assert match_rate == 1.0, assignment[~assignment['matches']]
result.cleanup()
allocation match rate: 100.0%
Out[11]:
1