Source code for solar

import math
from datetime import datetime
from math import isnan


# The calculations here are based on Chapter 6 of
# '   McQuiston, F.C. and J.D. Parker.  1998.
# '   Heating, Ventilating, and Air Conditioning Analysis and Design, Third Edition.
# '   John Wiley and Sons, New York.

# This was originally a class called SolarPosition, but that was actually a poor design
# The location is capable of moving each call, as well as the date/time.
# So there wasn't anything that needed to persist, and the arguments got funny between instantiation and function calls
# Thus it is just a little library of functions


[docs] class Angular: """ This class combines a numeric value with an angular measurement unit. Proper construction should call constructor with either radians=x or degrees=y; not both. The constructor will calculate the complementary. The value of the angle can then be retrieved from the .degrees or .radians value as needed. Another class member, called .valued is available to determine if the class members contain meaningful values. If the constructor is called without either argument, the .valued variable is False, and the numeric vars are None. If the constructor is called with both arguments, they will be assigned if they agree to within a small tolerance; otherwise a ValueError is thrown. """ def __init__(self, radians: float = math.nan, degrees: float = math.nan) -> None: """ Constructor for the class. Call it with either radians or degrees, not both. >>> a = Angular(radians=math.pi) >>> b = Angular(degrees=180) """ self.valued = False self._radians = math.nan self._degrees = math.nan if isnan(radians) and isnan(degrees): raise ValueError("Neither Radians or Degrees given; failing") elif isnan(degrees) and not isnan(radians): self.valued = True self._radians = radians self._degrees = math.degrees(radians) elif isnan(radians) and not isnan(degrees): self.valued = True self._radians = math.radians(degrees) self._degrees = degrees else: # degrees and radians if abs(math.degrees(radians) - degrees) > 0.01: raise ValueError("Radians and Degrees both given but don't agree") self.valued = True self._radians = radians self._degrees = degrees def __str__(self) -> str: return f"{self.valued=}, {self._radians=}, {self._degrees=}"
[docs] def radians(self) -> float: return self._radians
[docs] def degrees(self) -> float: return self._degrees
[docs] def day_of_year(time_stamp: datetime) -> int: """ Calculates the day of year (1-366) given a Python datetime.datetime instance. Basically a wrapper to ensure it is a full datetime instance . in subsequent calculations. If the type is *not* datetime.datetime, this will throw a TypeError. :param time_stamp: The current date and time to be used in calculating day of year :returns: [dimensionless] The day of year, from 1 to 365 for non-leap years and 1-366 for leap years. """ if not type(time_stamp) is datetime: raise TypeError("Expected datetime.datetime type") return time_stamp.timetuple().tm_yday
[docs] def equation_of_time(time_stamp: datetime) -> float: """ Calculates the Equation of Time for a given date. I wasn't able to get the McQuiston equation to match the values in the given table. I ended up using a different formulation here: http://holbert.faculty.asu.edu/eee463/SolarCalcs.pdf. :param time_stamp: The current date and time to be used in this calculation of day of year. :returns: The equation of time, which is the difference between local civil time and local solar time """ degrees = (day_of_year(time_stamp) - 81.0) * (360.0 / 365.0) radians = math.radians(degrees) return 9.87 * math.sin(2 * radians) - 7.53 * math.cos(radians) - 1.5 * math.sin(radians)
[docs] def declination_angle(time_stamp: datetime) -> Angular: """ Calculates the Solar Declination Angle for a given date. The solar declination angle is the angle between a line connecting the center of the sun and earth and the projection of that line on the equatorial plane. Calculation is based on McQuiston. :param time_stamp: The current date and time to be used in this calculation of day of year. :returns: The solar declination angle in an Angular with both radian and degree versions """ radians = math.radians((day_of_year(time_stamp) - 1.0) * (360.0 / 365.0)) dec_angle_deg = 0.3963723 - 22.9132745 * math.cos(radians) + 4.0254304 * math.sin(radians) - 0.387205 * math.cos( 2.0 * radians) + 0.05196728 * math.sin(2.0 * radians) - 0.1545267 * math.cos( 3.0 * radians) + 0.08479777 * math.sin(3.0 * radians) return Angular(degrees=dec_angle_deg)
[docs] def local_civil_time(time_stamp: datetime, daylight_savings_on: bool, longitude: Angular, standard_meridian: Angular) -> float: """ Calculates the local civil time for a given set of time and location conditions. The local civil time is the local time based on prime meridian and longitude. :param time_stamp: The current date and time to be used in this calculation of day of year. :param daylight_savings_on: A flag if the current time is a daylight savings number. If True, the hour is decremented. :param longitude: [west] The current longitude, west of the prime meridian. For Golden, CO, the variable should be = 105.2 degrees. :param standard_meridian: [west] The local standard meridian for the location, west of the prime meridian. For Golden, CO, the variable should be = 105 degrees. :returns: [hours] Returns the local civil time in hours for the given date/time/location """ civil_hour = time_stamp.time().hour if daylight_savings_on: civil_hour -= 1 local_civil_time_hours = civil_hour + time_stamp.time().minute / 60.0 + time_stamp.time().second / 3600.0 - 4 * ( longitude.degrees() - standard_meridian.degrees()) / 60.0 return local_civil_time_hours
[docs] def local_solar_time(time_stamp: datetime, daylight_savings_on: bool, longitude: Angular, standard_meridian: Angular) -> float: """ Calculates the local solar time for a given set of time and location conditions. The local solar time is the local civil time that has been corrected by the equation of time. :param time_stamp: The current date and time to be used in this calculation of day of year. :param daylight_savings_on: A flag if the current time is a daylight savings number. If True, the hour is decremented. :param longitude: [west] The current longitude, west of the prime meridian. For Golden, CO, the variable should be = 105.2 degrees. :param standard_meridian: [west] The local standard meridian for the location, west of the prime meridian. For Golden, CO, the variable should be = 105 degrees. :returns: [hours] Returns the local solar time in hours for the given date/time/location """ return local_civil_time( time_stamp, daylight_savings_on, longitude, standard_meridian ) + equation_of_time(time_stamp) / 60.0
[docs] def hour_angle(time_stamp: datetime, daylight_savings_on: bool, longitude: Angular, standard_meridian: Angular) -> Angular: """ Calculates the current hour angle for a given set of time and location conditions. The hour angle is the angle between solar noon and the current solar angle, so at local solar noon the value is zero, in the morning it is below zero, and in the afternoon it is positive. :param time_stamp: The current date and time to be used in this calculation of day of year. :param daylight_savings_on: A flag if the current time is a daylight savings number. If True, the hour is decremented. :param longitude: [west] The current longitude west of the prime meridian. For Golden, CO, the variable should be = 105.2 degrees. :param standard_meridian: [west] The local standard meridian for the location, west of the prime meridian. For Golden, CO, the variable should be = 105 degrees. :returns: The hour angle in an Angular with both radian and degree versions """ local_solar_time_hours = local_solar_time(time_stamp, daylight_savings_on, longitude, standard_meridian) hour_angle_deg = 15.0 * (local_solar_time_hours - 12) return Angular(degrees=hour_angle_deg)
[docs] def altitude_angle(time_stamp: datetime, daylight_savings_on: bool, longitude: Angular, standard_meridian: Angular, latitude: Angular) -> Angular: """ Calculates the current solar altitude angle for a given set of time and location conditions. The solar altitude angle is the angle between the sun rays and the horizontal plane. :param time_stamp: The current date and time to be used in this calculation of day of year. :param daylight_savings_on: A flag if the current time is a daylight savings number. If True, the hour is decremented. :param longitude: [degrees west] The current longitude in degrees west of the prime meridian. For Golden, CO, the variable should be = 105.2. :param standard_meridian: [west] The local standard meridian for the location, west of the prime meridian. For Golden, CO, the variable should be = 105 degrees. :param latitude: [north] The local latitude for the location, north of the equator. For Golden, CO, the variable should be = 39.75 degrees. :returns: [Angular] The solar altitude angle in an Angular with both radian and degree versions """ declination_radians = declination_angle(time_stamp).radians() hour_radians = hour_angle(time_stamp, daylight_savings_on, longitude, standard_meridian).radians() altitude_radians = math.asin( math.cos(latitude.radians()) * math.cos(declination_radians) * math.cos(hour_radians) + math.sin( latitude.radians()) * math.sin(declination_radians)) return Angular(radians=altitude_radians)
[docs] def azimuth_angle(time_stamp: datetime, daylight_savings_on: bool, longitude: Angular, standard_meridian: Angular, latitude: Angular) -> Angular: """ Calculates the current solar azimuth angle for a given set of time and location conditions. The solar azimuth angle is the angle in the horizontal plane between due north and the sun. It is measured clockwise, so that east is +90 degrees and west is +270 degrees. Throws if the angle cannot be calculated because the sun is down :param time_stamp: The current date and time to be used in this calculation of day of year. :param daylight_savings_on: A flag if the current time is a daylight savings number. If True, the hour is decremented. :param longitude: [west] The current longitude west of the prime meridian. For Golden, CO, the variable should be = 105.2 degrees. :param standard_meridian: [west] The local standard meridian for the location, west of the prime meridian. For Golden, CO, the variable should be = 105 degrees. :param latitude: [north] The local latitude for the location, north of the equator. For Golden, CO, the variable should be = 39.75 degrees. :returns: [Angular] The solar azimuth angle in an Angular with both radian and degree versions. """ declination_radians = declination_angle(time_stamp).radians() altitude = altitude_angle(time_stamp, daylight_savings_on, longitude, standard_meridian, latitude) if altitude.degrees() < 0: # sun is down raise ValueError("Cannot calculate azimuth angle because sun is down") hour_radians = hour_angle(time_stamp, daylight_savings_on, longitude, standard_meridian).radians() acos_from_south = math.acos( (math.sin(altitude.radians()) * math.sin(latitude.radians()) - math.sin(declination_radians)) / ( math.cos(altitude.radians()) * math.cos(latitude.radians()))) if hour_radians < 0: azimuth_from_south = acos_from_south else: azimuth_from_south = -acos_from_south azimuth_angle_radians = math.radians(180) - azimuth_from_south return Angular(radians=azimuth_angle_radians)
[docs] def wall_azimuth_angle(time_stamp: datetime, daylight_savings_on: bool, longitude: Angular, standard_meridian: Angular, latitude: Angular, surface_azimuth: Angular) -> Angular: """ Calculates the current wall azimuth angle for a given set of time/location conditions, and a surface orientation. The wall azimuth angle is the angle in the horizontal plane between the solar azimuth and the vertical wall's outward facing normal vector. Throws if the sun is behind the surface or the sun is down. :param time_stamp: The current date and time to be used in this calculation of day of year. :param daylight_savings_on: A flag if the current time is a daylight savings number. If True, the hour is decremented. :param longitude: [west] The current longitude west of the prime meridian. For Golden, CO, the variable should be = 105.2 degrees. :param standard_meridian: [west] The local standard meridian for the location, west of the prime meridian. For Golden, CO, the variable should be = 105 degrees. :param latitude: [north] The local latitude for the location, north of the equator. For Golden, CO, the variable should be = 39.75 degrees. :param surface_azimuth: [CW from North] The angle between north and the outward facing normal vector of the wall, measured as positive clockwise from south (southwest facing surface: 225 degrees, northwest facing surface: 315 degrees) :returns: [Angular] The wall azimuth angle in an Angular with both radian and degree versions. """ this_surface_azimuth_deg = surface_azimuth.degrees() % 360 try: solar_azimuth = azimuth_angle(time_stamp, daylight_savings_on, longitude, standard_meridian, latitude).degrees() except ValueError: # TODO: Make SunIsDownException raise ValueError("Cannot calculate wall azimuth angle because sun is down") from None wall_azimuth_degrees = solar_azimuth - this_surface_azimuth_deg if wall_azimuth_degrees > 90 or wall_azimuth_degrees < -90: raise ValueError("Cannot calculate wall azimuth angle because sun is behind surface") return Angular(degrees=wall_azimuth_degrees)
[docs] def solar_angle_of_incidence(time_stamp: datetime, daylight_savings_on: bool, longitude: Angular, standard_meridian: Angular, latitude: Angular, surface_azimuth: Angular) -> Angular: """ Calculates the solar angle of incidence for a given set of time and location conditions, and a surface orientation. The solar angle of incidence is the angle between the solar ray vector incident on the surface, and the outward facing surface normal vector. Throws if the wall azimuth cannot be calculated :param time_stamp: The current date and time to be used in this calculation of day of year. :param daylight_savings_on: A flag if the current time is a daylight savings number. If True, the hour is decremented. :param longitude: [west] The current longitude west of the prime meridian. For Golden, CO, the variable should be = 105.2 degrees. :param standard_meridian: [west] The local standard meridian for the location, west of the prime meridian. For Golden, CO, the variable should be = 105 degrees. :param latitude: [north] The local latitude for the location, north of the equator. For Golden, CO, the variable should be = 39.75 degrees. :param surface_azimuth: [CW from North] The angle between north and the outward facing normal vector of the wall, measured as positive clockwise from south (southwest facing surface: 225 degrees, northwest facing surface: 315 degrees) :returns: [Angular] The solar angle of incidence in an Angular with both radian & degree versions. """ try: wall_azimuth_rad = wall_azimuth_angle(time_stamp, daylight_savings_on, longitude, standard_meridian, latitude, surface_azimuth).radians() except ValueError: # TODO: Make SunIsDownException raise ValueError("Cannot calculate wall azimuth angle because sun is down") from None altitude_rad = altitude_angle(time_stamp, daylight_savings_on, longitude, standard_meridian, latitude).radians() incidence_angle_radians = math.acos(math.cos(altitude_rad) * math.cos(wall_azimuth_rad)) return Angular(radians=incidence_angle_radians)
[docs] def direct_radiation_on_surface(time_stamp: datetime, daylight_savings_on: bool, longitude: Angular, standard_meridian: Angular, latitude: Angular, surface_azimuth: Angular, horizontal_direct_irradiation: float) -> float: """ Calculates the amount of direct solar radiation incident on a surface for a set of time and location conditions, a surface orientation, and a total global horizontal direct irradiation. This is merely the global horizontal direct solar irradiation times the angle of incidence on the surface. :param time_stamp: The current date and time to be used in this calculation of day of year. :param daylight_savings_on: A flag if the current time is a daylight savings number. If True, the hour is decremented. :param longitude: [west] The current longitude west of the prime meridian. For Golden, CO, the variable should be = 105.2 degrees. :param standard_meridian: [west] The local standard meridian for the location, west of the prime meridian. For Golden, CO, the variable should be = 105 degrees. :param latitude: [north] The local latitude for the location, north of the equator. For Golden, CO, the variable should be = 39.75 degrees. :param surface_azimuth: [CW from North] The angle between north and the outward facing normal vector of the wall, measured as positive clockwise from south (southwest facing surface: 225 degrees, northwest facing surface: 315 degrees) :param horizontal_direct_irradiation: The global horizontal direct irradiation at the location, in any units :returns: The incident direct radiation on the surface. The units of this return value match the units of the parameter :horizontal_direct_irradiation: """ theta = solar_angle_of_incidence(time_stamp, daylight_savings_on, longitude, standard_meridian, latitude, surface_azimuth).radians() return horizontal_direct_irradiation * math.cos(theta)