DarkSting commited on
Commit
6062da0
·
verified ·
1 Parent(s): f8f6e7e

Upload folder using huggingface_hub

Browse files
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ model/tea_mobilenet_v2.keras filter=lfs diff=lfs merge=lfs -text
.idea/.gitignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # Default ignored files
2
+ /shelf/
3
+ /workspace.xml
4
+ # Editor-based HTTP Client requests
5
+ /httpRequests/
6
+ # Datasource local storage ignored files
7
+ /dataSources/
8
+ /dataSources.local.xml
.idea/inspectionProfiles/Project_Default.xml ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <component name="InspectionProjectProfileManager">
2
+ <profile version="1.0">
3
+ <option name="myName" value="Project Default" />
4
+ <inspection_tool class="PyInterpreterInspection" enabled="false" level="WARNING" enabled_by_default="false" />
5
+ <inspection_tool class="PyPackageRequirementsInspection" enabled="true" level="WARNING" enabled_by_default="true">
6
+ <option name="ignoredPackages">
7
+ <value>
8
+ <list size="2">
9
+ <item index="0" class="java.lang.String" itemvalue="pickle5" />
10
+ <item index="1" class="java.lang.String" itemvalue="Werkzeug" />
11
+ </list>
12
+ </value>
13
+ </option>
14
+ </inspection_tool>
15
+ </profile>
16
+ </component>
.idea/inspectionProfiles/profiles_settings.xml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ <component name="InspectionProjectProfileManager">
2
+ <settings>
3
+ <option name="USE_PROJECT_PROFILE" value="false" />
4
+ <version value="1.0" />
5
+ </settings>
6
+ </component>
.idea/misc.xml ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="Black">
4
+ <option name="sdkName" value="Python 3.10 (tea_yeild_api)" />
5
+ </component>
6
+ <component name="ProjectRootManager" version="2" project-jdk-name="Python 3.10 (tea_yeild_api)" project-jdk-type="Python SDK" />
7
+ </project>
.idea/modules.xml ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="ProjectModuleManager">
4
+ <modules>
5
+ <module fileurl="file://$PROJECT_DIR$/.idea/tea_yeild_api.iml" filepath="$PROJECT_DIR$/.idea/tea_yeild_api.iml" />
6
+ </modules>
7
+ </component>
8
+ </project>
.idea/tea_yeild_api.iml ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <module type="PYTHON_MODULE" version="4">
3
+ <component name="Flask">
4
+ <option name="enabled" value="true" />
5
+ </component>
6
+ <component name="NewModuleRootManager">
7
+ <content url="file://$MODULE_DIR$">
8
+ <excludeFolder url="file://$MODULE_DIR$/.venv" />
9
+ </content>
10
+ <orderEntry type="jdk" jdkName="Python 3.10 (tea_yeild_api)" jdkType="Python SDK" />
11
+ <orderEntry type="sourceFolder" forTests="false" />
12
+ </component>
13
+ <component name="PyDocumentationSettings">
14
+ <option name="format" value="PLAIN" />
15
+ <option name="myDocStringFormat" value="Plain" />
16
+ </component>
17
+ <component name="TemplatesService">
18
+ <option name="TEMPLATE_CONFIGURATION" value="Jinja2" />
19
+ </component>
20
+ </module>
.idea/vcs.xml ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="VcsDirectoryMappings" defaultProject="true" />
4
+ </project>
.idea/workspace.xml ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="AutoImportSettings">
4
+ <option name="autoReloadType" value="SELECTIVE" />
5
+ </component>
6
+ <component name="ChangeListManager">
7
+ <list default="true" id="9d4f44c7-a826-47ec-86f0-c0b84030edbc" name="Changes" comment="" />
8
+ <option name="SHOW_DIALOG" value="false" />
9
+ <option name="HIGHLIGHT_CONFLICTS" value="true" />
10
+ <option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
11
+ <option name="LAST_RESOLUTION" value="IGNORE" />
12
+ </component>
13
+ <component name="ProjectColorInfo">{
14
+ &quot;associatedIndex&quot;: 7
15
+ }</component>
16
+ <component name="ProjectId" id="37k79O196JgCOutZUqCoTP5pi8g" />
17
+ <component name="ProjectViewState">
18
+ <option name="hideEmptyMiddlePackages" value="true" />
19
+ <option name="showLibraryContents" value="true" />
20
+ </component>
21
+ <component name="PropertiesComponent">{
22
+ &quot;keyToString&quot;: {
23
+ &quot;Flask server.Flask (app.py).executor&quot;: &quot;Run&quot;,
24
+ &quot;ModuleVcsDetector.initialDetectionPerformed&quot;: &quot;true&quot;,
25
+ &quot;Python.app.executor&quot;: &quot;Run&quot;,
26
+ &quot;Python.tea_yeild_api.executor&quot;: &quot;Run&quot;,
27
+ &quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
28
+ &quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;,
29
+ &quot;ai.playground.ignore.import.keys.banner.in.settings&quot;: &quot;true&quot;,
30
+ &quot;ignore.virus.scanning.warn.message&quot;: &quot;true&quot;,
31
+ &quot;last_opened_file_path&quot;: &quot;D:/rp/tea_yeild_api&quot;,
32
+ &quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
33
+ &quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
34
+ &quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
35
+ &quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
36
+ &quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
37
+ &quot;settings.editor.selected.configurable&quot;: &quot;com.jetbrains.python.configuration.PyActiveSdkModuleConfigurable&quot;,
38
+ &quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
39
+ }
40
+ }</component>
41
+ <component name="RecentsManager">
42
+ <key name="CopyFile.RECENT_KEYS">
43
+ <recent name="D:\Sysconex\projects\tea\tea_yeild_api\model" />
44
+ <recent name="D:\Sysconex\projects\tea\tea_yeild_api" />
45
+ </key>
46
+ </component>
47
+ <component name="RunManager">
48
+ <configuration name="tea_yeild_api" type="PythonConfigurationType" factoryName="Python">
49
+ <module name="tea_yeild_api" />
50
+ <option name="ENV_FILES" value="" />
51
+ <option name="INTERPRETER_OPTIONS" value="" />
52
+ <option name="PARENT_ENVS" value="true" />
53
+ <envs>
54
+ <env name="PYTHONUNBUFFERED" value="1" />
55
+ </envs>
56
+ <option name="SDK_HOME" value="" />
57
+ <option name="WORKING_DIRECTORY" value="" />
58
+ <option name="IS_MODULE_SDK" value="false" />
59
+ <option name="ADD_CONTENT_ROOTS" value="true" />
60
+ <option name="ADD_SOURCE_ROOTS" value="true" />
61
+ <EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
62
+ <option name="RUN_TOOL" value="" />
63
+ <option name="SCRIPT_NAME" value="$PROJECT_DIR$/app.py" />
64
+ <option name="PARAMETERS" value="" />
65
+ <option name="SHOW_COMMAND_LINE" value="false" />
66
+ <option name="EMULATE_TERMINAL" value="false" />
67
+ <option name="MODULE_MODE" value="false" />
68
+ <option name="REDIRECT_INPUT" value="false" />
69
+ <option name="INPUT_FILE" value="" />
70
+ <method v="2" />
71
+ </configuration>
72
+ </component>
73
+ <component name="SharedIndexes">
74
+ <attachedChunks>
75
+ <set>
76
+ <option value="bundled-python-sdk-f2b7a9f6281b-6e1f45a539f7-com.jetbrains.pycharm.pro.sharedIndexes.bundled-PY-253.29346.142" />
77
+ </set>
78
+ </attachedChunks>
79
+ </component>
80
+ <component name="SpellCheckerSettings" RuntimeDictionaries="0" Folders="0" CustomDictionaries="0" DefaultDictionary="application-level" UseSingleDictionary="true" transferred="true" />
81
+ <component name="TaskManager">
82
+ <task active="true" id="Default" summary="Default task">
83
+ <changelist id="9d4f44c7-a826-47ec-86f0-c0b84030edbc" name="Changes" comment="" />
84
+ <created>1767435062937</created>
85
+ <option name="number" value="Default" />
86
+ <option name="presentableId" value="Default" />
87
+ <updated>1767435062937</updated>
88
+ <workItem from="1767435064032" duration="5756000" />
89
+ <workItem from="1767521643536" duration="8245000" />
90
+ <workItem from="1767692591838" duration="3762000" />
91
+ <workItem from="1767713823419" duration="579000" />
92
+ <workItem from="1767715409205" duration="3770000" />
93
+ <workItem from="1767757493332" duration="3910000" />
94
+ <workItem from="1767776322189" duration="975000" />
95
+ <workItem from="1769235674542" duration="825000" />
96
+ <workItem from="1770009282688" duration="18000" />
97
+ </task>
98
+ <servers />
99
+ </component>
100
+ <component name="TypeScriptGeneratedFilesManager">
101
+ <option name="version" value="3" />
102
+ </component>
103
+ <component name="com.intellij.coverage.CoverageDataManagerImpl">
104
+ <SUITE FILE_PATH="coverage/tea_yeild_api$Flask__app_py_.coverage" NAME="Flask (app.py) Coverage Results" MODIFIED="1769235917645" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="" />
105
+ <SUITE FILE_PATH="coverage/tea_yeild_api$tea_yeild_api.coverage" NAME="tea_yeild_api Coverage Results" MODIFIED="1767713952150" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="" />
106
+ </component>
107
+ </project>
__pycache__/app.cpython-310.pyc ADDED
Binary file (17.8 kB). View file
 
