I’ll follow the Sun

Through an odd coincidence, I started writing this post when the Sun was in Cancer, but by the time I’m done and get it published, the Sun will be in Leo. Don’t worry, this isn’t an astrology post—I don’t want to anger John Gruber—but it is a further coincidence that I’ve been puttering around with observational astronomy calculations at a time when there’s been some controversy in the Apple world about astronomy vs. astrology.

I’m often out walking at night, and a few months ago—when Mercury, Venus, and Jupiter were near each other and close to Castor and Pollux, the Gemini twins—I started thinking it would be nice to be able to write little scripts that would tell me where the planets were and when they’d be easily visible. One of the fun features would be including information on which constellation the planets were in.

This led me on a fun journey of discovery, the sort of thing AI companies want us to forget how to do. I learned that official boundaries for the Western constellations were defined only about a century ago and that although they were defined in a very simple way, corresponding to lines of right ascension and declination, precession of the equinoxes has since made those boundaries more complicated. They still look pretty well aligned, but they aren’t. Here’s the IAU image of Aries:

IAU Aries boundaries

If you look at the right ascension and declination coordinates of the points on its boundary, you’ll see that successive points don’t have the same RA or Dec values. Close, but not the same.

02 06 39.6594| 10.5143948|ARI 
01 46 37.3761| 10.5432396|ARI 
01 46 58.7219| 25.6263351|ARI 
02 02 03.2907| 25.6050701|ARI 
02 02 07.3479| 27.8550186|ARI 
02 32 16.8357| 27.8047638|ARI 
02 32 24.7665| 31.2213154|ARI 
02 50 30.8112| 31.1865025|ARI 
03 29 42.4003| 31.1003609|ARI 
03 29 09.7494| 19.4343338|ARI 
03 24 08.9363| 19.4461136|ARI 
03 23 47.1387| 10.3632069|ARI 

I also learned that there are a crapload of reference frames, and you had better know which one is being used for the data you’re accessing.

This is one of the things that led me to abandon Mathematica and the Wolfram Language for these calculations. Despite Wolfram’s promises of great solutions for astronomy, my test notebook showed that the RA and Dec for the Sun, for example, aren’t reported in the same frame as the RA and Dec of stars. This isn’t necessarily bad—it could be more natural to use one frame for one sort of object and another frame for another sort—but the documentation needs to tell you what frames are being used and how to convert between them. I never found that explanation, so I started exploring Python solutions.

There are two main Python modules for doing the kind of calculation I’m interested in: Astropy and Skyfield. Astropy is the standard Python module (or set of modules) for doing all sorts of astronomical calculations; Skyfield seems to be more focused on observational astronomy. That would suggest I should use Skyfield, but after looking through the documentation, I decided to get my feet wet with Astropy because it looked simpler. I can always switch to Skyfield if I find myself bumping up against some limitations in Astropy.

(I should also mention SPICE, which is NASA’s software toolkit for working with the positions of planets and other objects in space. It has a Python wrapper for its C version, and having the imprimatur of NASA certainly made it attractive. But it doesn’t have actual Python documentation; it wants users to refer to the C documentation to figure out how the Python functions work, and I’m not interested in that.)

Having settled on Astropy, I wrote up a little script this afternoon to see if my brief review of the documentation was enough to do some of the calculations I was interested in. The script calculates the position of the Sun today at 2:00 PM CDT, and converts the result into a form that I can compare with the NOAA Solar Calculator page.

NOAA Sun position page

I’ve set the observation point to the visitor center at the Morton Arboretum. The results of interest are the azimuth and altitude (or elevation) of the Sun at the appointed date and time. Here’s a zoomed-in view of the lower right corner:

NOAA Sun azimuth and altitude

So a person at the Arboretum at 2:00 would see the Sun in the southwest, 211.59° from north, at 60.35° up from the horizon. NOAA’s altitude calculation includes an atmospheric correction, the formula for which is given on a linked page.

Here’s my little script:

python:
 1:  #!/usr/bin/env python3
 2:  
 3:  from astropy.coordinates import ICRS, AltAz, EarthLocation, get_sun
 4:  from astropy.time import Time
 5:  import astropy.units as u
 6:  from trigd import *
 7:  
 8:  # 2:00 PM Central Daylight Time on August 10, 2026.
 9:  utcoffset = -5*u.hour
