DarkSting commited on
Commit
1660199
·
verified ·
1 Parent(s): 331a055

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +938 -938
app.py CHANGED
@@ -1,938 +1,938 @@
1
- from flask import Flask, request, jsonify
2
- from flask_cors import CORS
3
- import os
4
- import json
5
- import math
6
- import traceback
7
- import uuid
8
- from typing import Tuple
9
-
10
- import numpy as np
11
- import pandas as pd
12
- import joblib
13
-
14
- import tensorflow as tf
15
- from tensorflow.keras.utils import load_img, img_to_array
16
-
17
- # Hybrid ARIMA
18
- from statsmodels.tsa.arima.model import ARIMA
19
-
20
- app = Flask(__name__)
21
- CORS(app)
22
-
23
- # ---------------------------------------------------------------------
24
- # BASE DIRS
25
- # ---------------------------------------------------------------------
26
- BASE_DIR = os.path.dirname(__file__)
27
- MODEL_DIR = os.path.join(BASE_DIR, "model")
28
-
29
- # ---------------------------------------------------------------------
30
- # (A) TEA PRICE (NEW HYBRID ARIMA + RF)
31
- # ---------------------------------------------------------------------
32
- TEA_ARTIFACT_DIR = os.getenv("TEA_ARTIFACT_DIR", os.path.join(BASE_DIR, "artifacts_tea_hybrid"))
33
- TEA_DATA_PATH = os.getenv("TEA_DATA_PATH", os.path.join(BASE_DIR, "tea_auction_advanced_dataset.csv"))
34
-
35
- TEA_MODEL_PATH = os.path.join(TEA_ARTIFACT_DIR, "hybrid_arima_rf_model.joblib")
36
- TEA_CFG_PATH = os.path.join(TEA_ARTIFACT_DIR, "hybrid_config.json")
37
-
38
- TEA_MIN_ARIMA_POINTS = int(os.getenv("TEA_MIN_ARIMA_POINTS", "60"))
39
-
40
- tea_model = None
41
- tea_cfg = None
42
- tea_df_all = None
43
- tea_load_error = None
44
- tea_data_error = None
45
-
46
- # derived
47
- TEA_TARGET_COL = "auction_price_rs_per_kg"
48
- TEA_DATE_COL = "date_week"
49
- tea_cat_cols = ["elevation", "grade"]
50
- tea_num_cols = []
51
- TEA_ARIMA_ORDER = (2, 1, 2)
52
- TEA_GROUP_COLS = ["elevation", "grade"]
53
-
54
- tea_arima_models = {} # key: (elevation, grade) -> fitted ARIMA
55
- tea_ref_values = {}
56
- tea_fallback_col = None
57
- tea_global_median = None
58
-
59
-
60
- def month_sin_cos(month_num: int):
61
- angle = 2.0 * np.pi * (month_num - 1) / 12.0
62
- return float(np.sin(angle)), float(np.cos(angle))
63
-
64
-
65
- def _tea_safe_load():
66
- global tea_model, tea_cfg, tea_df_all
67
- global TEA_TARGET_COL, TEA_DATE_COL, tea_cat_cols, tea_num_cols, TEA_ARIMA_ORDER, TEA_GROUP_COLS
68
- global tea_load_error, tea_data_error
69
- global tea_fallback_col, tea_global_median, tea_ref_values
70
-
71
- # load artifacts
72
- try:
73
- if not os.path.exists(TEA_MODEL_PATH):
74
- raise FileNotFoundError(f"Missing tea model file: {TEA_MODEL_PATH}")
75
- if not os.path.exists(TEA_CFG_PATH):
76
- raise FileNotFoundError(f"Missing tea config file: {TEA_CFG_PATH}")
77
-
78
- tea_model = joblib.load(TEA_MODEL_PATH)
79
-
80
- with open(TEA_CFG_PATH, "r", encoding="utf-8") as f:
81
- tea_cfg = json.load(f)
82
-
83
- TEA_TARGET_COL = tea_cfg.get("TARGET_COL", TEA_TARGET_COL)
84
- TEA_DATE_COL = tea_cfg.get("DATE_COL", TEA_DATE_COL)
85
- tea_cat_cols = tea_cfg.get("cat_cols", tea_cat_cols)
86
- tea_num_cols = tea_cfg.get("num_cols", tea_num_cols)
87
- TEA_ARIMA_ORDER = tuple(tea_cfg.get("arima_order", list(TEA_ARIMA_ORDER)))
88
- TEA_GROUP_COLS = tea_cfg.get("group_cols", TEA_GROUP_COLS)
89
-
90
- except Exception as e:
91
- tea_load_error = f"Failed to load tea hybrid artifacts: {e}"
92
- tea_model = None
93
- tea_cfg = None
94
-
95
- # load data
96
- try:
97
- if not os.path.exists(TEA_DATA_PATH):
98
- raise FileNotFoundError(f"Missing tea dataset CSV: {TEA_DATA_PATH}")
99
-
100
- tea_df_all = pd.read_csv(TEA_DATA_PATH)
101
- tea_df_all[TEA_DATE_COL] = pd.to_datetime(tea_df_all[TEA_DATE_COL], errors="coerce")
102
- tea_df_all = tea_df_all.dropna(subset=[TEA_DATE_COL, TEA_TARGET_COL]).sort_values(TEA_DATE_COL).reset_index(drop=True)
103
-
104
- tea_fallback_col = "price_lag_1w_rs" if "price_lag_1w_rs" in tea_df_all.columns else None
105
- tea_global_median = float(tea_df_all[TEA_TARGET_COL].median())
106
-
107
- # typical values for local explanation
108
- tea_ref_values = {}
109
- for c in (tea_num_cols or []):
110
- if c in tea_df_all.columns and pd.api.types.is_numeric_dtype(tea_df_all[c]):
111
- tea_ref_values[c] = float(tea_df_all[c].median())
112
- for c in (tea_cat_cols or []):
113
- if c in tea_df_all.columns:
114
- mode = tea_df_all[c].dropna().mode()
115
- tea_ref_values[c] = str(mode.iloc[0]) if len(mode) else ""
116
- tea_ref_values["arima_pred"] = tea_global_median
117
-
118
- except Exception as e:
119
- tea_data_error = f"Failed to load tea dataset: {e}"
120
- tea_df_all = None
121
-
122
-
123
- def _tea_fit_arima_models():
124
- if tea_df_all is None:
125
- return
126
- if not all(c in tea_df_all.columns for c in TEA_GROUP_COLS):
127
- return
128
-
129
- tea_arima_models.clear()
130
-
131
- for key, g in tea_df_all.groupby(TEA_GROUP_COLS):
132
- g = g.sort_values(TEA_DATE_COL)
133
- y = g[TEA_TARGET_COL].astype(float).values
134
- if len(y) < TEA_MIN_ARIMA_POINTS:
135
- continue
136
- try:
137
- tea_arima_models[tuple(key)] = ARIMA(y, order=TEA_ARIMA_ORDER).fit()
138
- except Exception:
139
- continue
140
-
141
-
142
- def tea_build_next_week_input(elevation: str, grade: str, overrides=None):
143
- overrides = overrides or {}
144
-
145
- if tea_df_all is None:
146
- raise ValueError(f"Tea dataset not loaded. {tea_data_error or ''}".strip())
147
-
148
- if "elevation" not in tea_df_all.columns or "grade" not in tea_df_all.columns:
149
- raise ValueError("Tea dataset missing elevation/grade columns.")
150
-
151
- seg = tea_df_all[(tea_df_all["elevation"] == elevation) & (tea_df_all["grade"] == grade)].sort_values(TEA_DATE_COL)
152
- if len(seg) < 10:
153
- raise ValueError("Not enough history for this (elevation, grade). Need >= 10 rows.")
154
-
155
- last = seg.iloc[-1].copy()
156
- next_row = last.copy()
157
-
158
- # next week (+7 days)
159
- next_date = pd.to_datetime(last[TEA_DATE_COL]) + pd.Timedelta(days=7)
160
- next_row[TEA_DATE_COL] = next_date
161
-
162
- # calendar fields if present
163
- if "year" in tea_df_all.columns:
164
- next_row["year"] = int(next_date.year)
165
- if "month" in tea_df_all.columns:
166
- next_row["month"] = int(next_date.month)
167
-
168
- if "month_sin" in tea_df_all.columns and "month_cos" in tea_df_all.columns:
169
- s, c = month_sin_cos(int(next_date.month))
170
- next_row["month_sin"] = s
171
- next_row["month_cos"] = c
172
-
173
- # lag/rolling features if present
174
- if "price_lag_1w_rs" in tea_df_all.columns:
175
- next_row["price_lag_1w_rs"] = float(last[TEA_TARGET_COL])
176
-
177
- if "price_lag_4w_rs" in tea_df_all.columns and len(seg) >= 4:
178
- next_row["price_lag_4w_rs"] = float(seg.iloc[-4][TEA_TARGET_COL])
179
-
180
- if "price_lag_12w_rs" in tea_df_all.columns and len(seg) >= 12:
181
- next_row["price_lag_12w_rs"] = float(seg.iloc[-12][TEA_TARGET_COL])
182
-
183
- if "price_lag_48w_rs" in tea_df_all.columns and len(seg) >= 48:
184
- next_row["price_lag_48w_rs"] = float(seg.iloc[-48][TEA_TARGET_COL])
185
-
186
- if "price_rollmean_4w_rs" in tea_df_all.columns and len(seg) >= 4:
187
- next_row["price_rollmean_4w_rs"] = float(seg[TEA_TARGET_COL].tail(4).mean())
188
-
189
- if "price_rollmean_12w_rs" in tea_df_all.columns and len(seg) >= 12:
190
- next_row["price_rollmean_12w_rs"] = float(seg[TEA_TARGET_COL].tail(12).mean())
191
-
192
- if "price_rollmean_48w_rs" in tea_df_all.columns and len(seg) >= 48:
193
- next_row["price_rollmean_48w_rs"] = float(seg[TEA_TARGET_COL].tail(48).mean())
194
-
195
- # apply overrides
196
- for k, v in (overrides or {}).items():
197
- if k not in next_row.index:
198
- raise KeyError(f"Unknown override column: {k}")
199
- next_row[k] = v
200
-
201
- # target unknown
202
- next_row[TEA_TARGET_COL] = np.nan
203
- return next_row.to_frame().T
204
-
205
-
206
- def tea_get_arima_pred(elevation: str, grade: str, built_row: pd.DataFrame):
207
- key = (elevation, grade)
208
-
209
- if key in tea_arima_models:
210
- try:
211
- return float(tea_arima_models[key].forecast(steps=1)[0])
212
- except Exception:
213
- pass
214
-
215
- if tea_fallback_col and tea_fallback_col in built_row.columns and not pd.isna(built_row[tea_fallback_col].iloc[0]):
216
- return float(built_row[tea_fallback_col].iloc[0])
217
-
218
- return float(tea_global_median) if tea_global_median is not None else 0.0
219
-
220
-
221
- def tea_local_sensitivity_explain(model, X: pd.DataFrame, pred: float, ref_values: dict, top_k: int = 6):
222
- impacts = []
223
- for col in X.columns:
224
- if col not in ref_values:
225
- continue
226
-
227
- x_tmp = X.copy()
228
- original_val = x_tmp[col].iloc[0]
229
- typical_val = ref_values[col]
230
-
231
- try:
232
- if pd.isna(original_val) and pd.isna(typical_val):
233
- continue
234
- if str(original_val) == str(typical_val):
235
- continue
236
- except Exception:
237
- pass
238
-
239
- x_tmp[col] = typical_val
240
- try:
241
- pred_typical = float(model.predict(x_tmp)[0])
242
- except Exception:
243
- continue
244
-
245
- impact = pred - pred_typical
246
- impacts.append({
247
- "feature": col,
248
- "value": None if pd.isna(original_val) else (float(original_val) if isinstance(original_val, (int, float, np.number)) else str(original_val)),
249
- "typical": typical_val,
250
- "impact": float(impact)
251
- })
252
-
253
- impacts.sort(key=lambda d: abs(d["impact"]), reverse=True)
254
- return impacts[:top_k]
255
-
256
-
257
- def tea_segment_context(elevation: str, grade: str):
258
- if tea_df_all is None:
259
- return None
260
-
261
- seg = tea_df_all[(tea_df_all["elevation"] == elevation) & (tea_df_all["grade"] == grade)].sort_values(TEA_DATE_COL)
262
- if len(seg) == 0:
263
- return None
264
-
265
- last_price = float(seg.iloc[-1][TEA_TARGET_COL])
266
- mean_4w = float(seg[TEA_TARGET_COL].tail(4).mean()) if len(seg) >= 4 else None
267
- mean_12w = float(seg[TEA_TARGET_COL].tail(12).mean()) if len(seg) >= 12 else None
268
-
269
- trend = None
270
- if mean_4w is not None:
271
- trend = "up" if last_price > mean_4w else ("down" if last_price < mean_4w else "flat")
272
-
273
- return {
274
- "last_price": last_price,
275
- "avg_4w": mean_4w,
276
- "avg_12w": mean_12w,
277
- "trend_vs_4w_avg": trend,
278
- "history_points": int(len(seg))
279
- }
280
-
281
-
282
- def tea_describe_direction(val, typical):
283
- try:
284
- v = float(val); t = float(typical)
285
- if np.isfinite(v) and np.isfinite(t):
286
- if abs(v - t) <= (0.02 * (abs(t) + 1e-6)):
287
- return "close to usual"
288
- return "higher than usual" if v > t else "lower than usual"
289
- except Exception:
290
- pass
291
- return "different from usual"
292
-
293
-
294
- def tea_feature_display_name(f):
295
- nice = {
296
- "fx_lkr_per_usd": "USD→LKR exchange rate",
297
- "rainfall_mm": "rainfall",
298
- "temperature_c": "temperature",
299
- "arima_pred": "recent price trend (time-series)",
300
- "price_lag_1w_rs": "last week price",
301
- "price_rollmean_4w_rs": "last 4-week average price",
302
- "price_rollmean_12w_rs": "last 12-week average price",
303
- }
304
- return nice.get(f, f.replace("_", " "))
305
-
306
-
307
- def tea_build_explanation_text(pred, factors, segment_ctx=None, top_k=5):
308
- top = factors[:top_k]
309
- bullets = []
310
-
311
- for item in top:
312
- f = item["feature"]
313
- val = item["value"]
314
- typical = item["typical"]
315
- impact = item["impact"]
316
-
317
- if abs(impact) < 0.5:
318
- continue
319
-
320
- if f == "arima_pred":
321
- if segment_ctx and segment_ctx.get("trend_vs_4w_avg"):
322
- trend = segment_ctx["trend_vs_4w_avg"]
323
- bullets.append(
324
- f"Recent segment trend looks **{trend}**, which {'pushes up' if impact > 0 else 'pulls down'} the prediction (time-series effect)."
325
- )
326
- else:
327
- bullets.append("Recent price pattern in this segment influences the forecast (time-series effect).")
328
- continue
329
-
330
- name = tea_feature_display_name(f)
331
- direction = tea_describe_direction(val, typical)
332
-
333
- if impact > 0:
334
- bullets.append(f"{name} is **{direction}** ({val} vs typical {typical}), so the model expects price to be **higher**.")
335
- else:
336
- bullets.append(f"{name} is **{direction}** ({val} vs typical {typical}), so the model expects price to be **lower**.")
337
-
338
- seg_line = None
339
- if segment_ctx:
340
- lp = segment_ctx.get("last_price")
341
- a4 = segment_ctx.get("avg_4w")
342
- if lp is not None and a4 is not None:
343
- seg_line = f"Last recorded price was **{lp:.2f}** and the 4-week average is **{a4:.2f}**."
344
-
345
- if bullets:
346
- main_push = "higher" if sum([f["impact"] for f in top]) > 0 else "lower"
347
- summary = f"Predicted price is **{pred:.2f}** mainly because the strongest inputs/trend signals push the model **{main_push}** compared to typical conditions."
348
- else:
349
- summary = f"Predicted price is **{pred:.2f}** based on learned patterns from history for this segment and the provided inputs."
350
-
351
- if seg_line:
352
- summary = summary + " " + seg_line
353
-
354
- return summary, bullets
355
-
356
-
357
- # init tea
358
- _tea_safe_load()
359
- if tea_model is not None and tea_df_all is not None:
360
- _tea_fit_arima_models()
361
-
362
- # ---------------------------------------------------------------------
363
- # (B) YIELD MODEL (KEEP EXISTING)
364
- # ---------------------------------------------------------------------
365
- YIELD_MODEL_PATH = os.getenv("YIELD_MODEL_PATH", os.path.join(MODEL_DIR, "smarttea_yield_model.joblib"))
366
- YIELD_DATA_PATH = os.getenv("YIELD_DATA_PATH", os.path.join(BASE_DIR, "data/smarttea_monthly_yield_dataset_sri_lanka_synthetic_2000_2025.csv"))
367
- YIELD_DATE_COL = os.getenv("YIELD_DATE_COL", "date")
368
- YIELD_TARGET_COL = os.getenv("YIELD_TARGET_COL", "yield_kg_per_ha")
369
-
370
- REGION_DEFAULTS = {
371
- "Nuwara_Eliya": {"elevation_band": "high", "elevation_m": 1850, "country": "Sri_Lanka"},
372
- "Uva": {"elevation_band": "mid", "elevation_m": 1200, "country": "Sri_Lanka"},
373
- "Kandy": {"elevation_band": "mid", "elevation_m": 900, "country": "Sri_Lanka"},
374
- "Sabaragamuwa": {"elevation_band": "low", "elevation_m": 300, "country": "Sri_Lanka"},
375
- "Galle": {"elevation_band": "low", "elevation_m": 50, "country": "Sri_Lanka"},
376
- }
377
-
378
- yield_model = None
379
- yield_feature_cols = None
380
- yield_load_error = None
381
-
382
- yield_df = None
383
- yield_data_error = None
384
-
385
-
386
- def unwrap_model(obj):
387
- if isinstance(obj, dict):
388
- model = obj.get("model", obj)
389
- feature_cols = obj.get("feature_cols")
390
- target = obj.get("target")
391
- return model, feature_cols, target
392
- model = obj
393
- feature_cols = getattr(model, "feature_names_in_", None)
394
- target = None
395
- return model, (list(feature_cols) if feature_cols is not None else None), target
396
-
397
-
398
- try:
399
- if os.path.exists(YIELD_MODEL_PATH):
400
- yield_model_raw = joblib.load(YIELD_MODEL_PATH)
401
- yield_model, yield_feature_cols, _ = unwrap_model(yield_model_raw)
402
- else:
403
- yield_load_error = f"Yield model file not found at: {YIELD_MODEL_PATH}"
404
- except Exception as e:
405
- yield_load_error = f"Failed to load YIELD model: {e}"
406
-
407
- try:
408
- if os.path.exists(YIELD_DATA_PATH):
409
- yield_df = pd.read_csv(YIELD_DATA_PATH)
410
- yield_df[YIELD_DATE_COL] = pd.to_datetime(yield_df[YIELD_DATE_COL], errors="coerce")
411
- yield_df = yield_df.dropna(subset=[YIELD_DATE_COL]).sort_values([YIELD_DATE_COL]).reset_index(drop=True)
412
- else:
413
- yield_data_error = f"Yield dataset not found at: {YIELD_DATA_PATH}"
414
- except Exception as e:
415
- yield_data_error = f"Failed to load YIELD dataset: {e}"
416
-
417
-
418
- YIELD_REQUIRED_INPUTS = [
419
- "region", "year", "month",
420
- "rainfall_mm", "temp_avg_c", "temp_min_c", "temp_max_c",
421
- "humidity_pct", "soil_ph", "soil_ec_ds_m",
422
- "fertilizer_kg_per_ha", "disease_index",
423
- ]
424
-
425
-
426
- def get_region_history(region: str, current_date: pd.Timestamp) -> pd.DataFrame:
427
- if yield_df is None or "region" not in yield_df.columns:
428
- return pd.DataFrame()
429
- h = yield_df[(yield_df["region"] == region) & (yield_df[YIELD_DATE_COL] < current_date)].copy()
430
- return h.sort_values(YIELD_DATE_COL)
431
-
432
-
433
- def compute_yield_lags_rolls(region_hist: pd.DataFrame):
434
- if region_hist is None or len(region_hist) == 0 or YIELD_TARGET_COL not in region_hist.columns:
435
- return {
436
- "yield_lag_1": None, "yield_lag_3": None, "yield_lag_12": None,
437
- "yield_rollmean_3": None, "yield_rollmean_6": None, "yield_rollmean_12": None,
438
- }
439
-
440
- y = region_hist[YIELD_TARGET_COL].astype(float).values
441
-
442
- def lag(k):
443
- if len(y) >= k:
444
- return float(y[-k])
445
- return float(y[0])
446
-
447
- def roll(k):
448
- k = min(k, len(y))
449
- return float(np.mean(y[-k:]))
450
-
451
- return {
452
- "yield_lag_1": lag(1),
453
- "yield_lag_3": lag(3) if len(y) >= 3 else lag(1),
454
- "yield_lag_12": lag(12) if len(y) >= 12 else lag(1),
455
- "yield_rollmean_3": roll(3),
456
- "yield_rollmean_6": roll(6),
457
- "yield_rollmean_12": roll(12),
458
- }
459
-
460
-
461
- def compute_exog_rolls(region_hist: pd.DataFrame):
462
- def rmean(col, n):
463
- if region_hist is None or len(region_hist) == 0 or col not in region_hist.columns:
464
- return None
465
- vals = region_hist[col].astype(float).values
466
- if len(vals) >= n:
467
- return float(np.mean(vals[-n:]))
468
- return float(np.mean(vals)) if len(vals) else None
469
-
470
- return {
471
- "rain_rollmean_3": rmean("rainfall_mm", 3),
472
- "rain_rollmean_6": rmean("rainfall_mm", 6),
473
- "temp_rollmean_3": rmean("temp_avg_c", 3),
474
- "temp_rollmean_6": rmean("temp_avg_c", 6),
475
- "fert_rollmean_3": rmean("fertilizer_kg_per_ha", 3),
476
- "fert_rollmean_6": rmean("fertilizer_kg_per_ha", 6),
477
- "disease_rollmean_3": rmean("disease_index", 3),
478
- "disease_rollmean_6": rmean("disease_index", 6),
479
- }
480
-
481
-
482
- def local_feature_impact(model_pipeline, X_row: pd.DataFrame, numeric_features, steps=0.03, top_n=6):
483
- base = float(model_pipeline.predict(X_row)[0])
484
- impacts = []
485
-
486
- for f in numeric_features:
487
- if f not in X_row.columns:
488
- continue
489
-
490
- v = X_row.iloc[0][f]
491
- if pd.isna(v):
492
- continue
493
-
494
- delta = max(abs(float(v)) * steps, 0.01)
495
-
496
- X_up = X_row.copy()
497
- X_dn = X_row.copy()
498
-
499
- X_up.loc[X_up.index[0], f] = float(v) + delta
500
- X_dn.loc[X_dn.index[0], f] = float(v) - delta
501
-
502
- p_up = float(model_pipeline.predict(X_up)[0])
503
- p_dn = float(model_pipeline.predict(X_dn)[0])
504
-
505
- effect = (p_up - p_dn) / 2.0
506
-
507
- impacts.append({
508
- "feature": f,
509
- "impact_kg_per_ha": round(float(effect), 3),
510
- "direction": "increases" if effect > 0 else "decreases"
511
- })
512
-
513
- impacts.sort(key=lambda x: abs(x["impact_kg_per_ha"]), reverse=True)
514
- return base, impacts[:top_n]
515
-
516
-
517
- def build_yield_row(payload: dict):
518
- if yield_model is None:
519
- raise ValueError("Yield model is not loaded. Check YIELD_MODEL_PATH.")
520
-
521
- missing = [k for k in YIELD_REQUIRED_INPUTS if k not in payload]
522
- if missing:
523
- raise ValueError(f"Missing required fields: {missing}")
524
-
525
- region = str(payload["region"])
526
- year = int(payload["year"])
527
- month = int(payload["month"])
528
- current_date = pd.Timestamp(f"{year}-{month:02d}-01")
529
-
530
- defaults = REGION_DEFAULTS.get(region)
531
- if not defaults:
532
- raise ValueError(f"Unknown region '{region}'. Allowed: {list(REGION_DEFAULTS.keys())}")
533
-
534
- ms, mc = month_sin_cos(month)
535
-
536
- region_hist = get_region_history(region, current_date)
537
- lag_feats = compute_yield_lags_rolls(region_hist)
538
- exog_rolls = compute_exog_rolls(region_hist)
539
-
540
- row = {
541
- "region": region,
542
- "country": defaults["country"],
543
- "elevation_band": defaults["elevation_band"],
544
- "elevation_m": defaults["elevation_m"],
545
- "year": year,
546
- "month": month,
547
- "month_sin": ms,
548
- "month_cos": mc,
549
-
550
- "rainfall_mm": float(payload["rainfall_mm"]),
551
- "temp_avg_c": float(payload["temp_avg_c"]),
552
- "temp_min_c": float(payload["temp_min_c"]),
553
- "temp_max_c": float(payload["temp_max_c"]),
554
- "humidity_pct": float(payload["humidity_pct"]),
555
- "soil_ph": float(payload["soil_ph"]),
556
- "soil_ec_ds_m": float(payload["soil_ec_ds_m"]),
557
- "fertilizer_kg_per_ha": float(payload["fertilizer_kg_per_ha"]),
558
- "disease_index": float(payload["disease_index"]),
559
- }
560
-
561
- row.update(lag_feats)
562
- row.update(exog_rolls)
563
-
564
- X = pd.DataFrame([row])
565
-
566
- if yield_feature_cols:
567
- for c in yield_feature_cols:
568
- if c not in X.columns:
569
- X[c] = np.nan
570
- X = X[yield_feature_cols]
571
-
572
- return X, str(current_date.date()), int(len(region_hist))
573
-
574
- # ---------------------------------------------------------------------
575
- # (C) LEAF DISEASE MODEL (KEEP EXISTING)
576
- # ---------------------------------------------------------------------
577
- LEAF_WEIGHTS_PATH = os.getenv("LEAF_WEIGHTS_PATH", os.path.join(MODEL_DIR, "tea_mobilenet_v2.weights.h5"))
578
- LEAF_LABELS_PATH = os.getenv("LEAF_LABELS_PATH", os.path.join(MODEL_DIR, "labels.json"))
579
- UPLOAD_DIR = os.getenv("UPLOAD_DIR", os.path.join(BASE_DIR, "uploads"))
580
- IMG_SIZE: Tuple[int, int] = (224, 224)
581
- os.makedirs(UPLOAD_DIR, exist_ok=True)
582
-
583
- leaf_class_names = None
584
- leaf_model = None
585
- leaf_load_error = None
586
-
587
-
588
- def build_leaf_model(num_classes: int) -> tf.keras.Model:
589
- base_model = tf.keras.applications.MobileNetV2(
590
- input_shape=IMG_SIZE + (3,),
591
- include_top=False,
592
- weights="imagenet",
593
- )
594
- base_model.trainable = False
595
-
596
- inputs = tf.keras.Input(shape=IMG_SIZE + (3,), name="input_layer_1")
597
- x = tf.keras.applications.mobilenet_v2.preprocess_input(inputs)
598
- x = base_model(x, training=False)
599
- x = tf.keras.layers.GlobalAveragePooling2D(name="global_avg_pool")(x)
600
- x = tf.keras.layers.Dropout(0.2, name="dropout")(x)
601
- outputs = tf.keras.layers.Dense(num_classes, activation="softmax", name="dense")(x)
602
- return tf.keras.Model(inputs, outputs, name="tea_mobilenet_v2_inference")
603
-
604
-
605
- try:
606
- if os.path.exists(LEAF_LABELS_PATH):
607
- with open(LEAF_LABELS_PATH, "r", encoding="utf-8") as f:
608
- leaf_class_names = json.load(f)
609
- else:
610
- raise FileNotFoundError(f"Leaf labels not found at: {LEAF_LABELS_PATH}")
611
-
612
- leaf_model = build_leaf_model(num_classes=len(leaf_class_names))
613
-
614
- if not os.path.exists(LEAF_WEIGHTS_PATH):
615
- raise FileNotFoundError(f"Leaf weights not found at: {LEAF_WEIGHTS_PATH}")
616
-
617
- leaf_model.load_weights(LEAF_WEIGHTS_PATH)
618
-
619
- except Exception as e:
620
- leaf_load_error = f"Failed to load LEAF model: {e}"
621
- leaf_model = None
622
- leaf_class_names = None
623
-
624
-
625
- def predict_leaf_image(image_path: str):
626
- if leaf_model is None or leaf_class_names is None:
627
- raise RuntimeError(leaf_load_error or "Leaf model not loaded.")
628
-
629
- img = load_img(image_path, target_size=IMG_SIZE)
630
- img_array = img_to_array(img)
631
- img_batch = np.expand_dims(img_array, axis=0)
632
-
633
- probs = leaf_model.predict(img_batch, verbose=0)[0]
634
- pred_index = int(np.argmax(probs))
635
- pred_label = leaf_class_names[pred_index]
636
- confidence = float(probs[pred_index])
637
-
638
- probs_list = [float(p) for p in probs]
639
- probs_dict = {leaf_class_names[i]: probs_list[i] for i in range(len(leaf_class_names))}
640
- return pred_label, confidence, probs_dict
641
-
642
- # ---------------------------------------------------------------------
643
- # ROUTES
644
- # ---------------------------------------------------------------------
645
- @app.get("/health")
646
- def health():
647
- return jsonify({
648
- "status": "ok",
649
- "tea_price_hybrid_loaded": tea_model is not None,
650
- "tea_price_segments_with_arima": int(len(tea_arima_models)) if tea_model is not None else 0,
651
- "yield_model_loaded": yield_model is not None,
652
- "leaf_model_loaded": leaf_model is not None,
653
- "paths": {
654
- "tea_artifact_dir": TEA_ARTIFACT_DIR,
655
- "tea_model_path": TEA_MODEL_PATH,
656
- "tea_cfg_path": TEA_CFG_PATH,
657
- "tea_data_path": TEA_DATA_PATH,
658
- "yield_model_path": YIELD_MODEL_PATH,
659
- "yield_data_path": YIELD_DATA_PATH,
660
- "leaf_weights_path": LEAF_WEIGHTS_PATH,
661
- "leaf_labels_path": LEAF_LABELS_PATH,
662
- },
663
- "errors": {
664
- "tea_load_error": tea_load_error,
665
- "tea_data_error": tea_data_error,
666
- "yield_load_error": yield_load_error,
667
- "yield_data_error": yield_data_error,
668
- "leaf_load_error": leaf_load_error,
669
- },
670
- "endpoints": {
671
- "GET /tea-price/meta": "Tea price metadata (elevations, grades, override keys)",
672
- "POST /tea-price/predict-next-week": "Tea price next-week forecast (elevation+grade + overrides + explain)",
673
- "POST /predict/yield-simple": "Yield prediction",
674
- "POST /predict/leaf": "Leaf disease prediction (image upload)",
675
- }
676
- })
677
-
678
-
679
- # -------------------------
680
- # TEA PRICE: health/meta/predict-next-week
681
- # -------------------------
682
- @app.get("/tea-price/health")
683
- def tea_price_health():
684
- return jsonify({
685
- "ok": True,
686
- "model_loaded": tea_model is not None,
687
- "cfg_loaded": tea_cfg is not None,
688
- "rows_in_history": int(len(tea_df_all)) if tea_df_all is not None else 0,
689
- "segments_with_arima": int(len(tea_arima_models)),
690
- "target": TEA_TARGET_COL,
691
- "date_col": TEA_DATE_COL,
692
- "error": tea_load_error or tea_data_error
693
- })
694
-
695
-
696
- @app.get("/tea-price/meta")
697
- def tea_price_meta():
698
- if tea_df_all is None:
699
- return jsonify({"ok": False, "error": tea_data_error or "Tea dataset not loaded"}), 500
700
-
701
- return jsonify({
702
- "ok": True,
703
- "target": TEA_TARGET_COL,
704
- "date_col": TEA_DATE_COL,
705
- "cat_cols": tea_cat_cols,
706
- "num_cols": tea_num_cols,
707
- "example_override_keys": [c for c in tea_df_all.columns if c not in [TEA_TARGET_COL]],
708
- "unique_elevations": sorted(tea_df_all["elevation"].dropna().unique().tolist()) if "elevation" in tea_df_all.columns else [],
709
- "unique_grades": sorted(tea_df_all["grade"].dropna().unique().tolist()) if "grade" in tea_df_all.columns else [],
710
- })
711
-
712
-
713
- @app.post("/tea-price/predict-next-week")
714
- def tea_price_predict_next_week():
715
- if tea_model is None:
716
- return jsonify({"ok": False, "error": tea_load_error or "Tea hybrid model not loaded"}), 500
717
- if tea_df_all is None:
718
- return jsonify({"ok": False, "error": tea_data_error or "Tea dataset not loaded"}), 500
719
-
720
- body = request.get_json(silent=True) or {}
721
-
722
- elevation = str(body.get("elevation", "")).strip()
723
- grade = str(body.get("grade", "")).strip()
724
- overrides = body.get("overrides") or {}
725
-
726
- if not elevation or not grade:
727
- return jsonify({"ok": False, "error": "elevation and grade are required"}), 400
728
- if not isinstance(overrides, dict):
729
- return jsonify({"ok": False, "error": "overrides must be an object/dict"}), 400
730
-
731
- try:
732
- row = tea_build_next_week_input(elevation, grade, overrides=overrides)
733
- arima_pred = tea_get_arima_pred(elevation, grade, row)
734
-
735
- # build X exactly like notebook expects
736
- needed_cols = (tea_cat_cols or []) + (tea_num_cols or [])
737
- X = row.copy()
738
-
739
- # ensure required cols exist
740
- for c in needed_cols:
741
- if c not in X.columns:
742
- X[c] = np.nan
743
-
744
- X = X[needed_cols].copy()
745
- X["arima_pred"] = arima_pred
746
-
747
- pred = float(tea_model.predict(X)[0])
748
-
749
- want_explain = bool(body.get("explain", False))
750
- explain_payload = None
751
-
752
- if want_explain:
753
- factors = tea_local_sensitivity_explain(tea_model, X, pred, tea_ref_values, top_k=8)
754
- seg_ctx = tea_segment_context(elevation, grade)
755
- summary, bullets = tea_build_explanation_text(pred, factors, seg_ctx, top_k=5)
756
-
757
- explain_payload = {
758
- "summary": summary,
759
- "reasons": bullets,
760
- "top_factors": factors,
761
- "segment_context": seg_ctx,
762
- "disclaimer": "These reasons explain what the model learned from data (correlations), not guaranteed real-world causation."
763
- }
764
-
765
- return jsonify({
766
- "ok": True,
767
- "elevation": elevation,
768
- "grade": grade,
769
- "predicted_price": pred,
770
- "arima_pred": arima_pred,
771
- "next_date": str(pd.to_datetime(row[TEA_DATE_COL].iloc[0]).date()),
772
- "explanation": explain_payload
773
- })
774
-
775
- except KeyError as e:
776
- return jsonify({"ok": False, "error": str(e)}), 400
777
- except Exception as e:
778
- return jsonify({"ok": False, "error": str(e), "trace": traceback.format_exc()}), 500
779
-
780
-
781
- # -------------------------
782
- # YIELD
783
- # -------------------------
784
- @app.get("/debug/yield-model")
785
- def debug_yield_model():
786
- try:
787
- obj = joblib.load(YIELD_MODEL_PATH)
788
- return jsonify({
789
- "ok": True,
790
- "path": YIELD_MODEL_PATH,
791
- "type": str(type(obj)),
792
- "keys": list(obj.keys()) if isinstance(obj, dict) else None
793
- })
794
- except Exception as e:
795
- return jsonify({"ok": False, "error": str(e), "trace": traceback.format_exc()}), 500
796
-
797
-
798
- @app.post("/predict/yield-simple")
799
- def predict_yield():
800
- try:
801
- if yield_model is None:
802
- return jsonify({
803
- "success": False,
804
- "error": "Yield model not loaded",
805
- "details": yield_load_error,
806
- "hint": "Put your yield .joblib file in the model folder and set YIELD_MODEL_PATH if needed."
807
- }), 500
808
-
809
- payload = request.get_json(silent=True) or {}
810
- X, pred_date, history_months = build_yield_row(payload)
811
-
812
- pred = float(yield_model.predict(X)[0])
813
-
814
- numeric_for_explain = [
815
- "rainfall_mm", "temp_avg_c", "humidity_pct",
816
- "soil_ph", "soil_ec_ds_m", "fertilizer_kg_per_ha", "disease_index",
817
- "yield_lag_1", "yield_lag_3", "yield_lag_12",
818
- "rain_rollmean_3", "rain_rollmean_6",
819
- "temp_rollmean_3", "temp_rollmean_6",
820
- "fert_rollmean_3", "fert_rollmean_6",
821
- "disease_rollmean_3", "disease_rollmean_6",
822
- ]
823
-
824
- base_pred, top_impacts = local_feature_impact(yield_model, X, numeric_for_explain)
825
-
826
- pos = [i for i in top_impacts if i["impact_kg_per_ha"] > 0][:2]
827
- neg = [i for i in top_impacts if i["impact_kg_per_ha"] < 0][:2]
828
-
829
- parts = []
830
- if pos:
831
- parts.append("higher " + " & ".join([p["feature"] for p in pos]))
832
- if neg:
833
- parts.append("lower " + " & ".join([n["feature"] for n in neg]))
834
-
835
- explain_sentence = "Prediction is mainly influenced by " + (", and ".join(parts) if parts else "the input factors.")
836
-
837
- # labour estimation
838
- area_ha = float(payload.get("area_ha", 1.0))
839
- plucking_days = int(payload.get("plucking_days", 22))
840
- productivity = float(payload.get("productivity_kg_per_worker_day", 20.0))
841
- efficiency = float(payload.get("efficiency", 0.9))
842
-
843
- total_harvest_kg = pred * area_ha
844
- den = productivity * plucking_days * max(efficiency, 0.01)
845
- labourers_needed = int(math.ceil(total_harvest_kg / den))
846
-
847
- warnings = []
848
- if history_months < 12 and yield_df is not None:
849
- warnings.append(
850
- f"Only {history_months} months of history were available before {pred_date}; some lag/rolling features may be weak."
851
- )
852
-
853
- return jsonify({
854
- "success": True,
855
- "prediction": {
856
- "yield_kg_per_ha": round(pred, 2),
857
- "for_month": pred_date,
858
- "area_ha": area_ha,
859
- "total_harvest_kg": round(total_harvest_kg, 2),
860
- "labourers_needed": labourers_needed,
861
- "assumptions": {
862
- "plucking_days": plucking_days,
863
- "productivity_kg_per_worker_day": productivity,
864
- "efficiency": efficiency
865
- }
866
- },
867
- "explainability": {
868
- "summary": explain_sentence,
869
- "top_factors": top_impacts
870
- },
871
- "meta": {
872
- "region": payload.get("region"),
873
- "history_months_used": history_months,
874
- "warnings": warnings
875
- }
876
- })
877
-
878
- except Exception as e:
879
- return jsonify({"success": False, "error": str(e), "trace": traceback.format_exc()}), 400
880
-
881
-
882
- # -------------------------
883
- # LEAF
884
- # -------------------------
885
- @app.post("/predict/leaf")
886
- def predict_leaf():
887
- if leaf_model is None or leaf_class_names is None:
888
- return jsonify({
889
- "ok": False,
890
- "error": "Leaf model not loaded",
891
- "details": leaf_load_error,
892
- "hint": "Make sure model/labels.json and model/tea_mobilenet_v2.weights.h5 exist."
893
- }), 500
894
-
895
- if "image" not in request.files:
896
- return jsonify({"ok": False, "error": "No file part 'image' in the request"}), 400
897
-
898
- file = request.files["image"]
899
- if file.filename == "":
900
- return jsonify({"ok": False, "error": "No file selected"}), 400
901
-
902
- allowed_ext = (".jpg", ".jpeg", ".png")
903
- if not file.filename.lower().endswith(allowed_ext):
904
- return jsonify({"ok": False, "error": "Unsupported file type. Use JPG or PNG."}), 400
905
-
906
- temp_filename = f"{uuid.uuid4().hex}_{file.filename}"
907
- temp_path = os.path.join(UPLOAD_DIR, temp_filename)
908
- file.save(temp_path)
909
-
910
- try:
911
- label, confidence, probs_dict = predict_leaf_image(temp_path)
912
- return jsonify({
913
- "ok": True,
914
- "prediction": label,
915
- "confidence": confidence,
916
- "probabilities": probs_dict
917
- })
918
- except Exception as e:
919
- return jsonify({
920
- "ok": False,
921
- "error": "Failed to process image",
922
- "details": str(e),
923
- "trace": traceback.format_exc()
924
- }), 500
925
- finally:
926
- try:
927
- if os.path.exists(temp_path):
928
- os.remove(temp_path)
929
- except Exception:
930
- pass
931
-
932
-
933
- # ---------------------------------------------------------------------
934
- # MAIN
935
- # ---------------------------------------------------------------------
936
- if __name__ == "__main__":
937
- port = int(os.getenv("PORT", "5000"))
938
- app.run(host="0.0.0.0", port=port, debug=True)
 
