crc-framework 0.2.5__tar.gz → 0.2.6__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 (62) hide show
  1. {crc_framework-0.2.5 → crc_framework-0.2.6}/Cargo.lock +2 -2
  2. {crc_framework-0.2.5 → crc_framework-0.2.6}/Cargo.toml +1 -1
  3. {crc_framework-0.2.5 → crc_framework-0.2.6}/PKG-INFO +1 -1
  4. {crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/Cargo.toml +1 -1
  5. {crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/src/distribution.rs +377 -11
  6. {crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/src/reference/curve_fitting.rs +35 -10
  7. {crc_framework-0.2.5 → crc_framework-0.2.6}/.github/workflows/release.yaml +0 -0
  8. {crc_framework-0.2.5 → crc_framework-0.2.6}/.gitignore +0 -0
  9. {crc_framework-0.2.5 → crc_framework-0.2.6}/.python-version +0 -0
  10. {crc_framework-0.2.5 → crc_framework-0.2.6}/LICENSE +0 -0
  11. {crc_framework-0.2.5 → crc_framework-0.2.6}/README.md +0 -0
  12. {crc_framework-0.2.5 → crc_framework-0.2.6}/THIRD_PARTY_NOTICES.md +0 -0
  13. {crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/src/constants.rs +0 -0
  14. {crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/src/error.rs +0 -0
  15. {crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/src/lib.rs +0 -0
  16. {crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/src/metrics.rs +0 -0
  17. {crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/src/reference/climate/ag_params.rs +0 -0
  18. {crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/src/reference/climate/energy_params.rs +0 -0
  19. {crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/src/reference/climate/flood_params.rs +0 -0
  20. {crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/src/reference/climate/mod.rs +0 -0
  21. {crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/src/reference/climate/precipitation_thresholds.rs +0 -0
  22. {crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/src/reference/climate/spei_params.rs +0 -0
  23. {crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/src/reference/climate/wind_params.rs +0 -0
  24. {crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/src/reference/mod.rs +0 -0
  25. {crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/src/reference/spatial/continent_r4.rs +0 -0
  26. {crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/src/reference/spatial/geography_r5.rs +0 -0
  27. {crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/src/reference/spatial/ipcc.rs +0 -0
  28. {crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/src/reference/spatial/mod.rs +0 -0
  29. {crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/src/risk.rs +0 -0
  30. {crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/src/spatial.rs +0 -0
  31. {crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/src/transform.rs +0 -0
  32. {crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/tests/quantile_fit_golden.rs +0 -0
  33. {crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/tests/quantile_fit_golden.txt +0 -0
  34. {crc_framework-0.2.5 → crc_framework-0.2.6}/pyproject.toml +0 -0
  35. {crc_framework-0.2.5 → crc_framework-0.2.6}/python/crc_framework/__init__.py +0 -0
  36. {crc_framework-0.2.5 → crc_framework-0.2.6}/python/crc_framework/_core.pyi +0 -0
  37. {crc_framework-0.2.5 → crc_framework-0.2.6}/python/crc_framework/constants.py +0 -0
  38. {crc_framework-0.2.5 → crc_framework-0.2.6}/python/crc_framework/distributions/__init__.py +0 -0
  39. {crc_framework-0.2.5 → crc_framework-0.2.6}/python/crc_framework/distributions/base.py +0 -0
  40. {crc_framework-0.2.5 → crc_framework-0.2.6}/python/crc_framework/distributions/empirical.py +0 -0
  41. {crc_framework-0.2.5 → crc_framework-0.2.6}/python/crc_framework/distributions/fitted.py +0 -0
  42. {crc_framework-0.2.5 → crc_framework-0.2.6}/python/crc_framework/distributions/fitting.py +0 -0
  43. {crc_framework-0.2.5 → crc_framework-0.2.6}/python/crc_framework/distributions/hurdle.py +0 -0
  44. {crc_framework-0.2.5 → crc_framework-0.2.6}/python/crc_framework/distributions/point_mass.py +0 -0
  45. {crc_framework-0.2.5 → crc_framework-0.2.6}/python/crc_framework/distributions/tabulated.py +0 -0
  46. {crc_framework-0.2.5 → crc_framework-0.2.6}/python/crc_framework/metrics/__init__.py +0 -0
  47. {crc_framework-0.2.5 → crc_framework-0.2.6}/python/crc_framework/metrics/distribution.py +0 -0
  48. {crc_framework-0.2.5 → crc_framework-0.2.6}/python/crc_framework/metrics/microscores.py +0 -0
  49. {crc_framework-0.2.5 → crc_framework-0.2.6}/python/crc_framework/metrics/risk.py +0 -0
  50. {crc_framework-0.2.5 → crc_framework-0.2.6}/python/crc_framework/metrics/spanning.py +0 -0
  51. {crc_framework-0.2.5 → crc_framework-0.2.6}/python/crc_framework/models.py +0 -0
  52. {crc_framework-0.2.5 → crc_framework-0.2.6}/python/crc_framework/py.typed +0 -0
  53. {crc_framework-0.2.5 → crc_framework-0.2.6}/python/crc_framework/spatial.py +0 -0
  54. {crc_framework-0.2.5 → crc_framework-0.2.6}/python/crc_framework/transforms/__init__.py +0 -0
  55. {crc_framework-0.2.5 → crc_framework-0.2.6}/python/crc_framework/transforms/base.py +0 -0
  56. {crc_framework-0.2.5 → crc_framework-0.2.6}/python/crc_framework/transforms/builtin.py +0 -0
  57. {crc_framework-0.2.5 → crc_framework-0.2.6}/python/crc_framework/transforms/climate.py +0 -0
  58. {crc_framework-0.2.5 → crc_framework-0.2.6}/src/lib.rs +0 -0
  59. {crc_framework-0.2.5 → crc_framework-0.2.6}/tests/test_impacts.py +0 -0
  60. {crc_framework-0.2.5 → crc_framework-0.2.6}/tests/test_sdk.py +0 -0
  61. {crc_framework-0.2.5 → crc_framework-0.2.6}/tests/typing_smoke.py +0 -0
  62. {crc_framework-0.2.5 → crc_framework-0.2.6}/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.2.6"
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.2.6"
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.2.6"
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.2.6
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
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "crc-framework-core"
3
- version = "0.2.5"
3
+ version = "0.2.6"
4
4
  edition = "2024"
5
5
  rust-version = "1.85"
6
6
  description = "Core distribution and climate-risk algorithms for CRC Framework"
@@ -334,6 +334,19 @@ impl DistributionFamily {
334
334
  pub fn from_name(name: &str) -> Option<Self> {
335
335
  Self::ALL.into_iter().find(|family| family.name() == name)
336
336
  }
337
+
338
+ pub(crate) fn from_reference_kind(kind: reference_curve_fitting::DistributionKind) -> Self {
339
+ use reference_curve_fitting::DistributionKind as Kind;
340
+ match kind {
341
+ Kind::GenExtreme => Self::GenExtreme,
342
+ Kind::WeibullMin => Self::WeibullMin,
343
+ Kind::WeibullMax => Self::WeibullMax,
344
+ Kind::SkewNorm => Self::SkewNormal,
345
+ Kind::GumbelR => Self::GumbelRight,
346
+ Kind::GumbelL => Self::GumbelLeft,
347
+ Kind::GenPareto => Self::GenPareto,
348
+ }
349
+ }
337
350
  }
338
351
 
339
352
  #[derive(Debug, Clone)]
@@ -381,6 +394,148 @@ impl FittedDistribution {
381
394
  fn z(&self, x: f64) -> f64 {
382
395
  (x - self.location) / self.scale
383
396
  }
397
+
398
+ /// Upper-tail probability `P(X > x)`.
399
+ ///
400
+ /// Evaluated directly rather than as `1 - cdf(x)`, so it keeps full
401
+ /// relative precision deep in the upper tail where `cdf(x)` rounds to one.
402
+ pub fn sf(&self, x: f64) -> f64 {
403
+ let z = self.z(x);
404
+ match self.family {
405
+ DistributionFamily::GumbelRight => -(-(-z).exp()).exp_m1(),
406
+ DistributionFamily::GumbelLeft => (-z.exp()).exp(),
407
+ DistributionFamily::WeibullMin => {
408
+ if z < 0.0 {
409
+ 1.0
410
+ } else {
411
+ (-z.powf(self.shape.expect("validated"))).exp()
412
+ }
413
+ }
414
+ DistributionFamily::WeibullMax => {
415
+ if z >= 0.0 {
416
+ 0.0
417
+ } else {
418
+ -(-(-z).powf(self.shape.expect("validated"))).exp_m1()
419
+ }
420
+ }
421
+ DistributionFamily::GenPareto => {
422
+ let c = self.shape.expect("validated");
423
+ if z < 0.0 {
424
+ 1.0
425
+ } else if c.abs() < EPS {
426
+ (-z).exp()
427
+ } else {
428
+ let support = 1.0 + c * z;
429
+ if support <= 0.0 {
430
+ 0.0
431
+ } else {
432
+ support.powf(-1.0 / c)
433
+ }
434
+ }
435
+ }
436
+ DistributionFamily::GenExtreme => {
437
+ let c = self.shape.expect("validated");
438
+ if c.abs() < EPS {
439
+ -(-(-z).exp()).exp_m1()
440
+ } else {
441
+ let support = 1.0 - c * z;
442
+ if support <= 0.0 {
443
+ if c > 0.0 { 0.0 } else { 1.0 }
444
+ } else {
445
+ -(-support.powf(1.0 / c)).exp_m1()
446
+ }
447
+ }
448
+ }
449
+ // If X ~ SN(alpha, loc, scale) then -X ~ SN(-alpha, -loc, scale).
450
+ DistributionFamily::SkewNormal => skew_normal_cdf(-z, -self.shape.expect("validated")),
451
+ }
452
+ .clamp(0.0, 1.0)
453
+ }
454
+
455
+ /// Inverse of [`Self::sf`]: the value exceeded with probability `survival`.
456
+ ///
457
+ /// The upper-tail counterpart of `ppf`, accurate for survival
458
+ /// probabilities far below the spacing of `f64` values near one.
459
+ pub fn isf(&self, survival: f64) -> Result<f64> {
460
+ validate_probability(survival)?;
461
+ if survival <= 0.0 || survival >= 1.0 {
462
+ return Err(CrcError::InvalidInput(
463
+ "parametric isf requires probability strictly between zero and one".into(),
464
+ ));
465
+ }
466
+ let value = match self.family {
467
+ DistributionFamily::GumbelRight => {
468
+ self.location - self.scale * (-(-survival).ln_1p()).ln()
469
+ }
470
+ DistributionFamily::GumbelLeft => self.location + self.scale * (-survival.ln()).ln(),
471
+ DistributionFamily::WeibullMin => {
472
+ self.location
473
+ + self.scale * (-survival.ln()).powf(1.0 / self.shape.expect("validated"))
474
+ }
475
+ DistributionFamily::WeibullMax => {
476
+ self.location
477
+ - self.scale * (-(-survival).ln_1p()).powf(1.0 / self.shape.expect("validated"))
478
+ }
479
+ DistributionFamily::GenPareto => {
480
+ let c = self.shape.expect("validated");
481
+ if c.abs() < EPS {
482
+ self.location - self.scale * survival.ln()
483
+ } else {
484
+ self.location + self.scale * (survival.powf(-c) - 1.0) / c
485
+ }
486
+ }
487
+ DistributionFamily::GenExtreme => {
488
+ let c = self.shape.expect("validated");
489
+ // -ln(cdf), formed from the survival probability without rounding
490
+ // the cdf to one first.
491
+ let neg_log_cdf = -(-survival).ln_1p();
492
+ if c.abs() < EPS {
493
+ self.location - self.scale * neg_log_cdf.ln()
494
+ } else {
495
+ self.location + self.scale * (1.0 - neg_log_cdf.powf(c)) / c
496
+ }
497
+ }
498
+ DistributionFamily::SkewNormal => {
499
+ let reflected = Self {
500
+ shape: self.shape.map(|alpha| -alpha),
501
+ location: -self.location,
502
+ ..*self
503
+ };
504
+ -inverse_by_bisection(&reflected, survival)
505
+ }
506
+ };
507
+ Ok(value)
508
+ }
509
+ }
510
+
511
+ /// Quantile of `base` truncated to `(atom, inf)` at `conditional_probability`.
512
+ ///
513
+ /// `atom_cdf` and `atom_sf` are `base.cdf(atom)` and `base.sf(atom)`. The
514
+ /// target is inverted from whichever tail holds less than half the mass, so
515
+ /// the result stays accurate when the atom sits deep in either tail of the
516
+ /// base; e.g. an atom with `sf ~ 1e-12` leaves the whole conditional tail
517
+ /// within a few ULPs of one in cdf space.
518
+ pub(crate) fn truncated_ppf(
519
+ base: &FittedDistribution,
520
+ atom_cdf: f64,
521
+ atom_sf: f64,
522
+ conditional_probability: f64,
523
+ ) -> Result<f64> {
524
+ let survival = (1.0 - conditional_probability) * atom_sf;
525
+ if survival < 0.5 {
526
+ base.isf(survival)
527
+ } else {
528
+ base.ppf(atom_cdf + conditional_probability * atom_sf)
529
+ }
530
+ }
531
+
532
+ /// Conditional cdf of `base` truncated to `(atom, inf)`, evaluated at `x > atom`.
533
+ fn truncated_cdf(base: &FittedDistribution, atom_cdf: f64, atom_sf: f64, x: f64) -> f64 {
534
+ if atom_cdf < atom_sf {
535
+ (base.cdf(x) - atom_cdf) / atom_sf
536
+ } else {
537
+ (atom_sf - base.sf(x)) / atom_sf
538
+ }
384
539
  }
385
540
 
386
541
  impl Distribution for FittedDistribution {
@@ -535,6 +690,7 @@ pub struct HurdleDistribution {
535
690
  atom_probability: f64,
536
691
  base: FittedDistribution,
537
692
  base_cdf_at_atom: f64,
693
+ base_sf_at_atom: f64,
538
694
  }
539
695
 
540
696
  impl HurdleDistribution {
@@ -554,7 +710,14 @@ impl HurdleDistribution {
554
710
  ));
555
711
  }
556
712
  let base_cdf_at_atom = base.cdf(atom_location);
557
- if !base_cdf_at_atom.is_finite() || 1.0 - base_cdf_at_atom <= EPS {
713
+ let base_sf_at_atom = base.sf(atom_location);
714
+ // Only a tail that is empty (or has underflowed) is rejected: the
715
+ // conditional tail is evaluated in survival space, so an atom deep in
716
+ // the base's upper tail remains exact.
717
+ if !base_cdf_at_atom.is_finite()
718
+ || !base_sf_at_atom.is_finite()
719
+ || base_sf_at_atom < f64::MIN_POSITIVE
720
+ {
558
721
  return Err(CrcError::InvalidInput(
559
722
  "hurdle base distribution must have positive probability above the atom".into(),
560
723
  ));
@@ -564,6 +727,7 @@ impl HurdleDistribution {
564
727
  atom_probability,
565
728
  base,
566
729
  base_cdf_at_atom,
730
+ base_sf_at_atom,
567
731
  })
568
732
  }
569
733
 
@@ -593,7 +757,7 @@ impl Distribution for HurdleDistribution {
593
757
  if x <= self.atom_location {
594
758
  0.0
595
759
  } else {
596
- (1.0 - self.atom_probability) * self.base.pdf(x) / (1.0 - self.base_cdf_at_atom)
760
+ (1.0 - self.atom_probability) * self.base.pdf(x) / self.base_sf_at_atom
597
761
  }
598
762
  }
599
763
 
@@ -603,9 +767,9 @@ impl Distribution for HurdleDistribution {
603
767
  } else if x == self.atom_location {
604
768
  self.atom_probability
605
769
  } else {
606
- (self.atom_probability
607
- + (1.0 - self.atom_probability) * (self.base.cdf(x) - self.base_cdf_at_atom)
608
- / (1.0 - self.base_cdf_at_atom))
770
+ let conditional =
771
+ truncated_cdf(&self.base, self.base_cdf_at_atom, self.base_sf_at_atom, x);
772
+ (self.atom_probability + (1.0 - self.atom_probability) * conditional)
609
773
  .clamp(self.atom_probability, 1.0)
610
774
  }
611
775
  }
@@ -621,9 +785,12 @@ impl Distribution for HurdleDistribution {
621
785
  ));
622
786
  }
623
787
  let conditional_probability = (q - self.atom_probability) / (1.0 - self.atom_probability);
624
- let base_probability =
625
- self.base_cdf_at_atom + conditional_probability * (1.0 - self.base_cdf_at_atom);
626
- self.base.ppf(base_probability.min(1.0 - EPS))
788
+ truncated_ppf(
789
+ &self.base,
790
+ self.base_cdf_at_atom,
791
+ self.base_sf_at_atom,
792
+ conditional_probability,
793
+ )
627
794
  }
628
795
  }
629
796
 
@@ -811,9 +978,12 @@ fn fit_quantile_points_internal(
811
978
  .iter()
812
979
  .map(|&probability| {
813
980
  if let Some(atom) = truncation_atom {
814
- let atom_cdf = distribution.cdf(atom);
815
- let base_probability = (atom_cdf + probability * (1.0 - atom_cdf)).min(1.0 - EPS);
816
- distribution.ppf(base_probability)
981
+ truncated_ppf(
982
+ &distribution,
983
+ distribution.cdf(atom),
984
+ distribution.sf(atom),
985
+ probability,
986
+ )
817
987
  } else {
818
988
  distribution.ppf(probability)
819
989
  }
@@ -1345,6 +1515,202 @@ mod tests {
1345
1515
  assert!(statistics.mean > 0.0);
1346
1516
  }
1347
1517
 
1518
+ fn tail_test_distributions() -> Vec<FittedDistribution> {
1519
+ [
1520
+ (DistributionFamily::GumbelRight, None),
1521
+ (DistributionFamily::GumbelLeft, None),
1522
+ (DistributionFamily::GenExtreme, Some(0.0)),
1523
+ (DistributionFamily::GenExtreme, Some(0.2)),
1524
+ (DistributionFamily::GenExtreme, Some(-0.2)),
1525
+ (DistributionFamily::WeibullMin, Some(1.5)),
1526
+ (DistributionFamily::WeibullMax, Some(2.0)),
1527
+ (DistributionFamily::GenPareto, Some(0.0)),
1528
+ (DistributionFamily::GenPareto, Some(0.2)),
1529
+ (DistributionFamily::GenPareto, Some(-0.2)),
1530
+ (DistributionFamily::SkewNormal, Some(3.0)),
1531
+ ]
1532
+ .into_iter()
1533
+ .map(|(family, shape)| {
1534
+ FittedDistribution::from_parameters(family, shape, 1.5, 2.5).unwrap()
1535
+ })
1536
+ .collect()
1537
+ }
1538
+
1539
+ #[test]
1540
+ fn survival_function_complements_cdf_and_inverts_for_every_family() {
1541
+ for distribution in tail_test_distributions() {
1542
+ // The skew-normal cdf is a quadrature over an erf approximation.
1543
+ let (tolerance, isf_tolerance) =
1544
+ if distribution.family == DistributionFamily::SkewNormal {
1545
+ (1.0e-6, 1.0e-5)
1546
+ } else {
1547
+ (1.0e-12, 1.0e-8)
1548
+ };
1549
+ for q in [0.001, 0.05, 0.3, 0.5, 0.7, 0.95, 0.999] {
1550
+ let x = distribution.ppf(q).unwrap();
1551
+ let survival = distribution.sf(x);
1552
+ assert!(
1553
+ (survival + distribution.cdf(x) - 1.0).abs() < tolerance,
1554
+ "{:?} sf+cdf at q={q}",
1555
+ distribution.family
1556
+ );
1557
+ assert!(
1558
+ (survival - (1.0 - q)).abs() < tolerance,
1559
+ "{:?} sf at q={q}",
1560
+ distribution.family
1561
+ );
1562
+ let inverted = distribution.isf(1.0 - q).unwrap();
1563
+ assert!(
1564
+ (inverted - x).abs() < isf_tolerance * x.abs().max(1.0),
1565
+ "{:?} isf at q={q}: {inverted} vs {x}",
1566
+ distribution.family
1567
+ );
1568
+ }
1569
+ assert!(distribution.isf(0.0).is_err());
1570
+ assert!(distribution.isf(1.0).is_err());
1571
+ }
1572
+ }
1573
+
1574
+ #[test]
1575
+ fn survival_function_keeps_relative_precision_deep_in_the_upper_tail() {
1576
+ for distribution in tail_test_distributions() {
1577
+ if distribution.family == DistributionFamily::SkewNormal {
1578
+ continue;
1579
+ }
1580
+ // Far below the ~1.1e-16 spacing of f64 values just under one.
1581
+ for survival in [1.0e-13, 1.0e-20, 1.0e-40] {
1582
+ let Ok(x) = distribution.isf(survival) else {
1583
+ panic!("{:?} isf({survival})", distribution.family);
1584
+ };
1585
+ if !x.is_finite() {
1586
+ // Bounded upper supports (e.g. weibull_max) end first.
1587
+ continue;
1588
+ }
1589
+ let round_trip = distribution.sf(x);
1590
+ if distribution.family == DistributionFamily::WeibullMax
1591
+ || distribution.family == DistributionFamily::GenPareto
1592
+ && distribution.shape == Some(-0.2)
1593
+ || distribution.family == DistributionFamily::GenExtreme
1594
+ && distribution.shape == Some(0.2)
1595
+ {
1596
+ // Bounded above: the survival round-trips through a value
1597
+ // squeezed against the upper endpoint.
1598
+ assert!(round_trip <= 1.0e-12, "{:?}", distribution.family);
1599
+ continue;
1600
+ }
1601
+ assert!(
1602
+ ((round_trip - survival) / survival).abs() < 1.0e-9,
1603
+ "{:?} sf(isf({survival})) = {round_trip}",
1604
+ distribution.family
1605
+ );
1606
+ }
1607
+ }
1608
+ let gumbel =
1609
+ FittedDistribution::from_parameters(DistributionFamily::GumbelRight, None, 0.0, 1.0)
1610
+ .unwrap();
1611
+ assert_eq!(gumbel.cdf(40.0), 1.0);
1612
+ assert!(((gumbel.sf(40.0) - (-40.0f64).exp()) / (-40.0f64).exp()).abs() < 1.0e-15);
1613
+ }
1614
+
1615
+ /// Ottawa hot_days (852b83b3fffffff, ">4 degrees", 2050) as published in the
1616
+ /// canonical dataset: the atom sits ~27.6 scales into the base's upper tail,
1617
+ /// leaving 1.0232e-12 of base mass above it. Capping the base probability at
1618
+ /// `1 - 1e-12` collapsed every return period to ~0.41 days.
1619
+ fn ottawa_hot_days() -> HurdleDistribution {
1620
+ let base = FittedDistribution::from_parameters(
1621
+ DistributionFamily::GumbelRight,
1622
+ None,
1623
+ -489.120_459_721_483_3,
1624
+ 17.716_588_490_591_306,
1625
+ )
1626
+ .unwrap();
1627
+ HurdleDistribution::new(0.0, 0.032, base).unwrap()
1628
+ }
1629
+
1630
+ #[test]
1631
+ fn hurdle_quantiles_are_exact_when_the_atom_is_deep_in_the_base_tail() {
1632
+ let hurdle = ottawa_hot_days();
1633
+ // Reference values from 60-digit arithmetic on the stored parameters.
1634
+ for (return_period, expected) in [
1635
+ (10.0, 40.217_752_553_305_5),
1636
+ (25.0, 56.451_298_387_663_2),
1637
+ (100.0, 81.011_705_110_451_8),
1638
+ (1000.0, 121.805_657_667_597),
1639
+ ] {
1640
+ let probability = 1.0 - 1.0 / return_period;
1641
+ let value = hurdle.ppf(probability).unwrap();
1642
+ assert!(
1643
+ (value - expected).abs() < 1.0e-9,
1644
+ "RP{return_period}: {value} vs {expected}"
1645
+ );
1646
+ assert!((hurdle.cdf(value) - probability).abs() < 1.0e-12);
1647
+ }
1648
+ assert_eq!(hurdle.ppf(0.032).unwrap(), 0.0);
1649
+ let values = hurdle
1650
+ .quantiles(&[0.05, 0.2, 0.5, 0.8, 0.95, 0.999, 0.999_999])
1651
+ .unwrap();
1652
+ assert!(values.windows(2).all(|pair| pair[0] < pair[1]));
1653
+ // The continuous part is the base's (near-exponential) upper tail.
1654
+ let continuous_mass = {
1655
+ let steps = 20_000;
1656
+ let upper = values[values.len() - 1];
1657
+ let width = upper / steps as f64;
1658
+ (0..steps)
1659
+ .map(|index| hurdle.pdf((index as f64 + 0.5) * width) * width)
1660
+ .sum::<f64>()
1661
+ };
1662
+ assert!((continuous_mass - 0.968).abs() < 1.0e-4);
1663
+ }
1664
+
1665
+ #[test]
1666
+ fn hurdle_accepts_an_atom_beyond_the_former_tail_guard() {
1667
+ // cdf(atom) rounds to one here, but the survival mass is ~5.2e-17.
1668
+ let base =
1669
+ FittedDistribution::from_parameters(DistributionFamily::GumbelRight, None, -37.5, 1.0)
1670
+ .unwrap();
1671
+ assert_eq!(base.cdf(0.0), 1.0);
1672
+ let hurdle = HurdleDistribution::new(0.0, 0.25, base).unwrap();
1673
+ // The truncated tail is the unit exponential to within ~1e-15.
1674
+ for q in [0.3, 0.6, 0.9, 0.99] {
1675
+ let conditional: f64 = (q - 0.25) / 0.75;
1676
+ let expected = -(-conditional).ln_1p();
1677
+ assert!((hurdle.ppf(q).unwrap() - expected).abs() < 1.0e-9);
1678
+ }
1679
+ let empty =
1680
+ FittedDistribution::from_parameters(DistributionFamily::GumbelRight, None, -800.0, 1.0)
1681
+ .unwrap();
1682
+ assert!(HurdleDistribution::new(0.0, 0.25, empty).is_err());
1683
+ }
1684
+
1685
+ #[test]
1686
+ fn hurdle_quantile_fitting_scores_a_deep_tail_atom_on_its_true_quantiles() {
1687
+ let source = ottawa_hot_days();
1688
+ // The curve_fit_cdf knot grid, from the last plateau knot at the atom.
1689
+ let probabilities: Vec<f64> = (32..=999).map(|index| index as f64 / 1000.0).collect();
1690
+ let values = source.quantiles(&probabilities).unwrap();
1691
+ assert_eq!(values[0], 0.0);
1692
+ let result = fit_hurdle_quantiles(
1693
+ &probabilities,
1694
+ &values,
1695
+ None,
1696
+ DistributionFamily::GumbelRight,
1697
+ 0.0,
1698
+ 0.032,
1699
+ )
1700
+ .unwrap();
1701
+ // The truncated base is only identified up to its tail scale here.
1702
+ assert!((result.distribution.base().scale - 17.716_588_490_591_306).abs() < 1.0e-3);
1703
+ assert!(result.diagnostics.tail.normalized_rmse < 1.0e-4);
1704
+ for probability in [0.5, 0.9, 0.99] {
1705
+ let fitted = result.distribution.ppf(probability).unwrap();
1706
+ let expected = source.ppf(probability).unwrap();
1707
+ assert!(
1708
+ (fitted - expected).abs() < 1.0e-2,
1709
+ "{probability}: {fitted} vs {expected}"
1710
+ );
1711
+ }
1712
+ }
1713
+
1348
1714
  #[test]
1349
1715
  fn hurdle_quantile_fitting_uses_supplied_atom_probability() {
1350
1716
  let base =
@@ -10,6 +10,9 @@
10
10
 
11
11
  use std::f64::consts::{PI, SQRT_2};
12
12
 
13
+ use crate::distribution::{
14
+ Distribution as _, DistributionFamily, FittedDistribution as TailDistribution, truncated_ppf,
15
+ };
13
16
  use crate::risk::climate::utility::get_hazard_limit;
14
17
 
15
18
  // Numeric constants
@@ -429,15 +432,36 @@ fn quantile_objective(
429
432
  else {
430
433
  return f64::INFINITY;
431
434
  };
432
- let tail_start = truncation_atom.map(|atom| distribution.cdf(atom));
433
- if tail_start.is_some_and(|cdf| !cdf.is_finite() || 1.0 - cdf <= 1.0e-12) {
434
- return f64::INFINITY;
435
- }
435
+ // The truncated tail is scored with the same survival-space inversion a
436
+ // stored hurdle curve is read back with, so an atom deep in the base's
437
+ // upper tail (where the whole tail rounds to one in cdf space) is fitted
438
+ // against the curve callers will actually evaluate.
439
+ let tail = match truncation_atom {
440
+ None => None,
441
+ Some(atom) => {
442
+ let Ok(base) = TailDistribution::from_parameters(
443
+ DistributionFamily::from_reference_kind(kind),
444
+ shape,
445
+ loc,
446
+ scale,
447
+ ) else {
448
+ return f64::INFINITY;
449
+ };
450
+ let atom_cdf = base.cdf(atom);
451
+ let atom_sf = base.sf(atom);
452
+ // Beyond this the truncated base has degenerated into its limiting
453
+ // upper tail and `loc` is no longer identified by the knots, so the
454
+ // simplex is kept out of that ridge.
455
+ if !atom_cdf.is_finite() || !(atom_sf > 1.0e-12) {
456
+ return f64::INFINITY;
457
+ }
458
+ Some((base, atom_cdf, atom_sf))
459
+ }
460
+ };
436
461
  // The truncation branch is loop-invariant, so it is resolved once here
437
- // rather than per knot. Both arms compute exactly what the single combined
438
- // loop computed, in the same order.
462
+ // rather than per knot.
439
463
  let mut loss = 0.0;
440
- match tail_start {
464
+ match tail {
441
465
  None => {
442
466
  for ((&probability, &value), &weight) in probabilities.iter().zip(values).zip(weights) {
443
467
  if weight == 0.0 {
@@ -450,13 +474,14 @@ fn quantile_objective(
450
474
  loss += weight * ((fitted - value) / value_scale).powi(2);
451
475
  }
452
476
  }
453
- Some(cdf) => {
477
+ Some((base, atom_cdf, atom_sf)) => {
454
478
  for ((&probability, &value), &weight) in probabilities.iter().zip(values).zip(weights) {
455
479
  if weight == 0.0 {
456
480
  continue;
457
481
  }
458
- let base_probability = (cdf + probability * (1.0 - cdf)).min(1.0 - 1.0e-15);
459
- let fitted = distribution.ppf(base_probability);
482
+ let Ok(fitted) = truncated_ppf(&base, atom_cdf, atom_sf, probability) else {
483
+ return f64::INFINITY;
484
+ };
460
485
  if !fitted.is_finite() {
461
486
  return f64::INFINITY;
462
487
  }
File without changes
File without changes
File without changes
File without changes
File without changes