Source code for neural_lam.utils.time

"""Time-delta conversion and interior/boundary time-axis alignment."""

# Standard library
import datetime

# Third-party
import numpy as np
import xarray as xr

# Local
from .logging import log_on_rank_zero


[docs] def get_integer_time(tdelta: datetime.timedelta) -> tuple[int, str]: """ Express a :class:`datetime.timedelta` as an integer number of time units. Parameters ---------- tdelta : datetime.timedelta The time interval to convert. Returns ------- int Integer value of the timedelta in the largest unit that divides it exactly, or ``1`` if no such unit exists. str The time unit as a string (``'weeks'``, ``'days'``, ``'hours'``, ``'minutes'``, ``'seconds'``, ``'milliseconds'``, ``'microseconds'``). Returns ``'unknown'`` if no unit divides evenly. Examples -------- >>> from datetime import timedelta >>> get_integer_time(timedelta(days=14)) (2, 'weeks') >>> get_integer_time(timedelta(hours=5)) (5, 'hours') >>> get_integer_time(timedelta(milliseconds=1000)) (1, 'seconds') >>> get_integer_time(timedelta(days=0.001)) (1, 'unknown') """ total_seconds = tdelta.total_seconds() units = { "weeks": 604800, "days": 86400, "hours": 3600, "minutes": 60, "seconds": 1, "milliseconds": 0.001, "microseconds": 0.000001, } for unit, unit_in_seconds in units.items(): if total_seconds % unit_in_seconds == 0: return int(total_seconds / unit_in_seconds), unit return 1, "unknown"
[docs] def get_time_step(times: np.ndarray) -> np.timedelta64: """Calculate the (constant) time step from a 1D time array. Parameters ---------- times : np.ndarray 1D array of datetime64 or timedelta64 values. Returns ------- time_step : np.timedelta64 The constant spacing between successive values. Raises ------ ValueError If fewer than two values are given, or if the spacing is not constant. """ times = np.asarray(times) if times.size < 2: raise ValueError( "Cannot determine a time step from a time axis with " f"{times.size} value(s); at least 2 are required." ) time_diffs = np.diff(times) if not np.all(time_diffs == time_diffs[0]): raise ValueError( "Inconsistent time steps in data. " f"Found different time steps: {np.unique(time_diffs)}" ) return time_diffs[0]
def _requested_time_bounds( da_requested: xr.DataArray, da_available: xr.DataArray, da_requested_is_forecast: bool, da_available_is_forecast: bool, num_past_steps: int, num_future_steps: int, requested_max_lead: np.timedelta64 | None, ) -> tuple[np.datetime64, np.datetime64]: """Return the first and last ``da_requested`` time ``da_available`` covers. The window is measured along whichever ``da_available`` axis :meth:`WeatherDataset._window_forcing_in_time` walks: ``time`` for analysis data, ``elapsed_forecast_duration`` for a forecast. Parameters ---------- da_requested, da_available : xr.DataArray See :func:`get_time_crop_slice`. da_requested_is_forecast, da_available_is_forecast : bool See :func:`get_time_crop_slice`. num_past_steps, num_future_steps : int See :func:`get_time_crop_slice`. requested_max_lead : np.timedelta64 or None See :func:`get_time_crop_slice`. Returns ------- tuple of np.datetime64 Inclusive ``[first, last]`` bounds on the requested times. """ if not da_requested_is_forecast: requested_max_lead = np.timedelta64(0, "ns") elif requested_max_lead is None: requested_max_lead = da_requested.elapsed_forecast_duration.values.max() if da_available_is_forecast: times_available = da_available.analysis_time.values leads_available = da_available.elapsed_forecast_duration.values # Lead spacing sizes the window, not analysis spacing: stepping back # one launch buys `analysis / lead` window steps. step_window = get_time_step(leads_available) # The init time is never earlier than the requested time itself, so # the first launch alone bounds the start. first = ( times_available.min() + leads_available.min() + num_past_steps * step_window ) last = ( times_available.max() + leads_available.max() - requested_max_lead - num_future_steps * step_window ) else: times_available = da_available.time.values step_window = get_time_step(times_available) first = times_available.min() + num_past_steps * step_window last = ( times_available.max() - requested_max_lead - num_future_steps * step_window ) return first, last
[docs] def get_time_crop_slice( da_requested: xr.DataArray, da_available: xr.DataArray, da_requested_is_forecast: bool = False, da_available_is_forecast: bool = False, num_past_steps: int = 1, num_future_steps: int = 1, requested_max_lead: np.timedelta64 | None = None, ) -> tuple[str, slice]: """Return the ``da_requested`` dimension and slice ``da_available`` covers. Callers holding several dataarrays on the same time axis (interior state and forcing) apply this one slice to all of them so they stay aligned. Parameters ---------- da_requested : xr.DataArray Driving dataarray whose times must be supported (typically interior state). da_available : xr.DataArray Dataarray that must cover the requested windows (typically boundary forcing). da_requested_is_forecast, da_available_is_forecast : bool Whether each side is in forecast mode (``analysis_time`` + ``elapsed_forecast_duration`` dims) instead of plain ``time``. num_past_steps, num_future_steps : int Window size around each requested time, measured in ``da_available`` steps. requested_max_lead : np.timedelta64, optional Largest lead read from a forecast ``da_requested`` per sample; each ``analysis_time`` needs coverage out to it. Defaults to the full forecast length. Returns ------- tuple of (str, slice) The dimension to crop along (``"analysis_time"`` for a forecast ``da_requested``, else ``"time"``) and the positional slice to keep. Raises ------ ValueError If no requested time is covered at all. """ crop_dim = "analysis_time" if da_requested_is_forecast else "time" requested_tvals = da_requested[crop_dim].values first, last = _requested_time_bounds( da_requested, da_available, da_requested_is_forecast, da_available_is_forecast, num_past_steps, num_future_steps, requested_max_lead, ) first_valid_idx = int(np.searchsorted(requested_tvals, first, side="left")) last_valid_idx_plus_one = int( np.searchsorted(requested_tvals, last, side="right") ) if first_valid_idx >= last_valid_idx_plus_one: raise ValueError( f"`da_available` covers no `da_requested` `{crop_dim}` in " f"[{first}, {last}]; cannot align." ) return crop_dim, slice(first_valid_idx, last_valid_idx_plus_one)
[docs] def apply_time_crop( da: xr.DataArray, crop_dim: str, crop_slice: slice ) -> xr.DataArray: """Apply a :func:`get_time_crop_slice` result to ``da``, logging removals. Parameters ---------- da : xr.DataArray Dataarray to crop. crop_dim : str Dimension to crop along. crop_slice : slice Positional slice to keep. Returns ------- xr.DataArray ``da`` cropped, or unchanged when the slice keeps everything. """ n_removed_begin = crop_slice.start n_removed_end = da.sizes[crop_dim] - crop_slice.stop if n_removed_begin == 0 and n_removed_end == 0: return da log_on_rank_zero( f"Cropping `{da.name or 'dataarray'}` to align with the available " f"time coverage: removed {n_removed_begin} {crop_dim} steps at start " f"and {n_removed_end} at the end.", level="warning", ) return da.isel({crop_dim: crop_slice})