# coding=utf-8
"""Module for calculating sun positions and visualizing the sun path."""
from __future__ import division
from .location import Location
from .dt import DateTime
from .analysisperiod import AnalysisPeriod
from .compass import Compass
from ladybug_geometry.geometry3d.pointvector import Vector3D, Point3D
from ladybug_geometry.geometry3d.plane import Plane
from ladybug_geometry.geometry3d.arc import Arc3D
from ladybug_geometry.geometry3d.polyline import Polyline3D
from ladybug_geometry.geometry3d.line import LineSegment3D
from ladybug_geometry.geometry2d.pointvector import Point2D
from ladybug_geometry.geometry2d.polyline import Polyline2D
import datetime as py_datetime
import math
import sys
if (sys.version_info > (3, 0)): # python 3
xrange = range
[docs]class Sunpath(object):
"""Calculate sun positions and visualize the sun path
Args:
latitude: A number between -90 and 90 for the latitude of the location
in degrees. (Default: 0 for the equator)
longitude: A number between -180 and 180 for the longitude of the location
in degrees (Default: 0 for the prime meridian)
time_zone: A number representing the time zone of the location for the
sun path. Typically, this value is an integer, assuming that a
standard time zone is used but this value can also be a decimal
for the purposes of modeling location-specific solar time.
The time zone should follow the epw convention and should be
between -12 and +14, where 0 is at Greenwich, UK, positive values
are to the East of Greenwich and negative values are to the West.
If None, this value will be set to solar time using the Sunpath's
longitude. (Default: None).
north_angle: A number between -360 and 360 for the counterclockwise
difference between the North and the positive Y-axis in degrees.
90 is West and 270 is East (Default: 0).
daylight_saving_period: An analysis period for daylight saving time.
If None, no daylight saving time will be used. (Default: None)
Properties:
* latitude
* longitude
* time_zone
* north_angle
* daylight_saving_period
* is_leap_year
Usage:
.. code-block:: python
import ladybug.sunpath as sunpath
# initiate sunpath
sp = sunpath.Sunpath(50)
sun = sp.calculate_sun(1, 1, 12) # calculate sun data for Jan 1 at noon
print(sun.azimuth, sun.altitude)
"""
__slots__ = ('_longitude', '_latitude', '_north_angle', '_time_zone',
'_daylight_saving_period', '_is_leap_year')
PI = math.pi
def __init__(self, latitude=0, longitude=0, time_zone=None, north_angle=0,
daylight_saving_period=None):
"""Init sunpath.
"""
self.latitude = latitude
self.longitude = longitude
self.time_zone = time_zone
self.north_angle = north_angle
self.daylight_saving_period = daylight_saving_period
self._is_leap_year = False
[docs] @classmethod
def from_location(cls, location, north_angle=0, daylight_saving_period=None):
"""Create a sun path from a ladybug.location.Location."""
location = Location.from_location(location)
return cls(location.latitude, location.longitude,
location.time_zone, north_angle, daylight_saving_period)
@property
def latitude(self):
"""Get or set a number between -90 and 90 for the latitude in degrees."""
return math.degrees(self._latitude)
@latitude.setter
def latitude(self, value):
self._latitude = math.radians(float(value))
assert -self.PI / 2 <= self._latitude <= self.PI / 2, \
'latitude value should be between -90 and 90. Got {}.'.format(value)
if self._latitude == self.PI / 2: # prevent math domain errors
self._latitude = self._latitude - 1e-9
if self._latitude == -self.PI / 2: # prevent math domain errors
self._latitude = self._latitude + 1e-9
@property
def longitude(self):
"""Get or set a number between -180 and 180 for the longitude in degrees.
Note that you will also likely want to update the time zone of the
Sunpath if this value is set to something far from its original value.
"""
return math.degrees(self._longitude)
@longitude.setter
def longitude(self, value):
self._longitude = math.radians(float(value))
assert -self.PI <= self._longitude <= self.PI, \
'longitude value should be between -180 and 180. Got {}.'.format(value)
@property
def time_zone(self):
"""Get or set the time zone as a number between -12 and + 14.
Setting this property to None will automatically set the Sunpath to use
solar time for the Sunpath's longitude.
"""
return self._time_zone
@time_zone.setter
def time_zone(self, tz):
self._time_zone = self.longitude / 15 if tz is None else float(tz)
assert -12 <= self._time_zone <= 14, \
'Time zone must be between -12 and +14. Got {}.'.format(self._time_zone)
@property
def north_angle(self):
"""Get or set a number between -360 and 360 for the north_angle in degrees."""
return math.degrees(self._north_angle)
@north_angle.setter
def north_angle(self, value):
self._north_angle = math.radians(float(value))
assert -self.PI * 2 <= self._north_angle <= self.PI * 2, \
'north_angle value should be between -360 and 360. Got {}.'.format(value)
@property
def is_leap_year(self):
"""Get or set a boolean to indicate is sunpath calculated for a leap year."""
return self._is_leap_year
@is_leap_year.setter
def is_leap_year(self, value):
"""set sunpath to be calculated for a leap year."""
self._is_leap_year = bool(value)
@property
def daylight_saving_period(self):
"""Get or set an AnalysisPeriod for the daylight saving period.
Note that the st_hour and end_hour of the AnalysisPeriod will be interpreted
as indicating the time of day when the shift in clocks happen. So setting the
st_hour and end_hour to 2 will ensure that the time shift happens at 2 AM on
the dates specified. Note that this is different than how the hour inputs of
AnalysisPeriods typically work, where the st_hour and end_hour apply to every
day of the analysis period.
If None, no daylight saving period will be used.
"""
return self._daylight_saving_period
@daylight_saving_period.setter
def daylight_saving_period(self, value):
if value is not None:
assert isinstance(value, AnalysisPeriod), \
'Daylight saving period should be an AnalysisPeriod not %s' % type(value)
self._daylight_saving_period = value
[docs] def is_daylight_saving_hour(self, datetime):
"""Check if a datetime is within the daylight saving time."""
if not self.daylight_saving_period:
return False
if self.daylight_saving_period.is_reversed:
return self.daylight_saving_period.end_time.moy <= datetime.moy or \
self.daylight_saving_period.st_time.moy >= datetime.moy
else:
return self.daylight_saving_period.st_time.moy <= datetime.moy < \
self.daylight_saving_period.end_time.moy
[docs] def calculate_sun(self, month, day, hour, is_solar_time=False):
"""Get Sun data for an hour of the year.
Args:
month: An integer between 1 and 12.
day: An integer between 1 and 31.
hour: A positive number between 0 and 23. This can be a decimal value
to yield a solar position in between hours (eg. 12.5).
is_solar_time: A boolean to indicate if the input hour is in solar
time. (Default: False)
Returns:
A sun object for the input month, day, hour.
"""
datetime = DateTime(month, day, *self._calculate_hour_and_minute(hour),
leap_year=self.is_leap_year)
return self.calculate_sun_from_date_time(datetime, is_solar_time)
[docs] def calculate_sun_from_hoy(self, hoy, is_solar_time=False):
"""Get Sun data for an hour of the year.
Args:
hoy: A number for the hour of the year. This can be a decimal value
to yield a solar position in between hours (eg. 12.5).
is_solar_time: A boolean to indicate if the input hoy is in solar
time. (Default: False)
Returns:
A sun object for the input hoy.
"""
datetime = DateTime.from_hoy(hoy, self.is_leap_year)
return self.calculate_sun_from_date_time(datetime, is_solar_time)
[docs] def calculate_sun_from_moy(self, moy, is_solar_time=False):
"""Get Sun data for a minute of the year.
Args:
moy: An integer for the minute of the year.
is_solar_time: A boolean to indicate if the input moy is in solar
time. (Default: False)
Returns:
A sun object for the input moy.
"""
datetime = DateTime.from_moy(moy, self.is_leap_year)
return self.calculate_sun_from_date_time(datetime, is_solar_time)
[docs] def calculate_sun_from_date_time(self, datetime, is_solar_time=False):
"""Get Sun for a specific datetime.
This code is originally written by Trygve Wastvedt (Trygve.Wastvedt@gmail.com)
based on (NOAA) and modified by Chris Mackey and Mostapha Roudsari.
Args:
datetime: Ladybug datetime.
is_solar_time: A boolean to indicate if the input hour is in solar
time. (Default: False)
Returns:
A sun object for the input datetime.
"""
# TODO(mostapha): This should be more generic and based on a method
if datetime.year != 2016 and self.is_leap_year:
datetime = DateTime(datetime.month, datetime.day, datetime.hour,
datetime.minute, True)
# compute solar geometry
sol_dec, eq_of_time = self._calculate_solar_geometry(datetime)
# get the correct minute of the day for which solar position is to be computed
try:
hour = datetime.float_hour
except AttributeError: # native Python datetime; try to compute manually
hour = datetime.hour + datetime.minute / 60.0
is_daylight_saving = self.is_daylight_saving_hour(datetime)
hour = hour - 1 if is_daylight_saving else hour # spring forward!
sol_time = self._calculate_solar_time(hour, eq_of_time, is_solar_time) * 60
# degrees for the angle between solar noon and the current time.
hour_angle = sol_time / 4 + 180 if sol_time < 0 else sol_time / 4 - 180
# radians for the zenith and degrees for altitude
zenith = math.acos(math.sin(self._latitude) * math.sin(sol_dec) +
math.cos(self._latitude) * math.cos(sol_dec) *
math.cos(math.radians(hour_angle)))
altitude = 90 - math.degrees(zenith)
# approx atmospheric refraction used to correct the altitude
if altitude > 85:
atmos_refraction = 0
elif altitude > 5:
atmos_refraction = 58.1 / math.tan(math.radians(altitude)) - \
0.07 / (math.tan(math.radians(altitude))) ** 3 + \
0.000086 / (math.tan(math.radians(altitude))) ** 5
elif altitude > -0.575:
atmos_refraction = 1735 + altitude * \
(-518.2 + altitude * (103.4 + altitude * (-12.79 + altitude * 0.711)))
else:
atmos_refraction = -20.772 / math.tan(math.radians(altitude))
atmos_refraction /= 3600
altitude += atmos_refraction
# azimuth in degrees
az_init = ((math.sin(self._latitude) * math.cos(zenith)) - math.sin(sol_dec)) / \
(math.cos(self._latitude) * math.sin(zenith))
try:
if hour_angle > 0:
azimuth = (math.degrees(math.acos(az_init)) + 180) % 360
else:
azimuth = (540 - math.degrees(math.acos(az_init))) % 360
except ValueError: # perfect solar noon yields math domain error
azimuth = 180
# create the sun for this hour
return Sun(datetime, altitude, azimuth, is_solar_time, is_daylight_saving,
self.north_angle)
[docs] def calculate_sunrise_sunset(self, month, day, depression=0.5334,
is_solar_time=False):
"""Calculate sunrise, noon and sunset.
Args:
month: Integer for the month in which sunrise and sunset are computed.
day: Integer for the day of the month in which sunrise and sunset
are computed.
depression: An angle in degrees indicating the additional period
before/after the edge of the sun has passed the horizon where
the sun is still considered up. Setting this value to 0 will
compute sunrise/sunset as the time when the edge of the sun begins
to touch the horizon. Setting it to the angular diameter of the
sun (0.5334) will compute sunrise/sunset as the time when the sun
just finishes passing the horizon (actual sunset). Setting it
to 0.833 will compute the apparent sunrise/sunset, accounting for
atmospheric refraction. Setting this to 6 will compute sunrise/sunset
as the beginning/end of civil twilight. Setting this to 12 will
compute sunrise/sunset as the beginning/end of nautical twilight.
Setting this to 18 will compute sunrise/sunset as the beginning/end
of astronomical twilight. (Default: 0.5334).
is_solar_time: A boolean to indicate if the output datetimes for sunrise,
noon and sunset should be in solar time as opposed to the time zone
of this Sunpath. (Default: False)
Return:
A dictionary. Keys are ("sunrise", "noon", "sunset"). Values are
datetimes that correspond to these moments. Note that some values
may be None if there is no sunrise or sunset on the specified day.
"""
datetime = DateTime(month, day, hour=12, leap_year=self.is_leap_year)
return self.calculate_sunrise_sunset_from_datetime(
datetime, depression, is_solar_time)
[docs] def calculate_sunrise_sunset_from_datetime(self, datetime, depression=0.5334,
is_solar_time=False):
"""Calculate sunrise, sunset and noon for a day of year.
Args:
datetime: A ladybug DateTime object to indicate the month and day for
which sunrise and sunset are computed.
depression: An angle in degrees indicating the additional period
before/after the edge of the sun has passed the horizon where
the sun is still considered up. Setting this value to 0 will
compute sunrise/sunset as the time when the edge of the sun begins
to touch the horizon. Setting it to the angular diameter of the
sun (0.5334) will compute sunrise/sunset as the time when the sun
just finishes passing the horizon (actual sunset). Setting it
to 0.833 will compute the apparent sunrise/sunset, accounting for
atmospheric refraction. Setting this to 6 will compute sunrise/sunset
as the beginning/end of civil twilight. Setting this to 12 will
compute sunrise/sunset as the beginning/end of nautical twilight.
Setting this to 18 will compute sunrise/sunset as the beginning/end
of astronomical twilight. (Default: 0.5334).
is_solar_time: A boolean to indicate if the output datetimes for sunrise,
noon and sunset should be in solar time as opposed to the time zone
of this Sunpath. (Default: False)
Return:
A dictionary. Keys are ("sunrise", "noon", "sunset"). Values are
datetimes that correspond to these moments. Note that some values
may be None if there is no sunrise or sunset on the specified day.
"""
# TODO(mostapha): This should be more generic and based on a method
if datetime.year != 2016 and self.is_leap_year:
datetime = DateTime(datetime.month, datetime.day, datetime.hour,
datetime.minute, True)
sol_dec, eq_of_time = self._calculate_solar_geometry(datetime)
# calculate sunrise and sunset hour
if is_solar_time:
noon = .5
else:
noon = (720 - 4 * self.longitude - eq_of_time + self.time_zone * 60) / 1440.
try:
sunrise_hour_angle = self._calculate_sunrise_hour_angle(
sol_dec, math.radians(depression))
except ValueError:
# no sunrise/sunset on this day (eg. arctic circle in summer/winter)
noon = 24 * noon
is_daylight_saving = self.is_daylight_saving_hour(datetime)
noon = noon - 1 if is_daylight_saving else noon # spring forward!
return {
'sunrise': None,
'noon': DateTime(datetime.month, datetime.day,
*self._calculate_hour_and_minute(noon),
leap_year=self.is_leap_year),
'sunset': None
}
else:
sunrise = noon - sunrise_hour_angle * 4 / 1440.0
sunset = noon + sunrise_hour_angle * 4 / 1440.0
noon = 24 * noon
sunrise = 24 * sunrise
sunset = 24 * sunset
if self.is_daylight_saving_hour(datetime): # spring forward!
noon = noon - 1
sunrise = sunrise - 1
sunset = sunset - 1
# compute sunrise datetime
if sunrise >= 0:
sunrise = DateTime(
datetime.month, datetime.day,
*self._calculate_hour_and_minute(sunrise),
leap_year=self.is_leap_year)
else: # sunrise before midnight
sr_dt = datetime.sub_hour(24)
hr, mn = self._calculate_hour_and_minute(sunrise)
hr = 23 + hr
mn = 60 + mn
sunrise = DateTime(
sr_dt.month, sr_dt.day, hr, mn, leap_year=self.is_leap_year)
# compute noon datetime
noon = DateTime(
datetime.month, datetime.day, *self._calculate_hour_and_minute(noon),
leap_year=self.is_leap_year)
# compute sunset datetime
if sunset < 24:
sunset = DateTime(
datetime.month, datetime.day,
*self._calculate_hour_and_minute(sunset),
leap_year=self.is_leap_year)
else: # sunset after midnight
ss_dt = datetime.add_hour(24)
hr, mn = self._calculate_hour_and_minute(sunset)
hr = hr - 24
sunset = DateTime(
ss_dt.month, ss_dt.day, hr, mn, leap_year=self.is_leap_year)
return {'sunrise': sunrise, 'noon': noon, 'sunset': sunset}
[docs] def analemma_suns(
self, time, daytime_only=False, is_solar_time=False,
start_month=1, end_month=12, steps_per_month=1):
"""Get an array of Suns that represent an analemma for a single time of day.
Args:
time: A ladybug Time object for the specific time of day to make
the analemma.
daytime_only: A boolean to note whether only daytime suns should
be included in the resulting array. Note that this can
result in a completely empty array. (Default: False)
is_solar_time: A boolean to indicate if the output analemmas should
be for solar hours instead of the hours of the sunpath time
zone. (Default: False)
start_month: An integer from 1 to 12 to set the staring month for which
the analemma is drawn. (Default: 1).
end_month: An integer from 1 to 12 to set the ending month for which
the analemma is drawn. (Default: 12).
steps_per_month: An integer to set the number of sun positions that
will be used to represent a single month. Higher numbers will
take more time to compute but can produce smoother-looking
analemmas. (Default: 1).
Returns:
An array of suns representing an analemma for a specific time.
Analemmas will each have 12 suns for the 12 months of the year
if daytime_only is False.
"""
analemma = []
for mon in range(start_month, end_month + 1):
if steps_per_month == 1: # use the 21st of each month
dat_t = DateTime(mon, 21, time.hour, time.minute)
analemma.append(self.calculate_sun_from_date_time(dat_t, is_solar_time))
else:
dpm = AnalysisPeriod.NUMOFDAYSEACHMONTH[mon - 1]
for day in range(1, dpm + 1, int(dpm / steps_per_month)):
dat_t = DateTime(mon, day, time.hour, time.minute)
dat_t_sun = self.calculate_sun_from_date_time(dat_t, is_solar_time)
analemma.append(dat_t_sun)
if daytime_only: # filter out the nighttime sun positions
analemma = [sun for sun in analemma if sun.is_during_day]
return analemma
[docs] def hourly_analemma_suns(
self, daytime_only=False, is_solar_time=False,
start_month=1, end_month=12, steps_per_month=1):
"""Get a nested array of Suns with one sub-array for each hourly analemma.
Args:
daytime_only: A boolean to note whether only daytime suns should
be included in the resulting arrays. Note that this will likely
result in completely empty arrays for some hours. (Default: False)
is_solar_time: A boolean to indicate if the output analemmas should
be for solar hours instead of the hours of the sunpath time
zone. (Default: False).
start_month: An integer from 1 to 12 to set the staring month for which
the analemma is drawn. (Default: 1).
end_month: An integer from 1 to 12 to set the ending month for which
the analemma is drawn. (Default: 12).
steps_per_month: An integer to set the number of sun positions that
will be used to represent a single month. Higher numbers will
take more time to compute but can produce smoother-looking
analemmas. (Default: 1).
Returns:
An array of 24 arrays with each sub-array representing an analemma.
Analemmas will have a number of suns equal to (end_month - start_month) *
steps_per_month. The default is 12 suns for the 12 months of the year.
"""
analemmas = [] # list of polylines
for hr in range(24):
analem = []
for mon in range(start_month, end_month + 1):
if steps_per_month == 1: # use the 21st of each month
dat = DateTime(mon, 21, hr)
analem.append(self.calculate_sun_from_date_time(dat, is_solar_time))
else:
dpm = AnalysisPeriod.NUMOFDAYSEACHMONTH[mon - 1]
for day in range(1, dpm + 1, int(dpm / steps_per_month)):
dat = DateTime(mon, day, hr)
dat_sun = self.calculate_sun_from_date_time(dat, is_solar_time)
analem.append(dat_sun)
analemmas.append(analem)
if daytime_only: # filter out the nighttime sun positions
for i, analem in enumerate(analemmas):
analemmas[i] = [sun for sun in analem if sun.is_during_day]
return analemmas
[docs] def hourly_analemma_polyline3d(
self, origin=Point3D(), radius=100, daytime_only=True, is_solar_time=False,
start_month=1, end_month=12, steps_per_month=1):
"""Get an array of ladybug_geometry Polyline3D for hourly analemmas.
Args:
origin: A ladybug_geometry Point3D to note the center of the sun path.
radius: A number to note the radius of the sunpath.
daytime_only: A boolean to note whether only the daytime hours should
be represented in the output Polyline3D. (Default: True)
is_solar_time: A boolean to indicate if the output analemmas should
be for solar hours instead of the hours of the sunpath time
zone. (Default: False).
start_month: An integer from 1 to 12 to set the staring month for which
the analemma is drawn. (Default: 1).
end_month: An integer from 1 to 12 to set the ending month for which
the analemma is drawn. (Default: 12).
steps_per_month: An integer to set the number of sun positions that
will be used to represent a single month. Higher numbers will
take more time to compute but can produce smoother-looking
analemmas. (Default: 1).
Returns:
An array of ladybug_geometry Polyline3D with at least one polyline
for each analemma.
"""
analemmas = [] # list of polylines
analem_suns = self.hourly_analemma_suns(
is_solar_time=is_solar_time, start_month=start_month, end_month=end_month,
steps_per_month=steps_per_month)
for analem in analem_suns:
pts = []
for sun in analem:
pts.append(sun.position_3d(origin, radius))
if start_month == 1 and end_month == 12:
pts.append(pts[0]) # ensure that the Polyline3D is closed
analemmas.append(Polyline3D(pts, interpolated=True))
if not daytime_only: # no need to further process the analemmas
return analemmas
# extract only the daytime portion of the analemmas
daytime_analemmas = []
plane_o = Plane(o=origin)
for analem in analemmas:
if analem.min.z > origin.z: # fast check for analemmas above the plane
daytime_analemmas.append(analem)
elif analem.max.z < origin.z: # fast check for analemmas below the plane
pass
else: # split the Polylines with a plane at the input origin
split_lines = analem.split_with_plane(plane_o)
day_lines = []
first_pt = None
for pl in split_lines:
if isinstance(pl, Polyline3D) and pl.center.z > origin.z:
day_lines.append(pl)
elif isinstance(pl, LineSegment3D) and pl.midpoint.z > origin.z:
try: # last line segment in intersection; append it to the first
new_pl = Polyline3D((pl.p1,) + day_lines[0].vertices, True)
day_lines[0] = new_pl
except IndexError: # first line segment in the intersection
first_pt = pl.p2
if first_pt is not None and len(day_lines) > 0:
new_pl = Polyline3D(day_lines[0].vertices + (first_pt,), True)
day_lines[0] = new_pl
daytime_analemmas.extend(day_lines)
return daytime_analemmas
[docs] def hourly_analemma_polyline2d(
self, projection='Orthographic', origin=Point2D(), radius=100,
daytime_only=True, is_solar_time=False, start_month=1, end_month=12,
steps_per_month=1):
"""Get an array of ladybug_geometry Polyline2D for hourly analemmas.
Args:
projection: Text for the name of the projection to use from the sky
dome hemisphere to the 2D plane. (Default: 'Orthographic'). Choose
from the following:
* Orthographic
* Stereographic
origin: A ladybug_geometry Point2D to note the center of the sun path.
radius: A number to note the radius of the sunpath.
daytime_only: A boolean to note whether only the daytime hours should
be represented in the output Polyline2D. (Default: True)
is_solar_time: A boolean to indicate if the output analemmas should
be for solar hours instead of the hours of the sunpath time
zone. (Default: False).
start_month: An integer from 1 to 12 to set the staring month for which
the analemma is drawn. (Default: 1).
end_month: An integer from 1 to 12 to set the ending month for which
the analemma is drawn. (Default: 12).
steps_per_month: An integer to set the number of sun positions that
will be used to represent a single month. Higher numbers will
take more time to compute but can produce smoother-looking
analemmas. (Default: 1).
Returns:
An array of ladybug_geometry Polyline2D with at least one polyline for
each analemma.
"""
# compute the analemmas in 3D space
o_3d = Point3D(origin.x, origin.y, 0)
plines_3d = self.hourly_analemma_polyline3d(
o_3d, radius, daytime_only, is_solar_time, start_month, end_month,
steps_per_month)
return self._project_polyline_to_2d(plines_3d, projection, radius, o_3d)
[docs] def day_arc3d(self, month, day, origin=Point3D(), radius=100, daytime_only=True,
depression=0.5334):
"""Get a ladybug_geometry Arc3D for the path taken by the sun on a single day.
Args:
month: Integer for the month in which sunrise and sunset are computed.
day: Integer for the day of the month in which sunrise and sunset
are computed.
origin: A ladybug_geometry Point3D to note the center of the sun path.
radius: A number to note the radius of the sunpath.
daytime_only: A boolean to note whether None should be returned if
the sun never rises above the horizon on the the input day.
For example, in the arctic circle in winter. (Default: True)
depression: An angle in degrees indicating the additional period
before/after the edge of the sun has passed the horizon where
the sun is still considered up. Setting this value to 0 will
return an arc that ends when the edge of the sun begins to
touch the horizon. Setting it to the angular diameter of the
sun (0.5334) will return an arc that ends right at the horizon
(actual sunset). Setting it to 0.833 will compute the apparent
sunrise/sunset, accounting for atmospheric refraction. Setting to
6 will return an arc that ends at the beginning/end of civil twilight.
Setting to 12 will return an arc that ends at the beginning/end
of nautical twilight. Setting to 18 will return an arc that
ends at the beginning/end of astronomical twilight. (Default: 0.5334)
Returns:
An Arc3D for the path of the sun taken over the course of a day. Will be
None if daytime_only is True and the sun is completely below the horizon
for the entire day.
"""
# get the sunrise, noon, and sunset time
riseset_dict = self.calculate_sunrise_sunset(month, day, depression)
# create the arcs from the sun positions
if riseset_dict['sunrise'] is None: # no sunrise; add a full circle
noon = self.calculate_sun_from_date_time(riseset_dict['noon'])
if daytime_only and noon.sun_vector.z > 0: # night time
return None
sun6am = self.calculate_sun(month, day, 6)
sun6pm = self.calculate_sun(month, day, 18)
pts = [sun.position_3d(origin, radius) for sun in (sun6am, noon, sun6pm)]
return Arc3D.from_start_mid_end(pts[0], pts[1], pts[2], circle=True)
else: # create an arc that respects the depression
rise_noon_set_suns = (
self.calculate_sun_from_date_time(riseset_dict['sunrise']),
self.calculate_sun_from_date_time(riseset_dict['noon']),
self.calculate_sun_from_date_time(riseset_dict['sunset']))
positions = (sun.position_3d(origin, radius) for sun in rise_noon_set_suns)
return Arc3D.from_start_mid_end(*positions)
[docs] def day_polyline2d(
self, month, day, projection='Orthographic', origin=Point2D(),
radius=100, daytime_only=True, depression=0.5334, divisions=10):
"""Get a Polyline2D for the path taken by the sun on a single day.
Args:
month: Integer for the month in which sunrise and sunset are computed.
day: Integer for the day of the month in which sunrise and sunset
are computed.
projection: Text for the name of the projection to use from the sky
dome hemisphere to the 2D plane. (Default: 'Orthographic'). Choose
from the following:
* Orthographic
* Stereographic
origin: A ladybug_geometry Point2D to note the center of the sun path.
radius: A number to note the radius of the sunpath.
daytime_only: A boolean to note whether None should be returned if
the sun never rises above the horizon on the the input day.
For example, in the arctic circle in winter. (Default: True)
depression: An angle in degrees indicating the additional period
before/after the edge of the sun has passed the horizon where
the sun is still considered up. Setting this value to 0 will
return an arc that ends when the edge of the sun begins to
touch the horizon. Setting it to the angular diameter of the
sun (0.5334) will return an arc that ends right at the horizon
(actual sunset). Setting it to 0.833 will compute the apparent
sunrise/sunset, accounting for atmospheric refraction. Setting to
6 will return an arc that ends at the beginning/end of civil twilight.
Setting to 12 will return an arc that ends at the beginning/end
of nautical twilight. Setting to 18 will return an arc that
ends at the beginning/end of astronomical twilight. (Default: 0.5334)
Returns:
A Polyline2D for the path of the sun taken over the course of a day. Will be
None if daytime_only is True and the sun is completely below the horizon
for the entire day.
"""
# compute the daily arc in 3D space
o_3d = Point3D(origin.x, origin.y, 0)
arc_3d = self.day_arc3d(month, day, o_3d, radius, daytime_only, depression)
if arc_3d is not None:
pline_3d = arc_3d.to_polyline(divisions, interpolated=True)
return self._project_polyline_to_2d([pline_3d], projection, radius, o_3d)[0]
[docs] def monthly_day_arc3d(
self, origin=Point3D(), radius=100, daytime_only=True, depression=0.5334):
"""Get an array of Arc3Ds for the path taken by the sun on the 21st of each month.
Args:
origin: A ladybug_geometry Point3D to note the center of the sun path.
radius: A number to note the radius of the sunpath.
daytime_only: A boolean to note whether arcs should be excluded from the
output array if the sun never rises above the horizon on the the day.
For example, in the arctic circle in winter. (Default: True)
depression: An angle in degrees indicating the additional period
before/after the edge of the sun has passed the horizon where
the sun is still considered up. Setting this value to 0 will
return an arc that ends when the edge of the sun begins to
touch the horizon. Setting it to the angular diameter of the
sun (0.5334) will return an arc that ends right at the horizon
(actual sunset). Setting it to 0.833 will compute the apparent
sunrise/sunset, accounting for atmospheric refraction. Setting to
6 will return an arc that ends at the beginning/end of civil twilight.
Setting to 12 will return an arc that ends at the beginning/end
of nautical twilight. Setting to 18 will return an arc that
ends at the beginning/end of astronomical twilight. (Default: 0.5334)
Returns:
An array of ladybug_geometry Arc3D with an arc for the 21st of each month.
"""
day_arcs = []
for mon in range(1, 13):
arc = self.day_arc3d(mon, 21, origin, radius, daytime_only, depression)
if arc is not None:
day_arcs.append(arc)
return day_arcs
[docs] def monthly_day_polyline2d(
self, projection='Orthographic', origin=Point2D(), radius=100,
daytime_only=True, depression=0.5334, divisions=10):
"""Get an array of Polyline2Ds for the sun path on the 21st of each month.
Args:
projection: Text for the name of the projection to use from the sky
dome hemisphere to the 2D plane. (Default: 'Orthographic'). Choose
from the following:
* Orthographic
* Stereographic
origin: A ladybug_geometry Point2D to note the center of the sun path.
radius: A number to note the radius of the sunpath.
daytime_only: A boolean to note whether arcs should be excluded from the
output array if the sun never rises above the horizon on the the day.
For example, in the arctic circle in winter. (Default: True)
depression: An angle in degrees indicating the additional period
before/after the edge of the sun has passed the horizon where
the sun is still considered up. Setting this value to 0 will
return an arc that ends when the edge of the sun begins to
touch the horizon. Setting it to the angular diameter of the
sun (0.5334) will return an arc that ends right at the horizon
(actual sunset). Setting it to 0.833 will compute the apparent
sunrise/sunset, accounting for atmospheric refraction. Setting to
6 will return an arc that ends at the beginning/end of civil twilight.
Setting to 12 will return an arc that ends at the beginning/end
of nautical twilight. Setting to 18 will return an arc that
ends at the beginning/end of astronomical twilight. (Default: 0.5334)
divisions: An integer for the number of divisions to be used when
converting the daily arcs into Polyline2Ds. (Default: 10).
Returns:
An array of ladybug_geometry Polyline2D with a polyline for the 21st
of each month.
"""
# compute the daily arcs in 3D space
o_3d = Point3D(origin.x, origin.y, 0)
arcs_3d = self.monthly_day_arc3d(o_3d, radius, daytime_only, depression)
plines_3d = [arc.to_polyline(divisions, interpolated=True) for arc in arcs_3d]
return self._project_polyline_to_2d(plines_3d, projection, radius, o_3d)
def _calculate_solar_geometry(self, datetime):
"""Calculate parameters related to solar geometry for an hour of the year.
Attributes:
datetime: A Ladybug datetime
Returns:
A tuple with two values
- sol_dec: Solar declination in radians. Declination is analogous to
latitude on Earth's surface, and measures an angular displacement
north or south from the projection of Earth's equator on the
celestial sphere to the location of a celestial body.
- eq_of_time: Equation of time in minutes. This is an astronomical
term accounting for changes in the time of solar noon for a given
location over the course of a year. Earth's elliptical orbit and
Kepler's law of equal areas in equal times are the culprits
behind this phenomenon.
"""
year, month, day, hour, minute = \
datetime.year, datetime.month, datetime.day, datetime.hour, datetime.minute
julian_day = self._days_from_010119(year, month, day) + 2415018.5 + \
round((minute + hour * 60) / 1440.0, 2) - (float(self.time_zone) / 24)
julian_century = (julian_day - 2451545) / 36525
# degrees
geom_mean_long_sun = (280.46646 + julian_century *
(36000.76983 + julian_century * 0.0003032)
) % 360
# degrees
geom_mean_anom_sun = 357.52911 + julian_century * \
(35999.05029 - 0.0001537 * julian_century)
eccent_orbit = 0.016708634 - julian_century * \
(0.000042037 + 0.0000001267 * julian_century)
sun_eq_of_ctr = math.sin(
math.radians(geom_mean_anom_sun)) * \
(1.914602 - julian_century * (0.004817 + 0.000014 * julian_century)
) +\
math.sin(math.radians(2 * geom_mean_anom_sun)) * \
(0.019993 - 0.000101 * julian_century) + \
math.sin(math.radians(3 * geom_mean_anom_sun)) * \
0.000289
# degrees
sun_true_long = geom_mean_long_sun + sun_eq_of_ctr
# degrees
sun_app_long = sun_true_long - 0.00569 - 0.00478 * \
math.sin(math.radians(125.04 - 1934.136 * julian_century))
# degrees
mean_obliq_ecliptic = 23 + \
(26 + ((21.448 - julian_century * (46.815 + julian_century *
(0.00059 - julian_century *
0.001813)))) / 60) / 60
# degrees
oblique_corr = mean_obliq_ecliptic + 0.00256 * \
math.cos(math.radians(125.04 - 1934.136 * julian_century))
# RADIANS
sol_dec = math.asin(math.sin(math.radians(oblique_corr)) *
math.sin(math.radians(sun_app_long)))
var_y = math.tan(math.radians(oblique_corr / 2)) * \
math.tan(math.radians(oblique_corr / 2))
# minutes
eq_of_time = 4 \
* math.degrees(
var_y * math.sin(2 * math.radians(geom_mean_long_sun)) -
2 * eccent_orbit * math.sin(math.radians(geom_mean_anom_sun)) +
4 * eccent_orbit * var_y *
math.sin(math.radians(geom_mean_anom_sun)) *
math.cos(2 * math.radians(geom_mean_long_sun)) -
0.5 * (var_y ** 2) *
math.sin(4 * math.radians(geom_mean_long_sun)) -
1.25 * (eccent_orbit ** 2) *
math.sin(2 * math.radians(geom_mean_anom_sun))
)
return sol_dec, eq_of_time
def _calculate_sunrise_hour_angle(self, solar_dec, depression):
"""Calculate hour angle for sunrise time in degrees.
Args:
solar_dec: Solar declination in radians.
depression: Depression in radians.
"""
hour_angle_arg = math.degrees(math.acos(
math.cos(self.PI / 2 + depression) /
(math.cos(self._latitude) * math.cos(solar_dec)) -
math.tan(self._latitude) * math.tan(solar_dec)
))
return hour_angle_arg
def _calculate_solar_time(self, hour, eq_of_time, is_solar_time):
"""Calculate Solar time for an hour."""
if is_solar_time:
return hour
return ((hour * 60 + eq_of_time + 4 * math.degrees(self._longitude) -
60 * self.time_zone) % 1440) / 60
def _calculate_solar_time_by_doy(self, hour, doy):
"""This is how radiance calculates solar time.
This is a place holder and should be validated against _calculate_solar_time.
"""
raise NotImplementedError()
return (0.170 * math.sin((4 * self.PI / 373) * (doy - 80)) -
0.129 * math.sin((2 * self.PI / 355) * (doy - 8)) +
12 * (-(15 * self.time_zone) - self.longitude) / self.PI)
@staticmethod
def _calculate_hour_and_minute(float_hour):
"""Calculate hour and minutes as integers from a float hour."""
hour = int(float_hour)
minute = int(round((float_hour - int(float_hour)) * 60))
if minute >= 60:
return hour + 1, minute - 60
else:
return hour, minute
@staticmethod
def _days_from_010119(year, month, day):
"""Calculate the number of days from 01-01-1900 to the provided date.
Args:
year: Integer. The year in the date
month: Integer. The month in the date
day: Integer. The day in the date
Returns:
The number of days since 01-01-1900 to the provided date
"""
def is_leap_year(year):
"""Determine whether a year is a leap year over the past centuries."""
return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
if year == 2017: # fast check for the most common year used in ladybug
days_in_preceding_years = 42734
elif year == 2016: # fast check for the 2nd most common year in ladybug
days_in_preceding_years = 42368
else: # compute the number of days using math to figure out leap years
years = range(1900, year) # list of years from 1900
# Number of days in a year are 366 if it is a leap year
days_in_year = []
for item in years:
if is_leap_year(item):
days_in_year.append(366)
else:
days_in_year.append(365)
# Making the total of all the days in preceding years
days_in_preceding_years = 0
for days in days_in_year:
days_in_preceding_years += days
# get the total of all the days in preceding months in the same year
month_array = AnalysisPeriod.NUMOFDAYSEACHMONTHLEAP if is_leap_year(year) \
else AnalysisPeriod.NUMOFDAYSEACHMONTH
days_in_preceding_months = 0
for i in range(month - 1):
days_in_preceding_months += month_array[i]
return days_in_preceding_years + days_in_preceding_months + day + 1
@staticmethod
def _project_polyline_to_2d(plines_3d, projection, radius, origin_3d):
"""Project an array of Polyline3D into 2D space.
Args:
plines_3d: An array of Polyline3D to be projected to 2D space.
projection: Text for the name of the projection to use from the sky
dome hemisphere to the 2D plane. Choose from the following:
* Orthographic
* Stereographic
origin_3d: Point3D for the origin around which projection will occur.
"""
plines_2d = []
if projection.title() == 'Orthographic':
for pl in plines_3d:
pts = [Compass.point3d_to_orthographic(pt) for pt in pl.vertices]
plines_2d.append(Polyline2D(pts, True))
elif projection.title() == 'Stereographic':
for pline in plines_3d:
pts = [Compass.point3d_to_stereographic(pt, radius, origin_3d)
for pt in pline.vertices]
plines_2d.append(Polyline2D(pts, True))
else:
raise ValueError('Projection "{}" is not supported.'.format(projection))
return plines_2d
def __repr__(self):
"""Sunpath representation."""
return "Sunpath (lat:{}, lon:{}, time zone:{})".format(
self.latitude, self.longitude, self.time_zone)
[docs]class Sun(object):
"""An object representing a single Sun.
Args:
datetime: A DateTime that represents the datetime for this sun_vector
altitude: Solar Altitude in degrees.
azimuth: Solar Azimuth in degrees.
is_solar_time: Boolean indicating if the datetime represents a solar time.
is_daylight_saving: A Boolean indicating if the datetime is calculated
for a daylight saving period
north_angle: North angle of the sunpath in degrees. This is only used to
adjust the sun_vector and does not affect the sun altitude or azimuth.
Properties:
* datetime
* north_angle
* hoy
* altitude
* azimuth
* altitude_in_radians
* azimuth_in_radians
* is_solar_time
* is_daylight_saving
* data
* is_during_day
* sun_vector
* sun_vector_reversed
"""
__slots__ = ('_datetime', '_altitude', '_azimuth', '_is_solar_time',
'_is_daylight_saving', '_north_angle', '_data',
'_sun_vector', '_sun_vector_reversed')
PI = math.pi
def __init__(self, datetime, altitude, azimuth, is_solar_time,
is_daylight_saving, north_angle, data=None):
"""Init sun."""
assert isinstance(datetime, py_datetime.datetime), \
'datetime must be a DateTime (not {})'.format(type(datetime))
self._datetime = datetime # read-only
assert -90 <= altitude <= 90, \
'altitude({}) must be between {} and {}.' \
.format(altitude, -self.PI, self.PI)
self._altitude = altitude # read-only
assert -360 <= azimuth <= 360, \
'azimuth({}) should be between {} and {}.' \
.format(azimuth, -self.PI, self.PI)
self._azimuth = azimuth # read-only
self._is_solar_time = is_solar_time
self._is_daylight_saving = is_daylight_saving
self._north_angle = north_angle
self.data = data # place holder for metadata
self._sun_vector, self._sun_vector_reversed = self._calculate_sun_vector()
@property
def datetime(self):
"""Return datetime."""
return self._datetime
@property
def north_angle(self):
"""Return north angle for +YAxis."""
return self._north_angle
@property
def hoy(self):
"""Return Hour of the year."""
return self._datetime.hoy
@property
def altitude(self):
"""Return solar altitude in degrees."""
return self._altitude
@property
def azimuth(self):
"""Return solar azimuth in degrees.
This value is the same regardless of what the north_angle is.
"""
return self._azimuth
@property
def azimuth_from_y_axis(self):
"""Return solar azimuth in degrees with respect to the Y-axis of the scene.
This value will change as the north_angle is changed.
"""
angle = self._azimuth - self._north_angle
if angle > 360:
return angle - 360
elif angle < 0:
return angle + 360
return angle
@property
def altitude_in_radians(self):
"""Return solar altitude in radians."""
return math.radians(self._altitude)
@property
def azimuth_in_radians(self):
"""Return solar azimuth in radians."""
return math.radians(self._azimuth)
@property
def is_solar_time(self):
"""Return a Boolean that indicates is datetime is solar time."""
return self._is_solar_time
@property
def is_daylight_saving(self):
"""Return a Boolean that indicates is datetime is solar time."""
return self._is_daylight_saving
@property
def data(self):
"""Get or set metadata to this sun position.
No particular data type is enforced for this metadata but a dictionary
is recommended so that it can be extended for multiple properties.
"""
return self._data
@data.setter
def data(self, d):
self._data = d
@property
def is_during_day(self):
"""Boolean to note if this sun position is during day."""
# sun vector is flipped to look to the center
return self.sun_vector.z <= 0
@property
def sun_vector(self):
"""A ladybug_geometry Vector3D representing the vector for this sun.
Note that daytime sun vectors point downward (z will be negative).
"""
return self._sun_vector
@property
def sun_vector_reversed(self):
"""A ladybug_geometry Vector3D representing the reversed vector for this sun.
Daytime sun_vector_reversed point upward (z will be positive).
"""
return self._sun_vector_reversed
[docs] def position_3d(self, origin=Point3D(), radius=100):
"""Get a Point3D for the position of this sun on a sunpath.
Args:
origin: A ladybug_geometry Point3D to note the center of the sun path.
radius: A number to note the radius of the sunpath.
Returns:
A Point3D for the position of this sun on a sunpath.
"""
return Point3D(self.sun_vector_reversed.x * radius + origin.x,
self.sun_vector_reversed.y * radius + origin.y,
self.sun_vector_reversed.z * radius + origin.z)
[docs] def position_2d(self, projection='Orthographic', origin=Point2D(), radius=100):
"""Get a Point2D for the position of this sun on a sunpath.
Args:
projection: Text for the name of the projection to use from the sky
dome hemisphere to the 2D plane. (Default: 'Orthographic'). Choose
from the following:
* Orthographic
* Stereographic
origin: A ladybug_geometry Point2D to note the center of the sun path.
radius: A number to note the radius of the sunpath.
Returns:
A Point2D for the position of this sun on a sunpath.
"""
o_3d = Point3D(origin.x, origin.y, 0)
if projection.title() == 'Orthographic':
return Compass.point3d_to_orthographic(self.position_3d(o_3d, radius))
elif projection.title() == 'Stereographic':
return Compass.point3d_to_stereographic(
self.position_3d(o_3d, radius), radius, o_3d)
else:
raise ValueError('Projection "{}" is not supported.'.format(projection))
def _calculate_sun_vector(self):
"""Calculate sun vector for this sun."""
x_axis = Vector3D(1., 0., 0.)
north_vector = Vector3D(0., 1., 0.)
# rotate north vector based on azimuth, altitude, and north
_sun_vector_reversed = north_vector \
.rotate(x_axis, self.altitude_in_radians) \
.rotate_xy(-self.azimuth_in_radians)
if self.north_angle != 0:
_sun_vector_reversed = _sun_vector_reversed.rotate_xy(
math.radians(self.north_angle))
# reverse the vector
_sun_vector = _sun_vector_reversed.reverse()
return _sun_vector, _sun_vector_reversed
[docs] def ToString(self):
"""Overwrite .NET ToString method."""
return self.__repr__()
def __key(self):
"""A tuple based on the object properties, useful for hashing."""
return (self.datetime, self.altitude, self.azimuth, self.is_solar_time,
self.is_daylight_saving, self.north_angle)
def __hash__(self):
return hash(self.__key())
def __eq__(self, other):
return isinstance(other, Sun) and self.__key() == other.__key()
def __ne__(self, other):
return not self.__eq__(other)
def __repr__(self):
"""Sun representation."""
return "Sun at {} (x:{}, y:{}, z:{})".format(
self.datetime,
self.sun_vector.x,
self.sun_vector.y,
self.sun_vector.z
)