Skip to content

Commit 30c7ed6

Browse files
authored
solved _probe time detection issue (#337)
* solved _probe time detection issue * solved _probe time detection issue * reset and fix test conflict * fix test issue
1 parent 0d66492 commit 30c7ed6

2 files changed

Lines changed: 148 additions & 59 deletions

File tree

src/access_moppy/base.py

Lines changed: 3 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -353,34 +353,9 @@ def _preprocess(ds):
353353
if required_vars
354354
else list(_probe.data_vars)
355355
)
356-
if not _probe_target_vars:
357-
# None of the required variables are in the probe file; fall back
358-
# to checking whether the file itself is time-dependent so all
359-
# files are still concatenated (the missing-variable error will
360-
# surface downstream with proper context).
361-
_has_time = "time" in _probe.dims and any(
362-
"time" in _probe[v].dims for v in _probe.data_vars
363-
)
364-
logger.warning(
365-
"Required variables %s not found in probe file %s; "
366-
"inferring time-dependency from other variables in file (_has_time=%s).",
367-
list(required_vars) if required_vars else [],
368-
self.input_paths[0],
369-
_has_time,
370-
)
371-
else:
372-
_has_time = any(
373-
"time" in _probe[v].dims for v in _probe_target_vars
374-
)
375-
376-
# Fix #334: the CMOR table is the authoritative source for whether a
377-
# variable is time-independent (fx). A source file may carry a time
378-
# dimension even for fx variables (e.g. orog from a UM dump); using the
379-
# probe alone would then set _has_time=True and trigger a spurious
380-
# concat_dim="time" that fails later. Override here using the same
381-
# compound_name check already used for frequency validation below.
382-
if self.compound_name and "fx" in self.compound_name.lower():
383-
_has_time = False
356+
_has_time = (required_vars is None or "time" in required_vars) and any(
357+
"time" in _probe[v].dims for v in _probe_target_vars
358+
)
384359

385360
# Validate frequency consistency and CMIP6 compatibility before concatenation
386361
# Skip validation for time-independent variables (e.g., areacello, static grids)

tests/unit/test_base.py

