use crate::preprocess::clock_drift::{ClockDriftDetector, ClockDriftStrategy, DriftReport};
use crate::preprocess::interpolation::Interpolator;
use crate::preprocess::resampling::{ResampleConfig, Resampler};
use crate::preprocess::smoothing::Smoother;
#[derive(Debug, Clone)]
pub struct PreprocessResult {
pub values: Vec<f64>,
pub timestamps: Vec<i64>,
pub gaps_filled: usize,
pub drift_report: Option<DriftReport>,
}
#[derive(Debug, thiserror::Error)]
pub enum PreprocessError {
#[error("expected interval must be positive, got {0}")]
InvalidInterval(i64),
#[error("timestamps and values must have equal length: ts={ts_len}, vals={vals_len}")]
LengthMismatch {
ts_len: usize,
vals_len: usize,
},
#[error("invalid smoother alpha: must be in (0, 1], got {0}")]
InvalidAlpha(f64),
#[error("invalid smoother window: must be > 0")]
InvalidWindow,
#[error(transparent)]
Interpolation(#[from] crate::preprocess::interpolation::InterpolationError),
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PreprocessConfig {
pub expected_interval_ns: i64,
#[serde(default = "default_tolerance")]
pub gap_tolerance: f64,
pub interpolation: Option<Interpolator>,
pub smoothing: Option<Smoother>,
pub resample: Option<ResampleConfig>,
#[serde(default)]
pub clock_drift: ClockDriftStrategy,
}
fn default_tolerance() -> f64 {
1.5
}
impl Default for PreprocessConfig {
fn default() -> Self {
Self {
expected_interval_ns: 1_000_000_000, gap_tolerance: 1.5,
interpolation: None,
smoothing: None,
resample: None,
clock_drift: ClockDriftStrategy::None,
}
}
}
pub struct PreprocessPipeline;
impl PreprocessPipeline {
pub fn run(
config: &PreprocessConfig,
timestamps: &[i64],
values: &[f64],
) -> Result<PreprocessResult, PreprocessError> {
let _start = std::time::Instant::now();
if timestamps.len() != values.len() {
return Err(PreprocessError::LengthMismatch {
ts_len: timestamps.len(),
vals_len: values.len(),
});
}
if config.expected_interval_ns <= 0 {
return Err(PreprocessError::InvalidInterval(
config.expected_interval_ns,
));
}
if let Some(ref smoother) = config.smoothing {
match smoother {
Smoother::Exponential { alpha } => {
if *alpha <= 0.0 || *alpha > 1.0 {
return Err(PreprocessError::InvalidAlpha(*alpha));
}
}
Smoother::MovingAverage { window } => {
if *window == 0 {
return Err(PreprocessError::InvalidWindow);
}
}
Smoother::WeightedMovingAverage { weights } => {
if weights.is_empty() {
return Err(PreprocessError::InvalidWindow);
}
}
}
}
let mut current_ts = timestamps.to_vec();
let mut current_vals = values.to_vec();
let mut gaps_filled = 0usize;
let mut drift_report = None;
if config.clock_drift != ClockDriftStrategy::None {
let (corrected_ts, report) = ClockDriftDetector::correct(
¤t_ts,
config.expected_interval_ns,
config.clock_drift,
);
current_ts = corrected_ts;
drift_report = Some(report);
}
if let Some(ref interpolator) = config.interpolation {
let (new_ts, new_vals, filled, skipped) =
interpolator.fill(¤t_ts, ¤t_vals, config.expected_interval_ns)?;
current_ts = new_ts;
current_vals = new_vals;
gaps_filled = filled;
if skipped > 0 {
tracing::warn!(gaps_skipped = skipped, "some gaps were too large to fill");
}
}
if let Some(ref smoother) = config.smoothing {
current_vals = smoother.smooth(¤t_vals);
}
if let Some(ref resample_config) = config.resample {
let (new_ts, new_vals) =
Resampler::resample(¤t_ts, ¤t_vals, resample_config);
current_ts = new_ts;
current_vals = new_vals;
}
metrics::histogram!("chronix_preprocess_duration_seconds")
.record(_start.elapsed().as_secs_f64());
Ok(PreprocessResult {
values: current_vals,
timestamps: current_ts,
gaps_filled,
drift_report,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_ts(secs: &[i64]) -> Vec<i64> {
secs.iter().map(|&s| s * 1_000_000_000).collect()
}
#[test]
fn empty_config_passthrough() {
let ts = make_ts(&[0, 1, 2, 3]);
let vals = vec![1.0, 2.0, 3.0, 4.0];
let config = PreprocessConfig::default();
let result = PreprocessPipeline::run(&config, &ts, &vals).unwrap();
assert_eq!(result.timestamps, ts);
assert_eq!(result.values, vals);
assert_eq!(result.gaps_filled, 0);
assert!(result.drift_report.is_none());
}
#[test]
fn interpolation_only() {
let ts = make_ts(&[0, 1, 4, 5]); let vals = vec![10.0, 20.0, 50.0, 60.0];
let config = PreprocessConfig {
expected_interval_ns: 1_000_000_000,
interpolation: Some(Interpolator::Linear),
..Default::default()
};
let result = PreprocessPipeline::run(&config, &ts, &vals).unwrap();
assert_eq!(result.gaps_filled, 2);
assert_eq!(result.timestamps.len(), 6);
}
#[test]
fn smoothing_only() {
let ts = make_ts(&[0, 1, 2, 3, 4]);
let vals = vec![10.0, 20.0, 15.0, 25.0, 20.0];
let config = PreprocessConfig {
expected_interval_ns: 1_000_000_000,
smoothing: Some(Smoother::Exponential { alpha: 0.5 }),
..Default::default()
};
let result = PreprocessPipeline::run(&config, &ts, &vals).unwrap();
assert_eq!(result.values.len(), 5);
assert_eq!(result.gaps_filled, 0);
}
#[test]
fn full_pipeline() {
let ts = make_ts(&[0, 1, 4, 5, 6]); let vals = vec![10.0, 20.0, 50.0, 60.0, 70.0];
let config = PreprocessConfig {
expected_interval_ns: 1_000_000_000,
interpolation: Some(Interpolator::Linear),
smoothing: Some(Smoother::MovingAverage { window: 3 }),
..Default::default()
};
let result = PreprocessPipeline::run(&config, &ts, &vals).unwrap();
assert!(result.gaps_filled > 0);
assert!(result.values.len() > vals.len());
}
#[test]
fn validation_length_mismatch() {
let ts = make_ts(&[0, 1, 2]);
let vals = vec![1.0, 2.0];
let config = PreprocessConfig::default();
assert!(PreprocessPipeline::run(&config, &ts, &vals).is_err());
}
#[test]
fn validation_invalid_interval() {
let ts = make_ts(&[0, 1]);
let vals = vec![1.0, 2.0];
let config = PreprocessConfig {
expected_interval_ns: -1,
..Default::default()
};
assert!(PreprocessPipeline::run(&config, &ts, &vals).is_err());
}
#[test]
fn validation_invalid_alpha() {
let ts = make_ts(&[0, 1]);
let vals = vec![1.0, 2.0];
let config = PreprocessConfig {
expected_interval_ns: 1_000_000_000,
smoothing: Some(Smoother::Exponential { alpha: 0.0 }),
..Default::default()
};
assert!(PreprocessPipeline::run(&config, &ts, &vals).is_err());
}
#[test]
fn with_clock_drift_correction() {
let ts = vec![0, 100, 50, 200, 300]; let vals = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let config = PreprocessConfig {
expected_interval_ns: 100,
clock_drift: ClockDriftStrategy::Monotonic,
..Default::default()
};
let result = PreprocessPipeline::run(&config, &ts, &vals).unwrap();
assert!(result.drift_report.is_some());
let report = result.drift_report.unwrap();
assert!(report.corrections_applied > 0);
for i in 1..result.timestamps.len() {
assert!(result.timestamps[i] > result.timestamps[i - 1]);
}
}
}