Nature

The autumnal equinox

Here in the northern hemisphere we recently experienced the autumnal equinox, which means we saw an equal 12 hours of day and night. It also marked the beginning of fall—at least according to astronomical seasons. Meteorological seasons are different. For some reason.

I think plotting the annual progression of sunlight and dark hours will help put the change into perspective. It offers an opportunity to work with datetime data, which I remember seemed so unintuitive when I was learning it. I’ll also demonstrate an easy way to add inset images to a Matplotlib plot.

The approach will be:

  1. Get a dataset with daily sunrise and sunset information.
  2. Calculate daylight and dark duration for each day.
  3. Plot the result.

Since exact times vary depending on latitude, I’ll pick a city near the center of the U.S.—Lincoln, Nebraska.


1. Get a dataset

The CSV dataset looks like this. Notice that date and time are stored in separate columns. We’ll have to address this when we convert timestamps into datetime types.

date,sunrise,sunset
Jan-01-2021,07:51:37,17:09:58
Jan-02-2021,07:51:42,17:10:49
Jan-03-2021,07:51:46,17:11:42
.
.
.

Begin by reading the CSV into a pandas DataFrame. Let’s skip converting the date column into a datetime format for now. It will be easier to manipulate it as a string.

import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.dates import MonthLocator, DateFormatter
from matplotlib.offsetbox import OffsetImage, AnnotationBbox

df = pd.read_csv("lincoln_sunrise_sunset_2021.csv")

Create two new columns to hold sunrise and sunset datetimes. This is done by concatenating each day’s date and time strings together, then converting type to datetime.

df.loc[:, "sunrise_datetime"] = pd.to_datetime((df["date"] + " at " + df["sunrise"]))
df.loc[:, "sunset_datetime"] = pd.to_datetime((df["date"] + " at " + df["sunset"]))

Now the DataFrame’s head() and dtype look like this. The original columns are still object, i.e. string, and the new columns are datetime64.

          date   sunrise    sunset    sunrise_datetime     sunset_datetime
0  Jan-01-2021  07:51:37  17:09:58 2021-01-01 07:51:37 2021-01-01 17:09:58
1  Jan-02-2021  07:51:42  17:10:49 2021-01-02 07:51:42 2021-01-02 17:10:49
2  Jan-03-2021  07:51:46  17:11:42 2021-01-03 07:51:46 2021-01-03 17:11:42
3  Jan-04-2021  07:51:47  17:12:36 2021-01-04 07:51:47 2021-01-04 17:12:36
4  Jan-05-2021  07:51:46  17:13:32 2021-01-05 07:51:46 2021-01-05 17:13:32

date object
sunrise object
sunset object
sunrise_datetime datetime64[ns]
sunset_datetime datetime64[ns]

2. Calculate daylight and dark hours

Remember the goal is to plot duration of day and night, not sunrise and sunset times. To get this information we’ll need to subtract the new columns. Daylight hours can be calculated by subtracting sunrise from sunset. And since every day is 24 hours long, we can get dark hours by subtracting daylight hours from 24.

df.loc[:, "daylight_hours"] = (df["sunset_datetime"] - df["sunrise_datetime"]).dt.total_seconds() / 3600
df.loc[:, "dark_hours"] = 24.0 - df["daylight_hours"]

With our datetime parsing out of the way, we should convert the date column to datetime64. It will serve as the x-axis variable on the plot. Normally you would do this conversion inside read_csv by including a parse_dates argument, but this method works as well.

df["date"] = pd.to_datetime(df["date"])

3. Plot the result

Now we can plot the data like this:

  • x-axis
    • date
  • y-axis
    • daylight_hours
    • dark_hours

I created a new custom mplstyle for this plot. It will be linked at the bottom of this post.

There are a few different approaches but plt.subplots is a good way to begin Matplotlib code. It returns figure and axes objects, which is convenient because you’ll often need to reference them later. It also accepts parameters like figsize, although I set a default size within mplstyle so it won’t be necessary here.

Pass the appropriate DataFrame columns to ax.plot. Include label arguments so a legend can find them later.

plt.style.use("wollen_dark-blue.mplstyle")
fig, ax = plt.subplots()

ax.plot(df["date"], df["daylight_hours"], label="Daylight Hours")
ax.plot(df["date"], df["dark_hours"], label="Dark Hours")

Matplotlib has a few handy built-in tools for plotting dates. MonthLocator finds the first day of every month and places a tick there (or any other day-of-month you’d like). You have other options like WeekdayLocator and HourLocator as well.

DateFormatter accepts a datetime string format and applies it to every tick label. %b represents an abbreviated form of month, e.g. “Jan” for January. I also include %y, which is the shortened two-digit form of year. The rightmost x-tick will be January 2022—the next year—so it will be helpful to include year information.

