Spaces:
Sleeping
Sleeping
Commit
Β·
6ab97fb
1
Parent(s):
2077da5
commit
Browse files- app.py +330 -0
- requirements.txt +23 -0
app.py
ADDED
@@ -0,0 +1,330 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import ee
|
2 |
+
import os
|
3 |
+
import json
|
4 |
+
from google.oauth2 import service_account
|
5 |
+
|
6 |
+
from typing import Dict
|
7 |
+
from datetime import datetime, timedelta
|
8 |
+
from geopy.distance import geodesic
|
9 |
+
import requests
|
10 |
+
import pandas as pd
|
11 |
+
import folium
|
12 |
+
|
13 |
+
import requests
|
14 |
+
from langchain_core.tools import tool
|
15 |
+
from langgraph.prebuilt import create_react_agent
|
16 |
+
from typing_extensions import TypedDict, Literal, Annotated
|
17 |
+
|
18 |
+
from typing import Optional
|
19 |
+
|
20 |
+
from collections import Counter
|
21 |
+
from langgraph.graph import StateGraph, END
|
22 |
+
from typing import TypedDict, Annotated
|
23 |
+
import operator
|
24 |
+
from langchain_core.messages import AnyMessage, SystemMessage, HumanMessage, ToolMessage, AIMessage
|
25 |
+
from langchain_community.tools.tavily_search import TavilySearchResults
|
26 |
+
from langchain.chat_models import init_chat_model
|
27 |
+
from langchain.schema import HumanMessage
|
28 |
+
from langchain.tools import tool
|
29 |
+
import gradio as gr
|
30 |
+
|
31 |
+
|
32 |
+
# Load JSON string from Hugging Face secret (as env variable)
|
33 |
+
SERVICE_KEY_JSON = os.environ.get("GEE_SERVICE_KEY")
|
34 |
+
MISTRAL_API_KEY = os.getenv("MISTRAL_API_KEY")
|
35 |
+
FIRMS_API_KEY = os.getenv("FIRMS_API_KEY")
|
36 |
+
OPENCAGE_API_KEY = os.getenv("OPENCAGE_API_KEY")
|
37 |
+
|
38 |
+
|
39 |
+
if SERVICE_KEY_JSON is None:
|
40 |
+
raise RuntimeError("Missing Earth Engine service account key in environment.")
|
41 |
+
|
42 |
+
# Parse the JSON and create credentials
|
43 |
+
SERVICE_KEY_DICT = json.loads(SERVICE_KEY_JSON)
|
44 |
+
credentials = service_account.Credentials.from_service_account_info(
|
45 |
+
SERVICE_KEY_DICT,
|
46 |
+
scopes=['https://www.googleapis.com/auth/cloud-platform']
|
47 |
+
)
|
48 |
+
|
49 |
+
# Initialize EE
|
50 |
+
ee.Initialize(credentials=credentials)
|
51 |
+
|
52 |
+
|
53 |
+
@tool
|
54 |
+
def get_fire_risk_map(place: str, opencage_key: str, firms_key: str, min_brightness: int = 300, min_confidence: int = 60) -> Optional[str]:
|
55 |
+
"""
|
56 |
+
Returns an HTML string of a folium map showing fire locations, nearest water bodies, and fire stations.
|
57 |
+
Args:
|
58 |
+
place: Name of the place to fetch the bounding box for.
|
59 |
+
opencage_key: opencage API key.
|
60 |
+
firms_key: FIRMS API key.
|
61 |
+
min_brightness (int, optional): Minimum fire brightness to filter on (e.g., 300). Defaults to 300.
|
62 |
+
min_confidence (int, optional): Minimum confidence level (0-100) to filter fire data. Defaults to 60.
|
63 |
+
Returns:
|
64 |
+
Returns an HTML string of a folium map showing fire locations, nearest water bodies, and fire stations.
|
65 |
+
"""
|
66 |
+
try:
|
67 |
+
opencage_key = os.environ["OPENCAGE_API_KEY"]
|
68 |
+
firms_key = os.environ["FIRMS_API_KEY"]
|
69 |
+
|
70 |
+
# Step 1: Get bounding box from OpenCage
|
71 |
+
url = f"https://api.opencagedata.com/geocode/v1/json?q={place}&key={opencage_key}"
|
72 |
+
resp = requests.get(url)
|
73 |
+
resp.raise_for_status()
|
74 |
+
data = resp.json()
|
75 |
+
bounds = data['results'][0]['bounds']
|
76 |
+
bbox = {
|
77 |
+
'north': bounds['northeast']['lat'],
|
78 |
+
'south': bounds['southwest']['lat'],
|
79 |
+
'east': bounds['northeast']['lng'],
|
80 |
+
'west': bounds['southwest']['lng']
|
81 |
+
}
|
82 |
+
bbox_str = f"{bbox['west']},{bbox['south']},{bbox['east']},{bbox['north']}"
|
83 |
+
|
84 |
+
# Step 2: Get FIRMS fire data
|
85 |
+
source = 'MODIS_NRT'
|
86 |
+
url = f'https://firms.modaps.eosdis.nasa.gov/api/area/csv/{firms_key}/{source}/{bbox_str}/3'
|
87 |
+
df = pd.read_csv(url)
|
88 |
+
now = datetime.utcnow()
|
89 |
+
df['acq_datetime'] = pd.to_datetime(
|
90 |
+
df['acq_date'] + ' ' + df['acq_time'].astype(str).str.zfill(4),
|
91 |
+
format="%Y-%m-%d %H%M"
|
92 |
+
)
|
93 |
+
df_fires_filtered = df[
|
94 |
+
(df['brightness'] >= min_brightness) &
|
95 |
+
(df['confidence'] > min_confidence) &
|
96 |
+
(df['acq_datetime'] > (now - timedelta(hours=24)))
|
97 |
+
].dropna(subset=['latitude', 'longitude']).reset_index(drop=True)
|
98 |
+
|
99 |
+
if df_fires_filtered.empty:
|
100 |
+
return "<b>No significant fire activity detected in the past 24 hours for the specified location.</b>"
|
101 |
+
|
102 |
+
|
103 |
+
# Step 3: Water bodies (Earth Engine)
|
104 |
+
west, south, east, north = map(float, bbox_str.split(','))
|
105 |
+
geom = ee.Geometry.BBox(west, south, east, north)
|
106 |
+
water = ee.Image('JRC/GSW1_4/GlobalSurfaceWater').select('occurrence')
|
107 |
+
water_mask = water.gt(50).selfMask()
|
108 |
+
water_clipped = water_mask.clip(geom)
|
109 |
+
sampled_points = water_clipped.stratifiedSample(
|
110 |
+
numPoints=500,
|
111 |
+
classBand='occurrence',
|
112 |
+
region=geom,
|
113 |
+
scale=500,
|
114 |
+
geometries=True,
|
115 |
+
seed=42
|
116 |
+
).getInfo()
|
117 |
+
water_points = [
|
118 |
+
{
|
119 |
+
'latitude': f['geometry']['coordinates'][1],
|
120 |
+
'longitude': f['geometry']['coordinates'][0]
|
121 |
+
}
|
122 |
+
for f in sampled_points['features']
|
123 |
+
]
|
124 |
+
df_water = pd.DataFrame(water_points).dropna().reset_index(drop=True)
|
125 |
+
|
126 |
+
# Step 4: Compute nearest water body
|
127 |
+
def get_nearest_water(fire_lat, fire_lon):
|
128 |
+
fire_point = (fire_lat, fire_lon)
|
129 |
+
min_dist = float('inf')
|
130 |
+
nearest = None
|
131 |
+
for _, row in df_water.iterrows():
|
132 |
+
dist = geodesic(fire_point, (row['latitude'], row['longitude'])).km
|
133 |
+
if dist < min_dist:
|
134 |
+
min_dist = dist
|
135 |
+
nearest = row
|
136 |
+
return pd.Series({
|
137 |
+
'nearest_water_lat': nearest['latitude'],
|
138 |
+
'nearest_water_lon': nearest['longitude'],
|
139 |
+
'distance_km': min_dist
|
140 |
+
})
|
141 |
+
nearest_water = df_fires_filtered.apply(
|
142 |
+
lambda r: get_nearest_water(r['latitude'], r['longitude']), axis=1)
|
143 |
+
|
144 |
+
# Step 5: Fire stations using Overpass API
|
145 |
+
overpass_query = f"""
|
146 |
+
[out:json][timeout:25];
|
147 |
+
(
|
148 |
+
node["amenity"="fire_station"]({south},{west},{north},{east});
|
149 |
+
);
|
150 |
+
out body;
|
151 |
+
"""
|
152 |
+
res = requests.get("http://overpass-api.de/api/interpreter", params={"data": overpass_query})
|
153 |
+
data = res.json()
|
154 |
+
stations = [
|
155 |
+
{
|
156 |
+
'name': e.get('tags', {}).get('name', 'Unnamed Station'),
|
157 |
+
'lat': e['lat'],
|
158 |
+
'lon': e['lon']
|
159 |
+
}
|
160 |
+
for e in data.get('elements', []) if 'lat' in e and 'lon' in e
|
161 |
+
]
|
162 |
+
df_stations = pd.DataFrame(stations).dropna()
|
163 |
+
|
164 |
+
def get_nearest_station(fire_lat, fire_lon):
|
165 |
+
fire_point = (fire_lat, fire_lon)
|
166 |
+
min_dist = float('inf')
|
167 |
+
nearest = None
|
168 |
+
for _, row in df_stations.iterrows():
|
169 |
+
dist = geodesic(fire_point, (row['lat'], row['lon'])).km
|
170 |
+
if dist < min_dist:
|
171 |
+
min_dist = dist
|
172 |
+
nearest = row
|
173 |
+
return pd.Series({
|
174 |
+
'nearest_station_name': nearest['name'],
|
175 |
+
'nearest_station_lat': nearest['lat'],
|
176 |
+
'nearest_station_lon': nearest['lon'],
|
177 |
+
'station_distance_km': min_dist
|
178 |
+
})
|
179 |
+
nearest_station = df_fires_filtered.apply(
|
180 |
+
lambda r: get_nearest_station(r['latitude'], r['longitude']), axis=1)
|
181 |
+
|
182 |
+
# Final dataframe
|
183 |
+
df_final = pd.concat([df_fires_filtered, nearest_water, nearest_station], axis=1)
|
184 |
+
|
185 |
+
# Step 6: Create a folium map
|
186 |
+
center_lat = (bbox["north"] + bbox["south"]) / 2
|
187 |
+
center_lon = (bbox["east"] + bbox["west"]) / 2
|
188 |
+
m = folium.Map(location=[center_lat, center_lon], zoom_start=6)
|
189 |
+
|
190 |
+
for _, row in df_final.iterrows():
|
191 |
+
fire_loc = [row['latitude'], row['longitude']]
|
192 |
+
water_loc = [row['nearest_water_lat'], row['nearest_water_lon']]
|
193 |
+
station_loc = [row['nearest_station_lat'], row['nearest_station_lon']]
|
194 |
+
|
195 |
+
# π₯ 1. Uncertainty Zone (large faint circle) FIRST
|
196 |
+
folium.Circle(
|
197 |
+
location=fire_loc,
|
198 |
+
radius=500, # 3 km
|
199 |
+
color='orange',
|
200 |
+
fill=True,
|
201 |
+
fill_opacity=0.2,
|
202 |
+
popup="β οΈ Possible spread zone (~3 km radius)",
|
203 |
+
).add_to(m)
|
204 |
+
|
205 |
+
# π΄ 2. Fire point (small red circle) SECOND
|
206 |
+
folium.CircleMarker(
|
207 |
+
location=fire_loc,
|
208 |
+
radius=6,
|
209 |
+
color='red',
|
210 |
+
fill=True,
|
211 |
+
fill_color='red',
|
212 |
+
popup=f"π₯ Brightness: {row['brightness']}, Confidence: {row['confidence']}, DateTime: {row['acq_datetime']} Latitude: {row['latitude']:.3f}, Longitude: {row['longitude']:.3f}",
|
213 |
+
).add_to(m)
|
214 |
+
|
215 |
+
# π§ 3. Nearest water point
|
216 |
+
folium.Marker(
|
217 |
+
location=water_loc,
|
218 |
+
icon=folium.Icon(color='blue', icon='tint', prefix='fa'),
|
219 |
+
popup=f"π§ Nearest Water\nDistance: {row['distance_km']:.2f} km\nLat: {row['nearest_water_lat']:.3f}\nLon: {row['nearest_water_lon']:.3f}",
|
220 |
+
).add_to(m)
|
221 |
+
|
222 |
+
# π’ 4. Line between fire and water
|
223 |
+
folium.PolyLine(
|
224 |
+
locations=[fire_loc, water_loc],
|
225 |
+
color='green',
|
226 |
+
weight=2,
|
227 |
+
).add_to(m)
|
228 |
+
|
229 |
+
# π 5. Nearest fire station (NEW)
|
230 |
+
folium.Marker(
|
231 |
+
location=station_loc,
|
232 |
+
icon=folium.Icon(color='darkred', icon='fire-extinguisher', prefix='fa'),
|
233 |
+
popup=f"π Nearest Fire Station\nDistance: {row['station_distance_km']:.2f} km\nLat: {row['nearest_station_lat']:.3f}\nLon: {row['nearest_station_lon']:.3f}",
|
234 |
+
).add_to(m)
|
235 |
+
|
236 |
+
# π§― 6. Line between fire and station (NEW)
|
237 |
+
folium.PolyLine(
|
238 |
+
locations=[fire_loc, station_loc],
|
239 |
+
color='purple',
|
240 |
+
weight=2,
|
241 |
+
dash_array='5,10' # dashed line
|
242 |
+
).add_to(m)
|
243 |
+
|
244 |
+
return m._repr_html_()
|
245 |
+
|
246 |
+
|
247 |
+
|
248 |
+
except Exception as e:
|
249 |
+
return f"<b>Error generating fire risk map:</b> {str(e)}"
|
250 |
+
|
251 |
+
|
252 |
+
|
253 |
+
class AgentState(TypedDict):
|
254 |
+
messages: Annotated[list[AnyMessage], operator.add]
|
255 |
+
|
256 |
+
class Agent:
|
257 |
+
|
258 |
+
def __init__(self, model, tools, system=""):
|
259 |
+
self.system = system
|
260 |
+
graph = StateGraph(AgentState)
|
261 |
+
graph.add_node("llm", self.call_mistral_ai)
|
262 |
+
graph.add_node("action", self.take_action)
|
263 |
+
graph.add_node("final", self.final_answer)
|
264 |
+
graph.add_conditional_edges(
|
265 |
+
"llm",
|
266 |
+
self.exists_action,
|
267 |
+
{True: "action", False: END}
|
268 |
+
)
|
269 |
+
graph.add_edge("action", "final") # π
|
270 |
+
graph.add_edge("final", END) # π
|
271 |
+
graph.set_entry_point("llm")
|
272 |
+
self.graph = graph.compile()
|
273 |
+
self.tools = {t.name: t for t in tools}
|
274 |
+
self.model = model.bind_tools(tools)
|
275 |
+
|
276 |
+
def exists_action(self, state: AgentState):
|
277 |
+
result = state['messages'][-1]
|
278 |
+
return len(result.tool_calls) > 0
|
279 |
+
|
280 |
+
def call_mistral_ai(self, state: AgentState):
|
281 |
+
messages = state['messages']
|
282 |
+
if self.system:
|
283 |
+
messages = [SystemMessage(content=self.system)] + messages
|
284 |
+
message = self.model.invoke(messages)
|
285 |
+
return {'messages': [message]}
|
286 |
+
|
287 |
+
def take_action(self, state: AgentState):
|
288 |
+
tool_calls = state['messages'][-1].tool_calls
|
289 |
+
results = []
|
290 |
+
for t in tool_calls:
|
291 |
+
print(f"Calling: {t}")
|
292 |
+
if not t['name'] in self.tools: # check for bad tool name from LLM
|
293 |
+
print("\n ....bad tool name....")
|
294 |
+
result = "bad tool name, retry" # instruct LLM to retry if bad
|
295 |
+
else:
|
296 |
+
result = self.tools[t['name']].invoke(t['args'])
|
297 |
+
results.append(ToolMessage(tool_call_id=t['id'], name=t['name'], content=str(result)))
|
298 |
+
return {'messages': results}
|
299 |
+
|
300 |
+
def final_answer(self, state: AgentState):
|
301 |
+
"""Return the final tool output cleanly."""
|
302 |
+
return {"messages": [AIMessage(content=state['messages'][-1].content.strip())]}
|
303 |
+
|
304 |
+
|
305 |
+
prompt = """ You are a Wildfire Detection Assistant.
|
306 |
+
When users ask about fire spots, wildfires, or fire mapping in any region,
|
307 |
+
use the firespot_summary function to analyze the area and generate a fire map or status update.
|
308 |
+
"""
|
309 |
+
|
310 |
+
model = init_chat_model("mistral-large-latest", model_provider="mistralai")
|
311 |
+
abot = Agent(model, [get_fire_risk_map], system=prompt)
|
312 |
+
|
313 |
+
|
314 |
+
|
315 |
+
|
316 |
+
def process_prompt(prompt):
|
317 |
+
messages = [HumanMessage(content=prompt)]
|
318 |
+
result = abot.graph.invoke({"messages": messages})
|
319 |
+
html_map = result['messages'][-1].content # assuming tool returns HTML string
|
320 |
+
return html_map
|
321 |
+
|
322 |
+
iface = gr.Interface(
|
323 |
+
fn=process_prompt,
|
324 |
+
inputs=gr.Textbox(lines=2, placeholder="e.g., Check for fires in California..."),
|
325 |
+
outputs=gr.HTML(label="Fire Risk Map"),
|
326 |
+
title="Fire Risk Mapper",
|
327 |
+
description="Ask about fire risks in a region and view results as an interactive map."
|
328 |
+
)
|
329 |
+
|
330 |
+
iface.launch(share = True)
|
requirements.txt
ADDED
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Core Python libraries
|
2 |
+
pandas
|
3 |
+
requests
|
4 |
+
folium
|
5 |
+
geopy
|
6 |
+
google-auth
|
7 |
+
google-auth-oauthlib
|
8 |
+
google-auth-httplib2
|
9 |
+
|
10 |
+
# Earth Engine (GEE)
|
11 |
+
earthengine-api
|
12 |
+
|
13 |
+
# LangChain ecosystem
|
14 |
+
langchain
|
15 |
+
langgraph
|
16 |
+
langchain-community
|
17 |
+
langchain-core
|
18 |
+
|
19 |
+
# For Gradio UI
|
20 |
+
gradio
|
21 |
+
|
22 |
+
# Typing / Compatibility
|
23 |
+
typing-extensions
|