CoolFace
Apppublic

Minuka/STEMS-Backend

sourceHugging Faceupdated 6mo agoView on Hugging Face
0likes
main.py591 linesDownload Raw Back to root
1from fastapi import FastAPI, HTTPException2from fastapi.middleware.cors import CORSMiddleware3from pydantic import BaseModel4from typing import Optional, Literal5from datetime import date, timedelta, datetime6from pathlib import Path7import joblib, numpy as np, pandas as pd, json, math8 9app = FastAPI(title="STEMS Harvest Prediction API")10 11app.add_middleware(12    CORSMiddleware,13    allow_origins=["*"],14    allow_methods=["*"],15    allow_headers=["*"],16)17 18BASE = Path(__file__).parent19 20# ════════════════════════════════════════════════════════════════════21# LOAD ALL MODELS AT STARTUP22# ════════════════════════════════════════════════════════════════════23 24# STEMS25STEMS_BUNDLE   = joblib.load(BASE / "models" / "stems_svr_bundle.pkl")26STEMS_MODEL    = STEMS_BUNDLE["model"]27STEMS_T_SCALER = STEMS_BUNDLE["t_scaler"]28STEMS_CLIP_LO  = STEMS_BUNDLE["clip_lo"]29STEMS_CLIP_HI  = STEMS_BUNDLE["clip_hi"]30STEMS_FEATURES = STEMS_BUNDLE["feature_cols"]31STEMS_MAE      = 2.526332STEMS_FIELDS   = pd.read_csv(BASE / "models" / "stems_field_data.csv")33STEMS_FIELDS["Field_No"] = STEMS_FIELDS["Field_No"].astype(str).str.strip()34 35# MONTHLY PLUCKING AVERAGE LOOKUP36with open(BASE / "models" / "plucking_monthly_lookup.json") as _f:37    MONTHLY_LOOKUP = json.load(_f)38 39# FERTILIZER — new pkl files from teammate40FERT_FIELD_LOOKUP  = joblib.load(BASE / "models" / "field_lookup.pkl")41FERT_SCHEDULE_DATA = joblib.load(BASE / "models" / "schedule_data.pkl")42FERT_MODEL_AMT     = joblib.load(BASE / "models" / "fertilizer_model_amount.pkl")43FERT_MODEL_DAYS    = joblib.load(BASE / "models" / "fertilizer_model_days.pkl")44FERT_SCALER_AMT    = joblib.load(BASE / "models" / "scaler_amount.pkl")45FERT_SCALER_DAYS   = joblib.load(BASE / "models" / "scaler_days.pkl")46FERT_LE_DIV        = joblib.load(BASE / "models" / "label_encoder_division.pkl")47FERT_LE_VPSD       = joblib.load(BASE / "models" / "label_encoder_vpsd.pkl")48FERT_FEAT_AMT      = joblib.load(BASE / "models" / "feature_names_amount.pkl")49FERT_FEAT_DAYS     = joblib.load(BASE / "models" / "feature_names_days.pkl")50UREA_N             = 0.4651 52# SOIL53SOIL_PH_MODEL  = joblib.load(BASE / "models" / "soil-model-1.pkl")54SOIL_WET_MODEL = joblib.load(BASE / "models" / "soil-model-2.pkl")55SOIL_C_MODEL   = joblib.load(BASE / "models" / "soil-model-3.pkl")56with open(BASE / "models" / "soil_encoding_meta.json") as f:57    SOIL_META = json.load(f)58ESTATE_PH_MAP   = SOIL_META["estate_pH_map"]59CATEGORY_PH_MAP = SOIL_META["category_pH_map"]60ESTATE_C_MAP    = SOIL_META["estate_C_map"]61CATEGORY_C_MAP  = SOIL_META["category_C_map"]62GLOBAL_PH_MEAN  = SOIL_META["global_pH_mean"]63GLOBAL_C_MEAN   = SOIL_META["global_C_mean"]64CAT_COLS_PH = ["Category", "VP/SD", "Estate"]65NUM_COLS_PH = ["Extent (Ha)", "FieldAge", "C%", "C_Age", "C_sq",66               "log_Age", "log_C", "Ha_C", "Estate_enc_pH", "Cat_enc_pH"]67CAT_COLS_C  = ["Category", "VP/SD", "Estate"]68NUM_COLS_C  = ["Extent (Ha)", "FieldAge", "pH", "pH_Age", "pH_sq",69               "log_Age", "Estate_enc_C", "Cat_enc_C"]70 71# PRODUCTIVITY72PROD_MODEL   = joblib.load(BASE / "models" / "xgb_model.pkl")73PROD_SCALER  = joblib.load(BASE / "models" / "scaler.pkl")74PROD_DATA    = pd.read_csv(BASE / "models" / "productivity_data.csv")75with open(BASE / "models" / "medians.json") as _mf:76    PROD_MEDIANS = json.load(_mf)77PROD_RMSE    = 6143.2278PROD_SCALER_FEATURES = [79    "rainfall", "wet_days", "female_workforceRatio",80    "yield_lag_1", "yield_lag_2", "yield_lag_3", "yield_lag_12",81    "rainfall_lag_1", "irradiance_SW_DWN",82    "sin_month", "cos_month", "NDVI", "EVI", "yield_momentum"83]84 85 86# ════════════════════════════════════════════════════════════════════87# HEALTH CHECK88# ════════════════════════════════════════════════════════════════════89 90@app.get("/")91def root():92    return {93        "status": "running",94        "endpoints": [95            "POST /predict/stems",96            "POST /predict/fertilizer",97            "POST /predict/soil",98            "POST /predict/soil_wetdays",99            "POST /predict/productivity",100            "GET  /fields",101            "GET  /productivity/months"102        ],103        "docs": "/docs"104    }105 106 107# ════════════════════════════════════════════════════════════════════108# 1. STEMS — HARVEST INTERVAL + SCHEDULE109# ════════════════════════════════════════════════════════════════════110 111def stems_engineer(df):112    df = df.copy()113    df["Soil_Index"]      = df["Soil_Carbon"]       / (df["Soil_pH"].replace(0, np.nan) + 1e-9)114    df["Yield_Eff"]       = df["Yield_Prev_Year"]   / (df["Extent_Hect"].replace(0, np.nan) + 1e-9)115    df["Prune_Age_Ratio"] = df["Prune_Cycle_Stage"] / (df["Age_Months"] / 12 + 1e-9)116    df["Rain_Trend"]      = df["Rainfall_Lag1"]     - df["Rainfall_Lag3"]117    df["Growth_per_Prod"] = df["Growth_Response"]   / (df["Field_Productivity"] + 1e-9)118    return df119 120class StemsInput(BaseModel):121    field_no:          str122    last_harvest_date: str123    target_month:      Optional[str] = None124 125@app.post("/predict/stems")126def predict_stems(data: StemsInput):127    field_rows = STEMS_FIELDS[STEMS_FIELDS["Field_No"] == str(data.field_no).strip()]128    if len(field_rows) == 0:129        raise HTTPException(status_code=404,130            detail=f"Field {data.field_no} not found.")131 132    row        = field_rows.sort_values("Year", ascending=False).iloc[0]133    is_pruning = bool(row.get("Near_Pruning_Flag", 0) == 1)134    df_row     = stems_engineer(pd.DataFrame([row]))135 136    for c in STEMS_FEATURES:137        if c not in df_row.columns:138            df_row[c] = 0.0139 140    X              = df_row[STEMS_FEATURES].clip(lower=STEMS_CLIP_LO, upper=STEMS_CLIP_HI, axis=1)141    pred_n         = np.clip(STEMS_MODEL.predict(X), 0, 1)142    base_interval  = round(float(STEMS_T_SCALER.inverse_transform(pred_n.reshape(-1,1)).ravel()[0]), 2)143 144    # Parse last_harvest date FIRST before using it in monthly adjustment145    try:146        last_harvest = datetime.strptime(data.last_harvest_date, "%Y-%m-%d").date()147    except ValueError:148        raise HTTPException(status_code=400, detail="last_harvest_date must be YYYY-MM-DD.")149 150    # Monthly adjustment using division-level plucking averages151    division_str   = str(row["Division"]).strip()152    next_date_est  = last_harvest + timedelta(days=base_interval)153    harvest_month  = next_date_est.strftime("%B")154    annual_mean    = MONTHLY_LOOKUP.get("annual_mean", 22.42)155    div_monthly    = MONTHLY_LOOKUP.get("division_monthly_avg", {}).get(division_str, {})156    monthly_avg    = div_monthly.get(harvest_month, annual_mean)157    adjustment     = round(monthly_avg - annual_mean, 2)158    interval_days  = round(max(7.0, base_interval + adjustment), 2)159 160    upcoming = []161    for i in range(1, 7):162        harvest_date = last_harvest + timedelta(days=interval_days * i)163        if harvest_date.year > last_harvest.year + 1:164            break165        early = harvest_date - timedelta(days=STEMS_MAE)166        late  = harvest_date + timedelta(days=STEMS_MAE)167        upcoming.append({168            "round":         i,169            "date":          harvest_date.strftime("%Y-%m-%d"),170            "date_display":  harvest_date.strftime("%d %b %Y"),171            "earliest":      early.strftime("%d %b"),172            "latest":        late.strftime("%d %b"),173            "error_window":  f"+-{STEMS_MAE} days",174            "month":         harvest_date.strftime("%Y-%m"),175            "month_display": harvest_date.strftime("%B %Y"),176        })177 178    filtered = [h for h in upcoming if h["month"] == data.target_month] if data.target_month else upcoming179 180    return {181        "field_no":            data.field_no,182        "division":            str(row["Division"]),183        "season":              str(row.get("Season", "N/A")),184        "interval_days":       interval_days,185        "base_interval_days":  base_interval,186        "monthly_adjustment":  adjustment,187        "adjustment_month":    harvest_month,188        "mae_days":            STEMS_MAE,189        "last_harvest":        data.last_harvest_date,190        "target_month":        data.target_month or "all",191        "harvests":            filtered,192        "note":                f"Base interval {base_interval} days, adjusted {adjustment:+.1f} days for {harvest_month}. Final: {interval_days} days +-{STEMS_MAE} days.",193        "pruning_warning":     is_pruning,194        "warning_message":     "Near_Pruning_Flag=1: post-pruning recovery. Accuracy may be lower." if is_pruning else None,195        "status":              "success"196    }197 198 199 200# ════════════════════════════════════════════════════════════════════201# 2b. FERTILIZER SCHEDULE — Full schedule for all fields202# ════════════════════════════════════════════════════════════════════203 204 205@app.get("/schedule")206def get_fertilizer_schedule(division: str = None, status: str = None):207    df = FERT_SCHEDULE_DATA.copy()208    if division:209        df = df[df["Division"].str.upper() == division.upper()]210    if status:211        df = df[df["Schedule_Status"].str.upper() == status.upper()]212 213    order = {"OVERDUE": 0, "DUE TODAY": 1, "DUE SOON": 2, "UPCOMING": 3}214    df["_sort"] = df["Schedule_Status"].map(order).fillna(4)215    df = df.sort_values(["_sort", "Days_Until_Next"]).drop(columns=["_sort"])216 217    results = []218    for _, row in df.iterrows():219        apps     = 4 if str(row.get("VP_SD","VP")) == "SD" else 3220        n_app    = round(float(row["Pred_Dose_kgha"]) * apps / apps, 1)221        urea_app = round((n_app * float(row["Extent_Ha"])) / UREA_N, 1)222        interval = 365 // apps223        days     = int(row["Days_Until_Next"])224        if days < 0:   msg = f"Overdue by {abs(days)} days — apply immediately"225        elif days == 0: msg = "Due today — apply now"226        elif days <= 14: msg = f"Due soon in {days} days"227        else:           msg = f"Upcoming — next application in {days} days"228        results.append({229            "division":            str(row["Division"]),230            "field_no":            str(row["Field"]),231            "vp_sd":               str(row.get("VP_SD","VP")),232            "extent_ha":           float(row["Extent_Ha"]),233            "yield_kgha":          float(row["Annual_Yield_kgha"]) if pd.notna(row.get("Annual_Yield_kgha")) else None,234            "n_ratio_seas":        float(row["N_Ratio_Seas"]) if pd.notna(row.get("N_Ratio_Seas")) else None,235            "n_threshold":         float(row["N_Threshold"]) if pd.notna(row.get("N_Threshold")) else None,236            "fert_needed":         bool(row["Fert_Needed"]),237            "pred_dose_kgha":      float(row["Pred_Dose_kgha"]),238            "predicted_amount_kg": int(row["Pred_Amount_kg"]),239            "pred_cycle_days":     int(row["Pred_Cycle_Days"]),240            "days_until_next":     days,241            "next_app_date":       str(row["Next_App_Date"]),242            "status":              str(row["Schedule_Status"]),243            "priority_flag":       str(row.get("Priority_Flag","")),244            "apps_per_year":       apps,245            "n_per_app_kgha":      n_app,246            "urea_per_app_kg":     urea_app,247            "interval_days":       interval,248            "status_message":      msg,249        })250    return {251        "total":     len(results),252        "overdue":   sum(1 for r in results if r["status"] == "OVERDUE"),253        "due_today": sum(1 for r in results if r["status"] == "DUE TODAY"),254        "due_soon":  sum(1 for r in results if r["status"] == "DUE SOON"),255        "upcoming":  sum(1 for r in results if r["status"] == "UPCOMING"),256        "schedule":  results,257    }258 259@app.get("/fields")260def get_fields():261    fields    = sorted(STEMS_FIELDS["Field_No"].unique().tolist())262    divisions = STEMS_FIELDS.groupby("Field_No")["Division"].first().to_dict()263    return {"fields": [{"field_no": f, "division": divisions.get(f, "")} for f in fields]}264 265 266# ════════════════════════════════════════════════════════════════════267# 2. FERTILIZER — LOOKUP BASED (Division + Field_No only)268# ════════════════════════════════════════════════════════════════════269 270class FertilizerInput(BaseModel):271    division: Literal["AGO", "LDK", "LVO", "UDK", "UVO"]272    field_no: str273 274 275@app.post("/predict/fertilizer")276def predict_fertilizer(data: FertilizerInput):277    key    = (data.division.strip().upper(), str(data.field_no).strip())278    record = FERT_FIELD_LOOKUP.get(key)279    if record is None:280        raise HTTPException(status_code=404,281            detail=f"Field {data.field_no} not found in division {data.division}")282    apps     = 4 if record.get("vp_sd") == "SD" else 3283    n_annual = record["pred_dose_kgha"] * apps284    n_app    = round(n_annual / apps, 1)285    urea_app = round((n_app * record["extent_ha"]) / UREA_N, 1)286    interval = 365 // apps287    days     = record["days_until_next"]288    if days < 0:    msg = f"Overdue by {abs(days)} days — apply immediately"289    elif days == 0: msg = "Due today — apply now"290    elif days <= 14: msg = f"Due soon in {days} days"291    else:           msg = f"Upcoming — next application in {days} days"292    return {293        "division":            record["division"],294        "field_no":            str(record["field_no"]),295        "vp_sd":               record.get("vp_sd","VP"),296        "extent_ha":           record.get("extent_ha"),297        "yield_kgha":          record.get("yield_kgha"),298        "n_ratio_seas":        record.get("n_ratio_seas"),299        "n_threshold":         record.get("n_threshold"),300        "fert_needed":         record.get("fert_needed"),301        "pred_dose_kgha":      record.get("pred_dose_kgha"),302        "predicted_amount_kg": record.get("predicted_amount_kg"),303        "pred_cycle_days":     record.get("pred_cycle_days"),304        "days_until_next":     days,305        "next_app_date":       record.get("next_app_date"),306        "status":              record.get("status"),307        "priority_flag":       record.get("priority_flag",""),308        "apps_per_year":       apps,309        "n_per_app_kgha":      n_app,310        "urea_per_app_kg":     urea_app,311        "interval_days":       interval,312        "status_message":      msg,313        "status_code":         "success"314    }315 316 317# ════════════════════════════════════════════════════════════════════318# 3. SOIL — pH AND C% PREDICTOR (chained models)319# ════════════════════════════════════════════════════════════════════320 321class SoilInput(BaseModel):322    Estate:           str323    Category:         str324    VP_SD:            str325    Extent_Ha:        float326    Year_of_Planting: int327    Prediction_Year:  int328    Known_C:          Optional[float] = None329 330@app.post("/predict/soil")331def predict_soil(data: SoilInput):332    field_age  = data.Prediction_Year - data.Year_of_Planting333    c_pct      = data.Known_C if data.Known_C is not None else GLOBAL_C_MEAN334 335    est_enc_pH = ESTATE_PH_MAP.get(data.Estate,    GLOBAL_PH_MEAN)336    cat_enc_pH = CATEGORY_PH_MAP.get(data.Category, GLOBAL_PH_MEAN)337 338    c_age   = c_pct * field_age339    c_sq    = c_pct ** 2340    log_age = np.log1p(field_age)341    log_c   = np.log1p(c_pct)342    ha_c    = data.Extent_Ha * c_pct343 344    pH_row = pd.DataFrame([[345        data.Category, data.VP_SD, data.Estate,346        data.Extent_Ha, field_age, c_pct, c_age, c_sq,347        log_age, log_c, ha_c, est_enc_pH, cat_enc_pH348    ]], columns=CAT_COLS_PH + NUM_COLS_PH)349 350    pred_pH = round(float(SOIL_PH_MODEL.predict(pH_row)[0]), 3)351 352    est_enc_C = ESTATE_C_MAP.get(data.Estate,    GLOBAL_C_MEAN)353    cat_enc_C = CATEGORY_C_MAP.get(data.Category, GLOBAL_C_MEAN)354    pH_age    = pred_pH * field_age355    pH_sq     = pred_pH ** 2356 357    C_row = pd.DataFrame([[358        data.Category, data.VP_SD, data.Estate,359        data.Extent_Ha, field_age, pred_pH, pH_age, pH_sq,360        log_age, est_enc_C, cat_enc_C361    ]], columns=CAT_COLS_C + NUM_COLS_C)362 363    pred_C = round(float(SOIL_C_MODEL.predict(C_row)[0]), 3)364 365    ph_status = (366        "Optimal"    if 4.5 <= pred_pH <= 5.5 else367        "Acceptable" if (4.0 <= pred_pH < 4.5 or 5.5 < pred_pH <= 6.0) else368        "Poor"369    )370    c_status = "High" if pred_C >= 2.5 else "Medium" if pred_C >= 1.5 else "Low"371 372    return {373        "estate":           data.Estate,374        "category":         data.Category,375        "field_age_years":  field_age,376        "prediction_year":  data.Prediction_Year,377        "predicted_pH":     pred_pH,378        "pH_status":        ph_status,379        "pH_interpretation": (380            "Strongly acidic — lime application recommended." if pred_pH < 4.5 else381            "Moderately acidic — monitor and consider liming." if pred_pH < 5.5 else382            "Optimal range for tea (4.5-5.5)." if pred_pH < 6.0 else383            "Above optimal — monitor pH levels."384        ),385        "predicted_C_pct":  pred_C,386        "C_status":         c_status,387        "C_interpretation": (388            "High organic matter — excellent soil health." if c_status == "High" else389            "Medium organic matter — acceptable range." if c_status == "Medium" else390            "Low organic matter — consider organic amendments."391        ),392        "status": "success"393    }394 395 396# ════════════════════════════════════════════════════════════════════397# 4. SOIL — WET DAYS PREDICTOR398# ════════════════════════════════════════════════════════════════════399 400class SoilWetDaysInput(BaseModel):401    rainfall_mm: float402    month_num:   int403 404@app.post("/predict/soil_wetdays")405def predict_wet_days(data: SoilWetDaysInput):406    rain_sqrt = np.sqrt(data.rainfall_mm)407    X = pd.DataFrame([[data.rainfall_mm, data.month_num, rain_sqrt]],408                     columns=["Rainfall", "Month_num", "Rain_sqrt"])409    predicted_wet_days = round(float(SOIL_WET_MODEL.predict(X)[0]), 1)410    return {411        "rainfall_mm":        data.rainfall_mm,412        "month_num":          data.month_num,413        "predicted_wet_days": predicted_wet_days,414        "status":             "success"415    }416 417 418# ════════════════════════════════════════════════════════════════════419# 5. PRODUCTIVITY — LOOKUP BASED (year + month + workforce)420# ════════════════════════════════════════════════════════════════════421 422class ProductivityInput(BaseModel):423    year:             int424    month:            str425    female_workforce: float426    male_workforce:   float427 428@app.post("/predict/productivity")429def predict_productivity(data: ProductivityInput):430    rows = PROD_DATA[431        (PROD_DATA["year"].astype(int)  == int(data.year)) &432        (PROD_DATA["month"].astype(str) == str(data.month).capitalize())433    ]434    if len(rows) == 0:435        raise HTTPException(status_code=404,436            detail=f"No data found for {data.month} {data.year}. Available years: 2016-2025.")437 438    # yield column may be NaN for future months — that is fine, we are predicting it439    # Fill missing NDVI/EVI/irradiance/rainfall_lag_1 using medians440    row_data = rows.iloc[0].copy()441    month_name = str(data.month).capitalize()442    for col in ["NDVI","EVI","irradiance_SW_DWN","rainfall","wet_days"]:443        if pd.isna(row_data.get(col)):444            row_data[col] = PROD_MEDIANS.get(col, {}).get(month_name, 0)445    if pd.isna(row_data.get("rainfall_lag_1")):446        row_data["rainfall_lag_1"] = PROD_MEDIANS.get("rainfall_lag_1", 403.0)447    # Fix irradiance sentinel value -999448    if not pd.isna(row_data.get("irradiance_SW_DWN")):449        try:450            if float(row_data["irradiance_SW_DWN"]) == -999:451                row_data["irradiance_SW_DWN"] = PROD_MEDIANS.get("irradiance_SW_DWN", {}).get(month_name, 5.0)452        except:453            pass454 455    row          = row_data  # row_data has medians filled for missing values456 457    import math458    def _safe(val, fallback):459        try:460            v = float(val)461            return fallback if math.isnan(v) else v462        except:463            return fallback464 465    # Compute sin_month and cos_month at runtime from month_num466    month_num_val = int(row.get("month_num", 1)) if row.get("month_num") is not None else         {"January":1,"February":2,"March":3,"April":4,"May":5,"June":6,467         "July":7,"August":8,"September":9,"October":10,"November":11,"December":12468         }.get(str(data.month).capitalize(), 1)469    sin_month_val = math.sin(2 * math.pi * month_num_val / 12)470    cos_month_val = math.cos(2 * math.pi * month_num_val / 12)471 472    # Compute lag features at runtime from PROD_DATA sorted by time473    prod_sorted = PROD_DATA.sort_values(["year","month_num"]).reset_index(drop=True)474    req_idx = prod_sorted[(prod_sorted["year"]==int(data.year)) &475                          (prod_sorted["month"]==str(data.month).capitalize())].index476    if len(req_idx) > 0:477        idx = req_idx[0]478        def get_yield_at(offset):479            i = idx - offset480            if i >= 0:481                v = prod_sorted.iloc[i]["yield"]482                try:483                    f = float(v)484                    return f if not math.isnan(f) else np.nan485                except:486                    return np.nan487            return np.nan488        lag1_raw  = get_yield_at(1)489        lag2_raw  = get_yield_at(2)490        lag3_raw  = get_yield_at(3)491        lag12_raw = get_yield_at(12)492        rain_lag1_raw = np.nan493        i1 = idx - 1494        if i1 >= 0:495            try: rain_lag1_raw = float(prod_sorted.iloc[i1]["rainfall"])496            except: pass497    else:498        lag1_raw = lag2_raw = lag3_raw = lag12_raw = rain_lag1_raw = np.nan499 500    lag12    = _safe(lag12_raw, 35000.0)501    lag1     = _safe(lag1_raw,  lag12)502    lag2     = _safe(lag2_raw,  lag1)503    lag3     = _safe(lag3_raw,  lag2)504    momentum = lag1 - lag3505    rain_lag1_val = _safe(rain_lag1_raw, _safe(row.get("rainfall_lag_1"), 403.0))506 507    total_wf     = data.female_workforce + data.male_workforce508    female_ratio = data.female_workforce / total_wf if total_wf > 0 else 0.5509    # Clip ratio to training range to prevent extreme out-of-distribution predictions510    female_ratio = max(0.40, min(0.75, female_ratio))511 512    input_df = pd.DataFrame([{513        "rainfall":              _safe(row.get("rainfall"), 400.0),514        "wet_days":              _safe(row.get("wet_days"), 18.0),515        "female_workforceRatio": female_ratio,516        "yield_lag_1":           lag1,517        "yield_lag_2":           lag2,518        "yield_lag_3":           lag3,519        "yield_lag_12":          lag12,520        "rainfall_lag_1":        rain_lag1_val,521        "irradiance_SW_DWN":     _safe(row.get("irradiance_SW_DWN"), 5.0),522        "sin_month":             sin_month_val,523        "cos_month":             cos_month_val,524        "NDVI":                  _safe(row.get("NDVI"), 0.6),525        "EVI":                   _safe(row.get("EVI"), 0.54),526        "yield_momentum":        momentum,527    }])[PROD_SCALER_FEATURES]528 529    X_scaled        = PROD_SCALER.transform(input_df)530    raw_pred = float(PROD_MODEL.predict(X_scaled)[0])531 532    # If XGBoost extrapolates to an impossible value, fall back to seasonal estimate.533    # Seasonal estimate = 50% same month last year + 30% last month + 20% historical mean.534    # Historical monthly means computed from productivity_data.csv (2018-2025).535    MONTHLY_MEANS = {536        "January": 47481, "February": 34057, "March": 39886, "April": 35686,537        "May": 52434, "June": 38187, "July": 42073, "August": 28380,538        "September": 36932, "October": 32672, "November": 44562, "December": 44043539    }540    MIN_PLAUSIBLE = 16821541    MAX_PLAUSIBLE = 88699542    used_fallback = False543 544    if raw_pred < MIN_PLAUSIBLE or raw_pred > MAX_PLAUSIBLE:545        lag1       = lag1   # already filled above546        lag12      = lag12  # already filled above547        hist_mean  = MONTHLY_MEANS.get(data.month.capitalize(), 40000)548        raw_pred   = 0.5 * lag12 + 0.3 * lag1 + 0.2 * hist_mean549        used_fallback = True550 551    predicted_yield = round(max(MIN_PLAUSIBLE, min(MAX_PLAUSIBLE, raw_pred)))552    lower_bound     = round(max(MIN_PLAUSIBLE, predicted_yield - PROD_RMSE))553    upper_bound     = round(min(MAX_PLAUSIBLE, predicted_yield + PROD_RMSE))554 555    last_yr_rows = PROD_DATA[556        (PROD_DATA["year"].astype(int)  == int(data.year) - 1) &557        (PROD_DATA["month"].astype(str) == str(data.month).capitalize())558    ]559    last_year_yield = None560    yoy_change_pct  = None561    yoy_direction   = None562    if len(last_yr_rows) and not pd.isna(last_yr_rows.iloc[0]["yield"]):563        last_year_yield = round(float(last_yr_rows.iloc[0]["yield"]))564        yoy_change      = (predicted_yield - last_year_yield) / last_year_yield * 100565        yoy_change_pct  = round(yoy_change, 1)566        yoy_direction   = "up" if yoy_change > 0 else "down"567 568    return {569        "predicted_yield_kg":      predicted_yield,570        "lower_bound_kg":          lower_bound,571        "upper_bound_kg":          upper_bound,572        "predicted_month":         data.month,573        "predicted_year":          data.year,574        "last_year_same_month_kg": last_year_yield,575        "yoy_change_pct":          yoy_change_pct,576        "yoy_direction":           yoy_direction,577        "female_ratio_used":       round(female_ratio, 3),578        "used_seasonal_fallback":  used_fallback,579        "status":                  "success"580    }581 582@app.get("/productivity/months")583def get_productivity_months():584    available = PROD_DATA[PROD_DATA["yield"].notna() | PROD_DATA["year"].isin([2025,2026])][["year","month","month_num"]]585    available = available.drop_duplicates().sort_values(["year","month_num"])586    return {587        "available": [588            {"year": int(r["year"]), "month": r["month"]}589            for _, r in available.iterrows()590        ]591    }