ritujam12/FarmMatrix_Time_Series_API
0
1import os2import json3import logging4import warnings5import asyncio6from datetime import datetime, date, timedelta7from dateutil.relativedelta import relativedelta8from io import BytesIO9import base6410 11import numpy as np12import pandas as pd13import matplotlib14matplotlib.use("Agg")15import matplotlib.pyplot as plt16import matplotlib.patches as mpatches17from matplotlib.lines import Line2D18from scipy import stats19 20from fastapi import FastAPI, HTTPException21from fastapi.responses import HTMLResponse, Response22from fastapi.staticfiles import StaticFiles23from fastapi.middleware.cors import CORSMiddleware24from pydantic import BaseModel25from typing import List, Any26import uvicorn27import ee28from openai import OpenAI29 30warnings.filterwarnings("ignore")31logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")32 33# ─────────────────────────────────────────────────────────────────────────────34# CONFIG35# ─────────────────────────────────────────────────────────────────────────────36GROQ_API_KEY = os.environ.get("GROQ_API_KEY")37GROQ_MODEL = "llama-3.3-70b-versatile"38 39IDEAL_RANGES = {40 "pH": (6.5, 7.5),41 "Salinity": (None, 1.0),42 "Organic Carbon": (0.75, 1.50),43 "CEC": (10, 30),44 "LST": (15, 35),45 "NDVI": (0.2, 0.8),46 "EVI": (0.2, 0.8),47 "FVC": (0.3, 0.8),48 "NDWI": (-0.3, 0.2),49 "Nitrogen": (280, 560),50 "Phosphorus": (11, 22),51 "Potassium": (108, 280),52 "Calcium": (400, 800),53 "Magnesium": (50, 200),54 "Sulphur": (10, 40),55}56 57UNIT_MAP = {58 "pH": "", "Salinity": " mS/cm", "Organic Carbon": " %",59 "CEC": " cmol/kg", "LST": " °C",60 "NDWI": "", "NDVI": "", "EVI": "", "FVC": "",61 "Nitrogen": " kg/ha", "Phosphorus": " kg/ha", "Potassium": " kg/ha",62 "Calcium": " kg/ha", "Magnesium": " kg/ha", "Sulphur": " kg/ha",63}64 65FULL_NAME = {66 "Soil Health Score": "Overall Soil Health Score (ICAR)",67 "pH": "Soil pH",68 "Salinity": "Salinity / EC (mS/cm)",69 "Organic Carbon": "Organic Carbon (%)",70 "CEC": "Cation Exchange Capacity (cmol/kg)",71 "LST": "Land Surface Temperature (°C)",72 "NDVI": "NDVI — Vegetation Density",73 "EVI": "EVI — Enhanced Vegetation Index",74 "FVC": "FVC — Fractional Vegetation Cover",75 "NDWI": "NDWI — Soil Moisture Index",76 "Nitrogen": "Nitrogen (kg/ha)",77 "Phosphorus": "Phosphorus (kg/ha)",78 "Potassium": "Potassium (kg/ha)",79 "Calcium": "Calcium (kg/ha)",80 "Magnesium": "Magnesium (kg/ha)",81 "Sulphur": "Sulphur (kg/ha)",82}83 84COLOURS = {85 "Nitrogen": "#1E88E5", "Phosphorus": "#E53935", "Potassium": "#8E24AA",86 "Calcium": "#FB8C00", "Magnesium": "#43A047", "Sulphur": "#F9A825",87 "pH": "#00ACC1", "Organic Carbon": "#6D4C41", "Salinity": "#EF5350",88 "CEC": "#546E7A", "LST": "#FF7043",89 "NDVI": "#2E7D32", "EVI": "#388E3C", "FVC": "#81C784", "NDWI": "#1565C0",90}91 92ALL_BANDS = ["B2", "B3", "B4", "B5", "B6", "B7", "B8", "B8A", "B11", "B12"]93DOT_C = {"good": "#43A047", "low": "#FF9800", "high": "#E53935", "na": "#9E9E9E"}94 95# ─────────────────────────────────────────────────────────────────────────────96# EARTH ENGINE INIT97# ─────────────────────────────────────────────────────────────────────────────98def initialize_ee():99 global ee_initialized100 try:101 credentials_base64 = os.getenv("GEE_SERVICE_ACCOUNT_KEY")102 if not credentials_base64:103 raise ValueError("GEE_SERVICE_ACCOUNT_KEY env var is missing.")104 credentials_json_str = base64.b64decode(credentials_base64).decode("utf-8")105 credentials_dict = json.loads(credentials_json_str)106 from ee import ServiceAccountCredentials107 credentials = ServiceAccountCredentials(108 credentials_dict["client_email"], key_data=credentials_json_str109 )110 ee.Initialize(credentials)111 ee_initialized = True112 logging.info("✅ Google Earth Engine initialized successfully.")113 except Exception as e:114 ee_initialized = False115 logging.error(f"❌ GEE initialization failed: {e}")116 raise117 118initialize_ee()119 120# ─────────────────────────────────────────────────────────────────────────────121# SATELLITE HELPERS122# ─────────────────────────────────────────────────────────────────────────────123def get_all_visit_dates(region, start: date, end: date) -> list:124 try:125 coll = (126 ee.ImageCollection("COPERNICUS/S2_SR_HARMONIZED")127 .filterDate(start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d"))128 .filterBounds(region)129 .filter(ee.Filter.lt("CLOUDY_PIXEL_PERCENTAGE", 80))130 )131 dates_ms = coll.aggregate_array("system:time_start").getInfo()132 if not dates_ms:133 return []134 return sorted({datetime.utcfromtimestamp(ms / 1000).date() for ms in dates_ms})135 except Exception as exc:136 logging.error(f"get_all_visit_dates: {exc}")137 return []138 139 140def single_day_composite(region, day: date):141 s = day.strftime("%Y-%m-%d")142 e = (day + timedelta(days=1)).strftime("%Y-%m-%d")143 try:144 coll = (145 ee.ImageCollection("COPERNICUS/S2_SR_HARMONIZED")146 .filterDate(s, e)147 .filterBounds(region)148 .filter(ee.Filter.lt("CLOUDY_PIXEL_PERCENTAGE", 60))149 .select(ALL_BANDS)150 )151 if coll.size().getInfo() == 0:152 return None153 return coll.median().multiply(0.0001)154 except Exception as exc:155 logging.error(f"single_day_composite {day}: {exc}")156 return None157 158 159def get_band_stats(comp, region):160 try:161 raw = comp.reduceRegion(162 reducer=ee.Reducer.mean(), geometry=region,163 scale=10, maxPixels=1e13164 ).getInfo()165 return {k: (float(v) if v is not None else 0.0) for k, v in raw.items()}166 except Exception as exc:167 logging.error(f"get_band_stats: {exc}")168 return {}169 170 171def get_lst(region, day: date):172 s = (day - relativedelta(months=1)).strftime("%Y-%m-%d")173 e = day.strftime("%Y-%m-%d")174 try:175 coll = (176 ee.ImageCollection("MODIS/061/MOD11A2")177 .filterBounds(region.buffer(5000))178 .filterDate(s, e)179 .select("LST_Day_1km")180 )181 if coll.size().getInfo() == 0:182 return None183 img = coll.median().multiply(0.02).subtract(273.15).rename("lst").clip(region.buffer(5000))184 v = img.reduceRegion(ee.Reducer.mean(), region, 1000, maxPixels=1e13).getInfo().get("lst")185 return float(v) if v is not None else None186 except Exception as exc:187 logging.error(f"get_lst: {exc}")188 return None189 190 191# ─────────────────────────────────────────────────────────────────────────────192# NUTRIENT FORMULAS193# ─────────────────────────────────────────────────────────────────────────────194def get_ph(bs):195 b2,b3,b4,b5,b8,b11 = (bs.get(k,0.0) for k in ["B2","B3","B4","B5","B8","B11"])196 ndvi_re = ((b8-b5)/(b8+b5+1e-6)+(b8-b4)/(b8+b4+1e-6))/2.0197 ph = 6.5+1.2*ndvi_re+0.8*b11/(b8+1e-6)-0.5*b8/(b4+1e-6)+0.15*(1.0-(b2+b3+b4)/3.0)198 return max(4.0, min(9.0, ph))199 200def get_organic_carbon(bs):201 b2,b3,b4,b5,b8,b11,b12 = (bs.get(k,0.0) for k in ["B2","B3","B4","B5","B8","B11","B12"])202 ndvi_re = ((b8-b5)/(b8+b5+1e-6)+(b8-b4)/(b8+b4+1e-6))/2.0203 L=0.5; savi=((b8-b4)/(b8+b4+L+1e-6))*(1+L)204 evi=2.5*(b8-b4)/(b8+6*b4-7.5*b2+1+1e-6)205 return max(0.1, min(5.0, 1.2+3.5*ndvi_re+2.2*savi-1.5*(b11+b12)/2.0+0.4*evi))206 207def get_salinity(bs):208 b2,b3,b4,b8 = (bs.get(k,0.0) for k in ["B2","B3","B4","B8"])209 ndvi=(b8-b4)/(b8+b4+1e-6)210 si=((b3*b4)**0.5+(b3**2+b4**2)**0.5)/2.0 if (b3**2+b4**2)>0 else 0.0211 return max(0.0, min(16.0, 0.5+abs(si)*4.0+(1.0-max(0,min(1,ndvi)))*2.0+0.3*(1.0-(b2+b3+b4)/3.0)))212 213def get_cec(comp, region):214 try:215 clay = comp.expression("(B11-B8)/(B11+B8+1e-6)",216 {"B11": comp.select("B11"), "B8": comp.select("B8")}).rename("clay")217 om = comp.expression("(B8-B4)/(B8+B4+1e-6)",218 {"B8": comp.select("B8"), "B4": comp.select("B4")}).rename("om")219 c_m = clay.reduceRegion(ee.Reducer.mean(), region, 20, maxPixels=1e13).get("clay").getInfo()220 o_m = om.reduceRegion(ee.Reducer.mean(), region, 20, maxPixels=1e13).get("om").getInfo()221 if c_m is None or o_m is None: return None222 return 5.0+20.0*float(c_m)+15.0*float(o_m)223 except Exception as exc:224 logging.error(f"get_cec: {exc}")225 return None226 227def get_ndvi(bs):228 b8,b4 = bs.get("B8",0), bs.get("B4",0)229 return (b8-b4)/(b8+b4+1e-6)230 231def get_evi(bs):232 b8,b4,b2 = bs.get("B8",0), bs.get("B4",0), bs.get("B2",0)233 return 2.5*(b8-b4)/(b8+6*b4-7.5*b2+1+1e-6)234 235def get_fvc(bs):236 return max(0.0, min(1.0, ((get_ndvi(bs)-0.2)/0.6)**2))237 238def get_ndwi(bs):239 b3,b8 = bs.get("B3",0), bs.get("B8",0)240 return (b3-b8)/(b3+b8+1e-6)241 242def get_npk(bs):243 b2,b3,b4,b5,b6,b7,b8,b8a,b11,b12 = (bs.get(k,0.0)244 for k in ["B2","B3","B4","B5","B6","B7","B8","B8A","B11","B12"])245 ndvi=(b8-b4)/(b8+b4+1e-6); evi=2.5*(b8-b4)/(b8+6*b4-7.5*b2+1+1e-6)246 br=(b2+b3+b4)/3.0; ndre=(b8a-b5)/(b8a+b5+1e-6)247 ci_re=(b7/(b5+1e-6))-1.0; mcari=((b5-b4)-0.2*(b5-b3))*(b5/(b4+1e-6))248 N=max(50, min(600, 280+300*ndre+150*evi+20*(ci_re/5)-80*br+30*mcari))249 si=((b3*b4)**0.5+(b3**2+b4**2)**0.5)/2.0 if (b3**2+b4**2)>0 else 0.0250 P=max(2, min(60, 11+15*(1-br)+6*ndvi+4*abs(si)+2*b3))251 K=max(40, min(600, 150+200*b11/(b5+b6+1e-6)+80*(b11-b12)/(b11+b12+1e-6)+60*ndvi))252 return float(N), float(P), float(K)253 254def get_calcium(bs):255 b2,b3,b4,b8,b11,b12 = (bs.get(k,0.0) for k in ["B2","B3","B4","B8","B11","B12"])256 Ca=550+250*(b11+b12)/(b4+b3+1e-6)+150*(b2+b3+b4)/3-100*(b8-b4)/(b8+b4+1e-6)-80*(b11-b8)/(b11+b8+1e-6)257 return max(100.0, min(1200.0, float(Ca)))258 259def get_magnesium(bs):260 b4,b5,b7,b8,b8a,b11,b12 = (bs.get(k,0.0) for k in ["B4","B5","B7","B8","B8A","B11","B12"])261 Mg=110+60*(b8a-b5)/(b8a+b5+1e-6)+40*((b7/(b5+1e-6))-1)+30*(b11-b12)/(b11+b12+1e-6)+20*(b8-b4)/(b8+b4+1e-6)262 return max(10.0, min(400.0, float(Mg)))263 264def get_sulphur(bs):265 b2,b3,b4,b5,b8,b11,b12 = (bs.get(k,0.0) for k in ["B2","B3","B4","B5","B8","B11","B12"])266 si=((b3*b4)**0.5+(b3**2+b4**2)**0.5)/2.0 if (b3**2+b4**2)>0 else 0.0267 S=20+15*b11/(b3+b4+1e-6)+10*abs(si)+5*(b5/(b4+1e-6)-1)-8*b12/(b11+1e-6)+5*(b8-b4)/(b8+b4+1e-6)268 return max(2.0, min(80.0, float(S)))269 270 271# ─────────────────────────────────────────────────────────────────────────────272# STATUS & HEALTH SCORE273# ─────────────────────────────────────────────────────────────────────────────274def _is_valid(value):275 if value is None: return False276 try:277 return not (pd.isna(value) or not pd.api.types.is_number(value))278 except Exception:279 return False280 281def param_status(param, value):282 if not _is_valid(value): return "na"283 value = float(value)284 lo, hi = IDEAL_RANGES.get(param, (None, None))285 if lo is None: return "good" if value <= hi else "high"286 if hi is None: return "good" if value >= lo else "low"287 return "low" if value < lo else ("high" if value > hi else "good")288 289def health_score(snap: dict) -> float:290 valid = [(p, v) for p, v in snap.items() if _is_valid(v)]291 if not valid: return 0.0292 good = sum(1 for p, v in valid if param_status(p, v) == "good")293 return (good / len(valid)) * 100.0294 295 296# ─────────────────────────────────────────────────────────────────────────────297# FETCH SNAPSHOT298# ─────────────────────────────────────────────────────────────────────────────299def fetch_snapshot(region, day: date) -> dict | None:300 comp = single_day_composite(region, day)301 if comp is None: return None302 bs = get_band_stats(comp, region)303 if not bs: return None304 N, P, K = get_npk(bs)305 return {306 "pH": get_ph(bs),307 "Organic Carbon": get_organic_carbon(bs),308 "Salinity": get_salinity(bs),309 "CEC": get_cec(comp, region),310 "LST": get_lst(region, day),311 "NDVI": get_ndvi(bs),312 "EVI": get_evi(bs),313 "FVC": get_fvc(bs),314 "NDWI": get_ndwi(bs),315 "Nitrogen": N,316 "Phosphorus": P,317 "Potassium": K,318 "Calcium": get_calcium(bs),319 "Magnesium": get_magnesium(bs),320 "Sulphur": get_sulphur(bs),321 }322 323 324# ─────────────────────────────────────────────────────────────────────────────325# SHARED: fetch snapshots → sorted DataFrame326# (called by both endpoints to avoid code duplication)327# ─────────────────────────────────────────────────────────────────────────────328async def _build_dataframe(req_coordinates, req_start, req_end) -> tuple:329 """330 Returns (region, visit_dates, df) or raises HTTPException.331 """332 loop = asyncio.get_event_loop()333 try:334 start = datetime.strptime(req_start, "%Y-%m-%d").date()335 end = datetime.strptime(req_end, "%Y-%m-%d").date()336 region = ee.Geometry.Polygon(req_coordinates)337 except Exception as exc:338 raise HTTPException(status_code=422, detail=f"Invalid input: {exc}")339 340 visit_dates = await loop.run_in_executor(None, get_all_visit_dates, region, start, end)341 if not visit_dates:342 raise HTTPException(343 status_code=404,344 detail=(345 "No Sentinel-2 passes found for this region and date range. "346 "Try a wider date range or verify the coordinates."347 ),348 )349 350 records = []351 for day in visit_dates:352 snap = await loop.run_in_executor(None, fetch_snapshot, region, day)353 records.append({354 "date": day.strftime("%Y-%m-%d"),355 **(snap if snap else {p: None for p in IDEAL_RANGES}),356 })357 358 df = pd.DataFrame(records)359 df["date"] = pd.to_datetime(df["date"])360 df = df.sort_values("date").reset_index(drop=True)361 return region, visit_dates, df362 363 364# ─────────────────────────────────────────────────────────────────────────────365# CHART RENDERERS (return raw PNG bytes)366# ─────────────────────────────────────────────────────────────────────────────367def _fig_to_png(fig) -> bytes:368 buf = BytesIO()369 fig.savefig(buf, format="png", dpi=130, bbox_inches="tight")370 plt.close(fig)371 buf.seek(0)372 return buf.read()373 374 375def render_health_score_chart(df: pd.DataFrame) -> bytes | None:376 clean = df[df["date"].notna()].copy()377 if clean.empty: return None378 379 scores, dates, dots = [], [], []380 for _, row in clean.iterrows():381 snap = {p: row.get(p) for p in IDEAL_RANGES}382 sc = health_score(snap)383 scores.append(sc)384 dates.append(row["date"])385 dots.append(386 "#43A047" if sc >= 80 else387 "#8BC34A" if sc >= 60 else388 "#FF9800" if sc >= 40 else "#E53935"389 )390 391 fig, ax = plt.subplots(figsize=(12, 5))392 for lo, hi, label, bg in [393 (80, 100, "Excellent ≥80%", "#E8F5E9"),394 (60, 80, "Good 60–79%", "#F1F8E9"),395 (40, 60, "Fair 40–59%", "#FFF8E1"),396 (0, 40, "Poor <40%", "#FFEBEE"),397 ]:398 ax.axhspan(lo, hi, color=bg, alpha=0.70, zorder=0)399 ax.text(0.012, (lo+hi)/2, label, va="center", ha="left",400 transform=ax.get_yaxis_transform(),401 fontsize=8, color="#777", style="italic")402 403 ax.fill_between(dates, scores, alpha=0.10, color="#2E7D32", zorder=1)404 ax.plot(dates, scores, color="#2E7D32", lw=2.2, zorder=2)405 for dt, sc, co in zip(dates, scores, dots):406 ax.scatter(dt, sc, color=co, s=75, zorder=4, edgecolors="white", lw=1.0)407 ax.annotate(f"{sc:.0f}%", (dt, sc),408 xytext=(0, 11), textcoords="offset points",409 fontsize=8, ha="center", color="#333", fontweight="bold")410 411 if len(scores) >= 3:412 x = np.arange(len(scores), dtype=float)413 s_, i_, *_ = stats.linregress(x, scores)414 ax.plot(dates, s_*x+i_, color="#555", lw=1.2, ls="--", alpha=0.5, label="Trend")415 ax.legend(fontsize=8, loc="lower right", framealpha=0.6)416 417 ax.set_ylim(0, 115)418 ax.set_ylabel("Health Score (%)", fontsize=9)419 ax.set_title("Overall Soil Health Score — Every Sentinel-2 Pass (ICAR Standard)",420 fontsize=11, fontweight="bold", pad=10)421 ax.tick_params(axis="x", labelrotation=35, labelsize=8)422 ax.spines[["top","right"]].set_visible(False)423 ax.grid(axis="y", alpha=0.2, linestyle="--")424 plt.tight_layout()425 return _fig_to_png(fig)426 427 428def render_param_chart(df: pd.DataFrame, param: str) -> bytes | None:429 if param not in df.columns: return None430 431 tmp = df[df["date"].notna()].copy()432 tmp = tmp[tmp[param].apply(_is_valid)].copy()433 if tmp.empty: return None434 435 tmp["_val"] = tmp[param].apply(float)436 tmp = tmp.sort_values("date").reset_index(drop=True)437 clean_dates = tmp["date"].values438 clean_vals = tmp["_val"].values.astype(float)439 440 colour = COLOURS.get(param, "#1565C0")441 lo, hi = IDEAL_RANGES.get(param, (None, None))442 y_lo = lo if lo is not None else float(clean_vals.min()) * 0.85443 y_hi = hi if hi is not None else float(clean_vals.max()) * 1.15444 445 fig, ax = plt.subplots(figsize=(12, 5))446 ax.axhspan(y_lo, y_hi, color="#E8F5E9", alpha=0.55, zorder=0, label="ICAR ideal range")447 ax.axhline(y_lo, color="#81C784", lw=0.9, ls="--", alpha=0.7, zorder=1)448 ax.axhline(y_hi, color="#81C784", lw=0.9, ls="--", alpha=0.7, zorder=1)449 ax.fill_between(clean_dates, clean_vals, alpha=0.08, color=colour, zorder=1)450 ax.plot(clean_dates, clean_vals, color=colour, lw=2.2, zorder=3)451 452 for dt, val in zip(clean_dates, clean_vals):453 ax.scatter(dt, val, color=DOT_C[param_status(param, val)],454 s=65, zorder=5, edgecolors="white", lw=1.0)455 ax.annotate(f"{val:.2f}{UNIT_MAP.get(param,'')}",456 (dt, val), xytext=(0, 11), textcoords="offset points",457 fontsize=7.5, ha="center", color="#333")458 459 if len(clean_vals) >= 3:460 x = np.arange(len(clean_vals), dtype=float)461 s_, i_, *_ = stats.linregress(x, clean_vals)462 ax.plot(clean_dates, s_*x+i_, color="#555", lw=1.1, ls=":",463 alpha=0.6, zorder=2, label="Trend")464 465 pct = 0.0466 if len(clean_vals) >= 2:467 pct = (clean_vals[-1]-clean_vals[0]) / (abs(clean_vals[0])+1e-9) * 100468 arrow = "↑" if pct > 1 else ("↓" if pct < -1 else "→")469 direction = "Increasing" if pct > 1 else ("Decreasing" if pct < -1 else "Stable")470 471 ax.set_title(f"{FULL_NAME.get(param, param)}", fontsize=11, fontweight="bold", pad=10)472 ax.set_xlabel(473 f"Trend: {arrow} {direction} ({pct:+.1f}% over period) · "474 f"Each point = one Sentinel-2 satellite pass",475 fontsize=8.5, labelpad=6, color="#555"476 )477 ax.set_ylabel(f"Value{UNIT_MAP.get(param,'')}", fontsize=9)478 ax.tick_params(axis="x", labelrotation=35, labelsize=8)479 ax.spines[["top","right"]].set_visible(False)480 ax.grid(axis="y", alpha=0.2, linestyle="--")481 ax.legend(482 handles=[483 Line2D([0],[0], color=colour, lw=2.2, label=param),484 Line2D([0],[0], color="#555", lw=1.1, ls=":", label="Trend"),485 mpatches.Patch(facecolor="#E8F5E9", label="ICAR ideal range"),486 ],487 fontsize=8, loc="upper left", framealpha=0.65488 )489 plt.tight_layout()490 return _fig_to_png(fig)491 492 493# ─────────────────────────────────────────────────────────────────────────────494# AI ADVISORY495# ─────────────────────────────────────────────────────────────────────────────496SYSTEM_PROMPT = """497You are Dr. Arjun Patil, a Senior Soil Scientist and Precision Agriculture Specialist498with 30 years of field experience working with smallholder farmers across India499(Maharashtra, Punjab, Karnataka, Andhra Pradesh, Tamil Nadu).500You combine deep knowledge of:501• Soil chemistry and ICAR soil health standards502• Satellite remote sensing (Sentinel-2, MODIS) interpretation503• Indian crop calendars (Kharif / Rabi / Zaid seasons)504• Locally available, affordable fertilizer inputs505• Practical ground-level farming advice506Your communication style:507• Clear, simple language — no jargon508• Bullet points for easy reading509• Specific product names, doses per acre, timing510• Empathetic — you understand farmers' financial constraints511• Always prioritize soil long-term health, not just quick fixes512Format your response using EXACTLY the section structure given in the user prompt.513Use ✅ ⚠️ 🔴 🟡 🟢 emojis to make status instantly visible.514""".strip()515 516 517def build_focused_prompt(df: pd.DataFrame, location: str, period: str,518 n_passes: int, selected_param: str) -> str:519 today = date.today()520 month_name = today.strftime("%B %Y")521 mo = today.month522 season = ("Kharif — Monsoon Season (Jun–Oct)" if 6 <= mo <= 10 else523 "Rabi — Winter Season (Nov–Mar)" if (mo >= 11 or mo <= 3) else524 "Zaid — Summer Season (Mar–May)")525 526 if selected_param == "Soil Health Score":527 ts_rows = []528 for _, row in df.iterrows():529 snap = {p: row.get(p) for p in IDEAL_RANGES}530 sc = health_score(snap)531 ts_rows.append({"date": row["date"], "value": sc})532 ts_df = pd.DataFrame(ts_rows).dropna()533 param_label = "Overall Soil Health Score (%)"534 lo, hi = 60.0, 100.0535 unit = "%"536 else:537 if selected_param not in df.columns:538 return ""539 ts_df = df[["date", selected_param]].copy()540 ts_df = ts_df[ts_df[selected_param].apply(_is_valid)].rename(541 columns={selected_param: "value"}542 )543 param_label = FULL_NAME.get(selected_param, selected_param)544 lo, hi = IDEAL_RANGES.get(selected_param, (None, None))545 unit = UNIT_MAP.get(selected_param, "")546 547 if ts_df.empty:548 return ""549 550 vals = [float(v) for v in ts_df["value"]]551 dates_str = [pd.Timestamp(d).strftime("%d %b %Y") for d in ts_df["date"]]552 time_series_str = "\n".join(553 f" Pass {i+1} ({d}): {v:.3f}{unit}"554 for i, (d, v) in enumerate(zip(dates_str, vals))555 )556 557 first, last, avg = vals[0], vals[-1], sum(vals)/len(vals)558 peak = max(vals); trough = min(vals)559 pct = (last - first) / (abs(first) + 1e-9) * 100560 trend = "RISING" if pct > 3 else ("FALLING" if pct < -3 else "STABLE")561 562 if selected_param != "Soil Health Score":563 current_status = param_status(selected_param, last)564 status_icon = {"good": "🟢 WITHIN RANGE", "low": "🟡 BELOW IDEAL",565 "high": "🔴 ABOVE IDEAL", "na": "⚪ NO DATA"}[current_status]566 else:567 status_icon = ("🟢 GOOD" if last >= 60 else "🟡 FAIR" if last >= 40 else "🔴 POOR")568 569 ideal_str = (f"{lo}–{hi}{unit}" if (lo is not None and hi is not None) else570 (f"≤{hi}{unit}" if lo is None else f"≥{lo}{unit}"))571 572 return f"""573SATELLITE TIME-SERIES ADVISORY REQUEST574════════════════════════════════════════════════════════575Location : {location}576Period : {period}577Satellite : Sentinel-2 (ESA Copernicus) + MODIS (NASA)578Total passes : {n_passes} actual satellite overpasses579Current date : {month_name}580Season : {season}581════════════════════════════════════════════════════════582SELECTED PARAMETER FOR ANALYSIS583 Parameter : {param_label}584 ICAR ideal : {ideal_str}585 Current value: {last:.3f}{unit} → {status_icon}586 Period avg : {avg:.3f}{unit}587 Peak value : {peak:.3f}{unit}588 Lowest value : {trough:.3f}{unit}589 Change : {pct:+.1f}% → {trend}590TIME-SERIES DATA (one row per satellite pass)591──────────────────────────────────────────────────────────────────────592{time_series_str}593──────────────────────────────────────────────────────────────────────594GENERATE A FOCUSED ADVISORY IN EXACTLY THIS FORMAT:595📊 {param_label} — Satellite Time-Series Insight596🔍 What the Data Shows597• ...598• ...599---600⚠️ Current Status & Risk601Status: {status_icon}602- What this means for your crop: (one line)603- Why it may have changed: (one line)604- Risk if not addressed: (one line)605---606✅ Recommended Action6071. [Action] — [what, product, dose/acre, when]6082. ...6093. ...610---611📅 Watch Points612- Next check date: [specific date 15–20 days from now]613- Warning sign to watch for: [one observable field sign]614- Target value to reach: [specific number with unit]615---616Based on {n_passes} Sentinel-2 passes · ICAR standards · FarmMatrix617RULES:618- Focus ONLY on {selected_param}619- Use only locally available Indian inputs620- Doses per acre only621- Keep total length 200–280 words622"""623 624 625def call_groq(prompt: str) -> str | None:626 try:627 client = OpenAI(api_key=GROQ_API_KEY, base_url="https://api.groq.com/openai/v1")628 resp = client.chat.completions.create(629 model=GROQ_MODEL,630 messages=[631 {"role": "system", "content": SYSTEM_PROMPT},632 {"role": "user", "content": prompt},633 ],634 max_tokens=800,635 temperature=0.30,636 )637 return resp.choices[0].message.content.strip()638 except Exception as exc:639 logging.error(f"Groq API: {exc}")640 return None641 642 643# ─────────────────────────────────────────────────────────────────────────────644# FASTAPI APP645# ─────────────────────────────────────────────────────────────────────────────646app = FastAPI(title="FarmMatrix API", version="3.0.0")647 648app.add_middleware(649 CORSMiddleware,650 allow_origins=["*"],651 allow_methods=["*"],652 allow_headers=["*"],653)654 655app.mount("/static", StaticFiles(directory="static"), name="static")656 657 658# ── Shared request model ─────────────────────────────────────────────────────659class AnalyseRequest(BaseModel):660 """661 Used by BOTH /api/analyse and /api/chart — same body, different response.662 663 coordinates : GeoJSON polygon ring — list of [longitude, latitude] pairs.664 First and last point must be identical (closed ring).665 Example: [[73.85,18.52],[73.86,18.52],[73.86,18.53],666 [73.85,18.53],[73.85,18.52]]667 668 start_date : "YYYY-MM-DD"669 end_date : "YYYY-MM-DD"670 671 selected_param: One of:672 "Soil Health Score" | "pH" | "Salinity" | "Organic Carbon" |673 "CEC" | "LST" | "NDVI" | "EVI" | "FVC" | "NDWI" |674 "Nitrogen" | "Phosphorus" | "Potassium" |675 "Calcium" | "Magnesium" | "Sulphur"676 677 location : Human-readable farm/location name (appears in AI advisory).678 """679 coordinates: List[List[Any]]680 start_date: str681 end_date: str682 selected_param: str = "Soil Health Score"683 location: str = "Unknown Location"684 685 686# ── Health check ─────────────────────────────────────────────────────────────687@app.get("/health")688async def health_check():689 return {"status": "ok", "service": "FarmMatrix", "version": "3.0.0"}690 691 692# ── Frontend ──────────────────────────────────────────────────────────────────693@app.get("/", response_class=HTMLResponse)694async def index():695 html_path = os.path.join(os.path.dirname(__file__), "static", "index.html")696 with open(html_path, "r") as f:697 return HTMLResponse(content=f.read())698 699 700# ═════════════════════════════════════════════════════════════════════════════701# ENDPOINT 1 — /api/analyse702# Response: JSON (meta + summary + time_series + ai_insight)703# NO image in this response.704# ═════════════════════════════════════════════════════════════════════════════705@app.post("/api/analyse")706async def api_analyse(req: AnalyseRequest):707 """708 Runs the full satellite data pipeline and returns structured JSON:709 710 {711 "meta": { location, period, n_passes, pass_dates, selected_param },712 "summary": { health_score, rating, params: { <param>: {value, status, unit, label} } },713 "time_series": { records: [...], pass_scores: [{date, health_score}] },714 "ai_insight": { text, param, model }715 }716 717 Does NOT include a chart image — call /api/chart for the PNG.718 Typical response time: 2–8 min.719 """720 loop = asyncio.get_event_loop()721 722 _, visit_dates, df = await _build_dataframe(723 req.coordinates, req.start_date, req.end_date724 )725 n_passes = len(visit_dates)726 period = f"{req.start_date} to {req.end_date}"727 728 # Latest-pass summary729 last_row = df.dropna(subset=list(IDEAL_RANGES.keys()), how="all").iloc[-1]730 snap_latest = {p: last_row.get(p) for p in IDEAL_RANGES}731 hs = health_score(snap_latest)732 rating = ("Excellent" if hs >= 80 else "Good" if hs >= 60 else "Fair" if hs >= 40 else "Poor")733 734 params_summary: dict = {}735 for p in IDEAL_RANGES:736 v = snap_latest.get(p)737 params_summary[p] = {738 "value": round(float(v), 3) if _is_valid(v) else None,739 "status": param_status(p, v),740 "unit": UNIT_MAP.get(p, ""),741 "label": FULL_NAME.get(p, p),742 }743 744 # Per-pass health scores745 pass_scores = []746 for _, row in df.iterrows():747 s = {p: row.get(p) for p in IDEAL_RANGES}748 pass_scores.append({749 "date": row["date"].strftime("%Y-%m-%d"),750 "health_score": round(health_score(s), 1),751 })752 753 # AI advisory754 prompt = build_focused_prompt(df, req.location, period, n_passes, req.selected_param)755 ai_insight = None756 if prompt:757 ai_insight = await loop.run_in_executor(None, call_groq, prompt)758 759 # Serialise records (dates → strings)760 records_out = []761 for _, row in df.iterrows():762 r = {"date": row["date"].strftime("%Y-%m-%d")}763 for p in IDEAL_RANGES:764 v = row.get(p)765 r[p] = round(float(v), 4) if _is_valid(v) else None766 records_out.append(r)767 768 return {769 "meta": {770 "location": req.location,771 "period": period,772 "n_passes": n_passes,773 "pass_dates": [d.strftime("%Y-%m-%d") for d in visit_dates],774 "selected_param": req.selected_param,775 },776 "summary": {777 "health_score": round(hs, 1),778 "rating": rating,779 "params": params_summary,780 },781 "time_series": {782 "records": records_out,783 "pass_scores": pass_scores,784 },785 "ai_insight": {786 "text": ai_insight,787 "param": req.selected_param,788 "model": GROQ_MODEL,789 },790 }791 792 793# ═════════════════════════════════════════════════════════════════════════════794# ENDPOINT 2 — /api/chart795# Response: raw PNG image (Content-Type: image/png)796# NO JSON in this response.797# ═════════════════════════════════════════════════════════════════════════════798@app.post("/api/chart")799async def api_chart(req: AnalyseRequest):800 """801 Runs the same satellite data pipeline, then renders and returns a PNG chart802 directly as the response body (Content-Type: image/png).803 804 • In Postman: go to the response area → click "Visualize" tab to see the image,805 or save it via "Save Response → Save to a file".806 • In a browser / frontend: use as <img src="..."> after fetching.807 808 selected_param = "Soil Health Score" → overall health-score-over-time chart809 selected_param = any other param → that parameter's time-series chart810 811 Typical response time: 2–8 min.812 """813 loop = asyncio.get_event_loop()814 815 _, visit_dates, df = await _build_dataframe(816 req.coordinates, req.start_date, req.end_date817 )818 819 # Render PNG820 if req.selected_param == "Soil Health Score":821 png_bytes = await loop.run_in_executor(None, render_health_score_chart, df)822 else:823 png_bytes = await loop.run_in_executor(None, render_param_chart, df, req.selected_param)824 825 if not png_bytes:826 raise HTTPException(827 status_code=404,828 detail=f"No chart data available for parameter '{req.selected_param}'."829 )830 831 filename = f"farmmatrix_{req.selected_param.replace(' ', '_')}.png"832 return Response(833 content=png_bytes,834 media_type="image/png",835 headers={836 # inline → Postman Visualize tab shows it; attachment → triggers download837 "Content-Disposition": f'inline; filename="{filename}"',838 "X-FarmMatrix-Param": req.selected_param,839 "X-FarmMatrix-Passes": str(len(visit_dates)),840 "X-FarmMatrix-Period": f"{req.start_date} to {req.end_date}",841 },842 )843 844 845# ─────────────────────────────────────────────────────────────────────────────846# ENTRY POINT847# ─────────────────────────────────────────────────────────────────────────────848if __name__ == "__main__":849 port = int(os.environ.get("PORT", 7860))850 uvicorn.run("app:app", host="0.0.0.0", port=port, reload=False)