six courses, three methods, a simulated field with known answers

A method test on simulated data

The paired method finds each planted course penalty to within 8 seconds

My 2:48 marathon best came in Berlin, the course everyone calls fast, and I wanted to know what a slower course costs in seconds. Race results can't settle that, because nobody knows the true answer to check a method against. So I simulated a top-100 elite field for the six World Marathon Majors over 2015 to 2024, planted a time penalty on each course, and ran three ranking methods to see which one finds the penalties again. The within-runner paired comparison put Boston 101.0 seconds behind Berlin against a planted 105. The weather-normalized top-10 average came within 11.1 seconds on every course. The grade-adjusted energy-cost model, which reads only the course profiles, says Boston is the fastest of the six.

Simulated dataEvery finish time on this page was generated by src/build_data.py with seed 42. The numbers test the methods. None of them measures a real course.

+101.0 s

Boston against Berlin from the paired method (95% bootstrap interval 98.8 to 103.1). The planted value is 105.

7.3 s

The paired method's largest miss, on New York (77.7 against 85). Every estimate is below its planted value.

−89 s

Boston under the Minetti elevation model: faster than Berlin, because the course drops 136 m net.

42,567

Same-athlete course pairs inside 18 months, from 438 simulated athletes who ran more than one Major.

At a glance

  1. QuestionWhich of three methods recovers a course's time penalty, in seconds slower than Berlin, when the true value is known?
  2. DataSimulated top-100 field, seed 42: 10,800 results over 54 editions of the six Majors, 2015 to 2024 without 2020, and 42,567 same-athlete pairs
  3. MethodWithin-runner paired fit (OLS, 2,000-resample bootstrap), weather-normalized top-10 average, Minetti grade-adjusted energy cost
  4. CheckEvery estimate against the penalty planted in src/build_data.py: Berlin 0 up to Boston 105 s
  5. ResultPaired: within 7.3 s on every course (Boston 101.0 s against 105). Top-10: within 11.1 s. Grade-adjusted: Boston 89 s faster than Berlin
  6. Main limitationNo real course is measured. The weather correction divides by the curve that made the times, and the paired fit runs low on all five courses

Start · the lane board

Six courses, the planted answer, and what each method found

Each lane is one course. The first column is the penalty written into src/build_data.py; the next three are the estimates from src/analysis.py, all in seconds slower than Berlin for the same runner. Bars share one scale, and the planted value is the tick.

LaneCourse PlantedGrade-adjusted PairedTop-10, weather
1Boston105 −89.4 +101.0 +100.1
2New York85 +15.4 +77.7 +80.3
3London35 +3.6 +30.7 +29.7
4Tokyo28 −16.7 +21.0 +16.9
5Chicago12 −3.5 +11.4 +11.4
6Berlin0 0 0 0

Negative estimates (faster than Berlin) have no bar. Full width is 110 s.

Why simulate · a test with a known answer

Race results can't tell you whether a method is right

Nobody knows the true penalty of a course, so an estimate from race results has nothing to be checked against. The build script makes one up. It draws 400 men and 400 women with a fixed flat-course ability and sends 150 of them to each race, weighted toward the faster athletes. Each finish time is ability, plus the course penalty, plus a weather penalty, plus normal noise with a 90-second standard deviation. The top 100 per gender are kept: 10,800 results over 54 editions, with 2020 left out.

The planted penalties follow the courses' reputations: Berlin 0, Chicago 12, Tokyo 28, London 35, New York 85, Boston 105 seconds. The weather is a hand-entered table of start temperature, humidity and wind for every edition, which the script describes as anchored to reported race-day conditions (Boston 2018 at 3.9°C with 40 km/h wind, London 2018 at 23.5°C).

On average, the simulated weather costs more than the course on every Major

Seconds the simulation's weather term adds to a 2:10 runner in each edition, with the planted course penalty as a dashed line. Hover, tap, or focus the chart and use the arrow keys.

Temperature, humidity and wind from data/race_weather.csv, run through weather_multiplier() in src/analysis.py (the same curve build_data.py uses), times 7,800 s.

The average edition's weather costs 36 seconds in Tokyo and 209 in Berlin, where every race starts between 12 and 18°C, above the curve's 10°C floor. London 2018 adds 530 seconds, five times Boston's planted course penalty. A method that misreads the weather by a few percent would swamp the course signal, which is why methods 2 and 3 divide it out first.

Method 2 · within-runner paired

Same runner, two courses, 18 months apart at most

For every athlete who ran two different Majors within 18 months, each finish time is divided by its race's weather multiplier and the difference is taken. Ordinary least squares then fits one offset per course, with Berlin fixed at zero, over all 42,567 pairs. A 2,000-resample bootstrap gives the intervals, which come out near ±2 seconds for every course.

