diff --git a/src/access_moppy/utilities.py b/src/access_moppy/utilities.py index eac0488a..b1765bd3 100644 --- a/src/access_moppy/utilities.py +++ b/src/access_moppy/utilities.py @@ -687,6 +687,8 @@ def parse_cmip6_table_frequency(compound_name: str) -> pd.Timedelta: "Eday": pd.Timedelta(days=1), "Eyr": pd.Timedelta(days=365), "Efx": pd.Timedelta(days=0), + "fx": pd.Timedelta(days=0), + "Ofx": pd.Timedelta(days=0), # Sea ice tables "SImon": pd.Timedelta(days=30), "SIday": pd.Timedelta(days=1), @@ -2164,6 +2166,36 @@ def validate_and_resample_if_needed( Returns: tuple of (dataset, was_resampled) """ + # Get target frequency + target_freq = parse_cmip6_table_frequency(compound_name) + target_seconds = target_freq.total_seconds() + + # Time-invariant (fx/*fx) targets do not require temporal validation/resampling. + # Accept datasets with no time axis or a singleton time axis; reject true + # time-varying inputs for fixed-output tables. + if target_seconds == 0: + if time_coord not in ds.coords: + logger.debug( + "Static target table for %s with no '%s' coordinate - no resampling needed", + compound_name, + time_coord, + ) + return ds, False + + time_size = ds[time_coord].size + if time_size <= 1: + logger.debug( + "Static target table for %s with singleton '%s' coordinate - no resampling needed", + compound_name, + time_coord, + ) + return ds, False + + raise IncompatibleFrequencyError( + f"Cannot process '{compound_name}' as a fixed (fx) variable: " + f"dataset has {time_size} time steps on '{time_coord}'." + ) + # Detect current frequency detected_freq = detect_time_frequency_lazy(ds, time_coord) if detected_freq is None: @@ -2172,13 +2204,8 @@ def validate_and_resample_if_needed( f"Check that the '{time_coord}' coordinate has valid units and sufficient time points." ) - # Get target frequency - target_freq = parse_cmip6_table_frequency(compound_name) - # Check if resampling is needed input_seconds = detected_freq.total_seconds() - target_seconds = target_freq.total_seconds() - # Check compatibility first is_compatible, reason = is_frequency_compatible(detected_freq, target_freq) if not is_compatible: diff --git a/tests/unit/test_temporal_resampling.py b/tests/unit/test_temporal_resampling.py index fdce3ae8..ad54bef6 100644 --- a/tests/unit/test_temporal_resampling.py +++ b/tests/unit/test_temporal_resampling.py @@ -355,6 +355,46 @@ def test_invalid_compound_name_raises_error(self): with pytest.raises(ValueError): validate_and_resample_if_needed(ds, "Unknown.tas", "tas") + def test_static_target_without_time_coord_no_resampling(self): + """Static targets (fx) should skip time validation when no time coord exists.""" + ds = xr.Dataset( + {"mrsofc": (["lat", "lon"], np.ones((2, 3), dtype="f4"))}, + coords={"lat": [0.0, 1.0], "lon": [10.0, 20.0, 30.0]}, + ) + + ds_result, was_resampled = validate_and_resample_if_needed( + ds, "Efx.mrsofc", "mrsofc" + ) + + assert not was_resampled + assert ds_result is ds + + def test_static_target_singleton_time_coord_no_resampling(self): + """Static targets tolerate a singleton source time axis (common UM fx inputs).""" + time = pd.date_range("2000-01-01", periods=1, freq="D") + ds = xr.Dataset( + {"mrsofc": (["time", "lat", "lon"], np.ones((1, 2, 3), dtype="f4"))}, + coords={"time": time, "lat": [0.0, 1.0], "lon": [10.0, 20.0, 30.0]}, + ) + + ds_result, was_resampled = validate_and_resample_if_needed( + ds, "Efx.mrsofc", "mrsofc" + ) + + assert not was_resampled + assert ds_result is ds + + def test_static_target_rejects_time_varying_input(self): + """Static targets should reject inputs with multiple time steps.""" + time = pd.date_range("2000-01-01", periods=2, freq="D") + ds = xr.Dataset( + {"mrsofc": (["time", "lat", "lon"], np.ones((2, 2, 3), dtype="f4"))}, + coords={"time": time, "lat": [0.0, 1.0], "lon": [10.0, 20.0, 30.0]}, + ) + + with pytest.raises(IncompatibleFrequencyError, match=r"fixed \(fx\)"): + validate_and_resample_if_needed(ds, "Efx.mrsofc", "mrsofc") + class TestErrorHandling: """Tests for error handling in resampling operations."""