Minuka/STEMS-Backend
0
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 }