marEx 4.0.3__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.3 → marex-4.1}/.github/workflows/ci.yml +6 -1
- {marex-4.0.3 → marex-4.1}/PKG-INFO +2 -2
- {marex-4.0.3 → marex-4.1}/README.md +1 -1
- {marex-4.0.3 → marex-4.1}/marEx/detect.py +163 -32
- {marex-4.0.3 → marex-4.1}/marEx.egg-info/PKG-INFO +2 -2
- {marex-4.0.3 → marex-4.1}/tests/test_gridded_preprocessing.py +79 -0
- {marex-4.0.3 → marex-4.1}/tests/test_track_edge_cases.py +14 -12
- {marex-4.0.3 → marex-4.1}/.codecov.yml +0 -0
- {marex-4.0.3 → marex-4.1}/.github/workflows/README.md +0 -0
- {marex-4.0.3 → marex-4.1}/.github/workflows/release.yml +0 -0
- {marex-4.0.3 → marex-4.1}/.github/workflows/validate.py +0 -0
- {marex-4.0.3 → marex-4.1}/.readthedocs.yaml +0 -0
- {marex-4.0.3 → marex-4.1}/CITATION.cff +0 -0
- {marex-4.0.3 → marex-4.1}/CONTRIBUTING.md +0 -0
- {marex-4.0.3 → marex-4.1}/MANIFEST.in +0 -0
- {marex-4.0.3 → marex-4.1}/examples/batch jobs/run_detect.py +0 -0
- {marex-4.0.3 → marex-4.1}/examples/batch jobs/run_track.py +0 -0
- {marex-4.0.3 → marex-4.1}/examples/batch jobs/submit_track.sh +0 -0
- {marex-4.0.3 → marex-4.1}/examples/gridded data/01_preprocess_extremes.ipynb +0 -0
- {marex-4.0.3 → marex-4.1}/examples/gridded data/02_id_track_events.ipynb +0 -0
- {marex-4.0.3 → marex-4.1}/examples/gridded data/03_visualise_events.ipynb +0 -0
- {marex-4.0.3 → marex-4.1}/examples/regional data/01_preprocess_extremes.ipynb +0 -0
- {marex-4.0.3 → marex-4.1}/examples/regional data/02_id_track_events.ipynb +0 -0
- {marex-4.0.3 → marex-4.1}/examples/regional data/03_visualise_events.ipynb +0 -0
- {marex-4.0.3 → marex-4.1}/examples/unstructured data/01_preprocess_extremes.ipynb +0 -0
- {marex-4.0.3 → marex-4.1}/examples/unstructured data/02_id_track_events.ipynb +0 -0
- {marex-4.0.3 → marex-4.1}/examples/unstructured data/03_visualise_events.ipynb +0 -0
- {marex-4.0.3 → marex-4.1}/marEx/__init__.py +0 -0
- {marex-4.0.3 → marex-4.1}/marEx/_coverage_init.py +0 -0
- {marex-4.0.3 → marex-4.1}/marEx/_dependencies.py +0 -0
- {marex-4.0.3 → marex-4.1}/marEx/exceptions.py +0 -0
- {marex-4.0.3 → marex-4.1}/marEx/helper.py +0 -0
- {marex-4.0.3 → marex-4.1}/marEx/logging_config.py +0 -0
- {marex-4.0.3 → marex-4.1}/marEx/plotX/__init__.py +0 -0
- {marex-4.0.3 → marex-4.1}/marEx/plotX/base.py +0 -0
- {marex-4.0.3 → marex-4.1}/marEx/plotX/gridded.py +0 -0
- {marex-4.0.3 → marex-4.1}/marEx/plotX/unstructured.py +0 -0
- {marex-4.0.3 → marex-4.1}/marEx/track.py +0 -0
- {marex-4.0.3 → marex-4.1}/marEx.egg-info/SOURCES.txt +0 -0
- {marex-4.0.3 → marex-4.1}/marEx.egg-info/dependency_links.txt +0 -0
- {marex-4.0.3 → marex-4.1}/marEx.egg-info/requires.txt +0 -0
- {marex-4.0.3 → marex-4.1}/marEx.egg-info/top_level.txt +0 -0
- {marex-4.0.3 → marex-4.1}/pyproject.toml +0 -0
- {marex-4.0.3 → marex-4.1}/setup.cfg +0 -0
- {marex-4.0.3 → marex-4.1}/tests/__init__.py +0 -0
- {marex-4.0.3 → marex-4.1}/tests/conftest.py +0 -0
- {marex-4.0.3 → marex-4.1}/tests/coverage_config.py +0 -0
- {marex-4.0.3 → marex-4.1}/tests/make_test_data.ipynb +0 -0
- {marex-4.0.3 → marex-4.1}/tests/test_detect_detrending.py +0 -0
- {marex-4.0.3 → marex-4.1}/tests/test_detect_helpers.py +0 -0
- {marex-4.0.3 → marex-4.1}/tests/test_error_handling.py +0 -0
- {marex-4.0.3 → marex-4.1}/tests/test_exceptions.py +0 -0
- {marex-4.0.3 → marex-4.1}/tests/test_gridded_tracking.py +0 -0
- {marex-4.0.3 → marex-4.1}/tests/test_integration.py +0 -0
- {marex-4.0.3 → marex-4.1}/tests/test_logging_system.py +0 -0
- {marex-4.0.3 → marex-4.1}/tests/test_plotx.py +0 -0
- {marex-4.0.3 → marex-4.1}/tests/test_track_helpers.py +0 -0
- {marex-4.0.3 → marex-4.1}/tests/test_unstructured_preprocessing.py +0 -0
- {marex-4.0.3 → 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)
|
|
@@ -622,7 +641,9 @@ def preprocess_data(
|
|
|
622
641
|
ds = ds.isel({dimensions["time"]: time_sel})
|
|
623
642
|
|
|
624
643
|
# Break graph after expensive anomaly computation
|
|
625
|
-
|
|
644
|
+
# Only needed for shifting baseline, because this is the most expensive method
|
|
645
|
+
# Other methods result in an odd chunking structure that cannot be checkpointed easily
|
|
646
|
+
if use_temp_checkpoints and method_anomaly == "shifting_baseline":
|
|
626
647
|
logger.debug("Checkpointing anomaly dataset to break graph dependencies")
|
|
627
648
|
ds = checkpoint_to_zarr(ds, name="anomalies", timedim=dimensions["time"])
|
|
628
649
|
|
|
@@ -721,6 +742,7 @@ def preprocess_data(
|
|
|
721
742
|
smooth_days_baseline,
|
|
722
743
|
window_days_hobday,
|
|
723
744
|
window_spatial_hobday,
|
|
745
|
+
reference_period,
|
|
724
746
|
),
|
|
725
747
|
}
|
|
726
748
|
)
|
|
@@ -742,14 +764,18 @@ def preprocess_data(
|
|
|
742
764
|
}
|
|
743
765
|
)
|
|
744
766
|
elif method_anomaly == "fixed_baseline":
|
|
745
|
-
|
|
767
|
+
attrs = {}
|
|
768
|
+
if reference_period is not None:
|
|
769
|
+
attrs["reference_period"] = list(reference_period)
|
|
770
|
+
ds.attrs.update(attrs)
|
|
746
771
|
elif method_anomaly == "detrend_fixed_baseline":
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
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)
|
|
753
779
|
|
|
754
780
|
if method_extreme == "hobday_extreme":
|
|
755
781
|
ds.attrs.update({"window_days_hobday": window_days_hobday})
|
|
@@ -789,6 +815,7 @@ def preprocess_data(
|
|
|
789
815
|
show_progress=True,
|
|
790
816
|
):
|
|
791
817
|
ds = ds.persist(optimize_graph=True)
|
|
818
|
+
ds["thresholds"] = ds.thresholds.compute() # Patch for a dask-Zarr bug that has problems saving this data array...
|
|
792
819
|
ds["mask"] = ds.mask.compute()
|
|
793
820
|
ds["dat_anomaly"] = fix_dask_tuple_array(ds.dat_anomaly)
|
|
794
821
|
|
|
@@ -823,6 +850,7 @@ def _get_preprocessing_steps(
|
|
|
823
850
|
smooth_days_baseline: int,
|
|
824
851
|
window_days_hobday: int,
|
|
825
852
|
window_spatial_hobday: Optional[int],
|
|
853
|
+
reference_period: Optional[Tuple[int, int]] = None,
|
|
826
854
|
) -> List[str]:
|
|
827
855
|
"""Generate preprocessing steps description based on selected methods."""
|
|
828
856
|
steps = []
|
|
@@ -835,10 +863,16 @@ def _get_preprocessing_steps(
|
|
|
835
863
|
steps.append(f"Rolling climatology using {window_year_baseline} years")
|
|
836
864
|
steps.append(f"Smoothed with {smooth_days_baseline}-day window")
|
|
837
865
|
elif method_anomaly == "fixed_baseline":
|
|
838
|
-
|
|
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")
|
|
839
870
|
elif method_anomaly == "detrend_fixed_baseline":
|
|
840
871
|
steps.append(f"Removed polynomial trend orders={detrend_orders}")
|
|
841
|
-
|
|
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")
|
|
842
876
|
|
|
843
877
|
# Extreme method steps
|
|
844
878
|
if method_extreme == "global_extreme":
|
|
@@ -866,6 +900,7 @@ def compute_normalised_anomaly(
|
|
|
866
900
|
std_normalise: bool = False, # for detrend_harmonic
|
|
867
901
|
detrend_orders: Optional[List[int]] = None, # "
|
|
868
902
|
force_zero_mean: bool = True, # "
|
|
903
|
+
reference_period: Optional[Tuple[int, int]] = None, # for fixed_baseline & detrend_fixed_baseline
|
|
869
904
|
use_temp_checkpoints: bool = False,
|
|
870
905
|
verbose: Optional[bool] = None,
|
|
871
906
|
quiet: Optional[bool] = None,
|
|
@@ -898,7 +933,10 @@ def compute_normalised_anomaly(
|
|
|
898
933
|
Polynomial orders for trend removal (detrend_harmonic and detrend_fixed_baseline only)
|
|
899
934
|
force_zero_mean : bool, default=True
|
|
900
935
|
Explicitly enforce zero mean in final anomalies (detrend_harmonic and detrend_fixed_baseline only)
|
|
901
|
-
|
|
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.
|
|
902
940
|
|
|
903
941
|
Returns
|
|
904
942
|
-------
|
|
@@ -994,6 +1032,15 @@ def compute_normalised_anomaly(
|
|
|
994
1032
|
... )
|
|
995
1033
|
>>> # Climatology computed from all available years
|
|
996
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
|
+
|
|
997
1044
|
Fixed detrended baseline:
|
|
998
1045
|
|
|
999
1046
|
>>> # Remove long-term trends then compute fixed climatology
|
|
@@ -1018,6 +1065,17 @@ def compute_normalised_anomaly(
|
|
|
1018
1065
|
# Infer and validate dimensions and coordinates
|
|
1019
1066
|
dimensions, coordinates = _infer_dims_coords(da, dimensions, coordinates)
|
|
1020
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
|
+
|
|
1021
1079
|
if method_anomaly == "detrend_harmonic":
|
|
1022
1080
|
logger.debug(
|
|
1023
1081
|
f"Detrended baseline parameters: std_normalise={std_normalise}, orders={detrend_orders}, zero_mean={force_zero_mean}"
|
|
@@ -1029,11 +1087,16 @@ def compute_normalised_anomaly(
|
|
|
1029
1087
|
da, window_year_baseline, smooth_days_baseline, dimensions, coordinates, use_temp_checkpoints
|
|
1030
1088
|
)
|
|
1031
1089
|
elif method_anomaly == "fixed_baseline":
|
|
1032
|
-
logger.debug("Fixed baseline parameters:
|
|
1033
|
-
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)
|
|
1034
1092
|
elif method_anomaly == "detrend_fixed_baseline":
|
|
1035
|
-
logger.debug(
|
|
1036
|
-
|
|
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
|
+
)
|
|
1037
1100
|
else:
|
|
1038
1101
|
logger.error(f"Unknown anomaly method: {method_anomaly}")
|
|
1039
1102
|
raise ConfigurationError(
|
|
@@ -1856,16 +1919,41 @@ def _identify_extremes_hobday(
|
|
|
1856
1919
|
|
|
1857
1920
|
# Group by day-of-year and compute percentile
|
|
1858
1921
|
if method_percentile == "exact":
|
|
1859
|
-
#
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
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}},
|
|
1868
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", ...)
|
|
1869
1957
|
else: # Optimised histogram approximation method
|
|
1870
1958
|
thresholds = _compute_histogram_quantile_2d(
|
|
1871
1959
|
da,
|
|
@@ -2176,6 +2264,7 @@ def _compute_anomaly_detrended(
|
|
|
2176
2264
|
func="std",
|
|
2177
2265
|
isbin=False,
|
|
2178
2266
|
method="cohorts",
|
|
2267
|
+
dtype=np.float32,
|
|
2179
2268
|
)
|
|
2180
2269
|
|
|
2181
2270
|
# Calculate 30-day rolling standard deviation with annual wrapped padding
|
|
@@ -2211,12 +2300,14 @@ def _compute_anomaly_fixed_baseline(
|
|
|
2211
2300
|
da: xr.DataArray,
|
|
2212
2301
|
dimensions: Optional[Dict[str, str]] = None,
|
|
2213
2302
|
coordinates: Optional[Dict[str, str]] = None,
|
|
2303
|
+
reference_period: Optional[Tuple[int, int]] = None,
|
|
2214
2304
|
) -> xr.Dataset:
|
|
2215
2305
|
"""
|
|
2216
2306
|
Compute anomalies using fixed baseline method with full time series climatology.
|
|
2217
2307
|
|
|
2218
|
-
This method computes a daily climatology using all available years in the dataset
|
|
2219
|
-
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.
|
|
2220
2311
|
|
|
2221
2312
|
Parameters
|
|
2222
2313
|
----------
|
|
@@ -2226,6 +2317,10 @@ def _compute_anomaly_fixed_baseline(
|
|
|
2226
2317
|
Mapping of dimensions to names in the data
|
|
2227
2318
|
coordinates : dict, optional
|
|
2228
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.
|
|
2229
2324
|
|
|
2230
2325
|
Returns
|
|
2231
2326
|
-------
|
|
@@ -2235,11 +2330,41 @@ def _compute_anomaly_fixed_baseline(
|
|
|
2235
2330
|
# Infer and validate dimensions and coordinates
|
|
2236
2331
|
dimensions, coordinates = _infer_dims_coords(da, dimensions, coordinates)
|
|
2237
2332
|
|
|
2238
|
-
#
|
|
2239
|
-
|
|
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")
|
|
2240
2365
|
daily_climatology = flox.xarray.xarray_reduce(
|
|
2241
|
-
|
|
2242
|
-
|
|
2366
|
+
da_for_clim,
|
|
2367
|
+
da_for_clim[coordinates["time"]].dt.dayofyear,
|
|
2243
2368
|
dim=dimensions["time"],
|
|
2244
2369
|
func="nanmean",
|
|
2245
2370
|
isbin=False,
|
|
@@ -2278,13 +2403,14 @@ def _compute_anomaly_detrend_fixed_baseline(
|
|
|
2278
2403
|
dimensions: Optional[Dict[str, str]] = None,
|
|
2279
2404
|
coordinates: Optional[Dict[str, str]] = None,
|
|
2280
2405
|
force_zero_mean: bool = True,
|
|
2406
|
+
reference_period: Optional[Tuple[int, int]] = None,
|
|
2281
2407
|
) -> xr.Dataset:
|
|
2282
2408
|
"""
|
|
2283
2409
|
Compute anomalies using fixed detrended baseline method.
|
|
2284
2410
|
|
|
2285
2411
|
This method first removes polynomial trends (without harmonics) from the data,
|
|
2286
|
-
then removes a
|
|
2287
|
-
|
|
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.
|
|
2288
2414
|
|
|
2289
2415
|
Parameters
|
|
2290
2416
|
----------
|
|
@@ -2298,6 +2424,10 @@ def _compute_anomaly_detrend_fixed_baseline(
|
|
|
2298
2424
|
Mapping of coordinates to names in the data
|
|
2299
2425
|
force_zero_mean : bool, default=True
|
|
2300
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.
|
|
2301
2431
|
|
|
2302
2432
|
Returns
|
|
2303
2433
|
-------
|
|
@@ -2326,6 +2456,7 @@ def _compute_anomaly_detrend_fixed_baseline(
|
|
|
2326
2456
|
da=detrended_result,
|
|
2327
2457
|
dimensions=dimensions,
|
|
2328
2458
|
coordinates=coordinates,
|
|
2459
|
+
reference_period=reference_period,
|
|
2329
2460
|
)
|
|
2330
2461
|
|
|
2331
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
|