| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| """Computes TOA incident solar radiation compatible with ERA5. |
| |
| The Top-Of-the-Atmosphere (TOA) incident solar radiation is available in the |
| ERA5 dataset as the parameter `toa_incident_solar_radiation` (or `tisr`). This |
| represents the TOA solar radiation flux integrated over a period of one hour |
| ending at the timestamp given by the `datetime` coordinate. See |
| https://confluence.ecmwf.int/display/CKB/ERA5%3A+data+documentation and |
| https://codes.ecmwf.int/grib/param-db/?id=212. |
| """ |
|
|
| from collections.abc import Callable, Sequence |
| import dataclasses |
| import functools |
|
|
| import chex |
| import jax |
| import jax.numpy as jnp |
| import numpy as np |
| import pandas as pd |
| import xarray as xa |
|
|
|
|
| |
| _DEFAULT_INTEGRATION_PERIOD = pd.Timedelta(hours=1) |
|
|
| |
| |
| _DEFAULT_NUM_INTEGRATION_BINS = 360 |
|
|
| |
| |
| _JULIAN_YEAR_LENGTH_IN_DAYS = 365.25 |
|
|
| |
| |
| _J2000_EPOCH = 2451545.0 |
|
|
| |
| _SECONDS_PER_DAY = 60 * 60 * 24 |
|
|
|
|
| _TimestampLike = str | pd.Timestamp | np.datetime64 |
| _TimedeltaLike = str | pd.Timedelta | np.timedelta64 |
|
|
|
|
| |
| |
| |
| |
| TsiDataLoader = Callable[[], xa.DataArray] |
|
|
|
|
| |
| |
| |
| |
| |
| _REFERENCE_TSI = 1361.0 |
|
|
|
|
| def reference_tsi_data() -> xa.DataArray: |
| """A TsiDataProvider that returns a single reference TSI value.""" |
| return xa.DataArray( |
| np.array([_REFERENCE_TSI]), |
| dims=["time"], |
| coords={"time": np.array([0.0])}, |
| ) |
|
|
|
|
| def era5_tsi_data() -> xa.DataArray: |
| """A TsiDataProvider that returns ERA5 compatible TSI data.""" |
| |
| |
| |
| time = np.arange(1951.5, 2035.5, 1.0) |
| tsi = 0.9965 * np.array([ |
| |
| |
| 1365.7765, 1365.7676, 1365.6284, 1365.6564, 1365.7773, |
| 1366.3109, 1366.6681, 1366.6328, 1366.3828, 1366.2767, |
| 1365.9199, 1365.7484, 1365.6963, 1365.6976, 1365.7341, |
| 1365.9178, 1366.1143, 1366.1644, 1366.2476, 1366.2426, |
| 1365.9580, 1366.0525, 1365.7991, 1365.7271, 1365.5345, |
| 1365.6453, 1365.8331, 1366.2747, 1366.6348, 1366.6482, |
| 1366.6951, 1366.2859, 1366.1992, 1365.8103, 1365.6416, |
| 1365.6379, 1365.7899, 1366.0826, 1366.6479, 1366.5533, |
| 1366.4457, 1366.3021, 1366.0286, 1365.7971, 1365.6996, |
| |
| 1365.6121, 1365.7399, 1366.1021, 1366.3851, 1366.6836, |
| 1366.6022, 1366.6807, 1366.2300, 1366.0480, 1365.8545, |
| 1365.8107, 1365.7240, 1365.6918, |
| |
| 1365.6121, 1365.7399, 1366.1021, 1366.3851, 1366.6836, |
| 1366.6022, 1366.6807, 1366.2300, 1366.0480, 1365.8545, |
| 1365.8107, 1365.7240, 1365.6918, |
| |
| 1365.6121, 1365.7399, 1366.1021, 1366.3851, 1366.6836, |
| 1366.6022, 1366.6807, 1366.2300, 1366.0480, 1365.8545, |
| 1365.8107, 1365.7240, 1365.6918, |
| |
| ]) |
| return xa.DataArray(tsi, dims=["time"], coords={"time": time}) |
|
|
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
|
|
|
|
| _DEFAULT_TSI_DATA_LOADER: TsiDataLoader = era5_tsi_data |
|
|
|
|
| def get_tsi( |
| timestamps: Sequence[_TimestampLike], tsi_data: xa.DataArray |
| ) -> chex.Array: |
| """Returns TSI values for the given timestamps. |
| |
| TSI values are interpolated from the provided yearly TSI data. |
| |
| Args: |
| timestamps: Timestamps for which to compute TSI values. |
| tsi_data: A DataArray with a single dimension `time` that has coordinates in |
| units of years since 0000-1-1. E.g. 2023.5 corresponds to the middle of |
| the year 2023. |
| |
| Returns: |
| An Array containing interpolated TSI data. |
| """ |
| timestamps = pd.DatetimeIndex(timestamps) |
| timestamps_date = pd.DatetimeIndex(timestamps.date) |
| day_fraction = (timestamps - timestamps_date) / pd.Timedelta(days=1) |
| year_length = 365 + timestamps.is_leap_year |
| year_fraction = (timestamps.dayofyear - 1 + day_fraction) / year_length |
| fractional_year = timestamps.year + year_fraction |
| return np.interp(fractional_year, tsi_data.coords["time"].data, tsi_data.data) |
|
|
|
|
| @dataclasses.dataclass(frozen=True) |
| class _OrbitalParameters: |
| """Parameters characterising Earth's position relative to the Sun. |
| |
| The parameters characterize the position of the Earth in its orbit around the |
| Sun for specific points in time. Each attribute is an N-dimensional array |
| to represent orbital parameters for multiple points in time. |
| |
| Attributes: |
| theta: The number of Julian years since the Julian epoch J2000.0. |
| rotational_phase: The phase of the Earth's rotation along its axis as a |
| ratio with 0 representing the phase at Julian epoch J2000.0 at exactly |
| 12:00 Terrestrial Time (TT). Multiplying this value by `2*pi` yields the |
| phase in radians. |
| sin_declination: Sine of the declination of the Sun as seen from the Earth. |
| cos_declination: Cosine of the declination of the Sun as seen from the |
| Earth. |
| eq_of_time_seconds: The value of the equation of time, in seconds. |
| solar_distance_au: Earth-Sun distance in astronomical units. |
| """ |
|
|
| theta: chex.Array |
| rotational_phase: chex.Array |
| sin_declination: chex.Array |
| cos_declination: chex.Array |
| eq_of_time_seconds: chex.Array |
| solar_distance_au: chex.Array |
|
|
|
|
| def _get_j2000_days(timestamp: pd.Timestamp) -> float: |
| """Returns the number of days since the J2000 epoch. |
| |
| Args: |
| timestamp: A timestamp for which to compute the J2000 days. |
| |
| Returns: |
| The J2000 days corresponding to the input timestamp. |
| """ |
| return timestamp.to_julian_date() - _J2000_EPOCH |
|
|
|
|
| def _get_orbital_parameters(j2000_days: chex.Array) -> _OrbitalParameters: |
| """Computes the orbital parameters for the given J2000 days. |
| |
| Args: |
| j2000_days: Timestamps represented as the number of days since the J2000 |
| epoch. |
| |
| Returns: |
| Orbital parameters for the given timestamps. Each attribute of the return |
| value is an array containing the same dimensions as the input. |
| """ |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| theta = j2000_days / _JULIAN_YEAR_LENGTH_IN_DAYS |
| |
| |
| rotational_phase = j2000_days % 1.0 |
|
|
| |
| rel = 1.7535 + 6.283076 * theta |
| |
| rem = 6.240041 + 6.283020 * theta |
| |
| rlls = 4.8951 + 6.283076 * theta |
|
|
| |
| one = jnp.ones_like(theta) |
| sin_rel = jnp.sin(rel) |
| cos_rel = jnp.cos(rel) |
| sin_two_rel = jnp.sin(2.0 * rel) |
| cos_two_rel = jnp.cos(2.0 * rel) |
| sin_two_rlls = jnp.sin(2.0 * rlls) |
| cos_two_rlls = jnp.cos(2.0 * rlls) |
| sin_four_rlls = jnp.sin(4.0 * rlls) |
| sin_rem = jnp.sin(rem) |
| sin_two_rem = jnp.sin(2.0 * rem) |
|
|
| |
| rllls = jnp.dot( |
| jnp.stack( |
| [one, theta, sin_rel, cos_rel, sin_two_rel, cos_two_rel], axis=-1 |
| ), |
| jnp.array([4.8952, 6.283320, -0.0075, -0.0326, -0.0003, 0.0002]), |
| ) |
|
|
| |
| |
| repsm = 0.409093 |
|
|
| |
| sin_declination = jnp.sin(repsm) * jnp.sin(rllls) |
| cos_declination = jnp.sqrt(1.0 - sin_declination**2) |
|
|
| |
| eq_of_time_seconds = jnp.dot( |
| jnp.stack( |
| [ |
| sin_two_rlls, |
| sin_rem, |
| sin_rem * cos_two_rlls, |
| sin_four_rlls, |
| sin_two_rem, |
| ], |
| axis=-1, |
| ), |
| jnp.array([591.8, -459.4, 39.5, -12.7, -4.8]), |
| ) |
|
|
| |
| solar_distance_au = jnp.dot( |
| jnp.stack([one, sin_rel, cos_rel], axis=-1), |
| jnp.array([1.0001, -0.0163, 0.0037]), |
| ) |
|
|
| return _OrbitalParameters( |
| theta=theta, |
| rotational_phase=rotational_phase, |
| sin_declination=sin_declination, |
| cos_declination=cos_declination, |
| eq_of_time_seconds=eq_of_time_seconds, |
| solar_distance_au=solar_distance_au, |
| ) |
|
|
|
|
| def _get_solar_sin_altitude( |
| op: _OrbitalParameters, |
| sin_latitude: chex.Array, |
| cos_latitude: chex.Array, |
| longitude: chex.Array, |
| ) -> chex.Array: |
| """Returns the sine of the solar altitude angle. |
| |
| All computations are vectorized. Dimensions of all the inputs should be |
| broadcastable using standard NumPy rules. For example, if `op` has shape |
| `(T, 1, 1)`, `latitude` has shape `(1, H, 1)`, and `longitude` has shape |
| `(1, H, W)`, the return value will have shape `(T, H, W)`. |
| |
| Args: |
| op: Orbital parameters characterising Earth's position relative to the Sun. |
| sin_latitude: Sine of latitude coordinates. |
| cos_latitude: Cosine of latitude coordinates. |
| longitude: Longitude coordinates in radians. |
| |
| Returns: |
| Sine of the solar altitude angle for each set of orbital parameters and each |
| geographical coordinates. The returned array has the shape resulting from |
| broadcasting all the inputs together. |
| """ |
| solar_time = op.rotational_phase + op.eq_of_time_seconds / _SECONDS_PER_DAY |
| |
| hour_angle = 2.0 * jnp.pi * solar_time + longitude |
| |
| sin_altitude = ( |
| cos_latitude * op.cos_declination * jnp.cos(hour_angle) |
| + sin_latitude * op.sin_declination |
| ) |
| return sin_altitude |
|
|
|
|
| def _get_radiation_flux( |
| j2000_days: chex.Array, |
| sin_latitude: chex.Array, |
| cos_latitude: chex.Array, |
| longitude: chex.Array, |
| tsi: chex.Array, |
| ) -> chex.Array: |
| """Computes the instantaneous TOA incident solar radiation flux. |
| |
| Computes the instantanous Top-Of-the-Atmosphere (TOA) incident radiation flux |
| in W⋅m⁻² for the given timestamps and locations on the surface of the Earth. |
| See https://en.wikipedia.org/wiki/Solar_irradiance. |
| |
| All inputs are assumed to be broadcastable together using standard NumPy |
| rules. |
| |
| Args: |
| j2000_days: Timestamps represented as the number of days since the J2000 |
| epoch. |
| sin_latitude: Sine of latitude coordinates. |
| cos_latitude: Cosine of latitude coordinates. |
| longitude: Longitude coordinates in radians. |
| tsi: Total Solar Irradiance (TSI) in W⋅m⁻². This can be a scalar (default) |
| to use the same TSI value for all the inputs, or an array to allow TSI to |
| depend on the timestamps. |
| |
| Returns: |
| The instataneous TOA incident solar radiation flux in W⋅m⁻² for the given |
| timestamps and geographical coordinates. The returned array has the shape |
| resulting from broadcasting all the inputs together. |
| """ |
| op = _get_orbital_parameters(j2000_days) |
| |
| solar_factor = (1.0 / op.solar_distance_au) ** 2 |
| sin_altitude = _get_solar_sin_altitude( |
| op, sin_latitude, cos_latitude, longitude |
| ) |
| return tsi * solar_factor * jnp.maximum(sin_altitude, 0.0) |
|
|
|
|
| def _get_integrated_radiation( |
| j2000_days: chex.Array, |
| sin_latitude: chex.Array, |
| cos_latitude: chex.Array, |
| longitude: chex.Array, |
| tsi: chex.Array, |
| integration_period: pd.Timedelta, |
| num_integration_bins: int, |
| ) -> chex.Array: |
| """Returns the TOA solar radiation flux integrated over a time period. |
| |
| Integrates the instantaneous TOA solar radiation flux over a time period. |
| The input timestamps represent the end times of each integration period. |
| When the integration period is one hour this approximates the |
| `toa_incident_solar_radiation` (or `tisr`) parameter from the ERA5 dataset. |
| See https://confluence.ecmwf.int/display/CKB/ERA5%3A+data+documentation and |
| https://codes.ecmwf.int/grib/param-db/?id=212. |
| |
| All inputs are assumed to be broadcastable together using standard NumPy |
| rules. To approximate the integral, the instantaneous radiation is computed |
| at `num_integration_bins+1` time steps using `_get_radiation_flux` and |
| integrated using the trapezoidal rule. A dimension is appended at the end |
| of all inputs to compute the instantaneous radiation, which is then integrated |
| over to compute the final result. |
| |
| Args: |
| j2000_days: Timestamps represented as the number of days since the J2000 |
| epoch. These correspond to the end times of each integration period. |
| sin_latitude: Sine of latitude coordinates. |
| cos_latitude: Cosine of latitude coordinates. |
| longitude: Longitude in radians. |
| tsi: Total Solar Irradiance (TSI) in W⋅m⁻². |
| integration_period: Integration period. |
| num_integration_bins: Number of bins to divide the `integration_period` to |
| approximate the integral using the trapezoidal rule. |
| |
| Returns: |
| The TOA solar radiation flux integrated over the requested time period for |
| the given timestamps and geographical coordinates. Unit is J⋅m⁻² . |
| """ |
| |
| offsets = ( |
| pd.timedelta_range( |
| start=-integration_period, |
| end=pd.Timedelta(0), |
| periods=num_integration_bins + 1, |
| ) |
| / pd.Timedelta(days=1) |
| ).to_numpy() |
|
|
| |
| |
| |
| |
| fluxes = _get_radiation_flux( |
| j2000_days=jnp.expand_dims(j2000_days, axis=-1) + offsets, |
| sin_latitude=jnp.expand_dims(sin_latitude, axis=-1), |
| cos_latitude=jnp.expand_dims(cos_latitude, axis=-1), |
| longitude=jnp.expand_dims(longitude, axis=-1), |
| tsi=jnp.expand_dims(tsi, axis=-1), |
| ) |
|
|
| |
| |
| |
| dx = (integration_period / num_integration_bins) / pd.Timedelta(seconds=1) |
| return jax.scipy.integrate.trapezoid(fluxes, dx=dx) |
|
|
|
|
| _get_integrated_radiation_jitted = jax.jit( |
| _get_integrated_radiation, |
| static_argnames=["integration_period", "num_integration_bins"], |
| ) |
|
|
|
|
| def get_toa_incident_solar_radiation( |
| timestamps: Sequence[_TimestampLike], |
| latitude: chex.Array, |
| longitude: chex.Array, |
| tsi_data: xa.DataArray | None = None, |
| integration_period: _TimedeltaLike = _DEFAULT_INTEGRATION_PERIOD, |
| num_integration_bins: int = _DEFAULT_NUM_INTEGRATION_BINS, |
| use_jit: bool = False, |
| ) -> chex.Array: |
| """Computes the solar radiation incident at the top of the atmosphere. |
| |
| The solar radiation is computed for each element in `timestamps` for all the |
| locations on the grid determined by the `latitude` and `longitude` parameters. |
| |
| To approximate the `toa_incident_solar_radiation` (or `tisr`) parameter from |
| the ERA5 dataset, set `integration_period` to one hour (default). See |
| https://confluence.ecmwf.int/display/CKB/ERA5%3A+data+documentation and |
| https://codes.ecmwf.int/grib/param-db/?id=212. |
| |
| Args: |
| timestamps: Timestamps for which to compute the solar radiation. |
| latitude: The latitude coordinates in degrees of the grid for which to |
| compute the solar radiation. |
| longitude: The longitude coordinates in degrees of the grid for which to |
| compute the solar radiation. |
| tsi_data: A DataArray containing yearly TSI data as returned by a |
| `TsiDataLoader`. The default is to use ERA5 compatible TSI data. |
| integration_period: Timedelta to use to integrate the radiation, e.g. if |
| producing radiation for 1989-11-08 21:00:00, and `integration_period` is |
| "1h", radiation will be integrated from 1989-11-08 20:00:00 to 1989-11-08 |
| 21:00:00. The default value ("1h") matches ERA5. |
| num_integration_bins: Number of equally spaced bins to divide the |
| `integration_period` in when approximating the integral using the |
| trapezoidal rule. Performance and peak memory usage are affected by this |
| value. The default (360) provides a good approximation, but lower values |
| may work to improve performance and reduce memory usage. |
| use_jit: Set to True to use the jitted implementation, or False (default) to |
| use the non-jitted one. |
| |
| Returns: |
| An 3D array with dimensions (time, lat, lon) containing the total |
| top of atmosphere solar radiation integrated for the `integration_period` |
| up to each timestamp. |
| """ |
| |
| lat = jnp.radians(latitude).reshape((-1, 1)) |
| lon = jnp.radians(longitude) |
| sin_lat = jnp.sin(lat) |
| cos_lat = jnp.cos(lat) |
| integration_period = pd.Timedelta(integration_period) |
| if tsi_data is None: |
| tsi_data = _DEFAULT_TSI_DATA_LOADER() |
| tsi = get_tsi(timestamps, tsi_data) |
| fn = ( |
| _get_integrated_radiation_jitted if use_jit else _get_integrated_radiation |
| ) |
|
|
| |
| |
| |
| |
| |
| |
| |
| results = [] |
| for idx, timestamp in enumerate(timestamps): |
| results.append( |
| fn( |
| j2000_days=jnp.array(_get_j2000_days(pd.Timestamp(timestamp))), |
| sin_latitude=sin_lat, |
| cos_latitude=cos_lat, |
| longitude=lon, |
| tsi=tsi[idx], |
| integration_period=integration_period, |
| num_integration_bins=num_integration_bins, |
| ) |
| ) |
| return jnp.stack(results, axis=0) |
|
|
|
|
| def get_toa_incident_solar_radiation_for_xarray( |
| data_array_like: xa.DataArray | xa.Dataset, |
| tsi_data: xa.DataArray | None = None, |
| integration_period: _TimedeltaLike = _DEFAULT_INTEGRATION_PERIOD, |
| num_integration_bins: int = _DEFAULT_NUM_INTEGRATION_BINS, |
| use_jit: bool = False, |
| ) -> xa.DataArray: |
| """Computes the solar radiation incident at the top of the atmosphere. |
| |
| This method is a wrapper for `get_toa_incident_solar_radiation` using |
| coordinates from an Xarray and returning an Xarray. |
| |
| Args: |
| data_array_like: A xa.Dataset or xa.DataArray from which to take the time |
| and spatial coordinates for which to compute the solar radiation. It must |
| contain `lat` and `lon` spatial dimensions with corresponding coordinates. |
| If a `time` dimension is present, the `datetime` coordinate should be a |
| vector associated with that dimension containing timestamps for which to |
| compute the solar radiation. Otherwise, the `datetime` coordinate should |
| be a scalar representing the timestamp for which to compute the solar |
| radiation. |
| tsi_data: A DataArray containing yearly TSI data as returned by a |
| `TsiDataLoader`. The default is to use ERA5 compatible TSI data. |
| integration_period: Timedelta to use to integrate the radiation, e.g. if |
| producing radiation for 1989-11-08 21:00:00, and `integration_period` is |
| "1h", radiation will be integrated from 1989-11-08 20:00:00 to 1989-11-08 |
| 21:00:00. The default value ("1h") matches ERA5. |
| num_integration_bins: Number of equally spaced bins to divide the |
| `integration_period` in when approximating the integral using the |
| trapezoidal rule. Performance and peak memory usage are affected by this |
| value. The default (360) provides a good approximation, but lower values |
| may work to improve performance and reduce memory usage. |
| use_jit: Set to True to use the jitted implementation, or False to use the |
| non-jitted one. |
| |
| Returns: |
| xa.DataArray with dimensions `(time, lat, lon)` if `data_array_like` had |
| a `time` dimension; or dimensions `(lat, lon)` otherwise. The `datetime` |
| coordinates and those for the dimensions are copied to the returned array. |
| The array contains the total top of atmosphere solar radiation integrated |
| for `integration_period` up to the corresponding `datetime`. |
| |
| Raises: |
| ValueError: If there are missing coordinates or dimensions. |
| """ |
| missing_dims = set(["lat", "lon"]) - set(data_array_like.dims) |
| if missing_dims: |
| raise ValueError( |
| f"'{missing_dims}' dimensions are missing in `data_array_like`." |
| ) |
|
|
| missing_coords = set(["datetime", "lat", "lon"]) - set(data_array_like.coords) |
| if missing_coords: |
| raise ValueError( |
| f"'{missing_coords}' coordinates are missing in `data_array_like`." |
| ) |
|
|
| if "time" in data_array_like.dims: |
| timestamps = data_array_like.coords["datetime"].data |
| else: |
| timestamps = [data_array_like.coords["datetime"].data.item()] |
|
|
| radiation = get_toa_incident_solar_radiation( |
| timestamps=timestamps, |
| latitude=data_array_like.coords["lat"].data, |
| longitude=data_array_like.coords["lon"].data, |
| tsi_data=tsi_data, |
| integration_period=integration_period, |
| num_integration_bins=num_integration_bins, |
| use_jit=use_jit, |
| ) |
|
|
| if "time" in data_array_like.dims: |
| output = xa.DataArray(radiation, dims=("time", "lat", "lon")) |
| else: |
| output = xa.DataArray(radiation[0], dims=("lat", "lon")) |
|
|
| |
| |
| for k, coord in data_array_like.coords.items(): |
| if set(coord.dims).issubset(set(output.dims)): |
| output.coords[k] = coord |
| return output |
|
|