# OID_analysis/li22b/phase_a3_lse.py
"""Phase A.3: LSE — [sensors, b] → POD coefficients.

Li22b Eq 2.7-2.8: T_ij solves a_i = sum_j T_ij * q_j
where q = [sensors (18 channels), b (3)] = 21 inputs.

Usage:
  PYTHONPATH="src:$PYTHONPATH" python3 src/OID_analysis/li22b/phase_a3_lse.py
"""
import os, sys, json, glob
import numpy as np

_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
if _REPO not in sys.path:
    sys.path.insert(0, _REPO)

DATA_BASE = os.path.join(os.path.dirname(__file__), "..", "data_li22b")
DERIVED = os.path.join(DATA_BASE, "derived")
os.makedirs(os.path.join(DERIVED, "lse"), exist_ok=True)


def load_all():
    cmd_dirs = sorted(glob.glob(os.path.join(DATA_BASE, "[0-9][0-9][0-9]")))
    all_sens, all_forces, all_b_snap, all_configs = [], [], [], []
    for d in cmd_dirs:
        sfp = os.path.join(d, "sensors.npz")
        ffp = os.path.join(d, "forces.npz")
        if not os.path.isfile(sfp): continue
        sens = np.load(sfp)["sensors"]   # (200, 18)
        force = np.load(ffp)["forces"]   # (200, 6)
        all_sens.append(sens); all_forces.append(force)
        with open(os.path.join(d, "config.json")) as f:
            cfg = json.load(f)
        all_configs.append(cfg)
        # Repeat b for each time step
        b_rep = np.tile(cfg["b"], (sens.shape[0], 1))  # (200, 3)
        all_b_snap.append(b_rep)
    S = np.concatenate(all_sens, axis=0)
    B = np.concatenate(all_b_snap, axis=0)
    N = S.shape[0]
    print(f"Loaded {N} snapshots from {len(cmd_dirs)} commands")
    return S, B, all_configs, len(cmd_dirs)


def main():
    S, B, configs, n_cmds = load_all()
    N = S.shape[0]

    # Load POD coefs (handles .npy or .npy.npz naming)
    coef_base = os.path.join(DERIVED, "pod", "pod_coefs.npy")
    for ext in [".npy", ".npy.npz", ".npz"]:
        fp = coef_base + ext
        if os.path.isfile(fp):
            pod = np.load(fp)
            break
    else:
        raise FileNotFoundError(f"No pod_coefs found")
    A = pod["coefs"]  # (N, r)
    r = A.shape[1]
    print(f"POD coefs: {A.shape}, rank={r}")

    # Build input matrix Q = [S, B] (21-dim)
    # Standardize sensors per-channel
    S_std = np.zeros_like(S)
    S_mean, S_std_scale = [], []
    for j in range(18):
        m = np.mean(S[:, j]); s = np.std(S[:, j]) or 1.0
        S_std[:, j] = (S[:, j] - m) / s
        S_mean.append(m); S_std_scale.append(s)
    Q = np.hstack([S_std, B])  # (N, 21)

    # Train/test split by commands (80/20 per Li22b)
    rng = np.random.RandomState(42)
    cmd_indices = np.arange(n_cmds)
    rng.shuffle(cmd_indices)
    n_train = int(0.8 * n_cmds)
    # Map back to snapshot indices
    snaps_per_cmd = N // n_cmds
    train_mask = np.zeros(N, dtype=bool)
    for ci in cmd_indices[:n_train]:
        train_mask[ci*snaps_per_cmd:(ci+1)*snaps_per_cmd] = True
    test_mask = ~train_mask

    # Solve LSE: T = (Q^T Q)^{-1} Q^T A
    Q_train = Q[train_mask]; A_train = A[train_mask]
    QTQ = Q_train.T @ Q_train
    T = np.linalg.solve(QTQ + 1e-6 * np.eye(21), Q_train.T @ A_train)  # (21, r)
    print(f"T matrix shape: {T.shape}")

    # Predict test set
    Q_test = Q[test_mask]; A_test = A[test_mask]
    A_pred = Q_test @ T

    # Error metrics (Li22b Eq 3.3, 3.5)
    # Per-command error ε_a(b)
    eps_a = []
    for ci in cmd_indices[n_train:]:
        i0 = ci * snaps_per_cmd; i1 = (ci + 1) * snaps_per_cmd
        mask = np.zeros(N, dtype=bool); mask[i0:i1] = True
        mask_test = mask[test_mask]
        if np.sum(mask_test) == 0: continue
        a_true = A_test[mask_test]; a_pred = A_pred[mask_test]
        num = np.mean(np.sum((a_true - a_pred)**2, axis=1))
        den = np.mean(np.sum(a_true**2, axis=1))
        eps_a.append(float(np.sqrt(num / den)) if den > 1e-30 else 0.0)
    E = float(np.sqrt(np.mean(np.array(eps_a)**2)))

    print(f"\n=== LSE Results ===")
    print(f"Train commands: {n_train}, Test: {n_cmds - n_train}")
    print(f"Mean ε_a: {np.mean(eps_a):.4f} ± {np.std(eps_a):.4f}")
    print(f"Overall E: {E:.4f} (Li22b ~0.15-0.25 for periodic)")
    print(f"Best ε_a: {np.min(eps_a):.4f}, Worst: {np.max(eps_a):.4f}")

    # Per-mode estimation error
    per_mode_mse = np.mean((A_test - A_pred)**2, axis=0)
    per_mode_energy = np.mean(A_test**2, axis=0)
    per_mode_err = np.sqrt(per_mode_mse / (per_mode_energy + 1e-30))
    print(f"\nTop 10 mode errors: {per_mode_err[:10].round(4).tolist()}")

    # Save
    out_dir = os.path.join(DERIVED, "lse")
    np.savez(os.path.join(out_dir, "lse_T.npz"), T=T,
             S_mean=S_mean, S_std=S_std_scale)
    json.dump({"n_train": n_train, "n_test": n_cmds - n_train,
               "E": E, "mean_eps_a": float(np.mean(eps_a)),
               "std_eps_a": float(np.std(eps_a)),
               "eps_a_all": eps_a,
               "per_mode_err": per_mode_err[:20].tolist()},
              open(os.path.join(out_dir, "lse_results.json"), "w"), indent=2)
    print(f"\nSaved to {out_dir}")


if __name__ == "__main__":
    main()
