Source code for utopya.eval.plots._utils

"""Utility functions that assist plot functions"""

import itertools
import logging
from typing import Dict, Tuple

import numpy as np
import xarray as xr

log = logging.getLogger(__name__)

# -----------------------------------------------------------------------------


[docs]def calc_pxmap_rectangles( *, x_coords: np.ndarray, y_coords: np.ndarray, x_scale: str = "lin", y_scale: str = "lin", default_pos: Tuple[float, float] = (0.0, 0.0), default_distance: float = 1.0, size_factor: float = 1.0, extend: bool = False, ) -> Tuple[xr.Dataset, Dict[str, tuple]]: """Calculates the positions and sizes of rectangles centered at the given coordinates in such a way that they fully cover the 2D space spanned by the coordinates. The exact values of the coordinates are arbitrary, but they should be ordered. The resulting rectangles will not necessarily be centered around the coordinate, but the distance between rectangle edges is set such that it lies halfway to the next coordinate in that dimension; the "halfway" is evaluated according to a certain scale. Args: x_coords (numpy.ndarray): The x coordinates y_coords (numpy.ndarray): The y coordinates x_scale (str, optional): The x-axis scale, used to determine rectangle sizes. y_scale (str, optional): The y-axis scale, used to determine rectangle sizes. default_pos (Tuple[float, float], optional): If any of the coordinates is not given, this information will be used for creating the rectangle specification. default_distance (float, optional): If any of the coordinates is not given or of size 1, this distance to the next data point is assumed in that dimension. In such a case, the scale does not have an effect on the resulting rectangle. size_factor (float, optional): Scaling factor for the rectangle sizes extend (bool, optional): Whether to extend the rectangles of the points at the border of the domain such that the coordinate is _not_ at the edge of the rectangle but in the bulk. Returns: Tuple[xarray.Dataset, Dict[str, tuple]]: The first tuple element is the dataset of rectangle specifications, each available as a data variable: - ``pos_x``, ``pos_y``: position of the lower-value coordinate of the rectangle. Together, this specifies the bottom left- hand corner of the rectangle (in a right-hand coordinate system) - ``len_x``, ``len_y``: lengths of the rectangle sides in the specified dimensions. Adding both these to the position leads to the top-right hand corner of the rectangle - ``rect_spec``: A matplotlib-compatible rectangle specification, i.e. (position, width, height) The second tuple element are the limits in x and y direction, given as dict with keys x and y. """ # Define some allowed linear and logarithmic scales LIN_SCALES = ["lin", "linear"] LOG_SCALES = ["log", "symlog"] CATEGORIAL_SCALES = ["cat", "categorical", "cat_at_value"] # Helper functions ........................................................ def determine_diffs_and_scale( coords: np.ndarray, *, scale: str, axis_name: str ) -> Tuple[np.ndarray, str]: """Given the coordinates and the scale, determine the distances between coordinates in one dimension. This is also used to check the scale. Args: coords (array-like): Sequence of coordinates scale (str): The scale to use axis_name (str): The name of the axis; used for error message only Returns: Tuple[numpy.ndarray, str]: Coordinate distances and the new scale Raises: ValueError: On invalid scale argument """ if scale in LIN_SCALES: diffs = np.diff(coords) elif scale in LOG_SCALES: diffs = np.diff(np.log(coords)) elif scale in CATEGORIAL_SCALES: # For categorial scale at value, use just some arbitrary integeres # for the coordinates; the scale can then be assumed linear. if scale != "cat_at_value": coords = list(range(len(coords))) diffs = np.diff(coords) scale = "lin" else: raise ValueError( "Invalid scale argument for {} axis: '{}'! " "Expected one of: {}" "".format( scale, axis_name, ", ".join(LIN_SCALES + LOG_SCALES + CATEGORIAL_SCALES), ) ) return diffs, scale def expand_diffs(diffs, *, extend: bool, default: float) -> np.ndarray: """Adds the boundary values to the given diffs""" if diffs.size < 1: # Need to use the default value diffs = np.array([default, default]) elif extend: # Add the first item to the beginning and the last to the end of # the diffs sequences to account for the borders diffs = np.insert(diffs, 0, diffs[0]) diffs = np.append(diffs, diffs[-1]) else: # No extension of borders desired --> add zeros to diffs diffs = np.insert(diffs, 0, 0.0) diffs = np.append(diffs, 0.0) return diffs def determine_limits(*, coords, sizes, scale: str) -> Tuple[float, float]: """Determine the limits of the space that is covered by the rectangles at the given coordinates and sizes. """ if scale in LIN_SCALES: return (float(coords[0]) - sizes[0], float(coords[-1]) + sizes[-1]) elif scale in LOG_SCALES: return ( np.exp(np.log(float(coords[0])) - sizes[0]), np.exp(np.log(float(coords[-1])) + sizes[-1]), ) # NOTE This point is not reached; scale is checked elsewhere # The functions below create a rectangle specifier given the x and y # positions and the desired sizes. # Have 4 explicit functions (one for each scale specification) because it's # very tedious to create the rectangle specifier in a general way ... Grml. def calc_linlin_rect( x: float, y: float, x_sizes: tuple, y_sizes: tuple ) -> tuple: """ Create the rectangle specification for a single rectangle embedded in a lin-lin plot. Args: x (float): The x-coordinate y (float): The y-coordinate x_sizes (tuple): The distance to the rectangle edges in x direction y_sizes (tuple): The distance to the rectangle edges in y direction Returns: tuple: (x pos. bottom left-hand corner, y pos. bottom left-hand corner, total x length, total y length) """ return ( x - x_sizes[0], y - y_sizes[0], x_sizes[0] + x_sizes[1], y_sizes[0] + y_sizes[1], ) def calc_linlog_rect(x, y, x_sizes, y_sizes) -> tuple: """See calc_linlin_rect""" return ( x - x_sizes[0], np.exp(np.log(y) - y_sizes[0]), x_sizes[0] + x_sizes[1], np.exp(np.log(y) + y_sizes[1]) - np.exp(np.log(y) - y_sizes[0]), ) def calc_loglin_rect(x, y, x_sizes, y_sizes) -> tuple: """See calc_linlin_rect""" return ( np.exp(np.log(x) - x_sizes[0]), y - y_sizes[0], np.exp(np.log(x) + x_sizes[1]) - np.exp(np.log(x) - x_sizes[0]), y_sizes[0] + y_sizes[1], ) def calc_loglog_rect(x, y, x_sizes, y_sizes) -> tuple: """See calc_linlin_rect""" return ( np.exp(np.log(x) - x_sizes[0]), np.exp(np.log(y) - y_sizes[0]), np.exp(np.log(x) + x_sizes[1]) - np.exp(np.log(x) - x_sizes[0]), np.exp(np.log(y) + y_sizes[1]) - np.exp(np.log(y) - y_sizes[0]), ) # Create a map to select the callable via `in LIN_SCALES` boolean key pair FUNC_MAP = { # x lin? y lin? (True, True): calc_linlin_rect, (True, False): calc_linlog_rect, (False, True): calc_loglin_rect, (False, False): calc_loglog_rect, } # ......................................................................... log.debug("Calculating rectangle positions and sizes ...") # Allow None, in which case the rectangle is set to a default position on # that axis x_coords = x_coords if x_coords is not None else [default_pos[0]] y_coords = y_coords if y_coords is not None else [default_pos[1]] # Check the lengths if len(x_coords) < 1 or len(y_coords) < 1: raise ValueError( "Coordinate sequences need to be at least of length " "one or None (to use defaults), but were: {} and {}" "".format(x_coords, y_coords) ) # Calculate distances, distinguishing between linear and logarithmic scale x_diffs, x_scale = determine_diffs_and_scale( x_coords, scale=x_scale, axis_name="x" ) y_diffs, y_scale = determine_diffs_and_scale( y_coords, scale=y_scale, axis_name="y" ) # Determine rectangle calculation method calc_rect = FUNC_MAP[(x_scale in LIN_SCALES, y_scale in LIN_SCALES)] # Need two additional borders: the lower border of the first rectangle and # the upper border of the last rectangle. x_diffs = expand_diffs(x_diffs, extend=extend, default=default_distance) y_diffs = expand_diffs(y_diffs, extend=extend, default=default_distance) # Divide by two to have the _sizes_ of the rectangle, not the distance # between the points, and then apply the size factor. x_sizes = x_diffs / 2.0 * size_factor y_sizes = y_diffs / 2.0 * size_factor # Create rects Dataset, which is populated below. It contains the positions # and sizes as separate data variables. shape = (len(x_coords), len(y_coords)) rect_spec = np.dtype( [("xy", (float, (2,))), ("width", float), ("height", float)] ) rects = xr.Dataset( data_vars=dict( pos_x=(("x", "y"), np.full(shape, np.nan)), pos_y=(("x", "y"), np.full(shape, np.nan)), len_x=(("x", "y"), np.full(shape, np.nan)), len_y=(("x", "y"), np.full(shape, np.nan)), rect_spec=(("x", "y"), np.full(shape, np.nan, dtype=rect_spec)), ), coords=dict(x=(("x",), x_coords), y=(("y",), y_coords)), ) # Now, iterate over the coordinate combinations, calculate the rectangle # specification, and store it in the data array. it = itertools.product( enumerate(rects.coords["x"]), enumerate(rects.coords["y"]) ) for (x_idx, x_coord), (y_idx, y_coord) in it: # Get the relevant sizes calculated from the diffs and calculate the # rectangle specification from that pos_x, pos_y, len_x, len_y = calc_rect( x_coord, y_coord, (x_sizes[x_idx], x_sizes[x_idx + 1]), # left # right (y_sizes[y_idx], y_sizes[y_idx + 1]), # bottom ) # top # Store in the corresponding data variable in the xr.Dataset rects["pos_x"][x_idx, y_idx] = pos_x rects["pos_y"][x_idx, y_idx] = pos_y rects["len_x"][x_idx, y_idx] = len_x rects["len_y"][x_idx, y_idx] = len_y rects["rect_spec"][x_idx, y_idx] = (pos_x, pos_y), len_x, len_y # Calculate limits of the space that is covered by the rectangles x_lims = determine_limits(coords=x_coords, sizes=x_sizes, scale=x_scale) y_lims = determine_limits(coords=y_coords, sizes=y_sizes, scale=y_scale) # Done. return rects, dict(x=x_lims, y=y_lims)