from __future__ import annotations
import datetime
import itertools
import logging
import os
import re
import threading
from os import PathLike
from pathlib import Path
from typing import Any, Literal, overload
import cfgrib
import numpy as np
from .....utils.check.errclass import CifFileNotFoundError, CifKeyError, CifValueError
# ecCodes (used internally by cfgrib/gribapi) is not thread-safe: concurrent
# open/read/release calls from different dask worker threads can corrupt its
# shared C-level state and segfault the interpreter. Serialize all access.
_grib_lock = threading.Lock()
[docs]
class GribDataset:
"""Thread-safe wrapper around ``cfgrib`` to read GRIB files.
Exposes GRIB variables and attributes through a small, thread-safe API
(all access to the underlying ``cfgrib``/ecCodes handles is serialized
via a module-level lock, since ecCodes is not thread-safe).
Args:
path: Path to the GRIB file.
read_keys: Additional GRIB keys to read, on top of the default
ones read by ``cfgrib``. Defaults to none.
filter_by_keys: Dict of GRIB keys/values passed to
``cfgrib.open_file`` to filter which messages are read.
Defaults to none.
Raises:
CifFileNotFoundError: If ``path`` does not exist.
Example:
>>> ds = GribDataset(
"path/to/file.grib",
read_keys=["isOctahedral"],
filter_by_keys={"edition": 1}
)
>>> ds["latitude"] # get a variable
array([...])
>>> ds.get_attr("isOctahedral") # get an attribute
1
"""
def __init__(
self,
path: str | PathLike[str],
read_keys: list[str] | None = None,
filter_by_keys: dict[str, Any] | None = None,
) -> None:
self.path = Path(path)
read_keys = [] if read_keys is None else read_keys
filter_by_keys = {} if filter_by_keys is None else filter_by_keys
if not self.path.is_file():
raise CifFileNotFoundError(f"{self.path} was not found")
# Disable logging to avoid warning messages
logger = logging.getLogger()
log_level = logger.level
logger.setLevel(10 * log_level)
try:
with _grib_lock:
self._ds = cfgrib.open_file(
self.path,
indexpath="",
read_keys=read_keys,
filter_by_keys=filter_by_keys,
)
finally:
logger.setLevel(log_level)
[docs]
def close(self) -> None:
"""Explicitly release the underlying cfgrib handles under the grib lock,
instead of leaving it to a garbage-collection pass on an arbitrary thread.
"""
with _grib_lock:
self._ds = None
def __del__(self) -> None:
self.close()
@property
def variables(self) -> dict[str, cfgrib.dataset.Variable]:
"""Returns the variables mapping of the GRIB file"""
return self._ds.variables
@overload
def __getitem__(self, key: str) -> np.ndarray: ...
@overload
def __getitem__(self, key: list[str]) -> list[np.ndarray]: ...
def __getitem__(self, key: str | list[str]) -> np.ndarray | list[np.ndarray]:
"""Get one or several variables from the GRIB file.
Args:
key: Variable name, or list of variable names, to get.
Returns:
The variable data as a single Numpy array (if ``key`` is a
string) or a list of Numpy arrays (if ``key`` is a list).
"""
varname_list = [key] if isinstance(key, str) else key
data = []
for varname in varname_list:
data.append(self.get_var(varname))
if isinstance(key, str):
return data[0]
return data
[docs]
def get_var(self, varname: str) -> np.ndarray:
"""Get one variable from the GRIB file as a Numpy array.
Args:
varname: Variable name.
Returns:
np.ndarray: Variable data.
Raises:
CifKeyError: If the variable is not found in the GRIB file.
"""
if varname not in self.variables:
raise CifKeyError(
f"The variable {varname} is not available is the file {self.path}. "
+ f"Available variables: {list(self.variables.keys())}"
)
with _grib_lock:
data = self.variables[varname].data
if hasattr(data, "build_array"):
data = data.build_array() # type: ignore
return data
[docs]
def get_attr(self, attr: str, varname: str | None = None) -> Any:
"""Get an attribute from the GRIB file.
If ``varname`` is provided, looks for the attribute in that
variable's attributes; otherwise looks for it across all variables
and returns the first one found.
Args:
attr: Attribute name to get (will be prefixed with ``"GRIB_"``
to match ``cfgrib`` attribute naming).
varname: Variable name to restrict the search to. Defaults to
searching all variables.
Returns:
Any: The attribute value.
Raises:
CifKeyError: If the attribute is not found.
"""
grib_attr = f"GRIB_{attr}"
if varname is not None:
if grib_attr not in self.variables[varname].attributes:
raise CifKeyError(
f"Could not find attribute {attr} in variable {varname} of file {self.path}"
)
return self.variables[varname].attributes[grib_attr]
for varname in self.variables:
if grib_attr in self.variables[varname].attributes:
return self.variables[varname].attributes[grib_attr]
raise CifKeyError(f"Could not find attribute {attr} in file {self.path}")
[docs]
def find_valid_file(
file_format,
dd,
time_freq,
ref_dir,
ref_dir_next,
ref_dir_previous,
delta_tolerance=1,
cumul_variable=False,
cumul_length=12,
):
"""Find the GRIB file(s) closest to a requested date.
Lists files in ``ref_dir`` (plus, when the cumulation window straddles
a month boundary, ``ref_dir_previous``/``ref_dir_next``), parses their
names against ``file_format`` to recover each file's valid date and
forecast lead time, and picks the file with the closest valid date at
or before ``dd`` (preferring the smallest forecast lead time in case of
a tie). If ``cumul_variable`` is set, also finds the next file after
that date, matching the same forecast lead time, so the pair can be
used to decumulate the variable.
Args:
file_format: Date-format pattern (with optional ``{di}`` forecast
lead-time placeholder) used both to build expected file names
and to parse actual file names into dates.
dd: The date to find a valid file for.
time_freq: Expected time step between files, used together with
``delta_tolerance`` to bound how far the closest file's date
may be from ``dd``.
ref_dir: Directory to search for candidate files.
ref_dir_next: Directory to also search when the cumulation window
crosses into the next month.
ref_dir_previous: Directory to also search when the cumulation
window crosses into the previous month.
delta_tolerance: Tolerance, as a multiple of ``time_freq``, allowed
between ``dd`` and the closest file's valid date.
cumul_variable: If True, also locate the next file needed to
decumulate the variable.
cumul_length: Cumulation window length, in hours, used to decide
whether ``ref_dir_previous``/``ref_dir_next`` must be searched.
Returns:
tuple: ``(files, dates)``, each a list with one entry (or two, when
``cumul_variable`` is set and a next file is found) giving the
matched file path(s) and corresponding date(s) (re-expressed
relative to ``dd``).
Raises:
CifFileNotFoundError: If no candidate file matches ``file_format``,
or if none is close enough to ``dd`` (within
``delta_tolerance * time_freq``).
CifValueError: If decumulation is requested but no next file with a
matching forecast lead time can be found.
"""
# Get all files and dates matching the file and format
# list_files_orig = os.listdir(ref_dir)
ref_dir = Path(ref_dir)
ref_dir_next = Path(ref_dir_next)
ref_dir_previous = Path(ref_dir_previous)
list_files_orig = list(ref_dir.iterdir())
# Convert ref date
ref_date = datetime.datetime.strptime(dd.strftime(file_format), file_format)
previous_date = ref_date - datetime.timedelta(hours=cumul_length)
if previous_date.month < ref_date.month or previous_date.year < ref_date.year:
list_files_orig += list(ref_dir_previous.iterdir())
next_date = ref_date + datetime.timedelta(hours=cumul_length)
if next_date.month > ref_date.month or next_date.year < ref_date.year:
list_files_orig += list(ref_dir_next.iterdir())
list_dates_cur = []
list_forecast_cur = []
list_forecast_hour = []
list_files_cur = []
for f in list_files_orig:
# Ignore index files generated by xarray and cfgrib
if str(f).find("idx") >= 0:
continue
basef = f.name
# Fetch date information
re_format = (
file_format.replace(".", "/")
.replace("%Y", "(\\d{4})")
.replace("%m", "(\\d{2})")
.replace("%d", "(\\d{2})")
.replace("%H", "(\\d{1,2})")
.replace("%M", "(\\d{2})")
.replace("*", "\\d{1,2}")
.replace("{di}", "(\\d{1,2})")
)
match_file = re.search(re_format, basef.replace(".", "/"))
if match_file is None:
continue
if match_file.span() != (0, len(basef)):
continue
patterns = ["%Y", "%m", "%d", "%H", "%M", "{di}"]
pstarts = []
pout = []
for p in patterns:
match = re.search(p.replace("%", "\\%"), file_format)
if match is not None:
pstarts.append(match.start())
pout.append(p)
di = 0
groups = list(match_file.groups())
if "{di}" in file_format:
index_di = sorted(pstarts).index(pstarts[-1])
di_str = match_file.groups()[index_di]
di = int(di_str)
pout = pout[:-1]
pstarts = pstarts[:-1]
groups.pop(index_di)
# Deal with time stamps at 24h
shift_hour = 0
if "%H" in pout:
shift_hour = int(groups[pout.index("%H")])
groups[pout.index("%H")] = "00"
date_cur = datetime.datetime.strptime(
"".join([groups[k] for k in np.argsort(pstarts)]), "".join(pout)
)
date_cur += datetime.timedelta(hours=di + shift_hour)
list_dates_cur.append(date_cur)
list_files_cur.append(str(f))
list_forecast_cur.append((date_cur - datetime.timedelta(hours=di)).hour)
list_forecast_hour.append(di)
list_files = np.array(list_files_cur)
list_dates = np.array(list_dates_cur)
list_forecast_cur = np.array(list_forecast_cur)
list_forecast_hour = np.array(list_forecast_hour)
# Sorting along dates
isort = np.argsort(list_dates)
list_dates = list_dates[isort]
list_files = list_files[isort]
list_forecast_cur = list_forecast_cur[isort]
list_forecast_hour = list_forecast_hour[isort]
if list_files.size == 0:
raise CifFileNotFoundError(
f"Did not find any valid GRIB files in {ref_dir} "
f"with format {file_format}. Please check your yml file"
)
# Find nearest previous date
mask = (list_dates - ref_date) <= datetime.timedelta(0)
if mask.sum() == 0:
raise CifFileNotFoundError(
f"No file has valid date for {ref_date} in {ref_dir} "
f"with format {file_format}. Please check your yml file. \n"
f"The range of dates covered by files is: {list_dates.min()} / {list_dates.max()}"
)
max_date = np.max(list_dates[mask])
mask = list_dates == max_date
# Fetch date with smaller forecast hour
mask = mask & (list_forecast_cur == list_forecast_cur[mask].min())
ind_date = np.where(mask)[0][0]
file_ref1 = list_files[ind_date]
date_ref1 = list_dates[ind_date]
forecast_hour_ref1 = list_forecast_hour[ind_date]
forecast_cur_ref1 = list_forecast_cur[ind_date]
# Check that date_ref1 is not too far away from ref_date
delta = date_ref1 - ref_date
if np.abs(delta) > delta_tolerance * time_freq:
raise CifFileNotFoundError(
f"Could not find files close enough to the expected date (date_ref1):\n"
f"\t- requested date: {dd.isoformat()}\n"
f"\t- date using file formating: {ref_date.isoformat()}\n"
f"\t- closest valid date: {date_ref1.isoformat()}\n"
f"\t- timedelta from expected date: {delta!s}\n"
f"\t- timedelta from file frequency: {time_freq!s}\n"
)
# Deal differently between cumulated and instantaneous variables
# If not cumulative variable, just return instantaneous snapshot
if not cumul_variable:
dd1 = dd + (date_ref1 - ref_date)
return [file_ref1], [dd1]
#
# Now find nearest next date
#
mask = (list_dates - ref_date) > datetime.timedelta(0)
if mask.sum() == 0:
return [file_ref1], [dd + (date_ref1 - ref_date)]
min_date = np.min(list_dates[mask])
mask = list_dates == min_date
# Deal differently between cumulated and instantaneous variables
# For cumulated variable, make sure that the forecast date is the same as the previous date
mask = mask & (list_forecast_hour == forecast_hour_ref1)
if mask.sum() == 0:
raise CifValueError(
f"Could not decumulate variable in files {file_format} "
f"for date {dd} because could not guarantee that the same "
"forecast hour is available for the two sides of the interval"
)
ind_date = np.where(mask)[0][0]
file_ref2 = list_files[ind_date]
date_ref2 = list_dates[ind_date]
# Check that date_ref1 is not too far away from ref_date
delta = date_ref2 - ref_date
if np.abs(delta) > delta_tolerance * time_freq:
raise CifFileNotFoundError(
f"Could not find files close enough to the expected date (date_ref2):\n"
f"\t- requested date: {dd.isoformat()}\n"
f"\t- date using file formating: {ref_date.isoformat()}\n"
f"\t- closest valid date: {date_ref2.isoformat()}\n"
f"\t- timedelta from expected date: {delta!s}\n"
f"\t- timedelta from file frequency: {time_freq!s}\n"
f"\t- file format: {file_format}\n"
)
# Reconvert to original date
dd1 = dd + (date_ref1 - ref_date)
dd2 = dd + (date_ref2 - ref_date)
return [file_ref1, file_ref2], [dd1, dd2]
[docs]
def get_grid_type(
domain_file: str | PathLike[str],
filter_by_keys_dict: dict[str, Any] | None = None,
) -> Literal["regular", "octahedral", "reduced_gaussian"]:
"""Determine the GRIB grid type of a reference file.
Args:
domain_file: Path to the GRIB file to inspect.
filter_by_keys_dict: Dict of GRIB keys/values used to select which
message to read from the file.
Returns:
``"octahedral"`` if the GRIB ``isOctahedral`` attribute is set,
``"regular"`` if the latitude spacing is constant, or
``"reduced_gaussian"`` otherwise.
"""
ds = GribDataset(
domain_file,
read_keys=["isOctahedral"],
filter_by_keys=filter_by_keys_dict,
)
try:
is_octahedral = ds.get_attr("isOctahedral") == 1
except KeyError:
is_octahedral = False
if is_octahedral:
return "octahedral"
else:
lat = ds["latitude"]
if not np.any(np.diff(lat) == 0):
return "regular"
else:
return "reduced_gaussian"
[docs]
def get_jscan(
domain_file: str | PathLike[str],
filter_by_keys_dict: dict[str, Any] | None = None,
) -> int:
"""Get the GRIB ``jScansPositively`` scan-direction attribute.
Args:
domain_file: Path to the GRIB file to inspect.
filter_by_keys_dict: Dict of GRIB keys/values used to select which
message to read from the file.
Returns:
int: ``0`` if latitudes scan from North to South (the default
ECMWF convention), ``1`` if from South to North.
Raises:
CifValueError: If the attribute is not defined in the file.
"""
ds = GribDataset(
domain_file,
read_keys=["jScansPositively"],
filter_by_keys=filter_by_keys_dict,
)
try:
jscan = ds.get_attr("jScansPositively")
except KeyError as e:
raise CifValueError(
"When 'jScansPositively' is not defined, it is possible to force it "
"with the argument 'jScansPositively' of your Yml"
) from e
return jscan