# ============================================================
# ORIGINAL EXPERIMENTAL PIPELINE
# 7 BASELINE RECURRENT ARCHITECTURES
# PREDICTIVE SPC EVALUATION FRAMEWORK
#
# Models:
# 1. GRU
# 2. Vanilla LSTM
# 3. Deep LSTM
# 4. Direct Multi-Step LSTM
# 5. Encoder–Decoder LSTM
# 6. Attention LSTM
# 7. Bi-LSTM
#
# Notes:
# Forecasting error metrics (MAE, MSE, RMSE, MAPE, and R²)
# are computed for diagnostic purposes only.
# The associated manuscript focuses on predictive SPC
# classification performance and regime-based analysis.
# ============================================================
import os
from google.colab import drive

if not os.path.ismount("/content/drive"):
    drive.mount("/content/drive")
else:
    print("[INFO] Google Drive sudah ter-mount.")
    

import gc
import re
import json
import time
import math
import warnings
import traceback
from datetime import datetime
from pathlib import Path

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

from sklearn.preprocessing import MinMaxScaler
from sklearn.metrics import (
    mean_absolute_error,
    mean_squared_error,
    r2_score,
    precision_recall_fscore_support,
    accuracy_score,
)

import tensorflow as tf
from tensorflow.keras.models import Sequential, Model
from tensorflow.keras.layers import (
    Input, Dense, LSTM, GRU, Bidirectional, RepeatVector,
    Attention, GlobalAveragePooling1D
)
from tensorflow.keras.callbacks import EarlyStopping, TerminateOnNaN
import joblib

warnings.filterwarnings("ignore")

# ============================================================
# CONFIG
# ============================================================

MOUNT_GOOGLE_DRIVE = False

# ============================================================
# PATH DATA ORIGIN
# ============================================================
ROOT_OUTPUT = ""

# Update this path according to the location of the original dataset.
DATA_ORIGIN_FILE = ""

# Original sensor columns based on Excel positions:
# Excel column: 8,11,14,20,23,26,29,32,35,38,40
# Python index: 7,10,13,19,22,25,28,31,34,37,39
SENSOR_COLUMN_POSITIONS = {
    # Sensor mapping is preserved exactly as used in the original experiments
    # to ensure consistency with the reported manuscript and supplementary results.
    
    "Sensor_1": 7,
    "Sensor_2": 10,
    "Sensor_3": 13,
    "Sensor_4": 19,
    "Sensor_5": 22,
    "Sensor_6": 25,
    "Sensor_7": 28,
    "Sensor_8": 31,
    "Sensor_9": 34,
    "Sensor_10": 37,
    "Sensor_11": 39,
}

N_TRIALS = 10

WINDOW_SIZE = 60
HORIZON = 15
TRAIN_RATIO = 0.80
VAL_RATIO_FROM_TRAIN = 0.20

EPOCHS = 100
BATCH_SIZE = 32

# Forecasting metrics are retained only as diagnostic outputs.
# The associated manuscript reports SPC classification results as the primary outcomes.
INCLUDE_FORECASTING_DIAGNOSTICS = True


RANDOM_SEED_BASE = 2026
AUTO_RESUME = True
SKIP_SUCCESSFUL_COMBINATION = True
RERUN_FAILED_COMBINATION = True

SELECTED_MODELS = [
    "GRU",
    "VANILLA_LSTM",
    "DEEP_LSTM",
    "DIRECT_MULTI_STEP_LSTM",
    "ENCODER_DECODER_LSTM",
    "ATTENTION_LSTM",
    "BI_LSTM",
]

# Default: semua sensor.
SELECTED_SENSOR_NAMES = list(SENSOR_COLUMN_POSITIONS.keys())

MODEL_CONFIGS = {
    "GRU": {
        "display_name": "GRU",
        "patience": 10,
        "model_file_suffix": "gru",
        "architecture": "GRU(100) -> Dense(15)",
    },
    "VANILLA_LSTM": {
        "display_name": "Vanilla LSTM",
        "patience": 10,
        "model_file_suffix": "vanilla_lstm",
        "architecture": "LSTM(100) -> Dense(15)",
    },
    "DEEP_LSTM": {
        "display_name": "Deep LSTM",
        "patience": 15,
        "model_file_suffix": "deep_lstm",
        "architecture": "LSTM(100, return_sequences=True) -> LSTM(100, return_sequences=True) -> LSTM(100) -> Dense(15)",
    },
    "DIRECT_MULTI_STEP_LSTM": {
        "display_name": "Direct Multi-Step LSTM",
        "patience": 12,
        "model_file_suffix": "direct_multi_step_lstm",
        "architecture": "LSTM(100, return_sequences=True) -> LSTM(100) -> Dense(15)",
    },
    "ENCODER_DECODER_LSTM": {
        "display_name": "Encoder-Decoder LSTM",
        "patience": 15,
        "model_file_suffix": "encoder_decoder_lstm",
        "architecture": "LSTM(100) -> RepeatVector(15) -> LSTM(100) -> Dense(15)",
    },
    "ATTENTION_LSTM": {
        "display_name": "Attention LSTM",
        "patience": 10,
        "model_file_suffix": "attention_lstm",
        "architecture": "LSTM(100, return_sequences=True) -> Attention -> GlobalAveragePooling1D -> Dense(15)",
    },
    "BI_LSTM": {
        "display_name": "Bi-LSTM",
        "patience": 10,
        "model_file_suffix": "bi_lstm",
        "architecture": "Bidirectional(LSTM(100)) -> Dense(15)",
    },
}

# ============================================================
# UTILS
# ============================================================

def ensure_dir(path):
    os.makedirs(path, exist_ok=True)


def now_str():
    return datetime.now().strftime("%Y-%m-%d %H:%M:%S")


def compact_time():
    return datetime.now().strftime("%Y%m%d_%H%M%S")


def sanitize_name(x):
    x = str(x)
    x = re.sub(r"[^a-zA-Z0-9_\-\.]+", "_", x)
    x = re.sub(r"_+", "_", x).strip("_")
    return x


def log(msg, log_path=None):
    line = f"[{now_str()}] {msg}"
    print(line)
    if log_path:
        ensure_dir(os.path.dirname(log_path))
        with open(log_path, "a", encoding="utf-8") as f:
            f.write(line + "\n")


def cleanup_memory():
    gc.collect()
    try:
        tf.keras.backend.clear_session()
    except Exception:
        pass

def get_combo_status(combo_dir):
    done_flag = os.path.join(combo_dir, "COMBO_DONE.json")
    failed_flag = os.path.join(combo_dir, "COMBO_FAILED.json")

    if os.path.exists(done_flag):
        return "SUCCESS"
    if os.path.exists(failed_flag):
        return "FAILED"
    return "NEW"


def load_done_result(combo_dir):
    done_flag = os.path.join(combo_dir, "COMBO_DONE.json")
    if os.path.exists(done_flag):
        with open(done_flag, "r", encoding="utf-8") as f:
            return json.load(f)
    return None


