///|
/// Supported deterministic cold-chain fault scenarios.
pub(all) enum SimulationScenario {
StableTransit
DoorOpen
CoolingFailure
FreezerExposure
SensorDropout
SensorStuckFault
PowerCycle
MixedFaults
} derive(Debug, Eq)
///|
/// Simulation parameters. All timestamps are UTC Unix seconds.
pub(all) struct SimulationConfig {
scenario : SimulationScenario
sensor_ids : Array[String]
started_at : Int64
duration_seconds : Int64
interval_seconds : Int64
baseline_c : Double
ambient_c : Double
noise_amplitude_c : Double
humidity_percent : Double
battery_percent : Double
event_start_seconds : Int64
event_duration_seconds : Int64
seed : Int64
} derive(Debug, Eq)
///|
/// Result and prose explanation for a generated scenario.
pub(all) struct SimulationResult {
config : SimulationConfig
readings : Array[Reading]
description : String
diagnostics : Array[Diagnostic]
} derive(Debug, Eq)
///|
/// Minimal standard Park-Miller generator using Int64 arithmetic.
priv struct DeterministicRng {
mut state : Int64
}
///|
/// Construct a non-zero deterministic random state.
fn new_rng(seed : Int64) -> DeterministicRng {
let positive = if seed < 0L { -seed } else { seed }
let normalized = positive % 2147483647L
{ state: if normalized == 0L { 1L } else { normalized } }
}
///|
/// Advance and return the next positive integer.
fn DeterministicRng::next_int(self : DeterministicRng) -> Int64 {
self.state = self.state * 48271L % 2147483647L
self.state
}
///|
/// Uniform value in the open interval (0, 1).
fn DeterministicRng::next_unit(self : DeterministicRng) -> Double {
self.next_int().to_double() / 2147483647.0
}
///|
/// Symmetric random value in [-amplitude, amplitude].
fn DeterministicRng::jitter(
self : DeterministicRng,
amplitude : Double,
) -> Double {
(self.next_unit() * 2.0 - 1.0) * amplitude
}
///|
/// Defaults produce four hours of five-minute readings for two sensors.
pub fn default_simulation_config(
scenario : SimulationScenario,
) -> SimulationConfig {
{
scenario,
sensor_ids: ["logger-a", "logger-b"],
started_at: 1785916800L,
duration_seconds: 14400L,
interval_seconds: 300L,
baseline_c: 5.0,
ambient_c: 25.0,
noise_amplitude_c: 0.12,
humidity_percent: 62.0,
battery_percent: 94.0,
event_start_seconds: 3600L,
event_duration_seconds: 1800L,
seed: 20260805L,
}
}
///|
/// Validate a simulation request.
pub fn validate_simulation_config(
config : SimulationConfig,
) -> Array[Diagnostic] {
let diagnostics : Array[Diagnostic] = []
if config.sensor_ids.length() == 0 {
diagnostics.push(
error_diagnostic(
"simulate.no_sensors", "at least one sensor identifier is required",
),
)
}
for sensor_id in config.sensor_ids {
if sensor_id.trim().is_empty() {
diagnostics.push(
error_diagnostic(
"simulate.empty_sensor", "sensor identifiers cannot be empty",
),
)
}
}
if config.duration_seconds <= 0L {
diagnostics.push(
error_diagnostic(
"simulate.invalid_duration", "simulation duration must be positive",
),
)
}
if config.interval_seconds <= 0L {
diagnostics.push(
error_diagnostic(
"simulate.invalid_interval", "sample interval must be positive",
),
)
}
if config.event_start_seconds < 0L {
diagnostics.push(
error_diagnostic(
"simulate.invalid_event_start", "event start cannot be negative",
),
)
}
if config.event_duration_seconds < 0L {
diagnostics.push(
error_diagnostic(
"simulate.invalid_event_duration", "event duration cannot be negative",
),
)
}
if config.noise_amplitude_c < 0.0 {
diagnostics.push(
error_diagnostic(
"simulate.invalid_noise", "noise amplitude cannot be negative",
),
)
}
diagnostics
}
///|
/// Stable sinusoidal compressor cycle plus sensor-specific phase.
fn baseline_temperature(
config : SimulationConfig,
elapsed : Int64,
sensor_index : Int,
) -> Double {
let phase = elapsed.to_double() / 1800.0 * @math.PI * 2.0 +
sensor_index.to_double() * 0.7
config.baseline_c + @math.sin(phase) * 0.25 + sensor_index.to_double() * 0.08
}
///|
/// Whether elapsed time lies in the configured event interval.
fn inside_event(config : SimulationConfig, elapsed : Int64) -> Bool {
elapsed >= config.event_start_seconds &&
elapsed < config.event_start_seconds + config.event_duration_seconds
}
///|
/// Smooth linear progress through the configured event.
fn event_progress(config : SimulationConfig, elapsed : Int64) -> Double {
if config.event_duration_seconds <= 0L {
0.0
} else {
clamp_double(
(elapsed - config.event_start_seconds).to_double() /
config.event_duration_seconds.to_double(),
0.0,
1.0,
)
}
}
///|
/// Door opening approaches ambient temperature and then recovers gradually.
fn door_open_temperature(
config : SimulationConfig,
elapsed : Int64,
baseline : Double,
) -> Double {
if inside_event(config, elapsed) {
let progress = event_progress(config, elapsed)
baseline + (config.ambient_c - baseline) * progress * 0.45
} else {
let after = elapsed -
(config.event_start_seconds + config.event_duration_seconds)
if after >= 0L && after < config.event_duration_seconds {
let recovery = 1.0 -
after.to_double() / config.event_duration_seconds.to_double()
baseline + (config.ambient_c - baseline) * recovery * 0.18
} else {
baseline
}
}
}
///|
/// Cooling failure produces a continued temperature rise.
fn cooling_failure_temperature(
config : SimulationConfig,
elapsed : Int64,
baseline : Double,
) -> Double {
if elapsed < config.event_start_seconds {
baseline
} else {
let failure_elapsed = elapsed - config.event_start_seconds
let rise = failure_elapsed.to_double() / 3600.0 * 3.2
clamp_double(baseline + rise, baseline, config.ambient_c)
}
}
///|
/// Accidental freezer exposure drops and then recovers.
fn freezer_exposure_temperature(
config : SimulationConfig,
elapsed : Int64,
baseline : Double,
) -> Double {
if inside_event(config, elapsed) {
baseline - event_progress(config, elapsed) * 8.0
} else {
let after = elapsed -
(config.event_start_seconds + config.event_duration_seconds)
if after >= 0L && after < 1800L {
baseline - (1.0 - after.to_double() / 1800.0) * 4.0
} else {
baseline
}
}
}
///|
/// Scenario-specific temperature transformation.
fn scenario_temperature(
config : SimulationConfig,
elapsed : Int64,
baseline : Double,
) -> Double {
match config.scenario {
StableTransit | SensorDropout | PowerCycle => baseline
DoorOpen => door_open_temperature(config, elapsed, baseline)
CoolingFailure => cooling_failure_temperature(config, elapsed, baseline)
FreezerExposure => freezer_exposure_temperature(config, elapsed, baseline)
SensorStuckFault =>
if elapsed >= config.event_start_seconds {
config.baseline_c + 0.4
} else {
baseline
}
MixedFaults => {
let heated = door_open_temperature(config, elapsed, baseline)
if elapsed > config.event_start_seconds + config.event_duration_seconds {
cooling_failure_temperature(config, elapsed, heated)
} else {
heated
}
}
}
}
///|
/// Scenario can intentionally omit samples.
fn should_emit_sample(
config : SimulationConfig,
elapsed : Int64,
sensor_index : Int,
) -> Bool {
match config.scenario {
SensorDropout => !(sensor_index == 0 && inside_event(config, elapsed))
MixedFaults =>
!(sensor_index == 1 &&
inside_event(config, elapsed - config.event_duration_seconds))
_ => true
}
}
///|
/// Scenario can expose explicit device status.
fn simulated_status(config : SimulationConfig, elapsed : Int64) -> String {
match config.scenario {
PowerCycle => if inside_event(config, elapsed) { "offline" } else { "ok" }
MixedFaults =>
if elapsed >=
config.event_start_seconds + config.event_duration_seconds * 2L &&
elapsed <
config.event_start_seconds + config.event_duration_seconds * 2L + 600L {
"offline"
} else {
"ok"
}
_ => "ok"
}
}
///|
/// Battery discharge model with faster loss during a power cycle.
fn simulated_battery(config : SimulationConfig, elapsed : Int64) -> Double {
let normal_loss = elapsed.to_double() / 86400.0 * 2.0
let fault_loss = match config.scenario {
PowerCycle | MixedFaults =>
if elapsed >= config.event_start_seconds {
12.0
} else {
0.0
}
_ => 0.0
}
clamp_double(config.battery_percent - normal_loss - fault_loss, 0.0, 100.0)
}
///|
/// Human-readable scenario description.
pub fn simulation_scenario_description(scenario : SimulationScenario) -> String {
match scenario {
StableTransit => "stable cold-chain transit with compressor cycling"
DoorOpen => "temporary door opening followed by gradual recovery"
CoolingFailure => "progressive warming after refrigeration failure"
FreezerExposure => "accidental sub-zero exposure followed by recovery"
SensorDropout => "one logger stops reporting during the fault interval"
SensorStuckFault => "one logger becomes stuck at a constant value"
PowerCycle => "logger reports an explicit offline power-cycle interval"
MixedFaults => "door opening, dropout, warming and power-cycle faults"
}
}
///|
/// Parse a CLI-friendly scenario name.
pub fn parse_simulation_scenario(name : String) -> SimulationScenario? {
match name.trim().to_owned().to_lower() {
"stable" | "stable-transit" => Some(StableTransit)
"door" | "door-open" => Some(DoorOpen)
"cooling" | "cooling-failure" => Some(CoolingFailure)
"freezer" | "freezer-exposure" => Some(FreezerExposure)
"dropout" | "sensor-dropout" => Some(SensorDropout)
"stuck" | "sensor-stuck" => Some(SensorStuckFault)
"power" | "power-cycle" => Some(PowerCycle)
"mixed" | "mixed-faults" => Some(MixedFaults)
_ => None
}
}
///|
/// Generate a complete deterministic simulation.
pub fn simulate(config : SimulationConfig) -> SimulationResult {
let diagnostics = validate_simulation_config(config)
if diagnostics_have_errors(diagnostics) {
return {
config,
readings: [],
description: simulation_scenario_description(config.scenario),
diagnostics,
}
}
let readings : Array[Reading] = []
let rng = new_rng(config.seed)
let mut elapsed = 0L
while elapsed <= config.duration_seconds {
for sensor_index = 0
sensor_index < config.sensor_ids.length()
sensor_index = sensor_index + 1 {
if !should_emit_sample(config, elapsed, sensor_index) {
continue
}
let baseline = baseline_temperature(config, elapsed, sensor_index)
let transformed = scenario_temperature(config, elapsed, baseline)
let noise = if config.scenario == SensorStuckFault &&
sensor_index == 0 &&
elapsed >= config.event_start_seconds {
0.0
} else {
rng.jitter(config.noise_amplitude_c)
}
readings.push({
timestamp: config.started_at + elapsed,
sensor_id: config.sensor_ids[sensor_index],
temperature_c: transformed + noise,
humidity_percent: Some(
clamp_double(config.humidity_percent + rng.jitter(1.5), 0.0, 100.0),
),
battery_percent: Some(simulated_battery(config, elapsed)),
status: simulated_status(config, elapsed),
origin: Simulated,
flags: [],
source_line: None,
})
}
elapsed = elapsed + config.interval_seconds
}
{
config,
readings: sort_readings(readings),
description: simulation_scenario_description(config.scenario),
diagnostics,
}
}
///|
/// Convenience simulation used by examples and smoke tests.
pub fn simulate_named(name : String) -> SimulationResult {
match parse_simulation_scenario(name) {
Some(scenario) => simulate(default_simulation_config(scenario))
None => {
let config = default_simulation_config(StableTransit)
{
config,
readings: [],
description: "unknown simulation scenario",
diagnostics: [
error_diagnostic(
"simulate.unknown_scenario",
"unknown scenario: \{name}",
),
],
}
}
}
}