marEx 4.0.4__tar.gz → 4.1__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.
- {marex-4.0.4 → marex-4.1}/.github/workflows/ci.yml +6 -1
- {marex-4.0.4 → marex-4.1}/PKG-INFO +2 -2
- {marex-4.0.4 → marex-4.1}/README.md +1 -1
- {marex-4.0.4 → marex-4.1}/marEx/detect.py +159 -31
- {marex-4.0.4 → marex-4.1}/marEx.egg-info/PKG-INFO +2 -2
- {marex-4.0.4 → marex-4.1}/tests/test_gridded_preprocessing.py +79 -0
- {marex-4.0.4 → marex-4.1}/tests/test_track_edge_cases.py +14 -12
- {marex-4.0.4 → marex-4.1}/.codecov.yml +0 -0
- {marex-4.0.4 → marex-4.1}/.github/workflows/README.md +0 -0
- {marex-4.0.4 → marex-4.1}/.github/workflows/release.yml +0 -0
- {marex-4.0.4 → marex-4.1}/.github/workflows/validate.py +0 -0
- {marex-4.0.4 → marex-4.1}/.readthedocs.yaml +0 -0
- {marex-4.0.4 → marex-4.1}/CITATION.cff +0 -0
- {marex-4.0.4 → marex-4.1}/CONTRIBUTING.md +0 -0
- {marex-4.0.4 → marex-4.1}/MANIFEST.in +0 -0
- {marex-4.0.4 → marex-4.1}/examples/batch jobs/run_detect.py +0 -0
- {marex-4.0.4 → marex-4.1}/examples/batch jobs/run_track.py +0 -0
- {marex-4.0.4 → marex-4.1}/examples/batch jobs/submit_track.sh +0 -0
- {marex-4.0.4 → marex-4.1}/examples/gridded data/01_preprocess_extremes.ipynb +0 -0
- {marex-4.0.4 → marex-4.1}/examples/gridded data/02_id_track_events.ipynb +0 -0
- {marex-4.0.4 → marex-4.1}/examples/gridded data/03_visualise_events.ipynb +0 -0
- {marex-4.0.4 → marex-4.1}/examples/regional data/01_preprocess_extremes.ipynb +0 -0
- {marex-4.0.4 → marex-4.1}/examples/regional data/02_id_track_events.ipynb +0 -0
- {marex-4.0.4 → marex-4.1}/examples/regional data/03_visualise_events.ipynb +0 -0
- {marex-4.0.4 → marex-4.1}/examples/unstructured data/01_preprocess_extremes.ipynb +0 -0
- {marex-4.0.4 → marex-4.1}/examples/unstructured data/02_id_track_events.ipynb +0 -0
- {marex-4.0.4 → marex-4.1}/examples/unstructured data/03_visualise_events.ipynb +0 -0
- {marex-4.0.4 → marex-4.1}/marEx/__init__.py +0 -0
- {marex-4.0.4 → marex-4.1}/marEx/_coverage_init.py +0 -0
- {marex-4.0.4 → marex-4.1}/marEx/_dependencies.py +0 -0
- {marex-4.0.4 → marex-4.1}/marEx/exceptions.py +0 -0
- {marex-4.0.4 → marex-4.1}/marEx/helper.py +0 -0
- {marex-4.0.4 → marex-4.1}/marEx/logging_config.py +0 -0
- {marex-4.0.4 → marex-4.1}/marEx/plotX/__init__.py +0 -0
- {marex-4.0.4 → marex-4.1}/marEx/plotX/base.py +0 -0
- {marex-4.0.4 → marex-4.1}/marEx/plotX/gridded.py +0 -0
- {marex-4.0.4 → marex-4.1}/marEx/plotX/unstructured.py +0 -0
- {marex-4.0.4 → marex-4.1}/marEx/track.py +0 -0
- {marex-4.0.4 → marex-4.1}/marEx.egg-info/SOURCES.txt +0 -0
- {marex-4.0.4 → marex-4.1}/marEx.egg-info/dependency_links.txt +0 -0
- {marex-4.0.4 → marex-4.1}/marEx.egg-info/requires.txt +0 -0
- {marex-4.0.4 → marex-4.1}/marEx.egg-info/top_level.txt +0 -0
- {marex-4.0.4 → marex-4.1}/pyproject.toml +0 -0
- {marex-4.0.4 → marex-4.1}/setup.cfg +0 -0
- {marex-4.0.4 → marex-4.1}/tests/__init__.py +0 -0
- {marex-4.0.4 → marex-4.1}/tests/conftest.py +0 -0
- {marex-4.0.4 → marex-4.1}/tests/coverage_config.py +0 -0
- {marex-4.0.4 → marex-4.1}/tests/make_test_data.ipynb +0 -0
- {marex-4.0.4 → marex-4.1}/tests/test_detect_detrending.py +0 -0
- {marex-4.0.4 → marex-4.1}/tests/test_detect_helpers.py +0 -0
- {marex-4.0.4 → marex-4.1}/tests/test_error_handling.py +0 -0
- {marex-4.0.4 → marex-4.1}/tests/test_exceptions.py +0 -0
- {marex-4.0.4 → marex-4.1}/tests/test_gridded_tracking.py +0 -0
- {marex-4.0.4 → marex-4.1}/tests/test_integration.py +0 -0
- {marex-4.0.4 → marex-4.1}/tests/test_logging_system.py +0 -0
- {marex-4.0.4 → marex-4.1}/tests/test_plotx.py +0 -0
- {marex-4.0.4 → marex-4.1}/tests/test_track_helpers.py +0 -0
- {marex-4.0.4 → marex-4.1}/tests/test_unstructured_preprocessing.py +0 -0
- {marex-4.0.4 → marex-4.1}/tests/test_unstructured_tracking.py +0 -0
|
@@ -147,7 +147,7 @@ jobs:
|
|
|
147
147
|
- name: Run preprocessing tests (gridded)
|
|
148
148
|
timeout-minutes: 30 # Windows can be slow for these tests
|
|
149
149
|
run: |
|
|
150
|
-
pytest tests/test_gridded_preprocessing.py -n 2 -v --tb=short
|
|
150
|
+
pytest tests/test_gridded_preprocessing.py tests/test_detect_detrending.py -n 2 -v --tb=short
|
|
151
151
|
|
|
152
152
|
- name: Run preprocessing tests (unstructured)
|
|
153
153
|
timeout-minutes: 30 # Windows can be slow for these tests
|
|
@@ -164,6 +164,11 @@ jobs:
|
|
|
164
164
|
run: |
|
|
165
165
|
pytest tests/test_unstructured_tracking.py -n 2 -v --tb=short
|
|
166
166
|
|
|
167
|
+
- name: Run tracking edge case tests
|
|
168
|
+
timeout-minutes: 30 # Windows can be slow for these tests
|
|
169
|
+
run: |
|
|
170
|
+
pytest tests/test_track_edge_cases.py -n 2 -v --tb=short
|
|
171
|
+
|
|
167
172
|
- name: Run plotting tests
|
|
168
173
|
run: |
|
|
169
174
|
pytest tests/test_plotx.py -n 2 -v --tb=short
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: marEx
|
|
3
|
-
Version: 4.
|
|
3
|
+
Version: 4.1
|
|
4
4
|
Summary: Marine Extremes Detection and Tracking
|
|
5
5
|
Author-email: Aaron Wienkers <aaron@wienkers.com>
|
|
6
6
|
Project-URL: Homepage, https://github.com/wienkers/marEx
|
|
@@ -87,7 +87,7 @@ Requires-Dist: pytest-xdist; extra == "test"
|
|
|
87
87
|
Requires-Dist: coverage[toml]>=6.0.0; extra == "test"
|
|
88
88
|
Requires-Dist: psutil; extra == "test"
|
|
89
89
|
|
|
90
|
-
|
|
90
|
+
<img src="https://github.com/user-attachments/assets/55eab465-9aca-4754-a981-e26a36f7d045" alt="marEx_animated" width="100%">
|
|
91
91
|
|
|
92
92
|
[](https://github.com/wienkers/marEx/actions/workflows/ci.yml)
|
|
93
93
|
[](https://codecov.io/gh/wienkers/marEx)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
<img src="https://github.com/user-attachments/assets/55eab465-9aca-4754-a981-e26a36f7d045" alt="marEx_animated" width="100%">
|
|
2
2
|
|
|
3
3
|
[](https://github.com/wienkers/marEx/actions/workflows/ci.yml)
|
|
4
4
|
[](https://codecov.io/gh/wienkers/marEx)
|
|
@@ -298,6 +298,7 @@ def preprocess_data(
|
|
|
298
298
|
std_normalise: bool = False, # for detrend_harmonic
|
|
299
299
|
detrend_orders: Optional[List[int]] = None, # "
|
|
300
300
|
force_zero_mean: bool = True, # "
|
|
301
|
+
reference_period: Optional[Tuple[int, int]] = None, # for fixed_baseline & detrend_fixed_baseline
|
|
301
302
|
method_percentile: Literal["exact", "approximate"] = "approximate",
|
|
302
303
|
precision: float = 0.01,
|
|
303
304
|
max_anomaly: float = 5.0,
|
|
@@ -354,6 +355,12 @@ def preprocess_data(
|
|
|
354
355
|
Default is 1st order (linear) detrend. `[1,2]` e.g. would use a linear+quadratic detrending.
|
|
355
356
|
force_zero_mean : bool, default=True
|
|
356
357
|
Whether to enforce zero mean in detrended anomalies (detrend_harmonic method only).
|
|
358
|
+
reference_period : tuple of (int, int), optional
|
|
359
|
+
Year range (start_year, end_year) inclusive for computing the daily climatology
|
|
360
|
+
(fixed_baseline and detrend_fixed_baseline only). If None (default), uses all
|
|
361
|
+
available years. Anomalies are computed for the full time series regardless.
|
|
362
|
+
Example: reference_period=(1990, 2020) computes the climatology from 1990-2020
|
|
363
|
+
but outputs anomalies for the entire input time range.
|
|
357
364
|
method_percentile : str, default='approximate'
|
|
358
365
|
Method for percentile calculation ('exact' or 'approximate') for both global_extreme & hobday_extreme methods.
|
|
359
366
|
N.B.: Using the exact percentile calculation requires both careful/thoughtful chunking & sufficient memory,
|
|
@@ -560,6 +567,17 @@ def preprocess_data(
|
|
|
560
567
|
data_info={"data_type": type(da.data).__name__, "shape": da.shape},
|
|
561
568
|
)
|
|
562
569
|
|
|
570
|
+
# Validate reference_period before triggering any computation
|
|
571
|
+
if reference_period is not None and method_anomaly not in ("fixed_baseline", "detrend_fixed_baseline"):
|
|
572
|
+
raise ConfigurationError(
|
|
573
|
+
f"reference_period is not supported for method_anomaly='{method_anomaly}'",
|
|
574
|
+
details="reference_period is only applicable to 'fixed_baseline' and 'detrend_fixed_baseline' methods",
|
|
575
|
+
suggestions=[
|
|
576
|
+
"Remove the reference_period parameter, or",
|
|
577
|
+
"Use method_anomaly='fixed_baseline' or 'detrend_fixed_baseline'",
|
|
578
|
+
],
|
|
579
|
+
)
|
|
580
|
+
|
|
563
581
|
# Validate that all unmasked data is valid (finite values only)
|
|
564
582
|
logger.debug("Validating data values for NaN/infinite values")
|
|
565
583
|
_validate_data_values(da, dimensions)
|
|
@@ -588,6 +606,7 @@ def preprocess_data(
|
|
|
588
606
|
std_normalise,
|
|
589
607
|
detrend_orders,
|
|
590
608
|
force_zero_mean,
|
|
609
|
+
reference_period,
|
|
591
610
|
use_temp_checkpoints,
|
|
592
611
|
)
|
|
593
612
|
log_memory_usage(logger, "After anomaly computation", logging.DEBUG)
|
|
@@ -723,6 +742,7 @@ def preprocess_data(
|
|
|
723
742
|
smooth_days_baseline,
|
|
724
743
|
window_days_hobday,
|
|
725
744
|
window_spatial_hobday,
|
|
745
|
+
reference_period,
|
|
726
746
|
),
|
|
727
747
|
}
|
|
728
748
|
)
|
|
@@ -744,14 +764,18 @@ def preprocess_data(
|
|
|
744
764
|
}
|
|
745
765
|
)
|
|
746
766
|
elif method_anomaly == "fixed_baseline":
|
|
747
|
-
|
|
767
|
+
attrs = {}
|
|
768
|
+
if reference_period is not None:
|
|
769
|
+
attrs["reference_period"] = list(reference_period)
|
|
770
|
+
ds.attrs.update(attrs)
|
|
748
771
|
elif method_anomaly == "detrend_fixed_baseline":
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
772
|
+
attrs = {
|
|
773
|
+
"detrend_orders": detrend_orders,
|
|
774
|
+
"force_zero_mean": force_zero_mean,
|
|
775
|
+
}
|
|
776
|
+
if reference_period is not None:
|
|
777
|
+
attrs["reference_period"] = list(reference_period)
|
|
778
|
+
ds.attrs.update(attrs)
|
|
755
779
|
|
|
756
780
|
if method_extreme == "hobday_extreme":
|
|
757
781
|
ds.attrs.update({"window_days_hobday": window_days_hobday})
|
|
@@ -826,6 +850,7 @@ def _get_preprocessing_steps(
|
|
|
826
850
|
smooth_days_baseline: int,
|
|
827
851
|
window_days_hobday: int,
|
|
828
852
|
window_spatial_hobday: Optional[int],
|
|
853
|
+
reference_period: Optional[Tuple[int, int]] = None,
|
|
829
854
|
) -> List[str]:
|
|
830
855
|
"""Generate preprocessing steps description based on selected methods."""
|
|
831
856
|
steps = []
|
|
@@ -838,10 +863,16 @@ def _get_preprocessing_steps(
|
|
|
838
863
|
steps.append(f"Rolling climatology using {window_year_baseline} years")
|
|
839
864
|
steps.append(f"Smoothed with {smooth_days_baseline}-day window")
|
|
840
865
|
elif method_anomaly == "fixed_baseline":
|
|
841
|
-
|
|
866
|
+
if reference_period is not None:
|
|
867
|
+
steps.append(f"Daily climatology computed from {reference_period[0]}-{reference_period[1]}")
|
|
868
|
+
else:
|
|
869
|
+
steps.append("Daily climatology computed from full time series")
|
|
842
870
|
elif method_anomaly == "detrend_fixed_baseline":
|
|
843
871
|
steps.append(f"Removed polynomial trend orders={detrend_orders}")
|
|
844
|
-
|
|
872
|
+
if reference_period is not None:
|
|
873
|
+
steps.append(f"Daily climatology computed from detrended data ({reference_period[0]}-{reference_period[1]})")
|
|
874
|
+
else:
|
|
875
|
+
steps.append("Daily climatology computed from detrended data")
|
|
845
876
|
|
|
846
877
|
# Extreme method steps
|
|
847
878
|
if method_extreme == "global_extreme":
|
|
@@ -869,6 +900,7 @@ def compute_normalised_anomaly(
|
|
|
869
900
|
std_normalise: bool = False, # for detrend_harmonic
|
|
870
901
|
detrend_orders: Optional[List[int]] = None, # "
|
|
871
902
|
force_zero_mean: bool = True, # "
|
|
903
|
+
reference_period: Optional[Tuple[int, int]] = None, # for fixed_baseline & detrend_fixed_baseline
|
|
872
904
|
use_temp_checkpoints: bool = False,
|
|
873
905
|
verbose: Optional[bool] = None,
|
|
874
906
|
quiet: Optional[bool] = None,
|
|
@@ -901,7 +933,10 @@ def compute_normalised_anomaly(
|
|
|
901
933
|
Polynomial orders for trend removal (detrend_harmonic and detrend_fixed_baseline only)
|
|
902
934
|
force_zero_mean : bool, default=True
|
|
903
935
|
Explicitly enforce zero mean in final anomalies (detrend_harmonic and detrend_fixed_baseline only)
|
|
904
|
-
|
|
936
|
+
reference_period : tuple of (int, int), optional
|
|
937
|
+
Year range (start_year, end_year) inclusive for computing the daily climatology
|
|
938
|
+
(fixed_baseline and detrend_fixed_baseline only). If None (default), uses all
|
|
939
|
+
available years. Anomalies are computed for the full time series regardless.
|
|
905
940
|
|
|
906
941
|
Returns
|
|
907
942
|
-------
|
|
@@ -997,6 +1032,15 @@ def compute_normalised_anomaly(
|
|
|
997
1032
|
... )
|
|
998
1033
|
>>> # Climatology computed from all available years
|
|
999
1034
|
|
|
1035
|
+
Fixed baseline with a restricted reference period:
|
|
1036
|
+
|
|
1037
|
+
>>> # Compute climatology from 1990-2020 only, but output anomalies for all years
|
|
1038
|
+
>>> result_ref = marEx.compute_normalised_anomaly(
|
|
1039
|
+
... sst,
|
|
1040
|
+
... method_anomaly="fixed_baseline",
|
|
1041
|
+
... reference_period=(1990, 2020)
|
|
1042
|
+
... )
|
|
1043
|
+
|
|
1000
1044
|
Fixed detrended baseline:
|
|
1001
1045
|
|
|
1002
1046
|
>>> # Remove long-term trends then compute fixed climatology
|
|
@@ -1021,6 +1065,17 @@ def compute_normalised_anomaly(
|
|
|
1021
1065
|
# Infer and validate dimensions and coordinates
|
|
1022
1066
|
dimensions, coordinates = _infer_dims_coords(da, dimensions, coordinates)
|
|
1023
1067
|
|
|
1068
|
+
# Validate reference_period is only used with compatible methods
|
|
1069
|
+
if reference_period is not None and method_anomaly not in ("fixed_baseline", "detrend_fixed_baseline"):
|
|
1070
|
+
raise ConfigurationError(
|
|
1071
|
+
f"reference_period is not supported for method_anomaly='{method_anomaly}'",
|
|
1072
|
+
details="reference_period is only applicable to 'fixed_baseline' and 'detrend_fixed_baseline' methods",
|
|
1073
|
+
suggestions=[
|
|
1074
|
+
"Remove the reference_period parameter, or",
|
|
1075
|
+
"Use method_anomaly='fixed_baseline' or 'detrend_fixed_baseline'",
|
|
1076
|
+
],
|
|
1077
|
+
)
|
|
1078
|
+
|
|
1024
1079
|
if method_anomaly == "detrend_harmonic":
|
|
1025
1080
|
logger.debug(
|
|
1026
1081
|
f"Detrended baseline parameters: std_normalise={std_normalise}, orders={detrend_orders}, zero_mean={force_zero_mean}"
|
|
@@ -1032,11 +1087,16 @@ def compute_normalised_anomaly(
|
|
|
1032
1087
|
da, window_year_baseline, smooth_days_baseline, dimensions, coordinates, use_temp_checkpoints
|
|
1033
1088
|
)
|
|
1034
1089
|
elif method_anomaly == "fixed_baseline":
|
|
1035
|
-
logger.debug("Fixed baseline parameters:
|
|
1036
|
-
return _compute_anomaly_fixed_baseline(da, dimensions, coordinates)
|
|
1090
|
+
logger.debug(f"Fixed baseline parameters: reference_period={reference_period}")
|
|
1091
|
+
return _compute_anomaly_fixed_baseline(da, dimensions, coordinates, reference_period)
|
|
1037
1092
|
elif method_anomaly == "detrend_fixed_baseline":
|
|
1038
|
-
logger.debug(
|
|
1039
|
-
|
|
1093
|
+
logger.debug(
|
|
1094
|
+
f"Fixed detrended baseline parameters: orders={detrend_orders}, "
|
|
1095
|
+
f"zero_mean={force_zero_mean}, reference_period={reference_period}"
|
|
1096
|
+
)
|
|
1097
|
+
return _compute_anomaly_detrend_fixed_baseline(
|
|
1098
|
+
da, detrend_orders, dimensions, coordinates, force_zero_mean, reference_period
|
|
1099
|
+
)
|
|
1040
1100
|
else:
|
|
1041
1101
|
logger.error(f"Unknown anomaly method: {method_anomaly}")
|
|
1042
1102
|
raise ConfigurationError(
|
|
@@ -1859,16 +1919,41 @@ def _identify_extremes_hobday(
|
|
|
1859
1919
|
|
|
1860
1920
|
# Group by day-of-year and compute percentile
|
|
1861
1921
|
if method_percentile == "exact":
|
|
1862
|
-
#
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1922
|
+
# Use apply_ufunc to compute DOY percentiles per spatial chunk in pure numpy.
|
|
1923
|
+
da_ufunc = da.chunk({dimensions["time"]: -1})
|
|
1924
|
+
dayofyear_vals = da_ufunc[coordinates["time"]].dt.dayofyear.values
|
|
1925
|
+
half_w = window_days_hobday // 2
|
|
1926
|
+
|
|
1927
|
+
# Pre-compute boolean masks (which time indices contribute to each DOY)
|
|
1928
|
+
doy_masks = []
|
|
1929
|
+
for doy in range(1, 367):
|
|
1930
|
+
mask = np.zeros(len(dayofyear_vals), dtype=bool)
|
|
1931
|
+
for offset in range(-half_w, half_w + 1):
|
|
1932
|
+
target = ((doy - 1 + offset) % 366) + 1
|
|
1933
|
+
mask |= dayofyear_vals == target
|
|
1934
|
+
doy_masks.append(mask)
|
|
1935
|
+
|
|
1936
|
+
def _doy_percentiles(data, doy_masks, percentile):
|
|
1937
|
+
"""Compute per-DOY percentiles. data: (*spatial, time) -> (*spatial, 366)."""
|
|
1938
|
+
result = np.full(data.shape[:-1] + (366,), np.nan, dtype=np.float32)
|
|
1939
|
+
for i, mask in enumerate(doy_masks):
|
|
1940
|
+
if mask.any():
|
|
1941
|
+
result[..., i] = np.nanpercentile(data[..., mask], percentile, axis=-1)
|
|
1942
|
+
return result
|
|
1943
|
+
|
|
1944
|
+
thresholds = xr.apply_ufunc(
|
|
1945
|
+
_doy_percentiles,
|
|
1946
|
+
da_ufunc,
|
|
1947
|
+
input_core_dims=[[dimensions["time"]]],
|
|
1948
|
+
output_core_dims=[["dayofyear"]],
|
|
1949
|
+
dask="parallelized",
|
|
1950
|
+
kwargs={"doy_masks": doy_masks, "percentile": threshold_percentile},
|
|
1951
|
+
output_dtypes=[np.float32],
|
|
1952
|
+
dask_gufunc_kwargs={"output_sizes": {"dayofyear": 366}},
|
|
1871
1953
|
)
|
|
1954
|
+
|
|
1955
|
+
# Assign dayofyear coordinate values and move dayofyear to first dimension
|
|
1956
|
+
thresholds = thresholds.assign_coords(dayofyear=np.arange(1, 367)).transpose("dayofyear", ...)
|
|
1872
1957
|
else: # Optimised histogram approximation method
|
|
1873
1958
|
thresholds = _compute_histogram_quantile_2d(
|
|
1874
1959
|
da,
|
|
@@ -2179,6 +2264,7 @@ def _compute_anomaly_detrended(
|
|
|
2179
2264
|
func="std",
|
|
2180
2265
|
isbin=False,
|
|
2181
2266
|
method="cohorts",
|
|
2267
|
+
dtype=np.float32,
|
|
2182
2268
|
)
|
|
2183
2269
|
|
|
2184
2270
|
# Calculate 30-day rolling standard deviation with annual wrapped padding
|
|
@@ -2214,12 +2300,14 @@ def _compute_anomaly_fixed_baseline(
|
|
|
2214
2300
|
da: xr.DataArray,
|
|
2215
2301
|
dimensions: Optional[Dict[str, str]] = None,
|
|
2216
2302
|
coordinates: Optional[Dict[str, str]] = None,
|
|
2303
|
+
reference_period: Optional[Tuple[int, int]] = None,
|
|
2217
2304
|
) -> xr.Dataset:
|
|
2218
2305
|
"""
|
|
2219
2306
|
Compute anomalies using fixed baseline method with full time series climatology.
|
|
2220
2307
|
|
|
2221
|
-
This method computes a daily climatology using all available years in the dataset
|
|
2222
|
-
then subtracts this climatology from the
|
|
2308
|
+
This method computes a daily climatology using all available years in the dataset
|
|
2309
|
+
(or a specified reference period), then subtracts this climatology from the
|
|
2310
|
+
original data to obtain anomalies.
|
|
2223
2311
|
|
|
2224
2312
|
Parameters
|
|
2225
2313
|
----------
|
|
@@ -2229,6 +2317,10 @@ def _compute_anomaly_fixed_baseline(
|
|
|
2229
2317
|
Mapping of dimensions to names in the data
|
|
2230
2318
|
coordinates : dict, optional
|
|
2231
2319
|
Mapping of coordinates to names in the data
|
|
2320
|
+
reference_period : tuple of (int, int), optional
|
|
2321
|
+
Year range (start_year, end_year) inclusive for computing the daily climatology.
|
|
2322
|
+
If None (default), uses all available years. Anomalies are still computed for
|
|
2323
|
+
the full time series.
|
|
2232
2324
|
|
|
2233
2325
|
Returns
|
|
2234
2326
|
-------
|
|
@@ -2238,11 +2330,41 @@ def _compute_anomaly_fixed_baseline(
|
|
|
2238
2330
|
# Infer and validate dimensions and coordinates
|
|
2239
2331
|
dimensions, coordinates = _infer_dims_coords(da, dimensions, coordinates)
|
|
2240
2332
|
|
|
2241
|
-
#
|
|
2242
|
-
|
|
2333
|
+
# Select data for climatology computation (optionally restricted to reference period)
|
|
2334
|
+
if reference_period is not None:
|
|
2335
|
+
start_year, end_year = reference_period
|
|
2336
|
+
if start_year > end_year:
|
|
2337
|
+
raise ConfigurationError(
|
|
2338
|
+
f"Invalid reference_period: start year ({start_year}) must be <= end year ({end_year})",
|
|
2339
|
+
details="The reference_period tuple must be (start_year, end_year) with start_year <= end_year",
|
|
2340
|
+
suggestions=[f"Swap the order: use reference_period=({end_year}, {start_year})"],
|
|
2341
|
+
)
|
|
2342
|
+
years = da[coordinates["time"]].dt.year
|
|
2343
|
+
year_mask = (years >= start_year) & (years <= end_year)
|
|
2344
|
+
da_for_clim = da.isel({dimensions["time"]: year_mask})
|
|
2345
|
+
if da_for_clim.sizes[dimensions["time"]] == 0:
|
|
2346
|
+
data_min_year = int(years.min().values)
|
|
2347
|
+
data_max_year = int(years.max().values)
|
|
2348
|
+
raise ConfigurationError(
|
|
2349
|
+
f"No data found in reference_period ({start_year}, {end_year})",
|
|
2350
|
+
details=f"Dataset spans {data_min_year}-{data_max_year} but no timesteps fall within the specified period",
|
|
2351
|
+
suggestions=[
|
|
2352
|
+
f"Adjust reference_period to overlap with data range ({data_min_year}-{data_max_year})",
|
|
2353
|
+
"Set reference_period=None to use the full time series",
|
|
2354
|
+
],
|
|
2355
|
+
)
|
|
2356
|
+
logger.debug(
|
|
2357
|
+
f"Using reference_period ({start_year}-{end_year}): "
|
|
2358
|
+
f"{da_for_clim.sizes[dimensions['time']]} of {da.sizes[dimensions['time']]} timesteps"
|
|
2359
|
+
)
|
|
2360
|
+
else:
|
|
2361
|
+
da_for_clim = da
|
|
2362
|
+
|
|
2363
|
+
# Compute daily climatology using flox for efficiency
|
|
2364
|
+
logger.debug("Computing daily climatology across %s", "reference period" if reference_period else "all years")
|
|
2243
2365
|
daily_climatology = flox.xarray.xarray_reduce(
|
|
2244
|
-
|
|
2245
|
-
|
|
2366
|
+
da_for_clim,
|
|
2367
|
+
da_for_clim[coordinates["time"]].dt.dayofyear,
|
|
2246
2368
|
dim=dimensions["time"],
|
|
2247
2369
|
func="nanmean",
|
|
2248
2370
|
isbin=False,
|
|
@@ -2281,13 +2403,14 @@ def _compute_anomaly_detrend_fixed_baseline(
|
|
|
2281
2403
|
dimensions: Optional[Dict[str, str]] = None,
|
|
2282
2404
|
coordinates: Optional[Dict[str, str]] = None,
|
|
2283
2405
|
force_zero_mean: bool = True,
|
|
2406
|
+
reference_period: Optional[Tuple[int, int]] = None,
|
|
2284
2407
|
) -> xr.Dataset:
|
|
2285
2408
|
"""
|
|
2286
2409
|
Compute anomalies using fixed detrended baseline method.
|
|
2287
2410
|
|
|
2288
2411
|
This method first removes polynomial trends (without harmonics) from the data,
|
|
2289
|
-
then removes a
|
|
2290
|
-
|
|
2412
|
+
then removes a daily climatology from the detrended signal. The trend removal
|
|
2413
|
+
always uses the full time series; only the climatology step respects reference_period.
|
|
2291
2414
|
|
|
2292
2415
|
Parameters
|
|
2293
2416
|
----------
|
|
@@ -2301,6 +2424,10 @@ def _compute_anomaly_detrend_fixed_baseline(
|
|
|
2301
2424
|
Mapping of coordinates to names in the data
|
|
2302
2425
|
force_zero_mean : bool, default=True
|
|
2303
2426
|
Whether to enforce zero mean in detrended data
|
|
2427
|
+
reference_period : tuple of (int, int), optional
|
|
2428
|
+
Year range (start_year, end_year) inclusive for computing the daily climatology.
|
|
2429
|
+
If None (default), uses all available years. Only affects the climatology step,
|
|
2430
|
+
not the polynomial detrending.
|
|
2304
2431
|
|
|
2305
2432
|
Returns
|
|
2306
2433
|
-------
|
|
@@ -2329,6 +2456,7 @@ def _compute_anomaly_detrend_fixed_baseline(
|
|
|
2329
2456
|
da=detrended_result,
|
|
2330
2457
|
dimensions=dimensions,
|
|
2331
2458
|
coordinates=coordinates,
|
|
2459
|
+
reference_period=reference_period,
|
|
2332
2460
|
)
|
|
2333
2461
|
|
|
2334
2462
|
return final_result
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: marEx
|
|
3
|
-
Version: 4.
|
|
3
|
+
Version: 4.1
|
|
4
4
|
Summary: Marine Extremes Detection and Tracking
|
|
5
5
|
Author-email: Aaron Wienkers <aaron@wienkers.com>
|
|
6
6
|
Project-URL: Homepage, https://github.com/wienkers/marEx
|
|
@@ -87,7 +87,7 @@ Requires-Dist: pytest-xdist; extra == "test"
|
|
|
87
87
|
Requires-Dist: coverage[toml]>=6.0.0; extra == "test"
|
|
88
88
|
Requires-Dist: psutil; extra == "test"
|
|
89
89
|
|
|
90
|
-
|
|
90
|
+
<img src="https://github.com/user-attachments/assets/55eab465-9aca-4754-a981-e26a36f7d045" alt="marEx_animated" width="100%">
|
|
91
91
|
|
|
92
92
|
[](https://github.com/wienkers/marEx/actions/workflows/ci.yml)
|
|
93
93
|
[](https://codecov.io/gh/wienkers/marEx)
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
from pathlib import Path
|
|
2
2
|
|
|
3
3
|
import numpy as np
|
|
4
|
+
import pytest
|
|
4
5
|
import xarray as xr
|
|
5
6
|
|
|
6
7
|
import marEx
|
|
8
|
+
from marEx.exceptions import ConfigurationError
|
|
7
9
|
|
|
8
10
|
from .conftest import assert_percentile_frequency
|
|
9
11
|
|
|
@@ -767,3 +769,80 @@ class TestGriddedPreprocessing:
|
|
|
767
769
|
assert (
|
|
768
770
|
0.025 < extreme_frequency < 0.075
|
|
769
771
|
), f"{method_anomaly}+{method_extreme} produced unreasonable frequency: {extreme_frequency}"
|
|
772
|
+
|
|
773
|
+
def test_fixed_baseline_with_reference_period(self):
|
|
774
|
+
"""Test fixed_baseline reference_period restricts climatology to specified years."""
|
|
775
|
+
import pandas as pd
|
|
776
|
+
|
|
777
|
+
# Build a small synthetic dataset: 4 years of daily data at 3x3 grid
|
|
778
|
+
# Years 2000-2001 have a warm bias (+2), years 2002-2003 are baseline (0)
|
|
779
|
+
times = pd.date_range("2000-01-01", "2003-12-31", freq="D")
|
|
780
|
+
rng = np.random.default_rng(42)
|
|
781
|
+
base = rng.standard_normal((len(times), 3, 3)).astype(np.float32)
|
|
782
|
+
|
|
783
|
+
# Add a +2 warm bias to the first two years
|
|
784
|
+
warm_mask = np.array([t.year <= 2001 for t in times])
|
|
785
|
+
base[warm_mask] += 2.0
|
|
786
|
+
|
|
787
|
+
da = xr.DataArray(
|
|
788
|
+
base,
|
|
789
|
+
dims=["time", "lat", "lon"],
|
|
790
|
+
coords={"time": times, "lat": [0.0, 1.0, 2.0], "lon": [0.0, 1.0, 2.0]},
|
|
791
|
+
).chunk({"time": -1})
|
|
792
|
+
|
|
793
|
+
dims = {"time": "time", "x": "lon", "y": "lat"}
|
|
794
|
+
|
|
795
|
+
# Full-range climatology: mean mixes warm+cool years → anomalies centred on mixed mean
|
|
796
|
+
result_full = marEx.compute_normalised_anomaly(da, method_anomaly="fixed_baseline", dimensions=dims)
|
|
797
|
+
|
|
798
|
+
# Reference period restricted to cool years 2002-2003:
|
|
799
|
+
# climatology is ~0, so warm years produce large positive anomalies
|
|
800
|
+
result_ref = marEx.compute_normalised_anomaly(
|
|
801
|
+
da, method_anomaly="fixed_baseline", reference_period=(2002, 2003), dimensions=dims
|
|
802
|
+
)
|
|
803
|
+
|
|
804
|
+
# Output should preserve all time steps
|
|
805
|
+
assert result_ref.sizes["time"] == da.sizes["time"]
|
|
806
|
+
|
|
807
|
+
# Anomalies during the warm period (2000-2001) should be larger when the
|
|
808
|
+
# climatology is computed from the cool period only
|
|
809
|
+
warm_slice = {"time": slice("2000-01-01", "2001-12-31")}
|
|
810
|
+
mean_anom_full = float(result_full.dat_anomaly.sel(**warm_slice).mean())
|
|
811
|
+
mean_anom_ref = float(result_ref.dat_anomaly.sel(**warm_slice).mean())
|
|
812
|
+
assert mean_anom_ref > mean_anom_full + 0.5, (
|
|
813
|
+
f"Reference-period anomalies during warm years ({mean_anom_ref:.2f}) should be "
|
|
814
|
+
f"substantially larger than full-range anomalies ({mean_anom_full:.2f})"
|
|
815
|
+
)
|
|
816
|
+
|
|
817
|
+
def test_reference_period_attr_stored_in_preprocess_data(self):
|
|
818
|
+
"""Test that reference_period attribute is stored by preprocess_data."""
|
|
819
|
+
import pandas as pd
|
|
820
|
+
|
|
821
|
+
times = pd.date_range("2000-01-01", "2001-12-31", freq="D")
|
|
822
|
+
da = xr.DataArray(
|
|
823
|
+
np.random.default_rng(0).standard_normal((len(times), 3, 3)).astype(np.float32),
|
|
824
|
+
dims=["time", "lat", "lon"],
|
|
825
|
+
coords={"time": times, "lat": [0.0, 1.0, 2.0], "lon": [0.0, 1.0, 2.0]},
|
|
826
|
+
).chunk({"time": -1})
|
|
827
|
+
|
|
828
|
+
result = marEx.preprocess_data(
|
|
829
|
+
da,
|
|
830
|
+
method_anomaly="fixed_baseline",
|
|
831
|
+
method_extreme="global_extreme",
|
|
832
|
+
threshold_percentile=95,
|
|
833
|
+
reference_period=(2000, 2000),
|
|
834
|
+
dimensions={"time": "time", "x": "lon", "y": "lat"},
|
|
835
|
+
dask_chunks={"time": -1},
|
|
836
|
+
)
|
|
837
|
+
assert result.attrs.get("reference_period") == [2000, 2000]
|
|
838
|
+
|
|
839
|
+
def test_reference_period_invalid_for_shifting_baseline(self):
|
|
840
|
+
"""Test that reference_period raises ConfigurationError for incompatible methods."""
|
|
841
|
+
with pytest.raises(ConfigurationError, match="reference_period is not supported"):
|
|
842
|
+
marEx.preprocess_data(
|
|
843
|
+
self.sst_data,
|
|
844
|
+
method_anomaly="shifting_baseline",
|
|
845
|
+
reference_period=(1990, 2010),
|
|
846
|
+
dimensions=self.dimensions,
|
|
847
|
+
dask_chunks=self.dask_chunks,
|
|
848
|
+
)
|
|
@@ -20,14 +20,14 @@ from marEx.exceptions import ConfigurationError, DataValidationError
|
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
@pytest.fixture(scope="module")
|
|
23
|
-
def extremes_gridded():
|
|
23
|
+
def extremes_gridded(dask_client):
|
|
24
24
|
"""Load gridded extremes data for testing."""
|
|
25
25
|
test_data_path = Path(__file__).parent / "data" / "extremes_gridded.zarr"
|
|
26
26
|
return xr.open_zarr(str(test_data_path), chunks={}).persist()
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
@pytest.fixture(scope="module")
|
|
30
|
-
def extremes_unstructured():
|
|
30
|
+
def extremes_unstructured(dask_client):
|
|
31
31
|
"""Load unstructured extremes data for testing."""
|
|
32
32
|
test_data_path = Path(__file__).parent / "data" / "extremes_unstructured.zarr"
|
|
33
33
|
return xr.open_zarr(str(test_data_path), chunks={}).persist()
|
|
@@ -45,7 +45,7 @@ def temp_checkpoint_dir():
|
|
|
45
45
|
class TestCheckpointFunctionality:
|
|
46
46
|
"""Test checkpoint save and load functionality."""
|
|
47
47
|
|
|
48
|
-
def test_checkpoint_save(self, extremes_unstructured, temp_checkpoint_dir,
|
|
48
|
+
def test_checkpoint_save(self, extremes_unstructured, temp_checkpoint_dir, dask_client):
|
|
49
49
|
"""Test saving checkpoints during preprocessing (unstructured grids only)."""
|
|
50
50
|
neighbours = extremes_unstructured.neighbours
|
|
51
51
|
cell_areas = extremes_unstructured.cell_areas
|
|
@@ -84,7 +84,7 @@ class TestCheckpointFunctionality:
|
|
|
84
84
|
loaded_zarr = xr.open_zarr(str(checkpoint_zarr))
|
|
85
85
|
assert "data_bin_preproc" in loaded_zarr, "Preprocessed data not in checkpoint"
|
|
86
86
|
|
|
87
|
-
def test_checkpoint_load(self, extremes_unstructured, temp_checkpoint_dir,
|
|
87
|
+
def test_checkpoint_load(self, extremes_unstructured, temp_checkpoint_dir, dask_client):
|
|
88
88
|
"""Test loading from existing checkpoints (unstructured grids only)."""
|
|
89
89
|
neighbours = extremes_unstructured.neighbours
|
|
90
90
|
cell_areas = extremes_unstructured.cell_areas
|
|
@@ -144,7 +144,7 @@ class TestCheckpointFunctionality:
|
|
|
144
144
|
class TestChunkingValidationWarnings:
|
|
145
145
|
"""Test chunking validation and rechunking warnings."""
|
|
146
146
|
|
|
147
|
-
def test_neighbours_nv_chunking_warning(self, extremes_unstructured, temp_checkpoint_dir,
|
|
147
|
+
def test_neighbours_nv_chunking_warning(self, extremes_unstructured, temp_checkpoint_dir, dask_client):
|
|
148
148
|
"""Test warning when neighbours has multiple chunks in nv dimension."""
|
|
149
149
|
neighbours = extremes_unstructured.neighbours
|
|
150
150
|
cell_areas = extremes_unstructured.cell_areas
|
|
@@ -178,7 +178,7 @@ class TestChunkingValidationWarnings:
|
|
|
178
178
|
class TestDataValidationEdgeCases:
|
|
179
179
|
"""Test edge cases in data validation."""
|
|
180
180
|
|
|
181
|
-
def test_empty_data_bin_attrs(self, extremes_gridded,
|
|
181
|
+
def test_empty_data_bin_attrs(self, extremes_gridded, dask_client):
|
|
182
182
|
"""Test handling of DataArray with no attrs."""
|
|
183
183
|
# Remove all attributes
|
|
184
184
|
data_no_attrs = extremes_gridded.extreme_events.copy(deep=True)
|
|
@@ -195,7 +195,7 @@ class TestDataValidationEdgeCases:
|
|
|
195
195
|
|
|
196
196
|
assert tracker.data_attrs == {}, "Should handle empty attrs gracefully"
|
|
197
197
|
|
|
198
|
-
def test_missing_coordinates_unstructured(self,
|
|
198
|
+
def test_missing_coordinates_unstructured(self, dask_client):
|
|
199
199
|
"""Test error when required coordinates are missing in unstructured data."""
|
|
200
200
|
# Create data with missing coordinates
|
|
201
201
|
test_data = xr.DataArray(
|
|
@@ -231,7 +231,7 @@ class TestDataValidationEdgeCases:
|
|
|
231
231
|
class TestOverlapValidationAndWarnings:
|
|
232
232
|
"""Test overlap validation and warning conditions."""
|
|
233
233
|
|
|
234
|
-
def test_empty_overlap_list_return(self, extremes_gridded,
|
|
234
|
+
def test_empty_overlap_list_return(self, extremes_gridded, dask_client):
|
|
235
235
|
"""Test handling of empty overlap lists."""
|
|
236
236
|
# Create minimal tracker
|
|
237
237
|
tracker = marEx.tracker(
|
|
@@ -254,7 +254,7 @@ class TestOverlapValidationAndWarnings:
|
|
|
254
254
|
class TestComplexMergeSplitValidation:
|
|
255
255
|
"""Test complex merge/split validation and logging."""
|
|
256
256
|
|
|
257
|
-
def test_complex_merging_scenario(self, extremes_gridded,
|
|
257
|
+
def test_complex_merging_scenario(self, extremes_gridded, dask_client):
|
|
258
258
|
"""Test complex merging with validation logging."""
|
|
259
259
|
# Create tracker with aggressive merging settings
|
|
260
260
|
tracker = marEx.tracker(
|
|
@@ -277,7 +277,7 @@ class TestComplexMergeSplitValidation:
|
|
|
277
277
|
assert "allow_merging" in tracked_ds.attrs
|
|
278
278
|
assert tracked_ds.attrs["allow_merging"] == 1
|
|
279
279
|
|
|
280
|
-
def test_merging_with_temporal_fill(self, extremes_gridded,
|
|
280
|
+
def test_merging_with_temporal_fill(self, extremes_gridded, dask_client):
|
|
281
281
|
"""Test merging with temporal filling."""
|
|
282
282
|
tracker = marEx.tracker(
|
|
283
283
|
extremes_gridded.extreme_events,
|
|
@@ -301,10 +301,12 @@ class TestComplexMergeSplitValidation:
|
|
|
301
301
|
class TestUnstructuredPartitioning:
|
|
302
302
|
"""Test unstructured grid partitioning code paths."""
|
|
303
303
|
|
|
304
|
-
def test_unstructured_tracking_with_merging(self, extremes_unstructured, temp_checkpoint_dir,
|
|
304
|
+
def test_unstructured_tracking_with_merging(self, extremes_unstructured, temp_checkpoint_dir, dask_client):
|
|
305
305
|
"""Test unstructured grid tracking with merging to trigger partition code."""
|
|
306
306
|
neighbours = extremes_unstructured.neighbours
|
|
307
|
-
cell_areas
|
|
307
|
+
# Compute cell_areas to numpy so it doesn't serialise as a distributed
|
|
308
|
+
# future into Dask workers
|
|
309
|
+
cell_areas = extremes_unstructured.cell_areas.compute()
|
|
308
310
|
|
|
309
311
|
# Create tracker with merging enabled
|
|
310
312
|
tracker = marEx.tracker(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|