@@ -1921,14 +1921,18 @@ def test_fx_file_with_um_time1_dim_is_squeezed(
19211921
19221922
19231923class 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