def mark_combo_done(combo_dir, result):
    done_flag = os.path.join(combo_dir, "COMBO_DONE.json")
    failed_flag = os.path.join(combo_dir, "COMBO_FAILED.json")

    if os.path.exists(failed_flag):
        os.remove(failed_flag)

    with open(done_flag, "w", encoding="utf-8") as f:
        json.dump(result, f, indent=4, ensure_ascii=False)


def mark_combo_failed(combo_dir, result):
    failed_flag = os.path.join(combo_dir, "COMBO_FAILED.json")

    with open(failed_flag, "w", encoding="utf-8") as f:
        json.dump(result, f, indent=4, ensure_ascii=False)
        
def try_mount_drive():
    if not MOUNT_GOOGLE_DRIVE:
        return
    try:
        from google.colab import drive
        if not os.path.exists("/content/drive") or not os.path.ismount("/content/drive"):
            drive.mount("/content/drive")
    except Exception as e:
        print(f"[WARNING] Drive tidak bisa di-mount otomatis: {e}")


def safe_mape(y_true, y_pred, eps=1e-8):
    y_true = np.asarray(y_true).reshape(-1)
    y_pred = np.asarray(y_pred).reshape(-1)
    denom = np.where(np.abs(y_true) < eps, eps, np.abs(y_true))
    return float(np.mean(np.abs((y_true - y_pred) / denom)) * 100.0)


def rmse_score(y_true, y_pred):
    return float(np.sqrt(mean_squared_error(y_true, y_pred)))


# ============================================================
# ANTI-NaN + AUTO-DEBUG HELPERS
# ============================================================

def count_nan_inf(arr):
    """Identify and count NaN and infinite values while preserving the original array."""
    a = np.asarray(arr)
    return {
        "shape": list(a.shape),
        "nan_count": int(np.isnan(a).sum()) if np.issubdtype(a.dtype, np.number) else 0,
        "posinf_count": int(np.isposinf(a).sum()) if np.issubdtype(a.dtype, np.number) else 0,
        "neginf_count": int(np.isneginf(a).sum()) if np.issubdtype(a.dtype, np.number) else 0,
        "finite_count": int(np.isfinite(a).sum()) if np.issubdtype(a.dtype, np.number) else 0,
    }


def safe_float_stats(arr):
    """Safe summary statistics for JSON debugging."""
    a = np.asarray(arr, dtype=np.float64).reshape(-1)
    finite = a[np.isfinite(a)]
    if finite.size == 0:
        return {
            "min": None, "max": None, "mean": None, "std": None,
            "nan_count": int(np.isnan(a).sum()),
            "inf_count": int(np.isinf(a).sum()),
            "total_count": int(a.size),
        }
    return {
        "min": float(np.min(finite)),
        "max": float(np.max(finite)),
        "mean": float(np.mean(finite)),
        "std": float(np.std(finite, ddof=0)),
        "nan_count": int(np.isnan(a).sum()),
        "inf_count": int(np.isinf(a).sum()),
        "total_count": int(a.size),
    }


def write_debug_report(path, payload):
    """Write the JSON debug report. Do not let the main process fail if debugging fails.."""
    try:
        ensure_dir(os.path.dirname(path))
        with open(path, "w", encoding="utf-8") as f:
            json.dump(payload, f, indent=4, ensure_ascii=False)
    except Exception as debug_error:
        print(f"[WARNING] Gagal menulis debug report: {debug_error}")


def assert_all_finite(name, arr, combo_dir=None, extra=None):
    """Validate arrays before passing them to the scaler, metrics, or SPC evaluation so that errors can be traced clearly."""
    a = np.asarray(arr)
    if not np.all(np.isfinite(a)):
        payload = {
            "failed_stage": name,
            "array_check": count_nan_inf(a),
            "array_stats": safe_float_stats(a),
            "extra": extra or {},
            "created_at": now_str(),
        }
        if combo_dir is not None:
            write_debug_report(os.path.join(combo_dir, "NAN_DEBUG_REPORT.json"), payload)
        raise ValueError(
            f"Non-finite value detected at {name}: "
            f"NaN={payload['array_check']['nan_count']}, "
            f"+Inf={payload['array_check']['posinf_count']}, "
            f"-Inf={payload['array_check']['neginf_count']}, "
            f"shape={payload['array_check']['shape']}"
        )


def inverse_transform_multistep_safe(scaler, arr_2d, horizon, name, combo_dir=None):
    """
    Inverse transform multi-step outputs.
    Flatten from (n, horizon) to (-1, 1), apply inverse scaling,
    then reshape back to (n, horizon).
    """
    arr = np.asarray(arr_2d, dtype=np.float32)
    if arr.ndim != 2:
        raise ValueError(f"{name} harus 2D, mendapat shape={arr.shape}")
    if arr.shape[1] != horizon:
        raise ValueError(f"{name} horizon mismatch. Expected {horizon}, got {arr.shape[1]}")

    assert_all_finite(f"{name}_before_inverse_transform", arr, combo_dir)
    inv = scaler.inverse_transform(arr.reshape(-1, 1)).reshape(-1, horizon)
    assert_all_finite(f"{name}_after_inverse_transform", inv, combo_dir)
    return inv


def sanitize_numeric_array(arr, name, combo_dir=None, replace_nan=True):
    """
    Defensive sanitization for additional features used by the Regime-Aware model.
    These features are not applied to y_true/y_pred metric calculations
    to prevent unintended bias in the evaluation results.
    """
    a = np.asarray(arr, dtype=np.float32)
    if np.all(np.isfinite(a)):
        return a

    payload = {
        "stage": name,
        "before": count_nan_inf(a),
        "before_stats": safe_float_stats(a),
        "action": "np.nan_to_num(nan=0, posinf=0, neginf=0)" if replace_nan else "raise",
        "created_at": now_str(),
    }
    if combo_dir is not None:
        write_debug_report(os.path.join(combo_dir, f"{sanitize_name(name)}_SANITIZE_DEBUG.json"), payload)

    if not replace_nan:
        raise ValueError(f"Non-finite value detected at {name}")

    return np.nan_to_num(a, nan=0.0, posinf=0.0, neginf=0.0).astype(np.float32)


# ============================================================
# NELSON SPC RULES 1-8 TERBARU
# ============================================================

def detect_spc1(data, cl, ucl, lcl):
    data = np.asarray(data).flatten()
    return np.any((data > ucl) | (data < lcl))


def detect_spc2(data, cl, ucl=None, lcl=None):
    data = np.asarray(data).flatten()
    for i in range(len(data) - 8):
        seg = data[i:i + 9]
        if np.all(seg > cl) or np.all(seg < cl):
            return True
    return False


def detect_spc3(data):
    data = np.asarray(data).flatten()
    for i in range(len(data) - 5):
        seg = data[i:i + 6]
        diff = np.diff(seg)
        if np.all(diff > 0) or np.all(diff < 0):
            return True
    return False


def detect_spc4(data):
    data = np.asarray(data).flatten()
    for i in range(len(data) - 13):
        seg = data[i:i + 14]
        diff = np.diff(seg)
        if np.any(diff == 0):
            continue
        if np.all(diff[:-1] * diff[1:] < 0):
            return True
    return False


