use arrow::array;
use super::Chronix;
use crate::analytics::{AnomalyConfig, ForecastConfig};
use crate::error::{DbError, Result};
impl Chronix {
pub fn forecast(
&self,
measurement: &str,
field: &str,
tags: &[(&str, &str)],
start_ns: i64,
end_ns: i64,
horizon: usize,
config: Option<ForecastConfig>,
) -> Result<chronix_analytics::forecast::ForecastResult> {
use chronix_analytics::forecast::{
ArimaModel, ForecastModel, HoltLinearModel, HoltWintersModel, LinearRegressionModel,
SarimaModel, SesModel,
};
let cfg = config.unwrap_or_default();
let (timestamps, values) =
self.fetch_series_data(measurement, field, tags, start_ns, end_ns)?;
if values.len() < 2 {
return Err(DbError::Internal(
"forecast: need at least 2 data points".into(),
));
}
let mut model: Box<dyn ForecastModel> = match cfg.model.as_deref() {
Some("ses") | None => Box::new(SesModel::new(None)),
Some("holt") => Box::new(HoltLinearModel::new(None, None, 0.98)),
Some("holt_winters") => {
Box::new(HoltWintersModel::new(None, None, None, cfg.period, false))
}
Some("arima") => {
let (p, d, q) = cfg.arima_order.unwrap_or((1, 1, 1));
Box::new(ArimaModel::new(p, d, q))
}
Some("sarima") => {
let (p, d, q) = cfg.arima_order.unwrap_or((1, 1, 1));
let (sp, sd, sq, m) =
cfg.sarima_order
.unwrap_or((1, 1, 1, cfg.period.unwrap_or(24)));
Box::new(SarimaModel::new(p, d, q, sp, sd, sq, m))
}
Some("linear_regression") => Box::new(LinearRegressionModel::new()),
Some(name) => match chronix_analytics::registry::global_registry()
.try_create_model_default(name)
{
Some(Ok(model)) => model,
Some(Err(e)) => return Err(DbError::Internal(e.to_string())),
None => {
return Err(DbError::Internal(format!(
"forecast: unknown model {name:?}; expected one of \"ses\", \"holt\", \
\"holt_winters\", \"arima\", \"sarima\", \"linear_regression\", \
or a name registered with the model registry"
)))
}
},
};
model
.fit(×tamps, &values)
.map_err(|e| DbError::Internal(e.to_string()))?;
let result = model
.predict(horizon)
.map_err(|e| DbError::Internal(e.to_string()))?;
result
.with_confidence(cfg.confidence)
.map_err(|e| DbError::Internal(e.to_string()))
}
pub fn auto_forecast(
&self,
measurement: &str,
field: &str,
tags: &[(&str, &str)],
start_ns: i64,
end_ns: i64,
horizon: usize,
options: Option<chronix_analytics::forecast::AutoForecastOptions>,
) -> Result<chronix_analytics::forecast::AutoForecast> {
let (timestamps, values) =
self.fetch_series_data(measurement, field, tags, start_ns, end_ns)?;
chronix_analytics::forecast::auto_forecast(
×tamps,
&values,
horizon,
&options.unwrap_or_default(),
)
.map_err(|e| DbError::Internal(e.to_string()))
}
pub fn detect_anomalies(
&self,
measurement: &str,
field: &str,
tags: &[(&str, &str)],
start_ns: i64,
end_ns: i64,
config: Option<AnomalyConfig>,
) -> Result<Vec<chronix_analytics::anomaly::AnomalyScore>> {
use chronix_analytics::anomaly::{
AnomalyDetector, DynamicThresholdDetector, ForecastResidualDetector, IqrDetector,
ModifiedZScoreDetector, MovingAverageResidualDetector, ZScoreDetector,
};
let cfg = config.unwrap_or_default();
let (timestamps, values) =
self.fetch_series_data(measurement, field, tags, start_ns, end_ns)?;
if values.len() < 3 {
return Err(DbError::Internal(
"detect_anomalies: need at least 3 data points".into(),
));
}
let mut detector: Box<dyn AnomalyDetector> = match cfg.method.as_deref() {
Some("iqr") => Box::new(IqrDetector::new(Some(cfg.threshold))),
Some("modified_zscore") => Box::new(ModifiedZScoreDetector::new(Some(cfg.threshold))),
Some("dynamic_threshold") => Box::new(DynamicThresholdDetector::new(
cfg.window_size,
Some(cfg.threshold),
)),
Some("forecast_residual") => {
Box::new(ForecastResidualDetector::new(Some(cfg.threshold), None))
}
Some("moving_average") => Box::new(MovingAverageResidualDetector::new(
cfg.window_size,
Some(cfg.threshold),
)),
Some(name) => match chronix_analytics::registry::global_registry()
.try_create_detector_default(name)
{
Some(Ok(det)) => det,
Some(Err(e)) => return Err(DbError::Internal(e.to_string())),
None => Box::new(ZScoreDetector::new(Some(cfg.threshold))),
},
None => Box::new(ZScoreDetector::new(Some(cfg.threshold))),
};
detector
.fit(×tamps, &values)
.map_err(|e| DbError::Internal(e.to_string()))?;
detector
.detect(×tamps, &values)
.map_err(|e| DbError::Internal(e.to_string()))
}
#[allow(clippy::needless_pass_by_value)] pub fn preprocess(
&self,
measurement: &str,
field: &str,
tags: &[(&str, &str)],
start_ns: i64,
end_ns: i64,
config: chronix_analytics::preprocess::PreprocessConfig,
) -> Result<chronix_analytics::preprocess::PreprocessResult> {
let (timestamps, values) =
self.fetch_series_data(measurement, field, tags, start_ns, end_ns)?;
chronix_analytics::preprocess::PreprocessPipeline::run(&config, ×tamps, &values)
.map_err(|e| DbError::Internal(e.to_string()))
}
pub fn fetch_series_data(
&self,
measurement: &str,
field: &str,
tags: &[(&str, &str)],
start_ns: i64,
end_ns: i64,
) -> Result<(Vec<i64>, Vec<f64>)> {
let mut builder = self.query().measurement(measurement);
for &(k, v) in tags {
builder = builder.tag(k, v);
}
builder = builder.range(start_ns, end_ns);
let plan = builder.build()?;
let batches = self.execute_stream(&plan)?;
let mut timestamps = Vec::new();
let mut values = Vec::new();
for batch in &batches {
let schema = batch.schema();
let ts_idx = schema
.fields()
.iter()
.position(|f| f.name() == chronix_core::TIME_COLUMN);
let field_idx = schema.index_of(field).ok();
if let (Some(ts_idx), Some(field_idx)) = (ts_idx, field_idx) {
let ts_arr = batch.column(ts_idx);
let val_arr = batch.column(field_idx);
if let Some(ts_i64) = ts_arr.as_any().downcast_ref::<array::Int64Array>() {
for row in 0..batch.num_rows() {
if val_arr.is_null(row) {
continue;
}
let v = if let Some(a) =
val_arr.as_any().downcast_ref::<array::Float64Array>()
{
a.value(row)
} else if let Some(a) = val_arr.as_any().downcast_ref::<array::Int64Array>()
{
a.value(row) as f64
} else if let Some(a) =
val_arr.as_any().downcast_ref::<array::UInt64Array>()
{
a.value(row) as f64
} else {
continue;
};
timestamps.push(ts_i64.value(row));
values.push(v);
}
}
}
}
Ok((timestamps, values))
}
}