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