ISO 20414:2020 — Test 17: Route Choice Based on Geometric Layout¶

A two-floor structure (ISO 20414:2020 Figure 13). Start area and target area are connected by two flights of stairs and a corridor on the ground floor and by a longer corridor on the upper floor. Occupant either chooses the shortest path using the lower floor in the journey or they use the longer path on the upper floor. The path should match the underlying route choice implemented in the evacuation model.

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:58 UTC
In [2]:
from pathlib import Path
import logging
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import pedpy
import matplotlib.patches as patches
from matplotlib.patches import Patch
from matplotlib.lines import Line2D
from shapely.geometry import Point, Polygon
from shapely import from_wkt
from jupedsim_scenarios import load_scenario, run_scenario, run_sweep
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())
In [5]:
import json, tempfile
from scenario_builders.iso17_route_choice import (
    build_raw_scenario, build_geometry_wkt, route_length_reference,
    measure_route_choice, START_POS, TARGET_POS,
)
In [6]:
walkable_area = pedpy.WalkableArea(from_wkt(build_geometry_wkt()))
raw = build_raw_scenario()
fig, ax = plt.subplots(figsize=(7, 7))
pedpy.plot_walkable_area(walkable_area=walkable_area, axes=ax)
def add_polygon(ax, coordinates, alpha=0.35, hatch=None, zorder=4):
    poly = patches.Polygon(
        coordinates,
        closed=True,
        fill=True,
        alpha=alpha,
        hatch=hatch,
        edgecolor="black",
        linewidth=1.0,
        zorder=zorder,
    )
    ax.add_patch(poly)
start_area = raw["distributions"]["jps-distributions_0"]["coordinates"]
target_area = raw["exits"]["jps-exits_0"]["coordinates"]
add_polygon(ax, start_area, alpha=0.35, zorder=6)
add_polygon(ax, target_area, alpha=0.35, zorder=6)
for zone in raw["zones"].values():
    add_polygon(
        ax,
        zone["coordinates"],
        alpha=0.25,
        hatch="//",
        zorder=5,
    )
ax.plot(*START_POS, "go", markersize=10, zorder=8)
ax.plot(*TARGET_POS, "r*", markersize=14, zorder=8)
legend_handles = [
    Patch(facecolor="C0", edgecolor="black", alpha=0.35, label="start area ±0.5 m"),
    Patch(facecolor="C0", edgecolor="black", alpha=0.35, label="target area ±0.5 m"),
    Patch(facecolor="C0", edgecolor="black", alpha=0.25, hatch="//", label="stair zone, speed factor=0.5"),
    plt.Line2D([], [], color="green", marker="o", linestyle="None", markersize=10, label="start center"),
    plt.Line2D([], [], color="red", marker="*", linestyle="None", markersize=14, label="target center"),
]
ax.legend(
    handles=legend_handles,
    loc="center left",
    bbox_to_anchor=(1.02, 0.5),
    frameon=True,
)
ax.set_aspect("equal")
ax.set_xlabel("x [m]")
ax.set_ylabel("y [m]")
plt.tight_layout()
plt.show()
No description has been provided for this image

Build and Run¶

In [7]:
raw = build_raw_scenario()
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(), encoding='utf-8')
    scenario = load_scenario(str(p))
    result = run_scenario(scenario, seed=42)
    traj = result.trajectory_dataframe()
    frame_rate = result.frame_rate
    choice = measure_route_choice(traj, frame_rate)
    result.cleanup()
df = pd.DataFrame.from_dict(choice, orient='index', columns=['value'])
df
Out[7]:
value
n_shortcut 10.0
n_long_way 0.0
frac_shortcut 1.0

Plot Trajectories¶

In [8]:
walkable_area = pedpy.WalkableArea(result.walkable_polygon)
fig, ax = plt.subplots(figsize=(7, 7))
pedpy.plot_walkable_area(walkable_area=walkable_area, axes=ax)
for agent_id, sub in traj.groupby("id"):
    sub = sub.sort_values("frame")
    ax.plot(sub.x, sub.y, alpha=0.5, linewidth=0.9, label="_nolegend_")