1
+ from flask import Flask, request, jsonify
2
+ from flask_cors import CORS
3
+ import os
4
+ import json
5
+ import math
6
+ import traceback
7
+ import uuid
8
+ from typing import Tuple
9
+
10
+ import numpy as np
11
+ import pandas as pd
12
+ import joblib
13
+
14
+ import tensorflow as tf
15
+ from tensorflow.keras.utils import load_img, img_to_array
16
+
17
+ # Hybrid ARIMA
18
+ from statsmodels.tsa.arima.model import ARIMA
19
+
20
+ app = Flask(__name__)
21
+ CORS(app)
22
+
23
+ # ---------------------------------------------------------------------
24
+ # BASE DIRS
25
+ # ---------------------------------------------------------------------
26
+ BASE_DIR = os.path.dirname(__file__)
27
+ MODEL_DIR = os.path.join(BASE_DIR, "model")
28
+
29
+ # ---------------------------------------------------------------------
30
+ # (A) TEA PRICE (NEW HYBRID ARIMA + RF)
31
+ # ---------------------------------------------------------------------
32
+ TEA_ARTIFACT_DIR = os.getenv("TEA_ARTIFACT_DIR", os.path.join(BASE_DIR, "artifacts_tea_hybrid"))
33
+ TEA_DATA_PATH = os.getenv("TEA_DATA_PATH", os.path.join(BASE_DIR, "tea_auction_advanced_dataset.csv"))
34
+
35
+ TEA_MODEL_PATH = os.path.join(TEA_ARTIFACT_DIR, "hybrid_arima_rf_model.joblib")
36
+ TEA_CFG_PATH = os.path.join(TEA_ARTIFACT_DIR, "hybrid_config.json")
37
+
38
+ TEA_MIN_ARIMA_POINTS = int(os.getenv("TEA_MIN_ARIMA_POINTS", "60"))
39
+
40
+ tea_model = None
41
+ tea_cfg = None
42
+ tea_df_all = None
43
+ tea_load_error = None
44
+ tea_data_error = None
45
+
46
+ # derived
47
+ TEA_TARGET_COL = "auction_price_rs_per_kg"
48
+ TEA_DATE_COL = "date_week"
49
+ tea_cat_cols = ["elevation", "grade"]
50
+ tea_num_cols = []
51
+ TEA_ARIMA_ORDER = (2, 1, 2)
52
+ TEA_GROUP_COLS = ["elevation", "grade"]
53
+
54
+ tea_arima_models = {} # key: (elevation, grade) -> fitted ARIMA
55
+ tea_ref_values = {}
56
+ tea_fallback_col = None
57
+ tea_global_median = None
58
+
59
+
60
+ def month_sin_cos(month_num: int):
61
+ angle = 2.0 * np.pi * (month_num - 1) / 12.0
62
+ return float(np.sin(angle)), float(np.cos(angle))
63
+
64
+
65
+ def _tea_safe_load():
66
+ global tea_model, tea_cfg, tea_df_all
67
+ global TEA_TARGET_COL, TEA_DATE_COL, tea_cat_cols, tea_num_cols, TEA_ARIMA_ORDER, TEA_GROUP_COLS
68
+ global tea_load_error, tea_data_error
69
+ global tea_fallback_col, tea_global_median, tea_ref_values
70
+
71
+ # load artifacts
72
+ try:
73
+ if not os.path.exists(TEA_MODEL_PATH):
74
+ raise FileNotFoundError(f"Missing tea model file: {TEA_MODEL_PATH}")
75
+ if not os.path.exists(TEA_CFG_PATH):
76
+ raise FileNotFoundError(f"Missing tea config file: {TEA_CFG_PATH}")
77
+
78
+ tea_model = joblib.load(TEA_MODEL_PATH)
79
+
80
+ with open(TEA_CFG_PATH, "r", encoding="utf-8") as f:
81
+ tea_cfg = json.load(f)
82
+
83
+ TEA_TARGET_COL = tea_cfg.get("TARGET_COL", TEA_TARGET_COL)
84
+ TEA_DATE_COL = tea_cfg.get("DATE_COL", TEA_DATE_COL)
85
+ tea_cat_cols = tea_cfg.get("cat_cols", tea_cat_cols)
86
+ tea_num_cols = tea_cfg.get("num_cols", tea_num_cols)
87
+ TEA_ARIMA_ORDER = tuple(tea_cfg.get("arima_order", list(TEA_ARIMA_ORDER)))
88
+ TEA_GROUP_COLS = tea_cfg.get("group_cols", TEA_GROUP_COLS)
89
+
90
+ except Exception as e:
91
+ tea_load_error = f"Failed to load tea hybrid artifacts: {e}"
92
+ tea_model = None
93
+ tea_cfg = None
94
+
95
+ # load data
96
+ try:
97
+ if not os.path.exists(TEA_DATA_PATH):
98
+ raise FileNotFoundError(f"Missing tea dataset CSV: {TEA_DATA_PATH}")
99
+
100
+ tea_df_all = pd.read_csv(TEA_DATA_PATH)
101
+ tea_df_all[TEA_DATE_COL] = pd.to_datetime(tea_df_all[TEA_DATE_COL], errors="coerce")
102
+ tea_df_all = tea_df_all.dropna(subset=[TEA_DATE_COL, TEA_TARGET_COL]).sort_values(TEA_DATE_COL).reset_index(drop=True)
103
+
104
+ tea_fallback_col = "price_lag_1w_rs" if "price_lag_1w_rs" in tea_df_all.columns else None
105
+ tea_global_median = float(tea_df_all[TEA_TARGET_COL].median())
106
+
107
+ # typical values for local explanation
108
+ tea_ref_values = {}
109
+ for c in (tea_num_cols or []):
110
+ if c in tea_df_all.columns and pd.api.types.is_numeric_dtype(tea_df_all[c]):
111
+ tea_ref_values[c] = float(tea_df_all[c].median())
112
+ for c in (tea_cat_cols or []):
113
+ if c in tea_df_all.columns:
114
+ mode = tea_df_all[c].dropna().mode()
115
+ tea_ref_values[c] = str(mode.iloc[0]) if len(mode) else ""
116
+ tea_ref_values["arima_pred"] = tea_global_median
117
+
118
+ except Exception as e:
119
+ tea_data_error = f"Failed to load tea dataset: {e}"
120
+ tea_df_all = None
121
+
122
+
123
+ def _tea_fit_arima_models():
124
+ if tea_df_all is None:
125
+ return
126
+ if not all(c in tea_df_all.columns for c in TEA_GROUP_COLS):
127
+ return
128
+
129
+ tea_arima_models.clear()
130
+
131
+ for key, g in tea_df_all.groupby(TEA_GROUP_COLS):
132
+ g = g.sort_values(TEA_DATE_COL)
133
+ y = g[TEA_TARGET_COL].astype(float).values
134
+ if len(y) < TEA_MIN_ARIMA_POINTS:
135
+ continue
136
+ try:
137
+ tea_arima_models[tuple(key)] = ARIMA(y, order=TEA_ARIMA_ORDER).fit()
138
+ except Exception:
139
+ continue
140
+
141
+
142
+ def tea_build_next_week_input(elevation: str, grade: str, overrides=None):
143
+ overrides = overrides or {}
144
+
145
+ if tea_df_all is None:
146
+ raise ValueError(f"Tea dataset not loaded. {tea_data_error or ''}".strip())
147
+
148
+ if "elevation" not in tea_df_all.columns or "grade" not in tea_df_all.columns:
149
+ raise ValueError("Tea dataset missing elevation/grade columns.")
150
+
151
+ seg = tea_df_all[(tea_df_all["elevation"] == elevation) & (tea_df_all["grade"] == grade)].sort_values(TEA_DATE_COL)
152
+ if len(seg) < 10:
153
+ raise ValueError("Not enough history for this (elevation, grade). Need >= 10 rows.")
154
+
155
+ last = seg.iloc[-1].copy()
156
+ next_row = last.copy()
157
+
158
+ # next week (+7 days)
159
+ next_date = pd.to_datetime(last[TEA_DATE_COL]) + pd.Timedelta(days=7)
160
+ next_row[TEA_DATE_COL] = next_date
161
+
162
+ # calendar fields if present
163
+ if "year" in tea_df_all.columns:
164
+ next_row["year"] = int(next_date.year)
165
+ if "month" in tea_df_all.columns:
166
+ next_row["month"] = int(next_date.month)
167
+
168
+ if "month_sin" in tea_df_all.columns and "month_cos" in tea_df_all.columns:
169
+ s, c = month_sin_cos(int(next_date.month))
170
+ next_row["month_sin"] = s
171
+ next_row["month_cos"] = c
172
+
173
+ # lag/rolling features if present
174
+ if "price_lag_1w_rs" in tea_df_all.columns:
175
+ next_row["price_lag_1w_rs"] = float(last[TEA_TARGET_COL])
176
+
177
+ if "price_lag_4w_rs" in tea_df_all.columns and len(seg) >= 4:
178
+ next_row["price_lag_4w_rs"] = float(seg.iloc[-4][TEA_TARGET_COL])
179
+
180
+ if "price_lag_12w_rs" in tea_df_all.columns and len(seg) >= 12:
181
+ next_row["price_lag_12w_rs"] = float(seg.iloc[-12][TEA_TARGET_COL])
182
+
183
+ if "price_lag_48w_rs" in tea_df_all.columns and len(seg) >= 48:
184
+ next_row["price_lag_48w_rs"] = float(seg.iloc[-48][TEA_TARGET_COL])
185
+
186
+ if "price_rollmean_4w_rs" in tea_df_all.columns and len(seg) >= 4:
187
+ next_row["price_rollmean_4w_rs"] = float(seg[TEA_TARGET_COL].tail(4).mean())
188
+
189
+ if "price_rollmean_12w_rs" in tea_df_all.columns and len(seg) >= 12:
190
+ next_row["price_rollmean_12w_rs"] = float(seg[TEA_TARGET_COL].tail(12).mean())
191
+
192
+ if "price_rollmean_48w_rs" in tea_df_all.columns and len(seg) >= 48:
193
+ next_row["price_rollmean_48w_rs"] = float(seg[TEA_TARGET_COL].tail(48).mean())
194
+
195
+ # apply overrides
196
+ for k, v in (overrides or {}).items():
197
+ if k not in next_row.index:
198
+ raise KeyError(f"Unknown override column: {k}")
199
+ next_row[k] = v
200
+
201
+ # target unknown
202
+ next_row[TEA_TARGET_COL] = np.nan
203
+ return next_row.to_frame().T
204
+
205
+
206
+ def tea_get_arima_pred(elevation: str, grade: str, built_row: pd.DataFrame):
207
+ key = (elevation, grade)
208
+
209
+ if key in tea_arima_models:
210
+ try:
211
+ return float(tea_arima_models[key].forecast(steps=1)[0])
212
+ except Exception:
213
+ pass
214
+
215
+ if tea_fallback_col and tea_fallback_col in built_row.columns and not pd.isna(built_row[tea_fallback_col].iloc[0]):
216
+ return float(built_row[tea_fallback_col].iloc[0])
217
+
218
+ return float(tea_global_median) if tea_global_median is not None else 0.0
219
+
220
+
221
+ def tea_local_sensitivity_explain(model, X: pd.DataFrame, pred: float, ref_values: dict, top_k: int = 6):
222
+ impacts = []
223
+ for col in X.columns:
224
+ if col not in ref_values:
225
+ continue
226
+
227
+ x_tmp = X.copy()
228
+ original_val = x_tmp[col].iloc[0]
229
+ typical_val = ref_values[col]
230
+
231
+ try:
232
+ if pd.isna(original_val) and pd.isna(typical_val):
233
+ continue
234
+ if str(original_val) == str(typical_val):
235
+ continue
236
+ except Exception:
237
+ pass
238
+
239
+ x_tmp[col] = typical_val
240
+ try:
241
+ pred_typical = float(model.predict(x_tmp)[0])
242
+ except Exception:
243
+ continue
244
+
245
+ impact = pred - pred_typical
246
+ impacts.append({
247
+ "feature": col,
248
+ "value": None if pd.isna(original_val) else (float(original_val) if isinstance(original_val, (int, float, np.number)) else str(original_val)),
249
+ "typical": typical_val,
250
+ "impact": float(impact)
251
+ })
252
+
253
+ impacts.sort(key=lambda d: abs(d["impact"]), reverse=True)
254
+ return impacts[:top_k]
255
+
256
+
257
+ def tea_segment_context(elevation: str, grade: str):
258
+ if tea_df_all is None:
259
+ return None
260
+
261
+ seg = tea_df_all[(tea_df_all["elevation"] == elevation) & (tea_df_all["grade"] == grade)].sort_values(TEA_DATE_COL)
262
+ if len(seg) == 0:
263
+ return None
264
+
265
+ last_price = float(seg.iloc[-1][TEA_TARGET_COL])
266
+ mean_4w = float(seg[TEA_TARGET_COL].tail(4).mean()) if len(seg) >= 4 else None
267
+ mean_12w = float(seg[TEA_TARGET_COL].tail(12).mean()) if len(seg) >= 12 else None
268
+
269
+ trend = None
270
+ if mean_4w is not None:
271
+ trend = "up" if last_price > mean_4w else ("down" if last_price < mean_4w else "flat")
272
+
273
+ return {
274
+ "last_price": last_price,
275
+ "avg_4w": mean_4w,
276
+ "avg_12w": mean_12w,
277
+ "trend_vs_4w_avg": trend,
278
+ "history_points": int(len(seg))
279
+ }
280
+
281
+
282
+ def tea_describe_direction(val, typical):
283
+ try:
284
+ v = float(val); t = float(typical)
285
+ if np.isfinite(v) and np.isfinite(t):
286
+ if abs(v - t) <= (0.02 * (abs(t) + 1e-6)):
287
+ return "close to usual"
288
+ return "higher than usual" if v > t else "lower than usual"
289
+ except Exception:
290
+ pass
291
+ return "different from usual"
292
+
293
+
294
+ def tea_feature_display_name(f):
295
+ nice = {
296
+ "fx_lkr_per_usd": "USD→LKR exchange rate",
297
+ "rainfall_mm": "rainfall",
298
+ "temperature_c": "temperature",
299
+ "arima_pred": "recent price trend (time-series)",
300
+ "price_lag_1w_rs": "last week price",
301
+ "price_rollmean_4w_rs": "last 4-week average price",
302
+ "price_rollmean_12w_rs": "last 12-week average price",
303
+ }
304
+ return nice.get(f, f.replace("_", " "))
305
+
306
+
307
+ def tea_build_explanation_text(pred, factors, segment_ctx=None, top_k=5):
308
+ top = factors[:top_k]
309
+ bullets = []
310
+
311
+ for item in top:
312
+ f = item["feature"]
313
+ val = item["value"]
314
+ typical = item["typical"]
315
+ impact = item["impact"]
316
+
317
+ if abs(impact) < 0.5:
318
+ continue
319
+
320
+ if f == "arima_pred":
321
+ if segment_ctx and segment_ctx.get("trend_vs_4w_avg"):
322
+ trend = segment_ctx["trend_vs_4w_avg"]
323
+ bullets.append(
324
+ f"Recent segment trend looks **{trend}**, which {'pushes up' if impact > 0 else 'pulls down'} the prediction (time-series effect)."
325
+ )
326
+ else:
327
+ bullets.append("Recent price pattern in this segment influences the forecast (time-series effect).")
328
+ continue
329
+
330
+ name = tea_feature_display_name(f)
331
+ direction = tea_describe_direction(val, typical)
332
+
333
+ if impact > 0:
334
+ bullets.append(f"{name} is **{direction}** ({val} vs typical {typical}), so the model expects price to be **higher**.")
335
+ else:
336
+ bullets.append(f"{name} is **{direction}** ({val} vs typical {typical}), so the model expects price to be **lower**.")
337
+
338
+ seg_line = None
339
+ if segment_ctx:
340
+ lp = segment_ctx.get("last_price")
341
+ a4 = segment_ctx.get("avg_4w")
342
+ if lp is not None and a4 is not None:
343
+ seg_line = f"Last recorded price was **{lp:.2f}** and the 4-week average is **{a4:.2f}**."
344
+
345
+ if bullets:
346
+ main_push = "higher" if sum([f["impact"] for f in top]) > 0 else "lower"
347
+ summary = f"Predicted price is **{pred:.2f}** mainly because the strongest inputs/trend signals push the model **{main_push}** compared to typical conditions."
348
+ else:
349
+ summary = f"Predicted price is **{pred:.2f}** based on learned patterns from history for this segment and the provided inputs."
350
+
351
+ if seg_line:
352
+ summary = summary + " " + seg_line
353
+
354
+ return summary, bullets
355
+
356
+
357
+ # init tea
358
+ _tea_safe_load()
359
+ if tea_model is not None and tea_df_all is not None:
360
+ _tea_fit_arima_models()
361
+
362
+ # ---------------------------------------------------------------------
363
+ # (B) YIELD MODEL (KEEP EXISTING)
364
+ # ---------------------------------------------------------------------
365
+ YIELD_MODEL_PATH = os.getenv("YIELD_MODEL_PATH", os.path.join(MODEL_DIR, "smarttea_yield_model.joblib"))
366
+ YIELD_DATA_PATH = os.getenv("YIELD_DATA_PATH", os.path.join(BASE_DIR, "data/smarttea_monthly_yield_dataset_sri_lanka_synthetic_2000_2025.csv"))
367
+ YIELD_DATE_COL = os.getenv("YIELD_DATE_COL", "date")
368
+ YIELD_TARGET_COL = os.getenv("YIELD_TARGET_COL", "yield_kg_per_ha")
369
+
370
+ REGION_DEFAULTS = {
371
+ "Nuwara_Eliya": {"elevation_band": "high", "elevation_m": 1850, "country": "Sri_Lanka"},
372
+ "Uva": {"elevation_band": "mid", "elevation_m": 1200, "country": "Sri_Lanka"},
373
+ "Kandy": {"elevation_band": "mid", "elevation_m": 900, "country": "Sri_Lanka"},
374
+ "Sabaragamuwa": {"elevation_band": "low", "elevation_m": 300, "country": "Sri_Lanka"},
375
+ "Galle": {"elevation_band": "low", "elevation_m": 50, "country": "Sri_Lanka"},
376
+ }
377
+
378
+ yield_model = None
379
+ yield_feature_cols = None
380
+ yield_load_error = None
381
+
382
+ yield_df = None
383
+ yield_data_error = None
384
+
385
+
386
+ def unwrap_model(obj):
387
+ if isinstance(obj, dict):
388
+ model = obj.get("model", obj)
389
+ feature_cols = obj.get("feature_cols")
390
+ target = obj.get("target")
391
+ return model, feature_cols, target
392
+ model = obj
393
+ feature_cols = getattr(model, "feature_names_in_", None)
394
+ target = None
395
+ return model, (list(feature_cols) if feature_cols is not None else None), target
396
+
397
+
398
+ try:
399
+ if os.path.exists(YIELD_MODEL_PATH):
400
+ yield_model_raw = joblib.load(YIELD_MODEL_PATH)
401
+ yield_model, yield_feature_cols, _ = unwrap_model(yield_model_raw)
402
+ else:
403
+ yield_load_error = f"Yield model file not found at: {YIELD_MODEL_PATH}"
404
+ except Exception as e:
405
+ yield_load_error = f"Failed to load YIELD model: {e}"
406
+
407
+ try:
408
+ if os.path.exists(YIELD_DATA_PATH):
409
+ yield_df = pd.read_csv(YIELD_DATA_PATH)
410
+ yield_df[YIELD_DATE_COL] = pd.to_datetime(yield_df[YIELD_DATE_COL], errors="coerce")
411
+ yield_df = yield_df.dropna(subset=[YIELD_DATE_COL]).sort_values([YIELD_DATE_COL]).reset_index(drop=True)
412
+ else:
413
+ yield_data_error = f"Yield dataset not found at: {YIELD_DATA_PATH}"
414
+ except Exception as e:
415
+ yield_data_error = f"Failed to load YIELD dataset: {e}"
416
+
417
+
418
+ YIELD_REQUIRED_INPUTS = [
419
+ "region", "year", "month",
420
+ "rainfall_mm", "temp_avg_c", "temp_min_c", "temp_max_c",
421
+ "humidity_pct", "soil_ph", "soil_ec_ds_m",
422
+ "fertilizer_kg_per_ha", "disease_index",
423
+ ]
424
+
425
+
426
+ def get_region_history(region: str, current_date: pd.Timestamp) -> pd.DataFrame:
427
+ if yield_df is None or "region" not in yield_df.columns:
428
+ return pd.DataFrame()
429
+ h = yield_df[(yield_df["region"] == region) & (yield_df[YIELD_DATE_COL] < current_date)].copy()
430
+ return h.sort_values(YIELD_DATE_COL)
431
+
432
+
433
+ def compute_yield_lags_rolls(region_hist: pd.DataFrame):
434
+ if region_hist is None or len(region_hist) == 0 or YIELD_TARGET_COL not in region_hist.columns:
435
+ return {
436
+ "yield_lag_1": None, "yield_lag_3": None, "yield_lag_12": None,
437
+ "yield_rollmean_3": None, "yield_rollmean_6": None, "yield_rollmean_12": None,
438
+ }
439
+
440
+ y = region_hist[YIELD_TARGET_COL].astype(float).values
441
+
442
+ def lag(k):
443
+ if len(y) >= k:
444
+ return float(y[-k])
445
+ return float(y[0])
446
+
447
+ def roll(k):
448
+ k = min(k, len(y))
449
+ return float(np.mean(y[-k:]))
450
+
451
+ return {
452
+ "yield_lag_1": lag(1),
453
+ "yield_lag_3": lag(3) if len(y) >= 3 else lag(1),
454
+ "yield_lag_12": lag(12) if len(y) >= 12 else lag(1),
455
+ "yield_rollmean_3": roll(3),
456
+ "yield_rollmean_6": roll(6),
457
+ "yield_rollmean_12": roll(12),
458
+ }
459
+
460
+
461
+ def compute_exog_rolls(region_hist: pd.DataFrame):
462
+ def rmean(col, n):
463
+ if region_hist is None or len(region_hist) == 0 or col not in region_hist.columns:
464
+ return None
465
+ vals = region_hist[col].astype(float).values
466
+ if len(vals) >= n:
467
+ return float(np.mean(vals[-n:]))
468
+ return float(np.mean(vals)) if len(vals) else None
469
+
470
+ return {
471
+ "rain_rollmean_3": rmean("rainfall_mm", 3),
472
+ "rain_rollmean_6": rmean("rainfall_mm", 6),
473
+ "temp_rollmean_3": rmean("temp_avg_c", 3),
474
+ "temp_rollmean_6": rmean("temp_avg_c", 6),
475
+ "fert_rollmean_3": rmean("fertilizer_kg_per_ha", 3),
476
+ "fert_rollmean_6": rmean("fertilizer_kg_per_ha", 6),
477
+ "disease_rollmean_3": rmean("disease_index", 3),
478
+ "disease_rollmean_6": rmean("disease_index", 6),
479
+ }
480
+
481
+
482
+ def local_feature_impact(model_pipeline, X_row: pd.DataFrame, numeric_features, steps=0.03, top_n=6):
483
+ base = float(model_pipeline.predict(X_row)[0])
484
+ impacts = []
485
+
486
+ for f in numeric_features:
487
+ if f not in X_row.columns:
488
+ continue
489
+
490
+ v = X_row.iloc[0][f]
491
+ if pd.isna(v):
492
+ continue
493
+
494
+ delta = max(abs(float(v)) * steps, 0.01)
495
+
496
+ X_up = X_row.copy()
497
+ X_dn = X_row.copy()
498
+
499
+ X_up.loc[X_up.index[0], f] = float(v) + delta
500
+ X_dn.loc[X_dn.index[0], f] = float(v) - delta
501
+
502
+ p_up = float(model_pipeline.predict(X_up)[0])
503
+ p_dn = float(model_pipeline.predict(X_dn)[0])
504
+
505
+ effect = (p_up - p_dn) / 2.0
506
+
507
+ impacts.append({
508
+ "feature": f,
509
+ "impact_kg_per_ha": round(float(effect), 3),
510
+ "direction": "increases" if effect > 0 else "decreases"
511
+ })
512
+
513
+ impacts.sort(key=lambda x: abs(x["impact_kg_per_ha"]), reverse=True)
514
+ return base, impacts[:top_n]
515
+
516
+
517
+ def build_yield_row(payload: dict):
518
+ if yield_model is None:
519
+ raise ValueError("Yield model is not loaded. Check YIELD_MODEL_PATH.")
520
+
521
+ missing = [k for k in YIELD_REQUIRED_INPUTS if k not in payload]
522
+ if missing:
523
+ raise ValueError(f"Missing required fields: {missing}")
524
+
525
+ region = str(payload["region"])
526
+ year = int(payload["year"])
527
+ month = int(payload["month"])
528
+ current_date = pd.Timestamp(f"{year}-{month:02d}-01")
529
+
530
+ defaults = REGION_DEFAULTS.get(region)
531
+ if not defaults:
532
+ raise ValueError(f"Unknown region '{region}'. Allowed: {list(REGION_DEFAULTS.keys())}")
533
+
534
+ ms, mc = month_sin_cos(month)
535
+
536
+ region_hist = get_region_history(region, current_date)
537
+ lag_feats = compute_yield_lags_rolls(region_hist)
538
+ exog_rolls = compute_exog_rolls(region_hist)
539
+
540
+ row = {
541
+ "region": region,
542
+ "country": defaults["country"],
543
+ "elevation_band": defaults["elevation_band"],
544
+ "elevation_m": defaults["elevation_m"],
545
+ "year": year,
546
+ "month": month,
547
+ "month_sin": ms,
548
+ "month_cos": mc,
549
+
550
+ "rainfall_mm": float(payload["rainfall_mm"]),
551
+ "temp_avg_c": float(payload["temp_avg_c"]),
552
+ "temp_min_c": float(payload["temp_min_c"]),
553
+ "temp_max_c": float(payload["temp_max_c"]),
554
+ "humidity_pct": float(payload["humidity_pct"]),
555
+ "soil_ph": float(payload["soil_ph"]),
556
+ "soil_ec_ds_m": float(payload["soil_ec_ds_m"]),
557
+ "fertilizer_kg_per_ha": float(payload["fertilizer_kg_per_ha"]),
558
+ "disease_index": float(payload["disease_index"]),
559
+ }
560
+
561
+ row.update(lag_feats)
562
+ row.update(exog_rolls)
563
+
564
+ X = pd.DataFrame([row])
565
+
566
+ if yield_feature_cols:
567
+ for c in yield_feature_cols:
568
+ if c not in X.columns:
569
+ X[c] = np.nan
570
+ X = X[yield_feature_cols]
571
+
572
+ return X, str(current_date.date()), int(len(region_hist))
573
+
574
+ # ---------------------------------------------------------------------
575
+ # (C) LEAF DISEASE MODEL (KEEP EXISTING)
576
+ # ---------------------------------------------------------------------
577
+ LEAF_WEIGHTS_PATH = os.getenv("LEAF_WEIGHTS_PATH", os.path.join(MODEL_DIR, "tea_mobilenet_v2.weights.h5"))
578
+ LEAF_LABELS_PATH = os.getenv("LEAF_LABELS_PATH", os.path.join(MODEL_DIR, "labels.json"))
579
+ UPLOAD_DIR = os.getenv("UPLOAD_DIR", os.path.join(BASE_DIR, "uploads"))
580
+ IMG_SIZE: Tuple[int, int] = (224, 224)
581
+ os.makedirs(UPLOAD_DIR, exist_ok=True)
582
+
583
+ leaf_class_names = None
584
+ leaf_model = None
585
+ leaf_load_error = None
586
+
587
+
588
+ def build_leaf_model(num_classes: int) -> tf.keras.Model:
589
+ base_model = tf.keras.applications.MobileNetV2(
590
+ input_shape=IMG_SIZE + (3,),
591
+ include_top=False,
592
+ weights="imagenet",
593
+ )
594
+ base_model.trainable = False
595
+
596
+ inputs = tf.keras.Input(shape=IMG_SIZE + (3,), name="input_layer_1")
597
+ x = tf.keras.applications.mobilenet_v2.preprocess_input(inputs)
598
+ x = base_model(x, training=False)
599
+ x = tf.keras.layers.GlobalAveragePooling2D(name="global_avg_pool")(x)
600
+ x = tf.keras.layers.Dropout(0.2, name="dropout")(x)
601
+ outputs = tf.keras.layers.Dense(num_classes, activation="softmax", name="dense")(x)
602
+ return tf.keras.Model(inputs, outputs, name="tea_mobilenet_v2_inference")
603
+
604
+
605
+ try:
606
+ if os.path.exists(LEAF_LABELS_PATH):
607
+ with open(LEAF_LABELS_PATH, "r", encoding="utf-8") as f:
608
+ leaf_class_names = json.load(f)
609
+ else:
610
+ raise FileNotFoundError(f"Leaf labels not found at: {LEAF_LABELS_PATH}")
611
+
612
+ leaf_model = build_leaf_model(num_classes=len(leaf_class_names))
613
+
614
+ if not os.path.exists(LEAF_WEIGHTS_PATH):
615
+ raise FileNotFoundError(f"Leaf weights not found at: {LEAF_WEIGHTS_PATH}")
616
+
617
+ leaf_model.load_weights(LEAF_WEIGHTS_PATH)
618
+
619
+ except Exception as e:
620
+ leaf_load_error = f"Failed to load LEAF model: {e}"
621
+ leaf_model = None
622
+ leaf_class_names = None
623
+
624
+
625
+ def predict_leaf_image(image_path: str):
626
+ if leaf_model is None or leaf_class_names is None:
627
+ raise RuntimeError(leaf_load_error or "Leaf model not loaded.")
628
+
629
+ img = load_img(image_path, target_size=IMG_SIZE)
630
+ img_array = img_to_array(img)
631
+ img_batch = np.expand_dims(img_array, axis=0)
632
+
633
+ probs = leaf_model.predict(img_batch, verbose=0)[0]
634
+ pred_index = int(np.argmax(probs))
635
+ pred_label = leaf_class_names[pred_index]
636
+ confidence = float(probs[pred_index])
637
+
638
+ probs_list = [float(p) for p in probs]
639
+ probs_dict = {leaf_class_names[i]: probs_list[i] for i in range(len(leaf_class_names))}
640
+ return pred_label, confidence, probs_dict
641
+
642
+ # ---------------------------------------------------------------------
643
+ # ROUTES
644
+ # ---------------------------------------------------------------------
645
+ @app.get("/health")
646
+ def health():
647
+ return jsonify({
648
+ "status": "ok",
649
+ "tea_price_hybrid_loaded": tea_model is not None,
650
+ "tea_price_segments_with_arima": int(len(tea_arima_models)) if tea_model is not None else 0,
651
+ "yield_model_loaded": yield_model is not None,
652
+ "leaf_model_loaded": leaf_model is not None,
653
+ "paths": {
654
+ "tea_artifact_dir": TEA_ARTIFACT_DIR,
655
+ "tea_model_path": TEA_MODEL_PATH,
656
+ "tea_cfg_path": TEA_CFG_PATH,
657
+ "tea_data_path": TEA_DATA_PATH,
658
+ "yield_model_path": YIELD_MODEL_PATH,
659
+ "yield_data_path": YIELD_DATA_PATH,
660
+ "leaf_weights_path": LEAF_WEIGHTS_PATH,
661
+ "leaf_labels_path": LEAF_LABELS_PATH,
662
+ },
663
+ "errors": {
664
+ "tea_load_error": tea_load_error,
665
+ "tea_data_error": tea_data_error,
666
+ "yield_load_error": yield_load_error,
667
+ "yield_data_error": yield_data_error,
668
+ "leaf_load_error": leaf_load_error,
669
+ },
670
+ "endpoints": {
671
+ "GET /tea-price/meta": "Tea price metadata (elevations, grades, override keys)",
672
+ "POST /tea-price/predict-next-week": "Tea price next-week forecast (elevation+grade + overrides + explain)",
673
+ "POST /predict/yield-simple": "Yield prediction",
674
+ "POST /predict/leaf": "Leaf disease prediction (image upload)",
675
+ }
676
+ })
677
+
678
+
679
+ # -------------------------
680
+ # TEA PRICE: health/meta/predict-next-week
681
+ # -------------------------
682
+ @app.get("/tea-price/health")
683
+ def tea_price_health():
684
+ return jsonify({
685
+ "ok": True,
686
+ "model_loaded": tea_model is not None,
687
+ "cfg_loaded": tea_cfg is not None,
688
+ "rows_in_history": int(len(tea_df_all)) if tea_df_all is not None else 0,
689
+ "segments_with_arima": int(len(tea_arima_models)),
690
+ "target": TEA_TARGET_COL,
691
+ "date_col": TEA_DATE_COL,
692
+ "error": tea_load_error or tea_data_error
693
+ })
694
+
695
+
696
+ @app.get("/tea-price/meta")
697
+ def tea_price_meta():
698
+ if tea_df_all is None:
699
+ return jsonify({"ok": False, "error": tea_data_error or "Tea dataset not loaded"}), 500
700
+
701
+ return jsonify({
702
+ "ok": True,
703
+ "target": TEA_TARGET_COL,
704
+ "date_col": TEA_DATE_COL,
705
+ "cat_cols": tea_cat_cols,
706
+ "num_cols": tea_num_cols,
707
+ "example_override_keys": [c for c in tea_df_all.columns if c not in [TEA_TARGET_COL]],
708
+ "unique_elevations": sorted(tea_df_all["elevation"].dropna().unique().tolist()) if "elevation" in tea_df_all.columns else [],
709
+ "unique_grades": sorted(tea_df_all["grade"].dropna().unique().tolist()) if "grade" in tea_df_all.columns else [],
710
+ })
711
+
712
+
713
+ @app.post("/tea-price/predict-next-week")
714
+ def tea_price_predict_next_week():
715
+ if tea_model is None:
716
+ return jsonify({"ok": False, "error": tea_load_error or "Tea hybrid model not loaded"}), 500
717
+ if tea_df_all is None:
718
+ return jsonify({"ok": False, "error": tea_data_error or "Tea dataset not loaded"}), 500
719
+
720
+ body = request.get_json(silent=True) or {}
721
+
722
+ elevation = str(body.get("elevation", "")).strip()
723
+ grade = str(body.get("grade", "")).strip()
724
+ overrides = body.get("overrides") or {}
725
+
726
+ if not elevation or not grade:
727
+ return jsonify({"ok": False, "error": "elevation and grade are required"}), 400
728
+ if not isinstance(overrides, dict):
729
+ return jsonify({"ok": False, "error": "overrides must be an object/dict"}), 400
730
+
731
+ try:
732
+ row = tea_build_next_week_input(elevation, grade, overrides=overrides)
733
+ arima_pred = tea_get_arima_pred(elevation, grade, row)
734
+
735
+ # build X exactly like notebook expects
736
+ needed_cols = (tea_cat_cols or []) + (tea_num_cols or [])
737
+ X = row.copy()
738
+
739
+ # ensure required cols exist
740
+ for c in needed_cols:
741
+ if c not in X.columns:
742
+ X[c] = np.nan
743
+
744
+ X = X[needed_cols].copy()
745
+ X["arima_pred"] = arima_pred
746
+
747
+ pred = float(tea_model.predict(X)[0])
748
+
749
+ want_explain = bool(body.get("explain", False))
750
+ explain_payload = None
751
+
752
+ if want_explain:
753
+ factors = tea_local_sensitivity_explain(tea_model, X, pred, tea_ref_values, top_k=8)
754
+ seg_ctx = tea_segment_context(elevation, grade)
755
+ summary, bullets = tea_build_explanation_text(pred, factors, seg_ctx, top_k=5)
756
+
757
+ explain_payload = {
758
+ "summary": summary,
759
+ "reasons": bullets,
760
+ "top_factors": factors,
761
+ "segment_context": seg_ctx,
762
+ "disclaimer": "These reasons explain what the model learned from data (correlations), not guaranteed real-world causation."
763
+ }
764
+
765
+ return jsonify({
766
+ "ok": True,
767
+ "elevation": elevation,
768
+ "grade": grade,
769
+ "predicted_price": pred,
770
+ "arima_pred": arima_pred,
771
+ "next_date": str(pd.to_datetime(row[TEA_DATE_COL].iloc[0]).date()),
772
+ "explanation": explain_payload
773
+ })
774
+
775
+ except KeyError as e:
776
+ return jsonify({"ok": False, "error": str(e)}), 400
777
+ except Exception as e:
778
+ return jsonify({"ok": False, "error": str(e), "trace": traceback.format_exc()}), 500
779
+
780
+
781
+ # -------------------------
782
+ # YIELD
783
+ # -------------------------
784
+ @app.get("/debug/yield-model")
785
+ def debug_yield_model():
786
+ try:
787
+ obj = joblib.load(YIELD_MODEL_PATH)
788
+ return jsonify({
789
+ "ok": True,
790
+ "path": YIELD_MODEL_PATH,
791
+ "type": str(type(obj)),
792
+ "keys": list(obj.keys()) if isinstance(obj, dict) else None
793
+ })
794
+ except Exception as e:
795
+ return jsonify({"ok": False, "error": str(e), "trace": traceback.format_exc()}), 500
796
+
797
+
798
+ @app.post("/predict/yield-simple")
799
+ def predict_yield():
800
+ try:
801
+ if yield_model is None:
802
+ return jsonify({
803
+ "success": False,
804
+ "error": "Yield model not loaded",
805
+ "details": yield_load_error,
806
+ "hint": "Put your yield .joblib file in the model folder and set YIELD_MODEL_PATH if needed."
807
+ }), 500
808
+
809
+ payload = request.get_json(silent=True) or {}
810
+ X, pred_date, history_months = build_yield_row(payload)
811
+
812
+ pred = float(yield_model.predict(X)[0])
813
+
814
+ numeric_for_explain = [
815
+ "rainfall_mm", "temp_avg_c", "humidity_pct",
816
+ "soil_ph", "soil_ec_ds_m", "fertilizer_kg_per_ha", "disease_index",
817
+ "yield_lag_1", "yield_lag_3", "yield_lag_12",
818
+ "rain_rollmean_3", "rain_rollmean_6",
819
+ "temp_rollmean_3", "temp_rollmean_6",
820
+ "fert_rollmean_3", "fert_rollmean_6",
821
+ "disease_rollmean_3", "disease_rollmean_6",
822
+ ]
823
+
824
+ base_pred, top_impacts = local_feature_impact(yield_model, X, numeric_for_explain)
825
+
826
+ pos = [i for i in top_impacts if i["impact_kg_per_ha"] > 0][:2]
827
+ neg = [i for i in top_impacts if i["impact_kg_per_ha"] < 0][:2]
828
+
829
+ parts = []
830
+ if pos:
831
+ parts.append("higher " + " & ".join([p["feature"] for p in pos]))
832
+ if neg:
833
+ parts.append("lower " + " & ".join([n["feature"] for n in neg]))
834
+
835
+ explain_sentence = "Prediction is mainly influenced by " + (", and ".join(parts) if parts else "the input factors.")
836
+
837
+ # labour estimation
838
+ area_ha = float(payload.get("area_ha", 1.0))
839
+ plucking_days = int(payload.get("plucking_days", 22))
840
+ productivity = float(payload.get("productivity_kg_per_worker_day", 20.0))
841
+ efficiency = float(payload.get("efficiency", 0.9))
842
+
843
+ total_harvest_kg = pred * area_ha
844
+ den = productivity * plucking_days * max(efficiency, 0.01)
845
+ labourers_needed = int(math.ceil(total_harvest_kg / den))
846
+
847
+ warnings = []
848
+ if history_months < 12 and yield_df is not None:
849
+ warnings.append(
850
+ f"Only {history_months} months of history were available before {pred_date}; some lag/rolling features may be weak."
851
+ )
852
+
853
+ return jsonify({
854
+ "success": True,
855
+ "prediction": {
856
+ "yield_kg_per_ha": round(pred, 2),
857
+ "for_month": pred_date,
858
+ "area_ha": area_ha,
859
+ "total_harvest_kg": round(total_harvest_kg, 2),
860
+ "labourers_needed": labourers_needed,
861
+ "assumptions": {
862
+ "plucking_days": plucking_days,
863
+ "productivity_kg_per_worker_day": productivity,
864
+ "efficiency": efficiency
865
+ }
866
+ },
867
+ "explainability": {
868
+ "summary": explain_sentence,
869
+ "top_factors": top_impacts
870
+ },
871
+ "meta": {
872
+ "region": payload.get("region"),
873
+ "history_months_used": history_months,
874
+ "warnings": warnings
875
+ }
876
+ })
877
+
878
+ except Exception as e:
879
+ return jsonify({"success": False, "error": str(e), "trace": traceback.format_exc()}), 400
880
+
881
+
882
+ # -------------------------
883
+ # LEAF
884
+ # -------------------------
885
+ @app.post("/predict/leaf")
886
+ def predict_leaf():
887
+ if leaf_model is None or leaf_class_names is None:
888
+ return jsonify({
889
+ "ok": False,
890
+ "error": "Leaf model not loaded",
891
+ "details": leaf_load_error,
892
+ "hint": "Make sure model/labels.json and model/tea_mobilenet_v2.weights.h5 exist."
893
+ }), 500
894
+
895
+ if "image" not in request.files:
896
+ return jsonify({"ok": False, "error": "No file part 'image' in the request"}), 400
897
+
898
+ file = request.files["image"]
899
+ if file.filename == "":
900
+ return jsonify({"ok": False, "error": "No file selected"}), 400
901
+
902
+ allowed_ext = (".jpg", ".jpeg", ".png")
903
+ if not file.filename.lower().endswith(allowed_ext):
904
+ return jsonify({"ok": False, "error": "Unsupported file type. Use JPG or PNG."}), 400
905
+
906
+ temp_filename = f"{uuid.uuid4().hex}_{file.filename}"
907
+ temp_path = os.path.join(UPLOAD_DIR, temp_filename)
908
+ file.save(temp_path)
909
+
910
+ try:
911
+ label, confidence, probs_dict = predict_leaf_image(temp_path)
912
+ return jsonify({
913
+ "ok": True,
914
+ "prediction": label,
915
+ "confidence": confidence,
916
+ "probabilities": probs_dict
917
+ })
918
+ except Exception as e:
919
+ return jsonify({
920
+ "ok": False,
921
+ "error": "Failed to process image",
922
+ "details": str(e),
923
+ "trace": traceback.format_exc()
924
+ }), 500
925
+ finally:
926
+ try:
927
+ if os.path.exists(temp_path):
928
+ os.remove(temp_path)
929
+ except Exception:
930
+ pass
931
+
932
+
933
+ # ---------------------------------------------------------------------
934
+ # MAIN
935
+ # ---------------------------------------------------------------------
936
+ if __name__ == "__main__":
937
+ port = int(os.getenv("PORT", "7860"))
938
+ app.run(host="0.0.0.0", port=port)