use crate::data::{DataPoint, DataSeries, StaticDataSeries};
use crate::error::{DataError, DataResult};
#[cfg(not(feature = "std"))]
use micromath::F32Ext;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AggregationStrategy {
Mean,
Median,
MinMax,
First,
Last,
Max,
Min,
}
#[derive(Debug, Clone)]
pub struct AggregationConfig {
pub strategy: AggregationStrategy,
pub target_points: usize,
pub preserve_endpoints: bool,
pub min_group_size: usize,
}
impl Default for AggregationConfig {
fn default() -> Self {
Self {
strategy: AggregationStrategy::Mean,
target_points: 100,
preserve_endpoints: true,
min_group_size: 1,
}
}
}
#[derive(Debug, Clone)]
pub struct DownsamplingConfig {
pub max_points: usize,
pub preserve_endpoints: bool,
pub min_reduction_ratio: f32,
}
impl Default for DownsamplingConfig {
fn default() -> Self {
Self {
max_points: 1000,
preserve_endpoints: true,
min_reduction_ratio: 1.5, }
}
}
#[derive(Debug, Clone)]
pub struct GroupStats<T: DataPoint> {
pub count: usize,
pub min_x: T::X,
pub max_x: T::X,
pub min_y: T::Y,
pub max_y: T::Y,
pub mean_x: T::X,
pub mean_y: T::Y,
pub first: T,
pub last: T,
}
pub trait DataAggregation: DataSeries {
fn aggregate<const N: usize>(
&self,
config: &AggregationConfig,
) -> DataResult<StaticDataSeries<Self::Item, N>>;
fn downsample_lttb<const N: usize>(
&self,
config: &DownsamplingConfig,
) -> DataResult<StaticDataSeries<Self::Item, N>>;
fn downsample_uniform<const N: usize>(
&self,
config: &DownsamplingConfig,
) -> DataResult<StaticDataSeries<Self::Item, N>>;
fn calculate_group_stats(&self, points: &[Self::Item]) -> DataResult<GroupStats<Self::Item>>
where
Self::Item: Clone;
}
impl<T, const M: usize> DataAggregation for StaticDataSeries<T, M>
where
T: DataPoint + Clone + Copy,
T::X: PartialOrd
+ Copy
+ core::ops::Add<Output = T::X>
+ core::ops::Div<f32, Output = T::X>
+ Into<f32>
+ From<f32>,
T::Y: PartialOrd
+ Copy
+ core::ops::Add<Output = T::Y>
+ core::ops::Div<f32, Output = T::Y>
+ Into<f32>
+ From<f32>,
{
fn aggregate<const N: usize>(
&self,
config: &AggregationConfig,
) -> DataResult<StaticDataSeries<T, N>> {
if self.is_empty() {
return Ok(StaticDataSeries::new());
}
if self.len() <= config.target_points {
let mut result = StaticDataSeries::new();
for point in self.iter() {
result.push(point)?;
}
return Ok(result);
}
let mut result = StaticDataSeries::new();
let points = self.as_slice();
#[allow(clippy::manual_div_ceil)] let group_size = (self.len() + config.target_points - 1) / config.target_points;
let group_size = group_size.max(config.min_group_size);
let mut i = 0;
if config.preserve_endpoints && !points.is_empty() {
result.push(points[0])?;
i = 1;
}
while i < points.len() {
let mut end = (i + group_size).min(points.len());
if config.preserve_endpoints && end == points.len() && i + 1 < points.len() {
end = points.len() - 1;
}
if i < end {
let group = &points[i..end];
if !group.is_empty() {
let aggregated_point = self.aggregate_group(group, config.strategy)?;
result.push(aggregated_point)?;
}
}
i = end;
}
if config.preserve_endpoints && points.len() > 1 {
let last_point = points[points.len() - 1];
if result.is_empty() || result.as_slice()[result.len() - 1].x() != last_point.x() {
result.push(last_point)?;
}
}
Ok(result)
}
fn downsample_lttb<const N: usize>(
&self,
config: &DownsamplingConfig,
) -> DataResult<StaticDataSeries<T, N>> {
if self.is_empty() {
return Ok(StaticDataSeries::new());
}
let data_len = self.len();
if data_len <= config.max_points {
let mut result = StaticDataSeries::new();
for point in self.iter() {
result.push(point)?;
}
return Ok(result);
}
let reduction_ratio = data_len as f32 / config.max_points as f32;
if reduction_ratio < config.min_reduction_ratio {
let mut result = StaticDataSeries::new();
for point in self.iter() {
result.push(point)?;
}
return Ok(result);
}
let mut result = StaticDataSeries::new();
let points = self.as_slice();
result.push(points[0])?;
if config.max_points <= 2 {
if config.max_points == 2 && points.len() > 1 {
result.push(points[points.len() - 1])?;
}
return Ok(result);
}
let bucket_size = (data_len - 2) as f32 / (config.max_points - 2) as f32;
let mut bucket_start = 1.0;
for _i in 1..(config.max_points - 1) {
let bucket_end = bucket_start + bucket_size;
#[cfg(feature = "std")]
let start_idx = bucket_start.floor() as usize;
#[cfg(not(feature = "std"))]
let start_idx = bucket_start.floor() as usize;
#[cfg(feature = "std")]
let end_idx = (bucket_end.ceil() as usize).min(data_len - 1);
#[cfg(not(feature = "std"))]
let end_idx = (bucket_end.ceil() as usize).min(data_len - 1);
if start_idx >= end_idx {
continue;
}
let next_bucket_start = bucket_end;
let next_bucket_end = next_bucket_start + bucket_size;
#[cfg(feature = "std")]
let next_start_idx = next_bucket_start.floor() as usize;
#[cfg(not(feature = "std"))]
let next_start_idx = next_bucket_start.floor() as usize;
#[cfg(feature = "std")]
let next_end_idx = (next_bucket_end.ceil() as usize).min(data_len);
#[cfg(not(feature = "std"))]
let next_end_idx = (next_bucket_end.ceil() as usize).min(data_len);
let avg_next = if next_start_idx < next_end_idx && next_end_idx <= data_len {
self.calculate_average_point(&points[next_start_idx..next_end_idx])?
} else {
points[data_len - 1] };
let mut max_area = -1.0;
let mut selected_idx = start_idx;
for (j_offset, j) in (start_idx..end_idx).enumerate() {
let area = self.calculate_triangle_area(
&result.as_slice()[result.len() - 1], &points[j], &avg_next, );
if area > max_area {
max_area = area;
selected_idx = start_idx + j_offset;
}
}
result.push(points[selected_idx])?;
bucket_start = bucket_end;
}
if config.preserve_endpoints && points.len() > 1 {
result.push(points[points.len() - 1])?;
}
Ok(result)
}
fn downsample_uniform<const N: usize>(
&self,
config: &DownsamplingConfig,
) -> DataResult<StaticDataSeries<T, N>> {
if self.is_empty() {
return Ok(StaticDataSeries::new());
}
let data_len = self.len();
if data_len <= config.max_points {
let mut result = StaticDataSeries::new();
for point in self.iter() {
result.push(point)?;
}
return Ok(result);
}
let mut result = StaticDataSeries::new();
let points = self.as_slice();
let step = data_len as f32 / config.max_points as f32;
let mut current: f32 = 0.0;
for _ in 0..config.max_points {
#[cfg(feature = "std")]
let idx = (current.round() as usize).min(data_len - 1);
#[cfg(not(feature = "std"))]
let idx = (current.round() as usize).min(data_len - 1);
result.push(points[idx])?;
current += step;
}
Ok(result)
}
fn calculate_group_stats(&self, points: &[T]) -> DataResult<GroupStats<T>> {
if points.is_empty() {
return Err(DataError::insufficient_data("calculate_group_stats", 1, 0));
}
let first = points[0];
let last = points[points.len() - 1];
let mut min_x = first.x();
let mut max_x = first.x();
let mut min_y = first.y();
let mut max_y = first.y();
let mut sum_x: f32 = first.x().into();
let mut sum_y: f32 = first.y().into();
for point in points.iter().skip(1) {
let x = point.x();
let y = point.y();
if x < min_x {
min_x = x;
}
if x > max_x {
max_x = x;
}
if y < min_y {
min_y = y;
}
if y > max_y {
max_y = y;
}
sum_x += x.into();
sum_y += y.into();
}
let count_f = points.len() as f32;
let mean_x = T::X::from(sum_x / count_f);
let mean_y = T::Y::from(sum_y / count_f);
Ok(GroupStats {
count: points.len(),
min_x,
max_x,
min_y,
max_y,
mean_x,
mean_y,
first,
last,
})
}
}
impl<T, const M: usize> StaticDataSeries<T, M>
where
T: DataPoint + Clone + Copy,
T::X: PartialOrd
+ Copy
+ core::ops::Add<Output = T::X>
+ core::ops::Div<f32, Output = T::X>
+ Into<f32>
+ From<f32>,
T::Y: PartialOrd
+ Copy
+ core::ops::Add<Output = T::Y>
+ core::ops::Div<f32, Output = T::Y>
+ Into<f32>
+ From<f32>,
{
fn aggregate_group(&self, points: &[T], strategy: AggregationStrategy) -> DataResult<T> {
if points.is_empty() {
return Err(DataError::insufficient_data("aggregate_group", 1, 0));
}
match strategy {
AggregationStrategy::Mean => {
let stats = self.calculate_group_stats(points)?;
Ok(T::new(stats.mean_x, stats.mean_y))
}
AggregationStrategy::Median => {
let mut x_coords: heapless::Vec<T::X, 32> = heapless::Vec::new();
let mut y_coords: heapless::Vec<T::Y, 32> = heapless::Vec::new();
for point in points {
let _ = x_coords.push(point.x());
let _ = y_coords.push(point.y());
}
x_coords.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
y_coords.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
let median_x = if x_coords.len() % 2 == 0 {
let mid = x_coords.len() / 2;
let sum: f32 = x_coords[mid - 1].into() + x_coords[mid].into();
T::X::from(sum / 2.0)
} else {
x_coords[x_coords.len() / 2]
};
let median_y = if y_coords.len() % 2 == 0 {
let mid = y_coords.len() / 2;
let sum: f32 = y_coords[mid - 1].into() + y_coords[mid].into();
T::Y::from(sum / 2.0)
} else {
y_coords[y_coords.len() / 2]
};
Ok(T::new(median_x, median_y))
}
AggregationStrategy::MinMax => {
let point_with_max = points
.iter()
.max_by(|a, b| {
a.y()
.partial_cmp(&b.y())
.unwrap_or(core::cmp::Ordering::Equal)
})
.unwrap();
Ok(*point_with_max)
}
AggregationStrategy::First => Ok(points[0]),
AggregationStrategy::Last => Ok(points[points.len() - 1]),
AggregationStrategy::Max => {
let max_point = points
.iter()
.max_by(|a, b| {
a.y()
.partial_cmp(&b.y())
.unwrap_or(core::cmp::Ordering::Equal)
})
.unwrap();
Ok(*max_point)
}
AggregationStrategy::Min => {
let min_point = points
.iter()
.min_by(|a, b| {
a.y()
.partial_cmp(&b.y())
.unwrap_or(core::cmp::Ordering::Equal)
})
.unwrap();
Ok(*min_point)
}
}
}
fn calculate_average_point(&self, points: &[T]) -> DataResult<T> {
if points.is_empty() {
return Err(DataError::insufficient_data(
"calculate_average_point",
1,
0,
));
}
let mut sum_x: f32 = points[0].x().into();
let mut sum_y: f32 = points[0].y().into();
for point in points.iter().skip(1) {
sum_x += point.x().into();
sum_y += point.y().into();
}
let count = points.len() as f32;
let avg_x = T::X::from(sum_x / count);
let avg_y = T::Y::from(sum_y / count);
Ok(T::new(avg_x, avg_y))
}
fn calculate_triangle_area(&self, a: &T, b: &T, c: &T) -> f32 {
let ax: f32 = a.x().into();
let ay: f32 = a.y().into();
let bx: f32 = b.x().into();
let by: f32 = b.y().into();
let cx: f32 = c.x().into();
let cy: f32 = c.y().into();
let det = ax * (by - cy) + bx * (cy - ay) - cx * (ay - by);
det.abs() * 0.5
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data::{Point2D, StaticDataSeries};
#[test]
fn test_aggregation_config_default() {
let config = AggregationConfig::default();
assert_eq!(config.strategy, AggregationStrategy::Mean);
assert_eq!(config.target_points, 100);
assert!(config.preserve_endpoints);
assert_eq!(config.min_group_size, 1);
}
#[test]
fn test_downsampling_config_default() {
let config = DownsamplingConfig::default();
assert_eq!(config.max_points, 1000);
assert!(config.preserve_endpoints);
assert_eq!(config.min_reduction_ratio, 1.5);
}
#[test]
fn test_group_stats_calculation() {
let mut series: StaticDataSeries<Point2D, 256> = StaticDataSeries::new();
series.push(Point2D::new(0.0, 10.0)).unwrap();
series.push(Point2D::new(1.0, 20.0)).unwrap();
series.push(Point2D::new(2.0, 5.0)).unwrap();
let stats = series.calculate_group_stats(series.as_slice()).unwrap();
assert_eq!(stats.count, 3);
assert_eq!(stats.min_x, 0.0);
assert_eq!(stats.max_x, 2.0);
assert_eq!(stats.min_y, 5.0);
assert_eq!(stats.max_y, 20.0);
assert_eq!(stats.first.x(), 0.0);
assert_eq!(stats.last.x(), 2.0);
}
#[test]
fn test_mean_aggregation() {
let mut series: StaticDataSeries<Point2D, 256> = StaticDataSeries::new();
series.push(Point2D::new(0.0, 10.0)).unwrap();
series.push(Point2D::new(1.0, 20.0)).unwrap();
series.push(Point2D::new(2.0, 30.0)).unwrap();
series.push(Point2D::new(3.0, 40.0)).unwrap();
let config = AggregationConfig {
strategy: AggregationStrategy::Mean,
target_points: 2,
preserve_endpoints: false,
min_group_size: 1,
};
let aggregated: StaticDataSeries<Point2D, 256> = series.aggregate(&config).unwrap();
assert_eq!(aggregated.len(), 2);
let first = aggregated.get(0).unwrap();
assert_eq!(first.x(), 0.5);
assert_eq!(first.y(), 15.0);
let second = aggregated.get(1).unwrap();
assert_eq!(second.x(), 2.5);
assert_eq!(second.y(), 35.0);
}
#[test]
fn test_uniform_downsampling() {
let mut series: StaticDataSeries<Point2D, 256> = StaticDataSeries::new();
for i in 0..10 {
series
.push(Point2D::new(i as f32, (i * 10) as f32))
.unwrap();
}
let config = DownsamplingConfig {
max_points: 5,
preserve_endpoints: true,
min_reduction_ratio: 1.0,
};
let downsampled: StaticDataSeries<Point2D, 256> =
series.downsample_uniform(&config).unwrap();
assert_eq!(downsampled.len(), 5);
}
#[test]
fn test_no_aggregation_when_not_needed() {
let mut series: StaticDataSeries<Point2D, 256> = StaticDataSeries::new();
series.push(Point2D::new(0.0, 10.0)).unwrap();
series.push(Point2D::new(1.0, 20.0)).unwrap();
let config = AggregationConfig {
target_points: 5, ..Default::default()
};
let aggregated: StaticDataSeries<Point2D, 256> = series.aggregate(&config).unwrap();
assert_eq!(aggregated.len(), 2); assert_eq!(aggregated.get(0).unwrap().x(), 0.0);
assert_eq!(aggregated.get(1).unwrap().x(), 1.0);
}
}