def detect_spc5(data, cl, ucl, lcl):
    data = np.asarray(data).flatten()
    sigma = (ucl - cl) / 3.0
    upper_2sigma = cl + 2 * sigma
    lower_2sigma = cl - 2 * sigma
    for i in range(len(data) - 2):
        seg = data[i:i + 3]
        if np.sum(seg > upper_2sigma) >= 2 or np.sum(seg < lower_2sigma) >= 2:
            return True
    return False


def detect_spc6(data, cl, ucl, lcl):
    data = np.asarray(data).flatten()
    sigma = (ucl - cl) / 3.0
    upper_1sigma = cl + sigma
    lower_1sigma = cl - sigma
    for i in range(len(data) - 4):
        seg = data[i:i + 5]
        if np.sum(seg > upper_1sigma) >= 4 or np.sum(seg < lower_1sigma) >= 4:
            return True
    return False


def detect_spc7(data, cl, ucl, lcl):
    data = np.asarray(data).flatten()
    sigma = (ucl - cl) / 3.0
    for i in range(len(data) - 14):
        seg = data[i:i + 15]
        if np.all(np.abs(seg - cl) < sigma):
            return True
    return False


def detect_spc8(data, cl, ucl, lcl):
    data = np.asarray(data).flatten()
    sigma = (ucl - cl) / 3.0
    for i in range(len(data) - 7):
        seg = data[i:i + 8]
        outside_1sigma = np.all(np.abs(seg - cl) > sigma)
        both_sides = np.any(seg > cl) and np.any(seg < cl)
        if outside_1sigma and both_sides:
            return True
    return False


def detect_spc_multi_rules(data, cl, ucl, lcl):
    matched = []
    if detect_spc1(data, cl, ucl, lcl): matched.append(1)
    if detect_spc2(data, cl, ucl, lcl): matched.append(2)
    if detect_spc3(data): matched.append(3)
    if detect_spc4(data): matched.append(4)
    if detect_spc5(data, cl, ucl, lcl): matched.append(5)
    if detect_spc6(data, cl, ucl, lcl): matched.append(6)
    if detect_spc7(data, cl, ucl, lcl): matched.append(7)
    if detect_spc8(data, cl, ucl, lcl): matched.append(8)
    return matched


def resolve_spc_priority(matched_rules, priority_order=None):
    if not matched_rules:
        return 0
    if priority_order is None:
        priority_order = [1, 2, 3, 4, 5, 6, 7, 8]
    for r in priority_order:
        if r in matched_rules:
            return r
    return matched_rules[0]


def detect_spc_for_windows(series_2d, cl, ucl, lcl, priority_order=None):
    series_2d = np.asarray(series_2d)
    if series_2d.ndim != 2:
        raise ValueError(f"series_2d harus 2D, mendapat shape={series_2d.shape}")
    multi_labels, single_labels = [], []
    for row in series_2d:
        matched = detect_spc_multi_rules(row, cl, ucl, lcl)
        single = resolve_spc_priority(matched, priority_order)
        multi_labels.append(matched)
        single_labels.append(single)
    return multi_labels, single_labels


def calculate_spc_limits_from_reference_windows(reference_windows):
    values = np.asarray(reference_windows).astype(float).flatten()
    cl = float(np.mean(values))
    sigma = float(np.std(values, ddof=0))
    ucl = cl + 3.0 * sigma
    lcl = cl - 3.0 * sigma
    return cl, ucl, lcl


def multilabel_to_binary(multi_labels, num_classes=8):
    y = np.zeros((len(multi_labels), num_classes), dtype=int)
    for i, labs in enumerate(multi_labels):
        for lab in labs:
            if 1 <= lab <= num_classes:
                y[i, lab - 1] = 1
    return y


def format_spc_rules(rule_list):
    if not rule_list:
        return "NORMAL"
    return ",".join([f"SPC-{r}" for r in rule_list])


def evaluate_spc_multilabel(y_true_multi, y_pred_multi, sensor_name, model_name, dataset_no, trial_no):
    y_true_bin = multilabel_to_binary(y_true_multi, 8)
    y_pred_bin = multilabel_to_binary(y_pred_multi, 8)

    precisions, recalls, f1s, _ = precision_recall_fscore_support(
        y_true_bin, y_pred_bin, average=None, zero_division=0
    )

    rows = []
    for idx in range(8):
        yt = y_true_bin[:, idx]
        yp = y_pred_bin[:, idx]
        tp = int(np.sum((yt == 1) & (yp == 1)))
        fp = int(np.sum((yt == 0) & (yp == 1)))
        fn = int(np.sum((yt == 1) & (yp == 0)))
        tn = int(np.sum((yt == 0) & (yp == 0)))
        acc = (tp + tn) / max(len(yt), 1)
        rows.append({
            "Dataset_No": dataset_no,
            "Trial_No": trial_no,
            "Sensor": sensor_name,
            "Model": model_name,
            "SPC_Pattern": idx + 1,
            "Precision": float(precisions[idx]),
            "Recall": float(recalls[idx]),
            "F1-score": float(f1s[idx]),
            "Accuracy": float(acc),
            "GT Support": int(np.sum(yt)),
            "Predicted Support": int(np.sum(yp)),
            "TP": tp, "FP": fp, "FN": fn, "TN": tn,
        })

    subset_acc = float(np.mean(np.all(y_true_bin == y_pred_bin, axis=1)))
    rows.append({
        "Dataset_No": dataset_no,
        "Trial_No": trial_no,
        "Sensor": sensor_name,
        "Model": model_name,
        "SPC_Pattern": "Total",
        "Precision": float(np.mean(precisions)),
        "Recall": float(np.mean(recalls)),
        "F1-score": float(np.mean(f1s)),
        "Accuracy": subset_acc,
        "GT Support": int(np.sum(y_true_bin)),
        "Predicted Support": int(np.sum(y_pred_bin)),
        "TP": int(np.sum((y_true_bin == 1) & (y_pred_bin == 1))),
        "FP": int(np.sum((y_true_bin == 0) & (y_pred_bin == 1))),
        "FN": int(np.sum((y_true_bin == 1) & (y_pred_bin == 0))),
        "TN": int(np.sum((y_true_bin == 0) & (y_pred_bin == 0))),
    })
    return pd.DataFrame(rows)


def evaluate_spc_singlelabel(y_true_single, y_pred_single, sensor_name, model_name, dataset_no, trial_no):
    rows = []
    for lab in range(1, 9):
        yt = np.array([1 if y == lab else 0 for y in y_true_single], dtype=int)
        yp = np.array([1 if y == lab else 0 for y in y_pred_single], dtype=int)

        tp = int(np.sum((yt == 1) & (yp == 1)))
        fp = int(np.sum((yt == 0) & (yp == 1)))
        fn = int(np.sum((yt == 1) & (yp == 0)))
        tn = int(np.sum((yt == 0) & (yp == 0)))

        precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
        recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
        f1 = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0.0
        acc = (tp + tn) / max(len(yt), 1)

        rows.append({
            "Dataset_No": dataset_no,
            "Trial_No": trial_no,
            "Sensor": sensor_name,
            "Model": model_name,
            "SPC_Pattern": lab,
            "Precision": float(precision),
            "Recall": float(recall),
            "F1-score": float(f1),
            "Accuracy": float(acc),
            "GT Support": int(np.sum(yt)),
            "Predicted Support": int(np.sum(yp)),
            "TP": tp, "FP": fp, "FN": fn, "TN": tn,
        })

    rows.append({
        "Dataset_No": dataset_no,
        "Trial_No": trial_no,
        "Sensor": sensor_name,
        "Model": model_name,
        "SPC_Pattern": "Total",
        "Precision": float(np.mean([r["Precision"] for r in rows])),
        "Recall": float(np.mean([r["Recall"] for r in rows])),
        "F1-score": float(np.mean([r["F1-score"] for r in rows])),
        "Accuracy": float(accuracy_score(y_true_single, y_pred_single)),
        "GT Support": int(len([x for x in y_true_single if x != 0])),
        "Predicted Support": int(len([x for x in y_pred_single if x != 0])),
        "TP": None, "FP": None, "FN": None, "TN": None,
    })
    return pd.DataFrame(rows)


