World Cup combinatorics
June 15, 2026 at 8:54 PM by Dr. Drang
We’re in the middle of the first set of games in the group stage of the 2026 World Cup, and I’ve been thinking about how many ways the points can be distributed among the teams in a given group. I used Python to help with the enumeration.
Here’s a quick summary of how the group stage works: The teams are split into groups of four. Within each group, all the teams play each other once. A team gets three points for a win and one point for a draw in each of the three games it plays. The teams are ranked by their point totals within their group when this stage is over.1
Six games are played in each group. You can calculate that number in several ways, but it’s easy enough to just list them all. Let’s say the teams in our group are Alpha, Beta, Gamma, and Delta. Then the six games are:
Alpha vs. Beta
Alpha vs. Gamma Beta vs. Gamma
Alpha vs. Delta Beta vs. Delta Gamma vs. Delta
Since there are three possible outcomes in each game (W–L, L–W, and D–D), there are
possible results for the six games of the group stage.
We can get the point distribution among the teams for each of these possible results with this bit of Python code:
python:
1: #!/usr/bin/env python3
2:
3: from itertools import product
4: import numpy as np
5:
6: # Possible point distributions from each game.
7: game1 = [[3, 0, 0, 0], [0, 3, 0, 0], [1, 1, 0, 0]]
8: game2 = [[3, 0, 0, 0], [0, 0, 3, 0], [1, 0, 1, 0]]
9: game3 = [[3, 0, 0, 0], [0, 0, 0, 3], [1, 0, 0, 1]]
10: game4 = [[0, 3, 0, 0], [0, 0, 3, 0], [0, 1, 1, 0]]
11: game5 = [[0, 3, 0, 0], [0, 0, 0, 3], [0, 1, 0, 1]]
12: game6 = [[0, 0, 3, 0], [0, 0, 0, 3], [0, 0, 1, 1]]
13:
14: # Enumerate all possible six-game results, add up the points, and sort.
15: games = np.array(list(product(game1, game2, game3, game4, game5, game6)))
16: points = games.sum(axis=1)
17: points = points.tolist()
18: points.sort(reverse=True)
19: print(f'Number of point arrangements: {len(points)}')
The output is
Number of point arrangements: 729
which matches our calculation above.
Lines 7–12 give the three possible point allocations for each of the games, where the inner lists are the points given to Alpha, Beta, Gamma, and Delta—in that order. Line 15 uses the product function from the itertools library to build all 729 outcomes. It turns them into a NumPy array because I wanted to use the sum function (Line 16) to add up the points from each game without any laborious looping. After this step, points is a 729×4 NumPy array.
Line 17 then turns points into a list of lists, and Line 18 sorts the 729 entries. It looks like this:
[[9, 6, 3, 0],
[9, 6, 1, 1],
[9, 6, 0, 3],
[9, 4, 4, 0],
[9, 4, 3, 1],
.
.
.
[0, 4, 5, 7],
[0, 4, 4, 9],
[0, 3, 9, 6],
[0, 3, 7, 7],
[0, 3, 6, 9]]
There are repeats among these results. For example, there are two ways to get the result
[5, 5, 4, 1]
This set of points can come from either
Alpha draws Beta [1, 1, 0, 0]
Alpha beats Gamma [3, 0, 0, 0]
Alpha draws Delta [1, 0, 0, 1]
Beta draws Gamma [0, 1, 1, 0]
Beta beats Delta [0, 3, 0, 0]
Gamma beats Delta [0, 0, 3, 0]
or
Alpha draws Beta [1, 1, 0, 0]
Alpha draws Gamma [1, 0, 1, 0]
Alpha beats Delta [3, 0, 0, 0]
Beta beats Gamma [0, 3, 0, 0]
Beta draws Delta [0, 1, 0, 1]
Gamma beats Delta [0, 0, 3, 0]
Sum down the columns to prove to yourself that this yields point totals of
[5, 5, 4, 1]
OK, so how many unique point total results can there be? To answer this, I added the following code:
python:
21: # Unique points, team-by-team.
22: unique_team_points = [ list(q) for q in set(tuple(p) for p in points) ]
23: unique_team_points.sort(reverse=True)
24: print(f'Number of unique point arrangements: {len(unique_team_points)}')
which output
Number of unique point arrangements: 556
Unlike the 729 results we got earlier, I have no formula for calculating this number, and I have no interest in trying to come up with one. This is one of those situations where brute force programming is the best use of my time.
Line 22 uses the common Python trick of turning a list into a set to eliminate duplicates and then turns the set back into a list. The less-common trick is that it first turns the inner lists into tuples. This is needed because something like
set(points)
returns this error:
TypeError: cannot use 'list' as a set element (unhashable type: 'list')
In these 556 results, we kept the teams separated. In other words, we considered results
[9, 6, 3, 0]
[9, 6, 0, 3]
[9, 3, 6, 0]
[9, 3, 0, 6]
[9, 0, 6, 3]
[9, 0, 3, 6]
etc.
to be different because the team scores were different. But what if we just wanted to know the possible point combinations without regard to which team got which point total? Again, I have no formula for this, but it was easy to work it out in code:
python:
26: # Unique points without regard to team.
27: sorted_points = sorted((sorted(p, reverse=True) for p in points), reverse=True)
28: unique_points = [ list(q) for q in set(tuple(p) for p in sorted_points) ]
29: unique_points.sort(reverse=True)
30: print(f'Number of unique sets of points: {len(unique_points)}')
31: print()
32: point_strings = [ f'{p[0]} {p[1]} {p[2]} {p[3]}' for p in unique_points ]
33: print('\n'.join(point_strings))
The first line of output is
Number of unique sets of points: 40
which is quite a reduction. The number is now small enough to show all of them. Reshaping the 40 output lines that follow into four columns of ten gives
9 6 3 0 7 6 3 1 7 3 2 2 5 5 3 2
9 6 1 1 7 6 2 1 6 6 6 0 5 5 3 1
9 4 4 0 7 5 4 0 6 6 4 1 5 5 2 2
9 4 3 1 7 5 3 1 6 6 3 3 5 4 4 3
9 4 2 1 7 5 2 1 6 5 4 1 5 4 4 2
9 3 3 3 7 4 4 1 6 5 2 2 5 4 3 2
9 2 2 2 7 4 3 3 6 4 4 3 5 3 3 2
7 7 3 0 7 4 3 2 6 4 4 2 4 4 4 4
7 7 1 1 7 4 3 1 5 5 5 0 4 4 4 3
7 6 4 0 7 4 2 2 5 5 4 1 3 3 3 3
These are the point totals for the 40 different ways a group stage can end. The two levels of sorting in Line 27 are the key to bringing the results down from 729.
You can go to ESPN’s World Cup table for 2022 to show which of these 40 results occurred that year. There’s a popup menu button on the page that lets you check 2018, 2014, 2010, and 2006, too.
I’m sure serious soccer fans have been digging into the math behind this year’s change from eight groups to twelve. I’m not a serious fan, so this is good enough for me.
-
I won’t be going through the rules of how teams advance to the next stage—they start out simple but can get complicated quickly, especially now that the tournament has expanded. ↩
Missing miles
June 13, 2026 at 9:21 PM by Dr. Drang
I took a bike ride this morning on a portion of the I&M Canal Trail from Romeoville to Joliet and back. It’s a fairly short ride, eight miles each way, and that distance got me thinking about using Mathematica to do some calculations after I got back home.
The trail has mile markers with little snippets of information about the canal and the surrounding area. Here are the markers I passed this morning:
The mileage figures on the markers increase as you go south and west, which is downstream. There are several more markers in Lockport, where they’re graduated in tenths of a mile, but these will do for our purposes.
My ride started nearly a mile before Marker 27 and continued about a mile after Marker 31, which would lead you to believe I rode six miles, not eight. But the trip is definitely eight miles. I have my Fitness app set to record in kilometers, and it always tells me this trip is 13 kilometers one way. And as we know from the Fibonacci conversion, 13 km = 8 mi.
As I passed Marker 27 on the way south, I was reminded of this anomaly and decided to use the trip to figure out why the markers and the mileage don’t match up. I checked my Workout screen on my watch as I passed each marker and learned that there were two miles between Markers 28 and 29 and between Markers 30 and 31. After turning around in the parking lot of the Joliet Iron Works, I decided to take a photo of each marker on the way back north and use the photos to calculate distances.
I already knew about Mathematica’s GeoDistance function, and I figured it must have a way of extracting the latitude and longitude of photos from their EXIF data. That turned out to be the Import function, and I was able to put the two together to make this short notebook:
As you can see, the distances between the photos (and therefore between the markers) were:
| Markers | Distance |
|---|---|
| 27–28 | 1.019 mi |
| 28–29 | 1.886 mi |
| 29–30 | 1.041 mi |
| 30–31 | 1.939 mi |
These are geodesic or “as the crow flies” distances. The canal and its trail are pretty straight, but there are a couple of doglegs between Markers 28 and 29, which explains why the distance between them is over a tenth of a mile off from two miles.
As you can see near the bottom of the notebook, I used the GeoListPlot function to make a map with the marker positions on it. I combined that in Acorn with the map from my iPhone’s Fitness app to make this image:
It took some trial and error with the GeoRange option to get the scales to match up reasonably well, but I think it was worth it. You can see where the mile markers fall on my route, and it’s clear that the distances between Markers 28 and 29 and Markers 30 and 31 are about twice as far as the other distances. Also, you can see how the geodesic distance between Markers 28 and 29 cuts the corners, making it less than two miles.
So I’ve solved the six-mile/eight-mile mystery, but I still don’t understand why the mileage on the markers is wrong.
Fourier series in Mathematica
June 8, 2026 at 11:35 AM by Dr. Drang
After my last post—the one about using Fourier series—I started thinking about how to use Mathematica to develop Fourier series.1 I could, of course, use the Integrate function to determine the Fourier coefficients, but Mathematica has other functions that can do the job directly once you understand how they work.
Mathematica has several Fourier functions, but I’m going to stick with the ones associated with sine series, FourierSinCoefficient and FourierSinSeries. They’re meant to be easy to use, and they are, but you need to know how they’re defined.
The coefficients used in both of these functions are defined this way:
This doesn’t match up exactly with the definition I used for getting the Fourier coefficients of a loading function, :
To use the Mathematica functions to get the , we have to do a change of variable. Let
so
(Here, z is just another variable name; I’m not using it to represent a complex number.)
This changes the expression for to
which means we can use the Mathematica functions as long as we substitute in for in the expression for .
Let’s give it a try on this parabolic loading function:
It shouldn’t take many Fourier terms to get a good approximation of this.