ax.xaxis.set_major_locator(MonthLocator())
ax.xaxis.set_major_formatter(DateFormatter("%b '%y"))

In situations where you don’t want to customize font, size, and so on, you can pass several arguments to ax.set.

For example, we could write ax.set_xlim and on the next line ax.set_ylim, but it’s a bit cleaner to include both in a single method.

ax.set(xlim=(pd.Timestamp("Dec 20 2020"), pd.Timestamp("Jan 12 2022")),
       yticks=range(8, 18),
       ylim=(7.8, 17.0),
       ylabel="Hours",
       title="Lincoln, Nebraska  |  Daylight Hours  |  2021")

A legend will be helpful on this plot, even though we’ll identify the lines with images in a moment.

The legend’s location is “upper center”. This could equivalently be written as loc=9.

You can probably guess that ncol defines number of columns. With its default value of 1, the legend entries would be stacked vertically. I think a horizontal orientation looks nicer on this plot.

plt.legend(loc="upper center", ncol=2)

Now I’d like to add a couple images to the plot. I created very simple sun and moon clipart to identify the two curves—the same images featured at the top of this post.

This can be accomplished by passing an OffsetBox into an AnnotationBbox. I understand no one wants to hear me break this down more than necessary so I’ll link to an official Matplotlib demo for anyone interested in learning more.

It isn’t necessary to structure the code as a loop, especially if you plan to overlay only a single image. But I find it convenient to abstract images and their xy-coordinates away from the heavy lifting.

image_list = [(pd.Timestamp("Jan 10 2021"), 8.9, "sun.png"),
              (pd.Timestamp("Jan 10 2021"), 15.0, "moon.png")]

for x_pos, y_pos, path in image_list:
    ab = AnnotationBbox(OffsetImage(plt.imread(path), zoom=0.07), (x_pos, y_pos), frameon=False)
    ax.add_artist(ab)

The zoom parameter above is important. The output will look much better if you use a normal resolution image, e.g. 500×500 pixels, along with a small zoom argument, rather than attempting to shrink the image before plotting.

Set frameon=False so Matplotlib will respect transparency.

As always I suggest saving in vectorized SVG format whenever possible. If you need a raster image, it will help to increase dpi from its default of 100. Doing so will provide an anti-aliasing effect, i.e. fewer jaggy stairstep edges, which is especially important when adding images to a plot.

plt.savefig("daylight_2021.png", dpi=200)

The output:

You can see daylight and dark hours recently crossed in late September. That was the autumnal equinox—12 hours each of day and night. Just before Christmas we’ll experience the winter solstice, at which point daylight reaches its minimum, and then days will grow longer again.


Download the data.

Full code:

import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.dates import MonthLocator, DateFormatter
from matplotlib.offsetbox import OffsetImage, AnnotationBbox


df = pd.read_csv("lincoln_sunrise_sunset_2021.csv")

df.loc[:, "sunrise_datetime"] = pd.to_datetime((df["date"] + " at " + df["sunrise"]))
df.loc[:, "sunset_datetime"] = pd.to_datetime((df["date"] + " at " + df["sunset"]))

df.loc[:, "daylight_hours"] = (df["sunset_datetime"] - df["sunrise_datetime"]).dt.total_seconds() / 3600
df.loc[:, "dark_hours"] = 24.0 - df["daylight_hours"]

df["date"] = pd.to_datetime(df["date"])

plt.style.use("wollen_dark-blue.mplstyle")
fig, ax = plt.subplots()

ax.plot(df["date"], df["daylight_hours"], label="Daylight Hours")
ax.plot(df["date"], df["dark_hours"], label="Dark Hours")

ax.xaxis.set_major_locator(MonthLocator())
ax.xaxis.set_major_formatter(DateFormatter("%b '%y"))

ax.set(xlim=(pd.Timestamp("Dec 20 2020"), pd.Timestamp("Jan 12 2022")),
       yticks=range(8, 18),
       ylim=(7.8, 17.0),
       ylabel="Hours",
       title="Lincoln, Nebraska  |  Daylight Hours  |  2021")

plt.legend(loc="upper center", ncol=2)

image_list = [(pd.Timestamp("Jan 10 2021"), 8.9, "sun.png"),
              (pd.Timestamp("Jan 10 2021"), 15.0, "moon.png")]

for x_pos, y_pos, path in image_list:
    ab = AnnotationBbox(OffsetImage(plt.imread(path), zoom=0.07), (x_pos, y_pos), frameon=False)
    ax.add_artist(ab)

plt.savefig("daylight_2021.png", dpi=200)