diff --git a/src/access_moppy/base.py b/src/access_moppy/base.py index c9725566..38cbc8d7 100644 --- a/src/access_moppy/base.py +++ b/src/access_moppy/base.py @@ -590,8 +590,174 @@ def _ensure_numeric_time_coordinates(self, ds: xr.Dataset) -> xr.Dataset: def sort_time_dimension(self): if "time" in self.ds.dims: self.ds = self.ds.sortby("time") - # Clean up potential duplication - self.ds = self.ds.sel(time=~self.ds.get_index("time").duplicated()) + self._validate_time_axis_integrity() + + def _validate_time_axis_integrity(self) -> None: + """Enforce strict CMOR time-axis requirements. + + The time coordinate must be strictly increasing, have no duplicate + timestamps, and contain no gaps for the expected sampling cadence. + """ + if "time" not in self.ds.coords: + return + + time_index = self.ds.get_index("time") + + duplicated = time_index.duplicated() + if duplicated.any(): + duplicate_values = list(dict.fromkeys(time_index[duplicated].tolist())) + preview = duplicate_values[:5] + raise ValueError( + "Time coordinate contains duplicate timestamps. " + f"Found {len(duplicate_values)} duplicated value(s), including: {preview}" + ) + + if not time_index.is_monotonic_increasing: + raise ValueError( + "Time coordinate is not monotonic increasing after sorting. " + "This indicates an invalid time axis for CMORisation." + ) + + if len(time_index) < 2: + return + + self._validate_time_gaps_from_bounds_or_frequency() + + def _validate_time_gaps_from_bounds_or_frequency(self) -> None: + """Validate that there are no missing timesteps. + + Prefer CF time-bounds continuity when available. Otherwise fall back to + coarse frequency-aware checks derived from the target CMIP table. + """ + bounds = self._get_time_bounds_for_gap_validation() + if bounds is not None: + if self._time_bounds_have_gaps(bounds): + raise ValueError( + "Time bounds are not contiguous. Missing or overlapping " + "timesteps detected in the CMOR time axis." + ) + return + + freq_hint = self._target_frequency_hint() + if freq_hint is None: + return + + time_da = self.ds["time"] + time_values = time_da.values + time_units = time_da.attrs.get("units") + + # Numeric time coordinates (e.g. 0, 1, 2) are often synthetic in unit + # tests or pre-decoded placeholders. Frequency-fallback checks are not + # reliable there without explicit decoded datetimes, so only apply this + # fallback to datetime-like axes. Bounds-based checks above still apply. + if np.issubdtype(np.asarray(time_values).dtype, np.number): + logger.debug( + "Skipping frequency-fallback gap validation for numeric time axis" + ) + return + + deltas_days = [ + self._time_delta_days( + time_values[i], + time_values[i + 1], + time_units=time_units, + ) + for i in range(len(time_values) - 1) + ] + + if freq_hint == "daily": + invalid = [d for d in deltas_days if not np.isclose(d, 1.0, atol=1e-6)] + elif freq_hint == "monthly": + invalid = [d for d in deltas_days if d < 27.0 or d > 32.0] + elif freq_hint == "yearly": + invalid = [d for d in deltas_days if d < 360.0 or d > 370.0] + else: + invalid = [] + + if invalid: + raise ValueError( + "Missing timesteps detected in time coordinate for expected " + f"'{freq_hint}' cadence. Invalid interval day-lengths include: {invalid[:5]}" + ) + + def _get_time_bounds_for_gap_validation(self) -> Optional[xr.DataArray]: + """Return the time bounds variable when available and shape-compatible.""" + time_var = self.ds.get("time") + if time_var is None: + return None + + candidate_names = [] + bounds_name = time_var.attrs.get("bounds") + if bounds_name: + candidate_names.append(bounds_name) + candidate_names.extend(["time_bnds", "time_bounds", "time_bnd"]) + + seen = set() + for name in candidate_names: + if name in seen: + continue + seen.add(name) + if name not in self.ds: + continue + + bounds = self.ds[name] + if bounds.ndim != 2 or bounds.shape[-1] != 2: + continue + if bounds.shape[0] != self.ds.sizes.get("time"): + continue + return bounds + + return None + + @staticmethod + def _time_bounds_have_gaps(bounds: xr.DataArray) -> bool: + """Return True when adjacent intervals are not perfectly contiguous.""" + values = bounds.values + if values.shape[0] < 2: + return False + + left = values[:-1, 1] + right = values[1:, 0] + + if np.issubdtype(np.asarray(left).dtype, np.number): + scale = max(float(np.nanmax(np.abs(values))), 1.0) + atol = scale * 1e-10 + return bool(np.any(~np.isclose(left, right, rtol=0.0, atol=atol))) + + return bool(np.any(left != right)) + + @staticmethod + def _time_delta_days( + start: Any, end: Any, time_units: Optional[str] = None + ) -> float: + """Compute day-length between two timestamps for numpy/cftime values.""" + diff = end - start + if isinstance(diff, np.timedelta64): + return float(diff / np.timedelta64(1, "s")) / 86400.0 + if np.isscalar(diff) and isinstance( + diff, (int, float, np.integer, np.floating) + ): + return CMORiser._numeric_delta_to_days(float(diff), time_units) + if hasattr(diff, "total_seconds"): + return float(diff.total_seconds()) / 86400.0 + return float(diff.days) + float(getattr(diff, "seconds", 0)) / 86400.0 + + @staticmethod + def _numeric_delta_to_days(delta: float, time_units: Optional[str]) -> float: + """Convert numeric coordinate deltas to days using CF units when possible.""" + if not time_units: + return delta + + interval = str(time_units).split("since", 1)[0].strip().lower() + if interval in {"day", "days", "d"}: + return delta + if interval in {"hour", "hours", "hr", "hrs", "h"}: + return delta / 24.0 + if interval in {"minute", "minutes", "min", "mins", "m"}: + return delta / 1440.0 + if interval in {"second", "seconds", "sec", "secs", "s"}: + return delta / 86400.0 + return delta def rechunk_dataset(self): """ diff --git a/tests/unit/test_base.py b/tests/unit/test_base.py index f9f3aa5a..29b8e7a9 100644 --- a/tests/unit/test_base.py +++ b/tests/unit/test_base.py @@ -224,6 +224,545 @@ def test_getattr_fallback(self, mock_vocab, mock_mapping, temp_dir): with pytest.raises(AttributeError): _ = cmoriser.nonexistent_attribute + @pytest.mark.unit + def test_sort_time_dimension_raises_on_duplicate_timestamps( + self, mock_vocab, mock_mapping, temp_dir + ): + """CMORisation must fail fast when duplicate time stamps are present.""" + cmoriser = CMORiser( + input_paths=["test.nc"], + output_path=str(temp_dir), + vocab=mock_vocab, + variable_mapping=mock_mapping, + compound_name="Amon.tas", + ) + times = np.array( + ["2000-01-15", "2000-02-15", "2000-02-15"], + dtype="datetime64[ns]", + ) + cmoriser.ds = xr.Dataset( + {"tas": xr.DataArray([1.0, 2.0, 3.0], dims=["time"])}, + coords={"time": times}, + ) + + with pytest.raises(ValueError, match="duplicate timestamps"): + cmoriser.sort_time_dimension() + + @pytest.mark.unit + def test_sort_time_dimension_raises_on_missing_month( + self, mock_vocab, mock_mapping, temp_dir + ): + """CMORisation must fail when monthly cadence has gaps.""" + cmoriser = CMORiser( + input_paths=["test.nc"], + output_path=str(temp_dir), + vocab=mock_vocab, + variable_mapping=mock_mapping, + compound_name="Amon.tas", + ) + times = np.array( + ["2000-01-15", "2000-02-15", "2000-04-15"], + dtype="datetime64[ns]", + ) + cmoriser.ds = xr.Dataset( + {"tas": xr.DataArray([1.0, 2.0, 3.0], dims=["time"])}, + coords={"time": times}, + ) + + with pytest.raises(ValueError, match="Missing timesteps"): + cmoriser.sort_time_dimension() + + @pytest.mark.unit + def test_sort_time_dimension_keeps_valid_monthly_axis( + self, mock_vocab, mock_mapping, temp_dir + ): + """Valid monthly axes remain accepted after sorting.""" + cmoriser = CMORiser( + input_paths=["test.nc"], + output_path=str(temp_dir), + vocab=mock_vocab, + variable_mapping=mock_mapping, + compound_name="Amon.tas", + ) + times = np.array( + ["2000-03-15", "2000-01-15", "2000-02-15"], + dtype="datetime64[ns]", + ) + cmoriser.ds = xr.Dataset( + {"tas": xr.DataArray([3.0, 1.0, 2.0], dims=["time"])}, + coords={"time": times}, + ) + + cmoriser.sort_time_dimension() + + sorted_times = cmoriser.ds["time"].values + assert np.all(sorted_times[:-1] < sorted_times[1:]) + assert len(np.unique(sorted_times)) == len(sorted_times) + + @pytest.mark.unit + def test_validate_time_axis_integrity_no_time_coord_returns( + self, mock_vocab, mock_mapping, temp_dir + ): + """If 'time' is only a dimension (not a coordinate), validation returns.""" + cmoriser = CMORiser( + input_paths=["test.nc"], + output_path=str(temp_dir), + vocab=mock_vocab, + variable_mapping=mock_mapping, + compound_name="Amon.tas", + ) + cmoriser.ds = xr.Dataset( + {"tas": xr.DataArray([1.0, 2.0], dims=["time"])}, + ) + + cmoriser._validate_time_axis_integrity() + + @pytest.mark.unit + def test_validate_time_gaps_raises_for_non_contiguous_time_bounds( + self, mock_vocab, mock_mapping, temp_dir + ): + """Bounds-based path must fail when adjacent intervals are not contiguous.""" + cmoriser = CMORiser( + input_paths=["test.nc"], + output_path=str(temp_dir), + vocab=mock_vocab, + variable_mapping=mock_mapping, + compound_name="Amon.tas", + ) + time = np.array(["2000-01-15", "2000-02-15"], dtype="datetime64[ns]") + bounds = np.array( + [ + [np.datetime64("2000-01-01"), np.datetime64("2000-02-01")], + [np.datetime64("2000-02-02"), np.datetime64("2000-03-01")], + ] + ) + cmoriser.ds = xr.Dataset( + {"tas": xr.DataArray([1.0, 2.0], dims=["time"])}, + coords={"time": ("time", time)}, + ) + cmoriser.ds["time"].attrs["bounds"] = "time_bnds" + cmoriser.ds["time_bnds"] = xr.DataArray( + bounds, dims=["time", "bnds"], coords={"time": time, "bnds": [0, 1]} + ) + + with pytest.raises(ValueError, match="not contiguous"): + cmoriser._validate_time_gaps_from_bounds_or_frequency() + + @pytest.mark.unit + def test_validate_time_gaps_passes_for_contiguous_time_bounds( + self, mock_vocab, mock_mapping, temp_dir + ): + """Bounds-based path should accept contiguous intervals.""" + cmoriser = CMORiser( + input_paths=["test.nc"], + output_path=str(temp_dir), + vocab=mock_vocab, + variable_mapping=mock_mapping, + compound_name="Amon.tas", + ) + time = np.array(["2000-01-15", "2000-02-15"], dtype="datetime64[ns]") + bounds = np.array( + [ + [np.datetime64("2000-01-01"), np.datetime64("2000-02-01")], + [np.datetime64("2000-02-01"), np.datetime64("2000-03-01")], + ] + ) + cmoriser.ds = xr.Dataset( + {"tas": xr.DataArray([1.0, 2.0], dims=["time"])}, + coords={"time": ("time", time)}, + ) + cmoriser.ds["time"].attrs["bounds"] = "time_bnds" + cmoriser.ds["time_bnds"] = xr.DataArray( + bounds, dims=["time", "bnds"], coords={"time": time, "bnds": [0, 1]} + ) + + cmoriser._validate_time_gaps_from_bounds_or_frequency() + + @pytest.mark.unit + def test_validate_time_gaps_daily_and_yearly_fallback_raises( + self, mock_vocab, mock_mapping, temp_dir + ): + """Fallback cadence checks should raise for invalid daily/yearly intervals.""" + cmoriser = CMORiser( + input_paths=["test.nc"], + output_path=str(temp_dir), + vocab=mock_vocab, + variable_mapping=mock_mapping, + compound_name="Amon.tas", + ) + + cmoriser.ds = xr.Dataset( + {"tas": xr.DataArray([1.0, 2.0], dims=["time"])}, + coords={ + "time": np.array(["2000-01-01", "2000-01-03"], dtype="datetime64[ns]") + }, + ) + with patch.object(cmoriser, "_target_frequency_hint", return_value="daily"): + with pytest.raises(ValueError, match="Missing timesteps"): + cmoriser._validate_time_gaps_from_bounds_or_frequency() + + cmoriser.ds = xr.Dataset( + {"tas": xr.DataArray([1.0, 2.0], dims=["time"])}, + coords={ + "time": np.array(["2000-01-01", "2002-01-01"], dtype="datetime64[ns]") + }, + ) + with patch.object(cmoriser, "_target_frequency_hint", return_value="yearly"): + with pytest.raises(ValueError, match="Missing timesteps"): + cmoriser._validate_time_gaps_from_bounds_or_frequency() + + @pytest.mark.unit + def test_time_delta_days_and_numeric_unit_conversion( + self, mock_vocab, mock_mapping, temp_dir + ): + """Helper conversions handle timedelta64 and numeric CF intervals.""" + cmoriser = CMORiser( + input_paths=["test.nc"], + output_path=str(temp_dir), + vocab=mock_vocab, + variable_mapping=mock_mapping, + compound_name="Amon.tas", + ) + + assert ( + cmoriser._time_delta_days( + np.datetime64("2000-01-01"), np.datetime64("2000-01-03") + ) + == 2.0 + ) + assert np.isclose( + cmoriser._numeric_delta_to_days(48.0, "hours since 2000-01-01"), 2.0 + ) + assert np.isclose( + cmoriser._numeric_delta_to_days(2880.0, "minutes since 2000-01-01"), 2.0 + ) + assert np.isclose( + cmoriser._numeric_delta_to_days(172800.0, "seconds since 2000-01-01"), 2.0 + ) + assert np.isclose( + cmoriser._numeric_delta_to_days(2.0, "days since 2000-01-01"), 2.0 + ) + assert np.isclose( + cmoriser._numeric_delta_to_days(2.0, "fortnights since 2000-01-01"), 2.0 + ) + + @pytest.mark.unit + def test_validate_time_axis_integrity_non_monotonic_raises( + self, mock_vocab, mock_mapping, temp_dir + ): + """Direct integrity validation should reject non-monotonic time indexes.""" + cmoriser = CMORiser( + input_paths=["test.nc"], + output_path=str(temp_dir), + vocab=mock_vocab, + variable_mapping=mock_mapping, + compound_name="Amon.tas", + ) + cmoriser.ds = xr.Dataset( + {"tas": xr.DataArray([1.0, 2.0, 3.0], dims=["time"])}, + coords={ + "time": np.array( + ["2000-03-15", "2000-01-15", "2000-02-15"], + dtype="datetime64[ns]", + ) + }, + ) + + with pytest.raises(ValueError, match="not monotonic"): + cmoriser._validate_time_axis_integrity() + + @pytest.mark.unit + def test_validate_time_axis_integrity_single_timestep_returns( + self, mock_vocab, mock_mapping, temp_dir + ): + """Single-point time axes should return before gap validation.""" + cmoriser = CMORiser( + input_paths=["test.nc"], + output_path=str(temp_dir), + vocab=mock_vocab, + variable_mapping=mock_mapping, + compound_name="Amon.tas", + ) + cmoriser.ds = xr.Dataset( + {"tas": xr.DataArray([1.0], dims=["time"])}, + coords={"time": np.array(["2000-01-15"], dtype="datetime64[ns]")}, + ) + + cmoriser._validate_time_axis_integrity() + + @pytest.mark.unit + def test_get_time_bounds_for_gap_validation_filters_invalid_candidates( + self, mock_vocab, mock_mapping, temp_dir + ): + """Helper should ignore malformed bounds and return first valid candidate.""" + cmoriser = CMORiser( + input_paths=["test.nc"], + output_path=str(temp_dir), + vocab=mock_vocab, + variable_mapping=mock_mapping, + compound_name="Amon.tas", + ) + time = np.array(["2000-01-15", "2000-02-15"], dtype="datetime64[ns]") + cmoriser.ds = xr.Dataset( + {"tas": xr.DataArray([1.0, 2.0], dims=["time"])}, + coords={"time": ("time", time)}, + ) + cmoriser.ds["time"].attrs["bounds"] = "bad_bounds" + cmoriser.ds["bad_bounds"] = xr.DataArray(np.array([1.0, 2.0]), dims=["time"]) + cmoriser.ds["time_bnds"] = xr.DataArray( + np.array([[0.0, 1.0], [1.0, 2.0]]), + dims=["time", "bnds"], + coords={"time": time, "bnds": [0, 1]}, + ) + + bounds = cmoriser._get_time_bounds_for_gap_validation() + assert bounds is not None + assert bounds.name == "time_bnds" + + @pytest.mark.unit + def test_time_bounds_have_gaps_numeric_and_object_paths( + self, mock_vocab, mock_mapping, temp_dir + ): + """Gap helper should handle numeric and object arrays.""" + _ = CMORiser( + input_paths=["test.nc"], + output_path=str(temp_dir), + vocab=mock_vocab, + variable_mapping=mock_mapping, + compound_name="Amon.tas", + ) + + numeric_ok = xr.DataArray( + np.array([[0.0, 1.0], [1.0, 2.0]]), dims=["time", "bnds"] + ) + numeric_gap = xr.DataArray( + np.array([[0.0, 1.0], [1.1, 2.0]]), dims=["time", "bnds"] + ) + object_ok = xr.DataArray( + np.array([["a", "b"], ["b", "c"]], dtype=object), dims=["time", "bnds"] + ) + object_gap = xr.DataArray( + np.array([["a", "b"], ["x", "c"]], dtype=object), dims=["time", "bnds"] + ) + + assert not CMORiser._time_bounds_have_gaps(numeric_ok) + assert CMORiser._time_bounds_have_gaps(numeric_gap) + assert not CMORiser._time_bounds_have_gaps(object_ok) + assert CMORiser._time_bounds_have_gaps(object_gap) + + @pytest.mark.unit + def test_time_delta_days_scalar_and_days_object_paths( + self, mock_vocab, mock_mapping, temp_dir + ): + """_time_delta_days should cover scalar and days-based timedelta-like objects.""" + cmoriser = CMORiser( + input_paths=["test.nc"], + output_path=str(temp_dir), + vocab=mock_vocab, + variable_mapping=mock_mapping, + compound_name="Amon.tas", + ) + + assert np.isclose( + cmoriser._time_delta_days(0.0, 48.0, "hours since 2000-01-01"), 2.0 + ) + + class _DaysOnly: + def __init__(self, days, seconds=0): + self.days = days + self.seconds = seconds + + class _T: + def __init__(self, val): + self.val = val + + def __sub__(self, other): + return _DaysOnly(self.val - other.val) + + assert np.isclose(cmoriser._time_delta_days(_T(3), _T(5)), 2.0) + + @pytest.mark.unit + def test_validate_time_gaps_skips_numeric_axis_and_none_hint( + self, mock_vocab, mock_mapping, temp_dir + ): + """Cover the numeric-axis skip and the no-frequency-hint return path.""" + cmoriser = CMORiser( + input_paths=["test.nc"], + output_path=str(temp_dir), + vocab=mock_vocab, + variable_mapping=mock_mapping, + compound_name="Amon.tas", + ) + cmoriser.ds = xr.Dataset( + {"tas": xr.DataArray([1.0, 2.0], dims=["time"])}, + coords={"time": np.array([0.0, 1.0], dtype=float)}, + ) + with patch.object(cmoriser, "_target_frequency_hint", return_value="monthly"): + cmoriser._validate_time_gaps_from_bounds_or_frequency() + + with patch.object(cmoriser, "_target_frequency_hint", return_value=None): + cmoriser._validate_time_gaps_from_bounds_or_frequency() + + @pytest.mark.unit + def test_get_time_bounds_for_gap_validation_missing_time_and_invalid_shapes( + self, mock_vocab, mock_mapping, temp_dir + ): + """Cover the helper's early returns when time/bounds are absent or malformed.""" + cmoriser = CMORiser( + input_paths=["test.nc"], + output_path=str(temp_dir), + vocab=mock_vocab, + variable_mapping=mock_mapping, + compound_name="Amon.tas", + ) + + cmoriser.ds = xr.Dataset({"tas": xr.DataArray([1.0], dims=["time"])}) + assert cmoriser._get_time_bounds_for_gap_validation() is None + + time = np.array(["2000-01-15", "2000-02-15"], dtype="datetime64[ns]") + cmoriser.ds = xr.Dataset( + {"tas": xr.DataArray([1.0, 2.0], dims=["time"])}, + coords={"time": ("time", time)}, + ) + cmoriser.ds["time"].attrs["bounds"] = "bad_bounds" + cmoriser.ds["bad_bounds"] = xr.DataArray(np.array([1.0, 2.0]), dims=["time"]) + assert cmoriser._get_time_bounds_for_gap_validation() is None + + @pytest.mark.unit + def test_time_bounds_have_gaps_length_one_and_all_non_numeric( + self, mock_vocab, mock_mapping, temp_dir + ): + """Cover length-one and non-numeric comparison branches in bounds validation.""" + _ = CMORiser( + input_paths=["test.nc"], + output_path=str(temp_dir), + vocab=mock_vocab, + variable_mapping=mock_mapping, + compound_name="Amon.tas", + ) + short_bounds = xr.DataArray(np.array([[0.0, 1.0]]), dims=["time", "bnds"]) + assert not CMORiser._time_bounds_have_gaps(short_bounds) + + object_bounds = xr.DataArray( + np.array([["a", "b"], ["b", "c"]], dtype=object), + dims=["time", "bnds"], + ) + assert not CMORiser._time_bounds_have_gaps(object_bounds) + + @pytest.mark.unit + def test_time_delta_days_uses_total_seconds_branch( + self, mock_vocab, mock_mapping, temp_dir + ): + """Cover timedelta objects that expose total_seconds().""" + cmoriser = CMORiser( + input_paths=["test.nc"], + output_path=str(temp_dir), + vocab=mock_vocab, + variable_mapping=mock_mapping, + compound_name="Amon.tas", + ) + + class _TimedeltaLike: + def total_seconds(self): + return 172800.0 + + class _End: + def __sub__(self, other): + return _TimedeltaLike() + + assert np.isclose(cmoriser._time_delta_days(object(), _End()), 2.0) + + @pytest.mark.unit + def test_numeric_delta_to_days_no_units_and_known_units( + self, mock_vocab, mock_mapping, temp_dir + ): + """Cover the no-units return and explicit unit conversions.""" + cmoriser = CMORiser( + input_paths=["test.nc"], + output_path=str(temp_dir), + vocab=mock_vocab, + variable_mapping=mock_mapping, + compound_name="Amon.tas", + ) + + assert cmoriser._numeric_delta_to_days(5.0, None) == 5.0 + assert np.isclose( + cmoriser._numeric_delta_to_days(24.0, "hours since 2000-01-01"), 1.0 + ) + assert np.isclose( + cmoriser._numeric_delta_to_days(1440.0, "minutes since 2000-01-01"), 1.0 + ) + assert np.isclose( + cmoriser._numeric_delta_to_days(86400.0, "seconds since 2000-01-01"), 1.0 + ) + + @pytest.mark.unit + def test_validate_time_gaps_unknown_frequency_branch_returns( + self, mock_vocab, mock_mapping, temp_dir + ): + """An unrecognised target cadence should fall through without raising.""" + cmoriser = CMORiser( + input_paths=["test.nc"], + output_path=str(temp_dir), + vocab=mock_vocab, + variable_mapping=mock_mapping, + compound_name="Amon.tas", + ) + cmoriser.ds = xr.Dataset( + {"tas": xr.DataArray([1.0, 2.0], dims=["time"])}, + coords={ + "time": np.array(["2000-01-01", "2000-01-02"], dtype="datetime64[ns]") + }, + ) + + with patch.object(cmoriser, "_target_frequency_hint", return_value="subdaily"): + cmoriser._validate_time_gaps_from_bounds_or_frequency() + + @pytest.mark.unit + def test_get_time_bounds_for_gap_validation_duplicate_bounds_name_and_shape_mismatch( + self, mock_vocab, mock_mapping, temp_dir + ): + """Cover duplicate candidate names and mismatched bounds shapes.""" + cmoriser = CMORiser( + input_paths=["test.nc"], + output_path=str(temp_dir), + vocab=mock_vocab, + variable_mapping=mock_mapping, + compound_name="Amon.tas", + ) + time = np.array(["2000-01-15", "2000-02-15"], dtype="datetime64[ns]") + cmoriser.ds = xr.Dataset( + {"tas": xr.DataArray([1.0, 2.0], dims=["time"])}, + coords={"time": ("time", time)}, + ) + cmoriser.ds["time"].attrs["bounds"] = "time_bnds" + cmoriser.ds["time_bnds"] = xr.DataArray( + np.array([[0.0, 1.0]]), dims=["bad_time", "bnds"] + ) + assert cmoriser._get_time_bounds_for_gap_validation() is None + + cmoriser.ds["time_bnds"] = xr.DataArray( + np.array([[0.0, 1.0], [1.0, 2.0], [2.0, 3.0]]), + dims=["wrong_time", "bnds"], + ) + assert cmoriser._get_time_bounds_for_gap_validation() is None + + @pytest.mark.unit + def test_validate_time_gaps_time_variable_missing_returns( + self, mock_vocab, mock_mapping, temp_dir + ): + """The bounds helper should return cleanly when the time coordinate is absent.""" + cmoriser = CMORiser( + input_paths=["test.nc"], + output_path=str(temp_dir), + vocab=mock_vocab, + variable_mapping=mock_mapping, + compound_name="Amon.tas", + ) + cmoriser.ds = xr.Dataset({"tas": xr.DataArray([1.0], dims=["x"])}) + + assert cmoriser._get_time_bounds_for_gap_validation() is None + class TestCMIP6CMORiserWrite: """Unit tests for CMORiser.write() method with memory validation and string coordinate handling."""