///|
/// Internal state for an excursion candidate.
priv struct EventAccumulator {
sensor_id : String
kind : ExcursionKind
started_at : Int64
last_timestamp : Int64
last_temperature : Double
sample_count : Int
minimum_c : Double
maximum_c : Double
temperature_sum : Double
degree_seconds : Double
peak_deviation_c : Double
}
///|
/// Determine whether a sample begins a low or high excursion.
fn trigger_kind(
temperature : Double,
policy : TemperaturePolicy,
) -> ExcursionKind? {
if temperature < policy.lower_c {
Some(LowTemperature)
} else if temperature > policy.upper_c {
Some(HighTemperature)
} else {
None
}
}
///|
/// Deviation beyond the relevant temperature threshold.
fn threshold_deviation(
temperature : Double,
kind : ExcursionKind,
policy : TemperaturePolicy,
) -> Double {
match kind {
LowTemperature => clamp_double(policy.lower_c - temperature, 0.0, 10000.0)
HighTemperature => clamp_double(temperature - policy.upper_c, 0.0, 10000.0)
_ => 0.0
}
}
///|
/// Recovery requires crossing the hysteresis boundary.
fn recovered_from(
temperature : Double,
kind : ExcursionKind,
policy : TemperaturePolicy,
) -> Bool {
match kind {
LowTemperature => temperature >= policy.lower_c + policy.hysteresis_c
HighTemperature => temperature <= policy.upper_c - policy.hysteresis_c
_ => true
}
}
///|
/// Start a candidate from one out-of-range sample.
fn start_accumulator(
sample : Reading,
kind : ExcursionKind,
policy : TemperaturePolicy,
) -> EventAccumulator {
let deviation = threshold_deviation(sample.temperature_c, kind, policy)
{
sensor_id: sample.sensor_id,
kind,
started_at: sample.timestamp,
last_timestamp: sample.timestamp,
last_temperature: sample.temperature_c,
sample_count: 1,
minimum_c: sample.temperature_c,
maximum_c: sample.temperature_c,
temperature_sum: sample.temperature_c,
degree_seconds: 0.0,
peak_deviation_c: deviation,
}
}
///|
/// Advance a candidate with trapezoidal degree-second integration.
fn advance_accumulator(
accumulator : EventAccumulator,
sample : Reading,
policy : TemperaturePolicy,
) -> EventAccumulator {
let elapsed = sample.timestamp - accumulator.last_timestamp
let previous_deviation = threshold_deviation(
accumulator.last_temperature,
accumulator.kind,
policy,
)
let current_deviation = threshold_deviation(
sample.temperature_c,
accumulator.kind,
policy,
)
let exposure = if elapsed > 0L {
(previous_deviation + current_deviation) / 2.0 * elapsed.to_double()
} else {
0.0
}
{
..accumulator,
last_timestamp: sample.timestamp,
last_temperature: sample.temperature_c,
sample_count: accumulator.sample_count + 1,
minimum_c: if sample.temperature_c < accumulator.minimum_c {
sample.temperature_c
} else {
accumulator.minimum_c
},
maximum_c: if sample.temperature_c > accumulator.maximum_c {
sample.temperature_c
} else {
accumulator.maximum_c
},
temperature_sum: accumulator.temperature_sum + sample.temperature_c,
degree_seconds: accumulator.degree_seconds + exposure,
peak_deviation_c: if current_deviation > accumulator.peak_deviation_c {
current_deviation
} else {
accumulator.peak_deviation_c
},
}
}
///|
/// Stable event identifier derived from sensor, kind and start timestamp.
fn event_identifier(accumulator : EventAccumulator) -> String {
"\{accumulator.sensor_id}-\{excursion_kind_name(accumulator.kind)}-\{accumulator.started_at}"
}
///|
/// Convert an accumulator to an auditable event.
fn finalize_accumulator(
accumulator : EventAccumulator,
policy : TemperaturePolicy,
reason : String,
) -> ExcursionEvent {
let duration = accumulator.last_timestamp - accumulator.started_at
let qualifies = duration >= policy.grace_seconds &&
duration >= policy.minimum_event_seconds
{
event_id: event_identifier(accumulator),
sensor_id: accumulator.sensor_id,
kind: accumulator.kind,
status: if qualifies {
Confirmed
} else {
Suppressed
},
started_at: accumulator.started_at,
ended_at: accumulator.last_timestamp,
duration_seconds: duration,
sample_count: accumulator.sample_count,
minimum_c: Some(accumulator.minimum_c),
maximum_c: Some(accumulator.maximum_c),
mean_c: Some(
accumulator.temperature_sum / accumulator.sample_count.to_double(),
),
degree_seconds: accumulator.degree_seconds,
peak_deviation_c: accumulator.peak_deviation_c,
reason,
}
}
///|
/// Detect excursion events for an already ordered single-sensor stream.
pub fn detect_sensor_excursions(
readings : Array[Reading],
policy : TemperaturePolicy,
) -> Array[ExcursionEvent] {
let events : Array[ExcursionEvent] = []
let mut active : EventAccumulator? = None
for sample in readings {
if !sample.is_usable() {
continue
}
match active {
None =>
match trigger_kind(sample.temperature_c, policy) {
Some(kind) => active = Some(start_accumulator(sample, kind, policy))
None => ()
}
Some(accumulator) =>
if recovered_from(sample.temperature_c, accumulator.kind, policy) {
let closed = advance_accumulator(accumulator, sample, policy)
events.push(
finalize_accumulator(closed, policy, "temperature recovered"),
)
active = match trigger_kind(sample.temperature_c, policy) {
Some(kind) => Some(start_accumulator(sample, kind, policy))
None => None
}
} else {
let next = advance_accumulator(accumulator, sample, policy)
match trigger_kind(sample.temperature_c, policy) {
Some(kind) =>
if kind != accumulator.kind {
events.push(
finalize_accumulator(
next, policy, "temperature crossed directly into opposite excursion",
),
)
active = Some(start_accumulator(sample, kind, policy))
} else {
active = Some(next)
}
None => active = Some(next)
}
}
}
}
match active {
Some(accumulator) =>
events.push(
finalize_accumulator(accumulator, policy, "end of observation"),
)
None => ()
}
events
}
///|
/// Detect temperature events independently for every sensor.
pub fn detect_excursions(
readings : Array[Reading],
policy : TemperaturePolicy,
) -> Array[ExcursionEvent] {
let events : Array[ExcursionEvent] = []
let ordered = sort_readings(readings)
for sensor_id in unique_sensor_ids(ordered) {
events.append(
detect_sensor_excursions(readings_for_sensor(ordered, sensor_id), policy),
)
}
events.sort_by(fn(a, b) {
if a.started_at < b.started_at {
-1
} else if a.started_at > b.started_at {
1
} else {
a.sensor_id.compare(b.sensor_id)
}
})
events
}
///|
/// Convert sampling gaps to events so reports share one event timeline.
pub fn gap_events(gaps : Array[SamplingGap]) -> Array[ExcursionEvent] {
let events : Array[ExcursionEvent] = []
for gap in gaps {
events.push({
event_id: "\{gap.sensor_id}-data_gap-\{gap.previous_timestamp}",
sensor_id: gap.sensor_id,
kind: DataGap,
status: Confirmed,
started_at: gap.previous_timestamp,
ended_at: gap.next_timestamp,
duration_seconds: gap.duration_seconds,
sample_count: 0,
minimum_c: None,
maximum_c: None,
mean_c: None,
degree_seconds: 0.0,
peak_deviation_c: 0.0,
reason: "\{gap.estimated_missing_samples} samples estimated missing",
})
}
events
}
///|
/// Combine temperature and data-gap events in chronological order.
pub fn build_event_timeline(
readings : Array[Reading],
gaps : Array[SamplingGap],
policy : TemperaturePolicy,
) -> Array[ExcursionEvent] {
let events = detect_excursions(readings, policy)
events.append(gap_events(gaps))
events.sort_by(fn(a, b) {
if a.started_at < b.started_at {
-1
} else if a.started_at > b.started_at {
1
} else {
a.event_id.compare(b.event_id)
}
})
events
}
///|
/// Only confirmed events contribute to the primary risk score.
pub fn confirmed_events(
events : Array[ExcursionEvent],
) -> Array[ExcursionEvent] {
let output : Array[ExcursionEvent] = []
for event in events {
if event.status == Confirmed || event.status == Closed {
output.push(event)
}
}
output
}
///|
/// Total duration of confirmed events of one kind.
pub fn total_event_duration(
events : Array[ExcursionEvent],
kind : ExcursionKind,
) -> Int64 {
let mut total = 0L
for event in events {
if event.kind == kind &&
(event.status == Confirmed || event.status == Closed) {
total = total + event.duration_seconds
}
}
total
}
///|
/// Maximum peak deviation among confirmed temperature events.
pub fn maximum_peak_deviation(events : Array[ExcursionEvent]) -> Double {
let mut maximum = 0.0
for event in events {
if event.status == Confirmed && event.peak_deviation_c > maximum {
maximum = event.peak_deviation_c
}
}
maximum
}
///|
/// Count events matching kind and status.
pub fn count_events(
events : Array[ExcursionEvent],
kind : ExcursionKind,
status : EventStatus,
) -> Int {
let mut count = 0
for event in events {
if event.kind == kind && event.status == status {
count = count + 1
}
}
count
}