geomind-ai 1.1.1__py3-none-any.whl → 1.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
geomind/__init__.py CHANGED
@@ -3,7 +3,7 @@ GeoMind - Geospatial AI Agent
3
3
 
4
4
  """
5
5
 
6
- __version__ = "1.1.0"
6
+ __version__ = "1.2.0"
7
7
  __author__ = "Harsh Shinde, Rajat Shinde"
8
8
 
9
9
  from .agent import GeoMindAgent
geomind/agent.py CHANGED
@@ -1,144 +1,50 @@
1
1
  """
2
- GeoMind Agent - Main agent class powered by OpenRouter.
3
-
4
- This module implements the AI agent that can understand natural language
5
- queries about satellite imagery and execute the appropriate tools.
2
+ GeoMind Agent - Simplified AI agent for satellite imagery analysis.
6
3
  """
7
4
 
8
5
  import json
9
- import re
10
- from typing import Optional, Callable, Any
6
+ from typing import Optional
11
7
  from datetime import datetime
12
-
13
8
  from openai import OpenAI
14
9
 
15
- from .config import (
16
- OPENROUTER_API_KEY,
17
- OPENROUTER_API_URL,
18
- OPENROUTER_MODEL,
19
- )
10
+ from .config import OPENROUTER_API_KEY, OPENROUTER_API_URL, OPENROUTER_MODEL
20
11
  from .tools import (
21
- geocode_location,
22
- get_bbox_from_location,
23
- search_imagery,
24
- get_item_details,
25
12
  list_recent_imagery,
26
13
  create_rgb_composite,
27
14
  calculate_ndvi,
15
+ search_imagery,
16
+ get_bbox_from_location,
17
+ geocode_location,
18
+ get_item_details,
28
19
  get_band_statistics,
29
20
  )
30
21
 
31
-
32
- # Map tool names to functions
22
+ # Tool function mapping
33
23
  TOOL_FUNCTIONS = {
34
- "geocode_location": geocode_location,
35
- "get_bbox_from_location": get_bbox_from_location,
36
- "search_imagery": search_imagery,
37
24
  "list_recent_imagery": list_recent_imagery,
38
- "get_item_details": get_item_details,
39
25
  "create_rgb_composite": create_rgb_composite,
40
26
  "calculate_ndvi": calculate_ndvi,
27
+ "search_imagery": search_imagery,
28
+ "get_bbox_from_location": get_bbox_from_location,
29
+ "geocode_location": geocode_location,
30
+ "get_item_details": get_item_details,
41
31
  "get_band_statistics": get_band_statistics,
42
32
  }
43
33
 
44
- # Tool definitions for the LLM
34
+ # Simplified tool definitions
45
35
  TOOLS = [
46
- {
47
- "type": "function",
48
- "function": {
49
- "name": "geocode_location",
50
- "description": "Convert a place name to geographic coordinates (latitude, longitude). Use this when you need to find coordinates for a location.",
51
- "parameters": {
52
- "type": "object",
53
- "properties": {
54
- "place_name": {
55
- "type": "string",
56
- "description": "The name of the place to geocode (e.g., 'New York City', 'Paris, France')",
57
- }
58
- },
59
- "required": ["place_name"],
60
- },
61
- },
62
- },
63
- {
64
- "type": "function",
65
- "function": {
66
- "name": "get_bbox_from_location",
67
- "description": "Get a bounding box for a location, suitable for searching satellite imagery.",
68
- "parameters": {
69
- "type": "object",
70
- "properties": {
71
- "place_name": {
72
- "type": "string",
73
- "description": "The name of the place",
74
- },
75
- "buffer_km": {
76
- "type": "number",
77
- "description": "Buffer distance in kilometers (default: 10)",
78
- },
79
- },
80
- "required": ["place_name"],
81
- },
82
- },
83
- },
84
- {
85
- "type": "function",
86
- "function": {
87
- "name": "search_imagery",
88
- "description": "Search for Sentinel-2 satellite imagery in the EOPF catalog. Returns available scenes.",
89
- "parameters": {
90
- "type": "object",
91
- "properties": {
92
- "bbox": {
93
- "type": "array",
94
- "items": {"type": "number"},
95
- "description": "Bounding box as [min_lon, min_lat, max_lon, max_lat]",
96
- },
97
- "start_date": {
98
- "type": "string",
99
- "description": "Start date in YYYY-MM-DD format",
100
- },
101
- "end_date": {
102
- "type": "string",
103
- "description": "End date in YYYY-MM-DD format",
104
- },
105
- "max_cloud_cover": {
106
- "type": "number",
107
- "description": "Maximum cloud cover percentage (0-100)",
108
- },
109
- "max_items": {
110
- "type": "integer",
111
- "description": "Maximum number of results",
112
- },
113
- },
114
- "required": [],
115
- },
116
- },
117
- },
118
36
  {
119
37
  "type": "function",
120
38
  "function": {
121
39
  "name": "list_recent_imagery",
122
- "description": "List recent Sentinel-2 imagery for a location. Combines geocoding and search.",
40
+ "description": "Find recent Sentinel-2 imagery for a location",
123
41
  "parameters": {
124
42
  "type": "object",
125
43
  "properties": {
126
- "location_name": {
127
- "type": "string",
128
- "description": "Name of the location to search",
129
- },
130
- "days": {
131
- "type": "integer",
132
- "description": "Number of days to look back (default: 7)",
133
- },
134
- "max_cloud_cover": {
135
- "type": "number",
136
- "description": "Maximum cloud cover percentage",
137
- },
138
- "max_items": {
139
- "type": "integer",
140
- "description": "Maximum number of results",
141
- },
44
+ "location_name": {"type": "string", "description": "Location name"},
45
+ "days": {"type": "integer", "description": "Days to look back (default: 14)"},
46
+ "max_cloud_cover": {"type": "number", "description": "Max cloud cover %"},
47
+ "max_items": {"type": "integer", "description": "Max results"},
142
48
  },
143
49
  "required": [],
144
50
  },
@@ -147,37 +53,30 @@ TOOLS = [
147
53
  {
148
54
  "type": "function",
149
55
  "function": {
150
- "name": "get_item_details",
151
- "description": "Get detailed information about a specific Sentinel-2 scene by its ID.",
56
+ "name": "create_rgb_composite",
57
+ "description": "Create RGB composite from Sentinel-2 data",
152
58
  "parameters": {
153
59
  "type": "object",
154
60
  "properties": {
155
- "item_id": {"type": "string", "description": "The STAC item ID"}
61
+ "zarr_url": {"type": "string", "description": "SR_10m Zarr URL"},
62
+ "location_name": {"type": "string", "description": "Location for title"},
63
+ "subset_size": {"type": "integer", "description": "Image size (default: 1000)"},
156
64
  },
157
- "required": ["item_id"],
65
+ "required": ["zarr_url"],
158
66
  },
159
67
  },
160
68
  },
161
69
  {
162
70
  "type": "function",
163
71
  "function": {
164
- "name": "create_rgb_composite",
165
- "description": "Create an RGB true-color composite image from Sentinel-2 data.",
72
+ "name": "calculate_ndvi",
73
+ "description": "Calculate NDVI vegetation index",
166
74
  "parameters": {
167
75
  "type": "object",
168
76
  "properties": {
169
- "zarr_url": {
170
- "type": "string",
171
- "description": "URL to the SR_10m Zarr asset from a STAC item",
172
- },
173
- "output_path": {
174
- "type": "string",
175
- "description": "Optional path to save the output image",
176
- },
177
- "subset_size": {
178
- "type": "integer",
179
- "description": "Size to subset the image (default: 1000 pixels)",
180
- },
77
+ "zarr_url": {"type": "string", "description": "SR_10m Zarr URL"},
78
+ "location_name": {"type": "string", "description": "Location for title"},
79
+ "subset_size": {"type": "integer", "description": "Image size (default: 1000)"},
181
80
  },
182
81
  "required": ["zarr_url"],
183
82
  },
@@ -186,47 +85,33 @@ TOOLS = [
186
85
  {
187
86
  "type": "function",
188
87
  "function": {
189
- "name": "calculate_ndvi",
190
- "description": "Calculate NDVI (vegetation index) from Sentinel-2 data.",
88
+ "name": "search_imagery",
89
+ "description": "Search Sentinel-2 imagery by parameters",
191
90
  "parameters": {
192
91
  "type": "object",
193
92
  "properties": {
194
- "zarr_url": {
195
- "type": "string",
196
- "description": "URL to the SR_10m Zarr asset",
197
- },
198
- "output_path": {
199
- "type": "string",
200
- "description": "Optional path to save the NDVI image",
201
- },
202
- "subset_size": {
203
- "type": "integer",
204
- "description": "Size to subset the image",
205
- },
93
+ "bbox": {"type": "array", "items": {"type": "number"}, "description": "Bounding box"},
94
+ "start_date": {"type": "string", "description": "Start date YYYY-MM-DD"},
95
+ "end_date": {"type": "string", "description": "End date YYYY-MM-DD"},
96
+ "max_cloud_cover": {"type": "number", "description": "Max cloud cover %"},
97
+ "max_items": {"type": "integer", "description": "Max results"},
206
98
  },
207
- "required": ["zarr_url"],
99
+ "required": [],
208
100
  },
209
101
  },
210
102
  },
211
103
  {
212
104
  "type": "function",
213
105
  "function": {
214
- "name": "get_band_statistics",
215
- "description": "Get statistics (min, max, mean) for spectral bands.",
106
+ "name": "get_bbox_from_location",
107
+ "description": "Get bounding box for a location",
216
108
  "parameters": {
217
109
  "type": "object",
218
110
  "properties": {
219
- "zarr_url": {
220
- "type": "string",
221
- "description": "URL to the Zarr asset",
222
- },
223
- "bands": {
224
- "type": "array",
225
- "items": {"type": "string"},
226
- "description": "List of band names to analyze",
227
- },
111
+ "place_name": {"type": "string", "description": "Location name"},
112
+ "buffer_km": {"type": "number", "description": "Buffer distance in km"},
228
113
  },
229
- "required": ["zarr_url"],
114
+ "required": ["place_name"],
230
115
  },
231
116
  },
232
117
  },
@@ -234,233 +119,100 @@ TOOLS = [
234
119
 
235
120
 
236
121
  class GeoMindAgent:
237
- """
238
- GeoMind - An AI agent for geospatial analysis with Sentinel-2 imagery.
239
-
240
- Uses OpenRouter API for access to multiple AI models.
241
- """
122
+ """Simplified GeoMind agent for satellite imagery analysis."""
242
123
 
243
124
  def __init__(self, model: Optional[str] = None, api_key: Optional[str] = None):
244
- """
245
- Initialize the GeoMind agent.
246
-
247
- Args:
248
- model: Model name (default: nvidia/nemotron-3-nano-30b-a3b:free)
249
- api_key: OpenRouter API key (required).
250
- """
251
- self.provider = "openrouter"
252
125
  self.api_key = api_key or OPENROUTER_API_KEY
253
126
  self.model_name = model or OPENROUTER_MODEL
254
- self.base_url = OPENROUTER_API_URL
255
-
127
+
256
128
  if not self.api_key:
257
- raise ValueError(
258
- "OpenRouter API key required.\n"
259
- "Get your FREE API key at: https://openrouter.ai/settings/keys\n\n"
260
- "Then provide it in one of these ways:\n"
261
- "1. Run: geomind --api-key YOUR_KEY\n"
262
- "2. Set environment variable: OPENROUTER_API_KEY=YOUR_KEY\n"
263
- "3. Create .env file with: OPENROUTER_API_KEY=YOUR_KEY"
264
- )
265
-
129
+ raise ValueError("OpenRouter API key required")
130
+
266
131
  print(f"GeoMind Agent initialized with {self.model_name}")
267
-
268
- # Create OpenAI-compatible client
269
- self.client = OpenAI(base_url=self.base_url, api_key=self.api_key)
270
-
271
- # Chat history
132
+ self.client = OpenAI(base_url=OPENROUTER_API_URL, api_key=self.api_key)
272
133
  self.history = []
273
134
 
274
- # Add system message
275
- self.system_prompt = self._get_system_prompt()
276
-
277
135
  def _get_system_prompt(self) -> str:
278
- """Get the system prompt for the agent."""
279
- return f"""You are GeoMind, an expert AI assistant specialized in geospatial analysis
280
- and satellite imagery. You help users find, analyze, and visualize Sentinel-2 satellite data
281
- from the EOPF (ESA Earth Observation Processing Framework) catalog.
282
-
283
- Your capabilities include:
284
- 1. **Search**: Find Sentinel-2 L2A imagery by location, date, and cloud cover
285
- 2. **Geocoding**: Convert place names to coordinates for searching
286
- 3. **Visualization**: Create RGB composites and NDVI maps from imagery
287
- 4. **Analysis**: Calculate spectral indices and band statistics
288
-
289
- Key information:
290
- - Data source: EOPF STAC API (https://stac.core.eopf.eodc.eu)
291
- - Satellite: Sentinel-2 (L2A surface reflectance products)
292
- - Bands available: B01-B12 at 10m, 20m, or 60m resolution
293
- - Current date: {datetime.now().strftime('%Y-%m-%d')}
294
-
295
- IMPORTANT - Zarr URL usage:
296
- - STAC search results include both SR_10m (base URL) and individual band assets (B02_10m, B03_10m, B04_10m, B08_10m)
297
- - EITHER type of URL works for create_rgb_composite and calculate_ndvi:
298
- * SR_10m URL: Points to .../measurements/reflectance/r10m (contains all bands as subdirectories)
299
- * Individual band URLs: Point directly to specific bands like .../r10m/b02
300
- - Prefer using SR_10m URL as it's simpler and works for all bands
301
- - The processing functions automatically handle the correct path structure
302
-
303
- When users ask for imagery:
304
- 1. First use get_bbox_from_location or list_recent_imagery to search
305
- 2. Present the results clearly with key metadata (ID, date, cloud cover)
306
- 3. Offer to create visualizations if data is found
307
- 4. For visualizations, use the SR_10m asset URL from search results
308
-
309
- Always explain what you're doing and interpret results in a helpful way."""
310
-
311
- def _call_llm(self, messages: list, tools: list) -> dict:
312
- """Call LLM via OpenAI-compatible client."""
313
- response = self.client.chat.completions.create(
314
- model=self.model_name,
315
- messages=messages,
316
- tools=tools,
317
- tool_choice="auto",
318
- max_tokens=4096,
319
- )
320
- return response.model_dump()
136
+ """Short, focused system prompt."""
137
+ return f"""You are GeoMind, an AI assistant for satellite imagery analysis using Sentinel-2 data.
321
138
 
322
- def _execute_function(self, name: str, args: dict) -> dict:
323
- """Execute a function call and return the result."""
324
- print(f" Executing: {name}({args})")
139
+ Current date: {datetime.now().strftime('%Y-%m-%d')}
325
140
 
326
- if name not in TOOL_FUNCTIONS:
327
- return {"error": f"Unknown function: {name}"}
141
+ KEY RULES:
142
+ 1. Use list_recent_imagery to find satellite data for locations
143
+ 2. Extract Zarr URLs from results: item['zarr_assets']['SR_10m']['href']
144
+ 3. Always include location_name parameter when creating images
145
+ 4. Use actual function response data in your answers (don't make up paths/stats)
146
+ 5. Let functions use default output paths (saves to outputs/ folder as .png)
328
147
 
329
- try:
330
- result = TOOL_FUNCTIONS[name](**args)
331
- return result
332
- except Exception as e:
333
- return {"error": str(e)}
148
+ Be helpful and accurate - always use real data from function responses."""
334
149
 
335
150
  def chat(self, message: str, verbose: bool = True) -> str:
336
- """
337
- Send a message to the agent and get a response.
338
- """
151
+ """Send message to agent and get response."""
339
152
  if verbose:
340
153
  print(f"\nUser: {message}")
341
154
  print("Processing...")
342
155
 
343
- # Add user message to history
344
156
  self.history.append({"role": "user", "content": message})
157
+ messages = [{"role": "system", "content": self._get_system_prompt()}] + self.history
158
+
159
+ for _ in range(10): # Max 10 iterations
160
+ response = self.client.chat.completions.create(
161
+ model=self.model_name,
162
+ messages=messages,
163
+ tools=TOOLS,
164
+ tool_choice="auto",
165
+ max_tokens=4096,
166
+ )
345
167
 
346
- # Build messages with system prompt
347
- messages = [{"role": "system", "content": self.system_prompt}] + self.history
348
-
349
- max_iterations = 10
350
- iteration = 0
351
-
352
- while iteration < max_iterations:
353
- iteration += 1
354
-
355
- # Call the model (via proxy or direct)
356
- response_data = self._call_llm(messages, TOOLS)
357
-
358
- # Extract assistant message from response
359
- choice = response_data["choices"][0]
360
- assistant_message = choice["message"]
168
+ choice = response.choices[0]
169
+ assistant_message = choice.message
361
170
 
362
- # Check if there are tool calls
363
- tool_calls = assistant_message.get("tool_calls", [])
171
+ tool_calls = assistant_message.tool_calls
364
172
  if tool_calls:
365
- # Add assistant message with tool calls to messages
366
- messages.append(
367
- {
368
- "role": "assistant",
369
- "content": assistant_message.get("content") or "",
370
- "tool_calls": [
371
- {
372
- "id": tc["id"],
373
- "type": "function",
374
- "function": {
375
- "name": tc["function"]["name"],
376
- "arguments": tc["function"]["arguments"],
377
- },
378
- }
379
- for tc in tool_calls
380
- ],
381
- }
382
- )
383
-
384
- # Execute each tool call
385
- for tool_call in tool_calls:
386
- func_name = tool_call["function"]["name"]
387
- func_args = json.loads(tool_call["function"]["arguments"])
388
-
389
- result = self._execute_function(func_name, func_args)
390
-
391
- # Add tool result to messages
392
- messages.append(
173
+ # Add assistant message with tool calls
174
+ messages.append({
175
+ "role": "assistant",
176
+ "content": assistant_message.content or "",
177
+ "tool_calls": [
393
178
  {
394
- "role": "tool",
395
- "tool_call_id": tool_call["id"],
396
- "content": json.dumps(result, default=str),
179
+ "id": tc.id,
180
+ "type": "function",
181
+ "function": {"name": tc.function.name, "arguments": tc.function.arguments},
397
182
  }
398
- )
399
- else:
400
- # No tool calls, we have a final response
401
- final_text = assistant_message.get("content") or ""
183
+ for tc in tool_calls
184
+ ],
185
+ })
402
186
 
403
- # Add to history
187
+ # Execute tool calls
188
+ for tool_call in tool_calls:
189
+ func_name = tool_call.function.name
190
+ func_args = json.loads(tool_call.function.arguments)
191
+
192
+ print(f" Executing: {func_name}({func_args})")
193
+
194
+ try:
195
+ result = TOOL_FUNCTIONS[func_name](**func_args)
196
+ except Exception as e:
197
+ result = {"error": str(e)}
198
+
199
+ messages.append({
200
+ "role": "tool",
201
+ "tool_call_id": tool_call.id,
202
+ "content": json.dumps(result, default=str),
203
+ })
204
+ else:
205
+ # Final response
206
+ final_text = assistant_message.content or ""
404
207
  self.history.append({"role": "assistant", "content": final_text})
405
-
208
+
406
209
  if verbose:
407
210
  print(f"\nGeoMind: {final_text}")
408
-
211
+
409
212
  return final_text
410
213
 
411
- return "Max iterations reached."
214
+ return "Max iterations reached"
412
215
 
413
216
  def reset(self):
414
- """Reset the chat session."""
217
+ """Reset chat history."""
415
218
  self.history = []
416
- print("Chat session reset")
417
-
418
-
419
- def main(model: Optional[str] = None):
420
- """Main entry point for CLI usage."""
421
- import sys
422
-
423
- print("=" * 60)
424
- print("GeoMind - Geospatial AI Agent")
425
- print("=" * 60)
426
- print("Powered by OpenRouter | Sentinel-2 Imagery")
427
- print("Type 'quit' or 'exit' to end the session")
428
- print("Type 'reset' to start a new conversation")
429
- print("=" * 60)
430
-
431
- try:
432
- agent = GeoMindAgent(model=model)
433
- except ValueError as e:
434
- print(f"\nError: {e}")
435
- sys.exit(1)
436
- except Exception as e:
437
- print(f"\nError: {e}")
438
- print("\nPlease check your API key and internet connection.")
439
- sys.exit(1)
440
-
441
- while True:
442
- try:
443
- user_input = input("\nYou: ").strip()
444
-
445
- if not user_input:
446
- continue
447
-
448
- if user_input.lower() in ["quit", "exit", "q"]:
449
- print("\nGoodbye!")
450
- break
451
-
452
- if user_input.lower() == "reset":
453
- agent.reset()
454
- continue
455
-
456
- agent.chat(user_input)
457
-
458
- except KeyboardInterrupt:
459
- print("\n\nGoodbye!")
460
- break
461
- except Exception as e:
462
- print(f"\nError: {e}")
463
-
464
-
465
- if __name__ == "__main__":
466
- main()
geomind/config.py CHANGED
@@ -37,9 +37,9 @@ REFLECTANCE_OFFSET = -0.1
37
37
  RGB_BANDS = {"red": "b04", "green": "b03", "blue": "b02"}
38
38
 
39
39
  # Default search parameters
40
- DEFAULT_MAX_CLOUD_COVER = 20 # percent
41
- DEFAULT_BUFFER_KM = 10 # km buffer around point for bbox
42
- DEFAULT_MAX_ITEMS = 10
40
+ DEFAULT_MAX_CLOUD_COVER = 50 # percent (increased for better results)
41
+ DEFAULT_BUFFER_KM = 15 # km buffer around point for bbox (increased coverage)
42
+ DEFAULT_MAX_ITEMS = 20 # increased to find more options
43
43
 
44
44
  # Output directory for saved images
45
45
  OUTPUT_DIR = Path("outputs")
@@ -99,6 +99,7 @@ def create_rgb_composite(
99
99
  zarr_url: str,
100
100
  output_path: Optional[str] = None,
101
101
  subset_size: Optional[int] = 1000,
102
+ location_name: Optional[str] = None,
102
103
  ) -> dict:
103
104
  """
104
105
  Create an RGB composite image from Sentinel-2 10m bands.
@@ -109,6 +110,7 @@ def create_rgb_composite(
109
110
  zarr_url: URL to the SR_10m Zarr asset or individual band asset URL
110
111
  output_path: Optional path to save the image
111
112
  subset_size: Size to subset the image (for faster processing)
113
+ location_name: Optional location name to include in the title
112
114
 
113
115
  Returns:
114
116
  Dictionary with path to saved image and metadata
@@ -116,38 +118,49 @@ def create_rgb_composite(
116
118
  try:
117
119
  import xarray as xr
118
120
  import zarr
121
+ import re
119
122
 
120
123
  # Determine if this is a band-specific URL or base SR_10m URL
121
- # Band-specific URLs end with /b02, /b03, /b04, etc.
122
- # Base SR_10m URLs end with /r10m
123
- is_band_url = zarr_url.rstrip('/').split('/')[-1].startswith('b')
124
+ # Band-specific URLs end with /b01, /b02, etc. (Sentinel-2 band pattern)
125
+ url_parts = zarr_url.rstrip('/').split('/')
126
+ last_part = url_parts[-1]
127
+ is_band_url = re.match(r'^b(0[1-9]|1[0-2]|8a)(_\d+m)?$', last_part.lower()) is not None
124
128
 
125
129
  if is_band_url:
126
130
  # Individual band URL provided - need to construct URLs for each band
127
131
  base_url = '/'.join(zarr_url.rstrip('/').split('/')[:-1])
128
- red = np.array(zarr.open(f"{base_url}/b04", mode="r"))
129
- green = np.array(zarr.open(f"{base_url}/b03", mode="r"))
130
- blue = np.array(zarr.open(f"{base_url}/b02", mode="r"))
132
+ red_zarr = zarr.open(f"{base_url}/b04", mode="r")
133
+ green_zarr = zarr.open(f"{base_url}/b03", mode="r")
134
+ blue_zarr = zarr.open(f"{base_url}/b02", mode="r")
131
135
  else:
132
136
  # Base SR_10m URL - bands are subdirectories
133
137
  base_url = zarr_url.rstrip('/')
134
- red = np.array(zarr.open(f"{base_url}/b04", mode="r"))
135
- green = np.array(zarr.open(f"{base_url}/b03", mode="r"))
136
- blue = np.array(zarr.open(f"{base_url}/b02", mode="r"))
138
+ red_zarr = zarr.open(f"{base_url}/b04", mode="r")
139
+ green_zarr = zarr.open(f"{base_url}/b03", mode="r")
140
+ blue_zarr = zarr.open(f"{base_url}/b02", mode="r")
141
+
142
+ # Determine subset region before loading data
143
+ full_shape = red_zarr.shape
144
+ if subset_size and full_shape[0] > subset_size:
145
+ # Calculate center subset
146
+ h, w = full_shape
147
+ start_h = (h - subset_size) // 2
148
+ start_w = (w - subset_size) // 2
149
+
150
+ # Load only the subset (memory efficient)
151
+ red = np.array(red_zarr[start_h:start_h + subset_size, start_w:start_w + subset_size])
152
+ green = np.array(green_zarr[start_h:start_h + subset_size, start_w:start_w + subset_size])
153
+ blue = np.array(blue_zarr[start_h:start_h + subset_size, start_w:start_w + subset_size])
154
+ else:
155
+ # Load full arrays (be careful with large datasets)
156
+ red = np.array(red_zarr)
157
+ green = np.array(green_zarr)
158
+ blue = np.array(blue_zarr)
137
159
 
138
160
  # Subset if requested (for faster processing)
139
161
  if subset_size and red.shape[0] > subset_size:
140
- # Take center subset
141
- h, w = red.shape
142
- start_h = (h - subset_size) // 2
143
- start_w = (w - subset_size) // 2
144
- red = red[start_h : start_h + subset_size, start_w : start_w + subset_size]
145
- green = green[
146
- start_h : start_h + subset_size, start_w : start_w + subset_size
147
- ]
148
- blue = blue[
149
- start_h : start_h + subset_size, start_w : start_w + subset_size
150
- ]
162
+ # Already subsetted during loading - this section can be removed
163
+ pass
151
164
 
152
165
  # Apply scale and offset
153
166
  red = _apply_scale_offset(red)
@@ -171,7 +184,12 @@ def create_rgb_composite(
171
184
  # Create figure
172
185
  fig, ax = plt.subplots(figsize=(10, 10))
173
186
  ax.imshow(rgb)
174
- ax.set_title("Sentinel-2 RGB Composite (B4/B3/B2)")
187
+
188
+ # Create dynamic title with location
189
+ title = "Sentinel-2 RGB Composite (B4/B3/B2)"
190
+ if location_name:
191
+ title += f" - {location_name}"
192
+ ax.set_title(title)
175
193
  ax.axis("off")
176
194
 
177
195
  # Save
@@ -197,6 +215,7 @@ def calculate_ndvi(
197
215
  zarr_url: str,
198
216
  output_path: Optional[str] = None,
199
217
  subset_size: Optional[int] = 1000,
218
+ location_name: Optional[str] = None,
200
219
  ) -> dict:
201
220
  """
202
221
  Calculate NDVI (Normalized Difference Vegetation Index) from Sentinel-2 data.
@@ -208,6 +227,7 @@ def calculate_ndvi(
208
227
  zarr_url: URL to the SR_10m Zarr asset or individual band asset URL
209
228
  output_path: Optional path to save the NDVI image
210
229
  subset_size: Size to subset the image
230
+ location_name: Optional location name to include in the title
211
231
 
212
232
  Returns:
213
233
  Dictionary with NDVI statistics and output path
@@ -215,28 +235,40 @@ def calculate_ndvi(
215
235
  try:
216
236
  import zarr
217
237
  from matplotlib.colors import LinearSegmentedColormap
238
+ import re
218
239
 
219
240
  # Determine if this is a band-specific URL or base SR_10m URL
220
- is_band_url = zarr_url.rstrip('/').split('/')[-1].startswith('b')
241
+ url_parts = zarr_url.rstrip('/').split('/')
242
+ last_part = url_parts[-1]
243
+ is_band_url = re.match(r'^b(0[1-9]|1[0-2]|8a)(_\d+m)?$', last_part.lower()) is not None
221
244
 
222
245
  if is_band_url:
223
246
  # Individual band URL provided
224
247
  base_url = '/'.join(zarr_url.rstrip('/').split('/')[:-1])
225
- nir = np.array(zarr.open(f"{base_url}/b08", mode="r"))
226
- red = np.array(zarr.open(f"{base_url}/b04", mode="r"))
248
+ nir_zarr = zarr.open(f"{base_url}/b08", mode="r")
249
+ red_zarr = zarr.open(f"{base_url}/b04", mode="r")
227
250
  else:
228
251
  # Base SR_10m URL
229
252
  base_url = zarr_url.rstrip('/')
230
- nir = np.array(zarr.open(f"{base_url}/b08", mode="r"))
231
- red = np.array(zarr.open(f"{base_url}/b04", mode="r"))
253
+ nir_zarr = zarr.open(f"{base_url}/b08", mode="r")
254
+ red_zarr = zarr.open(f"{base_url}/b04", mode="r")
232
255
 
233
- # Subset if requested
234
- if subset_size and nir.shape[0] > subset_size:
235
- h, w = nir.shape
256
+ # Determine subset region and load efficiently
257
+ full_shape = nir_zarr.shape
258
+ if subset_size and full_shape[0] > subset_size:
259
+ h, w = full_shape
236
260
  start_h = (h - subset_size) // 2
237
261
  start_w = (w - subset_size) // 2
238
- nir = nir[start_h : start_h + subset_size, start_w : start_w + subset_size]
239
- red = red[start_h : start_h + subset_size, start_w : start_w + subset_size]
262
+ nir = np.array(nir_zarr[start_h:start_h + subset_size, start_w:start_w + subset_size])
263
+ red = np.array(red_zarr[start_h:start_h + subset_size, start_w:start_w + subset_size])
264
+ else:
265
+ nir = np.array(nir_zarr)
266
+ red = np.array(red_zarr)
267
+
268
+ # Subset if requested - already handled during loading
269
+ if subset_size and nir.shape[0] > subset_size:
270
+ # Already subsetted during loading
271
+ pass
240
272
 
241
273
  # Apply scale and offset
242
274
  nir = _apply_scale_offset(nir)
@@ -271,7 +303,12 @@ def calculate_ndvi(
271
303
  # Create figure
272
304
  fig, ax = plt.subplots(figsize=(10, 10))
273
305
  im = ax.imshow(ndvi, cmap=ndvi_cmap, vmin=-1, vmax=1)
274
- ax.set_title("NDVI - Normalized Difference Vegetation Index")
306
+
307
+ # Create dynamic title with location
308
+ title = "NDVI - Normalized Difference Vegetation Index"
309
+ if location_name:
310
+ title += f" - {location_name}"
311
+ ax.set_title(title)
275
312
  ax.axis("off")
276
313
 
277
314
  # Add colorbar
@@ -25,23 +25,32 @@ def _format_item(item) -> dict:
25
25
  """Format a STAC item into a simplified dictionary."""
26
26
  props = item.properties
27
27
 
28
- # Extract individual band assets for direct access
28
+ # Extract ALL assets, with special handling for Zarr assets
29
29
  assets = {}
30
+ zarr_assets = {}
31
+ other_assets = {}
32
+
30
33
  for key, asset in item.assets.items():
31
- if key in ["SR_10m", "SR_20m", "SR_60m", "TCI_10m", "product"]:
32
- assets[key] = {
33
- "title": asset.title,
34
- "href": asset.href,
35
- "type": asset.media_type,
36
- }
37
- # Include individual 10m band assets for direct access
38
- elif key in ["B02_10m", "B03_10m", "B04_10m", "B08_10m"]:
39
- assets[key] = {
40
- "title": asset.title,
41
- "href": asset.href,
42
- "type": asset.media_type,
43
- "band": key.split("_")[0].lower(), # Extract band name (b02, b03, b04, b08)
44
- }
34
+ asset_info = {
35
+ "title": asset.title,
36
+ "href": asset.href,
37
+ "type": asset.media_type,
38
+ }
39
+
40
+ # Add band information for individual band assets
41
+ if "_" in key and key.split("_")[0] in ["B01", "B02", "B03", "B04", "B05", "B06", "B07", "B08", "B8A", "B09", "B11", "B12"]:
42
+ band_name = key.split("_")[0].lower()
43
+ asset_info["band"] = band_name
44
+ asset_info["resolution"] = key.split("_")[1] if "_" in key else "unknown"
45
+
46
+ # Categorize assets by type
47
+ if asset.media_type and "zarr" in asset.media_type.lower():
48
+ zarr_assets[key] = asset_info
49
+ else:
50
+ other_assets[key] = asset_info
51
+
52
+ # Include all assets in main assets dict
53
+ assets[key] = asset_info
45
54
 
46
55
  return {
47
56
  "id": item.id,
@@ -51,7 +60,14 @@ def _format_item(item) -> dict:
51
60
  "bbox": item.bbox,
52
61
  "geometry": item.geometry,
53
62
  "assets": assets,
63
+ "zarr_assets": zarr_assets, # Separate zarr assets for easy access
64
+ "other_assets": other_assets, # Non-zarr assets
54
65
  "stac_url": f"{STAC_API_URL}/collections/{STAC_COLLECTION}/items/{item.id}",
66
+ "asset_summary": {
67
+ "total_assets": len(assets),
68
+ "zarr_assets": len(zarr_assets),
69
+ "other_assets": len(other_assets),
70
+ }
55
71
  }
56
72
 
57
73
 
@@ -139,6 +155,10 @@ def search_imagery(
139
155
  "datetime": datetime_str,
140
156
  "max_cloud_cover": max_cloud_cover,
141
157
  },
158
+ "quality_info": {
159
+ "avg_cloud_cover": sum(item.properties.get("eo:cloud_cover", 100) for item in items) / len(items) if items else None,
160
+ "best_cloud_cover": min(item.properties.get("eo:cloud_cover", 100) for item in items) if items else None,
161
+ } if items else {},
142
162
  }
143
163
 
144
164
  except Exception as e:
@@ -186,7 +206,7 @@ def get_item_details(item_id: str) -> dict:
186
206
 
187
207
  def list_recent_imagery(
188
208
  location_name: Optional[str] = None,
189
- days: int = 7,
209
+ days: int = 14, # Changed from 7 to 14 days default
190
210
  max_cloud_cover: Optional[float] = None,
191
211
  max_items: Optional[int] = None,
192
212
  ) -> dict:
@@ -194,10 +214,11 @@ def list_recent_imagery(
194
214
  List recent Sentinel-2 imagery, optionally for a specific location.
195
215
 
196
216
  This is a convenience function that combines geocoding and search.
217
+ If no good quality results found, automatically extends search period.
197
218
 
198
219
  Args:
199
220
  location_name: Optional place name to search around
200
- days: Number of days to look back (default: 7)
221
+ days: Number of days to look back (default: 14)
201
222
  max_cloud_cover: Maximum cloud cover percentage
202
223
  max_items: Maximum items to return
203
224
 
@@ -238,6 +259,25 @@ def list_recent_imagery(
238
259
  max_items=max_items,
239
260
  )
240
261
 
262
+ # If no results found and we used a restrictive cloud cover, try extending time and relaxing cloud cover
263
+ if result.get("success") and result.get("filtered_count", 0) == 0 and days < 30:
264
+ # Try 30 days with more permissive cloud cover
265
+ extended_start = end_date - timedelta(days=30)
266
+ relaxed_cloud = min((max_cloud_cover or DEFAULT_MAX_CLOUD_COVER) + 30, 80)
267
+
268
+ extended_result = search_imagery(
269
+ bbox=bbox,
270
+ start_date=extended_start.strftime("%Y-%m-%d"),
271
+ end_date=end_date.strftime("%Y-%m-%d"),
272
+ max_cloud_cover=relaxed_cloud,
273
+ max_items=max_items,
274
+ )
275
+
276
+ if extended_result.get("success") and extended_result.get("filtered_count", 0) > 0:
277
+ result = extended_result
278
+ result["extended_search"] = True
279
+ result["note"] = f"Extended search to 30 days with {relaxed_cloud}% cloud cover for better results"
280
+
241
281
  if location_info:
242
282
  result["location"] = location_info
243
283
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: geomind-ai
3
- Version: 1.1.1
3
+ Version: 1.2.0
4
4
  Summary: AI agent for geospatial analysis with Sentinel-2 satellite imagery
5
5
  Author: Harsh Shinde, Rajat Shinde
6
6
  License-Expression: MIT
@@ -0,0 +1,14 @@
1
+ geomind/__init__.py,sha256=LBwe1KUfm0hXjes2HDyeaN4LZb8tXlRnXqBUDRx3QoI,165
2
+ geomind/agent.py,sha256=My9OikB0RnZulztSML_sPY3-0-dpVvGyfmbzD42Ppp0,8108
3
+ geomind/cli.py,sha256=ipuMF0ZPseHHNKDD5VUMUG4fJDNmG_RQXNvAvT8Gbww,12085
4
+ geomind/config.py,sha256=pgHir8A5tSLbIYz9zOMMwEXhGyECEA0JzStPjQOQfpM,2116
5
+ geomind/tools/__init__.py,sha256=8iumGwIFHh8Bj1VJNgZtmKnEBqCy6_cRkzYENDUH7x4,720
6
+ geomind/tools/geocoding.py,sha256=hiLpzHpkJP6IgWAUtZMnHL6qpkWcYWVLpGe0yfYxXv8,3007
7
+ geomind/tools/processing.py,sha256=4sUrLGqxGqlVETMgHLxOeY-b1_kQ5bstKOjIHh2PbeU,12817
8
+ geomind/tools/stac_search.py,sha256=a0OrxSe4bDNtD_cNHKBnQZHeCtx9B6S65HWxsCBaFxY,8854
9
+ geomind_ai-1.2.0.dist-info/licenses/LICENSE,sha256=aveu0ERm7I3NnIu8rtpKdvd0eyRpmktXKU0PBABtSN0,1069
10
+ geomind_ai-1.2.0.dist-info/METADATA,sha256=-gHApQirq64EklvWj8j7_B-SQjxcsXDCt_HjFe7dJpc,2405
11
+ geomind_ai-1.2.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
12
+ geomind_ai-1.2.0.dist-info/entry_points.txt,sha256=2nPR3faYKl0-1epccvzMJ2xdi-Q1Vt7aOSvA84oIWnw,45
13
+ geomind_ai-1.2.0.dist-info/top_level.txt,sha256=rjKWNSNRhq4R9xJoZGsG-eAaH7BmTVNvfrrbcaJMIIs,8
14
+ geomind_ai-1.2.0.dist-info/RECORD,,
@@ -1,14 +0,0 @@
1
- geomind/__init__.py,sha256=MZ0Zr2vGCJ816ilSApbwhA6iEfCwEBk40etbvIGfpqs,165
2
- geomind/agent.py,sha256=-TF2VqOyTAJDrBPMbPKFoUwAHahBUUYOGtzqAKoNiUs,16226
3
- geomind/cli.py,sha256=ipuMF0ZPseHHNKDD5VUMUG4fJDNmG_RQXNvAvT8Gbww,12085
4
- geomind/config.py,sha256=7zPr0OKvK2SqQArUbjMvf5GVLQsmHcbn7e-BsSB1yv0,2030
5
- geomind/tools/__init__.py,sha256=8iumGwIFHh8Bj1VJNgZtmKnEBqCy6_cRkzYENDUH7x4,720
6
- geomind/tools/geocoding.py,sha256=hiLpzHpkJP6IgWAUtZMnHL6qpkWcYWVLpGe0yfYxXv8,3007
7
- geomind/tools/processing.py,sha256=hb_Y9JV6o8XxS8_HI9DuW-hjzkv8S5ZdBpm45NpWwXQ,11287
8
- geomind/tools/stac_search.py,sha256=3W9iZRObY6EkDn9K06Sp9ME-fYmubZRjPNDKDLEBUmM,6898
9
- geomind_ai-1.1.1.dist-info/licenses/LICENSE,sha256=aveu0ERm7I3NnIu8rtpKdvd0eyRpmktXKU0PBABtSN0,1069
10
- geomind_ai-1.1.1.dist-info/METADATA,sha256=3QKoizXhvfTGs6Bi73RdRFtx748Wzb7jGtbe39DObGw,2405
11
- geomind_ai-1.1.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
12
- geomind_ai-1.1.1.dist-info/entry_points.txt,sha256=2nPR3faYKl0-1epccvzMJ2xdi-Q1Vt7aOSvA84oIWnw,45
13
- geomind_ai-1.1.1.dist-info/top_level.txt,sha256=rjKWNSNRhq4R9xJoZGsG-eAaH7BmTVNvfrrbcaJMIIs,8
14
- geomind_ai-1.1.1.dist-info/RECORD,,