africas2s 0.2.0__py3-none-any.whl

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 (84) hide show
  1. africas2s/__init__.py +100 -0
  2. africas2s/_optional.py +10 -0
  3. africas2s/_spatial.py +30 -0
  4. africas2s/_warnings.py +30 -0
  5. africas2s/aggregations/__init__.py +21 -0
  6. africas2s/aggregations/_runs.py +73 -0
  7. africas2s/aggregations/spells.py +93 -0
  8. africas2s/aggregations/timing.py +446 -0
  9. africas2s/analog.py +457 -0
  10. africas2s/calibrate.py +1025 -0
  11. africas2s/climate.py +440 -0
  12. africas2s/combine.py +223 -0
  13. africas2s/compare.py +162 -0
  14. africas2s/completion.py +409 -0
  15. africas2s/cv.py +145 -0
  16. africas2s/downscale.py +85 -0
  17. africas2s/ensemble.py +454 -0
  18. africas2s/flex_forecast.py +129 -0
  19. africas2s/indices.py +524 -0
  20. africas2s/io.py +94 -0
  21. africas2s/logistic.py +337 -0
  22. africas2s/methods/__init__.py +6 -0
  23. africas2s/methods/_qm_kernel.py +118 -0
  24. africas2s/methods/base.py +84 -0
  25. africas2s/methods/bcsd.py +70 -0
  26. africas2s/methods/cca.py +525 -0
  27. africas2s/methods/chelsa.py +337 -0
  28. africas2s/methods/climatology.py +45 -0
  29. africas2s/methods/corrdiff.py +367 -0
  30. africas2s/methods/delta.py +39 -0
  31. africas2s/methods/dqm.py +86 -0
  32. africas2s/methods/ensemble_regression.py +255 -0
  33. africas2s/methods/qm.py +101 -0
  34. africas2s/methods/rank_analog.py +136 -0
  35. africas2s/methods/smoothed_regression.py +170 -0
  36. africas2s/metrics/__init__.py +12 -0
  37. africas2s/metrics/base.py +6 -0
  38. africas2s/metrics/cross_validation.py +71 -0
  39. africas2s/metrics/crpss.py +45 -0
  40. africas2s/metrics/generalized_roc.py +126 -0
  41. africas2s/metrics/heidke.py +70 -0
  42. africas2s/metrics/msss.py +30 -0
  43. africas2s/metrics/pearson.py +27 -0
  44. africas2s/metrics/reliability.py +71 -0
  45. africas2s/metrics/rmse.py +23 -0
  46. africas2s/metrics/roc.py +79 -0
  47. africas2s/metrics/rpss.py +144 -0
  48. africas2s/metrics/significance.py +107 -0
  49. africas2s/metrics/spearman.py +64 -0
  50. africas2s/metrics/spread_error.py +117 -0
  51. africas2s/metrics/two_afc.py +61 -0
  52. africas2s/optimize.py +103 -0
  53. africas2s/pev.py +66 -0
  54. africas2s/pipelines/__init__.py +3 -0
  55. africas2s/pipelines/seasonal.py +798 -0
  56. africas2s/plotting/__init__.py +47 -0
  57. africas2s/plotting/domains.py +81 -0
  58. africas2s/plotting/forecasts.py +733 -0
  59. africas2s/plotting/maps.py +435 -0
  60. africas2s/plotting/modes.py +229 -0
  61. africas2s/plotting/reliability.py +73 -0
  62. africas2s/plotting/scenarios.py +339 -0
  63. africas2s/plotting/skill.py +83 -0
  64. africas2s/plotting/style.py +41 -0
  65. africas2s/registry.py +55 -0
  66. africas2s/reporting/__init__.py +8 -0
  67. africas2s/reporting/_pages.py +541 -0
  68. africas2s/reporting/svslrf.py +57 -0
  69. africas2s/series.py +202 -0
  70. africas2s/skill.py +192 -0
  71. africas2s/strategies/__init__.py +10 -0
  72. africas2s/strategies/base.py +25 -0
  73. africas2s/strategies/bma.py +132 -0
  74. africas2s/strategies/drop_worst.py +103 -0
  75. africas2s/strategies/skill_weighted.py +62 -0
  76. africas2s/strategies/uniform.py +25 -0
  77. africas2s/tercile.py +335 -0
  78. africas2s/terrain.py +255 -0
  79. africas2s/time.py +346 -0
  80. africas2s/training/__init__.py +53 -0
  81. africas2s-0.2.0.dist-info/METADATA +285 -0
  82. africas2s-0.2.0.dist-info/RECORD +84 -0
  83. africas2s-0.2.0.dist-info/WHEEL +4 -0
  84. africas2s-0.2.0.dist-info/licenses/LICENSE +21 -0
