Spaces:
Running
Running
feat: add 'urban_centres' dataset for better city coverage and add population ranking
Browse files- DEPLOY_TO_HF.md +22 -5
- README.md +14 -3
- dataset/scripts/normalize_geodata.py +81 -5
- src/gazet/api.py +26 -14
- src/gazet/config.py +15 -7
- src/gazet/search.py +89 -15
- tests/conftest.py +39 -3
- tests/test_api.py +24 -0
- tests/test_search.py +37 -0
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
|
| 97 |
-
|
| 98 |
-
was not trained on
|
|
|
|
| 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 |
-
|
| 128 |
-
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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
|
| 65 |
-
"(defaults to
|
| 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
|
| 366 |
-
to the top ``limit``
|
| 367 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 '
|
| 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``
|
| 568 |
-
if omitted, it's inferred from the ID (Natural
|
| 569 |
-
``ne_``
|
| 570 |
-
in the resolved
|
|
|
|
| 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
|
| 38 |
-
|
| 39 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 78 |
-
"""
|
| 79 |
columns = duckdb.connect().execute("DESCRIBE SELECT * FROM read_parquet(?)", [path])
|
| 80 |
-
return
|
| 81 |
|
| 82 |
|
| 83 |
-
def
|
| 84 |
-
"""
|
| 85 |
|
| 86 |
-
Raw Overture downloads
|
|
|
|
| 87 |
"""
|
| 88 |
paths = [path] if isinstance(path, str) else path
|
| 89 |
-
if all(
|
| 90 |
-
return
|
| 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,
|
|
|
|
| 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,
|
|
|
|
| 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
|
|
|
|
| 380 |
"""
|
| 381 |
-
resolved_source = source or (
|
| 382 |
-
|
|
|
|
| 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,
|
|
|
|
| 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
|