aashish-bindal commited on
Commit
bb004f6
Β·
1 Parent(s): 346d3c7

Genesis AI deploy

Browse files
Files changed (7) hide show
  1. DEPLOY.md +105 -0
  2. Dockerfile +33 -0
  3. README.md +29 -5
  4. app.py +209 -0
  5. dabur_baseline_forecast_v2.py +556 -0
  6. dabur_demand_platform.html +1273 -0
  7. requirements.txt +6 -0
DEPLOY.md ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Deploy to Hugging Face Spaces – Step by Step
2
+
3
+ ## Prerequisites
4
+ - Hugging Face account (free) at https://huggingface.co
5
+ - Git installed on your machine
6
+
7
+ ---
8
+
9
+ ## Step 1 – Create a new Space
10
+
11
+ 1. Go to https://huggingface.co/spaces
12
+ 2. Click **Create new Space**
13
+ 3. Fill in:
14
+ - **Space name:** `genesisai-forecasting`
15
+ - **License:** MIT
16
+ - **SDK:** Docker ← important, select Docker not Gradio/Streamlit
17
+ - **Visibility:** Public (or Private if you prefer)
18
+ 4. Click **Create Space**
19
+
20
+ Your URL will be:
21
+ **https://huggingface.co/spaces/YOUR_USERNAME/genesisai-forecasting**
22
+
23
+ ---
24
+
25
+ ## Step 2 – Clone and push your code
26
+
27
+ Open terminal / Git Bash:
28
+
29
+ ```bash
30
+ # Clone the empty Space (replace YOUR_USERNAME)
31
+ git clone https://huggingface.co/spaces/YOUR_USERNAME/genesisai-forecasting
32
+ cd genesisai-forecasting
33
+
34
+ # Copy all your files into this folder:
35
+ # app.py
36
+ # dabur_baseline_forecast_v2.py
37
+ # dabur_demand_platform.html
38
+ # requirements.txt
39
+ # Dockerfile
40
+ # README.md
41
+ # .gitignore
42
+
43
+ # Commit and push
44
+ git add .
45
+ git commit -m "Genesis AI initial deploy"
46
+ git push
47
+ ```
48
+
49
+ HF will automatically detect the Dockerfile and start building.
50
+ Build takes ~3-5 minutes (installing statsforecast).
51
+
52
+ ---
53
+
54
+ ## Step 3 – Set Secrets (passwords)
55
+
56
+ 1. Go to your Space β†’ **Settings** β†’ **Repository secrets**
57
+ 2. Add these secrets:
58
+
59
+ | Secret name | Value |
60
+ |-------------|-------|
61
+ | `ANALYST_PASSWORD` | your secure analyst password |
62
+ | `VIEWER_PASSWORD` | your secure viewer password |
63
+ | `SECRET_KEY` | any random string (e.g. `hf-dabur-2025-xk92`) |
64
+
65
+ 3. After adding secrets β†’ **Factory reboot** the Space
66
+
67
+ ---
68
+
69
+ ## Step 4 – Open your Space
70
+
71
+ Go to: **https://huggingface.co/spaces/YOUR_USERNAME/genesisai-forecasting**
72
+
73
+ Log in with `analyst` and your analyst password.
74
+
75
+ ---
76
+
77
+ ## Sharing with viewers
78
+
79
+ Send viewers this URL and the viewer password.
80
+ They log in as `viewer` and see the latest forecast output immediately
81
+ (no upload, no run button β€” read only).
82
+
83
+ ---
84
+
85
+ ## Pushing updates
86
+
87
+ ```bash
88
+ cd genesisai-forecasting
89
+ # make your changes to the files
90
+ git add .
91
+ git commit -m "update"
92
+ git push
93
+ ```
94
+
95
+ HF rebuilds automatically in ~3 minutes.
96
+
97
+ ---
98
+
99
+ ## Notes
100
+
101
+ - **No sleep** – HF Spaces never sleep on public Spaces
102
+ - **16GB RAM, 2 vCPU** – plenty for statsforecast on your dataset
103
+ - **Forecast resets on restart** – results are in-memory.
104
+ If you redeploy, analyst needs to run once before viewer can see results.
105
+ - **Private Space** – costs $0.05/hour on HF Pro. Public is always free.
Dockerfile ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Genesis AI – Dabur Demand Intelligence Platform
2
+ # HF Spaces Docker deployment
3
+
4
+ FROM python:3.11-slim
5
+
6
+ # HF Spaces runs as non-root user 1000
7
+ RUN useradd -m -u 1000 appuser
8
+
9
+ WORKDIR /app
10
+
11
+ # Install dependencies first (layer caching)
12
+ COPY requirements.txt .
13
+ RUN pip install --no-cache-dir -r requirements.txt
14
+
15
+ # Copy application files
16
+ COPY app.py .
17
+ COPY dabur_baseline_forecast_v2.py .
18
+ COPY dabur_demand_platform.html .
19
+
20
+ # HF Spaces requires port 7860
21
+ EXPOSE 7860
22
+
23
+ # Switch to non-root user
24
+ USER appuser
25
+
26
+ # Run with gunicorn – threaded for background jobs, no timeout
27
+ CMD ["gunicorn", "app:app", \
28
+ "--bind", "0.0.0.0:7860", \
29
+ "--workers", "1", \
30
+ "--threads", "8", \
31
+ "--timeout", "0", \
32
+ "--worker-class", "gthread", \
33
+ "--access-logfile", "-"]
README.md CHANGED
@@ -1,11 +1,35 @@
1
  ---
2
- title: Genesisai Forecasting
3
- emoji: πŸ“š
4
  colorFrom: red
5
- colorTo: yellow
6
  sdk: docker
7
  pinned: false
8
- short_description: genesisai test app
9
  ---
10
 
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Genesis AI Forecasting
3
+ emoji: πŸ“ˆ
4
  colorFrom: red
5
+ colorTo: orange
6
  sdk: docker
7
  pinned: false
8
+ app_port: 7860
9
  ---
10
 
