CoolFace
Apppublic

ritujam12/FarmMatrix_Time_Series_API

sourceHugging Faceupdated 7mo agoView on Hugging Face
0likes
app.py850 linesDownload Raw Back to root
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)