app.py ADDED
@@ -0,0 +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)
artifacts_tea_hybrid/arima_models_by_segment.joblib ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:af267a423f60bc4a485911815d0ecf95e431707bee064d5a8f3ab782d756af85
3
+ size 44388510
artifacts_tea_hybrid/hybrid_arima_rf_model.joblib ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d8fe5e7155f5e16444afd4c0659c97eb281a01d4dc04668c5e4762afbbd5db8a
3
+ size 566702883
artifacts_tea_hybrid/hybrid_config.json ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "DATA_PATH": "tea_auction_advanced_dataset.csv",
3
+ "TARGET_COL": "auction_price_rs_per_kg",
4
+ "DATE_COL": "date_week",
5
+ "cat_cols": [
6
+ "elevation",
7
+ "grade",
8
+ "season"
9
+ ],
10
+ "num_cols": [
11
+ "year",
12
+ "month",
13
+ "week_in_month",
14
+ "fx_lkr_per_usd",
15
+ "rainfall_mm",
16
+ "temperature_c",
17
+ "production_kg",
18
+ "exports_kg",
19
+ "fuel_index",
20
+ "inflation_yoy_pct",
21
+ "holiday_newyear_april",
22
+ "covid_dummy_2020",
23
+ "economic_crisis_dummy_2022",
24
+ "price_lag_1w_rs",
25
+ "price_lag_4w_rs",
26
+ "price_lag_12w_rs",
27
+ "price_lag_48w_rs",
28
+ "price_rollmean_4w_rs",
29
+ "price_rollmean_12w_rs",
30
+ "price_rollmean_48w_rs",
31
+ "month_sin",
32
+ "month_cos",
33
+ "sold_quantity_kg",
34
+ "quality_score"
35
+ ],
36
+ "arima_order": [
37
+ 2,
38
+ 1,
39
+ 2
40
+ ],
41
+ "group_cols": [
42
+ "elevation",
43
+ "grade"
44
+ ],
45
+ "fallback_col": "price_lag_1w_rs",
46
+ "created_at_utc": "2026-02-25 16:01:42.116381+00:00"
47
+ }
data/smarttea_monthly_yield_dataset_sri_lanka_synthetic_2000_2025.csv ADDED
The diff for this file is too large to render. See raw diff
 
