///|
/// Semantic version of the stable report model.
pub const REPORT_SCHEMA : String = "thermo-trail.report/v1"
///|
/// Origin of a temperature sample.
pub(all) enum SampleOrigin {
Recorded
Interpolated
Simulated
} derive(Debug, Eq)
///|
/// Quality flags are additive: one sample may carry several independent issues.
pub(all) enum QualityFlag {
DuplicateTimestamp
MissingField
OutOfPhysicalRange
GapBefore
Calibrated
SensorStuck
SensorNoisy
SensorDrifting
SensorOffline
SensorConflict
UserExcluded
} derive(Debug, Eq)
///|
/// Domain-level diagnostic severity.
pub(all) enum DiagnosticLevel {
Info
Warning
Error
} derive(Debug, Eq)
///|
/// A diagnostic always points to a stable code and may include an input line.
pub(all) struct Diagnostic {
level : DiagnosticLevel
code : String
message : String
line : Int?
sensor_id : String?
} derive(Debug, Eq)
///|
/// Create an informational diagnostic.
pub fn info_diagnostic(code : String, message : String) -> Diagnostic {
{ level: Info, code, message, line: None, sensor_id: None }
}
///|
/// Create a warning diagnostic.
pub fn warning_diagnostic(code : String, message : String) -> Diagnostic {
{ level: Warning, code, message, line: None, sensor_id: None }
}
///|
/// Create an error diagnostic.
pub fn error_diagnostic(code : String, message : String) -> Diagnostic {
{ level: Error, code, message, line: None, sensor_id: None }
}
///|
/// Attach an input line to a diagnostic.
pub fn Diagnostic::at_line(self : Diagnostic, line : Int) -> Diagnostic {
{ ..self, line: Some(line) }
}
///|
/// Attach a sensor identifier to a diagnostic.
pub fn Diagnostic::for_sensor(
self : Diagnostic,
sensor_id : String,
) -> Diagnostic {
{ ..self, sensor_id: Some(sensor_id) }
}
///|
/// A normalized sensor reading. Timestamp is UTC Unix seconds.
pub(all) struct Reading {
timestamp : Int64
sensor_id : String
temperature_c : Double
humidity_percent : Double?
battery_percent : Double?
status : String
origin : SampleOrigin
flags : Array[QualityFlag]
source_line : Int?
} derive(Debug, Eq)
///|
/// Construct the smallest valid reading.
pub fn reading(
timestamp : Int64,
sensor_id : String,
temperature_c : Double,
) -> Reading {
{
timestamp,
sensor_id,
temperature_c,
humidity_percent: None,
battery_percent: None,
status: "ok",
origin: Recorded,
flags: [],
source_line: None,
}
}
///|
/// Add a quality flag once while preserving the existing order.
pub fn Reading::add_flag(self : Reading, flag : QualityFlag) -> Reading {
let next = self.flags.copy()
let mut found = false
for existing in next {
if existing == flag {
found = true
}
}
if !found {
next.push(flag)
}
{ ..self, flags: next }
}
///|
/// Whether the reading contains a particular quality flag.
pub fn Reading::has_flag(self : Reading, flag : QualityFlag) -> Bool {
for existing in self.flags {
if existing == flag {
return true
}
}
false
}
///|
/// True when a reading should participate in primary metrics.
pub fn Reading::is_usable(self : Reading) -> Bool {
!self.has_flag(UserExcluded) && !self.has_flag(OutOfPhysicalRange)
}
///|
/// Limits used by the excursion state machine.
pub(all) struct TemperaturePolicy {
lower_c : Double
upper_c : Double
hysteresis_c : Double
grace_seconds : Int64
minimum_event_seconds : Int64
maximum_gap_seconds : Int64
} derive(Debug, Eq)
///|
/// A conservative policy suitable for a 2–8 °C demonstration data set.
pub fn default_temperature_policy() -> TemperaturePolicy {
{
lower_c: 2.0,
upper_c: 8.0,
hysteresis_c: 0.3,
grace_seconds: 300L,
minimum_event_seconds: 120L,
maximum_gap_seconds: 900L,
}
}
///|
/// Validate policy relationships and ranges.
pub fn validate_temperature_policy(
policy : TemperaturePolicy,
) -> Array[Diagnostic] {
let issues : Array[Diagnostic] = []
if policy.lower_c >= policy.upper_c {
issues.push(
error_diagnostic(
"policy.invalid_range", "lower temperature must be below upper temperature",
),
)
}
if policy.hysteresis_c < 0.0 {
issues.push(
error_diagnostic(
"policy.negative_hysteresis", "hysteresis cannot be negative",
),
)
}
if policy.hysteresis_c * 2.0 >= policy.upper_c - policy.lower_c {
issues.push(
error_diagnostic(
"policy.excessive_hysteresis", "hysteresis consumes the accepted temperature band",
),
)
}
if policy.grace_seconds < 0L {
issues.push(
error_diagnostic(
"policy.negative_grace", "grace period cannot be negative",
),
)
}
if policy.minimum_event_seconds < 0L {
issues.push(
error_diagnostic(
"policy.negative_minimum_event", "minimum event duration cannot be negative",
),
)
}
if policy.maximum_gap_seconds <= 0L {
issues.push(
error_diagnostic(
"policy.invalid_gap", "maximum sampling gap must be positive",
),
)
}
issues
}
///|
/// Calibration and physical plausibility settings for one sensor.
pub(all) struct SensorProfile {
sensor_id : String
calibration_offset_c : Double
physical_min_c : Double
physical_max_c : Double
expected_interval_seconds : Int64
stuck_tolerance_c : Double
stuck_minimum_samples : Int
noise_step_c : Double
drift_window_samples : Int
drift_threshold_c : Double
} derive(Debug, Eq)
///|
/// Default profile for common electronic temperature loggers.
pub fn default_sensor_profile(sensor_id : String) -> SensorProfile {
{
sensor_id,
calibration_offset_c: 0.0,
physical_min_c: -100.0,
physical_max_c: 100.0,
expected_interval_seconds: 300L,
stuck_tolerance_c: 0.01,
stuck_minimum_samples: 6,
noise_step_c: 5.0,
drift_window_samples: 6,
drift_threshold_c: 1.5,
}
}
///|
/// Validate one sensor profile.
pub fn validate_sensor_profile(profile : SensorProfile) -> Array[Diagnostic] {
let issues : Array[Diagnostic] = []
if profile.sensor_id.trim().is_empty() {
issues.push(
error_diagnostic("sensor.empty_id", "sensor identifier cannot be empty"),
)
}
if profile.physical_min_c >= profile.physical_max_c {
issues.push(
error_diagnostic(
"sensor.invalid_physical_range", "physical minimum must be below maximum",
).for_sensor(profile.sensor_id),
)
}
if profile.expected_interval_seconds <= 0L {
issues.push(
error_diagnostic(
"sensor.invalid_interval", "expected sample interval must be positive",
).for_sensor(profile.sensor_id),
)
}
if profile.stuck_tolerance_c < 0.0 {
issues.push(
error_diagnostic(
"sensor.invalid_stuck_tolerance", "stuck tolerance cannot be negative",
).for_sensor(profile.sensor_id),
)
}
if profile.stuck_minimum_samples < 2 {
issues.push(
error_diagnostic(
"sensor.invalid_stuck_samples", "stuck detection needs at least two samples",
).for_sensor(profile.sensor_id),
)
}
if profile.noise_step_c <= 0.0 {
issues.push(
error_diagnostic(
"sensor.invalid_noise_step", "noise step threshold must be positive",
).for_sensor(profile.sensor_id),
)
}
if profile.drift_window_samples < 2 {
issues.push(
error_diagnostic(
"sensor.invalid_drift_window", "drift window needs at least two samples",
).for_sensor(profile.sensor_id),
)
}
issues
}
///|
/// Overall analysis behavior.
pub(all) struct AnalysisConfig {
policy : TemperaturePolicy
profiles : Array[SensorProfile]
default_profile : SensorProfile
merge_duplicate_average : Bool
interpolate_short_gaps : Bool
interpolation_limit_seconds : Int64
conflict_threshold_c : Double
include_flagged_in_metrics : Bool
} derive(Debug, Eq)
///|
/// Create a configuration for an arbitrary set of sensor identifiers.
pub fn default_analysis_config() -> AnalysisConfig {
{
policy: default_temperature_policy(),
profiles: [],
default_profile: default_sensor_profile("*"),
merge_duplicate_average: true,
interpolate_short_gaps: false,
interpolation_limit_seconds: 600L,
conflict_threshold_c: 1.5,
include_flagged_in_metrics: false,
}
}
///|
/// Resolve a sensor-specific profile or clone the wildcard defaults.
pub fn AnalysisConfig::profile_for(
self : AnalysisConfig,
sensor_id : String,
) -> SensorProfile {
for profile in self.profiles {
if profile.sensor_id == sensor_id {
return profile
}
}
{ ..self.default_profile, sensor_id, }
}
///|
/// Validate the complete analysis configuration.
pub fn validate_analysis_config(config : AnalysisConfig) -> Array[Diagnostic] {
let issues = validate_temperature_policy(config.policy)
issues.append(validate_sensor_profile(config.default_profile))
for profile in config.profiles {
issues.append(validate_sensor_profile(profile))
}
if config.interpolation_limit_seconds < 0L {
issues.push(
error_diagnostic(
"config.invalid_interpolation_limit", "interpolation limit cannot be negative",
),
)
}
if config.conflict_threshold_c <= 0.0 {
issues.push(
error_diagnostic(
"config.invalid_conflict_threshold", "sensor conflict threshold must be positive",
),
)
}
issues
}
///|
/// Classification of an excursion.
pub(all) enum ExcursionKind {
LowTemperature
HighTemperature
DataGap
SensorFailure
} derive(Debug, Eq)
///|
/// Life-cycle status retained for auditability.
pub(all) enum EventStatus {
Candidate
Confirmed
Suppressed
Closed
} derive(Debug, Eq)
///|
/// One confirmed or suppressed excursion segment.
pub(all) struct ExcursionEvent {
event_id : String
sensor_id : String
kind : ExcursionKind
status : EventStatus
started_at : Int64
ended_at : Int64
duration_seconds : Int64
sample_count : Int
minimum_c : Double?
maximum_c : Double?
mean_c : Double?
degree_seconds : Double
peak_deviation_c : Double
reason : String
} derive(Debug, Eq)
///|
/// Empty event helper used internally by state machines and tests.
pub fn empty_excursion(
event_id : String,
sensor_id : String,
kind : ExcursionKind,
started_at : Int64,
) -> ExcursionEvent {
{
event_id,
sensor_id,
kind,
status: Candidate,
started_at,
ended_at: started_at,
duration_seconds: 0L,
sample_count: 0,
minimum_c: None,
maximum_c: None,
mean_c: None,
degree_seconds: 0.0,
peak_deviation_c: 0.0,
reason: "",
}
}
///|
/// Describes one missing interval in an otherwise ordered sequence.
pub(all) struct SamplingGap {
sensor_id : String
previous_timestamp : Int64
next_timestamp : Int64
duration_seconds : Int64
estimated_missing_samples : Int
} derive(Debug, Eq)
///|
/// Summary statistics for one sensor.
pub(all) struct SensorStatistics {
sensor_id : String
sample_count : Int
usable_count : Int
first_timestamp : Int64?
last_timestamp : Int64?
minimum_c : Double?
maximum_c : Double?
mean_c : Double?
time_weighted_mean_c : Double?
mean_kinetic_temperature_c : Double?
standard_deviation_c : Double?
median_c : Double?
p05_c : Double?
p95_c : Double?
in_range_seconds : Int64
low_seconds : Int64
high_seconds : Int64
unknown_seconds : Int64
low_degree_seconds : Double
high_degree_seconds : Double
} derive(Debug, Eq)
///|
/// One fixed-width time-window aggregate.
pub(all) struct WindowStatistic {
sensor_id : String
started_at : Int64
ended_at : Int64
sample_count : Int
minimum_c : Double?
maximum_c : Double?
mean_c : Double?
low_degree_seconds : Double
high_degree_seconds : Double
} derive(Debug, Eq)
///|
/// Health assessment for a sensor stream.
pub(all) struct SensorHealth {
sensor_id : String
score : Int
stuck_runs : Int
noisy_steps : Int
drift_windows : Int
gaps : Int
conflicts : Int
low_battery_samples : Int
flagged_samples : Int
observations : Array[String]
} derive(Debug, Eq)
///|
/// Overall risk bands are deliberately non-regulatory.
pub(all) enum RiskBand {
Minimal
Low
Moderate
High
Critical
Indeterminate
} derive(Debug, Eq)
///|
/// Explainable risk score with component contributions.
pub(all) struct RiskAssessment {
score : Int
band : RiskBand
temperature_component : Int
duration_component : Int
data_quality_component : Int
sensor_health_component : Int
confidence_percent : Int
reasons : Array[String]
} derive(Debug, Eq)
///|
/// Complete immutable result of one analysis run.
pub(all) struct AnalysisReport {
schema : String
shipment_id : String
generated_at : Int64
source_name : String
sensor_ids : Array[String]
readings : Array[Reading]
gaps : Array[SamplingGap]
events : Array[ExcursionEvent]
statistics : Array[SensorStatistics]
health : Array[SensorHealth]
risk : RiskAssessment
diagnostics : Array[Diagnostic]
} derive(Debug, Eq)
///|
/// Result of parsing or normalizing readings.
pub(all) struct ReadingBatch {
readings : Array[Reading]
diagnostics : Array[Diagnostic]
} derive(Debug, Eq)
///|
/// Create an empty batch.
pub fn empty_reading_batch() -> ReadingBatch {
{ readings: [], diagnostics: [] }
}
///|
/// Count diagnostics at or above error level.
pub fn diagnostics_have_errors(diagnostics : Array[Diagnostic]) -> Bool {
for diagnostic in diagnostics {
if diagnostic.level == Error {
return true
}
}
false
}
///|
/// Count diagnostics of one level.
pub fn count_diagnostics(
diagnostics : Array[Diagnostic],
level : DiagnosticLevel,
) -> Int {
let mut count = 0
for diagnostic in diagnostics {
if diagnostic.level == level {
count = count + 1
}
}
count
}
///|
/// Stable lowercase name for a diagnostic level.
pub fn diagnostic_level_name(level : DiagnosticLevel) -> String {
match level {
Info => "info"
Warning => "warning"
Error => "error"
}
}
///|
/// Stable lowercase name for a quality flag.
pub fn quality_flag_name(flag : QualityFlag) -> String {
match flag {
DuplicateTimestamp => "duplicate_timestamp"
MissingField => "missing_field"
OutOfPhysicalRange => "out_of_physical_range"
GapBefore => "gap_before"
Calibrated => "calibrated"
SensorStuck => "sensor_stuck"
SensorNoisy => "sensor_noisy"
SensorDrifting => "sensor_drifting"
SensorOffline => "sensor_offline"
SensorConflict => "sensor_conflict"
UserExcluded => "user_excluded"
}
}
///|
/// Stable lowercase name for an event kind.
pub fn excursion_kind_name(kind : ExcursionKind) -> String {
match kind {
LowTemperature => "low_temperature"
HighTemperature => "high_temperature"
DataGap => "data_gap"
SensorFailure => "sensor_failure"
}
}
///|
/// Stable lowercase name for a risk band.
pub fn risk_band_name(band : RiskBand) -> String {
match band {
Minimal => "minimal"
Low => "low"
Moderate => "moderate"
High => "high"
Critical => "critical"
Indeterminate => "indeterminate"
}
}
///|
/// Clamp an integer to an inclusive range.
pub fn clamp_int(value : Int, minimum : Int, maximum : Int) -> Int {
if value < minimum {
minimum
} else if value > maximum {
maximum
} else {
value
}
}
///|
/// Clamp a double to an inclusive range.
pub fn clamp_double(
value : Double,
minimum : Double,
maximum : Double,
) -> Double {
if value < minimum {
minimum
} else if value > maximum {
maximum
} else {
value
}
}
///|
/// Absolute value without relying on target-specific math bindings.
pub fn abs_double(value : Double) -> Double {
if value < 0.0 {
-value
} else {
value
}
}
///|
/// Copy an array so APIs do not share mutable array storage accidentally.
pub fn[T] copy_array(source : Array[T]) -> Array[T] {
let result : Array[T] = []
for item in source {
result.push(item)
}
result
}