Source code for skyborn.windspharm.tools

"""
Data management tools for windspharm.VectorWind and spharm.Spharmt.

This module provides utilities for preparing data for use with spherical harmonic
wind analysis, including dimension reordering, shape management, and coordinate
system handling.

Main Functions:
    prep_data: Prepare data for VectorWind input
    recover_data: Restore original data shape/order
    get_recovery: Create recovery function for multiple arrays
    reverse_latdim: Reverse latitude dimension order
    order_latdim: Ensure north-to-south latitude ordering
"""

from __future__ import annotations

from typing import Any, Callable, Dict, List, Tuple, Union

import numpy as np

__all__ = [
    "prep_data",
    "recover_data",
    "get_recovery",
    "reverse_latdim",
    "order_latdim",
]

# Type aliases
ArrayLike = Union[np.ndarray, np.ma.MaskedArray]
DimOrder = str
RecoveryInfo = Dict[str, Any]


def _order_dims(data: np.ndarray, dim_order: DimOrder) -> Tuple[np.ndarray, DimOrder]:
    """
    Reorder array dimensions to place lat/lon first.

    Internal function to reorder dimensions so that latitude ('y') and
    longitude ('x') are the first two dimensions.

    Parameters
    ----------
    data : ndarray
        Input data array
    dim_order : str
        String describing dimension order using 'x' for longitude, 'y' for latitude

    Returns
    -------
    reordered_data : ndarray
        Data with dimensions reordered
    new_order : str
        New dimension order string

    Raises
    ------
    ValueError
        If 'x' or 'y' not found in dim_order
    """
    if "x" not in dim_order or "y" not in dim_order:
        raise ValueError(
            "Dimension order must contain 'x' (longitude) and 'y' (latitude). "
            f"Got: '{dim_order}'"
        )

    # Find positions of longitude and latitude
    lon_pos = dim_order.lower().find("x")
    lat_pos = dim_order.lower().find("y")

    # Move longitude to front
    data = np.rollaxis(data, lon_pos)
    if lat_pos < lon_pos:
        lat_pos += 1

    # Move latitude to front (after longitude)
    data = np.rollaxis(data, lat_pos)

    # Create new dimension order string
    out_order = dim_order.replace("x", "").replace("y", "")
    out_order = "yx" + out_order

    return data, out_order


def _reshape_for_spharm(data: np.ndarray) -> Tuple[np.ndarray, Tuple[int, ...]]:
    """
    Reshape data for spherical harmonic analysis.

    Reshapes to (nlat, nlon, other) where 'other' combines all remaining dimensions.

    Parameters
    ----------
    data : ndarray
        Input data array with lat/lon as first two dimensions

    Returns
    -------
    reshaped : ndarray
        Reshaped array
    original_shape : tuple
        Original shape for recovery
    """
    new_shape = data.shape[:2] + (np.prod(data.shape[2:], dtype=int),)
    return data.reshape(new_shape), data.shape


