Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,13 +123,13 @@ async def lifespan(app: FastAPI):
# PIP is only useful with a geocoder to produce coordinates, so the ~160 MB
# polygon load is skipped entirely in the Lite tier (no PC2NUTS_PHOTON_URL).
global _nuts_pip, _photon_client, _geo_http
if settings.photon_url:
if _config.settings.photon_url:
try:
with _httpx.Client() as _dl_client:
_nuts_pip = load_nuts_pip(
url=settings.nuts_geojson_url,
path=settings.nuts_geojson_path,
cache_dir=settings.data_dir,
url=_config.settings.nuts_geojson_url,
path=_config.settings.nuts_geojson_path,
cache_dir=_config.settings.data_dir,
client=_dl_client,
)
logger.info("NUTS polygons loaded — /resolve PIP ready.")
Expand Down
20 changes: 10 additions & 10 deletions app/photon_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,6 @@ def _candidates(street: str, city: str, postal_code: str) -> list[str]:
out.append(f"{postal_code} {city}")
if city:
out.append(city)
if street and postal_code and not city:
out.append(f"{street}, {postal_code}")
if postal_code:
out.append(postal_code)
if street and not city and not postal_code:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve street fallback when city is missing

When /resolve is called with a street but no city, postal_code is still always present, so after removing the street+postcode candidate this condition prevents any candidate containing the street from being sent. The client now queries Photon with only the postcode for these weak postal matches, which can return no_result or a coarse/wrong postcode hit despite the caller supplying street information; add a street-only candidate for this no-city case without combining it with the postcode.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked this against the module's premise before acting — I don't think it's a regression, and I'd push back on adding the street-only candidate here.

The candidate this PR removed was f"{street}, {postal_code}" — street combined with the postcode. The whole point of this module (docstring) is that Photon returns 0 results for a street+postcode string. So for a (street, no-city, postcode) input that candidate was always an empty round-trip; postcode-only was already the only query that could return a hit. Removing it changes correctness by zero and just drops the wasted request — which is what #125 asked for. No working street fallback was lost.

What you're proposing is a new street-only candidate (never combined), which is a different thing. I'd hold off on it: for a NUTS region lookup, a street with no city is low-precision — street names repeat across municipalities, so a street-alone Photon hit can land in the wrong city → wrong coordinates → wrong NUTS3. This service deliberately prefers no-result over a wrong region (0.85 geocode threshold, cross-border guard). Postcode-only is the reliable region signal for these no-city records, and "coarse" (postcode centroid) is exactly the right granularity for NUTS3.

If we do want a street-only last-resort, it should be gated and appended after the postcode query, and it's a separate enhancement rather than part of this bugfix. Happy to open an issue for it if you think the coverage is worth the precision risk.

Expand All @@ -53,15 +51,17 @@ def _query(self, q: str) -> tuple[float, float] | None:
)
resp.raise_for_status()
data = resp.json()
except (httpx.HTTPError, ValueError):
feats = data.get("features") or []
if not feats:
return None
coords = (feats[0].get("geometry") or {}).get("coordinates") or []
if len(coords) < 2:
return None
# (lat, lon) from [lon, lat]; a non-Point feature nests coordinates,
# so float() would raise TypeError — caught here to honour "never raises".
return (float(coords[1]), float(coords[0]))
except (httpx.HTTPError, ValueError, TypeError, IndexError):
return None
feats = data.get("features") or []
if not feats:
return None
coords = (feats[0].get("geometry") or {}).get("coordinates") or []
if len(coords) < 2:
return None
return (float(coords[1]), float(coords[0])) # (lat, lon) from [lon, lat]

def geocode(
self, street: str | None, city: str | None, postal_code: str | None
Expand Down
26 changes: 26 additions & 0 deletions tests/test_photon_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,32 @@ def handler(req):
assert all(not ("Museumpark 25" in q and "3000 AE" in q) for q in seen)


def test_never_combines_street_and_postcode_without_city():
# The no-city path (reachable via the offline enrich path) must also never
# put street and postcode in one query — Photon returns nothing for that pair.
seen = []

def handler(req):
seen.append(req.url.params["q"])
return httpx.Response(200, json={"features": []})

pc = PhotonClient("http://photon", _client(handler))
pc.geocode("Museumpark 25", "", "3000 AE")
assert seen, "expected at least one query"
assert all(not ("Museumpark 25" in q and "3000 AE" in q) for q in seen)


def test_non_point_geometry_returns_none():
# A non-Point feature has nested coordinates (e.g. [[lon,lat],...]); parsing
# coords[1] as a float would raise TypeError. The contract is "never raises".
def handler(req):
geom = {"type": "LineString", "coordinates": [[4.5, 50.8], [4.6, 50.9]]}
return httpx.Response(200, json={"features": [{"geometry": geom}]})

pc = PhotonClient("http://photon", _client(handler))
assert pc.geocode("Markt", "Tervuren", "3080") is None


def test_all_queries_empty_returns_none():
pc = PhotonClient("http://photon", _client(lambda r: httpx.Response(200, json={"features": []})))
assert pc.geocode("X", "Y", "0000") is None
Expand Down