10:  time = Time('2026-8-10 14:00:00') - utcoffset
11:  
12:  # Location of Morton Arboretum visitor center.
13:  morton = EarthLocation(lat=41.81433*u.deg, lon=-88.07093*u.deg, height=208*u.m)
14:  
15:  # Sun position in GCRS (default).
16:  sun = get_sun(time)
17:  
18:  # The constellation it's in.
19:  constellation = sun.get_constellation()
20:  
21:  # Sun position as azimuth and altitude
22:  sun_altaz = sun.transform_to(AltAz(obstime=time, location=morton))
23:  
24:  # The default transformation to AltAz makes no adjustment for refraction.
25:  # Use the NOAA refraction formula to adjust altitude for comparison
26:  # with the NOAA value.
27:  def noaa_refraction(alt):
28:    t = tand(alt)
29:    return (58.1/t - .007/t**3 + .000086/t**5)/3600
30:  
31:  # Print the results.
32:  az = sun_altaz.az.value
33:  alt = sun_altaz.alt.value
34:  alt_adj = alt + noaa_refraction(alt)
35:  print(f'      Azimuth: {az:-6.2f}°')
36:  print(f'     Altitude: {alt:-6.2f}° (without refraction)')
37:  print(f'     Altitude: {alt_adj:-6.2f}° (with NOAA refraction)')
38:  print(f'Constellation: {constellation}')

The script starts by importing various Astropy submodules and my trigd module, which calculates trigonometric functions for degrees instead of radians. We’ll use that to match NOAA’s atmospheric correction formula.

Lines 9 and 10 set the date and time as an Astropy Time object. Line 13 sets the location to the visitor center. Line 16 gets the position of the Sun at that time in the Geocentric Celestial Reference System (GCRS) frame. Line 22 then transforms the position to azimuth and altitude as viewed from the visitor center.

Because I didn’t include any atmospheric information in the AltAz specification, it assumes a vacuum and does no refraction adjustment. I did it that way so I could use NOAA’s refraction formula instead of whatever Astropy does. That formula is defined in Lines 27–29 (which uses the tand function to calculate the tangent of an angle given in degrees), and the adjusted altitude is calculated in Line 34.

Lines 35–38 print out the results, which look like this:

      Azimuth: 211.59°
     Altitude:  60.34° (without refraction)
     Altitude:  60.35° (with NOAA refraction)
Constellation: Cancer

We’ll return to the constellation part later. As you can see, the azimuth and adjusted altitude match the NOAA values to two decimal places, which made me feel pretty good.

As for the constellation the Sun was in at the specified time, that’s calculated by the aptly named get_constellation function on Line 19. The great thing about get_constellation is that it understands the reference frame of the object it’s called from and does whatever transformations are needed (in this case to ICRS) to figure out which constellation that object is in. The answer was printed out by Line 38.

To check on the constellation answer, I went to Heavens Above, a site I’ve been using since the late 90s. I entered the Arboretum location and 2:00 PM today as the time, and HA told me the Sun was in Cancer, just like Astropy. It also showed me this sky chart:

Heavens Above sky chart

The Sun was clearly near the end of its time in Cancer and would soon be in Leo. So I began a trial-and-error search at Heavens Above and with Astropy in an interactive Python session to find out when the Sun would move from Cancer to Leo.

Heavens Above told me that the Sun would enter Leo at 8:00:15 PM today. Astropy said it would happen at 8:07:23 PM. I suspect Heavens Above is giving the better answer, as I’ve been using Astropy in its most basic configuration. There are ways to set up Astropy to use ephemerides data, which should give more accurate positions. That’ll be my next step.

By the way, a 7-minute difference isn’t much. The Sun moves along the ecliptic at a rate of about 1° per day (roughly 360° in 365 days), and 7 minutes is about 0.005 of a day (7/1440). That means the difference between the Heavens Above Sun position and the Astropy Sun position is about 0.005°. Do I need the position of the Sun (or any of the planets) to a greater precision than that? No, but that won’t stop me from exploring ways to do so.


A two-month calendar on my Desktop

A few days ago, I posted this image on Mastodon:

Two-month calendar in TerminalWidget

The code I ran to get this two-month calendar on my Desktop was

bash:
if [ $(date +"%-d") -gt 15 ]; then
  cal -A 1
