Skip to content

2.0: modernize stack, replace vendored lmoments with lmoments3, add baseline/annual/SPEI features - #27

Merged
e-baumer merged 14 commits into
masterfrom
modernize-2.0
Aug 12, 2026
Merged

2.0: modernize stack, replace vendored lmoments with lmoments3, add baseline/annual/SPEI features#27
e-baumer merged 14 commits into
masterfrom
modernize-2.0

Conversation

@e-baumer

@e-baumer e-baumer commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Brings the package up to date after several years: it was broken on the modern scientific-Python stack, carried a GPL-3/Apache-2.0 license conflict, and had a backlog of feature requests. All 14 commits are self-contained and reviewable in order; the test suite grew from 23 golden-value tests to 87 tests including a numerical regression gate.

Fixes

  • pandas ≥ 2.0 / numpy ≥ 1.24 breakages: freq="W" (.dt.week removed), best_fit_distribution (normed= removed plus a missing import that made it raise NameError since introduction).
  • pe3 with MLE had been broken for years (infinite recursion caused by scipy's pearson3.fit breaking under subclassing); it works again and reproduces its historical golden value.
  • Silent-wrongness bugs: with scale > 1, unsorted input produced wrong rolling sums (now sorted by date, with a warning for gapped records); a NaN in one precipitation column corrupted other columns' fits; the vendored l-moments code sorted the caller's array in place; calculate() mutated the input DataFrame.
  • Crash bugs: small/empty fitting groups (ZeroDivisionError, **None), duplicate index labels with a baseline period, unconvergeable kappa fits (bare Exception from lmoments3), Wakeby on tiny groups. All now degrade to NaN with one aggregated warning per column.
  • Gamma MLE extreme values (closes Getting extreme values for SPI gamma mle calculation #22): unconstrained-loc MLE on zero-stripped data is ill-posed; gamma MLE now fixes loc=0 unless a constraint is passed. L-moments remains the recommended method.
  • Out-of-support observations map to large finite index values (~±8.2) instead of NaN — important when transforming records against a baseline climatology.

License / dependencies

  • Vendored GPL-3 lmoments code removed (closes @oliverangelil @iameztoy The newest lmoment3 package #23): L-moment fitting now uses the maintained lmoments3 package (>=1.0.7; 1.0.6 produces incorrect wak/gno fits). A committed regression gate proves the swap is numerically identical to 1e-9 across all 19 supported (distribution, fit) combinations. MLE uses plain scipy distributions.
  • New capability from the swap: L-moments fitting for the Generalized Logistic and Generalized Normal distributions (glo is exactly what SPEI wants).
  • Dependency floors are verified by running the suite against the exact minimum versions.

Features

Tooling

  • pyproject.toml (hatchling) + committed uv.lock; setup.py, requirements.txt, and the dead Travis config removed. Python 3.10–3.13.
  • GitHub Actions CI (lint + test matrix across 3.10–3.13, all verified locally) and a PyPI trusted-publishing release workflow. Note: publishing requires a one-time registration of this repo as a trusted publisher on pypi.org (project → Publishing → repo e-baumer/standard_precip, workflow publish.yml, environment pypi).
  • Ruff lint/format; README rewritten (the usage example now runs as written; closes the typo fixes from Fix typos in README #26); CHANGELOG added; example notebook regenerated and fully executed against the new API.

Incorporates the fixes from #24 and #19 — thanks @oshanmodi and @loicduffar. After merging, #19/#20/#24/#26 can be closed.

Breaking changes (2.0.0)

Python ≥ 3.10; vendored standard_precip.lmoments removed; gamma MLE defaults floc=0; fit_distribution/cdf_to_ppf signatures changed (stateless instances); output sorted by date; duplicate-date message is a UserWarning. Full details in CHANGELOG.md.

Collapse 23 copy-paste test functions into a parametrized golden-value table.
Add conftest.py with pathlib-based data fixtures so tests pass from any cwd
(previously they required running from the repo root). Use pytest.approx
with explicit tolerance instead of np.round equality.

pe3-mle is marked xfail: scipy's pearson3_gen.fit uses the legacy
super(type(self), self) idiom which infinitely recurses when subclassed by
the vendored lmoments code — broken since scipy ~1.7, fixed in a later commit.

Verified against the original suite under a 2021-era stack
(numpy 1.23 / pandas 1.5 / scipy 1.9): all golden values unchanged.
…t free

- freq="W": .dt.week was removed in pandas 2.0; use .dt.isocalendar().week
- utils: np.histogram(normed=) was removed in numpy 1.24; use density=True.
  Add the missing lmoments import (best_fit_distribution raised NameError)
- cdf_to_ppf: guard against params=None for all distributions, not only
  gam/pe3 (previously crashed with '** must be a mapping' for small groups)
- lmoments_base: np.asarray(...).sort() mutated the caller's array in place;
  use np.sort, which copies. The old descending pre-sort in calculate() was
  masking this by accident; it is now removed as redundant
- rolling_window_sum and calculate no longer mutate the caller's DataFrame
- min-sample guard derived from the distribution (numargs + 3) instead of a
  hardcoded 4, so Wakeby on tiny groups yields NaN + warning, not ValueError
- fit_distribution/cdf_to_ppf no longer communicate through instance state;
  instances are now reusable and thread-safe. fit_distribution returns
  (distribution, params, p_zero)
- freq_col: the custom-frequency column is now carried into the working frame
  (previously always raised KeyError) and validated
- grow-by-concat loop replaced with list accumulation (pandas 2.x deprecation
  and O(n^2) behavior); duplicate-date print() is now a UserWarning
- wei MLE golden test now constrains floc=0: unconstrained 3-param Weibull MLE
  is ill-posed and scipy >= ~1.12 converges to a degenerate fit

Golden values verified unchanged on both a 2021-era stack (numpy 1.23/
pandas 1.5/scipy 1.9) and a current stack (numpy 2.x/pandas 2.2/scipy 1.15).
Pin the full calculated-index series for all 19 supported (dist, fit)
combinations on monthly_data.csv, generated against the vendored l-moments
code. The next commit swaps in the external lmoments3 package; this gate
proves the swap changes nothing numerically (rel tol 1e-9).
The standard_precip/lmoments/ directory was a GPL-3 copy of lmoments3 inside
an Apache-2.0 project. It is replaced by a dependency on the maintained
lmoments3 package (Ouranosinc, >=1.0.6) plus a small registry adapter
(_distributions.py) that maps the public dist_type strings to fitting
backends (closes #23).

- L-moment fits use lmoments3 (the same Hosking code the vendored copy was
  extracted from). The regression gate passes at rel=1e-9: numerically
  identical.
- MLE fits use plain scipy.stats distributions. Calling .fit() on the
  lmoments3 subclasses recurses infinitely for distributions where scipy's
  fit override uses the legacy super(type(self), self) idiom; this is also
  why pe3/mle had been broken for years. It now works again and reproduces
  its historical golden value.
- New capability: glo and gno support L-moments fitting (Hosking's
  generalized logistic / generalized normal; previously NotImplementedError).
  kap L-moments is exposed too, but its L-moment ratios are unsolvable for
  some samples; such groups now warn and yield NaN instead of raising.
- wak with MLE raises a clear ValueError (it never worked: the tuple result
  was passed to cdf(**params) and crashed).
- pyproject.toml with the hatchling backend replaces setup.py (which listed
  packages with a path separator and shipped no long_description, so the PyPI
  page showed UNKNOWN). requirements.txt and the dead Travis config are gone;
  dev tooling is a uv dependency group (uv sync --group dev), and uv.lock is
  committed.
- Version 2.0.0, Python >= 3.10 (3.9 is EOL), dependencies bounded to the
  versions the code is actually tested against, lmoments3 declared.
- standard_precip/__init__.py: the package previously relied on implicit
  namespace packages with no __init__.py anywhere. Adds __version__ and
  re-exports SPI and BaseStandardIndex, so 'from standard_precip import SPI'
  works as the README always implied.

Verified: uv build; wheel installs into an isolated env and imports; test
suite passes from any working directory.
calculate() gains three optional keyword arguments (closes #18, supersedes
PR #20 - thanks @heroldn for the p_zero-from-baseline insight):

- baseline_start / baseline_end: fit the distributions (and the probability
  of zero precipitation) on a reference period only - integers are years,
  strings/Timestamps are dates, inclusive - then transform the full record.
  This is the standard setup for climate-projection work: fit on a
  historical baseline, apply to the projected record.
- return_params=True: additionally return a tidy dataframe of fitted
  parameters, one row per (column, frequency group), with the observation
  count and p_zero.

The default path is unchanged (regression gate still green).
- freq=None fits a single distribution to the entire column: the correct
  semantics for annual precipitation totals, where there is no seasonal
  cycle to condition on (closes #25). The docstring warns against the
  per-year freq_col mistake, which would leave each year alone in its own
  fitting group.
- freq_col is now actually usable: the column is carried into the working
  frame (it previously always raised KeyError), must exist, and must be an
  integer grouping column. Verified equivalent to freq="M" when the column
  is the calendar month.
- Unknown freq values raise ValueError instead of AttributeError.
spei.py was deleted during the pandas-API rewrite, leaving the package
description ('SPI and SPEI') unfulfilled. SPEI shares the calculation with
BaseStandardIndex; the class docstring documents what actually matters: the
input is the user-computed climatic water balance D = P - PET (PET is not
computed by this package), and because D goes negative the zero-stripping
distributions (gam/pe3) are inappropriate - use dist_type='glo' with
fit_type='lmom' per Vicente-Serrano et al. (2010), which the lmoments3
migration made possible.

'from standard_precip import SPEI' now works.
The extreme SPI values reported for gamma MLE were caused by
unconstrained-loc maximum-likelihood fits on zero-stripped (strictly
positive) data: the location parameter drifts toward or past the data
minimum, degenerating the fit and pushing the CDF toward 0/1. After zero
removal the support is (0, inf) and every published SPI formulation uses a
two-parameter gamma, so loc is now fixed at 0 unless the caller passes
floc/loc explicitly. L-moments remains the recommended fit method (it is
what NCAR's and R's implementations use).

Adds the first tests for plot_index and best_fit_distribution (which never
worked before this branch).
Replaces the long-dead Travis setup. CI lints with ruff and runs the test
suite with coverage across Python 3.10-3.13 using uv. The publish workflow
runs on GitHub releases via PyPI trusted publishing (OIDC); registering the
repo as a trusted publisher on pypi.org is a one-time manual step.
uv run ruff check --fix . && uv run ruff format .
Plus the two non-auto fixes: strict= on zip() in the distribution adapter
and two overlong docstring lines. No behavior change; suite green.
README: fix the broken usage example (undefined variable, wrong column
name), replace the dead Travis badge with CI + PyPI badges, fix typos
(closes #26), document the new features (baseline period, annual mode,
return_params, SPEI), update the distribution table (glo/gno/kap gained
L-moments), note the lmoments3 GPL-3 dependency, and add uv dev setup.

The example notebook is regenerated and fully executed against the new
API, adding baseline, fitted-parameters, annual and SPEI examples.

CHANGELOG.md documents 2.0.0 in Keep-a-Changelog format, including all
breaking/behavior changes.
… floors

Methodology fixes (verified against synthetic reproductions):
- Sort by date before the rolling window: with scale > 1, unsorted input
  produced silently wrong sums. Warn when dates are irregularly spaced,
  since the window would span gaps. Output is now date-sorted.
- Per-column dropna: a NaN in one precipitation column no longer removes
  that row from other columns' fits and outputs.
- Clip the CDF to [1e-16, 1 - 1e-16] (float64 resolution): observations
  outside the fitted distribution's support map to large finite index
  values (~ +/-8.2) instead of NaN - important when transforming records
  against a baseline period. All previously finite values are unchanged;
  the regression golden was regenerated for the 300 previously-NaN cells
  and the change is documented in gen_golden.py and the CHANGELOG.

Code-review fixes:
- Empty gamma/pe3 fitting groups (partial-year baselines with freq D/W)
  yield NaN instead of ZeroDivisionError.
- Fit failures no longer crash calculate(): lmoments3 raises a bare
  Exception('Failed to converge') for some samples, which escaped the
  ValueError-only handler. TypeErrors still propagate.
- Working-copy index is reset, so inputs with duplicate index labels
  (pd.concat without ignore_index) work with baseline periods.
- baseline_start/baseline_end accept numpy integers as years; they were
  previously interpreted as nanosecond timestamps.
- best_fit_distribution fits each candidate exactly as calculate() does
  (zero-stripping, mixed-CDF weighting, gamma MLE floc=0), so its ranking
  matches the model the index actually uses. Zero-handling and MLE
  defaults now live on the immutable distribution registry.
- return_params: n_fit excludes zeros stripped before fitting.
- Unfittable groups warn once per column instead of once per group
  (sparse daily data previously emitted 365 warnings).
- A user freq_col literally named 'freq' is no longer dropped.
- Removed the shared mutable class-level non_zero_distr list.
- Regression-gate docstring documents what the golden pins and when
  regeneration is legitimate.

Compatibility:
- DistSpec uses default_factory for its mapping default; a bare
  MappingProxyType default is rejected by dataclasses on Python <= 3.11.
  Suite verified on 3.10, 3.11, 3.12 and 3.13.
- lmoments3 floor raised to 1.0.7: 1.0.6 produces incorrect Wakeby and
  Generalized Normal L-moment fits (17 test failures). pandas floor
  raised to 2.2 (tests and examples use the 'YE' frequency alias). All
  floors are now verified by running the suite against the exact minimum
  versions.

Also removes all inline comments from the Python sources in favor of
docstrings, and adds tests for every fix (87 tests total).
@e-baumer
e-baumer merged commit 8ffb53f into master Aug 12, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant