Vadashuk commited on
Commit
f3523ce
·
verified ·
1 Parent(s): 6d1b63b

Upload 7 files

Browse files
Dockerfile ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+
7
+ # 🟢 Add this block BEFORE pip install:
8
+ RUN apt-get update && apt-get install -y libgomp1
9
+
10
+ RUN pip install --no-cache-dir -r requirements.txt
11
+
12
+ COPY main.py .
13
+
14
+ COPY hidden_logic/ ./hidden_logic/
15
+
16
+ # Create shutdown script
17
+ RUN echo '#!/bin/bash\nsleep 300\nkill -15 1' > /app/shutdown.sh && chmod +x /app/shutdown.sh
18
+
19
+ EXPOSE 7860
20
+
21
+ # Use 8 cores for numpy/scipy/pandas (OpenMP, MKL, OpenBLAS, NumExpr)
22
+ ENV OMP_NUM_THREADS=4
23
+ ENV MKL_NUM_THREADS=4
24
+ ENV OPENBLAS_NUM_THREADS=4
25
+ ENV NUMEXPR_NUM_THREADS=4
26
+
27
+ # Start both the API and shutdown script (1 worker so thread env vars apply to the single process)
28
+ CMD ["/bin/bash", "-c", "/app/shutdown.sh & uvicorn main:app --host 0.0.0.0 --port 7860 --workers 2"]
README.md CHANGED
@@ -1,11 +1,31 @@
1
- ---
2
- title: Datfid Api
3
- emoji: 🐨
4
- colorFrom: purple
5
- colorTo: gray
6
- sdk: docker
7
- pinned: false
8
- short_description: demo api datfid
9
- ---
10
-
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: DATFID
3
+ emoji: 📊
4
+ colorFrom: indigo
5
+ colorTo: blue
6
+ sdk: docker
7
+ app_file: shellmain.py
8
+ hidden: false
9
+ ---
10
+
11
+ # 📊 DATFID API (Hackathon Demo Backend)
12
+
13
+ This Space is configured as a **hackathon-safe demo backend**.
14
+ It keeps the same DATFID API routes, but uses a lightweight internal model (simple OLS with lag/trend features) instead of private DATFID internals.
15
+
16
+ > 🛠️ Powered by FastAPI, Docker, and hosted on Hugging Face Spaces.
17
+
18
+ ---
19
+
20
+ ## 📦 What You Can Do
21
+
22
+ - ✅ Use existing endpoints (`/modelfit*`, `/modelforecast*`) from `datfid-master`
23
+ - ✅ Fit and forecast with a transparent demo OLS-based model
24
+ - ✅ Use all endpoints without any auth token
25
+
26
+ ---
27
+
28
+ ## 🔐 Authentication
29
+
30
+ This demo backend is fully public for the hackathon setup.
31
+ No token is required.
hidden_logic/__pycache__/datfid.cpython-311.pyc ADDED
Binary file (71.9 kB). View file
 
hidden_logic/__pycache__/datfid.cpython-313.pyc ADDED
Binary file (67.5 kB). View file
 
hidden_logic/__pycache__/datfid.cpython-38.pyc ADDED
Binary file (29.5 kB). View file
 
