"""Evaluate gridded model data against gridded reference data.
The module loads one or more input and reference datasets, crops or masks them
to a target domain, computes direct or bootstrap comparison metrics, and writes
spatial diagnostics, seasonal summaries, and result tables.
Authors
-------
- Simon Lüdke
"""
import array
import logging
import random
import re
from pathlib import Path
from typing import Iterable, Tuple
import matplotlib.pyplot as plt
import numpy as np
import xarray as xr
from joblib import Parallel, delayed
from matplotlib.colors import BoundaryNorm
from mpl_toolkits.axes_grid1 import make_axes_locatable
from mhm_tools.common.file_handler import (
ChunkType,
get_coord_values,
get_dataset_from_path,
get_xarray_ds_from_file,
write_xarray_to_file,
)
from mhm_tools.common.logger import ErrorLogger, log_arguments, log_errors
from mhm_tools.common.metrics.metrics_handler import create_results_csv
from mhm_tools.common.netcdf import generate_bounds_for_all_coords
from mhm_tools.common.resolution_handler import Resolution, get_file_res
from mhm_tools.common.utils import cut_to_filled_area
from mhm_tools.common.xarray_utils import (
crop_ds,
get_clim_from_ds,
get_coord_key,
get_ds_extend,
get_overlapping_time_slice,
spearman_correlation,
timedelta_to_alias,
)
logger = logging.getLogger(__name__)
class EvalDataset:
"""Dataset containing all the necessary information about a evaluation dataset."""
path = None
name = None
var = None
factor = 1
file_name = "*.*"
def __init__(self, path, name, var, factor, file_name):
self.path = Path(path) if path is not None else None
self.name = name
self.var = var
self.factor = factor
self.file_name = file_name
def spearman_spatial(data1, data2):
"""Calculate pixel-wise Spearman correlation maps for two DataArrays.
Both inputs must have shape (12, Y, X): one climatology value per month.
"""
if len(np.shape(data1)) != len(np.shape(data2)) or len(np.shape(data1)) != 3:
with ErrorLogger(logger):
msg = "Wrong shape for spatial spearman correlation!"
raise ValueError(msg)
res = np.full(np.shape(data1[0]), np.nan)
pval = np.full(np.shape(data1[0]), np.nan)
for i, row in enumerate(data1[0]):
for j, _col in enumerate(row):
sp_corr, sp_pval = spearman_correlation(data1[:, i, j], data2[:, i, j])
res[i, j] = sp_corr
pval[i, j] = sp_pval
return res, pval
def spearman_spatial_joblib(
data1: np.ndarray, data2: np.ndarray, spearman_correlation, n_jobs: int = -1
) -> Tuple[np.ndarray, np.ndarray]:
"""Parallel pixel-wise Spearman correlation over two arrays of shape (T, Y, X).
Parameters
----------
data1, data2 : ndarray, shape (T, Y, X)
The two time-series stacks to correlate.
spearman_correlation : Callable
A function f(a: 1D, b: 1D) -> (rho, pval).
n_jobs : int
Number of parallel workers (-1 = all CPUs).
Returns
-------
res : ndarray, shape (Y, X)
Spearman rho for each pixel.
pval : ndarray, shape (Y, X)
Two-tailed p-value for each pixel.
"""
# materialize once to avoid expensive lazy per-pixel loads
data1 = np.asarray(data1)
data2 = np.asarray(data2)
# get spatial shape
_, ny, nx = data1.shape
# pre-allocate outputs
res = np.full((ny, nx), np.nan, dtype=np.float32)
pval = np.full((ny, nx), np.nan, dtype=np.float32)
# list of all pixel indices
indices = [(i, j) for i in range(ny) for j in range(nx)]
# worker for a single pixel
def _worker(i, j):
rho, p = spearman_correlation(data1[:, i, j], data2[:, i, j])
return i, j, rho, p
if n_jobs == 1:
for i, j in indices:
rho, p = spearman_correlation(data1[:, i, j], data2[:, i, j])
res[i, j] = rho
pval[i, j] = p
return res, pval
# dispatch in parallel
results = Parallel(n_jobs=n_jobs)(delayed(_worker)(i, j) for i, j in indices)
# scatter results back
for i, j, rho, p in results:
res[i, j] = rho
pval[i, j] = p
return res, pval
def crop_datasets_to_spatial_overlap(input_ds, ref_ds):
"""Crop input and reference datasets to their common spatial overlap."""
input_lon_min, input_lon_max, input_lat_min, input_lat_max = get_ds_extend(input_ds)
ref_lon_min, ref_lon_max, ref_lat_min, ref_lat_max = get_ds_extend(ref_ds)
overlap_lon_min = max(input_lon_min, ref_lon_min)
overlap_lon_max = min(input_lon_max, ref_lon_max)
overlap_lat_min = max(input_lat_min, ref_lat_min)
overlap_lat_max = min(input_lat_max, ref_lat_max)
if overlap_lon_min > overlap_lon_max or overlap_lat_min > overlap_lat_max:
msg = (
"Input and reference datasets do not share a common spatial overlap; "
"cannot compare them."
)
with ErrorLogger(logger):
raise ValueError(msg)
cropped_input = crop_ds(
input_ds,
overlap_lon_min,
overlap_lon_max,
overlap_lat_min,
overlap_lat_max,
)
cropped_ref = crop_ds(
ref_ds,
overlap_lon_min,
overlap_lon_max,
overlap_lat_min,
overlap_lat_max,
)
input_cropped = (
cropped_input["lat"].size != input_ds["lat"].size
or cropped_input["lon"].size != input_ds["lon"].size
)
ref_cropped = (
cropped_ref["lat"].size != ref_ds["lat"].size
or cropped_ref["lon"].size != ref_ds["lon"].size
)
input_bounds_match_overlap = (
np.isclose(input_lon_min, overlap_lon_min)
and np.isclose(input_lon_max, overlap_lon_max)
and np.isclose(input_lat_min, overlap_lat_min)
and np.isclose(input_lat_max, overlap_lat_max)
)
ref_bounds_match_overlap = (
np.isclose(ref_lon_min, overlap_lon_min)
and np.isclose(ref_lon_max, overlap_lon_max)
and np.isclose(ref_lat_min, overlap_lat_min)
and np.isclose(ref_lat_max, overlap_lat_max)
)
if input_cropped or ref_cropped:
if input_bounds_match_overlap and not ref_bounds_match_overlap:
logger.info(
"Input spatial extent is a subset of reference; cropping reference to the common overlap."
)
elif ref_bounds_match_overlap and not input_bounds_match_overlap:
logger.warning(
"Reference spatial extent is a subset of input; cropping input to the common overlap."
)
else:
logger.warning(
"Cropping input and reference datasets to their common overlapping spatial extent."
)
return cropped_input, cropped_ref
def get_std_from_ds(ds, input_var=None, clim=None, factor=1):
"""Calculate maps of temporal standard deviation from an DataArray.
If a climatology is provided the timeseries can be detrended by
seasonality.
"""
# Retrieve data and apply factor
data = ds * factor if input_var is None else ds[input_var] * factor
# Subtract climatology for each month
if clim is None:
data_reduced = data.groupby("time.month") - clim
std = data_reduced.std(dim="time", skipna=True)
else:
std = data.std(dim="time", skipna=True)
# Return as DataArray with appropriate coordinates
if type(std) is array and len(std.shape) == 2:
return xr.DataArray(
std,
coords={
"lat": get_coord_values(ds, lat=True),
"lon": get_coord_values(ds, lon=True),
},
dims=["lat", "lon"],
)
return std
def get_file_stats(
ds_in,
input_var,
factor=1,
coordinate_slice=None,
output_path=None,
avaiable_years=None,
direct_comp=False,
):
"""Get statistics for one file."""
# logger.debug(f"Get file stats {file}")
# Apply coordinate slicing if needed
logger.debug(f"before cropping the file {ds_in}")
lat_key = get_coord_key(ds_in, lat=True)
lon_key = get_coord_key(ds_in, lon=True)
# make sure that latitude order is from highest to lowest value
if ds_in[lat_key].shape[0] > 1 and ds_in[lat_key][1] > ds_in[lat_key][0]:
ds_croped = ds_in.isel(lat=slice(None, None, -1))
else:
ds_croped = ds_in
if coordinate_slice is not None:
ds_croped = ds_croped.sel(
{lat_key: coordinate_slice["lat"], lon_key: coordinate_slice["lon"]}
)
if avaiable_years is not None:
ds_croped = ds_croped.sel(time=ds_croped.time.dt.year.isin(avaiable_years))
logger.debug(
f"After cropping/year selection {input_var} is all nan: "
f"{bool(ds_croped[input_var].isnull().all().compute().item())}; "
f"shape={ds_croped[input_var].shape}"
)
# Calculate climatology and standard deviation along the time dimension
clim = get_clim_from_ds(ds_croped, input_var, factor)
std = get_std_from_ds(ds_croped, input_var, clim, factor)
mean = ds_croped[input_var].mean(dim="time", skipna=True) * factor
logger.debug(
f"After stats calculation {input_var} is all nan: "
f"clim={bool(clim.isnull().all().compute().item())}, "
f"std={bool(std.isnull().all().compute().item())}, "
f"mean={bool(mean.isnull().all().compute().item())}"
)
# Construct the output dataset with lazy evaluations
output = xr.Dataset(
{"clim": clim, "std": std, "mean": mean},
coords={
"month": np.arange(1, 13, 1),
"lat": get_coord_values(ds_croped, lat=True),
"lon": get_coord_values(ds_croped, lon=True),
},
)
output = generate_bounds_for_all_coords(output)
if direct_comp:
ts = ds_croped[input_var] * factor
ts.name = "time_series"
output = xr.merge([output, ts])
if output_path is not None:
write_xarray_to_file(ds=output, file_path=output_path)
return output
def _spatial_resolution(obj, lon_key, lat_key):
"""Return the absolute spatial resolution for a dataset or data array."""
return abs(
get_file_res(lon=obj[lon_key], lat=obj[lat_key], resolutions=Resolution())
)
def _first_2d_spatial_slice(mask_da, lat_key, lon_key):
"""Reduce non-spatial mask dimensions by selecting the first slice."""
mask_2d = mask_da
extra_dims = [d for d in mask_2d.dims if d not in [lat_key, lon_key]]
for dim in extra_dims:
mask_2d = mask_2d.isel({dim: 0}, drop=True)
return mask_2d
def _select_mask_for_grid(mask_ds, target_ds, mask_var=None):
"""Select the mask variable matching the target grid resolution."""
lat_key_ds = get_coord_key(target_ds, lat=True)
lon_key_ds = get_coord_key(target_ds, lon=True)
target_res = _spatial_resolution(target_ds, lon_key_ds, lat_key_ds)
if mask_var is not None:
if mask_var not in mask_ds.data_vars:
msg = f"Mask variable {mask_var!r} not found in mask dataset."
with ErrorLogger(logger):
raise ValueError(msg)
mask_da = mask_ds[mask_var]
lat_key_mask = get_coord_key(mask_da, lat=True)
lon_key_mask = get_coord_key(mask_da, lon=True)
mask_res = _spatial_resolution(mask_da, lon_key_mask, lat_key_mask)
logger.info(
f"Using explicitly selected mask variable {mask_var!r} "
f"with resolution {mask_res} for target resolution {target_res}."
)
return (
_first_2d_spatial_slice(mask_da, lat_key_mask, lon_key_mask),
mask_var,
lat_key_mask,
lon_key_mask,
mask_res,
target_res,
)
exact_match = None
finer_matches = []
coarser_matches = []
for var_name in mask_ds.data_vars:
mask_da = mask_ds[var_name]
try:
lat_key_mask = get_coord_key(mask_da, lat=True)
lon_key_mask = get_coord_key(mask_da, lon=True)
mask_res = _spatial_resolution(mask_da, lon_key_mask, lat_key_mask)
except Exception:
logger.debug(
f"Skipping mask variable {var_name!r}; no usable spatial resolution.",
exc_info=True,
)
continue
candidate = (
mask_da,
var_name,
lat_key_mask,
lon_key_mask,
mask_res,
target_res,
)
if abs(mask_res - target_res) <= 1e-5:
exact_match = candidate
break
if mask_res < target_res:
finer_matches.append(candidate)
else:
coarser_matches.append(candidate)
selected = exact_match
if selected is None and finer_matches:
selected = min(finer_matches, key=lambda item: item[4])
if selected is None:
available = [item[4] for item in coarser_matches]
msg = (
"Could not select a mask variable for gridded data evaluation: "
f"target resolution is {target_res}, and only coarser mask "
f"resolutions are available: {available}."
)
with ErrorLogger(logger):
raise ValueError(msg)
mask_da, var_name, lat_key_mask, lon_key_mask, mask_res, target_res = selected
logger.info(
f"Selected mask variable {var_name!r} with resolution {mask_res} "
f"for target resolution {target_res}."
)
return (
_first_2d_spatial_slice(mask_da, lat_key_mask, lon_key_mask),
var_name,
lat_key_mask,
lon_key_mask,
mask_res,
target_res,
)
def apply_spatial_mask(ds, mask_da, mask_var=None):
"""Mask all spatial data variables in a dataset with a spatial mask."""
if mask_da is None:
return ds
lat_key_ds = get_coord_key(ds, lat=True)
lon_key_ds = get_coord_key(ds, lon=True)
if isinstance(mask_da, xr.Dataset):
(
mask_2d,
selected_mask_var,
lat_key_mask,
lon_key_mask,
mask_res,
target_res,
) = _select_mask_for_grid(mask_da, ds, mask_var=mask_var)
from mhm_tools.pre.crop_mhm_setup import regrid_mask
mask_on_ds = regrid_mask(
mask_ds=mask_2d,
lon_key_mask=lon_key_mask,
lat_key_mask=lat_key_mask,
target_lon=ds[lon_key_ds].data,
target_lat=ds[lat_key_ds].data,
lon_key_target=lon_key_ds,
lat_key_target=lat_key_ds,
target_res=target_res,
mask_res=mask_res,
)
logger.debug(
f"Applied mask variable {selected_mask_var!r} to target grid "
f"with shape {mask_on_ds.shape}."
)
else:
lat_key_mask = get_coord_key(mask_da, lat=True)
lon_key_mask = get_coord_key(mask_da, lon=True)
mask_2d = _first_2d_spatial_slice(mask_da, lat_key_mask, lon_key_mask)
if (
mask_2d.sizes.get(lat_key_mask, 0) == 0
or mask_2d.sizes.get(lon_key_mask, 0) == 0
):
return ds
target_coords = {
lat_key_mask: ds[lat_key_ds].values,
lon_key_mask: ds[lon_key_ds].values,
}
mask_on_ds = mask_2d.interp(target_coords, method="nearest")
mask_on_ds = mask_on_ds.rename(
{lat_key_mask: lat_key_ds, lon_key_mask: lon_key_ds}
)
valid_mask = np.isfinite(mask_on_ds) & (mask_on_ds > 0)
logger.info(
f"Spatial mask has {int(np.count_nonzero(valid_mask.values))} valid cells "
f"on target grid with shape {valid_mask.shape}."
)
if not np.any(valid_mask.values):
msg = (
"Spatial mask does not select any valid cells on the target grid. "
"Check the mask extent, selected mask variable, and target dataset "
"resolution."
)
with ErrorLogger(logger):
raise ValueError(msg)
logger.debug(
f"Mask after regrid is all nan: "
f"{bool(mask_on_ds.isnull().all().compute().item())}; "
f"valid cells={int(np.count_nonzero(valid_mask.values))}"
)
logger.debug(
f"Before spatial mask stats are all nan: "
f"clim={bool(ds['clim'].isnull().all().compute().item()) if 'clim' in ds else None}, "
f"std={bool(ds['std'].isnull().all().compute().item()) if 'std' in ds else None}, "
f"mean={bool(ds['mean'].isnull().all().compute().item()) if 'mean' in ds else None}"
)
out = ds.copy()
for var_name in out.data_vars:
da = out[var_name]
if lat_key_ds in da.dims and lon_key_ds in da.dims:
out[var_name] = da.where(valid_mask)
logger.debug(
f"After spatial mask stats are all nan: "
f"clim={bool(out['clim'].isnull().all().compute().item()) if 'clim' in out else None}, "
f"std={bool(out['std'].isnull().all().compute().item()) if 'std' in out else None}, "
f"mean={bool(out['mean'].isnull().all().compute().item()) if 'mean' in out else None}"
)
# Crop to the bounding box of the filled mask area.
mask_np = np.asarray(valid_mask.values)
if np.any(mask_np):
l0_resolution = None
if out[lon_key_ds].size > 1:
l0_resolution = float(
np.abs(out[lon_key_ds].values[1] - out[lon_key_ds].values[0])
)
resolutions = Resolution(l0_resolution=l0_resolution)
cut_ds = xr.Dataset(coords={"lat": out[lat_key_ds], "lon": out[lon_key_ds]})
buffer = min(5, cut_ds[lon_key_ds].size // 20, cut_ds[lat_key_ds].size // 20)
lat_slice_idx, lon_slice_idx = cut_to_filled_area(
ds=cut_ds,
resolutions=resolutions,
catchment_mask=mask_np,
buffer=buffer,
)
lat_slice_idx = slice(
lat_slice_idx.start, min(lat_slice_idx.stop + 1, out[lat_key_ds].size)
)
lon_slice_idx = slice(
lon_slice_idx.start, min(lon_slice_idx.stop + 1, out[lon_key_ds].size)
)
out = out.isel({lat_key_ds: lat_slice_idx, lon_key_ds: lon_slice_idx})
logger.debug(
f"After mask crop stats are all nan: "
f"clim={bool(out['clim'].isnull().all().compute().item()) if 'clim' in out else None}, "
f"std={bool(out['std'].isnull().all().compute().item()) if 'std' in out else None}, "
f"mean={bool(out['mean'].isnull().all().compute().item()) if 'mean' in out else None}; "
f"shape={out.sizes}"
)
return out
def get_files(path, n_bootstrap_years=None, available_years=None, file_name="*.*"):
"""Recursevely find all netcdf files in directory."""
nc_files = []
# Search for .nc files at each depth level
if len(year_structure_paths(path, file_name=file_name)) > 0:
all_years = get_years_from_path(path, file_name=file_name)
if available_years is not None:
selectable_years = [y for y in all_years if y in available_years]
else:
selectable_years = all_years
logger.debug(
f"selectable years are {selectable_years} - n_bootstrap_years is {n_bootstrap_years}"
)
if n_bootstrap_years is not None:
# needs fixed folder structure of y/m/file
selectable_years = random.choices(selectable_years, k=n_bootstrap_years)
for year in selectable_years:
folder_path = path / str(year)
nc_files.extend(list(folder_path.rglob(file_name)))
else:
nc_files = list(path.rglob(file_name))
return nc_files
def combine_results(results):
"""Combine the statistics calculated for subsets into one."""
total_count = sum(count for _, _, count, _, _ in results)
if total_count == 0:
msg = "Total count of number of results is 0"
with ErrorLogger(logger):
raise ValueError(msg)
total_mean = sum(mean * count for mean, _, count, _, _ in results) / total_count
total_M2 = sum(M2 for _, M2, _, _, _ in results)
total_M2 += sum(
count * (mean - total_mean) ** 2 for mean, _, count, _, _ in results
)
monthly_sums = sum(ms for _, _, _, ms, _ in results)
monthly_counts = sum(mc for _, _, _, _, mc in results)
return total_mean, total_M2, total_count, monthly_sums, monthly_counts
def get_stats_one_pass_subset(files, input_var, factor=1, coordinate_slice=None):
"""Compute running statistics from a list of monthly files.
Iterates through NetCDF files (each containing one month's data for
`input_var`), optionally applies a spatial slice, multiplies by `factor`,
and updates running aggregates.
Returns
-------
mean : ndarray
Running mean over all time steps.
sum_square_diff : ndarray
Sum of squared deviations (for variance via Welford's algorithm).
count : int
Number of time steps processed.
monthly_sums : ndarray
Sum per calendar month, shape (12, ...).
monthly_counts : ndarray
Valid-count per calendar month, shape (12, ...).
"""
da = None
if not isinstance(files, Iterable):
# logger.warning(f"Files not a list of files but one file {files}.")
files = [files]
files = list(files)
if len(files) == 0:
msg = "Received an empty file subset for one-pass statistics."
with ErrorLogger(logger):
raise ValueError(msg)
logger.debug(files)
with get_xarray_ds_from_file(
files[0], engine="netcdf4", force_decending_y=True
) as ds:
# Apply coordinate slicing if needed
if coordinate_slice is not None:
lat_key = get_coord_key(ds, lat=True)
lon_key = get_coord_key(ds, lon=True)
da = ds.sel(
{lat_key: coordinate_slice["lat"], lon_key: coordinate_slice["lon"]}
)[input_var]
else:
da = ds[input_var]
count = 0 # xr.DataArray(np.ones(mean.shape, dtype=int).copy(), coords=mean.coords, dims=mean.dims).expand_dims(dim='time', axis=0)
mean = np.zeros(da.shape[1:])
sum_square_diff = np.zeros(da.shape[1:])
monthly_sums = np.zeros((12, *da.shape[1:]))
monthly_counts = np.zeros((12, *da.shape[1:]))
for f, file in enumerate(files):
with get_xarray_ds_from_file(
file, engine="netcdf4", force_decending_y=True
) as ds:
logger.info(f"timestep {count} in file {f+1} / {len(files)} from {file}")
if coordinate_slice is not None:
lat_key = get_coord_key(ds, lat=True)
lon_key = get_coord_key(ds, lon=True)
da = ds.sel(
{lat_key: coordinate_slice["lat"], lon_key: coordinate_slice["lon"]}
)[input_var]
else:
da = ds[input_var]
for _time_value, sub in da.groupby("time", squeeze=False):
data_slice = sub.isel(time=0)
try:
data_values = data_slice.values * factor
# logger.debug(f"{count} - {np.shape(data_values)}")
count += 1
delta = data_values - mean
mean += delta / count
delta2 = data_values - mean
sum_square_diff += delta * delta2
# climatology
month = int(data_slice.time.dt.month.item()) - 1
monthly_sums[month] += data_slice.fillna(0).values * factor
monthly_counts[month] += ~np.isnan(data_slice.values)
except Exception as e:
logger.error(data_slice)
with ErrorLogger(logger):
raise e
logger.debug(
f"{np.nanmean(mean)}, {np.nanmean(sum_square_diff)}, {count}, {np.nanmean(monthly_sums)}, {np.nanmean(monthly_counts)}"
)
return mean, sum_square_diff, count, monthly_sums, monthly_counts
def split_file_list(file_list, n_processes):
"""Split a list into sublists."""
file_list = list(file_list)
if n_processes > 1:
return [
subset
for subset in (file_list[i::n_processes] for i in range(n_processes))
if subset
]
return file_list
def get_stats_one_pass(
path,
var,
factor=1,
coordinate_slice=None,
ncpus=1,
n_bootstrap_years=None,
bootstrap_index=None,
output_path=None,
available_years=None,
file_name="*.*",
):
"""Compute streaming statistics from monthly/yearly files.
Reads one file at a time and updates running aggregates to produce mean,
standard deviation, and monthly climatology. Optionally slices coordinates,
applies a multiplicative factor, supports bootstrapping over years, and can
write the result to disk.
"""
files = []
if path.is_dir():
files = get_files(
path,
n_bootstrap_years=n_bootstrap_years,
available_years=available_years,
file_name=file_name,
)
elif path.is_file():
files = [path]
if len(files) == 0:
msg = (
f"No input files found for statistics at {path} "
f"(file pattern: {file_name}, years: {available_years})."
)
with ErrorLogger(logger):
raise FileNotFoundError(msg)
logger.debug(f"List of files: {files}")
file_subsets = split_file_list(files, ncpus) if ncpus > 1 else [files]
logger.info("creating statistics one pass...")
subset_results = Parallel(n_jobs=ncpus, backend="loky")(
delayed(get_stats_one_pass_subset)(file_subset, var, factor, coordinate_slice)
for file_subset in file_subsets
)
logger.info("combining results...")
mean, sum_square_diff, count, monthly_sums, monthly_counts = combine_results(
subset_results
)
logger.debug(
f"{mean.mean()}, {sum_square_diff.mean()}, {count}, {monthly_sums.mean()}, {monthly_counts.mean()}"
)
variance = sum_square_diff / (count - 1)
std_dev = np.sqrt(variance)
monthly_sums = np.where(monthly_counts > 0, monthly_sums, np.nan)
monthly_counts = np.where(monthly_counts > 0, monthly_counts, np.nan)
climatology = monthly_sums / monthly_counts
climatology = np.where(monthly_counts > 0, climatology, np.nan)
with get_xarray_ds_from_file(
files[0], engine="netcdf4", force_decending_y=True
) as ds_in:
lat_key = get_coord_key(ds_in, lat=True)
lon_key = get_coord_key(ds_in, lon=True)
# Apply coordinate slicing if needed
ds = (
ds_in.sel(
{lat_key: coordinate_slice["lat"], lon_key: coordinate_slice["lon"]}
)
if coordinate_slice is not None
else ds_in
)
lat = get_coord_values(ds, lat=True)
lon = get_coord_values(ds, lon=True)
# Calculate climatology and standard deviation along the time dimension
# Construct the output dataset with lazy evaluations
# climatology = climatology.rename({get_coord_key(climatology, lat=True): "lat", get_coord_key(climatology, lon=True): "lon"})
std = xr.DataArray(std_dev, coords={"lat": lat, "lon": lon}, dims=["lat", "lon"])
clim = xr.DataArray(
climatology,
coords={"month": np.arange(1, 13, 1), "lat": lat, "lon": lon},
dims=["month", "lat", "lon"],
)
mean = xr.DataArray(mean, coords={"lat": lat, "lon": lon}, dims=["lat", "lon"])
output = xr.Dataset(
{"clim": clim, "std": std, "mean": mean},
coords={"month": np.arange(1, 13, 1), "lat": lat, "lon": lon},
)
output = generate_bounds_for_all_coords(output)
# Trigger computation if needed
if output_path is not None:
output_file = (
output_path.parent / f"{output_path.stem}_{bootstrap_index}.nc"
if bootstrap_index is not None
else output_path
)
logger.info(f"Writing output to {output_file}")
write_xarray_to_file(ds=output, file_path=output_file)
return output
def plot_single_map(
ax,
values,
diff_to_mean=None,
center=1,
vmin=0,
vmax=1,
cmap=plt.cm.coolwarm_r,
# cmap = plt.cm.RdBu,
bounds_type="fixed",
):
"""Plot a single map on a Matplotlib Axes.
Handles bounds and colormap selection. Behavior by `bounds_type`:
- "max": set vmin=1 - diff_to_mean and vmax=1 + diff_to_mean
- "quantiles": set vmin/vmax to the 5th/95th percentiles of `values`
- "fixed": set vmin=0.5 and vmax=1.5
Returns
-------
im : AxesImage
The image artist.
bounds : ndarray
Bin edges used by BoundaryNorm.
extent : {"neither", "min", "max", "both"}
Whether data extend beyond bounds.
ticks : ndarray
Tick centers for colorbar labels (every second bin center).
"""
def _step_decimals(step):
step_abs = abs(float(step))
if step_abs == 0:
return 0
decimals = int(max(0, -np.floor(np.log10(step_abs))))
if not np.isclose(step_abs * (10**decimals), round(step_abs * (10**decimals))):
decimals += 1
return min(decimals, 6)
if bounds_type == "max" and diff_to_mean is not None:
vmin = center - diff_to_mean
vmax = center + diff_to_mean
if bounds_type == "quantiles":
vmin, vmax = (
np.nanquantile(values, 0.05),
np.nanquantile(values, 0.95),
)
if abs(vmax - vmin) < abs(vmax / 3) or vmin == vmax:
vmin, vmax = (float(np.nanmin(values)), float(np.nanmax(values)))
if abs(vmax - vmin) < abs(vmax / 3) or vmin == vmax:
vmin, vmax = (
vmin - abs(vmin / 3),
vmax + abs(vmax / 3),
)
if bounds_type == "fixed":
# vmin, vmax = 0.5, 1.5
vmin, vmax = center - 0.5625, center + 0.5625
values_np = np.asarray(values)
if not np.isfinite(vmin) or not np.isfinite(vmax):
finite_values = values_np[np.isfinite(values_np)]
if finite_values.size == 0:
vmin, vmax = 0.0, 1.0
else:
vmin, vmax = float(np.nanmin(finite_values)), float(
np.nanmax(finite_values)
)
if np.isclose(vmax, vmin):
delta = max(abs(vmax), 1.0) * 0.5
vmin, vmax = vmin - delta, vmax + delta
target_bins = 9
step = (vmax - vmin) / max(target_bins, 1)
if not np.isfinite(step) or step <= 0:
step = 1.0
tick_anchor = center if bounds_type in {"fixed", "max"} else 0.0
if not (vmin <= tick_anchor <= vmax):
tick_anchor = 0.0 if (vmin < 0 < vmax) else vmin
centers = np.linspace(vmin + step / 2, vmax - step / 2, target_bins)
if vmin <= tick_anchor <= vmax and centers.size > 0:
anchor_idx = int(np.argmin(np.abs(centers - tick_anchor)))
centers = centers + (tick_anchor - centers[anchor_idx])
bounds = np.concatenate(
(
[centers[0] - step / 2],
0.5 * (centers[:-1] + centers[1:]),
[centers[-1] + step / 2],
)
)
if centers.size <= 2:
ticks = centers
else:
anchor_idx = np.where(np.isclose(centers, tick_anchor))[0]
start_idx = int(anchor_idx[0] % 2) if anchor_idx.size else 0
ticks = centers[start_idx::2]
if ticks.size == 0:
ticks = centers
decimals = _step_decimals(step)
ticks = np.round(ticks, decimals=decimals)
bounds = np.round(bounds, decimals=decimals + 1)
extent = "neither"
if np.nanquantile(values_np, 0.96) > bounds[-1]:
extent = "max"
if np.nanquantile(values_np, 0.049) < bounds[0]:
extent = "min" if extent == "neither" else "both"
norm = BoundaryNorm(bounds, cmap.N)
im = ax.imshow(values_np, cmap=cmap, norm=norm)
return im, bounds, extent, ticks
def round_sensibly(value):
"""Round map half-range to sensible steps and return decimals for labels.
Returns
-------
tuple[float, int]
(rounded_value, round_dec)
"""
value = float(abs(value))
if not np.isfinite(value) or value == 0:
return 1e-6, 6
thresholds = [
(1.4, 2, 2), # step 0.5
(0.15, 5, 2), # step 0.2
(0.015, 50, 3), # step 0.02
(0.0015, 500, 4), # step 0.002
(0.00015, 5000, 5), # step 0.0002
(0.0, 50000, 6), # step 0.00002
]
for threshold, scale, round_dec in thresholds:
if value > threshold:
rounded = round(value * scale) / scale
rounded = max(rounded, 1 / scale)
return rounded, round_dec
return value, 6
def resample_to_coarser_calendar(
ds_input: xr.Dataset, ds_ref: xr.Dataset
) -> Tuple[xr.Dataset, xr.Dataset]:
"""Resample the higher-resolution dataset to the coarser calendar.
Compare the two datasets' median time steps, convert them to pandas/xarray
frequency aliases, and resample the finer one up to the coarser one using
calendar-aware frequencies (e.g., 'ME' not '720H').
"""
hours_in, alias_in = timedelta_to_alias(ds_input)
hours_ref, alias_ref = timedelta_to_alias(ds_ref)
if hours_in > hours_ref:
# input is coarser (e.g. monthly) → bring ref up to that
target_alias = alias_in
elif hours_ref > hours_in:
# ref is coarser → bring input up to that
target_alias = alias_ref
else:
# same resolution, nothing to do
target_alias = alias_in
logger.info(f"Both are already {target_alias}")
return resample_to_target_freq(ds_input, ds_ref, target_alias)
def resample_to_target_freq(
ds_input: xr.Dataset, ds_ref: xr.Dataset, target_freq
) -> Tuple[xr.Dataset, xr.Dataset]:
"""Resample both datasets to the provided target freq."""
hours_in, alias_in = timedelta_to_alias(ds_input)
hours_ref, alias_ref = timedelta_to_alias(ds_ref)
if target_freq != alias_ref:
# input is coarser (e.g. monthly) → bring ref up to that
logger.info(f"Resampling ref from {alias_ref} to {target_freq}")
ds_ref = ds_ref.resample(time=target_freq).mean()
if target_freq != alias_in:
# ref is coarser → bring input up to that
logger.info(f"Resampling input from {alias_in} to {target_freq}")
ds_input = ds_input.resample(time=target_freq).mean()
# Normalize anchors so both datasets share identical timestamps.
ds_input = normalize_time_axis(ds_input, target_freq)
ds_ref = normalize_time_axis(ds_ref, target_freq)
# finally, force them onto exactly the same time-axis
ds_input, ds_ref = xr.align(ds_input, ds_ref, join="inner")
# logger.debug(f"Input file after align {ds_input}")
return ds_input, ds_ref
def crop_data_to_overlapping_time(input_ds, ref_ds):
"""Crop data to overlapping time."""
time_slice = get_overlapping_time_slice(input_ds, ref_ds)
# Slice both datasets to that time range
logger.debug(f"Input time before crop: {input_ds.time}")
input_ds = input_ds.sel(time=time_slice)
logger.debug(f"Input time after crop: {input_ds.time}")
logger.debug(f"Ref time before crop: {ref_ds.time}")
ref_ds = ref_ds.sel(time=time_slice)
logger.debug(f"Ref time after crop: {ref_ds.time}")
# Normalize anchors at the current resolution to avoid mismatches.
# _, alias = timedelta_to_alias(input_ds)
# input_ds = normalize_time_axis(input_ds, alias)
# ref_ds = normalize_time_axis(ref_ds, alias)
# # Ensure identical time axis after slicing (e.g. monthly midpoints can differ)
# input_ds, ref_ds = xr.align(input_ds, ref_ds, join="inner")
return input_ds, ref_ds, time_slice
def normalize_time_axis(ds: xr.Dataset, alias: str) -> xr.Dataset:
"""Normalize time stamps to a consistent anchor for the given frequency alias."""
def _period_timestamp_index(period_freq: str, timestamp_freq: str):
time_index = ds.indexes.get("time")
if time_index is None:
return None
try:
period_index = time_index.to_period(period_freq)
except Exception:
try:
period_index = time_index.to_datetimeindex().to_period(period_freq)
except Exception:
return None
try:
return period_index.to_timestamp(timestamp_freq)
except Exception:
return period_index.to_timestamp(freq=timestamp_freq)
alias = alias.upper()
if "time" not in ds.coords:
ds_out = ds
elif alias.endswith("H"):
try:
ds_out = ds.assign_coords(time=ds.time.dt.floor("h"))
except ValueError:
ds_out = ds.assign_coords(time=ds.time.dt.floor("h"))
elif alias == "D":
ds_out = ds.assign_coords(time=ds.time.dt.floor("D"))
elif alias.startswith("W"):
new_time = _period_timestamp_index("W-MON", "W-MON")
ds_out = ds.assign_coords(time=new_time) if new_time is not None else ds
elif alias == "ME":
new_time = _period_timestamp_index("M", "M")
ds_out = ds.assign_coords(time=new_time) if new_time is not None else ds
elif alias == "MS":
new_time = _period_timestamp_index("M", "MS")
ds_out = ds.assign_coords(time=new_time) if new_time is not None else ds
else:
ds_out = ds
return ds_out
@log_errors(raise_exceptions=True)
def plot_map(
rel_mean,
rel_std,
spearman,
ref_clim,
input_clim,
input_name,
ref_name,
output_path,
overlapping_years=None,
):
"""Create a 2x2 figure of relative mean, relative std, Spearman correlation, and seasonal means.
Generates four subplots: (1) relative temporal mean, (2) relative temporal
standard deviation, (3) Spearman correlation of climatologies, and (4) monthly
seasonal means for both datasets.
"""
rel_mean = np.where(rel_mean == np.inf, np.nan, rel_mean)
rel_mean = np.where(rel_mean < 0, np.nan, rel_mean)
rel_std = np.where(rel_std == np.inf, np.nan, rel_std)
rel_std = np.where(rel_std < 0, np.nan, rel_std)
fig, axes = plt.subplots(2, 2, figsize=(10.5, 4.68))
if input_name is not None and ref_name is not None:
title = f"Comparision {input_name} with {ref_name}"
if overlapping_years is not None and len(overlapping_years) > 1:
title += f" for years {overlapping_years[0]}-{overlapping_years[-1]}"
fig.suptitle(title, fontweight="normal", fontsize="x-large")
# Set common colormap and normalization limits for mean_et and mean_aet
mean_diff_1 = max(np.abs(1 - np.nanmin(rel_mean)), np.abs(1 - np.nanmax(rel_mean)))
im0, bounds0, extend0, ticks0 = plot_single_map(
axes[0, 0], rel_mean, mean_diff_1, bounds_type="fixed"
)
axes[0, 0].set_title(
f"Relative temporal Mean (median={np.nanmedian(rel_mean):.2f})"
)
std_diff_1 = max(np.abs(1 - np.nanmin(rel_std)), np.abs(1 - np.nanmax(rel_std)))
im1, bounds1, extend1, ticks1 = plot_single_map(
axes[0, 1], rel_std, std_diff_1, bounds_type="quantiles"
)
axes[0, 1].set_title(
f"Relative temporal Standarddeviation (median={np.nanmedian(rel_std):.2f})"
)
im2, bounds2, extend2, ticks2 = plot_single_map(
axes[1, 0],
spearman,
vmin=np.nanmin(spearman),
vmax=1,
cmap=plt.cm.viridis_r,
bounds_type="quantiles",
)
axes[1, 0].set_title(f"Spearman Correlation (median={np.nanmedian(spearman):.2f})")
# Plot for the seasonality
months = np.arange(1, 13, 1)
bar_width = 0.4
axes[1, 1].bar(
months - bar_width / 2,
np.nanmean(ref_clim, axis=(1, 2)),
width=bar_width,
color="#008176",
label=ref_name,
alpha=0.8,
)
axes[1, 1].bar(
months + bar_width / 2,
np.nanmean(input_clim, axis=(1, 2)),
width=bar_width,
color="#79A3E6",
label=input_name,
alpha=0.8,
)
ax_twy = axes[1, 1].twinx()
ref_clim = np.where(ref_clim != 0, ref_clim, np.nan)
rel_clim = np.nanmean(input_clim, axis=(1, 2)) / np.nanmean(ref_clim, axis=(1, 2))
rel_clim_diff_1 = max(
np.abs(1 - np.nanmin(rel_clim)), np.abs(1 - np.nanmax(rel_clim))
)
ax_twy.errorbar(
months, rel_clim, label=f"{input_name}/{ref_name}", color="#0000A7", fmt="--"
)
ax_twy.axhline(y=1, color="#0000A7", linewidth=0.5)
axes[1, 1].set_xlabel("month of year")
handles, labels = [], []
for ax in [axes[1, 1], ax_twy]:
for handle, label in zip(*ax.get_legend_handles_labels()):
handles.append(handle)
labels.append(label)
axes[1, 1].legend(handles, labels, loc="upper right")
axes[1, 1].set_title("Seasonality")
axes[1, 1].set_ylabel("ET [mm/day]")
axes[1, 1].tick_params(axis="y", labelcolor="black")
axes[1, 1].set_xlim(1 - (1.1 * bar_width), 12 + (1.1 * bar_width))
axes[1, 1].set_xticks(months)
axes[1, 1].set_xticklabels(months)
ymax = 1 + rel_clim_diff_1 * 1.05 if not np.isnan(rel_clim_diff_1) else 1
ax_twy.set_ylim(
np.nanmax([0, 1 - rel_clim_diff_1 * 1.05]), ymax
) # Example range for the ratio
ax_twy.set_ylabel("Ratio (Input / Reference)", color="#0000A7")
ax_twy.tick_params(axis="y", labelcolor="#0000A7")
divider0 = make_axes_locatable(axes[0, 0])
cax = divider0.append_axes("right", size="5%", pad=0.1)
fig.colorbar(
im0, cax=cax, label="", boundaries=bounds0, extend=extend0, ticks=ticks0
)
divider1 = make_axes_locatable(axes[0, 1])
cax2 = divider1.append_axes("right", size="5%", pad=0.1)
fig.colorbar(
im1, cax=cax2, label="", boundaries=bounds1, extend=extend1, ticks=ticks1
)
divider2 = make_axes_locatable(axes[1, 0])
cax = divider2.append_axes("right", size="5%", pad=0.1)
fig.colorbar(
im2, cax=cax, label="", boundaries=bounds2, extend=extend2, ticks=ticks2
)
for ax in axes.flat:
for spine in ax.spines.values():
spine.set_linewidth(0.5)
axes[1, 1].spines["top"].set_linewidth(0.25)
for ax in [axes[0, 0], axes[1, 0], axes[0, 1]]:
ax.set_yticks([])
ax.set_xticks([])
ax.yaxis.labelpad = 0
for spine in ax.spines.values():
spine.set_linewidth(0.25)
plt.tight_layout()
file_name = f"et_map_{input_name}_{ref_name}.png".replace(" ", "_")
plt.savefig(output_path / file_name, dpi=800)
logger.info(f"created et_map {output_path / file_name}")
@log_errors(raise_exceptions=True)
def plot_map_bias_only(
rel_mean,
ref_clim,
input_clim,
input_name,
ref_name,
output_path,
overlapping_years=None,
total_rel_mean=None,
):
"""Create a 3-panel plot with relative mean, mean difference, and climatology."""
rel_mean = np.where(rel_mean == np.inf, np.nan, rel_mean)
ref_mean_map = np.asarray(np.nanmean(ref_clim, axis=0))
input_mean_map = np.asarray(np.nanmean(input_clim, axis=0))
diff_mean = input_mean_map - ref_mean_map
diff_mean = np.where(diff_mean == np.inf, np.nan, diff_mean)
fig = plt.figure(figsize=(10.5, 5.6))
gs = fig.add_gridspec(2, 4, height_ratios=[1.0, 0.5])
ax_rel_mean = fig.add_subplot(gs[0, 0:2])
ax_diff_mean = fig.add_subplot(gs[0, 2:4])
ax_clim = fig.add_subplot(gs[1, 1:3])
if input_name is not None and ref_name is not None:
title = f"Comparision {input_name} with {ref_name}"
if overlapping_years is not None and len(overlapping_years) > 1:
title += f" for years {overlapping_years[0]}-{overlapping_years[-1]}"
fig.suptitle(title, fontweight="normal", fontsize="x-large")
mean_diff_1 = max(np.abs(1 - np.nanmin(rel_mean)), np.abs(1 - np.nanmax(rel_mean)))
im0, bounds0, extend0, ticks0 = plot_single_map(
ax_rel_mean, rel_mean, mean_diff_1, bounds_type="fixed"
)
title_rel_mean = f"a) Relative Mean (median={np.nanmedian(rel_mean):.2f}"
if total_rel_mean is not None:
title_rel_mean += f", mean={total_rel_mean:.2f}"
title_rel_mean += ")"
ax_rel_mean.set_title(title_rel_mean)
# diff_abs_max = int(round(np.nanmax(np.abs(diff_mean)) + 0.5))
# if not np.isfinite(diff_abs_max) or diff_abs_max == 0:
# diff_abs_max = 1.0
vmin = np.nanquantile(diff_mean, 0.01)
vmax = np.nanquantile(diff_mean, 0.99)
diff_diff_1 = max(abs(vmin), abs(vmax))
if diff_diff_1 > 1.4:
# round to next 0.5
diff_diff_1 = round((diff_diff_1 * 2) / 2) / 4 * 4.5
else:
# round to next 0.2
diff_diff_1 = round((diff_diff_1 * 5) / 5) / 4 * 4.5
im1, bounds1, extend1, ticks1 = plot_single_map(
ax_diff_mean,
diff_mean,
vmin=-diff_diff_1,
vmax=diff_diff_1,
center=0,
bounds_type="max",
)
ax_diff_mean.set_title(
f"b) Difference (median={np.nanmedian(diff_mean):.2f}mm/day, mean={np.nanmean(diff_mean):.2f}mm/day)"
)
months = np.arange(1, 13, 1)
bar_width = 0.4
ref_monthly_mean = np.asarray(np.nanmean(ref_clim, axis=(1, 2)))
input_monthly_mean = np.asarray(np.nanmean(input_clim, axis=(1, 2)))
ax_clim.bar(
months - bar_width / 2,
ref_monthly_mean,
width=bar_width,
color="#008176",
label=ref_name,
alpha=0.8,
)
ax_clim.bar(
months + bar_width / 2,
input_monthly_mean,
width=bar_width,
color="#79A3E6",
label=input_name,
alpha=0.8,
)
ax_twy = ax_clim.twinx()
ref_clim = np.where(ref_clim != 0, ref_clim, np.nan)
rel_clim = input_monthly_mean / np.asarray(np.nanmean(ref_clim, axis=(1, 2)))
rel_clim_diff_1 = max(
np.abs(1 - np.nanmin(rel_clim)), np.abs(1 - np.nanmax(rel_clim))
)
ax_twy.errorbar(
months, rel_clim, label=f"{input_name}/{ref_name}", color="#0000A7", fmt="--"
)
ax_twy.axhline(y=1, color="#0000A7", linewidth=0.5)
ax_clim.set_xlabel("month of year")
handles, labels = [], []
for ax in [ax_clim, ax_twy]:
for handle, label in zip(*ax.get_legend_handles_labels()):
handles.append(handle)
labels.append(label)
ax_clim.legend(handles, labels, loc="upper right")
ax_clim.set_title("c) Climatology")
ax_clim.set_ylabel("ET [mm/day]")
ax_clim.tick_params(axis="y", labelcolor="black")
ax_clim.set_xlim(1 - (1.1 * bar_width), 12 + (1.1 * bar_width))
ax_clim.set_xticks(months)
ax_clim.set_xticklabels(months)
ymax = 1 + rel_clim_diff_1 * 1.05 if not np.isnan(rel_clim_diff_1) else 1
ax_twy.set_ylim(
np.nanmax([0, 1 - rel_clim_diff_1 * 1.05]), ymax
) # Example range for the ratio
ax_twy.set_ylabel("Ratio (Input / Reference)", color="#0000A7")
ax_twy.tick_params(axis="y", labelcolor="#0000A7")
divider0 = make_axes_locatable(ax_rel_mean)
cax = divider0.append_axes("right", size="5%", pad=0.1)
fig.colorbar(
im0, cax=cax, label="", boundaries=bounds0, extend=extend0, ticks=ticks0
)
divider1 = make_axes_locatable(ax_diff_mean)
cax1 = divider1.append_axes("right", size="5%", pad=0.1)
fig.colorbar(
im1,
cax=cax1,
label="mm/day",
boundaries=bounds1,
extend=extend1,
ticks=ticks1,
)
for ax in [ax_rel_mean, ax_diff_mean, ax_clim]:
for spine in ax.spines.values():
spine.set_linewidth(0.5)
ax_clim.spines["top"].set_linewidth(0.25)
for ax in [ax_rel_mean, ax_diff_mean]:
ax.set_yticks([])
ax.set_xticks([])
ax.yaxis.labelpad = 0
for spine in ax_rel_mean.spines.values():
spine.set_linewidth(0.25)
for spine in ax_diff_mean.spines.values():
spine.set_linewidth(0.25)
plt.tight_layout()
file_name = f"et_map_bias_only_{input_name}_{ref_name}.png".replace(" ", "_")
plt.savefig(output_path / file_name, dpi=800)
logger.info(f"created et_map {output_path / file_name}")
@log_errors(raise_exceptions=True)
def plot_map_global_climate(
rel_mean,
rel_std,
input_name,
ref_name,
output_path,
overlapping_years=None,
):
"""Create a plot showing relative mean, relative std and seasonality."""
rel_mean = np.where(rel_mean == np.inf, np.nan, rel_mean)
rel_std = np.where(rel_std == np.inf, np.nan, rel_std)
fig, axes = plt.subplots(2, 1, figsize=(10.5, 6.2))
if input_name is not None and ref_name is not None:
title = f"Comparision {input_name} with {ref_name}"
if overlapping_years is not None and len(overlapping_years) > 1:
title += f" for years {overlapping_years[0]}-{overlapping_years[-1]}"
fig.suptitle(title, fontweight="normal", fontsize="x-large")
mean_diff_1 = max(np.abs(1 - np.nanmin(rel_mean)), np.abs(1 - np.nanmax(rel_mean)))
im0, bounds0, extend0, ticks0 = plot_single_map(
axes[0], rel_mean, mean_diff_1, bounds_type="fixed"
)
axes[0].set_title(f"Relative temporal Mean (median={np.nanmedian(rel_mean):.2f})")
std_diff_1 = max(np.abs(1 - np.nanmin(rel_std)), np.abs(1 - np.nanmax(rel_std)))
im1, bounds1, extend1, ticks1 = plot_single_map(
axes[1], rel_std, std_diff_1, bounds_type="quantiles"
)
axes[1].set_title(
f"Relative temporal Standarddeviation (median={np.nanmedian(rel_std):.2f})"
)
divider0 = make_axes_locatable(axes[0])
cax0 = divider0.append_axes("right", size="5%", pad=0.1)
fig.colorbar(
im0, cax=cax0, label="", boundaries=bounds0, extend=extend0, ticks=ticks0
)
divider1 = make_axes_locatable(axes[1])
cax1 = divider1.append_axes("right", size="5%", pad=0.1)
fig.colorbar(
im1, cax=cax1, label="", boundaries=bounds1, extend=extend1, ticks=ticks1
)
for ax in axes.flat:
for spine in ax.spines.values():
spine.set_linewidth(0.5)
for ax in [axes[0], axes[1]]:
ax.set_yticks([])
ax.set_xticks([])
ax.yaxis.labelpad = 0
for spine in ax.spines.values():
spine.set_linewidth(0.25)
plt.tight_layout()
file_name = f"et_map_global_climate_{input_name}_{ref_name}.png".replace(" ", "_")
plt.savefig(output_path / file_name, dpi=800)
logger.info(f"created et_map {output_path / file_name}")
@log_errors(raise_exceptions=True)
def plot_map_global_climate2(
rel_mean,
diff_mean,
input_name,
ref_name,
output_path,
overlapping_years=None,
total_rel_mean=None,
):
"""Create a plot showing relative mean and monthly climatology difference."""
rel_mean = np.where(rel_mean == np.inf, np.nan, rel_mean)
diff_mean = np.asarray(np.where(diff_mean == np.inf, np.nan, diff_mean))
fig = plt.figure(figsize=(10.5, 6.2))
gs = fig.add_gridspec(2, 4, height_ratios=[1.0, 1.0])
ax_rel_mean = fig.add_subplot(gs[1, 1:3])
ax_diff = fig.add_subplot(gs[0, 1:3])
if input_name is not None and ref_name is not None:
title = f"Comparision {input_name} with {ref_name}"
if overlapping_years is not None and len(overlapping_years) > 1:
title += f" for years {overlapping_years[0]}-{overlapping_years[-1]}"
fig.suptitle(title, fontweight="normal", fontsize="x-large")
vmin = np.nanquantile(rel_mean, 0.01)
vmax = np.nanquantile(rel_mean, 0.99)
mean_diff_1 = max(abs(vmin), abs(vmax))
mean_diff_1, round_dec = round_sensibly(mean_diff_1)
logger.debug(
f"mean_diff_1={mean_diff_1}, vmin={vmin}, vmax={vmax}, "
f"round_dec={round_dec}"
)
im0, bounds0, extend0, ticks0 = plot_single_map(
ax_rel_mean, rel_mean, mean_diff_1, bounds_type="max", center=0
)
title_rel_mean = (
f"b) Relative Mean Difference (median={np.nanmedian(rel_mean):.{round_dec}f}"
)
if total_rel_mean is not None:
title_rel_mean += f", mean={total_rel_mean:.{round_dec}f}"
title_rel_mean += ")"
ax_rel_mean.set_title(title_rel_mean)
vmin = np.nanquantile(diff_mean, 0.01)
vmax = np.nanquantile(diff_mean, 0.99)
diff_diff_1 = max(abs(vmin), abs(vmax))
diff_diff_1, round_dec = round_sensibly(diff_diff_1)
logger.debug(
f"diff_diff_1={diff_diff_1}, vmin={vmin}, vmax={vmax}, "
f"round_dec={round_dec}"
)
im1, bounds1, extend1, ticks1 = plot_single_map(
ax_diff, diff_mean, diff_diff_1, center=0, bounds_type="max"
)
ax_diff.set_title(
f"a) Mean Difference (median={np.nanmedian(diff_mean):.{round_dec}f}mm/day, mean={np.nanmean(diff_mean):.{round_dec}f}mm/day)"
)
divider1 = make_axes_locatable(ax_diff)
cax1 = divider1.append_axes("right", size="5%", pad=0.1)
fig.colorbar(
im1, cax=cax1, label="mm/day", boundaries=bounds1, extend=extend1, ticks=ticks1
)
divider0 = make_axes_locatable(ax_rel_mean)
cax0 = divider0.append_axes("right", size="5%", pad=0.1)
fig.colorbar(
im0, cax=cax0, label="%", boundaries=bounds0, extend=extend0, ticks=ticks0
)
for ax in [ax_rel_mean, ax_diff]:
for spine in ax.spines.values():
spine.set_linewidth(0.5)
ax_rel_mean.set_yticks([])
ax_rel_mean.set_xticks([])
ax_rel_mean.yaxis.labelpad = 0
for spine in ax_rel_mean.spines.values():
spine.set_linewidth(0.25)
if diff_mean.ndim != 1:
ax_diff.set_yticks([])
ax_diff.set_xticks([])
ax_diff.yaxis.labelpad = 0
for spine in ax_diff.spines.values():
spine.set_linewidth(0.25)
plt.tight_layout()
file_name = f"et_map_global_climate2_{input_name}_{ref_name}.png".replace(" ", "_")
plt.savefig(output_path / file_name, dpi=800)
logger.info(f"created et_map {output_path / file_name}")
@log_errors(raise_exceptions=True)
def plot_map_local_climate(
input_clim,
ref_clim,
input_name,
ref_name,
output_path,
overlapping_years=None,
):
"""Create monthly local-climate maps for relative and absolute differences.
Produces two figures with 12 small maps (one per month):
- Relative climatology difference in percent
- Absolute climatology difference in native units (e.g. mm/day)
"""
month_labels = (
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
)
input_clim = np.asarray(input_clim)
ref_clim = np.asarray(ref_clim)
if input_clim.shape != ref_clim.shape or input_clim.ndim != 3:
msg = (
"input_clim and ref_clim must both be 3D arrays with equal shape "
"(month, lat, lon). "
f"Got {input_clim.shape} and {ref_clim.shape}."
)
with ErrorLogger(logger):
raise ValueError(msg)
if input_clim.shape[0] != 12:
msg = (
"Monthly local-climate plotting expects exactly 12 climatology slices. "
f"Got {input_clim.shape[0]}."
)
with ErrorLogger(logger):
raise ValueError(msg)
# Avoid unstable percentages where reference climatology is close to zero.
ref_safe = np.where(np.abs(ref_clim) < 0.1, np.nan, ref_clim)
rel_monthly = 100.0 * (input_clim - ref_safe) / ref_safe
abs_monthly = input_clim - ref_clim
def _plot_monthly_panels(values, panel_title, output_file_name, colorbar_label):
values = np.where(values == np.inf, np.nan, values)
values = np.where(values == -np.inf, np.nan, values)
finite_values = values[np.isfinite(values)]
if finite_values.size == 0:
diff_to_mean = 1e-6
else:
q01 = float(np.nanquantile(finite_values, 0.01))
q99 = float(np.nanquantile(finite_values, 0.99))
diff_to_mean = max(abs(q01), abs(q99))
diff_to_mean, _ = round_sensibly(diff_to_mean)
fig = plt.figure(figsize=(14.0, 8.5))
gs = fig.add_gridspec(
3,
5,
width_ratios=[1.0, 1.0, 1.0, 1.0, 0.085],
wspace=0.06,
hspace=0.18,
)
axes = np.array(
[[fig.add_subplot(gs[i, j]) for j in range(4)] for i in range(3)]
)
cax = fig.add_subplot(gs[:, 4])
if input_name is not None and ref_name is not None:
title = f"Comparision {input_name} with {ref_name}"
else:
title = "Local Climate Comparison"
if overlapping_years is not None and len(overlapping_years) > 1:
title += f" for years {overlapping_years[0]}-{overlapping_years[-1]}"
fig.suptitle(f"{title}\n{panel_title}", fontsize="x-large", fontweight="normal")
im = None
bounds = None
extend = None
ticks = None
for month_idx, ax in enumerate(axes.flat):
month_values = values[month_idx]
im, bounds, extend, ticks = plot_single_map(
ax,
month_values,
diff_to_mean=diff_to_mean,
center=0,
bounds_type="max",
)
ax.set_title(month_labels[month_idx], fontsize="medium")
ax.set_xticks([])
ax.set_yticks([])
ax.yaxis.labelpad = 0
for spine in ax.spines.values():
spine.set_linewidth(0.25)
cbar = fig.colorbar(
im,
cax=cax,
boundaries=bounds,
extend=extend,
ticks=ticks,
label=colorbar_label,
)
cbar.ax.tick_params(labelsize=8)
fig.subplots_adjust(left=0.03, right=0.94, bottom=0.04, top=0.89)
plt.savefig(output_path / output_file_name, dpi=400)
logger.info(f"created et_map {output_path / output_file_name}")
rel_file_name = f"et_map_local_climate_rel_{input_name}_{ref_name}.png".replace(
" ", "_"
)
abs_file_name = f"et_map_local_climate_abs_{input_name}_{ref_name}.png".replace(
" ", "_"
)
_plot_monthly_panels(
rel_monthly,
"Monthly Relative Climatology Difference",
rel_file_name,
"Relative Climatology Difference [%]",
)
_plot_monthly_panels(
abs_monthly,
"Monthly Absolute Climatology Difference",
abs_file_name,
"Absolute Climatology Difference [mm/day]",
)
def create_map_from_output(
output_path, input_name, ref_name, bias_only=False, global_climate=False
):
"""Read in statistics netcdf and create a map plots from it."""
file = get_rel_stat_file(output_path, input_name, ref_name)
logger.info(f"Plotting data from {file}")
with get_xarray_ds_from_file(file, force_decending_y=True) as ds:
rel_mean = ds["rel_mean"]
rel_mean = np.where(rel_mean == np.inf, np.nan, rel_mean)
rel_std = ds.get("rel_std", None)
rel_std = (
np.where(rel_std == np.inf, np.nan, rel_std)
if rel_std is not None
else None
)
spearman = ds.get("spearman", None)
input_clim = (
ds[f"{input_name}_clim"] if f"{input_name}_clim" in ds else ds["input_clim"]
)
ref_clim = (
ds[f"{ref_name}_clim"] if f"{ref_name}_clim" in ds else ds["ref_clim"]
)
if (
rel_std is not None
and spearman is not None
and not bias_only
and not global_climate
):
plot_map(
rel_std=rel_std,
rel_mean=rel_mean,
spearman=spearman,
ref_clim=ref_clim,
input_clim=input_clim,
input_name=input_name,
ref_name=ref_name,
output_path=output_path,
)
elif global_climate:
rel_mean_diff = (
100
* (np.nanmean(input_clim, axis=0) - np.nanmean(ref_clim, axis=0))
/ np.nanmean(ref_clim, axis=0)
)
rel_mean_diff = np.where(
np.nanmax(ref_clim, axis=0) < 0.1, np.nan, rel_mean_diff
)
plot_map_global_climate2(
rel_mean=rel_mean_diff,
diff_mean=np.nanmean(input_clim, axis=0) - np.nanmean(ref_clim, axis=0),
input_name=input_name,
ref_name=ref_name,
output_path=output_path,
total_rel_mean=np.nanmean(input_clim) / np.nanmean(ref_clim),
)
plot_map_local_climate(
input_clim=input_clim,
ref_clim=ref_clim,
input_name=input_name,
ref_name=ref_name,
output_path=output_path,
)
else:
plot_map_bias_only(
rel_mean=rel_mean,
ref_clim=ref_clim,
input_clim=input_clim,
input_name=input_name,
ref_name=ref_name,
output_path=output_path,
total_rel_mean=np.nanmean(input_clim) / np.nanmean(ref_clim),
)
def get_stats(
path,
var,
factor,
coordinate_slice,
n_bootstrap_years,
ncpus,
output_file,
available_years=None,
direct_comp=False,
available_mem=None,
mask_da=None,
mask_var=None,
file_name="*.*",
):
"""Get statistics dataset from a path to a file or directory with files."""
logger.info(f"Get stats for {path}")
if var is not None:
if path.is_dir() and not direct_comp:
stats_ds = get_stats_one_pass(
path,
var,
factor,
coordinate_slice,
n_bootstrap_years=n_bootstrap_years,
ncpus=ncpus,
output_path=output_file,
available_years=available_years,
file_name=file_name,
)
elif path.is_dir() or path.is_file():
if path.is_file() and path.suffix == ".nc":
chunking = available_mem is not None
ds = get_xarray_ds_from_file(
path,
chunking=chunking,
available_mem_gib=available_mem,
chunk_type=ChunkType.SPACE,
force_decending_y=True,
var_name=var,
)
stats_ds = get_file_stats(
ds,
var,
factor,
coordinate_slice,
output_path=output_file,
avaiable_years=available_years,
direct_comp=direct_comp,
)
if path.is_dir():
file_list = get_files(
path, available_years=available_years, file_name=file_name
)
with get_dataset_from_path(
file_list, available_mem=available_mem, file_name=file_name
) as ds_in:
stats_ds = get_file_stats(
ds_in,
var,
factor,
coordinate_slice,
output_path=output_file,
avaiable_years=available_years,
direct_comp=direct_comp,
)
else:
msg = f"Path {path} is neither dir nor file."
with ErrorLogger(logger):
raise ValueError(msg)
else:
with get_xarray_ds_from_file(
path, engine="netcdf4", force_decending_y=True
) as ds_input:
ds = ds_input
if coordinate_slice is not None:
ds = ds.sel(
{
get_coord_key(ds, lat=True): coordinate_slice["lat"],
get_coord_key(ds, lon=True): coordinate_slice["lon"],
}
)
if available_years is not None:
ds = ds.sel(time=ds_input.time.dt.year.isin(available_years))
if "clim" in ds and "std" in ds and "mean" in ds:
stats_ds = ds
else:
with ErrorLogger(logger):
msg = "Wrong statisitcs file. If you want to create new statistics you have to provide a var."
raise KeyError(msg)
masked_ds = apply_spatial_mask(stats_ds, mask_da, mask_var=mask_var)
return generate_bounds_for_all_coords(masked_ds)
def compare_input_with_ref( # noqa: PLR0912, PLR0913, PLR0915
input_path,
input_var,
output_path,
ref_path,
ref_var,
input_name="input",
ref_name="ref",
input_factor=1,
ref_factor=1,
coordinate_slice=None,
n_bootstrap_years=None,
bootstrap_index=None,
ncpus=1,
available_years=None,
direct_comp=False,
available_mem=None,
target_freq=None,
plot=True,
bias_only=False,
global_climate=False,
mask_da=None,
mask_var=None,
input_file_name=None,
ref_file_name=None,
result_metric="all",
):
"""Compare the two datasets."""
output_path = Path(output_path)
if bootstrap_index is not None:
random.seed(bootstrap_index)
overlapping_years = None
# get input statistics
input_stats_file = None
ref_stats_file = None
input = get_stats(
path=input_path,
var=input_var,
factor=input_factor,
coordinate_slice=coordinate_slice,
n_bootstrap_years=n_bootstrap_years,
ncpus=ncpus,
output_file=input_stats_file,
available_years=available_years,
direct_comp=direct_comp,
available_mem=available_mem,
mask_da=mask_da,
mask_var=mask_var,
file_name=input_file_name,
)
logger.debug(f"input ds: {input}")
# get reference statistics
ref = get_stats(
path=ref_path,
var=ref_var,
factor=ref_factor,
coordinate_slice=coordinate_slice,
n_bootstrap_years=n_bootstrap_years,
ncpus=ncpus,
output_file=ref_stats_file,
available_years=available_years,
direct_comp=direct_comp,
available_mem=available_mem,
mask_da=mask_da,
mask_var=mask_var,
file_name=ref_file_name,
)
logger.debug(f"ref ds: {ref}")
logger.debug(
f"After get_stats input/ref are all nan: "
f"input_mean={bool(input['mean'].isnull().all().compute().item())}, "
f"input_clim={bool(input['clim'].isnull().all().compute().item())}, "
f"ref_mean={bool(ref['mean'].isnull().all().compute().item())}, "
f"ref_clim={bool(ref['clim'].isnull().all().compute().item())}"
)
# regrid spatial resoution
# regridd to same spatial resolution
if len(input["lat"].data) < 1 or len(input["lon"].data) < 1:
logger.error("Input dataset has empty coordinate.")
if len(ref["lat"].data) < 1 or len(ref["lon"].data) < 1:
logger.error("Ref dataset has empty coordinate.")
input, ref = crop_datasets_to_spatial_overlap(input, ref)
input, ref = regridd_to_higher_spatial_resolution(input, ref)
logger.debug(
f"After spatial overlap/regrid input/ref are all nan: "
f"input_mean={bool(input['mean'].isnull().all().compute().item())}, "
f"input_clim={bool(input['clim'].isnull().all().compute().item())}, "
f"ref_mean={bool(ref['mean'].isnull().all().compute().item())}, "
f"ref_clim={bool(ref['clim'].isnull().all().compute().item())}"
)
output_name = f"{input_name}-{ref_name}".replace(" ", "_")
# compare and save statistics
full_metrics = not bias_only and not global_climate
with_std = not bias_only
if direct_comp:
input_ts, ref_ts = input["time_series"], ref["time_series"]
# If we already know the target frequency, resample to that; otherwise pick the coarser one.
if target_freq is not None:
input_ts, ref_ts = resample_to_target_freq(input_ts, ref_ts, target_freq)
else:
input_ts, ref_ts = resample_to_coarser_calendar(input_ts, ref_ts)
input_ts, ref_ts, time_slice = crop_data_to_overlapping_time(input_ts, ref_ts)
overlapping_years = [time_slice.start.year, time_slice.stop.year]
if input_ts.shape != ref_ts.shape:
msg = f"Input and ref time_series shapes differ after resampling/cropping: {input_ts.shape} vs {ref_ts.shape}"
with ErrorLogger(logger):
raise ValueError(msg)
pairwise_valid = np.isfinite(input_ts.values) & np.isfinite(ref_ts.values)
input_ts = input_ts.where(pairwise_valid)
ref_ts = ref_ts.where(pairwise_valid)
logger.info(
f"Creating data from timeseries with shape {input_ts.shape} and {ref_ts.shape}"
)
if input_ts.sizes.get("time", 0) == 0 or ref_ts.sizes.get("time", 0) == 0:
msg = "No overlapping time steps after alignment; cannot compare input and reference."
logger.error(msg)
raise ValueError(msg)
if not np.any(pairwise_valid):
msg = (
"Input and reference data have no paired finite values after "
"time alignment, spatial overlap, regridding, and masking."
)
with ErrorLogger(logger):
raise ValueError(msg)
try:
if full_metrics:
input_ts_np = np.asarray(input_ts.values)
ref_ts_np = np.asarray(ref_ts.values)
spearman, spearman_pval = spearman_spatial_joblib(
input_ts_np, ref_ts_np, spearman_correlation, ncpus
)
create_results_csv(
map1=input_ts_np,
map2=ref_ts_np,
ds1_name=input_name,
ds2_name=ref_name,
out_dir=output_path,
out_name=output_name,
metric=result_metric,
)
except ValueError as ve:
logger.error("Input and ref do not have the same temporal extent.")
logger.info(input_ts.time)
logger.info(ref_ts.time)
raise ve
elif full_metrics:
logger.info("Calculating spearman correlation from seasonalities.")
input_clim_np = np.asarray(input["clim"].values)
ref_clim_np = np.asarray(ref["clim"].values)
if not np.any(np.isfinite(input_clim_np) & np.isfinite(ref_clim_np)):
msg = (
"Input and reference climatologies have no paired finite values "
"after spatial overlap, regridding, and masking."
)
with ErrorLogger(logger):
raise ValueError(msg)
spearman, spearman_pval = spearman_spatial_joblib(
input_clim_np, ref_clim_np, spearman_correlation, ncpus
)
create_results_csv(
map1=input_clim_np,
map2=ref_clim_np,
ds1_name=input_name,
ds2_name=ref_name,
out_dir=output_path,
out_name=output_name,
metric=result_metric,
)
if input["mean"].shape != ref["mean"].shape:
msg = f"Input and ref mean shapes differ after regridding: {input['mean'].shape} vs {ref['mean'].shape}"
with ErrorLogger(logger):
raise ValueError(msg)
mean_valid = (
np.isfinite(input["mean"].values)
& np.isfinite(ref["mean"].values)
& (ref["mean"].values != 0)
)
logger.debug(
f"Mean comparison valid cells: "
f"{int(np.count_nonzero(mean_valid))}/{mean_valid.size}; "
f"input_mean_all_nan={bool(input['mean'].isnull().all().compute().item())}; "
f"ref_mean_all_nan={bool(ref['mean'].isnull().all().compute().item())}"
)
rel_mean_values = np.full(input["mean"].shape, np.nan, dtype=np.float64)
np.divide(
input["mean"].values,
ref["mean"].values,
out=rel_mean_values,
where=mean_valid,
)
rel_mean = xr.DataArray(
rel_mean_values, coords=input["mean"].coords, dims=input["mean"].dims
)
if with_std:
if input["std"].shape != ref["std"].shape:
msg = f"Input and ref std shapes differ after regridding: {input['std'].shape} vs {ref['std'].shape}"
with ErrorLogger(logger):
raise ValueError(msg)
std_valid = (
np.isfinite(input["std"].values)
& np.isfinite(ref["std"].values)
& (ref["std"].values != 0)
)
rel_std_values = np.full(input["std"].shape, np.nan, dtype=np.float64)
np.divide(
input["std"].values,
ref["std"].values,
out=rel_std_values,
where=std_valid,
)
rel_std = xr.DataArray(
rel_std_values, coords=input["std"].coords, dims=input["std"].dims
)
else:
rel_std = None
if full_metrics:
spearman = xr.DataArray(
spearman.data, coords=input["std"].coords, dims=input["std"].dims
)
spearman_pval = xr.DataArray(
spearman_pval.data, coords=input["std"].coords, dims=input["std"].dims
)
else:
spearman, spearman_pval = None, None
if input["clim"].shape != ref["clim"].shape:
msg = f"Input and ref clim shapes differ after regridding: {input['clim'].shape} vs {ref['clim'].shape}"
with ErrorLogger(logger):
raise ValueError(msg)
clim_valid = np.isfinite(input["clim"].values) & np.isfinite(ref["clim"].values)
logger.debug(
f"Climatology comparison valid cells: "
f"{int(np.count_nonzero(clim_valid))}/{clim_valid.size}; "
f"input_clim_all_nan={bool(input['clim'].isnull().all().compute().item())}; "
f"ref_clim_all_nan={bool(ref['clim'].isnull().all().compute().item())}"
)
input_clim = xr.DataArray(
np.where(clim_valid, input["clim"].values, np.nan),
coords={
"month": np.arange(1, 13, 1),
"lat": get_coord_values(input, lat=True),
"lon": get_coord_values(input, lon=True),
},
dims=["month", "lat", "lon"],
)
ref_clim = xr.DataArray(
np.where(clim_valid, ref["clim"].values, np.nan),
coords={
"month": np.arange(1, 13, 1),
"lat": get_coord_values(input, lat=True),
"lon": get_coord_values(input, lon=True),
},
dims=["month", "lat", "lon"],
)
rel_mean = rel_mean.where(np.isfinite(rel_mean) & (rel_mean >= 0))
output = xr.Dataset(
{
"rel_mean": rel_mean,
},
coords={
"month": np.arange(1, 13, 1),
"lat": get_coord_values(input, lat=True),
"lon": get_coord_values(input, lon=True),
},
)
if with_std:
rel_std = rel_std.where(np.isfinite(rel_std) & (rel_std >= 0))
output["rel_std"] = rel_std
if full_metrics:
output["spearman"] = spearman
output["spearman_pval"] = spearman_pval
file_name = "relative_stats"
if input_name is not None:
file_name += f"_{input_name}"
output[f"{input_name}_clim"] = input_clim
else:
output["input_clim"] = input_clim
if ref_name is not None:
file_name += f"_{ref_name}"
output[f"{ref_name}_clim"] = ref_clim
else:
output["ref_clim"] = ref_clim
logger.debug(
f"Before writing relative output is all nan: "
f"rel_mean={bool(output['rel_mean'].isnull().all().compute().item())}; "
f"input_clim={bool(input_clim.isnull().all().compute().item())}; "
f"ref_clim={bool(ref_clim.isnull().all().compute().item())}"
)
file_name = file_name.replace(" ", "_")
if bootstrap_index is not None:
file_name = output_path / f"{file_name}_{bootstrap_index}.nc"
else:
file_name = output_path / f"{file_name}.nc"
write_xarray_to_file(ds=output, file_path=file_name)
logger.info(f"Written output to {file_name}")
if plot:
if full_metrics:
plot_map(
rel_std=rel_std,
rel_mean=rel_mean,
spearman=spearman,
ref_clim=ref_clim,
input_clim=input_clim,
input_name=input_name,
ref_name=ref_name,
output_path=output_path,
overlapping_years=overlapping_years,
)
elif global_climate:
rel_mean_diff = (
100
* (np.nanmean(input_clim, axis=0) - np.nanmean(ref_clim, axis=0))
/ np.nanmean(ref_clim, axis=0)
)
rel_mean_diff = np.where(
np.nanmax(ref_clim, axis=0) < 0.1, np.nan, rel_mean_diff
)
plot_map_global_climate2(
rel_mean=rel_mean_diff,
diff_mean=np.nanmean(input_clim, axis=0) - np.nanmean(ref_clim, axis=0),
input_name=input_name,
ref_name=ref_name,
output_path=output_path,
overlapping_years=overlapping_years,
)
plot_map_local_climate(
input_clim=input_clim,
ref_clim=ref_clim,
input_name=input_name,
ref_name=ref_name,
output_path=output_path,
)
else:
plot_map_bias_only(
rel_mean=rel_mean,
ref_clim=ref_clim,
input_clim=input_clim,
input_name=input_name,
ref_name=ref_name,
output_path=output_path,
overlapping_years=overlapping_years,
total_rel_mean=np.nanmean(input_clim) / np.nanmean(ref_clim),
)
return file_name
def get_rel_stat_file(output_path, input_name, ref_name):
"""Return the path for the relative-statistics file for the two datasets."""
file_name = "relative_stats"
if input_name is not None:
file_name += f"_{input_name}"
if ref_name is not None:
file_name += f"_{ref_name}"
file_name = file_name.replace(" ", "_")
return output_path / f"{file_name}.nc"
def evaluate_boostraping_stat_files(stat_files, input_name, ref_name):
"""Evaluate bootstrapped stats and return medians across iterations."""
# Open the first file to initialize dimensions and weights
try:
with get_xarray_ds_from_file(
stat_files[0], force_decending_y=True
) as first_file:
shape = first_file["rel_mean"].shape
n_bootstrap = len(stat_files)
# Determine keys for climatology fields
input_clim_key = (
f"{input_name}_clim"
if f"{input_name}_clim" in first_file
else "input_clim"
)
ref_clim_key = (
f"{ref_name}_clim" if f"{ref_name}_clim" in first_file else "ref_clim"
)
has_rel_std = "rel_std" in first_file
has_spearman = "spearman" in first_file
# Preallocate arrays for all available variables
mean = np.empty((n_bootstrap, *shape))
std = np.empty((n_bootstrap, *shape)) if has_rel_std else None
spearman = np.empty((n_bootstrap, *shape)) if has_spearman else None
input_clim = np.empty((n_bootstrap, 12, *shape)) # Month x lat x lon
ref_clim = np.empty((n_bootstrap, 12, *shape)) # Month x lat x lon
except ValueError as ve:
logger.error(f"opening file {stat_files[0]} as first file failed.")
raise ve
# Fill the preallocated arrays with bootstrap data
for i, file in enumerate(stat_files):
with get_xarray_ds_from_file(file, force_decending_y=True) as ds:
mean[i] = ds["rel_mean"].values
if std is not None:
std[i] = ds["rel_std"].values
if spearman is not None:
spearman[i] = ds["spearman"].values
input_clim[i] = ds[input_clim_key].values
ref_clim[i] = ds[ref_clim_key].values
# Convert the arrays into xarray DataArrays
mean_da = xr.DataArray(mean, dims=["bootstrap", "lat", "lon"])
std_da = (
xr.DataArray(std, dims=["bootstrap", "lat", "lon"]) if std is not None else None
)
spearman_da = (
xr.DataArray(spearman, dims=["bootstrap", "lat", "lon"])
if spearman is not None
else None
)
input_clim_da = xr.DataArray(input_clim, dims=["bootstrap", "month", "lat", "lon"])
ref_clim_da = xr.DataArray(ref_clim, dims=["bootstrap", "month", "lat", "lon"])
# Combine results into an xarray Dataset
results = {
"rel_mean": mean_da.median(dim="bootstrap"),
"input_clim": input_clim_da.median(dim="bootstrap"),
"ref_clim": ref_clim_da.median(dim="bootstrap"),
}
if std_da is not None:
results["rel_std"] = std_da.median(dim="bootstrap")
if spearman_da is not None:
results["spearman"] = spearman_da.median(dim="bootstrap")
return results
def regridd_to_higher_spatial_resolution(ds1, ds2):
"""Regrid the coarser dataset to the finer dataset's resolution using nearest neighbor.
Parameters
----------
ds1 : xarray.Dataset
First dataset.
ds2 : xarray.Dataset
Second dataset. Both should have latitude ('lat') and longitude ('lon') as coordinates.
Returns
-------
xarray.Dataset
Regridded version of the coarser dataset to match the finer dataset.
xarray.Dataset
The finer dataset (unchanged).
"""
def _coord_spacing(coord):
if coord is None or coord.size < 2:
return None
diffs = np.diff(np.asarray(coord))
diffs = np.abs(diffs[~np.isnan(diffs)])
if diffs.size == 0:
return None
return float(np.nanmedian(diffs))
# lat_res_1 = abs(ds1["lat"][1] - ds1["lat"][0]).item()
# lon_res_1 = abs(ds1["lon"][1] - ds1["lon"][0]).item()
# lat_res_2 = abs(ds2["lat"][1] - ds2["lat"][0]).item()
# lon_res_2 = abs(ds2["lon"][1] - ds2["lon"][0]).item()
# as they are croped to the same extend the shape can be used as proxy:
lat_shape_1 = ds1["lat"].shape[0]
lon_shape_1 = ds1["lon"].shape[0]
lat_shape_2 = ds2["lat"].shape[0]
lon_shape_2 = ds2["lon"].shape[0]
# Identify the finer and coarser datasets
if (lat_shape_1 * lon_shape_1) == (lat_shape_2 * lon_shape_2):
return ds1, ds2
if (lat_shape_1 * lon_shape_1) < (lat_shape_2 * lon_shape_2):
coarse_ds, fine_ds = ds1, ds2
else:
coarse_ds, fine_ds = ds2, ds1
coarse_res = _coord_spacing(coarse_ds.get("lat")) or _coord_spacing(
coarse_ds.get("lon")
)
if coarse_res is None:
regridded_ds = coarse_ds.reindex(
lat=fine_ds["lat"], lon=fine_ds["lon"], method="nearest"
)
else:
regridded_ds = coarse_ds.reindex(
lat=fine_ds["lat"],
lon=fine_ds["lon"],
method="nearest",
tolerance=coarse_res,
)
if (
regridded_ds["lat"].shape != fine_ds["lat"].shape
or regridded_ds["lon"].shape != fine_ds["lon"].shape
):
debug_msg = (
f"Regridding resulted in unexpected coordinate shapes. "
f"Coarse lat shape: {coarse_ds['lat'].shape}, Coarse lon shape: {coarse_ds['lon'].shape}, "
f"Fine lat shape: {fine_ds['lat'].shape}, Fine lon shape: {fine_ds['lon'].shape}, "
f"Regridded lat shape: {regridded_ds['lat'].shape}, Regridded lon shape: {regridded_ds['lon'].shape}."
f"With bounds coarse lat: ({coarse_ds['lat'].min().item()}, {coarse_ds['lat'].max().item()}), "
f"With bounds fine lat: ({fine_ds['lat'].min().item()}, {fine_ds['lat'].max().item()}), "
f"With bounds regridded lat: ({regridded_ds['lat'].min().item()}, {regridded_ds['lat'].max().item()})"
)
logger.debug(debug_msg)
try:
aligned_coarse, aligned_fine = xr.align(regridded_ds, fine_ds, join="inner")
except Exception as e:
# If align fails for any reason, fall back to returning the best-effort regridded dataset
with ErrorLogger(logger):
logger.error(
f"Alignment of regridded dataset with fine dataset failed. Returning best-effort regridded dataset. Debug info: {debug_msg}"
)
raise e
else:
aligned_coarse, aligned_fine = regridded_ds, fine_ds
if coarse_ds is ds1:
return aligned_coarse, aligned_fine
return aligned_fine, aligned_coarse
def get_years_from_path(path, raise_exception=True, file_name="*.*"):
"""Get available years from a dataset folder structure or file."""
if path.is_dir():
if len(year_structure_paths(path, file_name=file_name)) == 0:
matching_files = list(path.rglob(file_name))
if not matching_files:
msg = f"No files matching pattern '{file_name}' found in directory {path}."
with ErrorLogger(logger):
raise ValueError(msg)
with get_dataset_from_path(matching_files, file_name=file_name) as ds:
if "time" in ds.coords:
return [int(y) for y in np.unique(ds.time.dt.year.data)]
msg = f"No year structure found in directory {path} and files do not contain a time coordinate to determine years."
with ErrorLogger(logger):
raise ValueError(msg)
return [int(p.name) for p in year_structure_paths(path, file_name=file_name)]
if path.is_file():
with get_xarray_ds_from_file(path, force_decending_y=True) as input_ds:
if "time" in input_ds.coords:
return [int(y) for y in np.unique(input_ds.time.dt.year.data)]
msg = f"File {path} does not contain a time coordinate to determine years."
with ErrorLogger(logger):
raise ValueError(msg)
if raise_exception:
msg = f"The provided path {path} is neither file nor directory."
with ErrorLogger(logger):
raise ValueError(msg)
return []
def get_available_years(
input_path,
ref_path,
year_slice=None,
direct_comp=True,
input_file_name="*.*",
ref_file_name="*.*",
):
"""Determine available years from constrains and datasets.
If no reference data is given it will only be the input years inside
the year slice.
"""
logger.info("Determining available years.")
# get all years from input data
years_in = get_years_from_path(input_path, file_name=input_file_name)
years_in.sort()
logger.debug(f"Input years: {years_in}")
# get all years from reference data
years_ref = get_years_from_path(
ref_path, raise_exception=False, file_name=ref_file_name
)
years_ref.sort()
logger.debug(f"Ref years: {years_ref}")
# find overlapping years
years = []
possible_years = years_in
if not direct_comp and years_ref:
possible_years.extend([y for y in years_ref if y not in possible_years])
possible_years.sort()
for year in possible_years:
if year_slice.start is not None and year < year_slice.start:
continue
if year_slice.stop is not None and year > year_slice.stop:
continue
if not years_ref or year in years_ref or not direct_comp:
years.append(year)
return years
def year_structure_paths(path: Path, file_name="*.*") -> bool:
"""Return any subdirectory of `path` with year structur.
That means any subdirectory whose name is exactly four digits (a
valid “year”), and that subdirectory contains at least one entry.
"""
if not path.is_dir():
return False
year_paths = []
year_re = re.compile(r"^\d{4}$")
for sub in path.iterdir():
# check that sub is dir, has a year as name and contains one dir or file
if (
sub.is_dir()
and year_re.fullmatch(sub.name)
and any(list(sub.rglob(file_name)))
):
year_paths.append(sub)
return year_paths
def infer_time_resolution_hours_from_files(files):
"""Infer dataset time resolution (hours) from one or more files."""
file_list = sorted(files)
if not file_list:
return None
max_probe_files = 24
if len(file_list) > max_probe_files:
# Probe a small consecutive subset to preserve local cadence.
file_list = file_list[:max_probe_files]
intra_file_diffs = []
start_times = []
for file in file_list:
with get_xarray_ds_from_file(file, force_decending_y=True) as ds:
if "time" not in ds.coords:
continue
times = np.asarray(ds.time.values)
if times.size == 0:
continue
start_times.append(times[0])
if times.size > 1:
times = times.astype("datetime64[ns]")
diffs = np.diff(times) / np.timedelta64(1, "h")
diffs = diffs[np.isfinite(diffs) & (diffs > 0)]
if diffs.size > 0:
intra_file_diffs.append(float(np.median(diffs)))
if intra_file_diffs:
return float(np.median(np.asarray(intra_file_diffs)))
if len(start_times) > 1:
start_times = np.unique(np.asarray(start_times, dtype="datetime64[ns]"))
start_times.sort()
diffs = np.diff(start_times) / np.timedelta64(1, "h")
diffs = diffs[np.isfinite(diffs) & (diffs > 0)]
if diffs.size > 0:
return float(np.median(diffs))
return None
[docs]
@log_arguments()
def gridded_data_evaluation( # noqa: PLR0913
input: EvalDataset,
ref: EvalDataset,
output_path,
coordinate_slice=None,
year_slice=None,
mask_da=None,
mask_var=None,
direct_comp=True,
n_bootstrap_years=None,
n_bootstrap_selections=None,
target_time_freq=None,
bias_only=False,
global_climate=False,
only_plot=False,
result_metric="all",
avaiable_mem=None,
n_cpus=1,
):
"""Validate a spatial variable by comparing dataset climatologies."""
output_path = Path(output_path)
input_path = input.path
ref_path = ref.path
logger.debug(
f"gridded_data_evaluation received mask as {type(mask_da).__name__}; "
f"data_vars={list(mask_da.data_vars) if hasattr(mask_da, 'data_vars') else None}; "
f"name={getattr(mask_da, 'name', None)}; mask_var={mask_var}"
)
output_path.mkdir(parents=True, exist_ok=True)
if only_plot and get_rel_stat_file(output_path, input.name, ref.name).is_file():
create_map_from_output(
output_path=output_path,
input_name=input.name,
ref_name=ref.name,
bias_only=bias_only,
global_climate=global_climate,
)
return
# when reading both datasets into memory, we need to ensure that we have enough memory for both
avaiable_mem = (
avaiable_mem / 2
if avaiable_mem is not None and ref_path is not None
else avaiable_mem
)
bootstrap_requested = (
n_bootstrap_years is not None
and n_bootstrap_selections is not None
and n_bootstrap_selections > 0
)
if bootstrap_requested:
if input_path.is_dir() and len(year_structure_paths(input_path)) == 0:
msg = (
"Bootstrapping requires year-structured input data directories (YYYY/...). "
f"Input path '{input_path}' has no year-folder structure."
)
with ErrorLogger(logger):
raise ValueError(msg)
if (
ref_path is not None
and ref_path.is_dir()
and len(year_structure_paths(ref_path)) == 0
):
msg = (
"Bootstrapping requires year-structured reference data directories (YYYY/...). "
f"Reference path '{ref_path}' has no year-folder structure."
)
with ErrorLogger(logger):
raise ValueError(msg)
available_years = get_available_years(
input_path,
ref_path,
year_slice,
direct_comp,
input_file_name=input.file_name,
ref_file_name=ref.file_name,
)
logger.info(f"Years {available_years} are available for comparison.")
if not available_years:
logger.error("Since no data is available the program is stoped.")
return
if ref_path is None:
# Only create statistics; do not compare
logger.info(
f"No ref file provided. Only creating a stat file for {input.name}."
)
output_name = f"{input.name}_stats.nc" if input.name is not None else "stats.nc"
output_name = output_name.replace(" ", "_")
if input_path.is_file():
# Write file stats to file
with get_xarray_ds_from_file(
input_path, chunking=True, available_mem_gib=10, force_decending_y=True
) as ds_in:
get_file_stats(
ds_in,
input.var,
input.factor,
coordinate_slice,
output=output_path / output_name,
avaiable_years=available_years, # keep parameter name as used elsewhere
)
elif (
n_bootstrap_years is not None
and n_bootstrap_selections > 0
and input_path.is_dir()
):
# Write file stats for each bootstrap selection
_ = Parallel(n_jobs=n_cpus)(
delayed(get_stats_one_pass)(
input_path,
input.var,
input.factor,
coordinate_slice,
n_bootstrap_years,
bootstrap_index,
output_path / output_name,
available_years=available_years,
file_name=input.file_name,
)
for bootstrap_index in range(n_bootstrap_selections)
)
elif input_path.is_dir():
# Stats for one dataset read from multiple files in a directory
get_stats_one_pass(
input_path,
input.var,
input.factor,
coordinate_slice,
output_path=output_path / output_name,
available_years=available_years,
file_name=input.file_name,
)
else:
with ErrorLogger(logger):
msg = "input path does not exist"
raise ValueError(msg)
elif (
n_bootstrap_years is not None
and n_bootstrap_selections > 0
and input_path.is_dir()
and ref_path.is_dir()
and not direct_comp
):
# Compare via bootstrapping
if only_plot:
stat_files = list(
output_path.glob(f"relative_stats_{input.name}_{ref.name}_*")
)
else:
stat_files = Parallel(n_jobs=n_cpus)(
delayed(compare_input_with_ref)(
input_path,
input.var,
output_path,
ref_path,
ref.var,
input.name,
ref.name,
input.factor,
ref.factor,
coordinate_slice,
n_bootstrap_years,
bootstrap_index,
available_years=available_years,
input_file_name=input.file_name,
ref_file_name=ref.file_name,
target_freq=target_time_freq,
bias_only=bias_only,
global_climate=global_climate,
mask_da=mask_da,
mask_var=mask_var,
result_metric=result_metric,
)
for bootstrap_index in range(n_bootstrap_selections)
)
stat_files = [file for file in stat_files if file is not None]
if stat_files:
results = evaluate_boostraping_stat_files(
stat_files, input_name=input.name, ref_name=ref.name
)
if (
"spearman" in results
and "rel_std" in results
and not bias_only
and not global_climate
):
plot_map(
**results,
output_path=output_path,
input_name=input.name,
ref_name=ref.name,
)
elif global_climate:
rel_mean_diff = (
100
* (
np.nanmean(results["input_clim"], axis=0)
- np.nanmean(results["ref_clim"], axis=0)
)
/ np.nanmean(results["ref_clim"], axis=0)
)
rel_mean_diff = np.where(
np.nanmax(results["ref_clim"], axis=0) < 0.1, np.nan, rel_mean_diff
)
plot_map_global_climate2(
rel_mean=rel_mean_diff,
diff_mean=np.nanmean(results["input_clim"], axis=0)
- np.nanmean(results["ref_clim"], axis=0),
output_path=output_path,
input_name=input.name,
ref_name=ref.name,
)
plot_map_local_climate(
input_clim=results["input_clim"],
ref_clim=results["ref_clim"],
input_name=input.name,
ref_name=ref.name,
output_path=output_path,
)
else:
plot_map_bias_only(
rel_mean=results["rel_mean"],
input_clim=results["input_clim"],
ref_clim=results["ref_clim"],
output_path=output_path,
input_name=input.name,
ref_name=ref.name,
)
else:
logger.error("There are no statfiles created from the evaluation.")
else:
logger.info("Compare without bootstrapping.")
compare_input_with_ref(
input_path,
input.var,
output_path,
ref_path,
ref.var,
input.name,
ref.name,
input.factor,
ref.factor,
coordinate_slice,
ncpus=n_cpus,
available_years=available_years,
direct_comp=direct_comp,
available_mem=avaiable_mem,
input_file_name=input.file_name,
ref_file_name=ref.file_name,
target_freq=target_time_freq,
bias_only=bias_only,
global_climate=global_climate,
mask_da=mask_da,
mask_var=mask_var,
result_metric=result_metric,
)