Check je fietsroute op omleidingen en afsluitingen — omleidingchecker.nl
  • Python 57.4%
  • JavaScript 17.3%
  • HTML 15.6%
  • CSS 9%
  • Shell 0.7%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jelmer ff1108e958
All checks were successful
Deploy / deploy (push) Successful in 2m7s
server: versie-header onderdrukken + socket-timeout (#41)
Twee resterende quick wins uit docs/aanbeveling-productieserver.md (externe
black-box review). Body-limiet en exception-hygiëne (de andere twee
bevindingen daar) waren al opgelost sinds #32/#36.

- server_version/sys_version op Handler: geen Python-versie meer in de
  Server-header.
- timeout = 30 op Handler: socket-timeout tegen een trage client die met maar
  een handvol reuseport-workers een worker kan gijzelen.

Bewust geen protocol_version = "HTTP/1.1": _stream_check() stuurt NDJSON
zonder Content-Length en leunt op verbinding-dicht voor de framing; HTTP/1.1
keep-alive zou dat breken. De timeout werkt daar los van.

Geverifieerd lokaal: Server-header aangepast, normale en streaming
/api/check blijven werken, regressietest + test_gipod_verfijning.py +
node --check groen.
2026-08-19 16:06:53 +02:00
.forgejo/workflows pontjes: signaleer veerverbindingen op de route (#39) 2026-08-14 23:38:13 +02:00
deploy pontjes: signaleer veerverbindingen op de route (#39) 2026-08-14 23:38:13 +02:00
docs server: versie-header onderdrukken + socket-timeout (#41) 2026-08-19 16:06:53 +02:00
static pontjes: signaleer veerverbindingen op de route (#39) 2026-08-14 23:38:13 +02:00
tests regressietest: gouden bestanden zelfconsistent maken met de fixture (#40) 2026-08-14 23:52:38 +02:00
.gitignore Ook .claude/ negeren 2026-07-20 14:09:23 +02:00
bron_gipod.py server.py gesplitst in modules per verantwoordelijkheid (analyse R3 + R6) 2026-07-30 17:03:40 +02:00
bron_melvin.py GIPOD begrensd en verfijnend: dichte Vlaamse steden van onbruikbaar naar 3s warm 2026-07-30 16:31:43 +02:00
check.py pontjes: signaleer veerverbindingen op de route (#39) 2026-08-14 23:38:13 +02:00
feedback.py server.py gesplitst in modules per verantwoordelijkheid (analyse R3 + R6) 2026-07-30 17:03:40 +02:00
geo.py server.py gesplitst in modules per verantwoordelijkheid (analyse R3 + R6) 2026-07-30 17:03:40 +02:00
komoot-check.json Route-import via URL: Komoot (incl. share_token) + Garmin-courses (TASK-3) 2026-07-18 18:32:44 +02:00
LICENSE Colofon, AGPL-3.0 en licentieteksten van derden (TASK-13) 2026-07-20 09:54:13 +02:00
monitor.py server.py gesplitst in modules per verantwoordelijkheid (analyse R3 + R6) 2026-07-30 17:03:40 +02:00
net.py GIPOD begrensd en verfijnend: dichte Vlaamse steden van onbruikbaar naar 3s warm 2026-07-30 16:31:43 +02:00
oordeel.py server.py gesplitst in modules per verantwoordelijkheid (analyse R3 + R6) 2026-07-30 17:03:40 +02:00
pontjes.py pontjes: signaleer veerverbindingen op de route (#39) 2026-08-14 23:38:13 +02:00
pontjes_data.json pontjes: signaleer veerverbindingen op de route (#39) 2026-08-14 23:38:13 +02:00
README.md pontjes: signaleer veerverbindingen op de route (#39) 2026-08-14 23:38:13 +02:00
route_import.py server.py gesplitst in modules per verantwoordelijkheid (analyse R3 + R6) 2026-07-30 17:03:40 +02:00
schijfcache.py GIPOD begrensd en verfijnend: dichte Vlaamse steden van onbruikbaar naar 3s warm 2026-07-30 16:31:43 +02:00
server.py server: versie-header onderdrukken + socket-timeout (#41) 2026-08-19 16:06:53 +02:00

Omleidingchecker

Webapp die een fietsroute checkt op wegwerkzaamheden, afsluitingen en evenementen. Twee bronnen:

  • NederlandMelvin (NDW), dezelfde data als de publieke Melvin-kaart, via de JSON-API waar die kaart zelf op draait
  • VlaanderenGIPOD (Digitaal Vlaanderen), via de publieke OGC API Features-dienst

De repo heet nog gpx-afsluitingen; dat is historie, geen betekenis.

Live: https://omleidingchecker.nlgpx.jelmer.org was het oude adres en verwijst met een 301 door, zodat links die al rondgingen blijven werken.

Gebruik

Sleep een GPX-bestand in het vak — of plak een Komoot-link (publieke tour, smarttour, of de deel-link met share_token voor privétours) — kies Vandaag / Morgen / Andere datum en klik Check route. Garmin en Strava vereisen beide een login voor hun route-API's; exporteer daar als GPX en upload dat. Resultaat: vier groepen, gesorteerd op km-punt langs de route:

  • Op de route (≤ 100 m)
  • In de buurt (100300 m)
  • Waarschijnlijk niet relevant voor fietsers — heuristische demotie van meldingen met alleen een snelheidsbeperking/parkeermaatregel zonder afsluiting, of die expliciet alleen motorvoertuigen noemen. Nooit verwijderd, alleen ingeklapt; alles met "fiets" in de tekst of een afsluiting blijft altijd bovenaan.
  • Snelwegen (A-wegen; de route kruist die meestal ongelijkvloers — meldingen met "fiets" in de naam blijven gewoon in de eerste groep)

Elke melding is uit te klappen voor opmerkingen van de wegbeheerder, beperkingen per fase (met voertuigtypes en richting), omleidingen, periodes en de bron — genoeg om zelf in te schatten hoe erg het echt is. Melvin-meldingen hebben een deeplink naar de melding zelf (melvin.ndw.nu/public/all-situation/<id>); GIPOD kent die niet, dus daar verwijst de knop naar de kaart op deze pagina.

De interface bestaat uit drie schermen: start (bron + datum), laden (voortgang per stap: route inlezen → Melvin matchen → AI-inschattingen) en resultaten.

Op het resultatenscherm staat een kaart (Leaflet, gevendored in static/leaflet/) met de route en een marker per melding, gekleurd op het AI-oordeel. Ondergrond is MapTiler streets-v4 als er een MAPTILER_KEY is — één kaart, geen laagwisselaar; valt automatisch terug op OpenStreetMap als MapTiler niet levert (quota op of origin niet toegestaan). Let op: MapTiler geeft bij een geweigerde key een geldige PNG terug, dus de terugval test de HTTP-status van één tegel in plaats van te wachten op een laadfout. De key is op MapTiler beperkt tot de eigen domeinen, dus zichtbaarheid in de browser is geen probleem.

Marker aanklikken opent de bijbehorende melding; een melding openklappen pant de kaart ernaartoe en tekent het afgesloten wegvak (rood) plus eventuele omleidingen (groen gestreept).

Fonts (Space Grotesk + Figtree) worden zelf gehost vanuit static/fonts/, zodat er geen verzoek naar Google Fonts gaat.

Boven de lijst staan tegels met de verdeling van het AI-oordeel (groot obstakel / kleine omweg / geen probleem, plus "niet in te schatten" als die er zijn — zo tellen ze altijd op tot het aantal meldingen) en filterchips: Belangrijk (standaard — alles behalve "geen probleem"), Op route, Afgesloten en Alles. De kaartmarkers volgen het gekozen filter.

Lokaal draaien

python3 server.py          # http://127.0.0.1:8765
python3 server.py 9000     # andere poort

Alleen Python-stdlib, geen dependencies.

Indeling

Bestand Verantwoordelijkheid
server.py HTTP (3 workers via SO_REUSEPORT), de check-pijplijn, route-import, LLM-oordelen, feedback, health/status
bron_melvin.py Nederland: ophalen, vector-tiles, trimmen, fietsrelevantie
bron_gipod.py Vlaanderen: OGC API Features, zones, fietsclassificatie
geo.py bronneutrale geometrie: projectie, afstanden, slippy tiles, punt-in-vlak, vereenvoudigen
net.py gedeelde HTTP-helpers
pontjes.py signaleert pontjes op de route uit een statische OSM-snapshot (pontjes_data.json)

Een bron is een module met DEKKING (bounding box), fetch_situations(datum, route), en de bronspecifieke oordelen is_snelweg(), fiets_relevant() en route_relatie(). Die laatste drie verschillen wezenlijk per bron: Melvin vraagt heuristiek op vrije tekst, GIPOD levert de verkeerssoort gewoon als veld. Meldingen komen terug in het interne model:

{"verts": [(lat, lon), ...],   # punten om afstand tot de route te meten
 "vehicleType": str | None,
 "trim": {...}}                # de velden die de frontend toont

server.py kiest op de bounding box van de route welke bronnen bevraagd worden — op de hele route, niet op het vertrekpunt, zodat een rit over de grens beide bronnen raakt.

Hoe het werkt

  1. GPX wordt in de browser gelezen en als tekst naar POST /api/check gestuurd.

  2. De server haalt op wat actief is op de gekozen datum — maar alleen in de buurt van de route. De situatie-API kent geen bbox-filter; de vector-tiles op /api/maps-public/points_public/{z}/{x}/{y} wél, want een tegel is een bbox. Daarom:

    • twee goedkope lijst-calls: alle ids, en de ids met bron MELVIN;
    • de tegels langs de route ophalen (z12 met één ring eromheen, ~46 tegels voor een route van 133 km, samen ±0,6 s) en daaruit de situatie-ids lezen;
    • details opvragen voor niet-Melvin Melvin-langs-de-route.

    Alles wat niet uit Melvin komt (LTC, SPIN, en elke bron die er later bijkomt) wordt dus altijd volledig opgehaald — die hebben geen gegarandeerde tegeldekking, en zo kan een nieuwe bron niet stil wegvallen. Lukt het ophalen van een tegel niet, dan valt de server terug op alles ophalen: liever traag dan een gemiste afsluiting.

    Gemeten op de Biesbosch-route: 1039 in plaats van 3899 situaties (73%), en met koude cache 3,1 s in plaats van 11,8 s — met exact dezelfde treffers. Details worden per id gecachet, 10 minuten per datum. Voor Vlaanderen ligt het anders: GIPOD heeft een bbox-filter, dus daar halen we per tegel langs de route rechtstreeks de zones op. Twee valkuilen zitten in bron_gipod.py afgevangen, met de meting erbij: hun datetime filtert op startdatum binnen het venster in plaats van actief-op-die-dag (werk dat maanden loopt zou wegvallen), en numberMatched ontbreekt, dus doorpagineren tot er minder dan de limiet terugkomt.

  3. Per situatie wordt de minimale afstand tot de route berekend (equirectangulaire projectie). Daarna bepaalt de bron zelf of de route er écht overheen gaat: bij Melvin of er een afgesloten wegvak parallel loopt, bij GIPOD of de route dwars door de zone gaat. Dat scheelt veel valse meldingen — op een Vlaamse testroute ging het van zes "grote obstakels" naar de ene die er werkelijk lag.

  4. Los van Melvin/GIPOD: pontjes.py checkt via dezelfde RouteIndex of de route binnen 150 m van een pontje uit OSM komt (issue #39). Bewust alleen signalering — naam en (indien bekend) website/openingstijden — geen "vaart hij vandaag"-oordeel: daar is geen centrale, betrouwbare bron voor. De snapshot is statisch (geen Overpass-call per check); ververs 'm met python3 pontjes.py --opnemen.

De API-response bevat ook de geometrie (route.polyline, restrictions[].polyline, detours[].polyline) waar de kaart op tekent. Bij Melvin zijn dat lijnen (het afgesloten wegvak), bij GIPOD vlakken (de werfzone, vereenvoudigd met Douglas-Peucker — zonder dat zou één zone al 22000 punten kunnen zijn).

Deploy — Uberspace, omleidingchecker.nl

Zelfde keten als de andere sites (zie ~/Dev/uberspace README): git.jelmer.org → Forgejo Actions (homelab-runner op argo) → rsync naar Uberspace, plus een supervisord-restart omdat dit een Python-backend is in plaats van een static site.

De bron is sinds 20 juli 2026 de eigen Forgejo; Codeberg is nog wel een push-mirror als publiek vangnet, maar draait geen CI meer. Reden: Codeberg Actions viel te vaak uit — die dag gaf het 504's op logupload en weigerde SSH verbindingen. Zie .forgejo/workflows/deploy.yml.

Schaal. De route-check is CPU-zwaar en één ThreadingHTTPServer-proces serialiseert die op de GIL: drie gelijktijdige checks duurden ~15 s in plaats van ~5 s. Daarom draaien er 3 workers die poort 8765 delen via SO_REUSEPORT (ReuseportServer in server.py, de kernel verdeelt de verbindingen); supervisord start ze met numprocs=3, samen de groep gpx-afsluitingen:*. ~60 MB per worker, ruim onder de 1,5 GB van U7; de fair-share-CPU blijft de bindende limiet, dus 3 is realistisch. De deploy-restart is groep-bewust (supervisorctl restart 'gpx-afsluitingen:*').

Eenmalige setup:

  1. DNS (Cloudflare, DNS-only / grijze wolk): @ en www A → 185.26.156.19, AAAA → 2a00:d0c0:200:0:ac4b:e8ff:fe16:98dc. Grijze wolk is essentieel: een Cloudflare-proxy zit Let's Encrypt in de weg.

  2. Uberspace (als starbase@kochab.uberspace.de):

    uberspace web domain add omleidingchecker.nl
    uberspace web backend set omleidingchecker.nl --http --port 8765
    
  3. Repo-instellingen op git.jelmer.org (Settings → Actions):

    • secrets UBERSPACE_SSH_KEY, OPENROUTER_API_KEY, MAPTILER_KEY, FEEDBACK_TOKEN, NTFY_URL, NTFY_TOPIC, NTFY_TOKEN
    • vars UBERSPACE_SSH_HOST, UBERSPACE_SSH_USER

    Faalmeldingen gaan via ntfy naar topic Forgejo met tokenauth (Bearer), net als de rest van het homelab. Zijn de NTFY_*-secrets leeg, dan slaat de stap netjes over in plaats van de deploy te laten falen.

  4. Push naar main — de workflow rsynct de app, installeert deploy/gpx-afsluitingen.ini in ~/etc/services.d/ en herstart de service.

Oude domeinen doorverwijzen

gpx.jelmer.org — het oude adres — krijgt een 301 naar omleidingchecker.nl, met behoud van het pad, zodat links die al rondgingen blijven werken. Dat gaat via de constanten CANONIEK en OUDE_DOMEINEN in server.py; alleen hosts uit die verzameling worden doorverwezen.

Bewust géén "alles wat niet canoniek is doorverwijzen": dan stuurt ook de ontwikkelserver op 127.0.0.1 je naar productie. www.omleidingchecker.nl serveert dus gewoon zelf; dubbele indexering wordt afgevangen met de rel="canonical" in de pagina.

/api/health is uitgezonderd: een controle die het oude adres aanroept hoort een status te krijgen, geen omleiding.

Als de CI plat ligt: deploy handmatig met ./deploy/handmatig.sh. Dat doet hetzelfde als de workflow, minus de secrets-stap, en controleert daarna /api/health.

Gebruik en kosten

/api/status toont wat de app die dag doet en of de AI-oordelen nog betaald kunnen worden:

checks · llmCalls · llmUitCache · bronOphalingen · creditsOver · keyLimietOver

Geeft 503 als het OpenRouter-saldo onder de CREDITS_LAAG-grens zakt (standaard $2) of de daglimiet vol is. Die grens ligt bewust onder de auto-top-up van OpenRouter bij $3: anders gaat de monitor elke keer af vlak voordat het probleem zichzelf oplost, en dan leer je hem negeren. Rood betekent nu dat de top-up het heeft laten afweten. Kuma-monitor "Omleidingchecker — AI-budget" polt dit elk uur en alarmeert via ntfy.

Bewust los van /api/health: health gaat over doet de site het en voedt de deploybewaking. Een leeg saldo is iets anders — de site blijft dan gewoon werken, alleen zonder inschattingen. Die twee door elkaar halen levert alarmen op de verkeerde vraag.

Het saldo wordt met de eigen app-key opgevraagd. /credits en /key werken met een gewone key, dus er hoeft geen management- of provisioning-key op een server te staan.

Let op: het OpenRouter-account wordt gedeeld met andere toepassingen. Deze app verbruikte in juli ongeveer $0,06 per dag; het account kan dus leegraken door iets waar deze app niets mee te maken heeft, terwijl híér de oordelen uitvallen. Dat is precies waarom er op het saldo gemonitord wordt en niet op ons eigen verbruik.

Feedbackformulier

"Bug of idee melden" in de footer maakt via POST /api/feedback een issue aan in deze repo (Forgejo-API). Daarvoor moet op de server een token van git.jelmer.org staan (scope: alleen issues schrijven) in ~/etc/gpx-afsluitingen.env:

FEEDBACK_TOKEN=<token>

De oude naam CODEBERG_TOKEN wordt nog gelezen als terugval, zodat een server met de oude env niet stilletjes stopt met feedback doorsturen. Instantie en repo zijn te overrulen met FEEDBACK_API en FEEDBACK_REPO.

(chmod 600; daarna supervisorctl restart gpx-afsluitingen.) Zonder token meldt het formulier netjes dat feedback niet geconfigureerd is. Spam wordt geweerd met een honeypot-veld en een rate-limit van 5 inzendingen per IP per uur; inzendingen krijgen het prefix [bug]/[idee] in de issue-titel.

Voorspelbaarheid van de AI-oordelen

Hetzelfde model twee keer bevragen gaf 4254% wisselende oordelen (OpenRouter routeert dezelfde modelnaam naar verschillende providers; temperature: 0 en seed helpen daar niet tegen). Drie maatregelen maken het resultaat stabiel:

  1. Deterministische voorfilter — meldingen waar de route de afsluiting alleen kruist/passeert (routeLooptLangs is False) krijgen een vast oordeel "geen-probleem" zonder LLM. Op een testroute is dat ~80% van de meldingen; die kunnen dus per definitie niet meer wisselen.
  2. Strikte beslisregels in de prompt (genummerde volgorde, "nooit groot-obstakel bij twijfel") — bracht de resterende instabiliteit in een meting van 5 runs van 60% naar 0%.
  3. Cache op schijf (~/.cache/gpx-afsluitingen-oordelen.json, te overriden met LLM_CACHE_FILE) op sleutel displayId|lastChangeAt — eenmaal geoordeeld blijft het oordeel gelijk, ook na een herstart of deploy, tot Melvin de melding zelf wijzigt. De workers delen die ene file; bij opslaan merget elke worker eerst wat er op schijf staat (behoud van entries van andere workers) en schrijft via een eigen .tmp.<pid>, zo geen lost-updates. Een per-worker file was fout: die verweesde de bestaande cache en liet alles opnieuw bij de LLM ophalen — onnodige spend.

Wie

Gemaakt door Jelmer Koekkoek — triatleet die niet van verdwalen houdt op z'n route. Liefhebberij, geen bedrijf: server, kaarttegels en de AI-inschattingen komen uit eigen zak. Wie wil helpen koopt een kop koffie via bunq.me.

Licentie en bronvermelding

De code staat onder AGPL-3.0 — zie LICENSE. Wie deze app (aangepast) als dienst aanbiedt, deelt de broncode.

Meegeleverd werk van derden houdt zijn eigen licentie, met de tekst ernaast: Leaflet 1.9.4 (BSD 2-Clause, static/leaflet/LICENSE.txt) en de lettertypen Space Grotesk en Figtree (SIL OFL 1.1, static/fonts/OFL-*.txt).

De data komt uit Melvin (NDW) en staat onder CC0; kaarttegels van MapTiler op basis van OpenStreetMap (ODbL). In de app bundelt het colofon — bereikbaar vanaf het startscherm en onder de resultaten — al deze bronnen, plus de disclaimer dat dit een hulpmiddel is en geen garantie dat een route vrij is. NDW vraagt expliciet niet de indruk te wekken dat zij afgeleide bewerkingen onderschrijven; dat staat er dus bij.

Taken

Werk staat in de issues.

Tot 20 juli 2026 liep het via Backlog.md in backlog/. Alle 22 taken zijn gemigreerd naar issues, inclusief de implementatienotities — die zijn vaak nuttiger dan de oorspronkelijke omschrijving, want daar staat wat er onderweg misging. De map is daarna verwijderd; wie de oude vorm wil zien vindt 'm in de git-historie.