Wayfinding
Note
This page is the specification of the wayfinding model. For worked cases on the assets (runs, figures, numbers), see Wayfinding in practice.
Based on: Visibility through smoke and Exit choice and familiarity.
fdsvismap is pinned to release 0.3.3 in pyproject.toml (dependency
fdsvismap).
The routing model ranks and refuses routes. This page describes the part of the model that decides which routes it may rank: what each agent knows about the stage graph, how that knowledge starts, how it grows, and how route choice reads it. The intuition is on Concepts › Wayfinding. Symbols follow the notation table.
Terms
These words are used with one meaning each, here and on the linked pages.
- Stage graph. The directed graph of spawn areas, checkpoints, waypoints
and exits, with the walkable legs between them as edges
(
route_graph.py,StageGraph.from_scenario). A neighbour of a node is the target of one of its outgoing edges. - Sign. A descriptor \(\{x, y, \alpha_s, C\}\) attached to a node: position, compass bearing \(\alpha_s\) of the direction the sign faces, and the visibility constant C of Jin’s law.
- Legible. A sign is legible from a grid cell at a time when it passes the precomputed sign-legibility test below. Legibility is a property of the present position and time. The test’s threshold is Jin’s obscuration threshold: a legible sign is barely seen at distance V, and its words or arrow need not be readable (Seeing a sign versus reading it). On this page, “read” and “reading distance” refer to this same seeing test. Jin (1978, Fig. 7, p. 143) uses “legible threshold” for reading the words of a sign; this page uses “legible” for his obscuration threshold instead; on Visibility, “legibility” keeps Jin’s sense.
- Known. A node or edge is known to an agent when it is stored in that agent’s cognitive map. Knowledge persists.
- Learn. A node or edge is learned when it is added to the map.
- Visited. A node is visited when the agent’s stage at that node
completes, after any wait there (
expand_on_arrival, called atscenario.py,run_scenario). This includes a stage with nothing scripted after it, where the agent goes idle (direct_steering_runtime.py,advance_path_target). The spawn node is visited from the start. - Frontier. A known node that is not visited.
- Sign-legibility test versus route exposure gate. The first decides whether a neighbour is learned. The second, described on the routing page, judges the smoke along a route that is already known. They are separate mechanisms.
Coded form
1. The sign-legibility test
Every exit, checkpoint and waypoint carries a sign (visibility.py, extract_sign_descriptors). A
non-empty authored "sign" is used as written and must give x and y. A
node without one, or with an empty one, receives a synthesised sign at its
routing point with \(C = 3\) and \(\alpha_s\) = None (visibility.py, _default_sign).
A node whose polygon cannot give a position gets no sign. Spawn areas carry no
sign. The model treats every node without a sign as legible from everywhere
(visibility.py, VisibilityModel.node_is_visible).
"exits": {
"exit_A": { "sign": {"x": 0.5, "y": 11.5, "alpha": 90, "c": 3} }
}An optional "max_distance" (m, positive) sets that sign’s reading distance
\(V_{\max}\), for example for a larger or internally illuminated sign.
\(\alpha_s\) is a bearing in degrees, clockwise from north (+y). The sign faces
\((\sin\alpha_s, \cos\alpha_s)\), so 90 is readable from the east, 270 from the
west and 180 from the south (visibility.py, VisibilityModel).
The test is computed by fdsvismap
(pinned at 0.3.3 in pyproject.toml). For sign k and a grid cell at
distance \(L\) (FDSVisMap._get_view_angle_array, _get_visibility_array,
get_sign_vismap):
- \((\Delta x, \Delta y)\) is the cell position minus the sign position
(
FDSVisMap.py,VisMap._get_view_angle_array), and \(L\) is their distance; reversing the vector would flip the readable half-plane. - \(U\) is 0 when an obstruction cell lies on the rasterised ray from the sign,
1 otherwise (anti-aliased rays,
aa=True). - \(\bar K\) is the arithmetic mean extinction over the cells of a rasterised line between the two cells, not a length-weighted integral.
- When \(\bar K = 0\), the capped term is \(V_{\max}\).
- \(V_{\max}\) is the sign’s reading distance: 30 m by default, as in
fdsvismap and Börger et al.
--max-sign-distancesets it for every sign, and a sign’s ownmax_distanceoverrides it for that sign (_apply_distance_capsinvisibility.py). It caps \(C/\bar K\) before \(A\) and \(U\) apply, as upstream, so in clear air a sign is legible up to \(A \cdot V_{\max}\).
The obstruction factor applies in clear air too: a wall hides a sign there as it does in smoke.

(a) Sign B of assets/t_junction in the FDS run fire_2MW_PVC at
t = 20 s, the first map time at which its legible area falls below half the
clear-air area (31.2 of 92.3 m²). Every value is read from the VisMap of a
run. For an unobstructed sight line (\(U = 1\)) below the cap, the test
\(V \ge L\) is \(\bar K L \le A\,C\), where \(\bar K L\) approximates
the extinction summed along the line; distance alone does not decide
legibility. Beyond the smoke, clear cells add little to that sum, so P,
29.5 m away, is legible while Q, 3 m from the sign, is not. (b) The steps, with the equation numbers
of Börger et al. Script: scripts/figures/sign_legibility_steps.py.

