Skip to content

Commit bd19269

Browse files
authored
Frequency detec (#123)
* Implement temporal frequency detection and validation Features: - Add lazy frequency detection from temporal coordinates using xarray/Dask - Validate frequency consistency across concatenated input files - Configurable validation with tolerance settings for slight differences - Custom FrequencyMismatchError for clear error reporting - integrate validation into all CMORiser classes with user control - Comprehensive unit tests covering various frequency scenarios This prevents issues when concatenating files with different temporal frequencies and provides early detection of data inconsistencies without loading full datasets into memory. The implementation is performance-oriented using lazy evaluation to check only the first few time points of each file. Usage: - Validation enabled by default: ACCESS_ESM_CMORiser(...) - Disable validation: ACCESS_ESM_CMORiser(..., validate_frequency=False) - Tolerance configurable via validate_consistent_frequency() function Files modified: - utilities.py: Core frequency detection functions - base.py: Integration with load_dataset method - driver.py: User-facing validate_frequency parameter - ocean.py: Updated constructor to pass through parameter - tests/unit/test_frequency_detection.py: Comprehensive test suite * Enhance CMIP6 frequency validation with compound name support and add new utility functions for frequency parsing and compatibility checks * Enhance temporal frequency detection and resampling functionality - Added support for detecting temporal frequency from CF-compliant time bounds in `detect_time_frequency_lazy`. - Implemented a new utility function `_detect_frequency_from_bounds` for improved frequency detection. - Introduced `validate_and_resample_if_needed` to validate dataset frequency and resample if necessary for CMIP6 compatibility. - Enhanced `resample_dataset_temporal` to handle various resampling methods based on variable metadata. - Updated tests to cover new frequency detection and resampling features, including edge cases for single time points and bounds detection. - Created a new test suite for temporal resampling functionality, ensuring robust validation and error handling. * Add ACCESS frequency metadata parsing and detection functions * Implement smart tolerance for frequency validation and add tests for calendar month variations * Enhance frequency compatibility validation for monthly data, allowing for calendar month variations. Implement helper functions to validate monthly files and adjust compatibility checks in CMIP6 frequency validation. * Enhance frequency compatibility validation to handle monthly data without resampling. Special handling for calendar month variations added to improve accuracy in CMIP6 frequency checks. * Implement efficient frequency detection for monthly files using concatenation; add fallback to individual file analysis. Remove outdated test scripts for exact error simulation and monthly calendar variations. * Set default validate_frequency to False in CMIP6_CMORiser constructor * Fix tests * Fix pre-commit * Remove codacy * Revert "Remove codacy" This reverts commit 936f1c6. * Add compound_name parameter to CMIP6_CMORiser and update tests * Refactor CMIP6_CMORiser to extract cmor_name from compound_name and update related tests * Remove unused import and simplify print statement in CMIP6_CMORiser * Refactor load_filtered_variables to support model-specific mappings and update integration tests accordingly * Fix pre-commit * Fix push trigger in CI workflow to include main branch
1 parent 32f7149 commit bd19269

10 files changed

Lines changed: 2819 additions & 38 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ on:
44
pull_request:
55
branches: [main]
66
push:
7-
branches-ignore: [main]
7+
branches: [main]
88
workflow_dispatch:
99
inputs:
1010
test_suite:

src/access_moppy/base.py

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import warnings
12
from datetime import datetime
23
from pathlib import Path
34
from typing import Any, Dict, List, Optional, Union
@@ -6,7 +7,14 @@
67
import xarray as xr
78
from cftime import num2date
89

9-
from access_moppy.utilities import type_mapping
10+
from access_moppy.utilities import (
11+
FrequencyMismatchError,
12+
IncompatibleFrequencyError,
13+
ResamplingRequiredWarning,
14+
type_mapping,
15+
validate_and_resample_if_needed,
16+
validate_cmip6_frequency_compatibility,
17+
)
1018

1119

1220
class CMIP6_CMORiser:
@@ -20,20 +28,28 @@ def __init__(
2028
self,
2129
input_paths: Union[str, List[str]],
2230
output_path: str,
23-
cmor_name: str,
2431
cmip6_vocab: Any,
2532
variable_mapping: Dict[str, Any],
33+
compound_name: str,
2634
drs_root: Optional[Path] = None,
35+
validate_frequency: bool = False,
36+
enable_resampling: bool = False,
37+
resampling_method: str = "auto",
2738
):
2839
self.input_paths = (
2940
input_paths if isinstance(input_paths, list) else [input_paths]
3041
)
3142
self.output_path = output_path
32-
self.cmor_name = cmor_name
43+
# Extract cmor_name from compound_name
44+
_, self.cmor_name = compound_name.split(".")
3345
self.vocab = cmip6_vocab
3446
self.mapping = variable_mapping
3547
self.drs_root = Path(drs_root) if drs_root is not None else None
3648
self.version_date = datetime.now().strftime("%Y%m%d")
49+
self.validate_frequency = validate_frequency
50+
self.compound_name = compound_name
51+
self.enable_resampling = enable_resampling
52+
self.resampling_method = resampling_method
3753
self.ds = None
3854

3955
def __getitem__(self, key):
@@ -50,9 +66,44 @@ def __repr__(self):
5066
return repr(self.ds)
5167

5268
def load_dataset(self, required_vars: Optional[List[str]] = None):
69+
"""
70+
Load dataset from input files with optional frequency validation.
71+
72+
Args:
73+
required_vars: Optional list of required variables to extract
74+
"""
75+
5376
def _preprocess(ds):
5477
return ds[list(required_vars & set(ds.data_vars))]
5578

79+
# Validate frequency consistency and CMIP6 compatibility before concatenation
80+
if self.validate_frequency and len(self.input_paths) > 0:
81+
try:
82+
# Enhanced validation with CMIP6 frequency compatibility
83+
detected_freq, resampling_required = (
84+
validate_cmip6_frequency_compatibility(
85+
self.input_paths,
86+
self.compound_name,
87+
time_coord="time",
88+
interactive=True,
89+
)
90+
)
91+
if resampling_required:
92+
print(
93+
f"✓ Temporal resampling will be applied: {detected_freq} → CMIP6 target frequency"
94+
)
95+
else:
96+
print(f"✓ Validated compatible temporal frequency: {detected_freq}")
97+
except (FrequencyMismatchError, IncompatibleFrequencyError) as e:
98+
raise e # Re-raise these specific errors as-is
99+
except InterruptedError as e:
100+
raise e # Re-raise user abort
101+
except Exception as e:
102+
warnings.warn(
103+
f"Could not validate temporal frequency: {e}. "
104+
f"Proceeding with concatenation but results may be inconsistent."
105+
)
106+
56107
self.ds = xr.open_mfdataset(
57108
self.input_paths,
58109
combine="nested", # avoids costly dimension alignment
@@ -64,6 +115,37 @@ def _preprocess(ds):
64115
parallel=True, # <--- enables concurrent preprocessing
65116
)
66117

118+
# Apply temporal resampling if enabled and needed
119+
if self.enable_resampling and self.compound_name:
120+
try:
121+
print(
122+
f"🔍 Checking if temporal resampling is needed for {self.cmor_name}..."
123+
)
124+
125+
self.ds, was_resampled = validate_and_resample_if_needed(
126+
self.ds,
127+
self.compound_name,
128+
self.cmor_name,
129+
time_coord="time",
130+
method=self.resampling_method,
131+
)
132+
133+
if was_resampled:
134+
print("✅ Applied temporal resampling to match CMIP6 requirements")
135+
else:
136+
print("✅ No resampling needed - frequency already compatible")
137+
138+
except (FrequencyMismatchError, IncompatibleFrequencyError) as e:
139+
raise e # Re-raise validation errors
140+
except Exception as e:
141+
raise RuntimeError(f"Failed to resample dataset: {e}")
142+
elif self.enable_resampling and not self.compound_name:
143+
warnings.warn(
144+
"Resampling enabled but no compound_name provided. "
145+
"Cannot determine target frequency for resampling.",
146+
ResamplingRequiredWarning,
147+
)
148+
67149
def sort_time_dimension(self):
68150
if "time" in self.ds.dims:
69151
self.ds = self.ds.sortby("time")

src/access_moppy/driver.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ def __init__(
2828
drs_root: Optional[Union[str, Path]] = None,
2929
parent_info: Optional[Dict[str, Dict[str, Any]]] = None,
3030
model_id: Optional[str] = None,
31+
validate_frequency: bool = True,
32+
enable_resampling: bool = False,
33+
resampling_method: str = "auto",
3134
):
3235
"""
3336
Initializes the CMORiser with necessary parameters.
@@ -42,9 +45,15 @@ def __init__(
4245
:param drs_root: Optional root path for DRS structure.
4346
:param parent_info: Optional dictionary with parent experiment metadata.
4447
:param model_id: Optional model identifier for model-specific mappings (e.g., 'ACCESS-ESM1.6').
48+
:param validate_frequency: Whether to validate temporal frequency consistency across input files (default: True).
49+
:param enable_resampling: Whether to enable automatic temporal resampling when frequency mismatches occur (default: False).
50+
:param resampling_method: Method for temporal resampling ('auto', 'mean', 'sum', 'min', 'max', 'first', 'last') (default: 'auto').
4551
"""
4652

4753
self.input_paths = input_paths
54+
self.validate_frequency = validate_frequency
55+
self.enable_resampling = enable_resampling
56+
self.resampling_method = resampling_method
4857
self.output_path = Path(output_path)
4958
self.compound_name = compound_name
5059
self.experiment_id = experiment_id
@@ -75,24 +84,30 @@ def __init__(
7584
)
7685

7786
# Initialize the CMORiser based on the compound name
78-
table, cmor_name = compound_name.split(".")
87+
table, _ = compound_name.split(".") # cmor_name now extracted internally
7988
if table in ("Amon", "Lmon", "Emon"):
8089
self.cmoriser = CMIP6_Atmosphere_CMORiser(
8190
input_paths=self.input_paths,
8291
output_path=str(self.output_path),
83-
cmor_name=cmor_name,
8492
cmip6_vocab=self.vocab,
8593
variable_mapping=self.variable_mapping,
94+
compound_name=self.compound_name,
8695
drs_root=drs_root if drs_root else None,
96+
validate_frequency=self.validate_frequency,
97+
enable_resampling=self.enable_resampling,
98+
resampling_method=self.resampling_method,
8799
)
88100
elif table in ("Oyr", "Oday", "Omon", "SImon"):
89101
self.cmoriser = CMIP6_Ocean_CMORiser(
90102
input_paths=self.input_paths,
91103
output_path=str(self.output_path),
92-
cmor_name=cmor_name,
93104
cmip6_vocab=self.vocab,
94105
variable_mapping=self.variable_mapping,
106+
compound_name=self.compound_name,
95107
drs_root=drs_root if drs_root else None,
108+
validate_frequency=self.validate_frequency,
109+
enable_resampling=self.enable_resampling,
110+
resampling_method=self.resampling_method,
96111
)
97112

98113
def __getitem__(self, key):

src/access_moppy/ocean.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,24 @@ def __init__(
1818
self,
1919
input_paths: Union[str, List[str]],
2020
output_path: str,
21-
cmor_name: str,
2221
cmip6_vocab: CMIP6Vocabulary,
2322
variable_mapping: Dict[str, Any],
23+
compound_name: str,
2424
drs_root: Optional[Path] = None,
25+
validate_frequency: bool = True,
26+
enable_resampling: bool = False,
27+
resampling_method: str = "auto",
2528
):
2629
super().__init__(
2730
input_paths=input_paths,
2831
output_path=output_path,
29-
cmor_name=cmor_name,
3032
cmip6_vocab=cmip6_vocab,
3133
variable_mapping=variable_mapping,
34+
compound_name=compound_name,
3235
drs_root=drs_root,
36+
validate_frequency=validate_frequency,
37+
enable_resampling=enable_resampling,
38+
resampling_method=resampling_method,
3339
)
3440

3541
nominal_resolution = cmip6_vocab._get_nominal_resolution()

0 commit comments

Comments
 (0)