ISO 20414:2020 — Test 16: Affiliation to Familiar Exits¶
A room of size 10 m x 15 m with two 1 m exits on the 15 m walls of the room (ISO 20414:2020 Figure 12). In Scenario 1, the agent, equidistant to both exits, in the room and is free to choose between exits 1 and 2 (both exit weights are 50%). After statistics harvested for exit choice through several runs, Scenario 2 is run in which the same occupant is affiliated with Exit 2. Test is run several times again. Expected result is that the usage of Exit 2 is increased in scenario 2.
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:47 UTC
In [2]:
from pathlib import Path
import logging
logging.basicConfig(level=logging.WARNING, force=True)
logging.getLogger().setLevel(logging.WARNING)
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import pedpy
from shapely.geometry import Point, Polygon
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())
Load and Run¶
In [5]:
%%capture
SCENARIO_ZIP = Path("scenario_files") / "Iso-16-familiar-exits.zip"
scenario = load_scenario(str(SCENARIO_ZIP))
result = run_scenario(scenario, seed=42)
Scenario figure¶
Room geometry from the loaded scenario.
In [6]:
# Scenario geometry from the loaded ZIP.
with plt.rc_context(VV_RC):
fig, ax = plt.subplots(figsize=(5.2, 6.2))
handles = vv_draw_scenario(ax, scenario)
ax.set_title("Room with two exits, free choice at 50/50", loc="left")
ax.legend(handles=handles, loc="center left", bbox_to_anchor=(1.02, 0.5))
plt.show()
In [7]:
def count_exit_choices(scenario, seeds, workers=4):
sweep = run_sweep(scenario, seeds=seeds, workers=workers)
exit_1_count = 0
exit_2_count = 0
unknown_count = 0
rows = []
for trial in sweep.trials:
seed = trial.seed
result = trial.result
df_1 = result.trajectory_dataframe()
final_rows = [df_1.sort_values("frame").iloc[-1]]
for last in final_rows:
if last["y"] >= 11.0:
if last["x"] <= 1.5:
chosen_exit = "exit_1"
exit_1_count += 1
elif last["x"] >= 8.5:
chosen_exit = "exit_2"
exit_2_count += 1
else:
chosen_exit = "unknown"
unknown_count += 1
else:
chosen_exit = "no exit"
unknown_count += 1
rows.append({
"seed": seed,
"id": int(last["id"]),
"x_final": last["x"],
"y_final": last["y"],
"chosen_exit": chosen_exit,
})
sweep.cleanup()
exit_choices = pd.DataFrame(rows)
total = len(exit_choices)
return {
"exit_1_count": exit_1_count,
"exit_2_count": exit_2_count,
"unknown_count": unknown_count,
"exit_1_fraction": exit_1_count / total,
"exit_2_fraction": exit_2_count / total,
"unknown_fraction": unknown_count / total,
"rows": exit_choices,
}
Scenario 1 Statistics¶
In [8]:
run_1a = count_exit_choices(scenario, seeds=range(1, 4001), workers=4)
run_1b = count_exit_choices(scenario, seeds=range(4001, 8001), workers=4)
comparison = pd.DataFrame([
{
"exit": "exit_1",
"run_1a_count": run_1a["exit_1_count"],
"run_1b_count": run_1b["exit_1_count"],
"run_1a_fraction": run_1a["exit_1_fraction"],
"run_1b_fraction": run_1b["exit_1_fraction"],
},
{
"exit": "exit_2",
"run_1a_count": run_1a["exit_2_count"],
"run_1b_count": run_1b["exit_2_count"],
"run_1a_fraction": run_1a["exit_2_fraction"],
"run_1b_fraction": run_1b["exit_2_fraction"],
},
])
comparison
Out[8]:
| exit | run_1a_count | run_1b_count | run_1a_fraction | run_1b_fraction | |
|---|---|---|---|---|---|
| 0 | exit_1 | 1894 | 1902 | 0.4735 | 0.4755 |
| 1 | exit_2 | 2106 | 2098 | 0.5265 | 0.5245 |
Acceptance - Scenario 1¶
In [9]:
assert abs(run_1a["exit_1_fraction"] - run_1b["exit_1_fraction"]) * 100 <= 1.0
assert abs(run_1a["exit_2_fraction"] - run_1b["exit_2_fraction"]) * 100 <= 1.0
Scenario 2 Statistics¶
In [10]:
DIST_ID = "jps-distributions_0"
JOURNEY_R1 = "journey-1781167866144-fp65t3"
JOURNEY_R2 = "journey-1781167890022-3hfgs4"
WORKERS=4
SEEDS_PER_COND=2
def set_journey_weight(scenario, dist_id, journey_id, weight):
for entry in scenario.raw["distributions"][dist_id]["journey_weights"]:
if entry["journey_id"] == journey_id:
entry["weight"] = weight
return
def classify_exit(row):
"""Infer exit from final x position (from your config.json coordinates)."""
if row["x"] <= 1.5:
return "exit_1"
elif row["x"] >= 8.5:
return "exit_2"
return "unknown"
sweep = run_sweep(
scenario,
axes={"route2_weight": [50, 60, 70, 80, 90, 100]},
apply={
"route2_weight": lambda s, w: (
set_journey_weight(s, DIST_ID, JOURNEY_R1, 100 - w),
set_journey_weight(s, DIST_ID, JOURNEY_R2, w),
)
},
seeds=range(100, 100 + SEEDS_PER_COND),
workers=WORKERS,
)
# Build per-agent exit choice table
rows = []
for trial in sweep.trials:
df_traj = trial.result.trajectory_dataframe()
# Last recorded frame per agent = where they exited
last = df_traj.sort_values("frame").groupby("id").last().reset_index()
last["exit"] = last.apply(classify_exit, axis=1)
last["route2_weight"] = trial.axis_values["route2_weight"]
last["seed"] = trial.seed
rows.append(last)
df_exits = pd.concat(rows, ignore_index=True)
# --- Aggregate: mean fraction going to each exit per weight condition ---
agg = (
df_exits.groupby(["route2_weight", "exit"])
.size()
.reset_index(name="count")
.pivot(index="route2_weight", columns="exit", values="count")
.fillna(0)
.reset_index()
)
agg.columns.name = None
agg["total"] = agg[["exit_1", "exit_2"]].sum(axis=1)
agg["exit_1_frac"] = agg["exit_1"] / agg["total"]
agg["exit_2_frac"] = agg["exit_2"] / agg["total"]
agg
Out[10]:
| route2_weight | exit_1 | exit_2 | total | exit_1_frac | exit_2_frac | |
|---|---|---|---|---|---|---|
| 0 | 50 | 2.0 | 2.0 | 4.0 | 0.5 | 0.5 |
| 1 | 60 | 2.0 | 2.0 | 4.0 | 0.5 | 0.5 |
| 2 | 70 | 2.0 | 2.0 | 4.0 | 0.5 | 0.5 |
| 3 | 80 | 0.0 | 4.0 | 4.0 | 0.0 | 1.0 |
| 4 | 90 | 0.0 | 4.0 | 4.0 | 0.0 | 1.0 |
| 5 | 100 | 0.0 | 4.0 | 4.0 | 0.0 | 1.0 |
Plot Trajectories¶
In [11]:
fig, ax = plt.subplots(figsize=(7, 4))
ax.plot(agg["route2_weight"], agg["exit_1_frac"], "o-", label="Exit 1 (non-familiar)", color="#1f6feb")
ax.plot(agg["route2_weight"], agg["exit_2_frac"], "o-", label="Exit 2 (familiar/affiliated)", color="#e05c2a")
ax.axvline(50, color="gray", linestyle="--", alpha=0.5, label="Scenario 1 (50/50)")
ax.axvline(80, color="green", linestyle="--", alpha=0.5, label="Scenario 2 (affiliated)")
ax.set_xlabel("Route 2 weight")
ax.set_ylabel("Fraction of agents")
ax.set_title("ISO 20414 Test 16 — Exit Choice vs Affiliation Weight")
ax.legend()
plt.tight_layout()
plt.show()
Acceptance - Scenario 2¶
In [12]:
sweep.cleanup()
scen1 = agg.loc[agg["route2_weight"] == 50].iloc[0]
scen2 = agg.loc[agg["route2_weight"] == 80].iloc[0]
assert abs(scen1["exit_1_frac"] - scen1["exit_2_frac"]) <= 0.10, \
f"FAIL Scenario 1: exits not balanced (exit_1={scen1['exit_1_frac']:.0%}, exit_2={scen1['exit_2_frac']:.0%})"
# Scenario 2: with affiliation to exit 2, exit 2 usage must be strictly greater than exit 1
assert scen2["exit_2_frac"] > scen2["exit_1_frac"], \
f"FAIL Scenario 2: exit 2 not preferred (exit_1={scen2['exit_1_frac']:.0%}, exit_2={scen2['exit_2_frac']:.0%})"
print(f" Scenario 1 — exit_1: {scen1['exit_1_frac']:.0%} exit_2: {scen1['exit_2_frac']:.0%} (balanced)")
print(f" Scenario 2 — exit_1: {scen2['exit_1_frac']:.0%} exit_2: {scen2['exit_2_frac']:.0%} (affiliation confirmed)")
Scenario 1 — exit_1: 50% exit_2: 50% (balanced) Scenario 2 — exit_1: 0% exit_2: 100% (affiliation confirmed)