Spaces:
Running on Zero
Running on Zero
| """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, | |
| } | |