Lines changed: 145 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1921,14 +1921,18 @@ def test_fx_file_with_um_time1_dim_is_squeezed(
19211921

19221922

19231923
class TestHasTimeProbeLogic:
1924-
"""Tests for the _has_time probe fallback in load_dataset (base.py).
1924+
"""
1925+
Tests for the _has_time probe logic in load_dataset (base.py).
19251926
1926-
When none of the required variables exist in the probe file,
1927-
_probe_target_vars is empty and the original any([]) returned False,
1928-
silently falling into the fx branch and loading only one file.
1927+
Logic:
1928+
_has_time = ("time" in required_vars) and any(
1929+
"time" in _probe[v].dims for v in _probe_target_vars
1930+
)
19291931
1930-
The fix: check whether any data variable in the probe file has a time
1931-
dimension, so time-series files still use open_mfdataset.
1932+
"time" in required_vars is the canonical signal for a time-series variable
1933+
(fx mappings never include a time dimension). The second condition confirms
1934+
that at least one required variable present in the probe file actually carries
1935+
a time axis — typically time_bnds when the model variable is missing.
19321936
"""
19331937

19341938
@pytest.fixture
@@ -1948,34 +1952,49 @@ def mock_mapping(self):
19481952

19491953
@pytest.fixture
19501954
def time_series_nc_without_target(self, tmp_path):
1951-
"""NetCDF file that is time-dependent but lacks the required target variable.
1955+
"""
1956+
NetCDF file that is time-dependent (has time_bnds with a time dim)
1957+
but does NOT contain the required target variable fld_s16i201.
19521958
1953-
Uses a non-bounds data variable so it appears in _probe.data_vars
1954-
after being read back with decode_cf=False.
1959+
This mimics daily zg files: the probe file lacks the model variable
1960+
but is still a time-series file that must be opened with open_mfdataset.
19551961
"""
1956-
nc_path = tmp_path / "timeseries_no_target.nc"
1962+
nc_path = tmp_path / "daily_no_target.nc"
19571963
ds = xr.Dataset(
1958-
# A real data variable with a time dim — but NOT the required fld_s16i201
1959-
{"some_other_var": (["time", "lat"], np.zeros((3, 5)))},
1964+
{
1965+
"time_bnds": (["time", "bnds"], np.zeros((3, 2))),
1966+
"lat_bnds": (["lat", "bnds"], np.zeros((5, 2))),
1967+
"lon_bnds": (["lon", "bnds"], np.zeros((8, 2))),
1968+
},
19601969
coords={
19611970
"time": (
1962-
["time"],
1971+
"time",
19631972
np.arange(3, dtype=float),
19641973
{"units": "days since 2000-01-01", "calendar": "standard"},
19651974
),
1966-
"lat": np.linspace(-90, 90, 5),
1975+
"lat": ("lat", np.linspace(-90, 90, 5)),
1976+
"lon": ("lon", np.linspace(0, 360, 8, endpoint=False)),
19671977
},
19681978
)
19691979
ds.to_netcdf(str(nc_path))
19701980
return nc_path
19711981

19721982
@pytest.fixture
1973-
def static_nc_without_target(self, tmp_path):
1974-
"""NetCDF file with no time dimension and no target variable."""
1975-
nc_path = tmp_path / "static_no_target.nc"
1983+
def fx_nc_without_target(self, tmp_path):
1984+
"""
1985+
Time-independent NetCDF file that also lacks the target variable.
1986+
Used to verify the fx path is still taken for genuinely static files.
1987+
"""
1988+
nc_path = tmp_path / "fx_no_target.nc"
19761989
ds = xr.Dataset(
1977-
{"lat_bnds": (["lat", "bnds"], np.zeros((3, 2)))},
1978-
coords={"lat": ("lat", [-30.0, 0.0, 30.0])},
1990+
{
1991+
"lat_bnds": (["lat", "bnds"], np.zeros((5, 2))),
1992+
"lon_bnds": (["lon", "bnds"], np.zeros((8, 2))),
1993+
},
1994+
coords={
1995+
"lat": ("lat", np.linspace(-90, 90, 5)),
1996+
"lon": ("lon", np.linspace(0, 360, 8, endpoint=False)),
1997+
},
19791998
)
19801999
ds.to_netcdf(str(nc_path))
19812000
return nc_path
@@ -1984,11 +2003,14 @@ def static_nc_without_target(self, tmp_path):
19842003
def test_time_series_file_uses_open_mfdataset_when_target_absent(
19852004
self, mock_vocab, mock_mapping, time_series_nc_without_target, tmp_path
19862005
):
1987-
"""When required vars are absent from the probe but the file IS time-dependent,
1988-
load_dataset must take the open_mfdataset path (_has_time=True).
2006+
"""
2007+
When none of the required variables are in the probe file but the
2008+
file IS time-dependent, load_dataset must call open_mfdataset (not
2009+
fall through to the single-file fx branch).
19892010
1990-
Before the fix: any([]) == False → _has_time=False → only first file opened.
1991-
After the fix: infer from other data vars → _has_time=True → open_mfdataset.
2011+
"time" is in required_vars (time-series signal) and time_bnds is both in
2012+
required_vars and in the probe file's data_vars with a time dimension, so
2013+
_has_time = True even though the model variable itself is absent.
19922014
"""
19932015
cmoriser = CMORiser(
19942016
input_paths=[str(time_series_nc_without_target)],
@@ -2004,24 +2026,115 @@ def test_time_series_file_uses_open_mfdataset_when_target_absent(
20042026
patch("access_moppy.base.xr.open_mfdataset") as mock_mfd,
20052027
patch.object(cmoriser, "_normalize_missing_values_early"),
20062028
):
2007-
mock_mfd.return_value = xr.Dataset()
2029+
mock_mfd.return_value = xr.Dataset(
2030+
{"time_bnds": (["time", "bnds"], np.zeros((3, 2)))},
2031+
coords={"time": np.arange(3, dtype=float)},
2032+
)
2033+
# realistic required_vars for a daily zg: includes "time" (time-series
2034+
# signal) and time_bnds (present in probe with decode_cf=False)
2035+
cmoriser.load_dataset(
2036+
required_vars={
2037+
"fld_s16i201",
2038+
"time",
2039+
"time_bnds",
2040+
"lat_bnds",
2041+
"lon_bnds",
2042+
}
2043+
)
2044+
2045+
mock_mfd.assert_called_once()
2046+
2047+
@pytest.mark.unit
2048+
def test_fx_file_without_target_does_not_use_open_mfdataset(
2049+
self, mock_vocab, mock_mapping, fx_nc_without_target, tmp_path
2050+
):
2051+
"""
2052+
When the probe file has no time dimension at all and the target
2053+
variable is also absent, the code must still use the fx (single-file)
2054+
path — open_mfdataset must NOT be called.
2055+
"""
2056+
cmoriser = CMORiser(
2057+
input_paths=[str(fx_nc_without_target)],
2058+
output_path=str(tmp_path),
2059+
vocab=mock_vocab,
2060+
variable_mapping=mock_mapping,
2061+
compound_name="fx.areacella",
2062+
validate_frequency=False,
2063+
enable_chunking=False,
2064+
)
2065+
2066+
with (
2067+
patch("access_moppy.base.xr.open_mfdataset") as mock_mfd,
2068+
patch.object(cmoriser, "_normalize_missing_values_early"),
2069+
):
20082070
cmoriser.load_dataset(required_vars={"fld_s16i201"})
20092071

2072+
mock_mfd.assert_not_called()
2073+
2074+
@pytest.mark.unit
2075+
def test_target_present_and_time_dependent_uses_open_mfdataset(
2076+
self, mock_vocab, mock_mapping, tmp_path
2077+
):
2078+
"""
2079+
Baseline: required_vars contains "time" AND the model variable is in
2080+
the probe file with a time dimension → open_mfdataset is called.
2081+
"""
2082+
nc_path = tmp_path / "with_target.nc"
2083+
ds = xr.Dataset(
2084+
{
2085+
"fld_s16i201": (
2086+
["time", "lat", "lon"],
2087+
np.ones((3, 5, 8), dtype="f4"),
2088+
)
2089+
},
2090+
coords={
2091+
"time": (
2092+
"time",
2093+
np.arange(3, dtype=float),
2094+
{"units": "days since 2000-01-01", "calendar": "standard"},
2095+
),
2096+
"lat": ("lat", np.linspace(-90, 90, 5)),
2097+
"lon": ("lon", np.linspace(0, 360, 8, endpoint=False)),
2098+
},
2099+
)
2100+
ds.to_netcdf(str(nc_path))
2101+
2102+
cmoriser = CMORiser(
2103+
input_paths=[str(nc_path)],
2104+
output_path=str(tmp_path),
2105+
vocab=mock_vocab,
2106+
variable_mapping=mock_mapping,
2107+
compound_name="Amon.zg",
2108+
validate_frequency=False,
2109+
enable_chunking=False,
2110+
)
2111+
2112+
with (
2113+
patch("access_moppy.base.xr.open_mfdataset") as mock_mfd,
2114+
patch.object(cmoriser, "_normalize_missing_values_early"),
2115+
):
2116+
mock_mfd.return_value = ds
2117+
cmoriser.load_dataset(required_vars={"fld_s16i201", "time"})
2118+
20102119
mock_mfd.assert_called_once()
20112120

20122121
@pytest.mark.unit
2013-
def test_static_file_uses_open_dataset_when_target_absent(
2014-
self, mock_vocab, mock_mapping, static_nc_without_target, tmp_path
2122+
def test_time_not_in_required_vars_short_circuits_to_false(
2123+
self, mock_vocab, mock_mapping, time_series_nc_without_target, tmp_path
20152124
):
2016-
"""When required vars are absent and the file has no time dimension at all,
2017-
load_dataset must take the open_dataset path (_has_time=False).
2125+
"""
2126+
When "time" is absent from required_vars the AND short-circuits to False
2127+
and open_mfdataset must NOT be called, even if the file itself is a
2128+
time-series file. This ensures fx-like variables are never accidentally
2129+
opened with open_mfdataset just because the probe file happens to have
2130+
a time dimension.
20182131
"""
20192132
cmoriser = CMORiser(
2020-
input_paths=[str(static_nc_without_target)],
2133+
input_paths=[str(time_series_nc_without_target)],
20212134
output_path=str(tmp_path),
20222135
vocab=mock_vocab,
20232136
variable_mapping=mock_mapping,
2024-
compound_name="fx.orog",
2137+
compound_name="fx.areacella",
20252138
validate_frequency=False,
20262139
enable_chunking=False,
20272140
)
@@ -2030,7 +2143,8 @@ def test_static_file_uses_open_dataset_when_target_absent(
20302143
patch("access_moppy.base.xr.open_mfdataset") as mock_mfd,
20312144
patch.object(cmoriser, "_normalize_missing_values_early"),
20322145
):
2033-
cmoriser.load_dataset(required_vars={"fld_s00i033"})
2146+
# No "time" in required_vars → _has_time = False regardless of file content
2147+
cmoriser.load_dataset(required_vars={"fld_s16i201", "lat_bnds", "lon_bnds"})
20342148

20352149
mock_mfd.assert_not_called()
20362150

0 commit comments

Comments
 (0)