Add a new city to the expanded-heatmap project - live-verify its real data sources, scaffold its pipeline, build its transit/business heatmap, and wire it into the Streamlit app. Use whenever the task is "add city X" or "build out city X" for this project, not for edits to an already-built city.
Install
npx skillscat add dacekroberts/expanded-heatmap/add-city Install via the SkillsCat registry.
Adding a city to expanded-heatmap
Distilled from the cities actually built - not a hypothetical plan. It began
with four of them (San Diego, San Francisco, Los Angeles, Chicago) and that
number stayed in this line until 2026-09-22, by which point sixteen were built
across four countries and most of what the skill had learned since came from
the later ones. Read app/cities.py for the current list. Every step below
either caught a real problem or is a design decision already made (see docs/project_context.md; the reasoning
trail is in DECISIONS.md). If this is a fresh session, read those, plusdocs/city_master_list.md and PLAN.md, first.
Adding a EUROPEAN city? Its region is Europe, and that is not negotiable
Tag "region": "Europe" in app/cities.py. Do not create a region for the
country. Ten European countries are on the remaining screen and they describe
one readable view between them; a region per country means ten entries to
create, order and later merge.
Spain was a leftover from before this rule and has been retired to Europe
— see docs/scaling_thresholds.md, "EVERY EUROPEAN CITY TAGS ONE REGION".
The two exceptions are the owner's, not a pattern: French cities tagFrance North or France South and Czech cities Czechia (owner,
2026-09-29/30; the comments at REGION_ORDER in app/cities.py), because
their labels could not share Europe's view.
This lives here rather than in a sibling city's config because a lesson written
in the previous city's comments does not reach the next city — the meta-ruleosm-rail exists for.
Step 0 - Live-verify data BEFORE writing any code
The step that matters most. Denver and San Jose both looked viable from
search summaries and dataset titles, and both had no usable data on direct
schema verification. Do this for every candidate before scaffolding:
Open the portal in a real browser first. Before any
curl, load the
site and watch what its own UI calls. This is a standing rule, not a
fallback for when fetching fails - seeadd-country§3, where five portals
in one sweep each gave up in one page load what rounds of path-guessing had
not: a sign-in wall (GeoMedellín), a server-rendered catalogue with no API
at all (data.gov.my), a DKAN install whose CKAN path needed the3
(Peru), and two real API roots read straight off the network panel (Seoul,
Jakarta). Guessing tells you a path 404s; navigating tells you what the
portal is.Business data. Find the real open-data portal and pull the live
schema, not a description page. Socrata:curl -s "<domain>/api/views/<id>.json"
for columns, then<domain>/resource/<id>.json?$limit=3for sample rows.
ArcGIS FeatureServer:<service-url>?f=jsonfor fields, then<service-url>/query?where=1=1&outFields=*&resultRecordCount=3&f=json.
CKAN:<domain>/api/3/action/package_list, printed in full and read -
neverpackage_search?q=<terms>, and never a keyword filter over the full
list either. Search hits and keyword greps both miss the dataset that is
named in the city's own vocabulary rather than yours. Boston proved the
first half (48 packages from seven search terms; 247 frompackage_list,
and the extra 199 held the boundary layer, the address points and the
property roll). Montréal proved the second on 2026-09-21: a keyword scan of
the full 447-name list wrongly concluded the city was food-only, becauselocaux-commerciaux- a 28,621-row, agglomeration-wide field survey of
street-level commerce with NAICS (SCIAN) codes, 100% coordinates, trade
names and a vacancy flag - contains none of the words business, licence,
permis, entreprise or commerce. Nor doesunités d'évaluation foncière, the property roll that would serve as its residence-check join.
Printing ~450 names costs about a minute and ~3 KB of context. Read them.
Also record the source encoding asSOURCE_ENCODINGin the city's
config (seedocs/data_sources.md) - declared, never inferred. Confirm, with
real non-null sample values: (a) some classification field - NAICS or
the city's own taxonomy, both fine; and (b) a street address and/or
lat/long that actually has data, not merely a column that exists. Also
note whether the data ships pre-geocoded (both San Diego and San
Francisco do) and whether it has an "active" flag or an end date to
filter on. Also check how often the trade-name column is blank, and what
the pipeline would fall back to: Los Angeles has nodba_nameon 68% of rows,
so it fell back to the registrant's own name and would have published ~4,000
individuals' names at their premises (see Step 5 andDECISIONS.md,
2026-09-21 "Privacy line"). A registry that nearly always carries a trade
name (San Diego, San Francisco, Chicago) has no such problem. Then check validity, not just population: count how many
coordinates fall inside the city's bounds and look at the ones that
don't. A populated field can still be corrupt - Los Angeles' registry had
~9% bad coordinates (longitude copied from latitude, (0,0), whole-degree
placeholders), concentrated in recent registrations. Madrid's premises
census is the second case and the worse one:coordenada_x/y_localare
non-empty on 100% of rows and 20% of them are a literal0. Two of the
three pre-geocoded registries measured at this depth had the same defect, so
run the bounding-box count on every city, before quoting its density -
Madrid's headline would have been a fifth too high, and the bad rows vanish
off-map rather than raising an error. And look at what the
dataset's city field actually holds: LA's holds postal community names
(Van Nuys, San Pedro...), so an exact match keeps about half the city;
find the authoritative in-city marker (LA'scouncil_district) instead.
Then date it from the rows, never the catalogue (the currency rule indocs/city_master_list.md, "Five rules", owner 2026-09-29): the source
must be able to drop closed businesses (a snapshot, a validity year, a
closure field or an expiry window), and the date it last showed what
was open must be under five years old. List the publisher's other
layers first: Atlanta's 2019-22 licence layer, screened alone, hid a
2024 one. Kansas City's rows, grouped by licence year, showed the year
its register froze.Transit data. Find the real GTFS feed URL and confirm it downloads
and unzips (curl -sL <url> -o gtfs.zip). A URL from search can still
time out or 404 - San Francisco's official feed host timed out from this
environment and a mirror linked from the agency's own page worked.
Screening several candidates at once? Runscripts/screen_rail.py
first, before any business-data research. Rail is the cheapest
disqualifier here - a city with no urban rail is out however good its
registry turns out to be - and the script readsroute_typecounts
straight from each agency's liveroutes.txt. It removed 6 of 13
Canadian cities in one pass on 2026-09-21, before a catalogue was opened.
Feed URLs come from the Mobility Database catalogue (bit.ly/catalogs-csv),
one CSV covering most of the world's feeds. Three traps it exists to catch.
A catalogue mirror can be STALE and silently missing an entire mode -
the Mobility Database copy of Toronto's TTC feed was three months expired
(feed_end_date 20260606) and contained no subway at all, only 17 trams
and the shuttle buses; the screen read that as "Toronto codes its subway as
route_type 0", which is false. The agency's own feed has 3 subway lines
asroute_type 1.screen_rail.pynow prints each feed's expiry and flags
stale ones: heed it, and prefer the agency's own feed. Separately,
an agency may genuinely code its subway asroute_type 0(San
Francisco's Muni Metro does, so a high type-0 count is a prompt to read the
route names, never a finished answer), and a regional feed covers cities
with no rail of their own
(TransLink's carries Surrey's SkyTrain stations). Commuter rail
(route_type 2) is not counted, matching every city built so far.Check
feed_info.txtfor a validity window, on the feed you will
actually use. WMATA declaresfeed_start_date/feed_end_dateten days
apart - the shortest here, short enough that a stored copy goes stale
inside a fortnight. Where the window is short, that city'sfetch_sources.pymust re-checkfeed_end_dateon EVERY run, including
runs that skip the download, and treat an expired copy as an error rather
than a warning. The failure mode is silent: an expired feed still parses,
still has the right station count, and still builds a map - it is just not
the current network. Seepipeline/washington_dc/fetch_sources.py. (This is
the same fieldscreen_rail.pyprints for staleness; the difference is that
screening reads it once and a build must re-read it forever.)If the feed needs a key - WMATA is the only one so far, returning 401
unauthenticated - read it from an environment variable, exit with
registration instructions when absent, and never echo it, not even in an
error message, so the 401 path prints "the key was rejected" rather than
the key. It is the agency's property and stays out of the repo. Record in
the country'sdocs/data_sources/<country>.mdthat the city cannot be rebuilt from a clean checkout
without it.City boundary polygon. Find a real GIS boundary layer (a regional
MPO/COG portal is often the source, e.g. SANDAG). Not optional: San
Diego's Trolley serves six other cities and 16 of 63 stations were
outside city limits, several not guessable by name. Eyeballing a station
list is not a substitute for any system with several lines or one that
plausibly crosses municipal borders.
Only proceed once all three are confirmed live. If a city fails, record the
real reason (cite the endpoint and response) as a row in the DISCARDED table
of docs/city_master_list.md, whose evidence columnsscripts/check_discard_evidence.py reads, and in a DECISIONS.md entry.
Then move to the next candidate.
Give every probe an explicit output path under the session scratchpad. The
commands above are curl/requests dumps, and -o la.json writes wherever
the shell happens to be: the 2026-09-18 screening left eight captures in the
home directory, including a saved 404 and a file named .json that was HTML.
Write probe output to the scratchpad (or a gitignored data/<city>/raw/), and
put the findings in docs/data_sources/<country>.md, the city'sdocs/build_briefs/<city>.md and its row in docs/city_master_list.md - a
raw capture is not a record, and it goes stale the moment the endpoint changes.
Record every confirmed source in its country's file,docs/data_sources/<country>.md, as you verify it - endpoint, the
server-side filter applied at download, and the date, under the same table
headings the other country files use. A country's first city creates the file
and adds its row to the index at the top of docs/data_sources.md; required
notices go in that entry point's numbered gate, not the country file. Those
files are the project's master provenance list and the only way a build can be
reproduced, because raw downloads are gitignored. Do it now, while the
endpoints are in front of you: recording them later is how San Francisco's
boundary layer ended up with no recorded URL at all. If an endpoint you tried
is dead, record that too, with its date - a replaced URL that leaves no trace
hides the fact that dataset IDs get retired.
That file is a reader's page too: About the Data renders it, one country at
a time. Write the city's rows for the reader (the data, its terms, its
notices, what is counted), and keep how a source was found, checked or decided
in your drafts file and the brief. A process pointer that must stay (a check
script, a skill or agent name, DECISIONS.md) goes between<!-- internal --> markers. The rules are docs/city_page_format.md,
sections 2 and 3.
Licence and required notices, per source, in the same pass. A city is
not verified until this is recorded indocs/data_sources/<country>.mdalongside its
endpoint. Use theread-licenceskill - it carries the procedure plus
the three corrections that motivated it, each of which came from not opening
a page an earlier review had merely cited. The two steps most easily skipped:
grepping the dataset page for terms incorporated by reference
("constitutes acceptance of the license, the City's terms of use ..." is
how Philadelphia's prohibition was hiding behind a licence that forbids
nothing), and asking of every document whether its language describes web
pages or data. "It's on a government open-data portal" is not an answer: the
2026-09-21 review found terms ranging from public-domain dedications to a
feed that forbids modifying its data, and both extremes inside Los Angeles
(its business registry is CC0; its GTFS is the most restrictive licence in
the project). For each source capture:- The declared licence. Socrata exposes it directly - fetch
<domain>/api/views/<id>.jsonand readlicense,licenseId,attributionandattributionLink.SEE_TERMS_OF_USEmeans go read the
terms page; a missinglicensedoes NOT mean permissive. - The governing terms where no licence is declared. Look for a portal
terms document, not the parent site's general footer - that mistake made
NYC look prohibited when Local Law 11 in fact forbids it from imposing
a licence, while the nyc.gov footer's "All Rights Reserved" covers only
that website's own content. - The transit feed's own terms, separately.
feed_info.txtalmost never
carries a licence (LA Metro's has an emptyfeed_licensecolumn), so go
to the agency's developer terms. Line geometry is redrawn fromshapes.txtinto the map, so these bear directly on what is published,
and they were the loosest end of the whole review. - Anything the project must DISPLAY. This is the part that becomes work
rather than a note: Chicago requires a verbatim disclaimer, SFMTA
requires specific attribution wording, LA Metro requires acknowledgement
as provider. Add any new one to the "Notices this project MUST display
when published" section - that list gates the public deploy. - Anything that needs a human decision, such as a purpose limitation or
a bar on modification. Raise it rather than reading the clause
generously, record the verdict and its reasoning inDECISIONS.md, and
attribute the call.
If a page blocks automated fetching (Chicago's data terms return 403), use
the browser rather than giving up or guessing - every item in that review
was resolvable that way except one.The standing commitment in
docs/data_sources.md- that removal requests
are honoured without argument - covers new cities automatically. Do not
weaken it for a source with tighter terms; the answer to tight terms is to
record them and comply, not to hedge the commitment.- The declared licence. Socrata exposes it directly - fetch
THE DOWNLOAD BOUNDARY IS THE PRIVACY CONTROL, where the source lets you
choose columns. Do not download a personal-name column and filter it later -
omit it from the request, so it is never on the machine to leak into a
processed file, a cache or a commit. Washington D.C.'s ArcGIS query names 16outFields and omits eight that exist and are populated on tens of thousands
of rows: BUSINESSOWNERFIRSTNAME, BUSINESSOWNERLASTNAME,BUSINESSOWNERMIDDLENAME, AGENTFIRSTNAME, AGENTLASTNAME,AGENTMIDDLENAME, AGENTENTITY and BILLINGADDRESS - that last being a
mailing address, i.e. frequently a home. Boston does the same in SQL; Miami
omits OWNERNAME and every MAIL* field.
Then assert in step 2 that they stayed absent, and have the fetch script
exit if the server returns a column it did not ask for. The assertion is what
makes the omission a guarantee rather than an intention.
One source need not be enough - and for most large US cities it is not.
Pull the category distribution here, not just the schema, and read it against
the three buckets: could a reader find a restaurant, a grocer, a clothes shop
and a hairdresser in this data? New York's recorded source held zero
restaurants, grocery, clothing or salons; 79% of Philadelphia's active licences
are residential landlord registrations. Both passed a schema check and answered
a different question, and a big row count hid it in each case.
Pull SEVERAL pages before concluding anything, and never judge a register by
its first record. A register is ordered by identifier or registration date,
so page one is systematically the oldest entities - holding companies,
dormant shells, long-established corporates - and is the least representative
sample it can give you. Finland was ruled out of the 2026-09-21 country screen
on exactly one record, the first the API returned: a financial holding company
with an empty street, a PO box and a c/o accounting firm. Counted across 500
instead, 89.8% carried a real street address. The ruling was reversed and
the reason for it had been false. The cost of doing this properly is one loop.
If any bucket comes back empty or thin, use the multi-source-city skill
(.claude/skills/multi-source-city/). It carries the diagnosis, the source
archetype per bucket, the dispatching-taxonomy architecture, the cross-source
dedup rules, and - the part most easily skipped - the licence and privacy
checks that every added source multiplies.
If it passes but the build won't start right away (a usage limit, a new
session), turn what the probe found and left open into an ordered checklist
under that city's item in PLAN.md before stopping. Write it from the
findings, not from a template: it holds only what is specific to this city
(catch-all license types, adjunct licenses, scope decisions, checks still to
run), and points back to these steps for everything generic. Chicago's is the
model. Skip it when the probe and build happen in the same session.
If the city has a brief, run python scripts/brief_check.py <city> now -
before Step 1, before any code. It re-runs the brief's factual claims against
the live sources and prints got-against-expected. It exists because Edmonton's
build inherited three wrong claims from its brief (a recommended GTFS path
that was stale, a feed_info.txt said to be absent that is present, and "no
required notice" where a redistribution clause applies) plus a station count
that was 33 where 30 was right. All four were cheap to check and none was
checked, because the brief's prose had already turned its own ASSERTED labels
into recommendations.
Add checks for whatever you newly verify in Step 0, in the brief's
failures - `gtfs_stations` measures platforms, the parent_station collapse, the
boardable count and the spacing median in one call; `socrata_distinct_split`
separates true categories from delimiter combinations; `geojson_area_km2`
catches a same-named stale boundary layer. A failing check is a brief to
correct, not a check to relax.
## Step 1 - Taxonomy
- NAICS: nothing to build; `pipeline/taxonomies/naics.py` is complete.
- Local system: add `pipeline/taxonomies/<short_name>.py` exposing
`classify(row) -> bucket | None`, `FIELD_LABEL`, `VALUE_COLUMN` and
`legend_label(bucket)` (interface in `pipeline/taxonomies/__init__.py`),
and register it in `TAXONOMY_MODULES`. Keep the honesty convention of the
existing skeletons: map only values confirmed in Step 0's sample rows and
mark the rest TODO - then, **before building the city**, do the full
`SELECT DISTINCT <field>` pull and fill the mapping in properly, with a
hand-sample of any catch-all categories.
- **Either way, every keep-or-drop call follows the other cities**:
`docs/category_rules.md` holds the owner's cross-city rules (funeral, canteens,
the personal-services catch-all, adult venues, massage, petrol stations,
gambling, vets...) with their precedents, and a "Station scope" section for
stations (closed for works, too rarely served, thinned; see Step 4).
Recommend the precedent; bring a departure to the owner with the precedent it
breaks. `scripts/check_category_continuity.py` needs a column for every new
taxonomy in `scripts/category_continuity_table.py` (every rule answered with
`loc`, `absent`, `outside` or `exception`); a new taxonomy without one fails
`check_all`. A departure goes in as `pending(...)`, with the cell listed in
AWAITING_OWNER, never as a decided row.
## Step 2 - Scaffold
**Use `scripts/scaffold_city.py` (the `scaffold-city` skill)** to write the
folders below, `config.py`, the map script, the app page and the `cities.py`
entry (Steps 2, 3, 6 and 8's files) instead of creating them by hand; it also
scaffolds a new local taxonomy. Steps 4 and 5 (`step1`, `step2`) are still
copied from the nearest built city. What it produces:
data//raw/ gitignored downloads
data//processed/ gitignored intermediates
outputs// committed; the app only reads here
pipeline// config.py, step1_stations.py,
step2_clean_businesses.py, step3_map.py
Steps are 1 stations, 2 clean businesses, 3 map. If the city's data is not
pre-geocoded, insert a geocoding step and renumber the map step; nothing
hardcodes the numbers (`pipeline/drift_check.py` globs `step*.py`). Ring,
chain and ridership analyses are out of scope.
## Step 3 - `pipeline/<city_slug>/config.py`
Per-city, not shared, until more cities show the common shape (see
`PLAN.md`). Mirror San Diego's or San Francisco's names and shape. Include:
- Paths, plus `RAW_CLASSIFICATION_COLUMN` (the raw export's classification
column; step 2 renames it to the taxonomy's `VALUE_COLUMN`).
- **The city's own projected CRS.** Look up the UTM zone from its
longitude; never copy another city's. Comment how you derived it.
- Ring edges: reuse `[0.0, 0.1, 0.2, 0.3, 0.6]` miles unless station spacing
is meaningfully different.
- Station scope: which lines count and why. Document the choice.
- How in-city rows are identified: `CITY_KEEP` (the exact string the dataset's
city field uses; note any neighbourhood it undercounts - San Diego's La
Jolla) or, better where the dataset has one, an authoritative district
field (Los Angeles' `council_district` 1-15).
- `TAXONOMY_SYSTEM` (a key from `TAXONOMY_MODULES`) and a sanity bounding
box for coordinates.
## Step 4 - `step1_stations.py`
GTFS -> station list -> **spatial filter against the real boundary polygon**,
never a hand-curated name list. Print the raw stop-name list first and
collapse platform-name duplicates via an alias dict. Print which stations
were excluded so a scope mistake is visible, not silent. Match routes by
exact `route_short_name`/`route_id` after printing the routes table - a
substring match once swept in a shuttle-bus route.
**AND MATCH ON `route_short_name` WHERE THE FEED VERSIONS ITS IDS.** Calgary's
`route_id` embeds the feed release: `201-20780` in the Mobility Database
mirror, `201-20786` in the agency's own feed. A config pinning the id matches
NOTHING after the next release, and matches nothing *silently*.
### A STATION COUNT IS THE MOST ERROR-PRONE NUMBER IN THIS PROJECT
**Five of the project's denominator errors have been station or percentage
counts, and two were platforms counted as stations.** Both survived review
because the wrong number was plausible:
- **Toronto**: the "234 stations" behind its ranking figure was 234
PLATFORMS - 118 stations. Its density was 41 per platform, i.e. **81 per
station**, which moved it from last of six to fourth.
- **Calgary**: "83 stations" was 83 PLATFORMS - 45 stations. 75 per platform
is **138 per station**, moving it from fifth to third.
**THE CHECK THAT CATCHES IT IS THE SPACING MEASUREMENT, NOT A DUPLICATE-NAME
ASSERTION.** Calgary's step 1 asserted no two stations share a name and
PASSED, because `NB Banff Trail CTrain Station` and `SB Banff Trail CTrain
Station` are different strings. What caught it was the nearest-neighbour
median: **17 m, with a minimum of 8 m**, which is physically impossible for
rail stations.
So: **print the nearest-neighbour distribution before trusting any station
count, and treat a median under ~100 m as platforms until proven otherwise.**
For calibration, every real figure measured so far - San Francisco's thinned
street-running median was **134 m**, Montréal 728 m, Vancouver 841 m, D.C.
962 m, Calgary after collapsing 1,023 m.
**Four collapse mechanisms, in descending order of reliability:**
1. **`parent_station` is populated** - the clean case. Collapse on it and
assert it is non-null on every served platform. D.C. 125 platforms -> 98,
Montréal 72 -> 68, Edmonton 65 -> 33. **Take the DISPLAY name from the
CHILD stop**: Montréal's parents are upper case (`STATION ANGRIGNON`) and
its children mixed (`Station Angrignon`), and an all-caps label reads as
shouting on a map.
2. **A regular suffix** - Vancouver's `<Station> @ Platform N`. Regex it, and
**assert every name containing the marker actually matched**, so a new
suffix form fails loudly instead of inventing stations.
3. **A direction PREFIX and no `parent_station`** - Calgary's `NB|SB|EB|WB`.
This is the dangerous one, because each name is unique and nothing looks
wrong.
4. **Nothing at all** - Toronto: 234 stops named `Finch Station - Southbound
Platform`, no `parent_station` column. Normalise by hand and check the
result against the operator's own published station count.
**Always sanity-check the collapsed count against the operator's published
figure.** Calgary collapsed to exactly 45, which is the CTrain's real station
count; that agreement is what turns a regex into evidence.
### Normalising station names: the traps, all of them real
- **A typo in the feed.** Calgary ships `CTrain Staion` alongside `CTrain
Station`.
- **Several suffixes for one system.** Calgary uses `CTrain Station`, `CTrain
Stn`, `Station (Free Fare Zone)` and bare `Station` in one file.
- **Inconsistent spelling BETWEEN THE TWO DIRECTIONS OF ONE STATION.**
Calgary has `EB Downtown West-Kerby Station` and `WB Downtown-West Kerby
Station` - the hyphen moves. Normalise hyphens to spaces before comparing.
- **Legitimately single-platform stations that must NOT be merged with a
neighbour.** Calgary's 7 Avenue downtown is a ONE-WAY COUPLET: `EB 3 Street
SW` and `WB 4 Street SW` are different places, one direction each. Seven of
its 45 stations are like this. Collapsing by normalised name handles it
correctly; collapsing by proximity would not.
- **The opposite error: over-stripping.** Boston's bug turned `North Station`
into `North` by removing a trailing token. Strip a LEADING prefix in
preference to a trailing one, and never strip a bare trailing word without
checking the whole list first.
### Non-English and non-Latin names: what to expect next
The project has met three of these already and should expect more:
- **Dash variants are a silent join killer.** Montréal's commercial survey
writes `Ahuntsic–Cartierville` with an EN DASH where the City's own boundary
layer writes `Ahuntsic-Cartierville` with a hyphen. Six of its borough names
differ this way, which made a 34-feature layer look like it covered only 32.
**Normalise U+2013/U+2014 to U+002D before any join.**
- **Apostrophe variants likewise.** `L’Île-Bizard` (U+2019) against
`L'Île-Bizard` (U+0027).
- **Case differs across sources for the same name.** `Baie-d'Urfé` against
`Baie-D'Urfé`. Use `casefold()` rather than `.upper()`, which is wrong for
some scripts, and apply **Unicode NFC** so that a precomposed `é` and an
`e`-plus-combining-accent compare equal. Two files can look identical and
not match.
- **Direction and platform words are language-specific.** A `NB|SB|EB|WB`
regex is an English-feed assumption; expect `Direction`/`Sens` (French),
`Richtung` (German), `Sentido`/`Andén` (Spanish), `Binario` (Italian).
Derive the pattern from the printed name list, never from a template.
- **GTFS has `translations.txt`, and some feeds ship it.** TransLink's and
STM's both do. Where a feed carries a non-Latin script, that file is where a
romanised name lives - prefer it to transliterating by hand, and record
which language the displayed name is in.
- **Business names stay in their own language, always** (an `add-country`
rule). Only the bucket labels are translated, and those belong to the
taxonomy module.
**Check the rail system's shape before assuming "keep every station."**
Uniformly sparse systems (San Diego's Trolley) keep every in-city station.
A system with a compact central corridor plus dense street-running
offshoots (San Francisco's Muni Metro: 147 stops, mostly every 1-2 blocks)
does not: keeping all makes the rings overlap continuously; keeping only the
central stations drops whole districts. For that shape use the
**sub-transit-line filters** (`docs/sub_transit_line_filters.md`): central
stations always kept, terminals always kept, surface stops thinned to a
target spacing measured along the real route, interchanges force-kept.
Document every cut station (name, line, reason, nearest kept station) to
`outputs/<city>/excluded_stations.csv`. Stations dropped by the boundary
filter deserve the same record, with the city each lies in (Los Angeles: 54
of 110 stations, across 23 other places) - use a multi-city boundary layer
so the city can be named.
**Three station rules hold in every city** (owner, 2026-09-29;
`docs/category_rules.md`, "Station scope"). Recommend the precedent, and bring a
departure to the owner with the precedent it breaks. The `reason` wording is load-bearing:
`app/station_scope.py` sorts each `excluded_stations.csv` row into the What Is
Excluded table by the words in it, and a row it cannot read is counted as "Other"
and fails `check_scope_disclosure.py`.
- **Closed for works: drawn as the timetable runs.** A station or branch the
current feed does not serve because it is closed is not drawn or ringed; track
that trains still run through is drawn. List it with a reason containing
`closed for works` and the operator's reopening date. Name it on the page with
when it is due back, make step 1 stop the build once the feed serves it again,
and add a dated PLAN item to put it back. If the operator's count includes it,
count it for gate 3 first, then move it out, as Prague does with Flora. Precedents:
Berlin's U6 Tegel branch, Sacramento's Township 9, Prague's Flora. A rule keyed
on how soon a station reopens was rejected, because operators' dates slip.
- **Too rarely served: the 15-minute test.** Light rail is drawn only where it
runs at least every 15 minutes by day. A stop on a slower stretch is listed
with a reason containing `15-minute` (Aarhus's two converted railway lines).
- **Thinned by spacing: name the lines.** A city whose file thins stops (reason
containing `spacing`) must say which lines, either on its own page ("thinned", or
"one stop per half mile is drawn") or in the spacing-filter paragraph of
`docs/excluded_categories.md`. Do both: the page is where that city's reader
looks, and the paragraph lists every city. `check_scope_disclosure.py` property F
fails a city with neither; it prints a NOTE for one on its page only.
**Measure inter-station distances against the 0.6 mi outer ring.** Where
several stations sit closer than that (downtown clusters), their rings
overlap. The map assigns each business to its nearest station so nothing is
double-counted, but the overlap is visible and should be a conscious
choice; it would matter directly if ring statistics are ever added.
**Stations excluded for lying in another city are a new project, not a
config change.** San Diego dropped 16 Trolley stations in neighbouring
cities; including them would need those cities' own business data, sourced
and verified separately (and geocoding coverage there may be worse). Log it
as a scoping decision rather than quietly widening a filter.
## Step 5 - `step2_clean_businesses.py`
Rename the raw classification column to the taxonomy's `VALUE_COLUMN`, then:
restrict to the city (`CITY_KEEP`), filter to storefront with
`pipeline.taxonomies.filter_to_storefront()` (never NAICS prefixes
directly), apply the active flag if there is one, drop rows without valid
coordinates, bounds-check them, and deduplicate on the dataset's real
primary key (not business name - chains share names). Rename to the shared
columns the map expects (`business_name`, `latitude`, `longitude`, plus the
`VALUE_COLUMN`). Read the printed row counts at every filter - that is how a
scope mistake surfaces, and they become the baseline in `DECISIONS.md`.
**Check whether the city has a property register you can join to, and read the
result carefully.** The residence test in `check_personal_exposure.py` only
fires on an `APT`/`FL`/`RM`/`#` indicator, so a sole trader at a detached house
reads as clean. Where the registry carries a parcel id (Philadelphia's
`opa_account_num`), an assessor/property layer usually carries the city's own
land-use category and an owner-occupancy flag, which tests directly for a
business at a home. Tested on Philadelphia 2026-09-21, with three lessons that
save repeating the work:
- **A mailing address matching the premises is NOT a signal.** It matched 41.9%
of mapped pins - a shop's mailing address is normally its own premises.
- **Land use alone is not a signal either, and this is the important one.** In a
dense city, shops sit inside residential buildings: "purely residential
parcel" flagged 7.95% of Philadelphia's pins, including 147 thirty-plus-seat
restaurants on `APARTMENTS > 4 UNITS` parcels. Filtering on it would delete
hundreds of real storefronts. Mixed-use parcels in particular - the rowhouse
with a shop below and a flat above - are the most characteristic storefront
in some cities, and their owners often claim an owner-occupancy exemption on
them.
- **Combine a name or entity-type signal with an occupancy one.** A person-like
name AND an individual entity AND (a purely residential parcel OR an
owner-occupancy exemption) gave 0.39%, against the 0.00% the unit indicator
reported. Treat what it finds as a **scope** question first - a home food
business is not a storefront - which is easier to justify and fixes both.
- **Names read by eye as a person's own are listed as KEYS, never as names**
(owner, 2026-10-01): `PERSON_NAMED` in the city's config holds each name's
`python pipeline/name_keys.py "NAME"` output, and step 2 compares
`keys_of(column)` against it (Kansas City is the worked example). A plain
list would publish in the repository the people the map leaves out;
`scripts/check_name_keys.py` fails one. Never write the name beside its key,
nor in DECISIONS.md, a brief or a commit message: count them instead.
**Sample the catch-all classification codes for this city, and decide.**
`pipeline/taxonomies/naics.py` lists the NAICS catch-alls (812990, 812930,
459999) and why each needs a per-city verdict; a local taxonomy has its own
equivalents (Chicago's "Limited"/"Regulated Business License"). These codes sweep
in home-based sole proprietors, which is both a data-quality problem (not
storefronts) and a privacy one (a person's name at their home on a public map).
Put the verdict in the city's own config as `NAICS_EXCLUDE_CODES` (Los Angeles is
the worked example), applied as its own printed filter in step 2, and record the
sample and reasoning in `DECISIONS.md`.
**Never silently drop a large share of rows.** If a filter drops more than a
few percent, find out why and whether the loss is uniform (by start year,
category, district) before accepting it. Los Angeles' coordinate check
dropped 9% of storefront rows, 22% of businesses started since 2020 versus
~1% of older ones; dropping them would have systematically under-counted new
openings. If the rows are recoverable (they still have street addresses),
flag them instead - blank the bad coordinates and set a `coord_status` - and
recover them in a geocoding step with `pipeline/census_geocoder.py` (US
only; responses cached by content hash so re-runs and drift checks stay
deterministic). Accept geocoded points only inside the city's bounds,
record `geocode_source` on every row, and report the residual loss and its
bias. Cross-check in-city identification against the boundary polygon.
**Only add a geocoding step if Step 0 found coordinates missing or unusable;**
when you do, it becomes step 3 and the map step 4 (LA is the worked
example).
## Step 6 - `step3_map.py`
Don't copy map code - call `render_heatmap()` from `pipeline/map_common.py`.
It owns everything generic (heat layers, rings, stations, clustered
per-category pins with the scaled cluster icon, line labels, legend). Copy a
built city's thin `step3_map.py` and change only what's city-specific: map
transit-line specs `{key: (shape_id, colour, real public name, label end)}`
for `load_line_shapes` (label end `None` = automatic), the city boundary for
`label_focus`, the config paths and the taxonomy name. Don't hand-pick a map
centre or zoom: the view is fitted to the stations and labels. Each line's `shape_id` is its single most-used trip shape - count trips
per shape for the route and take the mode. **Nothing in `map_common.py` may
name a taxonomy;** if a city needs behaviour it lacks, extend the module
rather than forking a per-city copy.
**Transit lines are a hard requirement:** draw each from real GTFS
`shapes.txt` geometry, and give every line BOTH a permanent on-map label
using its real public name (verify it - a GTFS short name is not
automatically what riders call it) AND a legend swatch. Label placement is
automatic: each label goes at its line's tail end (the end farthest from the
other lines), labels that would collide disperse along their lines, and they
sit above the pins. Always pass `label_focus` - the city's boundary geometry
- so tails are taken on the in-city stretch of lines that run far beyond it.
If a rendered map still shows a label landing badly, force that line's end
with `"start"`/`"end"` in its spec. The legend collapses by itself; check
both in the browser.
**Line colours: separate them WITHIN the city, and reuse freely across
cities.** If the agency's official colours are ambiguous, shared, or collide
with the three business-category pin colours, use your own palette. What has to
be distinct is (a) each line against the category colours that map spends, and
(b) each line against the other lines on the *same* map. **Nothing has to be
distinct across cities.** Each city renders its own map with its own legend and
its own on-map labels, so two cities sharing a red can never be confused;
Edmonton's Metro Line is deliberately the same red as Calgary's Red Line. Do
not spend effort keeping every line in every city unique — the usable palette
runs out long before the city list does, and the cost lands on later cities as
colours that read badly against their own pins, which is the constraint that
actually matters. Only the macro map shows every city at once, and it draws
city dots rather than transit lines.
The separation is **measurable, so measure it** rather than judging by eye.
This project's working threshold is a CIE76 Delta-E of about **45**, the figure
behind `#C2185B`'s selection (49.6) and `#FBB878`'s (44.8) in `map_common.py`.
Edmonton is the worked example: ETS signs Capital blue and Valley green, which
scored 23.9 against Retail's `#2a78d6` and 37.2 against Personal services'
`#1baf7a`, so each line kept its hue and was darkened until it cleared. Where
no combination clears the threshold — green-against-green is the hard pair, and
Edmonton's Valley Line settled at 44.2 — say so in the config as a tradeoff
rather than leaving it to look like an oversight. Worth knowing before trusting
an existing city: **Calgary's shipped Blue Line is Delta-E 3.3 from Retail
blue**, which is effectively the same colour and was never measured.
## Step 7 - Run it for real, then look at it
Run steps 1-3 and read the counts. Then **run
`python scripts/check_personal_exposure.py <city_slug>`** (add the city to its
`REGISTRIES` table first: the trade-name and fallback columns are per registry).
It reports how many pins show a registrant's name rather than a trade name, how
many look like an individual's name, and how many of those sit at an address with
a unit indicator. There is no pass mark - read the numbers, decide, and record the
verdict in `DECISIONS.md`. The project's line: publish public commercial
information, not personal information. Then open the rendered map in a browser
before calling it done: serve `outputs/<city_slug>/` (or use the
`deploy-verify` agent's `heatmap-static` config) and confirm the heat layer,
rings, line labels, legend and clustered pins render over real geography,
with no console errors. A pipeline that runs cleanly is not a map that looks
right.
## Step 8 - Wire into the app
**Everything a reader sees follows `docs/city_page_format.md`** (owner,
2026-10-01): the page (section 1), the city's sections of the two reference
pages (sections 2 and 3), US spelling (section 4), names (section 5) and who
approves the words (section 6). Read it before writing any of them, and never
start a page by copying an older one.
- **The page, `app/pages/N_<Slug>_Heatmap.py`, is written by
`scaffold_city.py` in that format.** In order, with nothing added between
the parts: `render_city_nav("<Name>")` (it renders the hidden links the
map's "Global View" button and Cities menu click, so never omit it; the name
matches the `cities.py` entry) and `render_city_title("<Name>")` (the name
alone, centered, over the shared subtitle; never an `st.title` of the
page's own); the map, `st.iframe(HEATMAP_HTML, width=1000, height=650)`
(static HTML, never `streamlit-folium` or the deprecated
`st.components.v1.html`; the OSM credit's check depends on that height);
the captions (`render_data_age`, or the city's own provenance caption, plus
any credit its sources prescribe word for word); the bullets;
`render_map_help(...)`, `render_excluded_stations(...)`,
`render_country_links(...)`; and `render_site_notices()` last, inline.
`set_base_font()` follows `st.set_page_config`. If a page file is ever
renamed, update its `page` path in `cities.py`.
- **The bullets.** Replace every TODO bullet under **The lines** and **The
businesses** (a third bold heading only when the city needs one;
`app/pages/43_Seoul_Heatmap.py` is the model). One claim per bullet, in
plain words for a general reader: the lines drawn, by name (each labeled on
the map and in the legend), what is not drawn and why, the area covered, a
station closed for works and when it is due back, any lines thinned by
spacing, the source and what it cannot show. Stations left out are "listed
below" (`render_excluded_stations` lists them). **Never on the page:** an
intro paragraph above the map, a controls paragraph of the city's own
(`render_map_help` is it), a repository path or file name
(`outputs/...`, `pipeline/...`), a script, check or skill name, the decision
log, a build brief. Detail a reference page already carries stays there,
reached by `render_country_links`. The scaffold's bullets, filled in for the
city, need no read-back; a sentence no template covers is a proposal,
flagged in your drafts file and at review time, and the build goes on.
- **The city's sections of the two reference pages.** In
`docs/excluded_categories.md`, every heading for the city names the city
(`### <City> - <what it leaves out>` under "Excluded in one city", or the
rail section's matching heading), because the page files a section under a
country by the city its heading names. What the city's source cannot show
and its own limits go in that section, never in the shared "What is
missing rather than excluded" or "Honest limits" sections, which hold only
what is true of every city. Its rows and notices in
`docs/data_sources/<country>.md` follow the same rule (Step 0). Process
notes stay out of both, or go between `<!-- internal -->` markers.
`check_scope_disclosure.py` still decides that both halves of the city's
scope reach the reader; `check_stray_bullets.py` catches a wrapped dash
that the app would draw as a bullet.
- Add the city to the `CITIES` list in `app/cities.py` (name, marker lat/lon,
page path, blurb). That one entry feeds the clickable macro map on the
Overview, its fallback link list, and every city page's switcher.
- **Give the entry its reader-facing phrases**, which `scaffold_city.py` does
not write: `data_age` (the caption `render_data_age` puts under the map, and
the Overview's tooltip and city list; the page raises `KeyError` without
it) and `placement` (the tooltip's "Placed by:"). Copy the phrasing
of a built city of the same family; `check_macro_facts.py` refuses either
field missing, and any date, year or percentage in them that the city's row
in table C (placement) or D (data age) of `docs/map_inconsistencies.md`
does not carry.
- **Give the entry its three summary fields** (owner, 2026-09-28): the city's
row in the table on the "Why the maps differ" page
(`app/pages/Why_the_Maps_Differ.py`) is built from them.
- `rail_extra`: what the map draws beyond the metro. `"Trams"` (light rail
counts; a light metro such as Montréal's REM does not), `"Suburban rail"`,
`"Both"` or `"—"`.
- `record_kind`: what the businesses come from, e.g. `"License register"`,
`"Permit registers"`, `"National register"`, `"Tax register"`.
- `categories`: `"All three"`, `"Two"`, `"Merged"`, `"Retail thin"`,
`"Personal services thin"`, `"Food service thin"`, `"Food premises only"`
or `"Personal services only"`. It must agree with `coverage` (owner,
2026-09-30): "All three" is `full`; "Two", "Merged" and any "thin" value
are `narrowed`; the two "only" values are `one_bucket`. **Food shops are
food**, not a second category: a register of
restaurants plus food shops is one category, even though table B counts
the shops under Retail.
- **The macro-map dot has TWO KEYS, and the entry sets both** (owner,
2026-09-30; the legend under the Overview map shows them):
- **Colour = `mode`**: the HIGHEST-ORDER rail mode the city's map draws,
`"metro"` > `"light_rail"` > `"tram"`. Trams drawn beside a metro leave
it `"metro"`; a light-rail-only system (Houston, Calgary) is
`"light_rail"`; street trams only (Dublin, Riga) is `"tram"`. It is
distinct from `rail_extra`, which lists what is drawn BESIDE the metro.
`scaffold_city.py --mode` writes it; `cities.py` raises at import without
it. Decide it from what step 1 keeps (GTFS `route_type`, OSM `route=`),
not the system's marketing name, and put it to the owner with the build.
- **Fill = `coverage`**: `"full"` (solid), `"narrowed"` (bottom half) or
`"one_bucket"` (hollow ring). `check_macro_facts.py` holds it to the
city's table B row, and `check_inconsistency_list.py` to `categories`.
A token third layer (Hong Kong's 34 bathhouses) does not lift a city out
of one category, but that is the owner's call per city, recorded in
`ONE_BUCKET_BY_OWNER` in `check_macro_facts.py`, never a threshold.
- The dots are 12 px (`DOT_PX` in `app/Overview.py`, `MARKER_R` in
`check_macro_labels.py`), so score the new label with that size.
The allowed values are `FIELDS` in `scripts/check_inconsistency_list.py`,
which refuses a missing field or a value not on the list. A genuinely new
kind (a new record type) is added there on purpose, never misspelled into
place. Read the values off the city's rows in tables A and B of
`docs/map_inconsistencies.md` (Step 9), so the two agree.
- **After the map is rendered and committed, run
`python scripts/check_ring_shares.py --write`** and commit
`app/ring_shares.json`. It counts the city's in-ring share from its map for
the same table and the page's share range. The check (in the pre-push hook)
refuses a city with no share, or a re-render that moved the counts.
- **Grep the city page for hardcoded prose** - words like "six lines",
station counts, "half mile" are literal text, not computed values, and go
stale when the pipeline changes. Prefer wording that doesn't restate
numbers; where it must, re-check it each time the city's pipeline changes.
Grep it for `outputs/`, `pipeline/`, `scripts/` and `.py` too: none belongs
in page text.
- **Never let the city's name become the project's identity.**
Then check the page with one quick browser render; the `deploy-verify` agent
(scope `city-added`: lean venv, the macro map shows every city, clicking each
marker opens its page, each switcher works, each map renders) runs once per
batch at review time (`CLAUDE.md`, `publish-city`).
## Step 9 - Commit, log, plan
- Run `python pipeline/drift_check.py <city>` after committing the city, to
confirm outputs regenerate identically.
- Add decisions entries (see the `decisions-entry` skill), in the session's
drafts file `docs/decisions_drafts/<session-or-branch>.md`, never in
`DECISIONS.md` itself (`CLAUDE.md`): the city added with per-step row counts, every scoping decision and rejected
alternative, workarounds (a mirror host, a hand-curated alias list), and
known limitations. Counts go there, not in `project_context.md`.
- Update `docs/project_context.md` (current state only - which cities exist,
what's distinctive; no counts), `docs/city_master_list.md` (the Built
table, and the city's band row; `scripts/check_master_list_counts.py`
checks the counts), and tick or add
items in `PLAN.md`.
- **Add the city to `docs/map_inconsistencies.md`**: one row in each of its
four tables, under its country, from the city's page, config and outputs;
and add it to any theme it changes, such as a missing category, a different
ring size or a regional scope. The owner wants this list current as cities
land, so that "why is this map different?" has one answer when it goes on
the site. `python scripts/check_inconsistency_list.py` fails until every
table has the row and the city's `app/cities.py` entry has its three
summary fields (Step 8). Check that the fields agree with the rows you just
wrote.
- **Run `python scripts/check_provenance.py` and make it name your city OK.**
This replaces confirming the rows by eye, because confirming by eye is what
failed: the sentence below has been in this skill since the start, was
followed for the US cities, and was silently skipped for every city that
arrived through a country profile instead. The script checks that the
city has a row in all three provenance tables, that **every URL its
`config.py` actually resolves to** appears in `docs/data_sources.md` or a
`docs/data_sources/` country file, and
that the notices list and `app/components.py`'s `_NOTICES` still match.
A city listed under `KNOWN_GAPS` there is a defect with a date on it, not a
city that passed.
- **Confirm the city's licence rows are actually in `docs/data_sources/<country>.md`**
(Step 0 item 4) before calling the city done - endpoint, filter, retrieval
date, licence, and any notice the source requires. A city whose data is
mapped but whose terms are unrecorded is not finished, because the gap is
invisible afterwards: it looks exactly like a city that was checked.
- **Count the source kinds, not the cities.** Four inputs have been missed this
way, every one of them a source that is neither a business registry, a
transit feed nor the city boundary: Vancouver's parcel + tax-report
**residence join**, the Province of British Columbia's **naming layer**, and
the same residence join in San Diego, San Francisco and Los Angeles. If a
file under `data/<city>/raw/` came from the internet, it needs a row, even
when nothing it produces is drawn on the map. `check_provenance.py` finds
these by importing the config, which is why it catches what a checklist
organised by city does not.
- Keep the honesty convention: mark what's verified vs. still a skeleton.