Spaces:
Sleeping
Sleeping
File size: 12,563 Bytes
f00d669 720767c f00d669 6c11e60 f00d669 b34c92a 6c11e60 f00d669 720767c f00d669 2c5a5d1 2577e90 2c5a5d1 720767c 2c5a5d1 720767c 2c5a5d1 720767c 2c5a5d1 f00d669 6c11e60 f00d669 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 | """The finite OSM category vocabulary + interpretable feature priors.
This is the single source of truth for:
1. mapping raw OSM tags -> a curated category a person would detour for, and
2. the per-category greenness / quietness priors (proxies derived from OSM), and
3. the confidence (tag-richness) computation.
The category vocabulary defined here is the same finite set that Brick 4's
embedding affinity will target (resolving spec open-question §12 "OSM category
vocabulary"). It is deliberately curated — generic noise (supermarkets, banks,
pharmacies, ATMs) is excluded because you do not detour for them.
Greenness and quietness are *category priors*: grounded in what the category
inherently is, not in per-place measurement. They are honest proxies (spec §9.3)
and a documented v1 simplification; richer sources (Sentinel greenness,
road-proximity quietness) are P2.
"""
from __future__ import annotations
import re
# Category -> (greenness 0..1, quietness 0..1). Order matters: classification
# walks the matcher list top-to-bottom and takes the first match, so put more
# specific categories before broader ones.
CATEGORIES: dict[str, dict[str, float]] = {
"park_garden": {"greenness": 1.00, "quietness": 0.80},
"water_feature": {"greenness": 0.45, "quietness": 0.60},
"viewpoint": {"greenness": 0.40, "quietness": 0.55},
"monument_historic":{"greenness": 0.20, "quietness": 0.50},
"museum_gallery": {"greenness": 0.10, "quietness": 0.80},
"artwork": {"greenness": 0.20, "quietness": 0.60},
"place_of_worship": {"greenness": 0.25, "quietness": 0.90},
"library": {"greenness": 0.10, "quietness": 0.95},
"bookshop": {"greenness": 0.10, "quietness": 0.80},
"theatre_cinema": {"greenness": 0.05, "quietness": 0.40},
"cafe": {"greenness": 0.10, "quietness": 0.40},
"bakery_food_shop": {"greenness": 0.10, "quietness": 0.50},
"restaurant": {"greenness": 0.10, "quietness": 0.30},
"bar_pub": {"greenness": 0.10, "quietness": 0.15},
"market": {"greenness": 0.20, "quietness": 0.25},
"specialty_shop": {"greenness": 0.05, "quietness": 0.55},
"attraction": {"greenness": 0.30, "quietness": 0.40},
}
# Human-readable gloss per category — fed to the text encoder in Brick 4 so a
# free-text vibe can be matched to categories by meaning.
CATEGORY_GLOSS: dict[str, str] = {
"park_garden": "a green park or garden, lawns and trees, calm open nature",
"water_feature": "a fountain, pond, canal or riverside water feature",
"viewpoint": "a scenic viewpoint or panorama overlooking the city",
"monument_historic": "a historic monument, statue, memorial or heritage site",
"museum_gallery": "an art museum or gallery, culture and exhibitions",
"artwork": "a piece of public art, street art or sculpture",
"place_of_worship": "a church, cathedral, temple or chapel for spirituality",
"library": "a library, quiet reading and books",
"bookshop": "an independent bookshop, browsing books",
"theatre_cinema": "a theatre or cinema, performance and film",
"cafe": "a cosy cafe or specialty coffee shop, espresso and a pause",
"bakery_food_shop": "a bakery, patisserie, chocolate or fine-food shop",
"restaurant": "a restaurant for a proper meal",
"bar_pub": "a lively bar, pub or wine bar, drinks and atmosphere",
"market": "a bustling open-air or covered market, food and stalls",
"specialty_shop": "a characterful specialty shop — antiques, art, design",
"attraction": "a major crowded tourist attraction or world-famous monument",
}
# Default posture per category: "stop" (you'd dwell) vs "pass" (you'd roll past).
# The mood can shift this globally (Brick 4). Dual stop/pass budgeting is P1-2.
POSTURE_DEFAULT: dict[str, str] = {
"park_garden": "pass",
"water_feature": "pass",
"viewpoint": "pass",
"monument_historic": "pass",
"museum_gallery": "stop",
"artwork": "pass",
"place_of_worship": "pass",
"library": "stop",
"bookshop": "stop",
"theatre_cinema": "stop",
"cafe": "stop",
"bakery_food_shop": "stop",
"restaurant": "stop",
"bar_pub": "stop",
"market": "stop",
"specialty_shop": "stop",
"attraction": "pass",
}
# Default dwell time (seconds) when stopping at a POI. Realistic estimates based on
# typical visit duration. Stops pay this cost; pass-bys pay 0. P1-2 dual budgeting.
DWELL_TIME_SEC: dict[str, float] = {
"park_garden": 300.0, # 5 min to stroll through a bit
"water_feature": 180.0, # 3 min to enjoy water
"viewpoint": 120.0, # 2 min to take in the view
"monument_historic": 180.0, # 3 min to absorb history
"museum_gallery": 900.0, # 15 min at a real museum
"artwork": 180.0, # 3 min to appreciate public art
"place_of_worship": 300.0, # 5 min for quiet reflection
"library": 600.0, # 10 min to browse shelves
"bookshop": 600.0, # 10 min browsing books
"theatre_cinema": 1800.0, # 30 min for a show (conservative lower bound)
"cafe": 600.0, # 10 min for coffee & pause
"bakery_food_shop": 300.0, # 5 min to pick up pastries
"restaurant": 1200.0, # 20 min for a light meal
"bar_pub": 900.0, # 15 min for a drink
"market": 600.0, # 10 min to explore stalls
"specialty_shop": 600.0, # 10 min to browse
"attraction": 300.0, # 5 min generic attraction
}
# Warm, natural noun per category for POIs that have no OSM name — so an unnamed
# place reads as "a quiet garden", not the clunky "a park garden" / raw snake_case.
PRETTY_CATEGORY: dict[str, str] = {
"park_garden": "garden",
"water_feature": "fountain",
"viewpoint": "viewpoint",
"monument_historic": "historic landmark",
"museum_gallery": "museum",
"artwork": "piece of public art",
"place_of_worship": "church",
"library": "library",
"bookshop": "bookshop",
"theatre_cinema": "theatre",
"cafe": "café",
"bakery_food_shop": "food shop", # bundles bakery/patisserie/wine/cheese/deli —
# "food shop" is never a false label (a wine
# shop is not "a bakery")
"restaurant": "restaurant",
"bar_pub": "bar",
"market": "market",
"specialty_shop": "shop",
"attraction": "landmark",
}
def pretty_category(category: str) -> str:
"""Human noun for a category, e.g. 'park_garden' -> 'garden'."""
return PRETTY_CATEGORY.get(category) or (category or "place").replace("_", " ")
# OSM sometimes stores a bare reference code in the name field (e.g. "PA_1570",
# a heritage ID). It's not a findable place name — treat it as unnamed so it
# self-demotes in scoring and shows as "a <noun>", not an unfindable code.
_REF_NAME_RE = re.compile(r"^([A-Za-z]{1,4}[_-]\d{2,}[A-Za-z]?|\d{3,})$")
def is_real_name(name) -> bool:
"""True if ``name`` is a usable place name (not empty, not a bare ref code)."""
if name is None:
return False
s = str(name).strip()
if not s:
return False
return not _REF_NAME_RE.match(s)
def display_label(poi) -> str:
"""The single source of truth for naming a POI in any UI surface.
The real OSM name when present; otherwise a natural 'a/an <noun>' phrase with
the correct article (never a raw 'a artwork', snake_case, or a ref code).
"""
name = getattr(poi, "name", None)
if is_real_name(name):
return str(name).strip()
noun = pretty_category(getattr(poi, "category", "") or "")
article = "an" if noun[:1].lower() in "aeiou" else "a"
return f"{article} {noun}"
def posture(category: str) -> str:
return POSTURE_DEFAULT.get(category, "pass")
def dwell_time_sec(category: str) -> float:
"""Dwell time in seconds for a stop at this category, or 0 if pass-by."""
default_posture = posture(category)
if default_posture == "stop":
return DWELL_TIME_SEC.get(category, 300.0)
return 0.0
def classify(tags: dict) -> str | None:
"""Map a POI's OSM tags to one curated category, or None if not of interest.
``tags`` is a flat dict of {osm_key: value} (NaN/None values allowed; they
are treated as absent).
"""
def has(key: str, *values: str) -> bool:
v = tags.get(key)
if v is None or (isinstance(v, float)): # NaN from pandas
return False
v = str(v)
return True if not values else v in values
# --- order: specific before general ---
if has("leisure", "park", "garden", "nature_reserve", "dog_park") \
or has("landuse", "grass", "forest", "meadow", "village_green") \
or has("natural", "wood", "grassland", "scrub"):
return "park_garden"
if has("tourism", "viewpoint"):
return "viewpoint"
if has("amenity", "fountain") or has("natural", "water", "spring") \
or has("water") or has("waterway", "canal"):
return "water_feature"
if has("tourism", "museum"):
return "museum_gallery"
if has("tourism", "gallery"):
return "museum_gallery"
if has("tourism", "artwork"):
return "artwork"
if has("historic") or has("tourism", "monument", "memorial"):
return "monument_historic"
if has("amenity", "place_of_worship"):
return "place_of_worship"
if has("amenity", "library"):
return "library"
if has("shop", "books"):
return "bookshop"
if has("amenity", "theatre", "cinema", "arts_centre"):
return "theatre_cinema"
if has("amenity", "cafe"):
return "cafe"
if has("shop", "bakery", "pastry", "confectionery", "chocolate",
"cheese", "deli", "wine", "coffee"):
return "bakery_food_shop"
if has("amenity", "restaurant"):
return "restaurant"
if has("amenity", "bar", "pub", "biergarten", "wine_bar"):
return "bar_pub"
if has("amenity", "marketplace") or has("shop", "greengrocer"):
return "market"
if has("shop", "art", "antiques", "antique", "craft", "interior_decoration",
"musical_instrument", "second_hand", "frame", "photo"):
return "specialty_shop"
if has("tourism", "attraction", "artwork", "theme_park", "gallery"):
# `tourism=attraction` is OSM's catch-all: it covers both famous sights
# AND commercial venues (escape rooms, mini-golf, ghost tours). The
# category gloss promises "a famous landmark or major sight", so admit a
# bare attraction only when it is *notable* — carries wikidata/wikipedia
# or a heritage listing. Confidence can't tell them apart (an escape room
# has name+website+hours → confidence 1.0); notability can.
if has("wikidata") or has("wikipedia") or has("heritage") \
or not has("tourism", "attraction"): # theme_park/gallery/artwork stay
return "attraction"
return None
return None
# OSM keys that signal a place is well-described. Presence => higher confidence.
_RICHNESS_KEYS = (
"name", "wikidata", "wikipedia", "description", "website", "contact:website",
"opening_hours", "phone", "contact:phone", "addr:housenumber", "addr:street",
"image", "heritage", "tourism", "cuisine", "operator", "start_date",
)
# A place with a name + wikidata + description is essentially fully documented.
_RICHNESS_SATURATION = 6.0
def confidence(tags: dict) -> float:
"""Tag-richness/completeness in [0,1]. Bare category tag -> low; rich -> high."""
present = 0
for key in _RICHNESS_KEYS:
v = tags.get(key)
if v is not None and not (isinstance(v, float)):
present += 1
# name is necessary for the place to be nameable at all — weight it.
name = tags.get("name")
has_name = name is not None and not isinstance(name, float)
score = present / _RICHNESS_SATURATION
if not has_name:
score *= 0.4 # unnamed places are inherently low-confidence
return min(1.0, score)
def greenness(category: str) -> float:
return CATEGORIES.get(category, {}).get("greenness", 0.0)
def quietness(category: str) -> float:
return CATEGORIES.get(category, {}).get("quietness", 0.5)
# Tags to request from OSM when extracting POIs (broad pull; classify() filters).
OSM_QUERY_TAGS: dict[str, bool] = {
"amenity": True,
"leisure": True,
"tourism": True,
"shop": True,
"historic": True,
"natural": True,
}
|