else
  cal -B 1
fi |\
terminal-widget --target cal --font Menlo --bg "#eeeeee" --text -

I have since updated a couple of things and need more room to talk about it all.

First, the app that put that widget on my Desktop is Brett Terpstra’s TerminalWidget, which he just released. As a one-time user of GeekTool, I’ve been waiting for TerminalWidget since Brett first began talking about it. While GeekTool and the similar NerdTool might still work, I wanted an app that worked with macOS’s modern widget system. TerminalWidget does.

I put a medium-sized widget in the upper-left corner of my Desktop by dragging one out from the widget editor window. If you’ve never done this before (I hadn’t), read Apple’s instructions.

Widget editor window

I then right-clicked on the widget and renamed it cal. Now it’s ready for me to run the command that puts the calendar into the widget.

The command is a pipeline, and the executable after the pipe is terminal-widget. This is a symbolic link to the command that’s buried in the TerminalWidget app package. I created the link with

ln -s /Applications/TerminalWidget.app/Contents/MacOS/TerminalWidget ~/bin/terminal-widget

where ~/bin is a directory in my $PATH.

I won’t go through the options given to terminal-widget; they’re explained in TerminalWidget’s CLI page. Suffice it to say that the options put whatever is sent to terminal-widget into the cal widget and format it the way I want.

Instead, let’s talk about the command that comes before the pipe:

bash:
if [ $(date +"%-d") -gt 15 ]; then
  cal -A 1
else
  cal -B 1
fi

I think this will run in any Bourne-like shell. I know it runs in zsh and bash.

The idea is to run the cal command, displaying the current month and either the month before or the month after. Which other month to show is determined by the if statement at the top. It runs the date command and formats the output as just the day of the month with no leading zero. If this is greater than 15, cal is passed the option to show one month after the current month. Otherwise, cal is passed the option to show one month before the current month.

(Brett uses cal as one of his example widgets, but his command doesn’t include an if. It always displays the current month and the month after.)

By the way, if you run the above command in the Terminal, you’ll notice its output is slightly different from what’s shown in the widget:

Output from cal in Terminal

The current date is highlighted. I think this is because cal is written to format its output as plain text when it’s being saved to a file or piped to another command but jumps back to invert today’s date when it’s being run in a terminal. It’s similar to the way ls lists files one per line when being piped but formats them in columns when its output goes to a terminal.

OK, this is nice for testing out TerminalWidget, but running this command once won’t get it to change when we get past the 15th of the month. It needs to be run every day, preferably in the morning.

The classic Unix way to schedule tasks is cron, but according to Apple your Mac must be awake when a cron job is scheduled. If it isn’t, the job runs the next time the job is scheduled and the Mac is awake. Because I don’t know when my MacBook Pro will be awake, cron is not a good option for scheduling this task. I need to use launchd, which will run the specified command at the scheduled time or the next time the Mac wakes up.

The easiest way to set up launchd agents is to use LaunchControl and let its GUI handle the tricky bits, but you can do it by hand if you must. First, make a plist file that describes what command is to be run and when and save it in your ~/Library/LaunchAgents folder. Here’s the file, named com.leancrew.calwidget.plist, that LaunchControl built for me:

xml:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>com.leancrew.calwidget</string>
  <key>ProgramArguments</key>
  <array>
    <string>/bin/zsh</string>
    <string>/Users/drang/bin/calwidget.sh</string>
  </array>
  <key>StartCalendarInterval</key>
  <array>
   <dict>
    <key>Hour</key>
    <integer>6</integer>
    <key>Minute</key>
    <integer>30</integer>
   </dict>
  </array>
</dict>
</plist>

The ProgramArguments section tells launchd to run my calwidget.sh script via zsh. Because launchd commands aren’t run under my usual environment, I give full file paths to both zsh and my script. The StartCalendarInterval section tells launchd to run the job every day at 6:30 AM.

The calwidget.sh script is basically what we’ve seen before, but with a couple of small changes:

bash:
if [ $(date +"%-d") -gt 7 ]; then
  cal -A 1
else
  cal -B 1
fi |\
/Applications/TerminalWidget.app/Contents/MacOS/TerminalWidget \
  --target cal --font Menlo --fg "000000" --bg "eeeeee" --text -

