crc-framework 0.2.6__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. {crc_framework-0.2.6 → crc_framework-0.3.0}/Cargo.lock +2 -2
  2. {crc_framework-0.2.6 → crc_framework-0.3.0}/Cargo.toml +1 -1
  3. {crc_framework-0.2.6 → crc_framework-0.3.0}/PKG-INFO +26 -1
  4. {crc_framework-0.2.6 → crc_framework-0.3.0}/README.md +25 -0
  5. {crc_framework-0.2.6 → crc_framework-0.3.0}/crates/core/Cargo.toml +1 -1
  6. crc_framework-0.3.0/crates/core/src/distribution/lmoments.rs +215 -0
  7. {crc_framework-0.2.6 → crc_framework-0.3.0}/crates/core/src/distribution.rs +18 -0
  8. {crc_framework-0.2.6 → crc_framework-0.3.0}/crates/core/src/lib.rs +1 -1
  9. {crc_framework-0.2.6 → crc_framework-0.3.0}/python/crc_framework/_core.pyi +1 -1
  10. {crc_framework-0.2.6 → crc_framework-0.3.0}/python/crc_framework/distributions/fitting.py +18 -1
  11. {crc_framework-0.2.6 → crc_framework-0.3.0}/src/lib.rs +24 -5
  12. crc_framework-0.3.0/tests/test_lmoments.py +138 -0
  13. crc_framework-0.3.0/tools/benchmark_lmoments.py +442 -0
  14. crc_framework-0.3.0/tools/benchmarks/lmoments-audit-m2-pro.json +851 -0
  15. crc_framework-0.3.0/tools/requirements-lmoments-benchmark.txt +6 -0
  16. {crc_framework-0.2.6 → crc_framework-0.3.0}/.github/workflows/release.yaml +0 -0
  17. {crc_framework-0.2.6 → crc_framework-0.3.0}/.gitignore +0 -0
  18. {crc_framework-0.2.6 → crc_framework-0.3.0}/.python-version +0 -0
  19. {crc_framework-0.2.6 → crc_framework-0.3.0}/LICENSE +0 -0
  20. {crc_framework-0.2.6 → crc_framework-0.3.0}/THIRD_PARTY_NOTICES.md +0 -0
  21. {crc_framework-0.2.6 → crc_framework-0.3.0}/crates/core/src/constants.rs +0 -0
  22. {crc_framework-0.2.6 → crc_framework-0.3.0}/crates/core/src/error.rs +0 -0
  23. {crc_framework-0.2.6 → crc_framework-0.3.0}/crates/core/src/metrics.rs +0 -0
  24. {crc_framework-0.2.6 → crc_framework-0.3.0}/crates/core/src/reference/climate/ag_params.rs +0 -0
  25. {crc_framework-0.2.6 → crc_framework-0.3.0}/crates/core/src/reference/climate/energy_params.rs +0 -0
  26. {crc_framework-0.2.6 → crc_framework-0.3.0}/crates/core/src/reference/climate/flood_params.rs +0 -0
  27. {crc_framework-0.2.6 → crc_framework-0.3.0}/crates/core/src/reference/climate/mod.rs +0 -0
  28. {crc_framework-0.2.6 → crc_framework-0.3.0}/crates/core/src/reference/climate/precipitation_thresholds.rs +0 -0
  29. {crc_framework-0.2.6 → crc_framework-0.3.0}/crates/core/src/reference/climate/spei_params.rs +0 -0
  30. {crc_framework-0.2.6 → crc_framework-0.3.0}/crates/core/src/reference/climate/wind_params.rs +0 -0
  31. {crc_framework-0.2.6 → crc_framework-0.3.0}/crates/core/src/reference/curve_fitting.rs +0 -0
  32. {crc_framework-0.2.6 → crc_framework-0.3.0}/crates/core/src/reference/mod.rs +0 -0
  33. {crc_framework-0.2.6 → crc_framework-0.3.0}/crates/core/src/reference/spatial/continent_r4.rs +0 -0
  34. {crc_framework-0.2.6 → crc_framework-0.3.0}/crates/core/src/reference/spatial/geography_r5.rs +0 -0
  35. {crc_framework-0.2.6 → crc_framework-0.3.0}/crates/core/src/reference/spatial/ipcc.rs +0 -0
  36. {crc_framework-0.2.6 → crc_framework-0.3.0}/crates/core/src/reference/spatial/mod.rs +0 -0
  37. {crc_framework-0.2.6 → crc_framework-0.3.0}/crates/core/src/risk.rs +0 -0
  38. {crc_framework-0.2.6 → crc_framework-0.3.0}/crates/core/src/spatial.rs +0 -0
  39. {crc_framework-0.2.6 → crc_framework-0.3.0}/crates/core/src/transform.rs +0 -0
  40. {crc_framework-0.2.6 → crc_framework-0.3.0}/crates/core/tests/quantile_fit_golden.rs +0 -0
  41. {crc_framework-0.2.6 → crc_framework-0.3.0}/crates/core/tests/quantile_fit_golden.txt +0 -0
  42. {crc_framework-0.2.6 → crc_framework-0.3.0}/pyproject.toml +0 -0
  43. {crc_framework-0.2.6 → crc_framework-0.3.0}/python/crc_framework/__init__.py +0 -0
  44. {crc_framework-0.2.6 → crc_framework-0.3.0}/python/crc_framework/constants.py +0 -0
  45. {crc_framework-0.2.6 → crc_framework-0.3.0}/python/crc_framework/distributions/__init__.py +0 -0
  46. {crc_framework-0.2.6 → crc_framework-0.3.0}/python/crc_framework/distributions/base.py +0 -0
  47. {crc_framework-0.2.6 → crc_framework-0.3.0}/python/crc_framework/distributions/empirical.py +0 -0
  48. {crc_framework-0.2.6 → crc_framework-0.3.0}/python/crc_framework/distributions/fitted.py +0 -0
  49. {crc_framework-0.2.6 → crc_framework-0.3.0}/python/crc_framework/distributions/hurdle.py +0 -0
  50. {crc_framework-0.2.6 → crc_framework-0.3.0}/python/crc_framework/distributions/point_mass.py +0 -0
  51. {crc_framework-0.2.6 → crc_framework-0.3.0}/python/crc_framework/distributions/tabulated.py +0 -0
  52. {crc_framework-0.2.6 → crc_framework-0.3.0}/python/crc_framework/metrics/__init__.py +0 -0
  53. {crc_framework-0.2.6 → crc_framework-0.3.0}/python/crc_framework/metrics/distribution.py +0 -0
  54. {crc_framework-0.2.6 → crc_framework-0.3.0}/python/crc_framework/metrics/microscores.py +0 -0
  55. {crc_framework-0.2.6 → crc_framework-0.3.0}/python/crc_framework/metrics/risk.py +0 -0
  56. {crc_framework-0.2.6 → crc_framework-0.3.0}/python/crc_framework/metrics/spanning.py +0 -0
  57. {crc_framework-0.2.6 → crc_framework-0.3.0}/python/crc_framework/models.py +0 -0
  58. {crc_framework-0.2.6 → crc_framework-0.3.0}/python/crc_framework/py.typed +0 -0
  59. {crc_framework-0.2.6 → crc_framework-0.3.0}/python/crc_framework/spatial.py +0 -0
  60. {crc_framework-0.2.6 → crc_framework-0.3.0}/python/crc_framework/transforms/__init__.py +0 -0
  61. {crc_framework-0.2.6 → crc_framework-0.3.0}/python/crc_framework/transforms/base.py +0 -0
  62. {crc_framework-0.2.6 → crc_framework-0.3.0}/python/crc_framework/transforms/builtin.py +0 -0
  63. {crc_framework-0.2.6 → crc_framework-0.3.0}/python/crc_framework/transforms/climate.py +0 -0
  64. {crc_framework-0.2.6 → crc_framework-0.3.0}/tests/test_impacts.py +0 -0
  65. {crc_framework-0.2.6 → crc_framework-0.3.0}/tests/test_sdk.py +0 -0
  66. {crc_framework-0.2.6 → crc_framework-0.3.0}/tests/typing_smoke.py +0 -0
  67. {crc_framework-0.2.6 → crc_framework-0.3.0}/uv.lock +0 -0
