Modelling the sun’s reflection into our apartment with Python

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.

Our living room lit up by the sun at around 3:30 pm in the afternoon… but our windows face east, not west?!

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:

  1. Measure the positions of the building acting as a mirror, and the position of our apartment.
  2. Calculate the range of sun azimuths and elevations that will reflect light into the apartment.
  3. Calculate the position of the sun over a year and check if the sun lies within the ranges calculated in step 2.
When the sun is at a certain azimuth (in plan view) and altitude (in elevation view), the light reflects off the building and into our apartment.

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.