model/labels.json ADDED
@@ -0,0 +1 @@
 
 
1
+ ["brown_blight", "gray_blight", "healthy"]
model/model_metadata.json ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "target": "auction_price_rs_per_kg",
3
+ "cat_cols": [
4
+ "grade",
5
+ "season"
6
+ ],
7
+ "num_cols": [
8
+ "rainfall_mm",
9
+ "temperature_c",
10
+ "production_kg",
11
+ "exports_kg",
12
+ "fuel_index",
13
+ "inflation_yoy_pct",
14
+ "price_lag_1w_rs",
15
+ "price_lag_4w_rs",
16
+ "price_lag_12w_rs",
17
+ "price_lag_48w_rs",
18
+ "price_rollmean_4w_rs",
19
+ "price_rollmean_12w_rs",
20
+ "price_rollmean_48w_rs",
21
+ "month_sin",
22
+ "month_cos",
23
+ "week_in_month",
24
+ "month",
25
+ "year"
26
+ ],
27
+ "report_user_inputs": {
28
+ "required": [
29
+ "grade"
30
+ ],
31
+ "optional_overrides": [
32
+ "rainfall_mm",
33
+ "temperature_c",
34
+ "production_kg",
35
+ "exports_kg",
36
+ "fuel_index",
37
+ "inflation_yoy_pct"
38
+ ],
39
+ "seasonality": "season (auto from date, can override)"
40
+ },
41
+ "internal_auto_features": [
42
+ "price_lag_1w_rs",
43
+ "price_lag_4w_rs",
44
+ "price_lag_12w_rs",
45
+ "price_lag_48w_rs",
46
+ "price_rollmean_4w_rs",
47
+ "price_rollmean_12w_rs",
48
+ "price_rollmean_48w_rs",
49
+ "month_sin",
50
+ "month_cos",
51
+ "week_in_month",
52
+ "month",
53
+ "year"
54
+ ],
55
+ "validation_metrics": {
56
+ "MAE": 236.42255651770014,
57
+ "RMSE": 264.914076185626,
58
+ "R2": -1.446572736879666,
59
+ "MAPE_%": 14.561939423976408
60
+ },
61
+ "test_metrics": {
62
+ "MAE": 235.94937865586834,
63
+ "RMSE": 270.28251148948794,
64
+ "R2": -0.43772939567736135,
65
+ "MAPE_%": 17.654617690880166
66
+ }
67
+ }
model/random_forest_report_inputs_model.joblib ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c1223731007c140d5d5b1126180fae4b2e2e0195c1a386908b017ad01f50b06f
3
+ size 320715488
model/smarttea_yield_model.joblib ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d6f235de3f8925f82f086169101759fdbeb7f84269746591fe24577dbf5a27c5
3
+ size 1339830
model/tea_mobilenet_v2.h5 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b773f1b3ba5f8013ec33414adc082ddf615b2c02f443f6d464619d08777413b0
3
+ size 9448432
model/tea_mobilenet_v2.keras ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d4d2571194b4d5ea123524ca3bc2d8737855789429ca581ba003e9a877125814
3
+ size 9677936
model/tea_mobilenet_v2.tflite ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d441460f1c03200ba8e5f21a5128fccc0d97696e3b40639d8ae75ced8172718d
3
+ size 2511056
model/tea_mobilenet_v2.weights.h5 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:43bad527fcaed851ecb637b233d108465805374ad7c5f24f26807271472d666b
3
+ size 9519904
requirements-yield.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ flask
2
+ numpy==1.24.4
3
+ pandas==2.0.3
4
+ scikit-learn==1.4.2
5
+ joblib==1.3.2
requirements.txt ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ flask
2
+ flask_cors
3
+ numpy
4
+ pandas
5
+ scikit-learn==1.6.1
6
+ joblib
7
+ statsmodels==0.14.2
8
+
9
+ tensorflow
10
+ Pillow
11
+ gunicorn
12
+
13
+
tea_auction_advanced_dataset.csv ADDED
The diff for this file is too large to render. See raw diff