from __future__ import annotations
from logging import debug, info, warning
from os import PathLike
from pathlib import Path
from typing import Literal
import numpy as np
import scipy.sparse as sparse
import xarray as xr
from numpy.typing import NDArray
from scipy.sparse.linalg import eigsh
from .....utils.check.errclass import (
CifFileExistsError,
CifFileNotFoundError,
CifValueError,
)
from .....utils.geometry.dist_matrix import dist_matrix
from .....utils.hdf5 import _hdf5_lock
try:
from sklearn.neighbors import BallTree
except ImportError:
BallTree = None
EARTH_RADIUS = 6371.03
[docs]
def build_hcorrelations(
load_dir: str | PathLike[str],
dump_dir: str | PathLike[str],
hresol: str,
hresoldim: int,
zlat: NDArray[np.floating],
zlon: NDArray[np.floating],
sigma_sea: int,
sigma_land: int,
is_lbc: bool,
landseamask: NDArray[np.floating] | None = None,
projection: Literal["gps", "xy"] = "gps",
evalues_cutoff: float = 0.0,
crop_chi: bool = False,
use_sparse: bool = False,
n_modes: int | None = None,
sparse_cutoff: float = 0.1,
target_prec: float | None = None,
dump: bool = False,
) -> tuple[NDArray[np.floating], NDArray[np.floating]]:
"""Reads from cache file or compute the horizontal correlation matrix eigen values
and vectors.
Parameters
----------
load_dir : str or path-like
Directory to load precomputed eigen values and vectors from
dump_dir : str or path-like
Directory to dump computed eigen values and vectors in if 'dump' is True
hresol : str
Horizontal resolution type
hresoldim : int
Horizontal grid size
zlat : 2D array
Grid latitudes
zlon : 2D array
Grid longitudes
sigma_sea : int
Decay distance in km for correlation between sea cells
sigma_land : int
Decay distance in km for correlation between land cells
is_lbc : bool
Is lateral boundary condition
landseamask : 2D array, optional
Land-sea mask, pixels are 1 for land and 0 for sea, by default None
projection : 'gps' or 'xy', optional
Projection used to compute distances, by default "gps"
evalues_cutoff : float, optional
Truncates eigenvalues below this value, by default 0.0
crop_chi : bool, optional
Truncated eigen values and vector are cropped is True, otherwise they are
padded with zeros, by default False
use_sparse : bool, optional
Use sparse matrix with 'eigsh', by default False
n_modes : int | None, optional
Number of eigenvalues to compute for sparse matrix, by default None
sparse_cutoff : float, optional
Correlation value threshold for sparse arrays, correlation below this value are
set to zero, by default 0.1
target_prec : float, optional
Target precision for sparse matrix, by default 1e-2
dump : bool, optional
Dumps computed eigen values and vector if True, by default False
Returns
-------
1D array, 2D array
square roots of eigenvalues and eigenvectors
"""
nlat, nlon = zlat.shape
evalues, evectors = read_hcorr(
hcorr_dir=load_dir,
hresol=hresol,
hresoldim=hresoldim,
nlon=nlon,
nlat=nlat,
sigma_sea=sigma_sea,
sigma_land=sigma_land,
k=n_modes,
is_lbc=is_lbc,
missing_ok=True,
)
if evalues is None:
info(f"Computing hcorr for {nlat}/{nlon} domain")
# No correlation between land and sea if lsm = True
if landseamask is not None:
land_grid = landseamask.flatten() >= 0.5
sea_grid = landseamask.flatten() < 0.5
sigma = (
sigma_land * land_grid[:, np.newaxis] * land_grid[np.newaxis, :]
+ sigma_sea * sea_grid[:, np.newaxis] * sea_grid[np.newaxis, :]
)
if np.ma.isMaskedArray(sigma):
sigma = sigma.data
# Otherwise, isotropic correlation, takes sigma_land
else:
sigma = sigma_land # float
# Compute the eigen values and vectores
if use_sparse:
evalues, evectors = compute_hcorrel_sparse(
zlat, zlon, sigma, projection, n_modes, sparse_cutoff, target_prec
)
else:
evalues, evectors = compute_hcorrel_dense(zlat, zlon, sigma, projection)
# Re-ordering values
order = np.argsort(evalues)[::-1]
evalues = evalues[order]
evectors = evectors[:, order]
# Dumping to a binary file
if dump:
dump_hcorr(
evalues,
evectors,
hcorr_dir=dump_dir,
hresol=hresol,
hresoldim=hresoldim,
nlon=nlon,
nlat=nlat,
sigma_sea=sigma_sea,
sigma_land=sigma_land,
k=n_modes,
is_lbc=is_lbc,
)
# Truncating values < evalmin
mask = evalues >= evalues_cutoff
if crop_chi:
return np.sqrt(evalues[mask]), evectors[:, mask]
else:
evalues[~mask] = 0
return np.sqrt(evalues), evectors
[docs]
def compute_hcorrel_matrix(
zlat: NDArray[np.floating],
zlon: NDArray[np.floating],
sigma: int | NDArray[np.integer],
projection: Literal["gps", "xy"] = "gps",
) -> NDArray[np.floating]:
# Compute the correlation matrix
corr = dist_matrix(zlat, zlon, projection) # distance matrix -> corr = dx
np.divide(corr, sigma, out=corr) # divides by sigma -> corr = dx/sigma
np.negative(corr, out=corr) # takes the opposite -> corr = -dx/sigma
np.exp(corr, out=corr) # applies exponential -> corr = exp(-dx/sigma)
# Land sea mask
if isinstance(sigma, np.ndarray):
corr[sigma == 0.0] = 0.0
return corr
[docs]
def compute_hcorrel_dense(
zlat: NDArray[np.floating],
zlon: NDArray[np.floating],
sigma: int | NDArray[np.integer],
projection: Literal["gps", "xy"] = "gps",
) -> tuple[NDArray[np.floating], NDArray[np.floating]]:
corr = compute_hcorrel_matrix(zlat, zlon, sigma, projection)
evalues, evectors = np.linalg.eigh(corr)
return evalues, evectors
[docs]
def compute_hcorrel_sparse(
zlat: NDArray[np.floating],
zlon: NDArray[np.floating],
sigma: int | NDArray[np.integer],
projection: Literal["gps", "xy"] = "gps",
n_modes: int | None = None,
sparse_cutoff: float = 0.1,
target_prec: float | None = None,
) -> tuple[NDArray[np.floating], NDArray[np.floating]]:
N = zlat.size
use_tree = True
if not 0 < sparse_cutoff < 1:
raise CifValueError(
f"sparse_cutoff must be between 0 and 1, got {sparse_cutoff}"
)
if n_modes is None:
warning(
"WARNING: Number of eigen modes not specified, using highly unoptimized "
+ "default of 20% of the grid size"
)
k = zlon.size // 5
else:
k = n_modes
d_cut = -sigma * np.log(sparse_cutoff)
debug(f"Cutoff distance: {d_cut:.2f} km")
if BallTree is None:
use_tree = False
reason = "BallTree not available, please install sklearn to use it"
if projection != "gps":
use_tree = False
reason = f"projection is different from 'gps', got {projection!r}"
if isinstance(sigma, np.ndarray):
use_tree = False
reason = f"the use of land-sea mask is not compatible"
if use_tree:
# Convert to radians
coords_rad = np.radians(np.column_stack([zlat.ravel(), zlon.ravel()]))
sigma_rad = sigma / EARTH_RADIUS
d_cut_rad = d_cut / EARTH_RADIUS
debug(f"Computing sparse distance tree for domain with size {N}")
tree = BallTree(coords_rad, metric="haversine")
nghbr_idx, nghbr_dist_rad = tree.query_radius(
coords_rad,
r=d_cut_rad,
return_distance=True,
)
# Computing sparse data
dist_rad = np.concatenate(nghbr_dist_rad)
data = np.exp(-dist_rad / sigma_rad).astype(np.float64)
# Constructing sparse indices
lengths = np.fromiter((len(idx) for idx in nghbr_idx), dtype=np.int64, count=N)
rows = np.repeat(np.arange(N, dtype=np.int32), lengths)
cols = np.concatenate(nghbr_idx).astype(np.int32)
# Constructing sparse matrix
corr_sparse = sparse.coo_matrix((data, (rows, cols)), shape=(N, N)).tocsr()
else:
warning(
"WARNING: Falling back to unoptimized distance matrix computation "
+ f"because {reason}"
)
corr = compute_hcorrel_matrix(zlat, zlon, sigma, projection)
corr[corr <= sparse_cutoff] = 0.0
corr_sparse = sparse.coo_matrix(corr).tocsr()
corr_sparse = corr_sparse.maximum(corr_sparse.T)
debug(f"Sparse matrix density={corr_sparse.nnz / N**2:.4%}")
evalues, evectors = eigsh(
corr_sparse,
k=k,
which="LA",
ncv=min(N, max(2 * k + 1, 20)),
)
if target_prec is not None:
# Computing error on a single matrix row
i = N // 2
corr = evectors @ (evalues * evectors[i, :])
corr_ref = corr[corr > sparse_cutoff]
err = np.sqrt(np.mean((corr - corr_ref) ** 2))
if err > target_prec:
raise CifValueError(
f"Target presision on {target_prec:.3g} not reached, got {err:.3g}"
)
return evalues, evectors
[docs]
def get_hcorr_file(
hcorr_dir: str | PathLike[str],
hresol: str,
hresoldim: int,
nlon: int,
nlat: int,
sigma_sea: int,
sigma_land: int,
k: int | None = None,
is_lbc: bool = False,
legacy_path: bool = False,
) -> Path:
"""Returns the path to the horizontal correlations eigenvalues and eigenvectors
cache file corresdonding to the given parameters.
If 'legacy_path' is True return the legacy path is the file exists to ensure
compatibility with file generated with older versions
"""
if legacy_path:
# Legacy path to Numpy binary file
name = f"horcor_{hresol}_{hresoldim}_{nlon}x{nlat}_cs{sigma_sea}_cl{sigma_land}"
if is_lbc:
name += "_lbc"
path = Path(hcorr_dir, name + ".bin")
if path.exists():
return path
# Path to NetCDF file
name = f"horcor_{hresol}_{hresoldim}_{nlon}x{nlat}"
if sigma_sea == -999:
name += f"_c{sigma_land}km"
else:
name += f"_cs{sigma_sea}km_cl{sigma_land}km"
if k is not None:
name += f"_k{k}"
if is_lbc:
name += "_lbc"
return Path(hcorr_dir, name + ".nc")
[docs]
def dump_hcorr(
evalues: NDArray[np.floating],
evectors: NDArray[np.floating],
hcorr_dir: str | PathLike[str],
hresol: str,
hresoldim: int,
nlon: int,
nlat: int,
sigma_sea: int,
sigma_land: int,
k: int | None = None,
is_lbc: bool = False,
overwrite: bool = False,
) -> None:
"""Dumps the horizontal correlations eigenvalues and eigenvectors to a cache file
corresdonding to the given parameters
"""
path = get_hcorr_file(
hcorr_dir, hresol, hresoldim, nlon, nlat, sigma_sea, sigma_land, k, is_lbc
)
if path.exists() and not overwrite:
raise CifFileExistsError(f"'{path}' already exists")
# Get a different name for eigen-dim if eigenvalues are truncated
kdim = "n" if k is None else "k"
ds = xr.Dataset(
{
"evalues": ([kdim], evalues),
"evectors": (["n", kdim], evectors),
},
attrs={
"hresol": hresol,
"hresoldim": hresoldim,
"nlon": nlon,
"nlat": nlat,
"is_lbc": "true" if is_lbc else "false",
},
)
if sigma_sea == -999:
ds.attrs["sigma_sea"] = sigma_sea
ds.attrs["sigma_land"] = sigma_land
else:
ds.attrs["sigma"] = sigma_land
# Add a reconstructed correlation matrix row if eigenvalues are truncated
# It can be use to inspect residuals
if k is not None:
i = hresoldim // 2
corr = evectors @ (evalues * evectors[i, :])
ds["corr"] = (["lat", "lon"], corr.reshape((nlat, nlon)))
ds["corr"].attrs = {
"long_name": f"reconstructed correlation matrix row {i}",
}
# Dumping the NetCDF file
debug(f"Dumping horizontal correlations to '{path}'")
path.parent.mkdir(parents=True, exist_ok=True)
with _hdf5_lock:
ds.to_netcdf(path)
[docs]
def read_hcorr(
hcorr_dir: str | PathLike[str],
hresol: str,
hresoldim: int,
nlon: int,
nlat: int,
sigma_sea: int,
sigma_land: int,
k: int | None = None,
is_lbc: bool = False,
missing_ok: bool = False,
) -> tuple[NDArray[np.floating], NDArray[np.floating]] | tuple[None, None]:
"""Reads the horizontal correlations eigenvalues and eigenvectors form the cache file
corresdonding to the given parameters.
"""
path = get_hcorr_file(
hcorr_dir, hresol, hresoldim, nlon, nlat, sigma_sea, sigma_land, k, is_lbc, True
)
if not path.is_file():
if not missing_ok:
raise CifFileNotFoundError(f"'{path}' does not exists")
return None, None
debug(f"Reading horizontal correlations from '{path}'")
if path.suffix == ".bin":
# Legacy Numpy binary file
data = np.fromfile(path).reshape((-1, nlon * nlat))
evalues = data[0]
evectors = data[1:]
else:
# NetCDF file
with _hdf5_lock:
with xr.open_dataset(path) as ds:
evalues = ds.evalues.values
evectors = ds.evectors.values
evalues[evalues < 0] = 0.0
return evalues, evectors