#!/usr/bin/env python
# Copyright (c) 2024-2026, Radarx developers.
# Distributed under the MIT License. See LICENSE for more info.
"""
CAPPI Retrieval
===============
.. autosummary::
:nosignatures:
:toctree: generated/
{}
"""
from __future__ import annotations
__all__ = ["create_cappi"]
__doc__ = __doc__.format("\n ".join(__all__))
import numpy as np
import xarray as xr
from scipy.spatial import cKDTree
_CARTESIAN_IDW_METHOD = "cartesian_idw"
_POLAR_VERTICAL_INTERPOLATION_METHOD = "polar_vertical_interpolation"
_HEIGHT_WINDOW_COMPOSITE_METHOD = "height_window_composite"
_DEFAULT_VERTICAL_TOLERANCES = {
_CARTESIAN_IDW_METHOD: 3000.0,
_POLAR_VERTICAL_INTERPOLATION_METHOD: None,
_HEIGHT_WINDOW_COMPOSITE_METHOD: 500.0,
}
_COMMON_VELOCITY_FIELDS = (
"velocity",
"mean_doppler_velocity",
"VRADH",
"VEL",
)
_COMMON_REFLECTIVITY_FIELDS = (
"reflectivity",
"reflectivity_horizontal",
"DBZH",
"DBZ",
"REF",
)
_METHOD_ALIASES = {
"cartesian": _CARTESIAN_IDW_METHOD,
_CARTESIAN_IDW_METHOD: _CARTESIAN_IDW_METHOD,
"polar": _POLAR_VERTICAL_INTERPOLATION_METHOD,
_POLAR_VERTICAL_INTERPOLATION_METHOD: _POLAR_VERTICAL_INTERPOLATION_METHOD,
"pseudo_cappi": _HEIGHT_WINDOW_COMPOSITE_METHOD,
_HEIGHT_WINDOW_COMPOSITE_METHOD: _HEIGHT_WINDOW_COMPOSITE_METHOD,
}
def _iter_sweeps(radar, sweeps=None):
if sweeps is not None:
return list(sweeps)
return [name for name in radar.children if "sweep" in name]
def _node_to_dataset(node):
try:
return node.to_dataset(inherit="all_coords")
except TypeError: # pragma: no cover
return node.to_dataset()
def _default_cappi_fields(ds):
"""Pick likely radar data variables, excluding metadata-like variables."""
skip = {
"x",
"y",
"z",
"time",
"range",
"azimuth",
"elevation",
"latitude",
"longitude",
"altitude",
"crs_wkt",
"sweep_number",
"sweep_fixed_angle",
"sweep_mode",
"follow_mode",
"prt_mode",
}
fields = []
for name, da in ds.data_vars.items():
if name in skip or da.ndim != 2:
continue
if "range" in da.dims and any(
dim in da.dims for dim in ("azimuth", "elevation", "time")
):
fields.append(name)
return fields
def _extract_scalar(ds, name):
if name not in ds:
return None
values = np.asarray(ds[name].values)
if values.size == 0:
return None
return values.reshape(-1)[0]
def _decode_if_bytes(value):
if isinstance(value, (bytes, np.bytes_)):
return value.decode()
return value
def _get_reference_time_axis(ds, axis_length):
if "time" not in ds:
return np.arange(axis_length, dtype=int)
values = np.asarray(ds["time"].values)
if values.size == axis_length:
return values.reshape(axis_length)
scalar = values.reshape(-1)[0]
return np.repeat(scalar, axis_length)
def _get_reference_metadata_value(ds, name, default=None):
value = _extract_scalar(ds, name)
if value is None:
return default
return _decode_if_bytes(value)
def _infer_field_name(ds, candidates):
for candidate in candidates:
if candidate in ds.data_vars:
return candidate
return None
def _collect_volume_points(
radar,
fields=None,
sweeps=None,
min_z=None,
max_z=None,
):
"""
Collect flattened gate coordinates and field values from georeferenced sweeps.
"""
volume_xyz = []
field_store = {}
for sw in _iter_sweeps(radar, sweeps=sweeps):
ds = _node_to_dataset(radar[sw])
for coord in ("x", "y", "z"):
if coord not in ds:
raise ValueError(
f"{sw} is missing '{coord}'. Run radar.xradar.georeference() first."
)
if fields is None:
these_fields = _default_cappi_fields(ds)
else:
these_fields = [field for field in fields if field in ds.data_vars]
x = np.asarray(ds["x"].values).ravel()
y = np.asarray(ds["y"].values).ravel()
z = np.asarray(ds["z"].values).ravel()
good_xyz = np.isfinite(x) & np.isfinite(y) & np.isfinite(z)
if min_z is not None:
good_xyz &= z >= min_z
if max_z is not None:
good_xyz &= z <= max_z
if not np.any(good_xyz):
continue
sweep_xyz = np.column_stack([x[good_xyz], y[good_xyz], z[good_xyz]])
volume_xyz.append(sweep_xyz)
for field in these_fields:
values = np.asarray(ds[field].values, dtype=float).ravel()[good_xyz]
good_values = np.isfinite(values)
if not np.any(good_values):
continue
field_store.setdefault(field, {"xyz": [], "values": []})
field_store[field]["xyz"].append(sweep_xyz[good_values])
field_store[field]["values"].append(values[good_values])
if not volume_xyz:
raise ValueError("No valid georeferenced gates found in requested volume.")
packed_fields = {}
for field, payload in field_store.items():
if payload["xyz"]:
packed_fields[field] = (
np.vstack(payload["xyz"]),
np.concatenate(payload["values"]),
)
if not packed_fields:
raise ValueError("No valid radar fields were found for CAPPI retrieval.")
return np.vstack(volume_xyz), packed_fields
def _build_target_grid(x, y):
"""
Build a regular target grid from 1D x/y vectors.
"""
if x is None or y is None:
raise ValueError("Both x and y must be provided together.")
return np.asarray(x, dtype=float), np.asarray(y, dtype=float)
def _make_grid_from_extent(
xyz,
x_res=1000.0,
y_res=1000.0,
padding=0.0,
):
"""
Build default x/y vectors from volume extent.
"""
xmin, ymin = np.nanmin(xyz[:, 0]), np.nanmin(xyz[:, 1])
xmax, ymax = np.nanmax(xyz[:, 0]), np.nanmax(xyz[:, 1])
xmin -= padding
xmax += padding
ymin -= padding
ymax += padding
x = np.arange(xmin, xmax + x_res, x_res, dtype=float)
y = np.arange(ymin, ymax + y_res, y_res, dtype=float)
return x, y
def _idw_interpolate_to_cappi(
xyz_src,
values,
x_tgt,
y_tgt,
z_tgt,
k=16,
search_radius=5000.0,
power=2.0,
vertical_scale=3.0,
min_neighbors=3,
):
"""
Interpolate one field to a constant-z plane using 3D anisotropic IDW.
"""
x_grid, y_grid = np.meshgrid(x_tgt, y_tgt)
z_grid = np.full_like(x_grid, float(z_tgt), dtype=float)
target_xyz = np.column_stack([x_grid.ravel(), y_grid.ravel(), z_grid.ravel()])
if values.size == 0:
return np.full(x_grid.shape, np.nan, dtype=float)
src_scaled = xyz_src.astype(float, copy=True)
tgt_scaled = target_xyz.astype(float, copy=True)
src_scaled[:, 2] *= vertical_scale
tgt_scaled[:, 2] *= vertical_scale
tree = cKDTree(src_scaled)
dists, idxs = tree.query(
tgt_scaled,
k=k,
distance_upper_bound=search_radius,
workers=-1,
)
if k == 1:
dists = dists[:, None]
idxs = idxs[:, None]
out = np.full(target_xyz.shape[0], np.nan, dtype=float)
source_count = len(values)
for i in range(target_xyz.shape[0]):
di = dists[i]
ii = idxs[i]
valid = np.isfinite(di) & (ii < source_count)
if np.count_nonzero(valid) < min_neighbors:
continue
di = di[valid]
ii = ii[valid]
if np.any(di == 0.0):
out[i] = values[ii[di == 0.0][0]]
continue
weights = 1.0 / np.power(di, power)
out[i] = np.sum(weights * values[ii]) / np.sum(weights)
return out.reshape(x_grid.shape)
def _cappi_reference_metadata(radar, sweeps=None):
for sw in _iter_sweeps(radar, sweeps=sweeps):
ds = _node_to_dataset(radar[sw])
coords = {}
for name in ("time", "latitude", "longitude", "altitude"):
value = _extract_scalar(ds, name)
if value is not None:
coords[name] = value
attrs = {
key: value
for key, value in ds.attrs.items()
if key in {"instrument_name", "radar_name", "site_name"}
}
return coords, attrs
return {}, {}
def _apply_velocity_texture_gate_filter(
ds,
velocity_field=None,
reflectivity_field=None,
texture_window=50,
texture_threshold=None,
reflectivity_min=-10.0,
reflectivity_max=75.0,
):
"""
Apply simple gate-level quality control using Doppler-velocity texture and
reflectivity limits.
"""
filtered = ds.copy()
if velocity_field is not None and velocity_field in filtered:
velocity_values = np.asarray(filtered[velocity_field].values, dtype=float)
finite_velocity = velocity_values[np.isfinite(velocity_values)]
if finite_velocity.size < 2:
velocity_texture = None
else:
velocity_texture = (
filtered[velocity_field]
.rolling(
range=texture_window,
min_periods=1,
center=True,
)
.std()
)
if velocity_texture is not None and texture_threshold is None:
texture_values = np.asarray(velocity_texture.values, dtype=float)
finite_texture = texture_values[np.isfinite(texture_values)]
if finite_texture.size == 0:
texture_threshold = np.inf
elif finite_texture.size == 1:
texture_threshold = abs(float(finite_texture[0]))
else:
texture_threshold = abs(
float(np.nanvar(finite_texture)) + float(np.nanstd(finite_texture))
)
if velocity_texture is not None:
filtered = filtered.where(velocity_texture < float(texture_threshold))
if reflectivity_field is not None and reflectivity_field in filtered:
filtered = filtered.where(
(filtered[reflectivity_field] >= reflectivity_min)
& (filtered[reflectivity_field] <= reflectivity_max)
)
return filtered
def _normalize_cappi_method(method):
try:
return _METHOD_ALIASES[method]
except KeyError as exc:
valid = ", ".join(sorted(_METHOD_ALIASES))
raise ValueError(
f"Unsupported CAPPI method '{method}'. Choose from: {valid}."
) from exc
def _resolve_vertical_tolerance(method, vertical_tolerance):
if vertical_tolerance is None:
return _DEFAULT_VERTICAL_TOLERANCES[method]
return float(vertical_tolerance)
def _create_cappi_cartesian_idw(
radar,
height,
fields=None,
sweeps=None,
x=None,
y=None,
x_res=1000.0,
y_res=1000.0,
padding=0.0,
vertical_window=3000.0,
k=16,
search_radius=5000.0,
power=2.0,
vertical_scale=3.0,
min_neighbors=3,
):
"""
Create CAPPI on a Cartesian ``x``/``y`` plane using 3D IDW.
Parameters
----------
radar : xarray.DataTree
Georeferenced radar volume. Each sweep must already contain ``x``, ``y``,
and ``z`` coordinates.
height : float
Requested CAPPI altitude in meters.
fields : list[str] or None, optional
Data variables to interpolate. If omitted, likely radar fields are
auto-selected.
sweeps : list[str] or None, optional
Sweep names to use. If omitted, all sweep groups are used.
x, y : 1D array-like or None, optional
Target horizontal grid coordinates. If omitted, they are built from the
source extent using ``x_res`` and ``y_res``.
x_res, y_res : float, optional
Target grid spacing in meters when ``x`` and ``y`` are not supplied.
padding : float, optional
Extra padding around the source extent in meters.
vertical_window : float, optional
Only gates within ``height +/- vertical_window`` are considered.
k : int, optional
Number of nearest neighbors for IDW.
search_radius : float, optional
Maximum search radius in meters in the anisotropically scaled space.
power : float, optional
IDW power parameter.
vertical_scale : float, optional
Vertical distance multiplier. Values greater than 1 penalize vertical
mismatch more strongly than horizontal mismatch.
min_neighbors : int, optional
Minimum valid neighbors required to produce a grid value.
Returns
-------
xarray.Dataset
CAPPI dataset on ``y``/``x`` coordinates with scalar ``z=height``.
"""
if height is None:
raise ValueError("height must be provided in meters.")
volume_xyz, field_store = _collect_volume_points(
radar,
fields=fields,
sweeps=sweeps,
min_z=height - vertical_window,
max_z=height + vertical_window,
)
if x is None or y is None:
x, y = _make_grid_from_extent(
volume_xyz,
x_res=x_res,
y_res=y_res,
padding=padding,
)
else:
x, y = _build_target_grid(x, y)
data_vars = {}
for field, (xyz_src, values) in field_store.items():
cappi = _idw_interpolate_to_cappi(
xyz_src=xyz_src,
values=values,
x_tgt=x,
y_tgt=y,
z_tgt=height,
k=k,
search_radius=search_radius,
power=power,
vertical_scale=vertical_scale,
min_neighbors=min_neighbors,
)
attrs = {}
ref_sweep = sweeps[0] if sweeps else _iter_sweeps(radar)[0]
ref_ds = _node_to_dataset(radar[ref_sweep])
attrs = ref_ds[field].attrs if field in ref_ds else {}
data_vars[field] = (("y", "x"), cappi, attrs)
ref_coords, ref_attrs = _cappi_reference_metadata(radar, sweeps=sweeps)
ds_cappi = xr.Dataset(
data_vars=data_vars,
coords={
"x": ("x", x),
"y": ("y", y),
"z": height,
**ref_coords,
},
attrs={
"product": "CAPPI",
"height": float(height),
"method": _CARTESIAN_IDW_METHOD,
"interpolation": "3D anisotropic IDW",
"vertical_window_m": float(vertical_window),
"search_radius_m": float(search_radius),
"idw_power": float(power),
"vertical_scale": float(vertical_scale),
"min_neighbors": int(min_neighbors),
**ref_attrs,
},
)
return ds_cappi
def _interp_to_reference(ds, field, az_ref, r_ref):
"""Interpolate a sweep field onto a reference azimuth/range grid."""
az_src = np.asarray(ds.azimuth.values, dtype=float)
data = np.asanyarray(ds[field].values)
if np.ma.isMaskedArray(data):
data = data.filled(np.nan)
data = np.asarray(data, dtype=float)
if data.shape[1] != len(r_ref):
raise ValueError(
f"Field '{field}' range dimension does not match reference grid."
)
order = np.argsort(az_src)
az_sorted = az_src[order]
data_sorted = data[order, :]
if len(az_sorted) == len(az_ref) and np.allclose(az_sorted, az_ref):
return data_sorted
out = np.full((len(az_ref), data_sorted.shape[1]), np.nan, dtype=float)
for j in range(data_sorted.shape[1]):
column = data_sorted[:, j]
valid = np.isfinite(column)
if np.count_nonzero(valid) == 0:
continue
az_valid = az_sorted[valid]
col_valid = column[valid]
if az_valid.size == 1:
out[:, j] = col_valid[0]
continue
az_periodic = np.concatenate([az_valid - 360.0, az_valid, az_valid + 360.0])
col_periodic = np.concatenate([col_valid, col_valid, col_valid])
sort_idx = np.argsort(az_periodic)
az_periodic = az_periodic[sort_idx]
col_periodic = col_periodic[sort_idx]
az_periodic, unique_idx = np.unique(az_periodic, return_index=True)
col_periodic = col_periodic[unique_idx]
out[:, j] = np.interp(az_ref, az_periodic, col_periodic)
return out
def _interp_vertical_column(z_col, v_col, height):
"""Linearly interpolate one vertical column to a target height."""
z_col = np.asarray(z_col, dtype=float)
v_col = np.asarray(v_col, dtype=float)
valid = np.isfinite(z_col) & np.isfinite(v_col)
if np.count_nonzero(valid) == 0:
return np.nan, True
z_valid = z_col[valid]
v_valid = v_col[valid]
order = np.argsort(z_valid)
z_valid = z_valid[order]
v_valid = v_valid[order]
z_valid, unique_idx = np.unique(z_valid, return_index=True)
v_valid = v_valid[unique_idx]
if z_valid.size == 1:
return np.nan, True
if height < z_valid[0] or height > z_valid[-1]:
return np.nan, True
return float(np.interp(height, z_valid, v_valid)), False
def _create_cappi_polar_vertical_interpolation(
radar,
height,
fields=None,
sweeps=None,
max_vertical_distance=None,
vertical_interpolation="linear",
):
"""
Create CAPPI in native polar coordinates using sweep-wise vertical interpolation.
"""
sweeps = _iter_sweeps(radar, sweeps=sweeps)
if not sweeps:
raise ValueError("No sweep groups found in radar DataTree.")
ref = _node_to_dataset(radar[sweeps[0]])
az = np.asarray(ref.azimuth.values, dtype=float)
r = np.asarray(ref.range.values, dtype=float)
time_axis = _get_reference_time_axis(ref, len(az))
if fields is None:
fields = _default_cappi_fields(ref)
if not fields:
raise ValueError("No 2D azimuth/range fields available for CAPPI retrieval.")
if vertical_interpolation not in {"linear", "nearest"}:
raise ValueError("vertical_interpolation must be either 'linear' or 'nearest'.")
z_stack = []
field_stack = {field: [] for field in fields}
for sw in sweeps:
ds = _node_to_dataset(radar[sw])
z_interp = _interp_to_reference(ds, "z", az, r)
z_stack.append(z_interp)
for field in fields:
data = _interp_to_reference(ds, field, az, r)
field_stack[field].append(data)
z_3d = np.stack(z_stack, axis=0)
max_available = np.nanmax(z_3d)
min_available = np.nanmin(z_3d)
if height > max_available or height < min_available:
raise ValueError(
f"Requested CAPPI height {height} m is outside available gate heights "
f"({min_available:.1f} to {max_available:.1f} m)."
)
idx = np.argmin(np.abs(z_3d - height), axis=0)
z_selected = np.take_along_axis(
z_3d,
idx[np.newaxis, :, :],
axis=0,
).squeeze(0)
if max_vertical_distance is None:
dz = np.diff(np.sort(z_3d.ravel()))
dz = dz[dz > 0]
tol = 2.0 * np.median(dz) if dz.size else 1000.0
else:
tol = float(max_vertical_distance)
mask = np.abs(z_selected - height) > tol
data_vars = {}
for field in fields:
data_3d = np.stack(field_stack[field], axis=0)
if vertical_interpolation == "nearest":
cappi = np.take_along_axis(
data_3d,
idx[np.newaxis, :, :],
axis=0,
).squeeze(0)
cappi = np.ma.array(cappi, mask=mask)
else:
cappi = np.full(z_3d.shape[1:], np.nan, dtype=float)
linear_mask = np.ones(z_3d.shape[1:], dtype=bool)
for i in range(z_3d.shape[1]):
for j in range(z_3d.shape[2]):
value, is_masked = _interp_vertical_column(
z_3d[:, i, j],
data_3d[:, i, j],
height,
)
cappi[i, j] = value
linear_mask[i, j] = is_masked
if np.all(linear_mask):
cappi = np.take_along_axis(
data_3d,
idx[np.newaxis, :, :],
axis=0,
).squeeze(0)
linear_mask = mask.copy()
cappi = np.ma.array(cappi, mask=linear_mask)
data_vars[field] = (("time", "range"), cappi, ref[field].attrs)
sweep_mode = _get_reference_metadata_value(
ref, "sweep_mode", "azimuth_surveillance"
)
follow_mode = _get_reference_metadata_value(ref, "follow_mode", "none")
prt_mode = _get_reference_metadata_value(ref, "prt_mode", "fixed")
longitude = _extract_scalar(ref, "longitude")
latitude = _extract_scalar(ref, "latitude")
radar_altitude = _extract_scalar(ref, "altitude")
data_vars.update(
{
"sweep_number": (("sweep",), np.array([0], dtype=int)),
"fixed_angle": (("sweep",), np.array([0.0], dtype=float)),
"sweep_mode": (("sweep",), np.array([sweep_mode], dtype=object)),
"follow_mode": (("sweep",), np.array([follow_mode], dtype=object)),
"prt_mode": (("sweep",), np.array([prt_mode], dtype=object)),
"sweep_start_ray_index": (("sweep",), np.array([0], dtype=int)),
"sweep_end_ray_index": (("sweep",), np.array([len(az) - 1], dtype=int)),
}
)
ds_cappi = xr.Dataset(
data_vars=data_vars,
coords={
"time": ("time", time_axis),
"azimuth": ("time", az),
"elevation": ("time", np.zeros_like(az, dtype=float)),
"range": ("range", r),
"sweep": ("sweep", np.array([0], dtype=int)),
**(
{
"longitude": longitude,
"latitude": latitude,
"altitude": float(height),
}
if longitude is not None and latitude is not None
else {}
),
},
attrs={
"product": "CAPPI",
"height": float(height),
"method": _POLAR_VERTICAL_INTERPOLATION_METHOD,
"vertical_interpolation": vertical_interpolation,
"max_vertical_distance": float(tol),
**(
{"radar_altitude": float(radar_altitude)}
if radar_altitude is not None
else {}
),
},
)
return ds_cappi
def _create_cappi_height_window_composite(
radar,
height,
fields=None,
sweeps=None,
height_window=500.0,
apply_quality_control=False,
velocity_field=None,
reflectivity_field=None,
texture_window=50,
texture_threshold=None,
reflectivity_min=-10.0,
reflectivity_max=75.0,
):
"""
Create a CAPPI-like horizontal composite by selecting gates within a
vertical window around the target height and compositing the closest gates
after sweep alignment.
"""
sweeps = _iter_sweeps(radar, sweeps=sweeps)
if not sweeps:
raise ValueError("No sweep groups found in radar DataTree.")
ref = _node_to_dataset(radar[sweeps[0]])
az = np.asarray(ref.azimuth.values, dtype=float)
r = np.asarray(ref.range.values, dtype=float)
time_axis = _get_reference_time_axis(ref, len(az))
if fields is None:
fields = _default_cappi_fields(ref)
if not fields:
raise ValueError(
"No 2D azimuth/range fields available for height-window compositing."
)
z_stack = []
field_stack = {field: [] for field in fields}
for sw in sweeps:
ds = _node_to_dataset(radar[sw])
if apply_quality_control:
ds = _apply_velocity_texture_gate_filter(
ds,
velocity_field=velocity_field,
reflectivity_field=reflectivity_field,
texture_window=texture_window,
texture_threshold=texture_threshold,
reflectivity_min=reflectivity_min,
reflectivity_max=reflectivity_max,
)
z_interp = _interp_to_reference(ds, "z", az, r)
z_stack.append(z_interp)
for field in fields:
field_stack[field].append(_interp_to_reference(ds, field, az, r))
z_3d = np.stack(z_stack, axis=0)
distance = np.abs(z_3d - float(height))
within_window = distance <= float(height_window)
best_idx = np.argmin(
np.where(within_window, distance, np.inf),
axis=0,
)
sweep_mode = _get_reference_metadata_value(
ref, "sweep_mode", "azimuth_surveillance"
)
follow_mode = _get_reference_metadata_value(ref, "follow_mode", "none")
prt_mode = _get_reference_metadata_value(ref, "prt_mode", "fixed")
longitude = _extract_scalar(ref, "longitude")
latitude = _extract_scalar(ref, "latitude")
radar_altitude = _extract_scalar(ref, "altitude")
data_vars = {}
for field in fields:
field_3d = np.stack(field_stack[field], axis=0)
composite = np.take_along_axis(
field_3d,
best_idx[np.newaxis, :, :],
axis=0,
).squeeze(0)
mask = ~np.take_along_axis(
within_window,
best_idx[np.newaxis, :, :],
axis=0,
).squeeze(0)
composite = np.ma.array(composite, mask=mask)
data_vars[field] = (("time", "range"), composite, ref[field].attrs)
data_vars.update(
{
"sweep_number": (("sweep",), np.array([0], dtype=int)),
"fixed_angle": (("sweep",), np.array([0.0], dtype=float)),
"sweep_mode": (("sweep",), np.array([sweep_mode], dtype=object)),
"follow_mode": (("sweep",), np.array([follow_mode], dtype=object)),
"prt_mode": (("sweep",), np.array([prt_mode], dtype=object)),
"sweep_start_ray_index": (("sweep",), np.array([0], dtype=int)),
"sweep_end_ray_index": (("sweep",), np.array([len(az) - 1], dtype=int)),
}
)
ds_cappi = xr.Dataset(
data_vars=data_vars,
coords={
"time": ("time", time_axis),
"azimuth": ("time", az),
"elevation": ("time", np.zeros_like(az, dtype=float)),
"range": ("range", r),
"sweep": ("sweep", np.array([0], dtype=int)),
**(
{
"longitude": longitude,
"latitude": latitude,
"altitude": float(height),
}
if longitude is not None and latitude is not None
else {}
),
},
attrs={
"product": "CAPPI",
"height": float(height),
"method": _HEIGHT_WINDOW_COMPOSITE_METHOD,
"height_window": float(height_window),
"apply_quality_control": bool(apply_quality_control),
**(
{"radar_altitude": float(radar_altitude)}
if radar_altitude is not None
else {}
),
},
)
return ds_cappi
[docs]
def create_cappi(
radar,
height,
method=_CARTESIAN_IDW_METHOD,
vertical_tolerance=None,
apply_filter=False,
*,
fields=None,
sweeps=None,
x=None,
y=None,
x_res=1000.0,
y_res=1000.0,
padding=0.0,
):
"""
Create a Constant Altitude Plan Position Indicator (CAPPI).
Parameters
----------
radar : xarray.DataTree
Georeferenced radar volume containing one or more sweep groups.
height : float
Target CAPPI altitude in meters.
method : {
"cartesian_idw",
"polar_vertical_interpolation",
"height_window_composite",
}, optional
CAPPI retrieval method. ``"cartesian_idw"`` performs 3D anisotropic
inverse-distance weighting on a regular ``x``/``y`` grid.
``"polar_vertical_interpolation"`` retains the native
``azimuth``/``range`` geometry and interpolates vertically across sweeps.
``"height_window_composite"`` forms a CAPPI-like composite by selecting
the nearest gates within a prescribed vertical window after sweep
alignment. Legacy aliases ``"cartesian"``, ``"polar"``, and
``"pseudo_cappi"`` are also accepted.
vertical_tolerance : float or None, optional
Maximum vertical distance above and below the requested CAPPI height,
in meters, used by the selected retrieval method. If omitted, a
method-specific default is used.
apply_filter : bool, optional
Apply built-in gate filtering when supported by the selected method.
Currently this is used by ``method="height_window_composite"``.
fields : list[str] or None, optional
Radar variables to retrieve. If omitted, likely 2D radar fields are
selected automatically.
sweeps : list[str] or None, optional
Sweep names to include in the retrieval. If omitted, all available
sweep groups are used.
x, y : array-like or None, optional
Target Cartesian grid coordinates in meters. Used only with
``method="cartesian_idw"``.
x_res, y_res : float, optional
Cartesian output spacing in meters when ``x`` and ``y`` are not
supplied. Used only with ``method="cartesian_idw"``.
padding : float, optional
Extra padding, in meters, applied to the Cartesian output domain.
Used only with ``method="cartesian_idw"``.
Returns
-------
xarray.Dataset
CAPPI dataset in either Cartesian ``(y, x)`` or native polar
``(azimuth, range)`` geometry, depending on the selected method.
"""
method = _normalize_cappi_method(method)
vertical_tolerance = _resolve_vertical_tolerance(method, vertical_tolerance)
if method == _CARTESIAN_IDW_METHOD:
return _create_cappi_cartesian_idw(
radar=radar,
height=height,
fields=fields,
sweeps=sweeps,
x=x,
y=y,
x_res=x_res,
y_res=y_res,
padding=padding,
vertical_window=vertical_tolerance,
)
if method == _POLAR_VERTICAL_INTERPOLATION_METHOD:
return _create_cappi_polar_vertical_interpolation(
radar=radar,
height=height,
fields=fields,
sweeps=sweeps,
max_vertical_distance=vertical_tolerance,
)
if method == _HEIGHT_WINDOW_COMPOSITE_METHOD:
ref_sweeps = _iter_sweeps(radar, sweeps=sweeps)
ref_ds = _node_to_dataset(radar[ref_sweeps[0]])
return _create_cappi_height_window_composite(
radar=radar,
height=height,
fields=fields,
sweeps=sweeps,
height_window=vertical_tolerance,
apply_quality_control=apply_filter,
velocity_field=_infer_field_name(ref_ds, _COMMON_VELOCITY_FIELDS),
reflectivity_field=_infer_field_name(ref_ds, _COMMON_REFLECTIVITY_FIELDS),
)
raise ValueError(f"Unsupported CAPPI method '{method}'.")