def build_bukti_df(y_true_windows, y_pred_windows, y_true_multi, y_pred_multi, y_true_single, y_pred_single,
                   dataset_no, trial_no, sensor_name, model_name):
    rows = []
    h = y_true_windows.shape[1]
    for i in range(len(y_true_windows)):
        row = {
            "Dataset_No": dataset_no,
            "Trial_No": trial_no,
            "Sample_Index": i,
            "Sensor": sensor_name,
            "Model": model_name,
        }
        for j in range(h):
            row[f"y_true_{j+1}"] = float(y_true_windows[i, j])
        for j in range(h):
            row[f"y_pred_{j+1}"] = float(y_pred_windows[i, j])
        row["SPC_true_rules"] = format_spc_rules(y_true_multi[i])
        row["SPC_pred_rules"] = format_spc_rules(y_pred_multi[i])
        row["SPC_true_single"] = f"SPC-{y_true_single[i]}" if y_true_single[i] != 0 else "NORMAL"
        row["SPC_pred_single"] = f"SPC-{y_pred_single[i]}" if y_pred_single[i] != 0 else "NORMAL"
        row["Multi_exact_match"] = int(set(y_true_multi[i]) == set(y_pred_multi[i]))
        row["Single_match"] = int(y_true_single[i] == y_pred_single[i])
        rows.append(row)
    return pd.DataFrame(rows)


# ============================================================
# WINDOWING
# ============================================================

def clean_series(series):
    s = pd.to_numeric(pd.Series(series), errors="coerce")
    s = s.replace([np.inf, -np.inf], np.nan)
    s = s.interpolate(method="linear", limit_direction="both")
    s = s.ffill().bfill()
    return s


def create_sequences_1d(data_scaled, window_size, horizon):
    X, y = [], []
    for i in range(len(data_scaled) - window_size - horizon + 1):
        X.append(data_scaled[i:i + window_size])
        y.append(data_scaled[i + window_size:i + window_size + horizon].flatten())
    return np.asarray(X, dtype=np.float32), np.asarray(y, dtype=np.float32)


def split_train_test(X, y, train_ratio):
    n = len(X)
    n_train = int(n * train_ratio)
    n_train = min(max(n_train, 1), n - 1)
    return X[:n_train], y[:n_train], X[n_train:], y[n_train:]


def split_train_val(X_train, y_train, val_ratio):
    n = len(X_train)
    n_sub = int(n * (1 - val_ratio))
    n_sub = min(max(n_sub, 1), n - 1)
    return X_train[:n_sub], y_train[:n_sub], X_train[n_sub:], y_train[n_sub:]


# ============================================================
# MODEL BUILDERS
# ============================================================

def build_gru(window_size, horizon):
    model = Sequential([Input(shape=(window_size, 1)), GRU(100), Dense(horizon)])
    model.compile(optimizer="adam", loss="mse", metrics=["mae"])
    return model


def build_vanilla_lstm(window_size, horizon):
    model = Sequential([Input(shape=(window_size, 1)), LSTM(100), Dense(horizon)])
    model.compile(optimizer="adam", loss="mse", metrics=["mae"])
    return model


def build_deep_lstm(window_size, horizon):
    model = Sequential([
        Input(shape=(window_size, 1)),
        LSTM(100, return_sequences=True),
        LSTM(100, return_sequences=True),
        LSTM(100),
        Dense(horizon),
    ])
    model.compile(optimizer="adam", loss="mse", metrics=["mae"])
    return model


def build_direct_multi_step_lstm(window_size, horizon):
    model = Sequential([
        Input(shape=(window_size, 1)),
        LSTM(100, return_sequences=True),
        LSTM(100),
        Dense(horizon),
    ])
    model.compile(optimizer="adam", loss="mse", metrics=["mae"])
    return model


def build_encoder_decoder_lstm(window_size, horizon):
    model = Sequential([
        Input(shape=(window_size, 1)),
        LSTM(100),
        RepeatVector(horizon),
        LSTM(100),
        Dense(horizon),
    ])
    model.compile(optimizer="adam", loss="mse", metrics=["mae"])
    return model


def build_attention_lstm(window_size, horizon):
    inp = Input(shape=(window_size, 1), name="input_sequence")
    x = LSTM(100, return_sequences=True)(inp)
    attn = Attention()([x, x])
    pooled = GlobalAveragePooling1D()(attn)
    out = Dense(horizon)(pooled)
    model = Model(inputs=inp, outputs=out)
    model.compile(optimizer="adam", loss="mse", metrics=["mae"])
    return model


def build_bi_lstm(window_size, horizon):
    model = Sequential([
        Input(shape=(window_size, 1)),
        Bidirectional(LSTM(100)),
        Dense(horizon),
    ])
    model.compile(optimizer="adam", loss="mse", metrics=["mae"])
    return model


def build_model(model_key, window_size, horizon):
    builders = {
        "GRU": build_gru,
        "VANILLA_LSTM": build_vanilla_lstm,
        "DEEP_LSTM": build_deep_lstm,
        "DIRECT_MULTI_STEP_LSTM": build_direct_multi_step_lstm,
        "ENCODER_DECODER_LSTM": build_encoder_decoder_lstm,
        "ATTENTION_LSTM": build_attention_lstm,
        "BI_LSTM": build_bi_lstm,
    }
    if model_key not in builders:
        raise ValueError(f"Model key tidak dikenal atau bukan 7 baseline: {model_key}")
    return builders[model_key](window_size, horizon)


def prepare_model_inputs(model_key, X_seq, combo_dir=None, stage="model_input"):
    # Semua 7 baseline hanya memakai input sekuens suhu shape: (batch, window_size, 1).
    return sanitize_numeric_array(X_seq, f"{stage}_X_seq", combo_dir=combo_dir, replace_nan=True)


# ============================================================
# PLOT
# ============================================================

def plot_actual_vs_pred(y_true, y_pred, title, path, n_last=200):
    y_true = np.asarray(y_true).reshape(-1)
    y_pred = np.asarray(y_pred).reshape(-1)
    if len(y_true) > n_last:
        y_true = y_true[-n_last:]
        y_pred = y_pred[-n_last:]
    plt.figure(figsize=(14, 5))
    plt.plot(y_true, label="Actual")
    plt.plot(y_pred, label="Predicted")
    plt.title(title)
    plt.xlabel("Index")
    plt.ylabel("Value")
    plt.legend()
    plt.grid(True, alpha=0.3)
    plt.tight_layout()
    plt.savefig(path, dpi=150)
    plt.close()


