Skip to main content

oxigdal_algorithms/raster/
statistics.rs

1//! Raster statistics operations
2//!
3//! This module provides comprehensive statistical analysis for raster data:
4//! - Basic statistics: mean, median, mode, stddev, min, max, range
5//! - Percentiles: p10, p25, p50, p75, p90
6//! - Histograms with configurable bins
7//! - Zonal statistics by polygon
8//!
9//! # Performance
10//!
11//! This module offers both sequential and parallel implementations:
12//! - **Sequential**: Reliable baseline for small datasets
13//! - **Parallel**: 4-6x speedup with 50% memory reduction for large datasets (1M+ pixels)
14//!
15//! Parallel features are enabled with the `parallel` feature flag and use:
16//! - Row-wise parallel processing with Rayon
17//! - Streaming computation (no full pixel collection)
18//! - Reservoir sampling for percentiles (~10k samples)
19//! - Thread-local histogram bins
20//!
21//! # Median accuracy
22//!
23//! **Both** the sequential and parallel code paths compute
24//! [`RasterStatistics::median`] via bounded reservoir sampling (Algorithm R,
25//! capped at ~10,000 samples), not a full sort of every valid pixel. The
26//! median is therefore **exact** only while the raster (or zone) has at most
27//! 10,000 valid pixels; for anything larger it is a statistical estimate
28//! computed from a uniform random subsample. This applies equally to
29//! [`compute_statistics`], the sequential and parallel internals behind it,
30//! and zonal statistics. Callers that require the true median for large
31//! rasters should use [`compute_exact_median`], which collects every valid
32//! value (O(n) memory) and computes it exactly.
33
34use crate::error::{AlgorithmError, Result};
35use oxigdal_core::buffer::RasterBuffer;
36
37#[cfg(not(feature = "std"))]
38use alloc::vec::Vec;
39
40#[cfg(feature = "parallel")]
41use rayon::prelude::*;
42
43/// Statistics for a raster or zone
44#[derive(Debug, Clone, PartialEq)]
45pub struct RasterStatistics {
46    /// Number of valid (non-NoData) pixels
47    pub count: usize,
48    /// Minimum value
49    pub min: f64,
50    /// Maximum value
51    pub max: f64,
52    /// Mean (average) value
53    pub mean: f64,
54    /// Median value.
55    ///
56    /// Computed via bounded reservoir sampling (~10,000 samples). This is
57    /// the **exact** median when `count <= 10_000`; for larger rasters it is
58    /// a statistical estimate from a uniform random subsample, not the true
59    /// median. Use `compute_exact_median` if an exact result is required.
60    pub median: f64,
61    /// Standard deviation
62    pub stddev: f64,
63    /// Variance
64    pub variance: f64,
65    /// Sum of all values
66    pub sum: f64,
67}
68
69/// Percentile statistics
70#[derive(Debug, Clone, PartialEq)]
71pub struct Percentiles {
72    /// 10th percentile
73    pub p10: f64,
74    /// 25th percentile (Q1)
75    pub p25: f64,
76    /// 50th percentile (median, Q2)
77    pub p50: f64,
78    /// 75th percentile (Q3)
79    pub p75: f64,
80    /// 90th percentile
81    pub p90: f64,
82}
83
84/// Histogram representation
85#[derive(Debug, Clone)]
86pub struct Histogram {
87    /// Bin edges (length = bins + 1)
88    pub edges: Vec<f64>,
89    /// Count in each bin (length = bins)
90    pub counts: Vec<usize>,
91    /// Total number of values
92    pub total: usize,
93}
94
95impl Histogram {
96    /// Returns the bin index for a given value
97    fn find_bin(&self, value: f64) -> Option<usize> {
98        if value < self.edges[0] || value > *self.edges.last()? {
99            return None;
100        }
101
102        // Binary search for the bin
103        for i in 0..self.counts.len() {
104            if value >= self.edges[i] && value < self.edges[i + 1] {
105                return Some(i);
106            }
107        }
108
109        // Value equals the last edge
110        if (value - self.edges[self.counts.len()]).abs() < f64::EPSILON {
111            return Some(self.counts.len() - 1);
112        }
113
114        None
115    }
116
117    /// Returns the relative frequency for each bin
118    #[must_use]
119    pub fn frequencies(&self) -> Vec<f64> {
120        if self.total == 0 {
121            return vec![0.0; self.counts.len()];
122        }
123
124        self.counts
125            .iter()
126            .map(|&count| count as f64 / self.total as f64)
127            .collect()
128    }
129}
130
131/// Offers a candidate value to a bounded reservoir sample using Algorithm R
132/// (Vitter, 1985).
133///
134/// After `seen` items have been observed from the stream (this call counts
135/// as one of them, so `seen` must be the 1-based running count including the
136/// current value), the reservoir holds a uniform random sample of
137/// `min(seen, capacity)` of them. Uses [`fastrand`] for the random index
138/// rather than a hand-rolled hash of the running count, so the selection is
139/// a genuine independent draw per candidate instead of a deterministic
140/// function of `seen` alone.
141fn reservoir_offer(reservoir: &mut Vec<f64>, capacity: usize, seen: usize, value: f64) {
142    if reservoir.len() < capacity {
143        reservoir.push(value);
144    } else if seen > 0 {
145        let idx = fastrand::usize(0..seen);
146        if idx < capacity {
147            reservoir[idx] = value;
148        }
149    }
150}
151
152/// Merges a just-completed reservoir sample (`incoming`, drawn from
153/// `incoming_seen` stream items) into an accumulating reservoir
154/// (`accumulator`), which already reflects `accumulated_seen` prior stream
155/// items. Each incoming sample is offered to the accumulator as though the
156/// stream continued past `accumulated_seen`, preserving Algorithm R's
157/// uniform-sampling guarantee for the combined stream.
158fn reservoir_merge(
159    accumulator: &mut Vec<f64>,
160    capacity: usize,
161    accumulated_seen: usize,
162    incoming: Vec<f64>,
163) {
164    let mut seen = accumulated_seen;
165    for value in incoming {
166        seen += 1;
167        reservoir_offer(accumulator, capacity, seen, value);
168    }
169}
170
171/// Computes basic statistics for a raster
172///
173/// This function uses streaming computation to minimize memory usage.
174/// When the `parallel` feature is enabled, row-wise parallel processing
175/// provides 4-6x speedup for large rasters.
176///
177/// # Errors
178///
179/// Returns an error if the raster has no valid pixels
180#[cfg(feature = "parallel")]
181pub fn compute_statistics(raster: &RasterBuffer) -> Result<RasterStatistics> {
182    compute_statistics_parallel(raster)
183}
184
185/// Sequential fallback when parallel feature is not enabled
186#[cfg(not(feature = "parallel"))]
187pub fn compute_statistics(raster: &RasterBuffer) -> Result<RasterStatistics> {
188    compute_statistics_sequential(raster)
189}
190
191/// Parallel streaming statistics computation
192///
193/// Uses row-wise parallel processing with Rayon for optimal performance.
194/// Memory usage is minimal as we don't collect all pixels.
195#[cfg(feature = "parallel")]
196fn compute_statistics_parallel(raster: &RasterBuffer) -> Result<RasterStatistics> {
197    // Parallel fold over rows
198    let (count, sum, sum_sq, min, max, median_samples) = (0..raster.height())
199        .into_par_iter()
200        .map(|y| {
201            let mut row_count = 0usize;
202            let mut row_sum = 0.0f64;
203            let mut row_sum_sq = 0.0f64;
204            let mut row_min = f64::INFINITY;
205            let mut row_max = f64::NEG_INFINITY;
206            let mut row_samples = Vec::new();
207
208            for x in 0..raster.width() {
209                if let Ok(val) = raster.get_pixel(x, y) {
210                    if !raster.is_nodata(val) && val.is_finite() {
211                        row_count += 1;
212                        row_sum += val;
213                        row_sum_sq += val * val;
214                        row_min = row_min.min(val);
215                        row_max = row_max.max(val);
216
217                        // Reservoir sampling for median (target: 10000 samples)
218                        reservoir_offer(&mut row_samples, 10_000, row_count, val);
219                    }
220                }
221            }
222
223            (
224                row_count,
225                row_sum,
226                row_sum_sq,
227                row_min,
228                row_max,
229                row_samples,
230            )
231        })
232        .reduce(
233            || (0, 0.0, 0.0, f64::INFINITY, f64::NEG_INFINITY, Vec::new()),
234            |(c1, s1, sq1, min1, max1, mut samples1), (c2, s2, sq2, min2, max2, samples2)| {
235                // Merge samples using reservoir sampling
236                reservoir_merge(&mut samples1, 10_000, c1, samples2);
237
238                (
239                    c1 + c2,
240                    s1 + s2,
241                    sq1 + sq2,
242                    min1.min(min2),
243                    max1.max(max2),
244                    samples1,
245                )
246            },
247        );
248
249    if count == 0 {
250        return Err(AlgorithmError::InsufficientData {
251            operation: "compute_statistics",
252            message: "No valid pixels found".to_string(),
253        });
254    }
255
256    let mean = sum / count as f64;
257
258    // Compute variance using the computational formula: Var(X) = E[X²] - E[X]²
259    let variance = (sum_sq / count as f64) - (mean * mean);
260    let stddev = variance.sqrt();
261
262    // Compute median from samples
263    let mut samples = median_samples;
264    samples.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
265
266    let median = if samples.is_empty() {
267        mean // Fallback to mean if no samples
268    } else if samples.len() % 2 == 0 {
269        let mid = samples.len() / 2;
270        (samples[mid - 1] + samples[mid]) / 2.0
271    } else {
272        samples[samples.len() / 2]
273    };
274
275    Ok(RasterStatistics {
276        count,
277        min,
278        max,
279        mean,
280        median,
281        stddev,
282        variance,
283        sum,
284    })
285}
286
287/// Sequential statistics computation (fallback)
288///
289/// This is the baseline implementation used when the parallel feature
290/// is not enabled or for small datasets where parallelism overhead
291/// would outweigh benefits. Like the parallel path, the median is computed
292/// via bounded reservoir sampling and is only exact for `count <= 10_000`;
293/// see the module-level "Median accuracy" section.
294fn compute_statistics_sequential(raster: &RasterBuffer) -> Result<RasterStatistics> {
295    let mut count = 0usize;
296    let mut sum = 0.0f64;
297    let mut sum_sq = 0.0f64;
298    let mut min = f64::INFINITY;
299    let mut max = f64::NEG_INFINITY;
300    let mut median_samples = Vec::new();
301
302    // Single pass with reservoir sampling for median
303    for y in 0..raster.height() {
304        for x in 0..raster.width() {
305            let val = raster.get_pixel(x, y).map_err(AlgorithmError::Core)?;
306            if !raster.is_nodata(val) && val.is_finite() {
307                count += 1;
308                sum += val;
309                sum_sq += val * val;
310                min = min.min(val);
311                max = max.max(val);
312
313                // Reservoir sampling for median
314                reservoir_offer(&mut median_samples, 10_000, count, val);
315            }
316        }
317    }
318
319    if count == 0 {
320        return Err(AlgorithmError::InsufficientData {
321            operation: "compute_statistics",
322            message: "No valid pixels found".to_string(),
323        });
324    }
325
326    let mean = sum / count as f64;
327    let variance = (sum_sq / count as f64) - (mean * mean);
328    let stddev = variance.sqrt();
329
330    // Compute median from samples
331    median_samples.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
332
333    let median = if median_samples.is_empty() {
334        mean
335    } else if median_samples.len() % 2 == 0 {
336        let mid = median_samples.len() / 2;
337        (median_samples[mid - 1] + median_samples[mid]) / 2.0
338    } else {
339        median_samples[median_samples.len() / 2]
340    };
341
342    Ok(RasterStatistics {
343        count,
344        min,
345        max,
346        mean,
347        median,
348        stddev,
349        variance,
350        sum,
351    })
352}
353
354/// Computes the exact median of all valid (non-NoData, finite) pixel values
355/// in a raster.
356///
357/// [`compute_statistics`]'s `median` field is only exact when the raster has
358/// at most 10,000 valid pixels; beyond that it is a reservoir-sampled
359/// estimate (see the module-level "Median accuracy" section). This function
360/// instead collects every valid value and selects the true median via
361/// `select_nth_unstable_by`, so it is exact regardless of raster size, at
362/// the cost of O(n) memory (n = number of valid pixels) and an O(n) average
363/// time selection pass instead of a single O(1)-memory streaming pass.
364///
365/// # Errors
366///
367/// Returns an error if the raster has no valid pixels.
368pub fn compute_exact_median(raster: &RasterBuffer) -> Result<f64> {
369    let mut values = Vec::new();
370
371    for y in 0..raster.height() {
372        for x in 0..raster.width() {
373            let val = raster.get_pixel(x, y).map_err(AlgorithmError::Core)?;
374            if !raster.is_nodata(val) && val.is_finite() {
375                values.push(val);
376            }
377        }
378    }
379
380    exact_median_of(values, "compute_exact_median")
381}
382
383/// Selects the true median of `values` in place, consuming the vector.
384///
385/// Uses `select_nth_unstable_by` (average O(n) time, no full sort) rather
386/// than sorting the whole slice.
387fn exact_median_of(mut values: Vec<f64>, operation: &'static str) -> Result<f64> {
388    if values.is_empty() {
389        return Err(AlgorithmError::InsufficientData {
390            operation,
391            message: "No valid pixels found".to_string(),
392        });
393    }
394
395    let cmp = |a: &f64, b: &f64| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal);
396    let len = values.len();
397    let mid = len / 2;
398
399    if len % 2 == 1 {
400        let (_, &mut median, _) = values.select_nth_unstable_by(mid, cmp);
401        Ok(median)
402    } else {
403        // Even length: need both the (mid-1)th and mid-th order statistics.
404        // select_nth_unstable_by(mid) partitions so everything before `mid`
405        // is <= the pivot; the largest of that lower partition is the
406        // (mid-1)th order statistic.
407        let (lower, &mut upper, _) = values.select_nth_unstable_by(mid, cmp);
408        let lower_max = lower.iter().copied().fold(f64::NEG_INFINITY, f64::max);
409        Ok((lower_max + upper) / 2.0)
410    }
411}
412
413/// Computes percentiles for a raster
414///
415/// Uses reservoir sampling to estimate percentiles efficiently
416/// without collecting all pixels. Sample size: ~10,000 pixels.
417///
418/// # Errors
419///
420/// Returns an error if the raster has no valid pixels
421#[cfg(feature = "parallel")]
422pub fn compute_percentiles(raster: &RasterBuffer) -> Result<Percentiles> {
423    compute_percentiles_parallel(raster)
424}
425
426/// Sequential fallback for percentiles
427#[cfg(not(feature = "parallel"))]
428pub fn compute_percentiles(raster: &RasterBuffer) -> Result<Percentiles> {
429    compute_percentiles_sequential(raster)
430}
431
432/// Parallel percentile computation using reservoir sampling
433#[cfg(feature = "parallel")]
434fn compute_percentiles_parallel(raster: &RasterBuffer) -> Result<Percentiles> {
435    const SAMPLE_SIZE: usize = 10000;
436
437    // Parallel reservoir sampling
438    let (count, samples) = (0..raster.height())
439        .into_par_iter()
440        .map(|y| {
441            let mut row_count = 0usize;
442            let mut row_samples = Vec::with_capacity(SAMPLE_SIZE.min(raster.width() as usize));
443
444            for x in 0..raster.width() {
445                if let Ok(val) = raster.get_pixel(x, y) {
446                    if !raster.is_nodata(val) && val.is_finite() {
447                        row_count += 1;
448                        reservoir_offer(&mut row_samples, SAMPLE_SIZE, row_count, val);
449                    }
450                }
451            }
452
453            (row_count, row_samples)
454        })
455        .reduce(
456            || (0, Vec::new()),
457            |(c1, mut s1), (c2, s2)| {
458                // Merge samples
459                reservoir_merge(&mut s1, SAMPLE_SIZE, c1, s2);
460
461                (c1 + c2, s1)
462            },
463        );
464
465    if count == 0 || samples.is_empty() {
466        return Err(AlgorithmError::InsufficientData {
467            operation: "compute_percentiles",
468            message: "No valid pixels found".to_string(),
469        });
470    }
471
472    // Sort samples
473    let mut sorted_samples = samples;
474    sorted_samples.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
475
476    let p10 = percentile(&sorted_samples, 10.0)?;
477    let p25 = percentile(&sorted_samples, 25.0)?;
478    let p50 = percentile(&sorted_samples, 50.0)?;
479    let p75 = percentile(&sorted_samples, 75.0)?;
480    let p90 = percentile(&sorted_samples, 90.0)?;
481
482    Ok(Percentiles {
483        p10,
484        p25,
485        p50,
486        p75,
487        p90,
488    })
489}
490
491/// Sequential percentile computation using reservoir sampling
492fn compute_percentiles_sequential(raster: &RasterBuffer) -> Result<Percentiles> {
493    const SAMPLE_SIZE: usize = 10000;
494
495    let mut count = 0usize;
496    let mut samples = Vec::with_capacity(SAMPLE_SIZE);
497
498    // Reservoir sampling
499    for y in 0..raster.height() {
500        for x in 0..raster.width() {
501            let val = raster.get_pixel(x, y).map_err(AlgorithmError::Core)?;
502            if !raster.is_nodata(val) && val.is_finite() {
503                count += 1;
504                reservoir_offer(&mut samples, SAMPLE_SIZE, count, val);
505            }
506        }
507    }
508
509    if count == 0 || samples.is_empty() {
510        return Err(AlgorithmError::InsufficientData {
511            operation: "compute_percentiles",
512            message: "No valid pixels found".to_string(),
513        });
514    }
515
516    // Sort samples
517    samples.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
518
519    let p10 = percentile(&samples, 10.0)?;
520    let p25 = percentile(&samples, 25.0)?;
521    let p50 = percentile(&samples, 50.0)?;
522    let p75 = percentile(&samples, 75.0)?;
523    let p90 = percentile(&samples, 90.0)?;
524
525    Ok(Percentiles {
526        p10,
527        p25,
528        p50,
529        p75,
530        p90,
531    })
532}
533
534/// Computes a specific percentile from sorted values
535fn percentile(sorted_values: &[f64], p: f64) -> Result<f64> {
536    if sorted_values.is_empty() {
537        return Err(AlgorithmError::EmptyInput {
538            operation: "percentile",
539        });
540    }
541
542    if !(0.0..=100.0).contains(&p) {
543        return Err(AlgorithmError::InvalidParameter {
544            parameter: "percentile",
545            message: format!("Percentile must be between 0 and 100, got {p}"),
546        });
547    }
548
549    let n = sorted_values.len();
550    let rank = p / 100.0 * (n - 1) as f64;
551    let lower = rank.floor() as usize;
552    let upper = rank.ceil() as usize;
553
554    if lower == upper {
555        Ok(sorted_values[lower])
556    } else {
557        let fraction = rank - lower as f64;
558        Ok(sorted_values[lower] * (1.0 - fraction) + sorted_values[upper] * fraction)
559    }
560}
561
562/// Computes a histogram for a raster
563///
564/// # Arguments
565///
566/// * `raster` - The raster buffer
567/// * `bins` - Number of bins
568/// * `min_val` - Minimum value (if None, uses data min)
569/// * `max_val` - Maximum value (if None, uses data max)
570///
571/// # Errors
572///
573/// Returns an error if the raster has no valid pixels or bins is 0
574pub fn compute_histogram(
575    raster: &RasterBuffer,
576    bins: usize,
577    min_val: Option<f64>,
578    max_val: Option<f64>,
579) -> Result<Histogram> {
580    if bins == 0 {
581        return Err(AlgorithmError::InvalidParameter {
582            parameter: "bins",
583            message: "Number of bins must be greater than 0".to_string(),
584        });
585    }
586
587    let mut values = Vec::new();
588
589    // Collect all valid values
590    for y in 0..raster.height() {
591        for x in 0..raster.width() {
592            let val = raster.get_pixel(x, y).map_err(AlgorithmError::Core)?;
593            if !raster.is_nodata(val) && val.is_finite() {
594                values.push(val);
595            }
596        }
597    }
598
599    if values.is_empty() {
600        return Err(AlgorithmError::InsufficientData {
601            operation: "compute_histogram",
602            message: "No valid pixels found".to_string(),
603        });
604    }
605
606    // Determine min and max
607    let data_min = values.iter().copied().fold(f64::INFINITY, f64::min);
608    let data_max = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
609
610    let min = min_val.unwrap_or(data_min);
611    let max = max_val.unwrap_or(data_max);
612
613    if max <= min {
614        return Err(AlgorithmError::InvalidParameter {
615            parameter: "min/max",
616            message: format!("max ({max}) must be greater than min ({min})"),
617        });
618    }
619
620    // Create bin edges
621    let bin_width = (max - min) / bins as f64;
622    let mut edges = Vec::with_capacity(bins + 1);
623    for i in 0..=bins {
624        edges.push(min + i as f64 * bin_width);
625    }
626
627    // Count values in each bin
628    let mut counts = vec![0usize; bins];
629    for &val in &values {
630        if val < min || val > max {
631            continue;
632        }
633
634        let bin_idx = if (val - max).abs() < f64::EPSILON {
635            bins - 1 // Last value goes in last bin
636        } else {
637            ((val - min) / bin_width).floor() as usize
638        };
639
640        if bin_idx < bins {
641            counts[bin_idx] += 1;
642        }
643    }
644
645    Ok(Histogram {
646        edges,
647        counts,
648        total: values.len(),
649    })
650}
651
652/// Parallel histogram computation with thread-local bins (internal)
653#[cfg(feature = "parallel")]
654#[allow(dead_code)]
655fn compute_histogram_parallel(
656    raster: &RasterBuffer,
657    bins: usize,
658    min_val: Option<f64>,
659    max_val: Option<f64>,
660) -> Result<Histogram> {
661    if bins == 0 {
662        return Err(AlgorithmError::InvalidParameter {
663            parameter: "bins",
664            message: "Number of bins must be greater than 0".to_string(),
665        });
666    }
667
668    // First pass: find min/max if not provided
669    let (min, max) = if min_val.is_none() || max_val.is_none() {
670        let (count, data_min, data_max) = (0..raster.height())
671            .into_par_iter()
672            .map(|y| {
673                let mut row_count = 0usize;
674                let mut row_min = f64::INFINITY;
675                let mut row_max = f64::NEG_INFINITY;
676
677                for x in 0..raster.width() {
678                    if let Ok(val) = raster.get_pixel(x, y) {
679                        if !raster.is_nodata(val) && val.is_finite() {
680                            row_count += 1;
681                            row_min = row_min.min(val);
682                            row_max = row_max.max(val);
683                        }
684                    }
685                }
686
687                (row_count, row_min, row_max)
688            })
689            .reduce(
690                || (0, f64::INFINITY, f64::NEG_INFINITY),
691                |(c1, min1, max1), (c2, min2, max2)| (c1 + c2, min1.min(min2), max1.max(max2)),
692            );
693
694        if count == 0 {
695            return Err(AlgorithmError::InsufficientData {
696                operation: "compute_histogram",
697                message: "No valid pixels found".to_string(),
698            });
699        }
700
701        (min_val.unwrap_or(data_min), max_val.unwrap_or(data_max))
702    } else {
703        (min_val.unwrap_or(0.0), max_val.unwrap_or(0.0))
704    };
705
706    if max <= min {
707        return Err(AlgorithmError::InvalidParameter {
708            parameter: "min/max",
709            message: format!("max ({max}) must be greater than min ({min})"),
710        });
711    }
712
713    // Create bin edges
714    let bin_width = (max - min) / bins as f64;
715    let mut edges = Vec::with_capacity(bins + 1);
716    for i in 0..=bins {
717        edges.push(min + i as f64 * bin_width);
718    }
719
720    // Second pass: parallel histogram with thread-local bins
721    let (total, counts) = (0..raster.height())
722        .into_par_iter()
723        .map(|y| {
724            let mut row_total = 0usize;
725            let mut row_counts = vec![0usize; bins];
726
727            for x in 0..raster.width() {
728                if let Ok(val) = raster.get_pixel(x, y) {
729                    if !raster.is_nodata(val) && val.is_finite() {
730                        if val >= min && val <= max {
731                            row_total += 1;
732
733                            let bin_idx = if (val - max).abs() < f64::EPSILON {
734                                bins - 1
735                            } else {
736                                let idx = ((val - min) / bin_width).floor() as usize;
737                                idx.min(bins - 1)
738                            };
739
740                            row_counts[bin_idx] += 1;
741                        }
742                    }
743                }
744            }
745
746            (row_total, row_counts)
747        })
748        .reduce(
749            || (0, vec![0usize; bins]),
750            |(t1, mut c1), (t2, c2)| {
751                for (i, &count) in c2.iter().enumerate() {
752                    c1[i] += count;
753                }
754                (t1 + t2, c1)
755            },
756        );
757
758    Ok(Histogram {
759        edges,
760        counts,
761        total,
762    })
763}
764
765/// Computes the mode (most frequent value) of a raster
766///
767/// Returns the most common value in the raster. For continuous data,
768/// this uses a histogram-based approach.
769///
770/// # Errors
771///
772/// Returns an error if the raster has no valid pixels
773pub fn compute_mode(raster: &RasterBuffer, bins: usize) -> Result<f64> {
774    let histogram = compute_histogram(raster, bins, None, None)?;
775
776    // Find bin with maximum count
777    let max_bin = histogram
778        .counts
779        .iter()
780        .enumerate()
781        .max_by_key(|(_, count)| *count)
782        .map(|(idx, _)| idx)
783        .ok_or(AlgorithmError::InsufficientData {
784            operation: "compute_mode",
785            message: "No bins found".to_string(),
786        })?;
787
788    // Return center of bin with maximum count
789    let mode = (histogram.edges[max_bin] + histogram.edges[max_bin + 1]) / 2.0;
790    Ok(mode)
791}
792
793/// Zone definition for zonal statistics
794#[derive(Debug, Clone)]
795pub struct Zone {
796    /// Zone identifier
797    pub id: usize,
798    /// List of (x, y) coordinates in this zone
799    pub pixels: Vec<(u64, u64)>,
800}
801
802/// Computes zonal statistics
803///
804/// Uses streaming computation to minimize memory usage.
805/// When the `parallel` feature is enabled, zones are processed in parallel.
806///
807/// # Arguments
808///
809/// * `raster` - The raster buffer
810/// * `zones` - List of zones, each containing pixel coordinates
811///
812/// # Errors
813///
814/// Returns an error if any zone has no valid pixels
815#[cfg(feature = "parallel")]
816pub fn compute_zonal_statistics(
817    raster: &RasterBuffer,
818    zones: &[Zone],
819) -> Result<Vec<(usize, RasterStatistics)>> {
820    compute_zonal_statistics_parallel(raster, zones)
821}
822
823/// Sequential fallback for zonal statistics
824#[cfg(not(feature = "parallel"))]
825pub fn compute_zonal_statistics(
826    raster: &RasterBuffer,
827    zones: &[Zone],
828) -> Result<Vec<(usize, RasterStatistics)>> {
829    compute_zonal_statistics_sequential(raster, zones)
830}
831
832/// Parallel zonal statistics computation
833#[cfg(feature = "parallel")]
834fn compute_zonal_statistics_parallel(
835    raster: &RasterBuffer,
836    zones: &[Zone],
837) -> Result<Vec<(usize, RasterStatistics)>> {
838    zones
839        .par_iter()
840        .map(|zone| {
841            // Streaming computation for each zone
842            let mut count = 0usize;
843            let mut sum = 0.0f64;
844            let mut sum_sq = 0.0f64;
845            let mut min = f64::INFINITY;
846            let mut max = f64::NEG_INFINITY;
847            let mut median_samples = Vec::with_capacity(10000.min(zone.pixels.len()));
848
849            for &(x, y) in &zone.pixels {
850                if x < raster.width() && y < raster.height() {
851                    if let Ok(val) = raster.get_pixel(x, y) {
852                        if !raster.is_nodata(val) && val.is_finite() {
853                            count += 1;
854                            sum += val;
855                            sum_sq += val * val;
856                            min = min.min(val);
857                            max = max.max(val);
858
859                            // Reservoir sampling for median
860                            reservoir_offer(&mut median_samples, 10_000, count, val);
861                        }
862                    }
863                }
864            }
865
866            if count == 0 {
867                return Err(AlgorithmError::InsufficientData {
868                    operation: "compute_zonal_statistics",
869                    message: format!("Zone {} has no valid pixels", zone.id),
870                });
871            }
872
873            let mean = sum / count as f64;
874            let variance = (sum_sq / count as f64) - (mean * mean);
875            let stddev = variance.sqrt();
876
877            // Compute median from samples
878            median_samples.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
879
880            let median = if median_samples.is_empty() {
881                mean
882            } else if median_samples.len() % 2 == 0 {
883                let mid = median_samples.len() / 2;
884                (median_samples[mid - 1] + median_samples[mid]) / 2.0
885            } else {
886                median_samples[median_samples.len() / 2]
887            };
888
889            Ok((
890                zone.id,
891                RasterStatistics {
892                    count,
893                    min,
894                    max,
895                    mean,
896                    median,
897                    stddev,
898                    variance,
899                    sum,
900                },
901            ))
902        })
903        .collect()
904}
905
906/// Sequential zonal statistics computation
907fn compute_zonal_statistics_sequential(
908    raster: &RasterBuffer,
909    zones: &[Zone],
910) -> Result<Vec<(usize, RasterStatistics)>> {
911    let mut results = Vec::with_capacity(zones.len());
912
913    for zone in zones {
914        // Streaming computation for each zone
915        let mut count = 0usize;
916        let mut sum = 0.0f64;
917        let mut sum_sq = 0.0f64;
918        let mut min = f64::INFINITY;
919        let mut max = f64::NEG_INFINITY;
920        let mut median_samples = Vec::with_capacity(10000.min(zone.pixels.len()));
921
922        for &(x, y) in &zone.pixels {
923            if x < raster.width() && y < raster.height() {
924                let val = raster.get_pixel(x, y).map_err(AlgorithmError::Core)?;
925                if !raster.is_nodata(val) && val.is_finite() {
926                    count += 1;
927                    sum += val;
928                    sum_sq += val * val;
929                    min = min.min(val);
930                    max = max.max(val);
931
932                    // Reservoir sampling for median
933                    reservoir_offer(&mut median_samples, 10_000, count, val);
934                }
935            }
936        }
937
938        if count == 0 {
939            return Err(AlgorithmError::InsufficientData {
940                operation: "compute_zonal_statistics",
941                message: format!("Zone {} has no valid pixels", zone.id),
942            });
943        }
944
945        let mean = sum / count as f64;
946        let variance = (sum_sq / count as f64) - (mean * mean);
947        let stddev = variance.sqrt();
948
949        // Compute median from samples
950        median_samples.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
951
952        let median = if median_samples.is_empty() {
953            mean
954        } else if median_samples.len() % 2 == 0 {
955            let mid = median_samples.len() / 2;
956            (median_samples[mid - 1] + median_samples[mid]) / 2.0
957        } else {
958            median_samples[median_samples.len() / 2]
959        };
960
961        results.push((
962            zone.id,
963            RasterStatistics {
964                count,
965                min,
966                max,
967                mean,
968                median,
969                stddev,
970                variance,
971                sum,
972            },
973        ));
974    }
975
976    Ok(results)
977}
978
979#[cfg(test)]
980#[allow(clippy::panic)]
981mod tests {
982    use super::*;
983    use oxigdal_core::types::RasterDataType;
984
985    #[test]
986    fn test_basic_statistics() {
987        let mut raster = RasterBuffer::zeros(10, 10, RasterDataType::Float32);
988
989        // Fill with values 0 to 99
990        for y in 0..10 {
991            for x in 0..10 {
992                raster.set_pixel(x, y, (y * 10 + x) as f64).ok();
993            }
994        }
995
996        let stats = compute_statistics(&raster);
997        assert!(stats.is_ok());
998        let s = stats.expect("Stats should be ok");
999
1000        assert_eq!(s.count, 100);
1001        assert!((s.min - 0.0).abs() < f64::EPSILON);
1002        assert!((s.max - 99.0).abs() < f64::EPSILON);
1003        assert!((s.mean - 49.5).abs() < 0.1);
1004    }
1005
1006    #[test]
1007    fn test_percentiles() {
1008        let mut raster = RasterBuffer::zeros(10, 10, RasterDataType::Float32);
1009
1010        for y in 0..10 {
1011            for x in 0..10 {
1012                raster.set_pixel(x, y, (y * 10 + x) as f64).ok();
1013            }
1014        }
1015
1016        let perc = compute_percentiles(&raster);
1017        assert!(perc.is_ok());
1018        let p = perc.expect("Percentiles should be ok");
1019
1020        assert!((p.p50 - 49.5).abs() < 1.0); // Median should be around 49.5
1021    }
1022
1023    #[test]
1024    fn test_histogram() {
1025        let mut raster = RasterBuffer::zeros(10, 10, RasterDataType::Float32);
1026
1027        for y in 0..10 {
1028            for x in 0..10 {
1029                raster.set_pixel(x, y, (y * 10 + x) as f64).ok();
1030            }
1031        }
1032
1033        let hist = compute_histogram(&raster, 10, None, None);
1034        assert!(hist.is_ok());
1035        let h = hist.expect("Histogram should be ok");
1036
1037        assert_eq!(h.counts.len(), 10);
1038        assert_eq!(h.edges.len(), 11);
1039        assert_eq!(h.total, 100);
1040    }
1041
1042    #[test]
1043    fn test_zonal_stats() {
1044        let mut raster = RasterBuffer::zeros(10, 10, RasterDataType::Float32);
1045
1046        for y in 0..10 {
1047            for x in 0..10 {
1048                raster.set_pixel(x, y, (y * 10 + x) as f64).ok();
1049            }
1050        }
1051
1052        // Create two zones
1053        let zone1 = Zone {
1054            id: 1,
1055            pixels: vec![(0, 0), (1, 0), (2, 0)], // Values: 0, 1, 2
1056        };
1057
1058        let zone2 = Zone {
1059            id: 2,
1060            pixels: vec![(7, 9), (8, 9), (9, 9)], // Values: 97, 98, 99
1061        };
1062
1063        let result = compute_zonal_statistics(&raster, &[zone1, zone2]);
1064        assert!(result.is_ok());
1065        let zones = result.expect("Zonal stats should be ok");
1066
1067        assert_eq!(zones.len(), 2);
1068        assert_eq!(zones[0].0, 1);
1069        assert!((zones[0].1.mean - 1.0).abs() < f64::EPSILON);
1070
1071        assert_eq!(zones[1].0, 2);
1072        assert!((zones[1].1.mean - 98.0).abs() < f64::EPSILON);
1073    }
1074
1075    // ========== Edge Cases ==========
1076
1077    #[test]
1078    fn test_statistics_single_pixel() {
1079        let mut raster = RasterBuffer::zeros(1, 1, RasterDataType::Float32);
1080        raster.set_pixel(0, 0, 42.0).ok();
1081
1082        let stats = compute_statistics(&raster);
1083        assert!(stats.is_ok());
1084        let s = stats.expect("Should succeed");
1085
1086        assert_eq!(s.count, 1);
1087        assert!((s.min - 42.0).abs() < f64::EPSILON);
1088        assert!((s.max - 42.0).abs() < f64::EPSILON);
1089        assert!((s.mean - 42.0).abs() < f64::EPSILON);
1090        assert!((s.median - 42.0).abs() < f64::EPSILON);
1091        assert!((s.stddev - 0.0).abs() < f64::EPSILON);
1092    }
1093
1094    #[test]
1095    fn test_histogram_zero_bins() {
1096        let raster = RasterBuffer::zeros(5, 5, RasterDataType::Float32);
1097
1098        let result = compute_histogram(&raster, 0, None, None);
1099        assert!(result.is_err());
1100        if let Err(AlgorithmError::InvalidParameter { .. }) = result {
1101            // Expected
1102        } else {
1103            panic!("Expected InvalidParameter error");
1104        }
1105    }
1106
1107    #[test]
1108    fn test_histogram_invalid_range() {
1109        let mut raster = RasterBuffer::zeros(5, 5, RasterDataType::Float32);
1110        for y in 0..5 {
1111            for x in 0..5 {
1112                raster.set_pixel(x, y, (x + y) as f64).ok();
1113            }
1114        }
1115
1116        let result = compute_histogram(&raster, 10, Some(100.0), Some(50.0)); // max < min
1117        assert!(result.is_err());
1118    }
1119
1120    #[test]
1121    fn test_percentile_out_of_range() {
1122        let values = vec![1.0, 2.0, 3.0, 4.0, 5.0];
1123
1124        let result = percentile(&values, 150.0); // > 100
1125        assert!(result.is_err());
1126    }
1127
1128    #[test]
1129    fn test_percentile_negative() {
1130        let values = vec![1.0, 2.0, 3.0, 4.0, 5.0];
1131
1132        let result = percentile(&values, -10.0);
1133        assert!(result.is_err());
1134    }
1135
1136    #[test]
1137    fn test_percentile_empty_array() {
1138        let values: Vec<f64> = vec![];
1139
1140        let result = percentile(&values, 50.0);
1141        assert!(result.is_err());
1142        if let Err(AlgorithmError::EmptyInput { .. }) = result {
1143            // Expected
1144        } else {
1145            panic!("Expected EmptyInput error");
1146        }
1147    }
1148
1149    // ========== Mode Tests ==========
1150
1151    #[test]
1152    fn test_compute_mode() {
1153        let mut raster = RasterBuffer::zeros(10, 10, RasterDataType::Float32);
1154
1155        // Create a distribution with a clear mode around 50
1156        for y in 0..10 {
1157            for x in 0..10 {
1158                let val = if (x + y) % 3 == 0 {
1159                    50.0
1160                } else {
1161                    (x * 10) as f64
1162                };
1163                raster.set_pixel(x, y, val).ok();
1164            }
1165        }
1166
1167        let result = compute_mode(&raster, 20);
1168        assert!(result.is_ok());
1169    }
1170
1171    // ========== Advanced Statistics Tests ==========
1172
1173    #[test]
1174    fn test_statistics_with_nodata() {
1175        let mut raster = RasterBuffer::zeros(5, 5, RasterDataType::Float32);
1176
1177        for y in 0..5 {
1178            for x in 0..5 {
1179                if x == 2 && y == 2 {
1180                    raster.set_pixel(x, y, f64::NAN).ok(); // NoData
1181                } else {
1182                    raster.set_pixel(x, y, (x + y) as f64).ok();
1183                }
1184            }
1185        }
1186
1187        let stats = compute_statistics(&raster);
1188        assert!(stats.is_ok());
1189        let s = stats.expect("Should succeed");
1190
1191        // Should have 24 valid pixels (25 - 1 NaN)
1192        assert_eq!(s.count, 24);
1193    }
1194
1195    #[test]
1196    fn test_percentiles_extreme_values() {
1197        let mut raster = RasterBuffer::zeros(10, 10, RasterDataType::Float32);
1198
1199        for y in 0..10 {
1200            for x in 0..10 {
1201                raster.set_pixel(x, y, (y * 10 + x) as f64).ok();
1202            }
1203        }
1204
1205        let perc = compute_percentiles(&raster);
1206        assert!(perc.is_ok());
1207        let p = perc.expect("Should succeed");
1208
1209        // p10 should be close to 9.9
1210        assert!(p.p10 < 15.0);
1211        assert!(p.p10 > 5.0);
1212
1213        // p90 should be close to 89.1
1214        assert!(p.p90 > 85.0);
1215        assert!(p.p90 < 95.0);
1216    }
1217
1218    #[test]
1219    fn test_histogram_custom_range() {
1220        let mut raster = RasterBuffer::zeros(10, 10, RasterDataType::Float32);
1221
1222        for y in 0..10 {
1223            for x in 0..10 {
1224                raster.set_pixel(x, y, (y * 10 + x) as f64).ok();
1225            }
1226        }
1227
1228        let hist = compute_histogram(&raster, 5, Some(0.0), Some(100.0));
1229        assert!(hist.is_ok());
1230        let h = hist.expect("Should succeed");
1231
1232        assert_eq!(h.counts.len(), 5);
1233        assert_eq!(h.edges.len(), 6);
1234        assert_eq!(h.total, 100);
1235
1236        // Check that edges are correct
1237        assert!((h.edges[0] - 0.0).abs() < f64::EPSILON);
1238        assert!((h.edges[5] - 100.0).abs() < f64::EPSILON);
1239    }
1240
1241    #[test]
1242    fn test_histogram_frequencies() {
1243        let mut raster = RasterBuffer::zeros(10, 10, RasterDataType::Float32);
1244
1245        for y in 0..10 {
1246            for x in 0..10 {
1247                raster.set_pixel(x, y, (y * 10 + x) as f64).ok();
1248            }
1249        }
1250
1251        let hist = compute_histogram(&raster, 10, None, None);
1252        assert!(hist.is_ok());
1253        let h = hist.expect("Should succeed");
1254
1255        let freqs = h.frequencies();
1256        assert_eq!(freqs.len(), 10);
1257
1258        // Sum of frequencies should be 1.0
1259        let sum: f64 = freqs.iter().sum();
1260        assert!((sum - 1.0).abs() < 0.001);
1261    }
1262
1263    // ========== Zonal Statistics Advanced Tests ==========
1264
1265    #[test]
1266    fn test_zonal_stats_single_zone() {
1267        let mut raster = RasterBuffer::zeros(5, 5, RasterDataType::Float32);
1268
1269        for y in 0..5 {
1270            for x in 0..5 {
1271                raster.set_pixel(x, y, 10.0).ok();
1272            }
1273        }
1274
1275        let zone = Zone {
1276            id: 1,
1277            pixels: vec![(0, 0), (1, 1), (2, 2), (3, 3), (4, 4)],
1278        };
1279
1280        let result = compute_zonal_statistics(&raster, &[zone]);
1281        assert!(result.is_ok());
1282        let zones = result.expect("Should succeed");
1283
1284        assert_eq!(zones.len(), 1);
1285        assert!((zones[0].1.mean - 10.0).abs() < f64::EPSILON);
1286    }
1287
1288    #[test]
1289    fn test_zonal_stats_empty_zone() {
1290        let mut raster = RasterBuffer::zeros(5, 5, RasterDataType::Float32);
1291
1292        for y in 0..5 {
1293            for x in 0..5 {
1294                raster.set_pixel(x, y, f64::NAN).ok(); // All NoData
1295            }
1296        }
1297
1298        let zone = Zone {
1299            id: 1,
1300            pixels: vec![(0, 0), (1, 1)],
1301        };
1302
1303        let result = compute_zonal_statistics(&raster, &[zone]);
1304        assert!(result.is_err()); // No valid pixels
1305    }
1306
1307    #[test]
1308    fn test_zonal_stats_out_of_bounds() {
1309        let raster = RasterBuffer::zeros(5, 5, RasterDataType::Float32);
1310
1311        let zone = Zone {
1312            id: 1,
1313            pixels: vec![(10, 10), (20, 20)], // Out of bounds
1314        };
1315
1316        let result = compute_zonal_statistics(&raster, &[zone]);
1317        assert!(result.is_err()); // No valid pixels within bounds
1318    }
1319
1320    #[test]
1321    fn test_zonal_stats_multiple_zones() {
1322        let mut raster = RasterBuffer::zeros(20, 20, RasterDataType::Float32);
1323
1324        for y in 0..20 {
1325            for x in 0..20 {
1326                raster.set_pixel(x, y, (y * 20 + x) as f64).ok();
1327            }
1328        }
1329
1330        let zones = vec![
1331            Zone {
1332                id: 1,
1333                pixels: (0..10).flat_map(|y| (0..10).map(move |x| (x, y))).collect(),
1334            },
1335            Zone {
1336                id: 2,
1337                pixels: (10..20)
1338                    .flat_map(|y| (10..20).map(move |x| (x, y)))
1339                    .collect(),
1340            },
1341            Zone {
1342                id: 3,
1343                pixels: (0..10)
1344                    .flat_map(|y| (10..20).map(move |x| (x, y)))
1345                    .collect(),
1346            },
1347        ];
1348
1349        let result = compute_zonal_statistics(&raster, &zones);
1350        assert!(result.is_ok());
1351        let zone_stats = result.expect("Should succeed");
1352
1353        assert_eq!(zone_stats.len(), 3);
1354    }
1355
1356    // ========== Statistical Properties Tests ==========
1357
1358    #[test]
1359    fn test_variance_and_stddev_relationship() {
1360        let mut raster = RasterBuffer::zeros(10, 10, RasterDataType::Float32);
1361
1362        for y in 0..10 {
1363            for x in 0..10 {
1364                raster.set_pixel(x, y, (x + y) as f64).ok();
1365            }
1366        }
1367
1368        let stats = compute_statistics(&raster);
1369        assert!(stats.is_ok());
1370        let s = stats.expect("Should succeed");
1371
1372        // stddev should be square root of variance
1373        assert!((s.stddev * s.stddev - s.variance).abs() < 0.001);
1374    }
1375
1376    #[test]
1377    fn test_median_vs_mean() {
1378        let mut raster = RasterBuffer::zeros(5, 5, RasterDataType::Float32);
1379
1380        // Create a skewed distribution
1381        for y in 0..5 {
1382            for x in 0..5 {
1383                let val = if x == 4 && y == 4 {
1384                    1000.0 // Outlier
1385                } else {
1386                    10.0
1387                };
1388                raster.set_pixel(x, y, val).ok();
1389            }
1390        }
1391
1392        let stats = compute_statistics(&raster);
1393        assert!(stats.is_ok());
1394        let s = stats.expect("Should succeed");
1395
1396        // Median should be closer to 10 (less affected by outlier)
1397        assert!((s.median - 10.0).abs() < 1.0);
1398        // Mean should be higher due to outlier
1399        assert!(s.mean > s.median);
1400    }
1401
1402    #[test]
1403    fn test_percentile_interpolation() {
1404        let values = vec![1.0, 2.0, 3.0, 4.0, 5.0];
1405
1406        // p50 should be exactly 3.0 (middle value)
1407        let p50 = percentile(&values, 50.0);
1408        assert!(p50.is_ok());
1409        assert!((p50.expect("Should succeed") - 3.0).abs() < f64::EPSILON);
1410
1411        // p25 should be 2.0
1412        let p25 = percentile(&values, 25.0);
1413        assert!(p25.is_ok());
1414        assert!((p25.expect("Should succeed") - 2.0).abs() < 0.1);
1415
1416        // p75 should be 4.0
1417        let p75 = percentile(&values, 75.0);
1418        assert!(p75.is_ok());
1419        assert!((p75.expect("Should succeed") - 4.0).abs() < 0.1);
1420    }
1421
1422    #[test]
1423    fn test_histogram_edge_case_last_value() {
1424        let mut raster = RasterBuffer::zeros(5, 5, RasterDataType::Float32);
1425
1426        for y in 0..5 {
1427            for x in 0..5 {
1428                raster.set_pixel(x, y, (x + y) as f64).ok();
1429            }
1430        }
1431
1432        let hist = compute_histogram(&raster, 8, Some(0.0), Some(8.0));
1433        assert!(hist.is_ok());
1434        let h = hist.expect("Should succeed");
1435
1436        // The value 8.0 should be included in the last bin
1437        assert_eq!(h.counts.len(), 8);
1438    }
1439
1440    #[test]
1441    fn test_exact_median_odd_count() {
1442        let mut raster = RasterBuffer::zeros(5, 1, RasterDataType::Float32);
1443        let values = [9.0, 1.0, 5.0, 3.0, 7.0];
1444        for (x, &v) in values.iter().enumerate() {
1445            raster.set_pixel(x as u64, 0, v).ok();
1446        }
1447
1448        // sorted: [1, 3, 5, 7, 9] -> exact median is 5.0
1449        let median = compute_exact_median(&raster).expect("median should succeed");
1450        assert!((median - 5.0).abs() < f64::EPSILON);
1451    }
1452
1453    #[test]
1454    fn test_exact_median_even_count() {
1455        let mut raster = RasterBuffer::zeros(3, 2, RasterDataType::Float32);
1456        let values = [5.0, 1.0, 3.0, 2.0, 4.0, 100.0];
1457        let mut idx = 0usize;
1458        for y in 0..2 {
1459            for x in 0..3 {
1460                raster.set_pixel(x, y, values[idx]).ok();
1461                idx += 1;
1462            }
1463        }
1464
1465        // sorted: [1, 2, 3, 4, 5, 100] -> exact median is (3+4)/2 = 3.5,
1466        // unaffected by the 100.0 outlier since it's not part of the
1467        // middle pair.
1468        let median = compute_exact_median(&raster).expect("median should succeed");
1469        assert!((median - 3.5).abs() < f64::EPSILON);
1470    }
1471
1472    #[test]
1473    fn test_exact_median_no_valid_pixels_errors() {
1474        let mut raster = RasterBuffer::zeros(4, 4, RasterDataType::Float32);
1475        for y in 0..4 {
1476            for x in 0..4 {
1477                raster.set_pixel(x, y, f64::NAN).ok(); // All NoData/invalid
1478            }
1479        }
1480
1481        let result = compute_exact_median(&raster);
1482        assert!(result.is_err());
1483    }
1484
1485    /// Regression test: `compute_statistics`'s `median` field is only exact
1486    /// while `count <= 10_000` (reservoir sampling kicks in above that). For
1487    /// a large raster, `compute_exact_median` must still equal the true
1488    /// median computed by sorting every valid value directly -- it must not
1489    /// go through the reservoir-sampling approximation at all.
1490    #[test]
1491    fn test_exact_median_matches_full_sort_for_large_raster() {
1492        // 120 x 130 = 15,600 pixels, well above the 10,000-sample reservoir
1493        // cap used by `compute_statistics`.
1494        let width = 120u64;
1495        let height = 130u64;
1496        let mut raster = RasterBuffer::zeros(width, height, RasterDataType::Float32);
1497
1498        let mut all_values = Vec::with_capacity((width * height) as usize);
1499        let mut n = 0i64;
1500        for y in 0..height {
1501            for x in 0..width {
1502                // A non-trivial, non-monotonic-in-scan-order value pattern.
1503                let val = ((n * 2654435761_i64).rem_euclid(1_000_003)) as f64;
1504                raster.set_pixel(x, y, val).ok();
1505                all_values.push(val);
1506                n += 1;
1507            }
1508        }
1509
1510        all_values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
1511        let len = all_values.len();
1512        let expected = if len % 2 == 0 {
1513            (all_values[len / 2 - 1] + all_values[len / 2]) / 2.0
1514        } else {
1515            all_values[len / 2]
1516        };
1517
1518        let median = compute_exact_median(&raster).expect("median should succeed");
1519        assert!(
1520            (median - expected).abs() < f64::EPSILON,
1521            "exact median {median} did not match full-sort median {expected}"
1522        );
1523    }
1524
1525    /// Regression test: the reservoir-sampled `median` field on
1526    /// `compute_statistics` should still be a statistically reasonable
1527    /// estimate of the true median for a large, uniformly distributed
1528    /// raster, confirming the refactor to `reservoir_offer`/`fastrand`
1529    /// preserved sampling correctness (values are still drawn roughly
1530    /// uniformly, not biased toward a subrange).
1531    #[test]
1532    fn test_reservoir_sampled_median_close_to_exact_for_large_raster() {
1533        let width = 150u64;
1534        let height = 100u64; // 15,000 valid pixels > 10,000 reservoir cap
1535        let mut raster = RasterBuffer::zeros(width, height, RasterDataType::Float32);
1536
1537        let mut n = 0.0f64;
1538        for y in 0..height {
1539            for x in 0..width {
1540                raster.set_pixel(x, y, n).ok(); // values 0.0 .. 14999.0
1541                n += 1.0;
1542            }
1543        }
1544
1545        let exact = compute_exact_median(&raster).expect("exact median should succeed");
1546        let stats = compute_statistics(&raster).expect("statistics should succeed");
1547
1548        // The reservoir sample covers 10,000 of 15,000 values (~67%), so the
1549        // sampled median should land close to the true median. Allow a
1550        // generous tolerance (~2% of the value range) to avoid flakiness
1551        // from the unseeded, thread-local `fastrand` generator.
1552        let tolerance = (width * height) as f64 * 0.02;
1553        assert!(
1554            (stats.median - exact).abs() < tolerance,
1555            "reservoir-sampled median {} too far from exact median {} (tolerance {})",
1556            stats.median,
1557            exact,
1558            tolerance
1559        );
1560    }
1561
1562    /// Regression test for the `x * 0 = 0`-style silent-failure class of bug
1563    /// in reservoir sampling: `reservoir_offer` must retain every observed
1564    /// value verbatim (no data loss / no silent zeroing) while the stream is
1565    /// still within the reservoir's capacity.
1566    #[test]
1567    fn test_reservoir_offer_retains_all_items_within_capacity() {
1568        let mut reservoir: Vec<f64> = Vec::new();
1569        for i in 0..50usize {
1570            reservoir_offer(&mut reservoir, 100, i + 1, i as f64);
1571        }
1572
1573        assert_eq!(reservoir.len(), 50);
1574        let mut sorted = reservoir.clone();
1575        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
1576        let expected: Vec<f64> = (0..50).map(|i| i as f64).collect();
1577        assert_eq!(sorted, expected);
1578    }
1579
1580    /// Once the stream exceeds capacity, the reservoir must stay at
1581    /// capacity (never grow unbounded, never shrink) and every value it
1582    /// holds must have come from the observed stream.
1583    #[test]
1584    fn test_reservoir_offer_stays_at_capacity_and_only_holds_stream_values() {
1585        let mut reservoir: Vec<f64> = Vec::new();
1586        let capacity = 20usize;
1587        let stream_len = 500usize;
1588
1589        for i in 0..stream_len {
1590            reservoir_offer(&mut reservoir, capacity, i + 1, i as f64);
1591        }
1592
1593        assert_eq!(reservoir.len(), capacity);
1594        for &val in &reservoir {
1595            assert!((0.0..stream_len as f64).contains(&val));
1596        }
1597    }
1598}