Downloads the results of Polimirun, the Politecnico di Milano run, from endu.net for every edition since 2016, and turns them into a single table you can rank, filter and query. Output is a terminal table, CSV, JSON or a SQLite database.
Why it exists: endu.net shows one race, one page at a time. The non-competitive race, which is most of the field, has no ranking at all. The 2022 and 2023 results are no longer served by the API and only survive as XLS and PDF exports. This tool collects all of it and ranks everyone the same way.
The results belong to endu.net. Its "Download the results" dialog states:
Information, data and images, including results and rankings, may be used for personal use only, therefore their commercial use and redistribution, even partial, in any way or form without express authorization is prohibited. Downloads are tracked.
This tool is for looking at the results yourself. Don't publish, share or sell what it produces, in whole or in part, without endu.net's authorization. For any other use, ask endu.net.
The output also contains names, birth years and times of real people. This repository
holds only code and no endu.net data; .gitignore excludes .db and .csv files.
Needs Rust 1.88 or newer.
cargo build --release
The binary is target/release/polimirun.
polimirun editions # list the editions on endu.net
polimirun results --year 2026 --ranking non-competitive --gender F --top 10
polimirun results --name rossi # a name, across every year
polimirun results --ranking competitive --out competitive.csv
polimirun results --out polimirun.db # everything, as SQLite
polimirun runner --db polimirun.db --year 2026 --bib 8275 --born 2000-2004
results options:
| Option | Meaning |
|---|---|
--year Y |
Only this edition. Repeatable. Default: every year. |
--ranking R |
general (both races, default), competitive or non-competitive. |
--gender M|F |
Only this gender. |
--name TEXT |
Names containing TEXT, case-insensitive. |
--top N |
First N rows of each edition. |
--out FILE |
Write .csv, .json or .db (SQLite) instead of printing a table. |
-j N |
Result pages downloaded in parallel. Default 8. |
A full download (all editions, about 72,500 rows) takes 5 to 20 seconds. With -j 1 it
takes about a minute. Above 4 there is little gain.
runner reads a SQLite file saved with results --out, so it doesn't download anything.
Give it --year and --bib, or --name (it lists the matches when there are several).
It prints the runner's position in their race, among their gender, their 5-year age group
and, with --born FROM-TO, among runners born in those years; their position across both
races; how far they finished from the median of their gender and age group; how many
runners share their time; their other editions; and who crossed the line with them.
Every finisher is one row, with the same columns for both races:
| Column | |
|---|---|
edition_id, year |
endu.net edition id and its year. |
runner_id |
The same person across years, see below. |
family_id |
Runners with the same surname who crossed the line together, see below. |
race |
competitive or non_competitive. |
bib, name, gender, year_of_birth, team, nationality, category |
As published. Gender is M, F or empty. |
surname |
The first word of the name (endu lists surname first), keeping particles: DI PRESA, DE LA CRUZ. |
age |
Race year minus year of birth; empty when that is outside 10 to 95. |
country |
nationality as an IOC code, see below. |
official_time, real_time |
H:MM:SS. Official is gun time, real is chip time. Non-time values such as DSQ are kept as published. |
seconds |
Chip time in seconds, or official time when there is no chip time. |
start_delay |
Seconds from the gun to crossing the start line (official minus chip time). Empty in races that publish chip time as official time. |
age_grade |
seconds as a percentage of the 10 km standard for the runner's age and gender, see below. |
rank, gender_rank |
Position in the runner's own race. |
general_rank, general_gender_rank |
Position across both races of the edition. |
- Competitive race: by official time. This reproduces the published ranking exactly.
- Non-competitive race: by chip time. endu.net publishes no ranking for it.
- General: both races together, by chip time (official time where there is none).
- Equal times keep endu's order, so there are no shared positions.
- Runners without a valid time (
DSQ,00:00:00) have no rank. Runners with unknown gender have no gender ranks.
runner_id links the rows of the same person across editions: same name, ignoring accents,
punctuation and word order, and same year of birth. Rows without a year of birth (the
2022 and 2023 non-competitive exports) join the only runner with that name. If there is no
such runner, or more than one, they share one id per name. Names of a single word get
their own id.
This is a heuristic. A typo in a name or birth year splits one person in two, and two people with the same name and birth year become one. Ids are numbered on every export, so don't store them elsewhere.
Runners with the same surname who finish in the same race within 10 seconds of each other
(official time, chained: A to B to C) get the same family_id. Groups where two runners
have the same full name are left out as namesakes or double entries.
Common surnames also finish close together by chance, which is why the window is short:
comparing with surnames shuffled at random, about 70% of same-surname pairs within 60
seconds are chance, against about 30% within 10 seconds. Each family has a chance value:
how many runners with that surname would finish that close by luck. Near 0 means almost
certainly together; above 0.1, possibly not. Couples and mothers with children usually
have different surnames in Italy, so most of them are not found. Like runner ids, family
ids are numbered on every export.
Timing companies publish nationality as IOC codes (GER), ISO codes (DEU) or the first
letters of the Italian name (SPA for Spagna, SVI for Svizzera). country converts
these to IOC codes where the meaning is certain, and keeps anything else as published
(COR, UNI, REP...). IRA is read as Iran: it only appears in 2018, 2021 and 2022,
years without IRI or IRQ. The list is in
polimirun/src/country.rs.
age_grade compares runners of different ages and genders: the 10 km standard time for
the runner's age and gender divided by their time. 100 matches the standard; road-running
convention calls 90 and above world class, 80 national, 70 regional and 60 local class.
The standards are Alan Jones's 2025 road age standards, with single-age bests by Tom
Bernhard, approved on 2025-01-10 by the USATF Masters Long Distance Running Council. They
come from
AlanLyttonJones/Age-Grade-Tables
(2025 Files/MaleRoadStd2025.xlsx and FemaleRoadStd2025.xlsx, version 2025-07-27,
10 km column), released under CC0 1.0, and are copied into
polimirun/src/age_grade.rs.
- Age is the race year minus the year of birth, so it can be one year too high; the grade is then slightly generous.
- The course is taken to be exactly 10 km.
- There is no grade without a gender or with an impossible age (outside 10 to 95), which includes every 2022 and 2023 non-competitive runner.
- A grade above 100 beats the standard for that age, which in practice means a wrong birth year or someone running on another person's bib. A full download on 2026-09-27 had two.
| Object | |
|---|---|
results |
One row per finisher, the columns above. |
runners |
One row per person: runner_id, name and country (latest edition), gender, year_of_birth, editions, first_year, last_year. |
families |
family_id, year, race, surname, members, official_time of the last to finish, chance. |
general, competitive, non_competitive |
results sorted by the matching rank. |
yearly |
Per year: finishers, change, competitive and non-competitive, share of women and foreigners, countries, median time, winners. |
retention |
Runners who came back to the next edition, and the share of the next field who had run before. |
loyalty |
People by number of editions. |
nations |
People by country. |
surnames |
People by surname. |
time_distribution |
Percentiles and shares under 40, 50 and 60 minutes, per year and race. |
gender_gap |
How much slower women are: winners, fastest 10% and median. |
age_groups |
5-year age groups by gender: runners, median and best time, mean age grade. |
progression, progression_summary |
Each runner's change in time from one edition to their next, and summaries by race. |
start_delay |
Median and longest wait at the start, per race that publishes gun time. |
finish_flow |
Finishers per minute after the gun, same races. |
clubs |
Competitive race clubs: runners, best position, sum of the three best positions. |
data_quality |
How many rows have each known problem. |
The SQL is in polimirun/src/schema.sql and
polimirun/src/views.sql. Writing to an existing file replaces
these objects and leaves anything else in it alone. Percentiles take the value at that
position, without averaging two middle values.
Some queries:
-- Top 10 women of the 2026 non-competitive race
SELECT rank, gender_rank, bib, name, real_time
FROM non_competitive WHERE year = 2026 AND gender = 'F' LIMIT 10;
-- One person across the years
SELECT year, race, official_time, real_time, rank
FROM results WHERE runner_id = 123 ORDER BY year;
-- Best age-graded results of 2026, leaving out impossible ones
SELECT name, gender, age, real_time, age_grade, race
FROM results WHERE year = 2026 AND age_grade <= 100
ORDER BY age_grade DESC LIMIT 10;
-- Fastest families of 2026 and their members
SELECT f.race, f.surname, f.official_time, f.chance, r.name, r.year_of_birth
FROM families f JOIN results r USING (family_id)
WHERE f.year = 2026 ORDER BY f.official_time, r.official_time LIMIT 20;
-- Retention and age groups
SELECT * FROM retention;
SELECT * FROM age_groups WHERE year = 2026;Numbers from a download on 2026-09-27.
| Year | Finishers | Competitive | Non-competitive | Source |
|---|---|---|---|---|
| 2016 | 2,346 | – | 2,346 | API |
| 2017 | 5,430 | 336 | 5,094 | API |
| 2018 | 7,755 | 553 | 7,202 | API |
| 2019 | 6,167 | 499 | 5,668 | API |
| 2020 | – | – | – | none on endu.net |
| 2021 | 2,557 | 145 | 2,412 | API |
| 2022 | 4,756 | 194 | 4,562 | XLS (competitive), PDF (non-competitive) |
| 2023 | 8,048 | 560 | 7,488 | XLS (competitive), PDF (non-competitive) |
| 2024 | 9,925 | 545 | 9,380 | API |
| 2025 | 13,782 | 967 | 12,815 | API |
| 2026 | 11,789 | 865 | 10,924 | API |
- 2016 has a single race. endu.net lists it alphabetically with positions that don't follow time, so it is treated as non-competitive and ranked by time.
- 2022 and 2023: the XLS exports have no year of birth or chip time; gender is taken from
the category (
SM,SF45, ...). The PDF exports only have bib, name, nationality and official time.
These come from the source and are left as published:
- Nationality codes mix standards: Spain is
ESPorSPA, ChinaCHNorCIN. A few are truncated (EL,MÉX).countryconverts the ones whose meaning is certain. - In 2017 and 2019 almost every runner is
ITA, probably a default value. - Some non-competitive birth years are impossible (the race year, 1900,
2). They are ignored when matching runners. - Nobody checks non-competitive times, and a few look implausible. Nothing is filtered.
- Two age grades are above 100 (a runner listed as 90 years old at 48:31, another as 63 at 32:43): wrong birth years or swapped bibs.
- In 2023 bib 12969 appears twice.
endu/: a small client for endu.net's public results API, with nothing specific to Polimirun. It also reads endu's XLS and PDF exports into the same shape as live results.polimirun/: the row schema, rankings, runner identity, families, country codes, age grading, the SQLite tables and views, and the CLI.
endu.net endpoints used (all public, no authentication):
| Endpoint | Returns |
|---|---|
GET /api/events/groups?slug={slug} |
The event group id for an endu.net/events/{slug} page. Polimirun is polimirunspring, group 6212. |
GET /api/events/groups/{groupId} |
The editions of an event. |
GET /api/results/events/{editionId}/settings |
Races, categories and ranking lists of an edition. 404 when there are no results. |
GET /api/results?editionId&raceId&categoryId&optionId&page&pageSize |
One page of a ranking. pageSize is at most 100. There is no total count: keep paging until a page is empty. |
GET /api/results/events/{editionId}/downloads |
PDF and XLS links for each ranking list. |
endu's XLS exports store text as UTF-16 in a way the calamine reader returns byte by byte,
so endu decodes it again. The PDFs are read by glyph position, using the header row
repeated on every page.