Commit Β·
bb004f6
1
Parent(s): 346d3c7
Genesis AI deploy
Browse files- DEPLOY.md +105 -0
- Dockerfile +33 -0
- README.md +29 -5
- app.py +209 -0
- dabur_baseline_forecast_v2.py +556 -0
- dabur_demand_platform.html +1273 -0
- 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:
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: red
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
-
|
| 9 |
---
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 & 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
|