klimapy 1.1.0__py3-none-win_amd64.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 (41) hide show
  1. klimapy/BCSD/BiasCorrection.py +209 -0
  2. klimapy/BCSD/BiasCorrectionSpatialDownscaling.py +89 -0
  3. klimapy/BCSD/SpatialDownscaling.py +198 -0
  4. klimapy/BCSD/__init__.py +20 -0
  5. klimapy/ETCCDI/ETCCDI.py +684 -0
  6. klimapy/ETCCDI/__init__.py +150 -0
  7. klimapy/Interpolation/__init__.py +23 -0
  8. klimapy/Interpolation/spatial_interpolation.py +233 -0
  9. klimapy/PrecipitationVariability/__init__.py +27 -0
  10. klimapy/PrecipitationVariability/precip_variability_netcdf.py +167 -0
  11. klimapy/PrecipitationVariability/precipvariability.py +25 -0
  12. klimapy/Supporting_Tools/CSM.py +69 -0
  13. klimapy/Supporting_Tools/NCtoTIFF.py +133 -0
  14. klimapy/Supporting_Tools/__init__.py +23 -0
  15. klimapy/__init__.py +54 -0
  16. klimapy/_backends/__init__.py +5 -0
  17. klimapy/_backends/_native_core.pyd +0 -0
  18. klimapy/_backends/rust.py +582 -0
  19. klimapy/_compat.py +43 -0
  20. klimapy/_core/__init__.py +1 -0
  21. klimapy/_core/common.py +161 -0
  22. klimapy/_core/engine.py +177 -0
  23. klimapy/_core/native_adapter.py +122 -0
  24. klimapy/_core/validation.py +164 -0
  25. klimapy/_licenses/splinefit-APACHE.txt +176 -0
  26. klimapy/_licenses/splinefit-MIT.txt +21 -0
  27. klimapy/_licenses/splinefit-NOTICE.md +26 -0
  28. klimapy/native/__init__.py +36 -0
  29. klimapy/native/correction.py +96 -0
  30. klimapy/native/field.py +309 -0
  31. klimapy/native/geometry.py +203 -0
  32. klimapy/native/io.py +448 -0
  33. klimapy/native/linalg.py +248 -0
  34. klimapy/native/statistics.py +142 -0
  35. klimapy/workflows/__init__.py +5 -0
  36. klimapy/workflows/batch.py +48 -0
  37. klimapy-1.1.0.dist-info/METADATA +110 -0
  38. klimapy-1.1.0.dist-info/RECORD +41 -0
  39. klimapy-1.1.0.dist-info/WHEEL +5 -0
  40. klimapy-1.1.0.dist-info/licenses/LICENSE +21 -0
  41. klimapy-1.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,209 @@