11
+ # Genesis AI – Dabur Demand Intelligence Platform
12
+
13
+ Baseline volume forecasting across Channel Γ— Design Brand Γ— Account using ensemble time-series models (AutoETS, AutoARIMA, AutoTheta, HoltWinters, MSTL).
14
+
15
+ ## Users
16
+
17
+ | Username | Password | Access |
18
+ |----------|----------|--------|
19
+ | `analyst` | set via Secret `ANALYST_PASSWORD` | Upload data, run forecast, view output |
20
+ | `viewer` | set via Secret `VIEWER_PASSWORD` | View latest forecast output only |
21
+
22
+ ## How to Use
23
+
24
+ 1. Log in as **analyst**
25
+ 2. Go to **Forecast Engine** β†’ upload your `.xlsx` file
26
+ 3. Click **Run Baseline Forecast** β€” pipeline runs in background
27
+ 4. Results appear automatically in **Forecast Output**
28
+ 5. Share the Space URL with viewers β€” they log in and see the output instantly
29
+
30
+ ## Setting Passwords (recommended)
31
+
32
+ In your HF Space β†’ **Settings** β†’ **Repository secrets**:
33
+ - `ANALYST_PASSWORD` β†’ your secure password
34
+ - `VIEWER_PASSWORD` β†’ your secure password
35
+ - `SECRET_KEY` β†’ any random string for session encryption
app.py ADDED
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Genesis AI – Dabur Demand Intelligence Platform
3
+ Hugging Face Spaces entry point.
4
+
5
+ HF Spaces requires:
6
+ - Entry file named app.py
7
+ - App listening on port 7860
8
+ - SDK: docker (we use Flask, not Gradio/Streamlit)
9
+ """
10
+
11
+ from flask import Flask, request, jsonify, send_from_directory, session
12
+ import io, traceback, os, uuid, threading, time
13
+
14
+ from dabur_baseline_forecast_v2 import run_pipeline, CONFIG
15
+
16
+ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
17
+ SECRET_KEY = os.environ.get("SECRET_KEY", "dabur-genesisai-hf-2025")
18
+ PORT = int(os.environ.get("PORT", 7860)) # HF Spaces requires 7860
19
+
20
+ app = Flask(__name__, static_folder=BASE_DIR, static_url_path="")
21
+ app.secret_key = SECRET_KEY
22
+
23
+ # =============================================================================
24
+ # USERS – set via HF Space Secrets for security
25
+ # ANALYST_PASSWORD / VIEWER_PASSWORD
26
+ # =============================================================================
27
+ USERS = {
28
+ "analyst": {
29
+ "password": os.environ.get("ANALYST_PASSWORD", "dabur@analyst"),
30
+ "role": "analyst",
31
+ },
32
+ "viewer": {
33
+ "password": os.environ.get("VIEWER_PASSWORD", "dabur@viewer"),
34
+ "role": "viewer",
35
+ },
36
+ }
37
+
38
+ # =============================================================================
39
+ # JOB STORE – background thread per forecast run
40
+ # Frontend submits β†’ gets job_id instantly β†’ polls /job/<id> every 5s
41
+ # =============================================================================
42
+ _jobs: dict = {}
43
+ _latest_forecast = None
44
+ _jobs_lock = threading.Lock()
45
+
46
+
47
+ def _run_job(job_id: str, file_bytes: bytes, cfg: dict):
48
+ """Pipeline runs in daemon thread. Safe to run long without timeout."""
49
+ global _latest_forecast
50
+ try:
51
+ result = run_pipeline(io.BytesIO(file_bytes), cfg)
52
+ with _jobs_lock:
53
+ _jobs[job_id].update({
54
+ "status": "done",
55
+ "result": result,
56
+ "finished_at": time.time(),
57
+ })
58
+ _latest_forecast = result
59
+ print(f"[JOB {job_id[:8]}] Done – {result.get('n_series',0)} series")
60
+ except Exception as e:
61
+ tb = traceback.format_exc()
62
+ print(f"[JOB {job_id[:8]}] ERROR:\n{tb}")
63
+ with _jobs_lock:
64
+ _jobs[job_id].update({
65
+ "status": "error",
66
+ "error": str(e),
67
+ "traceback": tb,
68
+ "finished_at": time.time(),
69
+ })
70
+ # Purge jobs older than 2 hours
71
+ now = time.time()
72
+ with _jobs_lock:
73
+ for jid in [k for k, v in _jobs.items()
74
+ if v.get("finished_at") and now - v["finished_at"] > 7200]:
75
+ del _jobs[jid]
76
+
77
+
78
+ # =============================================================================
79
+ # ROUTES
80
+ # =============================================================================
81
+
82
+ @app.route("/")
83
+ def index():
84
+ return send_from_directory(BASE_DIR, "dabur_demand_platform.html")
85
+
86
+
87
+ # ── Auth ───────────────────────────────────────────────────────────────
88
+ @app.route("/login", methods=["POST"])
89
+ def login():
90
+ data = request.get_json() or {}
91
+ username = data.get("username", "").strip().lower()
92
+ password = data.get("password", "")
93
+ user = USERS.get(username)
94
+ if not user or user["password"] != password:
95
+ return jsonify({"status": "error", "message": "Invalid username or password"}), 401
96
+ session["username"] = username
97
+ session["role"] = user["role"]
98
+ print(f"[AUTH] Login: {username} ({user['role']})")
99
+ return jsonify({"status": "ok", "username": username, "role": user["role"]})
100
+
101
+
102
+ @app.route("/logout", methods=["POST"])
103
+ def logout():
104
+ print(f"[AUTH] Logout: {session.get('username','?')}")
105
+ session.clear()
106
+ return jsonify({"status": "ok"})
107
+
108
+
109
+ @app.route("/me", methods=["GET"])
110
+ def me():
111
+ if "username" in session:
112
+ return jsonify({"status": "ok",
113
+ "username": session["username"],
114
+ "role": session["role"]})
115
+ return jsonify({"status": "error", "message": "Not logged in"}), 401
116
+
117
+
118
+ # ── Health ─────────────────────────────────────────────────────────────
119
+ @app.route("/health", methods=["GET"])
120
+ def health():
121
+ return jsonify({"status": "ok", "message": "Genesis AI – running on HF Spaces"})
122
+
123
+
124
+ # ── Submit forecast job ────────────────────────────────────────────────
125
+ @app.route("/run", methods=["POST"])
126
+ def run():
127
+ if session.get("role") != "analyst":
128
+ return jsonify({"status": "error",
129
+ "message": "Access denied – analyst role required"}), 403
130
+ if "file" not in request.files:
131
+ return jsonify({"status": "error", "message": "No file uploaded"}), 400
132
+
133
+ f = request.files["file"]
134
+ if not f.filename.lower().endswith((".xlsx", ".xls")):
135
+ return jsonify({"status": "error",
136
+ "message": "Only .xlsx files are supported"}), 400
137
+
138
+ cfg = CONFIG.copy()
139
+ try:
140
+ if request.form.get("horizon"): cfg["forecast_horizon"] = int(request.form["horizon"])
141
+ if request.form.get("season"): cfg["season_length"] = int(request.form["season"])
142
+ if request.form.get("cv_horizon"): cfg["cv_horizon"] = int(request.form["cv_horizon"])
143
+ if request.form.get("top_n"): cfg["top_n_ensemble"] = int(request.form["top_n"])
144
+ except ValueError as e:
145
+ return jsonify({"status": "error", "message": f"Invalid config value: {e}"}), 400
146
+
147
+ file_bytes = f.read()
148
+ job_id = str(uuid.uuid4())
149
+
150
+ with _jobs_lock:
151
+ _jobs[job_id] = {
152
+ "status": "running",
153
+ "result": None,
154
+ "error": None,
155
+ "started_at": time.time(),
156
+ "finished_at": None,
157
+ }
158
+
159
+ threading.Thread(target=_run_job, args=(job_id, file_bytes, cfg),
160
+ daemon=True).start()
161
+
162
+ print(f"[JOB {job_id[:8]}] Started by {session.get('username','?')}")
163
+ return jsonify({"status": "started", "job_id": job_id})
164
+
165
+
166
+ # ── Poll job status ────────────────────────────────────────────────────
167
+ @app.route("/job/<job_id>", methods=["GET"])
168
+ def job_status(job_id):
169
+ if "username" not in session:
170
+ return jsonify({"status": "error", "message": "Not logged in"}), 401
171
+
172
+ with _jobs_lock:
173
+ job = _jobs.get(job_id)
174
+
175
+ if not job:
176
+ return jsonify({"status": "error", "message": "Job not found"}), 404
177
+
178
+ if job["status"] == "running":
179
+ return jsonify({"status": "running",
180
+ "elapsed_seconds": round(time.time() - job["started_at"])})
181
+
182
+ if job["status"] == "error":
183
+ return jsonify({"status": "error", "message": job["error"]}), 500
184
+
185
+ return jsonify({"status": "done", "result": job["result"]})
186
+
187
+
188
+ # ── Latest forecast for viewer ─────────────────────────────────────────
189
+ @app.route("/forecast-result", methods=["GET"])
190
+ def forecast_result():
191
+ if "username" not in session:
192
+ return jsonify({"status": "error", "message": "Not logged in"}), 401
193
+ if _latest_forecast is None:
194
+ return jsonify({"status": "empty",
195
+ "message": "No forecast has been run yet. Ask the analyst to run the pipeline."})
196
+ return jsonify(_latest_forecast)
197
+
198
+
199
+ # =============================================================================
200
+ # BOOT
201
+ # =============================================================================
202
+ if __name__ == "__main__":
203
+ print("=" * 60)
204
+ print(" Genesis AI – Dabur Demand Intelligence Platform")
205
+ print(f" Running on port {PORT}")
206
+ print(f" analyst / {USERS['analyst']['password']} β†’ Full access")
207
+ print(f" viewer / {USERS['viewer']['password']} β†’ Output only")
208
+ print("=" * 60)
209
+ app.run(host="0.0.0.0", port=PORT, debug=False, threaded=True)
dabur_baseline_forecast_v2.py ADDED
@@ -0,0 +1,556 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =============================================================================
2
+ # DABUR BASELINE FORECAST PIPELINE v2 – Excel Input Compatible
3
+ # Format : Long format with Driver Level 3 as row identifier
4
+ # Scope : Multi Design Brand Γ— Multi Account (loops automatically)
5
+ # Target : Forecast Baseline Volume for next 12 months
6
+ # Input : Excel file (.xlsx) – reads "Base Sheet"
7
+ # Output : JSON to stdout (consumed by the web application)
8
+ # =============================================================================
9
+
10
+ import io, json, warnings, sys
11
+ import numpy as np
12
+ import pandas as pd
13
+
14
+ from statsforecast import StatsForecast
15
+ from statsforecast.models import (
16
+ AutoETS, AutoARIMA, AutoTheta,
17
+ MSTL, HoltWinters, SeasonalNaive, WindowAverage,
18
+ )
19
+
20
+ warnings.filterwarnings("ignore")
21
+ pd.set_option("display.float_format", "{:.4f}".format)
22
+
23
+
24
+ # =============================================================================
25
+ # SECTION 0 – CONFIGURATION
26
+ # =============================================================================
27
+
28
+ CONFIG = {
29
+ "channel_col" : "Channel",
30
+ "category_col" : "Category",
31
+ "division_col" : "Division",
32
+ "brand_group_col" : "Brand Group",
33
+ "account_col" : "Account",
34
+ "key_col" : "Key",
35
+ "design_brand_col": "Design Brand",
36
+ "date_col" : "DATE",
37
+ "fy_col" : "FY",
38
+ "volume_col" : "Volume",
39
+ "price_col" : "Price",
40
+ "value_col" : "Value",
41
+ "driver_col" : "Driver Level 3",
42
+
43
+ "baseline_label" : "Baseline",
44
+ "actual_label" : "Actual Volume",
45
+ "forecast_label" : "Baseline Forecast",
46
+
47
+ "freq" : "MS",
48
+ "season_length" : 12,
49
+ "forecast_horizon": 12,
50
+ "top_n_ensemble" : 3,
51
+ "cv_horizon" : 6,
52
+ "cv_windows" : 2,
53
+
54
+ # Minimum number of baseline data points required to run a forecast.
55
+ # Series with fewer than this many non-null, non-zero baseline months
56
+ # are skipped entirely – no CV, no forecast, reported in skipped_series.
57
+ "min_data_points" : 10,
58
+
59
+ # Excel sheet to read
60
+ "sheet_name" : "Base Sheet",
61
+ }
62
+
63
+ SERIES_KEYS = ["Channel", "Design Brand", "Account"]
64
+
65
+
66
+ # =============================================================================
67
+ # SECTION 1 – DATA INGESTION
68
+ # Accepts: file path (str), bytes (from web upload), or pd.DataFrame
69
+ # =============================================================================
70
+
71
+ def load_data(source, cfg: dict) -> pd.DataFrame:
72
+ if isinstance(source, pd.DataFrame):
73
+ return source.copy()
74
+ if isinstance(source, (str, bytes, io.BytesIO)):
75
+ df = pd.read_excel(source, sheet_name=cfg["sheet_name"])
76
+ else:
77
+ raise ValueError(f"Unsupported source type: {type(source)}")
78
+
79
+ # Ensure DATE is datetime
80
+ df[cfg["date_col"]] = pd.to_datetime(df[cfg["date_col"]], errors="coerce")
81
+ df[cfg["date_col"]] = df[cfg["date_col"]].dt.to_period("M").dt.to_timestamp()
82
+ return df
83
+
84
+
85
+ # =============================================================================
86
+ # SECTION 2 – PREPROCESSING (long β†’ wide, one row per series Γ— date)
87
+ # =============================================================================
88
+
89
+ def get_fy(date: pd.Timestamp) -> str:
90
+ """Dabur fiscal year: April–March."""
91
+ yr = date.year + 1 if date.month >= 4 else date.year
92
+ return f"FY{str(yr)[2:]}"
93
+
94
+
95
+ def preprocess_long_to_wide(df: pd.DataFrame, cfg: dict) -> pd.DataFrame:
96
+ dc = cfg["driver_col"]
97
+ vc = cfg["volume_col"]
98
+ pc = cfg["price_col"]
99
+ datec = cfg["date_col"]
100
+ bl = cfg["baseline_label"]
101
+
102
+ # Keep only Baseline and Actual Volume rows
103
+ df = df[df[dc].isin([bl, cfg["actual_label"]])].copy()
104
+
105
+ dim_cols = [
106
+ cfg["channel_col"], cfg["category_col"], cfg["division_col"],
107
+ cfg["brand_group_col"], cfg["account_col"], cfg["key_col"],
108
+ cfg["design_brand_col"],
109
+ ]
110
+
111
+ pivot = df.pivot_table(
112
+ index=dim_cols + [datec],
113
+ columns=dc,
114
+ values=vc,
115
+ aggfunc="first",
116
+ ).reset_index()
117
+
118
+ pivot.columns.name = None
119
+ rename = {datec: "ds"}
120
+ if bl in pivot.columns:
121
+ rename[bl] = "baseline"
122
+ if cfg["actual_label"] in pivot.columns:
123
+ rename[cfg["actual_label"]] = "actual"
124
+ pivot.rename(columns=rename, inplace=True)
125
+
126
+ # Carry price from Baseline rows
127
+ price_map = (
128
+ df[df[dc] == bl]
129
+ .set_index(dim_cols + [datec])[pc]
130
+ .rename("price")
131
+ )
132
+ pivot = pivot.join(price_map, on=dim_cols + ["ds"])
133
+
134
+ pivot.sort_values(dim_cols + ["ds"], inplace=True)
135
+ pivot.reset_index(drop=True, inplace=True)
136
+
137
+ pivot["unique_id"] = pivot[SERIES_KEYS].apply(
138
+ lambda r: " | ".join(r.astype(str)), axis=1
139
+ )
140
+
141
+ for col in ["baseline", "actual"]:
142
+ if col in pivot.columns:
143
+ neg = (pivot[col] < 0).sum()
144
+ if neg:
145
+ pivot[col] = pivot[col].clip(lower=0)
146
+
147
+ return pivot
148
+
149
+
150
+ def check_gaps(series_df: pd.DataFrame, uid: str) -> pd.DataFrame:
151
+ full_range = pd.date_range(series_df["ds"].min(), series_df["ds"].max(), freq="MS")
152
+ missing = full_range.difference(series_df["ds"])
153
+ if len(missing):
154
+ fill = pd.DataFrame({"ds": missing})
155
+ series_df = (
156
+ pd.concat([series_df, fill], ignore_index=True)
157
+ .sort_values("ds")
158
+ .ffill()
159
+ )
160
+ return series_df.reset_index(drop=True)
161
+
162
+
163
+ # =============================================================================
164
+ # SECTION 2b – PRE-FLIGHT VALIDATION
165
+ # Classifies every series as PASS or SKIP before any modelling begins.
166
+ # Returns two lists: valid_ids (will be forecast) and skipped (will not).
167
+ # =============================================================================
168
+
169
+ def validate_series(wide_df: pd.DataFrame, cfg: dict) -> tuple[list, list]:
170
+ """
171
+ Check each unique series for minimum data requirements.
172
+
173
+ Rules
174
+ -----
175
+ 1. Baseline column must exist and have at least cfg["min_data_points"]
176
+ non-null, non-zero values.
177
+ 2. Series date range must span at least cfg["min_data_points"] months
178
+ (guards against duplicate-date inflation).
179
+
180
+ Returns
181
+ -------
182
+ valid_ids : list of unique_id strings that pass – will be forecast
183
+ skipped : list of dicts with uid + reason – will be excluded
184
+ """
185
+ min_pts = cfg["min_data_points"]
186
+ valid_ids = []
187
+ skipped = []
188
+
189
+ all_ids = wide_df["unique_id"].unique().tolist()
190
+
191
+ print(f"\n{'='*70}")
192
+ print(f" PRE-FLIGHT VALIDATION ({len(all_ids)} series found in data)")
193
+ print(f" Minimum data points required : {min_pts} months of Baseline")
194
+ print(f"{'='*70}")
195
+
196
+ for uid in all_ids:
197
+ s_df = wide_df[wide_df["unique_id"] == uid]
198
+
199
+ # Count non-null, non-zero baseline points
200
+ if "baseline" not in s_df.columns:
201
+ reason = "SKIP – 'Baseline' column missing after pivot"
202
+ skipped.append({"uid": uid, "reason": reason, "n_points": 0})
203
+ print(f" [SKIP] {uid}")
204
+ print(f" Reason : {reason}")
205
+ continue
206
+
207
+ valid_pts = s_df["baseline"].replace(0, np.nan).dropna().shape[0]
208
+ date_span = s_df["ds"].nunique()
209
+
210
+ if valid_pts < min_pts:
211
+ reason = (
212
+ f"SKIP – only {valid_pts} valid baseline point(s), "
213
+ f"need β‰₯ {min_pts}. Date range spans {date_span} month(s)."
214
+ )
215
+ skipped.append({"uid": uid, "reason": reason, "n_points": valid_pts})
216
+ print(f" [SKIP] {uid}")
217
+ print(f" Points : {valid_pts} valid baseline months "
218
+ f"(threshold = {min_pts})")
219
+ print(f" Action : Series excluded from forecast")
220
+ else:
221
+ valid_ids.append(uid)
222
+ print(f" [PASS] {uid} ({valid_pts} pts)")
223
+
224
+ n_pass = len(valid_ids)
225
+ n_skip = len(skipped)
226
+ print(f"\n Summary : {n_pass} series will be forecast | "
227
+ f"{n_skip} series skipped")
228
+ if skipped:
229
+ print(f"\n Skipped series list:")
230
+ for s in skipped:
231
+ print(f" β€’ {s['uid']} [{s['n_points']} pts]")
232
+ print(f"{'='*70}\n")
233
+
234
+ return valid_ids, skipped
235
+
236
+
237
+ # =============================================================================
238
+ # SECTION 3 – MODEL BANK
239
+ # =============================================================================
240
+
241
+ def build_models(season_length: int) -> list:
242
+ return [
243
+ AutoETS(season_length=season_length),
244
+ AutoARIMA(season_length=season_length),
245
+ AutoTheta(season_length=season_length),
246
+ HoltWinters(season_length=season_length, error_type="A", alias="HWAdd"),
247
+ HoltWinters(season_length=season_length, error_type="M", alias="HWMult"),
248
+ MSTL(season_length=season_length, trend_forecaster=AutoTheta(), alias="MSTL_Theta"),
249
+ SeasonalNaive(season_length=season_length),
250
+ ]
251
+
252
+
253
+ # =============================================================================
254
+ # SECTION 4 – CROSS-VALIDATION
255
+ # =============================================================================
256
+
257
+ def cross_validate_series(sf_df: pd.DataFrame, cfg: dict) -> pd.DataFrame:
258
+ """
259
+ Rank models by CV MAE, auto-scaling settings for the available series length.
260
+
261
+ Statsforecast minimum-length formula: h + (n_windows - 1) * step + 1
262
+ The loop shrinks n_windows first, then h, until the series fits.
263
+ Falls back to a fixed default ranking if even h=2, nw=1 is too large.
264
+ """
265
+ DEFAULT_RANKING = [
266
+ "AutoETS", "AutoARIMA", "AutoTheta",
267
+ "HWAdd", "HWMult", "MSTL_Theta", "SeasonalNaive",
268
+ ]
269
+ n = len(sf_df)
270
+ h = cfg["cv_horizon"]
271
+ nw = cfg["cv_windows"]
272
+ step = 3
273
+ orig_h, orig_nw = h, nw
274
+
275
+ while True:
276
+ needed = h + (nw - 1) * step + 1
277
+ if n >= needed:
278
+ break
279
+ if nw > 1:
280
+ nw -= 1
281
+ elif h > 2:
282
+ h -= 1
283
+ else:
284
+ print(f" [CV] Series too short even for minimal CV "
285
+ f"(n={n}) – using default model ranking.")
286
+ return pd.DataFrame({
287
+ "Model": DEFAULT_RANKING,
288
+ "MAE": [float("nan")] * len(DEFAULT_RANKING),
289
+ })
290
+
291
+ if h != orig_h or nw != orig_nw:
292
+ print(f" [CV] Short series (n={n}): CV params reduced from "
293
+ f"h={orig_h}/nw={orig_nw} β†’ h={h}/nw={nw}")
294
+
295
+ sf = StatsForecast(
296
+ models=build_models(cfg["season_length"]),
297
+ freq=cfg["freq"],
298
+ fallback_model=WindowAverage(window_size=6),
299
+ n_jobs=1,
300
+ )
301
+ cv = sf.cross_validation(df=sf_df, h=h, n_windows=nw, step_size=step)
302
+ model_cols = [c for c in cv.columns if c not in ["unique_id", "ds", "cutoff", "y"]]
303
+ mae = {m: np.mean(np.abs(cv[m] - cv["y"])) for m in model_cols}
304
+ return (
305
+ pd.DataFrame.from_dict(mae, orient="index", columns=["MAE"])
306
+ .sort_values("MAE")
307
+ .reset_index()
308
+ .rename(columns={"index": "Model"})
309
+ )
310
+
311
+
312
+ # =============================================================================
313
+ # SECTION 5 – FORECAST GENERATION
314
+ # =============================================================================
315
+
316
+ def forecast_series(sf_df: pd.DataFrame, top_models: list, cfg: dict) -> pd.DataFrame:
317
+ sf = StatsForecast(
318
+ models=build_models(cfg["season_length"]),
319
+ freq=cfg["freq"],
320
+ fallback_model=WindowAverage(window_size=6),
321
+ n_jobs=1,
322
+ )
323
+ fcst = sf.forecast(df=sf_df, h=cfg["forecast_horizon"])
324
+ # Newer statsforecast versions may return ds as the index – normalise to column
325
+ if "ds" not in fcst.columns:
326
+ fcst = fcst.reset_index()
327
+ if "ds" not in fcst.columns:
328
+ for col in fcst.columns:
329
+ if col not in ["unique_id"] and pd.api.types.is_datetime64_any_dtype(fcst[col]):
330
+ fcst = fcst.rename(columns={col: "ds"})
331
+ break
332
+ if "ds" not in fcst.columns:
333
+ last_date = pd.to_datetime(sf_df["ds"].max())
334
+ fcst.insert(0, "ds",
335
+ pd.date_range(last_date, periods=cfg["forecast_horizon"] + 1,
336
+ freq=cfg["freq"])[1:])
337
+ fcst["ds"] = pd.to_datetime(fcst["ds"])
338
+ available = [m for m in top_models if m in fcst.columns]
339
+ fcst["ensemble_baseline_forecast"] = fcst[available].mean(axis=1).clip(lower=0)
340
+ return fcst
341
+
342
+
343
+ # =============================================================================
344
+ # SECTION 6 – OUTPUT FORMATTER
345
+ # =============================================================================
346
+
347
+ def build_output_rows(wide_df, fcst_df, dim_meta, cfg) -> pd.DataFrame:
348
+ last_price = wide_df["price"].dropna().tail(3).mean()
349
+ rows = []
350
+ for _, row in fcst_df.iterrows():
351
+ vol = round(row["ensemble_baseline_forecast"], 6)
352
+ price = round(last_price, 7)
353
+ value = round(vol * price, 3)
354
+ rows.append({
355
+ cfg["channel_col"] : dim_meta[cfg["channel_col"]],
356
+ cfg["category_col"] : dim_meta[cfg["category_col"]],
357
+ cfg["division_col"] : dim_meta[cfg["division_col"]],
358
+ cfg["brand_group_col"] : dim_meta[cfg["brand_group_col"]],
359
+ cfg["account_col"] : dim_meta[cfg["account_col"]],
360
+ cfg["key_col"] : dim_meta[cfg["key_col"]],
361
+ cfg["design_brand_col"]: dim_meta[cfg["design_brand_col"]],
362
+ cfg["date_col"] : row["ds"].strftime("%Y-%m-%d"),
363
+ cfg["fy_col"] : get_fy(row["ds"]),
364
+ cfg["volume_col"] : vol,
365
+ cfg["price_col"] : price,
366
+ cfg["value_col"] : value,
367
+ cfg["driver_col"] : cfg["forecast_label"],
368
+ })
369
+ return pd.DataFrame(rows)
370
+
371
+
372
+ # =============================================================================
373
+ # SECTION 7 – MAIN PIPELINE (returns JSON to stdout)
374
+ # =============================================================================
375
+
376
+ def run_pipeline(source, cfg: dict = CONFIG) -> dict:
377
+
378
+ # ── Step 1: Load ────────────────────────────────────────────────────
379
+ print("\n[STEP 1/5] Loading data...")
380
+ raw_df = load_data(source, cfg)
381
+ print(f" Rows loaded : {len(raw_df):,}")
382
+ print(f" Columns : {raw_df.shape[1]}")
383
+ print(f" Date range : {raw_df[cfg['date_col']].min().date()} "
384
+ f"β†’ {raw_df[cfg['date_col']].max().date()}")
385
+
386
+ # ── Step 2: Preprocess ──────────────────────────────────────────────
387
+ print("\n[STEP 2/5] Preprocessing (long β†’ wide pivot)...")
388
+ wide_df = preprocess_long_to_wide(raw_df, cfg)
389
+ total_series = wide_df["unique_id"].nunique()
390
+ print(f" Unique series found : {total_series} "
391
+ f"(Channel Γ— Design Brand Γ— Account)")
392
+
393
+ # ── Step 3: Validate ────────────────────────────────────────────────
394
+ print("\n[STEP 3/5] Running pre-flight validation...")
395
+ valid_ids, skipped_series = validate_series(wide_df, cfg)
396
+
397
+ if not valid_ids:
398
+ msg = ("All series were skipped due to insufficient data. "
399
+ f"Minimum required: {cfg['min_data_points']} data points.")
400
+ print(f"\n[ERROR] {msg}")
401
+ output = {
402
+ "status" : "error",
403
+ "message" : msg,
404
+ "skipped_series": skipped_series,
405
+ }
406
+ print(json.dumps(output))
407
+ return output
408
+
409
+ # ── Step 4: Model + Forecast ────────────────────────────────────────
410
+ print(f"\n[STEP 4/5] Forecasting {len(valid_ids)} series "
411
+ f"(horizon = {cfg['forecast_horizon']} months)...")
412
+
413
+ all_forecast_rows = []
414
+ mae_summary = []
415
+ series_results = []
416
+
417
+ for i, uid in enumerate(valid_ids, 1):
418
+ print(f"\n [{i:>3}/{len(valid_ids)}] {uid}")
419
+
420
+ s_df = wide_df[wide_df["unique_id"] == uid].copy()
421
+ s_df = check_gaps(s_df, uid)
422
+ n_pts = s_df["baseline"].replace(0, np.nan).dropna().shape[0]
423
+ print(f" Data : {n_pts} valid baseline months "
424
+ f"({s_df['ds'].min().strftime('%b %Y')} – "
425
+ f"{s_df['ds'].max().strftime('%b %Y')})")
426
+
427
+ sf_input = s_df[["unique_id", "ds", "baseline"]].rename(columns={"baseline": "y"})
428
+
429
+ # Cross-validation
430
+ print(f" CV : ranking models by MAE...")
431
+ mae_df = cross_validate_series(sf_input, cfg)
432
+ top_models = mae_df["Model"].head(cfg["top_n_ensemble"]).tolist()
433
+ mae_df["series"] = uid
434
+ mae_summary.append(mae_df)
435
+ print(f" Ensemble: {' + '.join(top_models)}")
436
+
437
+ # Forecast
438
+ print(f" Forecast: generating {cfg['forecast_horizon']}-month baseline...")
439
+ fcst_df = forecast_series(sf_input, top_models, cfg)
440
+
441
+ dim_meta = s_df.iloc[0][[
442
+ cfg["channel_col"], cfg["category_col"], cfg["division_col"],
443
+ cfg["brand_group_col"], cfg["account_col"], cfg["key_col"],
444
+ cfg["design_brand_col"],
445
+ ]].to_dict()
446
+
447
+ out_rows = build_output_rows(s_df, fcst_df, dim_meta, cfg)
448
+ all_forecast_rows.append(out_rows)
449
+
450
+ total_vol = round(out_rows[cfg["volume_col"]].sum(), 2)
451
+ print(f" Result : Total forecast volume = {total_vol:,.2f}")
452
+
453
+ # Historical data for this series (Baseline + Actual)
454
+ hist_baseline = s_df[["ds", "baseline", "actual", "price"]].copy()
455
+ hist_baseline["ds"] = hist_baseline["ds"].dt.strftime("%Y-%m-%d")
456
+
457
+ series_results.append({
458
+ "uid" : uid,
459
+ "channel" : dim_meta[cfg["channel_col"]],
460
+ "design_brand" : dim_meta[cfg["design_brand_col"]],
461
+ "account" : dim_meta[cfg["account_col"]],
462
+ "top_models" : top_models,
463
+ "n_data_points" : n_pts,
464
+ "mae_table" : mae_df[["Model", "MAE"]].round(2).to_dict(orient="records"),
465
+ "history" : hist_baseline.rename(columns={
466
+ "ds": "date", "baseline": "baseline_vol",
467
+ "actual": "actual_vol", "price": "price"
468
+ }).to_dict(orient="records"),
469
+ "forecast" : [
470
+ {
471
+ "date" : r[cfg["date_col"]],
472
+ "fy" : r[cfg["fy_col"]],
473
+ "volume": round(r[cfg["volume_col"]], 2),
474
+ "price" : round(r[cfg["price_col"]], 4),
475
+ "value" : round(r[cfg["value_col"]], 0),
476
+ }
477
+ for _, r in out_rows.iterrows()
478
+ ],
479
+ "total_forecast_vol": total_vol,
480
+ })
481
+
482
+ # ── Step 5: Build output payload ────────────────────────────────────
483
+ print(f"\n[STEP 5/5] Building output payload...")
484
+
485
+ # Decomp data for Page 1 (all drivers, not just Baseline)
486
+ decomp_drivers = raw_df[~raw_df[cfg["driver_col"]].isin([
487
+ cfg["actual_label"], "Predicted Volume"
488
+ ])].copy()
489
+ decomp_drivers["DATE_str"] = decomp_drivers[cfg["date_col"]].dt.strftime("%Y-%m-%d")
490
+
491
+ decomp_records = decomp_drivers[[
492
+ cfg["channel_col"], cfg["design_brand_col"], cfg["account_col"],
493
+ "DATE_str", cfg["fy_col"],
494
+ "Driver Level 1", "Driver Level 2", cfg["driver_col"],
495
+ cfg["volume_col"], cfg["price_col"], cfg["value_col"],
496
+ ]].rename(columns={
497
+ cfg["channel_col"] : "channel",
498
+ cfg["design_brand_col"]: "design_brand",
499
+ cfg["account_col"] : "account",
500
+ "DATE_str" : "date",
501
+ cfg["fy_col"] : "fy",
502
+ cfg["driver_col"] : "driver_l3",
503
+ cfg["volume_col"] : "volume",
504
+ cfg["price_col"] : "price",
505
+ cfg["value_col"] : "value",
506
+ }).to_dict(orient="records")
507
+
508
+ # Actual volume for overlay
509
+ actual_records = raw_df[raw_df[cfg["driver_col"]] == cfg["actual_label"]].copy()
510
+ actual_records["DATE_str"] = actual_records[cfg["date_col"]].dt.strftime("%Y-%m-%d")
511
+ actual_out = actual_records[[
512
+ cfg["design_brand_col"], cfg["account_col"], "DATE_str", cfg["volume_col"]
513
+ ]].rename(columns={
514
+ cfg["design_brand_col"]: "design_brand",
515
+ cfg["account_col"] : "account",
516
+ "DATE_str" : "date",
517
+ cfg["volume_col"] : "actual_volume",
518
+ }).to_dict(orient="records")
519
+
520
+ # Final summary
521
+ total_fcst_vol = sum(r["total_forecast_vol"] for r in series_results)
522
+ print(f"\n{'='*70}")
523
+ print(f" PIPELINE COMPLETE")
524
+ print(f" Series forecast : {len(series_results)}")
525
+ print(f" Series skipped : {len(skipped_series)}")
526
+ print(f" Total forecast vol: {total_fcst_vol:,.2f}")
527
+ print(f"{'='*70}\n")
528
+
529
+ output = {
530
+ "status" : "success",
531
+ "n_series" : len(valid_ids),
532
+ "n_skipped" : len(skipped_series),
533
+ "series_ids" : valid_ids,
534
+ "skipped_series" : skipped_series,
535
+ "series" : series_results,
536
+ "decomp_data" : decomp_records,
537
+ "actual_data" : actual_out,
538
+ }
539
+
540
+ print(json.dumps(output))
541
+ return output
542
+
543
+
544
+ # =============================================================================
545
+ # ENTRY POINT – run with: python dabur_baseline_forecast_v2.py data.xlsx
546
+ # =============================================================================
547
+
548
+ if __name__ == "__main__":
549
+ src = sys.argv[1] if len(sys.argv) > 1 else None
550
+ if src is None:
551
+ print(json.dumps({
552
+ "status" : "error",
553
+ "message": "No input file provided. Usage: python dabur_baseline_forecast_v2.py <path_to_excel>"
554
+ }))
555
+ sys.exit(1)
556
+ run_pipeline(src)
dabur_demand_platform.html ADDED
@@ -0,0 +1,1273 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8"/>
5
+ <meta name="viewport" content="width=device-width,initial-scale=1.0"/>
6
+ <title>Dabur Β· Demand Intelligence Platform</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com"/>
8
+ <link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
9
+ <!-- Pyodide removed: pipeline now runs via local Flask server on port 5050 -->
10
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
11
+ <style>
12
+ *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
13
+ :root{
14
+ --bg:#F4F3EF;
15
+ --white:#FFFFFF;
16
+ --surface:#FAFAF8;
17
+ --border:#E2E0D8;
18
+ --border2:#C8C5BA;
19
+ --text:#1A1916;
20
+ --text2:#6B6860;
21
+ --text3:#A39E92;
22
+ --accent:#C84B31;
23
+ --accent-soft:#F5E6E2;
24
+ --green:#2D6A4F;
25
+ --green-soft:#E8F4EE;
26
+ --blue:#1D4E89;
27
+ --blue-soft:#E5EDF7;
28
+ --amber:#C77A06;
29
+ --amber-soft:#FEF3D8;
30
+ --teal:#1A7A6E;
31
+ --teal-soft:#E3F4F1;
32
+ --font:'DM Sans',sans-serif;
33
+ --mono:'DM Mono',monospace;
34
+ --r:8px;
35
+ --r2:12px;
36
+ --shadow:0 1px 3px rgba(0,0,0,0.08),0 1px 2px rgba(0,0,0,0.04);
37
+ --shadow2:0 4px 16px rgba(0,0,0,0.1);
38
+ }
39
+ body{background:var(--bg);color:var(--text);font-family:var(--font);min-height:100vh;font-size:14px;line-height:1.5}
40
+
41
+ /* ── TOPBAR ── */
42
+ .topbar{height:56px;background:var(--white);border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;padding:0 24px;position:sticky;top:0;z-index:200;box-shadow:var(--shadow)}
43
+ .topbar-brand{display:flex;align-items:center;gap:10px}
44
+ .brand-logo{width:32px;height:32px;background:var(--accent);border-radius:6px;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:600;font-size:13px;letter-spacing:0.02em}
45
+ .brand-name{font-size:15px;font-weight:600;letter-spacing:-0.01em}
46
+ .brand-sub{font-size:11px;color:var(--text3);font-family:var(--mono)}
47
+ .topbar-nav{display:flex;gap:2px}
48
+ .nav-item{padding:6px 16px;border-radius:6px;font-size:13px;font-weight:500;cursor:pointer;border:none;background:none;color:var(--text2);transition:all 0.15s;display:flex;align-items:center;gap:6px}
49
+ .nav-item:hover{background:var(--bg);color:var(--text)}
50
+ .nav-item.active{background:var(--accent-soft);color:var(--accent)}
51
+ .nav-badge{background:var(--accent);color:#fff;border-radius:99px;padding:1px 6px;font-size:10px;font-family:var(--mono)}
52
+ .topbar-right{display:flex;align-items:center;gap:8px}
53
+ .status-chip{display:flex;align-items:center;gap:6px;padding:4px 10px;border-radius:99px;border:1px solid var(--border);font-size:12px;font-family:var(--mono);color:var(--text3)}
54
+ .status-dot{width:6px;height:6px;border-radius:50%}
55
+ .sd-loading{background:var(--amber);animation:blink 1s infinite}
56
+ .sd-ready{background:var(--green)}
57
+ .sd-error{background:var(--accent)}
58
+ @keyframes blink{0%,100%{opacity:1}50%{opacity:0.3}}
59
+
60
+ /* ── PAGES ── */
61
+ .page{display:none;min-height:calc(100vh - 56px);padding:24px}
62
+ .page.active{display:block}
63
+
64
+ /* ── SECTION HEADER ── */
65
+ .section-header{margin-bottom:20px}
66
+ .section-header h1{font-size:20px;font-weight:600;letter-spacing:-0.02em;margin-bottom:4px}
67
+ .section-header p{font-size:13px;color:var(--text2)}
68
+
69
+ /* ── CARDS ── */
70
+ .card{background:var(--white);border:1px solid var(--border);border-radius:var(--r2);box-shadow:var(--shadow)}
71
+ .card-header{padding:14px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between}
72
+ .card-title{font-size:13px;font-weight:600;color:var(--text)}
73
+ .card-body{padding:20px}
74
+
75
+ /* ── GRID ── */
76
+ .grid-2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
77
+ .grid-3{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}
78
+ .grid-4{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}
79
+
80
+ /* ── METRIC CARDS ── */
81
+ .metric{background:var(--white);border:1px solid var(--border);border-radius:var(--r2);padding:16px 20px}
82
+ .metric-label{font-size:11px;color:var(--text3);font-weight:500;text-transform:uppercase;letter-spacing:0.06em;margin-bottom:6px}
83
+ .metric-value{font-size:26px;font-weight:600;font-family:var(--mono);letter-spacing:-0.02em;line-height:1}
84
+ .metric-sub{font-size:11px;color:var(--text3);margin-top:4px;font-family:var(--mono)}
85
+ .metric.accent .metric-value{color:var(--accent)}
86
+ .metric.green .metric-value{color:var(--green)}
87
+ .metric.blue .metric-value{color:var(--blue)}
88
+ .metric.amber .metric-value{color:var(--amber)}
89
+
90
+ /* ── FILE UPLOAD ZONE ── */
91
+ .upload-zone{border:1.5px dashed var(--border2);border-radius:var(--r2);padding:32px;text-align:center;cursor:pointer;transition:all 0.2s;position:relative;background:var(--surface)}
92
+ .upload-zone:hover,.upload-zone.drag{border-color:var(--accent);background:var(--accent-soft)}
93
+ .upload-zone input{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%}
94
+ .upload-icon{font-size:28px;margin-bottom:10px;display:block}
95
+ .upload-title{font-size:14px;font-weight:500;margin-bottom:4px}
96
+ .upload-hint{font-size:12px;color:var(--text3);font-family:var(--mono)}
97
+ .file-loaded{display:flex;align-items:center;gap:10px;padding:10px 14px;background:var(--green-soft);border:1px solid #C3E6D4;border-radius:var(--r);margin-top:10px;font-size:12px;font-family:var(--mono);color:var(--green)}
98
+ .file-dot{width:8px;height:8px;border-radius:50%;background:var(--green);flex-shrink:0}
99
+
100
+ /* ── BUTTONS ── */
101
+ .btn{display:inline-flex;align-items:center;gap:6px;padding:8px 16px;border-radius:var(--r);font-size:13px;font-weight:500;cursor:pointer;border:none;font-family:var(--font);transition:all 0.15s}
102
+ .btn-primary{background:var(--accent);color:#fff}
103
+ .btn-primary:hover{background:#b04028}
104
+ .btn-primary:active{transform:scale(0.98)}
105
+ .btn-primary:disabled{background:var(--border2);color:var(--text3);cursor:not-allowed;transform:none}
106
+ .btn-outline{background:#fff;color:var(--text);border:1px solid var(--border2)}
107
+ .btn-outline:hover{background:var(--bg);border-color:var(--text3)}
108
+ .btn-sm{padding:5px 12px;font-size:12px}
109
+ .btn-run{background:var(--green);color:#fff;font-weight:600;padding:10px 24px}
110
+ .btn-run:hover{background:#22573e}
111
+ .btn-run:disabled{background:var(--border2);color:var(--text3);cursor:not-allowed}
112
+
113
+ /* ── FILTERS ── */
114
+ .filter-bar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:16px}
115
+ .filter-label{font-size:12px;font-weight:500;color:var(--text3)}
116
+ select{border:1px solid var(--border);border-radius:var(--r);padding:6px 10px;font-size:12px;font-family:var(--font);color:var(--text);background:var(--white);cursor:pointer;outline:none}
117
+ select:focus{border-color:var(--accent)}
118
+
119
+ /* ── CHART CANVAS ── */
120
+ .chart-wrap{position:relative;width:100%}
121
+ .chart-wrap canvas{width:100%!important}
122
+
123
+ /* ── STACKED BAR DECOMP ── */
124
+ .decomp-legend{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:12px}
125
+ .legend-item{display:flex;align-items:center;gap:5px;font-size:11px;color:var(--text2)}
126
+ .legend-dot{width:10px;height:10px;border-radius:2px;flex-shrink:0}
127
+
128
+ /* ── CODE EDITOR ── */
129
+ .editor-wrap{background:#1E1F23;border-radius:var(--r2);overflow:hidden}
130
+ .editor-bar{background:#2A2B30;padding:8px 16px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #363840}
131
+ .editor-filename{font-size:12px;font-family:var(--mono);color:#9DA3B0}
132
+ .editor-actions{display:flex;gap:6px}
133
+ .editor-btn{padding:4px 12px;border-radius:5px;font-size:11px;font-family:var(--font);font-weight:600;cursor:pointer;border:none;transition:all 0.15s}
134
+ .btn-code-run{background:#2D6A4F;color:#fff}
135
+ .btn-code-run:hover{background:#3a8562}
136
+ .btn-code-run:disabled{background:#363840;color:#555;cursor:not-allowed}
137
+ .btn-code-ghost{background:#363840;color:#9DA3B0}
138
+ .btn-code-ghost:hover{background:#404248;color:#E0E4EE}
139
+ textarea.editor{background:#1E1F23;color:#E0E4EE;font-family:var(--mono);font-size:12.5px;line-height:1.7;border:none;outline:none;resize:none;width:100%;padding:16px;tab-size:4;min-height:360px}
140
+ textarea.editor::selection{background:rgba(200,75,49,0.25)}
141
+
142
+ /* ── CONSOLE ── */
143
+ .console{background:#0F1012;border-radius:0 0 var(--r2) var(--r2);max-height:180px;overflow-y:auto;padding:12px 16px;font-family:var(--mono);font-size:11.5px;line-height:1.8}
144
+ .c-dim{color:#4A4F5C}
145
+ .c-info{color:#4FA3C8}
146
+ .c-ok{color:#7AC59A}
147
+ .c-warn{color:#D4A853}
148
+ .c-err{color:#E06060}
149
+
150
+ /* ── TABLES ── */
151
+ .data-table{width:100%;border-collapse:collapse;font-size:12px}
152
+ .data-table th{font-size:10px;font-weight:600;color:var(--text3);text-transform:uppercase;letter-spacing:0.06em;padding:8px 12px;border-bottom:1px solid var(--border);text-align:left;background:var(--surface)}
153
+ .data-table td{padding:9px 12px;border-bottom:1px solid var(--border);color:var(--text);font-family:var(--mono);font-size:12px}
154
+ .data-table tr:last-child td{border-bottom:none}
155
+ .data-table tr:hover td{background:var(--bg)}
156
+
157
+ /* ── PILLS / BADGES ── */
158
+ .pill{display:inline-flex;align-items:center;padding:2px 8px;border-radius:99px;font-size:10px;font-weight:600;font-family:var(--mono)}
159
+ .pill-green{background:var(--green-soft);color:var(--green)}
160
+ .pill-blue{background:var(--blue-soft);color:var(--blue)}
161
+ .pill-amber{background:var(--amber-soft);color:var(--amber)}
162
+ .pill-red{background:var(--accent-soft);color:var(--accent)}
163
+ .pill-teal{background:var(--teal-soft);color:var(--teal)}
164
+
165
+ /* ── PROGRESS ── */
166
+ .progress-bar{height:6px;background:var(--bg);border-radius:3px;overflow:hidden}
167
+ .progress-fill{height:100%;border-radius:3px;transition:width 0.5s ease}
168
+
169
+ /* ── SERIES TABS ── */
170
+ .series-tabs{display:flex;gap:4px;margin-bottom:16px}
171
+ .stab{padding:6px 14px;border-radius:6px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid var(--border);background:var(--white);color:var(--text2);transition:all 0.15s}
172
+ .stab:hover{border-color:var(--text3)}
173
+ .stab.active{background:var(--accent);color:#fff;border-color:var(--accent)}
174
+
175
+ /* ── EMPTY STATE ── */
176
+ .empty{text-align:center;padding:48px 24px;color:var(--text3)}
177
+ .empty-icon{font-size:36px;margin-bottom:12px;display:block;opacity:0.5}
178
+ .empty h3{font-size:15px;font-weight:600;color:var(--text2);margin-bottom:6px}
179
+ .empty p{font-size:12px;line-height:1.6}
180
+
181
+ /* ── SECTION DIVIDER ── */
182
+ .divider{height:1px;background:var(--border);margin:20px 0}
183
+
184
+ /* ── FORECAST OUTPUT ── */
185
+ .forecast-summary{display:grid;grid-template-columns:repeat(5,1fr);gap:12px;margin-bottom:20px}
186
+ .model-badge{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:4px;font-size:11px;font-family:var(--mono);background:var(--blue-soft);color:var(--blue);margin-right:4px}
187
+
188
+ /* ── PAGE 4 PLACEHOLDER ── */
189
+ .coming-soon{min-height:60vh;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:16px;text-align:center}
190
+ .coming-soon .big-icon{font-size:60px;opacity:0.15}
191
+ .coming-soon h2{font-size:22px;font-weight:600;color:var(--text2)}
192
+ .coming-soon p{font-size:14px;color:var(--text3);max-width:400px;line-height:1.7}
193
+ .coming-soon .placeholder-boxes{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;width:100%;max-width:700px;margin-top:20px}
194
+ .ph-box{background:var(--white);border:1.5px dashed var(--border2);border-radius:var(--r2);padding:24px;text-align:center}
195
+ .ph-box .ph-icon{font-size:24px;margin-bottom:8px;opacity:0.3}
196
+ .ph-box p{font-size:12px;color:var(--text3)}
197
+
198
+ /* ── SCROLLBARS ── */
199
+ ::-webkit-scrollbar{width:5px;height:5px}
200
+ ::-webkit-scrollbar-track{background:transparent}
201
+ ::-webkit-scrollbar-thumb{background:var(--border2);border-radius:3px}
202
+
203
+ /* ── RESPONSIVE ── */
204
+ @media(max-width:900px){
205
+ .grid-4{grid-template-columns:repeat(2,1fr)}
206
+ .forecast-summary{grid-template-columns:repeat(3,1fr)}
207
+ }
208
+
209
+ /* ── LOGIN SCREEN ── */
210
+ #login-screen{position:fixed;inset:0;z-index:1000;background:var(--bg);display:flex;align-items:center;justify-content:center}
211
+ #login-screen.hidden{display:none}
212
+ .login-box{background:var(--white);border:1px solid var(--border);border-radius:16px;box-shadow:var(--shadow2);padding:40px;width:380px}
213
+ .login-logo{width:48px;height:48px;background:var(--accent);border-radius:10px;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:700;font-size:18px;margin:0 auto 16px}
214
+ .login-title{text-align:center;font-size:20px;font-weight:600;letter-spacing:-0.02em;margin-bottom:4px}
215
+ .login-sub{text-align:center;font-size:12px;color:var(--text3);font-family:var(--mono);margin-bottom:28px}
216
+ .login-field{margin-bottom:14px}
217
+ .login-label{font-size:11px;font-weight:600;color:var(--text3);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:5px;display:block}
218
+ .login-input{width:100%;border:1px solid var(--border);border-radius:var(--r);padding:9px 12px;font-size:13px;font-family:var(--font);color:var(--text);background:var(--white);outline:none;transition:border 0.15s}
219
+ .login-input:focus{border-color:var(--accent)}
220
+ .login-btn{width:100%;padding:10px;border-radius:var(--r);background:var(--accent);color:#fff;font-size:14px;font-weight:600;border:none;cursor:pointer;font-family:var(--font);transition:background 0.15s;margin-top:4px}
221
+ .login-btn:hover{background:#b04028}
222
+ .login-btn:disabled{background:var(--border2);cursor:not-allowed}
223
+ .login-error{color:var(--accent);font-size:12px;text-align:center;margin-top:10px;min-height:18px;font-family:var(--mono)}
224
+
225
+ /* ── USER CHIP in topbar ── */
226
+ .user-chip{display:flex;align-items:center;gap:8px;padding:4px 12px 4px 8px;border-radius:99px;border:1px solid var(--border);background:var(--white);font-size:12px;cursor:pointer;transition:background 0.15s}
227
+ .user-chip:hover{background:var(--bg)}
228
+ .user-avatar{width:22px;height:22px;border-radius:50%;background:var(--accent);color:#fff;font-size:10px;font-weight:700;display:flex;align-items:center;justify-content:center}
229
+ .user-name{font-weight:500;color:var(--text)}
230
+ .user-role{font-family:var(--mono);color:var(--text3);font-size:10px}
231
+
232
+ </style>
233
+ </head>
234
+ <body>
235
+
236
+ <!-- ══════════════════════════════════════════════════════════════════
237
+ LOGIN SCREEN
238
+ ══════════════════════════════════════════════════════════════════ -->
239
+ <div id="login-screen">
240
+ <div class="login-box">
241
+ <div class="login-logo">DB</div>
242
+ <div class="login-title">Demand Intelligence Platform</div>
243
+ <div class="login-sub">Dabur Β· MMM Decomp & Forecasting</div>
244
+ <div class="login-field">
245
+ <label class="login-label">Username</label>
246
+ <input class="login-input" id="login-user" type="text" placeholder="analyst or viewer" autocomplete="username"/>
247
+ </div>
248
+ <div class="login-field">
249
+ <label class="login-label">Password</label>
250
+ <input class="login-input" id="login-pass" type="password" placeholder="β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’" autocomplete="current-password"/>
251
+ </div>
252
+ <button class="login-btn" id="login-btn" onclick="doLogin()">Sign In</button>
253
+ <div class="login-error" id="login-error"></div>
254
+ </div>
255
+ </div>
256
+
257
+
258
+ <!-- ══════════════════════════════════════════════════════════════════
259
+ TOP BAR
260
+ ══════════════════════════════════════════════════════════════════ -->
261
+ <div class="topbar">
262
+ <div class="topbar-brand">
263
+ <div class="brand-logo">DB</div>
264
+ <div>
265
+ <div class="brand-name">Demand Intelligence Platform</div>
266
+ <div class="brand-sub">Dabur Β· MMM Decomp & Forecasting</div>
267
+ </div>
268
+ </div>
269
+ <div class="topbar-nav" id="topbar-nav">
270
+ <button class="nav-item active" id="nav-p1" onclick="goPage('p1',this)">πŸ“Š Decomposition</button>
271
+ <button class="nav-item" id="nav-p2" onclick="goPage('p2',this)">βš™οΈ Forecast Engine</button>
272
+ <button class="nav-item" id="nav-p3" onclick="goPage('p3',this)">πŸ“ˆ Forecast Output</button>
273
+ <button class="nav-item" id="nav-p4" onclick="goPage('p4',this)">πŸ—ΊοΈ Full Demand <span class="nav-badge">Soon</span></button>
274
+ </div>
275
+ <div class="topbar-right" style="gap:10px">
276
+ <div class="status-chip" id="status-chip" style="display:none">
277
+ <div class="status-dot sd-loading" id="sdot"></div>
278
+ <span id="stext">loading…</span>
279
+ </div>
280
+ <div class="user-chip" id="user-chip" style="display:none" onclick="doLogout()">
281
+ <div class="user-avatar" id="user-avatar">?</div>
282
+ <div>
283
+ <div class="user-name" id="user-name-label">β€”</div>
284
+ <div class="user-role" id="user-role-label">β€”</div>
285
+ </div>
286
+ <span style="font-size:10px;color:var(--text3);margin-left:2px">⏏</span>
287
+ </div>
288
+ </div>
289
+ </div>
290
+
291
+
292
+ <!-- ══════════════════════════════════════════════════════════════════
293
+ PAGE 1 – DECOMPOSED VOLUME VISUALISATION
294
+ ══════════════════════════════════════════════════════════════════ -->
295
+ <div class="page active" id="p1">
296
+ <div class="section-header">
297
+ <h1>Volume Decomposition by Drivers</h1>
298
+ <p>Historical baseline and driver contribution breakdown from the input decomp file</p>
299
+ </div>
300
+
301
+ <!-- File upload -->
302
+ <div class="card" style="margin-bottom:16px">
303
+ <div class="card-header"><span class="card-title">Upload Decomposition File</span><span class="pill pill-amber">Required to start</span></div>
304
+ <div class="card-body">
305
+ <div class="grid-2" style="gap:20px;align-items:start">
306
+ <div>
307
+ <div class="upload-zone" id="decomp-upload-zone">
308
+ <input type="file" id="decomp-file-input" accept=".xlsx,.csv"/>
309
+ <span class="upload-icon">πŸ“‚</span>
310
+ <div class="upload-title">Drop your decomposition file here</div>
311
+ <div class="upload-hint">.xlsx (Base Sheet) Β· .csv supported</div>
312
+ </div>
313
+ <div id="decomp-file-loaded" style="display:none" class="file-loaded">
314
+ <div class="file-dot"></div><span id="decomp-file-name">β€”</span>
315
+ </div>
316
+ </div>
317
+ <div>
318
+ <p style="font-size:12px;color:var(--text2);line-height:1.8;margin-bottom:12px">
319
+ Expected sheet: <span style="font-family:var(--mono);color:var(--text)">Base Sheet</span><br>
320
+ Key columns: <span style="font-family:var(--mono);color:var(--text)">Channel, Design Brand, Account, DATE, Volume, Driver Level 3</span><br>
321
+ Drivers read: <span style="font-family:var(--mono);color:var(--text)">Baseline, Actual Volume, and all incremental drivers</span>
322
+ </p>
323
+ <button class="btn btn-primary" id="load-decomp-btn" onclick="loadDecompFile()" disabled>Load &amp; Visualise</button>
324
+ </div>
325
+ </div>
326
+ </div>
327
+ </div>
328
+
329
+ <!-- Filters -->
330
+ <div class="filter-bar" id="decomp-filters" style="display:none">
331
+ <span class="filter-label">Filter:</span>
332
+ <select id="f-channel" onchange="renderDecomp()"><option value="ALL">All Channels</option></select>
333
+ <select id="f-brand" onchange="renderDecomp()"><option value="ALL">All Brands</option></select>
334
+ <select id="f-account" onchange="renderDecomp()"><option value="ALL">All Accounts</option></select>
335
+ <select id="f-fy" onchange="renderDecomp()"><option value="ALL">All FY</option></select>
336
+ </div>
337
+
338
+ <!-- KPI row -->
339
+ <div class="grid-4" id="decomp-kpis" style="display:none;margin-bottom:16px">
340
+ <div class="metric accent"><div class="metric-label">Total Actual Volume</div><div class="metric-value" id="kpi-actual">β€”</div><div class="metric-sub">all periods</div></div>
341
+ <div class="metric green"><div class="metric-label">Total Baseline Vol</div><div class="metric-value" id="kpi-baseline">β€”</div><div class="metric-sub">structural demand</div></div>
342
+ <div class="metric blue"><div class="metric-label">Incremental Vol</div><div class="metric-value" id="kpi-incr">β€”</div><div class="metric-sub">promo + visibility + price</div></div>
343
+ <div class="metric amber"><div class="metric-label">Baseline Share</div><div class="metric-value" id="kpi-bshare">β€”</div><div class="metric-sub">of actual volume</div></div>
344
+ </div>
345
+
346
+ <!-- Charts -->
347
+ <div id="decomp-charts" style="display:none">
348
+ <div class="grid-2" style="margin-bottom:16px">
349
+ <div class="card">
350
+ <div class="card-header"><span class="card-title">Stacked Driver Decomposition Β· Monthly</span></div>
351
+ <div class="card-body"><div class="chart-wrap" style="height:280px"><canvas id="stackedChart"></canvas></div></div>
352
+ </div>
353
+ <div class="card">
354
+ <div class="card-header"><span class="card-title">Actual vs Baseline Volume</span></div>
355
+ <div class="card-body"><div class="chart-wrap" style="height:280px"><canvas id="actualBaseChart"></canvas></div></div>
356
+ </div>
357
+ </div>
358
+ <div class="card" style="margin-bottom:16px">
359
+ <div class="card-header">
360
+ <span class="card-title">Driver Contribution Share</span>
361
+ <div id="decomp-legend" class="decomp-legend" style="margin-bottom:0"></div>
362
+ </div>
363
+ <div class="card-body"><div class="chart-wrap" style="height:220px"><canvas id="driverShareChart"></canvas></div></div>
364
+ </div>
365
+ <div class="card">
366
+ <div class="card-header"><span class="card-title">Driver Contribution Summary</span></div>
367
+ <div class="card-body" style="padding:0 0 8px">
368
+ <table class="data-table" id="driver-table"><thead><tr><th>Driver</th><th>Level 1</th><th>Level 2</th><th>Total Volume</th><th>Avg Monthly</th><th>Share</th></tr></thead><tbody id="driver-tbody"></tbody></table>
369
+ </div>
370
+ </div>
371
+ </div>
372
+
373
+ <div id="decomp-empty" class="empty">
374
+ <span class="empty-icon">πŸ“Š</span>
375
+ <h3>No data loaded</h3>
376
+ <p>Upload a decomposition Excel file above to see the driver visualisation</p>
377
+ </div>
378
+ </div>
379
+
380
+
381
+ <!-- ══════════════════════════════════════════════════════════════════
382
+ PAGE 2 – FORECAST ENGINE
383
+ ══════════════════════════════════════════════════════════════════ -->
384
+ <div class="page" id="p2">
385
+ <div class="section-header">
386
+ <h1>Baseline Forecast Engine</h1>
387
+ <p>Upload the forecasting script and data file Β· run via in-browser Python interpreter (Pyodide)</p>
388
+ </div>
389
+
390
+ <div class="grid-2" style="gap:16px;margin-bottom:16px;align-items:start">
391
+ <!-- Upload panel -->
392
+ <div>
393
+ <div class="card" style="margin-bottom:12px">
394
+ <div class="card-header"><span class="card-title">Forecast Script</span><span class="pill pill-blue">.py</span></div>
395
+ <div class="card-body">
396
+ <div class="upload-zone" id="script-upload-zone">
397
+ <input type="file" id="script-file-input" accept=".py,.ipynb"/>
398
+ <span class="upload-icon">πŸ“œ</span>
399
+ <div class="upload-title">Upload forecast script</div>
400
+ <div class="upload-hint">dabur_baseline_forecast_v2.py</div>
401
+ </div>
402
+ <div id="script-file-loaded" style="display:none" class="file-loaded">
403
+ <div class="file-dot"></div><span id="script-file-name">β€”</span>
404
+ </div>
405
+ </div>
406
+ </div>
407
+ <div class="card">
408
+ <div class="card-header"><span class="card-title">Decomp Input Data</span><span class="pill pill-amber">.xlsx</span></div>
409
+ <div class="card-body">
410
+ <div class="upload-zone" id="data-upload-zone">
411
+ <input type="file" id="data-file-input" accept=".xlsx,.csv"/>
412
+ <span class="upload-icon">πŸ“‘</span>
413
+ <div class="upload-title">Upload decomp Excel file</div>
414
+ <div class="upload-hint">Base Sheet Β· same file as Page 1</div>
415
+ </div>
416
+ <div id="data-file-loaded" style="display:none" class="file-loaded">
417
+ <div class="file-dot"></div><span id="data-file-name">β€”</span>
418
+ </div>
419
+ </div>
420
+ </div>
421
+ </div>
422
+
423
+ <!-- Config panel -->
424
+ <div class="card" style="height:100%">
425
+ <div class="card-header"><span class="card-title">Run Configuration</span></div>
426
+ <div class="card-body">
427
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:16px">
428
+ <div>
429
+ <div style="font-size:11px;font-weight:600;color:var(--text3);margin-bottom:4px;text-transform:uppercase;letter-spacing:0.06em">Forecast horizon (months)</div>
430
+ <input type="number" id="cfg-horizon" value="12" min="1" max="36" style="width:100%;border:1px solid var(--border);border-radius:var(--r);padding:7px 10px;font-size:13px;font-family:var(--mono)"/>
431
+ </div>
432
+ <div>
433
+ <div style="font-size:11px;font-weight:600;color:var(--text3);margin-bottom:4px;text-transform:uppercase;letter-spacing:0.06em">Season length</div>
434
+ <input type="number" id="cfg-season" value="12" min="1" max="52" style="width:100%;border:1px solid var(--border);border-radius:var(--r);padding:7px 10px;font-size:13px;font-family:var(--mono)"/>
435
+ </div>
436
+ <div>
437
+ <div style="font-size:11px;font-weight:600;color:var(--text3);margin-bottom:4px;text-transform:uppercase;letter-spacing:0.06em">CV horizon</div>
438
+ <input type="number" id="cfg-cvh" value="6" min="1" max="12" style="width:100%;border:1px solid var(--border);border-radius:var(--r);padding:7px 10px;font-size:13px;font-family:var(--mono)"/>
439
+ </div>
440
+ <div>
441
+ <div style="font-size:11px;font-weight:600;color:var(--text3);margin-bottom:4px;text-transform:uppercase;letter-spacing:0.06em">Top N ensemble</div>
442
+ <input type="number" id="cfg-topn" value="3" min="1" max="7" style="width:100%;border:1px solid var(--border);border-radius:var(--r);padding:7px 10px;font-size:13px;font-family:var(--mono)"/>
443
+ </div>
444
+ </div>
445
+ <div style="padding:10px 14px;background:var(--bg);border-radius:var(--r);font-size:12px;color:var(--text2);margin-bottom:16px;line-height:1.7">
446
+ Pipeline: AutoETS Β· AutoARIMA Β· AutoTheta Β· HoltWinters (Additive + Multiplicative) Β· MSTL+Theta Β· SeasonalNaive<br>
447
+ Ensemble: top-N models by cross-validation MAE Β· fallback: WindowAverage(6)
448
+ </div>
449
+ <button class="btn btn-run" id="run-pipeline-btn" onclick="runForecastPipeline()" disabled style="width:100%">β–Ά Run Baseline Forecast</button>
450
+ </div>
451
+ </div>
452
+ </div>
453
+
454
+ <!-- Editor -->
455
+ <div style="margin-bottom:16px">
456
+ <div class="editor-wrap">
457
+ <div class="editor-bar">
458
+ <span class="editor-filename" id="p2-editor-filename">no script loaded</span>
459
+ <div class="editor-actions">
460
+ <button class="editor-btn btn-code-ghost" onclick="loadDemoScript()">Load demo script</button>
461
+ <button class="editor-btn btn-code-ghost" onclick="document.getElementById('editor-code').value=''">Clear</button>
462
+ <button class="editor-btn btn-code-run" id="run-editor-btn" onclick="runForecastPipeline()" disabled>β–Ά Run</button>
463
+ </div>
464
+ </div>
465
+ <textarea class="editor" id="editor-code" spellcheck="false" placeholder="Upload a script or click 'Load demo script'…"></textarea>
466
+ <div class="console" id="p2-console"><div class="c-dim">Β» Pyodide interpreter initialising…</div></div>
467
+ </div>
468
+ </div>
469
+ </div>
470
+
471
+
472
+ <!-- ══════════════════════════════════════════════════════════════════
473
+ PAGE 3 – FORECAST OUTPUT
474
+ ══════════════════════════════════════════════════════════════════ -->
475
+ <div class="page" id="p3">
476
+ <div class="section-header">
477
+ <h1>Baseline Forecast Output</h1>
478
+ <p>Results from the last pipeline run Β· 12-month baseline projection per series</p>
479
+ </div>
480
+
481
+ <div id="p3-empty" class="empty">
482
+ <span class="empty-icon">πŸ“ˆ</span>
483
+ <h3>No forecast run yet</h3>
484
+ <p>Go to the Forecast Engine tab, upload your files and run the pipeline. Results will appear here automatically.</p>
485
+ </div>
486
+
487
+ <div id="p3-content" style="display:none">
488
+ <!-- Top KPIs -->
489
+ <div class="forecast-summary" id="p3-kpis" style="margin-bottom:16px"></div>
490
+
491
+ <!-- Series selector -->
492
+ <div class="series-tabs" id="p3-series-tabs"></div>
493
+
494
+ <!-- Per-series view -->
495
+ <div id="p3-series-view">
496
+ <div class="grid-2" style="margin-bottom:16px">
497
+ <div class="card">
498
+ <div class="card-header">
499
+ <span class="card-title" id="p3-chart-title">Baseline Forecast Β· 12-Month Horizon</span>
500
+ <div id="p3-top-models" style="display:flex;gap:4px;flex-wrap:wrap"></div>
501
+ </div>
502
+ <div class="card-body"><div class="chart-wrap" style="height:280px"><canvas id="forecastChart"></canvas></div></div>
503
+ </div>
504
+ <div class="card">
505
+ <div class="card-header"><span class="card-title">Model Accuracy (CV MAE)</span></div>
506
+ <div class="card-body" style="padding:0">
507
+ <table class="data-table" id="mae-table">
508
+ <thead><tr><th>Model</th><th>MAE</th><th>Rank</th></tr></thead>
509
+ <tbody id="mae-tbody"></tbody>
510
+ </table>
511
+ </div>
512
+ </div>
513
+ </div>
514
+ <div class="card" style="margin-bottom:16px">
515
+ <div class="card-header">
516
+ <span class="card-title">12-Month Forecast Table</span>
517
+ <button class="btn btn-outline btn-sm" onclick="downloadForecast()">⬇ Download CSV</button>
518
+ </div>
519
+ <div class="card-body" style="padding:0">
520
+ <table class="data-table" id="forecast-table">
521
+ <thead><tr><th>Date</th><th>FY</th><th>Forecast Volume</th><th>Price</th><th>Forecast Value</th><th>MoM Ξ”%</th></tr></thead>
522
+ <tbody id="forecast-tbody"></tbody>
523
+ </table>
524
+ </div>
525
+ </div>
526
+ </div>
527
+ </div>
528
+ </div>
529
+
530
+
531
+ <!-- ══════════════════════════════════════════════════════════════════
532
+ PAGE 4 – FULL DEMAND FORECAST (PLACEHOLDER)
533
+ ══════════════════════════════════════════════════════════════════ -->
534
+ <div class="page" id="p4">
535
+ <div class="coming-soon">
536
+ <div class="big-icon">πŸ—ΊοΈ</div>
537
+ <h2>Full Demand Forecast</h2>
538
+ <p>This module will combine baseline forecast with plan data (CP spend, visibility, price) to produce total demand forecast across all channels and accounts.</p>
539
+ <div class="placeholder-boxes">
540
+ <div class="ph-box"><div class="ph-icon">πŸ“‹</div><p>Plan Data Upload<br><span style="color:var(--text3);font-size:10px">CP Β· Visibility Β· Price Plans</span></p></div>
541
+ <div class="ph-box"><div class="ph-icon">⚑</div><p>Incremental Modelling<br><span style="color:var(--text3);font-size:10px">Promo uplift · ROI attribution</span></p></div>
542
+ <div class="ph-box"><div class="ph-icon">πŸ“Š</div><p>Full Demand Output<br><span style="color:var(--text3);font-size:10px">Total Volume Β· Value Β· Share</span></p></div>
543
+ </div>
544
+ <p style="margin-top:24px;font-size:12px;background:var(--white);padding:10px 20px;border-radius:var(--r2);border:1px solid var(--border);color:var(--text3);font-family:var(--mono)">Status: Pending plan data integration Β· ETA: Next sprint</p>
545
+ </div>
546
+ </div>
547
+
548
+
549
+ <!-- ═══════════════════════════ JAVASCRIPT ═══════════════════════════ -->
550
+ <script>
551
+ // ─── GLOBAL STATE ─────────────────────────────────────────────────────
552
+ const SERVER_URL = "";
553
+ let decompData = null, actualData = null;
554
+ let forecastResult = null;
555
+ let activeSeriesIdx = 0;
556
+ let dataFile = null;
557
+ let chartInstances = {};
558
+ let currentUser = null; // { username, role }
559
+
560
+ const DRIVER_COLORS = {
561
+ "Baseline" : "#2D6A4F",
562
+ "Consumer Promotions" : "#C84B31",
563
+ "Visibility" : "#1D4E89",
564
+ "Rainfall" : "#4A90D9",
565
+ "Own Price" : "#C77A06",
566
+ "Baseline Forecast" : "#7B4EA0",
567
+ };
568
+ const DEFAULT_COLORS = ["#E76F51","#264653","#2A9D8F","#E9C46A","#457B9D","#A8DADC","#F4A261"];
569
+
570
+ // ─── AUTH ─────────────────────────────────────────────────────────────
571
+ async function doLogin(){
572
+ const btn = document.getElementById("login-btn");
573
+ const err = document.getElementById("login-error");
574
+ const user = document.getElementById("login-user").value.trim();
575
+ const pass = document.getElementById("login-pass").value;
576
+ if(!user||!pass){ err.textContent="Enter username and password."; return; }
577
+ btn.disabled=true; btn.textContent="Signing in…"; err.textContent="";
578
+ try {
579
+ const r = await fetch("/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:user,password:pass})});
580
+ const j = await r.json();
581
+ if(j.status!=="ok") throw new Error(j.message||"Login failed");
582
+ onLoginSuccess(j);
583
+ } catch(e){
584
+ err.textContent = e.message;
585
+ btn.disabled=false; btn.textContent="Sign In";
586
+ }
587
+ }
588
+
589
+ function onLoginSuccess(user){
590
+ currentUser = user;
591
+ document.getElementById("login-screen").classList.add("hidden");
592
+ document.getElementById("user-chip").style.display="flex";
593
+ document.getElementById("status-chip").style.display="flex";
594
+ document.getElementById("user-avatar").textContent = user.username[0].toUpperCase();
595
+ document.getElementById("user-name-label").textContent = user.username;
596
+ document.getElementById("user-role-label").textContent = user.role;
597
+ applyRoleUI(user.role);
598
+ // ─── BOOT: restore session or show login ─────────────────────────────
599
+ (async function boot(){
600
+ // Enter key on password field triggers login
601
+ document.getElementById("login-pass").addEventListener("keydown", e=>{
602
+ if(e.key==="Enter") doLogin();
603
+ });
604
+ try {
605
+ const r = await fetch("/me");
606
+ const j = await r.json();
607
+ if(j.status==="ok"){
608
+ onLoginSuccess(j); // session still valid – skip login screen
609
+ }
610
+ // else: stay on login screen
611
+ } catch(e){ /* stay on login screen */ }
612
+ })();
613
+ // If viewer, immediately try to load the latest forecast
614
+ if(user.role==="viewer") loadLatestForecast();
615
+ }
616
+
617
+ async function doLogout(){
618
+ if(!confirm("Sign out?")) return;
619
+ await fetch("/logout",{method:"POST"});
620
+ currentUser=null; forecastResult=null;
621
+ document.getElementById("login-screen").classList.remove("hidden");
622
+ document.getElementById("user-chip").style.display="none";
623
+ document.getElementById("status-chip").style.display="none";
624
+ document.getElementById("login-pass").value="";
625
+ document.getElementById("login-error").textContent="";
626
+ }
627
+
628
+ function applyRoleUI(role){
629
+ // analyst sees all 4 tabs; viewer sees only Forecast Output
630
+ const analystOnly = ["nav-p1","nav-p2","nav-p4"];
631
+ analystOnly.forEach(id=>{
632
+ const el=document.getElementById(id);
633
+ if(el) el.style.display = role==="analyst" ? "" : "none";
634
+ });
635
+ if(role==="viewer"){
636
+ // force viewer onto p3
637
+ document.querySelectorAll(".page").forEach(p=>p.classList.remove("active"));
638
+ document.getElementById("p3").classList.add("active");
639
+ document.querySelectorAll(".nav-item").forEach(n=>n.classList.remove("active"));
640
+ document.getElementById("nav-p3").classList.add("active");
641
+ }
642
+ }
643
+
644
+ async function loadLatestForecast(){
645
+ // Viewer polls the server for the latest forecast result
646
+ try {
647
+ const r = await fetch("/forecast-result");
648
+ const j = await r.json();
649
+ if(j.status==="success"){
650
+ forecastResult=j;
651
+ renderPage3();
652
+ setStatus("ready","forecast loaded βœ“");
653
+ } else if(j.status==="empty"){
654
+ setStatus("ready","awaiting forecast");
655
+ }
656
+ } catch(e){ /* silent */ }
657
+ }
658
+
659
+ // ─── SERVER HEALTH CHECK (runs after login) ────────────────────────────
660
+ async function initPyodide(){
661
+ if(currentUser?.role==="viewer"){
662
+ setStatus("ready","viewer mode");
663
+ return;
664
+ }
665
+ clog("Β» Checking server…","dim");
666
+ try {
667
+ const r=await fetch("/health");
668
+ const j=await r.json();
669
+ if(j.status==="ok"){
670
+ setStatus("ready","server ready βœ“");
671
+ clog("Β» Server ready βœ“ Upload your data file and run.","ok");
672
+ document.getElementById("run-pipeline-btn").disabled=false;
673
+ document.getElementById("run-editor-btn").disabled=false;
674
+ } else throw new Error(j.message);
675
+ } catch(e){
676
+ setStatus("error","server offline");
677
+ clog("βœ— "+e.message,"err");
678
+ }
679
+ }
680
+
681
+ function setStatus(state,text){
682
+ const d=document.getElementById("sdot"),s=document.getElementById("stext");
683
+ d.className="status-dot sd-"+state; s.textContent=text;
684
+ }
685
+ function clog(msg,cls="ok"){
686
+ const el=document.getElementById("p2-console");
687
+ if(!el) return;
688
+ const div=document.createElement("div");
689
+ div.className="c-"+cls; div.textContent=msg;
690
+ el.appendChild(div); el.scrollTop=el.scrollHeight;
691
+ }
692
+
693
+ // ─── NAVIGATION ───────────────────────────────────────────────────────
694
+ function goPage(id,el){
695
+ // viewers cannot navigate away from p3
696
+ if(currentUser?.role==="viewer" && id!=="p3") return;
697
+ document.querySelectorAll(".page").forEach(p=>p.classList.remove("active"));
698
+ document.querySelectorAll(".nav-item").forEach(n=>n.classList.remove("active"));
699
+ document.getElementById(id).classList.add("active");
700
+ el.classList.add("active");
701
+ }
702
+
703
+ // ─── FILE UPLOAD HELPERS ──────────────────────────────────────────────
704
+ function setupUpload(zoneId,inputId,onLoaded){
705
+ const zone=document.getElementById(zoneId), inp=document.getElementById(inputId);
706
+ inp.addEventListener("change",e=>{[...e.target.files].forEach(onLoaded); e.target.value="";});
707
+ zone.addEventListener("dragover",e=>{e.preventDefault();zone.classList.add("drag");});
708
+ zone.addEventListener("dragleave",()=>zone.classList.remove("drag"));
709
+ zone.addEventListener("drop",e=>{e.preventDefault();zone.classList.remove("drag");[...e.dataTransfer.files].forEach(onLoaded);});
710
+ }
711
+
712
+ function showFileLoaded(nameEl,loadedEl,filename){
713
+ document.getElementById(nameEl).textContent=filename;
714
+ document.getElementById(loadedEl).style.display="flex";
715
+ }
716
+
717
+ // ─────────────────────────────────────────────────────────────────────
718
+ // PAGE 1 – DECOMP FILE UPLOAD & VISUALISATION
719
+ // ─────────────────────────────────────────────────────────────────────
720
+ setupUpload("decomp-upload-zone","decomp-file-input",f=>{
721
+ showFileLoaded("decomp-file-name","decomp-file-loaded",f.name);
722
+ document.getElementById("load-decomp-btn").disabled=false;
723
+ window._decompFile=f;
724
+ });
725
+
726
+ async function loadDecompFile(){
727
+ const f=window._decompFile; if(!f) return;
728
+ const btn=document.getElementById("load-decomp-btn");
729
+ btn.disabled=true; btn.textContent="Loading…";
730
+ try {
731
+ const fd=new FormData();
732
+ fd.append("file",f);
733
+ // Reuse /run endpoint to get decomp_data and actual_data from the server
734
+ clog("Β» Sending file to server for parsing…","info");
735
+ // Submit and poll (same pattern as forecast engine)
736
+ const submitResp=await fetch(SERVER_URL+"/run",{method:"POST",body:fd});
737
+ if(!submitResp.ok) throw new Error("Server error "+submitResp.status);
738
+ const submitJson=await submitResp.json();
739
+ if(submitJson.status!=="started") throw new Error(submitJson.message||"Pipeline error");
740
+
741
+ // Poll until done
742
+ let json=null;
743
+ while(true){
744
+ await new Promise(r=>setTimeout(r,4000));
745
+ const poll=await (await fetch(SERVER_URL+"/job/"+submitJson.job_id)).json();
746
+ if(poll.status==="running"){ btn.textContent="Loading… "+poll.elapsed_seconds+"s"; continue; }
747
+ if(poll.status==="error") throw new Error(poll.message||"Pipeline error");
748
+ if(poll.status==="done"){ json=poll.result; break; }
749
+ }
750
+ if(json.status!=="success") throw new Error(json.message||"Pipeline error");
751
+ // Build records list from decomp_data returned by pipeline
752
+ const records=(json.decomp_data||[]).map(r=>({
753
+ channel:r.channel, design_brand:r.design_brand, account:r.account,
754
+ date:r.date, fy:r.fy, dl1:r["Driver Level 1"]||r.dl1||"",
755
+ dl2:r["Driver Level 2"]||r.dl2||"", dl3:r.driver_l3||r.dl3||"",
756
+ volume:r.volume, price:r.price, value:r.value
757
+ }));
758
+ // Also stash actual_data for overlay
759
+ actualData=json.actual_data||[];
760
+ processDecompData(records);
761
+ btn.textContent="βœ“ Loaded";
762
+ btn.style.background="var(--green)";
763
+ clog("Β» Decomp data loaded βœ“ "+records.length+" records.","ok");
764
+ } catch(e){
765
+ btn.disabled=false; btn.textContent="Load & Visualise";
766
+ clog("βœ— "+e.message,"err");
767
+ alert("Error loading file: "+e.message);
768
+ }
769
+ }
770
+
771
+ function processDecompData(records){
772
+ decompData=records;
773
+ // Populate filters
774
+ const channels=[...new Set(records.map(r=>r.Channel||r.channel))].filter(Boolean);
775
+ const brands=[...new Set(records.map(r=>r.design_brand))].filter(Boolean);
776
+ const accounts=[...new Set(records.map(r=>r.Account||r.account))].filter(Boolean);
777
+ const fys=[...new Set(records.map(r=>r.FY||r.fy))].filter(Boolean).sort();
778
+ populateSelect("f-channel",channels);
779
+ populateSelect("f-brand",brands);
780
+ populateSelect("f-account",accounts);
781
+ populateSelect("f-fy",fys);
782
+ document.getElementById("decomp-filters").style.display="flex";
783
+ document.getElementById("decomp-kpis").style.display="grid";
784
+ document.getElementById("decomp-charts").style.display="block";
785
+ document.getElementById("decomp-empty").style.display="none";
786
+ renderDecomp();
787
+ }
788
+
789
+ function populateSelect(id,opts){
790
+ const el=document.getElementById(id);
791
+ const first=el.options[0]; el.innerHTML=""; el.appendChild(first);
792
+ opts.forEach(o=>{const op=document.createElement("option");op.value=op.textContent=o;el.appendChild(op);});
793
+ }
794
+
795
+ function getFilteredDecomp(){
796
+ const ch=document.getElementById("f-channel").value;
797
+ const br=document.getElementById("f-brand").value;
798
+ const ac=document.getElementById("f-account").value;
799
+ const fy=document.getElementById("f-fy").value;
800
+ return (decompData||[]).filter(r=>{
801
+ const rc=r.Channel||r.channel, rb=r.design_brand, ra=r.Account||r.account, rf=r.FY||r.fy;
802
+ return (ch==="ALL"||rc===ch)&&(br==="ALL"||rb===br)&&(ac==="ALL"||ra===ac)&&(fy==="ALL"||rf===fy);
803
+ });
804
+ }
805
+
806
+ function renderDecomp(){
807
+ const rows=getFilteredDecomp();
808
+ if(!rows.length) return;
809
+
810
+ const baseline=rows.filter(r=>r.dl3==="Baseline");
811
+ const actual=rows.filter(r=>r.dl3==="Actual Volume");
812
+ const drivers=rows.filter(r=>!["Baseline","Actual Volume","Predicted Volume"].includes(r.dl3));
813
+
814
+ // KPIs
815
+ const totalActual=actual.reduce((s,r)=>s+(+r.volume||0),0);
816
+ const totalBaseline=baseline.reduce((s,r)=>s+(+r.volume||0),0);
817
+ const totalIncr=drivers.reduce((s,r)=>s+(+r.volume||0),0);
818
+ const bShare=totalActual>0?totalBaseline/totalActual*100:0;
819
+ document.getElementById("kpi-actual").textContent=fmtNum(totalActual);
820
+ document.getElementById("kpi-baseline").textContent=fmtNum(totalBaseline);
821
+ document.getElementById("kpi-incr").textContent=fmtNum(totalIncr);
822
+ document.getElementById("kpi-bshare").textContent=bShare.toFixed(1)+"%";
823
+
824
+ // Get sorted dates from baseline
825
+ const dates=[...new Set(baseline.map(r=>r.date))].sort();
826
+ const allDriverTypes=[...new Set(rows.map(r=>r.dl3))].filter(d=>!["Actual Volume","Predicted Volume"].includes(d));
827
+
828
+ // Stacked chart datasets
829
+ const stackedDatasets=allDriverTypes.map((dt,i)=>{
830
+ const color=DRIVER_COLORS[dt]||DEFAULT_COLORS[i%DEFAULT_COLORS.length];
831
+ const data=dates.map(d=>{
832
+ const found=rows.filter(r=>r.dl3===dt&&r.date===d);
833
+ return found.reduce((s,r)=>s+(+r.volume||0),0);
834
+ });
835
+ return {label:dt,data,backgroundColor:color+"CC",borderColor:color,borderWidth:1,stack:"s"};
836
+ });
837
+ renderChart("stackedChart","bar",dates,stackedDatasets,{stacked:true,height:280});
838
+
839
+ // Actual vs Baseline line chart
840
+ const actualVols=dates.map(d=>actual.filter(r=>r.date===d).reduce((s,r)=>s+(+r.volume||0),0)||null);
841
+ const baselineVols=dates.map(d=>baseline.filter(r=>r.date===d).reduce((s,r)=>s+(+r.volume||0),0)||null);
842
+ renderChart("actualBaseChart","line",dates,[
843
+ {label:"Actual Volume",data:actualVols,borderColor:"#1D4E89",backgroundColor:"#1D4E8920",borderWidth:2.5,pointRadius:3,tension:0.3,fill:false},
844
+ {label:"Baseline",data:baselineVols,borderColor:"#2D6A4F",backgroundColor:"#2D6A4F20",borderWidth:2.5,pointRadius:3,tension:0.3,fill:false},
845
+ ],{height:280});
846
+
847
+ // Driver share doughnut
848
+ const driverTotals={};
849
+ allDriverTypes.forEach(dt=>{
850
+ driverTotals[dt]=rows.filter(r=>r.dl3===dt).reduce((s,r)=>s+(+r.volume||0),0);
851
+ });
852
+ const dColors=allDriverTypes.map((dt,i)=>DRIVER_COLORS[dt]||DEFAULT_COLORS[i%DEFAULT_COLORS.length]);
853
+ renderChart("driverShareChart","bar",allDriverTypes,[
854
+ {label:"Total Volume",data:allDriverTypes.map(d=>Math.max(0,driverTotals[d]||0)),backgroundColor:dColors,borderRadius:4}
855
+ ],{indexAxis:"y",height:220,legend:false});
856
+
857
+ // Driver summary table
858
+ const totalAbsVol=Object.values(driverTotals).reduce((s,v)=>s+Math.abs(v),0);
859
+ const tbody=document.getElementById("driver-tbody");
860
+ tbody.innerHTML=allDriverTypes.sort((a,b)=>Math.abs(driverTotals[b])-Math.abs(driverTotals[a])).map(dt=>{
861
+ const count=[...new Set(rows.filter(r=>r.dl3===dt).map(r=>r.date))].length;
862
+ const tot=driverTotals[dt]||0;
863
+ const dl1=[...new Set(rows.filter(r=>r.dl3===dt).map(r=>r.dl1))].join(", ");
864
+ const dl2=[...new Set(rows.filter(r=>r.dl3===dt).map(r=>r.dl2))].join(", ");
865
+ const share=totalAbsVol>0?(Math.abs(tot)/totalAbsVol*100).toFixed(1):0;
866
+ const color=DRIVER_COLORS[dt]||"#888";
867
+ return `<tr>
868
+ <td><span style="display:inline-block;width:8px;height:8px;border-radius:2px;background:${color};margin-right:6px;vertical-align:middle"></span>${dt}</td>
869
+ <td style="color:var(--text2)">${dl1}</td>
870
+ <td style="color:var(--text2)">${dl2}</td>
871
+ <td>${fmtNum(tot)}</td>
872
+ <td>${count>0?fmtNum(tot/count):"β€”"}</td>
873
+ <td><div style="display:flex;align-items:center;gap:8px"><div class="progress-bar" style="width:80px"><div class="progress-fill" style="width:${share}%;background:${color}"></div></div><span>${share}%</span></div></td>
874
+ </tr>`;
875
+ }).join("");
876
+ }
877
+
878
+ // ─────────────────────────────────────────────────────────────────────
879
+ // PAGE 2 – FORECAST ENGINE
880
+ // ─────────────────────────────────────────────────────────────────────
881
+ setupUpload("script-upload-zone","script-file-input",f=>{
882
+ showFileLoaded("script-file-name","script-file-loaded",f.name);
883
+ const reader=new FileReader();
884
+ reader.onload=ev=>{
885
+ document.getElementById("editor-code").value=ev.target.result;
886
+ document.getElementById("p2-editor-filename").textContent=f.name;
887
+ clog("Β» Script loaded: "+f.name,"info");
888
+ };
889
+ reader.readAsText(f);
890
+ });
891
+
892
+ setupUpload("data-upload-zone","data-file-input",f=>{
893
+ showFileLoaded("data-file-name","data-file-loaded",f.name);
894
+ dataFile=f;
895
+ clog("Β» Data file ready: "+f.name,"info");
896
+ });
897
+
898
+ function loadDemoScript(){
899
+ const script=getDemoScript();
900
+ document.getElementById("editor-code").value=script;
901
+ document.getElementById("p2-editor-filename").textContent="dabur_baseline_forecast_v2.py";
902
+ clog("Β» Demo script loaded into editor.","info");
903
+ }
904
+
905
+ async function runForecastPipeline(){
906
+ if(!dataFile){clog("βœ— No data file uploaded.","err");return;}
907
+
908
+ const btn=document.getElementById("run-pipeline-btn");
909
+ const ebtn=document.getElementById("run-editor-btn");
910
+ btn.disabled=ebtn.disabled=true;
911
+ btn.textContent="⏳ Submitting…"; ebtn.textContent="⏳";
912
+ setStatus("loading","submitting job…");
913
+ document.getElementById("p2-console").innerHTML="";
914
+ clog("Β» Uploading file to Genesis AI server…","info");
915
+ clog(`Β» ${new Date().toLocaleTimeString()}`,"dim");
916
+
917
+ const horizon=parseInt(document.getElementById("cfg-horizon").value)||12;
918
+ const season=parseInt(document.getElementById("cfg-season").value)||12;
919
+ const cvh=parseInt(document.getElementById("cfg-cvh").value)||6;
920
+ const topn=parseInt(document.getElementById("cfg-topn").value)||3;
921
+
922
+ try {
923
+ const fd=new FormData();
924
+ fd.append("file",dataFile);
925
+ fd.append("horizon",horizon);
926
+ fd.append("season",season);
927
+ fd.append("cv_horizon",cvh);
928
+ fd.append("top_n",topn);
929
+
930
+ // Step 1: submit job – returns immediately with job_id
931
+ const submitResp=await fetch(SERVER_URL+"/run",{method:"POST",body:fd});
932
+ const submitJson=await submitResp.json();
933
+ if(submitJson.status!=="started") throw new Error(submitJson.message||"Failed to start job");
934
+
935
+ const jobId=submitJson.job_id;
936
+ clog("Β» Job started (id: "+jobId.slice(0,8)+"…)","info");
937
+ clog("Β» Pipeline running in background – polling for result…","dim");
938
+ btn.textContent="⏳ Running…";
939
+
940
+ // Step 2: poll /job/<id> every 5 seconds
941
+ await pollJob(jobId);
942
+
943
+ } catch(e){
944
+ clog("βœ— "+e.message,"err");
945
+ setStatus("error","pipeline error");
946
+ btn.disabled=ebtn.disabled=false;
947
+ btn.textContent="β–Ά Run Baseline Forecast"; ebtn.textContent="β–Ά Run";
948
+ }
949
+ }
950
+
951
+ async function pollJob(jobId){
952
+ const btn=document.getElementById("run-pipeline-btn");
953
+ const ebtn=document.getElementById("run-editor-btn");
954
+ const start=Date.now();
955
+
956
+ while(true){
957
+ await new Promise(r=>setTimeout(r,5000)); // wait 5 seconds between polls
958
+
959
+ let poll;
960
+ try { poll=await (await fetch(SERVER_URL+"/job/"+jobId)).json(); }
961
+ catch(e){ clog("⚠ Poll error: "+e.message,"warn"); continue; }
962
+
963
+ if(poll.status==="running"){
964
+ const elapsed=poll.elapsed_seconds||Math.round((Date.now()-start)/1000);
965
+ const mins=Math.floor(elapsed/60), secs=elapsed%60;
966
+ const t=mins>0?`${mins}m ${secs}s`:`${secs}s`;
967
+ clog(`Β» Still running… (${t} elapsed)`,"dim");
968
+ btn.textContent=`⏳ Running ${t}…`;
969
+ setStatus("loading",`running ${t}…`);
970
+ continue;
971
+ }
972
+
973
+ if(poll.status==="error"){
974
+ clog("βœ— Pipeline error: "+poll.message,"err");
975
+ setStatus("error","pipeline error");
976
+ break;
977
+ }
978
+
979
+ if(poll.status==="done"){
980
+ forecastResult=poll.result;
981
+ clog("Β» Pipeline complete βœ“ "+forecastResult.n_series+" series processed.","ok");
982
+ clog("Β» Skipped: "+forecastResult.n_skipped+" series (insufficient data).","dim");
983
+ clog("Β» Result saved – viewer users can now see the output.","info");
984
+ renderPage3();
985
+ setStatus("ready","forecast complete βœ“");
986
+ break;
987
+ }
988
+ }
989
+
990
+ btn.disabled=ebtn.disabled=false;
991
+ btn.textContent="β–Ά Run Baseline Forecast"; ebtn.textContent="β–Ά Run";
992
+ }
993
+
994
+ // ─────────────────────────────────────────────────────────────────────
995
+ // PAGE 3 – FORECAST OUTPUT
996
+ // ─────────────────────────────────────────────────────────────────────
997
+ function renderPage3(){
998
+ if(!forecastResult) return;
999
+ document.getElementById("p3-empty").style.display="none";
1000
+ document.getElementById("p3-content").style.display="block";
1001
+
1002
+ const series=forecastResult.series||[];
1003
+ const totalFcstVol=series.reduce((s,sr)=>s+(sr.total_forecast_vol||0),0);
1004
+
1005
+ // KPIs
1006
+ const kpiEl=document.getElementById("p3-kpis");
1007
+ kpiEl.innerHTML=`
1008
+ <div class="metric green"><div class="metric-label">Series Modelled</div><div class="metric-value">${series.length}</div><div class="metric-sub">Channel Γ— Brand Γ— Account</div></div>
1009
+ <div class="metric blue"><div class="metric-label">Total Forecast Vol</div><div class="metric-value">${fmtNum(totalFcstVol)}</div><div class="metric-sub">12-month horizon</div></div>
1010
+ <div class="metric accent"><div class="metric-label">Forecast Period</div><div class="metric-value">${series[0]?.forecast?.[0]?.fy||"β€”"}</div><div class="metric-sub">to ${series[0]?.forecast?.slice(-1)[0]?.fy||"β€”"}</div></div>
1011
+ <div class="metric amber"><div class="metric-label">Ensemble Models</div><div class="metric-value">${(series[0]?.top_models||[]).length}</div><div class="metric-sub">top-N by CV MAE</div></div>
1012
+ <div class="metric"><div class="metric-label">Date Run</div><div class="metric-value" style="font-size:16px">${new Date().toLocaleDateString()}</div><div class="metric-sub">${new Date().toLocaleTimeString()}</div></div>
1013
+ `;
1014
+
1015
+ // Series tabs
1016
+ const tabsEl=document.getElementById("p3-series-tabs");
1017
+ tabsEl.innerHTML=series.map((s,i)=>`<div class="stab ${i===0?"active":""}" onclick="selectSeries(${i},this)">${s.design_brand} Β· ${s.account}</div>`).join("");
1018
+
1019
+ activeSeriesIdx=0;
1020
+ renderSeriesView(0);
1021
+ }
1022
+
1023
+ function selectSeries(idx,el){
1024
+ document.querySelectorAll(".stab").forEach(t=>t.classList.remove("active"));
1025
+ el.classList.add("active");
1026
+ activeSeriesIdx=idx;
1027
+ renderSeriesView(idx);
1028
+ }
1029
+
1030
+ function renderSeriesView(idx){
1031
+ const series=(forecastResult?.series||[])[idx];
1032
+ if(!series) return;
1033
+
1034
+ document.getElementById("p3-chart-title").textContent=`Baseline Forecast Β· ${series.design_brand} Γ— ${series.account}`;
1035
+
1036
+ // Top models chips
1037
+ document.getElementById("p3-top-models").innerHTML=(series.top_models||[]).map(m=>`<span class="model-badge">${m}</span>`).join("");
1038
+
1039
+ // Forecast chart: history + forecast
1040
+ const hist=series.history||[];
1041
+ const fcst=series.forecast||[];
1042
+ const histDates=hist.map(r=>r.date);
1043
+ const fcstDates=fcst.map(r=>r.date);
1044
+ const allDates=[...histDates,...fcstDates];
1045
+ const actualLine=[...hist.map(r=>r.actual_vol), ...Array(fcstDates.length).fill(null)];
1046
+ const baseLine= [...hist.map(r=>r.baseline_vol), ...Array(fcstDates.length).fill(null)];
1047
+ const fcstLine= [...Array(histDates.length).fill(null),...fcst.map(r=>r.volume)];
1048
+ // Connect last historical baseline to first forecast
1049
+ if(hist.length>0 && fcst.length>0){
1050
+ fcstLine[histDates.length-1]=hist[hist.length-1].baseline_vol;
1051
+ }
1052
+
1053
+ renderChart("forecastChart","line",allDates,[
1054
+ {label:"Actual Volume",data:actualLine,borderColor:"#1D4E89",borderWidth:2.5,pointRadius:2,tension:0.3,fill:false,spanGaps:false},
1055
+ {label:"Historical Baseline",data:baseLine,borderColor:"#2D6A4F",borderWidth:2.5,pointRadius:2,tension:0.3,fill:false,spanGaps:false},
1056
+ {label:"Baseline Forecast (12M)",data:fcstLine,borderColor:"#C84B31",borderWidth:2.5,pointRadius:3,tension:0.3,fill:false,spanGaps:false,borderDash:[6,3]},
1057
+ ],{height:280,annotation:hist.length>0?histDates[histDates.length-1]:null});
1058
+
1059
+ // MAE table
1060
+ document.getElementById("mae-tbody").innerHTML=(series.mae_table||[]).map((r,i)=>`
1061
+ <tr>
1062
+ <td>${r.Model}<span style="margin-left:6px">${(series.top_models||[]).includes(r.Model)?'<span class="pill pill-green">ensemble</span>':""}</span></td>
1063
+ <td style="font-family:var(--mono)">${(+r.MAE).toLocaleString(undefined,{maximumFractionDigits:0})}</td>
1064
+ <td><span class="pill ${i===0?"pill-green":i<3?"pill-blue":"pill-amber"}">#${i+1}</span></td>
1065
+ </tr>`).join("");
1066
+
1067
+ // Forecast table
1068
+ document.getElementById("forecast-tbody").innerHTML=fcst.map((r,i)=>{
1069
+ const prev=i>0?fcst[i-1].volume:null;
1070
+ const mom=prev&&prev>0?((r.volume-prev)/prev*100):null;
1071
+ const momStr=mom!==null?`<span class="${mom>=0?"pill pill-green":"pill pill-red"}">${mom>=0?"+":""}${mom.toFixed(1)}%</span>`:"β€”";
1072
+ return `<tr>
1073
+ <td style="font-family:var(--mono)">${r.date}</td>
1074
+ <td><span class="pill pill-blue">${r.fy}</span></td>
1075
+ <td style="font-family:var(--mono);font-weight:600">${fmtNum(r.volume)}</td>
1076
+ <td style="font-family:var(--mono)">${(+r.price).toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2})}</td>
1077
+ <td style="font-family:var(--mono)">β‚Ή${fmtNum(r.value)}</td>
1078
+ <td>${momStr}</td>
1079
+ </tr>`;
1080
+ }).join("");
1081
+ }
1082
+
1083
+ function downloadForecast(){
1084
+ const series=(forecastResult?.series||[])[activeSeriesIdx];
1085
+ if(!series) return;
1086
+ const rows=[["Date","FY","Forecast Volume","Price","Forecast Value","Design Brand","Account","Channel"]];
1087
+ (series.forecast||[]).forEach(r=>rows.push([r.date,r.fy,r.volume,r.price,r.value,series.design_brand,series.account,series.channel]));
1088
+ const csv=rows.map(r=>r.join(",")).join("\n");
1089
+ const a=document.createElement("a"); a.href="data:text/csv;charset=utf-8,"+encodeURIComponent(csv);
1090
+ a.download=`dabur_baseline_forecast_${series.design_brand}_${series.account}.csv`.replace(/\s+/g,"_");
1091
+ a.click();
1092
+ }
1093
+
1094
+ // ─── CHART HELPER ─────────────────────────────────────────────────────
1095
+ function renderChart(id,type,labels,datasets,opts={}){
1096
+ if(chartInstances[id]){chartInstances[id].destroy();}
1097
+ const ctx=document.getElementById(id);
1098
+ if(!ctx) return;
1099
+ ctx.parentElement.style.height=(opts.height||260)+"px";
1100
+ chartInstances[id]=new Chart(ctx,{
1101
+ type,
1102
+ data:{labels,datasets},
1103
+ options:{
1104
+ responsive:true,maintainAspectRatio:false,
1105
+ animation:{duration:500},
1106
+ plugins:{
1107
+ legend:{display:opts.legend!==false,position:"top",labels:{boxWidth:10,font:{size:11,family:"DM Mono"},padding:12}},
1108
+ tooltip:{backgroundColor:"#1A1916",titleFont:{size:11,family:"DM Mono"},bodyFont:{size:11,family:"DM Mono"},padding:10}
1109
+ },
1110
+ scales:{
1111
+ x:{stacked:opts.stacked||false,ticks:{font:{size:10,family:"DM Mono"},maxRotation:45},grid:{display:false}},
1112
+ y:{stacked:opts.stacked||false,ticks:{font:{size:10,family:"DM Mono"},callback:v=>fmtNum(v)},grid:{color:"#F4F3EF"}},
1113
+ ...(opts.indexAxis==="y"?{y:{stacked:false,ticks:{font:{size:10,family:"DM Mono"}}},x:{ticks:{font:{size:10,family:"DM Mono"},callback:v=>fmtNum(v)}}}:{}),
1114
+ },
1115
+ indexAxis:opts.indexAxis||"x",
1116
+ }
1117
+ });
1118
+ }
1119
+
1120
+ // ─── UTILS ────────────────────────────────────────────────────────────
1121
+ function fmtNum(n){
1122
+ const v=+n||0;
1123
+ if(Math.abs(v)>=1e6) return (v/1e6).toFixed(1)+"M";
1124
+ if(Math.abs(v)>=1e3) return (v/1e3).toFixed(1)+"K";
1125
+ return Math.round(v).toLocaleString();
1126
+ }
1127
+
1128
+ // ─── DEMO SCRIPT (inline copy of v2) ──────────────────────────────────
1129
+ function getDemoScript(){
1130
+ return `# =============================================================================
1131
+ # DABUR BASELINE FORECAST PIPELINE v2
1132
+ # Reads from Excel Β· outputs JSON to stdout for the web app
1133
+ # =============================================================================
1134
+ import io, json, warnings, sys
1135
+ import numpy as np
1136
+ import pandas as pd
1137
+ from statsforecast import StatsForecast
1138
+ from statsforecast.models import (
1139
+ AutoETS, AutoARIMA, AutoTheta,
1140
+ MSTL, HoltWinters, SeasonalNaive, WindowAverage,
1141
+ )
1142
+ warnings.filterwarnings("ignore")
1143
+
1144
+ CONFIG = {
1145
+ "channel_col":"Channel","category_col":"Category","division_col":"Division",
1146
+ "brand_group_col":"Brand Group","account_col":"Account","key_col":"Key",
1147
+ "design_brand_col":"Design Brand","date_col":"DATE","fy_col":"FY",
1148
+ "volume_col":"Volume","price_col":"Price","value_col":"Value",
1149
+ "driver_col":"Driver Level 3",
1150
+ "baseline_label":"Baseline","actual_label":"Actual Volume",
1151
+ "forecast_label":"Baseline Forecast",
1152
+ "freq":"MS","season_length":12,"forecast_horizon":12,
1153
+ "top_n_ensemble":3,"cv_horizon":6,"cv_windows":2,
1154
+ "sheet_name":"Base Sheet",
1155
+ }
1156
+ SERIES_KEYS = ["Channel","Design Brand","Account"]
1157
+
1158
+ def load_data(source, cfg):
1159
+ if isinstance(source, pd.DataFrame): return source.copy()
1160
+ df = pd.read_excel(source, sheet_name=cfg["sheet_name"])
1161
+ df[cfg["date_col"]] = pd.to_datetime(df[cfg["date_col"]], errors="coerce")
1162
+ df[cfg["date_col"]] = df[cfg["date_col"]].dt.to_period("M").dt.to_timestamp()
1163
+ return df
1164
+
1165
+ def get_fy(date):
1166
+ yr = date.year+1 if date.month>=4 else date.year
1167
+ return f"FY{str(yr)[2:]}"
1168
+
1169
+ def preprocess_long_to_wide(df, cfg):
1170
+ dc,vc,pc,datec,bl = cfg["driver_col"],cfg["volume_col"],cfg["price_col"],cfg["date_col"],cfg["baseline_label"]
1171
+ df = df[df[dc].isin([bl,cfg["actual_label"]])].copy()
1172
+ dim_cols = [cfg["channel_col"],cfg["category_col"],cfg["division_col"],cfg["brand_group_col"],cfg["account_col"],cfg["key_col"],cfg["design_brand_col"]]
1173
+ pivot = df.pivot_table(index=dim_cols+[datec],columns=dc,values=vc,aggfunc="first").reset_index()
1174
+ pivot.columns.name=None
1175
+ rename={datec:"ds"}
1176
+ if bl in pivot.columns: rename[bl]="baseline"
1177
+ if cfg["actual_label"] in pivot.columns: rename[cfg["actual_label"]]="actual"
1178
+ pivot.rename(columns=rename,inplace=True)
1179
+ price_map = df[df[dc]==bl].set_index(dim_cols+[datec])[pc].rename("price")
1180
+ pivot = pivot.join(price_map,on=dim_cols+["ds"])
1181
+ pivot.sort_values(dim_cols+["ds"],inplace=True); pivot.reset_index(drop=True,inplace=True)
1182
+ pivot["unique_id"] = pivot[SERIES_KEYS].apply(lambda r:" | ".join(r.astype(str)),axis=1)
1183
+ for col in ["baseline","actual"]:
1184
+ if col in pivot.columns: pivot[col]=pivot[col].clip(lower=0)
1185
+ return pivot
1186
+
1187
+ def check_gaps(s_df, uid):
1188
+ full=pd.date_range(s_df["ds"].min(),s_df["ds"].max(),freq="MS")
1189
+ missing=full.difference(s_df["ds"])
1190
+ if len(missing):
1191
+ fill=pd.DataFrame({"ds":missing})
1192
+ s_df=pd.concat([s_df,fill],ignore_index=True).sort_values("ds").ffill()
1193
+ return s_df.reset_index(drop=True)
1194
+
1195
+ def build_models(sl):
1196
+ return [AutoETS(season_length=sl),AutoARIMA(season_length=sl),AutoTheta(season_length=sl),
1197
+ HoltWinters(season_length=sl,error_type="A",alias="HWAdd"),HoltWinters(season_length=sl,error_type="M",alias="HWMult"),
1198
+ MSTL(season_length=sl,trend_forecaster=AutoTheta(),alias="MSTL_Theta"),SeasonalNaive(season_length=sl)]
1199
+
1200
+ def cross_validate_series(sf_df, cfg):
1201
+ sf=StatsForecast(models=build_models(cfg["season_length"]),freq=cfg["freq"],fallback_model=WindowAverage(window_size=6),n_jobs=1)
1202
+ cv=sf.cross_validation(df=sf_df,h=cfg["cv_horizon"],n_windows=cfg["cv_windows"],step_size=3)
1203
+ model_cols=[c for c in cv.columns if c not in ["unique_id","ds","cutoff","y"]]
1204
+ mae={m:np.mean(np.abs(cv[m]-cv["y"])) for m in model_cols}
1205
+ return pd.DataFrame.from_dict(mae,orient="index",columns=["MAE"]).sort_values("MAE").reset_index().rename(columns={"index":"Model"})
1206
+
1207
+ def forecast_series(sf_df, top_models, cfg):
1208
+ sf=StatsForecast(models=build_models(cfg["season_length"]),freq=cfg["freq"],fallback_model=WindowAverage(window_size=6),n_jobs=1)
1209
+ fcst=sf.forecast(df=sf_df,h=cfg["forecast_horizon"])
1210
+ if "ds" not in fcst.columns: fcst.reset_index(inplace=True)
1211
+ available=[m for m in top_models if m in fcst.columns]
1212
+ fcst["ensemble_baseline_forecast"]=fcst[available].mean(axis=1).clip(lower=0)
1213
+ return fcst
1214
+
1215
+ def build_output_rows(wide_df, fcst_df, dim_meta, cfg):
1216
+ last_price=wide_df["price"].dropna().tail(3).mean()
1217
+ rows=[]
1218
+ for _,row in fcst_df.iterrows():
1219
+ vol=round(row["ensemble_baseline_forecast"],6); price=round(last_price,7); value=round(vol*price,3)
1220
+ rows.append({cfg["channel_col"]:dim_meta[cfg["channel_col"]],cfg["category_col"]:dim_meta[cfg["category_col"]],
1221
+ cfg["division_col"]:dim_meta[cfg["division_col"]],cfg["brand_group_col"]:dim_meta[cfg["brand_group_col"]],
1222
+ cfg["account_col"]:dim_meta[cfg["account_col"]],cfg["key_col"]:dim_meta[cfg["key_col"]],
1223
+ cfg["design_brand_col"]:dim_meta[cfg["design_brand_col"]],cfg["date_col"]:row["ds"].strftime("%Y-%m-%d"),
1224
+ cfg["fy_col"]:get_fy(row["ds"]),cfg["volume_col"]:vol,cfg["price_col"]:price,cfg["value_col"]:value,
1225
+ cfg["driver_col"]:cfg["forecast_label"]})
1226
+ return pd.DataFrame(rows)
1227
+
1228
+ def run_pipeline(source, cfg=CONFIG):
1229
+ raw_df=load_data(source,cfg)
1230
+ wide_df=preprocess_long_to_wide(raw_df,cfg)
1231
+ series_list=wide_df["unique_id"].unique().tolist()
1232
+ all_forecast_rows=[]; mae_summary=[]; series_results=[]
1233
+ for uid in series_list:
1234
+ s_df=wide_df[wide_df["unique_id"]==uid].copy(); s_df=check_gaps(s_df,uid)
1235
+ sf_input=s_df[["unique_id","ds","baseline"]].rename(columns={"baseline":"y"})
1236
+ mae_df=cross_validate_series(sf_input,cfg)
1237
+ top_models=mae_df["Model"].head(cfg["top_n_ensemble"]).tolist()
1238
+ mae_df["series"]=uid; mae_summary.append(mae_df)
1239
+ fcst_df=forecast_series(sf_input,top_models,cfg)
1240
+ dim_meta=s_df.iloc[0][[cfg["channel_col"],cfg["category_col"],cfg["division_col"],cfg["brand_group_col"],cfg["account_col"],cfg["key_col"],cfg["design_brand_col"]]].to_dict()
1241
+ out_rows=build_output_rows(s_df,fcst_df,dim_meta,cfg)
1242
+ all_forecast_rows.append(out_rows)
1243
+ hist=s_df[["ds","baseline","actual","price"]].copy()
1244
+ hist["ds"]=hist["ds"].dt.strftime("%Y-%m-%d")
1245
+ series_results.append({
1246
+ "uid":uid,"channel":dim_meta[cfg["channel_col"]],"design_brand":dim_meta[cfg["design_brand_col"]],
1247
+ "account":dim_meta[cfg["account_col"]],"top_models":top_models,
1248
+ "mae_table":mae_df[["Model","MAE"]].round(2).to_dict(orient="records"),
1249
+ "history":hist.rename(columns={"ds":"date","baseline":"baseline_vol","actual":"actual_vol","price":"price"}).to_dict(orient="records"),
1250
+ "forecast":[{"date":r[cfg["date_col"]],"fy":r[cfg["fy_col"]],"volume":round(r[cfg["volume_col"]],2),"price":round(r[cfg["price_col"]],4),"value":round(r[cfg["value_col"]],0)} for _,r in out_rows.iterrows()],
1251
+ "total_forecast_vol":round(out_rows[cfg["volume_col"]].sum(),2),
1252
+ })
1253
+ decomp_drivers=raw_df[~raw_df[cfg["driver_col"]].isin([cfg["actual_label"],"Predicted Volume"])].copy()
1254
+ decomp_drivers["DATE_str"]=decomp_drivers[cfg["date_col"]].dt.strftime("%Y-%m-%d")
1255
+ decomp_records=decomp_drivers[[cfg["channel_col"],cfg["design_brand_col"],cfg["account_col"],"DATE_str",cfg["fy_col"],"Driver Level 1","Driver Level 2",cfg["driver_col"],cfg["volume_col"],cfg["price_col"],cfg["value_col"]]].rename(columns={cfg["channel_col"]:"channel",cfg["design_brand_col"]:"design_brand",cfg["account_col"]:"account","DATE_str":"date",cfg["fy_col"]:"fy",cfg["driver_col"]:"driver_l3",cfg["volume_col"]:"volume",cfg["price_col"]:"price",cfg["value_col"]:"value"}).to_dict(orient="records")
1256
+ actual_records=raw_df[raw_df[cfg["driver_col"]]==cfg["actual_label"]].copy()
1257
+ actual_records["DATE_str"]=actual_records[cfg["date_col"]].dt.strftime("%Y-%m-%d")
1258
+ actual_out=actual_records[[cfg["design_brand_col"],cfg["account_col"],"DATE_str",cfg["volume_col"]]].rename(columns={cfg["design_brand_col"]:"design_brand",cfg["account_col"]:"account","DATE_str":"date",cfg["volume_col"]:"actual_volume"}).to_dict(orient="records")
1259
+ output={"status":"success","n_series":len(series_list),"series_ids":series_list,"series":series_results,"decomp_data":decomp_records,"actual_data":actual_out}
1260
+ print(json.dumps(output))
1261
+ return output
1262
+
1263
+ if __name__=="__main__":
1264
+ src=sys.argv[1] if len(sys.argv)>1 else None
1265
+ if src: run_pipeline(src)
1266
+ `;
1267
+ }
1268
+
1269
+ // ─── BOOT ─────────────────────────────────────────────────────────────
1270
+ initPyodide();
1271
+ </script>
1272
+ </body>
1273
+ </html>
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ flask>=3.0.0
2
+ gunicorn>=21.2.0
3
+ numpy>=1.26.0
4
+ pandas>=2.2.0
5
+ openpyxl>=3.1.2
6
+ statsforecast>=1.7.0