I decided I’d prefer to see the month before the current month only if we’re a week or less into the current month. Hence the change from 15 to 7 in the if statement. Also, I changed the terminal-widget command to the full path of the executable it’s linked to because launchd doesn’t know my usual $PATH. And finally, I added a --fg option to ensure that both the foreground and background colors are what I want—I decided not to rely on defaults.

I loaded this job into launchd via LaunchControl, but I also tested doing it with launchctl:

launchctl load -w ~/Library/LaunchAgents/com.leancrew.calwidget.plist

The launchctl man page calls load a “legacy subcommand,” suggesting that I should learn the newer way of doing things, but I find the descriptions of the recommended subcommands incomprehensible. And I haven’t found a good tutorial for them. Again, LaunchControl just does what I want it to do.

Update 5 Aug 2026 10:36 PM
Eric Hemmeter sent me a link on Mastodon to this 2016 article by Babo D, which does a much better job of explaining launchctl’s “new” syntax than Apple does anywhere that I’ve seen. While I don’t think this will take the place of LaunchControl for me, I certainly understand the recommended subcommands better now than I ever have. I’ve saved a web archive version of the page in case it disappears. Thanks, Eric!

While this exercise was mostly a proof of concept, I do like having quick access to a two-month calendar and will probably keep this widget. Now I need to think about what other information I want immediate access to.


My rules for using spreadsheets

My fundamental rule is Don’t, but a single word wouldn’t make for much of a blog post.

In what follows, I hope to explain how I’ve come to that rule and the exceptions I make to it. I’ve been thinking about how I use and don’t use spreadsheets quite a bit lately. This introspection was inspired in part by Allison Sheridan’s presentation at Macstock (which you can see on her site along with a couple of other recent spreadsheet posts) and in part by my recent use of Numbers to make difference tables and clean up a table of data.

Let’s start by considering what makes spreadsheets so attractive. Right off the bat, you’re presented with a grid of cells that act as data containers. You don’t have to define these containers, you don’t have to name them, you don’t have to initialize them—they’re just there, waiting for you to fill them as you see fit.

When it comes time to start operating on this data, you still don’t have to name the cells. You just click (or click and drag) to fill in the function arguments. The spreadsheet app fills in the appropriate row/column reference. If you want a reminder of what a cell is for, you can type a name or description in an adjacent cell. Similarly, you don’t have to figure out the appropriate order of the operations. The app works out the cell dependency chain and recalculates everything, everywhere, all at once.

And because you can set the size, color, border, and font styling of every cell, your spreadsheet can generate nice-looking tables for inserting into your reports, memos, and slideshows.

So a spreadsheet is a data store, a logic machine, and a presentation tool. Are you getting it?

But if spreadsheets are all that, where does my Don’t rule come from? There are many sources, but I’d have to say I’ve been strongly influenced by the last 10–15 years of my working life, during which time I had to analyze dozens and dozens of data sets, all of which were sent to me as Excel spreadsheets. The engineering firms that sent me the spreadsheets had created them not simply as data stores. They included some of their own analysis (which typically overlapped slightly with mine), and they formatted the spreadsheets as tables to put into their own reports. This made my work harder for a few reasons:

  1. Because I had to make sure I understood and agreed with their analysis, I had to review all their formulas. Some of these formulas were complex—nested IF statements are easy to follow in a traditional programming language, but they’re a mess in a spreadsheet. Some were inconsistent—different rows in the same table would have different formulas, as if they were written by different people at different times or adapted from a spreadsheet on a previous project. Some of them referred to cells that were far away and required a lot of scrolling to track down. None of them—not a single one in over a decade—used cell names to help make the formulas easier to understand.

    The complicated formulas mentioned above sometimes—not often, but sometimes—contained mistakes. And sometimes the formulas were correct, but the descriptions in the header cells were wrong. This meant phone calls were needed to resolve the discrepancies, further slowing the analysis.

  2. It was common for the data to be split over two or more sheets. I think this was done mainly to make the tables fit better into the other engineers’ reports, which was fine for their purposes but not for mine. I had to recombine the data for my analyses. Also, the sheets often had complicated, multiline headers, which meant I couldn’t just export them as CSV files.
  3. Every engineer I worked with built their spreadsheets in a different way. Those who worked for the same firm didn’t adhere to an “ABC Engineering” house style. Even individual engineers would change their spreadsheet styling from one project to the next. Basically, every spreadsheet that came in the door was sui generis, and I had to do all the data cleaning by hand. This slowed me down, not only because I couldn’t rely on automation for this step, but also because I had to double- and triple-check my work to avoid copy/paste mistakes.

