"""
Compute long-term means for NetCDF forcing data.
This script is designed for climate/hydrology forcing datasets that span
multiple timesteps (e.g., hourly, daily, monthly, yearly). It supports both
"intensive" variables (averaged over time, e.g., temperature) and "extensive"
variables (summed over time, e.g., precipitation).
The workflow includes:
- optional cropping to a spatial domain,
- optional temporal aggregation (hourly → daily, daily → monthly, etc.),
- merging multiple input files along the time dimension,
- computing the long-term mean (with optional masking thresholds),
- saving the result as a NetCDF file.
Intermediate files can be kept or automatically removed depending on options.
Authors
-------
- Jeisson Leal
"""
import logging
import re
import uuid
from pathlib import Path
from typing import Optional
try:
from cdo import Cdo
cdo = Cdo()
except Exception:
cdo = None
from mhm_tools.common.logger import ErrorLogger
from mhm_tools.pre.crop_mhm_setup import crop_file
logger = logging.getLogger(__name__)
# Shared mapping for CDO operations
OP_MAP = {
"hourly": ("hourmean", "hoursum"),
"daily": ("daymean", "daysum"),
"monthly": ("monmean", "monsum"),
"yearly": ("yearmean", "yearsum"),
}
def _summarize_inputs_for_log(files: list[Path]) -> str:
"""Return a compact summary for logging a file collection."""
if not files:
return "no files"
years = []
year_re = re.compile(r"^(\d{4})")
for file in files:
match = year_re.match(file.name)
if match:
years.append(int(match.group(1)))
if years:
return (
f"{len(files)} files (years {min(years)}-{max(years)}), "
f"sample: {files[0].name} ... {files[-1].name}"
)
return f"{len(files)} files, sample: {files[0].name} ... {files[-1].name}"
[docs]
def aggregate_files(
input_file: Path,
output_path: Path,
aggregation_type: str = "intensive",
long_term_mean_type: str = "monthly",
) -> None:
"""Aggregate a single NetCDF file into a coarser temporal resolution using CDO."""
if aggregation_type not in ("intensive", "extensive"):
msg = (
f"aggregation_type must be 'intensive' or 'extensive', "
f"got {aggregation_type!r}"
)
raise ValueError(msg)
if long_term_mean_type not in OP_MAP:
msg = (
f"Unsupported long_term_mean_type {long_term_mean_type!r}; "
f"choose from {list(OP_MAP)}"
)
raise ValueError(msg)
mean_op, sum_op = OP_MAP[long_term_mean_type]
cdo_op = mean_op if aggregation_type == "intensive" else sum_op
output_path.mkdir(parents=True, exist_ok=True)
if not input_file.exists():
msg = f"Input file not found: {input_file}"
raise FileNotFoundError(msg)
out_filename = f"{cdo_op}_{input_file.name}"
out_file = output_path / out_filename
cdo = Cdo()
try:
# Per-file aggregation can be numerous; keep this at debug level.
logger.debug(f"Running CDO {cdo_op} on {input_file} → {out_file}")
getattr(cdo, cdo_op)(input=str(input_file), output=str(out_file))
except Exception as e:
msg = f"CDO operation '{cdo_op}' failed on file '{input_file}': {e}"
with ErrorLogger(logger):
raise RuntimeError(msg) from e
[docs]
def cal_long_term_mean( # noqa: PLR0912, PLR0915
in_dir: str,
in_file: str,
out_dir: str,
long_term_mean_type: str = "monthly",
aggregation_type: str = "intensive",
keep_temporal_files: bool = False,
out_file: 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,
aggregate: bool = False,
lower_threshold: Optional[str] = None,
) -> None:
"""Compute long-term means for NetCDF forcing data.
Optionally perform temporal aggregation and then merge, or merge raw inputs first.
If only one file matches `in_file`, skip the CDO mergetime step.
"""
p_in_dir = Path(in_dir)
files = sorted(p_in_dir.glob(in_file))
if not files:
msg = f"No files match pattern {p_in_dir / in_file}"
with ErrorLogger(logger):
raise FileNotFoundError(msg)
# Track optional temporary directories for safe cleanup.
crop_dir = None
aggregation_dir = None
# If cropping is requested, verify bounds
if crop and None in (lon_min, lon_max, lat_min, lat_max):
msg = "All lon/lat bounds must be provided when --crop is used."
with ErrorLogger(logger):
raise ValueError(msg)
# Prepare output directory
p_out_dir = Path(out_dir)
p_out_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"Matched {_summarize_inputs_for_log(files)} using pattern '{in_file}'")
# If cropping, produce cropped files into a subfolder and use that as input
if crop:
# Unique temp folder avoids collisions across concurrent/repeated runs.
crop_dir = p_out_dir / f"cropped_files_{uuid.uuid4().hex}"
crop_dir.mkdir(parents=True, exist_ok=True)
# Note: flipping lat_min/lat_max because slice(lat_max, lat_min) often needed
lonslice = slice(lon_min, lon_max)
latslice = slice(lat_max, lat_min)
for fpath in files:
crop_file(
input_path=p_in_dir,
input_file=fpath,
output_path=crop_dir,
mask_da=False,
overwrite=True,
available_mem_gib=None,
latslice=latslice,
lonslice=lonslice,
)
# p_input_dir = crop_dir
# After cropping, map to flattened cropped outputs by file name.
files = sorted((crop_dir / fpath.name) for fpath in files)
# else:
# p_input_dir = p_in_dir
cdo = Cdo()
# If aggregation is requested, aggregate each file individually first
if aggregate:
if long_term_mean_type not in OP_MAP:
msg = (
f"Unsupported long_term_mean_type {long_term_mean_type!r}; "
f"choose from {list(OP_MAP)}"
)
raise ValueError(msg)
if aggregation_type not in ("intensive", "extensive"):
msg = (
f"aggregation_type must be 'intensive' or 'extensive', "
f"got {aggregation_type!r}"
)
raise ValueError(msg)
mean_op, sum_op = OP_MAP[long_term_mean_type]
cdo_op = mean_op if aggregation_type == "intensive" else sum_op
# Unique temp folder avoids collisions across concurrent/repeated runs.
aggregation_dir = p_out_dir / f"aggregated_files_{uuid.uuid4().hex}"
aggregation_dir.mkdir(parents=True, exist_ok=True)
logger.info(
f"Aggregating {_summarize_inputs_for_log(files)} with CDO operator '{cdo_op}'."
)
# Run `aggregate_files` on each matched input
for fpath in files:
aggregate_files(
input_file=fpath,
output_path=aggregation_dir,
aggregation_type=aggregation_type,
long_term_mean_type=long_term_mean_type,
)
merge_candidates = sorted(aggregation_dir.glob(f"{cdo_op}_*.nc"))
else:
# No aggregation: merge (or skip merging) the already matched raw inputs
merge_candidates = files
if not merge_candidates:
msg = f"No files available for merging from pattern {p_in_dir / in_file}"
with ErrorLogger(logger):
raise FileNotFoundError(msg)
# Determine whether to run `mergetime` or skip if there's only one file
if len(merge_candidates) == 1:
# Only a single file => skip `mergetime`
single_file = merge_candidates[0]
tmp_merge = single_file
logger.info(f"Single file '{single_file.name}' found; skipping mergetime.")
else:
# Multiple files => run `mergetime` to concatenate along time dimension.
# Use a unique temp name so concurrent/repeated runs in the same output
# folder never collide.
tmp_merge = p_out_dir / f"mergetime_{uuid.uuid4().hex}.nc"
try:
input_list_str = " ".join(str(p) for p in merge_candidates)
logger.info(
f"Running CDO mergetime on {_summarize_inputs_for_log(merge_candidates)} → {tmp_merge}"
)
cdo.mergetime(input=input_list_str, output=str(tmp_merge))
except Exception as e:
msg = f"CDO mergetime failed: {e}"
with ErrorLogger(logger):
raise RuntimeError(msg) from e
# Finally, compute the time mean on tmp_merge
final_name = out_file or "long_term_mean.nc"
final_path = p_out_dir / final_name
try:
if lower_threshold is not None:
logger.info(f"{lower_threshold=} selected.")
logger.info(f"Calculating CDO timmean above {lower_threshold=}.")
cdo.timmean(
input=f"-setrtomiss,-1e20,{lower_threshold} {tmp_merge}",
output=str(final_path),
)
else:
logger.info(f"Running CDO timmean on {tmp_merge} → {final_path}")
cdo.timmean(input=str(tmp_merge), output=str(final_path))
except Exception as e:
msg = f"CDO timmean failed: {e}"
with ErrorLogger(logger):
raise RuntimeError(msg) from e
# Remove intermediate files if requested
if not keep_temporal_files:
# Remove cropped files
if crop and crop_dir is not None:
for f in crop_dir.glob("*"):
f.unlink()
crop_dir.rmdir()
# Remove aggregated files
if aggregate and aggregation_dir is not None:
for f in aggregation_dir.glob("*"):
f.unlink()
aggregation_dir.rmdir()
# If mergetime was run, remove its output
if isinstance(tmp_merge, Path) and tmp_merge.name.startswith("mergetime"):
tmp_merge.unlink()