# ============================================================
# TRAIN ONE COMBINATION
# ============================================================

def train_one_combination(dataset_no, trial_no, signal_name, signal_values, model_key, output_dir):
    cfg = MODEL_CONFIGS[model_key]
    display_name = cfg["display_name"]
    patience = cfg["patience"]

    safe_signal = sanitize_name(signal_name)
    safe_model = sanitize_name(display_name)
    combo_dir = os.path.join(output_dir, safe_signal, safe_model)
    spc_dir = os.path.join(combo_dir, "spc")
    ensure_dir(combo_dir)
    ensure_dir(spc_dir)
    # ========================================================
    # AUTO RESUME CHECK
    # ========================================================
    if AUTO_RESUME:
        combo_status = get_combo_status(combo_dir)

        if combo_status == "SUCCESS" and SKIP_SUCCESSFUL_COMBINATION:
            old_result = load_done_result(combo_dir)
            if old_result is not None:
                old_result["Status"] = "SUCCESS"
                old_result["Resume_Status"] = "SKIPPED_ALREADY_DONE"
                return old_result

        if combo_status == "FAILED" and not RERUN_FAILED_COMBINATION:
            return {
                "#sensor": signal_name,
                "Dataset_No": dataset_no,
                "Trial_No": trial_no,
                "Sensor": signal_name,
                "Model": display_name,
                "Model_Key": model_key,
                "Status": "SKIPPED_PREVIOUSLY_FAILED",
                "Error_Message": "Combination previously failed and RERUN_FAILED_COMBINATION=False",
            }
    result = {
        "#sensor": signal_name,
        "Dataset_No": dataset_no,
        "Trial_No": trial_no,
        "Sensor": signal_name,
        "Model": display_name,
        "Model_Key": model_key,
        "Diagnostic_MAE": np.nan,
        "Diagnostic_MSE": np.nan,
        "Diagnostic_RMSE": np.nan,
        "Diagnostic_MAPE": np.nan,
        "Diagnostic_R2": np.nan,
        "SPC_Multi_Total_Precision": np.nan,
        "SPC_Multi_Total_Recall": np.nan,
        "SPC_Multi_Total_F1": np.nan,
        "SPC_Multi_Total_Accuracy": np.nan,
        "SPC_Single_Total_Precision": np.nan,
        "SPC_Single_Total_Recall": np.nan,
        "SPC_Single_Total_F1": np.nan,
        "SPC_Single_Total_Accuracy": np.nan,
        "Status": "SUCCESS",
        "Error_Message": "",
        "Model_Path": "",
        "Scaler_Path": "",
        "SPC_Rekap_Multi_Path": "",
        "SPC_Rekap_Single_Path": "",
        "SPC_Bukti_Path": "",
        "Epochs_Completed": np.nan,
        "Training_Duration_Seconds": np.nan,
        "Patience": patience,
        "Model_Architecture": cfg["architecture"],
    }

    try:
        seed_offset = (trial_no * 10000) + (dataset_no * 1000) + (sum(ord(c) for c in (signal_name + model_key)) % 1000)
        tf.keras.utils.set_random_seed(RANDOM_SEED_BASE + seed_offset)

        series = clean_series(signal_values)
        series_np = series.to_numpy(dtype=np.float32).reshape(-1, 1)
        assert_all_finite("series_np_after_clean_series", series_np, combo_dir, extra={
            "sensor": signal_name,
            "trial_no": trial_no,
            "dataset_no": dataset_no,
        })

        scaler = MinMaxScaler()
        scaled = scaler.fit_transform(series_np)
        assert_all_finite("scaled_after_minmax", scaled, combo_dir)

        X, y = create_sequences_1d(scaled, WINDOW_SIZE, HORIZON)
        X = X.reshape((X.shape[0], X.shape[1], 1))
        assert_all_finite("X_after_windowing", X, combo_dir)
        assert_all_finite("y_after_windowing", y, combo_dir)

        X_train, y_train, X_test, y_test = split_train_test(X, y, TRAIN_RATIO)
        X_sub, y_sub, X_val, y_val = split_train_val(X_train, y_train, VAL_RATIO_FROM_TRAIN)

        cleanup_memory()
        model = build_model(model_key, WINDOW_SIZE, HORIZON)

        X_sub_in = prepare_model_inputs(model_key, X_sub, combo_dir=combo_dir, stage="train")
        X_val_in = prepare_model_inputs(model_key, X_val, combo_dir=combo_dir, stage="val")
        X_test_in = prepare_model_inputs(model_key, X_test, combo_dir=combo_dir, stage="test")

        callbacks = [
            TerminateOnNaN(),
            EarlyStopping(
                monitor="val_loss",
                patience=patience,
                restore_best_weights=True,
                verbose=1,
            )
        ]

        t0 = time.time()
        history = model.fit(
            X_sub_in,
            y_sub,
            validation_data=(X_val_in, y_val),
            epochs=EPOCHS,
            batch_size=BATCH_SIZE,
            shuffle=False,
            callbacks=callbacks,
            verbose=1,
        )
        duration = time.time() - t0

        hist_df = pd.DataFrame(history.history)
        hist_df.insert(0, "Epoch", np.arange(1, len(hist_df) + 1))
        hist_df.to_excel(os.path.join(combo_dir, "training_history.xlsx"), index=False)

        y_pred_scaled = model.predict(X_test_in, verbose=0)

        # Anti-NaN checkpoint sebelum inverse scaling.
        # Untuk metric, NaN tidak diganti agar hasil tidak bias; jika ada NaN, kombinasi ditandai FAILED dengan debug report.
        assert_all_finite("y_test_scaled_before_inverse", y_test, combo_dir)
        assert_all_finite("y_train_scaled_before_inverse", y_train, combo_dir)
        assert_all_finite("y_pred_scaled_before_inverse", y_pred_scaled, combo_dir, extra={
            "model_key": model_key,
            "sensor": signal_name,
            "trial_no": trial_no,
            "dataset_no": dataset_no,
        })

        y_test_inv = inverse_transform_multistep_safe(scaler, y_test, HORIZON, "y_test", combo_dir)
        y_pred_inv = inverse_transform_multistep_safe(scaler, y_pred_scaled, HORIZON, "y_pred", combo_dir)
        y_train_inv = inverse_transform_multistep_safe(scaler, y_train, HORIZON, "y_train", combo_dir)

        y_true_flat = y_test_inv.reshape(-1)
        y_pred_flat = y_pred_inv.reshape(-1)

        assert_all_finite("y_true_flat_before_metrics", y_true_flat, combo_dir)
        assert_all_finite("y_pred_flat_before_metrics", y_pred_flat, combo_dir)

        mae = mean_absolute_error(y_true_flat, y_pred_flat)
        mse = mean_squared_error(y_true_flat, y_pred_flat)
        rmse = rmse_score(y_true_flat, y_pred_flat)
        mape = safe_mape(y_true_flat, y_pred_flat)
        r2 = r2_score(y_true_flat, y_pred_flat)

        # SPC from windows t+1..t+15 using training windows for limits.
        cl, ucl, lcl = calculate_spc_limits_from_reference_windows(y_train_inv)
        y_true_multi, y_true_single = detect_spc_for_windows(y_test_inv, cl, ucl, lcl)
        y_pred_multi, y_pred_single = detect_spc_for_windows(y_pred_inv, cl, ucl, lcl)

        rekap_multi = evaluate_spc_multilabel(
            y_true_multi, y_pred_multi, signal_name, display_name, dataset_no, trial_no
        )
        rekap_single = evaluate_spc_singlelabel(
            y_true_single, y_pred_single, signal_name, display_name, dataset_no, trial_no
        )
        bukti_df = build_bukti_df(
            y_test_inv, y_pred_inv, y_true_multi, y_pred_multi,
            y_true_single, y_pred_single, dataset_no, trial_no, signal_name, display_name
        )

        multi_path = os.path.join(spc_dir, "REKAP_SPC_MULTI.xlsx")
        single_path = os.path.join(spc_dir, "REKAP_SPC_SINGLE.xlsx")
        bukti_path = os.path.join(spc_dir, "BUKTI_PREDIKSI_SPC.xlsx")

        rekap_multi.to_excel(multi_path, index=False)
        rekap_single.to_excel(single_path, index=False)
        bukti_df.to_excel(bukti_path, index=False)

        multi_total = rekap_multi[rekap_multi["SPC_Pattern"].astype(str) == "Total"].iloc[0]
        single_total = rekap_single[rekap_single["SPC_Pattern"].astype(str) == "Total"].iloc[0]

        pred_df = pd.concat([
            pd.DataFrame(y_test_inv, columns=[f"Actual_t+{i+1}" for i in range(HORIZON)]),
            pd.DataFrame(y_pred_inv, columns=[f"Pred_t+{i+1}" for i in range(HORIZON)]),
        ], axis=1)
        pred_df.to_excel(os.path.join(combo_dir, "TEST_ACTUAL_VS_PREDICTED.xlsx"), index=False)

        plot_actual_vs_pred(
            y_test_inv[:, 0],
            y_pred_inv[:, 0],
            f"{signal_name} - {display_name} - Test t+1",
            os.path.join(combo_dir, "PLOT_TEST_TPLUS1_LAST200.png"),
            n_last=200,
        )

        model_path = os.path.join(combo_dir, f"{safe_signal}_{cfg['model_file_suffix']}.keras")
        scaler_path = os.path.join(combo_dir, f"{safe_signal}_scaler.pkl")
        model.save(model_path)
        joblib.dump(scaler, scaler_path)

        result.update({
            "Diagnostic_MAE": float(mae),
            "Diagnostic_MSE": float(mse),
            "Diagnostic_RMSE": float(rmse),
            "Diagnostic_MAPE": float(mape),
            "Diagnostic_R2": float(r2),
            "SPC_Multi_Total_Precision": float(multi_total["Precision"]),
            "SPC_Multi_Total_Recall": float(multi_total["Recall"]),
            "SPC_Multi_Total_F1": float(multi_total["F1-score"]),
            "SPC_Multi_Total_Accuracy": float(multi_total["Accuracy"]),
            "SPC_Single_Total_Precision": float(single_total["Precision"]),
            "SPC_Single_Total_Recall": float(single_total["Recall"]),
            "SPC_Single_Total_F1": float(single_total["F1-score"]),
            "SPC_Single_Total_Accuracy": float(single_total["Accuracy"]),
            "Model_Path": model_path,
            "Scaler_Path": scaler_path,
            "SPC_Rekap_Multi_Path": multi_path,
            "SPC_Rekap_Single_Path": single_path,
            "SPC_Bukti_Path": bukti_path,
            "Epochs_Completed": int(len(history.history.get("loss", []))),
            "Training_Duration_Seconds": float(duration),
        })
        mark_combo_done(combo_dir, result)
    except Exception as e:
        result["Status"] = "FAILED"
        result["Error_Message"] = str(e)
        mark_combo_failed(combo_dir, result)
        with open(os.path.join(combo_dir, "ERROR_TRACE.txt"), "w", encoding="utf-8") as f:
            f.write(traceback.format_exc())

    cleanup_memory()
    return result