Fundamentally, this experience—especially Item 1—soured me on the use of spreadsheets for anything large or complex. The engineers I was working with were smart, but their spreadsheets weren’t. My conclusion was that the simplicity of the typical click-and-drag method of assembling a spreadsheet encouraged poor organization and errors as the spreadsheets grew or were adapted to new data. It’s easy to say “Oh, I would never do that,” but I’m old enough to know that I would do that. I see the ease with which I can build spreadsheets with today’s apps as a Siren song that will lead me onto the rocks.

(If you’re getting ready to write to me about the Reinhart/Rogoff paper, you can relax. It is the prime example of elementary spreadsheet errors—errors that two Harvard professors would surely never make—and it led to a lot of suffering through unnecessary government austerity policies. And if you’re now getting ready to write to me about how Reinhart and Rogoff’s errors don’t negate the essential truth of their conclusions, you can just fuck off.)

The convenience of having the data and the analysis logic in the same document becomes a problem when you have to apply that logic to several datasets, especially when they differ in size. Spreadsheet templates are great when the data allow you make several spreadsheets with the exact same layout, but the data I tend to deal with don’t fit that rigid pattern. If I’m doing, for example, analyses and plots of several time series, those series seldom extend over the same length of time and the same number of data points. It’s far easier to deal with these size differences when the logic is in a program, separated from the data.

Another problem with spreadsheets is that the amount of data they can contain is more limited than when you use other data analysis workflows. The size limits on spreadsheets are, admittedly, quite large, but in an era of Big Data “quite large” may not be big enough. In her Macstock talk, Allison shows how she ran into that problem with the data set of US baby names. Let’s take a detour to talk about handling that data.


One of the ways you can download the baby name dataset is as a zipped collection of CSV files. Each file in the collection is associated with one year and has a name like yob1960.txt. The contents look like this:

Mary,F,51472
Susan,F,39208
Linda,F,37316
Karen,F,36378
Donna,F,34138
[etc]

where the first item is the name, the second is the sex at birth, and the third is the number of babies given that name in that year. The lines are ordered first by sex and then by number. If you concatenate all the files, you’ll find there are 2,181,032 entries. As Allison found out, this won’t fit into an Excel spreadsheet, as Excel is limited to 1,048,576 rows. That’s the very computery number 220 or 10242. The limit in Numbers is the less computery but more human 1,000,000 rows.

Allison got around the size problem by… er… cheating. She eliminated the less popular names to get the list to fit into Excel, and then demonstrated some pivot table stuff. You can see it starting at 1:13:50 in the video.

I decided to do something similar to her work but without the cheating. First, I concatenated all the individual files into one big CSV file that also included a field for the year. That was done through these shell commands:

echo 'Year,Name,Sex,Count' > all-years.csv
for f in yob*.txt; do
  y=${f:3:4}
  sed -e "s/\r$//;s/^/$y,/" $f >> all-years.csv
done

The year is extracted from the file name through substring expansion and then added to the beginning of each line via sed. The original files are in Windows format with CRLF line endings, so the sed command also deletes the CR characters. The upshot of all this is a file (with Unix line endings) named all-years.csv that looks like this:

Year,Name,Sex,Count
1880,Mary,F,7065
1880,Anna,F,2604
1880,Emma,F,2003
1880,Elizabeth,F,1939
1880,Minnie,F,1746
[etc]

(Yes, even though the data set is said to have come from Social Security registrations, it starts in 1880, decades before the Social Security Act. I can’t explain that. Nor can I explain how Minnie was once the fifth most popular girls’ name.)

I’m going to use Python and Pandas to extract the five most popular girls’ names from 2001 through 2025 (the last year in the dataset). Here’s the start of a simple interactive Python session that does it:

>>> import pandas as pd
>>> df = pd.read_csv('all-years.csv')
>>> cols = ['Name', 'Count']

This reads the CSV file into a dataframe and defines the columns of the dataframe that we want to include in our output. The >>> at the beginning of each line is the interactive Python prompt. Here’s how we get the list of names we’re interested in:

