Warszawa metro and tram stations — tram stops from the official ZTM GTFS #60

Merged
art merged 2 commits from night/issue-24-warsaw-stations into main 2026-08-05 10:55:41 +00:00
Owner

Closes #24


#24: Warszawa metro and tram stations — tram stops from the official ZTM GTFS

Warsaw finally has a stations layer. The source is the official ZTM Warszawa
GTFS (https://gtfs.ztm.waw.pl/last — the "always newest" feed on ZTM's own
server, Apache-2.0 terms, no credentials; announced on the Dane rozkładowe
page), fetched by the importer through a new downloads: entry like any
other dataset — no curl, no manual download.

The feed covers tram stops only. Its route types are 0 (tram, 208 routes),
2 (rail — WKD/KM/SKM suburban lines) and 3 (bus); ZTM's public GTFS export
carries no metro routes, so Poland's stations partition has no metro rows
and the style's M icon stays unused for now. That is the honest, official
answer to the issue's metro ask: the M glyph exists and renders automatically
for any mode='metro' feature, but there is no official metro stop source to
feed it yet. The extension paths (City of Warsaw API metro-entrances WFS with
a free key; the community-built mkuran.pl/gtfs/warsaw.zip) are documented in
the config and docs, not imported.

The commuter rail behind route type 2 is deliberately not imported either —
a scope decision, tracked as issue #63. The feed's 245 rail routes are
WKD/KM/SKM, reaching well beyond Warsaw across Mazovia, and the loader's
per-type agencies: narrowing (the mechanism Sweden uses for pendeltåg)
could scope them to SKM's S-lines without any style change — the J badge and
the station_icons commuter filter already exist. The config, country-poland
and workstream-a all record this now, so the "available but not imported yet"
state is documented the same way metro's absence is.

