Solar Calculations#

In shadow flickering calculations, determining the sun’s path for a specified longitude and latitude is important for precise simulation of the shadow dynamics induced by wind turbines.

The altitude and azimuth positions of the sun fluctuate throughout the day and across seasons. Computing the sun’s path for a particular longitude and latitude allows us to model the dynamic sun positions over time, thereby capturing the temporal variations in shadow patterns.

The calculations presented in this chapter are based on the principles outlined in Quae.nl [5].

Hide code cell source
import os
from pathlib import Path
import sys
sys.path.append(str(Path(os.getcwd()).parent.parent))
from src.utils import print_code, solar_angles_to_vector, solar_position, add_solar_axis
import numpy as np
from IPython.core.display import HTML
import pandas as pd
import matplotlib.pyplot as plt
from cartopy.io.img_tiles import GoogleTiles
import cartopy.crs as ccrs
from cartopy.mpl.gridliner import LATITUDE_FORMATTER, LONGITUDE_FORMATTER
from datetime import datetime
import matplotlib.dates as mdates

Calculate Azimuth and Altitude of Sun#

The key objective of solar calculations is to estimate the azimuth and altitude of the sun at a specified longitude, latitude, date, and time. The following code snippet achieves excactly this:

HTML(print_code(solar_position))
def solar_position(date, lat, lng):
    """
    Calculate the azimuth and altitude of the sun for a given date, latitude and longitude.

    Parameters:
    date (datetime): Date to calculate for
    lat (float): Latitude in degrees
    lng (float): Longitude in degrees
    
    Returns:
    dict: Azimuth and altitude in radians
    """

    # Constants
    rad = np.pi / 180
    epochStart = datetime(1970, 1, 1) 
    J1970 = 2440588
    J2000 = 2451545
    dayMs = 24 * 60 * 60 * 1000
    e = rad * 23.4397 # obliquity of the Earth

    # Convert date to required formats
    ms = (date - epochStart).total_seconds() * 1000
    julian = ms / dayMs - 0.5 + J1970 
    days = julian - J2000

    # Calculate right ascension and declination
    M = rad * (357.5291 + 0.98560028 * days) # Solar mean anomaly
    C = rad * (1.9148 * np.sin(M) + 0.02 * np.sin(2 * M) + 0.0003 * np.sin(3 * M)) # Equation of center
    P = rad * 102.9372 # Perihelion of Earth
    L = M + C + P + np.pi # Ecliptic longitude
    dec = np.arcsin(np.sin(0) * np.cos(e) + np.cos(0) * np.sin(e) * np.sin(L)) 
    ra = np.arctan2(np.sin(L) * np.cos(e), np.cos(L))

    # Calculate sidereal time
    lw = rad * -lng
    st = rad * (280.16 + 360.9856235 * days) - lw

    # Calculate azimuth and altitude
    H = st - ra
    az = np.radians(180) + np.arctan2(np.sin(H), np.cos(H) * np.sin(rad * lat) - np.tan(dec) * np.cos(rad * lat))
    alt = np.arcsin(np.sin(rad * lat) * np.sin(dec) + np.cos(rad * lat) * np.cos(dec) * np.cos(H))
    return az, alt

Test of Script#

Here is a brief test of the script, where the azimuth and altitude of the sun are determined in Copenhagen on the 6th of June at 12 o’clock.

date = datetime(2023, 6, 15, 12, 0, 0)
lat = 55.692858003050134 # Copenhagen
lng = 12.599278148554639

az, alt = solar_position(date, lat, lng)

print(f"Azimuth: {np.degrees(az):.2f} degrees")
print(f"Altitude: {np.degrees(alt):.2f} degrees")
Azimuth: 200.86 degrees
Altitude: 56.33 degrees

Convertion to Unit Vector#

Thereafter, the altitude and azimuth position of the sun is converted into a unit vector, such that it can be used by the ray tracing algorithm.

