climate_indices/lib.rs
1//! Pure-Rust numerical kernels for `climate_indices`.
2//!
3//! The public modules expose numerical kernels, not a complete SPI/SPEI workflow.
4//! Callers prepare calendars, select the Calibration Period, validate physical
5//! ranges, and apply any required aggregation and final index transformation.
6//! Kernels check the structural constraints documented on each function.
7//! This crate has no Python, I/O, logging, or metadata dependency.
8//!
9//! # Arrays and compatibility
10//!
11//! Array inputs borrow `ndarray` views; array outputs own their data.
12//! Other kernels accept scalars or slices and return their documented result types.
13//! Strided views are supported unless a function documents a restriction.
14//! `ndarray` is a public dependency, not a re-export: callers use a compatible
15//! `ndarray` dependency.
16//! An incompatible `ndarray` upgrade requires a breaking crate release.
17//! The crate has no feature flags and no optional runtime dependency.
18//!
19//! # Shapes, calendars, and units
20//!
21//! All array indices are zero-based. A daily block has shape `(days, cells)`.
22//! One series uses one cell; a grid uses one column per independent cell.
23//! Distribution fits use `(years, columns)`, with one column per calendar step
24//! and cell. A caller can use `column = step * cells + cell` consistently for
25//! values and fit parameters. Monthly records have 12 calendar steps per year;
26//! the prepared all-leap daily layout has 366, including a February 29 position
27//! in every year. Kernels do not infer calendar dates or insert missing days.
28//!
29//! Precipitation and PET kernels use mm, temperatures use degrees C, and daily
30//! window lengths count calendar days. PET latitude inputs use radians in
31//! [`eto`] and degrees in [`pm_eto`]. Palmer water-balance inputs instead use
32//! inches. Function documentation specifies other units and preconditions.
33//! NaN handling is kernel-specific; NaN is not a substitute for shape validation.
34//!
35//! # One series and a grid
36//!
37//! [`flood::effective_precipitation`] accepts both layouts without a wrapper or
38//! a copy of the input. Its output has the same shape as the input.
39//!
40//! ```
41//! use climate_indices::{ClimateError, flood::effective_precipitation};
42//! use ndarray::{Axis, array};
43//!
44//! let series_mm = array![1.0, 2.0, 3.0];
45//! let series = effective_precipitation(series_mm.view().insert_axis(Axis(1)), 2)?;
46//! assert_eq!(series.dim(), (3, 1));
47//! assert!(series[[0, 0]].is_nan());
48//! assert_eq!(series[[1, 0]], 3.5);
49//!
50//! let grid_mm = array![[1.0, 10.0], [2.0, 20.0], [3.0, 30.0]];
51//! let grid = effective_precipitation(grid_mm.view(), 2)?;
52//! assert_eq!(grid.dim(), (3, 2));
53//! assert_eq!(grid[[1, 0]], series[[1, 0]]);
54//! assert_eq!(grid[[1, 1]], 35.0);
55//! # Ok::<(), ClimateError>(())
56//! ```
57//!
58//! # Calendar-column fitting
59//!
60//! [`gamma::gamma_parameters`] fits each column independently. This small
61//! synthetic sample illustrates the layout, not a sufficient Calibration Period.
62//! Gamma shape is dimensionless; gamma scale has the input's units (here mm).
63//! The result below is a probability, not SPI.
64//!
65//! ```
66//! use climate_indices::{ClimateError, gamma::{gamma_parameters, gamma_probabilities}};
67//! use ndarray::{Array1, Array2};
68//!
69//! let (years, steps, cells) = (3, 12, 2);
70//! let rain_mm = Array2::from_shape_fn((years, steps * cells), |(year, column)| {
71//! 10.0 * (year + column + 1) as f64
72//! });
73//! let (shape, scale_mm) = gamma_parameters(rain_mm.view());
74//! let probability_of_zero = Array1::zeros(steps * cells);
75//! let probabilities = gamma_probabilities(
76//! rain_mm.view(), shape.view(), scale_mm.view(), probability_of_zero.view(),
77//! )?;
78//! let (year, step, cell) = (1, 5, 1);
79//! assert!((0.0..=1.0).contains(&probabilities[[year, step * cells + cell]]));
80//! # Ok::<(), ClimateError>(())
81//! ```
82//!
83//! # Errors and numerical behavior
84//!
85//! Match [`ClimateError`] variants and fields, not diagnostic text. Public enums
86//! are non-exhaustive, so downstream matches need a wildcard arm. Display text
87//! and diagnostic strings are not stable API and carry no Python-message promise.
88//! `climate-py` owns Python exception translation. The Python implementations
89//! remain the numerical parity oracles. Each public kernel documents its numerical
90//! contract; the measured evidence below distinguishes workflow and scalar errors.
91//! No kernel's operation order changes with this surface.
92
93#![doc = include_str!("../NUMERICS.md")]
94#![forbid(unsafe_code)]
95#![deny(missing_docs)]
96
97use std::fmt;
98
99pub mod eddi;
100pub mod eto;
101pub mod fire;
102pub mod flood;
103pub mod gamma;
104pub mod lmoments;
105pub mod loglogistic;
106pub mod palmer;
107pub mod palmer_pdi;
108pub mod palmer_wells;
109pub mod palmer_zindex;
110pub mod pci;
111pub mod pearson;
112pub mod pm_eto;
113pub mod pnp;
114pub mod recurrence;
115mod reduction;
116pub mod self_calibration;
117pub mod special;
118
119// compiles and runs the README example under `cargo test --doc`
120#[cfg(doctest)]
121#[doc = include_str!("../README.md")]
122struct ReadmeDoctests;
123
124/// Version of this crate, re-exported by the Python extension as `__version__`.
125pub const VERSION: &str = env!("CARGO_PKG_VERSION");
126
127/// A structural input error or a numerical failure reported by a kernel.
128///
129/// Variants and their typed fields form the error contract. Diagnostic strings,
130/// including [`Display`](fmt::Display) output, are not stable identifiers.
131///
132/// ```
133/// use climate_indices::{ClimateError, flood::effective_precipitation};
134/// use ndarray::array;
135///
136/// let rain = array![[1.0]];
137/// match effective_precipitation(rain.view(), 0) {
138/// Err(ClimateError::EmptyPeriod { argument }) => assert_eq!(argument, "duration"),
139/// Err(_) => panic!("unexpected error"),
140/// Ok(_) => panic!("a zero-day window must fail"),
141/// }
142/// ```
143#[derive(Debug, Clone, PartialEq, Eq)]
144#[non_exhaustive]
145pub enum ClimateError {
146 /// An argument whose shape does not match the kernel's expected shape.
147 ShapeMismatch {
148 /// The argument that has the wrong shape.
149 argument: &'static str,
150 /// The expected axis length or total element count, as the kernel specifies.
151 expected: usize,
152 /// The supplied axis length or total element count.
153 actual: usize,
154 },
155 /// A calendar-period parameter with no calendar steps.
156 EmptyPeriod {
157 /// The empty period argument.
158 argument: &'static str,
159 },
160 /// A recurrence step produced a non-finite value from finite inputs.
161 NonFinite {
162 /// The recurrence that failed.
163 index_type: &'static str,
164 },
165 /// A per-day or per-cell index outside the table it selects from.
166 IndexOutOfRange {
167 /// The table-index argument.
168 argument: &'static str,
169 /// The supplied index.
170 value: i64,
171 /// The inclusive lower bound.
172 minimum: i64,
173 /// The inclusive upper bound.
174 maximum: i64,
175 },
176 /// A missing-day policy name a kernel does not implement.
177 UnknownNanPolicy {
178 /// The unrecognized policy name.
179 value: String,
180 },
181 /// A row window (a Calibration Period) that does not lie inside its axis.
182 RowsOutOfRange {
183 /// The row-window argument.
184 argument: &'static str,
185 /// The inclusive first row.
186 start: usize,
187 /// The exclusive last row.
188 end: usize,
189 /// The number of available rows.
190 length: usize,
191 },
192 /// A calibration or recurrence stage that cannot produce a usable value.
193 NoConvergence {
194 /// A diagnostic description, not a stable failure identifier.
195 message: &'static str,
196 },
197 /// A series too short (or too sparse) to fill a rolling window.
198 InsufficientData {
199 /// The minimum number of periods the window needs.
200 required: usize,
201 /// The number of non-missing periods supplied.
202 available: usize,
203 },
204}
205
206impl fmt::Display for ClimateError {
207 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
208 match self {
209 Self::ShapeMismatch {
210 argument,
211 expected,
212 actual,
213 } => write!(f, "{argument} has length {actual}, expected {expected}"),
214 Self::EmptyPeriod { argument } => {
215 write!(f, "{argument} must contain at least one calendar step")
216 }
217 Self::NonFinite { index_type } => {
218 write!(
219 f,
220 "{index_type} produced a non-finite value from finite inputs."
221 )
222 }
223 Self::IndexOutOfRange {
224 argument,
225 value,
226 minimum,
227 maximum,
228 } => write!(
229 f,
230 "{argument} value {value} is outside the table rows [{minimum}, {maximum}]"
231 ),
232 Self::UnknownNanPolicy { value } => {
233 write!(f, "unknown missing-day policy {value:?}")
234 }
235 Self::RowsOutOfRange {
236 argument,
237 start,
238 end,
239 length,
240 } => write!(
241 f,
242 "{argument} rows [{start}, {end}) are outside the {length} available rows"
243 ),
244 Self::NoConvergence { message } => f.write_str(message),
245 Self::InsufficientData {
246 required,
247 available,
248 } => write!(
249 f,
250 "no complete {required}-period rolling window: only {available} non-missing periods were available"
251 ),
252 }
253 }
254}
255
256impl std::error::Error for ClimateError {}