# ============================================================
# AGGREGATION, CV, SCORE, PARETO
# ============================================================

def stability_class_from_cv(cv):
    if pd.isna(cv):
        return "UNKNOWN"
    if cv <= 0.10:
        return "STABLE"
    if cv <= 0.30:
        return "MODERATE"
    return "UNSTABLE"


def add_score_and_pareto(df):
    """
    Add ranking and Pareto indicators aligned with the manuscript focus.

    Primary ranking:
    - SPC_Multi_Total_F1_mean      : higher is better
    - SPC_Multi_Total_Accuracy_mean: higher is better
    - CV_F1_SPC                   : lower is better

    Forecasting errors are not used for primary ranking in this paper.
    They are retained only as diagnostic outputs.
    """
    out = df.copy()

    out["CV_F1_SPC"] = (
        out["SPC_Multi_Total_F1_std"] /
        out["SPC_Multi_Total_F1_mean"].replace(0, np.nan)
    )

    # Primary SPC score: high F1, low CV_F1, high accuracy.
    out["Rank_F1_SPC"] = out.groupby("Sensor")["SPC_Multi_Total_F1_mean"].rank(
        method="dense", ascending=False
    )
    out["Rank_CV_F1_SPC"] = out.groupby("Sensor")["CV_F1_SPC"].rank(
        method="dense", ascending=True
    )
    out["Rank_ACC_SPC"] = out.groupby("Sensor")["SPC_Multi_Total_Accuracy_mean"].rank(
        method="dense", ascending=False
    )
    out["Score_SPC"] = out[["Rank_F1_SPC", "Rank_CV_F1_SPC", "Rank_ACC_SPC"]].mean(axis=1)

    # Primary SPC Pareto: maximize F1, maximize accuracy, minimize CV_F1.
    out["Pareto_SPC"] = False

    for sensor, g in out.groupby("Sensor"):
        idxs = list(g.index)

        for i in idxs:
            a = out.loc[i]
            dominated = False
            for j in idxs:
                if i == j:
                    continue
                b = out.loc[j]
                cond_b_better_or_equal = (
                    (b["SPC_Multi_Total_F1_mean"] >= a["SPC_Multi_Total_F1_mean"]) and
                    (b["SPC_Multi_Total_Accuracy_mean"] >= a["SPC_Multi_Total_Accuracy_mean"]) and
                    (b["CV_F1_SPC"] <= a["CV_F1_SPC"])
                )
                cond_b_strict = (
                    (b["SPC_Multi_Total_F1_mean"] > a["SPC_Multi_Total_F1_mean"]) or
                    (b["SPC_Multi_Total_Accuracy_mean"] > a["SPC_Multi_Total_Accuracy_mean"]) or
                    (b["CV_F1_SPC"] < a["CV_F1_SPC"])
                )
                if cond_b_better_or_equal and cond_b_strict:
                    dominated = True
                    break
            out.loc[i, "Pareto_SPC"] = not dominated

    return out