[docs] def prep_data(data: ArrayLike, dim_order: DimOrder) -> Tuple[np.ndarray, RecoveryInfo]: """ Prepare data for input to VectorWind or spherical harmonic transforms. This function reorders and reshapes input data to the format required by VectorWind and spherical harmonic analysis routines. It returns the prepared data along with information needed to recover the original format. Parameters ---------- data : array_like Input data array with at least 2 dimensions. Must contain latitude and longitude dimensions. dim_order : str String specifying dimension order using 'x' for longitude and 'y' for latitude. Other characters represent additional dimensions. Returns ------- prepared_data : ndarray Data reshaped to (latitude, longitude, other) format recovery_info : dict Dictionary containing information needed to recover original format. Contains keys: 'intermediate_shape', 'intermediate_order', 'original_order' Raises ------ ValueError If dim_order doesn't contain both 'x' and 'y' See Also -------- recover_data : Recover original data format get_recovery : Create recovery function for multiple arrays Examples -------- >>> import numpy as np >>> # Prepare 4D data: (time=12, level=17, lat=73, lon=144) >>> data = np.random.randn(12, 17, 73, 144) >>> pdata, info = prep_data(data, 'tzyx') >>> print(pdata.shape) # (73, 144, 204) >>> >>> # Prepare data with arbitrary dimension labels >>> data = np.random.randn(144, 16, 73, 21) >>> pdata, info = prep_data(data, 'xayb') """ # Validate input data = np.asarray(data) if data.ndim < 2: raise ValueError( f"Data must have at least 2 dimensions, got {data.ndim}D array" ) # Reorder dimensions to put lat/lon first prepared_data, intermediate_order = _order_dims(data, dim_order) # Reshape for spherical harmonic analysis prepared_data, intermediate_shape = _reshape_for_spharm(prepared_data) # Create recovery information recovery_info = { "intermediate_shape": intermediate_shape, "intermediate_order": intermediate_order, "original_order": dim_order, } return prepared_data, recovery_info
[docs] def recover_data(prepared_data: np.ndarray, recovery_info: RecoveryInfo) -> np.ndarray: """ Recover original shape and dimension order of processed data. This function reverses the operations performed by prep_data, restoring the original shape and dimension ordering of data that has been processed by VectorWind or spherical harmonic methods. Parameters ---------- prepared_data : ndarray Data array with 2 or 3 dimensions where first two are (lat, lon) recovery_info : dict Recovery information dictionary from prep_data Returns ------- ndarray Data restored to original shape and dimension order See Also -------- prep_data : Prepare data for processing get_recovery : Create recovery function for multiple arrays Examples -------- >>> # Recover data processed with prep_data >>> original_data = recover_data(processed_data, info) >>> >>> # Use with VectorWind output >>> pdata, info = prep_data(wind_data, 'tzyx') >>> vw = VectorWind(u_prepared, v_prepared) >>> vorticity = vw.vorticity() >>> vort_original = recover_data(vorticity, info) """ if not isinstance(recovery_info, dict): raise TypeError("recovery_info must be a dictionary") required_keys = {"intermediate_shape", "intermediate_order", "original_order"} missing_keys = required_keys - set(recovery_info.keys()) if missing_keys: raise ValueError(f"Missing keys in recovery_info: {missing_keys}") # Restore intermediate shape (full dimensionality, spherical harmonic order) data = prepared_data.reshape(recovery_info["intermediate_shape"]) # Determine dimension reordering intermediate_order = recovery_info["intermediate_order"] original_order = recovery_info["original_order"] # Calculate how to reorder dimensions back to original roll_dims = np.array( [intermediate_order.index(dim) for dim in original_order[::-1]] ) # Apply dimension reordering for i in range(len(roll_dims)): # Roll axis to the front data = np.rollaxis(data, roll_dims[i]) # Update remaining roll positions roll_dims = np.where(roll_dims < roll_dims[i], roll_dims + 1, roll_dims) return data
_RECOVERY_DOCSTRING_TEMPLATE = """Shape and dimension recovery function. Recovers variable shape and dimension order according to: {recovery_info} Parameters ---------- *args : ndarray Variable number of arrays to recover Returns ------- list of ndarray Recovered arrays with original shape and dimension order """
[docs] def get_recovery(recovery_info: RecoveryInfo) -> Callable[..., List[np.ndarray]]: """ Create a recovery function for multiple arrays. Returns a function that can recover the original shape and dimension order of multiple arrays using a single recovery information dictionary. This is useful when processing multiple related arrays. Parameters ---------- recovery_info : dict Recovery information dictionary from prep_data Returns ------- recover_func : callable Function that takes variable number of arrays and returns them with original shape and dimension order restored See Also -------- prep_data : Prepare data for processing recover_data : Recover single array Examples -------- >>> # Prepare multiple wind components >>> u_prep, info = prep_data(u, 'tzyx') >>> v_prep, _ = prep_data(v, 'tzyx') >>> >>> # Process with VectorWind >>> vw = VectorWind(u_prep, v_prep) >>> sf, vp = vw.sfvp() >>> >>> # Create recovery function and use it >>> recover = get_recovery(info) >>> u_orig, v_orig, sf_orig, vp_orig = recover(u_prep, v_prep, sf, vp) """ def _recover_multiple(*arrays: np.ndarray) -> List[np.ndarray]: """Recover multiple arrays using the same recovery info.""" return [recover_data(array, recovery_info) for array in arrays] # Create nice documentation for the returned function info_str = "\n".join(f" '{key}': {value}" for key, value in recovery_info.items()) # Set function metadata _recover_multiple.__name__ = "recover" _recover_multiple.__doc__ = _RECOVERY_DOCSTRING_TEMPLATE.format( recovery_info=info_str ) return _recover_multiple
[docs] def reverse_latdim( u: ArrayLike, v: ArrayLike, axis: int = 0 ) -> Tuple[np.ndarray, np.ndarray]: """ Reverse the latitude dimension of wind components. Creates copies of the input wind components with the latitude dimension reversed. This is useful for converting between different latitude ordering conventions (e.g., north-to-south vs south-to-north). Parameters ---------- u, v : array_like Zonal and meridional wind components axis : int, default 0 Index of the latitude dimension to reverse Returns ------- u_reversed : ndarray Zonal wind component with latitude dimension reversed (copy) v_reversed : ndarray Meridional wind component with latitude dimension reversed (copy) See Also -------- order_latdim : Ensure north-to-south latitude ordering Examples -------- >>> # Reverse first dimension (default) >>> u_rev, v_rev = reverse_latdim(u, v) >>> >>> # Reverse third dimension >>> u_rev, v_rev = reverse_latdim(u, v, axis=2) """ u = np.asarray(u) v = np.asarray(v) if u.shape != v.shape: raise ValueError( f"Wind components must have same shape. " f"Got u: {u.shape}, v: {v.shape}" ) if axis >= u.ndim or axis < -u.ndim: raise ValueError( f"axis {axis} is out of bounds for array of dimension {u.ndim}" ) # Create slice to reverse the specified axis slice_list = [slice(None)] * u.ndim slice_list[axis] = slice(None, None, -1) # Apply reversal and make copies u_reversed = u.copy()[tuple(slice_list)] v_reversed = v.copy()[tuple(slice_list)] return u_reversed, v_reversed
[docs] def order_latdim( lat_coords: ArrayLike, u: ArrayLike, v: ArrayLike, axis: int = 0 ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: """ Ensure latitude dimension is ordered north-to-south. Checks the latitude coordinate array and ensures it runs from north to south. If necessary, reverses the latitude coordinates and the corresponding dimension in the wind components. Always returns copies of the input arrays. This helper is mainly for the NumPy ``standard.VectorWind`` interface, whose underlying spherical harmonic backend follows the SPHEREPACK convention that the first latitude row is the northernmost point and latitude indices increase southward. Parameters ---------- lat_coords : array_like Array of latitude coordinate values u, v : array_like Zonal and meridional wind components axis : int, default 0 Index of the latitude dimension in the wind components Returns ------- lat_ordered : ndarray Latitude coordinates ordered north-to-south (copy) u_ordered : ndarray Zonal wind component with latitude ordered north-to-south (copy) v_ordered : ndarray Meridional wind component with latitude ordered north-to-south (copy) See Also -------- reverse_latdim : Reverse latitude dimension Examples -------- >>> # Order latitude when it's the first dimension >>> lat_ord, u_ord, v_ord = order_latdim(lat, u, v) >>> >>> # Order latitude when it's the third dimension >>> lat_ord, u_ord, v_ord = order_latdim(lat, u, v, axis=2) """ lat_coords = np.asarray(lat_coords).copy() u = np.asarray(u) v = np.asarray(v) # Validate inputs if u.shape != v.shape: raise ValueError( f"Wind components must have same shape. " f"Got u: {u.shape}, v: {v.shape}" ) if axis >= u.ndim or axis < -u.ndim: raise ValueError( f"axis {axis} is out of bounds for array of dimension {u.ndim}" ) if len(lat_coords) != u.shape[axis]: raise ValueError( f"Length of lat_coords ({len(lat_coords)}) must match " f"size of axis {axis} ({u.shape[axis]}) in wind components" ) # Check if latitude needs to be reversed (south-to-north -> north-to-south) if lat_coords[0] < lat_coords[-1]: # Reverse latitude coordinates lat_coords = lat_coords[::-1] # Reverse wind components along the latitude axis u, v = reverse_latdim(u, v, axis=axis) else: # Already north-to-south, just make copies u, v = u.copy(), v.copy() return lat_coords, u, v