An agent stands 6 m from a sign in uniform smoke (\(\bar K\) = 0.3 m⁻¹,
C = 3, so \(C/\bar K\) = 10 m) while the sign turns away. Every value comes
from the pyFDS-Evac visibility model. Right: the visibility
\(A \cdot C/\bar K\) against the bearing \(\alpha_s\); the dotted line is the
agent’s distance \(L\). Solid blue line and filled marker: legible; dashed red
line and hollow marker: not legible. Yellow cells are the positions from which
the model reads the sign; the gold arrow shows which way it faces. \(V_{\max}\)
= 30 m does not bind. The sign is lost at about
53°, well before it is side-on.
Script: scripts/figures/sign_rotation.py.
Two ways to build the model (run_config.py, _build_vis_model):
- From an FDS run (
--fds-dir). Grid, obstructions and extinction come from the FDS output. TheSOOT EXTINCTION COEFFICIENTslice is the horizontal one nearest--smoke-slice-height(1.6 m by default since #164,config/parameters.py,SMOKE_SLICE_HEIGHT_M), chosen by the same rule as walking speed and FED, with a warning when it is more than 0.5 m away (fds_sampling.py,select_horizontal_slice). Stored times are spaced by--reroute-intervaland end at the last frame of the extinction slice, which is always stored (visibility.py,_build_vismap;run_config.py,_build_vis_model). A query past that frame by at most one output interval of the slice reads the last frame; a later one raisesFdsHorizonError, the window walking speed, FED and the setup check use (#510). - From clear air (no
--fds-dir). The walkable polygon is rasterised at--vis-cell-size. A cell is an obstruction when its centre lies outside the polygon (visibility.py,_blocked_runs,VisibilityModel.clear_air). A wall thinner than one cell may therefore let sight through, depending on how it falls on the grid. A warning is logged when the cell is not smaller than the thinnest wall (visibility.py,unresolved_wall). The check is a heuristic. It counts as a wall a part of the bounding box outside the walkable area that a disc one cell across cannot enter, longer than one cell and evenly thin, which is meant to leave out rounded corners, bevels and wedge tips. A junction of walls of different widths is measured per wall, and the width reported is that of the thinnest wall. The width is an estimate, recorded with the verdict asthin_wall_mandthin_wall_warningin the run’s visibility settings;--show-configpredicts both and lists the warning. No warning does not prove that every wall blocks: a wall oblique to the grid can leak below one cell width, and parts shorter than one cell are not reported. The extinction is zero, and one time point is stored (visibility.py,VisibilityModel.clear_air).
At run time the model answers node_is_visible(t, x, y, node) by looking up
the nearest stored time and cell
(visibility.py, _VisMapCache._nearest, VisibilityModel.node_is_visible). No ray is cast inside the time loop.
Off the FDS grid
(#426;
visibility.py, VisibilityModel._sight_outside_grid). The grid of an FDS
model covers the extinction slice plus half a cell. An agent beyond it has
no cell to look up, and reads the sign in clear air: the sign is legible
when it lies within its reading distance \(V_{\max}\), measured from the
agent. View angle, walls and smoke are not tested there, and
visibility_to_node returns None. In the half-cell strip between the
slice edge and the grid edge, the agent still reads the edge cell, while
its smoke and FED rows already count as outside the FDS domain. A sign that
lies off the grid logs a warning when the model is built: fdsvismap 0.2.1 to 0.3.3
casts its sight lines from the nearest grid edge and keeps the true
distance, so the part of the line outside the grid takes the mean K of
the part inside (VisibilityModel.signs_outside_grid). With
--require-fds-coverage both cases stop the run. A clear-air model covers
the walkable area, so neither case arises there. The other quantities
outside the FDS domain are on
FDS slice sampling.
--vis-cache stores the arrays in an .npz file. The FDS cache is keyed by
the FDS directory path, the signs, the time step, the slice height and the
maximum sign distance; it
does not hash the FDS output, so replacing the output in place leaves a stale
cache valid (visibility.py, _make_meta, _load_vismap_cache).
fdsvismap also reads the obstructions at the slice height, so which walls
occlude a sign can change with --smoke-slice-height
(#165).
Which model a run gets (run_config.py, _build_vis_model):
| invocation | model |
|---|---|
| no flags, some group with familiarity < 1 | clear air |
| no flags, every group at familiarity 1 | none |
--clear-air-visibility | clear air, even if every group is fully familiar |
--no-visibility | none |
--fds-dir DIR with a SOOT EXTINCTION COEFFICIENT slice, some group with familiarity < 1 | FDS smoke, uncached |
--fds-dir DIR --vis-cache PATH with a SOOT EXTINCTION COEFFICIENT slice | FDS smoke, cached |
--fds-dir DIR without a SOOT EXTINCTION COEFFICIENT slice, some group with familiarity < 1 | clear air, with a warning (#248) |
--fds-dir DIR --vis-cache PATH without a SOOT EXTINCTION COEFFICIENT slice | clear air, cached, with a warning |
--fds-dir DIR, every group at familiarity 1 | none (smoke still drives speed, FED and the exposure gate) |
--vis-cache PATH, no --fds-dir | clear air, cached |
In every case a scenario from which no sign can be extracted gets no model
(run_config.py, _build_vis_model). Rejected combinations: --clear-air-visibility
with --fds-dir, --clear-air-visibility with --no-visibility, and
--vis-cache with --no-enable-rerouting (run_config.py, validate_opts).
2. The knowledge contract
Haensel (2014) split the cognitive map into knowledge, perception and decision-making, with first-order knowledge (the spatial layout, as a graph) and second-order knowledge that adds perceived information such as smoke, signs or jams (as summarised by Schröder et al. 2015, p. 329). Schröder et al. added a smoke sensor that feeds this second-order knowledge. In pyFDS-Evac the map holds only the layout; smoke enters through the route cost, not the map.
Each agent’s AgentCognitiveMap holds known nodes, known edges and visited
nodes (cognitive_map.py, AgentCognitiveMap). The contract has four parts.
2.1 Initial knowledge (init_cognitive_map, cognitive_map.py).
familiarity is a scenario key on a distribution group, normalised to a
probability \(p\): "full" is 1, "discovery" is 0, a number in [0, 1] is
used as given, anything else raises (cognitive_map.py, familiarity_probability). The key
defaults to "full" (simulation_init.py, _initialize_with_fallback).
- \(p = 1\): the map is the whole stage graph. It is never extended.
- \(p < 1\): the map starts with the spawn node, marked visited. Then, in
order:
- Entrance. If
entrancenames an exit reachable from the spawn node, the shortest path to it is learned. A name that is missing, is not an exit, or cannot be reached is silently ignored (cognitive_map.py,_learn_route_to,init_cognitive_map). - Familiarity draw. Each other reachable exit is learned, with its
shortest path, with probability \(p\). The draw uses an RNG seeded from
the run seed and the agent’s spawn order, not its JuPedSim id
(
agent_seed.py,agent_rng;scenario.py,_assign_initial_exit,run_scenario). A library caller that passes no RNG gets no draw (cognitive_map.py,init_cognitive_map). - Perception at spawn. Each neighbour of the spawn node whose sign is
legible from the spawn node’s routing point is learned
(
cognitive_map.py,init_cognitive_map). Agents of one spawn area that spawn at the same time therefore start with the same perceived neighbours.
- Entrance. If
Paths learned in steps 1 and 2 add forward edges only
(cognitive_map.py, _learn_route_to).
2.2 Learning (\(p < 1\) only). Two calls add knowledge:
- Periodic. When an agent’s re-evaluation is due, each neighbour of its
current node (
current_origin, elsecurrent_target_stage) whose sign is legible from the agent’s stored position is learned (expand_from_visibility,cognitive_map.py, called atscenario.py,run_scenario). The stored position is written in the steering loop (scenario.py,run_scenario), which runs after the reroute pass, so it holds the previous step’s coordinates. Without a visibility model nothing is learned. - On advancing along the path. When the agent completes a stage and its
path advances (after any waiting time at that stage), the node is marked
visited, and each of its neighbours whose sign is legible from the agent’s
position is learned (
expand_on_arrival,cognitive_map.py, called atscenario.py,run_scenario). Without a visibility model, every neighbour is learned.
The four places knowledge is sensed from:
| When | Sensing position | Code |
|---|---|---|
| Initialisation | spawn node’s routing point | cognitive_map.py, init_cognitive_map |
| Periodic learning | previous step’s stored position | scenario.py, run_scenario |
| Stage completion (arrival) | current position | scenario.py, run_scenario |
| Look before a patrol | current position, within the agent’s radius of the node’s routing point | scenario.py, run_scenario |
Learning is limited to neighbours of one node. A legible sign that belongs
to any other node is not tested. An edge learned by either call also teaches
its reverse when the graph contains that reverse edge (_learn_edge,
cognitive_map.py). With automatic wiring, exits have no outgoing
edges and spawn areas no incoming ones (route_graph.py, StageGraph.from_scenario), so no
reverse edge into a spawn area or out of an exit is learned. Explicit
transitions are added without these restrictions (route_graph.py, StageGraph.from_scenario).
2.3 Memory. No code removes a node or an edge from the map of an agent
that is still in the simulation. A node learned while its sign was legible
stays known after the sign stops being legible, through distance, bearing,
obstruction or smoke. The map is deleted when the agent leaves
(scenario.py, run_scenario).
2.4 Decision. Route ranking works on the known subgraph only:
rank_routes replaces the stage graph with cognitive_subgraph(map, graph)
before it evaluates any edge (route_graph.py, rank_routes;
cognitive_map.py, cognitive_subgraph). Dijkstra, the exposure gate, the ordering and the
all-refused fallback see known nodes and edges only. An exit that is not known
is not refused; it is absent, and the fallback cannot restore it.
When no exit is known (#610,
#91).
Before its map exists, every agent receives a steering plan: the journey of
its spawn area or, for a spawn area without a journey, the exit nearest its
spawn point on foot, measured through the walkable area by the JuPedSim
routing engine (simulation_init.py, _find_nearest_exit). The opening
choice replaces that plan only when the map contains a reachable exit
(scenario.py, _assign_initial_exit). Otherwise the spawn area’s
no_known_exit decides, at the opening and at every re-evaluation that finds
no reachable known exit (route_graph.py, _decide_no_known_exit):
default_route(default). The agent keeps the plan as its default route, logged once asreason="default_route". A plan that ends at a closed exit, or nowhere, is replaced by the nearest open exit on foot (route_graph.py,nearest_exit_by_walking). This is the FDS+Evac counterpart: an agent with no known or visible door follows the main evacuation flow field (see Relation to FDS+Evac). The default route is modeller knowledge, not perception. The exit history marks every exit an agent leaves through that is not in its map (exit_in_map), and the run counts these agents (agents_left_by_unknown_exit).explore,return,stay(opt-in, “No known exit” below). No agent walks towards a stage absent from its map: a scripted leg to such a stage is not taken (direct_steering_runtime.py,advance_path_target), and an agent with nowhere known to go stands, logged asreason="stay". These modes act at re-evaluation, so a run without rerouting, and a--smoke-blindrun, refuses them; use a clear-air run as the control.
current_exit holds only an exit the agent knows. A first known exit that is
the default exit, or any first known exit while the default exit is not open,
is logged as reason="initial". Another exit, taken while the default exit is
open, leaves the default exit: the route history names it as old_exit, the
reason is the usual exit-change reason (exit_opened or learned_exit when
the new exit is news, else exit_unreachable when the default exit is not in
the agent’s map), and the exit-switch anchor holds the agent on its default
exit when it knows a route to it (route_graph.py, _anchored_exit;
#743). This
anchoring is a departure from FDS+Evac: there an agent on the main flow field
has no current door, so FAC_DOOR_OLD, FAC_DOOR_WAIT and FAC_DOOR_OLD2
favour no exit for it (FAC_DOOR_OLD at evac.f90:16591, :16685, :16734;
FAC_DOOR_WAIT at :16600, :16607, :16689, :16736; FAC_DOOR_OLD2 at
:16803). Ranking does not favour the default exit either: it reads only the
current exit
(#754, decided
no). Only the exit-switch anchor and the gate scan hold the agent on its
default exit. In the
queue tally, a default-route agent counts at its default exit; under the opt-in
modes an agent with no known exit counts nowhere.
The contract covers ranking, not adoption. Ranking orders the known routes; adoption is whether a moving agent switches to the first of them. The two differ:
- Opening choice (
_assign_initial_exit,scenario.py). It ranks the routes from the agent’s position, as re-evaluation does, and without the queue tally (scenario.py,_assign_initial_exit). The path search starts with the walks to the spawn node’s successors, and each exit gets one path (#185). Agents of one spawn area can therefore start towards different exits. - Re-evaluation (
evaluate_and_reroute). It ranks from the agent’s position: the path search starts there and never routes back through the origin node, and the current exit is never ranked behind the path the agent walks (Routing). It adopts a different exit only if the rival passes the switching rule of the routing model: an optical-depth margin, then an anchor on the ranking cost. The optional queue term (w_queue, 0 by default) enters the ranking cost. - All refused. When every known route fails the gate, the least smoky one
is promoted (it stays rejected, marked
fallback:), but the current route is kept first unless the rival’s optical depth \(\tau\) is more thanfallback_switch_marginlower (route_graph.py,_apply_fallback,_fallback_rival_wins).
An agent can therefore keep a known route that is not first in the ranking.
Scheduled exits. An exit with open_from_s or closed_after_s in the
scenario JSON is left out of every ranking while it is closed, and out of the
explore and wander targets below (route_graph.py, without_closed_stages).
An agent whose route ends at a closed exit re-evaluates at the next reroute
check, whatever its interval, on the map it holds; when the exit has closed
(closed_after_s passed) the switch is logged as reason="exit_closed". An
exit that has not opened yet was never the agent’s exit, so the first open
exit it is given is initial. An agent that knows no open exit and no other node to
walk to follows its no_known_exit mode: by default it takes the nearest open
exit on foot; under explore it waits at the closed exit. A closed exit
removes nobody.
An opened exit is taken at the agent’s next regular re-evaluation.
In a run without rerouting (--no-enable-rerouting, --smoke-blind) the
same one-second check re-decides only the agents whose route ends at a closed
exit, once for each closure, scored as their opening choice: on the map they hold, without the
queue term, with the smoke at that time or in clear air when smoke-blind
(scenario.py, _redirect_from_closed_exit;
#395). An
agent that knows no open exit takes the nearest one on foot, and nobody
re-decides when an exit opens. The schedule is on
Scenario JSON.
Worked example (clear air). Every \(\tau\) is 0, both routes are clean,
and the optical-depth deadband (\(\tau_{\max}\cdot\) tau_deadband =
6 × 0.1) is not crossed. The switch falls through to the anchor: a rival is
adopted only if its ranking cost, here its travel time, is below 0.9 times the
current route’s (route_graph.py, _AnchoredPolicy.anchor_allows;
exit_switch_anchor = 0.9, RouteCostConfig.exit_switch_anchor; tau_max = 6.0 and tau_deadband = 0.1,
RouteCostConfig.tau_max, RouteCostConfig.tau_deadband). An exit learned on the way is therefore adopted only if it is
more than 10 % faster than the exit the agent is heading for.
No known exit, opt-in modes. When the known subgraph contains no reachable
exit and the spawn area sets no_known_exit to explore,
evaluate_and_reroute looks for a target (route_graph.py, _decide_explore):
Explore. The frontier node with the lowest path cost through the known subgraph, with the first leg measured from the agent’s position; ties break on node id (
nearest_frontier_target,cognitive_map.py). Logged asreason="explore".Wander. When no frontier is reachable, the next node in a fixed rotation over the sorted known nodes other than the current one, reached over known edges taken in either direction (
wander_target,_undirected_known_path,cognitive_map.py). Logged asreason="wander".Look before wander. A stage completes within r + 0.5 m of a random point in it, r being the agent’s radius, or, for a checkpoint, as soon as the agent’s centre enters the polygon (
reached_stage,direct_steering_runtime.py), so an agent can complete it short of the doorway from which the next sign is legible. Before it leaves a node on a patrol, it first walks until the node’s routing point lies within its own body radius, the radius it was spawned with. From there it senses as on arrival, then decides again: an exit or a frontier learned there is taken, otherwise it patrols (#250;_look_point,route_graph.py).- The look happens once per visit. It writes no route-history row and leaves the patrol rotation unchanged.
- Waiting time, throughput limits and stage closure do not apply to it.
- A decision made during the look replaces the look.
- There is no look when the agent’s disc does not fit on the routing point inside the walkable area; a warning names the stage. A run whose stage graph has no walkable area has no such test, and no look.
- A guard ends a look whose routing point stays occupied, for example
by agents deadlocked in a door: a look not completed after twice the
free walking time plus 10 s of simulation time
(
LOOK_CROWD_ALLOWANCE_S,direct_steering_runtime.py) is given up with a warning, and the agent patrols from where it stands. It is not a behavioural parameter: the longest look measured in a 30-agent door crowd took 6 s, and no shipped deck is expected to reach it.
Stand. When neither yields a target and the agent is walking towards a stage not in its map, it stops where it is. Logged as
reason="stay".
return first walks back over known legs, in either direction, to the
nearest known node from which a known exit is reachable, logged as
reason="return"; without one it explores. It differs from explore only
where a one-way transition leads away from the known exit. stay stands
until an exit becomes known. Perception still runs at every re-evaluation, so
a standing agent can still read a sign.
What route choice does not read. Route choice never reads the
sign-legibility test or a line-of-sight visibility
(route_graph.py, AdditivePolicy.apply_candidate_set_rules). VisibilityModel.visibility_to_node and
distance_to_node have no caller in pyfds_evac/
(#158). Route
smoke is sampled from the global extinction field (see
routing) along the stored node-to-node polylines. When
the agent’s position is given, the first leg is sampled along the walk from the agent to its next node, for
every familiarity tier (_first_leg, _polyline_stats in route_graph.py).
That walk is the routing engine’s path through the walkable area, the same one
that measures the first leg’s length; without an engine, or when the query
fails, it is the straight line. On an FDS field the walk is read once in
every grid cell it crosses, as every route polyline is
(#653),
including any stretch behind the route’s origin node; samples every
sampling_step_m are used only off the grid. The walk’s mean \(\bar K\), length and travel time enter the route
mean \(\bar K\), \(\tau\), the gate and the ranking, so the agent is charged
for the smoke ahead of it only (_measure_route). The walk also gives the
route’s worst sample k_max_route, which is reported but decides nothing,
and the worst leg mean, which decides the optional clean tier, off by default
(clean_extinction_threshold = 0). Without a position, the first leg is the
whole node-to-node polyline. Under the "additive" model only, a route
whose every segment has \(\bar K \ge 0.5\) m⁻¹ is refused while another
non-refused route has a segment below it (route_graph.py, AdditivePolicy.apply_candidate_set_rules); this is
an extinction threshold, not a sign test.
Parameters
familiarity, entrance, no_known_exit and sign are scenario keys. The other rows are
command-line options, with the default of the underlying class for direct
library use.
| Setting | Source | run.py value | Library default | Unit | Meaning |
|---|---|---|---|---|---|
familiarity | distribution parameters | – | "full" (simulation_init.py, _initialize_with_fallback) | – | "full", "discovery" or \(p \in [0,1]\) |
entrance | distribution parameters | – | none (simulation_init.py, _initialize_with_fallback) | – | a reachable exit learned at spawn |
no_known_exit | distribution parameters | – | "default_route" (cognitive_map.py, DEFAULT_NO_KNOWN_EXIT) | – | "default_route", "explore", "return" or "stay": what an agent does with no reachable known exit (§2.4) |
sign.c | node sign | – | 3 (visibility.py, _default_sign, _build_vismap) | – | C |
sign.alpha | node sign | – | None, i.e. \(A = 1\) (visibility.py, _default_sign) | ° | bearing the sign faces |
| \(V_{\max}\) | --max-sign-distance | 30 | 30 (DEFAULT_MAX_SIGN_DISTANCE_M) | m | reading distance of every sign, also in clear air |
sign.max_distance | node sign | – | none, i.e. \(V_{\max}\) | m | reading distance of this sign |
--smoke-slice-height | CLI | 1.6 (config/parameters.py, SMOKE_SLICE_HEIGHT_M) | 1.6 (visibility.py, VisibilityModel.__init__) | m | FDS slice height |
--reroute-interval | CLI | 1.0 (config/parameters.py, REROUTE_INTERVAL_S) | 10.0 (route_graph.py, RerouteConfig.reevaluation_interval_s) | s | re-evaluation interval, hence periodic learning |
| vismap time step | = --reroute-interval | 1.0 (run_config.py, _build_vis_model) | 10.0 (visibility.py, VisibilityModel.__init__) | s | FDS model only; clear air stores one time |
--vis-cell-size | CLI | 0.25 (config/parameters.py, VIS_CELL_SIZE_M) | 0.25 (visibility.py, VisibilityModel.clear_air) | m | clear-air grid; FDS models use the FDS mesh |
--vis-cache | CLI | none | none | – | .npz cache path |
All defaults that differ between run.py and run_scenario() are listed in
Python API and command line.
The --vis-cell-size docstring advises a cell smaller than the thinnest wall
(visibility.py, VisibilityModel.clear_air), and the build warns when it is
not (#115). On the familiarity deck,
over 30 seeds, the median discovery egress time changes by about 1 % at
both halvings. The P90 changes by more than 5 % at both, because 2 to 4 runs
per grid end in doorway deadlocks
(#359;
SocialForceModel deck) and
count as 300 s; without them it changes by −3.0 % from 0.1 to 0.05 m and
+1.1 % from 0.05 to 0.025 m
(criterion 6;
#168).
Where it acts in the time step
- Before the loop. The visibility model is built or loaded
(
run_config.py,build_run_kwargs). Each agent placed at t = 0 gets its map and opening choice (scenario.py,run_scenario); flow-spawned agents get theirs when they appear (scenario.py,run_scenario). - Reroute pass. It runs at most once per simulated second
(
scenario.py,run_scenario). For each agent whose re-evaluation is due, periodic learning runs first, then ranking and possible adoption. - Steering loop, every step. Positions are stored
(
scenario.py,run_scenario), and learning on advancing along the path runs when a stage completes (scenario.py,run_scenario). This learning is not tied to the reroute pass. The loop is pyFDS-Evac’s side of JuPedSim’s direct steering, described in Routing › How agents are steered. - History. With
collect_cognitive_map_history(on inrun.py,run_config.py,build_run_kwargs), a row is written whenever the number of known nodes or edges changes (scenario.py,run_scenario).
With --no-enable-rerouting, the map is still built, still sets the opening
choice, and still grows on advancing along the path, but no later decision
reads it.
Deviations from the literature
The published visibility law is on Visibility through smoke. The legibility test is the waypoint method of Börger, Belt and Arnold (2024), Eqs. (2) and (7)–(10). The cognitive map follows the design of Haensel (2014).
- Per-sign reading distance. Börger et al. cap visibility at 30 m and
add that “the exit signs have a maximum visual distance even in a
smoke-free environment” (p. 5, below Eq. 10). pyFDS-Evac keeps 30 m as the
default and lets a sign set its own
max_distance. It does not derive that distance from sign size or a signage standard. - Cap before angle. Börger et al. write \(V = \min(U\,A\,C/\bar\sigma,\ V_{\max})\) (Eq. 10, p. 5). fdsvismap computes \(A\,U\,\min(C/\bar K, V_{\max})\). They agree while \(C/\bar K < V_{\max}\); in clear air, off-axis, the paper gives \(V_{\max}\) and fdsvismap gives \(A\,V_{\max}\). This is our reading of the rendered Eq. (10) against the pinned source.
- Averaged extinction. \(\bar K\) is the cell average of Börger et al. Eq. (9), a simplification of their Eq. (8). Jin measured his law in uniform smoke, so applying it to an average along a line of sight is an extrapolation.
- View angle. The cosine factor is the Lambertian assumption of Börger et al., who call it “highly simplified” (p. 4). It is not part of Jin’s experiments.
- Sign photometry. With smoke and ambient light held constant, “all factors affecting the contrast can be encapsulated by a single visibility factor C” (Börger et al., p. 1). The luminance, size and contrast of the sign and the ambient light are not modelled, nor light from other sources scattered into the line of sight. The Fundamentals page gives Jin’s contrast model behind C.
- Sampling height. Börger et al. use one slice at 2 m, with the eye at
sign height (p. 4). The code uses 1.6 m, the FDS+Evac
HUMAN_SMOKE_HEIGHT. The extinction slice is the horizontal one nearest that height, as for walking speed and FED. Our inference: exit signs are usually mounted above doors, higher than 1.6 m. Under a hot smoke layer, a 1.6 m slice reads less extinction than the line of sight to such a sign crosses, so it overstates legibility. That error is non-conservative. - Synthesised signs. A sign the building may not have: reflective, C = 3, readable from every direction. C = 3 is the FDS default for a reflecting sign; the Fundamentals page gives Jin’s range as 2–4 for reflecting and 5–10 for light-emitting signs.
- A seen sign counts as understood. A legible sign teaches the agent its node and the direction to it, although the test only says the sign is barely seen. Reading the letters of an exit sign needs 3–5 times more contrast (Seeing a sign versus reading it), so the knowledge the model grants is optimistic; a lit sign modelled with C = 3 is the exception (Jin’s lit-sign reading data lie near \(C_s V\) = 4.5, above 3; Jin 1978, Fig. 7).
- Legibility is binary and acts only on knowledge. In the discrete-choice studies on Exit choice and familiarity, exit visibility enters the utility as a graded attribute.
- Knowledge of topology, not of smoke
(#125).
Learning is limited by the sign-legibility test. Route costs are not: an
agent that knows an exit prices the whole route to it from the global
extinction field, including legs it has never seen and, with
anticipate, future times (route_graph.py,RouteCostConfig.anticipate,RouteCostConfig.foresight_horizon_s). The route choice of a discovery agent is therefore not limited by what it has perceived. - Haensel’s heuristics are not reproduced. Haensel (2014, §3.2) weights
known edges by sensors (smoke, density, room-to-corridor) and uses a
LastDestinationsSensoragainst back-tracking. He reports that with an empty map and no sensors agents stay where they are, and calls theDiscoverDoorsSensormandatory for empty maps (§4.3, p. 47). That sensor “adds all possible out edges” of the agent’s current sub-room (§3.2.2). It is the counterpart ofexpand_on_arrivalwithout a visibility model. With a visibility model, the code gates the same step by sign legibility. The code uses frontier exploration and a patrol instead of his weighting heuristics.
Limitations
- The default route is modeller knowledge
(#610,
#91). Under
the default
no_known_exit, an agent that knows no exit follows the journey or the nearest exit on foot, as FDS+Evac’s agents follow its flow field, and it can leave through an exit it never learned. The exit history flags each such exit (§2.4). Herding, the step FDS+Evac takes before its flow field, is not modelled (#78). The opt-in modes are mechanisms, not validated behaviour: we found no published data on how occupants explore unknown space. - FED of node-to-node legs. Each leg after the first takes its FED
growth from one FED-rate sample at the polyline midpoint. The first leg’s
dose, including a walk behind the route’s origin node, is charged as
Routing describes
(#171, fixed
in 0.5.0). Anticipated times are
counted from the agent’s position, and each smoke sample is read when the
agent reaches it (
route_graph.py,_measure_route; #650). See §2.4, “What route choice does not read”. - A sign is never read beyond its reading distance. \(V_{\max}\) is
30 m by default (#173),
so in clear air a sign farther away is illegible at any bearing, and a
discovery agent in a space wider than that must approach an exit to learn
it. The value is a default, not derived from sign size or a signage
standard; set
"max_distance"per sign where it matters. Jin’s law was measured at 5.5–15.5 m, so the 30 m default is an extrapolation (Visibility › Known limits). FDS+Evac has no such limit (Coming from FDS+Evac). - Off the FDS grid, signs are read in clear air
(#426). An
agent beyond the grid of an FDS model sees every sign within its reading
distance, measured from the agent’s true position, without testing view
angle, walls or smoke, also when the sight line crosses smoke inside the
domain (Off the FDS grid). In a T-junction whose
corridor extends 12 m past the FDS mesh, an agent 11 m outside it at
t = 120 s sees a sign 29.5 m away through corridor smoke of
K ≈ 5–11 m⁻¹. FDS+Evac’s
See_door(evac.f90:15682–15813) still walks that line across the evacuation grid: a wall stops it, and the mean K that enters its door choice counts the soot of the cells inside the fire meshes and zero for those outside. Off the grid, pyFDS-Evac is therefore less conservative than FDS+Evac on walls and smoke. FDS+Evac has no view-angle test and no reading distance anywhere. To exclude the case, extend the FDS meshes over the walkable area, or run with--require-fds-coverage. - Sight lines that leave the FDS meshes hide signs (#454, open). On the grid, a sight line that crosses a cell outside every FDS mesh gets a NaN extinction, and the sign is unreadable from that cell. Here pyFDS-Evac is stricter than FDS+Evac (Limitations › Outside the FDS domain).
- Learning is limited to neighbours, spawn perception is per spawn area, and periodic learning uses the previous step’s position (§2.1–2.2).
- The patrol can stall (#122). It visits only known nodes. When smoke limits legibility to a few metres, the walks between them may never pass a new legible sign.
- Exploring agents can deadlock in a doorway
(#359).
Two agents that meet head-on in a door, or four jammed at a checkpoint,
can stand there for the rest of the run. Under
explore, andreturnwhen it explores, nothing breaks the stand up: an agent with nothing left to explore looks from the node point before it patrols (#250), and the patrols that used to clear such stands no longer pass.familiarity_test_no_journey, an SFM deck (SocialForceModel), in clear air on the 0.05 m grid with the settings oftests/test_familiarity_no_journey.py, ends atc619a046with 2 agents left in 3 of 150 runs of 5 agents (seeds 1–150) and with 2 to 6 agents left in 7 of 60 runs of 20 agents (seeds 1–60). At 0.4.0 it was up to 4 agents in 5 of 150 and 5 of 60 runs (3 and none before #250). That test bounds the rate. UnderCollisionFreeSpeedModelthe deck does not deadlock (Movement models). - Discovery results depend on the clear-air grid (#168). On the familiarity deck the 30-seed criterion fails on both halvings because of #359 deadlocks, and passes on both without the deadlocked runs; #168 stays open, blocked on the #359 fix. Use a cell at most half the thinnest wall. Do not report a discovery egress time without its grid; Wayfinding in practice gives the measured spread.
- No per-exit familiarity
(#136). One
\(p\) per group, plus one
entrance.
Fixed behaviour. Three earlier defects are fixed: discovery agents ranked the first leg as a straight line through walls (#172, fixed by #174), clear-air travel time was under-priced when an agent was farther from its next node than the route’s origin (#167, fixed by #170), and exploring agents turned back at the door of the only exit, having completed their visit short of where its sign is legible (#250 and #387, fixed by the look before wander).
- No sharing. Maps are per agent; no agent learns from another.
- No individual variation in C, eye height or the legibility threshold.
Relation to FDS+Evac
From materials/evac.f90 (FDS commit c9da70d7a, FDS6.7.6-404-gc9da70d7a,
Evac 2.6.0, the version the 2021 guide describes); line numbers refer to
that file. The detailed account of FDS+Evac door choice is on
model comparison.
| Concept | FDS+Evac | pyFDS-Evac |
|---|---|---|
| Visible | Geometric line of sight from agent to door centre, or to the door’s XB centre from the correct side; no range limit, no contrast, no angle factor | Legible: sign test with C, view angle, obstruction, \(V_{\max}\) and line-of-sight extinction |
| Known | KNOWN_DOOR (default .FALSE.) and KNOWN_DOOR_PROBS per door, written at initialisation into a per-agent or per-group list; afterwards only downgraded | Known: seeded by familiarity (one \(p\) per group) and entrance, then learned from legible signs; never downgraded |
| Visible counts as known | for the current target in imode == 1 calls (:16533–16535), and for any visible door for type-1 agents; both for the current call only | a legible neighbour is learned and kept |
| Memory of the target | a current target with positive I_Target stays visible | every known node persists |
| Smoke-free door | tiers 1–3 admit a door with \(\bar K < \lvert\texttt{FED\_DOOR\_CRIT}\rvert\) = 0.03 m⁻¹ by default (≈ 100 m visibility); the door of the current flow field is tested at FAC_DOOR_OLD = 0.1 × \(\bar K\), so it passes below 0.3 m⁻¹. Evac 2.6.1 (FDS 6.7.7) tests \(\bar K d\) < 0.003 instead (Evac 2.6.1 door test) | roughly the exposure gate, applied to route optical depth \(\tau = \bar K L\) along the route |
| Last resort | tier 4 ranks by \(0.5\,d/(3/\bar K)\); a door with value ≥ 1 is struck out for that call | the all-refused fallback promotes the least smoky known route |
| Smoke memory | lone agents mark the previous target negative or zero | none |
| Default | agent type 2, KNOWN_DOOR = .FALSE. | familiarity = "full" |
| No known or visible door | the agent herds with visible neighbours that have a target (:8046, :8066–8211), else follows the main evacuation mesh flow field, which leads to every exit of the mesh (:16826–16842, :16868–16900); with I_HERDING_TYPE > 1 it stands | no_known_exit = "default_route": the journey, or the nearest exit on foot; no herding (#78). stay corresponds to standing |
Details, with line numbers:
- Default knowledge.
KNOWN_DOORdefaults to.FALSE.on&EXITand&DOOR(:2291,:2733), and the default agent type is 2, the “known door” agent (:3774). Our reading is that FDS+Evac’s default agent is closer todiscoverythan tofull. The two are not equivalent: see “Knowledge” below. - Visibility.
See_doorcasts a straight line and returns.FALSE.only when an obstruction blocks it (:15682–15813, walls at:15762,:15803). It also returns the mean extinction on that line. A door counts as seen when its centre is seen, or when the centre of itsXBis seen from the correct side:PP_see_door = See_door(X,Y) .OR. (See_door(XB) .AND. PP_correct_side)(:16486–16494). For DOORs withoutEXIT_SIGN, visibility also needs the door to be the current target or already known (:16500–16507). - Smoke acts in the door choice, not in seeing. \(\bar K\) is floored at
\(0.5\,\lvert\texttt{FED\_DOOR\_CRIT}\rvert\) = 0.015 m⁻¹ for either sign of the
criterion (
:16497), which is converted from a visibility with \(S = 3/K\) (:5494–5496).- Tiers 1–3 admit a door only while \(\bar K < \lvert\texttt{FED\_DOOR\_CRIT}\rvert\)
(
:16589,:16601,:16608,:16683–16690,:16732–16737). The door that shares the current target’s flow field is discounted first: its \(\bar K\) is multiplied byFAC_DOOR_OLD(default 0.1,:1571; applied at:16591,:16685,:16734), so the current door stays “smoke free” up to 0.3 m⁻¹ by default. This is a hysteresis on route retention, comparable in role to our anchor and deadband. - In tier 4, with the default criterion, a door is scored
\(0.5\,d/(3/\bar K)\). \(d\) is the Euclidean distance for a visible door
and the L1 distance for a non-visible one (
:16793–16797). A score ≥ 1 clears the door’s visible and known flags for that call (:16798–16801).
- Tiers 1–3 admit a door only while \(\bar K < \lvert\texttt{FED\_DOOR\_CRIT}\rvert\)
(
- Knowledge. The known list is written at initialisation (
:16230,:16279–16293), per agent or per group (Group_Known_Doors,:16259–16276), and it persists across calls. The visible and known flags of one call are rebuilt from it each time (:16170–16171). Type-1 agents count a visible door as known within the call (:16513), and a current target with positiveI_Targetstays visible (:16531). - Smoke marks (lone agents only,
HR%GROUP_ID < 0,:16628). For the previous target,L2_tmpandL2_tmp2are scaled byFAC_DOOR_OLD2andFAC_DOOR_OLD(:16626–16627). WhenL2_tmp2 ≥ |FED_DOOR_CRIT|, the list entry becomes its negative node id, or zero whenL2_tmp ≥ 1(:16628–16637). Under the defaults the mark starts at \(\bar K \ge 0.3\) m⁻¹. - The difference. In our reading of the code, FDS+Evac never adds a door to the known list after initialisation. It only downgrades entries. What an agent sees counts only within the call that sees it. pyFDS-Evac learns: a legible sign adds the node to the map, and the node stays known after the sign stops being legible. The legibility test itself (C, bearing, view angle, line-of-sight extinction) has no FDS+Evac counterpart. The exposure gate is a separate mechanism, related to FDS+Evac’s smoke-free and tier-4 criteria (see routing).
- No counterpart. FDS+Evac’s herding, which a default type-2 agent with no
door also does (
:8046), its follower agents (types 3 and 4) and its group behaviour. Exploration (explore,return) has no FDS+Evac counterpart.
Verification
These tests check implementation behaviour. They do not validate human
wayfinding or evacuation times. The coupled check, in clear air, is
Verification › Familiarity: its
criteria 1–5 pass, except criterion 4 on the 0.05 m reference grid, where 4
agents deadlock in a doorway (#359).
Its grid-convergence criterion fails on both halvings on this 30-seed sample
because of #359 deadlocks, and passes on both without the deadlocked runs
(#168, open).
The familiarity decks are SFM decks (SocialForceModel;
Movement models).
tests/test_exit_visibility_alpha.py(assets/exit_visibility_alpha): the bearing of the near sign decides whether the near exit is learned; an unknown exit is absent, not refused.tests/test_cognitive_map_memory.py(assets/cognitive_map_memory): an exit is learned inside its legibility region, stays known and routable outside it, is not learned by a probe that never enters the region, and ranks first at a return probe where its sign is not legible.tests/test_blind_spawn_discovery.py(assets/blind_spawn_discovery): learning hop by hop, occlusion on advancing, reverse edges, frontier choice by position, termination of exploration.tests/test_scalar_familiarity.py: \(p\) as a probability, reproducible draws,entranceknown and reachable.tests/test_initial_exit_from_cognitive_map.py: the opening choice ranks the map, with rerouting off.tests/test_initial_exit_per_agent.py: the opening choice is measured from each agent’s position; one spawn area in a room with two mirrored doors splits between them.tests/verification/test_cognitive_map_verif.py: tier invariants on a toy graph.tests/test_no_known_exit.py: eachno_known_exitmode on a toy graph; the default route leaves by the exit nearest on foot behind a wall and is flagged, while the opt-in modes never leave by an unknown exit; the walked leg is learned on arrival; the refusal with--smoke-blind.
tests/test_familiarity_routing.py::TestDiscoveryMeasuresAroundWalls pins
the #172 fix. No test pins convergence with the grid (#168); the grid study
is a verification script (scripts/verification/familiarity_grid_study.py).
Sources
- Börger, K., Belt, A., & Arnold, L. (2024). A waypoint based approach to visibility in performance based fire safety design. Fire Safety Journal, 150, 104269. doi:10.1016/j.firesaf.2024.104269
- Haensel, D. (2014). A knowledge-based routing framework for pedestrian dynamics simulation. Diploma thesis, Technische Universität Dresden. No DOI or public URL known.
- Schröder, B., Haensel, D., Chraibi, M., Arnold, L., Seyfried, A., & Andresen, E. (2015). Knowledge- and perception-based route choice modelling in case of fire. Proceedings of the 6th International Symposium on Human Behaviour in Fire, Cambridge, UK, pp. 327–338. Interscience Communications. No DOI; juser.fz-juelich.de/record/255940.
- fdsvismap, github.com/FireDynamics/fdsvismap, release 0.3.3 (PyPI).
- Korhonen, T. (2021). Fire Dynamics Simulator with Evacuation: FDS+Evac. Technical Reference and User’s Guide. github.com/tkorhon1/FDS-Evac-Guide.