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    /// Fit the GPD parameters using the available estimators
47    /// Returns the log-likelihood of the best fit
48    pub fn fit(&mut self) -> f64 {
49        if self.peaks.size() == 0 {
50            return f64::NAN;
51        }
52
53        // Match C implementation exactly: try each estimator and pick best
54        let mut max_llhood = f64::NAN;
55        let mut tmp_gamma;
56        let mut tmp_sigma;
57
58        // Try MoM estimator first (index 0 in C)
59        let llhood = {
60            let (gamma, sigma, llhood) = mom_estimator(&self.peaks);
61            tmp_gamma = gamma;
62            tmp_sigma = sigma;
63            llhood
64        };
65
66        if max_llhood.is_nan() || llhood > max_llhood {
67            max_llhood = llhood;
68            self.gamma = tmp_gamma;
69            self.sigma = tmp_sigma;
70        }
71
72        // Try Grimshaw estimator (index 1 in C)
73        let llhood = {
74            let (gamma, sigma, llhood) = grimshaw_estimator(&self.peaks);
75            tmp_gamma = gamma;
76            tmp_sigma = sigma;
77            llhood
78        };
79
80        if max_llhood.is_nan() || llhood > max_llhood {
81            // Back to original logic
82            max_llhood = llhood;
83            self.gamma = tmp_gamma;
84            self.sigma = tmp_sigma;
85        }
86
87        max_llhood
88    }
89
90    /// Compute the probability P(X > z) = p given the tail threshold difference d = z - t
91    pub fn probability(&self, s: f64, d: f64) -> f64 {
92        if self.gamma.is_nan() || self.sigma.is_nan() || self.sigma <= 0.0 {
93            return f64::NAN;
94        }
95
96        // Use exact equality check like C implementation (no tolerance)
97        if self.gamma == 0.0 {
98            s * xexp(-d / self.sigma)
99        } else {
100            let r = d * (self.gamma / self.sigma);
101            s * xpow(1.0 + r, -1.0 / self.gamma)
102        }
103    }
104
105    /// Compute the extreme quantile for given probability q
106    /// s is the ratio Nt/n (an estimator of P(X>t) = 1-F(t))
107    /// q is the desired low probability
108    pub fn quantile(&self, s: f64, q: f64) -> f64 {
109        if self.gamma.is_nan() || self.sigma.is_nan() || self.sigma <= 0.0 {
110            return f64::NAN;
111        }
112
113        let r = q / s;
114        // Use exact equality check like C implementation (no tolerance)
115        if self.gamma == 0.0 {
116            -self.sigma * xlog(r)
117        } else {
118            (self.sigma / self.gamma) * (xpow(r, -self.gamma) - 1.0)
119        }
120    }
121
122    /// Get the current gamma parameter
123    pub fn gamma(&self) -> f64 {
124        self.gamma
125    }
126
127    /// Get the current sigma parameter
128    pub fn sigma(&self) -> f64 {
129        self.sigma
130    }
131
132    /// Get the current size of the tail data
133    pub fn size(&self) -> usize {
134        self.peaks.size()
135    }
136
137    /// Get access to the underlying peaks structure
138    pub fn peaks(&self) -> &Peaks {
139        &self.peaks
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use crate::error::SpotError;
147
148    #[test]
149    fn test_tail_creation() {
150        let tail = Tail::new(10).unwrap();
151        assert_eq!(tail.size(), 0);
152        assert!(tail.gamma().is_nan());
153        assert!(tail.sigma().is_nan());
154    }
155
156    #[test]
157    fn test_tail_zero_size() {
158        let result = Tail::new(0);
159        assert!(result.is_err());
160        assert_eq!(result.unwrap_err(), SpotError::MemoryAllocationFailed);
161    }
162
163    #[test]
164    fn test_tail_push() {
165        let mut tail = Tail::new(5).unwrap();
166
167        tail.push(1.0);
168        assert_eq!(tail.size(), 1);
169
170        tail.push(2.0);
171        tail.push(3.0);
172        assert_eq!(tail.size(), 3);
173    }
174
175    #[test]
176    fn test_tail_fit_empty() {
177        let mut tail = Tail::new(5).unwrap();
178        let llhood = tail.fit();
179        assert!(llhood.is_nan());
180        assert!(tail.gamma().is_nan());
181        assert!(tail.sigma().is_nan());
182    }
183
184    #[test]
185    fn test_tail_fit_with_data() {
186        let mut tail = Tail::new(10).unwrap();
187
188        // Add some sample data
189        for value in [1.0, 1.5, 2.0, 2.5, 3.0, 1.2, 1.8, 2.2] {
190            tail.push(value);
191        }
192
193        let llhood = tail.fit();
194        assert!(!llhood.is_nan());
195        assert!(llhood.is_finite());
196
197        // Parameters should be fitted
198        assert!(!tail.gamma().is_nan());
199        assert!(!tail.sigma().is_nan());
200        assert!(tail.sigma() > 0.0); // Sigma should be positive
201    }
202
203    #[test]
204    fn test_tail_quantile_gamma_zero() {
205        let mut tail = Tail::new(10).unwrap();
206
207        // Manually set parameters for testing
208        tail.gamma = 0.0;
209        tail.sigma = 1.0;
210
211        let q = tail.quantile(0.1, 0.01);
212        assert!(!q.is_nan());
213        assert!(q > 0.0); // Should be positive for low probability
214    }
215
216    #[test]
217    fn test_tail_quantile_gamma_nonzero() {
218        let mut tail = Tail::new(10).unwrap();
219
220        // Manually set parameters for testing
221        tail.gamma = 0.1;
222        tail.sigma = 1.0;
223
224        let q = tail.quantile(0.1, 0.01);
225        assert!(!q.is_nan());
226        assert!(q.is_finite());
227    }
228
229    #[test]
230    fn test_tail_probability_gamma_zero() {
231        let mut tail = Tail::new(10).unwrap();
232
233        // Manually set parameters for testing
234        tail.gamma = 0.0;
235        tail.sigma = 1.0;
236
237        let p = tail.probability(0.1, 2.0);
238        assert!(!p.is_nan());
239        assert!((0.0..=0.1).contains(&p));
240    }
241
242    #[test]
243    fn test_tail_probability_gamma_nonzero() {
244        let mut tail = Tail::new(10).unwrap();
245
246        // Manually set parameters for testing
247        tail.gamma = 0.1;
248        tail.sigma = 1.0;
249
250        let p = tail.probability(0.1, 2.0);
251        assert!(!p.is_nan());
252        assert!(p >= 0.0);
253    }
254
255    #[test]
256    fn test_tail_invalid_parameters() {
257        let mut tail = Tail::new(10).unwrap();
258
259        // Test with invalid sigma
260        tail.gamma = 0.1;
261        tail.sigma = 0.0;
262
263        let q = tail.quantile(0.1, 0.01);
264        assert!(q.is_nan());
265
266        let p = tail.probability(0.1, 2.0);
267        assert!(p.is_nan());
268    }
269
270    #[test]
271    fn test_tail_consistency() {
272        let mut tail = Tail::new(10).unwrap();
273
274        // Add some data and fit
275        for value in [0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0] {
276            tail.push(value);
277        }
278
279        let _llhood = tail.fit();
280
281        // Test that quantile and probability are somewhat consistent
282        let s = 0.1;
283        let q = 0.01;
284        let quantile_val = tail.quantile(s, q);
285
286        if !quantile_val.is_nan() && quantile_val.is_finite() {
287            let prob_val = tail.probability(s, quantile_val);
288            if !prob_val.is_nan() && prob_val.is_finite() {
289                // The probability should be approximately q
290                // Allow for some numerical error
291                assert!((prob_val - q).abs() < q * 0.1 || prob_val < q * 2.0);
292            }
293        }
294    }
295}