///|
/// Time-series utilities for sampled process measurements.
pub(all) struct TimePoint {
timestamp : Double
value : Double
quality : DataQuality
} derive(Debug, Eq, ToJson)
///|
pub(all) struct TimeSeries {
name : String
unit : String
points : Array[TimePoint]
sampling_interval : Double
} derive(Debug, Eq, ToJson)
///|
pub(all) struct TrendResult {
slope : Double
intercept : Double
r_squared : Double
first_value : Double
last_value : Double
direction : String
} derive(Debug, Eq, ToJson)
///|
pub(all) struct WindowSummary {
start : Double
end : Double
count : Int
mean : Double
minimum : Double
maximum : Double
standard_deviation : Double
} derive(Debug, Eq, ToJson)
///|
pub fn TimeSeries::new(
name~ : String,
unit~ : String,
timestamps : ArrayView[Double],
values : ArrayView[Double],
interval? : Double = 0.0,
) -> TimeSeries raise @domain.ExtractionError {
if timestamps.length() != values.length() || timestamps.length() == 0 {
raise @domain.ExtractionError::InvalidArgument(
message="time series arrays are invalid",
)
}
let points = []
let mut previous = timestamps[0]
for index in 0.. 0 && timestamps[index] <= previous {
raise @domain.ExtractionError::UnsortedData(index~)
}
previous = timestamps[index]
points.push({
timestamp: timestamps[index],
value: values[index],
quality: Accepted,
})
}
let sampling = if interval > 0.0 {
interval
} else if points.length() > 1 {
(points[points.length() - 1].timestamp - points[0].timestamp) /
(points.length() - 1).to_double()
} else {
0.0
}
{ name, unit, points, sampling_interval: sampling }
}
///|
pub fn TimeSeries::timestamps(self : TimeSeries) -> Array[Double] {
[
for point in self.points => point.timestamp
]
}
///|
pub fn TimeSeries::values(self : TimeSeries) -> Array[Double] {
[
for point in self.points => point.value
]
}
///|
pub fn TimeSeries::mark_quality(
self : TimeSeries,
predicate : (Double) -> DataQuality,
) -> TimeSeries {
let points = [
for point in self.points => {
{
timestamp: point.timestamp,
value: point.value,
quality: predicate(point.value),
}
}
]
{
name: self.name,
unit: self.unit,
points,
sampling_interval: self.sampling_interval,
}
}
///|
pub fn TimeSeries::valid_points(self : TimeSeries) -> Array[TimePoint] {
let points = []
for point in self.points {
if point.quality == Accepted {
points.push(point)
}
}
points
}
///|
pub fn TimeSeries::slice(
self : TimeSeries,
start : Double,
end : Double,
) -> TimeSeries raise @domain.ExtractionError {
if start > end {
raise @domain.ExtractionError::InvalidArgument(
message="time slice is reversed",
)
}
let points = []
for point in self.points {
if point.timestamp >= start && point.timestamp <= end {
points.push(point)
}
}
if points.length() == 0 {
raise @domain.ExtractionError::InsufficientData(required=1, actual=0)
}
{
name: self.name,
unit: self.unit,
points,
sampling_interval: self.sampling_interval,
}
}
///|
pub fn TimeSeries::mean(self : TimeSeries) -> Double {
let points = self.valid_points()
if points.length() == 0 {
return 0.0
}
let mut total = 0.0
for point in points {
total += point.value
}
total / points.length().to_double()
}
///|
pub fn TimeSeries::minimum(self : TimeSeries) -> Double {
let points = self.valid_points()
if points.length() == 0 {
return 0.0
}
let mut result = points[0].value
for point in points {
result = result.min(point.value)
}
result
}
///|
pub fn TimeSeries::maximum(self : TimeSeries) -> Double {
let points = self.valid_points()
if points.length() == 0 {
return 0.0
}
let mut result = points[0].value
for point in points {
result = result.max(point.value)
}
result
}
///|
pub fn TimeSeries::variance(self : TimeSeries) -> Double {
let points = self.valid_points()
if points.length() == 0 {
return 0.0
}
let average = self.mean()
let mut total = 0.0
for point in points {
let difference = point.value - average
total += difference * difference
}
total / points.length().to_double()
}
///|
pub fn TimeSeries::standard_deviation(self : TimeSeries) -> Double {
self.variance().sqrt()
}
///|
pub fn TimeSeries::linear_trend(self : TimeSeries) -> TrendResult {
let points = self.valid_points()
if points.length() == 0 {
return {
slope: 0.0,
intercept: 0.0,
r_squared: 0.0,
first_value: 0.0,
last_value: 0.0,
direction: "flat",
}
}
let mean_x = points.fold(init=0.0, (sum, point) => sum + point.timestamp) /
points.length().to_double()
let mean_y = points.fold(init=0.0, (sum, point) => sum + point.value) /
points.length().to_double()
let mut covariance = 0.0
let mut variance_x = 0.0
let mut variance_y = 0.0
for point in points {
let dx = point.timestamp - mean_x
let dy = point.value - mean_y
covariance += dx * dy
variance_x += dx * dx
variance_y += dy * dy
}
let slope = if variance_x == 0.0 { 0.0 } else { covariance / variance_x }
let intercept = mean_y - slope * mean_x
let correlation = if variance_x == 0.0 || variance_y == 0.0 {
0.0
} else {
covariance / (variance_x * variance_y).sqrt()
}
{
slope,
intercept,
r_squared: correlation * correlation,
first_value: points[0].value,
last_value: points[points.length() - 1].value,
direction: if slope > 1.0e-12 {
"increasing"
} else if slope < -1.0e-12 {
"decreasing"
} else {
"flat"
},
}
}
///|
pub fn moving_average(
series : TimeSeries,
window : Int,
) -> TimeSeries raise @domain.ExtractionError {
if window < 1 || window > series.points.length() {
raise @domain.ExtractionError::InvalidArgument(
message="moving-average window is invalid",
)
}
let points = []
for index in 0.. TimeSeries raise @domain.ExtractionError {
if window < 1 || window > series.points.length() {
raise @domain.ExtractionError::InvalidArgument(
message="moving-sum window is invalid",
)
}
let points = []
for index in 0.. Array[WindowSummary] raise @domain.ExtractionError {
if window < 1 || window > series.points.length() {
raise @domain.ExtractionError::InvalidArgument(
message="rolling-summary window is invalid",
)
}
let summaries = []
for index in 0.. Array[TimePoint] {
let changes = []
for index in 1..= threshold {
changes.push(current)
}
}
changes
}
///|
pub fn detect_spikes(
series : TimeSeries,
threshold : Double,
) -> Array[TimePoint] {
let spikes = []
let average = series.mean()
let deviation = series.standard_deviation()
for point in series.points {
if deviation > 0.0 && (point.value - average).abs() >= threshold * deviation {
spikes.push(point)
}
}
spikes
}
///|
pub fn autocorrelation(
series : TimeSeries,
lag : Int,
) -> Double raise @domain.ExtractionError {
let points = series.valid_points()
if lag < 0 || lag >= points.length() {
raise @domain.ExtractionError::OutOfRange(
name="lag",
value=lag.to_double(),
lower=0.0,
upper=(points.length() - 1).to_double(),
)
}
let average = series.mean()
let mut numerator = 0.0
let mut denominator = 0.0
for index in 0.. TimeSeries raise @domain.ExtractionError {
if interval <= 0.0 || series.points.length() == 0 {
raise @domain.ExtractionError::InvalidArgument(
message="resampling interval is invalid",
)
}
let start = series.points[0].timestamp
let end = series.points[series.points.length() - 1].timestamp
let count = ((end - start) / interval).to_int() + 1
let points = []
for index in 0.. String {
let lines = ["timestamp,value,quality"]
for point in series.points {
let quality = match point.quality {
Accepted => "accepted"
Suspect(reason~) => "suspect:" + reason
Rejected(reason~) => "rejected:" + reason
}
lines.push(
point.timestamp.to_string() +
"," +
point.value.to_string() +
"," +
quality,
)
}
lines.join("\n")
}
///|
pub fn time_series_markdown(series : TimeSeries) -> String {
let trend = series.linear_trend()
let lines = [
"# " + series.name,
"",
"Unit: " + series.unit,
"Sampling interval: " + series.sampling_interval.to_string(),
"Trend: " + trend.direction,
"",
"| Time | Value | Quality |",
"|---:|---:|---|",
]
for point in series.points {
let quality = match point.quality {
Accepted => "accepted"
Suspect(reason~) => "suspect:" + reason
Rejected(reason~) => "rejected:" + reason
}
lines.push(
"| " +
point.timestamp.to_string() +
" | " +
point.value.to_string() +
" | " +
quality +
" |",
)
}
lines.join("\n")
}