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.
Files changed (59) hide show
  1. {marex-4.0.3 → marex-4.1}/.github/workflows/ci.yml +6 -1
  2. {marex-4.0.3 → marex-4.1}/PKG-INFO +2 -2
  3. {marex-4.0.3 → marex-4.1}/README.md +1 -1
  4. {marex-4.0.3 → marex-4.1}/marEx/detect.py +163 -32
  5. {marex-4.0.3 → marex-4.1}/marEx.egg-info/PKG-INFO +2 -2
  6. {marex-4.0.3 → marex-4.1}/tests/test_gridded_preprocessing.py +79 -0
  7. {marex-4.0.3 → marex-4.1}/tests/test_track_edge_cases.py +14 -12
  8. {marex-4.0.3 → marex-4.1}/.codecov.yml +0 -0
  9. {marex-4.0.3 → marex-4.1}/.github/workflows/README.md +0 -0
  10. {marex-4.0.3 → marex-4.1}/.github/workflows/release.yml +0 -0
  11. {marex-4.0.3 → marex-4.1}/.github/workflows/validate.py +0 -0
  12. {marex-4.0.3 → marex-4.1}/.readthedocs.yaml +0 -0
  13. {marex-4.0.3 → marex-4.1}/CITATION.cff +0 -0
  14. {marex-4.0.3 → marex-4.1}/CONTRIBUTING.md +0 -0
  15. {marex-4.0.3 → marex-4.1}/MANIFEST.in +0 -0
  16. {marex-4.0.3 → marex-4.1}/examples/batch jobs/run_detect.py +0 -0
  17. {marex-4.0.3 → marex-4.1}/examples/batch jobs/run_track.py +0 -0
  18. {marex-4.0.3 → marex-4.1}/examples/batch jobs/submit_track.sh +0 -0
  19. {marex-4.0.3 → marex-4.1}/examples/gridded data/01_preprocess_extremes.ipynb +0 -0
  20. {marex-4.0.3 → marex-4.1}/examples/gridded data/02_id_track_events.ipynb +0 -0
  21. {marex-4.0.3 → marex-4.1}/examples/gridded data/03_visualise_events.ipynb +0 -0
  22. {marex-4.0.3 → marex-4.1}/examples/regional data/01_preprocess_extremes.ipynb +0 -0
  23. {marex-4.0.3 → marex-4.1}/examples/regional data/02_id_track_events.ipynb +0 -0
  24. {marex-4.0.3 → marex-4.1}/examples/regional data/03_visualise_events.ipynb +0 -0
  25. {marex-4.0.3 → marex-4.1}/examples/unstructured data/01_preprocess_extremes.ipynb +0 -0
  26. {marex-4.0.3 → marex-4.1}/examples/unstructured data/02_id_track_events.ipynb +0 -0
  27. {marex-4.0.3 → marex-4.1}/examples/unstructured data/03_visualise_events.ipynb +0 -0
  28. {marex-4.0.3 → marex-4.1}/marEx/__init__.py +0 -0
  29. {marex-4.0.3 → marex-4.1}/marEx/_coverage_init.py +0 -0
  30. {marex-4.0.3 → marex-4.1}/marEx/_dependencies.py +0 -0
  31. {marex-4.0.3 → marex-4.1}/marEx/exceptions.py +0 -0
  32. {marex-4.0.3 → marex-4.1}/marEx/helper.py +0 -0
  33. {marex-4.0.3 → marex-4.1}/marEx/logging_config.py +0 -0
  34. {marex-4.0.3 → marex-4.1}/marEx/plotX/__init__.py +0 -0
  35. {marex-4.0.3 → marex-4.1}/marEx/plotX/base.py +0 -0
  36. {marex-4.0.3 → marex-4.1}/marEx/plotX/gridded.py +0 -0
  37. {marex-4.0.3 → marex-4.1}/marEx/plotX/unstructured.py +0 -0
  38. {marex-4.0.3 → marex-4.1}/marEx/track.py +0 -0
  39. {marex-4.0.3 → marex-4.1}/marEx.egg-info/SOURCES.txt +0 -0
  40. {marex-4.0.3 → marex-4.1}/marEx.egg-info/dependency_links.txt +0 -0
  41. {marex-4.0.3 → marex-4.1}/marEx.egg-info/requires.txt +0 -0
  42. {marex-4.0.3 → marex-4.1}/marEx.egg-info/top_level.txt +0 -0
  43. {marex-4.0.3 → marex-4.1}/pyproject.toml +0 -0
  44. {marex-4.0.3 → marex-4.1}/setup.cfg +0 -0
  45. {marex-4.0.3 → marex-4.1}/tests/__init__.py +0 -0
  46. {marex-4.0.3 → marex-4.1}/tests/conftest.py +0 -0
  47. {marex-4.0.3 → marex-4.1}/tests/coverage_config.py +0 -0
  48. {marex-4.0.3 → marex-4.1}/tests/make_test_data.ipynb +0 -0
  49. {marex-4.0.3 → marex-4.1}/tests/test_detect_detrending.py +0 -0
  50. {marex-4.0.3 → marex-4.1}/tests/test_detect_helpers.py +0 -0
  51. {marex-4.0.3 → marex-4.1}/tests/test_error_handling.py +0 -0
  52. {marex-4.0.3 → marex-4.1}/tests/test_exceptions.py +0 -0
  53. {marex-4.0.3 → marex-4.1}/tests/test_gridded_tracking.py +0 -0
  54. {marex-4.0.3 → marex-4.1}/tests/test_integration.py +0 -0
  55. {marex-4.0.3 → marex-4.1}/tests/test_logging_system.py +0 -0
  56. {marex-4.0.3 → marex-4.1}/tests/test_plotx.py +0 -0
  57. {marex-4.0.3 → marex-4.1}/tests/test_track_helpers.py +0 -0
  58. {marex-4.0.3 → marex-4.1}/tests/test_unstructured_preprocessing.py +0 -0
  59. {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.0.3
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
- ![logo](https://github.com/user-attachments/assets/df99a84b-c88f-447d-a362-ac538d9b924b)
90
+ <img src="https://github.com/user-attachments/assets/55eab465-9aca-4754-a981-e26a36f7d045" alt="marEx_animated" width="100%">
91
91
 
92
92
  [![CI](https://github.com/wienkers/marEx/actions/workflows/ci.yml/badge.svg)](https://github.com/wienkers/marEx/actions/workflows/ci.yml)
93
93
  [![codecov](https://codecov.io/gh/wienkers/marEx/branch/main/graph/badge.svg)](https://codecov.io/gh/wienkers/marEx)
@@ -1,4 +1,4 @@
1
- ![logo](https://github.com/user-attachments/assets/df99a84b-c88f-447d-a362-ac538d9b924b)
1
+ <img src="https://github.com/user-attachments/assets/55eab465-9aca-4754-a981-e26a36f7d045" alt="marEx_animated" width="100%">
2
2
 
3
3
  [![CI](https://github.com/wienkers/marEx/actions/workflows/ci.yml/badge.svg)](https://github.com/wienkers/marEx/actions/workflows/ci.yml)
4
4
  [![codecov](https://codecov.io/gh/wienkers/marEx/branch/main/graph/badge.svg)](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
- if use_temp_checkpoints:
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
- ds.attrs.update({}) # No method-specific parameters
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
- ds.attrs.update(
748
- {
749
- "detrend_orders": detrend_orders,
750
- "force_zero_mean": force_zero_mean,
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
- steps.append("Daily climatology computed from full time series")
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
- steps.append("Daily climatology computed from detrended data")
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: using full time series for daily climatology")
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(f"Fixed detrended baseline parameters: orders={detrend_orders}, zero_mean={force_zero_mean}")
1036
- return _compute_anomaly_detrend_fixed_baseline(da, detrend_orders, dimensions, coordinates, force_zero_mean)
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
- # Construct rolling window dimension
1860
- da_windowed = da.rolling({dimensions["time"]: window_days_hobday}, center=True).construct("window")
1861
-
1862
- # Ensure dayofyear coordinate is computed for groupby (required by newer xarray)
1863
- if "dayofyear" in da_windowed.coords:
1864
- da_windowed = da_windowed.assign_coords(dayofyear=da_windowed.dayofyear.compute())
1865
-
1866
- thresholds = da_windowed.groupby("dayofyear").reduce(
1867
- np.nanpercentile, q=threshold_percentile, dim=("window", dimensions["time"])
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 original data to obtain anomalies.
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
- # Compute daily climatology across all years using flox for efficiency
2239
- logger.debug("Computing daily climatology across all years")
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
- da,
2242
- da[coordinates["time"]].dt.dayofyear,
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 full-time-series daily climatology from the detrended signal.
2287
- Uses _compute_anomaly_detrended internally to perform the trend removal.
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.0.3
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
- ![logo](https://github.com/user-attachments/assets/df99a84b-c88f-447d-a362-ac538d9b924b)
90
+ <img src="https://github.com/user-attachments/assets/55eab465-9aca-4754-a981-e26a36f7d045" alt="marEx_animated" width="100%">
91
91
 
92
92
  [![CI](https://github.com/wienkers/marEx/actions/workflows/ci.yml/badge.svg)](https://github.com/wienkers/marEx/actions/workflows/ci.yml)
93
93
  [![codecov](https://codecov.io/gh/wienkers/marEx/branch/main/graph/badge.svg)](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, dask_client_unstructured):
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, dask_client_unstructured):
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, dask_client_unstructured):
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, dask_client_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, dask_client_unstructured):
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, dask_client_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, dask_client_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, dask_client_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, dask_client_unstructured):
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 = extremes_unstructured.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