Skip to main content

libspot_rs/
tail.rs

1//! Tail structure for GPD modeling
2//!
3//! This module implements the Tail structure that models the tail of a distribution
4//! using Generalized Pareto Distribution (GPD) parameters.
5
6use crate::error::SpotResult;
7
8use crate::estimator::{grimshaw_estimator, mom_estimator};
9use crate::math::{xexp, xlog, xpow};
10use crate::peaks::Peaks;
11
12/// Structure that embeds GPD parameters (GPD tail actually)
13///
14/// # Serialization
15///
16/// When the `serde` feature is enabled, this struct can be serialized and deserialized.
17/// This allows saving and restoring the GPD tail model parameters.
18#[derive(Debug, Clone)]
19#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
20pub struct Tail {
21    /// GPD gamma parameter
22    #[cfg_attr(feature = "serde", serde(with = "crate::ser::nan_safe_f64"))]
23    gamma: f64,
24    /// GPD sigma parameter
25    #[cfg_attr(feature = "serde", serde(with = "crate::ser::nan_safe_f64"))]
26    sigma: f64,
27    /// Underlying Peaks structure
28    peaks: Peaks,
29}
30
31impl Tail {
32    /// Initialize a new Tail structure with the given size
33    pub fn new(size: usize) -> SpotResult<Self> {
34        Ok(Self {
35            gamma: f64::NAN,
36            sigma: f64::NAN,
37            peaks: Peaks::new(size)?,
38        })
39    }
40
41    /// Add a new data point into the tail
42    pub fn push(&mut self, x: f64) {
43        self.peaks.push(x);
44    }
45
46    /// Reset the tail to its initial state, keeping the allocated buffer.
47    pub(crate) fn reset(&mut self) {
48        self.gamma = f64::NAN;
49        self.sigma = f64::NAN;
50        self.peaks.reset();
51    }
52
53    /// Fit the GPD parameters using the available estimators
54    /// Returns the log-likelihood of the best fit
55    pub fn fit(&mut self) -> f64 {
56        if self.peaks.size() == 0 {
57            return f64::NAN;
58        }
59
60        // Match C implementation exactly: try each estimator and pick best
61        let mut max_llhood = f64::NAN;
62        let mut tmp_gamma;
63        let mut tmp_sigma;
64
65        // Try MoM estimator first (index 0 in C)
66        let llhood = {
67            let (gamma, sigma, llhood) = mom_estimator(&self.peaks);
68            tmp_gamma = gamma;
69            tmp_sigma = sigma;
70            llhood
71        };
72
73        if max_llhood.is_nan() || llhood > max_llhood {
74            max_llhood = llhood;
75            self.gamma = tmp_gamma;
76            self.sigma = tmp_sigma;
77        }
78
79        // Try Grimshaw estimator (index 1 in C)
80        let llhood = {
81            let (gamma, sigma, llhood) = grimshaw_estimator(&self.peaks);
82            tmp_gamma = gamma;
83            tmp_sigma = sigma;
84            llhood
85        };
86
87        if max_llhood.is_nan() || llhood > max_llhood {
88            // Back to original logic
89            max_llhood = llhood;
90            self.gamma = tmp_gamma;
91            self.sigma = tmp_sigma;
92        }
93
94        max_llhood
95    }
96
97    /// Compute the probability P(X > z) = p given the tail threshold difference d = z - t
98    pub fn probability(&self, s: f64, d: f64) -> f64 {
99        if self.gamma.is_nan() || self.sigma.is_nan() || self.sigma <= 0.0 {
100            return f64::NAN;
101        }
102
103        // Use exact equality check like C implementation (no tolerance)
104        if self.gamma == 0.0 {
105            s * xexp(-d / self.sigma)
106        } else {
107            let r = d * (self.gamma / self.sigma);
108            s * xpow(1.0 + r, -1.0 / self.gamma)
109        }
110    }
111
112    /// Compute the extreme quantile for given probability q
113    /// s is the ratio Nt/n (an estimator of P(X>t) = 1-F(t))
114    /// q is the desired low probability
115    pub fn quantile(&self, s: f64, q: f64) -> f64 {
116        if self.gamma.is_nan() || self.sigma.is_nan() || self.sigma <= 0.0 {
117            return f64::NAN;
118        }
119
120        let r = q / s;
121        // Use exact equality check like C implementation (no tolerance)
122        if self.gamma == 0.0 {
123            -self.sigma * xlog(r)
124        } else {
125            (self.sigma / self.gamma) * (xpow(r, -self.gamma) - 1.0)
126        }
127    }
128
129    /// Get the current gamma parameter
130    pub fn gamma(&self) -> f64 {
131        self.gamma
132    }
133
134    /// Get the current sigma parameter
135    pub fn sigma(&self) -> f64 {
136        self.sigma
137    }
138
139    /// Get the current size of the tail data
140    pub fn size(&self) -> usize {
141        self.peaks.size()
142    }
143
144    /// Get access to the underlying peaks structure
145    pub fn peaks(&self) -> &Peaks {
146        &self.peaks
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use crate::error::SpotError;
154
155    #[test]
156    fn test_tail_reset_clears_gpd_params_and_peaks() {
157        let mut tail = Tail::new(50).unwrap();
158        for i in 0..40 {
159            tail.push(0.1 + i as f64 * 0.05);
160        }
161        let _ = tail.fit();
162        assert!(tail.size() > 0);
163        // gamma/sigma may be NaN if the fit fails on this trivial input,
164        // so we only assert they're cleared post-reset, not pre-reset.
165
166        tail.reset();
167
168        assert_eq!(tail.size(), 0);
169        assert!(tail.gamma().is_nan());
170        assert!(tail.sigma().is_nan());
171    }
172
173    #[test]
174    fn test_tail_creation() {
175        let tail = Tail::new(10).unwrap();
176        assert_eq!(tail.size(), 0);
177        assert!(tail.gamma().is_nan());
178        assert!(tail.sigma().is_nan());
179    }
180
181    #[test]
182    fn test_tail_zero_size() {
183        let result = Tail::new(0);
184        assert!(result.is_err());
185        assert_eq!(result.unwrap_err(), SpotError::MemoryAllocationFailed);
186    }
187
188    #[test]
189    fn test_tail_push() {
190        let mut tail = Tail::new(5).unwrap();
191
192        tail.push(1.0);
193        assert_eq!(tail.size(), 1);
194
195        tail.push(2.0);
196        tail.push(3.0);
197        assert_eq!(tail.size(), 3);
198    }
199
200    #[test]
201    fn test_tail_fit_empty() {
202        let mut tail = Tail::new(5).unwrap();
203        let llhood = tail.fit();
204        assert!(llhood.is_nan());
205        assert!(tail.gamma().is_nan());
206        assert!(tail.sigma().is_nan());
207    }
208
209    #[test]
210    fn test_tail_fit_with_data() {
211        let mut tail = Tail::new(10).unwrap();
212
213        // Add some sample data
214        for value in [1.0, 1.5, 2.0, 2.5, 3.0, 1.2, 1.8, 2.2] {
215            tail.push(value);
216        }
217
218        let llhood = tail.fit();
219        assert!(!llhood.is_nan());
220        assert!(llhood.is_finite());
221
222        // Parameters should be fitted
223        assert!(!tail.gamma().is_nan());
224        assert!(!tail.sigma().is_nan());
225        assert!(tail.sigma() > 0.0); // Sigma should be positive
226    }
227
228    #[test]
229    fn test_tail_quantile_gamma_zero() {
230        let mut tail = Tail::new(10).unwrap();
231
232        // Manually set parameters for testing
233        tail.gamma = 0.0;
234        tail.sigma = 1.0;
235
236        let q = tail.quantile(0.1, 0.01);
237        assert!(!q.is_nan());
238        assert!(q > 0.0); // Should be positive for low probability
239    }
240
241    #[test]
242    fn test_tail_quantile_gamma_nonzero() {
243        let mut tail = Tail::new(10).unwrap();
244
245        // Manually set parameters for testing
246        tail.gamma = 0.1;
247        tail.sigma = 1.0;
248
249        let q = tail.quantile(0.1, 0.01);
250        assert!(!q.is_nan());
251        assert!(q.is_finite());
252    }
253
254    #[test]
255    fn test_tail_probability_gamma_zero() {
256        let mut tail = Tail::new(10).unwrap();
257
258        // Manually set parameters for testing
259        tail.gamma = 0.0;
260        tail.sigma = 1.0;
261
262        let p = tail.probability(0.1, 2.0);
263        assert!(!p.is_nan());
264        assert!((0.0..=0.1).contains(&p));
265    }
266
267    #[test]
268    fn test_tail_probability_gamma_nonzero() {
269        let mut tail = Tail::new(10).unwrap();
270
271        // Manually set parameters for testing
272        tail.gamma = 0.1;
273        tail.sigma = 1.0;
274
275        let p = tail.probability(0.1, 2.0);
276        assert!(!p.is_nan());
277        assert!(p >= 0.0);
278    }
279
280    #[test]
281    fn test_tail_invalid_parameters() {
282        let mut tail = Tail::new(10).unwrap();
283
284        // Test with invalid sigma
285        tail.gamma = 0.1;
286        tail.sigma = 0.0;
287
288        let q = tail.quantile(0.1, 0.01);
289        assert!(q.is_nan());
290
291        let p = tail.probability(0.1, 2.0);
292        assert!(p.is_nan());
293    }
294
295    #[test]
296    fn test_tail_consistency() {
297        let mut tail = Tail::new(10).unwrap();
298
299        // Add some data and fit
300        for value in [0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0] {
301            tail.push(value);
302        }
303
304        let _llhood = tail.fit();
305
306        // Test that quantile and probability are somewhat consistent
307        let s = 0.1;
308        let q = 0.01;
309        let quantile_val = tail.quantile(s, q);
310
311        if !quantile_val.is_nan() && quantile_val.is_finite() {
312            let prob_val = tail.probability(s, quantile_val);
313            if !prob_val.is_nan() && prob_val.is_finite() {
314                // The probability should be approximately q
315                // Allow for some numerical error
316                assert!((prob_val - q).abs() < q * 0.1 || prob_val < q * 2.0);
317            }
318        }
319    }
320}