In how many German districts did I cycle?

In the previous post I described how I build the YAML file with all German districts. Now I wanted to use this data for something else. Wandrer.earth already tracks which streets I cycled, but not which district (Landkreis) they are in. So: a new score -- how many of the 401 districts and how many of the 16 states (Bundesländer) did I cycle in?

The input is my Wahoo export: 3288 FIT files, from 2019 to today. The tours from before the Wahoo ELEMNT BOLTs, so before February 2019, I checked in VeloViewer. Almost all of them were in Baden-Württemberg, so they would not change the statistics much. I did longer tours before that too, but without Strava and a bike computer I never recorded them. Reading the Wahoo FIT files is the easy part, fitdecode does that, and with StandardUnitsDataProcessor the positions come out as degrees instead of semicircles.

The district file I used for the kfz.cress.space page has an osm_id per district -- but that is a reference, not a shape. I first started looking for a ready-made GeoJSON of German district boundaries, and then realized I don't need one: the ids I already have can fetch exactly the 401 relations from my selfhosted Overpass in a single query.

[out:json][timeout:900];relation(id:27020,27021,...);out geom;

What Overpass returns for out geom is not a polygon, it is the relation's member ways with their coordinates, in arbitrary order and direction. Shapely turns that into polygons in two steps: linemerge glues the ways into closed rings, polygonize makes areas out of them.

The member roles matter here. Ways with role inner are holes, and for district boundaries these holes are mostly the enclaved kreisfreie Städte, e.g. Ansbach the city sits inside Landkreis Ansbach. In total 88 of the 401 districts have at least one such hole. Without subtracting them a ride through the city would count for the city and the surrounding Landkreis:

outer = close(rings["outer"])
inner = close(rings["inner"])
if inner is not None and not inner.is_empty:
    outer = outer.difference(inner)

For the lookup itself all polygons go into a shapely.STRtree, and the whole track of one ride is queried at once:

_, found = tree.query(shapely.points(lons, lats), predicate="intersects")
hits = {keys[i] for i in found}

That vectorized query is fast enough (~0.1s for a ride with 2000 points against all 401 districts) and it needs no shapely.prepare: I added a prepare call assuming it would speed things up, benchmarked it, and it changed exactly nothing -- STRtree.query with a predicate prepares the tree geometries itself.

Before the lookup I round every position to 4 decimals (~11m) and put them in a set, which keeps 58% of the recorded points. Less than I expected, because rounding is a grid and not a clustering: two points a few meters apart still end up in different cells when they straddle a boundary.

The first full run took 11 minutes. The results are cached, because a tour that is already calculated will never change. One entry per FIT file, keyed by filename with the file size as a change check:

"2019-05-19-061304-ELEMNT BOLT 8284-98-0.fit": {
  "districts": ["08111", "08115", "08118"],
  "date": "2019-05-19",
  "size": 570271
}

The districts are the Kreisschlüssel again, so Stuttgart, Böblingen and Ludwigsburg for this tour.

And the answer:

3288 rides
districts:      191 / 401  (47.6%)
federal states: 14 / 16
covered:        143,208 km² (40.1% of Germany)

per federal state
  Baden-Württemberg       41 /  44  █████████
  Bayern                  32 /  96  ███
  Berlin                   1 /   1  ██████████
  Brandenburg              9 /  18  █████
  Bremen                   0 /   2
  Hamburg                  1 /   1  ██████████
  Hessen                  21 /  26  ████████
  Mecklenburg-Vorpommern   2 /   8  ██
  Niedersachsen           17 /  45  ████
  Nordrhein-Westfalen     28 /  53  █████
  Rheinland-Pfalz         24 /  36  ███████
  Saarland                 2 /   6  ███
  Sachsen                  1 /  13  █
  Sachsen-Anhalt           7 /  14  █████
  Schleswig-Holstein       0 /  15
  Thüringen                5 /  23  ██

new districts per year
  2019  + 29  → 29
  2020  +  3  → 32
  2021  + 13  → 45
  2022  + 43  → 88
  2023  + 27  → 115
  2024  + 10  → 125
  2025  + 30  → 155
  2026  + 36  → 191

Just under half of the districts, and 14 of 16 states. Missing completely are Bremen and Schleswig-Holstein. I was in Bremen, Kiel and Lübeck in the past, but not cycling there.

The per-year column is basically a travel log. 2020 with +3 is the lockdown year of riding the same routes around Stuttgart, and since 2022 I cycle a lot of distance with my Brompton, mainly along rivers where a single tour crosses a handful of districts.

In the next years I will monitor this and see if I get even more coverage of districts, and obviously "catch" the last 2 states.

German license plate prefixes per Landkreis

I wanted a YAML file that maps every German county (Landkreis or kreisfreie Stadt) to its license plate prefixes (Kfz-Unterscheidungszeichen). The authoritative source for the prefixes is the KBA list, the county list I used is the de-counties.json with OSM ids, inhabitants and area per county. The json file is already a few years old. I iterated on the two scripts with Claude Code.

The KBA page has no API and no download other than a PDF. The data is static HTML, one page per initial letter, so the only option is scraping all letter pages. BeautifulSoup on the stable CSS classes of the accordion items worked fine -- no regex parsing of HTML.

