The final plot and full code are stored as a Github gist.
We moved apartments right before the COVID lockdowns began. Being stuck inside all day, I’ve started noticing things I probably would have missed in my regular routine. One of these things is that our east facing apartment gets lit up in the afternoon from the sun’s reflection from a particularly reflective building. The first time it happened was quite a surprise and it quickly passed. However, over the coming days, the glowing light started lasting longer.
It turns out the culprit was a particularly reflective building located a couple of blocks away. As the sun sets in the afternoon, our apartment is in the right position to collect the light which is bounced off the building.
The questions
The path of the sun through the sky changes throughout the year and so only a certain position would reflect light into our apartment. This then begs the following questions:
- What time of year can I expect to have the sun reflecting into our apartment?
- Would the time of day the reflection occurred change throughout the year?
- Would the duration of the reflection change throughout the year?
The approach
I’m no astrophysicist, but I thought I could tackle the problem in a couple of steps:
- Measure the positions of the building acting as a mirror, and the position of our apartment.
- Calculate the range of sun azimuths and elevations that will reflect light into the apartment.
- Calculate the position of the sun over a year and check if the sun lies within the ranges calculated in step 2.
This is going to be a very simplified approach, so there are a couple of assumptions:
- Reflection of the light from the building is specular (i.e. the angle of the incident ray equals the angle of the reflected ray). This may not be completely true if the glass is slightly diffuse, resulting in a different reflected angle.
- If it’s a cloudy day, we probably aren’t going to see a reflection even if the sun is in the correct location.
- Since our apartment isn’t very wide compared to the width of the reflective building, I’m going to model our apartment as a point.
- The reflective building is made up of many smaller panels, so the brightness of the reflection is going to vary as sun sets.
I’m sure there are many other things an architect or designer would be able to take into account, but this is going to be a decent first attempt.
Step 1) Measure positions
The first step is to figure out where our mirror building is in relation to our apartment. We don’t need any fancy tools for this, latitudes and longitudes can be read straight from Google Maps. We’re going to need two points to define the plane of the reflective building face (mirror_pt1
and mirror_pt2
) and one point to define the location of our apartment (focus_pt
). Each point is defined by a latitude, a longitude and an elevation. Elevation can be estimated by measuring the vertical angle from the apartment to each point. Note that area of the building that can act as a mirror can only be the top half of the reflected building about the apartment altitude.
Next, let’s load these into Python and store them in a Point class. Here, I’m using the dataclass
feature introduced in Python 3.7 to create a minimal class to store our points. The utm
package converts lat/lons to x/y coordinates in the correct UTM zone.
from dataclasses import dataclass import utm @dataclass class Point: """ Point class which helps us convert between lat,lon and x,y easily. """ lat: float lon: float z: float @property def x(self): x, _, _, _ = utm.from_latlon(self.lat, self.lon) return x @property def y(self): _, y, _, _ = utm.from_latlon(self.lat, self.lon) return y # Coordinates redacted here, but feel free to substitue your own. # Mirror points define the plane of the building which is reflecting the sun mirror_pt1 = Point(lat=-33.8, lon=151.2, z=200) mirror_pt2 = Point(lat=-33.8, lon=151.2, z=100) # Focus point is the apartment focus_pt = Point(lat=-33.8, lon=151.2, z=0)
Step 2) Calculate sun elevations & azimuths which result in a reflection
I’m defining the position of the sun in the sky by two angles, azimuth (clockwise from north) and it’s elevation (the angle between the horizon to the sun’s center). We want to back-calculate the azimuths and elevations which result in a reflection by using our law of reflections.
Let’s store the calculations in a ReflectionCalculator
class. The brunt of the work is done by the find_sun_positions()
method. We check both points defined by the mirror as they determine what the limits of the sun azimuth and elevations are going to be.
import math import numpy as np class ReflectionCalculator: def __init__(self, mirror_pt1, mirror_pt2, focus_pt): self.mirror_pt1 = mirror_pt1 self.mirror_pt2 = mirror_pt2 self.focus_pt = focus_pt self.valid_sun_azimuths, self.valid_sun_elevations = self.find_sun_positions() @property def mirror_azimuth(self): """ Returns the azimuth of the mirror in degrees. Needed to calculate the range of sun positions which result in a reflection. """ return 90 - math.degrees( math.atan( (self.mirror_pt2.y - self.mirror_pt1.y) / (self.mirror_pt2.x - self.mirror_pt1.x) ) ) def find_sun_positions(self): """ Find the minimum and maximum azimuth and elevations that the sun must be positioned at to reflect off the building mirror and into the focus point """ # Check both mirror points mirror_pts = [self.mirror_pt1, self.mirror_pt2] # Initialize lists to store the calculated azimuths and elevations sun_azimuths = [] sun_elevations = [] for mirror_pt in mirror_pts: elevation, azimuth = self.angles_between_points(mirror_pt, self.focus_pt) # Calculate the position of where the sun needs to be to result in a # reflection sun_azimuths.append(90 + azimuth + 2 * self.mirror_azimuth) sun_elevations.append(-elevation) valid_sun_azimuths = (min(sun_azimuths), max(sun_azimuths)) valid_sun_elevations = (min(sun_elevations), max(sun_elevations)) return valid_sun_azimuths, valid_sun_elevations @staticmethod def angles_between_points(pt1, pt2): """ Returns the elevation and azimuth in degrees at pt1, looking at pt2 """ dx = pt2.x - pt1.x dy = pt2.y - pt1.y dz = pt2.z - pt1.z azimuth = np.degrees(math.atan2(dy, dx)) if azimuth < 0: azimuth += 360 elevation = math.degrees(math.atan(dz / np.sqrt(dx ** 2 + dy ** 2))) return elevation, azimuth
Step 3) Calculate the position of the sun over the year
There are several packages that can return the azimuth and elevation of the sun in the sky at a particular location. I’ve gone with the pysolar
package which has fast (but less accurate methods) of doing these calculations. For our cases, the reduced accuracy is going to be fine. Inside our ReflectionCalculator
class, we add the following function which generates a timeseries, and checks whether the sun falls within the bounds of reflecting of the building and into our apartment.
import pandas as pd def calculate_reflective_times( self, start, end, freq, tz, hour_start=14, hour_end=18 ): """ Creates a pandas dataframe containing whether the sun will be reflecting into the apartment at a each time or not. :param start: datetime of the start time to analyse :param end: datetime of the end time to analyse :param freq: str of the frequency of items between start and end :param tz: pytz.timezone of the timezone of the location :param hour_start: int of the minimum hour of day to check :param hour_end: int of the maxmimum hour of day to check """ # Use position at the center of the mirror mirror_lat = statistics.mean([self.mirror_pt1.lat, self.mirror_pt2.lat]) mirror_lon = statistics.mean([self.mirror_pt1.lon, self.mirror_pt2.lon]) # Build timeseries index = pd.date_range(start, end, freq=freq, tz=tz) # Make dataframe, but only interested in certain times during the day df = pd.DataFrame(index=index) df = df.loc[ (df.index.to_series().dt.hour >= hour_start) & (df.index.to_series().dt.hour < hour_end) ] # Calculate position of sun at each time step df["sun_altitudes"] = df.index.to_series().apply( lambda x: get_altitude_fast(mirror_lat, mirror_lon, x) ) df["sun_azimuths"] = df.index.to_series().apply( lambda x: get_azimuth_fast(mirror_lat, mirror_lon, x) ) # Determine whether sun is in correct position to reflect off mirror df['in_elevation'] = False df['in_azimuth'] = False df['will_reflect'] = False df.loc[df.sun_altitudes.between(*self.valid_sun_elevations), 'in_elevation'] =True df.loc[df.sun_azimuths.between(*self.valid_sun_azimuths), 'in_azimuth'] =True df.loc[(df.in_elevation) & (df.in_azimuth), 'will_reflect'] = True return df
Now we can initalize our reflection class with our points and calculate a dataframe of times and showing whether the sun will reflect
# Initalize our reflection calculator class reflector = ReflectionCalculator(mirror_pt1, mirror_pt2, focus_pt) # Create a dataframe for the period we're interested in which calculates whether # the sun will reflect into the apartment df = reflector.calculate_reflective_times( start=datetime.datetime(2020, 3, 1), end=datetime.datetime(2020, 10, 31), freq="1min", tz=pytz.timezone("Australia/Brisbane"), )
This gives us a dataframe where each row shows whether the sun is at the correct azimuth and elevatation to reflect into our building. We can also get the values of the sun azimuths and elevations which result in a reflection
>>> df.head() sun_altitudes sun_azimuths in_elevation in_azimuth will_reflect 2020-03-01 14:00:00+10:00 53.432027 308.714212 False False False 2020-03-01 14:01:00+10:00 53.269642 308.401035 False False False 2020-03-01 14:02:00+10:00 53.106555 308.090071 False False False 2020-03-01 14:03:00+10:00 52.942775 307.781299 False False False 2020-03-01 14:04:00+10:00 52.778311 307.474697 False False False >>> reflector.valid_sun_elevations (10.9, 20.2) >>> reflector.valid_sun_azimuths (296.7, 303.4)
This is telling us the sun needs to be between 10.9 and 20.2 degrees above the horizon at an azimuth between 296.7 and 303.4 degrees clockwise from true north.
This is not bad, but still difficult to visualize. Let’s turn to matplotlib
to better make sense of what we have. The following function generates a heatmap and shows us exactly what’s happening with the reflection times.
def plot_time_heatmap(df, output_file, use_tex=False): """ Plot a time heat map of the times where the sun is reflecting """ # Let's pivot the table so it's a bit more useful # Add day to dataframe df["day"] = df.index.to_series().dt.floor("d") df["time"] = df.index.to_series().dt.time # Create another column with the reason if it will reflect or not df["reflect_reason"] = 3 df.loc[(df.in_azimuth) & (~df.in_elevation), "reflect_reason"] = 2 df.loc[(~df.in_azimuth) & (df.in_elevation), "reflect_reason"] = 1 df.loc[(df.in_azimuth) & (df.in_elevation), "reflect_reason"] = 0 # Pivot the table, se we have time as the index and each day as a column with if # it will reflect as the values df = pd.pivot_table(df, columns="day", index="time", values="reflect_reason") # Data to plot. plot_data = df.values # Use custom color map cmap = colors.ListedColormap(["#9e0142", "#fee08b", "#abdda4", "#5e4fa2"]) bounds = [-0.5, 0.5, 1.5, 2.5, 3.5] norm = colors.BoundaryNorm(bounds, cmap.N) # Create y_lims y_vals = [datetime.datetime.combine(datetime.date(2000, 1, 1), x) for x in df.index] y_vals = mdates.date2num(y_vals) # Create x-lims x_vals = mdates.date2num(df.columns) # Add settings to customize look of plot plt.rc("grid", linestyle="--", color="black", alpha=0.3) plt.rc("font", family="sans-serif") # Note that LaTeX needs to be install on the machine for this to run properly. # It's not necessary to use LaTeX, but you get nicer fonts if use_tex: plt.rc("text", usetex=True) plt.rc( "text.latex", preamble=[ r"\usepackage{siunitx}", r"\sisetup{detect-all}", r"\usepackage[default]{sourcesanspro}", r"\usepackage{amsmath}", r"\usepackage{sansmath}", r"\sansmath", ], ) # Create figure fig, ax = plt.subplots(figsize=(5, 4)) _ = ax.imshow( plot_data, cmap=cmap, norm=norm, extent=[x_vals[0], x_vals[-1], y_vals[-1], y_vals[0]], origin="upper", aspect="auto", ) ax.xaxis_date() ax.yaxis_date() month_year_format = mdates.DateFormatter("%b %Y") hour_min_format = mdates.DateFormatter("%H:%M %p") ax.xaxis.set_major_formatter(month_year_format) ax.yaxis.set_major_formatter(hour_min_format) # Rotate ticks plt.setp(ax.get_xticklabels(), rotation=45, ha="right", rotation_mode="anchor") # Make custom legend legend_elements = [ Patch( facecolor="#9e0142", edgecolor="#9e0142", label="Correct elevation\n& azimuth", ), Patch(facecolor="#fee08b", edgecolor="#fee08b", label="Correct elevation"), Patch(facecolor="#abdda4", edgecolor="#abdda4", label="Correct azimuth"), Patch( facecolor="#5e4fa2", edgecolor="#5e4fa2", label="Wrong elevation\n& azimuth" ), ] ax.legend(handles=legend_elements, prop={"size": 6}) plt.grid(True) ax.set_title("When will sun reflect from building to home?") plt.tight_layout() fig.savefig(output_file, dpi=300)
The conclusion
Calling plot_time_heatmap(df, output_file='plot.png)
gives us this overview of the position of the sun relative to our window of correct elevations and azimuths.
I’m really fond of this plot because it shows exactly when we’re going to get that reflection. The sun’s azimuth and elevation are changing throughout the year, and only when we get that perfect overlap do we see the right position for a reflection. We get reflections for about a month between mid-April to mid-May and again between mid-July and mid-August. At it’s longest duration, the reflection lasts for nearly 45 minutes.
The sketch below is another way we could have plotted these results. The sun follows slightly different paths throughout the year. Depending when they cross the red window of correct azimuth and elevation do we get that reflection.
I think this is a great example of how a little bit of programming knowledge can go a long way and uncover some interesting things about your daily, confined life. Domain-specific packages that are freely available make it easy to implement something that might have been too difficult in the past. If pysolar
or another package weren’t available, would I have implemented my own solar calculation routine? Probably not. But now, I can make sure not to miss the small things that brighten up the day.