///|
/// Tunable parameters for the graph-based hazard model.
pub struct HazardConfig {
spread_rate : Double
source_growth : Double
passive_decay : Double
door_transfer : Double
hallway_transfer : Double
stair_transfer : Double
ramp_transfer : Double
outdoor_transfer : Double
floor_penalty : Double
block_threshold : Double
} derive(Eq, Debug)
///|
/// Mutable scenario state at a specific simulation step.
pub struct ScenarioState {
mut step : Int
risks : Array[Double]
active_sources : Array[Double]
passage_enabled : Array[Bool]
event_log : Array[String]
} derive(Eq, Debug)
///|
/// Risk classification for reporting and routing policy.
pub(all) enum RiskBand {
Clear
Caution
Dangerous
Critical
} derive(Eq, Debug)
///|
/// Aggregate risk observation for one time step.
pub struct RiskObservation {
step : Int
clear_places : Int
caution_places : Int
dangerous_places : Int
critical_places : Int
maximum_risk : Double
mean_risk : Double
} derive(Eq, Debug)
///|
/// Conservative defaults for a teaching simulation.
pub fn HazardConfig::default() -> HazardConfig {
{
spread_rate: 0.12,
source_growth: 0.08,
passive_decay: 0.005,
door_transfer: 0.85,
hallway_transfer: 1.0,
stair_transfer: 1.15,
ramp_transfer: 0.95,
outdoor_transfer: 0.35,
floor_penalty: 0.8,
block_threshold: 0.9,
}
}
///|
/// A slower spread profile useful for drills.
pub fn HazardConfig::slow() -> HazardConfig {
{
spread_rate: 0.06,
source_growth: 0.04,
passive_decay: 0.008,
door_transfer: 0.75,
hallway_transfer: 0.9,
stair_transfer: 1.0,
ramp_transfer: 0.85,
outdoor_transfer: 0.25,
floor_penalty: 0.7,
block_threshold: 0.92,
}
}
///|
/// A fast spread stress-test profile.
pub fn HazardConfig::aggressive() -> HazardConfig {
{
spread_rate: 0.2,
source_growth: 0.14,
passive_decay: 0.002,
door_transfer: 0.95,
hallway_transfer: 1.1,
stair_transfer: 1.3,
ramp_transfer: 1.0,
outdoor_transfer: 0.45,
floor_penalty: 0.9,
block_threshold: 0.82,
}
}
///|
fn clamp_unit(value : Double) -> Double {
if value < 0.0 {
0.0
} else if value > 1.0 {
1.0
} else {
value
}
}
///|
fn passage_transfer(config : HazardConfig, kind : PassageKind) -> Double {
match kind {
Door => config.door_transfer
Hallway => config.hallway_transfer
Stairway => config.stair_transfer
Ramp => config.ramp_transfer
Outdoor => config.outdoor_transfer
}
}
///|
/// Initialize scenario state from plan defaults.
pub fn ScenarioState::from_plan(plan : EvacuationPlan) -> ScenarioState {
let risks : Array[Double] = []
let sources : Array[Double] = []
let enabled : Array[Bool] = []
for place in plan.places {
risks.push(clamp_unit(place.initial_risk))
sources.push(0.0)
}
for passage in plan.passages {
enabled.push(passage.enabled)
}
{
step: 0,
risks,
active_sources: sources,
passage_enabled: enabled,
event_log: [],
}
}
///|
/// Copy scenario state so scenario branches are independent.
pub fn ScenarioState::copy(self : ScenarioState) -> ScenarioState {
let risks : Array[Double] = []
let sources : Array[Double] = []
let enabled : Array[Bool] = []
let log : Array[String] = []
for value in self.risks {
risks.push(value)
}
for value in self.active_sources {
sources.push(value)
}
for value in self.passage_enabled {
enabled.push(value)
}
for value in self.event_log {
log.push(value)
}
{
step: self.step,
risks,
active_sources: sources,
passage_enabled: enabled,
event_log: log,
}
}
///|
/// Convert numeric risk to a stable reporting band.
pub fn risk_band(value : Double) -> RiskBand {
if value < 0.2 {
Clear
} else if value < 0.5 {
Caution
} else if value < 0.8 {
Dangerous
} else {
Critical
}
}
///|
/// Stable code for a risk band.
pub fn RiskBand::code(self : RiskBand) -> String {
match self {
Clear => "clear"
Caution => "caution"
Dangerous => "dangerous"
Critical => "critical"
}
}
///|
/// Display color used by the SVG renderer.
pub fn RiskBand::color(self : RiskBand) -> String {
match self {
Clear => "#2e7d32"
Caution => "#f9a825"
Dangerous => "#ef6c00"
Critical => "#c62828"
}
}
///|
fn event_due(event : ScenarioEvent, step : Int) -> Bool {
match event {
ClosePassage(_, at) => at == step
OpenPassage(_, at) => at == step
Ignite(_, at, _) => at == step
ClearRisk(_, at) => at == step
}
}
///|
/// Apply all events due at the state's current step.
pub fn apply_due_events(plan : EvacuationPlan, state : ScenarioState) -> Unit {
for event in plan.events {
if event_due(event, state.step) {
match event {
ClosePassage(id, _) => {
let index = plan.passage_index(id)
if index >= 0 && index < state.passage_enabled.length() {
state.passage_enabled[index] = false
state.event_log.push("step \{state.step}: closed passage \{id}")
} else {
state.event_log.push(
"step \{state.step}: ignored unknown passage \{id}",
)
}
}
OpenPassage(id, _) => {
let index = plan.passage_index(id)
if index >= 0 && index < state.passage_enabled.length() {
state.passage_enabled[index] = true
state.event_log.push("step \{state.step}: opened passage \{id}")
} else {
state.event_log.push(
"step \{state.step}: ignored unknown passage \{id}",
)
}
}
Ignite(id, _, intensity) => {
let index = plan.place_index(id)
if index >= 0 && index < state.active_sources.length() {
state.active_sources[index] = clamp_unit(intensity)
if state.risks[index] < intensity {
state.risks[index] = clamp_unit(intensity)
}
state.event_log.push(
"step \{state.step}: ignited \{id} at \{intensity}",
)
} else {
state.event_log.push(
"step \{state.step}: ignored unknown place \{id}",
)
}
}
ClearRisk(id, _) => {
let index = plan.place_index(id)
if index >= 0 && index < state.risks.length() {
state.risks[index] = 0.0
state.active_sources[index] = 0.0
state.event_log.push("step \{state.step}: cleared risk at \{id}")
} else {
state.event_log.push(
"step \{state.step}: ignored unknown place \{id}",
)
}
}
}
}
}
}
///|
fn transfer_amount(
plan : EvacuationPlan,
state : ScenarioState,
config : HazardConfig,
passage_index : Int,
from_index : Int,
to_index : Int,
) -> Double {
if passage_index < 0 || passage_index >= plan.passages.length() {
return 0.0
}
if passage_index >= state.passage_enabled.length() ||
!state.passage_enabled[passage_index] {
return 0.0
}
let passage = plan.passages[passage_index]
let source = state.risks[from_index]
let destination = state.risks[to_index]
if source <= destination {
return 0.0
}
let floor_difference = if plan.places[from_index].floor ==
plan.places[to_index].floor {
1.0
} else {
config.floor_penalty
}
let distance_factor = 1.0 / (1.0 + passage.length_m / 20.0)
let width_factor = if passage.width_m > 2.0 {
1.2
} else {
0.6 + passage.width_m * 0.3
}
(source - destination) *
config.spread_rate *
passage_transfer(config, passage.kind) *
floor_difference *
distance_factor *
width_factor
}
///|
/// Advance graph risk by one deterministic step.
pub fn advance_hazard(
plan : EvacuationPlan,
state : ScenarioState,
config : HazardConfig,
) -> Unit {
apply_due_events(plan, state)
let delta = Array::make(plan.places.length(), 0.0)
for i = 0; i < plan.places.length(); i = i + 1 {
if i < state.active_sources.length() && state.active_sources[i] > 0.0 {
delta[i] = delta[i] + state.active_sources[i] * config.source_growth
} else {
delta[i] = delta[i] - config.passive_decay
}
}
for p = 0; p < plan.passages.length(); p = p + 1 {
let passage = plan.passages[p]
let from_index = plan.place_index(passage.from_id)
let to_index = plan.place_index(passage.to_id)
if from_index >= 0 && to_index >= 0 {
let forward = transfer_amount(
plan, state, config, p, from_index, to_index,
)
delta[to_index] = delta[to_index] + forward
if passage.bidirectional {
let backward = transfer_amount(
plan, state, config, p, to_index, from_index,
)
delta[from_index] = delta[from_index] + backward
}
}
}
for i = 0; i < state.risks.length(); i = i + 1 {
state.risks[i] = clamp_unit(state.risks[i] + delta[i])
}
state.step = state.step + 1
}
///|
/// Disable passages touching places above the configured block threshold.
pub fn update_hazard_blocks(
plan : EvacuationPlan,
state : ScenarioState,
config : HazardConfig,
) -> Unit {
for i = 0; i < plan.passages.length(); i = i + 1 {
let passage = plan.passages[i]
let from_index = plan.place_index(passage.from_id)
let to_index = plan.place_index(passage.to_id)
if from_index >= 0 && to_index >= 0 {
if state.risks[from_index] >= config.block_threshold ||
state.risks[to_index] >= config.block_threshold {
state.passage_enabled[i] = false
}
}
}
}
///|
/// Produce congestion input compatible with the routing engine.
pub fn normalized_passage_congestion(
plan : EvacuationPlan,
queued : Array[Int],
) -> Array[Double] {
let result = Array::make(plan.passages.length(), 0.0)
for i = 0; i < result.length(); i = i + 1 {
let people = if i < queued.length() { queued[i] } else { 0 }
let width = if plan.passages[i].width_m > 0.1 {
plan.passages[i].width_m
} else {
0.1
}
result[i] = people.to_double() / (width * 10.0)
}
result
}
///|
/// Observe current risk distribution.
pub fn observe_risk(state : ScenarioState) -> RiskObservation {
let mut clear_places = 0
let mut caution_places = 0
let mut dangerous_places = 0
let mut critical_places = 0
let mut maximum_risk = 0.0
let mut sum = 0.0
for risk in state.risks {
if risk > maximum_risk {
maximum_risk = risk
}
sum = sum + risk
match risk_band(risk) {
Clear => clear_places = clear_places + 1
Caution => caution_places = caution_places + 1
Dangerous => dangerous_places = dangerous_places + 1
Critical => critical_places = critical_places + 1
}
}
let mean = if state.risks.length() > 0 {
sum / state.risks.length().to_double()
} else {
0.0
}
{
step: state.step,
clear_places,
caution_places,
dangerous_places,
critical_places,
maximum_risk,
mean_risk: mean,
}
}
///|
/// Return ids currently above a chosen risk threshold.
pub fn risky_place_ids(
plan : EvacuationPlan,
state : ScenarioState,
threshold : Double,
) -> Array[String] {
let result : Array[String] = []
let count = if plan.places.length() < state.risks.length() {
plan.places.length()
} else {
state.risks.length()
}
for i = 0; i < count; i = i + 1 {
if state.risks[i] >= threshold {
result.push(plan.places[i].id)
}
}
result
}
///|
/// Run a hazard-only preview and collect observations.
pub fn hazard_preview(
plan : EvacuationPlan,
initial : ScenarioState,
config : HazardConfig,
steps : Int,
) -> Array[RiskObservation] {
let state = initial.copy()
let result : Array[RiskObservation] = [observe_risk(state)]
let count = if steps < 0 { 0 } else { steps }
for _step = 0; _step < count; _step = _step + 1 {
advance_hazard(plan, state, config)
update_hazard_blocks(plan, state, config)
result.push(observe_risk(state))
}
result
}
///|
/// Integrate risk exposure over a series of risk samples.
pub fn accumulated_exposure(
samples : Array[Double],
timestep : Double,
) -> Double {
if samples.length() == 0 || timestep <= 0.0 {
return 0.0
}
let mut area = 0.0
for i = 0; i < samples.length(); i = i + 1 {
let weight = if i == 0 || i == samples.length() - 1 { 0.5 } else { 1.0 }
area = area + samples[i] * weight * timestep
}
area
}