Source code for mhm_tools.common.file_handler

"""Read, write, and convert gridded setup files.

The module provides shared helpers for NetCDF, ESRI ASCII, and GeoTIFF-style
workflows, including coordinate normalization, header creation, encoding
cleanup, grid metadata handling, and output provenance.

Authors
-------
- Simon Lüdke
"""

import contextlib
import logging
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Dict, Optional, Sequence, Tuple, Union

import numpy as np
import xarray as xr

from mhm_tools.common.constants import NC_ENCODE_DEFAULTS, NO_DATA
from mhm_tools.common.esri_grid import standardize_header, write_grid, write_header
from mhm_tools.common.logger import ErrorLogger, log_arguments
from mhm_tools.common.netcdf import (
    apply_cf_baseline_metadata,
    generate_bounds_for_all_coords,
    get_netcdf_metadata_data_vars,
    prepare_dataset_for_netcdf_write,
    prepare_time_bounds_encoding,
    read_dataset,
)
from mhm_tools.common.provenance import apply_output_provenance
from mhm_tools.common.xarray_utils import (
    get_coord_key,
    get_dtype,
    get_single_data_var,
    normalize_lat_lon,
)

logger = logging.getLogger(__name__)


[docs] @dataclass class GridDefinition: """Container to preserve a dataset's spatial grid metadata.""" template: xr.Dataset dims: Tuple[str, ...]
[docs] def get_grid( ds: xr.Dataset, data_vars: Optional[Union[str, Sequence[str]]] = None ) -> GridDefinition: """Extract a grid definition from ``ds``. Parameters ---------- ds : xr.Dataset Dataset that defines the spatial/temporal grid. data_vars : str | sequence[str], optional Data variables to drop from the returned template. When omitted the first data variable is used. """ if data_vars is None: var_name = get_single_data_var(ds) if var_name is None: msg = "Cannot determine data_var to describe grid." with ErrorLogger(logger): raise ValueError(msg) drop_vars = [var_name] elif isinstance(data_vars, str): drop_vars = [data_vars] var_name = data_vars else: drop_vars = list(data_vars) var_name = drop_vars[0] if var_name not in ds.data_vars: msg = f"Grid descriptor variable {var_name} not present in dataset." with ErrorLogger(logger): raise ValueError(msg) template = ds.drop_vars(drop_vars, errors="ignore") dims = tuple(ds[var_name].dims) return GridDefinition(template=template, dims=dims)
[docs] def set_grid( data: np.ndarray, grid: GridDefinition, var_name: str, data_attrs: Optional[Dict[str, Union[str, float, int]]] = None, ) -> xr.Dataset: """Attach ``data`` to a preserved grid definition.""" coords = {} for name, coord in grid.template.coords.items(): # Only include coords compatible with the target variable dims. if set(coord.dims).issubset(set(grid.dims)): coords[name] = coord da = xr.DataArray( data, coords=coords, dims=grid.dims, attrs=data_attrs or {}, name=var_name, ) ds = grid.template.copy(deep=False) ds[var_name] = da return ds
[docs] def create_header( ds, output_path=None, no_data_value=None, cellsize=None, xllcorner=None, yllcorner=None, ) -> dict: """Write a header file from a dataset. Takes an xarray Dataset and writes the ASCII header needed for GIS tools. """ if no_data_value is None: no_data_value = NO_DATA lat_key = get_coord_key(ds, lat=True) lon_key = get_coord_key(ds, lon=True) x = ds[lon_key].data y = ds[lat_key].data if cellsize is None: if len(x) > 1: cellsize = abs(x[1] - x[0]) elif len(y) > 1: cellsize = abs(y[1] - y[0]) else: msg = "Cannot determine cellsize from dataset with only one x and one y value. Please provide cellsize as an argument." with ErrorLogger(logger): raise ValueError(msg) if xllcorner is None: xllcorner = np.nanmin(x) - 0.5 * cellsize if yllcorner is None: yllcorner = np.nanmin(y) - 0.5 * cellsize ncols = len(x) nrows = len(y) dtype = get_dtype(ds) typ = int if issubclass(np.dtype(dtype).type, np.integer) else float header_dict = { "ncols": ncols, "nrows": nrows, "xllcorner": xllcorner, "yllcorner": yllcorner, "cellsize": cellsize, "nodata_value": typ(no_data_value), } header_dict = standardize_header(header_dict) if output_path is not None: output_path = Path(output_path) if output_path.is_dir() or not output_path.suffix: header_out_path = output_path / "header.txt" else: header_out_path = output_path header_out_path.parent.mkdir(parents=True, exist_ok=True) header_str = write_header(header_out_path, header_dict, dtype) logger.info( f"Writing header file to {header_out_path} with header str: \n{header_str}" ) return header_dict
[docs] def crop_file_by_mask(ds, mask_file): """Crop file by mask.""" if isinstance(mask_file, xr.Dataset): mask = mask_file else: mask = get_xarray_ds_from_file(mask_file) lat_key_mask = get_coord_key(mask, lat=True) lon_key_mask = get_coord_key(mask, lon=True) lat_key = get_coord_key(ds, lat=True) lon_key = get_coord_key(ds, lon=True) return ds.sel( { lat_key: slice(mask[lat_key_mask].max(), mask[lat_key_mask].min()), lon_key: slice(mask[lon_key_mask].min(), mask[lon_key_mask].max()), } )
[docs] def chunk_dataset_space_only( ds: xr.Dataset, available_mem_gib: float, var_name: Optional[str] = None ) -> Dict[str, int]: """Chunk only in space (lat/lon), leaving time whole, sized to available memory. - Uses 80% of available_mem_gib for a single chunk. - Computes how many total cells (t * y * x) fit, then allocates all t, and splits y/x so that t·y·x·bytes_per_cell ≤ work_bytes. - If no time dimension, behaves similarly with t=1. """ logger.info( f"Chunking spatial dims to fit ≈{available_mem_gib} GiB (time unchunked)" ) # --- pick one variable to get dtype size --- if var_name is None: var_name = get_single_data_var(ds) dtype_sz = ds[var_name].dtype.itemsize # bytes per element # --- find coordinate names --- lat_key = get_coord_key(ds, lat=True) lon_key = get_coord_key(ds, lon=True) time_key = None # with contextlib.suppress(ValueError): time_key = get_coord_key(ds, time=True, raise_exception=False) ny = ds.sizes[lat_key] nx = ds.sizes[lon_key] nt = ds.sizes.get(time_key, 1) # --- memory budget in bytes (80%) --- work_bytes = max(int(0.1 * available_mem_gib * 1024**3), 4 * 1024**2) # how many total cells fit max_cells = work_bytes // dtype_sz # allocate all time steps cells_per_slice = max_cells // nt # square-ish spatial block side = max(1, int(np.sqrt(cells_per_slice))) y_chunk = min(ny, side) x_chunk = min(nx, side) chunks = {lat_key: int(y_chunk), lon_key: int(x_chunk)} if time_key: # -1 means “take all” for that dim chunks[time_key] = -1 logger.debug( f"Chunk sizes → time: {chunks.get(time_key, '—')}, " f"{lat_key}: {y_chunk}, {lon_key}: {x_chunk}" ) return chunks
[docs] def chunk_dataset_space_and_time( ds, available_mem_gib, var_name: Optional[str] = None ) -> Dict[str, int]: """Chunk dataset adjusting chunk size to avaiable memory. Simple heuristic: - try to keep time chunks small (1…4)vi - make y/x chunks as square as possible """ logger.info( f"Chunking dataset with a max amount of mem of {available_mem_gib:.1f}Gb" ) # ---------------- metadata only (cheap) -------------------------------- if isinstance(ds, xr.Dataset): if var_name is None: var_name = next(iter(ds.data_vars)) # first data variable var = ds[var_name] # an xarray.Variable wrapper else: var = ds dtype_sz = var.dtype.itemsize # bytes per element lat_key = get_coord_key(ds, lat=True) lon_key = get_coord_key(ds, lon=True) time_key = get_coord_key(ds, time=True, raise_exception=False) if time_key is None: return chunk_dataset_space_only(ds, available_mem_gib, var_name) ny = ds.sizes[lat_key] nx = ds.sizes[lon_key] nt = ds.sizes.get(time_key, 1) # ---------------- convert GiB → bytes and keep 80 % --------------------- _MIN_BYTES_PER_CHUNK = 4 * 1024**2 # 4MB work_bytes = max(int(0.8 * available_mem_gib * 1024**3), _MIN_BYTES_PER_CHUNK) max_cells = work_bytes // dtype_sz # how many array elements fit # ---------------- choose chunk sizes ----------------------------------- t_chunk = min(nt, 4) if time_key else None # ≤4 along time cells_per_t = max_cells // (t_chunk or 1) side = max(1, int(np.sqrt(cells_per_t))) # square-ish y/x chunk y_chunk = min(ny, side) x_chunk = min(nx, side) chunks = {lat_key: int(y_chunk), lon_key: int(x_chunk)} if time_key: t_chunk = max(1, max_cells // max(1, y_chunk * x_chunk)) chunks[time_key] = int(t_chunk) logger.debug(f" The chunks used are {chunks}") return chunks
[docs] class ChunkType(Enum): """Define Types of chunking. SPACE: Only chunking in space. Time dimension is conserved. TIME: Chunking predominately in time. If necessary also in space. """ SPACE = 1 TIME = 2
[docs] @log_arguments() def chunk_dataset(ds, chunk_type, available_mem_gib, var_name=None): """Chunk xarray.DataSet depending on chunk_type and available memory.""" if chunk_type == ChunkType.TIME: chunks = chunk_dataset_space_and_time(ds, available_mem_gib, var_name) if chunk_type == ChunkType.SPACE: chunks = chunk_dataset_space_only(ds, available_mem_gib, var_name) try: return ds.chunk(chunks) except Exception as e: logger.error(chunks) logger.error(ds) with ErrorLogger(logger): raise e
[docs] def write_xarray_to_netcdf( ds, file_path, var_name=None, encoding=None, engine="netcdf4", ): """Write an xarray Dataset or DataArray to a NetCDF file. Parameters ---------- ds : xr.Dataset or xr.DataArray Dataset or data array to write. file_path : str or pathlib.Path Target NetCDF file path. var_name : str, optional Data variable to write or DataArray name override. encoding : dict, optional Per-variable NetCDF encoding. engine : str, default "netcdf4" Xarray NetCDF backend engine. Returns ------- None """ ds, data_vars = _get_netcdf_write_dataset_and_data_vars(ds, var_name) ds = apply_output_provenance(ds) apply_cf_baseline_metadata(ds, data_vars) if encoding is None: encoding = { var: { "zlib": True, "complevel": 4, "shuffle": True, **NC_ENCODE_DEFAULTS, } for var in data_vars } else: encoding = {key: value for key, value in encoding.items() if key in data_vars} ds = prepare_time_bounds_encoding(ds) try: ds_clean, safe_encoding = prepare_dataset_for_netcdf_write( ds, data_vars, encoding ) ds_clean.to_netcdf( file_path, engine=engine, format="NETCDF4", encoding=safe_encoding ) except ValueError: logger.error(f"Error while writing to {file_path}") logger.error(ds) logger.info(f"Trying to write without encoding {encoding}") ds = prepare_time_bounds_encoding(ds, strip_time_attrs=True) ds.to_netcdf(file_path, engine=engine, format="NETCDF4") except Exception as e: with ErrorLogger(logger): raise e
def _get_netcdf_write_dataset_and_data_vars(ds, var_name=None): """Return a Dataset and payload variable names for NetCDF writing. Parameters ---------- ds : xr.Dataset or xr.DataArray Object to prepare for NetCDF writing. var_name : str, optional Requested data variable name. Returns ------- tuple ``(dataset, data_vars)`` where data_vars excludes bounds/grid mappings. """ if isinstance(ds, xr.DataArray): var_name = ds.name if var_name is None else var_name logger.debug(f"var {var_name}") ds = ds.to_dataset(name=var_name) data_vars = [var_name] logger.debug(f"Creating data vars from dataarray name: {data_vars}") elif var_name is None or var_name not in ds.data_vars: if var_name is not None and var_name not in ds.data_vars: logger.warning( f"Requested var_name {var_name!r} not found in dataset. " "Using all data variables instead." ) logger.info(f"Taking data vars from list of ds.data_vars {ds.data_vars}") metadata_vars = get_netcdf_metadata_data_vars(ds) data_vars = [var for var in ds.data_vars if var not in metadata_vars] else: data_vars = [var_name] logger.info(f"Setting data vars as input varname [var_name]: {data_vars}") if not data_vars: logger.warning( "Dataset has no data variables. Writing coordinate-only dataset." ) logger.debug(f"data vars: {data_vars}") return ds, data_vars
[docs] @log_arguments() def get_xarray_ds_from_file( # noqa: PLR0912 file_path, var_name=None, chunking=False, available_mem_gib=None, chunk_type=ChunkType.SPACE, use_mfdataset=False, engine="netcdf4", normalize_latlon_coords=False, force_decending_y=False, force_ascending_y=False, landcover=False, landcover_year_start=None, create_bounds=False, ): """Read file and return xarray dataset.""" file_path = Path(file_path) logger.debug(f"Reading {file_path} to xarray with chunking = {chunking}") ds_out = None if not file_path.is_file(): msg = f"File path does not point to an existing file: {file_path}" with ErrorLogger(logger): raise ValueError(msg) suffix = file_path.suffix.lower() if suffix == ".asc": if landcover: logger.info("Reading ascii landcover file.") ds_out = read_ascii_to_xarray( filepath=file_path, var_name=var_name, landcover=landcover, landcover_year_start=landcover_year_start, ) else: ds_out = read_ascii_to_xarray( filepath=file_path, var_name=var_name, ) chunk_type = ChunkType.SPACE elif suffix == ".nc": ds_out = read_dataset( file_path=file_path, use_mfdataset=use_mfdataset, engine=engine, ) elif suffix in {".tif", ".tiff"}: import rioxarray as rxr da = rxr.open_rasterio(file_path) if var_name is not None: da = da.rename(var_name) else: da.name = "data" if "band" in da.dims and da.sizes.get("band", 0) == 1: da = da.squeeze("band", drop=True) nodata = None with contextlib.suppress(Exception): nodata = da.rio.nodata if nodata is not None: da.attrs.setdefault("nodata_value", nodata) da.attrs.setdefault("_FillValue", nodata) if "x" in da.coords: da["x"].attrs.setdefault("axis", "X") if "y" in da.coords: da["y"].attrs.setdefault("axis", "Y") ds_out = da.to_dataset() if "spatial_ref" in ds_out.coords: ds_out = ds_out.drop_vars("spatial_ref") else: msg = ( "Reading file types other than asci, netcdf, and geotiff is not " f"implemented. The suffix of the file was: {file_path.suffix}" ) with ErrorLogger(logger): raise NotImplementedError(msg) lat_key = get_coord_key(ds_out, lat=True, raise_exception=False) lon_key = get_coord_key(ds_out, lon=True, raise_exception=False) # force correct order of y coordinate if lat_key is not None and ( (force_decending_y and ds_out[lat_key].data[0] < ds_out[lat_key].data[-1]) or (force_ascending_y and ds_out[lat_key].data[0] > ds_out[lat_key].data[-1]) ): ds_out = ds_out.sel({lat_key: slice(None, None, -1)}) logger.debug(ds_out) logger.debug(lat_key) logger.debug(lon_key) if normalize_latlon_coords: # re-name input coords to lat and lon ds_out = normalize_lat_lon( ds_out, lat_key=lat_key, lon_key=lon_key, raise_exceptions=False ) if create_bounds: ds_out = generate_bounds_for_all_coords(ds_out) if lon_key is None and lat_key is None: logger.warning("Dataset does not have lon and lat key.") elif lon_key is None or lat_key is None: logger.error("Dataset has only one of lon at lat keys.") if chunking and available_mem_gib is not None: ds_out = chunk_dataset(ds_out, chunk_type, available_mem_gib, var_name) else: # if no chunking remove chunking encoding because this might cause errors while writing for name in list(ds_out.variables): enc = ds_out.variables[name].encoding enc.pop("chunksizes", None) enc.pop("_ChunkSizes", None) enc.pop("chunks", None) enc["contiguous"] = True if ds_out is None: msg = f"The dataset read from {file_path} is empty." with ErrorLogger(logger): raise NotImplementedError() logger.debug(f"ds_out: {ds_out}") return ds_out
[docs] def write_xarray_to_file( ds, file_path, var_name=None, create_folder=True, encoding=None, engine="netcdf4", resolution=None, ): """Write xarray Datasets to file with file type depending on the file suffix.""" file_path = Path(file_path) if file_path.suffix not in {".asc", ".nc"}: msg = ( "Writing to file types other than asci and netcdf is not implemented. " f"The suffix of the file was: {file_path.suffix}" ) with ErrorLogger(logger): raise NotImplementedError(msg) if create_folder and not file_path.parent.is_dir(): file_path.parent.mkdir(parents=True, exist_ok=True) if file_path.is_file(): file_path.unlink() logger.info(f"Writing file to {file_path}") # ds = chunk_if_too_big(ds) # ds = ds.chunk({'time': 512, 'lat': 121, 'lon': 131}) if file_path.suffix == ".asc": write_xarray_to_ascii(ds, file_path, var_name, resolution=resolution) elif file_path.suffix == ".nc": write_xarray_to_netcdf(ds, file_path, var_name, encoding, engine)
[docs] def write_xarray_to_ascii( dataset, filepath, data_var=None, nodata_value=None, resolution=None ): """Write xarray Dataset to an ASCII file that can be read by mHM.""" # check if a data_var can be optained for writing the data if data_var is None and isinstance(dataset, xr.Dataset): data_var = get_single_data_var(dataset) if data_var is None: logger.error( f"Data can not be written to {filepath} as the dataset has multiple data_vars, which is incompatible with asci or no datavar exists." ) return # get the data from the dataset data = dataset[data_var] if isinstance(dataset, xr.Dataset) else dataset # set the nodata value dtype = get_dtype(data) if nodata_value is None: is_int = issubclass(np.dtype(dtype).type, (np.integer, np.unsignedinteger)) typ = int if is_int else float nodata_value = typ(NO_DATA) header = create_header(dataset, no_data_value=nodata_value) if resolution is not None: header["cellsize"] = resolution data_to_write = data if isinstance(data_to_write, xr.DataArray): data_to_write = data_to_write.data if data_to_write.dtype.kind in ["i", "u", "f"]: # i=int, u=unsigned, f=float data_to_write = np.where(np.isnan(data_to_write), nodata_value, data_to_write) out_header_str = write_grid( file=filepath, header=header, dtype=dtype, data=data_to_write ) logger.info(f"Writting file to {filepath}") logger.debug(f"Header written:\n{out_header_str}")
[docs] def read_ascii_to_xarray( filepath, var_name=None, landcover=False, landcover_year_start=None, ): """Read an mHM readable asci file to an xarray dataset.""" # Read the header from the file with filepath.open("r") as f: header = {} for i, line in enumerate(f.readlines()): line_striped = line.strip() if not line_striped: continue logger.debug(f"File {filepath.name} {i}: {line_striped}") key, value = line_striped.split() header[key.lower()] = float(value) if "." in value else int(value) if len(header) == 6: break # Extract header information ncols = header["ncols"] nrows = header["nrows"] xllcorner = header["xllcorner"] yllcorner = header["yllcorner"] cellsize = header["cellsize"] nodata_value = header["nodata_value"] # Load the data values data_values = np.loadtxt(filepath, skiprows=i + 1) if isinstance(nodata_value, int): data_values = data_values.astype(np.int32) # Calculate latitude and longitude coordinates lon = np.arange( xllcorner + cellsize / 2, xllcorner + (ncols + 0.5) * cellsize, cellsize ) lat = np.arange( yllcorner + (nrows - 0.5) * cellsize, yllcorner - cellsize / 2, -cellsize ) logger.debug(lon) logger.debug(lat) # Create DataArray with lat/lon dimensions and nodata value name = "data" if var_name is None else var_name coords = {"lon": ("lon", lon, {"axis": "X"}), "lat": ("lat", lat, {"axis": "Y"})} # If this is a landcover file add a 1-element time coordinate if landcover and landcover_year_start is not None: start_ts = np.datetime64(f"{landcover_year_start}-01-01", "ns") time = np.array([start_ts], dtype="datetime64[ns]") coords["time"] = ("time", time) # If we added a time coordinate, expand the data to have a leading time dim if "time" in coords: data_arr = np.expand_dims(data_values, axis=0) # shape (1, nrows, ncols) dims = ["time", "lat", "lon"] else: data_arr = data_values dims = ["lat", "lon"] da = xr.DataArray( data=data_arr, dims=dims, coords=coords, name=name, attrs={"nodata_value": nodata_value, "_FillValue": nodata_value}, ) # Convert to Dataset ds = da.to_dataset() # Drop spatial_ref if present if "spatial_ref" in ds.coords: ds = ds.reset_coords("spatial_ref", drop=True) return ds
[docs] def get_coord_values(ds, lat=False, lon=False): """Get latitude or longitude values from DataSet.""" key = get_coord_key(ds, lat=lat, lon=lon) return ds[key].values
[docs] def get_dataset_from_path( path, var_name=None, chunking=None, available_mem_gib=None, chunk_type=ChunkType.SPACE, use_mfdataset=False, engine="netcdf4", normalize_latlon_coords=False, force_decending_y=False, force_ascending_y=False, landcover=False, landcover_year_start=None, available_mem=None, file_name="*.*", ): """Load a dataset from a file, directory, or pattern. This mirrors ``get_xarray_ds_from_file`` for single-file inputs and extends it to directories (multi-file datasets). """ if path is None: path_is_none_msg = "Input path is None. Please provide a valid file path, directory path, or glob pattern." with ErrorLogger(logger): raise ValueError(path_is_none_msg) if available_mem_gib is None and available_mem is not None: available_mem_gib = available_mem if chunking is None: chunking = True if chunking is None: chunking = False def _postprocess(ds_out): lat_key = get_coord_key(ds_out, lat=True, raise_exception=False) lon_key = get_coord_key(ds_out, lon=True, raise_exception=False) if lat_key is not None and ( (force_decending_y and ds_out[lat_key].data[0] < ds_out[lat_key].data[-1]) or ( force_ascending_y and ds_out[lat_key].data[0] > ds_out[lat_key].data[-1] ) ): ds_out = ds_out.sel({lat_key: slice(None, None, -1)}) logger.debug(ds_out) logger.debug(lat_key) logger.debug(lon_key) if normalize_latlon_coords: ds_out = normalize_lat_lon( ds_out, lat_key=lat_key, lon_key=lon_key, raise_exceptions=False ) if lon_key is None and lat_key is None: logger.warning("Dataset does not have lon and lat key.") elif lon_key is None or lat_key is None: logger.error("Dataset has only one of lon at lat keys.") if chunking and available_mem_gib is not None: ds_out = chunk_dataset(ds_out, chunk_type, available_mem_gib, var_name) else: for name in list(ds_out.variables): enc = ds_out.variables[name].encoding enc.pop("chunksizes", None) enc.pop("_ChunkSizes", None) enc.pop("chunks", None) enc["contiguous"] = True return ds_out def _is_dir_or_list(p): if isinstance(p, list): return True if isinstance(p, str): p = Path(p) return p.is_dir() if _is_dir_or_list(path): file_list = [] if not isinstance(path, list): path = Path(path) file_list = list(path.rglob(file_name)) else: path = [Path(p) for p in path] for p in path: if p.is_file(): file_list.append(p) elif p.is_dir(): file_list.extend(list(p.rglob(file_name))) if not file_list: with ErrorLogger(logger): msg = f"No files found in {path}." raise ValueError(msg) if len(file_list) == 1: return get_xarray_ds_from_file( file_list[0], var_name=var_name, chunking=chunking, available_mem_gib=available_mem_gib, chunk_type=chunk_type, use_mfdataset=use_mfdataset, engine=engine, normalize_latlon_coords=normalize_latlon_coords, force_decending_y=force_decending_y, force_ascending_y=force_ascending_y, landcover=landcover, landcover_year_start=landcover_year_start, ) non_nc = [p for p in file_list if Path(p).suffix != ".nc"] if non_nc: with ErrorLogger(logger): msg = "Multi-file loading supports NetCDF files only." raise ValueError(msg) ds_out = read_dataset(file_list, use_mfdataset=use_mfdataset, engine=engine) return _postprocess(ds_out) path_in = path path = Path(path_in) if path.is_file(): return get_xarray_ds_from_file( path, var_name=var_name, chunking=chunking, available_mem_gib=available_mem_gib, chunk_type=chunk_type, use_mfdataset=use_mfdataset, engine=engine, normalize_latlon_coords=normalize_latlon_coords, force_decending_y=force_decending_y, force_ascending_y=force_ascending_y, landcover=landcover, landcover_year_start=landcover_year_start, ) path_str = str(path_in) if any(w in path_str for w in ("*", "?", "[", "]")) and path_str.endswith(".nc"): ds_out = read_dataset(path_str, use_mfdataset=use_mfdataset, engine=engine) return _postprocess(ds_out) with ErrorLogger(logger): msg = f"Path {path} does not exist." raise ValueError(msg)