Implementation follows the Sweden pattern: a GTFS staging table
(pl_gtfs_stops, feature: stops, route_types [0]) and a stations
partition of the shared map_stations tables. Two Warsaw-specific wrinkles:

  • ZTM's stops.txt has no parent_station — every row is a pole (słupek), one
    per direction. The partition collapses same-named poles within 150 m
    (ST_ClusterDBSCAN, partitioned by name) to the cluster centroid: 596 served
    poles → 324 stop points. Without the collapse every przystanek would render
    twice with two labels.
  • importance is 1 for every stop (the tram rung, same as Sweden's), so
    Warsaw's stops appear from z12 — the same zoom Stockholm trams do. The
    repeated names in the output (Rondo ONZ, Okopowa, …) are genuinely separate
    przystanki on different streets, verified pairwise 280–600 m apart.

No style change was needed: tram stops render as the established purple-ringed
dot plus label (the style has badge icons only for metro M and commuter J —
the issue's "do trams have a standard icon?" answer is that the map's
convention is the dot, matching Stockholm).

Verified against the live DB: 324 station rows at z14 (all mode=tram, no NULL
geom/name/mode), partitions at every zoom z9–z14, decoded z14 tile
(9146/5394) carries 20 station features with name/mode/importance, real
browser render over CDP shows dots+labels at z12–z15 (z12: 229 dots; z11: 0 —
floor correct), local vision model confirms clean single rendering. make
build, go test ./... (importer, mapsrv) pass.

Docs: country-poland.md gains a Stations section; workstream-a.md gains the
GTFS row and drops the "only country without a stations partition" claim.
Review round (PR #60): cluster-comment attribution corrected, the metro
UNION-ALL note covers the staging route_types: too, the freshness signal
(Content-Length only) stated, and the commuter-rail deferral documented
everywhere (issue #63).

Closes #24 --- #24: Warszawa metro and tram stations — tram stops from the official ZTM GTFS Warsaw finally has a stations layer. The source is the official ZTM Warszawa GTFS (https://gtfs.ztm.waw.pl/last — the "always newest" feed on ZTM's own server, Apache-2.0 terms, no credentials; announced on the Dane rozkładowe page), fetched by the importer through a new `downloads:` entry like any other dataset — no curl, no manual download. The feed covers tram stops only. Its route types are 0 (tram, 208 routes), 2 (rail — WKD/KM/SKM suburban lines) and 3 (bus); ZTM's public GTFS export carries no metro routes, so Poland's `stations` partition has no metro rows and the style's M icon stays unused for now. That is the honest, official answer to the issue's metro ask: the M glyph exists and renders automatically for any `mode='metro'` feature, but there is no official metro stop source to feed it yet. The extension paths (City of Warsaw API metro-entrances WFS with a free key; the community-built mkuran.pl/gtfs/warsaw.zip) are documented in the config and docs, not imported. The commuter rail behind route type 2 is deliberately not imported either — a scope decision, tracked as issue #63. The feed's 245 rail routes are WKD/KM/SKM, reaching well beyond Warsaw across Mazovia, and the loader's per-type `agencies:` narrowing (the mechanism Sweden uses for pendeltåg) could scope them to SKM's S-lines without any style change — the J badge and the `station_icons` commuter filter already exist. The config, country-poland and workstream-a all record this now, so the "available but not imported yet" state is documented the same way metro's absence is. Implementation follows the Sweden pattern: a GTFS staging table (`pl_gtfs_stops`, `feature: stops`, route_types [0]) and a `stations` partition of the shared map_stations tables. Two Warsaw-specific wrinkles: - ZTM's stops.txt has no parent_station — every row is a pole (słupek), one per direction. The partition collapses same-named poles within 150 m (ST_ClusterDBSCAN, partitioned by name) to the cluster centroid: 596 served poles → 324 stop points. Without the collapse every przystanek would render twice with two labels. - `importance` is 1 for every stop (the tram rung, same as Sweden's), so Warsaw's stops appear from z12 — the same zoom Stockholm trams do. The repeated names in the output (Rondo ONZ, Okopowa, …) are genuinely separate przystanki on different streets, verified pairwise 280–600 m apart. No style change was needed: tram stops render as the established purple-ringed dot plus label (the style has badge icons only for metro M and commuter J — the issue's "do trams have a standard icon?" answer is that the map's convention is the dot, matching Stockholm). Verified against the live DB: 324 station rows at z14 (all mode=tram, no NULL geom/name/mode), partitions at every zoom z9–z14, decoded z14 tile (9146/5394) carries 20 station features with name/mode/importance, real browser render over CDP shows dots+labels at z12–z15 (z12: 229 dots; z11: 0 — floor correct), local vision model confirms clean single rendering. make build, go test ./... (importer, mapsrv) pass. Docs: country-poland.md gains a Stations section; workstream-a.md gains the GTFS row and drops the "only country without a stations partition" claim. Review round (PR #60): cluster-comment attribution corrected, the metro UNION-ALL note covers the staging `route_types:` too, the freshness signal (Content-Length only) stated, and the commuter-rail deferral documented everywhere (issue #63).
Poland's first `stations` partition (issue #24): Warsaw's tram stops from the
official ZTM Warszawa GTFS, fetched by the importer through a new
`downloads:` entry (https://gtfs.ztm.waw.pl/last — ZTM's own server, Apache
2.0 terms, no credentials; announced on their Dane rozkładowe page).

The feed is tram-only: its route types are 0 (tram, 208 routes), 2 (suburban
rail) and 3 (bus). ZTM's public GTFS export carries no metro routes, so there
is nothing official to import for the metro half of the issue — the style's M
glyph already renders any mode='metro' feature, and the extension paths
(City of Warsaw API metro-entrances WFS with a free key; the community
mkuran.pl feed) are documented in the config and docs rather than imported.

Implementation mirrors the Sweden pattern: a `pl_gtfs_stops` staging table
(`feature: stops`, route_types [0]) feeding a `stations` partition of the
shared map_stations tables. Two Warsaw-specific wrinkles:

- ZTM's stops.txt has no parent_station, so the loader emits one row per pole
  (słupek). The partition collapses same-named poles within 150 m
  (ST_ClusterDBSCAN, partitioned by name) to the cluster centroid: 596 served
  poles -> 324 stop points. Without it every przystanek would render twice
  with two labels.
- importance is 1 for every stop (the tram rung, same as Sweden's), so stops
  appear from z12 — the same zoom Stockholm trams do. Repeated names (Rondo
  ONZ, Okopowa, ...) are genuinely separate przystanki 280-600 m apart.

No style change needed: tram stops render as the established purple-ringed
dot plus label (badge icons exist only for metro M and commuter J).

Verified against the live DB: 324 rows at z14 (mode=tram, no NULL
geom/name/mode), partitions at every zoom z9-z14, decoded z14 tile
(9146/5394) carries 20 station features with name/mode/importance, real
browser render over CDP shows dots+labels z12-z15 (z12: 229 dots, z11: 0 —
floor correct). make build and go test ./... pass. Docs: country-poland.md
gains a Stations section; workstream-a.md gains the GTFS row.

Co-Authored-By: Hermes Agent <noreply@nousresearch.com>
Collaborator

Review — #60 (Warszawa stations, issue #24)

Builds clean, go vet and go test pass (only TestDumpGlyphPNG fails, which cannot
write /tmp/glyph_g.png in this sandbox and fails identically on every branch including
main).

A single commit on top of origin/main, config and docs only, no Go changes — and the
#51 review points were already folded in upstream (a104f61), so this one reviews clean.
Good to see it not stacked on the open generalization PRs.

I re-fetched the feed and every measurable claim in the body holds. From
https://gtfs.ztm.waw.pl/last today:

bytes: 46 804 032        (~47 MB, as documented)
Content-Length: 46804032 | Last-Modified: None
route_type counts: {'0': 208, '2': 245, '3': 2188}
  type 0 (tram):  208 routes  — 1, 2, 3, 4, 6, 7, 9, 10, 11, 14, ...
  type 2 (rail):  245 routes  — A1, A12, R1, R2, R3, R6, RE1, RE2, ...
  type 3 (bus):  2188 routes
M1/M2 routes found: []

208 tram routes exactly as stated, no route_type 1, no M1/M2 under any type, and no
frequencies.txt in the archive where a metro service could be hiding. "ZTM's public
GTFS export carries no metro routes" is correct
, and the absent Last-Modified header
is confirmed too. That is the honest answer to the issue's metro ask and it is the right
call to ship trams rather than reach for the community feed.

The DB agrees with the body as well: pl_gtfs_stops holds 596 poles across 260 distinct
names, collapsing to 324 station rows at z14 — and z12 324 / z11 0 / z9 0, so the
importance floor behaves. gtfsMode already maps basic 0 → tram and 1 → metro
(importer/gtfs.go:359), so the "the day an official feed carries metro stops" claim is
real and needs no code.

The same feed already carries commuter rail, and the J badge is waiting for it

This is the one substantive gap. The body treats metro as the only missing mode and
documents two extension paths for it, but says nothing about route_type 2 — the 245
suburban rail routes it names in passing as "WKD/KM/SKM". Those map to an existing mode
and an existing sprite with no style change at all:

station_icons | minzoom: 11 | filter: ["in", ["get","mode"], ["literal", ["metro","commuter"]]]
              | icon-image: ["get", "mode"]

commuter is already in that filter, the J sprite already exists, and Sweden already
populates it (se|commuter|3|30, se|commuter|2|25). So Warsaw's commuter stops are one
route_types: [0, 2] away plus a commuter branch in the query — the data is already
downloaded and staged.

It also fixes a gap this PR leaves behind. Poland's z9–z11 station levels are empty
(verified above), because every tram stop is importance 1. Sweden fills those levels with
metro and commuter. Once #57 lands and Warsaw's tram lines chain down to z9, Poland will
render transit lines at z9–z11 with no stations on them at all, where Stockholm shows both.
Commuter stops at importance 2/3 are exactly what fills that band.

One caveat that makes this a follow-up rather than a one-liner: 245 KM/RE routes reach well
beyond Warsaw across Mazovia, so importing them wholesale would scatter stations over a much
larger area than "Warszawa stations". The loader already supports the per-type agencies:
narrowing that Sweden uses for the same problem (importer/gtfs.go:166, 206), so the
mechanism exists — it just needs a decision about how far out the map wants to go.

At minimum the config comment and docs/workstream-a.md should say that commuter rail is
available in the feed and deliberately not imported yet, the same way metro's absence is
documented. Right now workstream-a's Poland work list mentions only that a metro source
"would light up the style's M icon" and does not mention that the J icon could be lit up
from a file already on disk.

Smaller notes

  • The cluster comment says the partition is "partitioned by name so two same-named groups
    in different towns can never merge". That attributes the guarantee to the wrong half of
    the expression: PARTITION BY name is what stops differently-named poles merging, and
    the 150 m eps is what keeps two same-named groups apart. Both are load-bearing and the
    behaviour is right — the sentence just explains the wrong one.
  • "this query gains a UNION ALL branch and the icons appear" understates it slightly: the
    staging entry's route_types: [0] would need the new type too, otherwise the rows never
    reach the query.
  • Freshness is Content-Length only here, since the server sends no Last-Modified (I
    confirmed that). "A new timetable re-downloads on the next import" is therefore true only
    when the byte count changes — effectively certain at 47 MB, but worth the half-sentence
    since the sidecar's usual two-signal contract is documented elsewhere.
  • WHERE mode = 'tram' in the served CTE is redundant given route_types: [0]. Harmless
    and arguably good defensive practice if the staging entry ever grows a type; no change
    needed, just noting it is not doing anything today.
  • Verifying "repeated names are genuinely separate przystanki 280–600 m apart" pairwise was
    the right check to run — that is the one thing the 150 m cluster could plausibly have got
    wrong, and it is good that it is recorded.
## Review — #60 (Warszawa stations, issue #24) Builds clean, `go vet` and `go test` pass (only `TestDumpGlyphPNG` fails, which cannot write `/tmp/glyph_g.png` in this sandbox and fails identically on every branch including `main`). A single commit on top of `origin/main`, config and docs only, no Go changes — and the #51 review points were already folded in upstream (`a104f61`), so this one reviews clean. Good to see it not stacked on the open generalization PRs. **I re-fetched the feed and every measurable claim in the body holds.** From `https://gtfs.ztm.waw.pl/last` today: ``` bytes: 46 804 032 (~47 MB, as documented) Content-Length: 46804032 | Last-Modified: None route_type counts: {'0': 208, '2': 245, '3': 2188} type 0 (tram): 208 routes — 1, 2, 3, 4, 6, 7, 9, 10, 11, 14, ... type 2 (rail): 245 routes — A1, A12, R1, R2, R3, R6, RE1, RE2, ... type 3 (bus): 2188 routes M1/M2 routes found: [] ``` 208 tram routes exactly as stated, no `route_type` 1, no M1/M2 under any type, and no `frequencies.txt` in the archive where a metro service could be hiding. **"ZTM's public GTFS export carries no metro routes" is correct**, and the absent `Last-Modified` header is confirmed too. That is the honest answer to the issue's metro ask and it is the right call to ship trams rather than reach for the community feed. The DB agrees with the body as well: `pl_gtfs_stops` holds 596 poles across 260 distinct names, collapsing to 324 station rows at z14 — and z12 324 / z11 0 / z9 0, so the importance floor behaves. `gtfsMode` already maps basic `0 → tram` and `1 → metro` (`importer/gtfs.go:359`), so the "the day an official feed carries metro stops" claim is real and needs no code. ### The same feed already carries commuter rail, and the J badge is waiting for it This is the one substantive gap. The body treats metro as the only missing mode and documents two extension paths for it, but says nothing about `route_type` 2 — the 245 suburban rail routes it names in passing as "WKD/KM/SKM". Those map to an existing mode and an existing sprite with **no style change at all**: ``` station_icons | minzoom: 11 | filter: ["in", ["get","mode"], ["literal", ["metro","commuter"]]] | icon-image: ["get", "mode"] ``` `commuter` is already in that filter, the J sprite already exists, and Sweden already populates it (`se|commuter|3|30`, `se|commuter|2|25`). So Warsaw's commuter stops are one `route_types: [0, 2]` away plus a `commuter` branch in the query — the data is already downloaded and staged. It also fixes a gap this PR leaves behind. Poland's z9–z11 station levels are empty (verified above), because every tram stop is importance 1. Sweden fills those levels with metro and commuter. Once #57 lands and Warsaw's tram *lines* chain down to z9, Poland will render transit lines at z9–z11 with no stations on them at all, where Stockholm shows both. Commuter stops at importance 2/3 are exactly what fills that band. One caveat that makes this a follow-up rather than a one-liner: 245 KM/RE routes reach well beyond Warsaw across Mazovia, so importing them wholesale would scatter stations over a much larger area than "Warszawa stations". The loader already supports the per-type `agencies:` narrowing that Sweden uses for the same problem (`importer/gtfs.go:166, 206`), so the mechanism exists — it just needs a decision about how far out the map wants to go. At minimum the config comment and `docs/workstream-a.md` should say that commuter rail is available in the feed and deliberately not imported yet, the same way metro's absence is documented. Right now workstream-a's Poland work list mentions only that a metro source "would light up the style's M icon" and does not mention that the J icon could be lit up from a file already on disk. ### Smaller notes - The cluster comment says the partition is "partitioned by name so two same-named groups in different towns can never merge". That attributes the guarantee to the wrong half of the expression: `PARTITION BY name` is what stops *differently*-named poles merging, and the 150 m eps is what keeps two same-named groups apart. Both are load-bearing and the behaviour is right — the sentence just explains the wrong one. - "this query gains a UNION ALL branch and the icons appear" understates it slightly: the staging entry's `route_types: [0]` would need the new type too, otherwise the rows never reach the query. - Freshness is Content-Length only here, since the server sends no `Last-Modified` (I confirmed that). "A new timetable re-downloads on the next import" is therefore true only when the byte count changes — effectively certain at 47 MB, but worth the half-sentence since the sidecar's usual two-signal contract is documented elsewhere. - `WHERE mode = 'tram'` in the `served` CTE is redundant given `route_types: [0]`. Harmless and arguably good defensive practice if the staging entry ever grows a type; no change needed, just noting it is not doing anything today. - Verifying "repeated names are genuinely separate przystanki 280–600 m apart" pairwise was the right check to run — that is the one thing the 150 m cluster could plausibly have got wrong, and it is good that it is recorded.
The review's substantive point: the ZTM feed's route type 2 (rail, 245 routes
of WKD/KM/SKM) is available and deliberately not imported yet. The config
comment, country-poland.md, workstream-a.md and the PR body now record that,
with the scope question filed as issue #63 (the loader's agencies: narrowing
can scope it to SKM's S-lines; the style's J badge is already in place).

The three smaller notes, all fixed in conf/plmap.yaml:
- the cluster comment now attributes the two halves of ST_ClusterDBSCAN
  correctly: PARTITION BY name stops differently-named poles merging, the
  150 m eps keeps same-named groups apart;
- the metro UNION-ALL note now covers the staging entry's route_types too;
- the freshness note states Content-Length is the sole signal (no
  Last-Modified), so a new timetable re-downloads only when the byte count
  changes.

Co-Authored-By: Hermes Agent <noreply@nousresearch.com>
Author
Owner

All review items addressed on the branch (ed067ca, pushed).

Commuter rail (the substantive gap) — not imported, but now documented as deliberately deferred, with the scope question filed as issue #63. The config's pl_gtfs_stops comment and stations comment, country-poland.md and workstream-a.md all record that the feed's route type 2 (245 routes of WKD/KM/SKM) is available, that the loader's agencies: narrowing can scope it to SKM's S-lines (agency_id 2 — the S1–S4/S40 routes filed under ZTM itself), and that the J badge + station_icons commuter filter already exist, so no style change would be needed. The PR body now says the same, replacing the "metro is the only missing mode" framing. Issue #63 carries the concrete implementation path for when the reach question is settled.

Smaller notes — all fixed in conf/plmap.yaml, or left as advised:

  • cluster comment: now attributes the two halves correctly — PARTITION BY name stops differently-named poles merging; the 150 m eps keeps same-named groups apart;
  • metro UNION-ALL note: now states the staging entry's route_types: must gain the new type too, or the rows never reach the query;
  • freshness: now states Content-Length is the sole signal (the server sends no Last-Modified), so a new timetable re-downloads only when the byte count changes;
  • WHERE mode = 'tram' in the served CTE: left as-is per your note — harmless defensive practice.

Config/docs only, no Go changes, so no reimport and the live tables are untouched.

All review items addressed on the branch (ed067ca, pushed). **Commuter rail (the substantive gap)** — not imported, but now documented as deliberately deferred, with the scope question filed as **issue #63**. The config's `pl_gtfs_stops` comment and `stations` comment, country-poland.md and workstream-a.md all record that the feed's route type 2 (245 routes of WKD/KM/SKM) is available, that the loader's `agencies:` narrowing can scope it to SKM's S-lines (agency_id 2 — the S1–S4/S40 routes filed under ZTM itself), and that the J badge + `station_icons` commuter filter already exist, so no style change would be needed. The PR body now says the same, replacing the "metro is the only missing mode" framing. Issue #63 carries the concrete implementation path for when the reach question is settled. **Smaller notes** — all fixed in conf/plmap.yaml, or left as advised: - cluster comment: now attributes the two halves correctly — `PARTITION BY name` stops differently-named poles merging; the 150 m eps keeps same-named groups apart; - metro UNION-ALL note: now states the staging entry's `route_types:` must gain the new type too, or the rows never reach the query; - freshness: now states Content-Length is the sole signal (the server sends no Last-Modified), so a new timetable re-downloads only when the byte count changes; - `WHERE mode = 'tram'` in the `served` CTE: left as-is per your note — harmless defensive practice. Config/docs only, no Go changes, so no reimport and the live tables are untouched.
art merged commit b2a4bc650f into main 2026-08-05 10:55:41 +00:00
art deleted branch night/issue-24-warsaw-stations 2026-08-05 10:55:41 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
art/ismap!60
No description provided.