diff --git a/src/access_moppy/driver.py b/src/access_moppy/driver.py index 7773ebdd..02fc5d89 100644 --- a/src/access_moppy/driver.py +++ b/src/access_moppy/driver.py @@ -498,6 +498,16 @@ def __init__( elif table in ("Oyr", "Oday", "Omon", "Ofx") or table.startswith( _mip_ocean_prefixes ): + # Ocean yearly tables can legitimately aggregate from higher-frequency + # inputs (for example monthly -> yearly). Keep explicit user control + # via enable_resampling, but auto-enable for yearly ocean requests. + ocean_enable_resampling = self.enable_resampling or table in ( + "Oyr", + "OPyr", + "OPyrLev", + "OByr", + "OByrLev", + ) if self.model_id in ("ACCESS-OM3", "ACCESS-CM3"): # ACCESS-OM3 uses MOM6 (C-grid) — requires dedicated CMORiser implementation # that handles C-grid supergrid logic, MOM6 metadata, and OM3-specific conventions @@ -510,6 +520,9 @@ def __init__( vocab=self.vocab, variable_mapping=self.variable_mapping.to_dict(), drs_root=drs_root if drs_root else None, + validate_frequency=self.validate_frequency, + enable_resampling=ocean_enable_resampling, + resampling_method=self.resampling_method, ) else: # ACCESS-OM2 uses MOM5 (B-grid) — handled by a separate CMORiser class @@ -523,6 +536,9 @@ def __init__( vocab=self.vocab, variable_mapping=self.variable_mapping.to_dict(), drs_root=drs_root if drs_root else None, + validate_frequency=self.validate_frequency, + enable_resampling=ocean_enable_resampling, + resampling_method=self.resampling_method, ) elif table in ("SImon", "SIday") or table.startswith(_mip_seaice_prefixes): self.cmoriser = SeaIce_CMORiser( diff --git a/src/access_moppy/file_discovery.py b/src/access_moppy/file_discovery.py index 0125494c..0e0abbb5 100644 --- a/src/access_moppy/file_discovery.py +++ b/src/access_moppy/file_discovery.py @@ -191,8 +191,16 @@ def _build_patterns( # --- Per-variable explicit override --- explicit = var_entry.get("file_pattern") if explicit: - # Explicit patterns are already relative to input_root - return [explicit] if isinstance(explicit, str) else list(explicit) + # Explicit patterns are already relative to input_root. + # They can be: + # - a string: one pattern for all frequencies + # - a list: several patterns for all frequencies + # - a dict: frequency-specific override, e.g. {"yr": [...], "default": ...} + if isinstance(explicit, dict): + explicit = explicit.get(freq, explicit.get("default")) + + if explicit: + return [explicit] if isinstance(explicit, str) else list(explicit) # --- Component-level config --- comp_cfg = file_discovery_cfg.get("components", {}).get(component) diff --git a/src/access_moppy/mappings/ACCESS-ESM1-6_mappings.json b/src/access_moppy/mappings/ACCESS-ESM1-6_mappings.json index 77d63f24..77bcd658 100644 --- a/src/access_moppy/mappings/ACCESS-ESM1-6_mappings.json +++ b/src/access_moppy/mappings/ACCESS-ESM1-6_mappings.json @@ -4940,6 +4940,12 @@ "model_variables": [ "salt_vdiffuse_impl" ], + "file_pattern": { + "yr": [ + "output[0-9][0-9][0-9]/ocean/ocean-*-salt_vdiffuse_impl-1mon-mean-y_*.nc", + "output[0-9][0-9][0-9]/ocean/ocean-*-salt_vdiffuse_impl-1monthly-*-ym_*.nc" + ] + }, "calculation": { "type": "direct", "formula": "salt_vdiffuse_impl" diff --git a/src/access_moppy/ocean.py b/src/access_moppy/ocean.py index 576440c2..a7b407eb 100644 --- a/src/access_moppy/ocean.py +++ b/src/access_moppy/ocean.py @@ -327,6 +327,9 @@ def __init__( vocab: CMIP6Vocabulary, variable_mapping: Dict[str, Any], drs_root: Optional[Path] = None, + validate_frequency: bool = True, + enable_resampling: bool = False, + resampling_method: str = "auto", # Backward compatibility input_paths: Optional[Union[str, List[str]]] = None, ): @@ -338,6 +341,9 @@ def __init__( vocab=vocab, variable_mapping=variable_mapping, drs_root=drs_root, + validate_frequency=validate_frequency, + enable_resampling=enable_resampling, + resampling_method=resampling_method, ) nominal_resolution = vocab._get_nominal_resolution(target_realm="ocean") @@ -399,6 +405,9 @@ def __init__( vocab: CMIP6Vocabulary, variable_mapping: Dict[str, Any], drs_root: Optional[Path] = None, + validate_frequency: bool = True, + enable_resampling: bool = False, + resampling_method: str = "auto", # Backward compatibility input_paths: Optional[Union[str, List[str]]] = None, ): @@ -410,6 +419,9 @@ def __init__( vocab=vocab, variable_mapping=variable_mapping, drs_root=drs_root, + validate_frequency=validate_frequency, + enable_resampling=enable_resampling, + resampling_method=resampling_method, ) nominal_resolution = vocab._get_nominal_resolution(target_realm="ocean") diff --git a/tests/unit/test_driver.py b/tests/unit/test_driver.py index c1b6af18..2277b8cc 100644 --- a/tests/unit/test_driver.py +++ b/tests/unit/test_driver.py @@ -630,6 +630,58 @@ def test_ocean_table_selects_om2_for_non_om3_source(self, valid_config, temp_dir mock_om2.assert_called_once() + @pytest.mark.unit + def test_ocean_yearly_auto_enables_resampling(self, valid_config, temp_dir): + with ( + patch("access_moppy.driver.load_model_mappings") as mock_load, + patch("access_moppy.driver.Ocean_CMORiser_OM2") as mock_om2, + ): + mock_load.return_value = {"osaltdiff": {"units": "kg m-2 s-1"}} + mock_om2_instance = MagicMock() + mock_om2_instance.ds = xr.Dataset() + mock_om2.return_value = mock_om2_instance + + ACCESS_ESM_CMORiser( + input_paths=["test.nc"], + compound_name="Oyr.osaltdiff", + source_id="ACCESS-ESM1-5", + output_path=temp_dir, + experiment_id=valid_config["experiment_id"], + variant_label=valid_config["variant_label"], + grid_label=valid_config["grid_label"], + activity_id=valid_config["activity_id"], + ) + + kwargs = mock_om2.call_args.kwargs + assert kwargs["enable_resampling"] is True + + @pytest.mark.unit + def test_ocean_monthly_does_not_auto_enable_resampling( + self, valid_config, temp_dir + ): + with ( + patch("access_moppy.driver.load_model_mappings") as mock_load, + patch("access_moppy.driver.Ocean_CMORiser_OM2") as mock_om2, + ): + mock_load.return_value = {"tos": {"units": "K"}} + mock_om2_instance = MagicMock() + mock_om2_instance.ds = xr.Dataset() + mock_om2.return_value = mock_om2_instance + + ACCESS_ESM_CMORiser( + input_paths=["test.nc"], + compound_name="Omon.tos", + source_id="ACCESS-ESM1-5", + output_path=temp_dir, + experiment_id=valid_config["experiment_id"], + variant_label=valid_config["variant_label"], + grid_label=valid_config["grid_label"], + activity_id=valid_config["activity_id"], + ) + + kwargs = mock_om2.call_args.kwargs + assert kwargs["enable_resampling"] is False + @pytest.mark.unit def test_unsupported_table_raises_value_error_with_supported_list( self, valid_config, temp_dir diff --git a/tests/unit/test_file_discovery.py b/tests/unit/test_file_discovery.py index 31af0d1e..69389857 100644 --- a/tests/unit/test_file_discovery.py +++ b/tests/unit/test_file_discovery.py @@ -164,6 +164,34 @@ def test_per_variable_file_pattern_overrides_component_config(self): patterns = _build_patterns(var_entry, "ocean", "mon", self.fd_cfg) assert patterns == ["output*/ocean/ocean-2d-surface_temp-1mon-mean-y_*.nc"] + def test_per_variable_file_pattern_freq_specific_override(self): + var_entry = { + "model_variables": ["salt_vdiffuse_impl"], + "file_pattern": { + "yr": [ + "output*/ocean/ocean-*-salt_vdiffuse_impl-1mon-mean-y_*.nc", + ] + }, + } + patterns = _build_patterns(var_entry, "ocean", "yr", self.fd_cfg) + assert patterns == [ + "output*/ocean/ocean-*-salt_vdiffuse_impl-1mon-mean-y_*.nc", + ] + + def test_per_variable_file_pattern_freq_specific_falls_back(self): + var_entry = { + "model_variables": ["salt_vdiffuse_impl"], + "file_pattern": { + "yr": [ + "output*/ocean/ocean-*-salt_vdiffuse_impl-1mon-mean-y_*.nc", + ] + }, + } + patterns = _build_patterns(var_entry, "ocean", "mon", self.fd_cfg) + assert len(patterns) == 2 + assert all("salt_vdiffuse_impl" in p for p in patterns) + assert all("1mon" in p or "1monthly" in p for p in patterns) + def test_unknown_component_raises(self): with pytest.raises(FileDiscoveryError, match="No file_discovery config"): _build_patterns({}, "nonexistent_component", "mon", self.fd_cfg)