msradam Claude Opus 4.5 commited on
Commit
1a5769f
·
1 Parent(s): 75ef3c0

Add full deployment package for HF Spaces

Browse files

- Dockerfile with Ollama + Python geospatial deps
- start.sh to run Ollama and Streamlit together
- App code, core modules, and data files

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Dockerfile ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # OUR-ERA: Hugging Face Spaces Deployment
2
+ # Includes Ollama + Streamlit in a single container
3
+
4
+ FROM python:3.11-slim
5
+
6
+ WORKDIR /app
7
+
8
+ # Install system dependencies
9
+ RUN apt-get update && apt-get install -y --no-install-recommends \
10
+ build-essential \
11
+ curl \
12
+ libgdal-dev \
13
+ libgeos-dev \
14
+ libproj-dev \
15
+ gdal-bin \
16
+ && rm -rf /var/lib/apt/lists/*
17
+
18
+ # Install Ollama
19
+ RUN curl -fsSL https://ollama.com/install.sh | sh
20
+
21
+ # Set GDAL environment variables
22
+ ENV GDAL_CONFIG=/usr/bin/gdal-config
23
+
24
+ # Copy and install Python dependencies
25
+ COPY requirements.txt .
26
+ RUN pip install --no-cache-dir -r requirements.txt
27
+
28
+ # Copy application code
29
+ COPY app.py .
30
+ COPY core/ ./core/
31
+ COPY data/ ./data/
32
+
33
+ # Copy startup script
34
+ COPY start.sh .
35
+ RUN chmod +x start.sh
36
+
37
+ # Hugging Face Spaces expects port 7860
38
+ EXPOSE 7860
39
+
40
+ ENV STREAMLIT_SERVER_PORT=7860
41
+ ENV STREAMLIT_SERVER_ADDRESS=0.0.0.0
42
+ ENV STREAMLIT_SERVER_HEADLESS=true
43
+ ENV OLLAMA_HOST=http://localhost:11434
44
+
45
+ # Start both Ollama and Streamlit
46
+ CMD ["./start.sh"]
README.md CHANGED
@@ -1,10 +1,14 @@
1
  ---
2
- title: Our Era
3
- emoji: 👀
4
- colorFrom: yellow
5
- colorTo: red
6
  sdk: docker
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
1
  ---
2
+ title: OUR-ERA
3
+ emoji: 🌊
4
+ colorFrom: blue
5
+ colorTo: green
6
  sdk: docker
7
  pinned: false
8
  ---
9
 
10
+ # OUR-ERA: Open Urban Resilience | Emergency Routing Assistant
11
+
12
+ Climate-aware pedestrian routing for Brownsville, Brooklyn. Find optimal walking routes that avoid flooding, heat exposure, and steep hills.
13
+
14
+ Powered by Ollama (qwen2.5:3b) for natural language queries.
app.py ADDED
@@ -0,0 +1,2231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Emergency Routing Assistant - Streamlit Frontend
3
+
4
+ This is a presentation-only layer. All logic lives in core/engine.py.
5
+ """
6
+
7
+ import os
8
+ from datetime import datetime
9
+ import streamlit as st
10
+ import folium
11
+ from streamlit_folium import st_folium
12
+ import json
13
+ import requests
14
+
15
+ from core.engine import (
16
+ RoutingEngine,
17
+ execute_tool,
18
+ BROWNSVILLE_CENTER,
19
+ get_poi_marker_style,
20
+ POI_MARKER_STYLES,
21
+ )
22
+
23
+ # =============================================================================
24
+ # Page Config
25
+ # =============================================================================
26
+
27
+ st.set_page_config(
28
+ page_title="Emergency Routing Assistant",
29
+ page_icon="🚨",
30
+ layout="wide"
31
+ )
32
+
33
+ # =============================================================================
34
+ # Session State
35
+ # =============================================================================
36
+
37
+ if "messages" not in st.session_state:
38
+ st.session_state.messages = []
39
+ if "map_data" not in st.session_state:
40
+ st.session_state.map_data = None
41
+ if "engine" not in st.session_state:
42
+ st.session_state.engine = RoutingEngine()
43
+
44
+ # =============================================================================
45
+ # LLM Config
46
+ # =============================================================================
47
+
48
+ OLLAMA_URL = "http://localhost:11434/api/chat"
49
+
50
+ # Available models for selection
51
+ AVAILABLE_MODELS = {
52
+ "Qwen 2.5 3B (Fast)": "qwen2.5:3b",
53
+ "Llama xLAM 8B (Accurate)": "hf.co/Salesforce/Llama-xLAM-2-8b-fc-r-gguf",
54
+ }
55
+ DEFAULT_MODEL = "Qwen 2.5 3B (Fast)"
56
+
57
+ # Initialize model selection in session state
58
+ if "selected_model" not in st.session_state:
59
+ st.session_state.selected_model = DEFAULT_MODEL
60
+
61
+ def get_current_model() -> str:
62
+ """Get the currently selected model ID."""
63
+ return AVAILABLE_MODELS.get(st.session_state.selected_model, AVAILABLE_MODELS[DEFAULT_MODEL])
64
+
65
+
66
+ def warmup_llm(model: str):
67
+ """Warmup LLM on startup to avoid cold start latency on first query.
68
+
69
+ This sends a simple request to load the model into memory.
70
+ Returns True if successful, False otherwise.
71
+ """
72
+ import requests
73
+ try:
74
+ response = requests.post(
75
+ OLLAMA_URL,
76
+ json={
77
+ "model": model,
78
+ "messages": [{"role": "user", "content": "Hi"}],
79
+ "stream": False,
80
+ },
81
+ timeout=120, # Model loading can take time
82
+ )
83
+ response.raise_for_status()
84
+ return True
85
+ except requests.exceptions.ConnectionError:
86
+ return False
87
+ except Exception:
88
+ return False
89
+
90
+ # =============================================================================
91
+ # "Less is More" - Embedding-Based Tool Selection
92
+ # Paper insight: Pre-filter tools using embeddings, only send relevant ones to LLM
93
+ # =============================================================================
94
+
95
+ import numpy as np
96
+ from sentence_transformers import SentenceTransformer
97
+
98
+ # Pre-computed tool embeddings (computed once at startup)
99
+ # Climate weight parameters are set by the LLM based on user context:
100
+ # flood_penalty_deep: 5.0 default, 10.0 for active flooding
101
+ # flood_penalty_shallow: 2.0 default, 4.0 for flooding
102
+ # heat_factor: 0.3 default, 0.5 for hot days / shade seeking
103
+ # shade_factor: 0.3 default, 0.5 for tree shade preference
104
+ # aqi_factor: 0.1 default, 0.5 for respiratory concerns
105
+ # grade_factor: 0.2 default, 0.5 for elderly/mobility-impaired users
106
+ # routing_mode: 'safe' (default) or 'fast'
107
+ TOOL_DEFINITIONS = {
108
+ "find_nearest": {
109
+ "description": "Find the single nearest closest resource like hospital clinic pharmacy shelter school fire station police with climate-safe walking route directions flooding heat asthma shade cooling center elderly wheelchair accessible flat",
110
+ "keywords": ["nearest", "closest", "find", "where is", "locate", "emergency", "help", "nearby", "flooding", "hot", "shade", "asthma", "cooling", "elderly", "wheelchair", "accessible", "flat"],
111
+ "params": "resource_type, lat, lon, flood_penalty_deep, heat_factor, shade_factor, aqi_factor, grade_factor"
112
+ },
113
+ "list_resources": {
114
+ "description": "List count inventory enumerate all resources of a type how many are there total number available",
115
+ "keywords": ["list", "how many", "count", "all", "show", "available", "total", "number", "inventory", "enumerate"],
116
+ "params": "resource_type"
117
+ },
118
+ "calculate_route": {
119
+ "description": "Calculate walking route directions path between two locations climate-safe safest low flood risk heat exposure shade trees avoidance evacuation storm weather resilient asthma air quality get to go to elderly wheelchair accessible flat hills avoid steep",
120
+ "keywords": ["route", "walk", "from", "to", "directions", "path", "evacuate", "go", "between", "travel", "safest", "climate", "flood", "heat", "safe", "exposure", "risk", "storm", "resilient", "shade", "trees", "asthma", "air", "breathing", "get to", "elderly", "wheelchair", "accessible", "flat", "hills", "steep"],
121
+ "params": "start_lat, start_lon, end_lat, end_lon, flood_penalty_deep, heat_factor, shade_factor, aqi_factor, grade_factor, routing_mode"
122
+ },
123
+ "generate_isochrone": {
124
+ "description": "Show what resources can residents reach within X minutes walking time reachable area coverage zone radius from a location assess emergency access accessibility response window service area",
125
+ "keywords": ["reach", "minutes", "reachable", "within", "coverage", "area", "time", "radius", "zone", "access", "assess", "accessibility", "residents", "response", "window", "service area", "can reach"],
126
+ "params": "lat, lon, time_limits"
127
+ },
128
+ "find_along_route": {
129
+ "description": "Find resources along a route corridor on the way between two points what is near the path during travel what resources are along",
130
+ "keywords": ["along", "route", "corridor", "between", "on the way", "during", "path", "near route", "along the way", "resources along"],
131
+ "params": "start_lat, start_lon, end_lat, end_lon, resource_types"
132
+ }
133
+ }
134
+
135
+ RESOURCE_TYPES = ["pharmacy", "clinic", "hospital", "fire_station", "police", "school", "library", "community_centre", "place_of_worship", "shelter"]
136
+
137
+ # =============================================================================
138
+ # GBNF Grammars for Type-Safe LLM Output
139
+ # These constrain the LLM to output properly typed JSON for each tool
140
+ # =============================================================================
141
+
142
+ # Common grammar components
143
+ _GBNF_COMMON = r'''
144
+ ws ::= [ \t\n]*
145
+ number ::= "-"? ([0-9] | [1-9] [0-9]*) ("." [0-9]+)?
146
+ integer ::= "-"? ([0-9] | [1-9] [0-9]*)
147
+ string ::= "\"" ([^"\\] | "\\" .)* "\""
148
+ '''
149
+
150
+ # Grammar for tool selection - enforces proper types for all tools
151
+ # NOTE: This is the FULL grammar. Use build_dynamic_grammar() to generate
152
+ # a grammar constrained to only the embedding-selected tools.
153
+ TOOL_SELECTION_GRAMMAR = r'''
154
+ root ::= "{" ws "\"name\"" ws ":" ws tool-name ws "," ws "\"arguments\"" ws ":" ws arguments ws "}"
155
+
156
+ tool-name ::= "\"find_nearest\"" | "\"list_resources\"" | "\"calculate_route\"" | "\"generate_isochrone\"" | "\"find_along_route\""
157
+
158
+ arguments ::= find-nearest-args | list-resources-args | calculate-route-args | generate-isochrone-args | find-along-route-args
159
+
160
+ resource-type ::= "\"pharmacy\"" | "\"clinic\"" | "\"hospital\"" | "\"fire_station\"" | "\"police\"" | "\"school\"" | "\"library\"" | "\"community_centre\"" | "\"place_of_worship\"" | "\"shelter\"" | "\"aed\"" | "\"senior_center\"" | "\"cooling_center\""
161
+
162
+ find-nearest-args ::= "{" ws "\"resource_type\"" ws ":" ws resource-type ws ( "," ws lat-lon-args )? ( "," ws climate-args )* ws "}"
163
+
164
+ list-resources-args ::= "{" ws "\"resource_type\"" ws ":" ws resource-type ws "}"
165
+
166
+ calculate-route-args ::= "{" ws start-lat-arg ws "," ws start-lon-arg ws "," ws end-lat-arg ws "," ws end-lon-arg ws ( "," ws climate-args )* ( "," ws routing-mode-arg )? ws "}"
167
+
168
+ generate-isochrone-args ::= "{" ws lat-arg ws "," ws lon-arg ws ( "," ws time-limits-arg )? ws "}"
169
+
170
+ find-along-route-args ::= "{" ws start-lat-arg ws "," ws start-lon-arg ws "," ws end-lat-arg ws "," ws end-lon-arg ws ( "," ws resource-types-arg )? ws "}"
171
+
172
+ lat-lon-args ::= lat-arg ws "," ws lon-arg
173
+ lat-arg ::= "\"lat\"" ws ":" ws number
174
+ lon-arg ::= "\"lon\"" ws ":" ws number
175
+ start-lat-arg ::= "\"start_lat\"" ws ":" ws number
176
+ start-lon-arg ::= "\"start_lon\"" ws ":" ws number
177
+ end-lat-arg ::= "\"end_lat\"" ws ":" ws number
178
+ end-lon-arg ::= "\"end_lon\"" ws ":" ws number
179
+
180
+ time-limits-arg ::= "\"time_limits\"" ws ":" ws "[" ws integer ( ws "," ws integer )* ws "]"
181
+
182
+ resource-types-arg ::= "\"resource_types\"" ws ":" ws "[" ws resource-type ( ws "," ws resource-type )* ws "]"
183
+
184
+ climate-args ::= flood-penalty-arg | heat-factor-arg | shade-factor-arg | aqi-factor-arg | grade-factor-arg
185
+ flood-penalty-arg ::= "\"flood_penalty_deep\"" ws ":" ws number
186
+ heat-factor-arg ::= "\"heat_factor\"" ws ":" ws number
187
+ shade-factor-arg ::= "\"shade_factor\"" ws ":" ws number
188
+ aqi-factor-arg ::= "\"aqi_factor\"" ws ":" ws number
189
+ grade-factor-arg ::= "\"grade_factor\"" ws ":" ws number
190
+
191
+ routing-mode-arg ::= "\"routing_mode\"" ws ":" ws ("\"safe\"" | "\"fast\"")
192
+
193
+ ws ::= [ \t\n]*
194
+ number ::= "-"? ([0-9] | [1-9] [0-9]*) ("." [0-9]+)?
195
+ integer ::= [0-9] | [1-9] [0-9]*
196
+ '''
197
+
198
+
199
+ def build_dynamic_grammar(selected_tools: list[str]) -> str:
200
+ """Build a GBNF grammar constrained to only the selected tools.
201
+
202
+ This is critical for the "Less is More" approach - embedding pre-selection
203
+ narrows to top-k tools, and the grammar ENFORCES that the LLM can only
204
+ output one of those tools. This prevents the LLM from hallucinating
205
+ a tool that wasn't in the pre-selected set.
206
+
207
+ Args:
208
+ selected_tools: List of tool names from embedding pre-selection
209
+
210
+ Returns:
211
+ GBNF grammar string that only allows the selected tools
212
+ """
213
+ # Build the tool-name rule with only selected tools
214
+ tool_name_parts = [f'"\"{t}\""' for t in selected_tools]
215
+ tool_name_rule = "tool-name ::= " + " | ".join(tool_name_parts)
216
+
217
+ # Build the arguments rule with only selected tools' argument types
218
+ arg_parts = []
219
+ for t in selected_tools:
220
+ arg_parts.append(f"{t.replace('_', '-')}-args")
221
+ arguments_rule = "arguments ::= " + " | ".join(arg_parts)
222
+
223
+ # Assemble the grammar
224
+ grammar = f'''
225
+ root ::= "{{" ws "\\"name\\"" ws ":" ws tool-name ws "," ws "\\"arguments\\"" ws ":" ws arguments ws "}}"
226
+
227
+ {tool_name_rule}
228
+
229
+ {arguments_rule}
230
+
231
+ resource-type ::= "\\"pharmacy\\"" | "\\"clinic\\"" | "\\"hospital\\"" | "\\"fire_station\\"" | "\\"police\\"" | "\\"school\\"" | "\\"library\\"" | "\\"community_centre\\"" | "\\"place_of_worship\\"" | "\\"shelter\\"" | "\\"aed\\"" | "\\"senior_center\\"" | "\\"cooling_center\\""
232
+
233
+ find-nearest-args ::= "{{" ws "\\"resource_type\\"" ws ":" ws resource-type ws ( "," ws lat-lon-args )? ( "," ws climate-args )* ws "}}"
234
+
235
+ list-resources-args ::= "{{" ws "\\"resource_type\\"" ws ":" ws resource-type ws "}}"
236
+
237
+ calculate-route-args ::= "{{" ws start-lat-arg ws "," ws start-lon-arg ws "," ws end-lat-arg ws "," ws end-lon-arg ws ( "," ws climate-args )* ( "," ws routing-mode-arg )? ws "}}"
238
+
239
+ generate-isochrone-args ::= "{{" ws lat-arg ws "," ws lon-arg ws ( "," ws time-limits-arg )? ws "}}"
240
+
241
+ find-along-route-args ::= "{{" ws start-lat-arg ws "," ws start-lon-arg ws "," ws end-lat-arg ws "," ws end-lon-arg ws ( "," ws resource-types-arg )? ws "}}"
242
+
243
+ lat-lon-args ::= lat-arg ws "," ws lon-arg
244
+ lat-arg ::= "\\"lat\\"" ws ":" ws number
245
+ lon-arg ::= "\\"lon\\"" ws ":" ws number
246
+ start-lat-arg ::= "\\"start_lat\\"" ws ":" ws number
247
+ start-lon-arg ::= "\\"start_lon\\"" ws ":" ws number
248
+ end-lat-arg ::= "\\"end_lat\\"" ws ":" ws number
249
+ end-lon-arg ::= "\\"end_lon\\"" ws ":" ws number
250
+
251
+ time-limits-arg ::= "\\"time_limits\\"" ws ":" ws "[" ws integer ( ws "," ws integer )* ws "]"
252
+
253
+ resource-types-arg ::= "\\"resource_types\\"" ws ":" ws "[" ws resource-type ( ws "," ws resource-type )* ws "]"
254
+
255
+ climate-args ::= flood-penalty-arg | heat-factor-arg | shade-factor-arg | aqi-factor-arg | grade-factor-arg
256
+ flood-penalty-arg ::= "\\"flood_penalty_deep\\"" ws ":" ws number
257
+ heat-factor-arg ::= "\\"heat_factor\\"" ws ":" ws number
258
+ shade-factor-arg ::= "\\"shade_factor\\"" ws ":" ws number
259
+ aqi-factor-arg ::= "\\"aqi_factor\\"" ws ":" ws number
260
+ grade-factor-arg ::= "\\"grade_factor\\"" ws ":" ws number
261
+
262
+ routing-mode-arg ::= "\\"routing_mode\\"" ws ":" ws ("\\"safe\\"" | "\\"fast\\"")
263
+
264
+ ws ::= [ \\t\\n]*
265
+ number ::= "-"? ([0-9] | [1-9] [0-9]*) ("." [0-9]+)?
266
+ integer ::= [0-9] | [1-9] [0-9]*
267
+ '''
268
+ return grammar
269
+
270
+ # =============================================================================
271
+ # Pre-computed Embeddings Loading (run build_tool_embeddings.py first!)
272
+ # =============================================================================
273
+
274
+ EMBEDDINGS_PATH = os.path.join(os.path.dirname(__file__), "data", "tool_embeddings.npz")
275
+
276
+ @st.cache_resource
277
+ def load_tool_embeddings():
278
+ """Load pre-computed tool embeddings from file (instant startup!)."""
279
+ if not os.path.exists(EMBEDDINGS_PATH):
280
+ st.warning("Tool embeddings not found. Run: python build_tool_embeddings.py")
281
+ return None, None, None
282
+
283
+ data = np.load(EMBEDDINGS_PATH, allow_pickle=True)
284
+ tool_names = data['tool_names'].tolist()
285
+ embeddings = data['embeddings']
286
+ model_name = str(data['model_name'])
287
+ return tool_names, embeddings, model_name
288
+
289
+ @st.cache_resource
290
+ def load_embedding_model():
291
+ """Load sentence transformer model for query encoding (cached)."""
292
+ _, _, model_name = load_tool_embeddings()
293
+ if model_name:
294
+ return SentenceTransformer(model_name)
295
+ return SentenceTransformer('all-MiniLM-L6-v2')
296
+
297
+ def select_tool_by_embedding(query: str, top_k: int = 2) -> list[str]:
298
+ """Select most relevant tools using embedding similarity (no LLM call!)."""
299
+ tool_names, embeddings, _ = load_tool_embeddings()
300
+
301
+ if tool_names is None:
302
+ # Fallback: return all tools if embeddings not available
303
+ return list(TOOL_DEFINITIONS.keys())[:top_k]
304
+
305
+ model = load_embedding_model()
306
+
307
+ # Encode the query
308
+ query_embedding = model.encode(query, normalize_embeddings=True)
309
+
310
+ # Compute similarities using matrix multiplication (fast!)
311
+ similarities = np.dot(embeddings, query_embedding)
312
+
313
+ # Get top-k indices
314
+ top_indices = np.argsort(similarities)[::-1][:top_k]
315
+
316
+ return [tool_names[i] for i in top_indices]
317
+
318
+
319
+ def build_minimal_tool_prompt(selected_tools: list[str]) -> str:
320
+ """Build a minimal prompt with only the selected tools."""
321
+ lines = ["Select ONE tool. Output JSON: {\"name\": \"tool\", \"arguments\": {...}}\n\nTools:"]
322
+
323
+ # Tool descriptions to help LLM understand what each tool does
324
+ tool_descriptions = {
325
+ "find_nearest": "find single nearest resource (hospital, pharmacy, shelter, etc.)",
326
+ "list_resources": "list/count all resources of a type",
327
+ "calculate_route": "get walking directions between two locations",
328
+ "generate_isochrone": "show what resources can be reached within X minutes from a location",
329
+ "find_along_route": "find resources along a route between two points",
330
+ }
331
+
332
+ for tool in selected_tools:
333
+ info = TOOL_DEFINITIONS[tool]
334
+ desc = tool_descriptions.get(tool, "")
335
+ lines.append(f"- {tool}({info['params']}) - {desc}")
336
+ lines.append(f"\nresource_type options: {'|'.join(RESOURCE_TYPES)}")
337
+
338
+ # Critical: Tell LLM when to OMIT resource_types filter for find_along_route
339
+ if "find_along_route" in selected_tools:
340
+ lines.append("""
341
+ IMPORTANT for find_along_route: OMIT resource_types to find ALL resources along the route.
342
+ Only include resource_types if user asks for specific types (e.g., "pharmacies along route").""")
343
+
344
+ # Add tool-specific guidance
345
+ if "generate_isochrone" in selected_tools:
346
+ lines.append("""
347
+ For "reach in X minutes" or "what can residents access" queries, use generate_isochrone with time_limits=[5,10,15]""")
348
+
349
+ # Add climate parameter guidance for tools that support it
350
+ climate_tools = {"find_nearest", "calculate_route", "compare_routes"}
351
+ if any(t in climate_tools for t in selected_tools):
352
+ lines.append("""
353
+ CLIMATE PARAMETERS - Set based on user context:
354
+ - If user mentions FLOODING/storm/rain: flood_penalty_deep=10.0
355
+ - If user mentions HEAT/hot/cooling/shade/trees: heat_factor=0.5, shade_factor=0.5
356
+ - If user mentions ASTHMA/breathing/air quality: aqi_factor=0.5
357
+ - If user mentions ELDERLY/wheelchair/walker/mobility/accessible/flat/hills/steep: grade_factor=0.5
358
+ - For FAST routes (user wants speed over safety): routing_mode="fast"
359
+ - Default: omit climate params for standard safe routing""")
360
+ return "\n".join(lines)
361
+
362
+
363
+ # Fallback prompt with all tools (used if embedding selection fails)
364
+ TOOL_SELECTION_PROMPT = """Select ONE tool. Output JSON: {"name": "tool", "arguments": {...}}
365
+
366
+ Tools:
367
+ - find_nearest(resource_type, lat, lon, flood_penalty_deep, heat_factor, shade_factor, aqi_factor, grade_factor)
368
+ - list_resources(resource_type)
369
+ - calculate_route(start_lat, start_lon, end_lat, end_lon, flood_penalty_deep, heat_factor, shade_factor, aqi_factor, grade_factor, routing_mode)
370
+ - generate_isochrone(lat, lon, time_limits)
371
+ - find_along_route(start_lat, start_lon, end_lat, end_lon, resource_types)
372
+
373
+ resource_type options: pharmacy|clinic|hospital|fire_station|police|school|library|community_centre|place_of_worship|shelter
374
+
375
+ CLIMATE PARAMETERS - Set based on user context:
376
+ - If user mentions FLOODING/storm/rain: flood_penalty_deep=10.0
377
+ - If user mentions HEAT/hot/cooling/shade/trees: heat_factor=0.5, shade_factor=0.5
378
+ - If user mentions ASTHMA/breathing/air quality: aqi_factor=0.5
379
+ - If user mentions ELDERLY/wheelchair/walker/mobility/accessible/flat/hills/steep: grade_factor=0.5
380
+ - For FAST routes (user wants speed over safety): routing_mode="fast"
381
+ - Default: omit climate params for standard safe routing"""
382
+
383
+ # =============================================================================
384
+ # Multi-Step Query Planner
385
+ # Key insight: Use variable references ($step1.lat) instead of LLM reproducing outputs
386
+ # =============================================================================
387
+
388
+ MULTI_STEP_PLANNER_PROMPT = """You are a query planner. Analyze if this query requires multiple steps.
389
+
390
+ If SINGLE step: Output {"multi_step": false, "steps": []}
391
+
392
+ If MULTIPLE steps needed (e.g., "find X then go to Y", "reach A then find nearest B"):
393
+ Output a plan with variable references. Results from step N are available as $stepN.field
394
+
395
+ Example for "Find nearest SNAP center avoiding floods, then route to nearest cooling center":
396
+ {
397
+ "multi_step": true,
398
+ "steps": [
399
+ {"step": 1, "tool": "find_nearest", "args": {"resource_type": "community_centre", "lat": 40.6594, "lon": -73.9126, "flood_penalty_deep": 10.0}, "description": "Find SNAP center avoiding floods"},
400
+ {"step": 2, "tool": "find_nearest", "args": {"resource_type": "shelter", "lat": "$step1.lat", "lon": "$step1.lon"}, "description": "Find cooling center from SNAP location"}
401
+ ]
402
+ }
403
+
404
+ Available tools:
405
+ - find_nearest(resource_type, lat, lon, flood_penalty_deep, heat_factor, shade_factor, aqi_factor, grade_factor) -> returns {lat, lon, name, distance_meters, walking_time_minutes}
406
+ - calculate_route(start_lat, start_lon, end_lat, end_lon, flood_penalty_deep, heat_factor, shade_factor, aqi_factor, grade_factor, routing_mode) -> returns route with distance, time
407
+ - generate_isochrone(lat, lon, time_limits) -> returns reachable areas
408
+ - find_along_route(start_lat, start_lon, end_lat, end_lon, resource_types) -> returns POIs along route
409
+
410
+ resource_type: pharmacy|clinic|hospital|fire_station|police|school|library|community_centre|place_of_worship|shelter
411
+
412
+ CLIMATE PARAMETERS - Set based on user context:
413
+ - FLOODING/storm/rain: flood_penalty_deep=10.0
414
+ - HEAT/hot/cooling/shade/trees: heat_factor=0.5, shade_factor=0.5
415
+ - ASTHMA/breathing/air quality: aqi_factor=0.5
416
+ - ELDERLY/wheelchair/walker/mobility/accessible/flat/hills/steep: grade_factor=0.5
417
+ - FAST routes: routing_mode="fast"
418
+
419
+ Output JSON only."""
420
+
421
+
422
+ def is_multi_step_query(query: str) -> bool:
423
+ """Quick heuristic check if query might need multiple steps."""
424
+ multi_step_indicators = [
425
+ " then ", " after ", " next ", " finally ",
426
+ " and then ", " before going ", " from there ",
427
+ ", then ", "after that", "once I", "when I reach"
428
+ ]
429
+ query_lower = query.lower()
430
+ return any(indicator in query_lower for indicator in multi_step_indicators)
431
+
432
+
433
+ def call_llm_planner(query: str) -> dict:
434
+ """Call LLM to generate a multi-step plan."""
435
+ try:
436
+ response = requests.post(
437
+ OLLAMA_URL,
438
+ json={
439
+ "model": get_current_model(),
440
+ "messages": [
441
+ {"role": "system", "content": MULTI_STEP_PLANNER_PROMPT},
442
+ {"role": "user", "content": query},
443
+ ],
444
+ "stream": False,
445
+ "format": "json",
446
+ },
447
+ timeout=60,
448
+ )
449
+ response.raise_for_status()
450
+ content = response.json().get("message", {}).get("content", "{}")
451
+ return json.loads(content)
452
+ except Exception as e:
453
+ return {"multi_step": False, "error": str(e)}
454
+
455
+
456
+ def resolve_variable_references(args: dict, step_results: dict) -> dict:
457
+ """Replace $stepN.field references with actual values from previous results."""
458
+ resolved = {}
459
+ for key, value in args.items():
460
+ if isinstance(value, str) and value.startswith("$step"):
461
+ # Parse reference like "$step1.lat"
462
+ try:
463
+ parts = value[1:].split(".") # Remove $ and split
464
+ step_ref = parts[0] # "step1"
465
+ field = parts[1] if len(parts) > 1 else None # "lat"
466
+
467
+ step_num = int(step_ref.replace("step", ""))
468
+ step_result = step_results.get(step_num, {})
469
+
470
+ if field:
471
+ resolved[key] = step_result.get(field, value)
472
+ else:
473
+ resolved[key] = step_result
474
+ except (ValueError, IndexError):
475
+ resolved[key] = value # Keep original if parsing fails
476
+ else:
477
+ resolved[key] = value
478
+ return resolved
479
+
480
+
481
+ def execute_multi_step_plan(plan: dict, engine) -> tuple[list[dict], list[dict]]:
482
+ """Execute a multi-step plan, passing results between steps.
483
+
484
+ Returns:
485
+ tuple: (list of results, list of map_data for each step)
486
+ """
487
+ steps = plan.get("steps", [])
488
+ step_results = {} # Store results keyed by step number
489
+ all_results = []
490
+ all_map_data = []
491
+
492
+ for step in steps:
493
+ step_num = step.get("step", len(step_results) + 1)
494
+ tool_name = step.get("tool", "")
495
+ args = step.get("args", {})
496
+ description = step.get("description", f"Step {step_num}")
497
+
498
+ # Resolve any variable references from previous steps
499
+ resolved_args = resolve_variable_references(args, step_results)
500
+
501
+ # Execute the tool
502
+ result, map_data = execute_tool(tool_name, resolved_args, engine)
503
+
504
+ # Store result for future reference
505
+ # Flatten key fields for easy reference
506
+ step_results[step_num] = {
507
+ "lat": result.get("lat", result.get("destination", {}).get("lat") if isinstance(result.get("destination"), dict) else None),
508
+ "lon": result.get("lon", result.get("destination", {}).get("lon") if isinstance(result.get("destination"), dict) else None),
509
+ "name": result.get("name", result.get("destination", {}).get("name") if isinstance(result.get("destination"), dict) else None),
510
+ "distance_meters": result.get("distance_meters", 0),
511
+ "walking_time_minutes": result.get("walking_time_minutes", 0),
512
+ "full_result": result
513
+ }
514
+
515
+ all_results.append({
516
+ "step": step_num,
517
+ "description": description,
518
+ "tool": tool_name,
519
+ "args": resolved_args,
520
+ "result": result
521
+ })
522
+ all_map_data.append(map_data)
523
+
524
+ return all_results, all_map_data
525
+
526
+
527
+ def format_multi_step_results(results: list[dict]) -> str:
528
+ """Format multi-step results for display."""
529
+ lines = ["**Multi-Step Query Results**\n"]
530
+
531
+ for step_result in results:
532
+ step_num = step_result.get("step", "?")
533
+ description = step_result.get("description", "")
534
+ result = step_result.get("result", {})
535
+
536
+ lines.append(f"### Step {step_num}: {description}")
537
+
538
+ if "error" in result:
539
+ lines.append(f"❌ Error: {result['error']}\n")
540
+ elif "name" in result:
541
+ # find_nearest result
542
+ lines.append(f"✅ **{result.get('name')}**")
543
+ lines.append(f" 📍 {result.get('distance_meters', 0):.0f}m away · 🚶 {result.get('walking_time_minutes', 0):.1f} min walk")
544
+ # Add climate metrics if available
545
+ if "climate_metrics" in result:
546
+ climate = result["climate_metrics"]
547
+ climate_parts = []
548
+ if climate.get("avg_flood_risk", 0) > 0.1:
549
+ climate_parts.append(f"🌊 Flood: {climate['avg_flood_risk']:.0%}")
550
+ if climate.get("avg_heat_risk", 0) > 0.1:
551
+ climate_parts.append(f"🌡️ Heat: {climate['avg_heat_risk']:.0%}")
552
+ if climate.get("avg_air_quality_risk", 0) > 0.1:
553
+ climate_parts.append(f"💨 Air: {climate['avg_air_quality_risk']:.0%}")
554
+ if climate_parts:
555
+ lines.append(f" Climate: {' · '.join(climate_parts)}")
556
+ lines.append("")
557
+ elif "success" in result:
558
+ # calculate_route result
559
+ lines.append(f"✅ Route calculated: {result.get('distance_meters', 0):.0f}m · {result.get('walking_time_minutes', 0):.1f} min")
560
+ # Add climate metrics for route
561
+ if "climate_metrics" in result:
562
+ climate = result["climate_metrics"]
563
+ combined = climate.get("avg_climate_risk", 0)
564
+ lines.append(f" Climate risk: {combined:.0%} (🌊 {climate.get('avg_flood_risk', 0):.0%} · 🌡️ {climate.get('avg_heat_risk', 0):.0%} · 💨 {climate.get('avg_air_quality_risk', 0):.0%})")
565
+ lines.append("")
566
+ else:
567
+ lines.append(f"✅ Completed\n")
568
+
569
+ return "\n".join(lines)
570
+
571
+
572
+ def merge_multi_step_map_data(all_map_data: list[dict]) -> dict:
573
+ """Merge map data from multiple steps into a single map display.
574
+
575
+ Handles both old format (route_coords) and new format (routes array).
576
+ Draws routes between consecutive step destinations for multi-step queries.
577
+ """
578
+ merged = {
579
+ "routes": [],
580
+ "markers": [],
581
+ "waypoints": [], # Intermediate stops between steps
582
+ }
583
+
584
+ colors = ["#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6"] # Blue, green, amber, red, purple
585
+
586
+ for i, map_data in enumerate(all_map_data):
587
+ if not map_data:
588
+ continue
589
+
590
+ color = colors[i % len(colors)]
591
+
592
+ # Merge routes - handle both formats
593
+ # New format: "routes" array with multiple route alternatives
594
+ if "routes" in map_data and map_data["routes"]:
595
+ # Use recommended route, or first route as fallback
596
+ recommended_name = map_data.get("recommended", "")
597
+ route_to_use = None
598
+ for route in map_data["routes"]:
599
+ if route.get("name") == recommended_name:
600
+ route_to_use = route
601
+ break
602
+ if not route_to_use:
603
+ route_to_use = map_data["routes"][0]
604
+
605
+ if route_to_use and route_to_use.get("coords"):
606
+ merged["routes"].append({
607
+ "coords": route_to_use["coords"],
608
+ "color": color,
609
+ "label": f"Step {i + 1}: {route_to_use.get('label', 'Route')}"
610
+ })
611
+ # Old format: single "route_coords"
612
+ elif "route_coords" in map_data:
613
+ merged["routes"].append({
614
+ "coords": map_data["route_coords"],
615
+ "color": color,
616
+ "label": f"Step {i + 1}"
617
+ })
618
+
619
+ # Merge origin/destination markers
620
+ if "origin" in map_data:
621
+ if i == 0: # Only use origin from first step
622
+ merged["origin"] = map_data["origin"]
623
+ else:
624
+ # Intermediate origins become waypoints
625
+ merged["waypoints"].append({
626
+ "coords": map_data["origin"],
627
+ "label": f"Waypoint {i}",
628
+ "step": i + 1
629
+ })
630
+
631
+ if "destination" in map_data:
632
+ # Track all destinations as potential waypoints
633
+ dest_info = {
634
+ "coords": map_data["destination"],
635
+ "name": map_data.get("dest_name", f"Step {i + 1}"),
636
+ "step": i + 1,
637
+ "is_final": True # Will be updated if more steps follow
638
+ }
639
+ # Mark previous destinations as not final
640
+ for wp in merged["waypoints"]:
641
+ if wp.get("is_final"):
642
+ wp["is_final"] = False
643
+ merged["waypoints"].append(dest_info)
644
+ # Always keep last destination
645
+ merged["destination"] = map_data["destination"]
646
+ merged["dest_name"] = map_data.get("dest_name", f"Step {i + 1} destination")
647
+
648
+ # Merge isochrones
649
+ if "isochrones" in map_data:
650
+ merged["isochrones"] = map_data["isochrones"]
651
+
652
+ # Merge resources_within
653
+ if "resources_within" in map_data:
654
+ merged["resources_within"] = map_data.get("resources_within", [])
655
+
656
+ # Merge POIs along route
657
+ if "pois_along_route" in map_data:
658
+ if "pois_along_route" not in merged:
659
+ merged["pois_along_route"] = []
660
+ merged["pois_along_route"].extend(map_data["pois_along_route"])
661
+
662
+ return merged
663
+
664
+ # =============================================================================
665
+ # NYC-Specific Emergency Guidance Templates
666
+ # =============================================================================
667
+
668
+ NYC_EMERGENCY_CONTACTS = """
669
+ ## EMERGENCY CONTACTS
670
+ | Service | Contact |
671
+ |---------|---------|
672
+ | Life-threatening emergencies | **911** |
673
+ | City services, cooling centers | **311** |
674
+ | Con Edison (power outages) | **1-800-752-6633** |
675
+ | NYC Emergency Alerts | **NYC.gov/notifynyc** |
676
+ | CB16 CERT (Brownsville) | **(718) 385-0323** |
677
+ """
678
+
679
+ BROWNSVILLE_RESOURCES = """
680
+ ## NEARBY RESOURCES IN BROWNSVILLE
681
+ - **Brownsville Recreation Center**: 1555 Linden Blvd (cooling center)
682
+ - **Stone Avenue Library**: 581 Mother Gaston Blvd
683
+ - **Brownsville Multi-Service Family Health Center**: 592 Rockaway Ave
684
+ - **CB16 Office**: 444 Thomas S. Boyland St - (718) 385-0323
685
+ """
686
+
687
+ def get_heat_guidance(flood_risk: float = 0) -> str:
688
+ """NYC official heat emergency guidance."""
689
+ guidance = """
690
+ ## HEAT EMERGENCY CHECKLIST
691
+
692
+ ### BEFORE DEPARTURE
693
+ - [ ] Bring water—drink even if not thirsty
694
+ - [ ] Wear light, loose-fitting clothing
695
+ - [ ] Check air quality: **dec.ny.gov** (high ozone accompanies heat waves)
696
+ - [ ] Confirm destination has AC (most heat deaths occur in homes without AC)
697
+
698
+ ### DURING TRANSIT
699
+ - [ ] Avoid strenuous activity, especially 12pm-6pm
700
+ - [ ] Take breaks in shade or AC—even a few hours helps
701
+ - [ ] Watch for heat illness signs: heavy sweating, muscle cramps, dizziness, headache
702
+
703
+ ### IF CONDITIONS WORSEN
704
+ - [ ] Find cooling center: Call **311** or **finder.nyc.gov/coolingcenters**
705
+ - [ ] Confusion + hot/dry skin + rapid pulse = **Call 911** (heat stroke)
706
+ - [ ] Code Red: Any shelter accepts people in heat distress
707
+ """
708
+ if flood_risk > 0.2:
709
+ guidance += """
710
+ ### ⚠️ COMBINED HEAT + FLOOD RISK
711
+ - [ ] Avoid flooded underpasses—heat + standing water = dangerous conditions
712
+ - [ ] Flash flooding can occur during summer storms
713
+ """
714
+ return guidance
715
+
716
+
717
+ def get_flood_guidance(heat_risk: float = 0) -> str:
718
+ """NYC official flood emergency guidance."""
719
+ guidance = """
720
+ ## FLOOD EMERGENCY CHECKLIST
721
+
722
+ ### BEFORE DEPARTURE
723
+ - [ ] This route avoids low-elevation flood-prone areas where possible
724
+ - [ ] Charge phone; have backup battery
725
+ - [ ] Know your evacuation zone: **NYC.gov/knowyourzone**
726
+
727
+ ### DURING TRANSIT
728
+ - [ ] **"Turn Around, Don't Drown"**—never walk through moving water
729
+ - [ ] 6 inches of moving water can knock you down
730
+ - [ ] Avoid underpasses and subway entrances where water collects
731
+ - [ ] Stay away from downed power lines
732
+
733
+ ### IF CONDITIONS WORSEN
734
+ - [ ] Move to higher ground immediately
735
+ - [ ] If trapped, **call 911**
736
+ - [ ] Do NOT enter basements or below-grade spaces
737
+ """
738
+ if heat_risk > 0.2:
739
+ guidance += """
740
+ ### ⚠️ COMBINED FLOOD + HEAT RISK
741
+ - [ ] Post-flood conditions can be humid and hot—stay hydrated
742
+ - [ ] Mold grows quickly after flooding—avoid prolonged exposure
743
+ """
744
+ return guidance
745
+
746
+
747
+ def get_blackout_guidance() -> str:
748
+ """NYC official blackout emergency guidance."""
749
+ return """
750
+ ## BLACKOUT EMERGENCY CHECKLIST
751
+
752
+ ### BEFORE DEPARTURE
753
+ - [ ] Route prioritizes ground-floor accessible resources
754
+ - [ ] Elevators will fail in high-rises—plan for stairs
755
+ - [ ] Charge all devices; bring flashlight
756
+
757
+ ### DURING TRANSIT
758
+ - [ ] Traffic signals may be out—cross carefully
759
+ - [ ] Subway service likely suspended
760
+ - [ ] Electronic door locks may not work
761
+
762
+ ### IF CONDITIONS WORSEN
763
+ - [ ] Report outages: **Con Edison 1-800-752-6633**
764
+ - [ ] Help seniors/disabled descend from upper floors
765
+ - [ ] Never use generators indoors (CO poisoning kills)
766
+ """
767
+
768
+
769
+ def get_general_guidance() -> str:
770
+ """General emergency preparedness guidance."""
771
+ return """
772
+ ## GENERAL EMERGENCY CHECKLIST
773
+
774
+ ### BEFORE DEPARTURE
775
+ - [ ] Check weather conditions: **weather.gov**
776
+ - [ ] Tell someone your route and expected arrival time
777
+ - [ ] Charge phone; bring water and ID
778
+
779
+ ### DURING TRANSIT
780
+ - [ ] Follow the recommended climate-safe route
781
+ - [ ] Stay aware of surroundings
782
+ - [ ] If conditions change, seek shelter immediately
783
+
784
+ ### IF CONDITIONS WORSEN
785
+ - [ ] Find nearest sturdy building or shelter
786
+ - [ ] **Call 911** for life-threatening emergencies
787
+ - [ ] **Call 311** for city services and information
788
+ """
789
+
790
+
791
+ def detect_scenario_type(climate: dict, user_query: str) -> str:
792
+ """Detect emergency scenario type from climate data and query."""
793
+ query_lower = user_query.lower()
794
+ flood_risk = climate.get("avg_flood_risk", 0)
795
+ heat_risk = climate.get("avg_heat_risk", 0)
796
+
797
+ # Check query for explicit mentions
798
+ if any(word in query_lower for word in ["blackout", "power outage", "no power", "electricity"]):
799
+ return "blackout"
800
+ if any(word in query_lower for word in ["heat", "hot", "cooling", "temperature", "heat wave"]):
801
+ return "heat"
802
+ if any(word in query_lower for word in ["flood", "rain", "storm", "water", "hurricane"]):
803
+ return "flood"
804
+
805
+ # Detect from risk metrics
806
+ if flood_risk > 0.4:
807
+ return "flood"
808
+ if heat_risk > 0.4:
809
+ return "heat"
810
+ if flood_risk > 0.2 and heat_risk > 0.2:
811
+ return "combined"
812
+ if flood_risk > 0.2:
813
+ return "flood"
814
+ if heat_risk > 0.2:
815
+ return "heat"
816
+
817
+ return "general"
818
+
819
+
820
+ # =============================================================================
821
+ # Code-Based Report Generation (No LLM needed - much faster!)
822
+ # =============================================================================
823
+
824
+ def generate_route_action_plan(result: dict, user_query: str) -> str:
825
+ """Generate Emergency Action Plan from route data with NYC-specific guidance."""
826
+ dest = result.get("destination", {})
827
+ dest_name = dest.get("name", "Destination") if isinstance(dest, dict) else "Destination"
828
+ distance = result.get("distance_meters", 0)
829
+ time_min = result.get("walking_time_minutes", 0)
830
+ metrics = result.get("route_metrics", {})
831
+ climate = result.get("climate_metrics", {})
832
+
833
+ terrain = metrics.get("difficulty", "unknown")
834
+ elev_gain = metrics.get("elevation_gain_m", 0)
835
+
836
+ # Climate risk assessment
837
+ flood_risk = climate.get("avg_flood_risk", 0)
838
+ heat_risk = climate.get("avg_heat_risk", 0)
839
+ air_quality_risk = climate.get("avg_air_quality_risk", 0)
840
+ flood_exposure = climate.get("flood_exposure_m", 0)
841
+ climate_avoided = climate.get("climate_avoided_m", 0)
842
+ combined_risk = climate.get("avg_climate_risk", 0)
843
+
844
+ # Determine scenario and risk level
845
+ scenario = detect_scenario_type(climate, user_query)
846
+ scenario_labels = {
847
+ "heat": "🌡️ EXTREME HEAT",
848
+ "flood": "🌊 FLOOD WARNING",
849
+ "blackout": "⚡ POWER OUTAGE",
850
+ "combined": "⚠️ COMBINED HEAT + FLOOD",
851
+ "general": "📋 GENERAL EMERGENCY"
852
+ }
853
+ scenario_label = scenario_labels.get(scenario, "📋 GENERAL EMERGENCY")
854
+
855
+ risk_level = "🟢 LOW"
856
+ if combined_risk > 0.6 or flood_risk > 0.4 or heat_risk > 0.4:
857
+ risk_level = "🔴 HIGH"
858
+ elif combined_risk > 0.4 or flood_risk > 0.2 or heat_risk > 0.2:
859
+ risk_level = "🟡 MODERATE"
860
+ elif combined_risk > 0.2:
861
+ risk_level = "🟠 ELEVATED"
862
+
863
+ # Build the report
864
+ report = f"""
865
+ ---
866
+ # 📋 ACTION PLAN REPORT
867
+
868
+ ## MISSION
869
+ | Field | Value |
870
+ |-------|-------|
871
+ | **Destination** | {dest_name} |
872
+ | **Distance** | {distance:.0f}m |
873
+ | **Walking Time** | {time_min:.1f} min |
874
+ | **Elevation** | +{elev_gain:.0f}m climb |
875
+ | **Scenario** | {scenario_label} |
876
+ | **Risk Level** | {risk_level} |
877
+
878
+ ## ROUTE INTELLIGENCE
879
+ | Climate Metric | Value |
880
+ |----------------|-------|
881
+ | **Combined Climate Risk** | {combined_risk:.0%} |
882
+ | Flood Risk | {flood_risk:.0%} ({flood_exposure:.0f}m exposed) |
883
+ | Heat Risk | {heat_risk:.0%} |
884
+ | Air Quality Risk | {air_quality_risk:.0%} |
885
+ | Terrain | {terrain.title()} |
886
+ """
887
+
888
+ # Add climate-safe route note if applicable
889
+ if climate_avoided > 0:
890
+ report += f"""
891
+ ### ✓ CLIMATE-SAFE ROUTE SELECTED
892
+ This route avoids **{climate_avoided:.0f}m** of high-risk streets.
893
+ """
894
+
895
+ # Add scenario-specific guidance
896
+ if scenario == "heat":
897
+ report += get_heat_guidance(flood_risk)
898
+ elif scenario == "flood":
899
+ report += get_flood_guidance(heat_risk)
900
+ elif scenario == "blackout":
901
+ report += get_blackout_guidance()
902
+ elif scenario == "combined":
903
+ report += get_heat_guidance(flood_risk)
904
+ report += get_flood_guidance(heat_risk)
905
+ else:
906
+ report += get_general_guidance()
907
+
908
+ # Add emergency contacts and local resources
909
+ report += NYC_EMERGENCY_CONTACTS
910
+ report += BROWNSVILLE_RESOURCES
911
+ report += "\n---"
912
+
913
+ return report
914
+
915
+
916
+ def generate_isochrone_report(result: dict, user_query: str) -> str:
917
+ """Generate Coverage Assessment from isochrone data with NYC-specific guidance."""
918
+ isochrones = result.get("isochrones", [])
919
+ resources = result.get("resources_within", [])
920
+
921
+ # Categorize resources by time
922
+ immediate = [r for r in resources if r.get("walking_time_minutes", 99) <= 5]
923
+ short_term = [r for r in resources if 5 < r.get("walking_time_minutes", 99) <= 10]
924
+ extended = [r for r in resources if 10 < r.get("walking_time_minutes", 99) <= 15]
925
+
926
+ # Find critical resource types
927
+ types_found = set(r.get("type", "") for r in resources)
928
+ critical_types = {"hospital", "clinic", "fire_station", "police", "shelter"}
929
+ missing = critical_types - types_found
930
+
931
+ # Find cooling centers specifically
932
+ cooling_centers = [r for r in resources if "cooling" in r.get("name", "").lower() or r.get("type") == "community_centre"]
933
+
934
+ report = f"""
935
+ ---
936
+ # 📋 EMERGENCY COVERAGE ASSESSMENT
937
+
938
+ ## REACHABILITY ZONES
939
+ """
940
+ for iso in isochrones:
941
+ emoji = {"5": "🟢", "10": "🟡", "15": "🟠"}.get(str(iso.get("time_min")), "⚪")
942
+ report += f"- {emoji} **{iso.get('time_min')} min**: {iso.get('node_count', 0)} intersections reachable\n"
943
+
944
+ report += f"""
945
+ ## RESOURCES BY PRIORITY
946
+
947
+ ### 🟢 IMMEDIATE (0-5 min) - {len(immediate)} resources
948
+ """
949
+ for r in immediate[:5]:
950
+ report += f"- {r.get('name')} ({r.get('type')}) - {r.get('walking_time_minutes', 0):.1f} min\n"
951
+
952
+ report += f"""
953
+ ### 🟡 SHORT-TERM (5-10 min) - {len(short_term)} resources
954
+ """
955
+ for r in short_term[:5]:
956
+ report += f"- {r.get('name')} ({r.get('type')}) - {r.get('walking_time_minutes', 0):.1f} min\n"
957
+
958
+ report += f"""
959
+ ### 🟠 EXTENDED (10-15 min) - {len(extended)} resources
960
+ """
961
+ for r in extended[:5]:
962
+ report += f"- {r.get('name')} ({r.get('type')}) - {r.get('walking_time_minutes', 0):.1f} min\n"
963
+
964
+ report += f"""
965
+ ## COVERAGE ASSESSMENT
966
+
967
+ | Assessment | Status |
968
+ |------------|--------|
969
+ | **Total Resources** | {len(resources)} within 15 min |
970
+ | **Critical Services** | {len(critical_types - missing)}/5 covered |
971
+ | **Gaps** | {', '.join(missing) if missing else '✓ All covered'} |
972
+ | **Cooling Centers** | {len(cooling_centers)} nearby |
973
+
974
+ ## NYC HOUSEHOLD EMERGENCY CHECKLIST
975
+ - [ ] Save nearest clinic/hospital address
976
+ - [ ] Know your evacuation zone: **NYC.gov/knowyourzone**
977
+ - [ ] Know 2 evacuation routes from your location
978
+ - [ ] Keep emergency kit ready (water, flashlight, phone charger, medications)
979
+ - [ ] Sign up for alerts: **NYC.gov/notifynyc**
980
+ - [ ] Share this plan with family members
981
+
982
+ ## HEAT EMERGENCY RESOURCES
983
+ - [ ] Nearest cooling center: Call **311** or **finder.nyc.gov/coolingcenters**
984
+ - [ ] Libraries and community centers often serve as cooling centers
985
+ - [ ] Code Red: Any shelter accepts people in heat distress
986
+ """
987
+ report += NYC_EMERGENCY_CONTACTS
988
+ report += BROWNSVILLE_RESOURCES
989
+ report += "\n---"
990
+ return report
991
+
992
+
993
+ def generate_corridor_report(result: dict, user_query: str) -> str:
994
+ """Generate Evacuation Corridor Report from along-route data with NYC-specific guidance."""
995
+ pois = result.get("pois_found", [])
996
+ buffer = result.get("buffer_meters", 100)
997
+ poi_count = result.get("poi_count", len(pois))
998
+ climate = result.get("climate_metrics", {})
999
+
1000
+ # Extract climate metrics
1001
+ flood_risk = climate.get("avg_flood_risk", 0)
1002
+ heat_risk = climate.get("avg_heat_risk", 0)
1003
+ air_quality_risk = climate.get("avg_air_quality_risk", 0)
1004
+ combined_risk = climate.get("avg_climate_risk", 0)
1005
+
1006
+ if not pois:
1007
+ report = f"""
1008
+ ---
1009
+ # 📋 EVACUATION CORRIDOR ANALYSIS
1010
+
1011
+ ## ASSESSMENT
1012
+ | Field | Value |
1013
+ |-------|-------|
1014
+ | **Resources Found** | 0 within {buffer}m of route |
1015
+ | **Recommendation** | Expand search or try alternate route |
1016
+
1017
+ ## NYC EMERGENCY GUIDANCE
1018
+ If no resources found along your route:
1019
+ - [ ] Try a wider search buffer (200-300m)
1020
+ - [ ] Check isochrone analysis for area coverage
1021
+ - [ ] Consider alternate evacuation routes
1022
+ - [ ] Call **311** for nearest cooling center or shelter
1023
+ - [ ] Know your evacuation zone: **NYC.gov/knowyourzone**
1024
+ """
1025
+ report += NYC_EMERGENCY_CONTACTS
1026
+ report += BROWNSVILLE_RESOURCES
1027
+ report += "\n---"
1028
+ return report
1029
+
1030
+ # Group by type
1031
+ by_type = {}
1032
+ for poi in pois:
1033
+ t = poi.get("type", "other")
1034
+ if t not in by_type:
1035
+ by_type[t] = []
1036
+ by_type[t].append(poi)
1037
+
1038
+ # Identify critical waypoints
1039
+ shelters = [p for p in pois if p.get("type") in ["shelter", "community_centre"]]
1040
+ medical = [p for p in pois if p.get("type") in ["hospital", "clinic", "pharmacy"]]
1041
+ emergency = [p for p in pois if p.get("type") in ["fire_station", "police"]]
1042
+
1043
+ # Build climate section if available
1044
+ climate_section = ""
1045
+ if climate:
1046
+ climate_section = f"""
1047
+ ## ROUTE CLIMATE ASSESSMENT
1048
+ | Climate Metric | Value |
1049
+ |----------------|-------|
1050
+ | **Combined Climate Risk** | {combined_risk:.0%} |
1051
+ | Flood Risk | {flood_risk:.0%} |
1052
+ | Heat Risk | {heat_risk:.0%} |
1053
+ | Air Quality Risk | {air_quality_risk:.0%} |
1054
+ """
1055
+
1056
+ report = f"""
1057
+ ---
1058
+ # 📋 EVACUATION CORRIDOR ANALYSIS
1059
+
1060
+ ## CORRIDOR SUMMARY
1061
+ | Field | Value |
1062
+ |-------|-------|
1063
+ | **Buffer Width** | {buffer}m each side |
1064
+ | **Total Resources** | {poi_count} facilities |
1065
+ | **Shelters/Centers** | {len(shelters)} |
1066
+ | **Medical Facilities** | {len(medical)} |
1067
+ | **Emergency Services** | {len(emergency)} |
1068
+ {climate_section}
1069
+ ## WAYPOINTS ALONG ROUTE
1070
+ | # | Resource | Type | Distance |
1071
+ |---|----------|------|----------|
1072
+ """
1073
+ for i, poi in enumerate(pois[:10], 1):
1074
+ report += f"| {i} | {poi.get('name', 'Unknown')[:30]} | {poi.get('type')} | {poi.get('distance_from_route_m', 0):.0f}m |\n"
1075
+
1076
+ if len(pois) > 10:
1077
+ report += f"| ... | *{len(pois) - 10} more resources* | | |\n"
1078
+
1079
+ report += f"""
1080
+ ## RESOURCES BY TYPE
1081
+ """
1082
+ for rtype, items in by_type.items():
1083
+ emoji = {"hospital": "🏥", "clinic": "🏥", "pharmacy": "💊", "shelter": "🏠",
1084
+ "fire_station": "🚒", "police": "🚔", "community_centre": "🏛️",
1085
+ "library": "📚", "school": "🏫"}.get(rtype, "📍")
1086
+ report += f"- {emoji} **{rtype}**: {len(items)} available\n"
1087
+
1088
+ report += f"""
1089
+ ## NYC EVACUATION CORRIDOR CHECKLIST
1090
+ - [ ] Note waypoint locations before departing
1091
+ - [ ] Identify which facilities can serve as shelter points
1092
+ - [ ] Know your evacuation zone: **NYC.gov/knowyourzone**
1093
+ - [ ] Share corridor plan with family/group members
1094
+ - [ ] Have backup route in mind if primary is blocked
1095
+
1096
+ ## IF CONDITIONS WORSEN EN ROUTE
1097
+ - [ ] Seek nearest waypoint shelter immediately
1098
+ - [ ] **"Turn Around, Don't Drown"**—never walk through moving water
1099
+ - [ ] Call **911** for life-threatening emergencies
1100
+ - [ ] Call **311** for city services and shelter locations
1101
+ """
1102
+ report += NYC_EMERGENCY_CONTACTS
1103
+ report += BROWNSVILLE_RESOURCES
1104
+ report += "\n---"
1105
+ return report
1106
+
1107
+
1108
+ def generate_multi_step_action_plan(all_results: list[dict], user_query: str) -> str:
1109
+ """Generate Action Plan for multi-step queries (e.g., find shelter then hospital)."""
1110
+
1111
+ # Calculate totals
1112
+ total_distance = 0
1113
+ total_time = 0
1114
+ step_summaries = []
1115
+
1116
+ for step_result in all_results:
1117
+ step_num = step_result.get("step", "?")
1118
+ description = step_result.get("description", "")
1119
+ result = step_result.get("result", {})
1120
+ tool = step_result.get("tool", "")
1121
+
1122
+ step_info = {
1123
+ "step": step_num,
1124
+ "description": description,
1125
+ "tool": tool,
1126
+ }
1127
+
1128
+ if "error" in result:
1129
+ step_info["status"] = "❌ Error"
1130
+ step_info["detail"] = result.get("error", "Unknown error")
1131
+ elif "name" in result:
1132
+ # find_nearest result
1133
+ name = result.get("name", "Unknown")
1134
+ dist = result.get("distance_meters", 0)
1135
+ time_m = result.get("walking_time_minutes", 0)
1136
+ total_distance += dist
1137
+ total_time += time_m
1138
+ step_info["status"] = "✅ Found"
1139
+ step_info["name"] = name
1140
+ step_info["distance"] = dist
1141
+ step_info["time"] = time_m
1142
+ step_info["climate"] = result.get("climate_metrics", {})
1143
+ elif "success" in result:
1144
+ # calculate_route result
1145
+ dist = result.get("distance_meters", 0)
1146
+ time_m = result.get("walking_time_minutes", 0)
1147
+ total_distance += dist
1148
+ total_time += time_m
1149
+ step_info["status"] = "✅ Route"
1150
+ step_info["distance"] = dist
1151
+ step_info["time"] = time_m
1152
+ step_info["climate"] = result.get("climate_metrics", {})
1153
+ else:
1154
+ step_info["status"] = "✅ Completed"
1155
+
1156
+ step_summaries.append(step_info)
1157
+
1158
+ # Build the report
1159
+ report = f"""
1160
+ ---
1161
+ # 📋 MULTI-STEP ACTION PLAN
1162
+
1163
+ ## MISSION OVERVIEW
1164
+ **Query:** {user_query}
1165
+
1166
+ | Metric | Value |
1167
+ |--------|-------|
1168
+ | **Total Steps** | {len(step_summaries)} |
1169
+ | **Total Distance** | {total_distance:.0f}m |
1170
+ | **Total Walking Time** | {total_time:.1f} min |
1171
+
1172
+ ## STEP-BY-STEP EXECUTION
1173
+ """
1174
+
1175
+ for step_info in step_summaries:
1176
+ step_num = step_info["step"]
1177
+ desc = step_info["description"]
1178
+ status = step_info["status"]
1179
+
1180
+ report += f"\n### Step {step_num}: {desc}\n"
1181
+ report += f"**Status:** {status}\n"
1182
+
1183
+ if "name" in step_info:
1184
+ report += f"- **Found:** {step_info['name']}\n"
1185
+ if "distance" in step_info:
1186
+ report += f"- **Distance:** {step_info['distance']:.0f}m\n"
1187
+ if "time" in step_info:
1188
+ report += f"- **Walking Time:** {step_info['time']:.1f} min\n"
1189
+
1190
+ climate = step_info.get("climate", {})
1191
+ if climate:
1192
+ heat = climate.get("avg_heat_risk", 0)
1193
+ flood = climate.get("avg_flood_risk", 0)
1194
+ aqi = climate.get("avg_air_quality_risk", 0)
1195
+ if heat > 0 or flood > 0 or aqi > 0:
1196
+ report += f"- **Climate:** 🌡️ Heat: {heat:.0%} · 🌊 Flood: {flood:.0%} · 💨 Air: {aqi:.0%}\n"
1197
+
1198
+ if "detail" in step_info:
1199
+ report += f"- **Detail:** {step_info['detail']}\n"
1200
+
1201
+ # Risk summary
1202
+ all_climate = [s.get("climate", {}) for s in step_summaries if s.get("climate")]
1203
+ if all_climate:
1204
+ avg_heat = sum(c.get("avg_heat_risk", 0) for c in all_climate) / len(all_climate)
1205
+ avg_flood = sum(c.get("avg_flood_risk", 0) for c in all_climate) / len(all_climate)
1206
+ avg_aqi = sum(c.get("avg_air_quality_risk", 0) for c in all_climate) / len(all_climate)
1207
+
1208
+ risk_level = "🟢 LOW"
1209
+ if avg_heat > 0.6 or avg_flood > 0.4:
1210
+ risk_level = "🔴 HIGH"
1211
+ elif avg_heat > 0.4 or avg_flood > 0.2:
1212
+ risk_level = "🟡 MODERATE"
1213
+
1214
+ report += f"""
1215
+ ## JOURNEY RISK ASSESSMENT
1216
+ | Risk Type | Average |
1217
+ |-----------|---------|
1218
+ | 🌡️ Heat Risk | {avg_heat:.0%} |
1219
+ | 🌊 Flood Risk | {avg_flood:.0%} |
1220
+ | 💨 Air Quality Risk | {avg_aqi:.0%} |
1221
+ | **Overall Risk Level** | {risk_level} |
1222
+ """
1223
+
1224
+ # Add safety recommendations based on query content
1225
+ report += """
1226
+ ## SAFETY RECOMMENDATIONS
1227
+ """
1228
+ query_lower = user_query.lower()
1229
+ if "flood" in query_lower or "storm" in query_lower:
1230
+ report += "- 🌊 **Flooding:** Avoid flood zones. Turn Around, Don't Drown!\n"
1231
+ if "heat" in query_lower or "hot" in query_lower:
1232
+ report += "- 💧 **Hydration:** Bring water, take rest breaks in shade\n"
1233
+ if "elderly" in query_lower or "grandmother" in query_lower or "grandfather" in query_lower:
1234
+ report += "- 👴 **Mobility:** Take frequent breaks, avoid rushing\n"
1235
+ report += "- 📍 **Navigation:** Confirm each waypoint before proceeding to next\n"
1236
+ report += "- 📱 **Communication:** Share your plan with someone\n"
1237
+
1238
+ report += """
1239
+ ## NYC EMERGENCY CHECKLIST
1240
+ - [ ] 📱 Phone charged for emergencies
1241
+ - [ ] 💧 Water bottle (especially in heat)
1242
+ - [ ] 💊 Medications if needed
1243
+ - [ ] 🗺️ Know evacuation zone: **NYC.gov/knowyourzone**
1244
+ - [ ] 📢 Sign up for alerts: **NYC.gov/notifynyc**
1245
+ """
1246
+
1247
+ report += NYC_EMERGENCY_CONTACTS
1248
+ report += BROWNSVILLE_RESOURCES
1249
+ report += "\n---"
1250
+
1251
+ return report
1252
+
1253
+
1254
+ # =============================================================================
1255
+ # LLM Prompts for Action Plan Generation (NYC-Specific)
1256
+ # =============================================================================
1257
+
1258
+ ROUTE_EXPLANATION_PROMPT = """You are an NYC Emergency Preparedness Analyst for Brownsville, Brooklyn. Generate a detailed ACTION PLAN REPORT from the route data.
1259
+
1260
+ REQUIRED OUTPUT STRUCTURE (you MUST include ALL these sections):
1261
+
1262
+ ## 🚶 1. ROUTE SUMMARY TABLE (REQUIRED)
1263
+ | Metric | Value |
1264
+ |--------|-------|
1265
+ | 📏 Distance | [X] meters |
1266
+ | ⏱️ Walking Time | [X] minutes |
1267
+ | 🛣️ Recommended Route | [name] |
1268
+
1269
+ ## 🌡️ 2. CLIMATE RISK ASSESSMENT TABLE (REQUIRED - MUST include ALL 4 metrics)
1270
+ | Risk Type | Value | Level |
1271
+ |-----------|-------|-------|
1272
+ | 🌳 Shade Coverage | [avg_tree_coverage as %] | [emoji] [level] |
1273
+ | 🌊 Flood Risk | [avg_flood_risk as %] | [emoji] [level] |
1274
+ | ☀️ Heat Vulnerability | [avg_heat_risk as %] | [emoji] [level] |
1275
+ | 💨 Air Quality Risk | [avg_air_quality_risk as %] | [emoji] [level] |
1276
+ | 🌊 Flood Exposure | [flood_exposure_m] meters in flood zones | |
1277
+
1278
+ CRITICAL - CLIMATE DATA INTERPRETATION:
1279
+ - avg_tree_coverage: 0-1 scale where HIGHER IS BETTER (more shade = safer in heat)
1280
+ * < 0.3 (under 30%) = 🔴 POOR SHADE - dangerous in heat, seek shade
1281
+ * 0.3-0.6 (30-60%) = 🟡 MODERATE SHADE - some protection
1282
+ * > 0.6 (over 60%) = 🟢 GOOD SHADE - comfortable walk
1283
+ * ALWAYS display this as "Shade Coverage: X%" with emoji rating
1284
+ - avg_heat_risk: 0-1 where HIGHER IS WORSE (Heat Vulnerability Index)
1285
+ - avg_air_quality_risk: 0-1 where HIGHER IS WORSE
1286
+ - avg_flood_risk: 0-1 where HIGHER IS WORSE
1287
+ - flood_exposure_m: meters of route through flood zones (0 is best)
1288
+
1289
+ ## ⚠️ 3. SAFETY RECOMMENDATIONS (REQUIRED)
1290
+ Use emoji bullets for ALL recommendations based on climate data:
1291
+ - 💧 Hydration: "Bring water, stay hydrated" (if heat risk > 0.5)
1292
+ - 🌳 Shade: "Seek shaded rest stops" (if shade coverage < 40%)
1293
+ - 🌊 Flooding: "Avoid flooded areas, X meters in flood zones" (if flood_exposure > 0)
1294
+ - 😷 Air Quality: "Wear mask if sensitive" (if air quality risk > 0.5)
1295
+ - 👴 Elderly/Mobility: "Take frequent breaks" (always for vulnerable populations)
1296
+ - 🏥 Medical: "Nearest hospital/clinic is X" (always include)
1297
+
1298
+ ## ✅ 4. NYC EMERGENCY CHECKLIST
1299
+ Use checkbox format with emojis:
1300
+ - [ ] 📱 Phone charged for emergencies
1301
+ - [ ] 💧 Water bottle (especially in heat)
1302
+ - [ ] 🧢 Sun protection (hat, sunscreen)
1303
+ - [ ] 💊 Medications if needed
1304
+ - [ ] 🗺️ Know alternate routes
1305
+
1306
+ ## 📞 5. EMERGENCY CONTACTS
1307
+ | Service | Contact |
1308
+ |---------|---------|
1309
+ | 🚨 Emergency | 911 |
1310
+ | 🏙️ NYC Services | 311 |
1311
+ | 🆘 CB16 CERT | (718) 385-0323 |
1312
+ | ❄️ Cooling Centers | Call 311 |
1313
+
1314
+ FORMATTING RULES:
1315
+ - Use markdown tables with clear headers
1316
+ - Use LARGE VISUAL EMOJI throughout for quick scanning:
1317
+ * 🟢 = Low Risk / Good / Safe
1318
+ * 🟡 = Moderate / Caution
1319
+ * 🟠 = Elevated / Warning
1320
+ * 🔴 = High Risk / Danger / Poor
1321
+ - Today's date is {date}
1322
+ - NEVER use placeholders - use actual data from the JSON
1323
+ - Make report SCANNABLE - emergencies require quick info
1324
+
1325
+ BROWNSVILLE RESOURCES:
1326
+ - 🏢 Brownsville Recreation Center: 1555 Linden Blvd (cooling center)
1327
+ - 📚 Stone Avenue Library: 581 Mother Gaston Blvd
1328
+ - 🚒 CB16 CERT: (718) 385-0323"""
1329
+
1330
+ ISOCHRONE_EXPLANATION_PROMPT = """You are an NYC Emergency Preparedness Analyst. Generate an EMERGENCY COVERAGE ASSESSMENT from the reachability data.
1331
+
1332
+ ## 🗺️ 1. REACHABILITY ZONES
1333
+ Use emoji time indicators:
1334
+ - 🟢 **0-5 min**: [list resources] - IMMEDIATE access
1335
+ - 🟡 **5-10 min**: [list resources] - SHORT-TERM access
1336
+ - 🟠 **10-15 min**: [list resources] - EXTENDED access
1337
+
1338
+ ## 📍 2. RESOURCES BY CATEGORY
1339
+ | Type | Count | Nearest | Walk Time |
1340
+ |------|-------|---------|-----------|
1341
+ | 🏥 Healthcare | X | [name] | X min |
1342
+ | 🏠 Shelters | X | [name] | X min |
1343
+ | 🚒 Fire/Police | X | [name] | X min |
1344
+ | 💊 Pharmacy | X | [name] | X min |
1345
+
1346
+ ## ⚠️ 3. COVERAGE GAPS
1347
+ Flag missing critical services with 🔴
1348
+
1349
+ ## ✅ 4. NYC HOUSEHOLD CHECKLIST
1350
+ - [ ] 🗺️ Know your evacuation zone: NYC.gov/knowyourzone
1351
+ - [ ] 📱 Sign up for alerts: NYC.gov/notifynyc
1352
+ - [ ] ❄️ Nearest cooling center: Call 311
1353
+ - [ ] 📞 CB16 CERT: (718) 385-0323
1354
+
1355
+ Use actual resource names and walking times from the data."""
1356
+
1357
+ ALONG_ROUTE_EXPLANATION_PROMPT = """You are an NYC Emergency Preparedness Analyst. Generate an EVACUATION CORRIDOR REPORT from the route corridor data.
1358
+
1359
+ ## 🛣️ 1. CORRIDOR SUMMARY
1360
+ | Metric | Value |
1361
+ |--------|-------|
1362
+ | 📏 Buffer Width | X meters |
1363
+ | 📍 Resources Found | X total |
1364
+ | 🏥 Healthcare | X |
1365
+ | 🏠 Shelters | X |
1366
+
1367
+ ## 📍 2. WAYPOINTS TABLE
1368
+ | Resource | Type | Distance from Route |
1369
+ |----------|------|---------------------|
1370
+ | [name] | 🏥/🏠/💊 | X meters |
1371
+
1372
+ ## ⚠️ 3. NYC EVACUATION GUIDANCE
1373
+ - [ ] 🗺️ Know your zone: NYC.gov/knowyourzone
1374
+ - [ ] 🌊 "Turn Around, Don't Drown"—never walk through moving water
1375
+ - [ ] 🚨 If trapped, call 911
1376
+ - [ ] ⚡ Power issues: Con Edison 1-800-752-6633
1377
+
1378
+ ## 📞 4. EMERGENCY CONTACTS
1379
+ | Service | Contact |
1380
+ |---------|---------|
1381
+ | 🚨 Emergency | 911 |
1382
+ | 🏙️ NYC Services | 311 |
1383
+ | 🆘 CB16 CERT | (718) 385-0323 |
1384
+
1385
+ If NO resources found:
1386
+ - 🔍 Recommend wider search buffer
1387
+ - 📞 Call 311 for nearest resources
1388
+ - 🗺️ Check NYC.gov/knowyourzone
1389
+
1390
+ Use actual data provided. Be specific with names and distances."""
1391
+
1392
+ MULTI_STEP_EXPLANATION_PROMPT = """You are an NYC Emergency Preparedness Analyst. Generate a MULTI-STEP EMERGENCY ACTION PLAN from the sequential query results.
1393
+
1394
+ Include:
1395
+ 1. MISSION OVERVIEW: What needs to be accomplished across all steps
1396
+ 2. STEP-BY-STEP EXECUTION: For each step, summarize key finding and action
1397
+ 3. TOTAL JOURNEY: Combined distance, time, climate risk assessment
1398
+ 4. NYC COORDINATION CHECKLIST:
1399
+ - [ ] Know your evacuation zone: NYC.gov/knowyourzone
1400
+ - [ ] Sign up for alerts: NYC.gov/notifynyc
1401
+ - [ ] Waypoint confirmations
1402
+ - [ ] Contingency if conditions worsen
1403
+
1404
+ 5. EMERGENCY CONTACTS:
1405
+ - 911: Life-threatening emergencies
1406
+ - 311: City services, cooling centers
1407
+ - CB16 CERT: (718) 385-0323
1408
+
1409
+ Use actual data from each step. Be specific about locations, distances, and times."""
1410
+
1411
+ # =============================================================================
1412
+ # Example Queries by Persona - Climate-Aware Emergency Routing
1413
+ # =============================================================================
1414
+ # These queries have been tested and validated with 80%+ success rate in E2E tests.
1415
+ # Mix of NYCHA housing names and street addresses from places.csv for realistic scenarios.
1416
+
1417
+ # CERT / Emergency Response Team - Evacuation & Corridor Analysis
1418
+ CERT_EXAMPLES = [
1419
+ # find_along_route - 24 POIs found in test
1420
+ "What resources are along the evacuation route from Van Dyke Houses to Betsy Head Park?",
1421
+ # generate_isochrone - 20 resources found
1422
+ "Show CERT response coverage from Betsy Head Park - 10 minute window",
1423
+ # calculate_route with flood params
1424
+ "Emergency response route from FDNY Rescue Company 2 to Betsy Head Park during flood conditions",
1425
+ # calculate_route - 565m, 7.5min
1426
+ "CERT deployment - fastest route from FDNY Engine 283 Division 15 to 753 Thomas S. Boyland Street",
1427
+ ]
1428
+
1429
+ # Urban Planner / Community Board - Coverage & Access Analysis
1430
+ PLANNING_EXAMPLES = [
1431
+ # generate_isochrone - 20 resources
1432
+ "What resources can residents of 457 Blake Avenue reach in 10 minutes?",
1433
+ # generate_isochrone - multi-time analysis
1434
+ "Assess emergency access coverage from Marcus Garvey Houses in 5, 10, 15 minutes",
1435
+ # generate_isochrone - 20 resources
1436
+ "What emergency resources are within 15 minutes walking from 663 Mother Gaston Blvd?",
1437
+ ]
1438
+
1439
+ # Healthcare / Social Worker - Patient Transport & Access
1440
+ HEALTHCARE_EXAMPLES = [
1441
+ # calculate_route - 431m, 5.7min with heat/shade params
1442
+ "It's 98 degrees and my elderly father needs to walk to Brookdale Hospital Medical Center from 899 Saratoga Avenue",
1443
+ # calculate_route - 2258m, 30.1min with AQI params
1444
+ "My patient has asthma - what's the best air quality route from 424 Mother Gaston Blvd to Brookdale Hospital Medical Center?",
1445
+ # find_nearest - 250m
1446
+ "Find the closest clinic to 35 Newport Street",
1447
+ # find_nearest - 589m
1448
+ "Where is the nearest hospital from 867 Saratoga Avenue?",
1449
+ ]
1450
+
1451
+ # Resident / Family - Emergency & Vulnerable Population Support
1452
+ RESIDENT_EXAMPLES = [
1453
+ # calculate_route - 1746m, 23.3min with heat params
1454
+ "I need to get my elderly mother to Brookdale Hospital Medical Center from 177 Chester Street during this heat wave",
1455
+ # find_nearest - 723m
1456
+ "Where is the nearest shelter from 550 Saratoga Avenue?",
1457
+ # find_nearest - 174m
1458
+ "Find the nearest cooling center from 456 Sutter Avenue during this heat wave",
1459
+ # calculate_route - 93m, 1.2min
1460
+ "Heat wave alert - need a shaded route from 45 Newport Street to Lincoln Terrace Park for my grandmother",
1461
+ ]
1462
+
1463
+ # Multi-step planning with climate awareness
1464
+ MULTI_STEP_EXAMPLES = [
1465
+ "Find the nearest shelter from Howard Houses, then find the closest hospital from there",
1466
+ ]
1467
+
1468
+ # Climate-safe routing (showcase climate features)
1469
+ CLIMATE_EXAMPLES = [
1470
+ # calculate_route with flood params - 687m, 9.2min
1471
+ "Storm surge warning - route from 360 Legion Street to Brookdale Hospital Medical Center avoiding flood zones",
1472
+ # calculate_route with AQI params - 595m, 7.9min
1473
+ "I have COPD and need to walk to BROWNSVILLE CHILD HEALTH CLINIC from 255 Legion Street - find a route with good air quality",
1474
+ # calculate_route - 372m, 5.0min
1475
+ "My grandmother has difficulty breathing. What's the safest route from 1413 Pitkin Avenue to RALPH AVENUE HEALTH CENTER?",
1476
+ # calculate_route - 1049m, 14.0min
1477
+ "Route from 1728 Pitkin Avenue to Betsy Head Park",
1478
+ ]
1479
+
1480
+ # Multilingual queries (Spanish, Haitian Creole - common languages in Brownsville)
1481
+ # These demonstrate the app's ability to understand queries in multiple languages
1482
+ MULTILINGUAL_EXAMPLES = [
1483
+ # Spanish find_nearest - 967m
1484
+ "¿Dónde está el hospital más cercano de 122 Dumont Avenue?",
1485
+ # Spanish find_nearest - 555m
1486
+ "¿Dónde está el refugio más cercano de Howard Houses? Hay una tormenta fuerte.",
1487
+ # Spanish calculate_route - 689m, 9.2min
1488
+ "Ruta segura desde 540 Chester Street hasta Brookdale Hospital Medical Center durante la inundación",
1489
+ # Haitian Creole find_nearest - 207m
1490
+ "Pitit mwen malad - ki klinik ki pi pre 189 Dumont Avenue?",
1491
+ # Haitian Creole find_nearest - 2096m
1492
+ "Ki lopital ki pi pre 333 Dumont Avenue?",
1493
+ # Spanish generate_isochrone - 20 resources
1494
+ "¿Qué recursos están a 10 minutos caminando de 651 Mother Gaston Boulevard?",
1495
+ ]
1496
+
1497
+ # Combined sample list for random display
1498
+ SAMPLE_QUERIES = CERT_EXAMPLES + PLANNING_EXAMPLES + HEALTHCARE_EXAMPLES + RESIDENT_EXAMPLES + CLIMATE_EXAMPLES + MULTILINGUAL_EXAMPLES
1499
+
1500
+ # =============================================================================
1501
+ # LLM Helpers
1502
+ # =============================================================================
1503
+
1504
+ def call_llm_tool_selection(query: str, model: str, use_embeddings: bool = True, use_grammar: bool = True, original_query: str = None) -> dict:
1505
+ """Call LLM to select appropriate tool.
1506
+
1507
+ "Less is More" approach: Use embeddings to pre-filter to top-k tools,
1508
+ then send only those to the LLM. This reduces context size and improves
1509
+ accuracy + speed.
1510
+
1511
+ IMPORTANT: When use_embeddings=True and use_grammar=True, we generate a
1512
+ DYNAMIC grammar that only allows the embedding-selected tools. This prevents
1513
+ the LLM from hallucinating tools that weren't in the pre-selected set.
1514
+
1515
+ Args:
1516
+ query: User query to process (may be geocoded with lat/lon)
1517
+ model: Model name to use
1518
+ use_embeddings: Whether to use embedding-based tool pre-filtering
1519
+ use_grammar: Whether to use GBNF grammar for type-safe output
1520
+ original_query: Original user query before geocoding (used for embedding selection)
1521
+ If None, uses query parameter for embeddings too
1522
+ """
1523
+ try:
1524
+ # Step 1: Use embeddings to select top-k most relevant tools
1525
+ # Use original_query for embedding selection if provided (preserves semantic meaning
1526
+ # before geocoding replaces location names with lat/lon coordinates)
1527
+ selected_tools = None
1528
+ if use_embeddings:
1529
+ embedding_query = original_query if original_query else query
1530
+ selected_tools = select_tool_by_embedding(embedding_query, top_k=2)
1531
+ system_prompt = build_minimal_tool_prompt(selected_tools)
1532
+ else:
1533
+ system_prompt = TOOL_SELECTION_PROMPT
1534
+
1535
+ # Step 2: Build request with optional GBNF grammar constraint
1536
+ request_json = {
1537
+ "model": get_current_model(),
1538
+ "messages": [
1539
+ {"role": "system", "content": system_prompt},
1540
+ {"role": "user", "content": query},
1541
+ ],
1542
+ "stream": False,
1543
+ "format": "json",
1544
+ }
1545
+
1546
+ # Add GBNF grammar for type-safe output
1547
+ # CRITICAL: When using embeddings, use dynamic grammar to ENFORCE that the
1548
+ # LLM can only output tools from the embedding-selected set.
1549
+ if use_grammar:
1550
+ if use_embeddings and selected_tools:
1551
+ # Dynamic grammar constrained to selected tools only
1552
+ grammar = build_dynamic_grammar(selected_tools)
1553
+ else:
1554
+ # Full grammar with all tools
1555
+ grammar = TOOL_SELECTION_GRAMMAR
1556
+ request_json["options"] = {
1557
+ "grammar": grammar
1558
+ }
1559
+
1560
+ # Step 3: Call LLM with reduced tool set
1561
+ response = requests.post(
1562
+ OLLAMA_URL,
1563
+ json=request_json,
1564
+ timeout=60,
1565
+ )
1566
+ response.raise_for_status()
1567
+ content = response.json().get("message", {}).get("content", "{}")
1568
+ return json.loads(content)
1569
+ except requests.exceptions.ConnectionError:
1570
+ return {"error": "Cannot connect to Ollama. Is it running?"}
1571
+ except json.JSONDecodeError as e:
1572
+ return {"error": f"Invalid JSON from LLM: {e}"}
1573
+ except Exception as e:
1574
+ return {"error": str(e)}
1575
+
1576
+
1577
+ def call_llm_explain_result(user_query: str, result: dict, model: str, tool_name: str) -> str:
1578
+ """Call LLM to generate detailed action plan based on tool type.
1579
+
1580
+ Uses local Ollama model for action plan generation.
1581
+ """
1582
+ # Select appropriate prompt based on tool type
1583
+ if tool_name == "generate_isochrone":
1584
+ system_prompt = ISOCHRONE_EXPLANATION_PROMPT
1585
+ elif tool_name == "find_along_route":
1586
+ system_prompt = ALONG_ROUTE_EXPLANATION_PROMPT
1587
+ elif tool_name == "multi_step":
1588
+ system_prompt = MULTI_STEP_EXPLANATION_PROMPT
1589
+ else:
1590
+ system_prompt = ROUTE_EXPLANATION_PROMPT
1591
+
1592
+ # Inject today's date into prompt
1593
+ today = datetime.now().strftime("%B %d, %Y")
1594
+ system_prompt = system_prompt.replace("{date}", today)
1595
+
1596
+ prompt = f'User asked: "{user_query}"\n\nAnalysis result:\n{json.dumps(result, indent=2)}\n\nGenerate the comprehensive report as specified.'
1597
+
1598
+ try:
1599
+ response = requests.post(
1600
+ OLLAMA_URL,
1601
+ json={
1602
+ "model": model,
1603
+ "messages": [
1604
+ {"role": "system", "content": system_prompt},
1605
+ {"role": "user", "content": prompt},
1606
+ ],
1607
+ "stream": False,
1608
+ },
1609
+ timeout=120, # Longer timeout for detailed reports
1610
+ )
1611
+ response.raise_for_status()
1612
+ content = response.json().get("message", {}).get("content", "")
1613
+
1614
+ # Validate response - reject if it looks like a tool call or JSON instead of a report
1615
+ if not content or content.strip().startswith("[{") or content.strip().startswith('{"'):
1616
+ return "" # Return empty to trigger fallback
1617
+
1618
+ # Basic sanity check - report should have some readable content
1619
+ if len(content) < 100:
1620
+ return ""
1621
+
1622
+ return content
1623
+ except Exception:
1624
+ return ""
1625
+
1626
+
1627
+ # =============================================================================
1628
+ # Display Helpers
1629
+ # =============================================================================
1630
+
1631
+ def render_map(map_data: dict | None, resources_df) -> folium.Map:
1632
+ """Render Folium map with routes, isochrones, and markers."""
1633
+ m = folium.Map(
1634
+ location=[BROWNSVILLE_CENTER["lat"], BROWNSVILLE_CENTER["lon"]],
1635
+ zoom_start=14,
1636
+ tiles="OpenStreetMap"
1637
+ )
1638
+
1639
+ # Resource markers - using POI type-specific styling
1640
+ if resources_df is not None:
1641
+ for _, row in resources_df.iterrows():
1642
+ poi_type = row.get("type", "facility")
1643
+ style = get_poi_marker_style(poi_type)
1644
+ folium.CircleMarker(
1645
+ location=[row["lat"], row["lon"]],
1646
+ radius=style["radius"],
1647
+ color=style["color"],
1648
+ fill=True,
1649
+ fillColor=style["fill_color"],
1650
+ fillOpacity=0.7,
1651
+ popup=f"<b>{row['name']}</b><br>{row['type']}"
1652
+ ).add_to(m)
1653
+
1654
+ if not map_data:
1655
+ return m
1656
+
1657
+ # Isochrones (render first so routes appear on top)
1658
+ if "isochrones" in map_data:
1659
+ # Render in reverse order so smaller isochrones appear on top
1660
+ for iso in reversed(map_data["isochrones"]):
1661
+ if iso.get("polygon_coords"):
1662
+ folium.Polygon(
1663
+ locations=iso["polygon_coords"],
1664
+ color=iso.get("color", "#6366f1"),
1665
+ fill=True,
1666
+ fillColor=iso.get("color", "#6366f1"),
1667
+ fillOpacity=0.2,
1668
+ weight=2,
1669
+ tooltip=f"{iso['time_min']} min walking"
1670
+ ).add_to(m)
1671
+
1672
+ # Resources within isochrone - using POI type-specific styling
1673
+ if "resources_within" in map_data:
1674
+ for res in map_data["resources_within"]:
1675
+ poi_type = res.get("type", "facility")
1676
+ style = get_poi_marker_style(poi_type)
1677
+ folium.CircleMarker(
1678
+ location=[res["lat"], res["lon"]],
1679
+ radius=style["radius"] + 1, # Slightly larger to stand out
1680
+ color=style["color"],
1681
+ fill=True,
1682
+ fillColor=style["fill_color"],
1683
+ fillOpacity=0.9,
1684
+ popup=f"<b>{res['name']}</b><br>{res['type']}<br>{res['walking_time_minutes']:.1f} min"
1685
+ ).add_to(m)
1686
+
1687
+ # Multiple routes
1688
+ if "routes" in map_data:
1689
+ for route in map_data["routes"]:
1690
+ folium.PolyLine(
1691
+ route["coords"],
1692
+ weight=5,
1693
+ color=route.get("color", "#3b82f6"),
1694
+ opacity=0.8,
1695
+ tooltip=route.get("label", "Route")
1696
+ ).add_to(m)
1697
+ # Single route fallback
1698
+ elif "route_coords" in map_data:
1699
+ folium.PolyLine(
1700
+ map_data["route_coords"],
1701
+ weight=5,
1702
+ color="#3b82f6",
1703
+ opacity=0.8
1704
+ ).add_to(m)
1705
+
1706
+ # Origin marker
1707
+ if "origin" in map_data:
1708
+ folium.Marker(
1709
+ map_data["origin"],
1710
+ popup="Start",
1711
+ icon=folium.Icon(color="green", icon="play")
1712
+ ).add_to(m)
1713
+
1714
+ # Destination marker
1715
+ if "destination" in map_data:
1716
+ folium.Marker(
1717
+ map_data["destination"],
1718
+ popup=f"{map_data.get('dest_name', 'Destination')}<br>{map_data.get('distance', 0):.0f}m",
1719
+ icon=folium.Icon(color="red", icon="flag")
1720
+ ).add_to(m)
1721
+
1722
+ # Waypoints for multi-step queries (intermediate stops)
1723
+ if "waypoints" in map_data:
1724
+ waypoint_colors = ["blue", "purple", "orange", "darkred", "cadetblue"]
1725
+ for i, wp in enumerate(map_data["waypoints"]):
1726
+ if wp.get("is_final"):
1727
+ continue # Skip final destination, it's already rendered above
1728
+ coords = wp.get("coords")
1729
+ if coords:
1730
+ color = waypoint_colors[i % len(waypoint_colors)]
1731
+ folium.Marker(
1732
+ coords,
1733
+ popup=f"<b>Step {wp.get('step', i+1)}</b><br>{wp.get('name', 'Waypoint')}",
1734
+ icon=folium.Icon(color=color, icon="info-sign")
1735
+ ).add_to(m)
1736
+
1737
+ # Geocoded location
1738
+ if "geocoded_location" in map_data:
1739
+ geo = map_data["geocoded_location"]
1740
+ folium.CircleMarker(
1741
+ [geo["lat"], geo["lon"]],
1742
+ radius=8,
1743
+ color="#fbbf24",
1744
+ fill=True,
1745
+ fillColor="#fbbf24",
1746
+ fillOpacity=0.9,
1747
+ popup=f"📍 {geo['name']}"
1748
+ ).add_to(m)
1749
+
1750
+ # POIs along route (rendered last so they appear on top of routes and other markers)
1751
+ # Uses POI type-specific styling for visual differentiation
1752
+ if "pois_along_route" in map_data:
1753
+ for poi in map_data["pois_along_route"]:
1754
+ poi_type = poi.get("type", "facility")
1755
+ style = get_poi_marker_style(poi_type)
1756
+ folium.CircleMarker(
1757
+ location=[poi["lat"], poi["lon"]],
1758
+ radius=style["radius"] + 2, # Larger to stand out along route
1759
+ color=style["color"],
1760
+ fill=True,
1761
+ fillColor=style["fill_color"],
1762
+ fillOpacity=0.9,
1763
+ weight=2,
1764
+ popup=f"<b>{poi['name']}</b><br>{poi['type']}<br>{poi['distance_from_route_m']:.0f}m from route"
1765
+ ).add_to(m)
1766
+
1767
+ return m
1768
+
1769
+
1770
+ def format_route_metrics(metrics: dict) -> str:
1771
+ """Format route metrics as a string."""
1772
+ if not metrics:
1773
+ return ""
1774
+ emoji = {"flat": "🟢", "moderate": "🟡", "hilly": "🔴"}.get(metrics.get("difficulty", ""), "⚪")
1775
+ parts = [
1776
+ f"{emoji} **{metrics.get('difficulty', 'unknown').title()}** terrain",
1777
+ f"↗️ +{metrics.get('elevation_gain_m', 0)}m / ↘️ -{metrics.get('elevation_loss_m', 0)}m"
1778
+ ]
1779
+ if metrics.get("max_grade_pct", 0) > 0:
1780
+ parts.append(f"⛰️ Max grade: {metrics['max_grade_pct']:.1f}%")
1781
+ return " · ".join(parts)
1782
+
1783
+
1784
+ def format_climate_metrics(climate: dict) -> str:
1785
+ """Format climate risk metrics as a string.
1786
+
1787
+ Note: Risk values are normalized indices (0.0-1.0 scale), NOT percentages.
1788
+ - flood_risk: FEMA flood zone risk (0=none, 1=high risk zone)
1789
+ - heat_risk: Heat Vulnerability Index (0=low, 1=high vulnerability)
1790
+ - air_quality_risk: Air quality risk index (0=good, 1=poor)
1791
+ """
1792
+ if not climate:
1793
+ return ""
1794
+
1795
+ # Determine overall risk level
1796
+ avg_risk = climate.get("avg_climate_risk", 0)
1797
+ if avg_risk < 0.2:
1798
+ risk_level = "Low"
1799
+ emoji = "🟢"
1800
+ elif avg_risk < 0.4:
1801
+ risk_level = "Moderate"
1802
+ emoji = "🟡"
1803
+ elif avg_risk < 0.6:
1804
+ risk_level = "Elevated"
1805
+ emoji = "🟠"
1806
+ else:
1807
+ risk_level = "High"
1808
+ emoji = "🔴"
1809
+
1810
+ parts = [f"{emoji} **{risk_level} climate risk**"]
1811
+
1812
+ # Add flood info if significant
1813
+ flood_risk = climate.get("avg_flood_risk", 0)
1814
+ if flood_risk > 0.1:
1815
+ flood_exposure = climate.get("flood_exposure_m", 0)
1816
+ if flood_exposure > 0:
1817
+ parts.append(f"🌊 {flood_exposure:.0f}m flood-prone")
1818
+ else:
1819
+ # Show as index value, not percentage
1820
+ flood_label = "Low" if flood_risk < 0.3 else "Moderate" if flood_risk < 0.6 else "High"
1821
+ parts.append(f"🌊 Flood: {flood_label}")
1822
+
1823
+ # Add heat info if significant (HVI index, not percentage)
1824
+ heat_risk = climate.get("avg_heat_risk", 0)
1825
+ if heat_risk > 0.2:
1826
+ # Convert to HVI-style label (1-5 scale commonly used)
1827
+ hvi_approx = 1 + heat_risk * 4 # Maps 0-1 to 1-5
1828
+ parts.append(f"🌡️ HVI: {hvi_approx:.1f}/5")
1829
+
1830
+ # Add air quality info if significant
1831
+ air_quality_risk = climate.get("avg_air_quality_risk", 0)
1832
+ if air_quality_risk > 0.2:
1833
+ # Show as qualitative label
1834
+ aqi_label = "Good" if air_quality_risk < 0.3 else "Moderate" if air_quality_risk < 0.6 else "Poor"
1835
+ parts.append(f"💨 Air: {aqi_label}")
1836
+
1837
+ # Add tree coverage if available
1838
+ tree_coverage = climate.get("avg_tree_coverage", 0)
1839
+ if tree_coverage > 0:
1840
+ shade_label = "Low" if tree_coverage < 0.3 else "Moderate" if tree_coverage < 0.6 else "Good"
1841
+ parts.append(f"🌳 Shade: {shade_label}")
1842
+
1843
+ return " · ".join(parts)
1844
+
1845
+
1846
+ def format_result(tool_name: str, result: dict) -> str:
1847
+ """Format tool result for chat display."""
1848
+ if "error" in result:
1849
+ return f"❌ {result['error']}"
1850
+
1851
+ if tool_name == "find_nearest":
1852
+ if result.get("found"):
1853
+ lines = [
1854
+ f"✅ **{result['name']}** ({result['type']})",
1855
+ f"📍 {result['distance_meters']:.0f}m away · 🚶 {result['walking_time_minutes']:.1f} min walk"
1856
+ ]
1857
+ if "route_metrics" in result:
1858
+ lines.append(format_route_metrics(result["route_metrics"]))
1859
+ if "climate_metrics" in result:
1860
+ lines.append(format_climate_metrics(result["climate_metrics"]))
1861
+ return "\n".join(lines)
1862
+ return f"No {result.get('resource_type', 'resources')} found nearby"
1863
+
1864
+ if tool_name == "list_resources":
1865
+ count = result.get("total_count", 0)
1866
+ lines = [f"Found **{count}** resources:"]
1867
+ for r in result.get("resources", [])[:5]:
1868
+ lines.append(f"• {r['name']} ({r['type']})")
1869
+ if count > 5:
1870
+ lines.append(f"_...and {count - 5} more_")
1871
+ return "\n".join(lines)
1872
+
1873
+ if tool_name == "calculate_route":
1874
+ alternatives = result.get("alternatives", [])
1875
+ climate_aware = result.get("climate_aware", False)
1876
+
1877
+ if len(alternatives) > 1:
1878
+ colors = {"shortest": "🔵", "flattest": "🟢", "balanced": "🟠", "safest": "🌿"}
1879
+ lines = ["**Route Options:**\n"]
1880
+ for alt in alternatives:
1881
+ indicator = colors.get(alt["name"], "⚪")
1882
+ check = " ✓" if alt["name"] == result.get("recommended") else ""
1883
+ metrics = alt.get("route_metrics", {})
1884
+
1885
+ # Build the line with basic info
1886
+ line = (
1887
+ f"{indicator} **{alt['label']}**{check}: "
1888
+ f"{alt['distance_meters']:.0f}m · {alt['walking_time_minutes']:.1f} min"
1889
+ )
1890
+
1891
+ # Add elevation info
1892
+ if metrics.get('elevation_gain_m', 0) > 0:
1893
+ line += f" · +{metrics['elevation_gain_m']}m climb"
1894
+
1895
+ # Add climate risk info for each alternative if available
1896
+ if "climate_metrics" in alt:
1897
+ climate = alt["climate_metrics"]
1898
+ risk = climate.get("avg_climate_risk", 0)
1899
+ if risk < 0.2:
1900
+ line += " · 🟢 Low risk"
1901
+ elif risk < 0.4:
1902
+ line += " · 🟡 Mod risk"
1903
+ elif risk < 0.6:
1904
+ line += " · 🟠 Elevated"
1905
+ else:
1906
+ line += " · 🔴 High risk"
1907
+
1908
+ lines.append(line)
1909
+
1910
+ if climate_aware:
1911
+ lines.append("\n_Climate-aware routing enabled. Recommended route (✓) minimizes flood & heat exposure._")
1912
+ else:
1913
+ lines.append("\n_Recommended route shown with ✓_")
1914
+ return "\n".join(lines)
1915
+ else:
1916
+ lines = [f"📏 {result['distance_meters']:.0f}m · 🚶 {result['walking_time_minutes']:.1f} min walk"]
1917
+ if "route_metrics" in result:
1918
+ lines.append(format_route_metrics(result["route_metrics"]))
1919
+ if "climate_metrics" in result:
1920
+ lines.append(format_climate_metrics(result["climate_metrics"]))
1921
+ return "\n".join(lines)
1922
+
1923
+ if tool_name == "generate_isochrone":
1924
+ isochrones = result.get("isochrones", [])
1925
+ resources = result.get("resources_within", [])
1926
+ lines = ["**🗺️ Reachable Area Analysis**\n"]
1927
+
1928
+ for iso in isochrones:
1929
+ emoji = {"5": "🟢", "10": "🟡", "15": "🟠", "20": "🔴"}.get(str(iso["time_min"]), "⚪")
1930
+ lines.append(f"{emoji} **{iso['time_min']} min**: {iso['node_count']} intersections reachable")
1931
+
1932
+ if resources:
1933
+ lines.append(f"\n**📍 {len(resources)} resources within reach:**")
1934
+ for r in resources[:8]:
1935
+ lines.append(f"• {r['name']} ({r['type']}) - {r['walking_time_minutes']:.1f} min")
1936
+ if len(resources) > 8:
1937
+ lines.append(f"_...and {len(resources) - 8} more_")
1938
+
1939
+ return "\n".join(lines)
1940
+
1941
+ if tool_name == "find_along_route":
1942
+ pois = result.get("pois_found", [])
1943
+ buffer = result.get("buffer_meters", 100)
1944
+
1945
+ if not pois:
1946
+ return f"No resources found within {buffer}m of the route"
1947
+
1948
+ lines = [f"**🛤️ Found {len(pois)} resources along the route** (within {buffer}m)\n"]
1949
+
1950
+ # Group by type
1951
+ by_type = {}
1952
+ for poi in pois:
1953
+ t = poi["type"]
1954
+ if t not in by_type:
1955
+ by_type[t] = []
1956
+ by_type[t].append(poi)
1957
+
1958
+ for resource_type, items in by_type.items():
1959
+ lines.append(f"**{resource_type}** ({len(items)}):")
1960
+ for item in items[:3]:
1961
+ lines.append(f" • {item['name']} ({item['distance_from_route_m']}m from route)")
1962
+ if len(items) > 3:
1963
+ lines.append(f" _...and {len(items) - 3} more_")
1964
+
1965
+ return "\n".join(lines)
1966
+
1967
+ return json.dumps(result, indent=2)
1968
+
1969
+
1970
+ # =============================================================================
1971
+ # Main App - Two Column Layout
1972
+ # =============================================================================
1973
+
1974
+ # Additional session state for action plan output
1975
+ if "action_plan" not in st.session_state:
1976
+ st.session_state.action_plan = None
1977
+
1978
+
1979
+ def main():
1980
+ engine = st.session_state.engine
1981
+
1982
+ # Load engine and warmup LLM on first run
1983
+ if not engine.is_loaded:
1984
+ with st.spinner("Loading network data..."):
1985
+ engine.load()
1986
+
1987
+ # Warmup LLM in background
1988
+ # Note: Embedding model loads lazily on first query to speed up startup
1989
+ with st.spinner("Warming up LLM..."):
1990
+ llm_ready = warmup_llm(get_current_model())
1991
+
1992
+ # --- Header ---
1993
+ st.title("🚨 Emergency Routing Assistant")
1994
+ st.caption("Climate-aware emergency services for Brownsville, Brooklyn")
1995
+
1996
+ # --- Sidebar with example queries ---
1997
+ with st.sidebar:
1998
+ st.header("📊 Status")
1999
+
2000
+ if engine.is_loaded:
2001
+ st.success("✓ Network loaded")
2002
+ st.caption(f"{engine.resource_count} resources · {engine.node_count:,} nodes")
2003
+ else:
2004
+ st.error("Failed to load network")
2005
+
2006
+ if llm_ready:
2007
+ st.success("✓ LLM ready")
2008
+ else:
2009
+ st.warning("⚠ LLM not available (check Ollama)")
2010
+
2011
+ # Model selection dropdown
2012
+ st.divider()
2013
+ st.markdown("### Model Selection")
2014
+ selected = st.selectbox(
2015
+ "LLM Model",
2016
+ options=list(AVAILABLE_MODELS.keys()),
2017
+ index=list(AVAILABLE_MODELS.keys()).index(st.session_state.selected_model),
2018
+ help="Qwen 2.5 3B is faster, xLAM 8B is more accurate for function calling"
2019
+ )
2020
+ if selected != st.session_state.selected_model:
2021
+ st.session_state.selected_model = selected
2022
+ st.rerun()
2023
+
2024
+ st.divider()
2025
+ if st.button("🗑️ Clear All", use_container_width=True):
2026
+ st.session_state.messages = []
2027
+ st.session_state.map_data = None
2028
+ st.session_state.action_plan = None
2029
+ st.rerun()
2030
+
2031
+ st.divider()
2032
+ st.markdown("### Example Queries")
2033
+
2034
+ st.markdown("**🚒 CERT / Emergency:**")
2035
+ for ex in CERT_EXAMPLES:
2036
+ if st.button(ex, key=f"cert_{ex[:20]}", use_container_width=True):
2037
+ st.session_state.pending_query = ex
2038
+ st.rerun()
2039
+
2040
+ st.markdown("**🏥 Healthcare:**")
2041
+ for ex in HEALTHCARE_EXAMPLES:
2042
+ if st.button(ex, key=f"health_{ex[:20]}", use_container_width=True):
2043
+ st.session_state.pending_query = ex
2044
+ st.rerun()
2045
+
2046
+ st.markdown("**🏠 Resident:**")
2047
+ for ex in RESIDENT_EXAMPLES:
2048
+ if st.button(ex, key=f"resident_{ex[:20]}", use_container_width=True):
2049
+ st.session_state.pending_query = ex
2050
+ st.rerun()
2051
+
2052
+ st.markdown("**📋 Multi-step:**")
2053
+ for ex in MULTI_STEP_EXAMPLES:
2054
+ if st.button(ex, key=f"multi_{ex[:20]}", use_container_width=True):
2055
+ st.session_state.pending_query = ex
2056
+ st.rerun()
2057
+
2058
+ st.markdown("**🌡️ Climate-Safe:**")
2059
+ for ex in CLIMATE_EXAMPLES:
2060
+ if st.button(ex, key=f"climate_{ex[:20]}", use_container_width=True):
2061
+ st.session_state.pending_query = ex
2062
+ st.rerun()
2063
+
2064
+ # ==========================================================================
2065
+ # TWO-COLUMN LAYOUT: Chat (left) | Map (right)
2066
+ # ==========================================================================
2067
+ col_chat, col_map = st.columns([1, 1], gap="medium")
2068
+
2069
+ # --- LEFT COLUMN: Chat Input & History ---
2070
+ with col_chat:
2071
+ st.subheader("💬 Query")
2072
+
2073
+ # Chat input at the top
2074
+ pending = st.session_state.pop("pending_query", None)
2075
+ prompt = st.chat_input("Ask about emergency services...") or pending
2076
+
2077
+ # Processing indicator placeholder (appears in chat column)
2078
+ processing_placeholder = st.empty()
2079
+
2080
+ # Chat history
2081
+ chat_container = st.container(height=350)
2082
+ with chat_container:
2083
+ if not st.session_state.messages:
2084
+ st.info("Enter a query above or select an example from the sidebar.")
2085
+ for msg in st.session_state.messages:
2086
+ with st.chat_message(msg["role"]):
2087
+ st.markdown(msg["content"])
2088
+
2089
+ # --- RIGHT COLUMN: Map ---
2090
+ with col_map:
2091
+ st.subheader("🗺️ Map")
2092
+ m = render_map(st.session_state.map_data, engine.resources_df)
2093
+ st_folium(m, width=None, height=400, returned_objects=[])
2094
+
2095
+ # ==========================================================================
2096
+ # ACTION PLAN OUTPUT - Below the two columns
2097
+ # ==========================================================================
2098
+ st.divider()
2099
+ st.subheader("📋 Action Plan Report")
2100
+
2101
+ action_plan_container = st.container()
2102
+ with action_plan_container:
2103
+ if st.session_state.action_plan:
2104
+ st.markdown(st.session_state.action_plan)
2105
+ else:
2106
+ st.info("Action plan will appear here after you submit a query.")
2107
+
2108
+ # ==========================================================================
2109
+ # QUERY PROCESSING (uses placeholder in chat column for spinner)
2110
+ # ==========================================================================
2111
+ if prompt:
2112
+ if not engine.is_loaded:
2113
+ st.error("Network not loaded")
2114
+ st.stop()
2115
+
2116
+ st.session_state.messages.append({"role": "user", "content": prompt})
2117
+
2118
+ # Helper to show thinking status
2119
+ def show_status(message: str):
2120
+ processing_placeholder.info(f"🤔 {message}")
2121
+
2122
+ # Geocode locations
2123
+ show_status("Recognizing locations...")
2124
+ modified_query, geocoded = engine.geocode_query(prompt)
2125
+
2126
+ # Check if this is a multi-step query
2127
+ if is_multi_step_query(prompt):
2128
+ show_status("Planning multi-step query...")
2129
+ plan = call_llm_planner(modified_query)
2130
+
2131
+ if plan.get("multi_step") and plan.get("steps"):
2132
+ steps = plan.get("steps", [])
2133
+ for i, step in enumerate(steps, 1):
2134
+ show_status(f"Executing step {i}/{len(steps)}: {step.get('description', 'Processing')}...")
2135
+
2136
+ all_results, all_map_data = execute_multi_step_plan(plan, engine)
2137
+
2138
+ # Merge map data from all steps
2139
+ merged_map_data = merge_multi_step_map_data(all_map_data)
2140
+ if geocoded:
2141
+ merged_map_data["geocoded_location"] = list(geocoded.values())[0]
2142
+ st.session_state.map_data = merged_map_data
2143
+
2144
+ # Format chat response (brief summary)
2145
+ chat_parts = []
2146
+ if geocoded:
2147
+ geo_text = ", ".join([f"📍 {k} ({v['lat']:.4f}, {v['lon']:.4f})" for k, v in geocoded.items()])
2148
+ chat_parts.append(f"_Geocoded: {geo_text}_")
2149
+ chat_parts.append(format_multi_step_results(all_results))
2150
+
2151
+ chat_response = "\n\n".join(chat_parts)
2152
+ st.session_state.messages.append({"role": "assistant", "content": chat_response})
2153
+
2154
+ # Generate action plan for separate display (code-based, no LLM needed)
2155
+ show_status("Generating action plan report...")
2156
+ st.session_state.action_plan = generate_multi_step_action_plan(all_results, prompt)
2157
+
2158
+ processing_placeholder.empty()
2159
+ st.rerun()
2160
+
2161
+ # Single-step query (default flow)
2162
+ show_status("Selecting appropriate tool...")
2163
+ # Pass original prompt for embedding selection (before geocoding replaces location names)
2164
+ tool_call = call_llm_tool_selection(modified_query, get_current_model(), original_query=prompt)
2165
+
2166
+ if "error" in tool_call:
2167
+ chat_response = f"❌ {tool_call['error']}"
2168
+ st.session_state.action_plan = None
2169
+ st.session_state.messages.append({"role": "assistant", "content": chat_response})
2170
+ processing_placeholder.empty()
2171
+ st.rerun()
2172
+
2173
+ tool_name = tool_call.get("name", "")
2174
+ tool_args = tool_call.get("arguments", {})
2175
+
2176
+ # Show what we're doing
2177
+ tool_labels = {
2178
+ "find_nearest": "Finding nearest resource...",
2179
+ "list_resources": "Listing resources...",
2180
+ "calculate_route": "Computing climate-safe route...",
2181
+ "generate_isochrone": "Calculating reachable area...",
2182
+ "find_along_route": "Finding resources along route...",
2183
+ }
2184
+ show_status(tool_labels.get(tool_name, "Executing query..."))
2185
+
2186
+ # Execute
2187
+ result, map_data = execute_tool(tool_name, tool_args, engine)
2188
+
2189
+ # Update map
2190
+ if map_data:
2191
+ if geocoded:
2192
+ map_data["geocoded_location"] = list(geocoded.values())[0]
2193
+ st.session_state.map_data = map_data
2194
+
2195
+ # Format chat response (brief summary)
2196
+ chat_parts = []
2197
+ if geocoded:
2198
+ geo_text = ", ".join([f"📍 {k} ({v['lat']:.4f}, {v['lon']:.4f})" for k, v in geocoded.items()])
2199
+ chat_parts.append(f"_Geocoded: {geo_text}_")
2200
+ chat_parts.append(format_result(tool_name, result))
2201
+ chat_response = "\n\n".join(chat_parts)
2202
+
2203
+ # Store result and display immediately
2204
+ st.session_state.messages.append({"role": "assistant", "content": chat_response})
2205
+
2206
+ # Generate action plan for separate display (show status while generating)
2207
+ if tool_name in ("find_nearest", "calculate_route", "generate_isochrone", "find_along_route") and "error" not in result:
2208
+ # Show placeholder while generating
2209
+ show_status("📋 Generating action plan report...")
2210
+
2211
+ # Try LLM explanation first (uses local Ollama model)
2212
+ explanation = call_llm_explain_result(prompt, result, get_current_model(), tool_name)
2213
+ if explanation:
2214
+ st.session_state.action_plan = explanation
2215
+ else:
2216
+ # Fallback: use code-based report generation
2217
+ if tool_name in ("find_nearest", "calculate_route"):
2218
+ st.session_state.action_plan = generate_route_action_plan(result, prompt)
2219
+ elif tool_name == "generate_isochrone":
2220
+ st.session_state.action_plan = generate_isochrone_report(result, prompt)
2221
+ elif tool_name == "find_along_route":
2222
+ st.session_state.action_plan = generate_corridor_report(result, prompt)
2223
+ else:
2224
+ st.session_state.action_plan = None
2225
+
2226
+ processing_placeholder.empty()
2227
+ st.rerun()
2228
+
2229
+
2230
+ if __name__ == "__main__":
2231
+ main()
core/__init__.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Core routing and engine logic."""
2
+
3
+ from .engine import RoutingEngine, execute_tool, BROWNSVILLE_CENTER, POI_MARKER_STYLES
4
+ from .config import *
5
+ from .tools import *
6
+
7
+ __all__ = [
8
+ "RoutingEngine",
9
+ "execute_tool",
10
+ "BROWNSVILLE_CENTER",
11
+ "POI_MARKER_STYLES",
12
+ ]
core/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (403 Bytes). View file
 
core/__pycache__/config.cpython-313.pyc ADDED
Binary file (5.9 kB). View file
 
core/__pycache__/engine.cpython-313.pyc ADDED
Binary file (88.7 kB). View file
 
core/__pycache__/tools.cpython-313.pyc ADDED
Binary file (33.4 kB). View file
 
core/config.py ADDED
@@ -0,0 +1,301 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Configuration File for Emergency Routing Assistant UI
3
+ Customize colors, icons, languages, and behavior here
4
+ """
5
+
6
+ # =============================================================================
7
+ # Application Settings
8
+ # =============================================================================
9
+
10
+ APP_CONFIG = {
11
+ "title": "Emergency Routing Assistant",
12
+ "page_icon": "🚨",
13
+ "layout": "wide",
14
+ "initial_sidebar_state": "expanded",
15
+ "theme": {
16
+ "primaryColor": "#ef4444",
17
+ "backgroundColor": "#0f172a",
18
+ "secondaryBackgroundColor": "#1e293b",
19
+ "textColor": "#f1f5f9",
20
+ "font": "Outfit"
21
+ }
22
+ }
23
+
24
+ # =============================================================================
25
+ # Map Configuration
26
+ # =============================================================================
27
+
28
+ MAP_CONFIG = {
29
+ "default_zoom": 14,
30
+ "min_zoom": 10,
31
+ "max_zoom": 18,
32
+ "default_style": "light", # Options: light, dark, street, satellite
33
+ "show_scale": True,
34
+ "show_fullscreen_button": True,
35
+ "cluster_resources": False, # Set to True to cluster nearby markers
36
+ "cluster_distance": 50, # pixels
37
+ }
38
+
39
+ # Center point for Brownsville, Brooklyn
40
+ BROWNSVILLE_CENTER = {
41
+ "lat": 40.6694,
42
+ "lon": -73.9125,
43
+ "name": "Brownsville, Brooklyn"
44
+ }
45
+
46
+ # =============================================================================
47
+ # Feature Flags
48
+ # =============================================================================
49
+
50
+ FEATURES = {
51
+ "voice_recording": True,
52
+ "media_upload": True,
53
+ "multi_language": True,
54
+ "emergency_banner": True,
55
+ "route_alternatives": True,
56
+ "isochrone_analysis": True,
57
+ "resources_along_route": True,
58
+ "real_time_updates": False, # Future feature
59
+ "offline_mode": False, # Future feature
60
+ "push_notifications": False, # Future feature
61
+ }
62
+
63
+ # =============================================================================
64
+ # UI Behavior Settings
65
+ # =============================================================================
66
+
67
+ UI_SETTINGS = {
68
+ "auto_dismiss_banner": False, # Auto-dismiss emergency banner after X seconds
69
+ "banner_dismiss_timeout": 10, # seconds
70
+ "animation_speed": "normal", # Options: slow, normal, fast, none
71
+ "show_coordinates": True, # Show lat/lon in geocoding results
72
+ "max_chat_messages": 100, # Maximum chat history to keep
73
+ "default_language": "en",
74
+ "map_height": 500, # pixels
75
+ "enable_dark_mode": True,
76
+ }
77
+
78
+ # =============================================================================
79
+ # Resource Display Settings
80
+ # =============================================================================
81
+
82
+ RESOURCE_DISPLAY = {
83
+ "show_all_on_load": True, # Show all resources when page loads
84
+ "max_results_display": 10, # Maximum results to show in list
85
+ "highlight_nearest": True, # Highlight nearest resource
86
+ "show_walking_time": True,
87
+ "show_distance": True,
88
+ "group_by_category": True,
89
+ }
90
+
91
+ # =============================================================================
92
+ # Route Calculation Settings
93
+ # =============================================================================
94
+
95
+ ROUTE_SETTINGS = {
96
+ "default_profile": "foot-walking", # walking profile
97
+ "calculate_alternatives": True,
98
+ "max_alternatives": 3,
99
+ "prefer_flat_routes": True, # For accessibility
100
+ "avoid_stairs": False, # Future feature
101
+ "max_route_distance": 10000, # meters (10km max)
102
+ }
103
+
104
+ # =============================================================================
105
+ # Isochrone Settings
106
+ # =============================================================================
107
+
108
+ ISOCHRONE_SETTINGS = {
109
+ "default_time_limits": [5, 10, 15], # minutes
110
+ "max_time_limit": 30, # minutes
111
+ "show_resources_within": True,
112
+ "resource_limit": 50, # max resources to show
113
+ }
114
+
115
+ # =============================================================================
116
+ # Media Upload Settings
117
+ # =============================================================================
118
+
119
+ MEDIA_SETTINGS = {
120
+ "max_file_size": 10 * 1024 * 1024, # 10MB
121
+ "allowed_image_types": ["jpg", "jpeg", "png", "gif"],
122
+ "allowed_video_types": ["mp4", "mov", "avi", "webm"],
123
+ "save_to_disk": False, # Save uploaded files to disk
124
+ "upload_directory": "./uploads",
125
+ }
126
+
127
+ # =============================================================================
128
+ # Voice Recording Settings
129
+ # =============================================================================
130
+
131
+ VOICE_SETTINGS = {
132
+ "max_recording_duration": 300, # seconds (5 minutes)
133
+ "audio_format": "wav",
134
+ "sample_rate": 44100,
135
+ "save_recordings": False,
136
+ "recording_directory": "./recordings",
137
+ }
138
+
139
+ # =============================================================================
140
+ # Notification Settings
141
+ # =============================================================================
142
+
143
+ NOTIFICATION_SETTINGS = {
144
+ "enable_success_messages": True,
145
+ "enable_error_messages": True,
146
+ "enable_info_messages": True,
147
+ "auto_clear_messages": True,
148
+ "message_duration": 3, # seconds
149
+ }
150
+
151
+ # =============================================================================
152
+ # Emergency Response Settings
153
+ # =============================================================================
154
+
155
+ EMERGENCY_SETTINGS = {
156
+ "priority_resources": ["hospital", "fire_station", "police"],
157
+ "emergency_phone": "911",
158
+ "show_emergency_contacts": True,
159
+ "emergency_contacts": {
160
+ "Police": "911",
161
+ "Fire": "911",
162
+ "Medical": "911",
163
+ "NYC Emergency Management": "311",
164
+ "Poison Control": "1-800-222-1222",
165
+ }
166
+ }
167
+
168
+ # =============================================================================
169
+ # Cooling Center Specific Settings
170
+ # =============================================================================
171
+
172
+ COOLING_CENTER_SETTINGS = {
173
+ "show_capacity": False, # Future feature
174
+ "show_hours": False, # Future feature
175
+ "highlight_24h_centers": True,
176
+ "show_amenities": False, # AC, water, restrooms, etc.
177
+ "temperature_threshold": 90, # °F - show special alerts above this
178
+ }
179
+
180
+ # =============================================================================
181
+ # Accessibility Settings
182
+ # =============================================================================
183
+
184
+ ACCESSIBILITY_SETTINGS = {
185
+ "high_contrast_mode": False,
186
+ "large_text_mode": False,
187
+ "screen_reader_support": True,
188
+ "keyboard_navigation": True,
189
+ "color_blind_friendly": True,
190
+ }
191
+
192
+ # =============================================================================
193
+ # Advanced Settings
194
+ # =============================================================================
195
+
196
+ ADVANCED_SETTINGS = {
197
+ "debug_mode": False,
198
+ "log_user_queries": False,
199
+ "cache_geocoding_results": True,
200
+ "cache_duration": 3600, # seconds (1 hour)
201
+ "enable_analytics": False,
202
+ "api_timeout": 30, # seconds
203
+ }
204
+
205
+ # =============================================================================
206
+ # Customization Examples
207
+ # =============================================================================
208
+
209
+ """
210
+ EXAMPLE CUSTOMIZATIONS:
211
+
212
+ 1. Change to dark theme:
213
+ APP_CONFIG["theme"]["primaryColor"] = "#3b82f6"
214
+ APP_CONFIG["theme"]["backgroundColor"] = "#000000"
215
+ UI_SETTINGS["enable_dark_mode"] = True
216
+
217
+ 2. Enable clustering for dense areas:
218
+ MAP_CONFIG["cluster_resources"] = True
219
+ MAP_CONFIG["cluster_distance"] = 100
220
+
221
+ 3. Focus on cooling centers:
222
+ COOLING_CENTER_SETTINGS["show_capacity"] = True
223
+ COOLING_CENTER_SETTINGS["show_hours"] = True
224
+ EMERGENCY_SETTINGS["priority_resources"] = ["cooling_center", "hospital"]
225
+
226
+ 4. Mobile-optimized settings:
227
+ UI_SETTINGS["map_height"] = 400
228
+ MAP_CONFIG["default_zoom"] = 13
229
+ RESOURCE_DISPLAY["max_results_display"] = 5
230
+
231
+ 5. Accessibility mode:
232
+ ACCESSIBILITY_SETTINGS["high_contrast_mode"] = True
233
+ ACCESSIBILITY_SETTINGS["large_text_mode"] = True
234
+ ROUTE_SETTINGS["prefer_flat_routes"] = True
235
+ ROUTE_SETTINGS["avoid_stairs"] = True
236
+
237
+ 6. Emergency mode (fast response):
238
+ UI_SETTINGS["animation_speed"] = "none"
239
+ RESOURCE_DISPLAY["show_all_on_load"] = False
240
+ MAP_CONFIG["default_zoom"] = 15
241
+ NOTIFICATION_SETTINGS["auto_clear_messages"] = True
242
+ """
243
+
244
+ # =============================================================================
245
+ # Validation
246
+ # =============================================================================
247
+
248
+ def validate_config():
249
+ """Validate configuration settings and return any errors."""
250
+ errors = []
251
+
252
+ # Validate map zoom levels
253
+ if MAP_CONFIG["default_zoom"] < MAP_CONFIG["min_zoom"]:
254
+ errors.append("default_zoom must be >= min_zoom")
255
+ if MAP_CONFIG["default_zoom"] > MAP_CONFIG["max_zoom"]:
256
+ errors.append("default_zoom must be <= max_zoom")
257
+
258
+ # Validate time limits
259
+ if ISOCHRONE_SETTINGS["max_time_limit"] > 60:
260
+ errors.append("max_time_limit should not exceed 60 minutes")
261
+
262
+ # Validate file sizes
263
+ if MEDIA_SETTINGS["max_file_size"] > 50 * 1024 * 1024:
264
+ errors.append("max_file_size should not exceed 50MB")
265
+
266
+ # Validate recording duration
267
+ if VOICE_SETTINGS["max_recording_duration"] > 600:
268
+ errors.append("max_recording_duration should not exceed 10 minutes")
269
+
270
+ return errors
271
+
272
+
273
+ def get_active_features():
274
+ """Return list of enabled features."""
275
+ return [feature for feature, enabled in FEATURES.items() if enabled]
276
+
277
+
278
+ def print_config_summary():
279
+ """Print a summary of current configuration."""
280
+ print("=" * 60)
281
+ print("EMERGENCY ROUTING ASSISTANT - CONFIGURATION SUMMARY")
282
+ print("=" * 60)
283
+ print(f"\nApp Title: {APP_CONFIG['title']}")
284
+ print(f"Default Language: {UI_SETTINGS['default_language']}")
285
+ print(f"Map Style: {MAP_CONFIG['default_style']}")
286
+ print(f"\nActive Features ({len(get_active_features())} enabled):")
287
+ for feature in get_active_features():
288
+ print(f" ✓ {feature}")
289
+
290
+ errors = validate_config()
291
+ if errors:
292
+ print(f"\n⚠️ Configuration Errors ({len(errors)}):")
293
+ for error in errors:
294
+ print(f" ❌ {error}")
295
+ else:
296
+ print("\n✅ Configuration is valid")
297
+ print("=" * 60)
298
+
299
+
300
+ if __name__ == "__main__":
301
+ print_config_summary()
core/engine.py ADDED
@@ -0,0 +1,2239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Routing engine for the Emergency Routing Assistant.
3
+
4
+ This module contains all routing logic, data loading, and processing.
5
+ The frontend (app.py) should only handle presentation.
6
+
7
+ Features inspired by dream-meridian:
8
+ - igraph backend for high-performance routing (optional)
9
+ - Isochrone generation (reachable area within X minutes)
10
+ - Find along route (discover POIs along a computed route)
11
+ """
12
+
13
+ import os
14
+ import re
15
+ import json
16
+ import networkx as nx
17
+ import pandas as pd
18
+ import geopandas as gpd
19
+ import osmnx as ox
20
+ import requests
21
+ from typing import Any
22
+ from dataclasses import dataclass, field
23
+ from scipy.spatial import cKDTree
24
+ import numpy as np
25
+
26
+ # Optional igraph support for high-performance routing
27
+ try:
28
+ import igraph as ig
29
+ HAS_IGRAPH = True
30
+ except ImportError:
31
+ HAS_IGRAPH = False
32
+
33
+ # =============================================================================
34
+ # Constants
35
+ # =============================================================================
36
+
37
+ WALK_SPEED_M_PER_MIN = 75 # ~4.5 km/h
38
+ ELEVATION_PENALTY_FACTOR = 3.0
39
+
40
+ BROWNSVILLE_CENTER = {"lat": 40.6594, "lon": -73.9126}
41
+ BROWNSVILLE_BOUNDS = {
42
+ "min_lat": 40.64,
43
+ "max_lat": 40.68,
44
+ "min_lon": -73.93,
45
+ "max_lon": -73.89
46
+ }
47
+
48
+ VALID_RESOURCE_TYPES = [
49
+ "pharmacy", "clinic", "hospital", "fire_station", "police",
50
+ "school", "library", "community_centre", "place_of_worship"
51
+ ]
52
+
53
+ # Route colors for display
54
+ ROUTE_COLORS = {
55
+ "shortest": "#3b82f6", # Blue
56
+ "flattest": "#22c55e", # Green
57
+ "balanced": "#f59e0b", # Amber
58
+ "safest": "#10b981", # Emerald (climate-safe route)
59
+ }
60
+
61
+ # Isochrone colors by time
62
+ ISOCHRONE_COLORS = {
63
+ 5: "#22c55e", # Green - 5 min
64
+ 10: "#84cc16", # Lime - 10 min
65
+ 15: "#f59e0b", # Amber - 15 min
66
+ 20: "#ef4444", # Red - 20 min
67
+ }
68
+
69
+ # =============================================================================
70
+ # POI Marker Styles
71
+ # =============================================================================
72
+ # This configuration allows UI developers to customize markers by POI type.
73
+ # Each entry defines: color, icon, and optional display properties.
74
+ #
75
+ # Icon options for Folium:
76
+ # - Font Awesome icons (prefix "fa"): "fa-hospital", "fa-fire", etc.
77
+ # - Glyphicons (prefix "glyphicon"): "glyphicon-home", etc.
78
+ # - Bootstrap icons: "heart", "star", "flag", etc.
79
+ #
80
+ # For custom SVG/image markers, UI devs can extend render_map() to use
81
+ # folium.CustomIcon or folium.DivIcon with the 'custom_icon_url' field.
82
+
83
+ POI_MARKER_STYLES = {
84
+ # Emergency Services
85
+ "hospital": {
86
+ "color": "#dc2626", # Red-600
87
+ "fill_color": "#fecaca", # Red-200
88
+ "icon": "fa-hospital",
89
+ "icon_prefix": "fa",
90
+ "radius": 8,
91
+ "category": "Emergency Service",
92
+ },
93
+ "clinic": {
94
+ "color": "#ef4444", # Red-500
95
+ "fill_color": "#fee2e2", # Red-100
96
+ "icon": "fa-stethoscope",
97
+ "icon_prefix": "fa",
98
+ "radius": 7,
99
+ "category": "Emergency Service",
100
+ },
101
+ "fire_station": {
102
+ "color": "#ea580c", # Orange-600
103
+ "fill_color": "#ffedd5", # Orange-100
104
+ "icon": "fa-fire-extinguisher",
105
+ "icon_prefix": "fa",
106
+ "radius": 8,
107
+ "category": "Emergency Service",
108
+ },
109
+ "police": {
110
+ "color": "#1d4ed8", # Blue-700
111
+ "fill_color": "#dbeafe", # Blue-100
112
+ "icon": "fa-shield",
113
+ "icon_prefix": "fa",
114
+ "radius": 8,
115
+ "category": "Emergency Service",
116
+ },
117
+
118
+ # Healthcare
119
+ "pharmacy": {
120
+ "color": "#16a34a", # Green-600
121
+ "fill_color": "#dcfce7", # Green-100
122
+ "icon": "fa-medkit",
123
+ "icon_prefix": "fa",
124
+ "radius": 6,
125
+ "category": "Healthcare",
126
+ },
127
+ "doctors": {
128
+ "color": "#22c55e", # Green-500
129
+ "fill_color": "#bbf7d0", # Green-200
130
+ "icon": "fa-user-md",
131
+ "icon_prefix": "fa",
132
+ "radius": 6,
133
+ "category": "Healthcare",
134
+ },
135
+
136
+ # Community Resources
137
+ "school": {
138
+ "color": "#7c3aed", # Violet-600
139
+ "fill_color": "#ede9fe", # Violet-100
140
+ "icon": "fa-graduation-cap",
141
+ "icon_prefix": "fa",
142
+ "radius": 7,
143
+ "category": "Community Resource",
144
+ },
145
+ "library": {
146
+ "color": "#8b5cf6", # Violet-500
147
+ "fill_color": "#f3e8ff", # Purple-100
148
+ "icon": "fa-book",
149
+ "icon_prefix": "fa",
150
+ "radius": 6,
151
+ "category": "Community Resource",
152
+ },
153
+ "community_centre": {
154
+ "color": "#6366f1", # Indigo-500
155
+ "fill_color": "#e0e7ff", # Indigo-100
156
+ "icon": "fa-users",
157
+ "icon_prefix": "fa",
158
+ "radius": 7,
159
+ "category": "Community Resource",
160
+ },
161
+ "place_of_worship": {
162
+ "color": "#a855f7", # Purple-500
163
+ "fill_color": "#f3e8ff", # Purple-100
164
+ "icon": "fa-church",
165
+ "icon_prefix": "fa",
166
+ "radius": 6,
167
+ "category": "Community Resource",
168
+ },
169
+ "youth_center": {
170
+ "color": "#ec4899", # Pink-500
171
+ "fill_color": "#fce7f3", # Pink-100
172
+ "icon": "fa-child",
173
+ "icon_prefix": "fa",
174
+ "radius": 6,
175
+ "category": "Community Resource",
176
+ },
177
+ "senior_center": {
178
+ "color": "#f97316", # Orange-500
179
+ "fill_color": "#ffedd5", # Orange-100
180
+ "icon": "fa-heart",
181
+ "icon_prefix": "fa",
182
+ "radius": 6,
183
+ "category": "Community Resource",
184
+ },
185
+ "childcare": {
186
+ "color": "#f472b6", # Pink-400
187
+ "fill_color": "#fbcfe8", # Pink-200
188
+ "icon": "fa-child",
189
+ "icon_prefix": "fa",
190
+ "radius": 5,
191
+ "category": "Community Resource",
192
+ },
193
+ "nycha_community_center": {
194
+ "color": "#0ea5e9", # Sky-500
195
+ "fill_color": "#e0f2fe", # Sky-100
196
+ "icon": "fa-building",
197
+ "icon_prefix": "fa",
198
+ "radius": 7,
199
+ "category": "Community Resource",
200
+ },
201
+
202
+ # Climate Infrastructure
203
+ "park": {
204
+ "color": "#16a34a", # Green-600
205
+ "fill_color": "#bbf7d0", # Green-200
206
+ "icon": "fa-tree",
207
+ "icon_prefix": "fa",
208
+ "radius": 6,
209
+ "category": "Climate Infrastructure",
210
+ },
211
+ "shelter": {
212
+ "color": "#0284c7", # Sky-600
213
+ "fill_color": "#bae6fd", # Sky-200
214
+ "icon": "fa-home",
215
+ "icon_prefix": "fa",
216
+ "radius": 8,
217
+ "category": "Climate Infrastructure",
218
+ },
219
+ "drinking_water": {
220
+ "color": "#0891b2", # Cyan-600
221
+ "fill_color": "#cffafe", # Cyan-100
222
+ "icon": "fa-tint",
223
+ "icon_prefix": "fa",
224
+ "radius": 5,
225
+ "category": "Climate Infrastructure",
226
+ },
227
+
228
+ # Local Business
229
+ "bodega": {
230
+ "color": "#f59e0b", # Amber-500
231
+ "fill_color": "#fef3c7", # Amber-100
232
+ "icon": "fa-shopping-basket",
233
+ "icon_prefix": "fa",
234
+ "radius": 5,
235
+ "category": "Local Business",
236
+ },
237
+ "supermarket": {
238
+ "color": "#d97706", # Amber-600
239
+ "fill_color": "#fef3c7", # Amber-100
240
+ "icon": "fa-shopping-cart",
241
+ "icon_prefix": "fa",
242
+ "radius": 6,
243
+ "category": "Local Business",
244
+ },
245
+ "fast_food": {
246
+ "color": "#fbbf24", # Amber-400
247
+ "fill_color": "#fef9c3", # Yellow-100
248
+ "icon": "fa-cutlery",
249
+ "icon_prefix": "fa",
250
+ "radius": 5,
251
+ "category": "Local Business",
252
+ },
253
+ "cafe": {
254
+ "color": "#92400e", # Amber-800
255
+ "fill_color": "#fef3c7", # Amber-100
256
+ "icon": "fa-coffee",
257
+ "icon_prefix": "fa",
258
+ "radius": 5,
259
+ "category": "Local Business",
260
+ },
261
+ "bank": {
262
+ "color": "#475569", # Slate-600
263
+ "fill_color": "#e2e8f0", # Slate-200
264
+ "icon": "fa-university",
265
+ "icon_prefix": "fa",
266
+ "radius": 5,
267
+ "category": "Local Business",
268
+ },
269
+
270
+ # Social Services
271
+ "social_facility": {
272
+ "color": "#0d9488", # Teal-600
273
+ "fill_color": "#ccfbf1", # Teal-100
274
+ "icon": "fa-handshake-o",
275
+ "icon_prefix": "fa",
276
+ "radius": 6,
277
+ "category": "Social Services",
278
+ },
279
+ "snap_center": {
280
+ "color": "#14b8a6", # Teal-500
281
+ "fill_color": "#ccfbf1", # Teal-100
282
+ "icon": "fa-id-card",
283
+ "icon_prefix": "fa",
284
+ "radius": 6,
285
+ "category": "Social Services",
286
+ },
287
+
288
+ # Default / Fallback for unknown types
289
+ "facility": {
290
+ "color": "#6b7280", # Gray-500
291
+ "fill_color": "#e5e7eb", # Gray-200
292
+ "icon": "fa-building-o",
293
+ "icon_prefix": "fa",
294
+ "radius": 5,
295
+ "category": "Other",
296
+ },
297
+ }
298
+
299
+ # Default style for POI types not explicitly defined
300
+ POI_DEFAULT_STYLE = {
301
+ "color": "#6b7280", # Gray-500
302
+ "fill_color": "#e5e7eb", # Gray-200
303
+ "icon": "fa-map-marker",
304
+ "icon_prefix": "fa",
305
+ "radius": 5,
306
+ "category": "Other",
307
+ }
308
+
309
+
310
+ def get_poi_marker_style(poi_type: str) -> dict:
311
+ """
312
+ Get marker style configuration for a given POI type.
313
+
314
+ Args:
315
+ poi_type: The type of POI (e.g., "hospital", "pharmacy", "school")
316
+
317
+ Returns:
318
+ Dictionary with marker style properties:
319
+ - color: Border/stroke color (hex)
320
+ - fill_color: Fill color (hex)
321
+ - icon: Font Awesome or Glyphicon name
322
+ - icon_prefix: Icon library prefix ("fa" or "glyphicon")
323
+ - radius: Circle marker radius in pixels
324
+ - category: POI category for grouping
325
+
326
+ Example:
327
+ >>> style = get_poi_marker_style("hospital")
328
+ >>> style["color"]
329
+ '#dc2626'
330
+ >>> style["icon"]
331
+ 'fa-hospital'
332
+ """
333
+ return POI_MARKER_STYLES.get(poi_type, POI_DEFAULT_STYLE).copy()
334
+
335
+
336
+ def get_all_poi_styles() -> dict:
337
+ """
338
+ Get all POI marker style configurations.
339
+
340
+ Returns:
341
+ Dictionary mapping POI type names to their style configurations.
342
+ Useful for UI developers to enumerate all available styles.
343
+
344
+ Example:
345
+ >>> styles = get_all_poi_styles()
346
+ >>> list(styles.keys())
347
+ ['hospital', 'clinic', 'fire_station', ...]
348
+ """
349
+ return POI_MARKER_STYLES.copy()
350
+
351
+
352
+ def get_poi_styles_by_category() -> dict[str, list[str]]:
353
+ """
354
+ Get POI types grouped by category.
355
+
356
+ Returns:
357
+ Dictionary mapping category names to lists of POI types.
358
+
359
+ Example:
360
+ >>> by_cat = get_poi_styles_by_category()
361
+ >>> by_cat["Emergency Service"]
362
+ ['hospital', 'clinic', 'fire_station', 'police']
363
+ """
364
+ categories: dict[str, list[str]] = {}
365
+ for poi_type, style in POI_MARKER_STYLES.items():
366
+ cat = style.get("category", "Other")
367
+ if cat not in categories:
368
+ categories[cat] = []
369
+ categories[cat].append(poi_type)
370
+ return categories
371
+
372
+
373
+ # =============================================================================
374
+ # Data Classes
375
+ # =============================================================================
376
+
377
+ @dataclass
378
+ class RouteMetrics:
379
+ elevation_gain_m: float = 0
380
+ elevation_loss_m: float = 0
381
+ max_elevation_m: float = 0
382
+ min_elevation_m: float = 0
383
+ avg_grade_pct: float = 0
384
+ max_grade_pct: float = 0
385
+ difficulty: str = "flat"
386
+
387
+ def to_dict(self) -> dict:
388
+ return {
389
+ "elevation_gain_m": self.elevation_gain_m,
390
+ "elevation_loss_m": self.elevation_loss_m,
391
+ "max_elevation_m": self.max_elevation_m,
392
+ "min_elevation_m": self.min_elevation_m,
393
+ "avg_grade_pct": self.avg_grade_pct,
394
+ "max_grade_pct": self.max_grade_pct,
395
+ "difficulty": self.difficulty,
396
+ }
397
+
398
+
399
+ @dataclass
400
+ class ClimateMetrics:
401
+ """Climate risk metrics for a route.
402
+
403
+ Climate weights are now computed at RUNTIME using configurable parameters,
404
+ allowing the LLM to adjust weights based on user context (e.g., flooding,
405
+ heat wave, air quality concerns).
406
+ """
407
+ avg_climate_risk: float = 0
408
+ max_climate_risk: float = 0
409
+ avg_flood_risk: float = 0
410
+ max_flood_risk: float = 0
411
+ avg_heat_risk: float = 0
412
+ max_heat_risk: float = 0
413
+ avg_air_quality_risk: float = 0
414
+ max_air_quality_risk: float = 0
415
+ avg_tree_coverage: float = 0
416
+ flood_exposure_m: float = 0 # meters of route where flood_risk > 0.3
417
+
418
+ def to_dict(self) -> dict:
419
+ return {
420
+ "avg_climate_risk": self.avg_climate_risk,
421
+ "max_climate_risk": self.max_climate_risk,
422
+ "avg_flood_risk": self.avg_flood_risk,
423
+ "max_flood_risk": self.max_flood_risk,
424
+ "avg_heat_risk": self.avg_heat_risk,
425
+ "max_heat_risk": self.max_heat_risk,
426
+ "avg_air_quality_risk": self.avg_air_quality_risk,
427
+ "max_air_quality_risk": self.max_air_quality_risk,
428
+ "avg_tree_coverage": self.avg_tree_coverage,
429
+ "flood_exposure_m": self.flood_exposure_m,
430
+ }
431
+
432
+
433
+ @dataclass
434
+ class ClimateWeightParams:
435
+ """Parameters for runtime climate weight calculation.
436
+
437
+ These parameters are inferred by the LLM based on user context:
438
+ - Flooding mentioned: increase flood penalties
439
+ - Hot day / shade requested: increase heat_factor and shade_factor
440
+ - Respiratory concerns: increase aqi_factor
441
+ - Mobility concerns / elderly: increase grade_factor
442
+ """
443
+ flood_penalty_deep: float = 5.0 # Multiplier for deep flood zones (flood_risk >= 1.0)
444
+ flood_penalty_shallow: float = 2.0 # Multiplier for shallow flood zones (flood_risk >= 0.6)
445
+ heat_factor: float = 0.3 # Penalty for high heat vulnerability (0.0-1.0)
446
+ shade_factor: float = 0.3 # Benefit from tree coverage (0.0-1.0)
447
+ aqi_factor: float = 0.1 # Penalty for poor air quality (0.0-1.0)
448
+ grade_factor: float = 0.2 # Penalty for steep grades (0.0-1.0)
449
+
450
+ def to_dict(self) -> dict:
451
+ return {
452
+ "flood_penalty_deep": self.flood_penalty_deep,
453
+ "flood_penalty_shallow": self.flood_penalty_shallow,
454
+ "heat_factor": self.heat_factor,
455
+ "shade_factor": self.shade_factor,
456
+ "aqi_factor": self.aqi_factor,
457
+ "grade_factor": self.grade_factor,
458
+ }
459
+
460
+
461
+ @dataclass
462
+ class RouteOption:
463
+ name: str
464
+ label: str
465
+ color: str
466
+ coords: list[tuple[float, float]]
467
+ distance_m: float
468
+ time_min: float
469
+ metrics: RouteMetrics
470
+ route_nodes: list[int] = field(default_factory=list)
471
+ climate_metrics: ClimateMetrics = field(default_factory=ClimateMetrics)
472
+
473
+ def to_dict(self) -> dict:
474
+ result = {
475
+ "name": self.name,
476
+ "label": self.label,
477
+ "color": self.color,
478
+ "coords": self.coords,
479
+ "distance_meters": self.distance_m,
480
+ "walking_time_minutes": self.time_min,
481
+ "route_metrics": self.metrics.to_dict(),
482
+ }
483
+ # Include climate metrics if they have data
484
+ if self.climate_metrics.avg_climate_risk > 0:
485
+ result["climate_metrics"] = self.climate_metrics.to_dict()
486
+ return result
487
+
488
+
489
+ @dataclass
490
+ class RoutingResult:
491
+ success: bool
492
+ recommended: str = ""
493
+ alternatives: list[RouteOption] = field(default_factory=list)
494
+ origin: tuple[float, float] = (0, 0)
495
+ destination: tuple[float, float] = (0, 0)
496
+ dest_name: str = ""
497
+ error: str = ""
498
+
499
+ def to_dict(self) -> dict:
500
+ if not self.success:
501
+ return {"error": self.error}
502
+
503
+ recommended_route = next(
504
+ (r for r in self.alternatives if r.name == self.recommended),
505
+ self.alternatives[0] if self.alternatives else None
506
+ )
507
+
508
+ return {
509
+ "success": True,
510
+ "recommended": self.recommended,
511
+ "alternatives": [r.to_dict() for r in self.alternatives],
512
+ "distance_meters": recommended_route.distance_m if recommended_route else 0,
513
+ "walking_time_minutes": recommended_route.time_min if recommended_route else 0,
514
+ "origin": {"lat": self.origin[0], "lon": self.origin[1]},
515
+ "destination": {"lat": self.destination[0], "lon": self.destination[1], "name": self.dest_name},
516
+ "route_metrics": recommended_route.metrics.to_dict() if recommended_route else {},
517
+ }
518
+
519
+ def to_map_data(self) -> dict | None:
520
+ if not self.success or not self.alternatives:
521
+ return None
522
+
523
+ recommended_route = next(
524
+ (r for r in self.alternatives if r.name == self.recommended),
525
+ self.alternatives[0]
526
+ )
527
+
528
+ return {
529
+ "routes": [
530
+ {"coords": r.coords, "color": r.color, "label": r.label, "name": r.name}
531
+ for r in self.alternatives
532
+ ],
533
+ "origin": list(self.origin),
534
+ "destination": list(self.destination),
535
+ "dest_name": self.dest_name,
536
+ "distance": recommended_route.distance_m,
537
+ "route_coords": recommended_route.coords, # backwards compat
538
+ }
539
+
540
+
541
+ @dataclass
542
+ class IsochroneResult:
543
+ """Result of isochrone generation - areas reachable within time limits."""
544
+ success: bool
545
+ origin: tuple[float, float] = (0, 0)
546
+ isochrones: list[dict] = field(default_factory=list) # [{time_min, polygon_coords, color}]
547
+ resources_within: list[dict] = field(default_factory=list) # Resources within max isochrone
548
+ error: str = ""
549
+
550
+ def to_dict(self) -> dict:
551
+ if not self.success:
552
+ return {"error": self.error}
553
+ return {
554
+ "success": True,
555
+ "origin": {"lat": self.origin[0], "lon": self.origin[1]},
556
+ "isochrones": self.isochrones,
557
+ "resources_within": self.resources_within,
558
+ }
559
+
560
+ def to_map_data(self) -> dict | None:
561
+ if not self.success:
562
+ return None
563
+ return {
564
+ "origin": list(self.origin),
565
+ "isochrones": self.isochrones,
566
+ "resources_within": self.resources_within,
567
+ }
568
+
569
+
570
+ @dataclass
571
+ class AlongRouteResult:
572
+ """Result of find_along_route - POIs discovered along a route."""
573
+ success: bool
574
+ route_coords: list[tuple[float, float]] = field(default_factory=list)
575
+ pois_found: list[dict] = field(default_factory=list)
576
+ origin: tuple[float, float] = (0, 0)
577
+ destination: tuple[float, float] = (0, 0)
578
+ buffer_meters: float = 100
579
+ climate_metrics: ClimateMetrics | None = None
580
+ error: str = ""
581
+
582
+ def to_dict(self) -> dict:
583
+ if not self.success:
584
+ return {"error": self.error}
585
+ result = {
586
+ "success": True,
587
+ "origin": {"lat": self.origin[0], "lon": self.origin[1]},
588
+ "destination": {"lat": self.destination[0], "lon": self.destination[1]},
589
+ "buffer_meters": self.buffer_meters,
590
+ "pois_found": self.pois_found,
591
+ "poi_count": len(self.pois_found),
592
+ }
593
+ if self.climate_metrics:
594
+ result["climate_metrics"] = self.climate_metrics.to_dict()
595
+ return result
596
+
597
+ def to_map_data(self) -> dict | None:
598
+ if not self.success:
599
+ return None
600
+ return {
601
+ "route_coords": self.route_coords,
602
+ "origin": list(self.origin),
603
+ "destination": list(self.destination),
604
+ "pois_along_route": self.pois_found,
605
+ }
606
+
607
+
608
+ @dataclass
609
+ class RouteComparisonResult:
610
+ """Result of comparing shortest vs safest routes."""
611
+ success: bool
612
+ shortest: RouteOption | None = None
613
+ safest: RouteOption | None = None
614
+ origin: tuple[float, float] = (0, 0)
615
+ destination: tuple[float, float] = (0, 0)
616
+ dest_name: str = ""
617
+ extra_distance_m: float = 0
618
+ extra_distance_pct: float = 0
619
+ risk_reduction: float = 0
620
+ error: str = ""
621
+
622
+ def to_dict(self) -> dict:
623
+ if not self.success:
624
+ return {"error": self.error}
625
+ return {
626
+ "success": True,
627
+ "shortest": self.shortest.to_dict() if self.shortest else None,
628
+ "safest": self.safest.to_dict() if self.safest else None,
629
+ "origin": {"lat": self.origin[0], "lon": self.origin[1]},
630
+ "destination": {"lat": self.destination[0], "lon": self.destination[1], "name": self.dest_name},
631
+ "comparison": {
632
+ "extra_distance_m": round(self.extra_distance_m, 1),
633
+ "extra_distance_pct": round(self.extra_distance_pct, 1),
634
+ "risk_reduction": round(self.risk_reduction, 3),
635
+ },
636
+ }
637
+
638
+ def to_map_data(self) -> dict | None:
639
+ if not self.success:
640
+ return None
641
+ routes = []
642
+ if self.shortest:
643
+ routes.append({
644
+ "coords": self.shortest.coords,
645
+ "color": self.shortest.color,
646
+ "label": self.shortest.label,
647
+ "name": self.shortest.name,
648
+ })
649
+ if self.safest and self.safest.coords != (self.shortest.coords if self.shortest else []):
650
+ routes.append({
651
+ "coords": self.safest.coords,
652
+ "color": self.safest.color,
653
+ "label": self.safest.label,
654
+ "name": self.safest.name,
655
+ })
656
+ return {
657
+ "routes": routes,
658
+ "origin": list(self.origin),
659
+ "destination": list(self.destination),
660
+ "dest_name": self.dest_name,
661
+ }
662
+
663
+
664
+ # =============================================================================
665
+ # Routing Engine
666
+ # =============================================================================
667
+
668
+ class RoutingEngine:
669
+ """
670
+ Core routing engine - handles all graph operations and route computation.
671
+
672
+ Supports optional igraph backend for high-performance routing.
673
+ Features:
674
+ - Multi-route computation (shortest, flattest, balanced)
675
+ - Isochrone generation (reachable area within X minutes)
676
+ - Find along route (POIs near a route corridor)
677
+ """
678
+
679
+ def __init__(self, use_igraph: bool = True):
680
+ self.G: nx.MultiDiGraph | None = None
681
+ self.resources_df: pd.DataFrame | None = None
682
+ self.known_places: dict[str, dict] = {}
683
+ self._loaded = False
684
+
685
+ # igraph backend (if available and requested)
686
+ self.use_igraph = use_igraph and HAS_IGRAPH
687
+ self.ig_graph: "ig.Graph | None" = None
688
+ self.ig_node_map: dict[int, int] = {} # NetworkX node -> igraph node
689
+ self.ig_reverse_map: dict[int, int] = {} # igraph node -> NetworkX node
690
+
691
+ # Edge attribute indices for igraph (for climate routing)
692
+ self.ig_length_idx: int = -1
693
+ self.ig_climate_weight_idx: int = -1
694
+
695
+ # Track if climate data is available
696
+ self.has_climate_data: bool = False
697
+
698
+ # Spatial index for fast nearest-neighbor queries
699
+ self._node_coords: np.ndarray | None = None
700
+ self._node_ids: list[int] = []
701
+ self._kdtree: cKDTree | None = None
702
+ self._resource_kdtree: cKDTree | None = None
703
+
704
+ def load(self) -> bool:
705
+ """Load network and resources from disk.
706
+
707
+ Tries to load from pre-built cache first (fast), falls back to GraphML (slow).
708
+ Run `python build_graph_cache.py` to generate the cache for faster startup.
709
+ """
710
+ if self._loaded:
711
+ return True
712
+
713
+ # Data is in project root's data/ directory, not core/data/
714
+ data_dir = os.path.join(os.path.dirname(__file__), "..", "data", "brownsville")
715
+
716
+ try:
717
+ # Try loading from cache first (much faster!)
718
+ cache_path = os.path.join(data_dir, "graph_cache.pkl")
719
+ if os.path.exists(cache_path):
720
+ loaded_from_cache = self._load_from_cache(cache_path)
721
+ if loaded_from_cache:
722
+ # Cache loaded successfully, skip GraphML parsing
723
+ pass
724
+ else:
725
+ # Cache failed, fall back to GraphML
726
+ self._load_from_graphml(data_dir)
727
+ else:
728
+ # No cache, load from GraphML
729
+ self._load_from_graphml(data_dir)
730
+
731
+ # Load resources
732
+ resources_path = os.path.join(data_dir, "all_resources.csv")
733
+ if os.path.exists(resources_path):
734
+ self.resources_df = pd.read_csv(resources_path)
735
+ else:
736
+ geojson_path = os.path.join(data_dir, "all_resources.geojson")
737
+ if os.path.exists(geojson_path):
738
+ gdf = gpd.read_file(geojson_path)
739
+ self.resources_df = pd.DataFrame({
740
+ "name": gdf["name"],
741
+ "type": gdf["type"],
742
+ "category": gdf["category"],
743
+ "lat": gdf.geometry.y,
744
+ "lon": gdf.geometry.x
745
+ })
746
+
747
+ # Build resource spatial index
748
+ if self.resources_df is not None:
749
+ self._build_resource_index()
750
+
751
+ # Load places for geocoding
752
+ self._load_known_places(data_dir)
753
+
754
+ self._loaded = True
755
+ return True
756
+
757
+ except Exception as e:
758
+ print(f"Error loading data: {e}")
759
+ return False
760
+
761
+ def _load_from_cache(self, cache_path: str) -> bool:
762
+ """Load pre-built graph data from pickle cache (fast startup)."""
763
+ import pickle
764
+ try:
765
+ with open(cache_path, 'rb') as f:
766
+ cache = pickle.load(f)
767
+
768
+ # Check cache version (v3 added air_quality_risk)
769
+ if cache.get("version", 1) < 3:
770
+ print("Cache version outdated, rebuilding from GraphML...")
771
+ return False
772
+
773
+ # Restore all cached data
774
+ self.G = cache["nx_graph"]
775
+ self.has_climate_data = cache["has_climate_data"]
776
+ self._node_ids = cache["node_ids"]
777
+ self._node_coords = cache["coords"]
778
+ self._kdtree = cache["kdtree"]
779
+
780
+ # Restore igraph if available
781
+ if self.use_igraph and cache.get("ig_graph") is not None:
782
+ self.ig_graph = cache["ig_graph"]
783
+ self.ig_node_map = cache["ig_node_map"]
784
+ self.ig_reverse_map = cache["ig_reverse_map"]
785
+
786
+ return True
787
+ except Exception as e:
788
+ print(f"Failed to load cache: {e}")
789
+ return False
790
+
791
+ def _load_from_graphml(self, data_dir: str):
792
+ """Load graph from GraphML file (slow fallback)."""
793
+ # Load climate-enhanced network in priority order:
794
+ # 1. Real climate data (walking_network_final.graphml)
795
+ # 2. Mock climate data (walking_network_climate.graphml)
796
+ # 3. Raw network (walking_network.graphml)
797
+ final_path = os.path.join(data_dir, "walking_network_final.graphml")
798
+ climate_path = os.path.join(data_dir, "walking_network_climate.graphml")
799
+ graphml_path = os.path.join(data_dir, "walking_network.graphml")
800
+
801
+ if os.path.exists(final_path):
802
+ self.G = ox.load_graphml(final_path)
803
+ self.has_climate_data = True
804
+ elif os.path.exists(climate_path):
805
+ self.G = ox.load_graphml(climate_path)
806
+ self.has_climate_data = True
807
+ elif os.path.exists(graphml_path):
808
+ self.G = ox.load_graphml(graphml_path)
809
+ self.has_climate_data = False
810
+ else:
811
+ from shapely.geometry import box
812
+ brownsville_bbox = box(-73.93, 40.64, -73.89, 40.68)
813
+ self.G = ox.graph_from_polygon(brownsville_bbox, network_type='walk', simplify=True)
814
+ self.has_climate_data = False
815
+
816
+ # Convert climate attributes from strings to floats (GraphML stores all as strings)
817
+ if self.has_climate_data:
818
+ climate_attrs = ['flood_risk', 'heat_risk', 'air_quality_risk', 'climate_risk', 'climate_weight']
819
+ for u, v, data in self.G.edges(data=True):
820
+ for attr in climate_attrs:
821
+ if attr in data and isinstance(data[attr], str):
822
+ try:
823
+ data[attr] = float(data[attr])
824
+ except (ValueError, TypeError):
825
+ data[attr] = 0.0
826
+
827
+ # Verify climate data by checking first edge
828
+ if self.has_climate_data:
829
+ sample_edge = next(iter(self.G.edges(data=True)), None)
830
+ if sample_edge and "climate_weight" not in sample_edge[2]:
831
+ self.has_climate_data = False
832
+
833
+ self.G = ox.project_graph(self.G)
834
+
835
+ # Build spatial index for fast nearest-node queries
836
+ self._build_spatial_index()
837
+
838
+ # Build igraph graph if available
839
+ if self.use_igraph:
840
+ self._build_igraph_graph()
841
+
842
+ def _build_spatial_index(self):
843
+ """Build KD-tree for fast nearest-node lookups."""
844
+ if self.G is None:
845
+ return
846
+
847
+ nodes = list(self.G.nodes())
848
+ coords = []
849
+ for node in nodes:
850
+ x = self.G.nodes[node].get("x", 0)
851
+ y = self.G.nodes[node].get("y", 0)
852
+ coords.append([x, y])
853
+
854
+ self._node_ids = nodes
855
+ self._node_coords = np.array(coords)
856
+ self._kdtree = cKDTree(self._node_coords)
857
+
858
+ def _build_resource_index(self):
859
+ """Build KD-tree for fast resource lookups."""
860
+ if self.resources_df is None or len(self.resources_df) == 0:
861
+ return
862
+
863
+ # Convert lat/lon to projected coordinates for consistency
864
+ coords = []
865
+ if "crs" in self.G.graph and self.G.graph["crs"] != "EPSG:4326":
866
+ import pyproj
867
+ transformer = pyproj.Transformer.from_crs("EPSG:4326", self.G.graph["crs"], always_xy=True)
868
+ for _, row in self.resources_df.iterrows():
869
+ x, y = transformer.transform(row["lon"], row["lat"])
870
+ coords.append([x, y])
871
+ else:
872
+ for _, row in self.resources_df.iterrows():
873
+ coords.append([row["lon"], row["lat"]])
874
+
875
+ self._resource_kdtree = cKDTree(np.array(coords))
876
+
877
+ def _build_igraph_graph(self):
878
+ """Build igraph graph from NetworkX graph for high-performance routing.
879
+
880
+ Preserves all edge attributes including climate data.
881
+ Uses ig.Graph.from_networkx() for automatic attribute transfer.
882
+ """
883
+ if not HAS_IGRAPH or self.G is None:
884
+ return
885
+
886
+ # Create node mapping (NetworkX uses arbitrary IDs, igraph uses 0..n-1)
887
+ nx_nodes = list(self.G.nodes())
888
+ self.ig_node_map = {nx_node: i for i, nx_node in enumerate(nx_nodes)}
889
+ self.ig_reverse_map = {i: nx_node for nx_node, i in self.ig_node_map.items()}
890
+
891
+ # Create igraph graph (directed)
892
+ self.ig_graph = ig.Graph(n=len(nx_nodes), directed=True)
893
+
894
+ # Store vertex osmids for reverse lookup
895
+ self.ig_graph.vs["_nx_name"] = nx_nodes
896
+
897
+ # Add edges with all attributes
898
+ edges = []
899
+ lengths = []
900
+ climate_weights = []
901
+ flood_risks = []
902
+ heat_risks = []
903
+ climate_risks = []
904
+
905
+ for u, v, data in self.G.edges(data=True):
906
+ ig_u = self.ig_node_map[u]
907
+ ig_v = self.ig_node_map[v]
908
+ edges.append((ig_u, ig_v))
909
+
910
+ length = data.get("length", 1.0)
911
+ if length is None:
912
+ length = 1.0
913
+ lengths.append(float(length))
914
+
915
+ # Climate attributes (default to safe values if missing)
916
+ flood_risks.append(float(data.get("flood_risk", 0) or 0))
917
+ heat_risks.append(float(data.get("heat_risk", 0) or 0))
918
+ climate_risks.append(float(data.get("climate_risk", 0) or 0))
919
+
920
+ # Climate weight for routing (default to length if missing)
921
+ cw = data.get("climate_weight")
922
+ if cw is None:
923
+ cw = length
924
+ climate_weights.append(float(cw))
925
+
926
+ self.ig_graph.add_edges(edges)
927
+ self.ig_graph.es["length"] = lengths
928
+ self.ig_graph.es["weight"] = lengths # Default weight is length
929
+ self.ig_graph.es["climate_weight"] = climate_weights
930
+ self.ig_graph.es["flood_risk"] = flood_risks
931
+ self.ig_graph.es["heat_risk"] = heat_risks
932
+ self.ig_graph.es["climate_risk"] = climate_risks
933
+
934
+ def _load_known_places(self, data_dir: str):
935
+ """Load known places for geocoding."""
936
+ places_path = os.path.join(data_dir, "places.csv")
937
+ if os.path.exists(places_path):
938
+ try:
939
+ df = pd.read_csv(places_path)
940
+ for _, row in df.iterrows():
941
+ self.known_places[row['name_lower']] = {
942
+ "lat": row['lat'],
943
+ "lon": row['lon'],
944
+ "name": row['name']
945
+ }
946
+ except Exception:
947
+ pass
948
+
949
+ @property
950
+ def is_loaded(self) -> bool:
951
+ return self._loaded
952
+
953
+ @property
954
+ def node_count(self) -> int:
955
+ return len(self.G.nodes) if self.G else 0
956
+
957
+ @property
958
+ def resource_count(self) -> int:
959
+ return len(self.resources_df) if self.resources_df is not None else 0
960
+
961
+ # -------------------------------------------------------------------------
962
+ # Graph utilities
963
+ # -------------------------------------------------------------------------
964
+
965
+ def _get_nearest_node(self, lat: float, lon: float) -> int:
966
+ """Find nearest network node to a point using KD-tree (fast)."""
967
+ # Convert to projected coordinates if needed
968
+ if "crs" in self.G.graph and self.G.graph["crs"] != "EPSG:4326":
969
+ import pyproj
970
+ transformer = pyproj.Transformer.from_crs("EPSG:4326", self.G.graph["crs"], always_xy=True)
971
+ x, y = transformer.transform(lon, lat)
972
+ else:
973
+ x, y = lon, lat
974
+
975
+ # Use KD-tree for O(log n) lookup
976
+ if self._kdtree is not None:
977
+ _, idx = self._kdtree.query([x, y])
978
+ return self._node_ids[idx]
979
+
980
+ # Fallback to OSMnx
981
+ return ox.nearest_nodes(self.G, x, y)
982
+
983
+ def _get_nearest_node_ig(self, lat: float, lon: float) -> int:
984
+ """Get igraph node ID for a location."""
985
+ nx_node = self._get_nearest_node(lat, lon)
986
+ return self.ig_node_map.get(nx_node, 0)
987
+
988
+ def _get_route_coords(self, route: list[int]) -> list[tuple[float, float]]:
989
+ """Extract lat/lon coordinates from route nodes."""
990
+ if "crs" in self.G.graph and self.G.graph["crs"] != "EPSG:4326":
991
+ import pyproj
992
+ transformer = pyproj.Transformer.from_crs(self.G.graph["crs"], "EPSG:4326", always_xy=True)
993
+ coords = []
994
+ for node in route:
995
+ x, y = self.G.nodes[node]["x"], self.G.nodes[node]["y"]
996
+ lon, lat = transformer.transform(x, y)
997
+ coords.append((lat, lon))
998
+ return coords
999
+ return [(self.G.nodes[node]["y"], self.G.nodes[node]["x"]) for node in route]
1000
+
1001
+ def _apply_elevation_weights(self, penalty_factor: float = ELEVATION_PENALTY_FACTOR) -> nx.MultiDiGraph:
1002
+ """Create graph copy with elevation-weighted edges."""
1003
+ G_weighted = self.G.copy()
1004
+ for u, v, key, data in G_weighted.edges(keys=True, data=True):
1005
+ base_length = data.get("length", 1)
1006
+ elev_u = G_weighted.nodes[u].get("elevation", 0) or 0
1007
+ elev_v = G_weighted.nodes[v].get("elevation", 0) or 0
1008
+ elev_diff = elev_v - elev_u
1009
+
1010
+ if elev_diff > 0:
1011
+ data["weighted_length"] = base_length + (elev_diff * penalty_factor)
1012
+ else:
1013
+ data["weighted_length"] = base_length
1014
+ return G_weighted
1015
+
1016
+ def _compute_edge_weight(
1017
+ self,
1018
+ data: dict,
1019
+ params: ClimateWeightParams = None
1020
+ ) -> float:
1021
+ """
1022
+ Compute routing weight for an edge using raw risk values and configurable penalties.
1023
+
1024
+ This is computed at RUNTIME, allowing the LLM to adjust weights based on
1025
+ what the user tells us about their situation (flooding, heat, air quality, mobility).
1026
+
1027
+ Design:
1028
+ - Flood: categorical penalty (physical danger)
1029
+ - Heat: continuous penalty based on HVI (social vulnerability / exposure proxy)
1030
+ - Tree coverage: REDUCES weight (shade is beneficial)
1031
+ - AQI: small continuous penalty (background air quality)
1032
+ - Grade: continuous penalty for uphill segments (mobility / exertion)
1033
+
1034
+ Args:
1035
+ data: Edge attribute dict
1036
+ params: ClimateWeightParams with penalty/factor values
1037
+
1038
+ Returns:
1039
+ float: Weighted length for Dijkstra's algorithm
1040
+ """
1041
+ if params is None:
1042
+ params = ClimateWeightParams()
1043
+
1044
+ length = float(data.get('length', 100))
1045
+
1046
+ # === FLOOD: Categorical penalty (physical danger) ===
1047
+ flood_risk = float(data.get('flood_risk', 0.1))
1048
+ if flood_risk >= 1.0:
1049
+ flood_mult = params.flood_penalty_deep # Deep flooding: major penalty
1050
+ elif flood_risk >= 0.6:
1051
+ flood_mult = params.flood_penalty_shallow # Shallow flooding: moderate penalty
1052
+ else:
1053
+ flood_mult = 1.0 # No flooding: no penalty
1054
+
1055
+ # === HEAT: Continuous penalty based on HVI ===
1056
+ heat_risk = float(data.get('heat_risk', 0.5))
1057
+ # At heat_factor=0.3: HVI 1 (0.2) adds 6%, HVI 5 (1.0) adds 30%
1058
+ heat_mult = 1.0 + params.heat_factor * heat_risk
1059
+
1060
+ # === TREE COVERAGE: Reduces weight (shade is good) ===
1061
+ tree_coverage = float(data.get('tree_coverage', 0.0))
1062
+ # At shade_factor=0.3: 0% trees = 1.0x, 100% trees = 0.7x
1063
+ shade_mult = 1.0 - (params.shade_factor * tree_coverage)
1064
+
1065
+ # === AIR QUALITY: Small continuous penalty ===
1066
+ aqi_risk = float(data.get('air_quality_risk', 0.0))
1067
+ # At aqi_factor=0.1: worst AQI adds 10%
1068
+ aqi_mult = 1.0 + params.aqi_factor * aqi_risk
1069
+
1070
+ # === GRADE: Continuous penalty for steep uphill segments ===
1071
+ # Grade is stored as decimal (0.05 = 5% grade)
1072
+ # Normalize: 10% grade (0.1) → grade_norm = 1.0
1073
+ grade = abs(float(data.get('grade', 0) or 0))
1074
+ grade_norm = min(grade * 10, 1.0) # Cap at 10% grade
1075
+ # At grade_factor=0.2: flat = 1.0x, 10% grade = 1.2x
1076
+ grade_mult = 1.0 + params.grade_factor * grade_norm
1077
+
1078
+ # Combine multiplicatively
1079
+ return length * flood_mult * heat_mult * shade_mult * aqi_mult * grade_mult
1080
+
1081
+ def _create_weight_function(self, params: ClimateWeightParams = None):
1082
+ """Create a weight function for NetworkX routing with given climate parameters.
1083
+
1084
+ Note: For MultiDiGraph, NetworkX passes the edge data as {key: {attrs}} dict,
1085
+ not the {attrs} dict directly. We extract the first edge's attributes.
1086
+ """
1087
+ if params is None:
1088
+ params = ClimateWeightParams()
1089
+
1090
+ def weight_func(u, v, data):
1091
+ # For MultiDiGraph, data is {key: {attrs}} - extract first edge's attrs
1092
+ if isinstance(data, dict) and data:
1093
+ first_key = next(iter(data))
1094
+ if isinstance(first_key, int) and isinstance(data[first_key], dict):
1095
+ data = data[first_key]
1096
+ return self._compute_edge_weight(data, params)
1097
+
1098
+ return weight_func
1099
+
1100
+ def _compute_route_metrics(self, route: list[int]) -> RouteMetrics:
1101
+ """Compute elevation metrics for a route."""
1102
+ if not route or len(route) < 2:
1103
+ return RouteMetrics()
1104
+
1105
+ elevations = []
1106
+ grades = []
1107
+ elevation_gain = 0
1108
+ elevation_loss = 0
1109
+
1110
+ for i, node in enumerate(route):
1111
+ elev = self.G.nodes[node].get("elevation", 0) or 0
1112
+ elevations.append(elev)
1113
+
1114
+ if i > 0:
1115
+ diff = elev - elevations[i-1]
1116
+ if diff > 0:
1117
+ elevation_gain += diff
1118
+ else:
1119
+ elevation_loss += abs(diff)
1120
+
1121
+ edge_data = self.G.get_edge_data(route[i-1], node)
1122
+ if edge_data:
1123
+ first_edge = list(edge_data.values())[0] if isinstance(edge_data, dict) else edge_data
1124
+ grade = abs(first_edge.get("grade", 0)) * 100
1125
+ grades.append(grade)
1126
+
1127
+ max_elev = max(elevations) if elevations else 0
1128
+ min_elev = min(elevations) if elevations else 0
1129
+ avg_grade = sum(grades) / len(grades) if grades else 0
1130
+ max_grade = max(grades) if grades else 0
1131
+
1132
+ if elevation_gain < 5 and max_grade < 3:
1133
+ difficulty = "flat"
1134
+ elif elevation_gain < 15 or max_grade < 8:
1135
+ difficulty = "moderate"
1136
+ else:
1137
+ difficulty = "hilly"
1138
+
1139
+ return RouteMetrics(
1140
+ elevation_gain_m=round(elevation_gain, 1),
1141
+ elevation_loss_m=round(elevation_loss, 1),
1142
+ max_elevation_m=round(max_elev, 1),
1143
+ min_elevation_m=round(min_elev, 1),
1144
+ avg_grade_pct=round(avg_grade, 1),
1145
+ max_grade_pct=round(max_grade, 1),
1146
+ difficulty=difficulty
1147
+ )
1148
+
1149
+ def _compute_climate_metrics(self, route: list[int]) -> ClimateMetrics:
1150
+ """Compute climate risk metrics for a route.
1151
+
1152
+ Climate risk is now computed at runtime using a simple weighted formula:
1153
+ climate_risk = 0.5 * flood_risk + 0.3 * heat_risk + 0.2 * air_quality_risk
1154
+
1155
+ Tree coverage is tracked but used separately (reduces routing weight).
1156
+ """
1157
+ if not route or len(route) < 2 or not self.has_climate_data:
1158
+ return ClimateMetrics()
1159
+
1160
+ flood_risks = []
1161
+ heat_risks = []
1162
+ air_quality_risks = []
1163
+ tree_coverages = []
1164
+ climate_risks = []
1165
+ flood_exposure = 0.0 # meters in flood risk > 0.3
1166
+
1167
+ for i in range(len(route) - 1):
1168
+ u, v = route[i], route[i + 1]
1169
+ edge_data = self.G.get_edge_data(u, v)
1170
+ if not edge_data:
1171
+ continue
1172
+
1173
+ # Get first edge (MultiDiGraph may have multiple)
1174
+ if isinstance(edge_data, dict) and 0 in edge_data:
1175
+ data = edge_data[0]
1176
+ else:
1177
+ data = next(iter(edge_data.values())) if isinstance(edge_data, dict) else edge_data
1178
+
1179
+ flood = float(data.get("flood_risk", 0) or 0)
1180
+ heat = float(data.get("heat_risk", 0) or 0)
1181
+ air_quality = float(data.get("air_quality_risk", 0) or 0)
1182
+ tree_coverage = float(data.get("tree_coverage", 0) or 0)
1183
+ length = float(data.get("length", 0) or 0)
1184
+
1185
+ # Compute combined climate risk at runtime
1186
+ climate = 0.5 * flood + 0.3 * heat + 0.2 * air_quality
1187
+
1188
+ flood_risks.append(flood)
1189
+ heat_risks.append(heat)
1190
+ air_quality_risks.append(air_quality)
1191
+ tree_coverages.append(tree_coverage)
1192
+ climate_risks.append(climate)
1193
+
1194
+ if flood > 0.3:
1195
+ flood_exposure += length
1196
+
1197
+ if not climate_risks:
1198
+ return ClimateMetrics()
1199
+
1200
+ return ClimateMetrics(
1201
+ avg_climate_risk=round(sum(climate_risks) / len(climate_risks), 3),
1202
+ max_climate_risk=round(max(climate_risks), 3),
1203
+ avg_flood_risk=round(sum(flood_risks) / len(flood_risks), 3),
1204
+ max_flood_risk=round(max(flood_risks), 3),
1205
+ avg_heat_risk=round(sum(heat_risks) / len(heat_risks), 3),
1206
+ max_heat_risk=round(max(heat_risks), 3),
1207
+ avg_air_quality_risk=round(sum(air_quality_risks) / len(air_quality_risks), 3),
1208
+ max_air_quality_risk=round(max(air_quality_risks), 3),
1209
+ avg_tree_coverage=round(sum(tree_coverages) / len(tree_coverages), 3),
1210
+ flood_exposure_m=round(flood_exposure, 1),
1211
+ )
1212
+
1213
+ # -------------------------------------------------------------------------
1214
+ # Route computation
1215
+ # -------------------------------------------------------------------------
1216
+
1217
+ def _compute_single_route(
1218
+ self,
1219
+ G: nx.MultiDiGraph,
1220
+ origin_node: int,
1221
+ dest_node: int,
1222
+ weight_key: str = "length"
1223
+ ) -> RouteOption | None:
1224
+ """Compute a single route."""
1225
+ try:
1226
+ route = nx.shortest_path(G, origin_node, dest_node, weight=weight_key)
1227
+ distance = sum(
1228
+ self.G[u][v][0].get("length", 0) for u, v in zip(route[:-1], route[1:])
1229
+ )
1230
+ return RouteOption(
1231
+ name="",
1232
+ label="",
1233
+ color="",
1234
+ coords=self._get_route_coords(route),
1235
+ distance_m=round(distance, 1),
1236
+ time_min=round(distance / WALK_SPEED_M_PER_MIN, 1),
1237
+ metrics=self._compute_route_metrics(route),
1238
+ route_nodes=route,
1239
+ climate_metrics=self._compute_climate_metrics(route),
1240
+ )
1241
+ except (nx.NetworkXNoPath, Exception):
1242
+ return None
1243
+
1244
+ def _compute_single_route_igraph(
1245
+ self,
1246
+ origin_node: int,
1247
+ dest_node: int,
1248
+ weight_attr: str = "length"
1249
+ ) -> RouteOption | None:
1250
+ """Compute a single route using igraph for better performance."""
1251
+ if not self.use_igraph or self.ig_graph is None:
1252
+ return None
1253
+
1254
+ try:
1255
+ ig_origin = self.ig_node_map.get(origin_node)
1256
+ ig_dest = self.ig_node_map.get(dest_node)
1257
+
1258
+ if ig_origin is None or ig_dest is None:
1259
+ return None
1260
+
1261
+ # Get shortest path using specified weight
1262
+ path = self.ig_graph.get_shortest_paths(
1263
+ ig_origin, to=ig_dest, weights=weight_attr, output="vpath"
1264
+ )[0]
1265
+
1266
+ if not path:
1267
+ return None
1268
+
1269
+ # Convert back to NetworkX node IDs
1270
+ route = [self.ig_reverse_map[ig_node] for ig_node in path]
1271
+
1272
+ # Compute distance using original graph
1273
+ distance = sum(
1274
+ self.G[u][v][0].get("length", 0) for u, v in zip(route[:-1], route[1:])
1275
+ )
1276
+
1277
+ return RouteOption(
1278
+ name="",
1279
+ label="",
1280
+ color="",
1281
+ coords=self._get_route_coords(route),
1282
+ distance_m=round(distance, 1),
1283
+ time_min=round(distance / WALK_SPEED_M_PER_MIN, 1),
1284
+ metrics=self._compute_route_metrics(route),
1285
+ route_nodes=route,
1286
+ climate_metrics=self._compute_climate_metrics(route),
1287
+ )
1288
+ except Exception:
1289
+ return None
1290
+
1291
+ def route(
1292
+ self,
1293
+ origin_coords: tuple[float, float],
1294
+ dest_coords: tuple[float, float],
1295
+ mode: str = "safest",
1296
+ climate_params: ClimateWeightParams = None
1297
+ ) -> RouteOption | None:
1298
+ """
1299
+ Compute a single route between two points with runtime climate weight calculation.
1300
+
1301
+ Climate weights are computed at RUNTIME using configurable parameters,
1302
+ allowing the LLM to adjust based on user context (flooding, heat, asthma, etc.).
1303
+
1304
+ Args:
1305
+ origin_coords: (lat, lon) tuple for origin
1306
+ dest_coords: (lat, lon) tuple for destination
1307
+ mode: 'safest' (use runtime climate weights) or 'fastest' (use length only)
1308
+ climate_params: ClimateWeightParams for runtime weight calculation.
1309
+ If None, uses default parameters.
1310
+
1311
+ Returns:
1312
+ RouteOption or None if no path found
1313
+ """
1314
+ if not self._loaded:
1315
+ return None
1316
+
1317
+ origin_lat, origin_lon = origin_coords
1318
+ dest_lat, dest_lon = dest_coords
1319
+
1320
+ try:
1321
+ origin_node = self._get_nearest_node(origin_lat, origin_lon)
1322
+ dest_node = self._get_nearest_node(dest_lat, dest_lon)
1323
+ except Exception:
1324
+ return None
1325
+
1326
+ # For 'fastest' mode, use simple length-based routing
1327
+ if mode == "fastest" or mode == "fast":
1328
+ result = self._compute_single_route(self.G, origin_node, dest_node, "length")
1329
+ if result:
1330
+ result.name = "fastest"
1331
+ result.label = "Fastest"
1332
+ result.color = ROUTE_COLORS.get("shortest", "#3b82f6")
1333
+ return result
1334
+
1335
+ # For 'safest' mode, use runtime climate weight calculation
1336
+ if climate_params is None:
1337
+ climate_params = ClimateWeightParams()
1338
+
1339
+ # Use NetworkX with callable weight function for runtime calculation
1340
+ weight_func = self._create_weight_function(climate_params)
1341
+ result = self._compute_single_route_with_weight_func(
1342
+ origin_node, dest_node, weight_func
1343
+ )
1344
+
1345
+ if result:
1346
+ result.name = "safest"
1347
+ result.label = "Safest"
1348
+ result.color = ROUTE_COLORS.get("safest", "#10b981")
1349
+ return result
1350
+
1351
+ def _compute_single_route_with_weight_func(
1352
+ self,
1353
+ origin_node: int,
1354
+ dest_node: int,
1355
+ weight_func
1356
+ ) -> RouteOption | None:
1357
+ """Compute a single route using a callable weight function."""
1358
+ try:
1359
+ route = nx.shortest_path(self.G, origin_node, dest_node, weight=weight_func)
1360
+ distance = sum(
1361
+ self.G[u][v][0].get("length", 0) for u, v in zip(route[:-1], route[1:])
1362
+ )
1363
+ return RouteOption(
1364
+ name="",
1365
+ label="",
1366
+ color="",
1367
+ coords=self._get_route_coords(route),
1368
+ distance_m=round(distance, 1),
1369
+ time_min=round(distance / WALK_SPEED_M_PER_MIN, 1),
1370
+ metrics=self._compute_route_metrics(route),
1371
+ route_nodes=route,
1372
+ climate_metrics=self._compute_climate_metrics(route),
1373
+ )
1374
+ except (nx.NetworkXNoPath, Exception):
1375
+ return None
1376
+
1377
+ def compare_routes(
1378
+ self,
1379
+ origin_coords: tuple[float, float],
1380
+ dest_coords: tuple[float, float],
1381
+ dest_name: str = "Destination",
1382
+ climate_params: ClimateWeightParams = None
1383
+ ) -> RouteComparisonResult:
1384
+ """
1385
+ Compare shortest and safest routes between two points.
1386
+
1387
+ Returns both routes with comparison statistics showing the trade-off
1388
+ between distance and climate risk.
1389
+
1390
+ Args:
1391
+ origin_coords: (lat, lon) tuple for origin
1392
+ dest_coords: (lat, lon) tuple for destination
1393
+ dest_name: Optional name for the destination
1394
+ climate_params: ClimateWeightParams for runtime weight calculation
1395
+
1396
+ Returns:
1397
+ RouteComparisonResult with both routes and comparison stats
1398
+ """
1399
+ if not self._loaded:
1400
+ return RouteComparisonResult(success=False, error="Engine not loaded")
1401
+
1402
+ if not self.has_climate_data:
1403
+ return RouteComparisonResult(
1404
+ success=False,
1405
+ error="Climate data not available. Load climate-enhanced network first."
1406
+ )
1407
+
1408
+ origin_lat, origin_lon = origin_coords
1409
+ dest_lat, dest_lon = dest_coords
1410
+
1411
+ # Compute both routes - fastest uses length, safest uses climate weights
1412
+ fastest = self.route(origin_coords, dest_coords, mode="fastest")
1413
+ safest = self.route(origin_coords, dest_coords, mode="safest", climate_params=climate_params)
1414
+
1415
+ if not fastest:
1416
+ return RouteComparisonResult(success=False, error="No path found for fastest route")
1417
+ if not safest:
1418
+ return RouteComparisonResult(success=False, error="No path found for safest route")
1419
+
1420
+ # Calculate comparison statistics
1421
+ extra_distance_m = safest.distance_m - fastest.distance_m
1422
+ extra_distance_pct = (extra_distance_m / fastest.distance_m * 100) if fastest.distance_m > 0 else 0
1423
+
1424
+ # Risk reduction is the difference in average climate risk
1425
+ risk_reduction = fastest.climate_metrics.avg_climate_risk - safest.climate_metrics.avg_climate_risk
1426
+
1427
+ return RouteComparisonResult(
1428
+ success=True,
1429
+ shortest=fastest,
1430
+ safest=safest,
1431
+ origin=(origin_lat, origin_lon),
1432
+ destination=(dest_lat, dest_lon),
1433
+ dest_name=dest_name,
1434
+ extra_distance_m=extra_distance_m,
1435
+ extra_distance_pct=extra_distance_pct,
1436
+ risk_reduction=risk_reduction,
1437
+ )
1438
+
1439
+ def compute_routes(
1440
+ self,
1441
+ origin_lat: float,
1442
+ origin_lon: float,
1443
+ dest_lat: float,
1444
+ dest_lon: float,
1445
+ dest_name: str = "Destination",
1446
+ climate_params: ClimateWeightParams = None
1447
+ ) -> RoutingResult:
1448
+ """Compute multiple route alternatives between two points.
1449
+
1450
+ Climate weights are computed at RUNTIME using configurable parameters.
1451
+ The safest route uses runtime weight calculation based on flood, heat,
1452
+ tree coverage, and air quality factors.
1453
+
1454
+ Args:
1455
+ origin_lat, origin_lon: Origin coordinates
1456
+ dest_lat, dest_lon: Destination coordinates
1457
+ dest_name: Name of destination
1458
+ climate_params: ClimateWeightParams for runtime weight calculation
1459
+ """
1460
+ if not self._loaded:
1461
+ return RoutingResult(success=False, error="Engine not loaded")
1462
+
1463
+ try:
1464
+ origin_node = self._get_nearest_node(origin_lat, origin_lon)
1465
+ dest_node = self._get_nearest_node(dest_lat, dest_lon)
1466
+ except Exception as e:
1467
+ return RoutingResult(success=False, error=f"Could not locate points: {e}")
1468
+
1469
+ alternatives = []
1470
+
1471
+ # Shortest route (length only)
1472
+ shortest = self._compute_single_route(self.G, origin_node, dest_node, "length")
1473
+ if shortest:
1474
+ shortest.name = "shortest"
1475
+ shortest.label = "Shortest"
1476
+ shortest.color = ROUTE_COLORS["shortest"]
1477
+ alternatives.append(shortest)
1478
+
1479
+ # Flattest route
1480
+ G_flat = self._apply_elevation_weights(penalty_factor=5.0)
1481
+ flattest = self._compute_single_route(G_flat, origin_node, dest_node, "weighted_length")
1482
+ if flattest and (not shortest or flattest.coords != shortest.coords):
1483
+ flattest.name = "flattest"
1484
+ flattest.label = "Flattest"
1485
+ flattest.color = ROUTE_COLORS["flattest"]
1486
+ alternatives.append(flattest)
1487
+
1488
+ # Balanced route
1489
+ G_balanced = self._apply_elevation_weights(penalty_factor=2.0)
1490
+ balanced = self._compute_single_route(G_balanced, origin_node, dest_node, "weighted_length")
1491
+ if balanced:
1492
+ existing_coords = [r.coords for r in alternatives]
1493
+ if balanced.coords not in existing_coords:
1494
+ balanced.name = "balanced"
1495
+ balanced.label = "Balanced"
1496
+ balanced.color = ROUTE_COLORS["balanced"]
1497
+ alternatives.append(balanced)
1498
+
1499
+ # Safest route (climate-aware with runtime weight calculation)
1500
+ if self.has_climate_data:
1501
+ if climate_params is None:
1502
+ climate_params = ClimateWeightParams()
1503
+
1504
+ weight_func = self._create_weight_function(climate_params)
1505
+ safest = self._compute_single_route_with_weight_func(origin_node, dest_node, weight_func)
1506
+ if safest:
1507
+ existing_coords = [r.coords for r in alternatives]
1508
+ if safest.coords not in existing_coords:
1509
+ # Different route - add as separate option
1510
+ safest.name = "safest"
1511
+ safest.label = "Safest (Climate)"
1512
+ safest.color = ROUTE_COLORS["safest"]
1513
+ alternatives.append(safest)
1514
+ else:
1515
+ # Safest route has same path as an existing route (likely shortest)
1516
+ # Update the existing route to show it's ALSO the safest option
1517
+ for route in alternatives:
1518
+ if route.coords == safest.coords:
1519
+ if route.name == "shortest":
1520
+ route.label = "Shortest & Safest (Climate)"
1521
+ route.name = "safest" # Mark as safest for recommendation
1522
+ break
1523
+
1524
+ if not alternatives:
1525
+ return RoutingResult(success=False, error="No path found")
1526
+
1527
+ # CLIMATE-AWARE BY DEFAULT: Always recommend safest route when climate
1528
+ # data is available to protect users from flood and heat exposure.
1529
+ if self.has_climate_data and any(r.name == "safest" for r in alternatives):
1530
+ recommended = "safest"
1531
+ elif any(r.name == "flattest" for r in alternatives):
1532
+ recommended = "flattest"
1533
+ else:
1534
+ recommended = "shortest"
1535
+
1536
+ return RoutingResult(
1537
+ success=True,
1538
+ recommended=recommended,
1539
+ alternatives=alternatives,
1540
+ origin=(origin_lat, origin_lon),
1541
+ destination=(dest_lat, dest_lon),
1542
+ dest_name=dest_name
1543
+ )
1544
+
1545
+ def find_nearest_resource(
1546
+ self,
1547
+ resource_type: str,
1548
+ origin_lat: float,
1549
+ origin_lon: float,
1550
+ prefer_safe: bool = True,
1551
+ climate_params: ClimateWeightParams = None
1552
+ ) -> tuple[dict[str, Any], dict | None]:
1553
+ """
1554
+ Find nearest resource of a given type with climate-aware route alternatives.
1555
+
1556
+ This function:
1557
+ 1. Finds the nearest resource by ROUTED distance (not crow-flies)
1558
+ 2. Computes multiple route alternatives (shortest, flattest, safest)
1559
+ 3. Recommends the safest route when climate data is available
1560
+
1561
+ Climate weights are computed at RUNTIME using configurable parameters,
1562
+ allowing the LLM to adjust based on user context (flooding, heat, asthma, etc.).
1563
+
1564
+ Args:
1565
+ resource_type: Type of resource to find
1566
+ origin_lat: Origin latitude
1567
+ origin_lon: Origin longitude
1568
+ prefer_safe: If True and climate data available, prefer climate-safe routes
1569
+ climate_params: ClimateWeightParams for runtime weight calculation
1570
+ """
1571
+ if not self._loaded:
1572
+ return {"error": "Engine not loaded"}, None
1573
+
1574
+ if self.resources_df is None:
1575
+ return {"error": "Resources not loaded"}, None
1576
+
1577
+ candidates = self.resources_df[self.resources_df["type"] == resource_type]
1578
+ if len(candidates) == 0:
1579
+ return {"error": f"No resources of type '{resource_type}' found"}, None
1580
+
1581
+ try:
1582
+ origin_node = self._get_nearest_node(origin_lat, origin_lon)
1583
+ except Exception as e:
1584
+ return {"error": f"Could not find origin: {e}"}, None
1585
+
1586
+ # Find the nearest resource by ROUTED distance (not crow-flies)
1587
+ # Use simple length-based routing to find which resource is nearest
1588
+ best_resource = None
1589
+ best_distance = float("inf")
1590
+
1591
+ for _, row in candidates.iterrows():
1592
+ try:
1593
+ dest_node = self._get_nearest_node(row["lat"], row["lon"])
1594
+ # Use actual path length to determine nearest
1595
+ route_length = nx.shortest_path_length(
1596
+ self.G, origin_node, dest_node, weight="length"
1597
+ )
1598
+ if route_length < best_distance:
1599
+ best_distance = route_length
1600
+ best_resource = row.to_dict()
1601
+ except (nx.NetworkXNoPath, Exception):
1602
+ continue
1603
+
1604
+ if best_resource is None:
1605
+ return {"error": f"Could not find route to any {resource_type}"}, None
1606
+
1607
+ # Now compute route alternatives to the nearest resource
1608
+ # This provides shortest, flattest, and safest (climate-aware) routes
1609
+ routing_result = self.compute_routes(
1610
+ origin_lat=origin_lat,
1611
+ origin_lon=origin_lon,
1612
+ dest_lat=best_resource["lat"],
1613
+ dest_lon=best_resource["lon"],
1614
+ dest_name=best_resource["name"],
1615
+ climate_params=climate_params
1616
+ )
1617
+
1618
+ if not routing_result.success:
1619
+ return {"error": routing_result.error or "Could not compute routes"}, None
1620
+
1621
+ # Get the recommended route (safest when climate data available)
1622
+ recommended_route = next(
1623
+ (r for r in routing_result.alternatives if r.name == routing_result.recommended),
1624
+ routing_result.alternatives[0] if routing_result.alternatives else None
1625
+ )
1626
+ if not recommended_route:
1627
+ return {"error": "No route alternatives computed"}, None
1628
+
1629
+ # Build map data with all route alternatives
1630
+ map_data = {
1631
+ "routes": [
1632
+ {
1633
+ "coords": alt.coords,
1634
+ "color": alt.color,
1635
+ "label": alt.label,
1636
+ "name": alt.name,
1637
+ }
1638
+ for alt in routing_result.alternatives
1639
+ ],
1640
+ "origin": [origin_lat, origin_lon],
1641
+ "destination": [best_resource["lat"], best_resource["lon"]],
1642
+ "dest_name": best_resource["name"],
1643
+ "recommended": routing_result.recommended,
1644
+ }
1645
+
1646
+ # Build result with resource info and route alternatives
1647
+ result = {
1648
+ "found": True,
1649
+ "name": best_resource["name"],
1650
+ "type": best_resource["type"],
1651
+ "category": best_resource.get("category", ""),
1652
+ "lat": best_resource["lat"],
1653
+ "lon": best_resource["lon"],
1654
+ "origin": {"lat": origin_lat, "lon": origin_lon},
1655
+ # Include all route alternatives
1656
+ "alternatives": [alt.to_dict() for alt in routing_result.alternatives],
1657
+ "recommended": routing_result.recommended,
1658
+ # Recommended route metrics for backward compatibility
1659
+ "distance_meters": round(recommended_route.distance_m, 1),
1660
+ "walking_time_minutes": round(recommended_route.time_min, 1),
1661
+ "route_metrics": recommended_route.metrics.to_dict() if recommended_route.metrics else {},
1662
+ "climate_metrics": recommended_route.climate_metrics.to_dict() if recommended_route.climate_metrics else {},
1663
+ "climate_aware": prefer_safe and self.has_climate_data,
1664
+ }
1665
+
1666
+ return result, map_data
1667
+
1668
+ def list_resources(self, resource_type: str = "", category: str = "") -> dict[str, Any]:
1669
+ """List available resources with optional filtering."""
1670
+ if self.resources_df is None:
1671
+ return {"error": "Resources not loaded"}
1672
+
1673
+ df = self.resources_df.copy()
1674
+ if category:
1675
+ df = df[df["category"] == category]
1676
+ if resource_type:
1677
+ df = df[df["type"] == resource_type]
1678
+
1679
+ summary = df.groupby("type").agg({"name": "count", "category": "first"}).rename(
1680
+ columns={"name": "count"}
1681
+ ).to_dict("index")
1682
+
1683
+ return {
1684
+ "total_count": len(df),
1685
+ "by_type": summary,
1686
+ "resources": df[["name", "type", "category", "lat", "lon"]].to_dict("records")[:20]
1687
+ }
1688
+
1689
+ # -------------------------------------------------------------------------
1690
+ # Isochrone Generation (reachable area within X minutes)
1691
+ # -------------------------------------------------------------------------
1692
+
1693
+ def generate_isochrone(
1694
+ self,
1695
+ origin_lat: float,
1696
+ origin_lon: float,
1697
+ time_limits: list[int] = None,
1698
+ resource_types: list[str] = None
1699
+ ) -> IsochroneResult:
1700
+ """
1701
+ Generate isochrones showing areas reachable within given time limits.
1702
+
1703
+ Uses igraph SSSP if available for better performance, otherwise NetworkX.
1704
+
1705
+ Args:
1706
+ origin_lat: Origin latitude
1707
+ origin_lon: Origin longitude
1708
+ time_limits: List of time limits in minutes (default: [5, 10, 15])
1709
+ resource_types: Optional filter for resources to include
1710
+
1711
+ Returns:
1712
+ IsochroneResult with polygon boundaries and resources within reach
1713
+ """
1714
+ if not self._loaded:
1715
+ return IsochroneResult(success=False, error="Engine not loaded")
1716
+
1717
+ if time_limits is None:
1718
+ time_limits = [5, 10, 15]
1719
+
1720
+ time_limits = sorted(time_limits)
1721
+ max_time = max(time_limits)
1722
+ max_distance = max_time * WALK_SPEED_M_PER_MIN
1723
+
1724
+ try:
1725
+ origin_node = self._get_nearest_node(origin_lat, origin_lon)
1726
+ except Exception as e:
1727
+ return IsochroneResult(success=False, error=f"Could not locate origin: {e}")
1728
+
1729
+ # Compute distances from origin to all reachable nodes
1730
+ if self.use_igraph and self.ig_graph is not None:
1731
+ # Use igraph SSSP (faster for large graphs)
1732
+ node_distances = self._compute_sssp_igraph(origin_node, max_distance)
1733
+ else:
1734
+ # Use NetworkX Dijkstra
1735
+ node_distances = self._compute_sssp_networkx(origin_node, max_distance)
1736
+
1737
+ # Build isochrone polygons for each time limit
1738
+ isochrones = []
1739
+ for time_min in time_limits:
1740
+ distance_limit = time_min * WALK_SPEED_M_PER_MIN
1741
+ reachable_nodes = [n for n, d in node_distances.items() if d <= distance_limit]
1742
+
1743
+ if not reachable_nodes:
1744
+ continue
1745
+
1746
+ # Get convex hull of reachable nodes
1747
+ polygon_coords = self._nodes_to_polygon(reachable_nodes)
1748
+
1749
+ isochrones.append({
1750
+ "time_min": time_min,
1751
+ "polygon_coords": polygon_coords,
1752
+ "color": ISOCHRONE_COLORS.get(time_min, "#6366f1"),
1753
+ "node_count": len(reachable_nodes),
1754
+ })
1755
+
1756
+ # Find resources within the max isochrone
1757
+ resources_within = []
1758
+ if self.resources_df is not None:
1759
+ max_distance_nodes = {n for n, d in node_distances.items() if d <= max_distance}
1760
+
1761
+ for _, row in self.resources_df.iterrows():
1762
+ if resource_types and row["type"] not in resource_types:
1763
+ continue
1764
+
1765
+ try:
1766
+ resource_node = self._get_nearest_node(row["lat"], row["lon"])
1767
+ if resource_node in max_distance_nodes:
1768
+ dist = node_distances.get(resource_node, float("inf"))
1769
+ time_to_reach = dist / WALK_SPEED_M_PER_MIN
1770
+ resources_within.append({
1771
+ "name": row["name"],
1772
+ "type": row["type"],
1773
+ "category": row.get("category", ""),
1774
+ "lat": row["lat"],
1775
+ "lon": row["lon"],
1776
+ "distance_meters": round(dist, 1),
1777
+ "walking_time_minutes": round(time_to_reach, 1),
1778
+ })
1779
+ except Exception:
1780
+ continue
1781
+
1782
+ # Sort by distance
1783
+ resources_within.sort(key=lambda x: x["distance_meters"])
1784
+
1785
+ return IsochroneResult(
1786
+ success=True,
1787
+ origin=(origin_lat, origin_lon),
1788
+ isochrones=isochrones,
1789
+ resources_within=resources_within[:20], # Limit for display
1790
+ )
1791
+
1792
+ def _compute_sssp_igraph(self, origin_node: int, max_distance: float) -> dict[int, float]:
1793
+ """Compute single-source shortest paths using igraph."""
1794
+ ig_origin = self.ig_node_map.get(origin_node)
1795
+ if ig_origin is None:
1796
+ return {}
1797
+
1798
+ # Run Dijkstra from origin using igraph's shortest_paths
1799
+ all_distances = self.ig_graph.distances(source=ig_origin, weights="weight", mode="out")[0]
1800
+
1801
+ # Convert back to NetworkX node IDs
1802
+ distances = {}
1803
+ for ig_node, dist in enumerate(all_distances):
1804
+ if dist != float("inf") and dist <= max_distance:
1805
+ nx_node = self.ig_reverse_map.get(ig_node)
1806
+ if nx_node is not None:
1807
+ distances[nx_node] = dist
1808
+
1809
+ return distances
1810
+
1811
+ def _compute_sssp_networkx(self, origin_node: int, max_distance: float) -> dict[int, float]:
1812
+ """Compute single-source shortest paths using NetworkX."""
1813
+ try:
1814
+ # Use cutoff for efficiency
1815
+ lengths = nx.single_source_dijkstra_path_length(
1816
+ self.G, origin_node, cutoff=max_distance, weight="length"
1817
+ )
1818
+ return dict(lengths)
1819
+ except Exception:
1820
+ return {}
1821
+
1822
+ def _nodes_to_polygon(self, nodes: list[int]) -> list[tuple[float, float]]:
1823
+ """Convert a set of nodes to a convex hull polygon in lat/lon."""
1824
+ if len(nodes) < 3:
1825
+ return []
1826
+
1827
+ # Get coordinates
1828
+ coords = []
1829
+ for node in nodes:
1830
+ x = self.G.nodes[node].get("x", 0)
1831
+ y = self.G.nodes[node].get("y", 0)
1832
+ coords.append([x, y])
1833
+
1834
+ coords = np.array(coords)
1835
+
1836
+ # Compute convex hull
1837
+ try:
1838
+ from scipy.spatial import ConvexHull
1839
+ hull = ConvexHull(coords)
1840
+ hull_points = coords[hull.vertices]
1841
+
1842
+ # Convert to lat/lon
1843
+ if "crs" in self.G.graph and self.G.graph["crs"] != "EPSG:4326":
1844
+ import pyproj
1845
+ transformer = pyproj.Transformer.from_crs(
1846
+ self.G.graph["crs"], "EPSG:4326", always_xy=True
1847
+ )
1848
+ result = []
1849
+ for x, y in hull_points:
1850
+ lon, lat = transformer.transform(x, y)
1851
+ result.append((lat, lon))
1852
+ # Close the polygon
1853
+ result.append(result[0])
1854
+ return result
1855
+ else:
1856
+ result = [(y, x) for x, y in hull_points]
1857
+ result.append(result[0])
1858
+ return result
1859
+ except Exception:
1860
+ return []
1861
+
1862
+ # -------------------------------------------------------------------------
1863
+ # Find Along Route (POI discovery along a route corridor)
1864
+ # -------------------------------------------------------------------------
1865
+
1866
+ def find_along_route(
1867
+ self,
1868
+ origin_lat: float,
1869
+ origin_lon: float,
1870
+ dest_lat: float,
1871
+ dest_lon: float,
1872
+ buffer_meters: float = 100,
1873
+ resource_types: list[str] = None
1874
+ ) -> AlongRouteResult:
1875
+ """
1876
+ Find resources/POIs along a route corridor.
1877
+
1878
+ Args:
1879
+ origin_lat, origin_lon: Route start
1880
+ dest_lat, dest_lon: Route end
1881
+ buffer_meters: Width of corridor to search (default 100m)
1882
+ resource_types: Optional filter for resource types
1883
+
1884
+ Returns:
1885
+ AlongRouteResult with POIs found along the route
1886
+ """
1887
+ if not self._loaded:
1888
+ return AlongRouteResult(success=False, error="Engine not loaded")
1889
+
1890
+ if self.resources_df is None:
1891
+ return AlongRouteResult(success=False, error="Resources not loaded")
1892
+
1893
+ # Compute the route first
1894
+ routing_result = self.compute_routes(origin_lat, origin_lon, dest_lat, dest_lon)
1895
+ if not routing_result.success:
1896
+ return AlongRouteResult(success=False, error=routing_result.error)
1897
+
1898
+ # Get the recommended route
1899
+ recommended = next(
1900
+ (r for r in routing_result.alternatives if r.name == routing_result.recommended),
1901
+ routing_result.alternatives[0]
1902
+ )
1903
+
1904
+ route_coords = recommended.coords
1905
+ if not route_coords:
1906
+ return AlongRouteResult(success=False, error="No route coordinates")
1907
+
1908
+ # Convert route to projected coordinates for distance calculations
1909
+ if "crs" in self.G.graph and self.G.graph["crs"] != "EPSG:4326":
1910
+ import pyproj
1911
+ transformer = pyproj.Transformer.from_crs(
1912
+ "EPSG:4326", self.G.graph["crs"], always_xy=True
1913
+ )
1914
+ projected_route = []
1915
+ for lat, lon in route_coords:
1916
+ x, y = transformer.transform(lon, lat)
1917
+ projected_route.append([x, y])
1918
+ projected_route = np.array(projected_route)
1919
+ else:
1920
+ projected_route = np.array([[lon, lat] for lat, lon in route_coords])
1921
+
1922
+ # Find resources within buffer distance of route
1923
+ pois_found = []
1924
+
1925
+ for _, row in self.resources_df.iterrows():
1926
+ if resource_types and row["type"] not in resource_types:
1927
+ continue
1928
+
1929
+ # Get resource in projected coordinates
1930
+ if "crs" in self.G.graph and self.G.graph["crs"] != "EPSG:4326":
1931
+ x, y = transformer.transform(row["lon"], row["lat"])
1932
+ point = np.array([x, y])
1933
+ else:
1934
+ point = np.array([row["lon"], row["lat"]])
1935
+
1936
+ # Calculate minimum distance to route
1937
+ min_dist = self._point_to_polyline_distance(point, projected_route)
1938
+
1939
+ if min_dist <= buffer_meters:
1940
+ # Find which segment of the route it's nearest to (for ordering)
1941
+ segment_idx = self._nearest_segment_index(point, projected_route)
1942
+
1943
+ pois_found.append({
1944
+ "name": row["name"],
1945
+ "type": row["type"],
1946
+ "category": row.get("category", ""),
1947
+ "lat": row["lat"],
1948
+ "lon": row["lon"],
1949
+ "distance_from_route_m": round(min_dist, 1),
1950
+ "route_segment": segment_idx,
1951
+ })
1952
+
1953
+ # Sort by route segment (so POIs appear in order along the route)
1954
+ pois_found.sort(key=lambda x: x["route_segment"])
1955
+
1956
+ return AlongRouteResult(
1957
+ success=True,
1958
+ route_coords=route_coords,
1959
+ pois_found=pois_found,
1960
+ origin=(origin_lat, origin_lon),
1961
+ destination=(dest_lat, dest_lon),
1962
+ buffer_meters=buffer_meters,
1963
+ climate_metrics=recommended.climate_metrics,
1964
+ )
1965
+
1966
+ def _point_to_polyline_distance(self, point: np.ndarray, polyline: np.ndarray) -> float:
1967
+ """Calculate minimum distance from a point to a polyline."""
1968
+ min_dist = float("inf")
1969
+
1970
+ for i in range(len(polyline) - 1):
1971
+ seg_start = polyline[i]
1972
+ seg_end = polyline[i + 1]
1973
+
1974
+ # Vector from start to end
1975
+ seg_vec = seg_end - seg_start
1976
+ seg_len_sq = np.dot(seg_vec, seg_vec)
1977
+
1978
+ if seg_len_sq == 0:
1979
+ # Segment is a point
1980
+ dist = np.linalg.norm(point - seg_start)
1981
+ else:
1982
+ # Project point onto segment
1983
+ t = max(0, min(1, np.dot(point - seg_start, seg_vec) / seg_len_sq))
1984
+ projection = seg_start + t * seg_vec
1985
+ dist = np.linalg.norm(point - projection)
1986
+
1987
+ min_dist = min(min_dist, dist)
1988
+
1989
+ return min_dist
1990
+
1991
+ def _nearest_segment_index(self, point: np.ndarray, polyline: np.ndarray) -> int:
1992
+ """Find which segment of the polyline is nearest to the point."""
1993
+ min_dist = float("inf")
1994
+ nearest_idx = 0
1995
+
1996
+ for i in range(len(polyline) - 1):
1997
+ seg_start = polyline[i]
1998
+ seg_end = polyline[i + 1]
1999
+ seg_vec = seg_end - seg_start
2000
+ seg_len_sq = np.dot(seg_vec, seg_vec)
2001
+
2002
+ if seg_len_sq == 0:
2003
+ dist = np.linalg.norm(point - seg_start)
2004
+ else:
2005
+ t = max(0, min(1, np.dot(point - seg_start, seg_vec) / seg_len_sq))
2006
+ projection = seg_start + t * seg_vec
2007
+ dist = np.linalg.norm(point - projection)
2008
+
2009
+ if dist < min_dist:
2010
+ min_dist = dist
2011
+ nearest_idx = i
2012
+
2013
+ return nearest_idx
2014
+
2015
+ # -------------------------------------------------------------------------
2016
+ # Geocoding
2017
+ # -------------------------------------------------------------------------
2018
+
2019
+ def geocode_query(self, query: str) -> tuple[str, dict]:
2020
+ """Resolve place names in query to coordinates."""
2021
+ geocode_info = {}
2022
+ modified = query
2023
+ query_lower = query.lower()
2024
+
2025
+ # Sort by name length for greedy matching
2026
+ sorted_places = sorted(self.known_places.items(), key=lambda x: -len(x[0]))
2027
+ used_spans = []
2028
+
2029
+ for name_lower, info in sorted_places:
2030
+ pattern = r"\b" + re.escape(name_lower) + r"\b"
2031
+ for match in re.finditer(pattern, query_lower):
2032
+ start, end = match.span()
2033
+ overlaps = any(not (end <= us or start >= ue) for us, ue in used_spans)
2034
+ if not overlaps:
2035
+ geocode_info[info["name"]] = {
2036
+ "lat": info["lat"],
2037
+ "lon": info["lon"],
2038
+ "name": info["name"]
2039
+ }
2040
+ original_text = query[start:end]
2041
+ modified = re.compile(re.escape(original_text), re.IGNORECASE).sub(
2042
+ f"(lat {info['lat']:.6f}, lon {info['lon']:.6f})",
2043
+ modified,
2044
+ count=1
2045
+ )
2046
+ used_spans.append((start, end))
2047
+
2048
+ return modified, geocode_info
2049
+
2050
+
2051
+ # =============================================================================
2052
+ # Tool Executor (bridges LLM output to engine)
2053
+ # =============================================================================
2054
+
2055
+ def _safe_str(val, default: str = "") -> str:
2056
+ if val is None:
2057
+ return default
2058
+ if isinstance(val, list):
2059
+ return str(val[0]) if val else default
2060
+ return str(val)
2061
+
2062
+
2063
+ def _safe_float(val, default: float) -> float:
2064
+ if val is None:
2065
+ return default
2066
+ if isinstance(val, list):
2067
+ val = val[0] if val else default
2068
+ try:
2069
+ return float(val)
2070
+ except (ValueError, TypeError):
2071
+ return default
2072
+
2073
+
2074
+ def _parse_climate_params(args: dict) -> ClimateWeightParams:
2075
+ """Extract climate weight parameters from tool args.
2076
+
2077
+ The LLM can pass these parameters based on user context:
2078
+ - flood_penalty_deep: 5.0 default, 10.0 for active flooding
2079
+ - flood_penalty_shallow: 2.0 default, 4.0 for flooding conditions
2080
+ - heat_factor: 0.3 default, 0.5 for hot days
2081
+ - shade_factor: 0.3 default, 0.5 for shade-seeking
2082
+ - aqi_factor: 0.1 default, 0.5 for respiratory concerns
2083
+ - grade_factor: 0.2 default, 0.5 for elderly/mobility-impaired users
2084
+ """
2085
+ return ClimateWeightParams(
2086
+ flood_penalty_deep=_safe_float(args.get("flood_penalty_deep"), 5.0),
2087
+ flood_penalty_shallow=_safe_float(args.get("flood_penalty_shallow"), 2.0),
2088
+ heat_factor=_safe_float(args.get("heat_factor"), 0.3),
2089
+ shade_factor=_safe_float(args.get("shade_factor"), 0.3),
2090
+ aqi_factor=_safe_float(args.get("aqi_factor"), 0.1),
2091
+ grade_factor=_safe_float(args.get("grade_factor"), 0.2),
2092
+ )
2093
+
2094
+
2095
+ def execute_tool(
2096
+ tool_name: str,
2097
+ args: dict,
2098
+ engine: RoutingEngine
2099
+ ) -> tuple[dict[str, Any], dict | None]:
2100
+ """Execute a tool by name using the routing engine.
2101
+
2102
+ Climate weight parameters can be passed in args and will be used for
2103
+ runtime weight calculation when routing.
2104
+ """
2105
+ # Parse climate parameters from args (LLM can set these based on context)
2106
+ climate_params = _parse_climate_params(args)
2107
+
2108
+ if tool_name == "list_resources":
2109
+ result = engine.list_resources(
2110
+ resource_type=_safe_str(args.get("resource_type"), ""),
2111
+ category=_safe_str(args.get("category"), "")
2112
+ )
2113
+ return result, None
2114
+
2115
+ elif tool_name == "find_nearest":
2116
+ lat = args.get("lat") or args.get("origin_lat")
2117
+ lon = args.get("lon") or args.get("origin_lon")
2118
+ return engine.find_nearest_resource(
2119
+ resource_type=_safe_str(args.get("resource_type"), ""),
2120
+ origin_lat=_safe_float(lat, BROWNSVILLE_CENTER["lat"]),
2121
+ origin_lon=_safe_float(lon, BROWNSVILLE_CENTER["lon"]),
2122
+ climate_params=climate_params
2123
+ )
2124
+
2125
+ elif tool_name == "calculate_route":
2126
+ # Check for routing_mode parameter
2127
+ routing_mode = _safe_str(args.get("routing_mode"), "safe")
2128
+
2129
+ result = engine.compute_routes(
2130
+ origin_lat=_safe_float(args.get("start_lat") or args.get("origin_lat"), BROWNSVILLE_CENTER["lat"]),
2131
+ origin_lon=_safe_float(args.get("start_lon") or args.get("origin_lon"), BROWNSVILLE_CENTER["lon"]),
2132
+ dest_lat=_safe_float(args.get("end_lat") or args.get("dest_lat"), BROWNSVILLE_CENTER["lat"]),
2133
+ dest_lon=_safe_float(args.get("end_lon") or args.get("dest_lon"), BROWNSVILLE_CENTER["lon"]),
2134
+ dest_name=_safe_str(args.get("dest_name"), "Destination"),
2135
+ climate_params=climate_params
2136
+ )
2137
+ return result.to_dict(), result.to_map_data()
2138
+
2139
+ elif tool_name == "generate_isochrone":
2140
+ # Parse time limits - handle various formats like "10", "10 minutes", "5, 10, 15"
2141
+ time_limits = args.get("time_limits", [5, 10, 15])
2142
+ if isinstance(time_limits, str):
2143
+ # Split by comma and extract numeric values
2144
+ parsed = []
2145
+ for x in time_limits.split(","):
2146
+ # Extract just the numeric part (handles "10 minutes", "15 min", etc.)
2147
+ import re
2148
+ match = re.search(r'(\d+)', x.strip())
2149
+ if match:
2150
+ parsed.append(int(match.group(1)))
2151
+ time_limits = parsed if parsed else [5, 10, 15]
2152
+ elif isinstance(time_limits, (int, float)):
2153
+ time_limits = [int(time_limits)]
2154
+ elif isinstance(time_limits, list):
2155
+ # Ensure all elements are integers (LLM may return strings like ["5", "10"])
2156
+ time_limits = [int(x) if isinstance(x, (int, float, str)) and str(x).isdigit() else 10 for x in time_limits]
2157
+ if not time_limits:
2158
+ time_limits = [5, 10, 15]
2159
+
2160
+ # Parse resource types filter
2161
+ resource_types = args.get("resource_types")
2162
+ if isinstance(resource_types, str):
2163
+ resource_types = [x.strip() for x in resource_types.split(",")]
2164
+
2165
+ lat = args.get("lat") or args.get("origin_lat")
2166
+ lon = args.get("lon") or args.get("origin_lon")
2167
+
2168
+ result = engine.generate_isochrone(
2169
+ origin_lat=_safe_float(lat, BROWNSVILLE_CENTER["lat"]),
2170
+ origin_lon=_safe_float(lon, BROWNSVILLE_CENTER["lon"]),
2171
+ time_limits=time_limits,
2172
+ resource_types=resource_types
2173
+ )
2174
+ return result.to_dict(), result.to_map_data()
2175
+
2176
+ elif tool_name == "find_along_route":
2177
+ # Parse resource types filter
2178
+ resource_types = args.get("resource_types")
2179
+ if isinstance(resource_types, str):
2180
+ resource_types = [x.strip() for x in resource_types.split(",")]
2181
+
2182
+ result = engine.find_along_route(
2183
+ origin_lat=_safe_float(args.get("start_lat") or args.get("origin_lat"), BROWNSVILLE_CENTER["lat"]),
2184
+ origin_lon=_safe_float(args.get("start_lon") or args.get("origin_lon"), BROWNSVILLE_CENTER["lon"]),
2185
+ dest_lat=_safe_float(args.get("end_lat") or args.get("dest_lat"), BROWNSVILLE_CENTER["lat"]),
2186
+ dest_lon=_safe_float(args.get("end_lon") or args.get("dest_lon"), BROWNSVILLE_CENTER["lon"]),
2187
+ buffer_meters=_safe_float(args.get("buffer_meters"), 100),
2188
+ resource_types=resource_types
2189
+ )
2190
+ return result.to_dict(), result.to_map_data()
2191
+
2192
+ elif tool_name == "compare_routes":
2193
+ # Compare fastest vs safest (climate-aware) routes
2194
+ origin_lat = _safe_float(args.get("start_lat") or args.get("origin_lat"), BROWNSVILLE_CENTER["lat"])
2195
+ origin_lon = _safe_float(args.get("start_lon") or args.get("origin_lon"), BROWNSVILLE_CENTER["lon"])
2196
+ dest_lat = _safe_float(args.get("end_lat") or args.get("dest_lat"), BROWNSVILLE_CENTER["lat"])
2197
+ dest_lon = _safe_float(args.get("end_lon") or args.get("dest_lon"), BROWNSVILLE_CENTER["lon"])
2198
+
2199
+ result = engine.compare_routes(
2200
+ origin_coords=(origin_lat, origin_lon),
2201
+ dest_coords=(dest_lat, dest_lon),
2202
+ dest_name=_safe_str(args.get("dest_name"), "Destination"),
2203
+ climate_params=climate_params
2204
+ )
2205
+ return result.to_dict(), result.to_map_data()
2206
+
2207
+ elif tool_name == "climate_route":
2208
+ # Single route with mode selection and climate parameters
2209
+ origin_lat = _safe_float(args.get("start_lat") or args.get("origin_lat"), BROWNSVILLE_CENTER["lat"])
2210
+ origin_lon = _safe_float(args.get("start_lon") or args.get("origin_lon"), BROWNSVILLE_CENTER["lon"])
2211
+ dest_lat = _safe_float(args.get("end_lat") or args.get("dest_lat"), BROWNSVILLE_CENTER["lat"])
2212
+ dest_lon = _safe_float(args.get("end_lon") or args.get("dest_lon"), BROWNSVILLE_CENTER["lon"])
2213
+ mode = _safe_str(args.get("mode") or args.get("routing_mode"), "safest")
2214
+
2215
+ result = engine.route(
2216
+ origin_coords=(origin_lat, origin_lon),
2217
+ dest_coords=(dest_lat, dest_lon),
2218
+ mode=mode,
2219
+ climate_params=climate_params
2220
+ )
2221
+
2222
+ if result is None:
2223
+ return {"error": "No path found"}, None
2224
+
2225
+ map_data = {
2226
+ "routes": [{
2227
+ "coords": result.coords,
2228
+ "color": result.color,
2229
+ "label": result.label,
2230
+ "name": result.name,
2231
+ }],
2232
+ "origin": [origin_lat, origin_lon],
2233
+ "destination": [dest_lat, dest_lon],
2234
+ }
2235
+
2236
+ return result.to_dict(), map_data
2237
+
2238
+ else:
2239
+ return {"error": f"Unknown tool: {tool_name}"}, None
core/tools.py ADDED
@@ -0,0 +1,884 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Routing tools for the Emergency Routing Assistant.
3
+
4
+ Uses dream-meridian pattern:
5
+ 1. Geocode place names BEFORE sending to LLM
6
+ 2. LLM outputs simple JSON tool call
7
+ 3. Execute tool and return result
8
+ """
9
+
10
+ import os
11
+ import re
12
+ import networkx as nx
13
+ import pandas as pd
14
+ import geopandas as gpd
15
+ import osmnx as ox
16
+ import requests
17
+ from typing import Any
18
+
19
+ # Walking speed: ~4.5 km/h = 75 m/min
20
+ WALK_SPEED_M_PER_MIN = 75
21
+
22
+ # Elevation penalty factor for routing (higher = more avoidance of elevation gain)
23
+ ELEVATION_PENALTY_FACTOR = 3.0
24
+
25
+ # Brownsville center and bounds
26
+ BROWNSVILLE_CENTER = {"lat": 40.6594, "lon": -73.9126}
27
+ BROWNSVILLE_BOUNDS = {
28
+ "min_lat": 40.64,
29
+ "max_lat": 40.68,
30
+ "min_lon": -73.93,
31
+ "max_lon": -73.89
32
+ }
33
+
34
+ # Known places - loaded from data/brownsville/places.csv
35
+ # These are matched BEFORE sending query to LLM
36
+ KNOWN_PLACES = {}
37
+
38
+
39
+ def load_known_places():
40
+ """Load known places from the places.csv file."""
41
+ global KNOWN_PLACES
42
+
43
+ # Look in project root's data directory (one level up from core/)
44
+ places_path = os.path.join(os.path.dirname(__file__), "..", "data", "brownsville", "places.csv")
45
+
46
+ if os.path.exists(places_path):
47
+ try:
48
+ df = pd.read_csv(places_path)
49
+ for _, row in df.iterrows():
50
+ name_lower = row['name_lower']
51
+ KNOWN_PLACES[name_lower] = {
52
+ "lat": row['lat'],
53
+ "lon": row['lon'],
54
+ "name": row['name']
55
+ }
56
+ print(f"Loaded {len(KNOWN_PLACES)} places for geocoding")
57
+ except Exception as e:
58
+ print(f"Warning: Could not load places: {e}")
59
+ else:
60
+ print(f"Warning: places.csv not found at {places_path}")
61
+
62
+
63
+ # Load places on module import
64
+ load_known_places()
65
+
66
+ # Intersection patterns to match
67
+ INTERSECTION_PATTERN = re.compile(
68
+ r"(\w+(?:\s+\w+)?)\s+(?:and|&|at)\s+(\w+(?:\s+\w+)?)",
69
+ re.IGNORECASE
70
+ )
71
+
72
+ # ============================================================================
73
+ # Geocoding (runs BEFORE LLM)
74
+ # ============================================================================
75
+
76
+ def find_place_in_query(query: str) -> list[tuple[str, dict]]:
77
+ """
78
+ Find known place names in a query.
79
+ Returns list of (matched_text, place_info) tuples.
80
+ Matches longest places first.
81
+ """
82
+ query_lower = query.lower()
83
+ matches = []
84
+ used_spans = []
85
+
86
+ # Sort by name length (longest first) for greedy matching
87
+ sorted_places = sorted(KNOWN_PLACES.items(), key=lambda x: -len(x[0]))
88
+
89
+ for name_lower, info in sorted_places:
90
+ # Use word boundaries
91
+ pattern = r"\b" + re.escape(name_lower) + r"\b"
92
+ for match in re.finditer(pattern, query_lower):
93
+ start, end = match.span()
94
+
95
+ # Check overlap with existing matches
96
+ overlaps = any(
97
+ not (end <= us or start >= ue) for us, ue in used_spans
98
+ )
99
+
100
+ if not overlaps:
101
+ original_text = query[start:end]
102
+ matches.append((original_text, info))
103
+ used_spans.append((start, end))
104
+
105
+ return matches
106
+
107
+
108
+ def geocode_nominatim(place_name: str) -> dict | None:
109
+ """Fallback: geocode using Nominatim API."""
110
+ try:
111
+ search_query = f"{place_name}, Brownsville, Brooklyn, NY"
112
+ url = "https://nominatim.openstreetmap.org/search"
113
+ params = {
114
+ "q": search_query,
115
+ "format": "json",
116
+ "limit": 3,
117
+ "viewbox": f"{BROWNSVILLE_BOUNDS['min_lon']},{BROWNSVILLE_BOUNDS['max_lat']},{BROWNSVILLE_BOUNDS['max_lon']},{BROWNSVILLE_BOUNDS['min_lat']}",
118
+ "bounded": 0
119
+ }
120
+ headers = {"User-Agent": "BrownsvilleEmergencyApp/1.0"}
121
+
122
+ response = requests.get(url, params=params, headers=headers, timeout=5)
123
+ results = response.json()
124
+
125
+ if results:
126
+ # Find result nearest to Brownsville
127
+ for r in results:
128
+ lat, lon = float(r["lat"]), float(r["lon"])
129
+ if (BROWNSVILLE_BOUNDS["min_lat"] - 0.02 <= lat <= BROWNSVILLE_BOUNDS["max_lat"] + 0.02 and
130
+ BROWNSVILLE_BOUNDS["min_lon"] - 0.02 <= lon <= BROWNSVILLE_BOUNDS["max_lon"] + 0.02):
131
+ return {"name": place_name, "lat": lat, "lon": lon}
132
+ # Fallback to first result
133
+ return {"name": place_name, "lat": float(results[0]["lat"]), "lon": float(results[0]["lon"])}
134
+ except Exception:
135
+ pass
136
+ return None
137
+
138
+
139
+ def geocode_query(query: str, resources_df: pd.DataFrame = None) -> tuple[str, dict]:
140
+ """
141
+ Process query to resolve place names to coordinates BEFORE sending to LLM.
142
+
143
+ Returns:
144
+ tuple: (modified_query, geocode_info)
145
+ - modified_query: Query with place names replaced by coordinates
146
+ - geocode_info: Dict of resolved places
147
+ """
148
+ geocode_info = {}
149
+ modified = query
150
+
151
+ # First try known places
152
+ matches = find_place_in_query(query)
153
+
154
+ for original_text, info in matches:
155
+ geocode_info[info["name"]] = {
156
+ "lat": info["lat"],
157
+ "lon": info["lon"],
158
+ "name": info["name"]
159
+ }
160
+ # Replace in query with coordinates
161
+ pattern = re.compile(re.escape(original_text), re.IGNORECASE)
162
+ modified = pattern.sub(
163
+ f"(lat {info['lat']:.6f}, lon {info['lon']:.6f})",
164
+ modified,
165
+ count=1
166
+ )
167
+
168
+ # If no matches, try to find location phrases and geocode them
169
+ if not matches:
170
+ # Look for "near X", "at X", "to X" patterns
171
+ location_patterns = [
172
+ r"near\s+([A-Za-z][A-Za-z\s]+?)(?:\s+(?:and|&)\s+|\s*$)",
173
+ r"to\s+([A-Za-z][A-Za-z\s]+?)(?:\s+(?:and|&)\s+|\s*$)",
174
+ r"at\s+([A-Za-z][A-Za-z\s]+?)(?:\s+(?:and|&)\s+|\s*$)",
175
+ ]
176
+ for pattern in location_patterns:
177
+ match = re.search(pattern, query, re.IGNORECASE)
178
+ if match:
179
+ place_name = match.group(1).strip()
180
+ # Skip if it's a resource type
181
+ if place_name.lower() not in ["pharmacy", "clinic", "hospital", "school", "library", "fire station", "police"]:
182
+ result = geocode_nominatim(place_name)
183
+ if result:
184
+ geocode_info[result["name"]] = result
185
+ modified = query.replace(
186
+ match.group(0),
187
+ f"near (lat {result['lat']:.6f}, lon {result['lon']:.6f}) "
188
+ )
189
+ break
190
+
191
+ return modified, geocode_info
192
+
193
+
194
+ def _convert_climate_attrs_to_float(G: nx.MultiDiGraph) -> None:
195
+ """Convert climate attributes from strings to floats (GraphML stores all as strings)."""
196
+ climate_attrs = ['flood_risk', 'heat_risk', 'climate_risk', 'climate_weight']
197
+
198
+ for u, v, data in G.edges(data=True):
199
+ for attr in climate_attrs:
200
+ if attr in data and isinstance(data[attr], str):
201
+ try:
202
+ data[attr] = float(data[attr])
203
+ except (ValueError, TypeError):
204
+ data[attr] = 0.0
205
+
206
+
207
+ def load_network_and_resources() -> tuple[nx.MultiDiGraph | None, pd.DataFrame | None, tuple[float, float]]:
208
+ """Load the walking network and resources from saved files."""
209
+ data_dir = os.path.join(os.path.dirname(__file__), "data", "brownsville")
210
+
211
+ # Default center
212
+ center = (40.6594, -73.9126)
213
+
214
+ try:
215
+ # Load walking network
216
+ graphml_path = os.path.join(data_dir, "walking_network_final.graphml")
217
+ if os.path.exists(graphml_path):
218
+ G_walk = ox.load_graphml(graphml_path)
219
+ else:
220
+ # Fallback: load from OSM directly
221
+ from shapely.geometry import box
222
+ brownsville_bbox = box(-73.93, 40.64, -73.89, 40.68)
223
+ G_walk = ox.graph_from_polygon(brownsville_bbox, network_type='walk', simplify=True)
224
+
225
+ # Convert climate attributes from strings to floats
226
+ _convert_climate_attrs_to_float(G_walk)
227
+
228
+ # Project the graph to enable fast nearest_nodes lookup without scikit-learn
229
+ G_walk = ox.project_graph(G_walk)
230
+
231
+ # Load resources
232
+ resources_path = os.path.join(data_dir, "all_resources.csv")
233
+ if os.path.exists(resources_path):
234
+ resources_df = pd.read_csv(resources_path)
235
+ else:
236
+ # Try GeoJSON
237
+ geojson_path = os.path.join(data_dir, "all_resources.geojson")
238
+ if os.path.exists(geojson_path):
239
+ gdf = gpd.read_file(geojson_path)
240
+ resources_df = pd.DataFrame({
241
+ "name": gdf["name"],
242
+ "type": gdf["type"],
243
+ "category": gdf["category"],
244
+ "lat": gdf.geometry.y,
245
+ "lon": gdf.geometry.x
246
+ })
247
+ else:
248
+ resources_df = None
249
+
250
+ return G_walk, resources_df, center
251
+
252
+ except Exception as e:
253
+ print(f"Error loading data: {e}")
254
+ return None, None, center
255
+
256
+
257
+ def get_nearest_node(G: nx.MultiDiGraph, lat: float, lon: float) -> int:
258
+ """Find the nearest network node to a point (handles projected graphs)."""
259
+ # For projected graphs, convert lat/lon to projected coordinates
260
+ if "crs" in G.graph and G.graph["crs"] != "EPSG:4326":
261
+ import pyproj
262
+ transformer = pyproj.Transformer.from_crs("EPSG:4326", G.graph["crs"], always_xy=True)
263
+ x, y = transformer.transform(lon, lat)
264
+ return ox.nearest_nodes(G, x, y)
265
+ return ox.nearest_nodes(G, lon, lat)
266
+
267
+
268
+ def get_route_coords(G: nx.MultiDiGraph, route: list) -> list[tuple[float, float]]:
269
+ """Extract lat/lon coordinates from route nodes (handles projected graphs)."""
270
+ if "crs" in G.graph and G.graph["crs"] != "EPSG:4326":
271
+ import pyproj
272
+ transformer = pyproj.Transformer.from_crs(G.graph["crs"], "EPSG:4326", always_xy=True)
273
+ coords = []
274
+ for node in route:
275
+ x, y = G.nodes[node]["x"], G.nodes[node]["y"]
276
+ lon, lat = transformer.transform(x, y)
277
+ coords.append((lat, lon))
278
+ return coords
279
+ return [(G.nodes[node]["y"], G.nodes[node]["x"]) for node in route]
280
+
281
+
282
+ def compute_route_metrics(G: nx.MultiDiGraph, route: list) -> dict:
283
+ """
284
+ Compute detailed metrics for a route including elevation profile.
285
+
286
+ Returns dict with:
287
+ - elevation_gain_m: Total meters climbed
288
+ - elevation_loss_m: Total meters descended
289
+ - max_elevation_m: Highest point on route
290
+ - min_elevation_m: Lowest point on route
291
+ - avg_grade_pct: Average slope percentage
292
+ - max_grade_pct: Steepest segment
293
+ - difficulty: "flat", "moderate", or "hilly"
294
+ """
295
+ if not route or len(route) < 2:
296
+ return {
297
+ "elevation_gain_m": 0, "elevation_loss_m": 0,
298
+ "max_elevation_m": 0, "min_elevation_m": 0,
299
+ "avg_grade_pct": 0, "max_grade_pct": 0,
300
+ "difficulty": "flat"
301
+ }
302
+
303
+ elevations = []
304
+ grades = []
305
+ elevation_gain = 0
306
+ elevation_loss = 0
307
+
308
+ for i, node in enumerate(route):
309
+ elev = G.nodes[node].get("elevation", 0)
310
+ if elev is None:
311
+ elev = 0
312
+ elevations.append(elev)
313
+
314
+ if i > 0:
315
+ prev_elev = elevations[i-1]
316
+ diff = elev - prev_elev
317
+ if diff > 0:
318
+ elevation_gain += diff
319
+ else:
320
+ elevation_loss += abs(diff)
321
+
322
+ # Get grade from edge if available
323
+ prev_node = route[i-1]
324
+ edge_data = G.get_edge_data(prev_node, node)
325
+ if edge_data:
326
+ # MultiDiGraph returns dict of edges
327
+ first_edge = list(edge_data.values())[0] if isinstance(edge_data, dict) else edge_data
328
+ grade = abs(first_edge.get("grade", 0)) * 100 # Convert to percentage
329
+ grades.append(grade)
330
+
331
+ max_elev = max(elevations) if elevations else 0
332
+ min_elev = min(elevations) if elevations else 0
333
+ avg_grade = sum(grades) / len(grades) if grades else 0
334
+ max_grade = max(grades) if grades else 0
335
+
336
+ # Classify difficulty
337
+ if elevation_gain < 5 and max_grade < 3:
338
+ difficulty = "flat"
339
+ elif elevation_gain < 15 or max_grade < 8:
340
+ difficulty = "moderate"
341
+ else:
342
+ difficulty = "hilly"
343
+
344
+ return {
345
+ "elevation_gain_m": round(elevation_gain, 1),
346
+ "elevation_loss_m": round(elevation_loss, 1),
347
+ "max_elevation_m": round(max_elev, 1),
348
+ "min_elevation_m": round(min_elev, 1),
349
+ "avg_grade_pct": round(avg_grade, 1),
350
+ "max_grade_pct": round(max_grade, 1),
351
+ "difficulty": difficulty
352
+ }
353
+
354
+
355
+ def has_climate_data(G: nx.MultiDiGraph) -> bool:
356
+ """Check if the graph has climate data on edges."""
357
+ sample_edge = next(iter(G.edges(data=True)), None)
358
+ if sample_edge and "climate_weight" in sample_edge[2]:
359
+ return True
360
+ return False
361
+
362
+
363
+ def compute_climate_metrics(G: nx.MultiDiGraph, route: list) -> dict:
364
+ """
365
+ Compute climate risk metrics for a route.
366
+
367
+ Returns dict with:
368
+ - avg_flood_risk: Average flood risk along route (0-1)
369
+ - max_flood_risk: Maximum flood risk encountered
370
+ - avg_heat_risk: Average heat risk along route (0-1)
371
+ - max_heat_risk: Maximum heat risk encountered
372
+ - avg_climate_risk: Combined climate risk (0-1)
373
+ - flood_exposure_m: Meters of route in high flood risk areas (>0.3)
374
+ """
375
+ if not route or len(route) < 2:
376
+ return {
377
+ "avg_flood_risk": 0, "max_flood_risk": 0,
378
+ "avg_heat_risk": 0, "max_heat_risk": 0,
379
+ "avg_climate_risk": 0, "flood_exposure_m": 0
380
+ }
381
+
382
+ flood_risks = []
383
+ heat_risks = []
384
+ climate_risks = []
385
+ flood_exposure = 0.0
386
+
387
+ for i in range(len(route) - 1):
388
+ u, v = route[i], route[i + 1]
389
+ edge_data = G.get_edge_data(u, v)
390
+ if not edge_data:
391
+ continue
392
+
393
+ # Get first edge (MultiDiGraph may have multiple)
394
+ if isinstance(edge_data, dict) and 0 in edge_data:
395
+ data = edge_data[0]
396
+ else:
397
+ data = next(iter(edge_data.values())) if isinstance(edge_data, dict) else edge_data
398
+
399
+ flood = float(data.get("flood_risk", 0) or 0)
400
+ heat = float(data.get("heat_risk", 0) or 0)
401
+ climate = float(data.get("climate_risk", 0) or 0)
402
+ length = float(data.get("length", 0) or 0)
403
+
404
+ flood_risks.append(flood)
405
+ heat_risks.append(heat)
406
+ climate_risks.append(climate)
407
+
408
+ if flood > 0.3:
409
+ flood_exposure += length
410
+
411
+ if not climate_risks:
412
+ return {
413
+ "avg_flood_risk": 0, "max_flood_risk": 0,
414
+ "avg_heat_risk": 0, "max_heat_risk": 0,
415
+ "avg_climate_risk": 0, "flood_exposure_m": 0
416
+ }
417
+
418
+ return {
419
+ "avg_flood_risk": round(sum(flood_risks) / len(flood_risks), 3),
420
+ "max_flood_risk": round(max(flood_risks), 3),
421
+ "avg_heat_risk": round(sum(heat_risks) / len(heat_risks), 3),
422
+ "max_heat_risk": round(max(heat_risks), 3),
423
+ "avg_climate_risk": round(sum(climate_risks) / len(climate_risks), 3),
424
+ "flood_exposure_m": round(flood_exposure, 1)
425
+ }
426
+
427
+
428
+ def apply_elevation_weights(G: nx.MultiDiGraph, penalty_factor: float = ELEVATION_PENALTY_FACTOR) -> nx.MultiDiGraph:
429
+ """
430
+ Create a copy of the graph with edge weights adjusted for elevation.
431
+
432
+ Penalizes uphill segments to find routes that minimize climbing.
433
+ """
434
+ G_weighted = G.copy()
435
+
436
+ for u, v, key, data in G_weighted.edges(keys=True, data=True):
437
+ base_length = data.get("length", 1)
438
+
439
+ # Get elevation change
440
+ elev_u = G_weighted.nodes[u].get("elevation", 0) or 0
441
+ elev_v = G_weighted.nodes[v].get("elevation", 0) or 0
442
+ elev_diff = elev_v - elev_u
443
+
444
+ # Only penalize uphill (positive elevation change)
445
+ if elev_diff > 0:
446
+ # Penalty proportional to climb: each meter of climb adds penalty_factor meters equivalent
447
+ penalty = elev_diff * penalty_factor
448
+ data["weighted_length"] = base_length + penalty
449
+ else:
450
+ data["weighted_length"] = base_length
451
+
452
+ return G_weighted
453
+
454
+
455
+
456
+
457
+ def list_resources(
458
+ resources_df: pd.DataFrame,
459
+ category: str = "",
460
+ resource_type: str = ""
461
+ ) -> dict[str, Any]:
462
+ """List available resources with optional filtering."""
463
+ if resources_df is None:
464
+ return {"error": "Resources not loaded"}
465
+
466
+ df = resources_df.copy()
467
+
468
+ # Ensure category and resource_type are strings (LLM might pass lists or other types)
469
+ if isinstance(category, list):
470
+ category = category[0] if category else ""
471
+ if isinstance(resource_type, list):
472
+ resource_type = resource_type[0] if resource_type else ""
473
+
474
+ category = str(category) if category else ""
475
+ resource_type = str(resource_type) if resource_type else ""
476
+
477
+ if category:
478
+ df = df[df["category"] == category]
479
+
480
+ if resource_type:
481
+ df = df[df["type"] == resource_type]
482
+
483
+ # Group by type
484
+ summary = df.groupby("type").agg({
485
+ "name": "count",
486
+ "category": "first"
487
+ }).rename(columns={"name": "count"}).to_dict("index")
488
+
489
+ # List of resources
490
+ resources = df[["name", "type", "category", "lat", "lon"]].to_dict("records")
491
+
492
+ return {
493
+ "total_count": len(df),
494
+ "by_type": summary,
495
+ "resources": resources[:20] # Limit to 20 for display
496
+ }
497
+
498
+
499
+ def find_nearest(
500
+ G: nx.MultiDiGraph,
501
+ resources_df: pd.DataFrame,
502
+ resource_type: str,
503
+ origin_lat: float = 40.6594,
504
+ origin_lon: float = -73.9126,
505
+ prefer_flat: bool = True,
506
+ prefer_safe: bool = True
507
+ ) -> tuple[dict[str, Any], dict | None]:
508
+ """
509
+ Find the nearest resource of a given type with climate and elevation-aware routing.
510
+
511
+ Args:
512
+ G: Walking network graph
513
+ resources_df: DataFrame of resources
514
+ resource_type: Type of resource to find
515
+ origin_lat: Origin latitude
516
+ origin_lon: Origin longitude
517
+ prefer_flat: If True, prefer routes with less elevation gain
518
+ prefer_safe: If True and climate data available, prefer climate-safe routes
519
+ """
520
+ if resources_df is None:
521
+ return {"error": "Resources not loaded"}, None
522
+
523
+ # Ensure resource_type is a string
524
+ if isinstance(resource_type, list):
525
+ resource_type = resource_type[0] if resource_type else ""
526
+ resource_type = str(resource_type) if resource_type else ""
527
+
528
+ if not resource_type:
529
+ return {"error": "resource_type is required"}, None
530
+
531
+ # Filter by type
532
+ candidates = resources_df[resources_df["type"] == resource_type]
533
+
534
+ if len(candidates) == 0:
535
+ return {"error": f"No resources of type '{resource_type}' found"}, None
536
+
537
+ # Get origin node
538
+ try:
539
+ origin_node = get_nearest_node(G, origin_lat, origin_lon)
540
+ except Exception as e:
541
+ return {"error": f"Could not find origin on network: {e}"}, None
542
+
543
+ # Choose routing strategy based on climate data availability
544
+ graph_has_climate = has_climate_data(G)
545
+
546
+ if prefer_safe and graph_has_climate:
547
+ # Use climate-aware routing (uses pre-computed climate_weight)
548
+ G_routing = G
549
+ weight_key = "climate_weight"
550
+ elif prefer_flat:
551
+ # Fall back to elevation-aware routing
552
+ G_routing = apply_elevation_weights(G)
553
+ weight_key = "weighted_length"
554
+ else:
555
+ G_routing = G
556
+ weight_key = "length"
557
+
558
+ # Find nearest
559
+ best_resource = None
560
+ best_distance = float("inf")
561
+ best_route = None
562
+ best_actual_distance = 0
563
+
564
+ for _, row in candidates.iterrows():
565
+ try:
566
+ dest_node = get_nearest_node(G, row["lat"], row["lon"])
567
+ # Use weighted distance for comparison
568
+ weighted_distance = nx.shortest_path_length(G_routing, origin_node, dest_node, weight=weight_key)
569
+
570
+ if weighted_distance < best_distance:
571
+ best_distance = weighted_distance
572
+ best_resource = row.to_dict()
573
+ best_route = nx.shortest_path(G_routing, origin_node, dest_node, weight=weight_key)
574
+ # Calculate actual distance (unweighted)
575
+ best_actual_distance = nx.shortest_path_length(G, origin_node, dest_node, weight="length")
576
+ except nx.NetworkXNoPath:
577
+ continue
578
+ except Exception:
579
+ continue
580
+
581
+ if best_resource is None:
582
+ return {"error": f"Could not find a route to any {resource_type}"}, None
583
+
584
+ walk_time = best_actual_distance / WALK_SPEED_M_PER_MIN
585
+
586
+ # Compute route metrics
587
+ route_metrics = compute_route_metrics(G, best_route)
588
+
589
+ # Build map data (convert projected coords to lat/lon)
590
+ route_coords = get_route_coords(G, best_route)
591
+
592
+ map_data = {
593
+ "route_coords": route_coords,
594
+ "origin": [origin_lat, origin_lon],
595
+ "destination": [best_resource["lat"], best_resource["lon"]],
596
+ "dest_name": best_resource["name"],
597
+ "distance": best_actual_distance
598
+ }
599
+
600
+ result = {
601
+ "found": True,
602
+ "name": best_resource["name"],
603
+ "type": best_resource["type"],
604
+ "category": best_resource["category"],
605
+ "lat": best_resource["lat"],
606
+ "lon": best_resource["lon"],
607
+ "distance_meters": round(best_actual_distance, 1),
608
+ "walking_time_minutes": round(walk_time, 1),
609
+ "origin": {"lat": origin_lat, "lon": origin_lon},
610
+ "route_metrics": route_metrics
611
+ }
612
+
613
+ # Add climate metrics if available
614
+ if graph_has_climate:
615
+ result["climate_metrics"] = compute_climate_metrics(G, best_route)
616
+ result["climate_aware"] = prefer_safe
617
+
618
+ return result, map_data
619
+
620
+
621
+ def compute_single_route(
622
+ G: nx.MultiDiGraph,
623
+ origin_node: int,
624
+ dest_node: int,
625
+ weight_key: str = "length"
626
+ ) -> dict | None:
627
+ """Compute a single route and its metrics."""
628
+ try:
629
+ route = nx.shortest_path(G, origin_node, dest_node, weight=weight_key)
630
+ # Always compute actual distance using length
631
+ distance = sum(
632
+ G[u][v][0].get("length", 0) for u, v in zip(route[:-1], route[1:])
633
+ )
634
+ walk_time = distance / WALK_SPEED_M_PER_MIN
635
+ route_metrics = compute_route_metrics(G, route)
636
+ route_coords = get_route_coords(G, route)
637
+
638
+ return {
639
+ "route": route,
640
+ "coords": route_coords,
641
+ "distance_m": round(distance, 1),
642
+ "time_min": round(walk_time, 1),
643
+ "metrics": route_metrics
644
+ }
645
+ except nx.NetworkXNoPath:
646
+ return None
647
+ except Exception:
648
+ return None
649
+
650
+
651
+ def compute_alternative_routes(
652
+ G: nx.MultiDiGraph,
653
+ origin_lat: float,
654
+ origin_lon: float,
655
+ dest_lat: float,
656
+ dest_lon: float
657
+ ) -> list[dict]:
658
+ """
659
+ Compute multiple route alternatives with different optimization criteria.
660
+
661
+ Returns list of route options:
662
+ - shortest: Minimum distance
663
+ - flattest: Minimum elevation gain (penalizes uphill)
664
+ - balanced: Compromise between distance and elevation
665
+ - safest: Minimum climate risk (flood + heat) if climate data available
666
+ """
667
+ origin_node = get_nearest_node(G, origin_lat, origin_lon)
668
+ dest_node = get_nearest_node(G, dest_lat, dest_lon)
669
+
670
+ routes = []
671
+ graph_has_climate = has_climate_data(G)
672
+
673
+ # 1. Shortest route (distance only)
674
+ shortest = compute_single_route(G, origin_node, dest_node, weight_key="length")
675
+ if shortest:
676
+ shortest["name"] = "shortest"
677
+ shortest["label"] = "Shortest"
678
+ shortest["color"] = "#3b82f6" # Blue
679
+ if graph_has_climate:
680
+ shortest["climate_metrics"] = compute_climate_metrics(G, shortest["route"])
681
+ routes.append(shortest)
682
+
683
+ # 2. Flattest route (heavy elevation penalty)
684
+ G_flat = apply_elevation_weights(G, penalty_factor=5.0)
685
+ flattest = compute_single_route(G_flat, origin_node, dest_node, weight_key="weighted_length")
686
+ if flattest:
687
+ flattest["name"] = "flattest"
688
+ flattest["label"] = "Flattest"
689
+ flattest["color"] = "#22c55e" # Green
690
+ if graph_has_climate:
691
+ flattest["climate_metrics"] = compute_climate_metrics(G, flattest["route"])
692
+ # Check if it's actually different from shortest
693
+ if not shortest or flattest["coords"] != shortest["coords"]:
694
+ routes.append(flattest)
695
+
696
+ # 3. Balanced route (moderate elevation penalty)
697
+ G_balanced = apply_elevation_weights(G, penalty_factor=2.0)
698
+ balanced = compute_single_route(G_balanced, origin_node, dest_node, weight_key="weighted_length")
699
+ if balanced:
700
+ balanced["name"] = "balanced"
701
+ balanced["label"] = "Balanced"
702
+ balanced["color"] = "#f59e0b" # Amber
703
+ if graph_has_climate:
704
+ balanced["climate_metrics"] = compute_climate_metrics(G, balanced["route"])
705
+ # Check if it's different from both shortest and flattest
706
+ existing_coords = [r["coords"] for r in routes]
707
+ if balanced["coords"] not in existing_coords:
708
+ routes.append(balanced)
709
+
710
+ # 4. Safest route (climate-aware) - only if climate data available
711
+ if graph_has_climate:
712
+ safest = compute_single_route(G, origin_node, dest_node, weight_key="climate_weight")
713
+ if safest:
714
+ safest["name"] = "safest"
715
+ safest["label"] = "Safest (Climate)"
716
+ safest["color"] = "#10b981" # Emerald
717
+ safest["climate_metrics"] = compute_climate_metrics(G, safest["route"])
718
+ # Check if it's different from existing routes
719
+ existing_coords = [r["coords"] for r in routes]
720
+ if safest["coords"] not in existing_coords:
721
+ routes.append(safest)
722
+
723
+ return routes
724
+
725
+
726
+ def calculate_route(
727
+ G: nx.MultiDiGraph,
728
+ origin_lat: float,
729
+ origin_lon: float,
730
+ dest_lat: float,
731
+ dest_lon: float,
732
+ dest_name: str = "Destination",
733
+ prefer_flat: bool = True,
734
+ prefer_safe: bool = True
735
+ ) -> tuple[dict[str, Any], dict | None]:
736
+ """
737
+ Calculate multiple walking routes between two points with different criteria.
738
+
739
+ Args:
740
+ G: Walking network graph
741
+ origin_lat, origin_lon: Origin coordinates
742
+ dest_lat, dest_lon: Destination coordinates
743
+ dest_name: Name of destination
744
+ prefer_flat: Prefer routes with less elevation gain
745
+ prefer_safe: If True and climate data available, recommend safest route
746
+ """
747
+ try:
748
+ # Compute all route alternatives
749
+ alternatives = compute_alternative_routes(G, origin_lat, origin_lon, dest_lat, dest_lon)
750
+
751
+ if not alternatives:
752
+ return {"error": "No path found between origin and destination"}, None
753
+
754
+ # Check if climate data is available
755
+ graph_has_climate = has_climate_data(G)
756
+
757
+ # Pick recommended route based on preferences
758
+ if prefer_safe and graph_has_climate:
759
+ recommended = next((r for r in alternatives if r["name"] == "safest"), alternatives[0])
760
+ elif prefer_flat:
761
+ recommended = next((r for r in alternatives if r["name"] == "flattest"), alternatives[0])
762
+ else:
763
+ recommended = next((r for r in alternatives if r["name"] == "shortest"), alternatives[0])
764
+
765
+ # Build map data with all routes
766
+ map_data = {
767
+ "routes": [
768
+ {
769
+ "coords": r["coords"],
770
+ "color": r["color"],
771
+ "label": r["label"],
772
+ "name": r["name"]
773
+ }
774
+ for r in alternatives
775
+ ],
776
+ "origin": [origin_lat, origin_lon],
777
+ "destination": [dest_lat, dest_lon],
778
+ "dest_name": dest_name,
779
+ "distance": recommended["distance_m"],
780
+ # Keep route_coords for backwards compatibility
781
+ "route_coords": recommended["coords"]
782
+ }
783
+
784
+ # Build alternatives with climate metrics if available
785
+ alt_list = []
786
+ for r in alternatives:
787
+ alt_info = {
788
+ "name": r["name"],
789
+ "label": r["label"],
790
+ "distance_meters": r["distance_m"],
791
+ "walking_time_minutes": r["time_min"],
792
+ "route_metrics": r["metrics"]
793
+ }
794
+ if graph_has_climate and "climate_metrics" in r:
795
+ alt_info["climate_metrics"] = r["climate_metrics"]
796
+ alt_list.append(alt_info)
797
+
798
+ # Build result
799
+ result = {
800
+ "success": True,
801
+ "recommended": recommended["name"],
802
+ "alternatives": alt_list,
803
+ "distance_meters": recommended["distance_m"],
804
+ "walking_time_minutes": recommended["time_min"],
805
+ "route_points": len(recommended["route"]),
806
+ "origin": {"lat": origin_lat, "lon": origin_lon},
807
+ "destination": {"lat": dest_lat, "lon": dest_lon, "name": dest_name},
808
+ "route_metrics": recommended["metrics"]
809
+ }
810
+
811
+ # Add climate metrics to result if available
812
+ if graph_has_climate:
813
+ result["climate_aware"] = True
814
+ if "climate_metrics" in recommended:
815
+ result["climate_metrics"] = recommended["climate_metrics"]
816
+
817
+ return result, map_data
818
+
819
+ except nx.NetworkXNoPath:
820
+ return {"error": "No path found between origin and destination"}, None
821
+ except Exception as e:
822
+ return {"error": f"Route calculation failed: {e}"}, None
823
+
824
+
825
+ def _safe_str(val, default: str = "") -> str:
826
+ """Safely convert a value to string, handling lists."""
827
+ if val is None:
828
+ return default
829
+ if isinstance(val, list):
830
+ return str(val[0]) if val else default
831
+ return str(val)
832
+
833
+
834
+ def _safe_float(val, default: float) -> float:
835
+ """Safely convert a value to float."""
836
+ if val is None:
837
+ return default
838
+ if isinstance(val, list):
839
+ val = val[0] if val else default
840
+ try:
841
+ return float(val)
842
+ except (ValueError, TypeError):
843
+ return default
844
+
845
+
846
+ def execute_tool(
847
+ tool_name: str,
848
+ args: dict,
849
+ G: nx.MultiDiGraph,
850
+ resources_df: pd.DataFrame
851
+ ) -> tuple[dict[str, Any], dict | None]:
852
+ """Execute a tool by name with given arguments."""
853
+ if tool_name == "list_resources":
854
+ result = list_resources(
855
+ resources_df,
856
+ category=_safe_str(args.get("category"), ""),
857
+ resource_type=_safe_str(args.get("resource_type"), "")
858
+ )
859
+ return result, None
860
+
861
+ elif tool_name == "find_nearest":
862
+ # Accept both "lat"/"lon" (from system prompt) and "origin_lat"/"origin_lon"
863
+ lat = args.get("lat") or args.get("origin_lat")
864
+ lon = args.get("lon") or args.get("origin_lon")
865
+ return find_nearest(
866
+ G,
867
+ resources_df,
868
+ resource_type=_safe_str(args.get("resource_type"), ""),
869
+ origin_lat=_safe_float(lat, BROWNSVILLE_CENTER["lat"]),
870
+ origin_lon=_safe_float(lon, BROWNSVILLE_CENTER["lon"])
871
+ )
872
+
873
+ elif tool_name == "calculate_route":
874
+ return calculate_route(
875
+ G,
876
+ origin_lat=_safe_float(args.get("start_lat") or args.get("origin_lat"), BROWNSVILLE_CENTER["lat"]),
877
+ origin_lon=_safe_float(args.get("start_lon") or args.get("origin_lon"), BROWNSVILLE_CENTER["lon"]),
878
+ dest_lat=_safe_float(args.get("end_lat") or args.get("dest_lat"), BROWNSVILLE_CENTER["lat"]),
879
+ dest_lon=_safe_float(args.get("end_lon") or args.get("dest_lon"), BROWNSVILLE_CENTER["lon"]),
880
+ dest_name=_safe_str(args.get("dest_name"), "Destination")
881
+ )
882
+
883
+ else:
884
+ return {"error": f"Unknown tool: {tool_name}"}, None
data/brownsville/all_resources.csv ADDED
The diff for this file is too large to render. See raw diff
 
data/brownsville/graph_cache.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:39d0af50afbc39fdfd05246ee2d4874a6daf978444012d06caaa5c800537618c
3
+ size 2786588
data/brownsville/places.csv ADDED
The diff for this file is too large to render. See raw diff
 
data/brownsville/pois_metadata.json ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "location": "Brownsville, Brooklyn, New York City, USA",
3
+ "center": [
4
+ 40.6594,
5
+ -73.9126
6
+ ],
7
+ "bounds": {
8
+ "min_lat": 40.64,
9
+ "max_lat": 40.68,
10
+ "min_lon": -73.93,
11
+ "max_lon": -73.89
12
+ },
13
+ "created": "2026-01-16 21:37:19",
14
+ "updated": "2026-01-16",
15
+ "enriched": true,
16
+ "stats": {
17
+ "emergency_services": 10,
18
+ "community_resources": 35,
19
+ "places": 25233,
20
+ "total_resources": 1204,
21
+ "bodegas": 168,
22
+ "grocery_stores": 73,
23
+ "supermarkets": 9
24
+ },
25
+ "data_sources": [
26
+ "nyc_facilities",
27
+ "osm",
28
+ "overpass",
29
+ "ny_state_retail"
30
+ ]
31
+ }
data/brownsville/walking_network_final.graphml ADDED
The diff for this file is too large to render. See raw diff
 
data/tool_embeddings.npz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e6324efa3251f3b319f206fb683e298c03d0b58f668a7b16cb3f673cf07b5dc3
3
+ size 8882
requirements.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ streamlit>=1.28.0
2
+ pandas>=2.1.0
3
+ folium>=0.14.0
4
+ streamlit-folium>=0.15.0
5
+ networkx>=3.0
6
+ osmnx>=1.6.0
7
+ ollama>=0.1.0
8
+ geopandas>=0.14.0
9
+ shapely>=2.0.0
10
+ scikit-learn>=1.3.0
11
+ pyproj>=3.6.0
12
+ igraph>=0.11.0
13
+ sentence-transformers>=2.2.0
14
+ numpy>=1.24.0
start.sh ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+
3
+ # Start Ollama server in the background
4
+ ollama serve &
5
+
6
+ # Wait for Ollama to be ready
7
+ echo "Waiting for Ollama to start..."
8
+ until curl -s http://localhost:11434/api/tags > /dev/null 2>&1; do
9
+ sleep 1
10
+ done
11
+ echo "Ollama is ready!"
12
+
13
+ # Pull the model (if not already present)
14
+ echo "Pulling qwen2.5:3b model..."
15
+ ollama pull qwen2.5:3b
16
+
17
+ # Start Streamlit
18
+ echo "Starting Streamlit app..."
19
+ exec streamlit run app.py