1
+ """Calendar-aligned precipitation quantile correction using Rust FITPACK."""
2
+
3
+ import logging
4
+ from pathlib import Path
5
+ from contextlib import ExitStack
6
+
7
+ import numpy as np
8
+
9
+ from ..native import Field, TimeAxis
10
+ from ..native.correction import Transfer
11
+ from .._core.common import WINDOW_SIZE, N_QUANTILES
12
+ from .._core.native_adapter import open_precip_dataset
13
+ from .._core.engine import (
14
+ calendar_info,
15
+ align_reference,
16
+ mapped_days,
17
+ GridWeights,
18
+ allocate,
19
+ )
20
+
21
+ logger = logging.getLogger(__name__)
22
+ DEBUG_CHECK = False
23
+ __all__ = ["BC", "create_cdf_spline", "close_result"]
24
+
25
+
26
+ def create_cdf_spline(gcm_data, obs_data, n_quantiles=50):
27
+ try:
28
+ return Transfer(gcm_data, obs_data, n_quantiles)
29
+ except ValueError as exc:
30
+ if "20 finite observations" in str(exc):
31
+ return None
32
+ raise
33
+
34
+
35
+ def BC(
36
+ obs_path,
37
+ gcm_hist_path,
38
+ gcm_fut_path,
39
+ model_name,
40
+ ref_start_year,
41
+ ref_end_year,
42
+ *,
43
+ strict=False,
44
+ time_chunk_size=128,
45
+ work_dir=None,
46
+ window_size=None,
47
+ n_quantiles=None,
48
+ ):
49
+ buffers = []
50
+ try:
51
+ window = WINDOW_SIZE if window_size is None else window_size
52
+ quantiles = N_QUANTILES if n_quantiles is None else n_quantiles
53
+ if (
54
+ isinstance(time_chunk_size, bool)
55
+ or not isinstance(time_chunk_size, int)
56
+ or time_chunk_size < 1
57
+ or isinstance(window, bool)
58
+ or not isinstance(window, int)
59
+ or window < 0
60
+ or isinstance(quantiles, bool)
61
+ or not isinstance(quantiles, int)
62
+ or quantiles < 4
63
+ ):
64
+ raise ValueError(
65
+ "Positive time_chunk_size, nonnegative window_size and n_quantiles>=4 required"
66
+ )
67
+ with ExitStack() as stack:
68
+ obs, historical, future = [
69
+ stack.enter_context(open_precip_dataset(p))
70
+ for p in (obs_path, gcm_hist_path, gcm_fut_path)
71
+ ]
72
+ for d in ("lat", "lon"):
73
+ if not np.array_equal(historical.coords[d], future.coords[d]):
74
+ raise ValueError(
75
+ "Historical and future model grids must match exactly"
76
+ )
77
+ cal, ndays = calendar_info(historical.time)
78
+ if calendar_info(future.time)[0] != cal:
79
+ raise ValueError("Historical and future calendars must match")
80
+ oi, hi = align_reference(
81
+ obs.time, historical.time, ref_start_year, ref_end_year
82
+ )
83
+ weights = stack.enter_context(
84
+ GridWeights(
85
+ obs.coords["lat"],
86
+ obs.coords["lon"],
87
+ historical.coords["lat"],
88
+ historical.coords["lon"],
89
+ )
90
+ )
91
+ reference = allocate(
92
+ (len(oi), *historical.shape[1:]), np.float64, work_dir, "reference"
93
+ )
94
+ buffers.append(reference)
95
+ for start in range(0, len(oi), time_chunk_size):
96
+ indices = oi[start : start + time_chunk_size]
97
+ reference[start : start + len(indices)] = weights.apply(
98
+ obs.read(time=indices)
99
+ )
100
+ ref_time = TimeAxis(
101
+ historical.coords["time"][hi],
102
+ historical.coordinate_attrs.get("time", {}),
103
+ )
104
+ reference_days = mapped_days(ref_time, cal)
105
+ history_days, future_days = (
106
+ mapped_days(historical.time, cal),
107
+ mapped_days(future.time, cal),
108
+ )
109
+ windows = {}
110
+ for day in range(1, ndays + 1):
111
+ distance = np.abs(reference_days - day)
112
+ windows[day] = np.flatnonzero(
113
+ (reference_days > 0)
114
+ & (np.minimum(distance, ndays - distance) <= window)
115
+ )
116
+ outputs = [
117
+ allocate(a.shape, np.float64, work_dir, "corrected")
118
+ for a in (historical, future)
119
+ ]
120
+ buffers.extend(outputs)
121
+ for out in outputs:
122
+ out[:] = np.nan
123
+ stats = {
124
+ "matching_reference_days": len(oi),
125
+ "fitted_functions": 0,
126
+ "unfitted_functions": 0,
127
+ "approximate_functions": 0,
128
+ "reference_period": [ref_start_year, ref_end_year],
129
+ "calendar": cal,
130
+ }
131
+ for row in range(historical.shape[1]):
132
+ histrow, futrow = historical.read(lat=row), future.read(lat=row)
133
+ for col in range(historical.shape[2]):
134
+ if not np.isfinite(reference[:, row, col]).any():
135
+ stats["unfitted_functions"] += ndays
136
+ continue
137
+ train = histrow[hi, col]
138
+ for day, indices in windows.items():
139
+ function = create_cdf_spline(
140
+ train[indices], reference[indices, row, col], quantiles
141
+ )
142
+ stats[
143
+ "fitted_functions"
144
+ if function is not None
145
+ else "unfitted_functions"
146
+ ] += 1
147
+ if (
148
+ function is not None
149
+ and getattr(function, "fit_status", 0) > 0
150
+ ):
151
+ stats["approximate_functions"] += 1
152
+ try:
153
+ for series, days, out in [
154
+ (histrow[:, col], history_days, outputs[0]),
155
+ (futrow[:, col], future_days, outputs[1]),
156
+ ]:
157
+ mask = days == day
158
+ out[mask, row, col] = (
159
+ function(series[mask])
160
+ if function is not None
161
+ else series[mask]
162
+ )
163
+ finally:
164
+ if function is not None:
165
+ function.close()
166
+ answer = {
167
+ "corrected_hist_pr": historical.result(outputs[0]),
168
+ "corrected_fut_pr": future.result(outputs[1]),
169
+ "calendar_type": cal,
170
+ "max_doy": ndays,
171
+ "gcm_time_coord": "time",
172
+ "gcm_lat_coord": "lat",
173
+ "gcm_lon_coord": "lon",
174
+ "original_hist_path": str(gcm_hist_path),
175
+ "original_fut_path": str(gcm_fut_path),
176
+ "model_name": model_name,
177
+ "diagnostics": stats,
178
+ }
179
+ for array in (answer["corrected_hist_pr"], answer["corrected_fut_pr"]):
180
+ array.attrs["calibration_period"] = f"{ref_start_year}-{ref_end_year}"
181
+ _release(reference, remove=True)
182
+ return answer
183
+ except Exception:
184
+ for buffer in buffers:
185
+ _release(buffer, remove=True)
186
+ if strict:
187
+ raise
188
+ logger.exception("Bias correction failed")
189
+ return None
190
+
191
+
192
+ def _release(values, *, remove=False):
193
+ while (
194
+ not isinstance(values, np.memmap) and getattr(values, "base", None) is not None
195
+ ):
196
+ values = values.base
197
+ if isinstance(values, np.memmap):
198
+ path = values.filename
199
+ if not values._mmap.closed:
200
+ values._mmap.close()
201
+ if remove:
202
+ Path(path).unlink(missing_ok=True)
203
+
204
+
205
+ def close_result(result, *, remove_work_files=False):
206
+ if result:
207
+ for key in ("corrected_hist_pr", "corrected_fut_pr"):
208
+ if key in result:
209
+ _release(result[key].values, remove=remove_work_files)
@@ -0,0 +1,89 @@
1
+ """BCSD 一体化入口及旧导入路径;计算实现位于同一子包下的 BiasCorrection 和 SpatialDownscaling。"""
2
+
3
+ from .BiasCorrection import BC, create_cdf_spline, close_result
4
+ import logging
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+ from .SpatialDownscaling import SD, area_weighted_regrid, fft_smooth_three_harmonics
9
+ from .._core.common import (
10
+ WINDOW_SIZE,
11
+ N_QUANTILES,
12
+ MIN_PRECIP,
13
+ MAX_RATIO,
14
+ MIN_RATIO,
15
+ DEBUG_CHECK,
16
+ DEBUG_PRINT_SAMPLES,
17
+ calculate_bounds,
18
+ get_precip_var_name,
19
+ convert_precip_to_mm_day,
20
+ cftime_to_datetime,
21
+ parse_cftime_date,
22
+ detect_calendar_type,
23
+ calculate_doy,
24
+ _safe_min,
25
+ _safe_max,
26
+ _safe_mean,
27
+ print_array_diagnostics,
28
+ )
29
+
30
+
31
+ def BCSD(
32
+ obs_path,
33
+ gcm_hist_path,
34
+ gcm_fut_path,
35
+ model_name,
36
+ output_dir,
37
+ ref_start_year,
38
+ ref_end_year,
39
+ *,
40
+ strict=False,
41
+ bc_options=None,
42
+ sd_options=None,
43
+ ):
44
+ """
45
+ BCSD完整流程:先BC后SD
46
+ """
47
+ logger.info(f"\n处理模式: {model_name}")
48
+ logger.info("=" * 60)
49
+
50
+ # 第一步:偏差校正 (BC)
51
+ bc_result = BC(
52
+ obs_path=obs_path,
53
+ gcm_hist_path=gcm_hist_path,
54
+ gcm_fut_path=gcm_fut_path,
55
+ model_name=model_name,
56
+ ref_start_year=ref_start_year,
57
+ ref_end_year=ref_end_year,
58
+ **dict(bc_options or {}, strict=strict),
59
+ )
60
+
61
+ if not bc_result:
62
+ logger.info(f"✗ {model_name} BC失败,停止处理")
63
+ return None
64
+
65
+ # 第二步:空间降尺度 (SD)
66
+ try:
67
+ sd_result = SD(
68
+ obs_path=obs_path,
69
+ bc_result=bc_result,
70
+ output_dir=output_dir,
71
+ ref_start_year=ref_start_year,
72
+ ref_end_year=ref_end_year,
73
+ **dict(sd_options or {}, strict=strict),
74
+ )
75
+ finally:
76
+ close_result(bc_result, remove_work_files=True)
77
+
78
+ if not sd_result:
79
+ logger.info(f"✗ {model_name} SD失败")
80
+ return None
81
+
82
+ logger.info(f"✓ {model_name} BCSD完整流程完成!")
83
+ logger.info("=" * 60)
84
+
85
+ return {
86
+ "model_name": model_name,
87
+ "hist_bcsd_path": sd_result["hist_bcsd_path"],
88
+ "fut_bcsd_path": sd_result["fut_bcsd_path"],
89
+ }
@@ -0,0 +1,198 @@
1
+ """Native streamed spatial downscaling; climatology and harmonics run in Rust."""
2
+
3
+ import logging
4
+ from contextlib import ExitStack
5
+ from pathlib import Path
6
+ import tempfile
7
+
8
+ import numpy as np
9
+
10
+ from .._backends import rust as _accelerate
11
+ from ..native.correction import smooth
12
+ from .._core.common import MIN_PRECIP, MIN_RATIO, MAX_RATIO
13
+ from .._core.native_adapter import open_precip_dataset
14
+ from .._core.engine import (
15
+ read_precip,
16
+ mapped_days,
17
+ calendar_info,
18
+ GridWeights,
19
+ NetCDFWriter,
20
+ allocate,
21
+ check_reference,
22
+ )
23
+
24
+ logger = logging.getLogger(__name__)
25
+ DEBUG_CHECK = False
26
+ __all__ = ["SD", "area_weighted_regrid", "fft_smooth_three_harmonics"]
27
+
28
+
29
+ def fft_smooth_three_harmonics(daily_series, n_days, n_harmonics=3):
30
+ if len(daily_series) != n_days:
31
+ raise ValueError("Series length differs from n_days")
32
+ return smooth(daily_series, n_harmonics)
33
+
34
+
35
+ def _smooth_block(block, n_harmonics=3):
36
+ return smooth(block, n_harmonics)
37
+
38
+
39
+ def area_weighted_regrid(source_lat, source_lon, source_data, target_lat, target_lon):
40
+ with GridWeights(source_lat, source_lon, target_lat, target_lon) as plan:
41
+ return plan.apply(source_data).astype("float32")
42
+
43
+
44
+ def SD(
45
+ obs_path,
46
+ bc_result,
47
+ output_dir,
48
+ ref_start_year,
49
+ ref_end_year,
50
+ *,
51
+ strict=False,
52
+ time_chunk_size=32,
53
+ spatial_chunk_size=16,
54
+ ):
55
+ try:
56
+ if any(
57
+ isinstance(n, bool) or not isinstance(n, int) or n < 1
58
+ for n in (time_chunk_size, spatial_chunk_size)
59
+ ):
60
+ raise ValueError("Chunk sizes must be positive integers")
61
+ with ExitStack() as stack:
62
+ obs = stack.enter_context(open_precip_dataset(obs_path))
63
+ check_reference(obs.time, ref_start_year, ref_end_year)
64
+ history, future = [
65
+ stack.enter_context(open_precip_dataset(bc_result[key]))
66
+ for key in ("corrected_hist_pr", "corrected_fut_pr")
67
+ ]
68
+ for d in ("lat", "lon"):
69
+ if not np.array_equal(history.coords[d], future.coords[d]):
70
+ raise ValueError("Historical/future model grids differ")
71
+ cal, ndays = calendar_info(history.time)
72
+ if calendar_info(future.time)[0] != cal:
73
+ raise ValueError("Historical/future calendars differ")
74
+ model_name = str(bc_result.get("model_name", "unknown"))
75
+ outdir = Path(output_dir) / model_name
76
+ outdir.mkdir(parents=True, exist_ok=True)
77
+ work = stack.enter_context(
78
+ tempfile.TemporaryDirectory(prefix=".climate-", dir=outdir)
79
+ )
80
+
81
+ def mapped(shape, dtype, prefix):
82
+ a = allocate(shape, dtype, work, prefix)
83
+ stack.callback(a._mmap.close)
84
+ return a
85
+
86
+ shape = (ndays, *obs.shape[1:])
87
+ sums, counts, climate = (
88
+ mapped(shape, np.float64, "sum"),
89
+ mapped(shape, np.int32, "count"),
90
+ mapped(shape, np.float64, "climatology"),
91
+ )
92
+ sums[:] = 0.0
93
+ counts[:] = 0
94
+ years = obs.time.labels()[0]
95
+ days = mapped_days(obs.time, cal)
96
+ chunk = min(
97
+ time_chunk_size,
98
+ max(1, 64 * 1024**2 // (obs.shape[1] * obs.shape[2] * 8)),
99
+ )
100
+ for start in range(0, obs.shape[0], chunk):
101
+ stop = min(start + chunk, obs.shape[0])
102
+ labels = days[start:stop].copy() - 1
103
+ labels[
104
+ (years[start:stop] < ref_start_year)
105
+ | (years[start:stop] > ref_end_year)
106
+ ] = -1
107
+ _accelerate.climate_accumulate(
108
+ obs.read(time=slice(start, stop)), labels, sums, counts
109
+ )
110
+ for row in range(0, obs.shape[1], spatial_chunk_size):
111
+ sl = (slice(None), slice(row, row + spatial_chunk_size), slice(None))
112
+ # Group means are part of the Rust climate pipeline, not a NumPy
113
+ # fallback. Use the native normalizer before harmonic smoothing.
114
+ climate[sl] = smooth(_accelerate.climate_mean(sums[sl], counts[sl]))
115
+ coarse_weights = stack.enter_context(
116
+ GridWeights(
117
+ obs.coords["lat"],
118
+ obs.coords["lon"],
119
+ history.coords["lat"],
120
+ history.coords["lon"],
121
+ )
122
+ )
123
+ coarse = np.empty((ndays, *history.shape[1:]))
124
+ for start in range(0, ndays, chunk):
125
+ coarse[start : start + chunk] = coarse_weights.apply(
126
+ climate[start : start + chunk]
127
+ )
128
+ downscaler = stack.enter_context(
129
+ _accelerate.Downscaler(
130
+ history.coords["lat"],
131
+ history.coords["lon"],
132
+ obs.coords["lat"],
133
+ obs.coords["lon"],
134
+ coarse,
135
+ climate,
136
+ minimum=MIN_PRECIP,
137
+ low=MIN_RATIO,
138
+ high=MAX_RATIO,
139
+ )
140
+ )
141
+ paths, failures = [], []
142
+ for label, data in [("historical", history), ("future", future)]:
143
+ labels = mapped_days(data.time, cal) - 1
144
+ if np.any(labels < 0):
145
+ raise ValueError("Unmappable calendar days in model output")
146
+ path = outdir / f"{model_name}_{label}_BCSD.nc"
147
+ attrs = {
148
+ "units": "mm/day",
149
+ "method": "Rust FITPACK BC + low-harmonic SD",
150
+ "calendar_type": cal,
151
+ "reference_period": f"{ref_start_year}-{ref_end_year}",
152
+ "model_name": model_name,
153
+ "failed_time_steps": 0,
154
+ }
155
+ with NetCDFWriter(
156
+ path, data.time, obs.coords["lat"], obs.coords["lon"], attrs
157
+ ) as writer:
158
+ for start in range(0, len(labels), chunk):
159
+ stop = min(start + chunk, len(labels))
160
+ try:
161
+ scaled = downscaler.apply(
162
+ data.read(time=slice(start, stop)), labels[start:stop]
163
+ )
164
+ except Exception as exc:
165
+ if strict:
166
+ raise
167
+ failures.append(
168
+ {
169
+ "period": label,
170
+ "start": start,
171
+ "stop": stop,
172
+ "error": str(exc),
173
+ }
174
+ )
175
+ scaled = np.full(
176
+ (stop - start, *obs.shape[1:]), np.nan, dtype="float32"
177
+ )
178
+ writer.write(start, scaled)
179
+ attrs["failed_time_steps"] = sum(
180
+ f["stop"] - f["start"] for f in failures if f["period"] == label
181
+ )
182
+ paths.append(str(path))
183
+ return {
184
+ "hist_bcsd_path": paths[0],
185
+ "fut_bcsd_path": paths[1],
186
+ "time_coordinate": "time",
187
+ "diagnostics": {
188
+ "failed_chunks": failures,
189
+ "climatology_days": ndays,
190
+ "time_chunk_size": chunk,
191
+ "spatial_chunk_size": spatial_chunk_size,
192
+ },
193
+ }
194
+ except Exception:
195
+ if strict:
196
+ raise
197
+ logger.exception("Spatial downscaling failed")
198
+ return None
@@ -0,0 +1,20 @@
1
+ """Bias correction and spatial downscaling stages and combined workflow."""
2
+
3
+ import sys
4
+
5
+ from . import BiasCorrection, SpatialDownscaling, BiasCorrectionSpatialDownscaling
6
+ from .BiasCorrection import BC
7
+ from .SpatialDownscaling import SD
8
+
9
+ # Preserve the existing combined-workflow module import.
10
+ BCSD = BiasCorrectionSpatialDownscaling
11
+ sys.modules[__name__ + ".BCSD"] = BCSD
12
+
13
+ __all__ = [
14
+ "BiasCorrection",
15
+ "SpatialDownscaling",
16
+ "BiasCorrectionSpatialDownscaling",
17
+ "BC",
18
+ "SD",
19
+ "BCSD",
20
+ ]