HTML(print_code(solar_angles_to_vector))
def solar_angles_to_vector(azimuth, altitude):
    """
    Convert solar azimuth and altitude angles to a 3D vector

    Args:
        azimuth (float): Solar azimuth angle in radians
        altitude (float): Solar altitude angle in radians

    Returns:
        vec (np.ndarray): Normalized 3D vector representing sun direction from origin
    """
    azimuth = azimuth+np.pi/2

    # Calculate 3D vector components
    x = -np.cos(azimuth) * np.cos(altitude)
    y = np.sin(azimuth) * np.cos(altitude)
    z = np.sin(altitude)

    # Normalize to unit vector
    vec = np.array([x, y, z])
    vec /= np.linalg.norm(vec)

    return vec

Test of Script#

Below a small test of the script can be found. Note that the vector defines the direction vector from the ground towards the sun.

vec = solar_angles_to_vector(135, 179)
print(f"Components of unit vector:\nX: {vec[0]:.2f}\nY: {vec[1]:.2f}\nZ: {vec[2]:.2f}\n")
Components of unit vector:
X: -0.09
Y: 0.99
Z: 0.07

Solar Analemma#

An analemma is a figure-eight curve that illustrates the Sun’s changing position at the same time each day throughout the year. This curve can be generated by plotting the Sun’s location from a fixed spot on Earth daily or by graphing its declination against time. The size and shape of the analemma are influenced by the observer’s position, providing a visual representation of the Sun’s varying position throughout the year.

start_date = '2023-01-01 12:00:00'
end_date = '2023-12-31 12:00:00'
date_range = pd.date_range(start=start_date, end=end_date, freq="W")
sun_pos = np.empty([len(date_range), 2])
fig, ax = plt.subplots()
fig.suptitle("Solar Analemma")
for i, date in enumerate(date_range):
    az, alt = solar_position(date, lat, lng)
    sun_pos[i,:] = az, alt

scat = ax.scatter(*np.rad2deg(sun_pos).T, marker="o", c=date_range.strftime('%U').astype(int).tolist(), cmap='viridis')
cbar = plt.colorbar(scat, ax = ax)
cbar.set_label('Week of Year')
ax.set(xlabel = "Azimuth [deg]",
       ylabel = "Altitude [deg]",
       aspect = "auto")
ax.grid()
plt.show()
../../_images/b9b8e1e4dd779da95b8e19c805fabaa458e79ceb57adc266f6360ebe8eaa5820.png

Position of the sun throughout the day#

Using the previously displayed functions, the path of the sun throughout the day can be mapped you as below:

start_date = '2023-06-15 00:00:00'
end_date = '2023-06-15 23:59:59'
date_range = pd.date_range(start=start_date, end=end_date, freq="min")
sun_pos = np.zeros([len(date_range), 2])
sun_vec = np.zeros([len(date_range), 3])
fig, ax = plt.subplots()
for i, date in enumerate(date_range):
    az, alt = solar_position(date, lat, lng)
    sun_pos[i,:] = az, alt
    sun_vec[i,:] = solar_angles_to_vector((az), (alt))

hours_of_day = date_range.hour + date_range.minute / 60
scat = ax.scatter(*np.rad2deg(sun_pos).T, c = hours_of_day)
cbar = plt.colorbar(scat, ax=ax)
cbar.set_label("Hour of the Day")
ax.set(xlabel = "Azimuth [deg]",
       ylabel = "Altitude [deg]",
       aspect = "auto")
ax.grid()
plt.show()
../../_images/a29c4c3c73102fbe861e24076f2b7cdd4618589fdf8c80dcf0402cf49e7af933.png

Alternatively, the information can also be displayed using two plots as seen below:

# Plots for solar zenith and solar azimuth angles
fig, axs = plt.subplots(1, 2, figsize=(10, 5))
fig.suptitle('Solar Position')

# plot for solar zenith angle
axs[0].plot(date_range, np.rad2deg(sun_pos[:,0]))
axs[0].set_ylabel('Solar azimuth angle (degree)')
axs[0].set_xlabel('Time (hour)')
axs[0].xaxis.set_major_formatter(mdates.DateFormatter('%H'))

