"""Merge time-split NetCDF files with CDO.
The module groups matching input files, merges them along time, and can repeat
the merge within subfolders while preserving a top-level directory structure.
Large file lists are processed in chunks before creating the final output.
Authors
-------
- Simon Lüdke
"""
import logging
import math
import tempfile
from pathlib import Path
try:
from cdo import Cdo, CDOException
cdo = Cdo(returnNoneOnError=False)
except Exception:
cdo = None
from joblib import Parallel, delayed
from mhm_tools.common.logger import ErrorLogger, log_arguments
logger = logging.getLogger(__name__)
def _merge_chunk(files, out_path, options):
try:
res = cdo.mergetime(
input=" ".join(map(str, files)), output=str(out_path), options=options
)
except CDOException as e:
# keep the debugging gold, but raise a picklable error#
logger.error(f"merge to {out_path} failed")
msg = (
f"cdo mergetime failed (returncode={getattr(e, 'returncode', 'NA')})\n"
f"STDOUT:\n{getattr(e, 'stdout', '')}\n"
f"STDERR:\n{getattr(e, 'stderr', '')}"
)
with ErrorLogger(logger):
raise RuntimeError(msg) from e
if res is None:
logger.error(f"merge to {out_path} failed")
return ""
logger.debug(res)
return str(out_path)
[docs]
def merge_files_from_folder(
tmpdir, files, out_file, n_cpus, max_files=30, recursive_depth=0
):
"""Merge files from a folder in chunks, writing intermediates to tmpdir."""
# Chunk the inputs ~ evenly across workers
chunk_size = math.ceil(min(len(files) / n_cpus, max_files))
chunks = [files[i : i + chunk_size] for i in range(0, len(files), chunk_size)]
logger.info(
f"Found {len(files)} files in the folder. Merging them in chunks of {chunk_size}"
)
Path(tmpdir).mkdir(parents=True, exist_ok=True)
part_paths = [
Path(tmpdir) / f"part_{i:04d}_rec{recursive_depth}.nc"
for i in range(len(chunks))
]
# Merge each chunk in parallel; keep CDO single-threaded per task (-P 1)
n_jobs = min(n_cpus, len(chunks))
logger.info(f"Parallelizing it on {n_jobs} jobs.")
part_parts_merged = Parallel(n_jobs=n_jobs, backend="threading")(
delayed(_merge_chunk)(chunk, part_paths[i], options="-P 1 -O")
for i, chunk in enumerate(chunks)
)
part_parts_merged = [
Path(p) for p in part_parts_merged if p is not None and p != ""
]
# if len(part_parts_merged) != n_jobs:
# raise RuntimeError('mergtime failed probably OOM Error')
# Final merge of parts (now allow CDO to use n_cpus internally)
if len(part_parts_merged) > max_files:
logger.info(
f"Merged files {len(part_parts_merged)}/{max_files} increasing recursive depth to {recursive_depth+1}"
)
out_files = merge_files_from_folder(
tmpdir,
part_parts_merged,
out_file=out_file,
n_cpus=n_cpus,
max_files=max_files,
recursive_depth=recursive_depth + 1,
)
part_parts_merged = [
Path(p)
for p in out_files
if p is not None and p != "" and Path(p).is_file()
]
logger.debug(f"{recursive_depth} {len(out_files)} {len(part_parts_merged)}")
if len(out_files) != len(part_parts_merged):
logger.error(f"out_files {out_files}")
if recursive_depth > 0:
logger.info(
f"Reducing recusive depth to {recursive_depth-1} for {len(part_parts_merged)} files"
)
return part_parts_merged
logger.info(part_parts_merged)
if len(part_parts_merged) == 1:
logger.info("Only one file that can be moved.")
# same filesystem (tmp inside out_dir), so rename is atomic/fast
part_parts_merged[0].rename(out_file)
else:
logger.info(f"Merging {len(part_parts_merged)} files")
logger.info(" ".join(map(str, part_parts_merged)))
logger.info(str(out_file))
cdo.mergetime(
input=" ".join(map(str, part_parts_merged)),
output=str(out_file),
env={"SKIP_SAME_TIME": "1"},
options="-O",
)
return [str(out_file)]
[docs]
@log_arguments()
def merge_files(input_path, input_file_part, output, n_cpus, preserve_folders=False):
"""
Merge NetCDF files along time using CDO via its Python API.
- Assumes files share the same grid and DO NOT overlap in time.
- Parallelizes by merging chunks in parallel with joblib, then merges parts.
Args:
input_path (str): directory containing input files
input_file_part (str): glob pattern (e.g., "tavg_*.nc")
output_path (str): directory to write output
output_file_name (str): name of output NetCDF (e.g., "merged.nc")
n_cpus (int): number of parallel workers
preserve_folders (bool): Decides if the top level folder structure should be preserved.
Returns
-------
str: path to the merged file
"""
n_cpus = max(1, int(n_cpus)) if n_cpus is not None else 1
in_dir = Path(input_path)
output = Path(output)
if not output.suffix:
msg = "Output must specifiy a filename"
with ErrorLogger(logger):
raise ValueError(msg)
# subdir_files = {'.': }
files = sorted(in_dir.glob(input_file_part))
subdir_files = {".": sorted(in_dir.glob(input_file_part))} if files else {}
for f in in_dir.glob("*"):
if not f.is_dir():
continue
files = sorted(f.rglob(input_file_part))
if files:
subdir_files[f.name] = files
# all_files = [file for rel_path, file_list in subdir_files.items() for file in file_list]
sum_files = 0
out_files = []
for folder, file_list in subdir_files.items():
# Fast path: single file -> direct copy via CDO (keeps everything simple)
if not file_list or len(file_list) == 0:
continue
out_dir = output.parent / folder
out_dir.mkdir(parents=True, exist_ok=True)
out_file = out_dir / output.name
logger.info(f"Found {len(file_list)} files in {in_dir / folder}")
if len(file_list) == 1:
# cdo.copy(input=str(files[0]), output=str(out_file), options="-O")
if out_file.exists():
out_file.unlink()
out_file.symlink_to(file_list[0])
out_files.append(out_file)
elif len(file_list) > 1:
with tempfile.TemporaryDirectory(dir=out_file.parent) as tmpdir:
out_files.extend(
merge_files_from_folder(tmpdir, file_list, out_file, n_cpus)
)
sum_files += len(file_list)
if sum_files == 0:
msg = f"No files match {in_dir}/{input_file_part}"
with ErrorLogger(logger):
raise FileNotFoundError(msg)
if not preserve_folders:
with tempfile.TemporaryDirectory(dir=out_file.parent) as tmpdir:
final_merge = merge_files_from_folder(tmpdir, out_files, output, n_cpus)
logger.info(f"Merged a total of {sum_files} to: {final_merge}")
else:
logger.info(
f"Merged a total of {sum_files} into these {len(out_files)} files: {out_files}"
)