Géocodage : requêtes Nominatim structurées (street/city/state) + repli centroïde de rue
La recherche libre confondait « Québec » ville/province (141 rue Saint-Paul -> Repentigny, rejeté par la bbox). Stratégies en cascade : structurée civique+rue, libre complète, rue seule. Double civique « 6275 et 6375 » géré. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 1 changed file with +36 and −10
modified
louka/geocode.py
+36 −10
@@ -62,6 +62,8 @@ class Geocoder: | ||
| 62 | 62 | s = address.strip() |
| 63 | 63 | # « 101-2905 Rue X » = unité 101, civique 2905 -> garder le civique |
| 64 | 64 | s = re.sub(r"^(\d+)-(\d+)\s", r"\2 ", s) |
| 65 | + # « 6275 et 6375 boulevard X » -> premier civique | |
| 66 | + s = re.sub(r"^(\d+)\s+et\s+\d+\s", r"\1 ", s) | |
| 65 | 67 | # « Montréal - Laval » / « Montréal - Île-des-Soeurs » -> garder le vrai lieu |
| 66 | 68 | s = re.sub(r"montr[ée]al\s*-\s*", "", s, flags=re.I) |
| 67 | 69 | # abréviations cardinales : « Rue Salaberry O » -> « Ouest » |
@@ -78,14 +80,14 @@ class Geocoder: | ||
| 78 | 80 | q += ", Québec" |
| 79 | 81 | return q + ", Canada" |
| 80 | 82 | |
| 81 | − def _query_nominatim(self, q: str) -> tuple[float, float] | None: | |
| 83 | + def _query_nominatim(self, params: dict) -> tuple[float, float] | None: | |
| 82 | 84 | """Une requête Nominatim, throttlée.""" |
| 83 | 85 | wait = REQUEST_DELAY - (time.time() - self._last) |
| 84 | 86 | if wait > 0: |
| 85 | 87 | time.sleep(wait) |
| 86 | 88 | try: |
| 87 | 89 | resp = self.session.get(NOMINATIM_URL, params={ |
| 88 | − "q": q, "format": "jsonv2", "limit": 1, "countrycodes": "ca", | |
| 90 | + "format": "jsonv2", "limit": 1, "countrycodes": "ca", **params, | |
| 89 | 91 | }, timeout=20) |
| 90 | 92 | self._last = time.time() |
| 91 | 93 | resp.raise_for_status() |
@@ -100,6 +102,30 @@ class Geocoder: | ||
| 100 | 102 | except (KeyError, ValueError): |
| 101 | 103 | return None |
| 102 | 104 | |
| 105 | + def _attempts(self, address: str, city: str) -> list[dict]: | |
| 106 | + """Stratégies de requête, de la plus précise à la moins précise. | |
| 107 | + | |
| 108 | + 1) structurée civique+rue (évite l'ambiguïté « Québec » ville/province) | |
| 109 | + 2) recherche libre complète | |
| 110 | + 3) structurée rue seule -> centroïde de rue (repli acceptable pour la | |
| 111 | + carte quand le numéro civique est absent d'OpenStreetMap) | |
| 112 | + """ | |
| 113 | + premier = self._clean(address).split(",")[0].strip() | |
| 114 | + ville = (city or "").strip() | |
| 115 | + if not ville: | |
| 116 | + k = strip_accents(address.lower()) | |
| 117 | + ville = "Lévis" if "levis" in k else "Québec" | |
| 118 | + commun = {"city": ville, "state": "Québec", "country": "Canada"} | |
| 119 | + | |
| 120 | + tries: list[dict] = [] | |
| 121 | + m = re.match(r"^(\d+)[,\s]+(.{4,})$", premier) | |
| 122 | + if m: | |
| 123 | + tries.append({"street": f"{m.group(1)} {m.group(2)}", **commun}) | |
| 124 | + tries.append({"q": self._build_query(address, city)}) | |
| 125 | + if m: | |
| 126 | + tries.append({"street": m.group(2), **commun}) | |
| 127 | + return tries | |
| 128 | + | |
| 103 | 129 | def resolve(self, address: str, city: str) -> tuple[float, float] | None: |
| 104 | 130 | """Adresse -> (lat, lng), via cache puis Nominatim. None si introuvable.""" |
| 105 | 131 | key = norm_key(address) |
@@ -114,14 +140,14 @@ class Geocoder: | ||
| 114 | 140 | if time.time() - (row["ts"] or 0) < RETRY_FAILED_AFTER: |
| 115 | 141 | return None # échec récent : ne pas marteler l'API |
| 116 | 142 | |
| 117 | − coords = self._query_nominatim(self._build_query(address, city)) | |
| 118 | − if coords is None: | |
| 119 | − # repli : civique + rue seulement (sans code postal ni secteur) | |
| 120 | − simple = self._clean(address).split(",")[0].strip() | |
| 121 | − if simple and re.match(r"^\d", simple): | |
| 122 | − q = f"{simple}, {city or 'Québec'}, Québec, Canada" | |
| 123 | − coords = self._query_nominatim(q) | |
| 124 | − ok = coords is not None and _in_bbox(*coords, _bbox_for(city)) | |
| 143 | + bbox = _bbox_for(city) | |
| 144 | + coords = None | |
| 145 | + for params in self._attempts(address, city): | |
| 146 | + c = self._query_nominatim(params) | |
| 147 | + if c is not None and _in_bbox(*c, bbox): | |
| 148 | + coords = c | |
| 149 | + break | |
| 150 | + ok = coords is not None | |
| 125 | 151 | self.con.execute( |
| 126 | 152 | "INSERT INTO geocode_cache (address, lat, lng, provider, failed, ts)" |
| 127 | 153 | " VALUES (?,?,?,?,?,?)" |
| 128 | 154 | |