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.
Files changed (59) hide show
  1. {marex-4.0.4 → marex-4.1}/.github/workflows/ci.yml +6 -1
  2. {marex-4.0.4 → marex-4.1}/PKG-INFO +2 -2
  3. {marex-4.0.4 → marex-4.1}/README.md +1 -1
  4. {marex-4.0.4 → marex-4.1}/marEx/detect.py +159 -31
  5. {marex-4.0.4 → marex-4.1}/marEx.egg-info/PKG-INFO +2 -2
  6. {marex-4.0.4 → marex-4.1}/tests/test_gridded_preprocessing.py +79 -0
  7. {marex-4.0.4 → marex-4.1}/tests/test_track_edge_cases.py +14 -12
  8. {marex-4.0.4 → marex-4.1}/.codecov.yml +0 -0
  9. {marex-4.0.4 → marex-4.1}/.github/workflows/README.md +0 -0
  10. {marex-4.0.4 → marex-4.1}/.github/workflows/release.yml +0 -0
  11. {marex-4.0.4 → marex-4.1}/.github/workflows/validate.py +0 -0
  12. {marex-4.0.4 → marex-4.1}/.readthedocs.yaml +0 -0
  13. {marex-4.0.4 → marex-4.1}/CITATION.cff +0 -0
  14. {marex-4.0.4 → marex-4.1}/CONTRIBUTING.md +0 -0
  15. {marex-4.0.4 → marex-4.1}/MANIFEST.in +0 -0
  16. {marex-4.0.4 → marex-4.1}/examples/batch jobs/run_detect.py +0 -0
  17. {marex-4.0.4 → marex-4.1}/examples/batch jobs/run_track.py +0 -0
  18. {marex-4.0.4 → marex-4.1}/examples/batch jobs/submit_track.sh +0 -0
  19. {marex-4.0.4 → marex-4.1}/examples/gridded data/01_preprocess_extremes.ipynb +0 -0
  20. {marex-4.0.4 → marex-4.1}/examples/gridded data/02_id_track_events.ipynb +0 -0
  21. {marex-4.0.4 → marex-4.1}/examples/gridded data/03_visualise_events.ipynb +0 -0
  22. {marex-4.0.4 → marex-4.1}/examples/regional data/01_preprocess_extremes.ipynb +0 -0
  23. {marex-4.0.4 → marex-4.1}/examples/regional data/02_id_track_events.ipynb +0 -0
  24. {marex-4.0.4 → marex-4.1}/examples/regional data/03_visualise_events.ipynb +0 -0
  25. {marex-4.0.4 → marex-4.1}/examples/unstructured data/01_preprocess_extremes.ipynb +0 -0
  26. {marex-4.0.4 → marex-4.1}/examples/unstructured data/02_id_track_events.ipynb +0 -0
  27. {marex-4.0.4 → marex-4.1}/examples/unstructured data/03_visualise_events.ipynb +0 -0
  28. {marex-4.0.4 → marex-4.1}/marEx/__init__.py +0 -0
  29. {marex-4.0.4 → marex-4.1}/marEx/_coverage_init.py +0 -0
  30. {marex-4.0.4 → marex-4.1}/marEx/_dependencies.py +0 -0
  31. {marex-4.0.4 → marex-4.1}/marEx/exceptions.py +0 -0
  32. {marex-4.0.4 → marex-4.1}/marEx/helper.py +0 -0
  33. {marex-4.0.4 → marex-4.1}/marEx/logging_config.py +0 -0
  34. {marex-4.0.4 → marex-4.1}/marEx/plotX/__init__.py +0 -0
  35. {marex-4.0.4 → marex-4.1}/marEx/plotX/base.py +0 -0
  36. {marex-4.0.4 → marex-4.1}/marEx/plotX/gridded.py +0 -0
  37. {marex-4.0.4 → marex-4.1}/marEx/plotX/unstructured.py +0 -0
  38. {marex-4.0.4 → marex-4.1}/marEx/track.py +0 -0
  39. {marex-4.0.4 → marex-4.1}/marEx.egg-info/SOURCES.txt +0 -0
  40. {marex-4.0.4 → marex-4.1}/marEx.egg-info/dependency_links.txt +0 -0
  41. {marex-4.0.4 → marex-4.1}/marEx.egg-info/requires.txt +0 -0
  42. {marex-4.0.4 → marex-4.1}/marEx.egg-info/top_level.txt +0 -0
  43. {marex-4.0.4 → marex-4.1}/pyproject.toml +0 -0
  44. {marex-4.0.4 → marex-4.1}/setup.cfg +0 -0
  45. {marex-4.0.4 → marex-4.1}/tests/__init__.py +0 -0
  46. {marex-4.0.4 → marex-4.1}/tests/conftest.py +0 -0
  47. {marex-4.0.4 → marex-4.1}/tests/coverage_config.py +0 -0
  48. {marex-4.0.4 → marex-4.1}/tests/make_test_data.ipynb +0 -0
  49. {marex-4.0.4 → marex-4.1}/tests/test_detect_detrending.py +0 -0
  50. {marex-4.0.4 → marex-4.1}/tests/test_detect_helpers.py +0 -0
  51. {marex-4.0.4 → marex-4.1}/tests/test_error_handling.py +0 -0
  52. {marex-4.0.4 → marex-4.1}/tests/test_exceptions.py +0 -0
  53. {marex-4.0.4 → marex-4.1}/tests/test_gridded_tracking.py +0 -0
  54. {marex-4.0.4 → marex-4.1}/tests/test_integration.py +0 -0
  55. {marex-4.0.4 → marex-4.1}/tests/test_logging_system.py +0 -0
  56. {marex-4.0.4 → marex-4.1}/tests/test_plotx.py +0 -0
  57. {marex-4.0.4 → marex-4.1}/tests/test_track_helpers.py +0 -0
  58. {marex-4.0.4 → marex-4.1}/tests/test_unstructured_preprocessing.py +0 -0
  59. {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.0.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
- ![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)
@@ -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
- 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)
748
771
  elif method_anomaly == "detrend_fixed_baseline":
749
- ds.attrs.update(
750
- {
751
- "detrend_orders": detrend_orders,
752
- "force_zero_mean": force_zero_mean,
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
- 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")
842
870
  elif method_anomaly == "detrend_fixed_baseline":
843
871
  steps.append(f"Removed polynomial trend orders={detrend_orders}")
844
- 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")
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: using full time series for daily climatology")
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(f"Fixed detrended baseline parameters: orders={detrend_orders}, zero_mean={force_zero_mean}")
1039
- 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
+ )
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
- # Construct rolling window dimension
1863
- da_windowed = da.rolling({dimensions["time"]: window_days_hobday}, center=True).construct("window")
1864
-
1865
- # Ensure dayofyear coordinate is computed for groupby (required by newer xarray)
1866
- if "dayofyear" in da_windowed.coords:
1867
- da_windowed = da_windowed.assign_coords(dayofyear=da_windowed.dayofyear.compute())
1868
-
1869
- thresholds = da_windowed.groupby("dayofyear").reduce(
1870
- 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}},
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 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.
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
- # Compute daily climatology across all years using flox for efficiency
2242
- 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")
2243
2365
  daily_climatology = flox.xarray.xarray_reduce(
2244
- da,
2245
- da[coordinates["time"]].dt.dayofyear,
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 full-time-series daily climatology from the detrended signal.
2290
- 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.
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.0.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
- ![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