@@ -43,7 +43,7 @@ dependencies = [
43
43
 
44
44
  [[package]]
45
45
  name = "crc-framework-core"
46
- version = "0.2.6"
46
+ version = "0.3.0"
47
47
  dependencies = [
48
48
  "h3o",
49
49
  "phf",
@@ -52,7 +52,7 @@ dependencies = [
52
52
 
53
53
  [[package]]
54
54
  name = "crc-framework-rs"
55
- version = "0.2.6"
55
+ version = "0.3.0"
56
56
  dependencies = [
57
57
  "crc-framework-core",
58
58
  "h3o",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "crc-framework-rs"
3
- version = "0.2.6"
3
+ version = "0.3.0"
4
4
  edition = "2024"
5
5
  rust-version = "1.85"
6
6
  description = "Rust-powered Python bindings for the Climate Risk Commons Framework"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crc-framework
3
- Version: 0.2.6
3
+ Version: 0.3.0
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)
6
6
  Classifier: Operating System :: OS Independent
@@ -121,6 +121,31 @@ Kolmogorov–Smirnov p-value. The supported families are `genextreme`,
121
121
  diagnostics. Passing a tabulated or fitted distribution is an error: those
122
122
  values are not independent observations.
123
123
 
124
+ For a standalone L-moment estimate of GEV or Gumbel observations, select the
125
+ estimator explicitly:
126
+
127
+ ```python
128
+ from crc_framework import fit_distribution
129
+
130
+ result = fit_distribution(annual_extremes, family="genextreme", method="lmoments")
131
+ ```
132
+
133
+ This uses unbiased sample L-moments (Hosking, 1990), with an exact numerical
134
+ solution of the GEV L-skewness equation. It supports `genextreme`, `gumbel_r`
135
+ and `gumbel_l`, requires at least four finite, nonconstant observations, and
136
+ performs no likelihood optimization or automatic family selection. GEV shape
137
+ uses the SciPy convention (`c = -xi`); the finite-mean domain is `c > -1`.
138
+ Degenerate L-skewness is rejected rather than clipped. Fit constraints can
139
+ accept or reject the estimated distribution without refitting it.
140
+
141
+ The native implementation sorts once, uses compensated sums in scaled and
142
+ centered coordinates, and reuses that sort for diagnostics. Work is O(n log n)
143
+ with O(n) storage. The Python binding releases the GIL during native fitting.
144
+ The returned KS p-value is a descriptive diagnostic, not a calibrated hypothesis
145
+ test after parameter estimation. Defaults and the existing sample fitter are
146
+ unchanged. See [NIST's L-moments reference](https://www.itl.nist.gov/div898/software/dataplot/refman2/auxillar/lmoment.htm)
147
+ for background and the Hosking references.
148
+
124
149
  Use `fit_quantiles` for probability/value knots. It minimizes weighted
125
150
  value-space differences between the supplied values and the selected family's
126
151
  PPF, requires an explicit family, and reports residual diagnostics rather than
@@ -95,6 +95,31 @@ Kolmogorov–Smirnov p-value. The supported families are `genextreme`,
95
95
  diagnostics. Passing a tabulated or fitted distribution is an error: those
96
96
  values are not independent observations.
97
97
 
98
+ For a standalone L-moment estimate of GEV or Gumbel observations, select the
99
+ estimator explicitly:
100
+
101
+ ```python
102
+ from crc_framework import fit_distribution
103
+
104
+ result = fit_distribution(annual_extremes, family="genextreme", method="lmoments")
105
+ ```
106
+
107
+ This uses unbiased sample L-moments (Hosking, 1990), with an exact numerical
108
+ solution of the GEV L-skewness equation. It supports `genextreme`, `gumbel_r`
109
+ and `gumbel_l`, requires at least four finite, nonconstant observations, and
110
+ performs no likelihood optimization or automatic family selection. GEV shape
111
+ uses the SciPy convention (`c = -xi`); the finite-mean domain is `c > -1`.
112
+ Degenerate L-skewness is rejected rather than clipped. Fit constraints can
113
+ accept or reject the estimated distribution without refitting it.
114
+
115
+ The native implementation sorts once, uses compensated sums in scaled and
116
+ centered coordinates, and reuses that sort for diagnostics. Work is O(n log n)
117
+ with O(n) storage. The Python binding releases the GIL during native fitting.
118
+ The returned KS p-value is a descriptive diagnostic, not a calibrated hypothesis
119
+ test after parameter estimation. Defaults and the existing sample fitter are
120
+ unchanged. See [NIST's L-moments reference](https://www.itl.nist.gov/div898/software/dataplot/refman2/auxillar/lmoment.htm)
121
+ for background and the Hosking references.
122
+
98
123
  Use `fit_quantiles` for probability/value knots. It minimizes weighted
99
124
  value-space differences between the supplied values and the selected family's
100
125
  PPF, requires an explicit family, and reports residual diagnostics rather than
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "crc-framework-core"
3
- version = "0.2.6"
3
+ version = "0.3.0"
4
4
  edition = "2024"
5
5
  rust-version = "1.85"
6
6
  description = "Core distribution and climate-risk algorithms for CRC Framework"
@@ -0,0 +1,215 @@
1
+ //! Unbiased sample L-moments (Hosking, 1990) for annual extreme families.
2
+ //! One O(n log n) sort, O(n) moment/diagnostic passes, no likelihood optimizer.
3
+
4
+ use super::{DistributionFamily, FittedDistribution};
5
+ use crate::error::{CrcError, Result};
6
+
7
+ const EULER: f64 = 0.577_215_664_901_532_9;
8
+
9
+ // Neumaier summation keeps the signed order-statistic sums accurate.
10
+ #[derive(Default)]
11
+ struct Sum(f64, f64);
12
+ impl Sum {
13
+ fn add(&mut self, x: f64) {
14
+ let next = self.0 + x;
15
+ self.1 += if self.0.abs() >= x.abs() {
16
+ (self.0 - next) + x
17
+ } else {
18
+ (x - next) + self.0
19
+ };
20
+ self.0 = next;
21
+ }
22
+ fn value(&self) -> f64 {
23
+ self.0 + self.1
24
+ }
25
+ }
26
+
27
+ pub(super) fn fit(
28
+ samples: &[f64],
29
+ family: DistributionFamily,
30
+ ) -> Result<(FittedDistribution, Vec<f64>)> {
31
+ if !matches!(
32
+ family,
33
+ DistributionFamily::GenExtreme
34
+ | DistributionFamily::GumbelRight
35
+ | DistributionFamily::GumbelLeft
36
+ ) {
37
+ return Err(CrcError::Unsupported(
38
+ "L-moments supports genextreme, gumbel_r and gumbel_l only".into(),
39
+ ));
40
+ }
41
+ if samples.len() < 4 || samples.iter().any(|x| !x.is_finite()) {
42
+ return Err(CrcError::InvalidInput(
43
+ "L-moments requires at least four finite samples".into(),
44
+ ));
45
+ }
46
+ let mut sorted = samples.to_vec();
47
+ sorted.sort_unstable_by(f64::total_cmp);
48
+ if sorted[0] == sorted[sorted.len() - 1] {
49
+ return Err(CrcError::InvalidInput(
50
+ "L-moments requires nonconstant samples".into(),
51
+ ));
52
+ }
53
+ // Work in centered, bounded coordinates, avoiding overflow and cancellation
54
+ // in records with a large offset or a very small physical scale.
55
+ let magnitude = sorted[0].abs().max(sorted[sorted.len() - 1].abs());
56
+ let offset = sorted[sorted.len() / 2];
57
+ let n = sorted.len() as f64;
58
+ let mut sums = [Sum::default(), Sum::default(), Sum::default()];
59
+ for (i, &x) in sorted.iter().enumerate() {
60
+ let i = i as f64;
61
+ let delta = x - offset;
62
+ let y = if delta.is_finite() {
63
+ delta / magnitude
64
+ } else {
65
+ x / magnitude - offset / magnitude
66
+ };
67
+ let w1 = i / (n - 1.0);
68
+ let w2 = i * (i - 1.0) / ((n - 1.0) * (n - 2.0));
69
+ sums[0].add(y);
70
+ sums[1].add((2.0 * w1 - 1.0) * y);
71
+ sums[2].add((6.0 * w2 - 6.0 * w1 + 1.0) * y);
72
+ }
73
+ let l1 = sums[0].value() / n;
74
+ let l2 = sums[1].value() / n;
75
+ let l3 = sums[2].value() / n;
76
+ if !l2.is_finite() || l2 <= 0.0 {
77
+ return Err(CrcError::InvalidInput(
78
+ "L-moments requires positive L-scale".into(),
79
+ ));
80
+ }
81
+ let (shape, location, scale) = match family {
82
+ DistributionFamily::GumbelRight => {
83
+ let scale = l2 / std::f64::consts::LN_2;
84
+ (None, l1 - EULER * scale, scale)
85
+ }
86
+ DistributionFamily::GumbelLeft => {
87
+ let scale = l2 / std::f64::consts::LN_2;
88
+ (None, l1 + EULER * scale, scale)
89
+ }
90
+ DistributionFamily::GenExtreme => gev_parameters(l1, l2, l3 / l2)?,
91
+ _ => unreachable!(),
92
+ };
93
+ let distribution = FittedDistribution::from_parameters(
94
+ family,
95
+ shape,
96
+ location * magnitude + offset,
97
+ scale * magnitude,
98
+ )?;
99
+ Ok((distribution, sorted))
100
+ }
101
+
102
+ fn gev_skew(shape: f64) -> f64 {
103
+ if shape.abs() < 1e-8 {
104
+ // Continuous limit and first derivative at the Gumbel shape.
105
+ let ratio = 3.0_f64.ln() / std::f64::consts::LN_2;
106
+ return 2.0 * ratio - 3.0 - ratio * (3.0_f64.ln() - std::f64::consts::LN_2) * shape;
107
+ }
108
+ 2.0 * (-shape * 3.0_f64.ln()).exp_m1() / (-shape * std::f64::consts::LN_2).exp_m1() - 3.0
109
+ }
110
+
111
+ fn gev_parameters(l1: f64, l2: f64, skew: f64) -> Result<(Option<f64>, f64, f64)> {
112
+ if !skew.is_finite() || skew.abs() >= 1.0 {
113
+ return Err(CrcError::InvalidInput(
114
+ "GEV L-skewness must be strictly between -1 and 1".into(),
115
+ ));
116
+ }
117
+ // The exact L-skewness equation is monotone for c > -1 (finite mean).
118
+ // Bisection avoids approximation/clipping of strongly skewed samples.
119
+ let mut low = -1.0;
120
+ let mut high = 1.0;
121
+ for _ in 0..7 {
122
+ if gev_skew(high) <= skew {
123
+ break;
124
+ }
125
+ high *= 2.0;
126
+ }
127
+ if gev_skew(high) > skew {
128
+ return Err(CrcError::InvalidInput(
129
+ "GEV L-skewness cannot be resolved numerically".into(),
130
+ ));
131
+ }
132
+ for _ in 0..64 {
133
+ let mid = (low + high) / 2.0;
134
+ if mid == low || mid == high {
135
+ break;
136
+ }
137
+ if gev_skew(mid) > skew {
138
+ low = mid;
139
+ } else {
140
+ high = mid;
141
+ }
142
+ }
143
+ let shape = (low + high) / 2.0;
144
+ if shape.abs() < 1e-10 {
145
+ let scale = l2 / std::f64::consts::LN_2;
146
+ return Ok((Some(0.0), l1 - EULER * scale, scale));
147
+ }
148
+ let log_gamma = log_gamma_one_plus(shape);
149
+ let denominator = -(-shape * std::f64::consts::LN_2).exp_m1();
150
+ let scale = l2 * (shape / denominator) * (-log_gamma).exp();
151
+ // Algebraically scale*(Gamma(1+c)-1)/c, without gamma overflow.
152
+ let location = l1 + l2 / denominator * -(-log_gamma).exp_m1();
153
+ Ok((Some(shape), location, scale))
154
+ }
155
+
156
+ fn log_gamma_one_plus(x: f64) -> f64 {
157
+ if x.abs() < 0.01 {
158
+ // log Gamma(1+x) = -gamma*x + sum (-1)^k zeta(k)*x^k/k.
159
+ return x
160
+ * (-EULER
161
+ + x * (0.822_467_033_424_113_2
162
+ + x * (-0.400_685_634_386_531_4
163
+ + x * (0.270_580_808_427_784_5
164
+ + x * (-0.207_385_551_028_674
165
+ + x * (0.169_557_176_997_408_2 - x * 0.144_049_896_768_846_1))))));
166
+ }
167
+ // Lanczos in log space; recurrence keeps the argument >= 1, even c -> -1.
168
+ let z = 1.0 + x;
169
+ if z < 1.0 {
170
+ return log_gamma_one_plus(x + 1.0) - z.ln();
171
+ }
172
+ let coefficients = [
173
+ 676.520_368_121_885_1,
174
+ -1_259.139_216_722_402_8,
175
+ 771.323_428_777_653_1,
176
+ -176.615_029_162_140_6,
177
+ 12.507_343_278_686_905,
178
+ -0.138_571_095_265_720_12,
179
+ 9.984_369_578_019_572e-6,
180
+ 1.505_632_735_149_311_6e-7,
181
+ ];
182
+ let mut a = 0.999_999_999_999_809_9;
183
+ for (i, coefficient) in coefficients.iter().enumerate() {
184
+ a += coefficient / (x + i as f64 + 1.0);
185
+ }
186
+ let t = x + 7.5;
187
+ 0.918_938_533_204_672_7 + (x + 0.5) * t.ln() - t + a.ln()
188
+ }
189
+
190
+ #[cfg(test)]
191
+ mod tests {
192
+ use super::*;
193
+ #[test]
194
+ fn exact_shape_equation_including_heavy_tails() {
195
+ for shape in [-0.95, -0.7, -0.2, -1e-5, 0.0, 1e-5, 0.2, 1.0, 5.0] {
196
+ let (actual, _, scale) = gev_parameters(2.0, 1.0, gev_skew(shape)).unwrap();
197
+ assert!(
198
+ (actual.unwrap() - shape).abs() < 1e-10,
199
+ "shape {shape}: {actual:?}"
200
+ );
201
+ assert!(scale > 0.0 && scale.is_finite());
202
+ }
203
+ }
204
+ #[test]
205
+ fn small_scales_and_large_offsets() {
206
+ for samples in [
207
+ [1e-20, 2e-20, 3e-20, 4e-20],
208
+ [1e15, 1e15 + 1.0, 1e15 + 2.0, 1e15 + 3.0],
209
+ [-1e308, -5e307, 5e307, 1e308],
210
+ ] {
211
+ let (fit, _) = fit(&samples, DistributionFamily::GumbelRight).unwrap();
212
+ assert!(fit.scale.is_finite() && fit.scale > 0.0);
213
+ }
214
+ }
215
+ }
@@ -7,6 +7,8 @@ use crate::{
7
7
  reference::curve_fitting as reference_curve_fitting,
8
8
  };
9
9
 
10
+ mod lmoments;
11
+
10
12
  const EPS: f64 = 1.0e-12;
11
13
 
12
14
  pub trait Distribution: Send + Sync {
@@ -856,6 +858,18 @@ pub fn fit_distribution(samples: &[f64], family: Option<DistributionFamily>) ->
856
858
  .ok_or_else(|| CrcError::Unsupported("no candidate distribution could be fitted".into()))
857
859
  }
858
860
 
861
+ /// Fit GEV or Gumbel observations by unbiased sample L-moments.
862
+ ///
863
+ /// This is a standalone estimator: no likelihood optimization or family selection.
864
+ /// GEV shape follows the SciPy convention (`c = -xi`).
865
+ pub fn fit_lmoments(samples: &[f64], family: DistributionFamily) -> Result<FitResult> {
866
+ let (distribution, sorted) = lmoments::fit(samples, family)?;
867
+ Ok(FitResult {
868
+ diagnostics: diagnostics_sorted(&sorted, &distribution),
869
+ distribution,
870
+ })
871
+ }
872
+
859
873
  pub fn fit_quantiles(
860
874
  probabilities: &[f64],
861
875
  values: &[f64],
@@ -1057,6 +1071,10 @@ fn reference_diagnostic(diagnostic: reference_curve_fitting::DiagnosticFit) -> R
1057
1071
  fn diagnostics(samples: &[f64], distribution: &dyn Distribution) -> DiagnosticMetrics {
1058
1072
  let mut sorted = samples.to_vec();
1059
1073
  sorted.sort_by(f64::total_cmp);
1074
+ diagnostics_sorted(&sorted, distribution)
1075
+ }
1076
+
1077
+ fn diagnostics_sorted(sorted: &[f64], distribution: &dyn Distribution) -> DiagnosticMetrics {
1060
1078
  let count = sorted.len() as f64;
1061
1079
  let mut ks: f64 = 0.0;
1062
1080
  let mut squared_error = 0.0;
@@ -11,7 +11,7 @@ pub use distribution::{
11
11
  DiagnosticMetrics, Distribution, DistributionFamily, EmpiricalDistribution, FitResult,
12
12
  FittedDistribution, HurdleDistribution, HurdleQuantileFitDiagnostics, HurdleQuantileFitResult,
13
13
  Interpolation, PointMassDistribution, QuantileFitDiagnostics, QuantileFitResult,
14
- TabulatedDistribution, Tail, fit_hurdle_quantiles, fit_quantiles,
14
+ TabulatedDistribution, Tail, fit_hurdle_quantiles, fit_lmoments, fit_quantiles,
15
15
  };
16
16
  pub use error::{CrcError, Result};
17
17
  pub use metrics::{
@@ -122,7 +122,7 @@ def hurdle_distribution(
122
122
  atom_probability: float,
123
123
  atom_location: float = ...,
124
124
  ) -> NativeDistribution: ...
125
- def fit(samples: Sequence[float], family: Optional[str] = ...) -> NativeFitResult: ...
125
+ def fit(samples: Sequence[float], family: Optional[str] = ..., *, method: str = ...) -> NativeFitResult: ...
126
126
  def fit_candidates(samples: Sequence[float]) -> List[NativeFitResult]: ...
127
127
  def fit_quantiles(
128
128
  probabilities: Sequence[float],
@@ -158,9 +158,21 @@ def fit_distribution(
158
158
  *,
159
159
  candidates: Optional[Sequence[DistributionFamily]] = None,
160
160
  selector: str = "ks",
161
+ method: str = "default",
161
162
  constraints: Optional[Union[FitConstraints, Mapping[str, float]]] = None,
162
163
  ) -> FitResult:
163
- """Fit explicitly; metric functions never call this automatically."""
164
+ """Fit observations explicitly; metrics never fit automatically.
165
+
166
+ ``method="default"`` preserves the existing sample fitter.
167
+ ``method="lmoments"`` uses unbiased sample L-moments for an explicit
168
+ ``genextreme``, ``gumbel_r`` or ``gumbel_l`` family, without optimization.
169
+ GEV shape uses the SciPy convention (c = -xi). Automatic family selection
170
+ and candidate lists are unavailable for L-moments.
171
+ """
172
+ if method not in ("default", "lmoments"):
173
+ raise ValueError(f"unknown fitting method {method!r}")
174
+ if method == "lmoments" and (family == "auto" or candidates is not None):
175
+ raise ValueError("L-moments fitting requires an explicit family and no candidates")
164
176
  if selector != "ks":
165
177
  raise ValueError("only the 'ks' selector is currently supported")
166
178
  active_constraints = (
@@ -168,6 +180,11 @@ def fit_distribution(
168
180
  if isinstance(constraints, Mapping)
169
181
  else constraints
170
182
  )
183
+ if method == "lmoments":
184
+ result = _result(_core.fit(_samples(data), family, method=method))
185
+ if active_constraints is not None and not active_constraints.accepts(result):
186
+ raise ValueError("L-moments fit does not satisfy the requested constraints")
187
+ return result
171
188
  if candidates is not None or active_constraints is not None:
172
189
  allowed = (
173
190
  set(candidates)
@@ -354,17 +354,36 @@ fn hurdle_distribution(
354
354
  }
355
355
 
356
356
  #[pyfunction]
357
- #[pyo3(signature = (samples, family=None))]
358
- fn fit(samples: Vec<f64>, family: Option<&str>) -> PyResult<PyFitResult> {
357
+ #[pyo3(signature = (samples, family=None, *, method="default"))]
358
+ fn fit(
359
+ py: Python<'_>,
360
+ samples: Vec<f64>,
361
+ family: Option<&str>,
362
+ method: &str,
363
+ ) -> PyResult<PyFitResult> {
359
364
  let family = family
360
365
  .map(|name| {
361
366
  DistributionFamily::from_name(name)
362
367
  .ok_or_else(|| PyValueError::new_err(format!("unknown distribution family {name}")))
363
368
  })
364
369
  .transpose()?;
365
- crc_framework_core::distribution::fit_distribution(&samples, family)
366
- .map(fit_result_py)
367
- .map_err(py_error)
370
+ let result = match method {
371
+ "default" => {
372
+ py.detach(|| crc_framework_core::distribution::fit_distribution(&samples, family))
373
+ }
374
+ "lmoments" => {
375
+ let family = family.ok_or_else(|| {
376
+ PyValueError::new_err("L-moments fitting requires an explicit family")
377
+ })?;
378
+ py.detach(|| crc_framework_core::distribution::fit_lmoments(&samples, family))
379
+ }
380
+ _ => {
381
+ return Err(PyValueError::new_err(format!(
382
+ "unknown fitting method {method}"
383
+ )));
384
+ }
385
+ };
386
+ result.map(fit_result_py).map_err(py_error)
368
387
  }
369
388
 
370
389
  #[pyfunction]
@@ -0,0 +1,138 @@
1
+ """Independent numerical references for the standalone native estimator."""
2
+
3
+ import math
4
+
5
+ import numpy as np
6
+ import pytest
7
+ from crc_framework import EmpiricalDistribution, FitConstraints, fit_distribution
8
+
9
+
10
+ # Unbiased PWMs evaluated separately in NumPy, with the exact GEV L-skewness
11
+ # equation solved by SciPy brentq and parameters evaluated with scipy.special.gamma.
12
+ # No SciPy runtime dependency is needed to check these fixed reference cases.
13
+ @pytest.mark.parametrize(
14
+ "samples, expected",
15
+ [
16
+ (
17
+ [1, 2, 3, 4, 5, 6, 7, 8],
18
+ (0.2837755261699676, 3.5673697128875066, 2.6485046284617866),
19
+ ),
20
+ (
21
+ [0, 0, 1, 3, 8, 15, 30, 100],
22
+ (-0.6906224839693698, 2.5435518253418365, 6.22052453291623),
23
+ ),
24
+ (
25
+ [1, 2, 2, 3, 5, 8, 13, 21, 34, 55],
26
+ (-0.46999345573819784, 4.659674734457967, 6.785717967583603),
27
+ ),
28
+ (
29
+ [-100, -30, -15, -8, -3, -1, 0, 0],
30
+ (2.343489515427684, -6.78103358608292, 16.64903073472396),
31
+ ),
32
+ ],
33
+ )
34
+ def test_gev_reference_parameters(samples, expected):
35
+ fit = fit_distribution(samples, "genextreme", method="lmoments")
36
+ d = fit.distribution
37
+ np.testing.assert_allclose([d.shape, d.location, d.scale], expected, rtol=2e-12)
38
+ assert all(math.isfinite(x) for x in vars(fit.diagnostics).values())
39
+ # Sorting is internal and the estimator is deterministic.
40
+ other = fit_distribution(
41
+ samples[::-1], "genextreme", method="lmoments"
42
+ ).distribution
43
+ assert (d.shape, d.location, d.scale) == (other.shape, other.location, other.scale)
44
+
45
+
46
+ def test_gumbel_analytic_and_reflected_fit():
47
+ # For [1,...,8], L1 = 4.5 and unbiased L2 = 1.5.
48
+ d = fit_distribution(list(range(1, 9)), "gumbel_r", method="lmoments").distribution
49
+ scale = 1.5 / math.log(2)
50
+ assert d.scale == pytest.approx(scale, rel=1e-14)
51
+ assert d.location == pytest.approx(4.5 - 0.5772156649015329 * scale, rel=1e-14)
52
+ reflected = fit_distribution(
53
+ list(range(-8, 0)), "gumbel_l", method="lmoments"
54
+ ).distribution
55
+ assert reflected.location == pytest.approx(-d.location)
56
+ assert reflected.scale == pytest.approx(d.scale)
57
+
58
+
59
+ @pytest.mark.parametrize("shape", [-0.4, -0.2, 0.0, 0.2, 1.2])
60
+ def test_recovers_population_quantiles(shape):
61
+ from crc_framework import FittedDistribution
62
+
63
+ original = FittedDistribution("genextreme", shape=shape, location=10, scale=3)
64
+ data = original.quantiles((np.arange(20000) + 0.5) / 20000)
65
+ fitted = fit_distribution(data, "genextreme", method="lmoments").distribution
66
+ # Heavy tails converge slowly; compare return levels within the sample record.
67
+ np.testing.assert_allclose(
68
+ fitted.quantiles([0.5, 0.9, 0.99]),
69
+ original.quantiles([0.5, 0.9, 0.99]),
70
+ rtol=0.035,
71
+ )
72
+
73
+
74
+ @pytest.mark.parametrize("family", ["genextreme", "gumbel_r", "gumbel_l"])
75
+ @pytest.mark.parametrize("scale, offset", [(1e-20, 0), (1e20, 1e22), (0.1, 1e5)])
76
+ def test_affine_equivariance(family, scale, offset):
77
+ values = np.array([0, 0, 1, 3, 8, 15, 30, 100], dtype=float)
78
+ base = fit_distribution(values, family, method="lmoments").distribution
79
+ actual = fit_distribution(
80
+ values * scale + offset, family, method="lmoments"
81
+ ).distribution
82
+ assert actual.shape == pytest.approx(base.shape, abs=1e-10)
83
+ assert actual.location == pytest.approx(base.location * scale + offset, rel=1e-10)
84
+ assert actual.scale == pytest.approx(base.scale * scale, rel=1e-10)
85
+
86
+
87
+ @pytest.mark.parametrize(
88
+ "samples", [[], [1, 2, 3], [2] * 8, [0, 1, 2, np.nan], [0, 1, 2, np.inf]]
89
+ )
90
+ def test_invalid_records(samples):
91
+ with pytest.raises(ValueError):
92
+ fit_distribution(samples, "genextreme", method="lmoments")
93
+
94
+
95
+ def test_explicit_policy_and_constraints():
96
+ values = EmpiricalDistribution([1, 2, 3, 4, 5, 6, 7, 8])
97
+ with pytest.raises(ValueError, match="explicit family"):
98
+ fit_distribution(values, method="lmoments")
99
+ with pytest.raises(ValueError, match="no candidates"):
100
+ fit_distribution(values, "gumbel_r", candidates=["gumbel_r"], method="lmoments")
101
+ with pytest.raises(ValueError, match="supports"):
102
+ fit_distribution(values, "genpareto", method="lmoments")
103
+ with pytest.raises(ValueError, match="unknown fitting method"):
104
+ fit_distribution(values, "gumbel_r", method="not-an-estimator")
105
+ fit_distribution(
106
+ values,
107
+ "gumbel_r",
108
+ method="lmoments",
109
+ constraints=FitConstraints(minimum_value=0),
110
+ )
111
+ with pytest.raises(ValueError, match="constraints"):
112
+ fit_distribution(
113
+ values,
114
+ "gumbel_r",
115
+ method="lmoments",
116
+ constraints=FitConstraints(maximum_value=0),
117
+ )
118
+
119
+
120
+ def test_heavy_tail_estimate_converges_with_record_length():
121
+ from crc_framework import FittedDistribution
122
+
123
+ original = FittedDistribution("genextreme", shape=-0.8, location=10, scale=3)
124
+ target = original.ppf(0.99)
125
+ errors = []
126
+ for n in (1000, 20000):
127
+ values = original.quantiles((np.arange(n) + 0.5) / n)
128
+ estimated = fit_distribution(
129
+ values, "genextreme", method="lmoments"
130
+ ).distribution
131
+ errors.append(abs(estimated.ppf(0.99) - target))
132
+ assert errors[1] < errors[0]
133
+
134
+
135
+ @pytest.mark.parametrize("values", [[0, 0, 0, 1], [0, 1, 1, 1]])
136
+ def test_degenerate_skewness_is_rejected(values):
137
+ with pytest.raises(ValueError, match="L-skewness"):
138
+ fit_distribution(values, "genextreme", method="lmoments")