main.py ADDED
@@ -0,0 +1,1135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # main.py
2
+ import os, json, base64, requests, traceback
3
+ import pandas as pd
4
+ import numpy as np
5
+ import io, tempfile, textwrap
6
+ import statsmodels.api as sm
7
+
8
+ from fastapi import FastAPI, HTTPException, Body, Header, Depends, UploadFile, File, Form, Response, status, Request
9
+ from fastapi.responses import FileResponse, PlainTextResponse, ORJSONResponse
10
+ from fastapi.concurrency import run_in_threadpool
11
+ from datetime import datetime
12
+ from typing import List, Dict, Any, Optional
13
+ from dataclasses import dataclass
14
+ from starlette.middleware.base import BaseHTTPMiddleware
15
+ import time
16
+
17
+ # ---------- Server-side config ----------
18
+ GITHUB_OWNER = "datfid-valeriidashuk"
19
+ GITHUB_REPO = "datfid-hf"
20
+ USAGE_PATH = "hf_usage.json" # <— add this
21
+ BRANCH = "main"
22
+ GITHUB_PAT = os.environ.get("Github_key") # must have Contents: Read & Write
23
+
24
+ # Predefined globals
25
+ stored_model = None
26
+ stored_result_join = None
27
+
28
+ @dataclass
29
+ class DemoFitResult:
30
+ formula: str
31
+ alpha: List[List[float]]
32
+ beta: List[List[float]]
33
+ headers_alpha: List[str]
34
+ headers_beta: List[str]
35
+ Performance: List[List[float]]
36
+ R2_individual: List[float]
37
+ R2_individual_labels: List[str]
38
+
39
+ @dataclass
40
+ class DemoFitJoin:
41
+ result: DemoFitResult
42
+
43
+ class DATFIDModel:
44
+ """
45
+ Demo-safe replacement for the private DATFID model.
46
+ Uses simple OLS with optional lagged features and trend.
47
+ """
48
+ def __init__(
49
+ self,
50
+ df: pd.DataFrame,
51
+ id_col: str,
52
+ time_col: str,
53
+ y: str,
54
+ lag_y: Any = None,
55
+ lagged_features: Any = None,
56
+ current_features: Any = None,
57
+ filter_by_significance: bool = False,
58
+ meanvar_test: bool = False,
59
+ signif: float = 0.05,
60
+ ):
61
+ self.df = df.copy()
62
+ self.id_col = id_col
63
+ self.time_col = time_col
64
+ self.y = y
65
+ self.lag_y = lag_y
66
+ self.lagged_features = lagged_features if isinstance(lagged_features, dict) else {}
67
+ if current_features == "all":
68
+ self.current_features = "all"
69
+ elif isinstance(current_features, list):
70
+ self.current_features = current_features
71
+ else:
72
+ self.current_features = []
73
+ self.filter_by_significance = filter_by_significance
74
+ self.meanvar_test = meanvar_test
75
+ self.signif = signif
76
+ self._fitted_model = None
77
+ self._train_columns: List[str] = []
78
+ self._last_y_by_id: Dict[str, float] = {}
79
+ self._last_y_global: float = 0.0
80
+
81
+ def _get_lag_int(self, value: Any, default: int = 1) -> int:
82
+ try:
83
+ out = int(value)
84
+ return out if out > 0 else default
85
+ except Exception:
86
+ return default
87
+
88
+ def _sorted(self, df: pd.DataFrame) -> pd.DataFrame:
89
+ if self.id_col in df.columns and self.time_col in df.columns:
90
+ return df.sort_values([self.id_col, self.time_col]).copy()
91
+ if self.time_col in df.columns:
92
+ return df.sort_values([self.time_col]).copy()
93
+ return df.copy()
94
+
95
+ def _trend(self, df: pd.DataFrame) -> pd.Series:
96
+ if self.time_col in df.columns:
97
+ ts = pd.to_datetime(df[self.time_col], errors="coerce")
98
+ if ts.notna().any():
99
+ base = ts.min()
100
+ return (ts - base).dt.days.fillna(0.0).astype(float)
101
+ return pd.Series(np.arange(len(df), dtype=float), index=df.index)
102
+
103
+ def _resolve_current_features(self, df: pd.DataFrame) -> List[str]:
104
+ if self.current_features == "all":
105
+ return [
106
+ c for c in df.columns
107
+ if c not in {self.id_col, self.time_col, self.y}
108
+ and pd.api.types.is_numeric_dtype(df[c])
109
+ ]
110
+ return [c for c in self.current_features if c in df.columns]
111
+
112
+ def _build_matrix(self, df: pd.DataFrame, is_forecast: bool) -> pd.DataFrame:
113
+ work = self._sorted(df)
114
+ x = pd.DataFrame(index=work.index)
115
+ x["trend_index"] = self._trend(work)
116
+
117
+ for col in self._resolve_current_features(work):
118
+ x[col] = pd.to_numeric(work[col], errors="coerce")
119
+
120
+ if self.y in work.columns:
121
+ lag_y_int = self._get_lag_int(self.lag_y, default=1) if self.lag_y else None
122
+ if lag_y_int:
123
+ lag_name = f"{self.y}_lag_{lag_y_int}"
124
+ if self.id_col in work.columns:
125
+ x[lag_name] = work.groupby(self.id_col, sort=False)[self.y].shift(lag_y_int)
126
+ else:
127
+ x[lag_name] = work[self.y].shift(lag_y_int)
128
+
129
+ for feat, lag in (self.lagged_features or {}).items():
130
+ if feat not in work.columns:
131
+ continue
132
+ lag_int = self._get_lag_int(lag, default=1)
133
+ col_name = f"{feat}_lag_{lag_int}"
134
+ if self.id_col in work.columns:
135
+ x[col_name] = work.groupby(self.id_col, sort=False)[feat].shift(lag_int)
136
+ else:
137
+ x[col_name] = work[feat].shift(lag_int)
138
+
139
+ x = x.apply(pd.to_numeric, errors="coerce")
140
+ if is_forecast:
141
+ x = x.fillna(0.0)
142
+ return x
143
+
144
+ def fit(self) -> DemoFitJoin:
145
+ if self.y not in self.df.columns:
146
+ raise ValueError(f"Target column '{self.y}' is missing.")
147
+
148
+ train = self._sorted(self.df)
149
+ x = self._build_matrix(train, is_forecast=False)
150
+ y = pd.to_numeric(train[self.y], errors="coerce")
151
+ valid = y.notna()
152
+ if x.shape[1] > 0:
153
+ valid = valid & x.notna().all(axis=1)
154
+ x = x.loc[valid].copy()
155
+ y = y.loc[valid].copy()
156
+
157
+ if len(y) < 3:
158
+ raise ValueError("Not enough valid rows to fit demo model (need >= 3).")
159
+
160
+ x_const = sm.add_constant(x, has_constant="add")
161
+ self._fitted_model = sm.OLS(y.astype(float), x_const.astype(float)).fit()
162
+ self._train_columns = list(x_const.columns)
163
+
164
+ if self.id_col in train.columns:
165
+ last_vals = train.groupby(self.id_col, sort=False)[self.y].last().dropna()
166
+ self._last_y_by_id = {str(k): float(v) for k, v in last_vals.items()}
167
+ self._last_y_global = float(y.iloc[-1]) if len(y) else 0.0
168
+
169
+ params = self._fitted_model.params
170
+ bse = self._fitted_model.bse
171
+ tvals = self._fitted_model.tvalues
172
+ pvals = self._fitted_model.pvalues
173
+
174
+ const_name = "const" if "const" in params.index else params.index[0]
175
+ beta_names = [n for n in params.index if n != const_name]
176
+
177
+ alpha = [[float(params.get(const_name, 0.0))], [float(bse.get(const_name, 0.0))], [float(tvals.get(const_name, 0.0))], [float(pvals.get(const_name, 1.0))]]
178
+ beta = [
179
+ [float(params.get(n, 0.0)) for n in beta_names],
180
+ [float(bse.get(n, 0.0)) for n in beta_names],
181
+ [float(tvals.get(n, 0.0)) for n in beta_names],
182
+ [float(pvals.get(n, 1.0)) for n in beta_names],
183
+ ]
184
+
185
+ pred = self._fitted_model.predict(x_const)
186
+ mse = float(np.mean((y - pred) ** 2))
187
+ mae = float(np.mean(np.abs(y - pred)))
188
+ r2 = float(getattr(self._fitted_model, "rsquared", 0.0))
189
+ r2_adj = float(getattr(self._fitted_model, "rsquared_adj", r2))
190
+ perf = [
191
+ [r2, r2],
192
+ [r2_adj, r2_adj],
193
+ [r2, r2_adj],
194
+ [mse, mse],
195
+ [mae, mae],
196
+ ]
197
+
198
+ r2_individual: List[float] = []
199
+ r2_labels: List[str] = []
200
+ if self.id_col in train.columns:
201
+ joined = pd.DataFrame({
202
+ self.id_col: train.loc[valid, self.id_col].astype(str),
203
+ "_y": y.values,
204
+ "_p": pred.values,
205
+ })
206
+ for id_val, sub in joined.groupby(self.id_col, sort=False):
207
+ den = float(((sub["_y"] - sub["_y"].mean()) ** 2).sum())
208
+ if den <= 0:
209
+ r2_i = 0.0
210
+ else:
211
+ num = float(((sub["_y"] - sub["_p"]) ** 2).sum())
212
+ r2_i = 1.0 - (num / den)
213
+ r2_labels.append(str(id_val))
214
+ r2_individual.append(r2_i)
215
+
216
+ formula = f"{self.y} ~ " + " + ".join(self._train_columns)
217
+ fit_result = DemoFitResult(
218
+ formula=formula,
219
+ alpha=alpha,
220
+ beta=beta,
221
+ headers_alpha=[const_name],
222
+ headers_beta=beta_names,
223
+ Performance=perf,
224
+ R2_individual=r2_individual,
225
+ R2_individual_labels=r2_labels,
226
+ )
227
+ return DemoFitJoin(result=fit_result)
228
+
229
+ def forecast(self, extern_self: Any, df_forecast: pd.DataFrame) -> pd.DataFrame:
230
+ if self._fitted_model is None:
231
+ raise ValueError("Model not fitted.")
232
+
233
+ out = self._sorted(df_forecast).copy()
234
+ x = self._build_matrix(out, is_forecast=True)
235
+ x_const = sm.add_constant(x, has_constant="add")
236
+
237
+ for col in self._train_columns:
238
+ if col not in x_const.columns:
239
+ x_const[col] = 0.0
240
+ x_const = x_const[self._train_columns].astype(float)
241
+
242
+ pred = self._fitted_model.predict(x_const)
243
+ out[f"{self.y}_forecast"] = np.asarray(pred, dtype=float)
244
+ out["forecast"] = out[f"{self.y}_forecast"]
245
+ return out
246
+
247
+ def _gh_get(path: str):
248
+ url = f"https://api.github.com/repos/{GITHUB_OWNER}/{GITHUB_REPO}/contents/{path}?ref={BRANCH}"
249
+ headers = {
250
+ "Authorization": f"Bearer {GITHUB_PAT}",
251
+ "Accept": "application/vnd.github+json",
252
+ "X-GitHub-Api-Version": "2022-11-28",
253
+ }
254
+ r = requests.get(url, headers=headers, timeout=15)
255
+ if r.status_code == 404:
256
+ return None, None # file not found
257
+ if r.status_code != 200:
258
+ raise HTTPException(status_code=502, detail=f"GitHub GET {path} failed")
259
+ data = r.json()
260
+ content = base64.b64decode(data["content"]).decode("utf-8")
261
+ sha = data["sha"]
262
+ return content, sha
263
+
264
+ def _gh_put(path: str, text: str, *, sha: Optional[str], message: str):
265
+ url = f"https://api.github.com/repos/{GITHUB_OWNER}/{GITHUB_REPO}/contents/{path}"
266
+ headers = {
267
+ "Authorization": f"Bearer {GITHUB_PAT}",
268
+ "Accept": "application/vnd.github+json",
269
+ "X-GitHub-Api-Version": "2022-11-28",
270
+ }
271
+ payload = {
272
+ "message": message,
273
+ "content": base64.b64encode(text.encode("utf-8")).decode("utf-8"),
274
+ "branch": BRANCH,
275
+ }
276
+ if sha:
277
+ payload["sha"] = sha
278
+ r = requests.put(url, headers=headers, json=payload, timeout=20)
279
+ if r.status_code in (200, 201):
280
+ return r.json()["content"]["sha"]
281
+ # surface conflict so we can retry
282
+ if r.status_code in (409, 422):
283
+ raise RuntimeError("GITHUB_CONFLICT")
284
+ raise HTTPException(status_code=502, detail=f"GitHub PUT {path} failed")
285
+
286
+ def _today() -> str:
287
+ """Return current date as YYYY-MM-DD."""
288
+ return datetime.utcnow().strftime("%Y-%m-%d")
289
+
290
+ def _bucket(nbytes: Optional[int]) -> str:
291
+ """Categorize byte size into buckets."""
292
+ if not isinstance(nbytes, int):
293
+ return "<1MB"
294
+ if nbytes < 1_000_000:
295
+ return "<1MB"
296
+ if nbytes < 10_000_000:
297
+ return "1-10MB"
298
+ return ">10MB"
299
+
300
+ def _new_day_bucket():
301
+ """Create a new day bucket with all metrics initialized."""
302
+ return {
303
+ "calls": 0,
304
+ "ok_2xx": 0,
305
+ "client_4xx": 0,
306
+ "server_5xx": 0,
307
+ "total_duration_ms": 0,
308
+ "req_size_bucket": {"<1MB": 0, "1-10MB": 0, ">10MB": 0},
309
+ "resp_size_bucket": {"<1MB": 0, "1-10MB": 0, ">10MB": 0},
310
+ }
311
+
312
+ def bump_usage(user_id: str, endpoint: str, max_retries: int = 5,
313
+ status_code: Optional[int] = None, duration_ms: Optional[int] = None,
314
+ req_bytes: Optional[int] = None, resp_bytes: Optional[int] = None):
315
+ """
316
+ Increment usage counters in hf_usage.json with optimistic concurrency.
317
+ Now supports extended metrics: status codes, duration, request/response sizes.
318
+ """
319
+ yyyymm = datetime.utcnow().strftime("%Y-%m")
320
+ yyyymmdd = _today()
321
+
322
+ attempt = 0
323
+ while True:
324
+ attempt += 1
325
+ # read current usage (or create)
326
+ txt, sha = _gh_get(USAGE_PATH)
327
+ if txt is None:
328
+ usage = {}
329
+ sha = None
330
+ else:
331
+ try:
332
+ usage = json.loads(txt) if txt.strip() else {}
333
+ except Exception:
334
+ # corrupt file -> start fresh but do not lose the old SHA reference
335
+ usage = {}
336
+
337
+ # Get or create user record
338
+ rec = usage.get(user_id) or {
339
+ "total": 0,
340
+ "by_month": {},
341
+ "by_endpoint": {},
342
+ "by_endpoint_month": {},
343
+ "updated_at": None,
344
+ }
345
+
346
+ # Initialize endpoints structure if not present
347
+ if "endpoints" not in rec:
348
+ rec["endpoints"] = {}
349
+
350
+ # Get or create endpoint record
351
+ ep_rec = rec["endpoints"].get(endpoint) or {
352
+ "total": 0,
353
+ }
354
+
355
+ # Get or create day bucket for this endpoint
356
+ if yyyymmdd not in ep_rec:
357
+ ep_rec[yyyymmdd] = _new_day_bucket()
358
+
359
+ day_bucket = ep_rec[yyyymmdd]
360
+
361
+ # Increment basic counters
362
+ rec["total"] = int(rec.get("total", 0)) + 1
363
+ rec["by_month"][yyyymm] = int(rec["by_month"].get(yyyymm, 0)) + 1
364
+ rec["by_endpoint"][endpoint] = int(rec["by_endpoint"].get(endpoint, 0)) + 1
365
+ key_ep_month = f"{yyyymm}:{endpoint}"
366
+ rec["by_endpoint_month"][key_ep_month] = int(rec["by_endpoint_month"].get(key_ep_month, 0)) + 1
367
+ rec["updated_at"] = datetime.utcnow().isoformat() + "Z"
368
+
369
+ # Increment endpoint-level counters
370
+ ep_rec["total"] = int(ep_rec.get("total", 0)) + 1
371
+
372
+ # Increment extended metrics if provided
373
+ day_bucket["calls"] += 1
374
+ if status_code is not None:
375
+ if 200 <= status_code < 300:
376
+ day_bucket["ok_2xx"] += 1
377
+ elif 400 <= status_code < 500:
378
+ day_bucket["client_4xx"] += 1
379
+ elif 500 <= status_code < 600:
380
+ day_bucket["server_5xx"] += 1
381
+ if duration_ms is not None:
382
+ day_bucket["total_duration_ms"] += duration_ms
383
+ if req_bytes is not None:
384
+ day_bucket["req_size_bucket"][_bucket(req_bytes)] += 1
385
+ if resp_bytes is not None:
386
+ day_bucket["resp_size_bucket"][_bucket(resp_bytes)] += 1
387
+
388
+ # Update nested structures
389
+ ep_rec[yyyymmdd] = day_bucket
390
+ rec["endpoints"][endpoint] = ep_rec
391
+ usage[user_id] = rec
392
+
393
+ new_txt = json.dumps(usage, ensure_ascii = False, indent = 2, sort_keys = True) + "\n"
394
+ try:
395
+ _gh_put(USAGE_PATH, new_txt, sha=sha, message=f"usage: +1 {user_id} {endpoint} {yyyymmdd}")
396
+ return
397
+ except RuntimeError as e:
398
+ # conflict -> backoff & retry
399
+ if str(e) == "GITHUB_CONFLICT" and attempt < max_retries:
400
+ import time as _t
401
+ _t.sleep(0.25 * attempt)
402
+ continue
403
+ raise
404
+
405
+ def _maybe_json_list(s: Optional[str]):
406
+ if s is None or s == "":
407
+ return []
408
+ s = s.strip()
409
+ if s.lower() == "all":
410
+ return "all"
411
+ try:
412
+ val = json.loads(s)
413
+ if isinstance(val, list):
414
+ return val
415
+ return []
416
+ except Exception:
417
+ # allow comma-separated as a fallback
418
+ return [x.strip() for x in s.split(",") if x.strip()]
419
+
420
+ def _maybe_json_dict(s: Optional[str]):
421
+ if not s:
422
+ return {}
423
+ try:
424
+ val = json.loads(s)
425
+ return val if isinstance(val, dict) else {}
426
+ except Exception:
427
+ return {}
428
+
429
+ def _read_table_from_upload(upload: UploadFile) -> pd.DataFrame:
430
+ name = (upload.filename or "").lower()
431
+ data = upload.file.read()
432
+ bio = io.BytesIO(data)
433
+ if name.endswith(".csv"):
434
+ return pd.read_csv(bio)
435
+ # default to Excel (supports .xls, .xlsx)
436
+ return pd.read_excel(bio)
437
+
438
+ def _result_to_text(result_obj: Any) -> str:
439
+ """
440
+ Build a compact, readable DATFID model summary.
441
+
442
+ Expected fields in `result_obj` (object with attributes or a dict):
443
+ - formula: str
444
+ - alpha: 2D array-like (rows ~ [Estimate, SE, T, P], columns = time-invariant features)
445
+ - beta: 2D array-like (rows ~ [Estimate, SE, T, P], columns = time-variant features)
446
+ - headers_alpha: list[str] (names for alpha columns)
447
+ - headers_beta: list[str] (names for beta columns)
448
+ - Performance: 2D array-like with the last 5 rows (in this order):
449
+ R2 within, R2 between, R2 overall, MSE, MAE
450
+ and 2 columns:
451
+ 2SFE, 2SFE_c
452
+ - R2_individual: 1D array-like of per-individual R² (optional)
453
+ - R2_individual_labels: list[str] of same length as R2_individual (optional)
454
+ If provided, labels will be shown instead of ID numbers in the summary.
455
+
456
+ The function tolerates:
457
+ - dict or object input
458
+ - missing headers (falls back to generic names)
459
+ - extra rows in alpha/beta (truncated to 4)
460
+ - extra rows in Performance (only last 5 kept)
461
+ """
462
+
463
+ # --- Safe getters for both dicts and objects
464
+ def get(key, default=None):
465
+ if isinstance(result_obj, dict):
466
+ return result_obj.get(key, default)
467
+ return getattr(result_obj, key, default)
468
+
469
+ def as_2d(a):
470
+ if a is None:
471
+ return np.empty((0, 0), dtype=float)
472
+ arr = np.asarray(a, dtype=float)
473
+ if arr.ndim == 1:
474
+ arr = arr[None, :]
475
+ return arr
476
+
477
+ # --- Pull fields
478
+ formula = (get("formula", "") or "").strip()
479
+
480
+ alpha_arr = as_2d(get("alpha"))
481
+ beta_arr = as_2d(get("beta"))
482
+ perf_arr = as_2d(get("Performance"))
483
+
484
+ headers_alpha = list(get("headers_alpha", [])) or [f"Alpha_{i+1}" for i in range(alpha_arr.shape[1])]
485
+ headers_beta = list(get("headers_beta", [])) or [f"Beta_{i+1}" for i in range(beta_arr.shape[1])]
486
+
487
+ r2_individual = get("R2_individual", None)
488
+ r2_labels = get("R2_individual_labels", None)
489
+
490
+ # --- Shape/label guards
491
+ row_labels = ["Estimate", "Standard Error", "T statistic", "P value"]
492
+
493
+ if alpha_arr.shape[0] > 4:
494
+ alpha_arr = alpha_arr[:4, :]
495
+ if beta_arr.shape[0] > 4:
496
+ beta_arr = beta_arr[:4, :]
497
+
498
+ # If perf has >5 rows, keep the last 5 (assumes metrics are at the end)
499
+ if perf_arr.shape[0] >= 5:
500
+ perf_arr = perf_arr[-5:, :]
501
+ perf_rows = ["R2 within", "R2 between", "R2 overall", "MSE", "MAE"]
502
+ perf_cols = ["2SFE", "2SFE_c"]
503
+ # Guard columns
504
+ n_perf_cols = min(perf_arr.shape[1], 2)
505
+ perf_cols = perf_cols[:n_perf_cols]
506
+
507
+ # --- Build tables
508
+ alpha_df = pd.DataFrame(alpha_arr, index=row_labels[:alpha_arr.shape[0]],
509
+ columns=headers_alpha[:alpha_arr.shape[1]])
510
+ beta_df = pd.DataFrame(beta_arr, index=row_labels[:beta_arr.shape[0]],
511
+ columns=headers_beta[:beta_arr.shape[1]])
512
+ perf_df = pd.DataFrame(perf_arr, index=perf_rows[:perf_arr.shape[0]],
513
+ columns=perf_cols)
514
+
515
+ # --- R² summary (min/median/max)
516
+ r2_lines = []
517
+ if r2_individual is not None:
518
+ r2_vals = np.asarray(r2_individual, dtype=float).ravel()
519
+ if r2_vals.size > 0 and np.isfinite(r2_vals).any():
520
+ # Prepare labels (either provided or fallback to 1-based IDs)
521
+ if r2_labels and len(r2_labels) == r2_vals.size:
522
+ ids = np.array(list(r2_labels), dtype=object)
523
+ def _lab(idx): return str(ids[idx])
524
+ else:
525
+ def _lab(idx): return f"ID {idx+1}"
526
+
527
+ r2_min = float(np.nanmin(r2_vals))
528
+ r2_med = float(np.nanmedian(r2_vals))
529
+ r2_max = float(np.nanmax(r2_vals))
530
+
531
+ # Nearest index to each statistic (handles non-exact medians)
532
+ imin = int(np.nanargmin(r2_vals))
533
+ imed = int(np.nanargmin(np.abs(r2_vals - r2_med)))
534
+ imax = int(np.nanargmax(r2_vals))
535
+
536
+ r2_lines = [
537
+ f"min ({_lab(imin)}): {r2_min:.6g}",
538
+ f"median ({_lab(imed)}): {r2_med:.6g}",
539
+ f"max ({_lab(imax)}): {r2_max:.6g}",
540
+ ]
541
+
542
+ # --- Pretty printers
543
+ ffmt = lambda x: f"{x:.6g}"
544
+ def _df_text(df: pd.DataFrame) -> str:
545
+ # Right-justified columns; scientific format where appropriate
546
+ return df.to_string(justify="right", float_format=ffmt)
547
+
548
+ # --- Compose report
549
+ parts = []
550
+ parts.append("DATFID Fit Result")
551
+ parts.append(f"Generated: {datetime.utcnow().isoformat()}Z")
552
+ parts.append("=" * 72)
553
+ parts.append("=== Model Summary ===\n")
554
+ parts.append("Formula:")
555
+ parts.append(f" {formula}\n")
556
+
557
+ parts.append("Alpha (time invariant):")
558
+ parts.append(_df_text(alpha_df) + "\n")
559
+
560
+ parts.append("Beta (time variant):")
561
+ parts.append(_df_text(beta_df) + "\n")
562
+
563
+ parts.append("Performance metrics:")
564
+ parts.append(_df_text(perf_df) + "\n")
565
+
566
+ if r2_lines:
567
+ parts.append("Individual R² summary:")
568
+ parts.extend(r2_lines)
569
+
570
+ return ("\n".join(parts)).rstrip() + "\n"
571
+
572
+ # ---------- FastAPI app ----------
573
+ app = FastAPI(
574
+ title="DATFID API",
575
+ description="Public demo API (no token auth)",
576
+ docs_url="/docs",
577
+ redoc_url=None,
578
+ default_response_class=ORJSONResponse,
579
+ )
580
+
581
+ # ---------- Metrics Middleware ----------
582
+ class UsageMetricsMiddleware(BaseHTTPMiddleware):
583
+ """Middleware to capture extended usage metrics and call bump_usage with full context."""
584
+
585
+ async def dispatch(self, request: Request, call_next):
586
+ # Capture start time
587
+ t0 = time.time()
588
+
589
+ # Get request size
590
+ req_bytes = None
591
+ try:
592
+ cl = request.headers.get("content-length")
593
+ if cl:
594
+ req_bytes = int(cl)
595
+ except Exception:
596
+ pass
597
+
598
+ # Process request (this will execute dependencies and handlers)
599
+ response = await call_next(request)
600
+
601
+ # After handler execution, check if this request was metered
602
+ # (dependencies set request.state during handler execution)
603
+ has_metered = hasattr(request.state, "metered_user_id") and hasattr(request.state, "metered_endpoint")
604
+
605
+ if not has_metered:
606
+ # Not a metered endpoint, just return response
607
+ return response
608
+
609
+ # Calculate duration
610
+ duration_ms = int((time.time() - t0) * 1000)
611
+
612
+ # Get response size
613
+ resp_bytes = None
614
+ try:
615
+ rcl = response.headers.get("content-length")
616
+ if rcl:
617
+ resp_bytes = int(rcl)
618
+ except Exception:
619
+ pass
620
+
621
+ # Get status code
622
+ status_code = getattr(response, "status_code", None)
623
+
624
+ # Call bump_usage with extended metrics (non-blocking)
625
+ # In fully public mode without Github_key, skip persistence silently.
626
+ if not GITHUB_PAT:
627
+ return response
628
+ try:
629
+ user_id = request.state.metered_user_id
630
+ endpoint = request.state.metered_endpoint
631
+ bump_usage(
632
+ user_id=user_id,
633
+ endpoint=endpoint,
634
+ status_code=status_code,
635
+ duration_ms=duration_ms,
636
+ req_bytes=req_bytes,
637
+ resp_bytes=resp_bytes,
638
+ )
639
+ except Exception:
640
+ # Never break the request due to metrics failure
641
+ traceback.print_exc()
642
+
643
+ return response
644
+
645
+ # Register middleware
646
+ app.add_middleware(UsageMetricsMiddleware)
647
+
648
+ # In-memory model state
649
+ @dataclass
650
+ class ModelSlot:
651
+ model: Optional["DATFIDModel"] = None
652
+ result: Optional[Any] = None
653
+
654
+ # Per-ID registry + silent internal stats (not returned)
655
+ model_store: Dict[str, ModelSlot] = {}
656
+ fit_stats: Dict[str, int] = {"fitted": 0, "failed": 0} # internal only
657
+ fc_stats: Dict[str, int] = {"forecasted": 0, "skipped": 0} # internal only
658
+
659
+ @app.get("/")
660
+ def root():
661
+ return {"message": "DATFID API is alive."}
662
+
663
+ # ✅ Validate directly here
664
+ @app.get("/secure-ping/")
665
+ def secure_ping():
666
+ return {"ok": True}
667
+
668
+ # Reusable dependency for protected routes
669
+ def require_valid_token(x_api_key: Optional[str] = Header(None)) -> str:
670
+ return x_api_key or ""
671
+
672
+ def metered(endpoint_name: str):
673
+ """Public dependency: no auth, keeps endpoint metrics labels."""
674
+ async def _dep(request: Request, x_api_key: Optional[str] = Header(None)) -> str:
675
+ user_id = "public"
676
+ # Store in request state for middleware to use
677
+ request.state.metered_user_id = user_id
678
+ request.state.metered_endpoint = endpoint_name
679
+ return x_api_key or ""
680
+ return _dep
681
+
682
+ # ---------- Model endpoints (guarded) ----------
683
+ @app.post("/modelfit/")
684
+ async def modelfit(
685
+ _: str = Depends(metered("modelfit")),
686
+ df: List[Dict] = Body(...),
687
+ id_col: str = Body(...),
688
+ time_col: str = Body(...),
689
+ y: str = Body(...),
690
+ lag_y: Any = Body(None),
691
+ lagged_features: Any = Body({}),
692
+ current_features: Any = Body([]),
693
+ filter_by_significance: bool = Body(False),
694
+ meanvar_test: bool = Body(False),
695
+ signif: Any = Body(0.05),
696
+ ):
697
+ global stored_model, stored_result_join
698
+ try:
699
+ sig_val = float(signif)
700
+ except (TypeError, ValueError):
701
+ sig_val = 0.05
702
+ df1 = pd.DataFrame(df)
703
+ try:
704
+ model = DATFIDModel(
705
+ df=df1,
706
+ id_col=id_col,
707
+ time_col=time_col,
708
+ y=y,
709
+ lag_y=lag_y,
710
+ lagged_features=lagged_features,
711
+ current_features=current_features,
712
+ filter_by_significance=filter_by_significance,
713
+ meanvar_test=meanvar_test,
714
+ signif=sig_val,
715
+ )
716
+ result_join = model.fit()
717
+ result = result_join.result
718
+
719
+ # save for forecast
720
+ stored_model = model
721
+ stored_result_join = result_join
722
+
723
+ # jsonify result object
724
+ result_dict = {}
725
+ for k, v in result.__dict__.items():
726
+ if isinstance(v, pd.DataFrame):
727
+ result_dict[k] = v.to_dict(orient="records")
728
+ elif isinstance(v, pd.Series):
729
+ result_dict[k] = v.to_dict()
730
+ elif isinstance(v, (list, dict, str, int, float, bool, type(None))):
731
+ result_dict[k] = v
732
+ else:
733
+ result_dict[k] = str(v)
734
+ return result_dict
735
+ except Exception as e:
736
+ traceback.print_exc()
737
+ raise HTTPException(status_code=500, detail=f"Error during model fit: {str(e)}")
738
+
739
+ @app.post("/modelforecast/")
740
+ async def modelforecast(
741
+ _: str = Depends(metered("modelforecast")),
742
+ df_forecast: List[Dict] = Body(...),
743
+ ):
744
+ global stored_model, stored_result_join
745
+ if stored_model is None or stored_result_join is None:
746
+ raise HTTPException(status_code=400, detail="Model not fitted. Call /modelfit/ first.")
747
+ try:
748
+ df_forecast1 = pd.DataFrame(df_forecast)
749
+ forecast_df = stored_model.forecast(extern_self=stored_result_join, df_forecast=df_forecast1)
750
+ return forecast_df.to_dict(orient="records")
751
+ except Exception as e:
752
+ traceback.print_exc()
753
+ raise HTTPException(status_code=500, detail=f"Error during model forecast: {str(e)}")
754
+
755
+ @app.post("/modelfit-file/")
756
+ async def modelfit_file(
757
+ _: str = Depends(metered("modelfit-file")),
758
+ file: UploadFile = File(...),
759
+ id_col: str = Form(...),
760
+ time_col: str = Form(...),
761
+ y: str = Form(...),
762
+ lag_y: str = Form(""),
763
+ lagged_features: str = Form(""),
764
+ current_features: str = Form(""),
765
+ filter_by_significance: str = Form("false"),
766
+ meanvar_test: str = Form("false"),
767
+ signif: str = Form("0.05"),
768
+ ):
769
+ global stored_model, stored_result_join
770
+ try:
771
+ df = _read_table_from_upload(file)
772
+
773
+ # harmonize datetimes → str (like SDK)
774
+ for col in df.columns:
775
+ if pd.api.types.is_datetime64_any_dtype(df[col]):
776
+ df[col] = df[col].astype(str)
777
+
778
+ lagged = _maybe_json_dict(lagged_features)
779
+ curr = _maybe_json_list(current_features)
780
+ filt_sig = str(filter_by_significance).strip().lower() == "true"
781
+ mv_test = str(meanvar_test).strip().lower() == "true"
782
+ ly = None if (lag_y is None or lag_y.strip() == "") else lag_y.strip()
783
+ try:
784
+ sig_val = float(signif)
785
+ except (TypeError, ValueError):
786
+ sig_val = 0.05
787
+
788
+
789
+ model = DATFIDModel(
790
+ df=df,
791
+ id_col=id_col,
792
+ time_col=time_col,
793
+ y=y,
794
+ lag_y=ly,
795
+ lagged_features=lagged,
796
+ current_features=curr,
797
+ filter_by_significance=filt_sig,
798
+ meanvar_test=mv_test,
799
+ signif=sig_val,
800
+ )
801
+ result_join = model.fit()
802
+ stored_model = model
803
+ stored_result_join = result_join
804
+
805
+ # Build textual report
806
+ report_text = _result_to_text(result_join.result)
807
+ # Write to a temp file and return as attachment
808
+ tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".txt")
809
+ tmp.write(report_text.encode("utf-8"))
810
+ tmp.flush(); tmp.close()
811
+
812
+ fname = "result.txt"
813
+ return FileResponse(
814
+ tmp.name,
815
+ media_type="text/plain; charset=utf-8",
816
+ filename=fname,
817
+ )
818
+ except HTTPException:
819
+ raise
820
+ except Exception as e:
821
+ traceback.print_exc()
822
+ raise HTTPException(status_code=500, detail=f"Error during model fit (file): {str(e)}")
823
+
824
+ @app.post("/modelforecast-file/")
825
+ async def modelforecast_file(
826
+ _: str = Depends(metered("modelforecast-file")),
827
+ df_forecast: UploadFile = File(...),
828
+ ):
829
+ global stored_model, stored_result_join
830
+ if stored_model is None or stored_result_join is None:
831
+ raise HTTPException(status_code=400, detail="Model not fitted. Call /modelfit-file/ (or /modelfit/) first.")
832
+ try:
833
+ df_fc = _read_table_from_upload(df_forecast)
834
+
835
+ # harmonize datetimes → str (like SDK)
836
+ for col in df_fc.columns:
837
+ if pd.api.types.is_datetime64_any_dtype(df_fc[col]):
838
+ df_fc[col] = df_fc[col].astype(str)
839
+
840
+ forecast_df = stored_model.forecast(extern_self=stored_result_join, df_forecast=df_fc)
841
+
842
+ # Save to CSV and return (Excel-friendly)
843
+ tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".csv")
844
+ tmp_path = tmp.name
845
+ tmp.close() # we'll reopen with encoding
846
+
847
+ # Build CSV in memory
848
+ buf = io.StringIO()
849
+ forecast_df.to_csv(buf, index=False) # keep commas
850
+ csv_text = buf.getvalue()
851
+
852
+ # Excel friendliness:
853
+ # 1) 'sep=,' header makes Excel use commas even in ; locales
854
+ # 2) Also prevents 'ID' being the first two characters -> avoids SYLK warning
855
+ csv_text = "sep=,\n" + csv_text
856
+
857
+ # Write with BOM so Excel recognizes UTF-8
858
+ with open(tmp_path, "w", encoding="utf-8-sig", newline="") as f:
859
+ f.write(csv_text)
860
+
861
+ return FileResponse(
862
+ tmp_path,
863
+ media_type="text/csv; charset=utf-8",
864
+ filename="forecast.csv",
865
+ )
866
+
867
+ except Exception as e:
868
+ traceback.print_exc()
869
+ raise HTTPException(status_code=500, detail=f"Error during model forecast (file): {str(e)}")
870
+
871
+ @app.post("/modelfit_ind/")
872
+ async def modelfit_ind(
873
+ _: str = Depends(metered("modelfit_ind")),
874
+ df: List[Dict] = Body(...),
875
+ id_col: str = Body(...),
876
+ time_col: str = Body(...),
877
+ y: str = Body(...),
878
+ lag_y: Any = Body(None),
879
+ lagged_features: Any = Body({}),
880
+ current_features: Any = Body([]),
881
+ filter_by_significance: bool = Body(False),
882
+ meanvar_test: bool = Body(False),
883
+ signif: Any = Body(0.05),
884
+ ):
885
+ global model_store, fit_stats
886
+ try:
887
+ sig_val = float(signif)
888
+ except (TypeError, ValueError):
889
+ sig_val = 0.05
890
+ df1 = pd.DataFrame(df)
891
+
892
+ if id_col not in df1.columns:
893
+ raise HTTPException(status_code=400, detail=f"id_col '{id_col}' not in data.")
894
+
895
+ # reset registry + counters
896
+ model_store = {}
897
+ fit_stats = {"fitted": 0, "failed": 0}
898
+
899
+ per_id_results: Dict[str, Dict[str, Any]] = {}
900
+
901
+ for id_val, sub in df1.groupby(id_col, sort=False):
902
+ key = str(id_val)
903
+ try:
904
+ mdl = DATFIDModel(
905
+ df=sub,
906
+ id_col=id_col,
907
+ time_col=time_col,
908
+ y=y,
909
+ lag_y=lag_y,
910
+ lagged_features=lagged_features,
911
+ current_features=current_features,
912
+ filter_by_significance=filter_by_significance,
913
+ meanvar_test=meanvar_test,
914
+ signif=sig_val,
915
+ )
916
+ rj = await run_in_threadpool(mdl.fit)
917
+ model_store[key] = ModelSlot(model=mdl, result=rj)
918
+ fit_stats["fitted"] += 1
919
+ result = rj.result
920
+ result_dict: Dict[str, Any] = {}
921
+ for k, v in result.__dict__.items():
922
+ if isinstance(v, pd.DataFrame):
923
+ result_dict[k] = v.to_dict(orient="records")
924
+ elif isinstance(v, pd.Series):
925
+ result_dict[k] = v.to_dict()
926
+ elif isinstance(v, (list, dict, str, int, float, bool, type(None))):
927
+ result_dict[k] = v
928
+ else:
929
+ result_dict[k] = str(v)
930
+ per_id_results[key] = result_dict
931
+ except Exception:
932
+ traceback.print_exc()
933
+ fit_stats["failed"] += 1
934
+
935
+ return per_id_results
936
+
937
+ @app.post("/modelforecast_ind/")
938
+ async def modelforecast_ind(
939
+ _: str = Depends(metered("modelforecast_ind")),
940
+ df_forecast: List[Dict] = Body(...),
941
+ ):
942
+ # Use per-ID models created by /modelfit_ind/
943
+ global model_store
944
+ if not model_store:
945
+ raise HTTPException(status_code=400, detail="No per-ID models. Call /modelfit_ind/ first.")
946
+
947
+ dfF = pd.DataFrame(df_forecast)
948
+
949
+ # Infer the id column name from any stored model (same as used in fit)
950
+ try:
951
+ any_slot = next(iter(model_store.values()))
952
+ id_col_name = getattr(any_slot.model, "id_col")
953
+ except StopIteration:
954
+ raise HTTPException(status_code=400, detail="No per-ID models available.")
955
+ except Exception:
956
+ raise HTTPException(status_code=500, detail="Could not infer id column from stored models.")
957
+
958
+ if id_col_name not in dfF.columns:
959
+ raise HTTPException(status_code=400, detail=f"Forecast data must include id column '{id_col_name}'.")
960
+
961
+ frames = []
962
+ for id_val, sub in dfF.groupby(id_col_name, sort=False):
963
+ key = str(id_val)
964
+ slot = model_store.get(key)
965
+ if not slot or not slot.model or not slot.result:
966
+ continue
967
+ try:
968
+ fc = await run_in_threadpool(
969
+ slot.model.forecast,
970
+ extern_self=slot.result,
971
+ df_forecast=sub,
972
+ )
973
+ if id_col_name not in fc.columns:
974
+ fc[id_col_name] = key
975
+ for col in list(fc.columns):
976
+ if col and col.lower() == "product":
977
+ fc = fc.drop(columns=[col])
978
+ break
979
+ frames.append(fc)
980
+ except Exception:
981
+ traceback.print_exc()
982
+
983
+ out_df = pd.concat(frames, ignore_index=True) if frames else pd.DataFrame()
984
+ return out_df.to_dict(orient="records")
985
+
986
+
987
+ @app.post("/modelfit-file_ind/")
988
+ async def modelfit_file_ind(
989
+ _: str = Depends(metered("modelfit-file_ind")),
990
+ file: UploadFile = File(...),
991
+ id_col: str = Form(...),
992
+ time_col: str = Form(...),
993
+ y: str = Form(...),
994
+ lag_y: str = Form(""),
995
+ lagged_features: str = Form(""),
996
+ current_features: str = Form(""),
997
+ filter_by_significance: str = Form("false"),
998
+ meanvar_test: str = Form("false"),
999
+ signif: str = Form("0.05"),
1000
+ ):
1001
+ global model_store, fit_stats
1002
+ try:
1003
+ df = _read_table_from_upload(file)
1004
+
1005
+ for col in df.columns:
1006
+ if pd.api.types.is_datetime64_any_dtype(df[col]):
1007
+ df[col] = df[col].astype(str)
1008
+
1009
+ if id_col not in df.columns:
1010
+ raise HTTPException(status_code=400, detail=f"id_col '{id_col}' not in data.")
1011
+
1012
+ lagged = _maybe_json_dict(lagged_features)
1013
+ curr = _maybe_json_list(current_features)
1014
+ filt_sig = str(filter_by_significance).strip().lower() == "true"
1015
+ mv_test = str(meanvar_test).strip().lower() == "true"
1016
+ ly = None if (lag_y is None or lag_y.strip() == "") else lag_y.strip()
1017
+ try:
1018
+ sig_val = float(signif)
1019
+ except (TypeError, ValueError):
1020
+ sig_val = 0.05
1021
+
1022
+ model_store = {}
1023
+ fit_stats = {"fitted": 0, "failed": 0}
1024
+ report_parts: List[str] = []
1025
+
1026
+ for id_val, sub in df.groupby(id_col, sort=False):
1027
+ key = str(id_val)
1028
+ try:
1029
+ mdl = DATFIDModel(
1030
+ df=sub,
1031
+ id_col=id_col,
1032
+ time_col=time_col,
1033
+ y=y,
1034
+ lag_y=ly,
1035
+ lagged_features=lagged,
1036
+ current_features=curr,
1037
+ filter_by_significance=filt_sig,
1038
+ meanvar_test=mv_test,
1039
+ signif=sig_val,
1040
+ )
1041
+ rj = await run_in_threadpool(mdl.fit)
1042
+ model_store[key] = ModelSlot(model=mdl, result=rj)
1043
+ fit_stats["fitted"] += 1
1044
+ section = f"=== Entity ({id_col}): {key} ===\n\n" + _result_to_text(rj.result)
1045
+ report_parts.append(section)
1046
+ except Exception:
1047
+ traceback.print_exc()
1048
+ fit_stats["failed"] += 1
1049
+
1050
+ report_text = ("\n\n" + "=" * 72 + "\n\n").join(report_parts)
1051
+ tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".txt")
1052
+ tmp.write(report_text.encode("utf-8"))
1053
+ tmp.flush()
1054
+ tmp.close()
1055
+ return FileResponse(
1056
+ tmp.name,
1057
+ media_type="text/plain; charset=utf-8",
1058
+ filename="result.txt",
1059
+ )
1060
+ except HTTPException:
1061
+ raise
1062
+ except Exception as e:
1063
+ traceback.print_exc()
1064
+ raise HTTPException(status_code=500, detail=f"Error during model fit (file_ind): {str(e)}")
1065
+
1066
+
1067
+ @app.post("/modelforecast-file_ind/")
1068
+ async def modelforecast_file_ind(
1069
+ _: str = Depends(metered("modelforecast-file_ind")),
1070
+ df_forecast: UploadFile = File(...),
1071
+ ):
1072
+ global model_store
1073
+ if not model_store:
1074
+ raise HTTPException(status_code=400, detail="No per-ID models. Call /modelfit-file_ind/ or /modelfit_ind/ first.")
1075
+ try:
1076
+ df_fc = _read_table_from_upload(df_forecast)
1077
+ for col in df_fc.columns:
1078
+ if pd.api.types.is_datetime64_any_dtype(df_fc[col]):
1079
+ df_fc[col] = df_fc[col].astype(str)
1080
+
1081
+ try:
1082
+ any_slot = next(iter(model_store.values()))
1083
+ id_col_name = getattr(any_slot.model, "id_col")
1084
+ except StopIteration:
1085
+ raise HTTPException(status_code=400, detail="No per-ID models available.")
1086
+ except Exception:
1087
+ raise HTTPException(status_code=500, detail="Could not infer id column from stored models.")
1088
+
1089
+ if id_col_name not in df_fc.columns:
1090
+ raise HTTPException(status_code=400, detail=f"Forecast data must include id column '{id_col_name}'.")
1091
+
1092
+ frames = []
1093
+ for id_val, sub in df_fc.groupby(id_col_name, sort=False):
1094
+ key = str(id_val)
1095
+ slot = model_store.get(key)
1096
+ if not slot or not slot.model or not slot.result:
1097
+ continue
1098
+ try:
1099
+ fc = await run_in_threadpool(
1100
+ slot.model.forecast,
1101
+ extern_self=slot.result,
1102
+ df_forecast=sub,
1103
+ )
1104
+ if id_col_name not in fc.columns:
1105
+ fc[id_col_name] = key
1106
+ for col in list(fc.columns):
1107
+ if col and col.lower() == "product":
1108
+ fc = fc.drop(columns=[col])
1109
+ break
1110
+ frames.append(fc)
1111
+ except Exception:
1112
+ traceback.print_exc()
1113
+
1114
+ out_df = pd.concat(frames, ignore_index=True) if frames else pd.DataFrame()
1115
+
1116
+ tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".csv")
1117
+ tmp_path = tmp.name
1118
+ tmp.close()
1119
+ buf = io.StringIO()
1120
+ out_df.to_csv(buf, index=False)
1121
+ csv_text = buf.getvalue()
1122
+ csv_text = "sep=,\n" + csv_text
1123
+ with open(tmp_path, "w", encoding="utf-8-sig", newline="") as f:
1124
+ f.write(csv_text)
1125
+
1126
+ return FileResponse(
1127
+ tmp_path,
1128
+ media_type="text/csv; charset=utf-8",
1129
+ filename="forecast.csv",
1130
+ )
1131
+ except HTTPException:
1132
+ raise
1133
+ except Exception as e:
1134
+ traceback.print_exc()
1135
+ raise HTTPException(status_code=500, detail=f"Error during model forecast (file_ind): {str(e)}")
requirements.txt ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn
3
+ pandas
4
+ openpyxl
5
+ numpy
6
+ scikit-learn
7
+ httpx
8
+ statsmodels
9
+ huggingface_hub
10
+ python-multipart
11
+ matplotlib
12
+ scipy
13
+ nixtla
14
+ mlforecast
15
+ lightgbm
16
+ statsforecast
17
+ psutil>=5.9.0
18
+ gunicorn
19
+ requests
20
+ polars