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 +46 -0
- README.md +9 -5
- app.py +2231 -0
- core/__init__.py +12 -0
- core/__pycache__/__init__.cpython-313.pyc +0 -0
- core/__pycache__/config.cpython-313.pyc +0 -0
- core/__pycache__/engine.cpython-313.pyc +0 -0
- core/__pycache__/tools.cpython-313.pyc +0 -0
- core/config.py +301 -0
- core/engine.py +2239 -0
- core/tools.py +884 -0
- data/brownsville/all_resources.csv +0 -0
- data/brownsville/graph_cache.pkl +3 -0
- data/brownsville/places.csv +0 -0
- data/brownsville/pois_metadata.json +31 -0
- data/brownsville/walking_network_final.graphml +0 -0
- data/tool_embeddings.npz +3 -0
- requirements.txt +14 -0
- start.sh +19 -0
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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|