Build a Czech tram city - the six Czech T1 cities (Brno, Ostrava, Plzeň, Olomouc, Liberec with Jablonec, Most with Litvínov) - on the national chain Prague built - ROS02 establishments, RES activity and the RÚIAN join, with their traps; the per-city RÚIAN coordinate control; the two-obec change a regional city needs; the tram leg (Brno's stops from KORDIS's CC BY feed, everything else from OpenStreetMap through the shared osm_tram module); the scope calls, halved rings, UTM per city (Ostrava 34N), the notices, the page-text template and a sheet per city. Use for any Czech city after Prague, with the city's brief; read with add-city, osm-rail and publish-city, which it does not replace.
Install
npx skillscat add dacekroberts/expanded-heatmap/czech-tram-city Install via the SkillsCat registry.
Building a Czech tram city
Written 2026-09-30 by the Czech kit session, from Prague's build (DECISIONS.md
2026-09-24, the week of 2026-09-20), the six briefs staging wrote on
2026-09-30, and live reads that day (the KORDIS feed, the screen's OSM caches,brief_check.py on all six: 23 of 23 claims hold). Czechia is Mexico's
shape, like France: one national register chain, one address file per obec,
one national licence family. So a Czech city costs its tram leg, a scope call
and its notices. The register and taxonomy are shared code already.
The batch is the six Czech T1 cities in docs/tram_city_list.md: Brno,
Ostrava, Plzeň, Olomouc, Liberec (with Jablonec nad Nisou) and Most (with
Litvínov). Prague is built and draws its metro only.
Every call is approved as recommended, the page text and notices word for
word (owner, 2026-09-30: DECISIONS "The Czech batch's calls and prose
approved as recommended"). Builds wait for the owner's explicit go, which
approving the calls did not give. When it comes, build on a branch, never on
master: app/ lands at review time only.
Order of work for one city
python scripts/brief_check.py <slug>, and with--vs-configonce the
config exists. A failing claim is a brief to correct, and the briefs are
staging's: ask staging, do not edit them from a build.- Read
docs/build_briefs/<slug>.mdand this skill's sheet for the city
(the last section). The calls in both were put to the owner together. python scripts/scaffold_city.py --slug <slug> --name "<Name>" --system-name "<operator> trams" --taxonomy czech_nace2025 --lat <lat> --lon <lon> --region Czechia --country Czechia --mode tram --dry-run,
then without--dry-runonce the owner has said go. Pass the city's
own longitude: the script derives the UTM zone from it, which is what
makes Ostrava 34N.--modeis required.- Replace the generated
config.pywith the Czech template (section 8) and
fill it from the sheet. Replace the generated step 3's GTFS line loader
withload_osm_line_shapes(every Czech tram city draws its geometry from
OSM, Brno included). - Write step 2 as Prague's (41 lines, a call to
build_storefronts), step 1
onpipeline/osm_tram.py(section 3), andfetch_sources.py(section 3).
Run the steps and render. - Fill the page
scaffold_city.pywrote (already in the city-page format)
from section 7, and write the city's sections ofdocs/excluded_categories.mdanddocs/data_sources/czechia.md(section
6). The format for all three isdocs/city_page_format.md; never start a
page by copying an older one. Then run "Before publishing".
No batch scaffold script (DECISIONS "The Czech batch kit": measured, it
does not pay for six cities). Everything a script would fill is on the
sheets below.
What already exists - reuse it, do not rewrite it
| Module | What it does |
|---|---|
pipeline/countries/czechia.py |
The national facts: ROS02, RES and CZ-NACE URLs, the SHARED cache (data/czechia/raw/: ROS02 58.5 MB, RES 543 MB, the five CZ-NACE 2025 codebooks), RUIAN_ATOM_TEMPLATE, the legal-form codes, the RÚIAN column names, CRS and encoding. Imported by every Czech config.py, so it stays lean-venv importable |
pipeline/countries/czechia_register.py |
THE step 2: build_storefronts(cfg, bbox). ROS02 -> RÚIAN -> RES -> CZ-NACE 2025 -> storefronts. Imports pandas and pyproj, so only step files import it |
pipeline/taxonomies/czech_nace2025.py |
CZ-NACE 2025 on prefix at ragged depth; the structural exclusions (479, 5612, 562, 564, 964, 9691). It deliberately does not decide the catch-alls |
pipeline/prague/ |
The built Czech city. Its step 1 is the model for Brno's stations (parent_station), its fetch_sources.py for the national files and the ATOM resolve, its boundary.py for polygonising an OSM relation with an area gate |
pipeline/aarhus/step1_stations.py |
The built OSM tram step 1 (stop_rows()), until pipeline/osm_tram.py exists |
pipeline/map_common.py load_osm_line_shapes |
Draws a line from OSM route relations by ref, one alignment per line (the relation with the most geometry) |
scripts/line_colour_search.py |
Chooses a palette where the operator's colours are unusable: 3:1 on both map pages, CIE76 45 or more from every pin, lines near each other kept apart |
The owner's standing calls - do not re-ask
- Trams-only maps are approved (DECISIONS "Yes to trams-only maps",
2026-09-29): with no metro, the trams are the rapid transit (Riga's
precedent), and every tram stop gets rings. No stop thinning. - Ring size by the spacing rule (section 5): halved at a median stop gap
of about 550 m or less. All six measured 294 to 514 m. - The natural-person rule (owner, Prague 2026-09-24): an establishment of
a natural person at their own registered seat is excluded, and the pin
of every other natural person (forms 101, 105, 107, 424, 425) or v.o.s.
partnership (111) shows its address, never the name. - The catch-all rule (owner, 2026-09-29):
CATCH_ALL_EXCLUDE = ("96990", "969")in every Czech city ("other personal services", exact codes, the
bare group included because RES is ragged). 96230 (day spas, saunas) is a
specific class and stays. - The currency rule: ROS02 drops closed establishments (active is judged
byDATUKON), its snapshot (DATPLAT, 2026-08-31 today) is well under five
years old, and the snapshot date goes on the page. - Brno's feed is fetched from
kordis-jmk.czunder KORDIS's own CC BY 4.0
grant (owner, 2026-09-30), never from data.brno.cz or the stalecontent.idsjmk.czcopy. - Olomouc's trams come from OSM (owner, 2026-09-30): DPMO's feed declares
no licence, and its colours are all white. - Every Czech city tags
"region": "Czechia"(owner, 2026-09-30:
DECISIONS "Czechia is its own macro region, Prague in it"). - Buses, trolleybuses and trains are never drawn. Brno's S-trains fail the
spacing rule (11 stations at a 1,974 m median gap; the tram audit,
2026-09-29). - Approved with the kit (owner, 2026-09-30, "all recommended approaches to
the calls", "and prose"):- the five OSM cities' colours are the project's own, from
scripts/line_colour_search.pywith target hues spaced evenly; - Ostrava's line 5 is left out (3 of 10 stops in the city);
- Liberec is "Liberec (Regional)", with Jablonec nad Nisou;
- Most is built, last, as "Most (Regional)", covering Litvínov;
- Brno's H4 and P1 are left out;
modetramandcoveragefullfor all six;- OSM stops collapse by Aarhus's rule (section 3), Brno's stations are
the feed's parents; - the build order Brno, Plzeň, Olomouc, Ostrava, Liberec, Most;
- the page text (section 7) and the notices (section 6), word for word.
- the five OSM cities' colours are the project's own, from
1. The register chain - czechia_register.py, with its traps
Prague is the control: 25,275 storefronts from 84,192 active establishments
(2026-09-24). Change nothing in the module without re-running Prague's step 2
and drift_check.py prague.
- ROS02 is the premises register; RES is not. ROS02 records WHERE each
establishment (ICP) trades (PKODADM, a RÚIAN address code). RES records
the owner's registered SEAT, which is why Prague paused on RES alone. The
location always comes from ROS02. - Rows repeat: dedupe on
ICP. - Active is judged against the file's own
DATPLAT, never today, so a
drift check next month gives this month's answer. - 6.6% of active establishments nationally carry no
PKODADMand cannot
be placed. They fall out at the obec filter, silently. Step 2 prints the
in-obec count; publish its figures, not the brief's. - The activity is the OWNER's single CZ-NACE 2025 code (
NACE2025, never
the olderNACEcolumn), inherited by every establishment it holds: a
chain's office counts as the chain's trade. The page says so. KATPOis never read: its000means "not stated", so it cannot be an
employee filter.- RÚIAN is EPSG:5513 with the published (X, Y). The other axis orders land
in Germany or the Arctic and still look like coordinates, which is why every
city declares a control (section 2). An address with no coordinates
transforms to INFINITY, not NaN;ruian()makes it missing first. The file
is cp1250 with;. - RÚIAN comes through ČÚZK's ATOM service, which names the current
month's file. It sidesteps the VDP application's ban on automated
extraction. Never hard-code avymenny_formatpath: its date goes stale on
the 1st. - ⛔ Never RŽP (the Trade Register) through ARES, though it carries an
establishment type. It is not open data, and ÚOOÚ fined a site in 2019 for
republishing sole traders' trade data from it (UOOU-10201/18-31). - The national files are shared. ROS02, RES and the codebooks live once in
the main checkout'sdata/czechia/raw/, behind every worktree'sdata/
junction. Never refresh them from a city branch: a new edition moves
Prague too. A refresh is its own job, announced, with Prague re-run. - Step 2 reads the 543 MB RES file in 500,000-row chunks, keeping only
the city's owners: a measured 0.37 GB peak (Prague, 2026-09-30), so it
is not a heavy job under the owner's margin rule. Still run Czech step 2s
one at a time, since they share one disk read.
The screen's figures (2026-09-27, business_leg.py): placement 100% in every
city, and the restaurant control (NACE 5611 against OSM's restaurants) at 1.61
to 3.71 times. Prague reads 1.59 to 1.63. A high ratio here is OSM's gap, not
the register's, so the page says so (section 7).
2. The RÚIAN control - one per obec file, or ruian() stops
czechia_register.ruian() exits unless the config declaresRUIAN_CRS_CONTROL = (RÚIAN code, lat, lon, label): one known address in the
obec, whose expected position comes from a source other than this file
(the building's published coordinates, or OSM's node for it). It must come out
within 0.001° on both axes. Prague's is the castle. It was a module constant
until 2026-09-27, which would have stopped every other Czech town.
All eight obce files have one, measured by staging on 2026-09-30 (the sheets),
Jablonec's (563510) and Litvínov's (567256) included. For a new obec: pick the
town hall, take OSM's node for it through a bbox-bounded query, and find its
RÚIAN code in the obec file.
Two obce: the one change to shared code
Liberec (Regional) and Most (Regional) each span two obce, and the module
reads one (cfg.OBEC, cfg.RUIAN_ZIP, cfg.RUIAN_CRS_CONTROL). Extend it
once, in czechia_register.py, never a fork (Monterrey's and
Kitchener–Waterloo's regional precedent):
- the config declares
OBEC_CODES(a list, one code for a single-obec city)
andRUIAN_CRS_CONTROLS = {obec: (code, lat, lon, label)}, with one file
per obec atDATA_RAW / f"ruian_adr_{obec}.csv.zip"; ruian()reads each file, runs that file's control, and concatenates.
Address codes are national, so assert the index stays unique;- step 2 prints and emits the placed count per obec, for the page;
- a config with only
OBEC(Prague) takes the old path unchanged.
Done 2026-09-30, on czech-build, with RES read in 500,000-row chunks of
the city's own owners. The control was Prague's step 2, run from the branch
into the scratchpad (never into the shared data/prague/processed/):
byte-identical, every baseline count equal, peak 0.37 GB. So a Czech
step 2 is not a heavy job; announce it as a 0.5 GB one, one at a time.
3. The tram leg
Brno: stops from KORDIS's feed, lines from OSM
- Feed:
https://kordis-jmk.cz/gtfs/gtfs.zip, about 9 MB, republished
weekly on Sunday. Nofeed_info.txtand noshapes.txt. Read
2026-09-30: calendar 2026-09-25 to 2026-12-13, calendar_dates to
2026-12-10. - Route types: 299 bus, 28 rail, 14 trolleybus (800), 13 tram (0), 1
ferry. Selectroute_type 0ANDroute_short_namein the 11 regular lines
(1-10, 12). H4 (heritage) and P1 (the Arena Brno event shuttle, sharing line
1's colour) run no weekday trips; both are out (owner, 2026-09-30). - Stations are the feed's own
parent_stationrows, Prague's shape. All
329 platforms the 11 lines serve have a parent (2026-09-30): 149 parents, no
two sharing a name. Take the parent's id, name and coordinates; no name
collapse and no mean. Exit if a platform has no parent or two parents share
a name, as Prague's step 1 does. - ⚠️ Request stops are stops. KORDIS codes every request stop (na
znamení)pickup_type/drop_off_type3, which GTFS counts as
boardable.boardable_stop_ids' old default accepted only 0 and dropped 25
of 149 stations as "non-revenue"; the default is("0", "2", "3")sinced928f50(owner, 2026-09-30), so a new step needs no argument. - ⚠️ The feed carries 2.5 months of timetable, so a line's union of trips
is too wide (line 4: 54 stations against a 24-stop route). A line serves a
station when it calls there on at least 10% of its trips in one
direction (STOP_MIN_SHARE; Riga's threshold, per station, not per
exact pattern). Measured: it drops only the Vozovna Medlánky depot. Built:
148 stations, 146 in the city, 336 m median. - The spacing gate's floor is 200 m for trams (
SPACING_MIN_M, Riga's
and Aarhus's), never the shared 400 m metro default. - Colours are the feed's
route_color(DECISIONS 2026-09-30). All 11 passcheck_line_coloursas published; line 6's0777C1is closest to the pins,
at 13.2 from Retail, recorded rather than moved (Prague's line C precedent). - Geometry from OSM:
load_osm_line_shapesbyref. OSM carries all 11
refs, operator "Dopravní podnik města Brna", 26 relations, and even the
feed's own colours incolour. Useosm_tram's lines-only mode to
validate the relations (every ref present, every relation kept or placed). - Gate 3: OSM's route relations' stop names per line, which are
independent of the feed. Built: 9 of 11 lines agree; lines 1 and 10 run
variants on 12-21% of trips (line 1 via Tábor, line 10 to Technologický
park and Bystrc) that OSM's relations lack. The feed is the operator's, so
this is recorded, not "fixed". - The feed self-attests nothing, so
fetch_sources.pyrecords the
calendar window (the earlieststart_dateand latestend_dateacrosscalendar.txtandcalendar_dates.txt) inprovenance.json, prints it,
and refuses an expired feed.
The other five: OpenStreetMap, through pipeline/osm_tram.py
Agreed with the tram kit session on 2026-09-30: one country-neutral module,pipeline/osm_tram.py, written by whichever OSM tram build goes first (Czech
or the tram kit's), from Aarhus's stop_rows(). Its control is Aarhus's
inputs reproducing Aarhus's stations.csv; Aarhus itself is not rewired. It
is shared pipeline code, so tell the app/chrome role before writing it.
If it exists, use it. Its contract:
- Relations by route AND operator AND ref, one Overpass query per city
over a bbox (osm-rail). Never a node tag search. - Every relation in the box is kept or placed in
NOT_DRAWNby relation
id with a reason; the step exits on one that is neither. Ostrava's 9 and
19 (no stop members), its unref'd line 11 variant (19177807) and Plzeň's
1X, 4X and depot run (no stops) are placed, not dropped silently. - Several relations per ref are normal (Ostrava's line 8 has four):
a line's stops are the union of its kept relations; the drawing takes the
one with the most geometry. - Stop members are
public_transport=stop_positionorrailway=tram_stop; exit only on a member that is untagged or unnamed,
naming it.STATION_ADDby node id covers a stop on no relation. - Stations are collapsed by exact name at the mean of their stop
positions within 200 m, exiting when a name spreads wider (Aarhus). This
is the OSM rule. France's never-a-mean rule came from the French portal's
ODbL conditions and does not reach OSM or Czechia. - Scope over a union of polygons (two obce), stations outside named with
the obec or town they lie in. - The operator filter is optional for the tram kit's cities. Czech
relations all carry one (the sheets), so set it.
It exists now (the tram kit's, on tram-build; taken unchanged ontoczech-build, its Aarhus control passing). The Czech wrapper ispipeline/countries/czechia_osm_tram.py: a city's step 1 isstep1(config) and its step 3 step3(config, system_name). Step 1 writes
each kept ref's relation with the most track to lines.geojson, which step 3
draws, so the drawn lines are exactly the kept ones. What the five taught:
- An unjudged relation stops the step, and that is the point. Plzeň's two
line 4 directions carry no operator tag in OSM: named inNOT_DRAWN,
their reverse directions kept, and the excluded list shows whether a stop
was lost (none was). - One station under two names: OSM names a stop's stands apart
(Ostrava's Hranečník (St. 1) / (St. 5), 120 m apart; Nová Huť hlavní brána
1 / 2, 61 m). The station gate's close-pair note finds them; fold each withSTATION_NAME_ALIASESinto the spelling more lines carry. - An interchange on two arms of a junction can spread past 200 m
(Ostrava's Sport Aréna 321 m, Mariánské náměstí 223 m). Raise that city'sCOLLAPSE_MAX_SPREAD_Mon the measurement (Ostrava 330), inside Riga's 300
and Osaka's 400, and record the next widest name. - ⚠️ Count a feed by SERVICE DAY, never over the whole file. Plzeň's gate 3
first counted PMDP's trips across its six-month calendar and "found" two
centre stops OSM lacks, Jízdecká and U Synagogy. Both run on one day
only (service 44: no weekday flags, 2026-10-10 bycalendar_dates), a
diversion. On an ordinary Wednesday at the 10% rule, line 2 matches OSM
exactly, and lines 1 and 4 differ only by the depot. ASTATION_ADDtaken
from a whole-feed count would have drawn rings round a one-day diversion.
Where no feed can be read, gate 3 comes from IDOS (below), never a silent
pass. - A line's relation can pass a stop it calls at, while another line's
relation lists that stop (Liberec's 11 at Fügnerova and Sídliště Nové
Vratislavice).STATION_ADDrefuses a node already on a route (still so
after tram-build's 2aeed892 made its name check per line), so useLINE_STOP_ADD{node: (line or tuple, name)}, read byczechia_osm_tram.add_line_stops. It stops the build when the line's own
track is more than 25 m from the node, or when OSM fills the gap. - A stop on no relation is still in the cache:
fetch_sources.py's tram
query also fetches everyrailway=tram_stopnode in the box. A cache
fetched without that clause lacks it (Most's Vrchlického, 2026-09-30), so
re-fetch before concluding a stop is not in OSM. - Map the REGULAR network through temporary works (owner, 2026-09-30;
Prague's Flora is the precedent). Most's Litvínov section, Liberec's 2 and
3 to Šaldovo náměstí and Ostrava's diversion all keep their OSM track. A
stop closed for the works is added from OSM, and a dated sentence for the
page is drafted for review time.
Line colours: OSM tags none in the five cities, and no operator's colours
are licensed. Use the project's own palette (Riga's precedent, and Le Havre's
approved call): scripts/line_colour_search.py <slug> with the config'sLINES[key]["hue"] targets spaced evenly around the hue wheel in line order,
and cite the run in the config. It needs step 1's lines.geojson, so run it
after step 1. Never take colours from an operator's website or network plan.
Gate 3 for an OSM city needs a count OSM did not make: the operator's
published stop list per line (a count, as Prague used Wikipedia's), or, for
Plzeň, PMDP's feed (read 2026-09-30, permitted; lines 1, 2 and 4 asroute_type 0, no colours, no shapes; https://jizdnirady.pmdp.cz/jr/gtfs,
GET only). Name the source in OPERATOR_COUNTS_SOURCE.
Where the operators publish only PDFs, read IDOS's per-stop timetables
(2026-09-30). These are HTML, nothing is downloaded, and dpmost.cz links them
as its own:https://idos.cz/<city>/zjr/vysledky/?date=DD.MM.YYYY&l=Tram%20N&f=<from>&t=<to>&ttn=<City>.
- Read both directions on a regular weekday that is clear of works, and
count a stop when either direction calls. - Some sections need one ordinary search on the form first.
- IDOS holds only its timetable period, and Ostrava's only from 2026-09-21.
A date inside long works cannot check the regular network, so record the
check as partial and re-check it after the works (Ostrava, about
2026-12-13). - Results so far: Olomouc is exact as built, Most after one add, and Liberec
after three.
fetch_sources.py, per city
Thin over pipeline/countries/czechia_fetch.py (built 2026-09-30, imported
only by fetch scripts), with pipeline/countries/czechia_boundary.py reading
the boundary for the steps. On Prague's, with these differences:
- Feeds are rolling: always download, even when a copy exists. That covers
Brno's GTFS and every city's OSM query. Prague's keeps a cached copy unless--force; do not copy that for a feed. - The national files are only checked, never refetched from a city branch
(section 1). Record the ROS02 snapshot (DATPLAT) inprovenance.json, as
Prague does, for the page. - RÚIAN: resolve each obec's file through ATOM on every run and record its
name (it carries the edition date). - The OSM boundary:
rel(<id>);out geom;for each obec's relation (the
sheets). Gate it onrefending in the obec code (OSM's obecrefis the
district code plus the RÚIAN obec code: Brno's isCZ0642582786) and on
area, as Prague'sboundary.pygates on ČÚZK's area. - The screen's 2026-09-27 OSM caches (
data/_staging_scratch_2026-09-27/ second_cities/czechia/osm_<city>_*.json) are evidence, never build input.
4. Scope - the obec, or two
The businesses are scoped by RÚIAN's own address list for the obec
(ROS02's PKODADM in it), never by a polygon. The OSM polygon scopes stations
and anchors labels only.
- One obec: Brno, Ostrava, Plzeň, Olomouc. Brno's line 2 keeps 36 of 38
stop names (its Modřice branch leaves the city); every other line in the
four stays whole, apart from Ostrava's line 5 (left out, owner, 2026-09-30).
Step 1 assertsEXPECTED_INSIDE_PER_LINE; a change is a decision re-taken. - Two obce, "(Regional)" in the display name, as Lille's: Liberec with
Jablonec nad Nisou (line 11 is 14 + 7), and Most with Litvínov. Most alone
fails the stub test (its lines keep 12 of 24, 6 of 18 and 9 of 21), so the
joint scope is not optional there. The slug stays the core city's (liberec,most). - The stub rule the scope follows: Toulouse's 52% stayed; the stub
precedents are Minneapolis and Pittsburgh at 42% (owner). Ostrava's line 5 at
3 of 10 (30%) is below them all.
5. Rings, and the CRS per city
Halved rings for all six: edges [0.0, 0.05, 0.1, 0.2, 0.3] mi, labels0-0.05 mi to 0.2-0.3 mi, because every city's median stop gap is under
about 550 m (the briefs, 2026-09-30: Plzeň 294, Olomouc 318, Brno 329, Liberec
with Jablonec 378, Ostrava 424, Most with Litvínov 514). Step 1 prints the
in-scope median; the config comment records it; scripts/ring_rules_table.py --write regenerates docs/ring_rules.md after the city lands. Most is 36 m
from the line: if its build-day median is over 550, keep the standard edges
and drop the page's half-size sentence.
UTM per city, never copied: EPSG:32633 (33N, 12-18° E) for Brno, Plzeň,
Olomouc, Liberec and Most; EPSG:32634 (34N) for Ostrava, at 18.29° E. Never
EPSG:4326 for distance, and never RÚIAN's S-JTSK for this project's geometry:
it is converted on read.
6. Notices
The page's notices come from app/components.py _NOTICES, one per
database. Wording is the owner's; draft any new one in chat first.
| Source | Cities | What it needs |
|---|---|---|
| RES (ČSÚ), CC BY 4.0 with ČSÚ's data conditions | all six | The existing "Czech Statistical Office (Prague)" notice. Its text names no city, so add each city to the title, as Norway's "(Oslo, Bergen)" and Denmark's "(Copenhagen, Aarhus)" do. It discharges ČSÚ's two duties: link the conditions, and mark the data as derived, not official statistics |
| RÚIAN (ČÚZK), CC BY 4.0, "ČÚZK, " prescribed | all six | The existing "ČÚZK (Prague)" notice, the title extended the same way. The year is the file's |
| ROS02 (DIA) | all six | Declared open data, no personal data, no database right: nothing to display (read 2026-09-24). Name it in the page text |
| KORDIS JMK's GTFS, CC BY 4.0 (read 2026-09-30) | Brno | A new notice: credit KORDIS JMK, a.s. and DPMB (the feed's agency.txt reads "IDS JMK (Data from: KORDIS JMK, DPMB)", which CC BY §3(a)(1)(A) says to retain); name data.brno.cz (Statutární město Brno) as the distributor; CC BY 4.0 linked; a link to the feed; the changes. No endorsement, no IDS JMK, KORDIS or DPMB logos. The wording, approved (owner, 2026-09-30), is below the table |
| PMDP's GTFS, if used as Plzeň's gate 3 | Plzeň | Credit it anyway, on the stricter CC BY reading: "Plzeňské městské dopravní podniky, a.s. (PMDP), GTFS published by the Statutory City of Plzeň at opendata.plzen.eu, CC BY 4.0, modified by this project". Never the city's arms or PMDP's logo |
| OpenStreetMap, ODbL 1.0 | all six | © OpenStreetMap contributors on the render, and a clause in the "OpenStreetMap (rail geometry)" notice for each city: its tram lines (and, except Brno, their stops) and the obec boundaries used to select them |
Brno's notice, approved word for word (owner, 2026-09-30), titled
"KORDIS JMK (Brno)":
Tram stops for Brno come from the IDS JMK timetable data (GTFS) published by
KORDIS JMK, a.s., with data from KORDIS JMK and DPMB, and distributed by the
Statutory City of Brno at data.brno.cz, under
CC BY 4.0
(feed). Changes: the 11 regular tram
lines are selected, each stop is reduced to one point, and distance rings are
computed around it. Not produced or endorsed by KORDIS JMK, DPMB or the City
of Brno.
The existing notices (approved the same day): the ČSÚ and ČÚZK titles add
each Czech city as it lands ("Czech Statistical Office (Prague, Brno)", and so
on), their text unchanged; each city adds its clause to "OpenStreetMap (rail
geometry)".
Also, for each city: a row per new source in docs/data_sources/czechia.md
(the feed or the OSM tram relations, the RÚIAN file, the boundary) before the
page exists; the excluded categories in docs/excluded_categories.md, under
a heading that names the city (### Brno - Prague's register, and trams as the rapid transit is the shape), with the city's own gaps and limits in that
section; and check_scope_disclosure.py passing. Write both for the reader:
the data, its terms, its notices and what is counted. How a source was found
or checked stays out, or goes between <!-- internal --> markers
(docs/city_page_format.md, sections 2 and 3).
7. Page text - approved by the owner, word for word (2026-09-30)
The page scaffold_city.py writes is already in the city-page format
(docs/city_page_format.md, section 1): render_city_title("{City}") (the
name alone, over the shared subtitle; the old "{City}: commercial density
around tram stops" title is gone), the map, the caption, the bullets below
under bold headings, then render_map_help, render_excluded_stations,render_country_links and render_site_notices last. The wording is the
template as the six built pages carry it after the 2026-10-01 prose pass
(app/pages/150_Brno_Heatmap.py to 155_Most_Heatmap.py). Braces are
per-city facts, filled from this city's own step 1, step 2 and rendered map,
never from a brief. Brackets choose a variant. A sentence from this template
needs no read-back. Departures are owner calls, never edits: a sentence
the template does not cover is a proposal, flagged in the session's drafts
file (docs/city_page_format.md, section 6).
Caption, directly under the map (from provenance.json): "Snapshot:
establishments as of {DATPLAT} (ROS02); tram timetable data valid
{start} to {end} (KORDIS JMK)." for Brno; for the OSM cities, the
second half reads "tram lines and stops from OpenStreetMap, fetched
{date}". Dates are ISO.
The trams
- {N} tram lines are drawn, {operator}'s trams {lines}, each labeled on
the map and in the legend.- [Brno:] The stops come from the IDS JMK timetable data published by
KORDIS JMK, and the lines are shown in the operator's colors; their routes
are drawn from OpenStreetMap, because the timetable data carries none.
[The other five:] The lines and stops are drawn from OpenStreetMap, and
the colors are this project's, because none are published for reuse.- Buses, trolleybuses and trains are not drawn{Brno: ", nor the heritage
tram H4 or the event shuttle P1"}.- {City} has no metro, so its trams are its rapid transit, as in Riga.
Every tram stop here gets rings.- Tram stops sit closer together than metro stations, a median of
{spacing} m here, so the rings are drawn at half the usual size (0.05 to
0.3 mi).- The map covers the {city of X / cities of Liberec and Jablonec nad
Nisou, which tram line 11 joins / towns of Most and Litvínov, which the
trams join}. {Brno: "Line 2's last two stops, in Modřice beyond the
city boundary, are left out." / Ostrava: "Line 5, the suburban line to
Budišovice, is not drawn: only 3 of its 10 stops are in the city."}The businesses
- Businesses come from the Czech register of active business
establishments (ROS02), which records each place a business operates at
that place's own address. The national address register (RÚIAN) puts each
one on the map; Prague's map uses the same two sources.- What each establishment does comes from the Czech Statistical Office's
business register (RES). RES records one main activity per business, so
every establishment inherits its owner's, and a chain's office or
warehouse counts as the chain's trade.- Businesses whose main activity is something else, such as a brewery's pub
or a wholesaler's shop, are not shown, because no open source records
what each establishment itself does.- Where a business belongs to a person trading in their own name, or to a
partnership, the map shows its address instead of its name. Where such
an establishment is at the owner's own registered address, which is
usually their home, it is left off the map altogether.- About {share}% of storefronts sit within a ring.
Reading the density
- Read the density as a register, not a street survey. Some
establishments are newly registered and may not have opened yet.- Czechia's classification files a web shop under the goods it sells, so
some dots are businesses with no shop a passer-by could walk into.- Against OpenStreetMap's mapped restaurants, cafés and takeaways in the
{city / two cities / two towns}, the register carries about {ratio, to
one decimal} times as many. {Where the ratio is over 2 (Ostrava,
Liberec and Most as built): "OpenStreetMap maps fewer restaurants here
than in Prague, so the ratio says as much about OpenStreetMap's gaps as
about the register."}
Then render_map_help("three business categories (Retail, Food service and Personal services)"), the same on every page: no controls paragraph or heat
caveat of the city's own. Stations left out (Brno's Modřice stops) are listed
on the page by render_excluded_stations; never a repository path, script or
check name in page text.
A city whose build-day median stop gap is over about 550 m (only Most is
close) keeps the standard rings and drops the ring bullet.
8. The config template
Paste over scaffold_city.py's generic config, keeping its path block, and
fill the braces from the sheet. Each value carries a comment saying where it
came from, as Prague's does.
from pipeline.countries import czechia as CZ # noqa: F401 (the national facts)
SLUG = "{slug}"
# ... scaffold_city's path block, plus:
PROVENANCE_JSON = OUTPUTS / "provenance.json"
OSM_BOUNDARY_JSON = DATA_RAW / "osm_boundary.json"
OSM_TRAM_JSON = DATA_RAW / "osm_tram.json"
LINES_GEOJSON = DATA_PROCESSED / "lines.geojson" # for line_colour_search.py
# --- Scope ---
SCOPE = "{obec|regional}"
OBEC_CODES = [{obec codes}]
RUIAN_CRS_CONTROLS = {{obec}: ({code}, {lat}, {lon}, "{label}")} # per file
OSM_BOUNDARY_RELATIONS = {{obec}: {relation id}} # gated on ref and area
BOUNDARY_AREA_KM2 = ({lo}, {hi}) # the union, against ČÚZK's area
CRS_GEOGRAPHIC = "EPSG:4326"
CRS_PROJECTED = "{EPSG:32633 | EPSG:32634 for Ostrava}"
# --- Rings: halved on the spacing rule; the measured median in the comment ---
RING_EDGES_MILES = [0.0, 0.05, 0.1, 0.2, 0.3]
RING_LABELS = ["0-0.05 mi", "0.05-0.1 mi", "0.1-0.2 mi", "0.2-0.3 mi"]
# --- Trams ---
TRAM_SOURCE = "{kordis_gtfs|osm}"
OSM_TRAM_BBOX = ({s}, {w}, {n}, {e}) # the brief's check box
OSM_TRAM_OPERATOR = "{operator, exactly as OSM tags it}"
LINE_ORDER = [...]
LINE_NAMES = {k: f"Tram {k}" for k in LINE_ORDER}
LINES = {k: {"hue": "#..."} for k in LINE_ORDER} # OSM cities: target hues
LINE_COLOURS = {...} # the feed's (Brno) or line_colour_search.py's, cited
NOT_DRAWN = {relation_id: "reason"} # every relation not kept
EXPECTED_INSIDE_PER_LINE = {...} # step 1 asserts it
OPERATOR_STATION_COUNTS = {...}; OPERATOR_COUNTS_SOURCE = "..." # gate 3
# --- Businesses ---
TAXONOMY_SYSTEM = "czech_nace2025"
RAW_CLASSIFICATION_COLUMN = "nace2025_label"
CATCH_ALL_EXCLUDE = ("96990", "969") # (owner, 2026-09-29) R2
{CITY}_BBOX = {...} # the placed storefronts' extent plus ~0.02 deg
# --- The macro map ---
MAP_MODE = "tram"
MAP_COVERAGE = "full"brief_check.py <slug> --vs-config diffs OBEC_CODES, CRS_PROJECTED,SCOPE, MAP_MODE and MAP_COVERAGE against the brief: each brief's CRS
check carries the mappings (staging, 2026-09-30). SCOPE is "obec" or"regional", and OBEC_CODES is a list of strings, as the briefs have them.
9. The macro map and the app entry
- Two keys (the macro-legend branch, approved):
modeis the dot colour,coveragethe fill. All six aremode"tram"andcoverage"full"
(ROS02 and RES carry all three buckets), as the briefs propose. - The
app/cities.pyentry follows Prague's:placement"Joined by address
({share})",data_age"As of {DATPLAT}",record_kind"National register",categories"All three",country"Czechia". The label width is measured
in a real browser forcheck_macro_labels.py. - Landing: in groups, one sitting each, per
docs/review_time.md, with onecity-addeddeploy-verify per group. Approvals andapp/landings wait for
review time. Landingapp/cities.pymeans rebooting the deployed app.
Before publishing (each city)
python scripts/check_personal_exposure.py <slug>, after adding the city to
its table in Prague's shape (processed="businesses_clean.csv",address=("business_name",): the structural guarantee by legal form), with
the verdict in the session's drafts file and docs/privacy_verdicts.md;python scripts/check_provenance.py names the city OK; python scripts/check_scope_disclosure.py passes; python scripts/check_map_markup.py
passes (line-colour contrast); the page and the city's reference-doc sections
read as docs/city_page_format.md sets out; python pipeline/drift_check.py <slug>; grep -rn TODO pipeline/<slug> app is empty; a decisions entry in
the session's drafts file (docs/decisions_drafts/<session-or-branch>.md,decisions-entry). Then publish-city.
The six, one sheet each
Figures are the briefs' and the 2026-09-30 reads. Storefronts are the screen's
(2026-09-27), and step 2 replaces them. Every call was approved as
recommended (owner, 2026-09-30).
Brno
| Obec | 582786; OSM relation 438171 (ref CZ0642582786) |
| Control | ("19095597", 49.1936, 16.6069, "Brno New Town Hall"), Dominikánské náměstí 196/1 |
| CRS | EPSG:32633 |
| Trams | KORDIS feed, lines 1-10 and 12, route_type 0; 149 parent stations network-wide, 147 inside the city (the tram list's 146 was the screen's count); line 2 keeps 36 of 38 |
| Geometry | OSM, operator "Dopravní podnik města Brna", 26 relations, all 11 refs |
| Colours | The feed's: 1 D40000, 2 4AB95D, 3 009E9E, 4 EE7E1E, 5 F31D7F, 6 0777C1, 7 939DAC, 8 E1CB31, 9 8C4A9A, 10 A05A2C, 12 00CCFF |
| Spacing | 329 m: halved rings |
| Storefronts | 7,229 (retail 2,520, food 2,126, personal 2,583); restaurants 1.61× OSM |
| Calls | approved: H4 and P1 out; built first |
| Notices | ČSÚ, ČÚZK, a new KORDIS notice, OSM (lines) |
Ostrava
| Obec | 554821; OSM relation 437354 (ref CZ0806554821) |
| Control | ("3182860", 49.84130, 18.28927, "Magistrát města Ostravy, 30. dubna 635/35") |
| CRS | EPSG:32634 (UTM 34N) |
| Trams | OSM, operator "Dopravní podnik Ostrava": 1-4, 6-8, 10-12, 14, 15, 17, 18; 96 stop names inside |
| Not drawn | 9 and 19 (no stop members); relation 19177807 (an unref'd line 11 variant); line 5 (approved) |
| Spacing | 424 m: halved rings |
| Storefronts | 3,971; restaurants 2.23× OSM (OSM thin) |
| Calls | approved: line 5 out (3 of 10 stops inside, 30%; costs Poruba,koupaliště and Krásné Pole); the project's palette; fourth in order |
| Traps | 34N; 33 or 34 relations and 18 refs on 2026-09-30 (OSM moved between reads, so step 1 counts, not the brief); line 7 has a 2-stop relation (17625150) beside its full one |
Plzeň
| Obec | 554791; OSM relation 438344 (ref CZ0323554791) |
| Control | ("24570222", 49.74836, 13.37775, "Magistrát města Plzně, náměstí Republiky 1/1") |
| CRS | EPSG:32633 |
| Trams | OSM, operator "Plzeňské městské dopravní podniky, a.s.": 1, 2, 4; 53 stop names, all inside |
| Not drawn | 1X, 4X and a depot run from Vozovna Slovany (no stop members; one untagged operator) |
| Gate 3 | PMDP's GTFS (permitted), credited if used |
| Spacing | 294 m: halved rings |
| Storefronts | 3,513; restaurants 1.99× OSM |
| Calls | approved: the project's palette; second in order |
Olomouc
| Obec | 500496; OSM relation 437057 (ref CZ0712500496) |
| Control | ("25321960", 49.59393, 17.25164, "Olomouc Town Hall, Horní náměstí 583") |
| CRS | EPSG:32633 |
| Trams | OSM, operator "Dopravní podnik města Olomouce": 1-7, 14 relations; 36 stop names, all inside |
| Spacing | 318 m: halved rings |
| Storefronts | 2,246; restaurants 1.80× OSM |
| Calls | approved: the project's palette; third in order. DPMO's feed is not used at all, not even for gate 3, which IDOS gives exact on all seven lines |
Liberec (Regional)
| Obce | 563889 Liberec (OSM 439073, ref CZ0513563889) + 563510 Jablonec nad Nisou (OSM 438931, ref CZ0512563510) |
| Controls | Liberec ("23653124", 50.77000, 15.05845, "Liberec Town Hall, nám. Dr. E. Beneše 1/1"); Jablonec ("12188018", 50.72452, 15.17128, "Jablonec Town Hall, Mírové náměstí 3100/19") |
| CRS | EPSG:32633 |
| Trams | OSM, operator "Dopravní podnik měst Liberce a Jablonce nad Nisou": 2, 3, 5, 11; 39 stop names in scope, every line whole (line 11: 14 + 7); 40 after gate 3 (IDOS: Šaldovo náměstí on 2 and 3, line 11 at Fügnerova and Sídliště Nové Vratislavice) |
| Spacing | 378 m: halved rings |
| Storefronts | 2,498 (Jablonec 618); restaurants 2.45× OSM |
| Calls | approved: with Jablonec, as "Liberec (Regional)" (Liberec alone would keep line 11 at 14 of 21, 67%); the project's palette; fifth in order |
| Traps | the two-obec change (section 2); DPMLJ's own GTFS reset the connection at the screen |
Most (Regional)
| Obce | 567027 Most (OSM 436570, ref CZ0425567027) + 567256 Litvínov (OSM 436574, ref CZ0425567256) |
| Controls | Most ("25298429", 50.50284, 13.64078, "Most Town Hall (Magistrát), Radniční 1/2"); Litvínov ("5150507", 50.59881, 13.61171, "Litvínov Town Hall, náměstí Míru 11") |
| CRS | EPSG:32633 |
| Trams | OSM, operator "Dopravní podnik měst Mostu a Litvínova": 1-4, 8 relations; 27 stop names in scope, every line whole; 28 after gate 3 (IDOS: Litvínov, Vrchlického on 1, 3 and 4, closed for works) |
| Spacing | 514 m: halved rings, 36 m under the line |
| Storefronts | 1,030 (Litvínov 241); restaurants 3.71× OSM (OSM thin) |
| Calls | approved: built, last (the smallest Czech page), as "Most (Regional)" covering Litvínov; the project's palette |
| Traps | the joint scope is required; the two-obec change |