>>> df[(df.Sex=='F') & (df.Year>2000)][cols].groupby('Name')\
... .sum().sort_values('Count', ascending=False)[:5]
           Count
Name            
Emma      449576
Olivia    423613
Isabella  381577
Sophia    368619
Emily     353077

The ... indicates a continuation input line. Everything after that is output.

Reading through the command, we see that we’re

  1. getting the subset of data consisting of girls born after 2000;
  2. limiting the output to the Name and Count fields;
  3. grouping the output by Name;
  4. summing the Counts for each Name;
  5. sorting the results by Count in descending order; and
  6. limiting the output to the top five names.

That’s obviously a long command, but you can see how it’s constructed in a logical fashion.

If we want to compare these to the popular girls’ names from a century earlier, the command is very similar:

>>> df[(df.Sex=='F') & (df.Year>1900) & (df.Year<=1925)][cols].groupby('Name')\
... .sum().sort_values('Count', ascending=False)[:5]
            Count
Name             
Mary      1056333
Helen      505522
Dorothy    475151
Margaret   402317
Ruth       364923

My wife and I had great aunts with some of these names.

If you’re a database maven, you recognize the Pandas groupby function as a copy of the SQL GROUP BY construct. Let’s redo this in an interactive session with SQLite. We start by importing the data from the CSV file:

sqlite> .mode csv
sqlite> .import all-years.csv names
sqlite> .mode columns

The mode is set to csv in order to import the data, then set back to columns to make the output look the way we want.

Now we get the top five girls’ names from the 21st century and show them in descending order:

sqlite> select Name, sum(Count) from names
     ...> where Sex is "F" and Year > 2000
     ...> group by Name order by sum(Count) desc limit 5;
Name      sum(Count)
--------  ----------
Emma      449576    
Olivia    423613    
Isabella  381577    
Sophia    368619    
Emily     353077    

SQL is certainly more English-like, but you can see the parallels between it and Pandas. Now for the early 20th century:

sqlite> select Name, sum(Count) from names
     ...> where Sex is "F" and Year > 1900 and Year <= 1925
     ...> group by Name order by sum(Count) desc limit 5;
Name      sum(Count)
--------  ----------
Mary      1056333   
Helen     505522    
Dorothy   475151    
Margaret  402317    
Ruth      364923    

Allison does similar things with her truncated Excel file using pivot tables. I hate the name “pivot table,” because I think it’s an obscure term for the simple operations of grouping and summarizing. For some reason, my feelings on this don’t matter, and pivot tables are here to stay. Pandas has even added a pivot_table function to placate people who’ve come over from Excel. Under the hood, pivot_table calls groupby.


Well, that was kind of a long detour, and I forgive you if you’ve forgotten where we were. I had just gone through a list of things that made me leery of using spreadsheets—why my first rule of using spreadsheets is Don’t.

But I allow for exceptions. My two main exceptions are:

  1. When the problem is both small enough to see on the screen with almost no scrolling and the operations are simple enough to be easily understood without counting commas and parentheses. That was what I did for my sum of cubes difference tables. The formulas consisted mainly of subtractions, with some power and division operations here and there. Only the simultaneous equations solution in the lower right involved actual function calls, and there were no nested calls.

    Cubic sum spreadsheet

  2. When I’m using the spreadsheet as a way station for editing data before passing it along. I did that in the baseball team progress post to edit down the large and unwieldy season results tables from Baseball Reference. It was fast and easy to select the table in Safari, paste it into Numbers, and then delete the columns and rows I didn’t need. But I did it this way only because this was a one-off project. If I were given the job of making progress charts for all 30 teams every day of the season, I’d never do it by hand like that. I’d use the Pandas read_html function to pull the HTML table into a dataframe and a variety of drop commands to pare it down.

I used to use spreadsheets for data entry, too, but not anymore. It was once the only reliable way to put a table of numbers found in a book into electronic form. But OCR has gotten so much better, I can’t remember the last time I did this.

I know there are lots of people who love using spreadsheets. They’ve spent a lot of time learning the ins and outs and don’t want to switch to another tool. That’s fine. This post was about my rules, not anyone else’s. I’m not saying good, accurate, complex work can’t be done in spreadsheets. It just won’t be done by me.


Plotting baseball team progress

I noticed today that the Cubs and Yankees have the same record. I knew that the Cubs have had some extreme ups and downs this year, but I wasn’t sure if the Yankees had, so I decided to plot their progress over the course of the season so far.

