Aniket2006 commited on
Commit
4a86366
·
1 Parent(s): 4a05f0f

v3.0.0: Multi-mode Heatmap Service

Browse files

PIXEL-WISE MODE (fast):
- soil_moisture (SMI)
- soil_organic_matter (SOMI)
- soil_fertility (SFI)
- soil_salinity (SASI)
- greenness (NDVI)
- nitrogen_level (NDRE)
- photosynthetic_capacity (PRI)

LLM MODE (CNN+Clustering+LLM):
- pest_risk
- disease_risk
- nutrient_stress
- stress_zones

Features:
- Auto-detect mode from metric type
- CNN stress detection with patch_size=4, stride=2
- K-Means clustering for stress patterns
- Gemini LLM analysis with full context
- Added GNDVI index for nutrient stress

Dockerfile CHANGED
@@ -12,9 +12,12 @@ RUN apt-get update && apt-get install -y \
12
  COPY requirements.txt .
13
  RUN pip install --no-cache-dir -r requirements.txt
14
 
15
- # Copy application files
16
  COPY app.py .
17
  COPY vegetation_indices.py .
 
 
 
18
 
19
  # Expose port
20
  EXPOSE 7860
 
12
  COPY requirements.txt .
13
  RUN pip install --no-cache-dir -r requirements.txt
14
 
15
+ # Copy all application files
16
  COPY app.py .
17
  COPY vegetation_indices.py .
18
+ COPY stress_detection_model.py .
19
+ COPY stress_detection_preprocessing.py .
20
+ COPY llm_analysis.py .
21
 
22
  # Expose port
23
  EXPOSE 7860
app.py CHANGED
@@ -1,10 +1,13 @@
1
  """
2
  AGROW Heatmap Service
3
  =====================
4
- Generates heatmap images from Sentinel-2 satellite data using pixel-wise
5
- vegetation indices with patch-based statistics.
6
 
7
- Version: 2.3.0 - Pixel-wise + 100-200 patch analysis
 
 
 
 
8
  """
9
 
10
  import os
@@ -13,7 +16,7 @@ import base64
13
  import logging
14
  import traceback
15
  from datetime import datetime, timedelta
16
- from typing import Optional, List
17
 
18
  import numpy as np
19
  from scipy.ndimage import gaussian_filter
@@ -32,10 +35,14 @@ from sentinelhub import (
32
  MimeType, bbox_to_dimensions
33
  )
34
 
35
- from vegetation_indices import INDEX_FUNCTIONS
 
 
 
 
36
 
37
  # ============================================================================
38
- # LOGGING CONFIGURATION
39
  # ============================================================================
