1use serde::Serialize;
4
5use super::summary::{self, Batch};
6
7const MIN_SAMPLES_FOR_STD_DEV: usize = 2;
8const PERCENTILE_25: usize = 25;
9const PERCENTILE_50: usize = 50;
10const PERCENTILE_75: usize = 75;
11const PERCENTILE_99: usize = 99;
12const SMA_WINDOW: usize = 5;
13const MIN_POINTS_FOR_REGRESSION: usize = 2;
14const HALF_WINDOW_DIVISOR: usize = 2;
15
16#[derive(Clone, Debug, Serialize)]
18#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
19#[cfg_attr(feature = "wasm", tsify(into_wasm_abi))]
20pub struct ChartOverlayView {
21 pub mean: f64,
22 pub std_dev: f64,
23 pub p25: f64,
24 pub p50: f64,
25 pub p75: f64,
26 pub p99: f64,
27 pub moving_average: Vec<f64>,
28 pub trendline_start: f64,
29 pub trendline_end: f64,
30 pub trendline_r_squared: f64,
31}
32
33#[must_use]
35#[expect(
36 clippy::cast_precision_loss,
37 reason = "sample indexes are converted to f64 regression coordinates"
38)]
39pub fn compute_chart_overlay(y_values: &[f64]) -> ChartOverlayView {
40 let n = y_values.len();
41
42 if n == 0 {
43 return ChartOverlayView {
44 mean: 0.0,
45 std_dev: 0.0,
46 p25: 0.0,
47 p50: 0.0,
48 p75: 0.0,
49 p99: 0.0,
50 moving_average: vec![],
51 trendline_start: 0.0,
52 trendline_end: 0.0,
53 trendline_r_squared: 0.0,
54 };
55 }
56
57 let mut batch = Batch::new(y_values.to_vec());
58 let mean = batch.mean();
59 let std_dev = if n < MIN_SAMPLES_FOR_STD_DEV {
60 0.0
61 } else {
62 batch.std_dev()
63 };
64 let p25 = batch.percentile(PERCENTILE_25);
65 let p50 = batch.percentile(PERCENTILE_50);
66 let p75 = batch.percentile(PERCENTILE_75);
67 let p99 = batch.percentile(PERCENTILE_99);
68
69 let moving_average = centered_sma(y_values, SMA_WINDOW);
70
71 let (trendline_start, trendline_end, trendline_r_squared) = if n < MIN_POINTS_FOR_REGRESSION {
72 (mean, mean, 0.0)
73 } else {
74 let x: Vec<f64> = (0..n).map(|i| i as f64).collect();
75
76 match summary::linear_regression(&x, y_values) {
77 Some(lr) => {
78 let start_y = lr.intercept;
79 let end_y = lr.slope * (n - 1) as f64 + lr.intercept;
80
81 (start_y, end_y, lr.r_squared)
82 }
83 None => (mean, mean, 0.0),
84 }
85 };
86
87 ChartOverlayView {
88 mean,
89 std_dev,
90 p25,
91 p50,
92 p75,
93 p99,
94 moving_average,
95 trendline_start,
96 trendline_end,
97 trendline_r_squared,
98 }
99}
100
101#[derive(Clone, Debug, Serialize)]
103#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
104#[cfg_attr(feature = "wasm", tsify(into_wasm_abi))]
105pub struct ScatterOverlayView {
106 pub mean_x: f64,
107 pub mean_y: f64,
108 pub x_min: f64,
109 pub x_max: f64,
110 pub slope: f64,
111 pub intercept: f64,
112 pub r_squared: f64,
113 pub valid: bool,
114}
115
116const EMPTY_SCATTER: ScatterOverlayView = ScatterOverlayView {
117 mean_x: 0.0,
118 mean_y: 0.0,
119 x_min: 0.0,
120 x_max: 0.0,
121 slope: 0.0,
122 intercept: 0.0,
123 r_squared: 0.0,
124 valid: false,
125};
126
127#[must_use]
129#[expect(
130 clippy::cast_precision_loss,
131 reason = "the finite sample count is converted to the f64 statistics domain"
132)]
133pub fn compute_scatter_overlay(xs: &[f64], ys: &[f64]) -> ScatterOverlayView {
134 let n = xs.len().min(ys.len());
135
136 if n < MIN_POINTS_FOR_REGRESSION {
137 return EMPTY_SCATTER;
138 }
139
140 let mut clean_x: Vec<f64> = Vec::with_capacity(n);
141 let mut clean_y: Vec<f64> = Vec::with_capacity(n);
142 let mut x_min = f64::INFINITY;
143 let mut x_max = f64::NEG_INFINITY;
144 let mut sum_x = 0.0;
145 let mut sum_y = 0.0;
146
147 for (&x, &y) in xs.iter().zip(ys.iter()) {
148 if !x.is_finite() || !y.is_finite() {
149 continue;
150 }
151
152 clean_x.push(x);
153 clean_y.push(y);
154
155 if x < x_min {
156 x_min = x;
157 }
158
159 if x > x_max {
160 x_max = x;
161 }
162
163 sum_x += x;
164 sum_y += y;
165 }
166
167 if clean_x.len() < MIN_POINTS_FOR_REGRESSION {
168 return EMPTY_SCATTER;
169 }
170
171 let count = clean_x.len() as f64;
172 let mean_x = sum_x / count;
173 let mean_y = sum_y / count;
174
175 match summary::linear_regression(&clean_x, &clean_y) {
176 Some(lr) => ScatterOverlayView {
177 mean_x,
178 mean_y,
179 x_min,
180 x_max,
181 slope: lr.slope,
182 intercept: lr.intercept,
183 r_squared: lr.r_squared,
184 valid: true,
185 },
186 None => ScatterOverlayView {
187 mean_x,
188 mean_y,
189 x_min,
190 x_max,
191 slope: 0.0,
192 intercept: mean_y,
193 r_squared: 0.0,
194 valid: false,
195 },
196 }
197}
198
199#[expect(
200 clippy::cast_precision_loss,
201 reason = "the bounded window length is converted to the f64 statistics domain"
202)]
203fn centered_sma(data: &[f64], window: usize) -> Vec<f64> {
204 let n = data.len();
205 let half = window / HALF_WINDOW_DIVISOR;
206
207 (0..n)
208 .map(|i| {
209 let start = i.saturating_sub(half);
210 let end = (i + half).min(n - 1);
211 let slice = data.get(start..=end).unwrap_or(&[]);
212 let count = slice.len();
213 let sum: f64 = slice.iter().sum();
214
215 if count == 0 { 0.0 } else { sum / count as f64 }
216 })
217 .collect()
218}
219
220#[cfg(test)]
221mod tests {
222 use googletest::prelude::*;
223
224 use super::*;
225
226 const TOL: f64 = 1e-10;
227
228 #[gtest]
229 fn empty_input() -> Result<()> {
230 let view = compute_chart_overlay(&[]);
231
232 wowlab_test_support::verify_all!(
233 view.mean => near(0.0, TOL),
234 view.std_dev => near(0.0, TOL),
235 view.moving_average => is_empty(),
236 )
237 }
238
239 #[gtest]
240 fn single_value() -> Result<()> {
241 let view = compute_chart_overlay(&[42.0]);
242
243 wowlab_test_support::verify_all!(
244 view.mean => near(42.0, TOL),
245 view.std_dev => near(0.0, TOL),
246 view.p25 => near(42.0, TOL),
247 view.p50 => near(42.0, TOL),
248 view.p75 => near(42.0, TOL),
249 view.p99 => near(42.0, TOL),
250 view.moving_average.as_slice() => elements_are![near(42.0, TOL)],
251 )
252 }
253
254 #[gtest]
255 fn known_statistics() -> Result<()> {
256 let values = vec![1.0, 2.0, 3.0, 4.0, 5.0];
257 let view = compute_chart_overlay(&values);
258
259 wowlab_test_support::verify_all!(
260 view.mean => near(3.0, TOL),
261 view.std_dev => near(2.5_f64.sqrt(), TOL),
262 view.moving_average => len(eq(5)),
263 )
264 }
265
266 #[gtest]
267 fn percentile_interpolation() -> Result<()> {
268 let values: Vec<f64> = (1..=10).map(f64::from).collect();
269 let view = compute_chart_overlay(&values);
270
271 verify_that!(view.p50, near(5.5, TOL))
272 }
273
274 #[gtest]
275 fn perfect_trendline() -> Result<()> {
276 let values = vec![1.0, 3.0, 5.0, 7.0, 9.0];
277 let view = compute_chart_overlay(&values);
278
279 wowlab_test_support::verify_all!(
280 view.trendline_start => near(1.0, TOL),
281 view.trendline_end => near(9.0, TOL),
282 view.trendline_r_squared => near(1.0, TOL),
283 )
284 }
285
286 #[gtest]
287 fn scatter_perfect_fit() -> Result<()> {
288 let xs = vec![1.0, 2.0, 3.0, 4.0, 5.0];
289 let ys: Vec<f64> = xs.iter().map(|x| 2.0 * x + 1.0).collect();
290 let view = compute_scatter_overlay(&xs, &ys);
291
292 wowlab_test_support::verify_all!(
293 view.valid => eq(true),
294 view.slope => near(2.0, TOL),
295 view.intercept => near(1.0, TOL),
296 view.r_squared => near(1.0, TOL),
297 view.mean_x => near(3.0, TOL),
298 view.mean_y => near(7.0, TOL),
299 view.x_min => near(1.0, TOL),
300 view.x_max => near(5.0, TOL),
301 )
302 }
303
304 #[gtest]
305 fn scatter_empty_is_invalid() -> Result<()> {
306 let view = compute_scatter_overlay(&[], &[]);
307
308 wowlab_test_support::verify_all!(
309 view.valid => eq(false),
310 view.slope => near(0.0, TOL),
311 )
312 }
313
314 #[gtest]
315 fn scatter_zero_variance_is_invalid() -> Result<()> {
316 let xs = vec![3.0, 3.0, 3.0, 3.0];
317 let ys = vec![1.0, 2.0, 3.0, 4.0];
318 let view = compute_scatter_overlay(&xs, &ys);
319
320 wowlab_test_support::verify_all!(
321 view.valid => eq(false),
322 view.mean_x => near(3.0, TOL),
323 view.mean_y => near(2.5, TOL),
324 )
325 }
326
327 #[gtest]
328 fn moving_average_centered() -> Result<()> {
329 let values = vec![10.0, 20.0, 30.0, 40.0, 50.0];
330 let view = compute_chart_overlay(&values);
331
332 verify_that!(
333 view.moving_average.as_slice(),
334 elements_are![
335 near(20.0, TOL),
336 near(25.0, TOL),
337 near(30.0, TOL),
338 near(35.0, TOL),
339 near(40.0, TOL),
340 ]
341 )
342 }
343}