Here are the Mathematica commands I used:
q = 4 qmax x (L - x)
qn = FourierSinCoefficient[q /. x -> L z/Pi, z, n]
You can see the substitution in the first argument to FourierSinCoefficient.
After simplification, the results were
We could also get the series (through ) directly with FourierSinSeries:
qapprox2 = FourierSinSeries[q /. x -> L z/Pi, z, 7] /. z -> Pi x /L
where the inverse substitution comes at the end to put the expression back in the form we want. Here’s a screenshot from the Mathematica notebook:

You can see that the coefficients match what we got earlier.
A quick plot of the difference between this truncated Fourier series and the original parabolic function shows that, as expected, the series does a good job of replicating the original. The largest error, near the ends, is just 0.3% of , and that’s with only four terms.

If you’re interested, here’s the entire Mathematica notebook:
Now that I understand the way these functions work, I can do more complicated Fourier analysis in Mathematica without questioning myself on whether I’m using them correctly.
-
Something I couldn’t do in any of the posts in that series, as that would be breaking the rules I had set up for myself. ↩
Simply supported beam—Fourier series solution of the ODE
June 6, 2026 at 3:36 PM by Dr. Drang
We’re finally here, at the end of all things. In this post, we’ll use a Fourier series to get the formula for the center deflection of a simply supported beam with a uniformly distributed load. We’ll see some of the same math that we saw in the previous Fourier series solution, but the fundamental approach will be different.
Let’s start with the fourth-order ordinary differential equation for a beam with a general loading function, :
The simple supports at both ends give us these boundary conditions:
Let’s work out the solution for a loading condition we haven’t considered before, a distributed load in the form of a sine wave:
where is a positive integer and is some constant amplitude. Given the form of the governing ODE and the boundary conditions, it seems likely that the solution will look like this:
It satisfies the boundary conditions, and if we plug it and the expression for q into the governing ODE, we get
which is a solution for all values of x if
This will work for any value of m that’s a positive integer.
Since the governing ODE is linear, if q is the sum of several sine terms, the solution will be the sum of several sine terms. Returning to our favorite problem,

we now have a path for expressing the solution as a sum of sines, as long as we can express the constant function, , as a sum of sines.
And of course we can. The Fourier sine series expression that fits a constant is
where
This result comes from the orthogonality of the sine function. The Fourier coefficients are the same as those for a square wave.
Plugging this result into the expression for , we get
for odd m. Therefore,
You may recall from our earlier post that there’s a closed-form solution for this sum:

The last entry in this table with gives us
Substituting the closed-form solution in for the sum yields our old friend:
The purpose of this series of posts—apart from a bit of showing off—was to demonstrate why I love structural analysis. It has, even in the simplest of problems, a depth that rewards those who study it. And when you’re confronted with problems that aren’t so simple, you can draw on that depth to understand and solve them.

