Source code for mhm_tools.pre.prepare_mhm_forcings

"""
Prepare NetCDF MHM forcing files.

This module provides functions to:
- Convert meteorological time series into the units expected by MHM
- Crop spatial fields to a user-defined region
- Write the pre-processed data out as CF-compliant NetCDF

Authors
-------
- Jeisson Leal
- Simon Lüdke
"""

import logging
from pathlib import Path
from typing import Optional, Union

import pandas as pd
import xarray as xr

from mhm_tools.common.file_handler import get_xarray_ds_from_file, write_xarray_to_file
from mhm_tools.common.logger import ErrorLogger, log_arguments
from mhm_tools.common.time_utils import resample_to_daily_or_hourly_adaptive
from mhm_tools.common.xarray_utils import crop_ds, get_single_data_var

logger = logging.getLogger(__name__)

# Define acceptable input unit lists for detection
TEMPERATURE_UNITS = [
    "K",
    "Kelvin",
    "kelvin",
    "C",
    "°C",
    "degC",
    "celsius",
    "F",
    "°F",
    "degF",
    "fahrenheit",
]
PRECIPITATION_UNITS = ["m", "kg m-2", "mm"]
PRECIPITATION_RATE_UNITS = ["kg m-2 s-1", "mm s-1", "mm d-1"]


def _normalize_unit_string(units: str) -> str:
    """Normalize common unit-string variants to canonical forms."""
    normalized = units.strip().lower().replace("**", "")
    return " ".join(normalized.split())


[docs] def convert_units(ds: Union[xr.Dataset, xr.DataArray], var: str) -> xr.DataArray: """Convert variable to standard units. Temperature variables are converted to degrees Celsius (degC), and precipitation variables are converted to millimeters (mm). """ logger.info(f"Converting units for variable '{var}'") logger.debug(f"Original dataset: {ds}") if isinstance(ds, xr.Dataset): if var not in ds: msg = f"Variable '{var}' not found in dataset." raise ValueError(msg) da = ds[var] else: da = ds units = da.attrs.get("units") if not units: msg = f"Variable '{var}' missing 'units' attribute." raise ValueError(msg) normalized_units = _normalize_unit_string(units) logger.info(f"units are: {units} (normalized: {normalized_units})") # Temperature if normalized_units in [_normalize_unit_string(u) for u in TEMPERATURE_UNITS]: if normalized_units in ["k", "kelvin"]: da = da - 273.15 elif normalized_units in ["f", "°f", "degf", "fahrenheit"]: da = (da - 32) * (5 / 9) da.attrs["units"] = "degC" # Total precipitation elif normalized_units in [_normalize_unit_string(u) for u in PRECIPITATION_UNITS]: if normalized_units in ["m", "kg m-2"]: da = da * 1000 da.attrs["units"] = "mm" # Precipitation rate elif normalized_units in [ _normalize_unit_string(u) for u in PRECIPITATION_RATE_UNITS ]: freq = pd.infer_freq(da.indexes["time"]) if not freq or not freq.startswith(("h", "D")): msg = ( f"Cannot infer frequency from time coordinate with freq={freq!r}. " f"Expected hourly or daily frequency." ) with ErrorLogger(logger): raise ValueError(msg) factor = 1.0 if "kg" in normalized_units and "s-1" in normalized_units: factor = ( 86400 if freq.startswith("D") else 3600 if freq.startswith("h") else 1 ) elif normalized_units == "mm d-1" and freq: factor = ( 1 if freq.startswith("D") else 1 / 24 if freq.startswith("h") else 1 ) elif normalized_units == "mm s-1": factor = ( 90000 if freq.startswith("D") else 3600 if freq.startswith("h") else 1 ) da = da * factor da.attrs["units"] = "mm" else: msg = f"Unexpected units '{units}' for variable '{var}'." raise ValueError(msg) mv = -9999.0 encoding = {"_FillValue": mv, "missing_value": mv} da.attrs.update({"_FillValue": mv, "missing_value": mv}) logger.info(f"Converted variable '{var}' with units {da.attrs['units']}") return da, encoding
[docs] @log_arguments("DEBUG") def prepare_forcings( in_dir: str, in_file: str, out_dir: str, out_file: str, var: Optional[str] = None, crop: bool = False, lon_min: Optional[float] = None, lon_max: Optional[float] = None, lat_min: Optional[float] = None, lat_max: Optional[float] = None, use_mfdataset: bool = False, target_frequency: Optional[str] = None, out_var: Optional[str] = None, ) -> None: """Loop through all files matching in_file in in_dir, convert units. Optionally crop, and write to NetCDF in out_dir with naming controlled by out_file. """ files = sorted(Path(in_dir).glob(in_file)) if not files: with ErrorLogger(logger): msg = f"No files match pattern {in_file!r} in directory {in_dir!r}" raise FileNotFoundError(msg) Path(out_dir).mkdir(parents=True, exist_ok=True) for path in files: # Load dataset ds = get_xarray_ds_from_file( file_path=str(path), use_mfdataset=use_mfdataset, normalize_latlon_coords=True, force_decending_y=True, ) if var is None: var = get_single_data_var(ds) # needs to be before unit conversion because that changes rates to quantities if target_frequency is not None: ds = resample_to_daily_or_hourly_adaptive( in_obj=ds, target=target_frequency, var=var ) # Convert units and get DataArray da, encoding = convert_units(ds, var) if out_var: da = da.rename(out_var) # Crop spatially if crop: if None in (lon_min, lon_max, lat_min, lat_max): with ErrorLogger(logger): msg = "All lon/lat bounds must be provided when crop=True." raise ValueError(msg) da = crop_ds(da, lon_min, lon_max, lat_min, lat_max) # Determine output name name = path.name if out_file == "*" else out_file # Write output logger.info(da) write_xarray_to_file( ds=da, file_path=Path(out_dir) / name ) # , encoding=encoding)