{"id":802,"date":"2021-10-01T07:00:38","date_gmt":"2021-10-01T12:00:38","guid":{"rendered":"https:\/\/wollen.org\/blog\/?p=802"},"modified":"2025-07-06T19:09:15","modified_gmt":"2025-07-07T00:09:15","slug":"the-autumnal-equinox","status":"publish","type":"post","link":"https:\/\/wollen.org\/blog\/2021\/10\/the-autumnal-equinox\/","title":{"rendered":"The autumnal equinox"},"content":{"rendered":"<p>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\u2014at least according to astronomical seasons. Meteorological seasons are different. For some reason.<\/p>\n<p>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&#8217;ll also demonstrate an easy way to add inset images to a <em>Matplotlib<\/em> plot.<\/p>\n<p>The approach will be:<\/p>\n<ol>\n<li>Get a <a href=\"http:\/\/www.world-timedate.com\/astronomy\/sunrise_sunset\/sunrise_sunset_time.php?city_id=136&amp;month=1&amp;year=2021\" target=\"_blank\" rel=\"noopener\">dataset<\/a> with daily sunrise and sunset information.<\/li>\n<li>Calculate daylight and dark duration for each day.<\/li>\n<li>Plot the result.<\/li>\n<\/ol>\n<p>Since exact times vary depending on latitude, I&#8217;ll pick a city near the center of the U.S.\u2014Lincoln, Nebraska.<\/p>\n<hr \/>\n<h4>1. Get a dataset<\/h4>\n<p>The CSV dataset looks like this. Notice that date and time are stored in separate columns. We&#8217;ll have to address this when we convert timestamps into datetime types.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\">date,sunrise,sunset\r\nJan-01-2021,07:51:37,17:09:58\r\nJan-02-2021,07:51:42,17:10:49\r\nJan-03-2021,07:51:46,17:11:42\r\n.\r\n.\r\n.<\/pre>\n<p>Begin by reading the CSV into a <em>pandas<\/em> DataFrame. Let&#8217;s skip converting the <em>date <\/em>column into a datetime format for now. It will be easier to manipulate it as a string.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">import pandas as pd\r\nimport matplotlib.pyplot as plt\r\nfrom matplotlib.dates import MonthLocator, DateFormatter\r\nfrom matplotlib.offsetbox import OffsetImage, AnnotationBbox\r\n\r\ndf = pd.read_csv(\"lincoln_sunrise_sunset_2021.csv\")<\/pre>\n<p>Create two new columns to hold sunrise and sunset datetimes. This is done by concatenating each day&#8217;s date and time strings together, then converting type to <code>datetime<\/code>.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">df.loc[:, \"sunrise_datetime\"] = pd.to_datetime((df[\"date\"] + \" at \" + df[\"sunrise\"]))\r\ndf.loc[:, \"sunset_datetime\"] = pd.to_datetime((df[\"date\"] + \" at \" + df[\"sunset\"]))<\/pre>\n<p>Now the DataFrame&#8217;s <code>head()<\/code> and <code>dtype<\/code> look like this. The original columns are still <code>object<\/code>, i.e. string, and the new columns are <code>datetime64<\/code>.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\">          date   sunrise    sunset    sunrise_datetime     sunset_datetime\r\n0  Jan-01-2021  07:51:37  17:09:58 2021-01-01 07:51:37 2021-01-01 17:09:58\r\n1  Jan-02-2021  07:51:42  17:10:49 2021-01-02 07:51:42 2021-01-02 17:10:49\r\n2  Jan-03-2021  07:51:46  17:11:42 2021-01-03 07:51:46 2021-01-03 17:11:42\r\n3  Jan-04-2021  07:51:47  17:12:36 2021-01-04 07:51:47 2021-01-04 17:12:36\r\n4  Jan-05-2021  07:51:46  17:13:32 2021-01-05 07:51:46 2021-01-05 17:13:32\r\n\r\ndate object\r\nsunrise object\r\nsunset object\r\nsunrise_datetime datetime64[ns]\r\nsunset_datetime datetime64[ns]<\/pre>\n<h4>2. Calculate daylight and dark hours<\/h4>\n<p>Remember the goal is to plot <strong>duration<\/strong> of day and night, not sunrise and sunset times. To get this information we&#8217;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.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">df.loc[:, \"daylight_hours\"] = (df[\"sunset_datetime\"] - df[\"sunrise_datetime\"]).dt.total_seconds() \/ 3600\r\ndf.loc[:, \"dark_hours\"] = 24.0 - df[\"daylight_hours\"]<\/pre>\n<p>With our datetime parsing out of the way, we should convert the <em>date<\/em> column to <code>datetime64<\/code>. It will serve as the x-axis variable on the plot. Normally you would do this conversion inside <code>read_csv<\/code> by including a <code>parse_dates<\/code> argument, but this method works as well.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">df[\"date\"] = pd.to_datetime(df[\"date\"])<\/pre>\n<hr \/>\n<h4>3. Plot the result<\/h4>\n<p>Now we can plot the data like this:<\/p>\n<ul>\n<li>x-axis\n<ul style=\"margin-bottom: 0px;\">\n<li>date<\/li>\n<\/ul>\n<\/li>\n<li>y-axis\n<ul style=\"margin-bottom: 0px;\">\n<li>daylight_hours<\/li>\n<li>dark_hours<\/li>\n<\/ul>\n<\/li>\n<\/ul>\n<p>I created a custom <code>mplstyle<\/code> for this plot based on <a href=\"https:\/\/www.apricitas.io\/\" target=\"_blank\" rel=\"noopener\">Joey Politano&#8217;s work<\/a>. It will be linked at the bottom of this post.<\/p>\n<p>There are a few different approaches but <code>plt.subplots<\/code> is a good way to begin <em>Matplotlib<\/em> code. It returns <em>figure<\/em> and <em>axes<\/em> objects, which is convenient because you&#8217;ll often need to reference them later. It also accepts parameters like <code>figsize<\/code>, although I set a default size within <code>mplstyle<\/code> so it won&#8217;t be necessary here.<\/p>\n<p>Pass the appropriate DataFrame columns to <code>ax.plot<\/code>. Include label arguments so a legend can find them later.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">plt.style.use(\"wollen_dark-blue.mplstyle\")\r\nfig, ax = plt.subplots()\r\n\r\nax.plot(df[\"date\"], df[\"daylight_hours\"], label=\"Daylight Hours\")\r\nax.plot(df[\"date\"], df[\"dark_hours\"], label=\"Dark Hours\")<\/pre>\n<p><em>Matplotlib<\/em> has a few handy built-in tools for plotting dates. <code>MonthLocator<\/code> finds the first day of every month and places a tick there (or any other day-of-month you&#8217;d like). You have other options like <code>WeekdayLocator<\/code> and <code>HourLocator<\/code> as well.<\/p>\n<p><code>DateFormatter<\/code> accepts a datetime string format and applies it to every tick label. <code>%b<\/code> represents an abbreviated form of month, e.g. &#8220;Jan&#8221; for January. I also include <code>%y<\/code>, which is the shortened two-digit form of year. The rightmost x-tick will be January 2022\u2014the next year\u2014so it will be helpful to include year information.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">ax.xaxis.set_major_locator(MonthLocator())\r\nax.xaxis.set_major_formatter(DateFormatter(\"%b '%y\"))<\/pre>\n<p>In situations where you don&#8217;t want to customize font, size, and so on, you can pass several arguments to <code>ax.set<\/code>.<\/p>\n<p>For example, we could write <code>ax.set_xlim<\/code> and on the next line <code>ax.set_ylim<\/code>, but it&#8217;s a bit cleaner to include both in a single method.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">ax.set(xlim=(pd.Timestamp(\"Dec 20 2020\"), pd.Timestamp(\"Jan 12 2022\")),\r\n       yticks=range(8, 18),\r\n       ylim=(7.8, 17.0),\r\n       ylabel=\"Hours\",\r\n       title=\"Lincoln, Nebraska  |  Daylight Hours  |  2021\")<\/pre>\n<p>A legend will be helpful on this plot, even though we&#8217;ll identify the lines with images in a moment.<\/p>\n<p>The legend&#8217;s location is &#8220;upper center&#8221;. This could equivalently be written as <code>loc=9<\/code>.<\/p>\n<p>You can probably guess that <code>ncol<\/code> 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.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">plt.legend(loc=\"upper center\", ncol=2)<\/pre>\n<p>Now I&#8217;d like to add a couple images to the plot. I created very simple sun and moon clipart to identify the two curves\u2014the same images featured at the top of this post.<\/p>\n<p>This can be accomplished by passing an <code>OffsetBox<\/code> into an <code>AnnotationBbox<\/code>. I understand no one wants to hear me break this down more than necessary so I&#8217;ll link to an <a href=\"https:\/\/matplotlib.org\/stable\/gallery\/text_labels_and_annotations\/demo_annotation_box.html\" target=\"_blank\" rel=\"noopener\">official Matplotlib demo<\/a> for anyone interested in learning more.<\/p>\n<p>It isn&#8217;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.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">image_list = [(pd.Timestamp(\"Jan 10 2021\"), 8.9, \"sun.png\"),\r\n              (pd.Timestamp(\"Jan 10 2021\"), 15.0, \"moon.png\")]\r\n\r\nfor x_pos, y_pos, path in image_list:\r\n    ab = AnnotationBbox(OffsetImage(plt.imread(path), zoom=0.07), (x_pos, y_pos), frameon=False)\r\n    ax.add_artist(ab)<\/pre>\n<p>The <code>zoom<\/code> parameter above is important. The output will look much better if you use a normal resolution image, e.g. 500&#215;500 pixels, along with a small <code>zoom<\/code> argument, rather than attempting to shrink the image before plotting.<\/p>\n<p>Set <code>frameon=False<\/code> so <em>Matplotlib<\/em> will respect transparency.<\/p>\n<p>As always I suggest saving in vectorized SVG format whenever possible. If you need a raster image, it will help to increase <code>dpi<\/code> 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.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">plt.savefig(\"daylight_2021.png\", dpi=200)<\/pre>\n<p>The output:<\/p>\n<p><a href=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2021\/10\/daylight_2021-1.png\"><img loading=\"lazy\" decoding=\"async\" class=\"aligncenter wp-image-2249 size-full\" src=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2021\/10\/daylight_2021-1.png\" alt=\"\" width=\"2400\" height=\"1300\" srcset=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2021\/10\/daylight_2021-1.png 2400w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2021\/10\/daylight_2021-1-300x163.png 300w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2021\/10\/daylight_2021-1-1024x555.png 1024w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2021\/10\/daylight_2021-1-768x416.png 768w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2021\/10\/daylight_2021-1-1536x832.png 1536w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2021\/10\/daylight_2021-1-2048x1109.png 2048w\" sizes=\"auto, (max-width: 2400px) 100vw, 2400px\" \/><\/a><\/p>\n<p>You can see daylight and dark hours recently crossed in late September. That was the autumnal equinox\u201412 hours each of day and night. Just before Christmas we&#8217;ll experience the winter solstice, at which point daylight reaches its minimum, and then days will grow longer again.<\/p>\n<hr \/>\n<p><strong><a href=\"https:\/\/wollen.org\/misc\/daylight_data_10-1-2021.zip\">Download the data<\/a>.<\/strong><\/p>\n<p><strong>Full code:<\/strong><\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">import pandas as pd\r\nimport matplotlib.pyplot as plt\r\nfrom matplotlib.dates import MonthLocator, DateFormatter\r\nfrom matplotlib.offsetbox import OffsetImage, AnnotationBbox\r\n\r\n\r\ndf = pd.read_csv(\"lincoln_sunrise_sunset_2021.csv\")\r\n\r\ndf.loc[:, \"sunrise_datetime\"] = pd.to_datetime((df[\"date\"] + \" at \" + df[\"sunrise\"]))\r\ndf.loc[:, \"sunset_datetime\"] = pd.to_datetime((df[\"date\"] + \" at \" + df[\"sunset\"]))\r\n\r\ndf.loc[:, \"daylight_hours\"] = (df[\"sunset_datetime\"] - df[\"sunrise_datetime\"]).dt.total_seconds() \/ 3600\r\ndf.loc[:, \"dark_hours\"] = 24.0 - df[\"daylight_hours\"]\r\n\r\ndf[\"date\"] = pd.to_datetime(df[\"date\"])\r\n\r\nplt.style.use(\"wollen_dark-blue.mplstyle\")\r\nfig, ax = plt.subplots()\r\n\r\nax.plot(df[\"date\"], df[\"daylight_hours\"], label=\"Daylight Hours\")\r\nax.plot(df[\"date\"], df[\"dark_hours\"], label=\"Dark Hours\")\r\n\r\nax.xaxis.set_major_locator(MonthLocator())\r\nax.xaxis.set_major_formatter(DateFormatter(\"%b '%y\"))\r\n\r\nax.set(xlim=(pd.Timestamp(\"Dec 20 2020\"), pd.Timestamp(\"Jan 12 2022\")),\r\n       yticks=range(8, 18),\r\n       ylim=(7.8, 17.0),\r\n       ylabel=\"Hours\",\r\n       title=\"Lincoln, Nebraska  |  Daylight Hours  |  2021\")\r\n\r\nplt.legend(loc=\"upper center\", ncol=2)\r\n\r\nimage_list = [(pd.Timestamp(\"Jan 10 2021\"), 8.9, \"sun.png\"),\r\n              (pd.Timestamp(\"Jan 10 2021\"), 15.0, \"moon.png\")]\r\n\r\nfor x_pos, y_pos, path in image_list:\r\n    ab = AnnotationBbox(OffsetImage(plt.imread(path), zoom=0.07), (x_pos, y_pos), frameon=False)\r\n    ax.add_artist(ab)\r\n\r\nplt.savefig(\"daylight_2021.png\", dpi=200)<\/pre>\n<p>&nbsp;<\/p>\n","protected":false},"excerpt":{"rendered":"<p>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\u2014at least according to astronomical seasons. Meteorological seasons are different. For<\/p>\n","protected":false},"author":1,"featured_media":834,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[279],"tags":[136,138,135,22,122,53,124,128,130,24,126,75,137,30,25,134,129,133,131,125,54,127,132],"class_list":["post-802","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-nature","tag-annotationbbox","tag-artist","tag-csv","tag-data","tag-dataset","tag-datetime","tag-datetime64","tag-equinox","tag-fall","tag-matplotlib","tag-mplstyle","tag-numpy","tag-offsetimage","tag-pandas","tag-python","tag-seasons","tag-solstice","tag-spring","tag-summer","tag-timedelta64","tag-timestamp","tag-weather","tag-winter"],"_links":{"self":[{"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/posts\/802","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/comments?post=802"}],"version-history":[{"count":34,"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/posts\/802\/revisions"}],"predecessor-version":[{"id":3175,"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/posts\/802\/revisions\/3175"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/media\/834"}],"wp:attachment":[{"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/media?parent=802"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/categories?post=802"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/tags?post=802"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}