The method lands within 7.3 seconds of every planted value, and below it each time: by 0.6 s on Chicago, 4.0 on Boston, 4.3 on London, 7.0 on Tokyo and 7.3 on New York. Only Chicago's interval contains its planted value. On the other four the shortfall is too large for sampling noise, so it is a bias, and the code doesn't test where it comes from.

Forest plot of mean same-athlete time differences for the 15 course pairs, with intervals; the pairs involving Boston and New York have the largest gaps.
Mean same-athlete delta for each of the 15 course pairs, with 95% intervals, as plotted by src/analysis.py.

Methods 1 and 3 · terrain and weather

One method agrees with the paired fit; the other reads only the map

The weather-normalized method averages each edition's top 10 per gender, divides by the same Maughan / El Helou style multiplier (0.5% per °C above 10°C, 0.2% per °C below 5°C, plus small humidity and wind terms), and averages across years. It puts Boston at +100.1 s and New York at +80.3 s, and misses worst on Tokyo (16.9 against 28).

The grade-adjusted method integrates the Minetti et al. (2002) energy-cost polynomial over each course, treating it as 40% climbing at the average up-grade, 40% descending and 20% flat, with small extra penalties for grades over 1.5% and for courses with many turns. It never sees a race result. On these profiles it scores Boston 89 s faster than Berlin and Tokyo 17 s faster. The simulation has no elevation term, so the gap between this column and the planted one says nothing about whether the model is right about Boston.

Men's raw median finish time and the Minetti elevation-adjusted equivalent for each course; Boston's adjusted value moves below its raw median.
Raw median against the elevation-adjusted equivalent, men.

The composite · course difficulty index

The weights decide whether Boston or New York comes out hardest

Each method's offset becomes a factor on a 7,800-second (2:10) reference, and the index is 0.15 × grade-adjusted + 0.50 × paired + 0.35 × weather-normalized. The order is Boston 1.0092, New York 1.0089, London 1.0034, Tokyo 1.0018, Chicago 1.0012, Berlin 1.0000. Boston's composite is only 72 seconds over Berlin, against 101 from the paired fit alone, because the elevation model's negative number pulls it down. The weights are a judgment call in the code, so the chart below lets you set your own.

At the script's weights, Boston leads New York by 3 seconds

Composite = 1 + (w1·grade-adjusted + w2·paired + w3·top-10) / 7,800 s, the formula in compute_cdi(). Like the script, the chart does not rescale weights that don't add to 1.

Per-method offsets from outputs/analysis_results.csv; planted penalties from COURSE_PENALTY in src/build_data.py. The planted tick is only a fair comparison when the weights add to 1.

The script's own sensitivity runs agree with the chart. Dropping the grade-adjusted method (the "drop elevation" preset) raises Boston to 1.0110. Restricting the paired fit to men's pairs with both times under 2:10 gives 1.0088. The third run swaps the top two: with a simpler gain-and-loss elevation model in place of Minetti, New York moves to 1.0166, above Boston at 1.0131.

Grouped bars of the index under four assumption sets for each course; New York rises above Boston under the gain-and-loss elevation model.
Index under the baseline and the three alternative assumptions, as plotted by src/analysis.py.

Limitations · what this doesn't show

None of these numbers measures an actual course

The athletes and results are generated by src/build_data.py with seed 42. The names are combinations of real elite first and last names with a number attached, and the countries are drawn at random. An earlier version of this page presented Boston's +101 s as a finding about the real course; it is the paired method's estimate of a penalty that was set to 105 in code.

The test is also kinder than race data would be. The weather correction divides by the same curve that generated the times, so it is exactly right by construction. The simulation also leaves out pacing strategy, fatigue that depends on where the hills fall, and differences in who chooses to race where. Actual fields have all three.

The course profiles (gain, loss, maximum grade, turn count) are typed into data/course_profiles.csv, and the 40/40/20 split is a rough stand-in for a segment-by-segment integration. Sydney and Cape Town appear only under the grade-adjusted method.

Finish · how it was built

Where the numbers come from

Results
Simulated: 10,800 top-100 finishes, 6 courses × 9 years × 2 genders, seed 42
Pairs
42,567 same-athlete course pairs within 18 months, derived from the results
Weather
54 hand-entered editions: start temperature, humidity, dew point, wind
Elevation
Eight hand-entered course profiles: gain, loss, net drop, maximum grade, turns
Energy cost
Minetti et al. (2002), J. Appl. Physiol. 93:1039–1046
Heat penalty
Curve after Maughan (2010) and El Helou et al. (2012), PLOS ONE 7(5)
Techniques
Least-squares fixed effects, bootstrap intervals, weighted composite index, sensitivity analysis
Reproduce
python src/build_data.py then python src/analysis.py; matches the committed results

A method check, with the answer key in the code