def aggregate_mean_std(detail_df):
    # Primary metrics reported by the manuscript.
    spc_metric_cols = [
        "SPC_Multi_Total_Precision",
        "SPC_Multi_Total_Recall",
        "SPC_Multi_Total_F1",
        "SPC_Multi_Total_Accuracy",
        "SPC_Single_Total_Precision",
        "SPC_Single_Total_Recall",
        "SPC_Single_Total_F1",
        "SPC_Single_Total_Accuracy",
    ]

    # Diagnostic forecasting metrics retained for audit/reuse, not used for primary ranking.
    diagnostic_cols = [
        "Diagnostic_MAE",
        "Diagnostic_MSE",
        "Diagnostic_RMSE",
        "Diagnostic_MAPE",
        "Diagnostic_R2",
    ] if INCLUDE_FORECASTING_DIAGNOSTICS else []

    metric_cols = spc_metric_cols + diagnostic_cols

    work = detail_df.copy()
    if "Status" in work.columns:
        work = work[work["Status"].astype(str).str.upper() == "SUCCESS"].copy()

    grouped = work.groupby(["Sensor", "Model"], as_index=False).agg(
        {c: ["mean", "std"] for c in metric_cols if c in work.columns}
    )
    grouped.columns = [
        f"{a}_{b}" if b else a
        for a, b in grouped.columns.to_flat_index()
    ]
    grouped = grouped.rename(columns={"Sensor_": "Sensor", "Model_": "Model"})

    grouped.insert(0, "#sensor", grouped["Sensor"])

    grouped = add_score_and_pareto(grouped)

    final_cols = [
        "#sensor", "Sensor", "Model",
        "SPC_Multi_Total_Precision_mean", "SPC_Multi_Total_Precision_std",
        "SPC_Multi_Total_Recall_mean", "SPC_Multi_Total_Recall_std",
        "SPC_Multi_Total_F1_mean", "SPC_Multi_Total_F1_std",
        "SPC_Multi_Total_Accuracy_mean", "SPC_Multi_Total_Accuracy_std",
        "SPC_Single_Total_Precision_mean", "SPC_Single_Total_Precision_std",
        "SPC_Single_Total_Recall_mean", "SPC_Single_Total_Recall_std",
        "SPC_Single_Total_F1_mean", "SPC_Single_Total_F1_std",
        "SPC_Single_Total_Accuracy_mean", "SPC_Single_Total_Accuracy_std",
        "CV_F1_SPC", "Score_SPC", "Pareto_SPC",
        # Diagnostic forecasting outputs; not reported as primary results in the manuscript.
        "Diagnostic_MAE_mean", "Diagnostic_MAE_std",
        "Diagnostic_MSE_mean", "Diagnostic_MSE_std",
        "Diagnostic_RMSE_mean", "Diagnostic_RMSE_std",
        "Diagnostic_MAPE_mean", "Diagnostic_MAPE_std",
        "Diagnostic_R2_mean", "Diagnostic_R2_std",
    ]
    for c in final_cols:
        if c not in grouped.columns:
            grouped[c] = np.nan
    return grouped[final_cols].sort_values(["Sensor", "Score_SPC"]).reset_index(drop=True)

def save_detail_and_aggregate(detail_rows, output_dir, prefix):
    detail_df = pd.DataFrame(detail_rows)
    detail_path = os.path.join(output_dir, f"{prefix}_DETAIL.xlsx")
    detail_df.to_excel(detail_path, index=False)

    aggregate_df = aggregate_mean_std(detail_df)
    aggregate_path = os.path.join(output_dir, f"{prefix}_MEAN_STD_LEADERBOARD.xlsx")
    aggregate_df.to_excel(aggregate_path, index=False)

    return detail_df, aggregate_df, detail_path, aggregate_path


# ============================================================
# RANKING PER REGIME × TRIAL
# ============================================================
def build_ranking_model_per_regime_trial(detail_df):
    """
    Optional SPC-only ranking per regime and trial.

    This helper is aligned with the manuscript focus on SPC classification.
    Forecasting diagnostics are intentionally not used in this ranking.
    """
    work = detail_df.copy()
    work = work[work["Status"].astype(str).str.upper() == "SUCCESS"].copy()

    # This map is retained for backward compatibility only.
    # The final manuscript regime assignment is reported separately in the clustering output.
    regime_map = {
        "Sensor_1": "Transitional-Dynamic",
        "Sensor_2": "Transitional-Dynamic",
        "Sensor_3": "Transitional-Dynamic",
        "Sensor_6": "Transitional-Dynamic",
        "Sensor_7": "Transitional-Dynamic",
        "Sensor_4": "Structured-Persistent",
        "Sensor_5": "Structured-Persistent",
        "Sensor_8": "Structured-Persistent",
        "Sensor_10": "Structured-Persistent",
        "Sensor_11": "Structured-Persistent",
        "Sensor_9": "Spike-Dominated",
    }

    work["Regime"] = work["Sensor"].map(regime_map).fillna(work["Sensor"])

    metric_cols = [
        "SPC_Multi_Total_F1",
        "SPC_Multi_Total_Accuracy",
    ]

    agg = work.groupby(
        ["Trial_No", "Regime", "Model"],
        as_index=False
    )[metric_cols].agg(["mean", "std"])

    agg.columns = [
        "_".join(col).strip("_") if isinstance(col, tuple) else col
        for col in agg.columns
    ]

    agg = agg.reset_index(drop=True)

    agg["Rank_SPC_F1"] = agg.groupby(["Trial_No", "Regime"])["SPC_Multi_Total_F1_mean"].rank(
        method="dense", ascending=False
    )

    agg["Rank_SPC_ACC"] = agg.groupby(["Trial_No", "Regime"])["SPC_Multi_Total_Accuracy_mean"].rank(
        method="dense", ascending=False
    )

    agg["SPC_Score"] = agg[["Rank_SPC_F1", "Rank_SPC_ACC"]].mean(axis=1)

    agg["SPC_Rank"] = agg.groupby(["Trial_No", "Regime"])["SPC_Score"].rank(
        method="dense", ascending=True
    )

    agg = agg.sort_values(
        ["Trial_No", "Regime", "SPC_Rank", "SPC_Score"]
    ).reset_index(drop=True)

    return agg

# ============================================================
# MAIN PIPELINE DATA ORIGIN
# ============================================================

def load_origin_sensor_data():
    if not os.path.exists(DATA_ORIGIN_FILE):
        raise FileNotFoundError(
            f"File data origin tidak ditemukan: {DATA_ORIGIN_FILE}\n"
            "Silakan cek kembali path DATA_ORIGIN_FILE."
        )

    df = pd.read_excel(DATA_ORIGIN_FILE)
    sensor_series = {}

    for sensor_name in SELECTED_SENSOR_NAMES:
        if sensor_name not in SENSOR_COLUMN_POSITIONS:
            raise ValueError(f"{sensor_name} tidak ada di SENSOR_COLUMN_POSITIONS.")

        col_idx = SENSOR_COLUMN_POSITIONS[sensor_name]
        if col_idx >= df.shape[1]:
            raise ValueError(
                f"{sensor_name} memakai index kolom {col_idx}, "
                f"tetapi data hanya memiliki {df.shape[1]} kolom."
            )

        sensor_series[sensor_name] = df.iloc[:, col_idx].values

    return df, sensor_series