40
  logging.basicConfig(
41
  level=logging.INFO,
@@ -58,19 +65,44 @@ def log_detail(key: str, value):
58
  # ============================================================================
59
  # STARTUP
60
  # ============================================================================
61
- log_section("AGROW HEATMAP SERVICE v2.3.0")
62
- log_detail("Mode", "Pixel-wise indices + 100-200 patch analysis")
63
- log_detail("Indices available", list(INDEX_FUNCTIONS.keys()))
64
- log_detail("SH_CLIENT_ID", " Set" if os.environ.get('SH_CLIENT_ID') else "✗ NOT SET")
65
- log_detail("SH_CLIENT_SECRET", "✓ Set" if os.environ.get('SH_CLIENT_SECRET') else "✗ NOT SET")
 
66
 
67
  # ============================================================================
68
- # FASTAPI SETUP
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  # ============================================================================
70
  app = FastAPI(
71
  title="AGROW Heatmap Service",
72
- description="Pixel-wise vegetation indices with 100-200 patch analysis",
73
- version="2.3.0"
74
  )
75
 
76
  app.add_middleware(
@@ -99,27 +131,32 @@ class HeatmapRequest(BaseModel):
99
  center_lat: float
100
  center_lon: float
101
  field_size_hectares: float
102
- index_type: str = "NDVI"
103
  gaussian_sigma: float = 1.5
104
  show_field_boundary: bool = True
105
- target_patches: int = 150 # Target 100-200 patches
106
 
107
 
108
  class HeatmapResponse(BaseModel):
109
  success: bool
110
- index_type: str
 
 
111
  min_value: float
112
  max_value: float
113
  mean_value: float
114
- std_value: float
115
  image_base64: str
116
  timestamp: str
117
  image_date: Optional[str] = None
118
  image_size: Optional[str] = None
119
- num_patches: int
120
- patch_grid: Optional[str] = None
121
- patches: Optional[List[dict]] = None
122
  health_summary: Optional[dict] = None
 
 
 
 
 
 
123
 
124
  # ============================================================================
125
  # COLORMAPS
@@ -132,22 +169,9 @@ def get_water_colormap():
132
  colors = [(0.9, 0.6, 0.3), (0.95, 0.9, 0.5), (0.5, 0.8, 0.9), (0.2, 0.5, 0.8), (0.1, 0.3, 0.6)]
133
  return LinearSegmentedColormap.from_list('water', colors, N=256)
134
 
135
- # ============================================================================
136
- # HEALTH CATEGORIZATION
137
- # ============================================================================
138
- def get_health_category(value: float, index_type: str) -> str:
139
- if index_type in ['NDVI', 'EVI', 'NDRE']:
140
- if value >= 0.6: return 'Healthy'
141
- elif value >= 0.3: return 'Moderate'
142
- else: return 'Stressed'
143
- elif index_type in ['NDWI', 'SMI']:
144
- if value >= 0.2: return 'Adequate'
145
- elif value >= 0.0: return 'Moderate'
146
- else: return 'Dry'
147
- else:
148
- if value >= 0.5: return 'Healthy'
149
- elif value >= 0.25: return 'Moderate'
150
- else: return 'Stressed'
151
 
152
  # ============================================================================
153
  # EVALSCRIPT
@@ -170,129 +194,98 @@ function evaluatePixel(sample) {
170
  """
171
 
172
  # ============================================================================
173
- # PATCH ANALYSIS (100-200 patches)
174
  # ============================================================================
175
- def analyze_patches(data: np.ndarray, index_type: str, target_patches: int = 150) -> tuple:
176
- """
177
- Divide field into ~100-200 patches for statistical analysis.
178
- Uses pixel-wise index values, then aggregates by patch.
179
- """
 
 
 
 
 
 
 
 
 
 
 
 
180
  h, w = data.shape
181
-
182
- # Calculate grid size to get ~target_patches
183
- # patches = rows * cols, so we want sqrt(target_patches) for each dimension
184
- grid_size = int(np.sqrt(target_patches))
185
- grid_size = max(10, min(15, grid_size)) # Keep between 10x10 and 15x15
186
-
187
- patch_h = max(1, h // grid_size)
188
- patch_w = max(1, w // grid_size)
189
-
190
  actual_rows = h // patch_h if patch_h > 0 else 1
191
  actual_cols = w // patch_w if patch_w > 0 else 1
192
 
193
- logger.info(f" • Grid: {actual_rows} rows × {actual_cols} cols = {actual_rows * actual_cols} patches")
194
- logger.info(f" • Patch size: {patch_h}×{patch_w} pixels")
195
-
196
  patches_list = []
197
  health_counts = {}
198
 
199
  for row in range(actual_rows):
200
  for col in range(actual_cols):
201
- y_start = row * patch_h
202
- y_end = min((row + 1) * patch_h, h)
203
- x_start = col * patch_w
204
- x_end = min((col + 1) * patch_w, w)
205
-
206
  patch = data[y_start:y_end, x_start:x_end]
207
  valid_pixels = np.sum(~np.isnan(patch))
208
 
209
  if valid_pixels > 0:
210
  mean_val = float(np.nanmean(patch))
211
  health = get_health_category(mean_val, index_type)
212
-
213
  patches_list.append({
214
- 'id': f"P{row}_{col}",
215
- 'row': row,
216
- 'col': col,
217
- 'center_x': (x_start + x_end) // 2,
218
- 'center_y': (y_start + y_end) // 2,
219
- 'mean': round(mean_val, 4),
220
- 'std': round(float(np.nanstd(patch)), 4),
221
- 'min': round(float(np.nanmin(patch)), 4),
222
- 'max': round(float(np.nanmax(patch)), 4),
223
- 'health': health,
224
- 'pixels': int(valid_pixels)
225
  })
226
-
227
  health_counts[health] = health_counts.get(health, 0) + 1
228
 
229
  total = len(patches_list)
230
- health_summary = {
231
  'total_patches': total,
232
  'grid': f"{actual_rows}x{actual_cols}",
233
  'counts': health_counts,
234
  'percentages': {k: round(100 * v / total, 1) for k, v in health_counts.items()} if total > 0 else {}
235
  }
236
-
237
- # Log health distribution
238
- logger.info(" • Health Distribution:")
239
- for cat, count in health_counts.items():
240
- pct = 100 * count / total if total > 0 else 0
241
- bar = "█" * int(pct // 5) + "░" * (20 - int(pct // 5))
242
- logger.info(f" {cat:12s}: {bar} {pct:.1f}% ({count} patches)")
243
-
244
- return patches_list, health_summary
245
 
246
  # ============================================================================
247
  # HEATMAP GENERATION
248
  # ============================================================================
249
- def generate_heatmap_image(data: np.ndarray, index_type: str, gaussian_sigma: float,
250
- show_boundary: bool, patches_list: list = None) -> tuple:
251
- """Generate heatmap from pixel-wise index data."""
252
-
253
  valid_mask = ~np.isnan(data)
254
- valid_pct = 100 * np.sum(valid_mask) / data.size
255
- logger.info(f" • Valid pixels: {np.sum(valid_mask):,} / {data.size:,} ({valid_pct:.1f}%)")
256
-
257
  if not np.any(valid_mask):
258
  raise ValueError("No valid data pixels")
259
 
260
- min_val = float(np.nanmin(data))
261
- max_val = float(np.nanmax(data))
262
  mean_val = float(np.nanmean(data))
263
- std_val = float(np.nanstd(data))
264
-
265
- logger.info(f" • {index_type} Stats: min={min_val:.4f}, max={max_val:.4f}, mean={mean_val:.4f}, std={std_val:.4f}")
266
 
267
- # Normalize
268
  data_norm = np.clip((data - min_val) / (max_val - min_val + 1e-8), 0, 1)
269
  data_norm = np.nan_to_num(data_norm, nan=0.5)
270
 
271
- # Gaussian smoothing
272
  if gaussian_sigma > 0:
273
- logger.info(f" • Applying Gaussian smoothing (σ={gaussian_sigma})")
274
  data_norm = gaussian_filter(data_norm, sigma=gaussian_sigma)
275
 
276
- # Create figure
277
  fig, ax = plt.subplots(figsize=(8, 8), dpi=100)
278
- cmap = get_water_colormap() if index_type in ['NDWI', 'SMI'] else get_vegetation_colormap()
 
 
 
 
 
 
 
279
  im = ax.imshow(data_norm, cmap=cmap, interpolation='bilinear')
280
 
281
- # Field boundary
282
  if show_boundary:
283
  h, w = data_norm.shape
284
- rect = plt.Rectangle((w*0.02, h*0.02), w*0.96, h*0.96, fill=False,
285
  edgecolor='white', linewidth=2, linestyle='--', alpha=0.7)
286
  ax.add_patch(rect)
287
 
288
- # Colorbar
289
  cbar = plt.colorbar(im, ax=ax, shrink=0.8, pad=0.02)
290
- cbar.set_label(f'{index_type}', fontsize=10)
291
- ticks = np.linspace(0, 1, 5)
292
- cbar.set_ticks(ticks)
293
- cbar.set_ticklabels([f'{min_val + t*(max_val-min_val):.2f}' for t in ticks])
294
 
295
- ax.set_title(f'{index_type} Heatmap', fontsize=14, fontweight='bold')
296
  ax.axis('off')
297
 
298
  buf = io.BytesIO()
@@ -300,10 +293,61 @@ def generate_heatmap_image(data: np.ndarray, index_type: str, gaussian_sigma: fl
300
  plt.close(fig)
301
  buf.seek(0)
302
 
303
- img_b64 = base64.b64encode(buf.getvalue()).decode('utf-8')
304
- logger.info(f" • Image generated: {len(img_b64):,} bytes (base64)")
305
-
306
- return img_b64, min_val, max_val, mean_val, std_val
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
307
 
308
  # ============================================================================
309
  # API ENDPOINTS
@@ -312,39 +356,42 @@ def generate_heatmap_image(data: np.ndarray, index_type: str, gaussian_sigma: fl
312
  async def root():
313
  return {
314
  "service": "AGROW Heatmap Service",
315
- "version": "2.3.0",
316
- "mode": "Pixel-wise + 100-200 patch analysis",
317
- "indices": list(INDEX_FUNCTIONS.keys())
318
  }
319
 
320
  @app.get("/health")
321
  async def health():
322
- return {"status": "healthy"}
323
 
324
 
325
  @app.post("/generate-heatmap", response_model=HeatmapResponse)
326
  async def generate_heatmap(request: HeatmapRequest):
327
- """Generate heatmap with pixel-wise index calculation and patch analysis."""
328
 
329
  req_id = datetime.now().strftime("%H%M%S")
330
 
331
- log_section(f"REQUEST [{req_id}]")
332
- log_detail("Location", f"({request.center_lat:.6f}, {request.center_lon:.6f})")
333
- log_detail("Field Size", f"{request.field_size_hectares} hectares")
334
- log_detail("Index", request.index_type)
335
- log_detail("Target Patches", request.target_patches)
 
 
336
 
337
- if request.index_type not in INDEX_FUNCTIONS:
338
- raise HTTPException(400, f"Invalid index: {request.index_type}")
 
 
339
 
340
  try:
341
- # STEP 1: Config
342
- log_step(1, 5, "Loading Sentinel Hub config")
343
  config = get_sh_config()
344
- log_detail("Client ID", f"{config.sh_client_id[:8]}..." if config.sh_client_id else "NOT SET")
345
 
346
- # STEP 2: Bounding Box
347
- log_step(2, 5, "Calculating bounding box")
348
  radius_km = np.sqrt(request.field_size_hectares / 100) / 2
349
  lat_off = radius_km / 111
350
  lon_off = radius_km / (111 * np.cos(np.radians(request.center_lat)))
@@ -355,14 +402,12 @@ async def generate_heatmap(request: HeatmapRequest):
355
  ), crs=CRS.WGS84)
356
 
357
  size = bbox_to_dimensions(bbox, resolution=10)
358
- log_detail("Image Size", f"{size[0]}×{size[1]} pixels @ 10m")
359
- log_detail("Coverage", f"{size[0]*10}m × {size[1]*10}m")
360
 
361
- # STEP 3: Fetch Data
362
- log_step(3, 5, "Fetching Sentinel-2 data")
363
  end_date = datetime.now()
364
  start_date = end_date - timedelta(days=30)
365
- log_detail("Date Range", f"{start_date.strftime('%Y-%m-%d')} → {end_date.strftime('%Y-%m-%d')}")
366
 
367
  SENTINEL2 = DataCollection.define(
368
  "S2_CDSE", api_id="sentinel-2-l2a",
@@ -382,51 +427,130 @@ async def generate_heatmap(request: HeatmapRequest):
382
  )
383
 
384
  data = sh_request.get_data()[0]
385
-
386
  if data is None or data.size == 0:
387
  raise HTTPException(404, "No satellite data available")
388
 
389
  log_detail("Data Shape", f"{data.shape}")
390
- log_detail("Data Type", f"{data.dtype}")
391
 
392
- # STEP 4: Calculate Index (PIXEL-WISE)
393
- log_step(4, 5, f"Calculating {request.index_type} (pixel-wise)")
394
  img_data = data[:, :, :12]
395
- index_func = INDEX_FUNCTIONS[request.index_type]
396
- index_data = index_func(img_data)
397
-
398
- log_detail("Index Shape", f"{index_data.shape}")
399
- log_detail("Index Range", f"[{np.nanmin(index_data):.4f}, {np.nanmax(index_data):.4f}]")
400
- log_detail("Index Mean", f"{np.nanmean(index_data):.4f}")
401
-
402
- # STEP 5: Patch Analysis & Heatmap
403
- log_step(5, 5, "Analyzing patches & generating heatmap")
404
- patches_list, health_summary = analyze_patches(index_data, request.index_type, request.target_patches)
405
 
406
- img_b64, min_v, max_v, mean_v, std_v = generate_heatmap_image(
407
- index_data, request.index_type, request.gaussian_sigma,
408
- request.show_field_boundary, patches_list
409
- )
410
-
411
- log_section(f"SUCCESS [{req_id}]")
412
- logger.info(f" Generated {request.index_type} heatmap with {len(patches_list)} patches")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
413
 
414
- return HeatmapResponse(
415
- success=True,
416
- index_type=request.index_type,
417
- min_value=min_v,
418
- max_value=max_v,
419
- mean_value=mean_v,
420
- std_value=std_v,
421
- image_base64=img_b64,
422
- timestamp=datetime.now().isoformat(),
423
- image_date=end_date.strftime('%Y-%m-%d'),
424
- image_size=f"{size[0]}x{size[1]}",
425
- num_patches=len(patches_list),
426
- patch_grid=health_summary['grid'],
427
- patches=patches_list[:50], # Return 50 patches
428
- health_summary=health_summary
429
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
430
 
431
  except HTTPException:
432
  raise
@@ -439,11 +563,11 @@ async def generate_heatmap(request: HeatmapRequest):
439
  @app.get("/generate-heatmap-image")
440
  async def get_heatmap_image(
441
  center_lat: float, center_lon: float, field_size_hectares: float,
442
- index_type: str = "NDVI", gaussian_sigma: float = 1.5
443
  ):
444
  request = HeatmapRequest(
445
  center_lat=center_lat, center_lon=center_lon,
446
- field_size_hectares=field_size_hectares, index_type=index_type,
447
  gaussian_sigma=gaussian_sigma
448
  )
449
  response = await generate_heatmap(request)
 
1
  """
2
  AGROW Heatmap Service
3
  =====================
4
+ Multi-mode heatmap generation with pixel-wise indices AND CNN+Clustering+LLM analysis.
 
5
 
6
+ Version: 3.0.0 - Integrated Stress Detection
7
+
8
+ Modes (auto-detected from metric):
9
+ - Pixel-wise: SMI, SOMI, SFI, SASI, NDVI, NDRE, PRI, GNDVI
10
+ - CNN+LLM: pest_risk, disease_risk, nutrient_stress, stress_zones
11
  """
12
 
13
  import os
 
16
  import logging
17
  import traceback
18
  from datetime import datetime, timedelta
19
+ from typing import Optional, List, Dict, Any
20
 
21
  import numpy as np
22
  from scipy.ndimage import gaussian_filter
 
35
  MimeType, bbox_to_dimensions
36
  )
37
 
38
+ # Import modules
39
+ from vegetation_indices import INDEX_FUNCTIONS, calculate_all_indices
40
+ from stress_detection_model import StressDetectionModel, get_stress_category, prepare_llm_context
41
+ from stress_detection_preprocessing import preprocess_for_model
42
+ from llm_analysis import configure_gemini, prepare_indices_context, format_stress_context
43
 
44
  # ============================================================================
45
+ # LOGGING
46
  # ============================================================================
47
  logging.basicConfig(
48
  level=logging.INFO,
 
65
  # ============================================================================
66
  # STARTUP
67
  # ============================================================================
68
+ log_section("AGROW HEATMAP SERVICE v3.0.0")
69
+ log_detail("Mode", "Pixel-wise + CNN+Clustering+LLM")
70
+ log_detail("Pixel-wise indices", "SMI, SOMI, SFI, SASI, NDVI, NDRE, PRI, GNDVI")
71
+ log_detail("LLM metrics", "pest_risk, disease_risk, nutrient_stress, stress_zones")
72
+ log_detail("SH_CLIENT_ID", "✓" if os.environ.get('SH_CLIENT_ID') else "✗")
73
+ log_detail("GEMINI_API_KEY", "✓" if os.environ.get('GEMINI_API_KEY') else "✗")
74
 
75
  # ============================================================================
76
+ # METRIC CONFIGURATION
77
+ # ============================================================================
78
+ # Metrics that use simple pixel-wise index calculation
79
+ PIXELWISE_METRICS = {
80
+ 'soil_moisture': 'SMI',
81
+ 'soil_organic_matter': 'SOMI',
82
+ 'soil_fertility': 'SFI',
83
+ 'soil_salinity': 'SASI',
84
+ 'greenness': 'NDVI',
85
+ 'nitrogen_level': 'NDRE',
86
+ 'photosynthetic_capacity': 'PRI',
87
+ }
88
+
89
+ # Metrics that require CNN+Clustering+LLM
90
+ LLM_METRICS = {
91
+ 'pest_risk': {'primary_index': 'NDVI', 'use_stress': True},
92
+ 'disease_risk': {'primary_index': 'PSRI', 'use_stress': True},
93
+ 'nutrient_stress': {'primary_index': 'GNDVI', 'use_stress': True},
94
+ 'stress_zones': {'primary_index': 'NDVI', 'use_stress': True},
95
+ }
96
+
97
+ ALL_METRICS = list(PIXELWISE_METRICS.keys()) + list(LLM_METRICS.keys())
98
+
99
+ # ============================================================================
100
+ # FASTAPI
101
  # ============================================================================
102
  app = FastAPI(
103
  title="AGROW Heatmap Service",
104
+ description="Multi-mode heatmap with pixel-wise and CNN+LLM analysis",
105
+ version="3.0.0"
106
  )
107
 
108
  app.add_middleware(
 
131
  center_lat: float
132
  center_lon: float
133
  field_size_hectares: float
134
+ metric: str # e.g., "soil_moisture", "pest_risk"
135
  gaussian_sigma: float = 1.5
136
  show_field_boundary: bool = True
 
137
 
138
 
139
  class HeatmapResponse(BaseModel):
140
  success: bool
141
+ metric: str
142
+ mode: str # "pixelwise" or "llm"
143
+ index_used: str
144
  min_value: float
145
  max_value: float
146
  mean_value: float
 
147
  image_base64: str
148
  timestamp: str
149
  image_date: Optional[str] = None
150
  image_size: Optional[str] = None
151
+ # Patch analysis (for pixel-wise)
152
+ num_patches: Optional[int] = None
 
153
  health_summary: Optional[dict] = None
154
+ # LLM analysis (for risk metrics)
155
+ level: Optional[str] = None
156
+ analysis: Optional[str] = None
157
+ stress_score: Optional[float] = None
158
+ cluster_distribution: Optional[dict] = None
159
+ recommendations: Optional[List[str]] = None
160
 
161
  # ============================================================================
162
  # COLORMAPS
 
169
  colors = [(0.9, 0.6, 0.3), (0.95, 0.9, 0.5), (0.5, 0.8, 0.9), (0.2, 0.5, 0.8), (0.1, 0.3, 0.6)]
170
  return LinearSegmentedColormap.from_list('water', colors, N=256)
171
 
172
+ def get_stress_colormap():
173
+ colors = [(0.2, 0.7, 0.2), (0.8, 0.8, 0.2), (0.9, 0.5, 0.1), (0.8, 0.2, 0.2)]
174
+ return LinearSegmentedColormap.from_list('stress', colors, N=256)
 
 
 
 
 
 
 
 
 
 
 
 
 
175
 
176
  # ============================================================================
177
  # EVALSCRIPT
 
194
  """
195
 
196
  # ============================================================================
197
+ # PIXEL-WISE ANALYSIS
198
  # ============================================================================
199
+ def get_health_category(value: float, index_type: str) -> str:
200
+ if index_type in ['NDVI', 'EVI', 'NDRE', 'GNDVI']:
201
+ if value >= 0.6: return 'Healthy'
202
+ elif value >= 0.3: return 'Moderate'
203
+ else: return 'Stressed'
204
+ elif index_type in ['NDWI', 'SMI']:
205
+ if value >= 0.2: return 'Adequate'
206
+ elif value >= 0.0: return 'Moderate'
207
+ else: return 'Dry'
208
+ else:
209
+ if value >= 0.5: return 'Healthy'
210
+ elif value >= 0.25: return 'Moderate'
211
+ else: return 'Stressed'
212
+
213
+
214
+ def analyze_patches_pixelwise(data: np.ndarray, index_type: str, target_patches: int = 150) -> tuple:
215
+ """Divide field into ~100-200 patches for statistical analysis."""
216
  h, w = data.shape
217
+ grid_size = max(10, min(15, int(np.sqrt(target_patches))))
218
+ patch_h, patch_w = max(1, h // grid_size), max(1, w // grid_size)
 
 
 
 
 
 
 
219
  actual_rows = h // patch_h if patch_h > 0 else 1
220
  actual_cols = w // patch_w if patch_w > 0 else 1
221
 
 
 
 
222
  patches_list = []
223
  health_counts = {}
224
 
225
  for row in range(actual_rows):
226
  for col in range(actual_cols):
227
+ y_start, y_end = row * patch_h, min((row + 1) * patch_h, h)
228
+ x_start, x_end = col * patch_w, min((col + 1) * patch_w, w)
 
 
 
229
  patch = data[y_start:y_end, x_start:x_end]
230
  valid_pixels = np.sum(~np.isnan(patch))
231
 
232
  if valid_pixels > 0:
233
  mean_val = float(np.nanmean(patch))
234
  health = get_health_category(mean_val, index_type)
 
235
  patches_list.append({
236
+ 'id': f"P{row}_{col}", 'mean': round(mean_val, 4),
237
+ 'health': health, 'pixels': int(valid_pixels)
 
 
 
 
 
 
 
 
 
238
  })
 
239
  health_counts[health] = health_counts.get(health, 0) + 1
240
 
241
  total = len(patches_list)
242
+ return patches_list, {
243
  'total_patches': total,
244
  'grid': f"{actual_rows}x{actual_cols}",
245
  'counts': health_counts,
246
  'percentages': {k: round(100 * v / total, 1) for k, v in health_counts.items()} if total > 0 else {}
247
  }
 
 
 
 
 
 
 
 
 
248
 
249
  # ============================================================================
250
  # HEATMAP GENERATION
251
  # ============================================================================
252
+ def generate_heatmap_image(data: np.ndarray, index_type: str, gaussian_sigma: float = 1.5,
253
+ show_boundary: bool = True, is_stress: bool = False) -> tuple:
254
+ """Generate heatmap from index data."""
 
255
  valid_mask = ~np.isnan(data)
 
 
 
256
  if not np.any(valid_mask):
257
  raise ValueError("No valid data pixels")
258
 
259
+ min_val, max_val = float(np.nanmin(data)), float(np.nanmax(data))
 
260
  mean_val = float(np.nanmean(data))
 
 
 
261
 
 
262
  data_norm = np.clip((data - min_val) / (max_val - min_val + 1e-8), 0, 1)
263
  data_norm = np.nan_to_num(data_norm, nan=0.5)
264
 
 
265
  if gaussian_sigma > 0:
 
266
  data_norm = gaussian_filter(data_norm, sigma=gaussian_sigma)
267
 
 
268
  fig, ax = plt.subplots(figsize=(8, 8), dpi=100)
269
+
270
+ if is_stress:
271
+ cmap = get_stress_colormap()
272
+ elif index_type in ['NDWI', 'SMI']:
273
+ cmap = get_water_colormap()
274
+ else:
275
+ cmap = get_vegetation_colormap()
276
+
277
  im = ax.imshow(data_norm, cmap=cmap, interpolation='bilinear')
278
 
 
279
  if show_boundary:
280
  h, w = data_norm.shape
281
+ rect = plt.Rectangle((w*0.02, h*0.02), w*0.96, h*0.96, fill=False,
282
  edgecolor='white', linewidth=2, linestyle='--', alpha=0.7)
283
  ax.add_patch(rect)
284
 
 
285
  cbar = plt.colorbar(im, ax=ax, shrink=0.8, pad=0.02)
286
+ cbar.set_label(f'{index_type}' if not is_stress else 'Stress Score', fontsize=10)
 
 
 
287
 
288
+ ax.set_title(f'{index_type} Heatmap' if not is_stress else 'Stress Heatmap', fontsize=14, fontweight='bold')
289
  ax.axis('off')
290
 
291
  buf = io.BytesIO()
 
293
  plt.close(fig)
294
  buf.seek(0)
295
 
296
+ return base64.b64encode(buf.getvalue()).decode('utf-8'), min_val, max_val, mean_val
297
+
298
+ # ============================================================================
299
+ # LLM ANALYSIS (for risk metrics)
300
+ # ============================================================================
301
+ def run_llm_analysis(metric: str, stress_context: dict, indices_data: dict) -> dict:
302
+ """Call Gemini LLM with full context from stress detection."""
303
+ try:
304
+ import google.generativeai as genai
305
+
306
+ api_key = os.environ.get("GEMINI_API_KEY")
307
+ if not api_key:
308
+ return {"level": "Unknown", "analysis": "GEMINI_API_KEY not set", "recommendations": []}
309
+
310
+ genai.configure(api_key=api_key, transport='rest')
311
+ model = genai.GenerativeModel('gemini-flash-latest')
312
+
313
+ # Format stress context
314
+ stress_text = format_stress_context(stress_context)
315
+
316
+ # Create targeted prompt based on metric
317
+ prompt = f"""
318
+ CROP STRESS ANALYSIS REQUEST
319
+
320
+ {stress_text}
321
+
322
+ METRIC TO ANALYZE: {metric.upper().replace('_', ' ')}
323
+
324
+ Based on the stress detection results above, provide analysis for {metric}.
325
+
326
+ Respond with ONLY a valid JSON object (no markdown):
327
+ {{
328
+ "level": "Low" or "Moderate" or "High",
329
+ "analysis": "4-5 words describing the current state",
330
+ "temporal_trend": "Improving" or "Stable" or "Worsening",
331
+ "recommendations": ["action 1", "action 2"]
332
+ }}
333
+ """
334
+
335
+ response = model.generate_content(prompt)
336
+ response_text = response.text.strip()
337
+
338
+ # Clean markdown if present
339
+ if response_text.startswith("```"):
340
+ lines = response_text.split("\n")
341
+ response_text = "\n".join(lines[1:-1])
342
+ if response_text.startswith("json"):
343
+ response_text = response_text[4:].strip()
344
+
345
+ import json
346
+ return json.loads(response_text)
347
+
348
+ except Exception as e:
349
+ logger.error(f"LLM analysis failed: {e}")
350
+ return {"level": "Moderate", "analysis": "Analysis unavailable", "recommendations": ["Manual inspection recommended"]}
351
 
352
  # ============================================================================
353
  # API ENDPOINTS
 
356
  async def root():
357
  return {
358
  "service": "AGROW Heatmap Service",
359
+ "version": "3.0.0",
360
+ "modes": {"pixelwise": list(PIXELWISE_METRICS.keys()), "llm": list(LLM_METRICS.keys())},
361
+ "all_metrics": ALL_METRICS
362
  }
363
 
364
  @app.get("/health")
365
  async def health():
366
+ return {"status": "healthy", "metrics": ALL_METRICS}
367
 
368
 
369
  @app.post("/generate-heatmap", response_model=HeatmapResponse)
370
  async def generate_heatmap(request: HeatmapRequest):
371
+ """Generate heatmap - auto-detects mode based on metric."""
372
 
373
  req_id = datetime.now().strftime("%H%M%S")
374
 
375
+ # Validate metric
376
+ if request.metric not in ALL_METRICS:
377
+ raise HTTPException(400, f"Invalid metric: {request.metric}. Valid: {ALL_METRICS}")
378
+
379
+ # Determine mode
380
+ is_llm_mode = request.metric in LLM_METRICS
381
+ mode = "llm" if is_llm_mode else "pixelwise"
382
 
383
+ log_section(f"REQUEST [{req_id}] - {mode.upper()} MODE")
384
+ log_detail("Metric", request.metric)
385
+ log_detail("Location", f"({request.center_lat:.6f}, {request.center_lon:.6f})")
386
+ log_detail("Field Size", f"{request.field_size_hectares} ha")
387
 
388
  try:
389
+ # Step 1: Config
390
+ log_step(1, 6 if is_llm_mode else 5, "Loading Sentinel Hub config")
391
  config = get_sh_config()
 
392
 
393
+ # Step 2: Bounding Box
394
+ log_step(2, 6 if is_llm_mode else 5, "Calculating bounding box")
395
  radius_km = np.sqrt(request.field_size_hectares / 100) / 2
396
  lat_off = radius_km / 111
397
  lon_off = radius_km / (111 * np.cos(np.radians(request.center_lat)))
 
402
  ), crs=CRS.WGS84)
403
 
404
  size = bbox_to_dimensions(bbox, resolution=10)
405
+ log_detail("Image Size", f"{size[0]}×{size[1]} pixels")
 
406
 
407
+ # Step 3: Fetch Data
408
+ log_step(3, 6 if is_llm_mode else 5, "Fetching Sentinel-2 data")
409
  end_date = datetime.now()
410
  start_date = end_date - timedelta(days=30)
 
411
 
412
  SENTINEL2 = DataCollection.define(
413
  "S2_CDSE", api_id="sentinel-2-l2a",
 
427
  )
428
 
429
  data = sh_request.get_data()[0]
 
430
  if data is None or data.size == 0:
431
  raise HTTPException(404, "No satellite data available")
432
 
433
  log_detail("Data Shape", f"{data.shape}")
 
434
 
435
+ # Get image data (remove dataMask)
 
436
  img_data = data[:, :, :12]
 
 
 
 
 
 
 
 
 
 
437
 
438
+ # ================================================================
439
+ # PIXEL-WISE MODE
440
+ # ================================================================
441
+ if not is_llm_mode:
442
+ index_type = PIXELWISE_METRICS[request.metric]
443
+
444
+ log_step(4, 5, f"Calculating {index_type} (pixel-wise)")
445
+ index_func = INDEX_FUNCTIONS[index_type]
446
+ index_data = index_func(img_data)
447
+
448
+ log_step(5, 5, "Generating heatmap & patch analysis")
449
+ patches_list, health_summary = analyze_patches_pixelwise(index_data, index_type)
450
+ img_b64, min_v, max_v, mean_v = generate_heatmap_image(
451
+ index_data, index_type, request.gaussian_sigma, request.show_field_boundary
452
+ )
453
+
454
+ log_section(f"SUCCESS [{req_id}]")
455
+
456
+ return HeatmapResponse(
457
+ success=True,
458
+ metric=request.metric,
459
+ mode="pixelwise",
460
+ index_used=index_type,
461
+ min_value=min_v,
462
+ max_value=max_v,
463
+ mean_value=mean_v,
464
+ image_base64=img_b64,
465
+ timestamp=datetime.now().isoformat(),
466
+ image_date=end_date.strftime('%Y-%m-%d'),
467
+ image_size=f"{size[0]}x{size[1]}",
468
+ num_patches=len(patches_list),
469
+ health_summary=health_summary
470
+ )
471
 
472
+ # ================================================================
473
+ # LLM MODE (CNN + Clustering + LLM)
474
+ # ================================================================
475
+ else:
476
+ metric_config = LLM_METRICS[request.metric]
477
+ primary_index = metric_config['primary_index']
478
+
479
+ log_step(4, 6, f"Running CNN stress detection (patch=4, stride=2)")
480
+
481
+ # Reshape data for stress detection: (1, h, w, bands) -> (time, h, w, bands)
482
+ all_images = img_data[np.newaxis, :, :, :] # Add time dimension
483
+
484
+ # Preprocess for stress model
485
+ patches, patch_coords, metadata = preprocess_for_model(
486
+ all_images, patch_size=4, stride=2
487
+ )
488
+
489
+ log_detail("Patches extracted", f"{len(patch_coords)}")
490
+ log_detail("Patch shape", f"{patches.shape}")
491
+
492
+ # Build and run stress model
493
+ stress_model = StressDetectionModel(
494
+ patch_size=metadata['patch_size'],
495
+ num_bands=metadata['num_bands'],
496
+ num_timestamps=1,
497
+ spatial_embedding_dim=64,
498
+ temporal_embedding_dim=64
499
+ )
500
+
501
+ stress_results = stress_model.predict(patches, n_clusters=3, contamination=0.1)
502
+
503
+ # Prepare LLM context
504
+ stress_context = prepare_llm_context(stress_results, patch_coords, patches, metadata)
505
+
506
+ log_detail("Overall stress score", f"{stress_results['stress_scores'].mean():.3f}")
507
+ log_detail("Clusters", f"{stress_results['n_clusters']}")
508
+
509
+ log_step(5, 6, f"Running LLM analysis for {request.metric}")
510
+
511
+ # Calculate primary index for visualization
512
+ index_func = INDEX_FUNCTIONS[primary_index]
513
+ index_data = index_func(img_data)
514
+
515
+ # Run LLM analysis
516
+ llm_result = run_llm_analysis(request.metric, stress_context, {'primary': index_data})
517
+
518
+ log_step(6, 6, "Generating heatmap")
519
+
520
+ # Generate stress-based heatmap
521
+ # Create stress map from patch scores
522
+ h, w = img_data.shape[:2]
523
+ stress_map = np.zeros((h, w))
524
+ for i, (py, px) in enumerate(patch_coords):
525
+ stress_map[py:py+4, px:px+4] = stress_results['stress_scores'][i]
526
+
527
+ img_b64, min_v, max_v, mean_v = generate_heatmap_image(
528
+ stress_map, "Stress", request.gaussian_sigma, request.show_field_boundary, is_stress=True
529
+ )
530
+
531
+ # Get cluster distribution
532
+ cluster_dist = stress_context['field_statistics']['stress_distribution']
533
+
534
+ log_section(f"SUCCESS [{req_id}]")
535
+
536
+ return HeatmapResponse(
537
+ success=True,
538
+ metric=request.metric,
539
+ mode="llm",
540
+ index_used=primary_index,
541
+ min_value=min_v,
542
+ max_value=max_v,
543
+ mean_value=mean_v,
544
+ image_base64=img_b64,
545
+ timestamp=datetime.now().isoformat(),
546
+ image_date=end_date.strftime('%Y-%m-%d'),
547
+ image_size=f"{size[0]}x{size[1]}",
548
+ level=llm_result.get('level', 'Unknown'),
549
+ analysis=llm_result.get('analysis', ''),
550
+ stress_score=float(stress_results['stress_scores'].mean()),
551
+ cluster_distribution=cluster_dist,
552
+ recommendations=llm_result.get('recommendations', [])
553
+ )
554
 
555
  except HTTPException:
556
  raise
 
563
  @app.get("/generate-heatmap-image")
564
  async def get_heatmap_image(
565
  center_lat: float, center_lon: float, field_size_hectares: float,
566
+ metric: str = "soil_moisture", gaussian_sigma: float = 1.5
567
  ):
568
  request = HeatmapRequest(
569
  center_lat=center_lat, center_lon=center_lon,
570
+ field_size_hectares=field_size_hectares, metric=metric,
571
  gaussian_sigma=gaussian_sigma
572
  )
573
  response = await generate_heatmap(request)
llm_analysis.py ADDED
@@ -0,0 +1,422 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ LLM Integration for Vegetation Indices Analysis
3
+ ================================================
4
+
5
+ This module integrates with Google Gemini to analyze vegetation indices
6
+ and provide comprehensive soil and crop insights.
7
+ """
8
+
9
+ import os
10
+ import json
11
+ import numpy as np
12
+ import google.generativeai as genai
13
+ from typing import Dict, Any
14
+
15
+ def configure_gemini():
16
+ """Configure Gemini API with key from environment."""
17
+ api_key = os.environ.get("GEMINI_API_KEY")
18
+ if not api_key:
19
+ raise ValueError("GEMINI_API_KEY not found in environment variables")
20
+ genai.configure(api_key=api_key, transport='rest')
21
+ return genai.GenerativeModel('gemini-flash-latest')
22
+
23
+ def prepare_indices_context(summary_report: Dict, crop_type: str, farmer_context: Dict,
24
+ temporal_stats: Dict = None) -> str:
25
+ """
26
+ Prepare a comprehensive context string for the LLM including temporal statistics.
27
+
28
+ Args:
29
+ summary_report: Dictionary with all indices data
30
+ crop_type: Type of crop being analyzed
31
+ farmer_context: Farmer profile information
32
+ temporal_stats: Dictionary with temporal statistics (optional)
33
+
34
+ Returns:
35
+ Formatted context string
36
+ """
37
+ context = f"""
38
+ CROP MONITORING ANALYSIS REQUEST
39
+
40
+ CROP INFORMATION:
41
+ - Crop Type: {crop_type}
42
+ - Analysis Period: {summary_report['dates'][0]} to {summary_report['dates'][-1]}
43
+ - Number of Images Analyzed: {summary_report['num_images']}
44
+
45
+ FARMER CONTEXT:
46
+ - Role: {farmer_context.get('role', 'Unknown')}
47
+ - Experience: {farmer_context.get('years_farming', 'Unknown')} years
48
+ - Irrigation Method: {farmer_context.get('irrigation_method', 'Unknown')}
49
+ - Farming Goal: {farmer_context.get('farming_goal', 'Unknown')}
50
+
51
+ VEGETATION INDICES DATA (ALL 13 INDICES):
52
+ """
53
+
54
+ for index_name, stats in summary_report['indices'].items():
55
+ context += f"\n{index_name}:"
56
+ context += f"\n - Latest Mean Value: {stats['latest']['mean']:.4f}"
57
+ context += f"\n - Maximum in Field: {stats['max_in_field']:.4f}"
58
+ context += f"\n - Minimum in Field: {stats['min_in_field']:.4f}"
59
+ context += f"\n - Temporal Change (Latest - Oldest): {stats['change']:+.4f}"
60
+ context += f"\n - Temporal Trend (All Values): {stats['mean_values_over_time']}"
61
+
62
+
63
+ # Add temporal statistics if provided
64
+ if temporal_stats:
65
+ context += "\n\nTEMPORAL STATISTICS (FEATURE ENGINEERING):\n"
66
+ for index_name, t_stats in temporal_stats.items():
67
+ context += f"\n{index_name} Temporal Features:"
68
+
69
+ # Mean and std over time
70
+ mean_spatial = float(np.nanmean(t_stats['mean_over_time']))
71
+ std_spatial = float(np.nanmean(t_stats['std_over_time']))
72
+ context += f"\n - Spatial Mean (averaged over time): {mean_spatial:.4f}"
73
+ context += f"\n - Spatial Std (averaged over time): {std_spatial:.4f}"
74
+
75
+ # Max and min over time
76
+ max_val = float(np.nanmax(t_stats['max_over_time']))
77
+ min_val = float(np.nanmin(t_stats['min_over_time']))
78
+ range_val = float(np.nanmean(t_stats['range']))
79
+ context += f"\n - Maximum Value Over Time: {max_val:.4f}"
80
+ context += f"\n - Minimum Value Over Time: {min_val:.4f}"
81
+ context += f"\n - Average Range (Max-Min): {range_val:.4f}"
82
+
83
+ # Temporal trend
84
+ trend_mean = float(np.nanmean(t_stats['temporal_trend']))
85
+ context += f"\n - Average Temporal Trend: {trend_mean:+.4f}"
86
+
87
+ # Rolling average if available
88
+ if 'rolling_avg_3' in t_stats:
89
+ latest_rolling = float(np.nanmean(t_stats['rolling_avg_3'][-1]))
90
+ context += f"\n - Latest Rolling Average (3-period): {latest_rolling:.4f}"
91
+
92
+ return context
93
+
94
+ def format_stress_context(stress_context: Dict) -> str:
95
+ """
96
+ Format stress detection results for LLM prompt.
97
+
98
+ Args:
99
+ stress_context: Dictionary with stress detection results
100
+
101
+ Returns:
102
+ Formatted string with stress patterns, clusters, and anomalies
103
+ """
104
+ if not stress_context:
105
+ return ""
106
+
107
+ c = "\nDEEP LEARNING STRESS DETECTION RESULTS:\n"
108
+ c += "=======================================\n"
109
+
110
+ # Field Statistics
111
+ fs = stress_context.get('field_statistics', {})
112
+ c += f"Overall Field Stress Score: {fs.get('overall_stress', {}).get('mean', 0):.3f} (0=Healthy, 1=Severe Stress)\n"
113
+ c += f"Stress Category Distribution: {fs.get('stress_distribution', {})}\n"
114
+
115
+ # Cluster Statistics (Patterns)
116
+ c += "\nIDENTIFIED CLUSTERING PATTERNS (SPATIAL-TEMPORAL BEHAVIOR):\n"
117
+ for cluster in stress_context.get('cluster_statistics', []):
118
+ c += f" * Cluster {cluster['cluster_id']} ({cluster['percentage']:.1f}% of field):\n"
119
+ c += f" - Average Stress Score: {cluster['stress_score']['mean']:.3f}\n"
120
+ c += f" - Stress Variability (Std): {cluster['stress_score']['std']:.3f}\n"
121
+ # Add key band stats if available to explain *why* it's a cluster
122
+ if 'band_statistics' in cluster:
123
+ c += " - Key Spectral Characteristics:\n"
124
+ # Just show a few key bands to keep it concise
125
+ for band in ['B04', 'B08', 'B11']: # Red, NIR, SWIR
126
+ if band in cluster['band_statistics']:
127
+ val = cluster['band_statistics'][band]['mean']
128
+ c += f" {band}: {val:.4f}\n"
129
+
130
+ # Add temporal trends if available
131
+ if 'temporal_trends' in cluster:
132
+ c += " - Temporal Trends (Change over analysis period):\n"
133
+ for band in ['B04', 'B08', 'B11']: # Red, NIR, SWIR
134
+ if band in cluster['temporal_trends']:
135
+ trend = cluster['temporal_trends'][band]
136
+ c += f" {band}: {trend['trend_direction']} ({trend['change']:+.4f})\n"
137
+
138
+ # Anomaly Information
139
+ anom = stress_context.get('anomaly_information', {})
140
+ c += f"\nANOMALY DETECTION (UNUSUAL PATTERNS):\n"
141
+ c += f"- Total Anomalies Detected: {anom.get('total_anomalies', 0)} patches ({anom.get('anomaly_percentage', 0):.1f}% of field)\n"
142
+ if anom.get('anomaly_patches'):
143
+ c += "- Sample Anomalies:\n"
144
+ for p in anom['anomaly_patches'][:3]:
145
+ c += f" * Patch at {p['coordinates']}: Stress={p['stress_score']:.3f}, Category={p['stress_category']}\n"
146
+
147
+ return c
148
+
149
+ def analyze_with_llm(summary_report: Dict, crop_type: str, farmer_context: Dict,
150
+ center_lat: float, center_lon: float, field_size_hectares: float,
151
+ temporal_stats: Dict = None, stress_context: Dict = None) -> Dict[str, Any]:
152
+ """
153
+ Analyze vegetation indices using Gemini LLM and extract soil insights.
154
+
155
+ Args:
156
+ summary_report: Dictionary with all indices data
157
+ crop_type: Type of crop
158
+ farmer_context: Farmer profile information
159
+ center_lat: Latitude
160
+ center_lon: Longitude
161
+ field_size_hectares: Field size
162
+ temporal_stats: Dictionary with temporal statistics
163
+ stress_context: Dictionary with stress detection results (clustering, anomalies)
164
+
165
+ Returns:
166
+ Dictionary with structured LLM analysis results
167
+ """
168
+ model = configure_gemini()
169
+
170
+ # Prepare context with temporal statistics
171
+ indices_context = prepare_indices_context(summary_report, crop_type, farmer_context, temporal_stats)
172
+
173
+ # Prepare stress context
174
+ stress_text = format_stress_context(stress_context)
175
+
176
+ # Create prompt for LLM
177
+ # Using concatenation to avoid potential f-string parsing issues with long multi-line strings
178
+ prompt = f"{indices_context}\n\n{stress_text}\n\n"
179
+ prompt += "FIELD METADATA:\n"
180
+ # Add location details
181
+ prompt += f"- Location: Latitude {center_lat:.4f}, Longitude {center_lon:.4f}\n"
182
+ prompt += f"- Field Size: {field_size_hectares:.2f} hectares\n\n"
183
+
184
+ prompt += """Based on the vegetation indices data AND the deep learning stress detection results above,
185
+ provide a comprehensive analysis.
186
+
187
+ Use the cluster patterns to identify distinct zones in the field.
188
+ Use the anomaly detection results to pinpoint specific problem areas.
189
+ Analyze the temporal trends in each cluster to determine if stress is worsening or recovering.
190
+ Combine the spectral indices (NDVI, NDWI, etc.) with the stress scores to explain the *cause* of stress.
191
+
192
+ You MUST respond with a valid JSON object (no markdown, no code blocks) with EXACTLY this structure:
193
+
194
+ {
195
+ "soil_moisture": {
196
+ "level": "Low" or "Moderate" or "High",
197
+ "maximum_value": <float>,
198
+ "minimum_value": <float>,
199
+ "analysis": "Analyse mainly SMI patterns,spatial and temporal and variation,then check all other information along with the context given to give four words,not necessarily full sentences,but capture the sense which are very impactful,very simple to understand about the current soil moisture content of the overall field"
200
+ },
201
+ "soil_salinity": {
202
+ "level": "Low" or "Moderate" or "High",
203
+ "analysis": "Analyse mainly NDSI patterns,spatial and temporal and variation,then check all other information along with the context given to give four words,not necessarily full sentences,but capture the sense which are very impactful,very simple to understand about the current soil salinity of the overall field"
204
+ },
205
+ "organic_matter": {
206
+ "level": "Low" or "Moderate" or "High",
207
+ "analysis": "Analyse mainly SOMI patterns,spatial and temporal and variation,then check all other information along with the context given to give four words,not necessarily full sentences,but capture the sense which are very impactful,very simple to understand about the current soil organic matter content of the overall field"
208
+ },
209
+ "soil_fertility": {
210
+ "level": "Low" or "Moderate" or "High",
211
+ "analysis": "Analyse mainly SFI patterns,spatial and temporal and variation,then check all other information along with the context given to give four words,not necessarily full sentences,but capture the sense which are very impactful,very simple to understand about the current soil fertility of the overall field"
212
+ },
213
+ "Pest Rsk": {
214
+ "level": "Low" or "Moderate" or "High",
215
+ "analysis": "Analyse field patterns,temporal and spatial health variations very meticulously,catch the pattern and use the indices as additional confirmation to give accurate pest risk diseases and give 4 words not necessarily connected sentences,but capture the sense which are very impactful,very simple to understand about current pest risk or its spreading pattern"
216
+ },
217
+ "Nutrient Stress": {
218
+ "level": "Low" or "Moderate" or "High",
219
+ "analysis":"Analyse field patterns,temporal and spatial health variations very meticulously,catch the pattern and use NDRE,NDVI,MCARI,OSAVI as primary indices whose trends both spatial and temporal should be closely analysed and four words not neccesarily connected sentences,but capture the sense which are very impactful,very simple to understand about current nutrient stress"
220
+ },
221
+ "Disease Risk": {
222
+ "level": "Low" or "Moderate" or "High",
223
+ "analysis": "Analyse field patterns,temporal and spatial health variations very meticulously,catch the pattern be closely analysed and four words not neccesarily connected sentences,but capture the sense which are very impactful,very simple to understand about current disease rsik of the entire field,preferably a possible attacking agent/pest name"
224
+ },
225
+ "Stress Zone": {
226
+ "level": "Low" or "Moderate" or "Alert",
227
+ "analysis": "Analyse field patterns,temporal and spatial health variations very meticulously,catch the pattern be closely analysed and four words not neccesarily connected sentences,but capture the sense which are very impactful,very simple to understand about current stress zones in the entire field,the location,intensity,duration of stress or stress spread pattern in the field "
228
+ },
229
+ "overall_health": {
230
+ "status": "poor" or "fair" or "good" or "excellent",
231
+ "key_concerns": ["concern1", "concern2"],
232
+ "recommendations": ["recommendation1", "recommendation2"]
233
+ }
234
+ }
235
+
236
+ IMPORTANT GUIDELINES:
237
+ - For soil_moisture.maximum_value and minimum_value, use the SMI index values from the data
238
+ - For soil_salinity.trend, provide EXACTLY four words describing the trend based on SASI values
239
+ - For organic_matter.status, provide EXACTLY four words based on SOMI index values
240
+ - For soil_fertility.status, provide EXACTLY four words (not a sentence) about soil health based on SFI values
241
+ - For vegetation_stress.status, provide EXACTLY four words based on NDVI, EVI, NDRE temporal patterns
242
+ - For photosynthetic_stress.status, provide EXACTLY four words based on PRI, PSRI, RECI values
243
+ - For hotspot_detection.description, provide LESS THAN 6 words about stress direction and intensity
244
+ - For moisture_zones.description, provide NOT MORE THAN 6 words about moisture variation and trend
245
+ - Use spatial statistics (max, min, range) to identify hotspots and zones
246
+ - Consider temporal trends to detect spreading patterns
247
+ - Base your analysis on the actual index values provided
248
+ - Provide actionable insights relevant to the farmer's context
249
+
250
+ Return ONLY the JSON object, no additional text.
251
+ """
252
+
253
+ # Get LLM response
254
+ response = model.generate_content(prompt)
255
+ response_text = response.text.strip()
256
+
257
+ # Remove markdown code blocks if present
258
+ if response_text.startswith("```"):
259
+ lines = response_text.split("\n")
260
+ response_text = "\n".join(lines[1:-1])
261
+ if response_text.startswith("json"):
262
+ response_text = response_text[4:].strip()
263
+
264
+ # Parse JSON response
265
+ try:
266
+ analysis = json.loads(response_text)
267
+ return analysis
268
+ except json.JSONDecodeError as e:
269
+ print(f"Error parsing LLM response: {e}")
270
+ print(f"Response text: {response_text}")
271
+ # Return fallback structure
272
+ return {
273
+ "soil_moisture": {
274
+ "level": "Moderate",
275
+ "maximum_value": summary_report['indices']['SMI']['max_in_field'],
276
+ "minimum_value": summary_report['indices']['SMI']['min_in_field'],
277
+ "analysis": "Unable to parse LLM response"
278
+ },
279
+ "soil_salinity": {
280
+ "level": "Moderate",
281
+ "analysis": "Unable to parse LLM response"
282
+ },
283
+ "organic_matter": {
284
+ "level": "Moderate",
285
+ "analysis": "Unable to parse LLM response"
286
+ },
287
+ "soil_fertility": {
288
+ "level": "Moderate",
289
+ "analysis": "Unable to parse LLM response"
290
+ },
291
+ "pest_risk": {
292
+ "level": "Moderate",
293
+ "analysis": "Unable to parse LLM response"
294
+ },
295
+ "disease_risk": {
296
+ "level": "Moderate",
297
+ "analysis": "Unable to parse LLM response"
298
+ },
299
+ "nutrient_stress": {
300
+ "level": "Moderate",
301
+ "analysis": "Unable to parse LLM response"
302
+ },
303
+ "stress_zone": {
304
+ "level": "Moderate",
305
+ "analysis": "Unable to parse LLM response"
306
+ },
307
+ "overall_health": {
308
+ "status": "fair",
309
+ "key_concerns": ["Analysis unavailable"],
310
+ "recommendations": ["Please review indices manually"]
311
+ },
312
+ "overall_biorisk": 0.5,
313
+ "overall_soil_health": 0.5
314
+ }
315
+
316
+ def format_llm_output(analysis: Dict) -> str:
317
+ """
318
+ Format LLM analysis into a readable report.
319
+
320
+ Args:
321
+ analysis: Dictionary with LLM analysis results
322
+
323
+ Returns:
324
+ Formatted string report
325
+ """
326
+ report = """
327
+ +================================================================+
328
+ | LLM ANALYSIS - SOIL & CROP INSIGHTS |
329
+ +================================================================+
330
+
331
+ SOIL MOISTURE ANALYSIS:
332
+ ----------------------------------------------------------------
333
+ """
334
+
335
+ sm = analysis['soil_moisture']
336
+ report += f" Level: {sm['level'].upper()}\n"
337
+ report += f" Maximum Value: {sm['maximum_value']:.4f}\n"
338
+ report += f" Minimum Value: {sm['minimum_value']:.4f}\n"
339
+ report += f" Analysis: {sm['analysis']}\n"
340
+
341
+ report += """
342
+ SOIL SALINITY ANALYSIS:
343
+ ----------------------------------------------------------------
344
+ """
345
+
346
+ ss = analysis['soil_salinity']
347
+ report += f" Level: {ss['level'].upper()}\n"
348
+ report += f" Analysis: {ss['analysis']}\n"
349
+
350
+ report += """
351
+ ORGANIC MATTER ANALYSIS:
352
+ ----------------------------------------------------------------
353
+ """
354
+
355
+ om = analysis['organic_matter']
356
+ report += f" Level: {om['level'].upper()}\n"
357
+ report += f" Analysis: {om['analysis']}\n"
358
+
359
+ report += """
360
+ SOIL FERTILITY ANALYSIS:
361
+ ----------------------------------------------------------------
362
+ """
363
+
364
+ sf = analysis['soil_fertility']
365
+ report += f" Level: {sf['level'].upper()}\n"
366
+ report += f" Analysis: {sf['analysis']}\n"
367
+
368
+
369
+
370
+ report += """
371
+ PEST RISK ANALYSIS:
372
+ ----------------------------------------------------------------
373
+ """
374
+
375
+ pr = analysis.get('pest_risk', {'level': 'unknown', 'analysis': 'No data'})
376
+ report += f" Level: {pr['level'].upper()}\n"
377
+ report += f" Analysis: {pr['analysis']}\n"
378
+
379
+ report += """
380
+ DISEASE RISK ANALYSIS:
381
+ ----------------------------------------------------------------
382
+ """
383
+
384
+ dr = analysis.get('disease_risk', {'level': 'unknown', 'analysis': 'No data'})
385
+ report += f" Level: {dr['level'].upper()}\n"
386
+ report += f" Analysis: {dr['analysis']}\n"
387
+
388
+ report += """
389
+ NUTRIENT STRESS ANALYSIS:
390
+ ----------------------------------------------------------------
391
+ """
392
+
393
+ ns = analysis.get('nutrient_stress', {'level': 'unknown', 'analysis': 'No data'})
394
+ report += f" Level: {ns['level'].upper()}\n"
395
+ report += f" Analysis: {ns['analysis']}\n"
396
+
397
+ report += """
398
+ STRESS ZONE ANALYSIS:
399
+ ----------------------------------------------------------------
400
+ """
401
+
402
+ sz = analysis.get('stress_zone', {'level': 'unknown', 'analysis': 'No data'})
403
+ report += f" Level: {sz['level'].upper()}\n"
404
+ report += f" Analysis: {sz['analysis']}\n"
405
+
406
+ report += """
407
+ OVERALL CROP HEALTH:
408
+ ----------------------------------------------------------------
409
+ """
410
+
411
+ oh = analysis['overall_health']
412
+ report += f" Status: {oh['status'].upper()}\n"
413
+ report += f"\n Key Concerns:\n"
414
+ for concern in oh['key_concerns']:
415
+ report += f" • {concern}\n"
416
+ report += f"\n Recommendations:\n"
417
+ for rec in oh['recommendations']:
418
+ report += f" • {rec}\n"
419
+
420
+ report += "\n" + "=" * 64 + "\n"
421
+
422
+ return report
requirements.txt CHANGED
@@ -7,3 +7,6 @@ matplotlib>=3.7.0
7
  Pillow>=9.0.0
8
  sentinelhub>=3.9.0
9
  python-dotenv>=1.0.0
 
 
 
 
7
  Pillow>=9.0.0
8
  sentinelhub>=3.9.0
9
  python-dotenv>=1.0.0
10
+ tensorflow>=2.12.0
11
+ scikit-learn>=1.3.0
12
+ google-generativeai>=0.3.0
stress_detection_model.py ADDED
@@ -0,0 +1,475 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Stress Detection Model
3
+ =======================
4
+
5
+ Deep learning model for crop stress detection using spatial-temporal encoding.
6
+ Architecture: Spatial CNN → Temporal LSTM → Clustering → Anomaly Detection
7
+ """
8
+
9
+ import numpy as np
10
+ import tensorflow as tf
11
+ from tensorflow import keras
12
+ from tensorflow.keras import layers
13
+ from sklearn.cluster import KMeans
14
+ from sklearn.ensemble import IsolationForest
15
+ from sklearn.preprocessing import StandardScaler
16
+ from sklearn.metrics import silhouette_score
17
+ from typing import Tuple, Dict, List
18
+ import warnings
19
+ warnings.filterwarnings('ignore')
20
+
21
+
22
+ class SpatialEncoder(keras.Model):
23
+ """
24
+ CNN-based spatial feature extractor.
25
+ Processes each timestamp independently to extract spatial features.
26
+ """
27
+
28
+ def __init__(self, embedding_dim=128):
29
+ super(SpatialEncoder, self).__init__()
30
+
31
+ # Convolutional layers
32
+ self.conv1 = layers.Conv2D(32, (3, 3), activation='relu', padding='same')
33
+ self.bn1 = layers.BatchNormalization()
34
+ self.pool1 = layers.MaxPooling2D((2, 2))
35
+ self.dropout1 = layers.Dropout(0.25)
36
+
37
+ self.conv2 = layers.Conv2D(64, (3, 3), activation='relu', padding='same')
38
+ self.bn2 = layers.BatchNormalization()
39
+ self.pool2 = layers.MaxPooling2D((2, 2))
40
+ self.dropout2 = layers.Dropout(0.25)
41
+
42
+ self.conv3 = layers.Conv2D(128, (3, 3), activation='relu', padding='same')
43
+ self.bn3 = layers.BatchNormalization()
44
+
45
+ # Global pooling and dense layers
46
+ self.global_pool = layers.GlobalAveragePooling2D()
47
+ self.dense1 = layers.Dense(256, activation='relu')
48
+ self.dropout3 = layers.Dropout(0.3)
49
+ self.dense2 = layers.Dense(embedding_dim, activation='relu')
50
+
51
+ def call(self, x, training=False):
52
+ # x shape: (batch, height, width, channels)
53
+ x = self.conv1(x)
54
+ x = self.bn1(x, training=training)
55
+ x = self.pool1(x)
56
+ x = self.dropout1(x, training=training)
57
+
58
+ x = self.conv2(x)
59
+ x = self.bn2(x, training=training)
60
+ x = self.pool2(x)
61
+ x = self.dropout2(x, training=training)
62
+
63
+ x = self.conv3(x)
64
+ x = self.bn3(x, training=training)
65
+
66
+ x = self.global_pool(x)
67
+ x = self.dense1(x)
68
+ x = self.dropout3(x, training=training)
69
+ x = self.dense2(x)
70
+
71
+ return x # (batch, embedding_dim)
72
+
73
+
74
+ class TemporalEncoder(keras.Model):
75
+ """
76
+ LSTM-based temporal feature extractor.
77
+ Processes sequence of spatial embeddings to capture temporal patterns.
78
+ """
79
+
80
+ def __init__(self, embedding_dim=128, lstm_units=64):
81
+ super(TemporalEncoder, self).__init__()
82
+
83
+ self.lstm = layers.Bidirectional(
84
+ layers.LSTM(lstm_units, return_sequences=False, dropout=0.2)
85
+ )
86
+ self.dense = layers.Dense(embedding_dim, activation='relu')
87
+
88
+ def call(self, x, training=False):
89
+ # x shape: (batch, time, spatial_embedding_dim)
90
+ x = self.lstm(x, training=training)
91
+ x = self.dense(x)
92
+ return x # (batch, embedding_dim)
93
+
94
+
95
+ class StressDetectionModel:
96
+ """
97
+ Complete stress detection pipeline with spatial-temporal encoding,
98
+ clustering, and anomaly detection.
99
+ """
100
+
101
+ def __init__(self, patch_size=16, num_bands=8, num_timestamps=10,
102
+ spatial_embedding_dim=128, temporal_embedding_dim=128):
103
+ self.patch_size = patch_size
104
+ self.num_bands = num_bands
105
+ self.num_timestamps = num_timestamps
106
+ self.spatial_embedding_dim = spatial_embedding_dim
107
+ self.temporal_embedding_dim = temporal_embedding_dim
108
+
109
+ # Build encoders
110
+ self.spatial_encoder = SpatialEncoder(embedding_dim=spatial_embedding_dim)
111
+ self.temporal_encoder = TemporalEncoder(
112
+ embedding_dim=temporal_embedding_dim,
113
+ lstm_units=64
114
+ )
115
+
116
+ # Build spatial encoder input
117
+ self.spatial_encoder.build((None, patch_size, patch_size, num_bands))
118
+
119
+ # Clustering and anomaly detection (fitted during inference)
120
+ self.kmeans = None
121
+ self.anomaly_detector = None
122
+ self.scaler = StandardScaler()
123
+
124
+ def encode_spatial_features(self, patches: np.ndarray) -> np.ndarray:
125
+ """
126
+ Extract spatial features from all patches and timestamps.
127
+
128
+ Args:
129
+ patches: Array of shape (num_patches, time, height, width, bands)
130
+
131
+ Returns:
132
+ spatial_embeddings: Array of shape (num_patches, time, spatial_embedding_dim)
133
+ """
134
+ num_patches, time, height, width, bands = patches.shape
135
+
136
+ # Reshape to process all patches and timestamps together
137
+ # (num_patches * time, height, width, bands)
138
+ reshaped = patches.reshape(-1, height, width, bands)
139
+
140
+ # Extract spatial features
141
+ spatial_features = self.spatial_encoder(reshaped, training=False).numpy()
142
+
143
+ # Reshape back to (num_patches, time, embedding_dim)
144
+ spatial_embeddings = spatial_features.reshape(
145
+ num_patches, time, self.spatial_embedding_dim
146
+ )
147
+
148
+ return spatial_embeddings
149
+
150
+ def encode_temporal_features(self, spatial_embeddings: np.ndarray) -> np.ndarray:
151
+ """
152
+ Extract temporal features from spatial embeddings.
153
+
154
+ Args:
155
+ spatial_embeddings: Array of shape (num_patches, time, spatial_embedding_dim)
156
+
157
+ Returns:
158
+ temporal_embeddings: Array of shape (num_patches, temporal_embedding_dim)
159
+ """
160
+ temporal_embeddings = self.temporal_encoder(
161
+ spatial_embeddings, training=False
162
+ ).numpy()
163
+
164
+ return temporal_embeddings
165
+
166
+ def cluster_stress_patterns(self, embeddings: np.ndarray, n_clusters=4) -> Tuple[np.ndarray, np.ndarray]:
167
+ """
168
+ Cluster embeddings into stress categories and compute stress scores.
169
+
170
+ Args:
171
+ embeddings: Array of shape (num_patches, embedding_dim)
172
+ n_clusters: Number of clusters (4: high, moderate, low, noise)
173
+
174
+ Returns:
175
+ cluster_labels: Cluster assignment for each patch
176
+ stress_scores: Normalized stress scores in [0, 1]
177
+ """
178
+ # Standardize embeddings
179
+ embeddings_scaled = self.scaler.fit_transform(embeddings)
180
+
181
+ # K-Means clustering
182
+ self.kmeans = KMeans(n_clusters=n_clusters, random_state=42, n_init=10)
183
+ cluster_labels = self.kmeans.fit_predict(embeddings_scaled)
184
+
185
+ # Compute stress scores based on distance to cluster centers
186
+ distances = self.kmeans.transform(embeddings_scaled)
187
+
188
+ # For each patch, compute stress score as weighted distance to all clusters
189
+ # Normalize to [0, 1] range
190
+ stress_scores = np.min(distances, axis=1) # Distance to nearest cluster
191
+ stress_scores = 1 - (stress_scores - stress_scores.min()) / (stress_scores.max() - stress_scores.min() + 1e-10)
192
+
193
+ # Alternative: Use cluster centers to assign stress levels
194
+ # Identify which cluster represents highest stress (largest distance from origin)
195
+ cluster_stress_levels = np.linalg.norm(self.kmeans.cluster_centers_, axis=1)
196
+ cluster_stress_levels = (cluster_stress_levels - cluster_stress_levels.min()) / \
197
+ (cluster_stress_levels.max() - cluster_stress_levels.min() + 1e-10)
198
+
199
+ # Assign stress score based on cluster membership
200
+ stress_scores = cluster_stress_levels[cluster_labels]
201
+
202
+ return cluster_labels, stress_scores
203
+
204
+ def detect_anomalies(self, embeddings: np.ndarray, contamination=0.1) -> Tuple[np.ndarray, np.ndarray]:
205
+ """
206
+ Detect anomalous stress patterns using Isolation Forest.
207
+
208
+ Args:
209
+ embeddings: Array of shape (num_patches, embedding_dim)
210
+ contamination: Expected proportion of anomalies
211
+
212
+ Returns:
213
+ anomaly_labels: 1 for normal, -1 for anomaly
214
+ anomaly_scores: Anomaly scores (lower = more anomalous)
215
+ """
216
+ self.anomaly_detector = IsolationForest(
217
+ contamination=contamination,
218
+ random_state=42
219
+ )
220
+ anomaly_labels = self.anomaly_detector.fit_predict(embeddings)
221
+ anomaly_scores = self.anomaly_detector.score_samples(embeddings)
222
+
223
+ return anomaly_labels, anomaly_scores
224
+
225
+ def predict(self, patches: np.ndarray, n_clusters=4, contamination=0.1) -> Dict:
226
+ """
227
+ Complete stress detection pipeline.
228
+
229
+ Args:
230
+ patches: Array of shape (num_patches, time, height, width, bands)
231
+ n_clusters: Number of stress clusters
232
+ contamination: Expected proportion of anomalies
233
+
234
+ Returns:
235
+ results: Dictionary with all predictions and embeddings
236
+ """
237
+ # Step 1: Spatial encoding
238
+ spatial_embeddings = self.encode_spatial_features(patches)
239
+
240
+ # Step 2: Temporal encoding
241
+ temporal_embeddings = self.encode_temporal_features(spatial_embeddings)
242
+
243
+ # Step 3: Clustering
244
+ cluster_labels, stress_scores = self.cluster_stress_patterns(
245
+ temporal_embeddings, n_clusters=n_clusters
246
+ )
247
+
248
+ # Step 4: Anomaly detection
249
+ anomaly_labels, anomaly_scores = self.detect_anomalies(temporal_embeddings, contamination=contamination)
250
+
251
+ return {
252
+ 'spatial_embeddings': spatial_embeddings,
253
+ 'temporal_embeddings': temporal_embeddings,
254
+ 'cluster_labels': cluster_labels,
255
+ 'stress_scores': stress_scores,
256
+ 'anomaly_labels': anomaly_labels,
257
+ 'anomaly_scores': anomaly_scores,
258
+ 'cluster_centers': self.kmeans.cluster_centers_,
259
+ 'n_clusters': n_clusters
260
+ }
261
+
262
+
263
+ def get_stress_category(stress_score: float) -> str:
264
+ """Convert stress score to category label."""
265
+ if stress_score < 0.25:
266
+ return "Low Stress"
267
+ elif stress_score < 0.5:
268
+ return "Moderate Stress"
269
+ elif stress_score < 0.75:
270
+ return "High Stress"
271
+ else:
272
+ return "Severe Stress"
273
+
274
+
275
+ def find_optimal_clusters(embeddings: np.ndarray,
276
+ min_clusters: int = 2,
277
+ max_clusters: int = 10) -> Tuple[int, Dict]:
278
+ """
279
+ Find optimal number of clusters using elbow method and silhouette score.
280
+
281
+ Args:
282
+ embeddings: Array of shape (num_samples, embedding_dim)
283
+ min_clusters: Minimum number of clusters to test
284
+ max_clusters: Maximum number of clusters to test
285
+
286
+ Returns:
287
+ optimal_k: Optimal number of clusters
288
+ metrics: Dictionary with inertia and silhouette scores
289
+ """
290
+ print("\nFinding optimal number of clusters...")
291
+
292
+ scaler = StandardScaler()
293
+ embeddings_scaled = scaler.fit_transform(embeddings)
294
+
295
+ inertias = []
296
+ silhouette_scores = []
297
+ k_range = range(min_clusters, max_clusters + 1)
298
+
299
+ for k in k_range:
300
+ kmeans = KMeans(n_clusters=k, random_state=42, n_init=10)
301
+ labels = kmeans.fit_predict(embeddings_scaled)
302
+
303
+ inertias.append(kmeans.inertia_)
304
+
305
+ # Calculate silhouette score (higher is better)
306
+ if k > 1:
307
+ sil_score = silhouette_score(embeddings_scaled, labels)
308
+ silhouette_scores.append(sil_score)
309
+ else:
310
+ silhouette_scores.append(0)
311
+
312
+ print(f" k={k}: Inertia={kmeans.inertia_:.2f}, Silhouette={silhouette_scores[-1]:.3f}")
313
+
314
+ # Find elbow using rate of change
315
+ inertia_diffs = np.diff(inertias)
316
+ inertia_diffs_2 = np.diff(inertia_diffs)
317
+
318
+ # Optimal k is where second derivative is maximum (elbow point)
319
+ elbow_k = min_clusters + np.argmax(np.abs(inertia_diffs_2)) + 1
320
+
321
+ # Also consider silhouette score
322
+ best_silhouette_k = min_clusters + np.argmax(silhouette_scores)
323
+
324
+ # Use silhouette score as primary metric, elbow as secondary
325
+ optimal_k = best_silhouette_k
326
+
327
+ print(f"\n[OK] Optimal clusters: {optimal_k} (Elbow: {elbow_k}, Best Silhouette: {best_silhouette_k})")
328
+
329
+ metrics = {
330
+ 'k_range': list(k_range),
331
+ 'inertias': inertias,
332
+ 'silhouette_scores': silhouette_scores,
333
+ 'optimal_k': optimal_k,
334
+ 'elbow_k': elbow_k,
335
+ 'best_silhouette_k': best_silhouette_k
336
+ }
337
+
338
+ return optimal_k, metrics
339
+
340
+
341
+ def prepare_llm_context(results: Dict,
342
+ patch_coords: List,
343
+ patches: np.ndarray,
344
+ metadata: Dict) -> Dict:
345
+ """
346
+ Prepare comprehensive context for LLM including cluster statistics and anomaly information.
347
+
348
+ Args:
349
+ results: Dictionary from model.predict()
350
+ patch_coords: List of (h, w) coordinates for each patch
351
+ patches: Original patches array
352
+ metadata: Preprocessing metadata
353
+
354
+ Returns:
355
+ context: Dictionary with cluster-wise and anomaly statistics
356
+ """
357
+ cluster_labels = results['cluster_labels']
358
+ stress_scores = results['stress_scores']
359
+ anomaly_labels = results['anomaly_labels']
360
+ temporal_embeddings = results['temporal_embeddings']
361
+
362
+ # Get anomaly scores (distance from decision boundary)
363
+ anomaly_scores = results.get('anomaly_scores',
364
+ results['anomaly_labels'].astype(float))
365
+
366
+ # Cluster-wise statistics
367
+ cluster_stats = []
368
+ for cluster_id in range(results['n_clusters']):
369
+ cluster_mask = cluster_labels == cluster_id
370
+ cluster_patches = patches[cluster_mask]
371
+ cluster_stress = stress_scores[cluster_mask]
372
+ cluster_embeddings = temporal_embeddings[cluster_mask]
373
+
374
+ # Calculate statistics for this cluster
375
+ stats = {
376
+ 'cluster_id': int(cluster_id),
377
+ 'num_patches': int(np.sum(cluster_mask)),
378
+ 'percentage': float(100 * np.sum(cluster_mask) / len(cluster_labels)),
379
+ 'stress_score': {
380
+ 'mean': float(cluster_stress.mean()),
381
+ 'std': float(cluster_stress.std()),
382
+ 'min': float(cluster_stress.min()),
383
+ 'max': float(cluster_stress.max())
384
+ },
385
+ 'embedding_stats': {
386
+ 'mean_norm': float(np.linalg.norm(cluster_embeddings.mean(axis=0))),
387
+ 'std_norm': float(np.linalg.norm(cluster_embeddings.std(axis=0)))
388
+ },
389
+ 'band_statistics': {}
390
+ }
391
+
392
+ # Calculate per-band statistics for this cluster
393
+ for band_idx, band_name in enumerate(metadata['selected_bands']):
394
+ band_data = cluster_patches[:, :, :, :, band_idx] # (patches, time, h, w)
395
+ stats['band_statistics'][band_name] = {
396
+ 'mean': float(np.nanmean(band_data)),
397
+ 'std': float(np.nanstd(band_data)),
398
+ 'min': float(np.nanmin(band_data)),
399
+ 'max': float(np.nanmax(band_data))
400
+ }
401
+
402
+ cluster_stats.append(stats)
403
+
404
+ # Calculate temporal trends for this cluster
405
+ # Shape: (num_patches, time, h, w, bands) -> (time, bands)
406
+ if cluster_patches.shape[0] > 0:
407
+ cluster_time_series = np.nanmean(cluster_patches, axis=(0, 2, 3))
408
+
409
+ stats['temporal_trends'] = {}
410
+ for band_idx, band_name in enumerate(metadata['selected_bands']):
411
+ series = cluster_time_series[:, band_idx]
412
+ if len(series) > 1:
413
+ change = float(series[-1] - series[0])
414
+ trend_direction = "stable"
415
+ if change > 0.05: trend_direction = "increasing"
416
+ elif change < -0.05: trend_direction = "decreasing"
417
+
418
+ stats['temporal_trends'][band_name] = {
419
+ 'change': change,
420
+ 'trend_direction': trend_direction,
421
+ 'latest_value': float(series[-1]),
422
+ 'earliest_value': float(series[0])
423
+ }
424
+
425
+ # Anomaly information
426
+ anomaly_mask = anomaly_labels == -1
427
+ anomaly_indices = np.where(anomaly_mask)[0]
428
+
429
+ anomaly_info = {
430
+ 'total_anomalies': int(np.sum(anomaly_mask)),
431
+ 'anomaly_percentage': float(100 * np.sum(anomaly_mask) / len(anomaly_labels)),
432
+ 'anomaly_patches': []
433
+ }
434
+
435
+ # Detailed info for each anomaly patch
436
+ for idx in anomaly_indices[:20]: # Limit to first 20 anomalies
437
+ patch_info = {
438
+ 'patch_id': int(idx),
439
+ 'coordinates': patch_coords[idx],
440
+ 'stress_score': float(stress_scores[idx]),
441
+ 'stress_category': get_stress_category(stress_scores[idx]),
442
+ 'cluster_id': int(cluster_labels[idx]),
443
+ 'anomaly_score': float(anomaly_scores[idx]) if hasattr(anomaly_scores, '__getitem__') else -1.0,
444
+ 'embedding_norm': float(np.linalg.norm(temporal_embeddings[idx]))
445
+ }
446
+ anomaly_info['anomaly_patches'].append(patch_info)
447
+
448
+ # Overall field statistics
449
+ field_stats = {
450
+ 'total_patches': len(cluster_labels),
451
+ 'patch_size': metadata['patch_size'],
452
+ 'num_bands': metadata['num_bands'],
453
+ 'selected_bands': metadata['selected_bands'],
454
+ 'overall_stress': {
455
+ 'mean': float(stress_scores.mean()),
456
+ 'std': float(stress_scores.std()),
457
+ 'min': float(stress_scores.min()),
458
+ 'max': float(stress_scores.max())
459
+ },
460
+ 'stress_distribution': {
461
+ 'low': int(np.sum(stress_scores < 0.25)),
462
+ 'moderate': int(np.sum((stress_scores >= 0.25) & (stress_scores < 0.5))),
463
+ 'high': int(np.sum((stress_scores >= 0.5) & (stress_scores < 0.75))),
464
+ 'severe': int(np.sum(stress_scores >= 0.75))
465
+ }
466
+ }
467
+
468
+ context = {
469
+ 'field_statistics': field_stats,
470
+ 'cluster_statistics': cluster_stats,
471
+ 'anomaly_information': anomaly_info
472
+ }
473
+
474
+ return context
475
+
stress_detection_preprocessing.py ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Stress Detection Preprocessing Module
3
+ ======================================
4
+
5
+ Prepares Sentinel-2 multi-spectral data for stress detection model.
6
+ Includes band harmonization and normalization.
7
+ """
8
+
9
+ import numpy as np
10
+ from typing import Tuple, List
11
+
12
+ # Band indices in the 12-band Sentinel-2 data
13
+ BAND_INDICES = {
14
+ 'B02': 1, # Blue
15
+ 'B03': 2, # Green
16
+ 'B04': 3, # Red
17
+ 'B05': 4, # Red Edge 1
18
+ 'B08': 7, # NIR
19
+ 'B8A': 8, # NIR Narrow
20
+ 'B11': 10, # SWIR1
21
+ 'B12': 11 # SWIR2
22
+ }
23
+
24
+ # Selected bands for stress detection (8 major bands)
25
+ SELECTED_BANDS = ['B02', 'B03', 'B04', 'B05', 'B08', 'B8A', 'B11', 'B12']
26
+
27
+
28
+ def extract_major_bands(all_images: np.ndarray) -> np.ndarray:
29
+ """
30
+ Extract 8 major bands from 12-band Sentinel-2 data.
31
+
32
+ Args:
33
+ all_images: Array of shape (time, height, width, 12)
34
+
35
+ Returns:
36
+ Array of shape (time, height, width, 8) with selected bands
37
+ """
38
+ band_idx = [BAND_INDICES[band] for band in SELECTED_BANDS]
39
+ return all_images[:, :, :, band_idx]
40
+
41
+
42
+ def harmonize_bands(images: np.ndarray) -> np.ndarray:
43
+ """
44
+ Harmonize band data to same scale [0, 1].
45
+
46
+ Sentinel-2 reflectance values are already in [0, 1] range after DN/10000 conversion.
47
+ This function ensures all bands are properly normalized and handles any outliers.
48
+
49
+ Args:
50
+ images: Array of shape (time, height, width, bands)
51
+
52
+ Returns:
53
+ Harmonized array with values clipped to [0, 1]
54
+ """
55
+ # Clip to [0, 1] range to handle any outliers
56
+ harmonized = np.clip(images, 0, 1)
57
+
58
+ # Additional per-band normalization to ensure uniform scale
59
+ # Use percentile-based normalization to handle outliers
60
+ time, height, width, bands = harmonized.shape
61
+
62
+ for b in range(bands):
63
+ band_data = harmonized[:, :, :, b]
64
+
65
+ # Calculate 2nd and 98th percentiles to handle outliers
66
+ p2 = np.nanpercentile(band_data, 2)
67
+ p98 = np.nanpercentile(band_data, 98)
68
+
69
+ # Normalize to [0, 1] using percentiles
70
+ if p98 > p2:
71
+ harmonized[:, :, :, b] = np.clip((band_data - p2) / (p98 - p2), 0, 1)
72
+
73
+ return harmonized
74
+
75
+
76
+ def handle_nan_values(images: np.ndarray, method='mean') -> np.ndarray:
77
+ """
78
+ Handle NaN values in the data.
79
+
80
+ Args:
81
+ images: Array of shape (time, height, width, bands)
82
+ method: 'mean', 'zero', or 'interpolate'
83
+
84
+ Returns:
85
+ Array with NaN values handled
86
+ """
87
+ if method == 'zero':
88
+ return np.nan_to_num(images, nan=0.0)
89
+ elif method == 'mean':
90
+ # Replace NaN with temporal mean for each pixel
91
+ return np.where(np.isnan(images),
92
+ np.nanmean(images, axis=0, keepdims=True),
93
+ images)
94
+ elif method == 'interpolate':
95
+ # Simple linear interpolation along time axis
96
+ result = images.copy()
97
+ time, height, width, bands = images.shape
98
+
99
+ for h in range(height):
100
+ for w in range(width):
101
+ for b in range(bands):
102
+ pixel_series = result[:, h, w, b]
103
+ if np.any(np.isnan(pixel_series)):
104
+ # Interpolate NaN values
105
+ mask = ~np.isnan(pixel_series)
106
+ if np.any(mask):
107
+ indices = np.arange(time)
108
+ result[:, h, w, b] = np.interp(
109
+ indices, indices[mask], pixel_series[mask]
110
+ )
111
+ else:
112
+ result[:, h, w, b] = 0.0
113
+ return result
114
+ else:
115
+ return images
116
+
117
+
118
+ def create_patches(images: np.ndarray, patch_size: int = 4, stride: int = 2) -> Tuple[np.ndarray, List]:
119
+ """
120
+ Create overlapping patches from images for spatial analysis.
121
+
122
+ Args:
123
+ images: Array of shape (time, height, width, bands)
124
+ patch_size: Size of each patch
125
+ stride: Stride for patch extraction
126
+
127
+ Returns:
128
+ patches: Array of shape (num_patches, time, patch_size, patch_size, bands)
129
+ patch_coords: List of (h_start, w_start) coordinates for each patch
130
+ """
131
+ time, height, width, bands = images.shape
132
+ patches = []
133
+ patch_coords = []
134
+
135
+ for h in range(0, height - patch_size + 1, stride):
136
+ for w in range(0, width - patch_size + 1, stride):
137
+ patch = images[:, h:h+patch_size, w:w+patch_size, :]
138
+
139
+ # Only include patches with sufficient valid data
140
+ valid_ratio = np.sum(~np.isnan(patch)) / patch.size
141
+ if valid_ratio > 0.5: # At least 50% valid data
142
+ patches.append(patch)
143
+ patch_coords.append((h, w))
144
+
145
+ if len(patches) == 0:
146
+ # If no valid patches, create at least one from center
147
+ h_center = (height - patch_size) // 2
148
+ w_center = (width - patch_size) // 2
149
+ patch = images[:, h_center:h_center+patch_size, w_center:w_center+patch_size, :]
150
+ patches.append(patch)
151
+ patch_coords.append((h_center, w_center))
152
+
153
+ return np.array(patches), patch_coords
154
+
155
+
156
+ def preprocess_for_model(all_images: np.ndarray,
157
+ patch_size: int = 4,
158
+ stride: int = 2) -> Tuple[np.ndarray, List, dict]:
159
+ """
160
+ Complete preprocessing pipeline for stress detection model.
161
+
162
+ Args:
163
+ all_images: Raw images of shape (time, height, width, 12)
164
+ patch_size: Size of patches for spatial analysis
165
+ stride: Stride for patch extraction
166
+
167
+ Returns:
168
+ patches: Preprocessed patches ready for model
169
+ patch_coords: Coordinates of each patch
170
+ metadata: Dictionary with preprocessing information
171
+ """
172
+ print("Preprocessing data for stress detection model...")
173
+
174
+ # Step 1: Extract major bands
175
+ print(" [1/4] Extracting 8 major bands...")
176
+ major_bands = extract_major_bands(all_images)
177
+
178
+ # Step 2: Harmonize bands to same scale
179
+ print(" [2/4] Harmonizing bands to [0, 1] scale...")
180
+ harmonized = harmonize_bands(major_bands)
181
+
182
+ # Step 3: Handle NaN values
183
+ print(" [3/4] Handling NaN values...")
184
+ clean_data = handle_nan_values(harmonized, method='mean')
185
+
186
+ # Step 4: Create patches
187
+ print(" [4/4] Creating spatial patches...")
188
+ patches, patch_coords = create_patches(clean_data, patch_size, stride)
189
+
190
+ metadata = {
191
+ 'original_shape': all_images.shape,
192
+ 'selected_bands': SELECTED_BANDS,
193
+ 'num_bands': len(SELECTED_BANDS),
194
+ 'patch_size': patch_size,
195
+ 'stride': stride,
196
+ 'num_patches': len(patches),
197
+ 'harmonized': True
198
+ }
199
+
200
+ print(f"[OK] Preprocessing complete. Created {len(patches)} patches.")
201
+ print(f" Patch shape: {patches.shape}")
202
+
203
+ return patches, patch_coords, metadata
vegetation_indices.py CHANGED
@@ -113,6 +113,13 @@ def calculate_sfi(img: np.ndarray) -> np.ndarray:
113
  sasi = calculate_sasi(img)
114
  return (ndvi * somi) / (sasi + 1e-10)
115
 
 
 
 
 
 
 
 
116
  # Index registry
117
  INDEX_FUNCTIONS = {
118
  'NDVI': calculate_ndvi,
@@ -127,7 +134,8 @@ INDEX_FUNCTIONS = {
127
  'MCARI': calculate_mcari,
128
  'SASI': calculate_sasi,
129
  'SOMI': calculate_somi,
130
- 'SFI': calculate_sfi
 
131
  }
132
 
133
  # ==================== BATCH CALCULATION ====================
 
113
  sasi = calculate_sasi(img)
114
  return (ndvi * somi) / (sasi + 1e-10)
115
 
116
+ def calculate_gndvi(img: np.ndarray) -> np.ndarray:
117
+ """GNDVI (Green NDVI) = (NIR - GREEN) / (NIR + GREEN)
118
+ Highly sensitive to chlorophyll content and nitrogen status"""
119
+ nir = img[:, :, 7] # B08
120
+ green = img[:, :, 2] # B03
121
+ return (nir - green) / (nir + green + 1e-10)
122
+
123
  # Index registry
124
  INDEX_FUNCTIONS = {
125
  'NDVI': calculate_ndvi,
 
134
  'MCARI': calculate_mcari,
135
  'SASI': calculate_sasi,
136
  'SOMI': calculate_somi,
137
+ 'SFI': calculate_sfi,
138
+ 'GNDVI': calculate_gndvi
139
  }
140
 
141
  # ==================== BATCH CALCULATION ====================