A plot of how far above or below .500 they were seemed like the best way to compare their seasons, so I got their records from baseball-reference.com (Cubs results, Yankees results) and cleaned up the data through a combination of Numbers and BBEdit. The result was a pair of CSV files that looked like this:

Date,Home,Opponent,Win
Mar 26 2026,TRUE,WSN,-1
Mar 28 2026,TRUE,WSN,1
Mar 29 2026,TRUE,WSN,-1
Mar 30 2026,TRUE,LAA,1
Mar 31 2026,TRUE,LAA,-1
Apr 1 2026,TRUE,LAA,1
Apr 3 2026,FALSE,CLE,-1
Apr 5 2026,FALSE,CLE,1
Apr 5 2026,FALSE,CLE,-1
Apr 6 2026,FALSE,TBR,-1
[etc.]

These are the first ten games for the Cubs. The Win field contains a +1 for wins and a -1 for losses. The plot of their progress looks like this:

Cubs and Yankees 2026

The teams were running in parallel from mid-April through early May, before the Cubs’ disastrous slide. The Yankees had their own smaller slide starting in mid-June but seem to have recovered. History has taught Cub fans not to trust the team’s recent success; 60 more games is plenty of time for another slide (or two).

The plot was made with Python, Pandas, NumPy, and Matplotlib. Here’s the code:

python:
 1:  #!/usr/bin/env python3
 2:  
 3:  import pandas as pd
 4:  import numpy as np
 5:  from datetime import datetime
 6:  import matplotlib.pyplot as plt
 7:  from matplotlib.ticker import MultipleLocator, AutoMinorLocator
 8:  from matplotlib.dates import DateFormatter, YearLocator, MonthLocator
 9:  
10:  # Read the files into dataframes
11:  dfCubs = pd.read_csv('cubs-2026.csv', parse_dates=[0])
12:  dfYankees = pd.read_csv('yankees-2026.csv', parse_dates=[0])
13:  
14:  # Calculate the games above .500
15:  dfCubs['Above'] = np.cumsum(dfCubs.Win)
16:  dfYankees['Above'] = np.cumsum(dfYankees.Win)
17:  
18:  # Create the plot with a given size in inches
19:  fig, ax = plt.subplots(figsize=(6, 4))
20:  
21:  # Add lines for each team. Team colors from teamcolorcodes.com.
22:  ax.plot(dfCubs.Date, dfCubs.Above, '-', color='#0E3386', lw=1.5, label='Cubs')
23:  ax.plot(dfYankees.Date, dfYankees.Above, '.', color='#0C2340',ms=5, label='Yankees')
24:  
25:  # Set the limits
26:  plt.ylim(ymin=-5, ymax=20)
27:  
28:  # Set the ticks and add a grid
29:  ax.xaxis.set_major_locator(MonthLocator())
30:  ax.xaxis.set_major_formatter(DateFormatter('%-m/%-d/%y'))
31:  ax.yaxis.set_major_locator(MultipleLocator(5))
32:  ax.grid(linewidth=.5, axis='x', which='major', color='#dddddd', linestyle='-')
33:  ax.grid(linewidth=.5, axis='y', which='major', color='#dddddd', linestyle='-')
34:  
35:  # Title and axis labels
36:  plt.title('Cubs and Yankees 2026')
37:  plt.ylabel('Games above .500')
38:  
39:  # Make the border and tick marks 0.5 points wide
40:  [ i.set_linewidth(0.5) for i in ax.spines.values() ]
41:  ax.tick_params(which='both', width=.5)
42:  
43:  # Add the legend
44:  ax.legend(loc='lower right')
45:  
46:  # Save as PDF
47:  plt.savefig('20260723-Cubs and Yankees 2026.png', format='png', dpi=200)

I wanted to use lines for both teams and let the team colors distinguish them, but the team colors are too close, so I used markers for the Yankees and added a legend. I did still use the team colors, which I got from the Team Color Codes website. I fiddled with the marker size and line width until the two looked to be of roughly equal importance.

It may seem like this is a lot of code to write for a simple plot, but I use a Typinator abbreviation to insert a code template and just tweak the template to get things looking the way I want. The Pandas and NumPy part of the code, Lines 10–16, was trivial.