"""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 ", 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 ' 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, }