The real work was joining the two datasets, because the only common field is the county name. Some of the things that came up:

  • The KBA names are free text: qualifiers like "Stadt", "Landkreis" or "Hansestadt", multi-county entries ("Ortenaukreis, Rastatt"), abbreviations ("a. d." vs "an der") and footnote markers.

  • Same-name pairs like München (city) and Landkreis München share some prefixes but not all. The KBA qualifiers disambiguate some of them, for the rest I used the Wikipedia lists of Landkreise and kreisfreie Städte to decide which side gets an ambiguous prefix.

  • The official source has actual typos ("Bad Dürkeim", "Biturg-Prüm") and is even missing one valid prefix completely: LD for Landau in der Pfalz.

The biggest learning: join by key, not by name, whenever possible. The keys in de-counties.json are the official Regionalschlüssel and their first 5 digits are the Kreisschlüssel. The Wikipedia tables carry the Kreisschlüssel in a column, so that join needed no name matching at all. I used the Kreisschlüssel as key for the final YAML file, too.

Because the population in the json file is outdated, I added Wikidata as another source. The Wikidata property P440 is the German district key, so every county gets its Wikidata item and current population (P1082) -- again without name matching. The area (P2046) was a surprise: for 20 of the 403 counties the Wikidata value differs more than 10% from the OSM polygon area, and almost all of them are errors on the Wikidata side -- wrong units (5007 km² for the city of Amberg, the München item even carries its 1867 area in square miles), values from before a county merger, or only the land area. The date qualifiers don't help to filter these, the wrong values often carry recent dates. So the script keeps the OSM polygon area when the two disagree, with one exception: Suhl really grew in 2018/2019 by incorporating neighbouring municipalities, there Wikidata is the correct one.

A YAML detail I didn't know before: PyYAML dumps '01001' quoted but 09162 unquoted, because 09162 is not a valid octal number. A YAML 1.2 parser reads the unquoted version as the integer 9162, so the script now forces quotes on all digit keys.

An entry of the resulting file looks like this:

'09162':
  area_km2: 310.71
  inhabitants: 1505005
  name: München
  osm_id: 62428
  plates:
  - M
  - MUC
  wikidata: Q1726

Everything that cannot be matched is printed to stderr instead of being silently dropped. That report is what surfaced the typos, the missing LD and the counties that needed manual supplements (i.e. Eisenach, which lost its kreisfrei status in 2021). The 2017 county list also still contained the dissolved Landkreis Osterode and the pre-merger Landkreis Göttingen as separate entries, and the old Göttingen even got Osterode's OHA prefix because the KBA list describes today's assignment -- so counties marked as dissolved are left out now. In the end all 401 counties have plates and every KBA prefix is assigned to at least one county.

The generated file is the data source I use for kfz.cress.space. I build the site to experiment with Preact. The internet doesn't actually need another Landkreise/KFZ-Kennzeichen page, but I still wanted to build my own.

Screenshot:

img1

One dot-config Repository for two Notebooks

For a while my ~/.config on my main notebook has been a git repository, pushed to my private Forgejo. Now I wanted the same repository on my travel notebook. The catch: the notebooks are not identical. The main notebook runs sway on Wayland, the travel notebook runs i3 on X11. And some configs are simply different on each machine, on purpose.

Adopting the repository on the second notebook

I initialized git inside the existing folder on the travel notebook and pointed the branch at the remote without touching any files:

cd ~/.config
git init -b main
git remote add origin ssh://git@forgejo/mfa/dot-config.git
git fetch origin
git reset origin/main

After the git reset the working tree is unchanged, but git status shows exactly how this machine differs from the repository. The repository uses a deny-by-default .gitignore (first line is /*, then a !/name line for every config that is actually versioned), so all the application caches and state folders stay invisible.

Most differences were easy to sort: window manager configs don't conflict because sway/ and i3/ are different folders, so both live in the repository and each machine only uses its own. Some files existed only in the repository and were safe to check out. Some local drift was not worth keeping (btop, htop, mc) and I reset it to the repository version. And some files I stopped tracking completely: window geometry state like git/gitk and pavucontrol.ini, and uv/uv.toml.

But two files remained that genuinely need different content per machine: alacritty/alacritty.toml is tuned per display server and screen, and atuin/config.toml has a different sync_address, because the main notebook reaches the Atuin server over the LAN IP, while the travel notebook uses always the Tailscale address.

Alternatives I looked at

There are plenty of dotfile managers that solve the per-machine problem.

yadm was the closest match. Its "alternates" feature does natively what link.sh does: track alacritty.toml##hostname.foo and yadm symlinks the right one on checkout. But yadm wants $HOME as its worktree, so my ~/.config-rooted repository would need a path migration, and it brings encryption, templating, bootstrap hooks — a lot of tool for one symlink.

toml-bombadil has profiles, which is the right idea. But it renders every file through its template engine into a shadow directory and symlinks from there, so editing a config means re-linking afterwards. I wanted the profiles without the templating.

chezmoi is templating-first: one file with {{ if eq .chezmoi.hostname ... }} blocks instead of two files. Powerful, but the config in the repository is no longer the config on disk, and live editing is gone.

GNU stow is nicely minimal, but it symlinks from a package farm into ~/.config. My repository is ~/.config, and I didn't want to restructure everything into packages.

A branch per machine needs no symlinks at all, but means merge discipline forever, and in my experience those branches drift apart and rot.

For two notebooks and (currently) two per-host files, the shell script wins.