# plot for solar azimuth angle
axs[1].plot(date_range,  np.rad2deg(sun_pos[:,1]))
axs[1].set_ylabel('Solar altitude angle (degree)')
axs[1].set_xlabel('Time (hour)')
axs[1].xaxis.set_major_formatter(mdates.DateFormatter('%H'))

[ax.grid(True) for ax in axs.flatten()]
plt.tight_layout()
../../_images/5b4e1f71fd06531961cdadd561ba414f5fdc9df4d7bffbd89ac9ff08f0a1940d.png

Sun path on a polar graph#

Finally, utilizing the functions to showcase the sun path on a polar graph provides a clear visualization of the solar trajectory throughout the day and across seasons. This proves to be an effective tool for comprehending solar dynamics in various geographic locations.

positive_indices = np.where(sun_pos[:,1] > 0)[0]

if positive_indices.size > 0:
    first_azimuth, last_azimuth = sun_pos[positive_indices[[0, -1]], 0]

# Create a unit circle
theta = np.linspace(0, 2 * np.pi, 100)
circle_coords = np.array([np.cos(theta), np.sin(theta)])
sunrise_vec = np.array([[0, np.sin(first_azimuth)], [0, np.cos(first_azimuth)]])

fig, ax = plt.subplots()
ax.plot(*circle_coords, 'k', label = "Horizon") # circle
ax.plot([0, np.sin(first_azimuth)], [0, np.cos(first_azimuth)], 'r', label=f'Sunrise: {np.rad2deg(first_azimuth):.1f}°')
ax.plot([0, np.sin(last_azimuth)], [0, np.cos(last_azimuth)], 'b', label=f'Sunset: {np.rad2deg(last_azimuth):.1f}°')
ax.plot(sun_vec[:,0][sun_vec[:,2] > 0], sun_vec[:,1][sun_vec[:,2] > 0], "g-", label = "Path of Sun (Day)")
ax.legend(loc='upper center', bbox_to_anchor=(0.5, -0.2), ncol=4)

ax.set_aspect('equal', adjustable='box')
plt.title('Solar Position')
plt.grid()
plt.show()
../../_images/e44433eadea5393068ee8d8b01cf22b313d2ba433cafa315444fc95c266edab1.png

Furthermore, this solar position can when be plotted on a map.

proj = ccrs.PlateCarree()
size = 0.5 # size of map radius
extent = [lng - size, lng + size, lat - size, lat + size]# Specify the map extent (latitude and longitude bounds)

fig, ax = plt.subplots(subplot_kw={'projection': proj}, figsize=(8, 6))
ax.set_extent(extent, crs=proj)
imagery = GoogleTiles(style = "satellite") # Valid styles: street, satellite, terrain, only_streets
ax.add_image(imagery, 12) # 16
ax.set_xlabel('Longitude')
ax.set_ylabel('Latitude')
fig.suptitle(f"Solar Position for Longitude: {lng:.2f}, Latitude: {lat:.2f} and Date: " + start_date[:10])
ax2 = add_solar_axis(fig, ax)

ax2.plot(*circle_coords, 'k') # circle
ax2.plot([0, np.sin(first_azimuth)], [0, np.cos(first_azimuth)], 'r', label=f'Sunrise: {np.rad2deg(first_azimuth):.1f}°')
ax2.plot([0, np.sin(last_azimuth)], [0, np.cos(last_azimuth)], 'b', label=f'Sunset: {np.rad2deg(last_azimuth):.1f}°')
ax2.plot(sun_vec[:,0], sun_vec[:,1], label = "Path of Sun")
ax2.legend(loc = "upper left")

gl = ax.gridlines(crs=ccrs.PlateCarree(), draw_labels=True, linewidth=1, color='gray', alpha=0.5, linestyle='--')
gl.top_labels = gl.right_labels = False
gl.xformatter = LONGITUDE_FORMATTER
gl.yformatter = LATITUDE_FORMATTER

plt.show()
../../_images/c33ffcbef002a6b4de512b426cd4d979f8f3ea245525b3a2fbef476498d944f9.png