def main():
    try_mount_drive()
    ensure_dir(ROOT_OUTPUT)

    root_log = os.path.join(ROOT_OUTPUT, "ROOT_PIPELINE_LOG.txt")
    log("Starting the original-data training pipeline: 11 sensors, 7 baseline models, 10 independent trials", root_log)

    df_origin, sensor_series_map = load_origin_sensor_data()

    root_all_detail_rows = []
    trial_aggregate_frames = []

    config = {
        "ROOT_OUTPUT": ROOT_OUTPUT,
        "DATA_ORIGIN_FILE": DATA_ORIGIN_FILE,
        "N_TRIALS": N_TRIALS,
        "WINDOW_SIZE": WINDOW_SIZE,
        "HORIZON": HORIZON,
        "TRAIN_RATIO": TRAIN_RATIO,
        "VAL_RATIO_FROM_TRAIN": VAL_RATIO_FROM_TRAIN,
        "EPOCHS": EPOCHS,
        "BATCH_SIZE": BATCH_SIZE,
        "INCLUDE_FORECASTING_DIAGNOSTICS": INCLUDE_FORECASTING_DIAGNOSTICS,
        "SELECTED_MODELS": SELECTED_MODELS,
        "SELECTED_SENSOR_NAMES": SELECTED_SENSOR_NAMES,
        "SENSOR_COLUMN_POSITIONS": SENSOR_COLUMN_POSITIONS,
        "DATA_SHAPE": list(df_origin.shape),
        "created_at": now_str(),
    }
    with open(os.path.join(ROOT_OUTPUT, "EXPERIMENT_CONFIG.json"), "w", encoding="utf-8") as f:
        json.dump(config, f, indent=4, ensure_ascii=False)

    for trial_no in range(1, N_TRIALS + 1):
        trial_root_dir = os.path.join(ROOT_OUTPUT, f"TRIAL_{trial_no:02d}")
        ensure_dir(trial_root_dir)

        log(f"Mulai TRIAL_{trial_no:02d}", root_log)
        trial_detail_rows = []

        for sensor_name, sensor_values in sensor_series_map.items():
            sensor_dir = os.path.join(trial_root_dir, sensor_name)
            ensure_dir(sensor_dir)

            log(f"TRIAL_{trial_no:02d} | Mulai {sensor_name}", root_log)

            sensor_detail_rows = []

            for model_key in SELECTED_MODELS:
                display_name = MODEL_CONFIGS[model_key]["display_name"]
                log(
                    f"TRIAL_{trial_no:02d} | {sensor_name} | {display_name}",
                    root_log
                )

                result = train_one_combination(
                    dataset_no=1,  # data origin tunggal
                    trial_no=trial_no,
                    signal_name=sensor_name,
                    signal_values=sensor_values,
                    model_key=model_key,
                    output_dir=sensor_dir,
                )

                # Tambahan identitas agar jelas bahwa ini data origin.
                result["Data_Source"] = "ORIGIN"
                result["Origin_File"] = DATA_ORIGIN_FILE
                result["Sensor_Column_Index_Python"] = SENSOR_COLUMN_POSITIONS[sensor_name]
                result["Sensor_Column_Number_Excel"] = SENSOR_COLUMN_POSITIONS[sensor_name] + 1

                sensor_detail_rows.append(result)
                trial_detail_rows.append(result)
                root_all_detail_rows.append(result)

                # Current detail per sensor.
                pd.DataFrame(sensor_detail_rows).to_excel(
                    os.path.join(
                        sensor_dir,
                        f"HASIL_EXPERIMENT_TRIAL_{trial_no:02d}_{sensor_name}_CURRENT_DETAIL.xlsx"
                    ),
                    index=False
                )

                # Current detail root supaya aman kalau runtime terputus.
                pd.DataFrame(root_all_detail_rows).to_excel(
                    os.path.join(ROOT_OUTPUT, "TOTAL_ALL_SENSOR_DETAIL_CURRENT.xlsx"),
                    index=False
                )

            # Selesai semua model pada 1 sensor.
            sensor_detail_df, sensor_agg_df, sensor_detail_path, sensor_agg_path = save_detail_and_aggregate(
                sensor_detail_rows,
                sensor_dir,
                prefix=f"HASIL_EXPERIMENT_TRIAL_{trial_no:02d}_{sensor_name}"
            )

            log(f"Selesai TRIAL_{trial_no:02d} | {sensor_name}. Detail: {sensor_detail_path}", root_log)
            log(f"Selesai TRIAL_{trial_no:02d} | {sensor_name}. Leaderboard: {sensor_agg_path}", root_log)

        # Completed processing all sensors for the current trial.
        trial_detail_df = pd.DataFrame(trial_detail_rows)
        trial_detail_path = os.path.join(
            trial_root_dir,
            f"TRIAL_{trial_no:02d}_DETAIL.xlsx"
        )
        trial_detail_df.to_excel(trial_detail_path, index=False)

        trial_agg_df = aggregate_mean_std(trial_detail_df)
        trial_agg_path = os.path.join(
            trial_root_dir,
            f"TRIAL_{trial_no:02d}_AGGREGATE_MEAN_STD.xlsx"
        )
        trial_agg_df.to_excel(trial_agg_path, index=False)

        trial_aggregate_frames.append(
            trial_agg_df.assign(
                Trial_No=trial_no,
                Data_Source="ORIGIN"
            )
        )

        log(f"Selesai agregasi TRIAL_{trial_no:02d}: {trial_agg_path}", root_log)

    # Root-level detailed results and aggregated summaries across
    # 10 trials × 11 sensors × 7 baseline models.       
    root_detail_df = pd.DataFrame(root_all_detail_rows)
    root_detail_path = os.path.join(ROOT_OUTPUT, "TOTAL_ALL_SENSOR_DETAIL.xlsx")
    root_detail_df.to_excel(root_detail_path, index=False)

    root_agg_df = aggregate_mean_std(root_detail_df)
    root_agg_path = os.path.join(ROOT_OUTPUT, "TOTAL_ALL_SENSOR_MEAN_STD.xlsx")
    root_agg_df.to_excel(root_agg_path, index=False)

    leaderboard_path = os.path.join(ROOT_OUTPUT, "FINAL_LEADERBOARD_ALL_SENSOR.xlsx")
    root_agg_df.sort_values(["Sensor", "Score_SPC"]).to_excel(leaderboard_path, index=False)

    if trial_aggregate_frames:
        all_trial_agg = pd.concat(trial_aggregate_frames, ignore_index=True)
    else:
        all_trial_agg = pd.DataFrame()

    all_trial_agg.to_excel(
        os.path.join(ROOT_OUTPUT, "ALL_TRIAL_AGGREGATE_PER_TRIAL.xlsx"),
        index=False
    )

    log(f"Pipeline selesai. Detail root: {root_detail_path}", root_log)
    log(f"Pipeline selesai. Mean/std total: {root_agg_path}", root_log)
    log(f"Pipeline selesai. Leaderboard final: {leaderboard_path}", root_log)


if __name__ == "__main__":
    main()
