How-to: egress time from an ensemble

How do I get the egress time and its spread from an ensemble of seeds?

This how-to reads tracked FDS output. You do not need to run FDS. Part 1 needs no FDS output at all.

Download the files for this page (21 kB zip)

Run the scenario once per seed. Read each agent’s exit time from the trajectory file, not from result.evacuation_time. Then report the last exit time over the seeds as a mean, a standard deviation (SD), a range and a high percentile, not as a single value.

What the simulation gives you

ISO/TR 16738 splits the required safe escape time as

RSET = tdet + ta + tpre + ttrav

where tdet is the detection time, ta the alarm time, tpre the pre-movement time and ttrav the travel time, all in seconds.

A pyFDS-Evac run starts at t = 0 and gives at most tpre + ttrav. It includes tpre only if the scenario models pre-movement (use_premovement in a spawn area; a spawn area that sets no pre-movement key gets a constant 10 s, as FDS+Evac). You add tdet and ta yourself. The scenario in Part 1 models no pre-movement, so its exit times are travel times only.

A run that stops at its time limit with agents still inside has no RSET, and more seeds do not supply one, since every seed stops at the same limit; A crowd in a fire is such a run.

Why not result.evacuation_time?

  • result.evacuation_time is the time at which the simulation loop stopped. The loop stops when no agent is left and no flow source can still spawn, when only incapacitated agents are left, or at max_simulation_time. An incapacitated agent stays in the simulation, so with one incapacitation evacuation_time is the time the last mobile agent left or, if none remain mobile, the time of the last collapse (#141). It is still not an egress time: read last_exit_time or the trajectory.
  • result.success is False and result.status is "incomplete" when the run reaches max_simulation_time with agents still inside, when the building empties after its flow windows closed with flow agents not spawned, or when only incapacitated agents remain inside and no flow window is open (end_reason "all_mobile_left") (#139). None of these says when the last mobile agent left.

Part 2 below shows both.

Runnable example

The zip holds the decks but no FDS output; its README.txt runs the part 2 deck with FDS first (about 30 s).

The full script is examples/rset_ensemble.py. Run it from the repository root (runtime about 10 s):

uv run python examples/rset_ensemble.py
import json
import pathlib
import sqlite3
import sys
import tempfile

import numpy as np
from matplotlib.figure import Figure

from pyfds_evac import (
    DefaultFedConfig,
    DefaultFedModel,
    FdsFedField,
    TenabilityConfig,
    load_scenario,
    run_scenario,
)

SEEDS = [1, 2, 3, 4, 5]
FIGURE = pathlib.Path(
    sys.argv[1]
    if len(sys.argv) > 1
    else pathlib.Path(tempfile.mkdtemp()) / "egress_exit_curve.png"
)
COLOURS = ["#0072B2", "#E69F00", "#009E73", "#CC79A7", "#56B4E9"]  # Okabe-Ito
LINESTYLES = ["-", "--", "-.", ":", (0, (5, 1, 1, 1, 1, 1))]

Read exit times from the trajectory

run_scenario writes the trajectory to a SQLite file (result.sqlite_file) at 10 frames per second. An agent’s exit time is the time of the last frame it appears in, so it is accurate to 0.1 s. Agents still inside at the end appear up to the last frame and are dropped. The incapacitated agents are read from result.fed_history, which exists only when a FED model ran.

def exit_times(result):
    """Exit time [s] of every agent that left, from the trajectory file."""
    con = sqlite3.connect(result.sqlite_file)
    try:
        fps = float(
            con.execute("SELECT value FROM metadata WHERE key = 'fps'").fetchone()[0]
        )
        last = con.execute("SELECT MAX(frame) FROM trajectory_data GROUP BY id")
        times = sorted(frame / fps for (frame,) in last)
    finally:
        con.close()
    # Agents still inside at the end are the ones seen in the last frames.
    return times[: len(times) - result.agents_remaining]


def incapacitated(result):
    """Number of agents incapacitated during the run."""
    rows = result.fed_history or []
    return len({row["agent_id"] for row in rows if row["incapacitated"]})

Part 1: one run per seed

The scenario is assets/fic_vs_fed_speed: 30 agents at one end of a 4 m × 50 m corridor with one exit, in clear air. Each run writes a manifest next to its trajectory, and the loop reads the seed back from it.

scenario = load_scenario("assets/fic_vs_fed_speed")
for name, dist in scenario.distributions.items():
    params = dist["parameters"]
    print(
        f"{name}: {params['number']} agents, "
        f"use_premovement={params.get('use_premovement', False)}"
    )

curves = {}
for seed in SEEDS:
    result = run_scenario(scenario, seed=seed)
    manifest = json.loads(pathlib.Path(result.manifest_file).read_text())
    curves[manifest["seed"]] = exit_times(result)
    print(
        f"seed {manifest['seed']}: last exit {curves[seed][-1]:.1f} s, "
        f"evacuated {len(curves[seed])}, incapacitated {incapacitated(result)}, "
        f"remaining {result.agents_remaining}"
    )
    result.cleanup()
print(f"versions: {manifest['versions']}")
print(f"git: {manifest['git_commit']}, dirty: {manifest['git_dirty']}")

Expected output, without the Evacuated 30/30 … done progress line that run_scenario prints for each run:

jps-distributions_0: 30 agents, use_premovement=False
seed 1: last exit 41.5 s, evacuated 30, incapacitated 0, remaining 0
seed 2: last exit 46.0 s, evacuated 30, incapacitated 0, remaining 0
seed 3: last exit 44.9 s, evacuated 30, incapacitated 0, remaining 0
seed 4: last exit 44.5 s, evacuated 30, incapacitated 0, remaining 0
seed 5: last exit 45.8 s, evacuated 30, incapacitated 0, remaining 0
versions: {'pyfds-evac': '0.1.0', 'jupedsim': '1.4.2', 'fdsreader': '1.11.7', 'fdsvismap': '0.2.1'}
git: b42f07ef4865142d55f3ff9e0e642370f0cb7b56, dirty: True

This loop runs all five seeds in one Python process. Each seed gives the same result as in a process of its own; only the JuPedSim agent ids differ.

The versions and the git state depend on your checkout. dirty: True means the working tree had uncommitted changes when the run started. The seed changes where the agents start in their spawn area, so each seed gives a different time. There is no fire in Part 1, so nobody is incapacitated.

Summarise the last exit over the seeds

last = np.array([times[-1] for times in curves.values()])
print(f"n = {len(last)} seeds")
print(f"mean = {last.mean():.1f} s, SD = {last.std(ddof=1):.1f} s")
print(f"min-max = {last.min():.1f}-{last.max():.1f} s")
print(f"95th percentile = {np.percentile(last, 95):.1f} s")
n = 5 seeds
mean = 44.5 s, SD = 1.8 s
min-max = 41.5-46.0 s
95th percentile = 46.0 s

Five seeds are too few for a study. They keep this example fast. The SD and the percentiles need many more runs to settle. The 95th percentile uses NumPy’s default linear interpolation; with five values it lies between the two largest. Increase SEEDS, and check that the summary no longer changes much when you add runs. DIN 18009-2:2022-08 recommends at least ten runs of a microscopic model and takes RSET as the 95 % quantile; mean values alone are not suitable (§6.6.2, p. 21; §8.3.1 and Note 2, p. 27).

Plot the exit curves

fig = Figure(figsize=(6, 4))
ax = fig.subplots()
for (seed, times), colour, style in zip(curves.items(), COLOURS, LINESTYLES):
    counts = np.arange(1, len(times) + 1)
    ax.step(times, counts, where="post", color=colour, ls=style, label=f"seed {seed}")
ax.axvspan(last.min(), last.max(), color="lightgrey", alpha=0.5, lw=0)
ax.text(
    last.min() - 0.3,
    2,
    f"last exit {last.min():.1f}-{last.max():.1f} s\nover {len(last)} seeds",
    color="dimgrey",
    ha="right",
)
ax.grid(color="lightgrey", linewidth=0.8)
ax.set_axisbelow(True)
for spine in ax.spines.values():
    spine.set_visible(False)
ax.tick_params(length=0, labelcolor="dimgrey")
ax.set_xlabel("time since start of simulation [s]", color="dimgrey")
ax.set_ylabel("agents evacuated [-]", color="dimgrey")
ax.legend(
    frameon=True,
    facecolor="white",
    framealpha=0.8,
    edgecolor="lightgrey",
    labelcolor="dimgrey",
)
FIGURE.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(FIGURE, dpi=150, bbox_inches="tight")
print(f"figure: {FIGURE}")

The script writes the figure into a new temporary directory and prints its path. To write it elsewhere, pass the path: uv run python examples/rset_ensemble.py egress_exit_curve.png.

Five step curves of agents evacuated against time, one per seed in its own colour and line style; each rises from 1 near 34 s to 30 between 41 and 46 s, and a grey band spans the range of last exit times

Cumulative number of agents evacuated [-] against time since the start of the simulation [s], assets/fic_vs_fed_speed, N = 30 agents, clear air, no pre-movement. One line per seed, each with its own colour and line style, n = 5 seeds. The grey band spans the last exit time over the seeds, from the earliest to the latest. Exit times are read from the trajectory at 0.1 s resolution. Regenerated by uv run python examples/rset_ensemble.py site/static/images/howto/egress_exit_curve.png.

Part 2: an incapacitated agent and evacuation_time

No tracked FDS case gives incapacitations and exits in the same run within a few seconds of runtime. Part 2 therefore uses the ISO 20414 Test 19 room of the Real-FDS walkthrough: one occupant held still in 0.10 % CO, 2.0 % CO2 and 15.0 % O2, read from the FDS slices in assets/iso_table22_coupled/fds/a.

room = load_scenario("assets/iso_table22_coupled/config_a.json")
gas_dir = "assets/iso_table22_coupled/fds/a"
fed = DefaultFedModel(FdsFedField.from_fds(gas_dir), DefaultFedConfig())
tenability = TenabilityConfig(incapacitation_mode="deterministic")
fire = run_scenario(room, seed=420, fed_model=fed, tenability_config=tenability)
print(
    f"evacuation_time {fire.evacuation_time:.0f} s "
    f"(max_simulation_time {room.max_simulation_time:.0f} s), "
    f"success {fire.success}"
)
print(
    f"exit times {exit_times(fire)}, incapacitated {incapacitated(fire)}, "
    f"remaining {fire.agents_remaining}"
)
fire.cleanup()
evacuation_time 982 s (max_simulation_time 1150 s), success False
exit times [], incapacitated 1, remaining 1

The occupant is incapacitated at 982 s and never leaves. The run ends then, so evacuation_time reports 982 s, and success is False because the run is incomplete. The exit times, the incapacitated count and the remaining count tell you what happened. From the command line, run.py exits with status 2 for such a run (Exit status).

Incapacitation mode

TenabilityConfig() defaults to incapacitation_mode="deterministic": every agent is incapacitated at fed_threshold (1.0), as in FDS+Evac. With TenabilityConfig(incapacitation_mode="probabilistic"), or --incapacitation-mode probabilistic on the command line, each agent draws its own threshold, fed_threshold * exp(susceptibility_sigma * Z) with Z ~ N(0, 1): a log-normal with median fed_threshold. The defaults are on the FED model page. Part 2 uses the deterministic mode.

Related options

  • To add smoke or FED from FDS output to an ensemble, pass the same models to run_scenario inside the loop, as in the Real-FDS walkthrough.
  • To compare RSET from a run without the fire with coupled runs, see Evacuation with and without the fire.
  • Pre-movement is set per spawn area in the scenario JSON, with use_premovement, premovement_distribution (gamma, lognormal, weibull, uniform or constant), premovement_param_a, premovement_param_b and premovement_seed. If premovement_seed is null, it is derived from the run seed, so the pre-movement times change with the seed. If it is set, they stay the same across seeds.
  • Each manifest also records uv_lock_sha256, scenario_path, fds_dir, fds_version and created_utc. cleanup() deletes it with the trajectory, so copy both first if you want to keep them. With run.py --output-sqlite PATH, the manifest is copied next to PATH as <stem>.manifest.json.

See also

pyFDS-Evac is research software, provided without warranty.

Last updated on