World Cup combinatorics again
June 16, 2026 at 10:12 PM by Dr. Drang
At the end of yesterday’s post about the combinatorics of the World Cup group stage, I said “this is good enough for me.” Turns out that was a lie. Today I rewrote some of the code to come up with a slightly more detailed result.
The last bit of work in that post was to generate the 40 unique point totals that can come from a group. These are the possible totals, with no regard for which team gets which total:
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
As discussed early in yesterday’s post, there are ways the six games of a group can turn out, so today’s script associates each of the above totals with the game results that add up to that total. I built a dictionary in which the keys are the totals shown above—expressed as tuples, like (7, 7, 1, 1)—and the values are lists of lists of game result lists, like
[[[3, 0, 0, 0],
[3, 0, 0, 0],
[1, 0, 0, 1],
[0, 1, 1, 0],
[0, 0, 0, 3],
[0, 0, 0, 3]],
[[3, 0, 0, 0],
[1, 0, 1, 0],
[3, 0, 0, 0],
[0, 0, 3, 0],
[0, 1, 0, 1],
[0, 0, 3, 0]],
[[0, 3, 0, 0],
[0, 0, 3, 0],
[1, 0, 0, 1],
[0, 1, 1, 0],
[0, 3, 0, 0],
[0, 0, 3, 0]],
[[0, 3, 0, 0],
[1, 0, 1, 0],
[0, 0, 0, 3],
[0, 3, 0, 0],
[0, 1, 0, 1],
[0, 0, 0, 3]],
[[1, 1, 0, 0],
[3, 0, 0, 0],
[3, 0, 0, 0],
[0, 3, 0, 0],
[0, 3, 0, 0],
[0, 0, 1, 1]],
[[1, 1, 0, 0],
[0, 0, 3, 0],
[0, 0, 0, 3],
[0, 0, 3, 0],
[0, 0, 0, 3],
[0, 0, 1, 1]]]
The innermost lists in this output are the points accumulated in a single game. For example, [1, 1, 0, 0] represents a game in which the first two teams in the group played to a draw. So the above result tells us that there are six ways the six games in a group can lead to a (7, 7, 1, 1) table at the end of the stage.
Here’s the Python code that built the dictionary and printed out a summary of the results:
python:
1: #!/usr/bin/env python3
2:
3: from itertools import product
4: from collections import defaultdict
5: import numpy as np
6:
7: # Possible point distributions from each game.
8: game1 = [[3, 0, 0, 0], [0, 3, 0, 0], [1, 1, 0, 0]]
9: game2 = [[3, 0, 0, 0], [0, 0, 3, 0], [1, 0, 1, 0]]
10: game3 = [[3, 0, 0, 0], [0, 0, 0, 3], [1, 0, 0, 1]]
11: game4 = [[0, 3, 0, 0], [0, 0, 3, 0], [0, 1, 1, 0]]
12: game5 = [[0, 3, 0, 0], [0, 0, 0, 3], [0, 1, 0, 1]]
13: game6 = [[0, 0, 3, 0], [0, 0, 0, 3], [0, 0, 1, 1]]
14:
15: # Enumerate all possible six-game results, and put them in a dictionary.
16: # The keys are the sorted point totals and the values are the list of
17: # game results.
18: games = defaultdict(list)
19: results = np.array(list(product(game1, game2, game3, game4, game5, game6)))
20: for r in results:
21: points = tuple(sorted(r.sum(axis=0).tolist(), reverse=True))
22: games[points].append(r.tolist())
23:
24: # Show how many game results lead to each tuple of sorted point totals.
25: count = 0
26: p = sorted(games.keys(), reverse=True)
27: for k in p:
28: count += len(games[k])
29: print(f'({" ".join(str(p) for p in k)}): {len(games[k]):3d}')
30: print(f' Total: {count:3d}')
The output is
(9 6 3 0): 24
(9 6 1 1): 12
(9 4 4 0): 12
(9 4 3 1): 24
(9 4 2 1): 24
(9 3 3 3): 8
(9 2 2 2): 4
(7 7 3 0): 12
(7 7 1 1): 6
(7 6 4 0): 24
(7 6 3 1): 24
(7 6 2 1): 24
(7 5 4 0): 24
(7 5 3 1): 24
(7 5 2 1): 24
(7 4 4 1): 36
(7 4 3 3): 24
(7 4 3 2): 24
(7 4 3 1): 24
(7 4 2 2): 24
(7 3 2 2): 12
(6 6 6 0): 8
(6 6 4 1): 24
(6 6 3 3): 24
(6 5 4 1): 24
(6 5 2 2): 12
(6 4 4 3): 36
(6 4 4 2): 24
(5 5 5 0): 4
(5 5 4 1): 24
(5 5 3 2): 12
(5 5 3 1): 12
(5 5 2 2): 12
(5 4 4 3): 24
(5 4 4 2): 24
(5 4 3 2): 24
(5 3 3 2): 12
(4 4 4 4): 6
(4 4 4 3): 8
(3 3 3 3): 1
Total: 729
where the numbers after the colons are the number of ways the games can go to result in the given totals. There is, for example, only one way to get (3, 3, 3, 3), which is for every game to end in a draw.
The code is not clever in any way. I build the games dictionary (actually a defaultdict) one step at a time via the loop in Lines 20–22. As with yesterday’s script, I generate all the 729 game results using the product function from the itertools library and use NumPy’s sum function to add up the points from each of those results. The output comes from Lines 25–30.
If I run this script in an interactive environment, like IPython or Jupyter, I can pull out the games that lead to any set of points. I think the (5, 4, 3, 2) total is fun because it’s the only straight among the 40 possibilities. After some reformatting, here’s what games[(5, 4, 3, 2)] returns:
(3 0 0 0) (0 0 3 0) (1 0 0 1) (0 1 1 0) (0 1 0 1) (0 0 1 1)
(3 0 0 0) (1 0 1 0) (0 0 0 3) (0 1 1 0) (0 1 0 1) (0 0 1 1)
(3 0 0 0) (1 0 1 0) (1 0 0 1) (0 3 0 0) (0 1 0 1) (0 0 1 1)
(3 0 0 0) (1 0 1 0) (1 0 0 1) (0 1 1 0) (0 3 0 0) (0 0 1 1)
(0 3 0 0) (3 0 0 0) (1 0 0 1) (0 1 1 0) (0 1 0 1) (0 0 1 1)
(0 3 0 0) (1 0 1 0) (3 0 0 0) (0 1 1 0) (0 1 0 1) (0 0 1 1)
(0 3 0 0) (1 0 1 0) (1 0 0 1) (0 0 3 0) (0 1 0 1) (0 0 1 1)
(0 3 0 0) (1 0 1 0) (1 0 0 1) (0 1 1 0) (0 0 0 3) (0 0 1 1)
(1 1 0 0) (3 0 0 0) (0 0 0 3) (0 1 1 0) (0 1 0 1) (0 0 1 1)
(1 1 0 0) (3 0 0 0) (1 0 0 1) (0 0 3 0) (0 1 0 1) (0 0 1 1)
(1 1 0 0) (3 0 0 0) (1 0 0 1) (0 1 1 0) (0 1 0 1) (0 0 3 0)
(1 1 0 0) (0 0 3 0) (3 0 0 0) (0 1 1 0) (0 1 0 1) (0 0 1 1)
(1 1 0 0) (0 0 3 0) (1 0 0 1) (0 3 0 0) (0 1 0 1) (0 0 1 1)
(1 1 0 0) (0 0 3 0) (1 0 0 1) (0 1 1 0) (0 1 0 1) (0 0 0 3)
(1 1 0 0) (1 0 1 0) (3 0 0 0) (0 1 1 0) (0 0 0 3) (0 0 1 1)
(1 1 0 0) (1 0 1 0) (3 0 0 0) (0 1 1 0) (0 1 0 1) (0 0 0 3)
(1 1 0 0) (1 0 1 0) (0 0 0 3) (0 1 1 0) (0 3 0 0) (0 0 1 1)
(1 1 0 0) (1 0 1 0) (0 0 0 3) (0 1 1 0) (0 1 0 1) (0 0 3 0)
(1 1 0 0) (1 0 1 0) (1 0 0 1) (0 3 0 0) (0 0 0 3) (0 0 1 1)
(1 1 0 0) (1 0 1 0) (1 0 0 1) (0 3 0 0) (0 1 0 1) (0 0 3 0)
(1 1 0 0) (1 0 1 0) (1 0 0 1) (0 0 3 0) (0 3 0 0) (0 0 1 1)
(1 1 0 0) (1 0 1 0) (1 0 0 1) (0 0 3 0) (0 1 0 1) (0 0 0 3)
(1 1 0 0) (1 0 1 0) (1 0 0 1) (0 1 1 0) (0 3 0 0) (0 0 0 3)
(1 1 0 0) (1 0 1 0) (1 0 0 1) (0 1 1 0) (0 0 0 3) (0 0 3 0)
Notice that in every set of six games (i.e., each row), there are two games that end in a win and four that end in a draw. The team with five points has a win and two draws; the team with four points has a win, a draw, and a loss; the team with three points has three draws; and the team with two points has two draws and a loss.
OK, now I’m done.
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. ↩

