crc-framework 0.2.5__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.5 → crc_framework-0.3.0}/Cargo.lock +2 -2
  2. {crc_framework-0.2.5 → crc_framework-0.3.0}/Cargo.toml +1 -1
  3. {crc_framework-0.2.5 → crc_framework-0.3.0}/PKG-INFO +26 -1
  4. {crc_framework-0.2.5 → crc_framework-0.3.0}/README.md +25 -0
  5. {crc_framework-0.2.5 → 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.5 → crc_framework-0.3.0}/crates/core/src/distribution.rs +395 -11
  8. {crc_framework-0.2.5 → crc_framework-0.3.0}/crates/core/src/lib.rs +1 -1
  9. {crc_framework-0.2.5 → crc_framework-0.3.0}/crates/core/src/reference/curve_fitting.rs +35 -10
  10. {crc_framework-0.2.5 → crc_framework-0.3.0}/python/crc_framework/_core.pyi +1 -1
  11. {crc_framework-0.2.5 → crc_framework-0.3.0}/python/crc_framework/distributions/fitting.py +18 -1
  12. {crc_framework-0.2.5 → crc_framework-0.3.0}/src/lib.rs +24 -5
  13. crc_framework-0.3.0/tests/test_lmoments.py +138 -0
  14. crc_framework-0.3.0/tools/benchmark_lmoments.py +442 -0
  15. crc_framework-0.3.0/tools/benchmarks/lmoments-audit-m2-pro.json +851 -0
  16. crc_framework-0.3.0/tools/requirements-lmoments-benchmark.txt +6 -0
  17. {crc_framework-0.2.5 → crc_framework-0.3.0}/.github/workflows/release.yaml +0 -0
  18. {crc_framework-0.2.5 → crc_framework-0.3.0}/.gitignore +0 -0
  19. {crc_framework-0.2.5 → crc_framework-0.3.0}/.python-version +0 -0
  20. {crc_framework-0.2.5 → crc_framework-0.3.0}/LICENSE +0 -0
  21. {crc_framework-0.2.5 → crc_framework-0.3.0}/THIRD_PARTY_NOTICES.md +0 -0
  22. {crc_framework-0.2.5 → crc_framework-0.3.0}/crates/core/src/constants.rs +0 -0
  23. {crc_framework-0.2.5 → crc_framework-0.3.0}/crates/core/src/error.rs +0 -0
  24. {crc_framework-0.2.5 → crc_framework-0.3.0}/crates/core/src/metrics.rs +0 -0
  25. {crc_framework-0.2.5 → crc_framework-0.3.0}/crates/core/src/reference/climate/ag_params.rs +0 -0
  26. {crc_framework-0.2.5 → crc_framework-0.3.0}/crates/core/src/reference/climate/energy_params.rs +0 -0
  27. {crc_framework-0.2.5 → crc_framework-0.3.0}/crates/core/src/reference/climate/flood_params.rs +0 -0
  28. {crc_framework-0.2.5 → crc_framework-0.3.0}/crates/core/src/reference/climate/mod.rs +0 -0
  29. {crc_framework-0.2.5 → crc_framework-0.3.0}/crates/core/src/reference/climate/precipitation_thresholds.rs +0 -0
  30. {crc_framework-0.2.5 → crc_framework-0.3.0}/crates/core/src/reference/climate/spei_params.rs +0 -0
  31. {crc_framework-0.2.5 → crc_framework-0.3.0}/crates/core/src/reference/climate/wind_params.rs +0 -0
  32. {crc_framework-0.2.5 → crc_framework-0.3.0}/crates/core/src/reference/mod.rs +0 -0
  33. {crc_framework-0.2.5 → crc_framework-0.3.0}/crates/core/src/reference/spatial/continent_r4.rs +0 -0
  34. {crc_framework-0.2.5 → crc_framework-0.3.0}/crates/core/src/reference/spatial/geography_r5.rs +0 -0
  35. {crc_framework-0.2.5 → crc_framework-0.3.0}/crates/core/src/reference/spatial/ipcc.rs +0 -0
  36. {crc_framework-0.2.5 → crc_framework-0.3.0}/crates/core/src/reference/spatial/mod.rs +0 -0
  37. {crc_framework-0.2.5 → crc_framework-0.3.0}/crates/core/src/risk.rs +0 -0
  38. {crc_framework-0.2.5 → crc_framework-0.3.0}/crates/core/src/spatial.rs +0 -0
  39. {crc_framework-0.2.5 → crc_framework-0.3.0}/crates/core/src/transform.rs +0 -0
  40. {crc_framework-0.2.5 → crc_framework-0.3.0}/crates/core/tests/quantile_fit_golden.rs +0 -0
  41. {crc_framework-0.2.5 → crc_framework-0.3.0}/crates/core/tests/quantile_fit_golden.txt +0 -0
  42. {crc_framework-0.2.5 → crc_framework-0.3.0}/pyproject.toml +0 -0
  43. {crc_framework-0.2.5 → crc_framework-0.3.0}/python/crc_framework/__init__.py +0 -0
  44. {crc_framework-0.2.5 → crc_framework-0.3.0}/python/crc_framework/constants.py +0 -0
  45. {crc_framework-0.2.5 → crc_framework-0.3.0}/python/crc_framework/distributions/__init__.py +0 -0
  46. {crc_framework-0.2.5 → crc_framework-0.3.0}/python/crc_framework/distributions/base.py +0 -0
  47. {crc_framework-0.2.5 → crc_framework-0.3.0}/python/crc_framework/distributions/empirical.py +0 -0
  48. {crc_framework-0.2.5 → crc_framework-0.3.0}/python/crc_framework/distributions/fitted.py +0 -0
  49. {crc_framework-0.2.5 → crc_framework-0.3.0}/python/crc_framework/distributions/hurdle.py +0 -0
  50. {crc_framework-0.2.5 → crc_framework-0.3.0}/python/crc_framework/distributions/point_mass.py +0 -0
  51. {crc_framework-0.2.5 → crc_framework-0.3.0}/python/crc_framework/distributions/tabulated.py +0 -0
  52. {crc_framework-0.2.5 → crc_framework-0.3.0}/python/crc_framework/metrics/__init__.py +0 -0
  53. {crc_framework-0.2.5 → crc_framework-0.3.0}/python/crc_framework/metrics/distribution.py +0 -0
  54. {crc_framework-0.2.5 → crc_framework-0.3.0}/python/crc_framework/metrics/microscores.py +0 -0
  55. {crc_framework-0.2.5 → crc_framework-0.3.0}/python/crc_framework/metrics/risk.py +0 -0
  56. {crc_framework-0.2.5 → crc_framework-0.3.0}/python/crc_framework/metrics/spanning.py +0 -0
  57. {crc_framework-0.2.5 → crc_framework-0.3.0}/python/crc_framework/models.py +0 -0
  58. {crc_framework-0.2.5 → crc_framework-0.3.0}/python/crc_framework/py.typed +0 -0
  59. {crc_framework-0.2.5 → crc_framework-0.3.0}/python/crc_framework/spatial.py +0 -0
  60. {crc_framework-0.2.5 → crc_framework-0.3.0}/python/crc_framework/transforms/__init__.py +0 -0
  61. {crc_framework-0.2.5 → crc_framework-0.3.0}/python/crc_framework/transforms/base.py +0 -0
  62. {crc_framework-0.2.5 → crc_framework-0.3.0}/python/crc_framework/transforms/builtin.py +0 -0
  63. {crc_framework-0.2.5 → crc_framework-0.3.0}/python/crc_framework/transforms/climate.py +0 -0
  64. {crc_framework-0.2.5 → crc_framework-0.3.0}/tests/test_impacts.py +0 -0
  65. {crc_framework-0.2.5 → crc_framework-0.3.0}/tests/test_sdk.py +0 -0
  66. {crc_framework-0.2.5 → crc_framework-0.3.0}/tests/typing_smoke.py +0 -0
  67. {crc_framework-0.2.5 → 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.5"
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.5"
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.5"
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.5
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.5"
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
+ }