ax.plot(*START_POS, "go", markersize=10, zorder=5)
ax.plot(*TARGET_POS, "r*", markersize=14, zorder=5)
ax.axhline(0.0, color="gray", linestyle="--", linewidth=0.8, zorder=2)
legend_handles = [
    Line2D(
        [],
        [],
        color="black",
        linewidth=0.9,
        alpha=0.5,
        label="agent trajectories",
    ),
    Line2D(
        [],
        [],
        color="green",
        marker="o",
        linestyle="None",
        markersize=10,
        label="start (5)",
    ),
    Line2D(
        [],
        [],
        color="red",
        marker="*",
        linestyle="None",
        markersize=14,
        label="target (3)",
    ),
    Line2D(
        [],
        [],
        color="gray",
        linestyle="--",
        linewidth=0.8,
        label="upper/lower floor boundary",
    ),
]
ax.legend(
    handles=legend_handles,
    loc="center left",
    bbox_to_anchor=(1.02, 0.5),
    frameon=True,
)
ax.set_aspect("equal")
ax.set_xlabel("x [m]")
ax.set_ylabel("y [m]")
plt.tight_layout()
plt.show()
No description has been provided for this image

Result figure¶

Share of agents that took the shorter stair shortcut. Acceptance: more than half of the agents.

In [9]:
# Share of agents taking the shortcut against the acceptance threshold.
n_s, n_l = choice["n_shortcut"], choice["n_long_way"]
total = n_s + n_l
with plt.rc_context(VV_RC):
    fig, ax = plt.subplots(figsize=(8, 2.8))
    ax.barh([0], [n_s / total], color=to_rgba(VV_GREEN, 0.75), edgecolor=VV_GREEN, hatch="//", height=0.5,
            label=f"via shortcut ({n_s})")
    ax.barh([0], [n_l / total], left=[n_s / total], color=to_rgba(VV_ORANGE, 0.75), edgecolor=VV_ORANGE,
            hatch="xx", height=0.5, label=f"via long way ({n_l})")
    ax.axvline(0.5, color="dimgrey", ls="--", lw=1.4, label="required: more than 50% via shortcut")
    ax.set_xlim(0, 1)
    ax.set_ylim(-0.6, 1.0)
    ax.set_yticks([])
    ax.set_xlabel("share of agents [-]")
    ax.set_title("Route choice against the geometrically shorter route", loc="left")
    ax.legend(loc="upper left", ncol=3, fontsize=8)
    ax.text(n_s / total * 0.25, 0, f"{n_s / total:.0%}", ha="center", va="center", color="dimgrey", fontweight="semibold")
    ax.grid(axis="y", visible=False)
    plt.show()
No description has been provided for this image

Acceptance¶

Test 17 passes if agents prefer the geometrically shorter route. Given the reference lengths below, the stair shortcut is shorter than going the long way around the ring, so the large majority of agents should be classified as having taken it.

In [10]:
ref = route_length_reference()
print(f"reference lengths: long way = {ref['long_way_m']:.1f} m, "
      f"shortcut = {ref['shortcut_m']:.1f} m")
print(f"agents via shortcut: {choice['n_shortcut']}, "
      f"via long way: {choice['n_long_way']}, "
      f"fraction via shortcut: {choice['frac_shortcut']:.2%}")

assert ref['shortcut_m'] < ref['long_way_m'], (
    "Geometry error: the shortcut is not actually shorter than the long way; "
    "Test 17 is meaningless with these dimensions."
)
assert choice['frac_shortcut'] > 0.5, choice
reference lengths: long way = 54.0 m, shortcut = 30.0 m
agents via shortcut: 10, via long way: 0, fraction via shortcut: 100.00%