Ciaran Sweet commited on
Commit
05c2301
·
unverified ·
2 Parent(s): e23e10db20c32c

feat: add 'urban_centres' dataset for better city coverage and add population ranking

Browse files
DEPLOY_TO_HF.md CHANGED
@@ -91,11 +91,13 @@ Ensure normalized data exists locally:
91
  data/overture_normalized/divisions_area/*.parquet
92
  data/overture_normalized/localities/*.parquet
93
  data/natural_earth_normalized/ne_geography.parquet
 
94
  ```
95
 
96
- `localities/` holds cities and towns. Only fuzzy search reads it; the
97
- natural-language pipeline reads `divisions_area/` alone, because the model
98
- was not trained on those subtypes.
 
99
 
100
  ### Upload dataset
101
 
@@ -113,6 +115,13 @@ hf upload developmentseed/gazet-geodata \
113
  /natural_earth_normalized \
114
  --repo-type dataset \
115
  --commit-message <message>
 
 
 
 
 
 
 
116
  ```
117
 
118
  The HF repo will only contain:
@@ -124,8 +133,10 @@ gazet-geodata/
124
  │ │ └── *.parquet
125
  │ └── localities/
126
  │ └── *.parquet
127
- └── natural_earth_normalized/
128
- └── ne_geography.parquet
 
 
129
  ```
130
 
131
  This keeps the repo size minimal - only what's needed for deployment.
@@ -218,6 +229,12 @@ hf upload developmentseed/gazet-geodata \
218
  /natural_earth_normalized \
219
  --repo-type dataset \
220
  --commit-message <message>
 
 
 
 
 
 
221
  ```
222
 
223
  The Space will use the updated data on next rebuild.
 
91
  data/overture_normalized/divisions_area/*.parquet
92
  data/overture_normalized/localities/*.parquet
93
  data/natural_earth_normalized/ne_geography.parquet
94
+ data/urban_centres_normalized/*.parquet
95
  ```
96
 
97
+ `localities/` holds cities and towns, and `urban_centres_normalized/` the
98
+ GHSL urban centres. Only fuzzy search reads them; the natural-language
99
+ pipeline reads `divisions_area/` alone, because the model was not trained on
100
+ those subtypes.
101
 
102
  ### Upload dataset
103
 
 
115
  /natural_earth_normalized \
116
  --repo-type dataset \
117
  --commit-message <message>
118
+
119
+ # Upload GHSL urban centres
120
+ hf upload developmentseed/gazet-geodata \
121
+ ./data/urban_centres_normalized \
122
+ /urban_centres_normalized \
123
+ --repo-type dataset \
124
+ --commit-message <message>
125
  ```
126
 
127
  The HF repo will only contain:
 
133
  │ │ └── *.parquet
134
  │ └── localities/
135
  │ └── *.parquet
136
+ ├── natural_earth_normalized/
137
+ │ └── ne_geography.parquet
138
+ └── urban_centres_normalized/
139
+ └── *.parquet
140
  ```
141
 
142
  This keeps the repo size minimal - only what's needed for deployment.
 
229
  /natural_earth_normalized \
230
  --repo-type dataset \
231
  --commit-message <message>
232
+
233
+ hf upload developmentseed/gazet-geodata \
234
+ ./data/urban_centres_normalized \
235
+ /urban_centres_normalized \
236
+ --repo-type dataset \
237
+ --commit-message <message>
238
  ```
239
 
240
  The Space will use the updated data on next rebuild.
README.md CHANGED
@@ -31,15 +31,18 @@ uv sync --extra dev --extra demo
31
 
32
  ### Data preparation
33
 
34
- 1. Download Overture divisions data
35
  2. Download the 10m physical layer from [Natural Earth](https://www.naturalearthdata.com/downloads/10m-physical-vectors/)
36
  3. Unzip the data
37
  4. Convert natural earth data to parquet
 
 
38
 
39
  Example for downloading overture
40
 
41
  ```bash
42
  aws s3 sync s3://overturemaps-us-west-2/release/2026-02-18.0/theme=divisions/type=division_area/ data/overture/divisions_area
 
43
  ```
44
 
45
  Example for running conversion script for natural earth
@@ -49,6 +52,13 @@ unzip ~/Downloads/10m_physical.zip -d data/natural_earth
49
  python -m ingest.convert_natural_earth data/natural_earth
50
  ```
51
 
 
 
 
 
 
 
 
52
  ### Based on ollama
53
 
54
  For now, gazet relies on [ollama](https://ollama.com/). For remote (cloud) models, ensure you are loged into Ollama.
@@ -90,8 +100,9 @@ uv run streamlit run gazet_demo.py # demo UI
90
  ## Design notes
91
 
92
  - `api.py` exposes GET `/search?q=<query>`; returns GeoJSON FeatureCollection and logs intermediate output. This path runs two LLM calls (place extraction, then SQL generation for candidate selection/disambiguation and GIS ops).
93
- - `api.py` also exposes GET `/search/fuzzy?q=<place name>&limit=5&simplify=true&sources=divisions_area,natural_earth` — pure Jaro-Winkler fuzzy match with no LLM involved. `q` is a place-name string, not a natural-language query (there's no place-extraction step). Returns the combined top-`limit` matches across sources as a GeoJSON FeatureCollection with raw (or simplified, if `simplify=true`) geometry attached. Pass `ids_only=true` to skip full geometry and get back `{"ids": [{"source", "id", "name", "matched_name", "country", "subtype", "admin_level", "bbox", "similarity", "is_substring_match"}, ...]}` — `country`/`subtype`/`admin_level` disambiguate same-named places (e.g. multiple real "Loja"s across Ecuador and Spain, or Ecuador's "Loja" region vs. its nested "Loja" county), `bbox` is `[minx, miny, maxx, maxy]`, a much smaller payload than full geometry, for picking a candidate before fetching its full geometry, and `similarity` is the score the list was ranked on — Jaro-Winkler always returns a best row, so without it a caller cannot tell a hit from the closest thing to a miss.
94
- - Fuzzy search (`mode=fuzzy`) also searches cities and towns (Overture `locality` and `localadmin`), stored apart in `overture_normalized/localities/`. The natural-language mode never sees them, because its model was not trained on them.
 
95
  - Fuzzy matching (`search.py`) ranks exact-substring matches (query literally contained in the name, e.g. "Loja" in "Loja Province") ahead of pure edit-distance near-misses (e.g. "Lodja"), even when the near-miss scores a slightly higher raw Jaro-Winkler similarity. Each record is matched under two names and keeps the better score: its own (`names.primary`) and its English one (`names.common.en` for `divisions_area`, `names.en` for `natural_earth`). Neither works alone — `names.common.en` is null for 77% of `divisions_area` rows, which matching on English alone would drop, while matching on `names.primary` alone leaves a place unreachable by the English name it is usually asked for ("Copenhagen" for `Københavns Kommune`). `matched_name` reports which of the two the query hit; `name` is the English one where the record has one, the same expression `/geometry/{id}` uses, so a candidate and a later lookup of it agree on what the place is called.
96
  - `api.py` also exposes GET `/geometry/{id}?source=divisions_area&simplify=true` — fetch a single feature's geometry directly by ID, no fuzzy matching or LLM involved. `source` is inferred from the ID if omitted (Natural Earth IDs are prefixed `ne_`). Returns a single GeoJSON Feature, 404 if not found.
97
  - The spatial extension is loaded once at API startup (FastAPI `lifespan`) onto a shared DuckDB connection stored on `app.state`; each request gets a cheap `cursor()` off it instead of paying DuckDB's ~90ms `LOAD spatial` cost per call.
 
31
 
32
  ### Data preparation
33
 
34
+ 1. Download Overture divisions data: the areas, and the division records that carry each place's population
35
  2. Download the 10m physical layer from [Natural Earth](https://www.naturalearthdata.com/downloads/10m-physical-vectors/)
36
  3. Unzip the data
37
  4. Convert natural earth data to parquet
38
+ 5. Download the [GHSL Urban Centre Database](https://human-settlement.emergency.copernicus.eu/ghs_ucdb_2024.php) (GHS-UCDB R2024A): named city polygons for places Overture has only as a point, such as London
39
+ 6. Normalize everything: `python -m dataset.scripts.normalize_geodata`
40
 
41
  Example for downloading overture
42
 
43
  ```bash
44
  aws s3 sync s3://overturemaps-us-west-2/release/2026-02-18.0/theme=divisions/type=division_area/ data/overture/divisions_area
45
+ aws s3 sync s3://overturemaps-us-west-2/release/2026-02-18.0/theme=divisions/type=division/ data/overture/division
46
  ```
47
 
48
  Example for running conversion script for natural earth
 
52
  python -m ingest.convert_natural_earth data/natural_earth
53
  ```
54
 
55
+ Example for downloading the GHSL urban centres
56
+
57
+ ```bash
58
+ curl -LO https://jeodpp.jrc.ec.europa.eu/ftp/jrc-opendata/GHSL/GHS_UCDB_GLOBE_R2024A/GHS_UCDB_GLOBE_R2024A/V1-1/GHS_UCDB_GLOBE_R2024A_V1_1.zip
59
+ unzip GHS_UCDB_GLOBE_R2024A_V1_1.zip GHS_UCDB_GLOBE_R2024A.gpkg -d data/ghsl
60
+ ```
61
+
62
  ### Based on ollama
63
 
64
  For now, gazet relies on [ollama](https://ollama.com/). For remote (cloud) models, ensure you are loged into Ollama.
 
100
  ## Design notes
101
 
102
  - `api.py` exposes GET `/search?q=<query>`; returns GeoJSON FeatureCollection and logs intermediate output. This path runs two LLM calls (place extraction, then SQL generation for candidate selection/disambiguation and GIS ops).
103
+ - `api.py` also exposes GET `/search/fuzzy?q=<place name>&limit=5&simplify=true&sources=divisions_area,natural_earth,urban_centres` — pure Jaro-Winkler fuzzy match with no LLM involved. `q` is a place-name string, not a natural-language query (there's no place-extraction step). Returns the combined top-`limit` matches across sources as a GeoJSON FeatureCollection with raw (or simplified, if `simplify=true`) geometry attached. Pass `ids_only=true` to skip full geometry and get back `{"ids": [{"source", "id", "name", "matched_name", "country", "subtype", "admin_level", "bbox", "similarity", "is_substring_match"}, ...]}` — `country`/`subtype`/`admin_level` disambiguate same-named places (e.g. multiple real "Loja"s across Ecuador and Spain, or Ecuador's "Loja" region vs. its nested "Loja" county), `bbox` is `[minx, miny, maxx, maxy]`, a much smaller payload than full geometry, for picking a candidate before fetching its full geometry, and `similarity` is the score the list was ranked on — Jaro-Winkler always returns a best row, so without it a caller cannot tell a hit from the closest thing to a miss.
104
+ - Fuzzy search (`mode=fuzzy`) also searches cities and towns (Overture `locality` and `localadmin`), stored apart in `overture_normalized/localities/`, and GHSL urban centres (`urban_centres_normalized/`, ids prefixed `ghsl_`): named city polygons for places Overture has only as a point, such as London. The natural-language mode never sees either, because its model was not trained on them.
105
+ - Among equal scores, fuzzy search puts the more populous place first, so "London" returns the UK's before a same-named town. Population comes from Overture's `division` records and from GHSL; a place without one sorts last.
106
  - Fuzzy matching (`search.py`) ranks exact-substring matches (query literally contained in the name, e.g. "Loja" in "Loja Province") ahead of pure edit-distance near-misses (e.g. "Lodja"), even when the near-miss scores a slightly higher raw Jaro-Winkler similarity. Each record is matched under two names and keeps the better score: its own (`names.primary`) and its English one (`names.common.en` for `divisions_area`, `names.en` for `natural_earth`). Neither works alone — `names.common.en` is null for 77% of `divisions_area` rows, which matching on English alone would drop, while matching on `names.primary` alone leaves a place unreachable by the English name it is usually asked for ("Copenhagen" for `Københavns Kommune`). `matched_name` reports which of the two the query hit; `name` is the English one where the record has one, the same expression `/geometry/{id}` uses, so a candidate and a later lookup of it agree on what the place is called.
107
  - `api.py` also exposes GET `/geometry/{id}?source=divisions_area&simplify=true` — fetch a single feature's geometry directly by ID, no fuzzy matching or LLM involved. `source` is inferred from the ID if omitted (Natural Earth IDs are prefixed `ne_`). Returns a single GeoJSON Feature, 404 if not found.
108
  - The spatial extension is loaded once at API startup (FastAPI `lifespan`) onto a shared DuckDB connection stored on `app.state`; each request gets a cheap `cursor()` off it instead of paying DuckDB's ~90ms `LOAD spatial` cost per call.
dataset/scripts/normalize_geodata.py CHANGED
@@ -13,6 +13,7 @@ Output layout under data/ by default:
13
  overture_normalized/divisions_area/part-000.parquet
14
  overture_normalized/localities/part-000.parquet
15
  natural_earth_normalized/ne_geography.parquet
 
16
 
17
  Cities and towns (Overture ``locality`` and ``localadmin``) go to their own
18
  file. Fuzzy search reads it; the training pipeline and the natural-language
@@ -21,7 +22,14 @@ those subtypes.
21
 
22
  Both Overture files also store ``search_names``: every name a record
23
  answers to, folded for comparison, so fuzzy search does not rebuild and
24
- fold them on every query.
 
 
 
 
 
 
 
25
  """
26
 
27
  from pathlib import Path
@@ -29,12 +37,24 @@ from pathlib import Path
29
  import duckdb
30
 
31
  from gazet.config import _DATA_DIR
32
- from gazet.search import DIVISIONS_AREA_SEARCH_NAMES, SEARCH_NAMES_COLUMN
 
 
 
 
33
 
34
  #: The subtypes the natural-language model was trained on.
35
  TRAINED_SUBTYPES = ("country", "region", "county")
36
  #: Cities and towns, for fuzzy search only.
37
  LOCALITY_SUBTYPES = ("localadmin", "locality")
 
 
 
 
 
 
 
 
38
 
39
 
40
  def normalize_geodata(output_root: Path | None = None) -> dict[str, str]:
@@ -51,7 +71,13 @@ def normalize_geodata(output_root: Path | None = None) -> dict[str, str]:
51
  overture_dir = root / "overture_normalized" / "divisions_area"
52
  localities_dir = root / "overture_normalized" / "localities"
53
  natural_earth_dir = root / "natural_earth_normalized"
54
- for directory in (overture_dir, localities_dir, natural_earth_dir):
 
 
 
 
 
 
55
  directory.mkdir(parents=True, exist_ok=True)
56
 
57
  natural_earth_path = natural_earth_dir / "ne_geography.parquet"
@@ -69,9 +95,14 @@ def normalize_geodata(output_root: Path | None = None) -> dict[str, str]:
69
  f"""
70
  COPY (
71
  SELECT
72
- * REPLACE (ST_GeomFromWKB(ST_AsWKB(geometry)) AS geometry),
 
73
  {DIVISIONS_AREA_SEARCH_NAMES} AS {SEARCH_NAMES_COLUMN}
74
- FROM read_parquet('{root / "overture/divisions_area/*.parquet"}')
 
 
 
 
75
  WHERE geometry IS NOT NULL
76
  AND subtype IN {subtypes}
77
  AND is_land = true
@@ -90,12 +121,57 @@ def normalize_geodata(output_root: Path | None = None) -> dict[str, str]:
90
  ) TO '{natural_earth_path}' (FORMAT PARQUET)
91
  """
92
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  con.close()
94
 
95
  return {
96
  "divisions_area": str(overture_dir / "*.parquet"),
97
  "localities": str(localities_dir / "*.parquet"),
98
  "natural_earth": str(natural_earth_path),
 
99
  }
100
 
101
 
 
13
  overture_normalized/divisions_area/part-000.parquet
14
  overture_normalized/localities/part-000.parquet
15
  natural_earth_normalized/ne_geography.parquet
16
+ urban_centres_normalized/part-000.parquet
17
 
18
  Cities and towns (Overture ``locality`` and ``localadmin``) go to their own
19
  file. Fuzzy search reads it; the training pipeline and the natural-language
 
22
 
23
  Both Overture files also store ``search_names``: every name a record
24
  answers to, folded for comparison, so fuzzy search does not rebuild and
25
+ fold them on every query. They also store ``population``, joined from
26
+ Overture's ``division`` records, so fuzzy search can rank a city above a
27
+ same-named village.
28
+
29
+ Urban centres come from the GHSL Urban Centre Database (GHS-UCDB R2024A):
30
+ named city polygons for places Overture has only as a point, such as
31
+ London. They are a source of their own, never merged into an Overture
32
+ record, so every result traces back to one dataset.
33
  """
34
 
35
  from pathlib import Path
 
37
  import duckdb
38
 
39
  from gazet.config import _DATA_DIR
40
+ from gazet.search import (
41
+ DIVISIONS_AREA_SEARCH_NAMES,
42
+ SEARCH_NAMES_COLUMN,
43
+ search_names_sql,
44
+ )
45
 
46
  #: The subtypes the natural-language model was trained on.
47
  TRAINED_SUBTYPES = ("country", "region", "county")
48
  #: Cities and towns, for fuzzy search only.
49
  LOCALITY_SUBTYPES = ("localadmin", "locality")
50
+ #: The GHS-UCDB layer that carries each urban centre's names, country and
51
+ #: population. The other layers repeat the same polygons with other attributes.
52
+ GHSL_UCDB_LAYER = "GHS_UCDB_THEME_GENERAL_CHARACTERISTICS_GLOBE_R2024A"
53
+
54
+
55
+ def _ghsl(column: str) -> str:
56
+ """A GHS-UCDB column, whose name and text values start with a byte-order mark."""
57
+ return f'ltrim("\ufeff{column}", chr(65279))'
58
 
59
 
60
  def normalize_geodata(output_root: Path | None = None) -> dict[str, str]:
 
71
  overture_dir = root / "overture_normalized" / "divisions_area"
72
  localities_dir = root / "overture_normalized" / "localities"
73
  natural_earth_dir = root / "natural_earth_normalized"
74
+ urban_centres_dir = root / "urban_centres_normalized"
75
+ for directory in (
76
+ overture_dir,
77
+ localities_dir,
78
+ natural_earth_dir,
79
+ urban_centres_dir,
80
+ ):
81
  directory.mkdir(parents=True, exist_ok=True)
82
 
83
  natural_earth_path = natural_earth_dir / "ne_geography.parquet"
 
95
  f"""
96
  COPY (
97
  SELECT
98
+ area.* REPLACE (ST_GeomFromWKB(ST_AsWKB(area.geometry)) AS geometry),
99
+ division.population,
100
  {DIVISIONS_AREA_SEARCH_NAMES} AS {SEARCH_NAMES_COLUMN}
101
+ FROM read_parquet('{root / "overture/divisions_area/*.parquet"}') AS area
102
+ LEFT JOIN (
103
+ SELECT id, population
104
+ FROM read_parquet('{root / "overture/division/*.parquet"}')
105
+ ) AS division ON division.id = area.division_id
106
  WHERE geometry IS NOT NULL
107
  AND subtype IN {subtypes}
108
  AND is_land = true
 
121
  ) TO '{natural_earth_path}' (FORMAT PARQUET)
122
  """
123
  )
124
+
125
+ # Shaped like the Overture files, so search and lookup read every source
126
+ # the same way. Names come as one "; "-separated list of alternates.
127
+ alternates = f"string_split({_ghsl('GC_UCN_LIS_2025')}, '; ')"
128
+ con.execute(
129
+ f"""
130
+ COPY (
131
+ SELECT
132
+ * EXCLUDE (alternate_names),
133
+ {search_names_sql("names.primary", "CAST(NULL AS VARCHAR)", "alternate_names")}
134
+ AS {SEARCH_NAMES_COLUMN}
135
+ FROM (
136
+ SELECT
137
+ 'ghsl_' || CAST("\ufeffID_UC_G0" AS VARCHAR) AS id,
138
+ ST_GeomFromWKB(ST_AsWKB(geometry)) AS geometry,
139
+ {{
140
+ 'xmin': ST_XMin(geometry), 'xmax': ST_XMax(geometry),
141
+ 'ymin': ST_YMin(geometry), 'ymax': ST_YMax(geometry)
142
+ }} AS bbox,
143
+ {_ghsl("GC_CNT_GAD_2025")} AS country,
144
+ 'urban_centre' AS subtype,
145
+ CAST(NULL AS VARCHAR) AS class,
146
+ {{'primary': {_ghsl("GC_UCN_MAI_2025")}}} AS names,
147
+ CAST(NULL AS VARCHAR) AS region,
148
+ CAST(NULL AS INTEGER) AS admin_level,
149
+ true AS is_land,
150
+ CAST(NULL AS BOOLEAN) AS is_territorial,
151
+ CAST(round("\ufeffGC_POP_TOT_2025") AS BIGINT) AS population,
152
+ {alternates} AS alternate_names
153
+ FROM (
154
+ SELECT
155
+ *,
156
+ -- Mollweide to lon/lat.
157
+ ST_Transform(geom, 'ESRI:54009', 'EPSG:4326', always_xy := true)
158
+ AS geometry
159
+ FROM st_read(
160
+ '{root / "ghsl/GHS_UCDB_GLOBE_R2024A.gpkg"}',
161
+ layer = '{GHSL_UCDB_LAYER}'
162
+ )
163
+ )
164
+ )
165
+ ) TO '{urban_centres_dir / "part-000.parquet"}' (FORMAT PARQUET)
166
+ """
167
+ )
168
  con.close()
169
 
170
  return {
171
  "divisions_area": str(overture_dir / "*.parquet"),
172
  "localities": str(localities_dir / "*.parquet"),
173
  "natural_earth": str(natural_earth_path),
174
+ "urban_centres": str(urban_centres_dir / "*.parquet"),
175
  }
176
 
177
 
src/gazet/api.py CHANGED
@@ -32,7 +32,8 @@ from .sql import run_geo_sql_dspy, run_geo_sql_gguf
32
  #: endpoint reports the backends, not the code.
33
  API_VERSION = version("gazet")
34
 
35
- _FUZZY_SOURCES = ("divisions_area", "natural_earth")
 
36
 
37
  SearchMode = Literal["nl", "fuzzy"]
38
 
@@ -61,8 +62,8 @@ _Q_SIMPLIFY = Query(
61
  _Q_SOURCES = Query(
62
  None,
63
  description=(
64
- "Comma-separated subset of divisions_area,natural_earth for mode=fuzzy "
65
- "(defaults to both); ignored for mode=nl."
66
  ),
67
  )
68
  _Q_IDS_ONLY = Query(
@@ -293,13 +294,16 @@ def health(request: Request) -> dict[str, Any]:
293
  def sources(request: Request) -> dict[str, Any]:
294
  """List available data sources with row counts and name ranges."""
295
  con = request.app.state.duckdb_con
296
- from .config import DIVISIONS_AREA_PATH, NATURAL_EARTH_PATH
297
 
298
  info = {}
299
  for name, path in [
300
  ("divisions_area", DIVISIONS_AREA_PATH),
301
  ("natural_earth", NATURAL_EARTH_PATH),
 
302
  ]:
 
 
303
  try:
304
  row = con.execute(
305
  f"SELECT COUNT(*) as count, MIN(names.primary) as min_name, MAX(names.primary) as max_name FROM read_parquet('{path}')"
@@ -362,9 +366,10 @@ def _fuzzy_search(
362
  ``q`` is a place-name string (not a natural-language query) — no
363
  place-extraction step. Matches are ranked by Jaro-Winkler similarity
364
  across the requested ``sources`` (comma-separated subset of
365
- divisions_area/natural_earth; defaults to both), combined and truncated
366
- to the top ``limit``. Each record is matched under both its own name and
367
- its English one, so "Copenhagen" and "København" find the same place.
 
368
 
369
  Pass ``ids_only=true`` to skip fetching full geometry and get back
370
  lightweight candidates (id/name/country/subtype/admin_level/bbox, plus
@@ -403,7 +408,13 @@ def _fuzzy_search(
403
  candidates_df = (
404
  pd.concat(candidate_dfs, ignore_index=True)
405
  .drop_duplicates(subset=["source", "id"])
406
- .sort_values(["is_substring_match", "similarity"], ascending=[False, False])
 
 
 
 
 
 
407
  .head(limit)
408
  .reset_index(drop=True)
409
  )
@@ -533,7 +544,7 @@ def search_fuzzy(
533
  simplify: bool = Query(True, description="Simplify geometry to GeoJSON."),
534
  sources: str | None = Query(
535
  None,
536
- description="Comma-separated subset of divisions_area,natural_earth (defaults to both).",
537
  ),
538
  ids_only: bool = Query(
539
  False,
@@ -558,16 +569,17 @@ def get_geometry(
558
  id: str,
559
  source: str | None = Query(
560
  None,
561
- description="Restrict lookup to 'divisions_area' or 'natural_earth'; inferred from id if omitted.",
562
  ),
563
  simplify: bool = Query(True, description="Simplify geometry to GeoJSON."),
564
  ) -> dict[str, Any]:
565
  """Fetch a single feature's geometry directly by ID — no fuzzy matching, no LLM.
566
 
567
- ``source`` restricts the lookup to ``divisions_area`` or ``natural_earth``;
568
- if omitted, it's inferred from the ID (Natural Earth IDs are prefixed
569
- ``ne_``). Returns a single GeoJSON Feature, or 404 if the ID doesn't exist
570
- in the resolved source.
 
571
  """
572
  if source is not None and source not in _FUZZY_SOURCES:
573
  raise HTTPException(status_code=400, detail=f"Unknown source: {source}")
 
32
  #: endpoint reports the backends, not the code.
33
  API_VERSION = version("gazet")
34
 
35
+ #: urban_centres is fuzzy-only: the natural-language model was not trained on it.
36
+ _FUZZY_SOURCES = ("divisions_area", "natural_earth", "urban_centres")
37
 
38
  SearchMode = Literal["nl", "fuzzy"]
39
 
 
62
  _Q_SOURCES = Query(
63
  None,
64
  description=(
65
+ "Comma-separated subset of divisions_area,natural_earth,urban_centres "
66
+ "for mode=fuzzy (defaults to all); ignored for mode=nl."
67
  ),
68
  )
69
  _Q_IDS_ONLY = Query(
 
294
  def sources(request: Request) -> dict[str, Any]:
295
  """List available data sources with row counts and name ranges."""
296
  con = request.app.state.duckdb_con
297
+ from .config import DIVISIONS_AREA_PATH, NATURAL_EARTH_PATH, URBAN_CENTRES_PATH
298
 
299
  info = {}
300
  for name, path in [
301
  ("divisions_area", DIVISIONS_AREA_PATH),
302
  ("natural_earth", NATURAL_EARTH_PATH),
303
+ ("urban_centres", URBAN_CENTRES_PATH),
304
  ]:
305
+ if not path:
306
+ continue
307
  try:
308
  row = con.execute(
309
  f"SELECT COUNT(*) as count, MIN(names.primary) as min_name, MAX(names.primary) as max_name FROM read_parquet('{path}')"
 
366
  ``q`` is a place-name string (not a natural-language query) — no
367
  place-extraction step. Matches are ranked by Jaro-Winkler similarity
368
  across the requested ``sources`` (comma-separated subset of
369
+ divisions_area/natural_earth/urban_centres; defaults to all), combined
370
+ and truncated to the top ``limit``, the more populous place first among
371
+ equal scores. Each record is matched under both its own name and its
372
+ English one, so "Copenhagen" and "København" find the same place.
373
 
374
  Pass ``ids_only=true`` to skip fetching full geometry and get back
375
  lightweight candidates (id/name/country/subtype/admin_level/bbox, plus
 
408
  candidates_df = (
409
  pd.concat(candidate_dfs, ignore_index=True)
410
  .drop_duplicates(subset=["source", "id"])
411
+ # Stable, so rows that tie on all three keep each source's order.
412
+ .sort_values(
413
+ ["is_substring_match", "similarity", "population"],
414
+ ascending=[False, False, False],
415
+ na_position="last",
416
+ kind="stable",
417
+ )
418
  .head(limit)
419
  .reset_index(drop=True)
420
  )
 
544
  simplify: bool = Query(True, description="Simplify geometry to GeoJSON."),
545
  sources: str | None = Query(
546
  None,
547
+ description="Comma-separated subset of divisions_area,natural_earth,urban_centres (defaults to all).",
548
  ),
549
  ids_only: bool = Query(
550
  False,
 
569
  id: str,
570
  source: str | None = Query(
571
  None,
572
+ description="Restrict lookup to 'divisions_area', 'natural_earth' or 'urban_centres'; inferred from id if omitted.",
573
  ),
574
  simplify: bool = Query(True, description="Simplify geometry to GeoJSON."),
575
  ) -> dict[str, Any]:
576
  """Fetch a single feature's geometry directly by ID — no fuzzy matching, no LLM.
577
 
578
+ ``source`` restricts the lookup to ``divisions_area``, ``natural_earth``
579
+ or ``urban_centres``; if omitted, it's inferred from the ID (Natural
580
+ Earth IDs are prefixed ``ne_``, urban centre IDs ``ghsl_``). Returns a
581
+ single GeoJSON Feature, or 404 if the ID doesn't exist in the resolved
582
+ source.
583
  """
584
  if source is not None and source not in _FUZZY_SOURCES:
585
  raise HTTPException(status_code=400, detail=f"Unknown source: {source}")
src/gazet/config.py CHANGED
@@ -32,16 +32,24 @@ DIVISIONS_AREA_PATH = str(
32
  _DATA_DIR / "overture/divisions_area/*.parquet",
33
  )
34
  )
 
 
 
 
 
 
 
 
 
35
  # Cities and towns (Overture locality and localadmin), kept out of
36
  # DIVISIONS_AREA_PATH because the natural-language model was not trained on
37
- # them. Only fuzzy search reads them; empty where they were never built.
38
- _LOCALITIES_GLOB = _DATA_DIR / "overture_normalized/localities/*.parquet"
39
- LOCALITIES_PATH = (
40
- str(_LOCALITIES_GLOB)
41
- if os.environ.get("GAZET_USE_NORMALIZED_DATA", "1") != "0"
42
- and any(_LOCALITIES_GLOB.parent.glob(_LOCALITIES_GLOB.name))
43
- else ""
44
  )
 
 
 
45
  NATURAL_EARTH_PATH = str(
46
  _prefer_normalized(
47
  _DATA_DIR / "natural_earth_normalized/ne_geography.parquet",
 
32
  _DATA_DIR / "overture/divisions_area/*.parquet",
33
  )
34
  )
35
+
36
+
37
+ def _normalized_only(glob: pathlib.Path) -> str:
38
+ """A dataset that exists only as a normalized copy; empty where it was never built."""
39
+ if os.environ.get("GAZET_USE_NORMALIZED_DATA", "1") == "0":
40
+ return ""
41
+ return str(glob) if any(glob.parent.glob(glob.name)) else ""
42
+
43
+
44
  # Cities and towns (Overture locality and localadmin), kept out of
45
  # DIVISIONS_AREA_PATH because the natural-language model was not trained on
46
+ # them. Only fuzzy search reads them.
47
+ LOCALITIES_PATH = _normalized_only(
48
+ _DATA_DIR / "overture_normalized/localities/*.parquet"
 
 
 
 
49
  )
50
+ # GHSL urban centres: named city polygons for places Overture has only as a
51
+ # point, such as London. Only fuzzy search reads them.
52
+ URBAN_CENTRES_PATH = _normalized_only(_DATA_DIR / "urban_centres_normalized/*.parquet")
53
  NATURAL_EARTH_PATH = str(
54
  _prefer_normalized(
55
  _DATA_DIR / "natural_earth_normalized/ne_geography.parquet",
src/gazet/search.py CHANGED
@@ -4,7 +4,12 @@ from functools import cache, partial
4
  import duckdb
5
  import pandas as pd
6
 
7
- from .config import DIVISIONS_AREA_PATH, LOCALITIES_PATH, NATURAL_EARTH_PATH
 
 
 
 
 
8
  from .schemas import Place
9
 
10
  logger = logging.getLogger(__name__)
@@ -73,24 +78,37 @@ DIVISIONS_AREA_SEARCH_NAMES = search_names_sql(
73
  NATURAL_EARTH_SEARCH_NAMES = search_names_sql(PRIMARY_NAME, NATURAL_EARTH_ENGLISH_NAME)
74
 
75
 
 
 
 
 
 
 
 
76
  @cache
77
- def _stores_search_names(path: str) -> bool:
78
- """Whether a parquet file (or glob) carries the stored search names."""
79
  columns = duckdb.connect().execute("DESCRIBE SELECT * FROM read_parquet(?)", [path])
80
- return SEARCH_NAMES_COLUMN in {row[0] for row in columns.fetchall()}
81
 
82
 
83
- def _search_names(path: str | list[str], computed: str) -> str:
84
- """The stored search names where every file has them, else ``computed``.
85
 
86
- Raw Overture downloads and the test fixtures have no stored column.
 
87
  """
88
  paths = [path] if isinstance(path, str) else path
89
- if all(_stores_search_names(p) for p in paths):
90
- return SEARCH_NAMES_COLUMN
91
  return computed
92
 
93
 
 
 
 
 
 
94
  def _divisions_area_paths(include_localities: bool) -> str | list[str]:
95
  """The divisions_area file, and the localities beside it where asked for.
96
 
@@ -115,6 +133,7 @@ def simple_fuzzy_search(
115
  include_geometry: bool = False,
116
  include_bbox: bool = False,
117
  bbox_expr: str = GEOMETRY_BBOX,
 
118
  ) -> pd.DataFrame:
119
  """Jaro-Winkler similarity search using only the place name.
120
 
@@ -137,6 +156,10 @@ def simple_fuzzy_search(
137
  Jaro-Winkler similarity — substring containment is a stronger signal of
138
  a true match than character-level similarity alone.
139
 
 
 
 
 
140
  ``include_geometry``/``include_bbox`` require the spatial extension to
141
  already be loaded on ``con`` (``INSTALL spatial; LOAD spatial;``).
142
  ``include_bbox`` returns ``[minx, miny, maxx, maxy]`` from ``bbox_expr``
@@ -163,6 +186,7 @@ def simple_fuzzy_search(
163
  admin_level,
164
  is_land,
165
  is_territorial{extra_clause}{bbox_clause}{carried_geometry},
 
166
  {_readable(name_expr)} AS primary_name,
167
  {_readable(english_expr)} AS english_name,
168
  {search_names_expr} AS search_names
@@ -192,7 +216,8 @@ def simple_fuzzy_search(
192
  -- id last, so rows that tie on everything else (such as
193
  -- same-named towns, which have no admin_level) come back
194
  -- in the same order on every run.
195
- ORDER BY best.substring DESC, best.similarity DESC, admin_level ASC, id
 
196
  LIMIT $3
197
  )
198
  SELECT
@@ -205,11 +230,13 @@ def simple_fuzzy_search(
205
  admin_level,
206
  is_land,
207
  is_territorial{extra_clause}{", bbox" if include_bbox else ""}{geometry_clause},
 
208
  best.name AS matched_name,
209
  best.similarity AS similarity,
210
  best.substring AS is_substring_match
211
  FROM ranked
212
- ORDER BY is_substring_match DESC, similarity DESC, admin_level ASC, id
 
213
  """,
214
  [place.place, path, limit],
215
  )
@@ -254,6 +281,35 @@ def search_divisions_area(
254
  include_geometry=include_geometry,
255
  include_bbox=include_bbox,
256
  bbox_expr=DIVISIONS_AREA_BBOX,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257
  )
258
 
259
 
@@ -356,16 +412,32 @@ def get_natural_earth_by_id(
356
  )
357
 
358
 
 
 
 
 
 
 
 
 
 
 
 
359
  _SOURCE_SEARCH_FNS = {
360
  "divisions_area": search_divisions_area,
361
  "natural_earth": search_natural_earth,
 
362
  }
363
 
364
  _SOURCE_FETCH_FNS = {
365
  "divisions_area": get_division_by_id,
366
  "natural_earth": get_natural_earth_by_id,
 
367
  }
368
 
 
 
 
369
 
370
  def get_by_id(
371
  con: duckdb.DuckDBPyConnection,
@@ -375,11 +447,13 @@ def get_by_id(
375
  ) -> pd.DataFrame:
376
  """Look up a single record by ID, inferring the source if not given.
377
 
378
- Natural Earth IDs are always prefixed ``ne_`` (see ``config.SCHEMA_INFO``);
379
- anything else is assumed to be a divisions_area ID.
 
380
  """
381
- resolved_source = source or (
382
- "natural_earth" if id.startswith("ne_") else "divisions_area"
 
383
  )
384
  return _SOURCE_FETCH_FNS[resolved_source](
385
  con, id, include_geometry=include_geometry
 
4
  import duckdb
5
  import pandas as pd
6
 
7
+ from .config import (
8
+ DIVISIONS_AREA_PATH,
9
+ LOCALITIES_PATH,
10
+ NATURAL_EARTH_PATH,
11
+ URBAN_CENTRES_PATH,
12
+ )
13
  from .schemas import Place
14
 
15
  logger = logging.getLogger(__name__)
 
78
  NATURAL_EARTH_SEARCH_NAMES = search_names_sql(PRIMARY_NAME, NATURAL_EARTH_ENGLISH_NAME)
79
 
80
 
81
+ #: The column normalize_geodata stores a place's population in, where its
82
+ #: source has one: Overture joined from its ``division`` records, GHSL from
83
+ #: its own. Ranks a city above a same-named village.
84
+ POPULATION_COLUMN = "population"
85
+ NO_POPULATION = "CAST(NULL AS BIGINT)"
86
+
87
+
88
  @cache
89
+ def _stored_columns(path: str) -> frozenset[str]:
90
+ """The columns a parquet file (or glob) carries."""
91
  columns = duckdb.connect().execute("DESCRIBE SELECT * FROM read_parquet(?)", [path])
92
+ return frozenset(row[0] for row in columns.fetchall())
93
 
94
 
95
+ def _stored(path: str | list[str], column: str, computed: str) -> str:
96
+ """``column`` where every file stores it, else ``computed``.
97
 
98
+ Raw Overture downloads, files normalized before a column was added, and
99
+ the test fixtures may lack one.
100
  """
101
  paths = [path] if isinstance(path, str) else path
102
+ if all(column in _stored_columns(p) for p in paths):
103
+ return column
104
  return computed
105
 
106
 
107
+ def _search_names(path: str | list[str], computed: str) -> str:
108
+ """The stored search names where every file has them, else ``computed``."""
109
+ return _stored(path, SEARCH_NAMES_COLUMN, computed)
110
+
111
+
112
  def _divisions_area_paths(include_localities: bool) -> str | list[str]:
113
  """The divisions_area file, and the localities beside it where asked for.
114
 
 
133
  include_geometry: bool = False,
134
  include_bbox: bool = False,
135
  bbox_expr: str = GEOMETRY_BBOX,
136
+ population_expr: str = NO_POPULATION,
137
  ) -> pd.DataFrame:
138
  """Jaro-Winkler similarity search using only the place name.
139
 
 
156
  Jaro-Winkler similarity — substring containment is a stronger signal of
157
  a true match than character-level similarity alone.
158
 
159
+ Among equal scores, the larger ``population_expr`` comes first, so
160
+ "London" puts the city of ten million above a village of the same name.
161
+ A place with no population sorts after every place that has one.
162
+
163
  ``include_geometry``/``include_bbox`` require the spatial extension to
164
  already be loaded on ``con`` (``INSTALL spatial; LOAD spatial;``).
165
  ``include_bbox`` returns ``[minx, miny, maxx, maxy]`` from ``bbox_expr``
 
186
  admin_level,
187
  is_land,
188
  is_territorial{extra_clause}{bbox_clause}{carried_geometry},
189
+ CAST({population_expr} AS BIGINT) AS population,
190
  {_readable(name_expr)} AS primary_name,
191
  {_readable(english_expr)} AS english_name,
192
  {search_names_expr} AS search_names
 
216
  -- id last, so rows that tie on everything else (such as
217
  -- same-named towns, which have no admin_level) come back
218
  -- in the same order on every run.
219
+ ORDER BY best.substring DESC, best.similarity DESC,
220
+ population DESC NULLS LAST, admin_level ASC, id
221
  LIMIT $3
222
  )
223
  SELECT
 
230
  admin_level,
231
  is_land,
232
  is_territorial{extra_clause}{", bbox" if include_bbox else ""}{geometry_clause},
233
+ population,
234
  best.name AS matched_name,
235
  best.similarity AS similarity,
236
  best.substring AS is_substring_match
237
  FROM ranked
238
+ ORDER BY is_substring_match DESC, similarity DESC,
239
+ population DESC NULLS LAST, admin_level ASC, id
240
  """,
241
  [place.place, path, limit],
242
  )
 
281
  include_geometry=include_geometry,
282
  include_bbox=include_bbox,
283
  bbox_expr=DIVISIONS_AREA_BBOX,
284
+ population_expr=_stored(path, POPULATION_COLUMN, NO_POPULATION),
285
+ )
286
+
287
+
288
+ def search_urban_centres(
289
+ con: duckdb.DuckDBPyConnection,
290
+ place: Place,
291
+ limit: int = 5,
292
+ include_geometry: bool = False,
293
+ include_bbox: bool = False,
294
+ ) -> pd.DataFrame:
295
+ """Fuzzy-match a place against GHSL urban centres (named city polygons).
296
+
297
+ Matched on each centre's main name and its alternates. Returns an empty
298
+ frame where the urban centres were never built.
299
+ """
300
+ if not URBAN_CENTRES_PATH:
301
+ return pd.DataFrame()
302
+ return simple_fuzzy_search(
303
+ con,
304
+ URBAN_CENTRES_PATH,
305
+ "urban_centres",
306
+ place,
307
+ SEARCH_NAMES_COLUMN,
308
+ limit=limit,
309
+ include_geometry=include_geometry,
310
+ include_bbox=include_bbox,
311
+ bbox_expr=DIVISIONS_AREA_BBOX,
312
+ population_expr=POPULATION_COLUMN,
313
  )
314
 
315
 
 
412
  )
413
 
414
 
415
+ def get_urban_centre_by_id(
416
+ con: duckdb.DuckDBPyConnection, id: str, include_geometry: bool = True
417
+ ) -> pd.DataFrame:
418
+ """Look up a single GHSL urban centre by exact ID; empty where none were built."""
419
+ if not URBAN_CENTRES_PATH:
420
+ return pd.DataFrame()
421
+ return fetch_by_id(
422
+ con, URBAN_CENTRES_PATH, "urban_centres", id, include_geometry=include_geometry
423
+ )
424
+
425
+
426
  _SOURCE_SEARCH_FNS = {
427
  "divisions_area": search_divisions_area,
428
  "natural_earth": search_natural_earth,
429
+ "urban_centres": search_urban_centres,
430
  }
431
 
432
  _SOURCE_FETCH_FNS = {
433
  "divisions_area": get_division_by_id,
434
  "natural_earth": get_natural_earth_by_id,
435
+ "urban_centres": get_urban_centre_by_id,
436
  }
437
 
438
+ #: ID prefixes that name their source; any other ID is a divisions_area one.
439
+ _ID_PREFIXES = {"ne_": "natural_earth", "ghsl_": "urban_centres"}
440
+
441
 
442
  def get_by_id(
443
  con: duckdb.DuckDBPyConnection,
 
447
  ) -> pd.DataFrame:
448
  """Look up a single record by ID, inferring the source if not given.
449
 
450
+ Natural Earth IDs are always prefixed ``ne_`` (see ``config.SCHEMA_INFO``)
451
+ and urban centre IDs ``ghsl_``; anything else is assumed to be a
452
+ divisions_area ID.
453
  """
454
+ resolved_source = source or next(
455
+ (src for prefix, src in _ID_PREFIXES.items() if id.startswith(prefix)),
456
+ "divisions_area",
457
  )
458
  return _SOURCE_FETCH_FNS[resolved_source](
459
  con, id, include_geometry=include_geometry
tests/conftest.py CHANGED
@@ -87,8 +87,27 @@ _LOCALITY_ROWS = [
87
  # reverse id order, so a search only returns them sorted if it sorts.
88
  ("lisbon-b", "Lisbon", None, "US", "locality", None, -70.1, 44.0),
89
  ("lisbon-a", "Lisbon", None, "US", "locality", None, -83.2, 42.9),
 
 
 
 
90
  ]
91
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
 
93
  def _literal(value):
94
  """A string as a SQL literal, or NULL where there is no value."""
@@ -121,7 +140,8 @@ def _write_divisions(path, rows):
121
  values = ",\n".join(
122
  f"({_literal(id)}, {_literal(primary)}, {_name_map(english)}, {_rules(id)}, "
123
  f"{_literal(country)}, {_literal(subtype)}, "
124
- f"{'NULL' if admin_level is None else admin_level}, {lon}, {lat})"
 
125
  for id, primary, english, country, subtype, admin_level, lon, lat in rows
126
  )
127
  c.execute(
@@ -149,11 +169,13 @@ def _write_divisions(path, rows):
149
  CAST(admin_level AS INTEGER) AS admin_level,
150
  true AS is_land,
151
  false AS is_territorial,
152
- id AS division_id
 
153
  FROM (VALUES
154
  {values}
155
  ) AS t(
156
- id, primary_name, common, rules, country, subtype, admin_level, lon, lat
 
157
  )
158
  )
159
  ) TO '{path}' (FORMAT PARQUET)
@@ -177,6 +199,20 @@ def localities_parquet(tmp_path_factory):
177
  return _write_divisions(path, _LOCALITY_ROWS)
178
 
179
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  @pytest.fixture()
181
  def exonym_source(monkeypatch, exonym_parquet):
182
  """Point divisions_area at the exonym parquet for the duration of a test."""
 
87
  # reverse id order, so a search only returns them sorted if it sorts.
88
  ("lisbon-b", "Lisbon", None, "US", "locality", None, -70.1, 44.0),
89
  ("lisbon-a", "Lisbon", None, "US", "locality", None, -83.2, 42.9),
90
+ # Same name, but populations tell them apart: the larger comes first,
91
+ # whatever its id.
92
+ ("london-ky", "London", None, "US", "locality", None, -84.1, 37.1),
93
+ ("london-oh", "London", None, "US", "locality", None, -83.4, 39.9),
94
  ]
95
 
96
+ #: GHSL urban centres, shaped like the normalized urban_centres file. The
97
+ #: UK's London is the place Overture has only as a point.
98
+ _URBAN_CENTRE_ROWS = [
99
+ ("ghsl_2255", "London", None, "Canada", "urban_centre", None, -81.3, 42.9),
100
+ ("ghsl_5816", "London", None, "United Kingdom", "urban_centre", None, -0.5, 51.3),
101
+ ]
102
+
103
+ #: ``population`` where the fixture has one; every other row has none.
104
+ _POPULATION = {
105
+ "london-ky": 7_000,
106
+ "london-oh": 10_000,
107
+ "ghsl_2255": 365_269,
108
+ "ghsl_5816": 10_408_332,
109
+ }
110
+
111
 
112
  def _literal(value):
113
  """A string as a SQL literal, or NULL where there is no value."""
 
140
  values = ",\n".join(
141
  f"({_literal(id)}, {_literal(primary)}, {_name_map(english)}, {_rules(id)}, "
142
  f"{_literal(country)}, {_literal(subtype)}, "
143
+ f"{'NULL' if admin_level is None else admin_level}, {lon}, {lat}, "
144
+ f"{_POPULATION.get(id, 'NULL')})"
145
  for id, primary, english, country, subtype, admin_level, lon, lat in rows
146
  )
147
  c.execute(
 
169
  CAST(admin_level AS INTEGER) AS admin_level,
170
  true AS is_land,
171
  false AS is_territorial,
172
+ id AS division_id,
173
+ CAST(population AS BIGINT) AS population
174
  FROM (VALUES
175
  {values}
176
  ) AS t(
177
+ id, primary_name, common, rules, country, subtype, admin_level,
178
+ lon, lat, population
179
  )
180
  )
181
  ) TO '{path}' (FORMAT PARQUET)
 
199
  return _write_divisions(path, _LOCALITY_ROWS)
200
 
201
 
202
+ @pytest.fixture(scope="session")
203
+ def urban_centres_parquet(tmp_path_factory):
204
+ """Write the urban centre rows to a parquet with stored search names."""
205
+ path = tmp_path_factory.mktemp("urban_centres") / "urban_centres.parquet"
206
+ return _write_divisions(path, _URBAN_CENTRE_ROWS)
207
+
208
+
209
+ @pytest.fixture()
210
+ def urban_centres_source(monkeypatch, urban_centres_parquet):
211
+ """Point urban_centres at the fixture parquet for the duration of a test."""
212
+ monkeypatch.setattr("gazet.search.URBAN_CENTRES_PATH", urban_centres_parquet)
213
+ return urban_centres_parquet
214
+
215
+
216
  @pytest.fixture()
217
  def exonym_source(monkeypatch, exonym_parquet):
218
  """Point divisions_area at the exonym parquet for the duration of a test."""
tests/test_api.py CHANGED
@@ -181,6 +181,30 @@ class TestSearchFuzzy:
181
  assert top["id"] == "manaus"
182
  assert top["subtype"] == "locality"
183
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  def test_fuzzy_search_with_sources(self, client):
185
  try:
186
  resp = client.get(
 
181
  assert top["id"] == "manaus"
182
  assert top["subtype"] == "locality"
183
 
184
+ def test_fuzzy_search_puts_the_largest_london_first(
185
+ self, client, localities_source, urban_centres_source
186
+ ):
187
+ # Every candidate matches "London" exactly; population alone orders
188
+ # them, across sources.
189
+ resp = client.get(
190
+ "/search",
191
+ params={"q": "London", "mode": "fuzzy", "ids_only": "true", "limit": 4},
192
+ )
193
+ assert resp.status_code == 200
194
+ ids = resp.json()["ids"]
195
+ assert [i["id"] for i in ids] == [
196
+ "ghsl_5816",
197
+ "ghsl_2255",
198
+ "london-oh",
199
+ "london-ky",
200
+ ]
201
+ assert ids[0]["source"] == "urban_centres"
202
+
203
+ def test_urban_centre_geometry_by_id(self, client, urban_centres_source):
204
+ resp = client.get("/geometry/ghsl_5816")
205
+ assert resp.status_code == 200
206
+ assert resp.json()["properties"]["name"] == "London"
207
+
208
  def test_fuzzy_search_with_sources(self, client):
209
  try:
210
  resp = client.get(
tests/test_search.py CHANGED
@@ -11,6 +11,7 @@ from gazet.search import (
11
  search_candidates,
12
  search_divisions_area,
13
  search_natural_earth,
 
14
  )
15
 
16
 
@@ -321,6 +322,42 @@ class TestLocalities:
321
  df = get_by_id(con, "manaus", include_geometry=False)
322
  assert df.iloc[0]["name"] == "Manaus"
323
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
324
 
325
  class TestStoredSearchNames:
326
  """normalize_geodata stores each record's folded names; a file without
 
11
  search_candidates,
12
  search_divisions_area,
13
  search_natural_earth,
14
+ search_urban_centres,
15
  )
16
 
17
 
 
322
  df = get_by_id(con, "manaus", include_geometry=False)
323
  assert df.iloc[0]["name"] == "Manaus"
324
 
325
+ def test_the_more_populous_of_equal_matches_comes_first(
326
+ self, con, localities_source
327
+ ):
328
+ df = search_divisions_area(
329
+ con, Place(place="London"), limit=2, include_localities=True
330
+ )
331
+ assert df["id"].tolist() == ["london-oh", "london-ky"]
332
+
333
+
334
+ class TestUrbanCentres:
335
+ """GHSL urban centres give a polygon to cities Overture has only as a
336
+ point, such as London; fuzzy search only."""
337
+
338
+ def test_the_largest_london_comes_first(self, con, urban_centres_source):
339
+ df = search_urban_centres(con, Place(place="London"), limit=2)
340
+ assert df["id"].tolist() == ["ghsl_5816", "ghsl_2255"]
341
+ assert df.iloc[0]["population"] == 10_408_332
342
+
343
+ def test_not_among_default_candidates(self, con, urban_centres_source):
344
+ ids = [
345
+ id
346
+ for df in search_candidates(con, Place(place="London"))
347
+ for id in df["id"]
348
+ ]
349
+ assert "ghsl_5816" not in ids
350
+
351
+ def test_an_id_resolves_to_its_source(self, con, urban_centres_source):
352
+ df = get_by_id(con, "ghsl_5816", include_geometry=False)
353
+ assert df.iloc[0]["source"] == "urban_centres"
354
+ assert df.iloc[0]["country"] == "United Kingdom"
355
+
356
+ def test_nothing_when_never_built(self, con, monkeypatch):
357
+ monkeypatch.setattr(search, "URBAN_CENTRES_PATH", "")
358
+ assert search_urban_centres(con, Place(place="London")).empty
359
+ assert get_by_id(con, "ghsl_5816", include_geometry=False).empty
360
+
361
 
362
  class TestStoredSearchNames:
363
  """normalize_geodata stores each record's folded names; a file without