africas2s/__init__.py ADDED
@@ -0,0 +1,100 @@
1
+ from .downscale import downscale
2
+ from .optimize import optimize
3
+ from .ensemble import ensemble, pool_ensembles
4
+ from .skill import skill, SkillReport
5
+ from .compare import skill_compare, ComparisonReport
6
+ from .pev import prediction_error_variance
7
+ from .flex_forecast import flex_forecast, FlexForecastResult
8
+ from .pipelines import seasonal_mme, SeasonalMMEResult
9
+ from .indices import Index
10
+ from .calibrate import calibrate, CalibrateResult, LogitConfig
11
+ from .climate import (
12
+ accumulate,
13
+ frequency_below,
14
+ percent_of_normal,
15
+ percentile_of,
16
+ rank_of_record,
17
+ seasonal_reduce,
18
+ seasonal_stack,
19
+ )
20
+ from .analog import (
21
+ AnalogSet, analogs_from_years, analogs_from_index, analogs_from_field, analogs_where,
22
+ )
23
+ from .completion import complete, CompletionResult
24
+ from .series import quantile_map, error_bounds, ErrorBounds
25
+ from .methods.smoothed_regression import seasonal_coefficients
26
+ from .io import write_terciles, tercile_mae
27
+ from .terrain import load_terrain
28
+ from .combine import combine_terciles, mask_by_skill, dry_mask
29
+ from .plotting.forecasts import plot_tercile_forecast as plot_terciles
30
+ from .plotting.scenarios import plot_accumulation_scenarios, plot_index_scatter
31
+ from .plotting.maps import natural_earth_borders, plot_field_map, plot_choropleth
32
+ from .plotting.style import TercileStyle
33
+ from .plotting.forecasts import plot_field, plot_tercile_comparison, render_styled_terciles
34
+ from . import methods # trigger registration
35
+ from . import metrics # trigger registration
36
+ from .metrics import loo_predict, loo_corr, permutation_test, fdr
37
+ from . import strategies # trigger registration
38
+ from . import time # calendar / season-step utilities
39
+ from . import aggregations # daily-rainfall season timing and dry spells
40
+ from . import training # train / inference separation (§10.2)
41
+ from .training import train
42
+
43
+ __all__ = [
44
+ "downscale",
45
+ "train",
46
+ "optimize",
47
+ "ensemble",
48
+ "pool_ensembles",
49
+ "skill",
50
+ "SkillReport",
51
+ "skill_compare",
52
+ "ComparisonReport",
53
+ "prediction_error_variance",
54
+ "flex_forecast",
55
+ "FlexForecastResult",
56
+ "seasonal_mme",
57
+ "SeasonalMMEResult",
58
+ "Index",
59
+ "calibrate",
60
+ "CalibrateResult",
61
+ "LogitConfig",
62
+ "accumulate",
63
+ "frequency_below",
64
+ "percent_of_normal",
65
+ "percentile_of",
66
+ "rank_of_record",
67
+ "seasonal_reduce",
68
+ "seasonal_stack",
69
+ "AnalogSet",
70
+ "analogs_from_years",
71
+ "analogs_from_index",
72
+ "analogs_from_field",
73
+ "analogs_where",
74
+ "complete",
75
+ "CompletionResult",
76
+ "quantile_map",
77
+ "error_bounds",
78
+ "ErrorBounds",
79
+ "seasonal_coefficients",
80
+ "write_terciles",
81
+ "tercile_mae",
82
+ "load_terrain",
83
+ "combine_terciles",
84
+ "mask_by_skill",
85
+ "dry_mask",
86
+ "loo_predict",
87
+ "loo_corr",
88
+ "permutation_test",
89
+ "fdr",
90
+ "plot_terciles",
91
+ "plot_accumulation_scenarios",
92
+ "plot_index_scatter",
93
+ "plot_field_map",
94
+ "plot_choropleth",
95
+ "natural_earth_borders",
96
+ "TercileStyle",
97
+ "plot_field",
98
+ "render_styled_terciles",
99
+ "plot_tercile_comparison",
100
+ ]
africas2s/_optional.py ADDED
@@ -0,0 +1,10 @@
1
+ def require_optional(package_name, install_hint=None):
2
+ """Import and return a package, raising a clear error if missing."""
3
+ try:
4
+ return __import__(package_name)
5
+ except ImportError:
6
+ hint = install_hint or f"pip install {package_name}"
7
+ raise ImportError(
8
+ f"{package_name} is required for this feature. "
9
+ f"Install with: {hint}"
10
+ )
africas2s/_spatial.py ADDED
@@ -0,0 +1,30 @@
1
+ """Shared lat/lon dimension resolution for gridded DataArrays.
2
+
3
+ A single place to recognize the spatial-axis naming conventions africas2s
4
+ accepts (lat/latitude/Y/y and lon/longitude/X/x), so every entry point —
5
+ indices, ensemble regression, logistic calibration, plotting — agrees on which
6
+ dims are spatial. Adding a new alias here reaches all of them at once.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import xarray as xr
11
+
12
+ _LAT_ALIASES = ("lat", "latitude", "Y", "y")
13
+ _LON_ALIASES = ("lon", "longitude", "X", "x")
14
+
15
+
16
+ def spatial_dims(da: xr.DataArray, *, context: str = "data") -> tuple[str, str]:
17
+ """Return the ``(lat_dim, lon_dim)`` names on ``da``.
18
+
19
+ Resolves the common aliases (lat/latitude/Y/y and lon/longitude/X/x).
20
+ Raises ``ValueError`` naming ``context`` when either axis can't be found.
21
+ """
22
+ lat = next((d for d in _LAT_ALIASES if d in da.dims), None)
23
+ lon = next((d for d in _LON_ALIASES if d in da.dims), None)
24
+ if lat is None or lon is None:
25
+ raise ValueError(
26
+ f"{context} could not find lat/lon dims on data with dims "
27
+ f"{tuple(da.dims)}; expected one of {_LAT_ALIASES} and "
28
+ f"{_LON_ALIASES}."
29
+ )
30
+ return lat, lon
africas2s/_warnings.py ADDED
@@ -0,0 +1,30 @@
1
+ """Scoped suppression of numpy's benign "All-NaN slice encountered" warning.
2
+
3
+ AfricaS2S computes per-cell statistics (tercile boundaries via ``xarray.quantile``,
4
+ climatologies via ``nanmean``, logit labels via ``nanpercentile``) over gridded
5
+ fields whose masked cells — ocean, out-of-region, dry-masked — are legitimately
6
+ all-NaN along the reduction axis. numpy warns "All-NaN slice encountered" on those
7
+ slices even though NaN-in / NaN-out is exactly the intended result (the cell has
8
+ no forecast and is masked downstream).
9
+
10
+ Decorate a public entry point with :func:`quiet_all_nan_slices` to silence just
11
+ that one message for the duration of the call. It uses ``catch_warnings`` so the
12
+ process-wide warning filters are restored on exit — no global state is mutated,
13
+ and only this specific benign message is affected.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import functools
18
+ import warnings
19
+
20
+
21
+ def quiet_all_nan_slices(fn):
22
+ """Wrap ``fn`` so numpy's "All-NaN slice encountered" RuntimeWarning is
23
+ suppressed while it runs (masked grid cells are expected to be all-NaN)."""
24
+ @functools.wraps(fn)
25
+ def wrapper(*args, **kwargs):
26
+ with warnings.catch_warnings():
27
+ warnings.filterwarnings(
28
+ "ignore", "All-NaN slice encountered", RuntimeWarning)
29
+ return fn(*args, **kwargs)
30
+ return wrapper
@@ -0,0 +1,21 @@
1
+ """Daily-rainfall aggregations: season timing and dry spells.
2
+
3
+ Access is module-qualified, following ``africas2s.time`` and
4
+ ``africas2s.tercile``::
5
+
6
+ from africas2s import aggregations as agg
7
+
8
+ onset = agg.onset(daily, season="MAM")
9
+ """
10
+ from .spells import DrySpellResult, dry_spell
11
+ from .timing import ONSET_DEFAULTS, TimingResult, cessation, onset, season_length
12
+
13
+ __all__ = [
14
+ "onset",
15
+ "cessation",
16
+ "season_length",
17
+ "dry_spell",
18
+ "TimingResult",
19
+ "DrySpellResult",
20
+ "ONSET_DEFAULTS",
21
+ ]
@@ -0,0 +1,73 @@
1
+ """Rolling totals and consecutive-run lengths over a daily step axis.
2
+
3
+ Onset's false-start guard, cessation, and dry-spell statistics are one question
4
+ read three ways: where are the runs of consecutive days at or below a
5
+ threshold. This module answers it once, for every cell, year and member
6
+ simultaneously. Nothing here knows what a rainy season is.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import numpy as np
11
+ import xarray as xr
12
+
13
+ __all__ = ["rolling_total", "dry_run_lengths", "has_data"]
14
+
15
+
16
+ def rolling_total(rain: xr.DataArray, window: int, *, dim: str = "step") -> xr.DataArray:
17
+ """Total over ``window`` steps *starting* at each position.
18
+
19
+ ``xarray.rolling`` reports the window *ending* at each position, so the
20
+ result is shifted back by ``window - 1``. A position whose window runs off
21
+ the end of the axis, or contains a NaN, is NaN: an accumulation cannot be
22
+ confirmed from incomplete data.
23
+ """
24
+ if window < 1:
25
+ raise ValueError(f"window must be >= 1, got {window}")
26
+ rolled = rain.rolling({dim: window}, min_periods=window).sum()
27
+ return rolled.shift({dim: -(window - 1)})
28
+
29
+
30
+ def _run_lengths_1d(dry: np.ndarray) -> np.ndarray:
31
+ """Length of the run of True ending at each position; 0 where False.
32
+
33
+ Walks the axis once. ``maximum.accumulate`` over the indices of False
34
+ entries gives, at each position, the index of the most recent False, and
35
+ the distance to it is the run length.
36
+ """
37
+ n = dry.shape[-1]
38
+ idx = np.arange(n)
39
+ reset = np.where(~dry, idx, -1)
40
+ last_false = np.maximum.accumulate(reset, axis=-1)
41
+ return np.where(dry, idx - last_false, 0).astype(np.int64)
42
+
43
+
44
+ def dry_run_lengths(rain: xr.DataArray, threshold: float, *,
45
+ dim: str = "step") -> xr.DataArray:
46
+ """Length of the consecutive at-or-below-``threshold`` run ending at each step.
47
+
48
+ Wet steps are 0. The threshold is inclusive, matching the "1 mm/day or
49
+ less" wording of the criterion.
50
+
51
+ NaN steps count as not dry, because ``NaN <= threshold`` is False. A gap in
52
+ the record therefore breaks a run rather than extending it, which is the
53
+ conservative direction: missing data never manufactures a dry spell.
54
+ """
55
+ dry = rain <= threshold
56
+ return xr.apply_ufunc(
57
+ _run_lengths_1d,
58
+ dry,
59
+ input_core_dims=[[dim]],
60
+ output_core_dims=[[dim]],
61
+ dask="parallelized",
62
+ output_dtypes=[np.int64],
63
+ ).transpose(*rain.dims)
64
+
65
+
66
+ def has_data(rain: xr.DataArray, *, dim: str = "step") -> xr.DataArray:
67
+ """True where the cell has at least one finite value along ``dim``.
68
+
69
+ The three-state ``occurred`` contract depends on this. Rolling sums over an
70
+ all-NaN cell yield NaN, NaN fails every comparison, and a naive ``any``
71
+ would report the cell as a failed season rather than as absent data.
72
+ """
73
+ return rain.notnull().any(dim)
@@ -0,0 +1,93 @@
1
+ """Dry-spell statistics within a season.
2
+
3
+ The same run-length kernel that powers onset's false-start guard, read as
4
+ counting rather than dating. Emmett's observation in the design thread: because
5
+ the onset criterion already needs a dry-spell definition, the dry-spell metric
6
+ falls out of it rather than being built separately.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+
12
+ import xarray as xr
13
+
14
+ from ..climate import seasonal_stack
15
+ from ._runs import dry_run_lengths, has_data
16
+ from .timing import (
17
+ _provenance,
18
+ _require_daily,
19
+ _stamp,
20
+ _warn_on_incomplete_record,
21
+ _warn_on_suspicious_units,
22
+ )
23
+
24
+ __all__ = ["DrySpellResult", "dry_spell"]
25
+
26
+
27
+ @dataclass
28
+ class DrySpellResult:
29
+ """Dry-spell statistics, one value per season.
30
+
31
+ ``longest`` is the length of the longest run at or below the threshold,
32
+ whatever its length. ``count`` is the number of distinct runs reaching
33
+ ``dry_spell_days``. Both are NaN for cells with no data, and 0.0 for cells
34
+ that simply had no dry days.
35
+ """
36
+
37
+ longest: xr.DataArray
38
+ count: xr.DataArray
39
+ params: dict = field(default_factory=dict)
40
+
41
+
42
+ def dry_spell(
43
+ daily: xr.DataArray,
44
+ season,
45
+ *,
46
+ dry_spell_mm: float = 1.0,
47
+ dry_spell_days: int = 7,
48
+ time_dim: str = "time",
49
+ ) -> DrySpellResult:
50
+ """Longest dry run and count of qualifying dry runs, per season.
51
+
52
+ A run is dry when each of its days receives ``dry_spell_mm`` or less. The
53
+ defaults are the dry-spell half of the default onset criterion.
54
+
55
+ No tail is stacked: unlike onset, this question is entirely contained
56
+ within the season. ``daily`` must be daily; any other cadence raises,
57
+ because a dry-spell length counted in days has no meaning on a coarser
58
+ axis.
59
+ """
60
+ _require_daily(daily, time_dim)
61
+
62
+ # Metres-instead-of-millimetres is as silently wrong here as it is for
63
+ # onset, in the opposite direction: every day falls under the threshold and
64
+ # the whole season reads as one dry spell.
65
+ _warn_on_suspicious_units(daily)
66
+
67
+ rain = seasonal_stack(daily, season, time_dim=time_dim)
68
+
69
+ # A record that stops mid-season does not return NaN here: seasonal_stack
70
+ # NaN-pads the gap, NaN counts as not-dry, so every run spanning the cut is
71
+ # silently truncated and `longest` under-reports. Warn rather than let a
72
+ # confident wrong number through.
73
+ _warn_on_incomplete_record(
74
+ daily, season, rain["year"].values, 0, time_dim,
75
+ window="full season",
76
+ consequence=("Dry runs are cut where the record ends, so longest and "
77
+ "count under-report rather than reporting no data."),
78
+ )
79
+
80
+ lengths = dry_run_lengths(rain, dry_spell_mm)
81
+ ok = has_data(rain)
82
+
83
+ longest = lengths.max("step").astype(float).where(ok)
84
+
85
+ # A run reaches length L at exactly one step, the L-th day of that run, so
86
+ # equality counts each qualifying run once regardless of how long it grows.
87
+ count = (lengths == dry_spell_days).sum("step").astype(float).where(ok)
88
+
89
+ params = dict(dry_spell_mm=dry_spell_mm, dry_spell_days=dry_spell_days)
90
+ prov = _provenance(params, season)
91
+ arrays = {"longest": longest, "count": count}
92
+ _stamp(arrays, prov)
93
+ return DrySpellResult(**arrays, params=prov)