# (C) Crown Copyright, Met Office. All rights reserved.
#
# This file is part of 'IMPROVER' and is released under the BSD 3-Clause license.
# See LICENSE in the root of the repository for full licensing details.
"""Utilities to find the relative position of the sun."""
from datetime import datetime, timedelta
from typing import Union
import iris
import numpy as np
from iris.cube import Cube
from numpy import ndarray
from improver import BasePlugin
from improver.constants import DAYS_IN_YEAR, HOURS_IN_DAY, MINUTES_IN_HOUR
from improver.metadata.utilities import (
create_new_diagnostic_cube,
generate_mandatory_attributes,
)
from improver.utilities.spatial import lat_lon_determine, transform_grid_to_lat_lon
[docs]
def get_day_of_year(time: datetime) -> int:
"""Get day of the year from given datetime, starting at 0 for 1st Jan.
Args:
time:
Datetime from which to extract day-of-year.
Returns:
The day of year corresponding to the specified datetime.
"""
return time.timetuple().tm_yday - 1
[docs]
def get_hour_of_day(time: datetime) -> float:
"""Get the hour of day from datetime, with the minute values included as
a fraction of the hour, and seconds rounded to the nearest minute.
Where rounding seconds moves the datetime into the following day, the
hour_of_day returned is 24.0 rather than 0.0 to ensure the hour_of_day is
consistent with the day_of_year value associated with datetime.
Args:
time:
Datetime from which to extract utc-hour.
Returns:
The utc hour corresponding to the specified datetime. Minute values
are expressed as fraction of an hour.
"""
# Round times to nearest minute by adding seconds component to times
rounded_time = time + timedelta(seconds=time.second)
# Want to avoid the situation where rounding time takes us into the next day.
if rounded_time.day != time.day:
return float(HOURS_IN_DAY)
else:
return (
rounded_time.hour * MINUTES_IN_HOUR + rounded_time.minute
) / MINUTES_IN_HOUR
[docs]
def calc_solar_declination(day_of_year: int) -> float:
"""
Calculate the Declination for the day of the year.
Calculation equivalent to the calculation defined in
NOAA Earth System Research Lab Low Accuracy Equations
https://www.esrl.noaa.gov/gmd/grad/solcalc/sollinks.html
Args:
day_of_year:
Day of the year 0 to 365, 0 = 1st January
Returns:
Declination in degrees.North-South
"""
# Declination (degrees):
# = -(axial_tilt)*cos(360./orbital_year * day_of_year - solstice_offset)
if day_of_year < 0 or day_of_year > DAYS_IN_YEAR:
msg = "Day of the year must be between 0 and 365"
raise ValueError(msg)
solar_declination = -23.5 * np.cos(np.radians(0.9856 * day_of_year + 9.3))
return solar_declination
[docs]
def calc_solar_time(
longitudes: Union[float, ndarray],
day_of_year: int,
utc_hour: float,
normalise=False,
) -> Union[float, ndarray]:
"""
Calculate the Local Solar Time for each element of an array of longitudes.
Calculation equivalent to the calculation defined in
NOAA Earth System Research Lab Low Accuracy Equations
https://www.esrl.noaa.gov/gmd/grad/solcalc/sollinks.html
Args:
longitudes:
A single Longitude or array of Longitudes
longitudes needs to be between 180.0 and -180.0 degrees
day_of_year:
Day of the year 0 to 365, 0 = 1st January
utc_hour:
Hour of the day in UTC
normalise:
Normalise hour values to be in range 0-24
Returns:
Local solar time in hours.
"""
if day_of_year < 0 or day_of_year > DAYS_IN_YEAR:
msg = "Day of the year must be between 0 and 365"
raise ValueError(msg)
if utc_hour < 0.0 or utc_hour > 24.0:
msg = "Hour must be between 0 and 24.0"
raise ValueError(msg)
thetao = 2 * np.pi * day_of_year / DAYS_IN_YEAR
eqt = (
0.000075
+ 0.001868 * np.cos(thetao)
- 0.032077 * np.sin(thetao)
- 0.014615 * np.cos(2 * thetao)
- 0.040849 * np.sin(2 * thetao)
)
# Longitudinal Correction from the Grenwich Meridian
lon_correction = 24.0 * longitudes / 360.0
# Solar time (hours):
solar_time = utc_hour + lon_correction + eqt * 12 / np.pi
# Normalise the hours to the range 0-24
if normalise:
solar_time = solar_time % 24
return solar_time
[docs]
def calc_solar_hour_angle(
longitudes: Union[float, ndarray], day_of_year: int, utc_hour: float
) -> Union[float, ndarray]:
"""
Calculate the Solar Hour angle from the Local Solar Time.
Args:
longitudes:
A single Longitude or array of Longitudes
longitudes needs to be between 180.0 and -180.0 degrees
day_of_year:
Day of the year 0 to 365, 0 = 1st January
utc_hour:
Hour of the day in UTC
Returns:
Hour angles in degrees East-West.
"""
# Solar time (hours):
solar_time = calc_solar_time(longitudes, day_of_year, utc_hour)
# Hour angle (degrees):
solar_hour_angle = (solar_time - 12.0) * 15.0
return solar_hour_angle
[docs]
def calc_solar_elevation(
latitudes: Union[float, ndarray],
longitudes: Union[float, ndarray],
day_of_year: int,
utc_hour: float,
return_sine: bool = False,
) -> Union[float, ndarray]:
"""
Calculate the Solar elevation.
Args:
latitudes:
A single Latitude or array of Latitudes
latitudes needs to be between -90.0 and 90.0
longitudes:
A single Longitude or array of Longitudes
longitudes needs to be between 180.0 and -180.0
day_of_year:
Day of the year 0 to 365, 0 = 1st January
utc_hour:
Hour of the day in UTC in hours
return_sine:
If True return sine of solar elevation.
Default False.
Returns:
Solar elevation in degrees for each location.
"""
if np.min(latitudes) < -90.0 or np.max(latitudes) > 90.0:
msg = "Latitudes must be between -90.0 and 90.0"
raise ValueError(msg)
if day_of_year < 0 or day_of_year > DAYS_IN_YEAR:
msg = "Day of the year must be between 0 and 365"
raise ValueError(msg)
if utc_hour < 0.0 or utc_hour > 24.0:
msg = "Hour must be between 0 and 24.0"
raise ValueError(msg)
declination = calc_solar_declination(day_of_year)
decl = np.radians(declination)
hour_angle = calc_solar_hour_angle(longitudes, day_of_year, utc_hour)
rad_hours = np.radians(hour_angle)
lats = np.radians(latitudes)
# Calculate solar position:
solar_elevation = np.sin(decl) * np.sin(lats) + np.cos(decl) * np.cos(
lats
) * np.cos(rad_hours)
if not return_sine:
solar_elevation = np.degrees(np.arcsin(solar_elevation))
return solar_elevation
[docs]
def daynight_terminator(
longitudes: ndarray, day_of_year: int, utc_hour: float
) -> ndarray:
"""
Calculate the Latitude values of the daynight terminator
for the given longitudes.
Args:
longitudes:
Array of longitudes.
longitudes needs to be between 180.0 and -180.0 degrees
day_of_year:
Day of the year 0 to 365, 0 = 1st January
utc_hour:
Hour of the day in UTC
Returns:
latitudes of the daynight terminator
"""
if day_of_year < 0 or day_of_year > DAYS_IN_YEAR:
msg = "Day of the year must be between 0 and 365"
raise ValueError(msg)
if utc_hour < 0.0 or utc_hour > 24.0:
msg = "Hour must be between 0 and 24.0"
raise ValueError(msg)
declination = calc_solar_declination(day_of_year)
decl = np.radians(declination)
hour_angle = calc_solar_hour_angle(longitudes, day_of_year, utc_hour)
rad_hour = np.radians(hour_angle)
lats = np.arctan(-np.cos(rad_hour) / np.tan(decl))
lats = np.degrees(lats)
return lats
[docs]
class DayNightMask(BasePlugin):
"""
Plugin Class to generate a daynight mask for the provided cube
"""
[docs]
def __init__(self) -> None:
"""Initial the DayNightMask Object"""
self.night = 0
self.day = 1
self.irregular = False
def __repr__(self) -> str:
"""Represent the configured plugin instance as a string."""
result = "<DayNightMask : Day = {}, Night = {}>".format(self.day, self.night)
return result
[docs]
def _create_daynight_mask(self, cube: Cube) -> Cube:
"""
Create blank daynight mask cube
Args:
cube:
cube with the times and coordinates required for mask
Returns:
Blank daynight mask cube. The resulting cube will be the
same shape as the time, y, and x coordinate, other coordinates
will be ignored although they might appear as attributes
on the cube as it is extracted from the first slice.
"""
slice_coords = [cube.coord(axis="y"), cube.coord(axis="x")]
# To handle non-orthogonal spatial coordinates, i.e. multiple coordinates
# that share the same dimension, as in a spot-forecast.
spatial_coords = []
for dim in set([cube.coord_dims(crd) for crd in slice_coords]):
spatial_coords.append(cube.coords(dimensions=dim)[0])
if len(spatial_coords) != len(slice_coords):
self.irregular = True
slice_coords = spatial_coords
if cube.coord("time") in cube.coords(dim_coords=True):
slice_coords.insert(0, cube.coord("time"))
template = next(cube.slices(slice_coords))
demoted_coords = [
crd
for crd in cube.coords(dim_coords=True)
if crd not in template.coords(dim_coords=True)
]
for crd in demoted_coords:
template.remove_coord(crd)
attributes = generate_mandatory_attributes([template])
title_attribute = {"title": "Day-Night mask"}
data = np.full(template.data.shape, self.night, dtype=np.int32)
daynight_mask = create_new_diagnostic_cube(
"day_night_mask",
1,
template,
attributes,
optional_attributes=title_attribute,
data=data,
dtype=np.int32,
)
return daynight_mask
[docs]
def _daynight_lat_lon_cube(
self, mask_cube: Cube, day_of_year: int, utc_hour: float
) -> Cube:
"""
Calculate the daynight mask for the provided Lat Lon cube
Args:
mask_cube:
daynight mask cube - data initially set to self.night
day_of_year:
day of the year 0 to 365, 0 = 1st January
utc_hour:
Hour in UTC
Returns:
daynight mask cube - daytime set to self.day
"""
lons = mask_cube.coord("longitude").points
lats = mask_cube.coord("latitude").points
terminator_lats = daynight_terminator(lons, day_of_year, utc_hour)
if self.irregular:
lats_on_lon = lats
terminator_on_lon = terminator_lats
else:
lons_zeros = np.zeros_like(lons)
lats_zeros = np.zeros_like(lats).reshape(len(lats), 1)
lats_on_lon = lats.reshape(len(lats), 1) + lons_zeros
terminator_on_lon = lats_zeros + terminator_lats
dec = calc_solar_declination(day_of_year)
if dec > 0.0:
index = np.where(lats_on_lon >= terminator_on_lon)
else:
index = np.where(lats_on_lon < terminator_on_lon)
mask_cube.data[index] = self.day
return mask_cube
[docs]
def process(self, cube: Cube) -> Cube:
"""
Calculate the daynight mask for the provided cube. Note that only the
hours and minutes of the dtval variable are used. To ensure consistent
behaviour with changes of second or subsecond precision, the second
component is added to the time object. This means that when the hours
and minutes are used, we have correctly rounded to the nearest minute,
e.g.::
dt(2017, 1, 1, 11, 59, 59) -- +59 --> dt(2017, 1, 1, 12, 0, 58)
dt(2017, 1, 1, 12, 0, 1) -- +1 --> dt(2017, 1, 1, 12, 0, 2)
dt(2017, 1, 1, 12, 0, 30) -- +30 --> dt(2017, 1, 1, 12, 1, 0)
If the cube's time coordinate has time bounds the mid-point of these
bounds rather than the time point is used in the calculation. This
ensures that should a majority of the period described fall into
daylight hours, the data is not classified as falling at night. This
is an issue as the time point is always located at the end of the
time bounds. Without this approach weather symbols representing
e.g. 20-21Z will be marked as night symbols should the sun set at
20:59, which is not suitable.
Args:
cube:
input cube
Returns:
daynight mask cube, daytime set to self.day
nighttime set to self.night.
The resulting cube will be the same shape as
the time, y, and x coordinate, other coordinates
will be ignored although they might appear as attributes
on the cube as it is extracted from the first slice.
"""
daynight_mask = self._create_daynight_mask(cube)
modified_masks = iris.cube.CubeList()
for mask_cube in daynight_mask.slices_over("time"):
if mask_cube.coord("time").has_bounds():
lb, ub = cube.coord("time").cell(0).bound
dtval = lb + (ub - lb) / 2
else:
dtval = mask_cube.coord("time").cell(0).point
day_of_year = get_day_of_year(dtval)
utc_hour = get_hour_of_day(dtval)
trg_crs = lat_lon_determine(mask_cube)
# Grids that are not Lat Lon
if trg_crs is not None:
lats, lons = transform_grid_to_lat_lon(mask_cube)
solar_el = calc_solar_elevation(lats, lons, day_of_year, utc_hour)
mask_cube.data[np.where(solar_el > 0.0)] = self.day
else:
mask_cube = self._daynight_lat_lon_cube(
mask_cube, day_of_year, utc_hour
)
modified_masks.append(mask_cube)
return modified_masks.merge_cube()