FatResConBUT/TT-DMD
1
1"""2TT-DMD Online Solver3Copyright (c) 2026 Yue Li, Miroslav Vořechovský4Institute of Structural Mechanics, Brno University of Technology (BUT), Czech Republic.5ERC Synergy Grant project FatResCon · MIT License6"""7 8import streamlit as st9import numpy as np10import plotly.graph_objects as go11from scipy.linalg import eig12from scipy import io as sio13from typing import List, Optional, Tuple14import time15import io16import json17from pathlib import Path18 19st.set_page_config(20 page_title="TT-DMD Solver · FatResCon",21 layout="wide",22 initial_sidebar_state="collapsed",23 page_icon="⚡"24)25 26APP_DIR = Path(__file__).resolve().parent27FATRESCON_LOGO_PATH = APP_DIR / "src" / "FatResCon_logo.png"28EU_LOGO_PATH = APP_DIR / "src" / "EU_fund_logo.png"29 30# ══════════════════════════════════════════════════════════════════════════════31# DESIGN SYSTEM32# ══════════════════════════════════════════════════════════════════════════════33st.markdown("""34<style>35@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Bebas+Neue&family=Inter:wght@400;500;600;700&display=swap');36 37:root {38 --bg: #0d1117;39 --s1: #161b27;40 --s2: #1e2535;41 --s3: #252d42;42 --b1: #2d3654;43 --b2: #3d4f7a;44 --tx: #f0f4ff;45 --tx2: #9aaccc;46 --tx3: #5a6d94;47 --ac: #ff6b35;48 --ac2: #ffd166;49 --ac3: #06d6a0;50 --ac4: #a78bfa;51 --ok: #22d3a0;52 --err: #f87171;53 --info: #60a5fa;54}55 56html, body, .stApp {57 background: var(--bg) !important;58 color: var(--tx) !important;59 font-family: 'Inter', -apple-system, sans-serif;60}61 62#MainMenu, footer, header { visibility: hidden; }63.stDeployButton { display: none; }64 65/* ── Scrollbar ── */66::-webkit-scrollbar { width: 5px; height: 5px; }67::-webkit-scrollbar-track { background: var(--bg); }68::-webkit-scrollbar-thumb { background: var(--b2); border-radius: 3px; }69 70/* ══ TYPOGRAPHY ══ */71h1, h2, h3, h4, h5, h6 { color: var(--tx) !important; }72 73/* ══ HERO BANNER ══ */74.hero-wrap {75 padding: 2rem 0 1.5rem;76 border-bottom: 1px solid var(--b1);77 margin-bottom: 1.5rem;78}79.hero-eyebrow {80 font-family: 'IBM Plex Mono', monospace;81 font-size: 0.68rem;82 font-weight: 500;83 letter-spacing: 0.22em;84 color: var(--ac);85 text-transform: uppercase;86 margin-bottom: 0.5rem;87}88.hero-title {89 font-family: 'Bebas Neue', sans-serif;90 font-size: 3rem;91 font-weight: 400;92 color: var(--tx);93 letter-spacing: 0.06em;94 line-height: 0.95;95 margin-bottom: 0.5rem;96}97.hero-title em { color: var(--ac); font-style: normal; }98.hero-sub {99 font-family: 'IBM Plex Mono', monospace;100 font-size: 0.72rem;101 font-weight: 400;102 color: var(--tx2);103 letter-spacing: 0.04em;104}105 106/* ══ SECTION LABELS ══ */107.sec {108 font-family: 'IBM Plex Mono', monospace;109 font-size: 0.62rem;110 font-weight: 600;111 letter-spacing: 0.18em;112 color: var(--ac);113 text-transform: uppercase;114 padding-bottom: 0.5rem;115 border-bottom: 1px solid var(--b1);116 margin-bottom: 0.7rem;117}118 119/* ══ METRIC CARDS ══ */120.mcard-row {121 display: grid;122 grid-template-columns: 1fr 1fr;123 gap: 0.6rem;124 margin: 0.8rem 0;125}126.mcard {127 background: var(--s1);128 border: 1px solid var(--b1);129 border-radius: 6px;130 padding: 0.8rem 1rem;131 position: relative;132 overflow: hidden;133}134.mcard::after {135 content: '';136 position: absolute;137 top: 0; left: 0; right: 0; height: 2px;138}139.mcard.orange::after { background: var(--ac); }140.mcard.teal::after { background: var(--ac3); }141.mcard.yellow::after { background: var(--ac2); }142.mcard.purple::after { background: var(--ac4); }143.mcard.red::after { background: var(--err); }144.mcard.blue::after { background: var(--info); }145 146.mcard-label {147 font-family: 'IBM Plex Mono', monospace;148 font-size: 0.6rem;149 font-weight: 500;150 letter-spacing: 0.14em;151 color: var(--tx3);152 text-transform: uppercase;153 margin-bottom: 0.35rem;154}155.mcard-val {156 font-family: 'Bebas Neue', sans-serif;157 font-size: 1.9rem;158 letter-spacing: 0.04em;159 color: var(--tx);160 line-height: 1;161}162.mcard-sub {163 font-family: 'IBM Plex Mono', monospace;164 font-size: 0.62rem;165 font-weight: 400;166 color: var(--tx3);167 margin-top: 0.2rem;168}169 170/* ══ BIG STAT ROW ══ */171.stat-row {172 display: flex;173 gap: 0;174 margin: 1rem 0;175 border: 1px solid var(--b1);176 border-radius: 6px;177 overflow: hidden;178}179.stat-cell {180 flex: 1;181 padding: 0.85rem 0.6rem;182 background: var(--s1);183 border-right: 1px solid var(--b1);184 text-align: center;185}186.stat-cell:last-child { border-right: none; }187.stat-num {188 font-family: 'Bebas Neue', sans-serif;189 font-size: 2rem;190 letter-spacing: 0.05em;191 line-height: 1;192 color: var(--tx);193}194.stat-lbl {195 font-family: 'IBM Plex Mono', monospace;196 font-size: 0.58rem;197 font-weight: 500;198 letter-spacing: 0.12em;199 color: var(--tx3);200 text-transform: uppercase;201 margin-top: 0.2rem;202}203 204/* ══ PCT BOXES ══ */205.pct-row { display: flex; gap: 0.5rem; margin: 0.5rem 0; }206.pct-box {207 flex: 1; text-align: center;208 border-radius: 5px; padding: 0.5rem 0;209}210.pct-box.train {211 background: rgba(6,214,160,0.08);212 border: 1px solid rgba(6,214,160,0.25);213}214.pct-box.pred {215 background: rgba(255,107,53,0.08);216 border: 1px solid rgba(255,107,53,0.25);217}218.pct-num { font-family: 'Bebas Neue', sans-serif; font-size: 1.5rem; letter-spacing: .05em; line-height: 1; }219.pct-num.t { color: var(--ac3); }220.pct-num.p { color: var(--ac); }221.pct-lbl { font-family: 'IBM Plex Mono', monospace; font-size: 0.58rem; font-weight: 500; color: var(--tx3); text-transform: uppercase; letter-spacing: .1em; }222 223/* ══ INFO BOX ══ */224.info-box {225 background: var(--s1);226 border: 1px solid var(--b1);227 border-radius: 5px;228 padding: 0.6rem 0.85rem;229 margin: 0.4rem 0;230 font-family: 'IBM Plex Mono', monospace;231 font-size: 0.68rem;232 font-weight: 400;233 color: var(--tx2);234 line-height: 1.65;235}236.info-box b { color: var(--ac); font-weight: 600; }237.info-box a { color: var(--ac3) !important; }238 239/* ══ WARN BOX ══ */240.warn-box {241 background: rgba(255,209,102,0.07);242 border-left: 3px solid var(--ac2);243 border-radius: 0 4px 4px 0;244 padding: 0.6rem 0.85rem;245 margin: 0.5rem 0;246 font-family: 'IBM Plex Mono', monospace;247 font-size: 0.68rem;248 font-weight: 500;249 color: var(--ac2);250 line-height: 1.5;251}252 253/* ══ NATIVE STREAMLIT OVERRIDES ══ */254 255/* Inputs */256.stSelectbox > div > div {257 background: var(--s2) !important;258 border: 1px solid var(--b1) !important;259 color: var(--tx) !important;260 font-family: 'IBM Plex Mono', monospace !important;261 font-size: 0.78rem !important;262 font-weight: 500 !important;263}264[data-testid="stNumberInput"] input {265 background: var(--s2) !important;266 color: var(--tx) !important;267 border: 1px solid var(--b1) !important;268 font-family: 'IBM Plex Mono', monospace !important;269 font-size: 0.78rem !important;270 font-weight: 500 !important;271}272[data-testid="stNumberInput"] button {273 background: var(--s3) !important;274 color: var(--tx) !important;275 border: 1px solid var(--b1) !important;276}277 278/* Slider */279.stSlider [data-baseweb="thumb"] { background: var(--ac) !important; border-color: var(--ac) !important; }280.stSlider [data-baseweb="track-background"] { background: var(--b2) !important; }281.stSlider [data-baseweb="track"] { background: var(--ac) !important; }282 283/* Buttons */284.stButton > button {285 font-family: 'Inter', sans-serif !important;286 font-weight: 600 !important;287 font-size: 0.82rem !important;288 background: var(--s2) !important;289 border: 1px solid var(--b2) !important;290 color: var(--tx) !important;291 border-radius: 5px !important;292 letter-spacing: 0.03em;293 transition: all 0.18s;294}295.stButton > button:hover {296 background: var(--s3) !important;297 border-color: var(--ac) !important;298 color: var(--ac) !important;299}300button[data-testid="baseButton-primary"] {301 background: var(--ac) !important;302 border-color: var(--ac) !important;303 color: #fff !important;304}305 306/* File uploader */307[data-testid="stFileUploader"] {308 background: var(--s1);309 border: 1px dashed var(--b2);310 border-radius: 6px;311 padding: 0.8rem;312}313[data-testid="stFileUploaderDropzone"] {314 background: var(--s1) !important;315 border: none !important;316}317[data-testid="stFileUploaderDropzone"] * { color: var(--tx) !important; }318[data-testid="stFileUploaderDropzone"] button,319[data-testid="stFileUploader"] button {320 background: var(--s2) !important;321 color: var(--tx) !important;322 border: 1px solid var(--b2) !important;323 font-family: 'IBM Plex Mono', monospace !important;324 font-size: 0.72rem !important;325}326 327/* Textarea log */328.stTextArea textarea {329 font-family: 'IBM Plex Mono', monospace !important;330 font-size: 0.7rem !important;331 font-weight: 400 !important;332 background: var(--s1) !important;333 color: #5af7b0 !important;334 -webkit-text-fill-color: #5af7b0 !important;335 opacity: 1 !important;336 border: 1px solid var(--b1) !important;337 line-height: 1.65 !important;338}339.stTextArea textarea:disabled {340 color: #5af7b0 !important;341 -webkit-text-fill-color: #5af7b0 !important;342 opacity: 1 !important;343}344 345/* Radio */346.stRadio label {347 font-family: 'Inter', sans-serif !important;348 font-size: 0.82rem !important;349 font-weight: 500 !important;350 color: var(--tx) !important;351}352.stRadio [data-testid="stMarkdownContainer"] p { color: var(--tx) !important; }353 354/* All labels */355div[data-testid="stWidgetLabel"] *,356[data-testid="stWidgetLabel"] p,357label p, label span,358.stSlider span, .stMarkdown p, .stMarkdown span {359 color: var(--tx2) !important;360 font-size: 0.8rem !important;361 font-weight: 500 !important;362}363 364/* Divider */365hr { border-color: var(--b1) !important; margin: 1rem 0 !important; }366 367/* ══ PROJECT FOOTER ══ */368.proj-footer {369 background: var(--s1);370 border: 1px solid var(--b1);371 border-top: 2px solid var(--ac);372 border-radius: 6px;373 padding: 1rem 1.2rem;374 margin-top: 1.5rem;375}376.proj-footer p {377 font-family: 'Inter', sans-serif;378 font-size: 0.78rem;379 color: var(--tx2);380 margin: 0.2rem 0;381 line-height: 1.55;382}383.proj-footer strong { color: var(--ac); }384.proj-footer a { color: var(--ac3) !important; }385.foot-meta {386 font-family: 'IBM Plex Mono', monospace;387 font-size: 0.65rem;388 font-weight: 400;389 color: var(--tx3);390 margin-top: 0.8rem;391 line-height: 1.6;392}393 394/* ══ VIZ SECTION HEADERS ══ */395.viz-sec {396 font-family: 'IBM Plex Mono', monospace;397 font-size: 0.65rem;398 font-weight: 600;399 letter-spacing: 0.18em;400 color: var(--tx2);401 text-transform: uppercase;402 padding: 0.5rem 0 0.35rem;403 border-bottom: 1px solid var(--b1);404 margin-bottom: 0.4rem;405}406 407/* ══ PLACEHOLDER CHIPS ══ */408.placeholder-banner {409 background: var(--s1);410 border: 1px solid var(--b1);411 border-radius: 6px;412 padding: 1.5rem;413 text-align: center;414 margin: 0.4rem 0;415}416.placeholder-banner .ph-icon {417 font-family: 'Bebas Neue', sans-serif;418 font-size: 2rem;419 letter-spacing: .1em;420 color: var(--tx3);421}422.placeholder-banner .ph-label {423 font-family: 'IBM Plex Mono', monospace;424 font-size: 0.65rem;425 font-weight: 500;426 color: var(--tx3);427 margin-top: 0.3rem;428 letter-spacing: .1em;429}430</style>431""", unsafe_allow_html=True)432 433 434# ══════════════════════════════════════════════════════════════════════════════435# CORE COMPUTATION (UNCHANGED)436# ══════════════════════════════════════════════════════════════════════════════437 438def tt_svd(X, ranks=None, epsilon=1e-10):439 shape = X.shape; d = len(shape)440 if ranks is None: ranks = [None] * (d - 1)441 cores = []; X_temp = X.copy(); r_prev = 1442 for i in range(d - 1):443 N_i = shape[i]; N_remaining = int(np.prod(shape[i+1:]))444 X_mat = X_temp.reshape(r_prev * N_i, N_remaining)445 U, S, Vt = np.linalg.svd(X_mat, full_matrices=False)446 if ranks[i] is not None: r_i = min(ranks[i], len(S))447 else: r_i = max(np.sum(S > epsilon * S[0]), 1)448 U_r, S_r, Vt_r = U[:, :r_i], S[:r_i], Vt[:r_i, :]449 cores.append(U_r.reshape(r_prev, N_i, r_i))450 remaining_shape = [r_i] + list(shape[i+1:])451 X_temp = (np.diag(S_r) @ Vt_r).reshape(remaining_shape)452 r_prev = r_i453 cores.append(X_temp.reshape(r_prev, shape[-1], 1))454 return cores455 456def reconstruct_tt_from_cores(cores):457 X_hat = cores[0]458 for core in cores[1:]:459 X_hat = np.tensordot(X_hat, core, axes=([-1], [0]))460 return np.squeeze(X_hat, axis=(0, -1))461 462def predict_last_core_latent(model, time_offsets):463 time_offsets = np.asarray(time_offsets, dtype=float)464 U_r = model["U_r"]; W = model["W"]465 Omega_ttdmd = model["Omega_ttdmd"]; b_ttdmd = model["b_ttdmd"]466 Z_pred = np.zeros((W.shape[0], len(time_offsets)), dtype=complex)467 for i, t in enumerate(time_offsets):468 Z_pred[:, i] = W @ (np.exp(Omega_ttdmd * t) * b_ttdmd)469 return np.real(U_r @ Z_pred)470 471def reconstruct_from_latent(V_pred, spatial_cores, spatial_shape):472 n_steps = V_pred.shape[1]; r_last = spatial_cores[-1].shape[-1]473 X_pred = np.zeros((*spatial_shape, n_steps), dtype=float)474 for i in range(n_steps):475 temporal_core_pred = V_pred[:, i].reshape(r_last, 1, 1)476 field_i = reconstruct_tt_from_cores([*spatial_cores, temporal_core_pred])477 if field_i.shape == (*spatial_shape, 1):478 field_i = np.squeeze(field_i, axis=-1)479 X_pred[..., i] = field_i480 return X_pred481 482def predict_tt_dmd_from_model(model, time_offsets):483 V_pred = predict_last_core_latent(model, time_offsets)484 return reconstruct_from_latent(V_pred, model["spatial_cores"], model["spatial_shape"])485 486def classic_dmd_predict(Phi, Omega, b, time_steps, result_shape):487 time_steps = np.asarray(time_steps, dtype=float)488 X_pred = np.zeros((Phi.shape[0], len(time_steps)), dtype=complex)489 for idx, t in enumerate(time_steps):490 X_pred[:, idx] = Phi @ (np.exp(Omega * t) * b)491 return np.real(X_pred).reshape(result_shape)492 493def run_tt_dmd(X_input, t_start, t_end, tt_ranks=None, epsilon=1e-5, log_fn=None):494 def log(msg):495 if log_fn: log_fn(msg)496 X_train_full = X_input[..., t_start - 1:t_end]497 spatial_shape = X_train_full.shape[:-1]; n_time = X_train_full.shape[-1]498 log(f"Input tensor shape: {X_input.shape}")499 log(f"Training tensor shape: {X_train_full.shape}")500 log(f"Spatial dims: {spatial_shape}, Time steps: {n_time}")501 log(f"Using epsilon truncation threshold: {epsilon:.2e}")502 if tt_ranks is not None and len(tt_ranks) != X_train_full.ndim - 1:503 tt_ranks = [tt_ranks[0]] * (X_train_full.ndim - 1)504 log(f"Normalized TT ranks: {tt_ranks}")505 tt_cores = tt_svd(X_train_full, ranks=tt_ranks, epsilon=epsilon)506 log("TT core shapes:")507 for i, core in enumerate(tt_cores): log(f" G_{i+1}: {core.shape}")508 spatial_cores = tt_cores[:-1]; temporal_core = tt_cores[-1]509 if temporal_core.shape[1] != n_time:510 raise ValueError(f"Temporal core dim ({temporal_core.shape[1]}) must match n_time ({n_time}).")511 r_d = temporal_core.shape[0]512 log(f"Last core shape: {temporal_core.shape}, Latent rank r_d: {r_d}")513 V_latent = temporal_core[:, :, 0]; V_train = V_latent[:, :-1]; V_target = V_latent[:, 1:]514 epsilon_svd = 1e-5515 U_v, s_v, Vh_v = np.linalg.svd(V_train, full_matrices=False)516 if s_v.size == 0: raise ValueError('SVD of V_train returned no singular values.')517 V_v = Vh_v.T; r_v = max(1, int(np.sum((s_v / s_v[0]) > epsilon_svd)))518 U_r = U_v[:, :r_v]; s_r = s_v[:r_v]; V_r = V_v[:, :r_v]519 A_tilde = U_r.T @ V_target @ V_r @ np.diag(1.0 / s_r)520 log(f"A_tilde shape: {A_tilde.shape} (rank r_v={r_v})")521 Lambda, W = eig(A_tilde)522 idx = np.argsort(np.abs(Lambda))[::-1]; Lambda = Lambda[idx]; W = W[:, idx]523 Omega_ttdmd = np.log(Lambda + 1e-14) / 1.0524 log(f"Eigenvalues computed: {len(Lambda)}")525 log(f" |λ| range: [{np.min(np.abs(Lambda)):.4e}, {np.max(np.abs(Lambda)):.4e}]")526 for i in range(min(5, len(Lambda))):527 mag = np.abs(Lambda[i]); phase = np.angle(Lambda[i]) * 180 / np.pi528 log(f" λ_{i+1}: |λ|={mag:.6f}, ∠={phase:+.2f}°")529 v0 = V_latent[:, 0]; z0 = U_r.T @ v0530 b_ttdmd = np.linalg.lstsq(W, z0, rcond=None)[0]531 log(f"Initial latent amplitude shape: {b_ttdmd.shape}")532 Phi = U_r @ W; Omega = Omega_ttdmd; b = b_ttdmd533 X_recon_input = reconstruct_from_latent(V_latent, spatial_cores, spatial_shape)534 rel_recon_error = np.linalg.norm(X_recon_input - X_train_full) / (np.linalg.norm(X_train_full) + 1e-14)535 log(f"Input-window reconstruction relative error: {rel_recon_error:.6e}")536 tt_model = {"U_r": U_r, "W": W, "Omega_ttdmd": Omega_ttdmd, "b_ttdmd": b_ttdmd,537 "spatial_cores": spatial_cores, "spatial_shape": spatial_shape,538 "V_latent": V_latent, "r_d": r_d, "r_v": r_v}539 return Phi, Omega, b, Lambda, tt_model540 541def run_classic_dmd(X_input, t_start, t_end, svd_rank=0, svd_rank_epsilon=None, log_fn=None):542 def log(msg):543 if log_fn: log_fn(msg)544 X_train_full = X_input[..., t_start - 1:t_end]545 original_shape = X_train_full.shape[:-1]; n_time = X_train_full.shape[-1]546 log(f"Training tensor shape: {X_train_full.shape}")547 X_train_flat = X_train_full.reshape(-1, n_time)548 X_snapshot = X_train_flat[:, :-1]; Y_snapshot = X_train_flat[:, 1:]549 epsilon_dmd = svd_rank_epsilon if svd_rank_epsilon is not None else 1e-5550 U_dmd, S_dmd, Vt_dmd = np.linalg.svd(X_snapshot, full_matrices=False)551 if S_dmd.size == 0: raise ValueError('SVD returned no singular values.')552 if svd_rank > 0: r_dmd = min(svd_rank, len(S_dmd))553 else: r_dmd = max(1, int(np.sum((S_dmd / S_dmd[0]) > epsilon_dmd)))554 log(f"SVD rank r_dmd={r_dmd}")555 U_dmd = U_dmd[:, :r_dmd]; S_dmd = S_dmd[:r_dmd]; Vt_dmd = Vt_dmd[:r_dmd, :]556 A_tilde_dmd = U_dmd.T @ Y_snapshot @ Vt_dmd.T @ np.diag(1.0 / S_dmd)557 Lambda_dmd, W_dmd = eig(A_tilde_dmd)558 idx_sort = np.argsort(np.abs(Lambda_dmd))[::-1]559 Lambda_dmd = Lambda_dmd[idx_sort]; W_dmd = W_dmd[:, idx_sort]560 Phi_dmd = U_dmd @ W_dmd; Omega_dmd = np.log(Lambda_dmd + 1e-14)561 X1_classic = X_snapshot[:, 0]562 b_dmd = np.linalg.lstsq(Phi_dmd, X1_classic, rcond=None)[0]563 log(f"Eigenvalues computed: {len(Lambda_dmd)}")564 log(f" |λ| range: [{np.min(np.abs(Lambda_dmd)):.4e}, {np.max(np.abs(Lambda_dmd)):.4e}]")565 X_recon_train = np.zeros_like(X_train_flat, dtype=complex)566 for t_i in range(X_train_flat.shape[1]):567 X_recon_train[:, t_i] = Phi_dmd @ (np.exp(Omega_dmd * t_i) * b_dmd)568 X_recon_train = np.real(X_recon_train)569 train_rel_err = np.linalg.norm(X_recon_train - X_train_flat) / (np.linalg.norm(X_train_flat) + 1e-14)570 log(f" Training Relative Error: {train_rel_err:.4e} ({train_rel_err*100:.2f}%)")571 return Phi_dmd, Omega_dmd, b_dmd, Lambda_dmd572 573 574# ══════════════════════════════════════════════════════════════════════════════575# SESSION STATE576# ══════════════════════════════════════════════════════════════════════════════577defaults = {578 'raw_data': None, 'reconstruction': None, 'dmd_results': None, 'errors': None,579 'axis_mapping': {'x': None, 'y': None, 'z': None, 't': None},580 'frame_idx': 0, 'playing': False, 't_start': 1, 't_end': 40,581 'camera': dict(eye=dict(x=1.5, y=1.5, z=1.2)),582 'lambda_clipped': False, 'compute_log': [], 'dmd_method': 'TT-DMD',583 'file_format': None, 'file_name': None, 'extra_steps': 100,584 'prev_axis_mapping': None, 'prev_t_start': None, 'prev_t_end': None,585 'prev_dmd_method': None, 'prev_control_mode': None, 'prev_rank': None, 'prev_eps': None,586 'log_update_key': 0, 'temporal_sync_key': 0587}588for k, v in defaults.items():589 if k not in st.session_state: st.session_state[k] = v590 591_log_ph = {'placeholder': None}592 593if len(st.session_state.compute_log) == 0:594 ts = time.strftime('%H:%M:%S')595 st.session_state.compute_log += [596 f"[{ts}] ══════════════════════════════════",597 f"[{ts}] TT-DMD SOLVER · INITIALIZED",598 f"[{ts}] ══════════════════════════════════",599 f"[{ts}] Upload .npy / .mat / .csv to begin",600 f"[{ts}] Demo data → drive.google.com link",601 ]602 603def add_log(msg):604 st.session_state.compute_log.append(f"[{time.strftime('%H:%M:%S')}] {msg}")605 st.session_state.log_update_key += 1606 ph = _log_ph.get('placeholder')607 if ph is not None:608 ph.text_area("", value="\n".join(st.session_state.compute_log),609 height=180, disabled=True,610 key=f"log_{st.session_state.log_update_key}",611 label_visibility="collapsed")612 613def get_spatial_dims():614 return sum(1 for k in ['x','y','z'] if st.session_state.axis_mapping.get(k) is not None)615 616 617# ══════════════════════════════════════════════════════════════════════════════618# PLOT HELPERS619# ══════════════════════════════════════════════════════════════════════════════620 621BASE = dict(622 template="plotly_dark",623 paper_bgcolor='rgba(0,0,0,0)',624 plot_bgcolor='rgba(13,17,23,0.6)',625 font=dict(family='IBM Plex Mono, monospace', size=11, color='#9aaccc'),626 margin=dict(t=42, b=32, l=50, r=20),627)628 629def _title(txt, sz=13):630 return dict(text=txt, font=dict(family='Bebas Neue, sans-serif', size=sz, color='#f0f4ff'), x=0.5)631 632def _axis(label, grid=True):633 return dict(title=dict(text=label, font=dict(size=11, color='#9aaccc')),634 tickfont=dict(size=10, color='#5a6d94'),635 gridcolor='rgba(255,255,255,0.05)' if grid else 'rgba(0,0,0,0)',636 linecolor='#2d3654', linewidth=1)637 638def make_heatmap_2d_static(data_2d, title, cs='RdBu_r'):639 fig = go.Figure(go.Heatmap(z=data_2d, colorscale=cs,640 colorbar=dict(thickness=10, tickfont=dict(size=10, color='#9aaccc'))))641 fig.update_layout(**BASE, height=330, title=_title(title),642 xaxis=_axis('t'), yaxis={**_axis('x'), 'scaleanchor': 'x'}, dragmode='pan')643 return fig644 645def make_heatmap_2d_frame(data_2d, title, cs='RdBu_r', z_min=None, z_max=None):646 fig = go.Figure(go.Heatmap(z=data_2d, colorscale=cs, zsmooth='fast',647 zmin=z_min, zmax=z_max,648 colorbar=dict(thickness=10, tickfont=dict(size=10, color='#9aaccc'))))649 fig.update_layout(**BASE, height=310, title=_title(title),650 xaxis=_axis('y', grid=False), yaxis={**_axis('x', grid=False), 'scaleanchor': 'x'}, dragmode='pan')651 return fig652 653def make_volume_3d(data_3d, title, camera):654 nx, ny, nz = data_3d.shape655 X, Y, Z = np.mgrid[0:nx, 0:ny, 0:nz]656 fig = go.Figure(go.Volume(657 x=X.flatten(), y=Y.flatten(), z=Z.flatten(), value=data_3d.flatten(),658 isomin=np.percentile(data_3d, 10), isomax=np.percentile(data_3d, 90),659 opacity=0.1, surface_count=15, colorscale='Plasma',660 colorbar=dict(thickness=10, tickfont=dict(size=10, color='#9aaccc'))))661 fig.update_layout(**BASE, height=320, title=_title(title),662 scene=dict(xaxis=_axis('x'), yaxis=_axis('y'), zaxis=_axis('z'),663 bgcolor='rgba(0,0,0,0)', camera=camera))664 return fig665 666def make_placeholder(label):667 """Dark grid placeholder shown before data/results are available."""668 rng = np.random.default_rng(99)669 noise = rng.uniform(0, 0.015, (20, 20))670 fig = go.Figure(go.Heatmap(z=noise, colorscale=[[0,'#0d1117'],[0.5,'#161b27'],[1,'#1e2535']],671 showscale=False, zsmooth='best'))672 fig.update_layout(**BASE, height=290,673 title=dict(text=f'<span style="color:#2d3654;font-family:IBM Plex Mono">── {label} ──</span>',674 font=dict(size=11), x=0.5),675 xaxis=dict(showticklabels=False, showgrid=False, zeroline=False),676 yaxis=dict(showticklabels=False, showgrid=False, zeroline=False))677 return fig678 679def make_placeholder_spectrum(label):680 """Unit-circle placeholder for eigenvalue spectrum before results."""681 theta = np.linspace(0, 2*np.pi, 200)682 fig = go.Figure()683 fig.add_trace(go.Scatter(x=np.cos(theta), y=np.sin(theta), mode='lines',684 line=dict(color='rgba(45,54,84,0.8)', width=1.5, dash='dot'), showlegend=False, hoverinfo='skip'))685 for xs, ys in [([-1.4,1.4],[0,0]),([0,0],[-1.4,1.4])]:686 fig.add_trace(go.Scatter(x=xs, y=ys, mode='lines',687 line=dict(color='rgba(45,54,84,0.5)', width=0.8), showlegend=False, hoverinfo='skip'))688 fig.add_trace(go.Scatter(x=[0], y=[0], mode='markers',689 marker=dict(size=4, color='#2d3654'), showlegend=False, hoverinfo='skip'))690 fig.update_layout(**BASE, height=310,691 title=dict(text=f'<span style="color:#2d3654;font-family:IBM Plex Mono">── {label} ──</span>',692 font=dict(size=11), x=0.5),693 xaxis=dict(title=dict(text='Re(λ)', font=dict(size=11, color='#2d3654')),694 tickfont=dict(size=10, color='#2d3654'), range=[-1.6,1.6],695 gridcolor='rgba(45,54,84,0.3)', zeroline=False),696 yaxis=dict(title=dict(text='Im(λ)', font=dict(size=11, color='#2d3654')),697 tickfont=dict(size=10, color='#2d3654'), range=[-1.6,1.6],698 scaleanchor='x', gridcolor='rgba(45,54,84,0.3)', zeroline=False))699 return fig700 701def make_placeholder_line(label):702 """Empty axes placeholder for charts before results."""703 fig = go.Figure()704 fig.update_layout(**BASE, height=220,705 title=dict(text=f'<span style="color:#2d3654;font-family:IBM Plex Mono">── {label} ──</span>',706 font=dict(size=11), x=0.5),707 xaxis=dict(showgrid=True, gridcolor='rgba(45,54,84,0.3)', tickfont=dict(size=10, color='#2d3654'),708 zeroline=False),709 yaxis=dict(showgrid=True, gridcolor='rgba(45,54,84,0.3)', tickfont=dict(size=10, color='#2d3654'),710 zeroline=False))711 return fig712 713# ── BUG-FIXED error chart ──────────────────────────────────────────────────────714def make_error_chart(errors, t_end):715 """Relative L1 Error over Time. FIX: use len() not truthiness on ndarray."""716 errors = np.asarray(errors)717 n = len(errors)718 x = list(range(1, n + 1))719 t_end_clipped = min(t_end, n)720 train_x = x[:t_end_clipped]721 train_y = errors[:t_end_clipped].tolist()722 pred_x = x[t_end_clipped:]723 pred_y = errors[t_end_clipped:].tolist()724 725 fig = go.Figure()726 fig.add_trace(go.Scatter(727 x=train_x, y=train_y, mode='lines', name='Training',728 line=dict(color='#06d6a0', width=2.5),729 fill='tozeroy', fillcolor='rgba(6,214,160,0.12)'))730 731 if len(pred_y) > 0: # ← FIX: was `if pred_y:` which fails on numpy array732 fig.add_trace(go.Scatter(733 x=pred_x, y=pred_y, mode='lines', name='Prediction',734 line=dict(color='#ff6b35', width=2.5),735 fill='tozeroy', fillcolor='rgba(255,107,53,0.12)'))736 737 fig.add_vline(x=t_end_clipped, line=dict(color='#ffd166', width=1.5, dash='dot'))738 if len(errors) > 0:739 y_max = float(np.max(errors))740 fig.add_annotation(741 x=t_end_clipped, y=y_max * 0.82,742 text='training cutoff',743 font=dict(family='IBM Plex Mono', size=10, color='#ffd166'),744 showarrow=False, xanchor='left', xshift=8)745 746 fig.update_layout(**BASE, height=185,747 title=_title('Relative L1 Error over Time', sz=13),748 xaxis=dict(title=dict(text='Time Step', font=dict(size=11, color='#9aaccc')),749 tickfont=dict(size=10, color='#5a6d94'),750 gridcolor='rgba(255,255,255,0.05)', linecolor='#2d3654'),751 yaxis=dict(title=dict(text='Rel. L1 Error', font=dict(size=11, color='#9aaccc')),752 tickfont=dict(size=10, color='#5a6d94'), tickformat='.2e',753 gridcolor='rgba(255,255,255,0.05)', linecolor='#2d3654'),754 showlegend=True,755 legend=dict(font=dict(family='IBM Plex Mono', size=10, color='#9aaccc'),756 bgcolor='rgba(0,0,0,0)', orientation='h', y=1.15, x=0.5, xanchor='center'))757 return fig758 759def make_eigenvalue_spectrum(Lambda):760 """Complex eigenvalue plot on unit circle."""761 re = np.real(Lambda); im = np.imag(Lambda)762 mag = np.abs(Lambda)763 stable_mask = mag <= 1.0764 n_stable = int(np.sum(stable_mask))765 766 theta = np.linspace(0, 2*np.pi, 300)767 fig = go.Figure()768 769 # Unit circle770 fig.add_trace(go.Scatter(x=np.cos(theta), y=np.sin(theta), mode='lines',771 line=dict(color='rgba(255,209,102,0.35)', width=1.2, dash='dot'),772 showlegend=False, hoverinfo='skip'))773 774 # Axes775 for xs, ys in [([-1.5,1.5],[0,0]),([0,0],[-1.5,1.5])]:776 fig.add_trace(go.Scatter(x=xs, y=ys, mode='lines',777 line=dict(color='rgba(255,255,255,0.10)', width=0.8),778 showlegend=False, hoverinfo='skip'))779 780 # Radius spokes (faint)781 for i in range(len(Lambda)):782 fig.add_trace(go.Scatter(x=[0, float(re[i])], y=[0, float(im[i])], mode='lines',783 line=dict(color='rgba(255,107,53,0.07)', width=0.8),784 showlegend=False, hoverinfo='skip'))785 786 # Stable eigenvalues787 if n_stable > 0:788 re_s = re[stable_mask]; im_s = im[stable_mask]; mag_s = mag[stable_mask]789 sz_s = (7 + 12 * (mag_s / (mag_s.max() + 1e-14))).tolist()790 htxt_s = [f"λ_{i+1} |λ|={mag_s[i]:.4f} ∠={float(np.angle(Lambda[stable_mask][i]))*180/np.pi:+.1f}° STABLE"791 for i in range(len(re_s))]792 fig.add_trace(go.Scatter(x=re_s.tolist(), y=im_s.tolist(), mode='markers', name=f'Stable ({n_stable})',793 marker=dict(size=sz_s, color='#06d6a0', opacity=0.9,794 line=dict(color='rgba(255,255,255,0.25)', width=0.8)),795 hovertext=htxt_s, hoverinfo='text'))796 797 # Unstable eigenvalues798 n_unstable = len(Lambda) - n_stable799 if n_unstable > 0:800 unstable_mask = ~stable_mask801 re_u = re[unstable_mask]; im_u = im[unstable_mask]; mag_u = mag[unstable_mask]802 sz_u = (7 + 12 * (mag_u / (mag_u.max() + 1e-14))).tolist()803 htxt_u = [f"λ_{i+1} |λ|={mag_u[i]:.4f} ∠={float(np.angle(Lambda[unstable_mask][i]))*180/np.pi:+.1f}° UNSTABLE"804 for i in range(len(re_u))]805 fig.add_trace(go.Scatter(x=re_u.tolist(), y=im_u.tolist(), mode='markers', name=f'Unstable ({n_unstable})',806 marker=dict(size=sz_u, color='#f87171', opacity=0.9,807 line=dict(color='rgba(255,255,255,0.25)', width=0.8)),808 hovertext=htxt_u, hoverinfo='text'))809 810 title_txt = (f'<span style="font-family:Bebas Neue;font-size:14px;color:#f0f4ff">Eigenvalue Spectrum</span>'811 f' <span style="font-size:11px;color:#06d6a0">● {n_stable} stable</span>'812 f' <span style="font-size:11px;color:#f87171">● {n_unstable} unstable</span>')813 814 fig.update_layout(**BASE, height=320,815 title=dict(text=title_txt, font=dict(size=13), x=0.5),816 xaxis=dict(title=dict(text='Re(λ)', font=dict(size=12, color='#9aaccc')),817 tickfont=dict(size=10, color='#5a6d94'), range=[-1.55, 1.55],818 gridcolor='rgba(255,255,255,0.05)', zeroline=False, linecolor='#2d3654'),819 yaxis=dict(title=dict(text='Im(λ)', font=dict(size=12, color='#9aaccc')),820 tickfont=dict(size=10, color='#5a6d94'), range=[-1.55, 1.55],821 scaleanchor='x', gridcolor='rgba(255,255,255,0.05)', zeroline=False, linecolor='#2d3654'),822 legend=dict(font=dict(family='IBM Plex Mono', size=10, color='#9aaccc'),823 bgcolor='rgba(13,17,23,0.7)', orientation='h', y=1.12, x=0.5, xanchor='center'))824 return fig825 826def make_frequency_chart(Omega):827 """Growth rate vs. frequency map of DMD modes."""828 growth = np.real(Omega)829 freq = np.imag(Omega) / (2 * np.pi)830 831 stable_m = growth <= 0832 n_decaying = int(np.sum(stable_m))833 n_growing = len(Omega) - n_decaying834 835 fig = go.Figure()836 837 # Zero line838 fig.add_hline(y=0, line=dict(color='rgba(255,209,102,0.4)', width=1.2, dash='dot'))839 840 # Decaying modes841 if n_decaying > 0:842 fig.add_trace(go.Scatter(843 x=freq[stable_m].tolist(), y=growth[stable_m].tolist(),844 mode='markers', name=f'Decaying ({n_decaying})',845 marker=dict(size=8, color='#06d6a0', opacity=0.85,846 line=dict(color='rgba(255,255,255,0.2)', width=0.6)),847 hovertemplate='freq=%{x:.4f}<br>growth=%{y:.4f}<extra>Decaying</extra>'))848 849 # Growing modes850 if n_growing > 0:851 fig.add_trace(go.Scatter(852 x=freq[~stable_m].tolist(), y=growth[~stable_m].tolist(),853 mode='markers', name=f'Growing ({n_growing})',854 marker=dict(size=8, color='#f87171', opacity=0.85,855 line=dict(color='rgba(255,255,255,0.2)', width=0.6)),856 hovertemplate='freq=%{x:.4f}<br>growth=%{y:.4f}<extra>Growing</extra>'))857 858 fig.update_layout(**BASE, height=235,859 title=_title('Growth / Frequency Map', sz=13),860 xaxis=dict(title=dict(text='Frequency (cycles/step)', font=dict(size=11, color='#9aaccc')),861 tickfont=dict(size=10, color='#5a6d94'),862 gridcolor='rgba(255,255,255,0.05)', zeroline=False, linecolor='#2d3654'),863 yaxis=dict(title=dict(text='Growth Rate', font=dict(size=11, color='#9aaccc')),864 tickfont=dict(size=10, color='#5a6d94'),865 gridcolor='rgba(255,255,255,0.05)', zeroline=False, linecolor='#2d3654'),866 legend=dict(font=dict(family='IBM Plex Mono', size=10, color='#9aaccc'),867 bgcolor='rgba(13,17,23,0.7)', orientation='h', y=1.18, x=0.5, xanchor='center'))868 return fig869 870 871# ══════════════════════════════════════════════════════════════════════════════872# HEADER873# ══════════════════════════════════════════════════════════════════════════════874st.markdown("""875<div class="hero-wrap">876 <div class="hero-eyebrow">ERC Synergy Grant · FatResCon · VUT Brno · 2026</div>877 <div class="hero-title">Tensor-Train <em>DMD</em> Online Solver</div>878 <div class="hero-sub">Dynamic Mode Decomposition for Spatiotemporal Tensor Data</div>879</div>880""", unsafe_allow_html=True)881 882ctrl_col, viz_col = st.columns([1, 1.2])883 884 885# ══════════════════════════════════════════════════════════════════════════════886# CONTROL PANEL887# ══════════════════════════════════════════════════════════════════════════════888with ctrl_col:889 890 # ── ① INPUT ───────────────────────────────────────────────────────────────891 st.markdown('<div class="sec">① Input Data</div>', unsafe_allow_html=True)892 st.markdown(893 '<div class="info-box">Upload spatiotemporal tensor — <b>.npy · .mat · .csv</b><br>'894 '📁 <a href="https://drive.google.com/drive/folders/1lkrPxlxPeS73P69qs4izPLXCRR25U2kC" '895 'target="_blank">Demo test data ↗</a></div>',896 unsafe_allow_html=True)897 uploaded_file = st.file_uploader("", type=['npy','mat','csv'], label_visibility="collapsed")898 899 data = st.session_state.raw_data900 mapping = st.session_state.axis_mapping901 902 if uploaded_file:903 try:904 file_name = uploaded_file.name905 file_ext = file_name.split('.')[-1].lower()906 if file_ext == 'npy':907 new_data = np.load(uploaded_file)908 elif file_ext == 'mat':909 mat = sio.loadmat(uploaded_file)910 keys = [k for k in mat.keys() if not k.startswith('__')]911 arrs = [mat[k] for k in keys if isinstance(mat[k], np.ndarray)]912 if not arrs: raise ValueError("No data found in .mat file")913 new_data = max(arrs, key=lambda x: x.size)914 elif file_ext == 'csv':915 new_data = np.loadtxt(uploaded_file, delimiter=',')916 else:917 raise ValueError(f"Unsupported format: {file_ext}")918 919 if data is None or new_data.shape != getattr(data, 'shape', None):920 st.session_state.raw_data = new_data921 for k in ['reconstruction','errors','dmd_results']:922 st.session_state[k] = None923 st.session_state.lambda_clipped = False924 st.session_state.compute_log = []925 st.session_state.file_format = file_ext926 st.session_state.file_name = file_name927 data = new_data; ndim = data.ndim928 st.session_state.axis_mapping = {929 'x': 0 if ndim >= 1 else None,930 'y': 1 if ndim >= 2 else None,931 'z': 2 if ndim >= 4 else None,932 't': ndim - 1 if ndim >= 2 else None933 }934 mapping = st.session_state.axis_mapping935 t_max_init = data.shape[mapping['t']] if mapping['t'] is not None else 100936 st.session_state.t_start = 1937 st.session_state.t_end = min(40, t_max_init)938 st.session_state.prev_axis_mapping = mapping.copy()939 st.session_state.prev_t_start = st.session_state.t_start940 st.session_state.prev_t_end = st.session_state.t_end941 st.session_state.prev_dmd_method = 'TT-DMD'942 st.session_state.prev_control_mode = 'Fixed Rank'943 st.session_state.prev_rank = 10944 st.session_state.prev_eps = 1e-5945 add_log("══════════════════════════════════")946 add_log(f"FILE {file_name} [{file_ext.upper()}]")947 add_log(f"Shape {data.shape}")948 add_log(f"dtype {data.dtype} MB {data.nbytes/1024/1024:.2f}")949 add_log(f"Range [{data.min():.3e}, {data.max():.3e}]")950 add_log("Auto-axis mapping:")951 for axis, idx2 in mapping.items():952 if idx2 is not None:953 add_log(f" {axis} → dim {idx2} size={data.shape[idx2]}")954 add_log(f"Training window t=[1, {st.session_state.t_end}]")955 add_log("══════════════════════════════════")956 except Exception as e:957 st.error(str(e))958 959 if data is not None:960 t_ax = mapping.get('t')961 t_len_disp = data.shape[t_ax] if t_ax is not None else '—'962 mem_mb = data.nbytes / 1024 / 1024963 st.markdown(f"""964<div class="mcard-row">965 <div class="mcard teal">966 <div class="mcard-label">Dimensions</div>967 <div class="mcard-val">{data.ndim}<span style="font-size:1rem;color:#5a6d94"> D</span></div>968 <div class="mcard-sub">{data.shape}</div>969 </div>970 <div class="mcard blue">971 <div class="mcard-label">Time Steps</div>972 <div class="mcard-val">{t_len_disp}</div>973 <div class="mcard-sub">{data.dtype} · {mem_mb:.2f} MB</div>974 </div>975</div>""", unsafe_allow_html=True)976 977 # ── ② DIMENSION ASSIGNMENT ────────────────────────────────────────────────978 st.markdown('<div class="sec">② Dimension Assignment</div>', unsafe_allow_html=True)979 if data is not None:980 shape, ndim = data.shape, data.ndim981 opts = ["--"] + [f"d{i}: {shape[i]}" for i in range(ndim)]982 def g_idx(k):983 v = mapping.get(k); return (v if v is not None else -1) + 1984 def p_dim(v): return None if v == "--" else int(v.split(":")[0].replace("d",""))985 c1, c2, c3 = st.columns(3)986 mx = c1.selectbox("x", opts, index=g_idx('x'), key="ax_x")987 my = c2.selectbox("y", opts, index=g_idx('y'), key="ax_y")988 mz = c3.selectbox("z", opts, index=g_idx('z'), key="ax_z")989 mt = st.selectbox("t (temporal)", opts, index=g_idx('t'), key="ax_t")990 new_m = {'x': p_dim(mx), 'y': p_dim(my), 'z': p_dim(mz), 't': p_dim(mt)}991 asgn = [v for v in new_m.values() if v is not None]992 if len(asgn) == len(set(asgn)):993 if st.session_state.prev_axis_mapping != new_m:994 add_log("Axis mapping: " + " ".join(f"{k}→d{v}" for k,v in new_m.items() if v is not None))995 st.session_state.prev_axis_mapping = new_m.copy()996 st.session_state.axis_mapping = new_m; mapping = new_m997 998 # ── ③ TRAINING WINDOW ─────────────────────────────────────────────────────999 st.markdown('<div class="sec">③ Training Window</div>', unsafe_allow_html=True)1000 t_idx = mapping.get('t')1001 t_max = data.shape[t_idx] if data is not None and t_idx is not None else 1001002 if st.session_state.t_end > t_max: st.session_state.t_end = t_max1003 if st.session_state.t_start > t_max: st.session_state.t_start = 11004 if st.session_state.t_start > st.session_state.t_end: st.session_state.t_start = st.session_state.t_end1005 1006 tc1, tc2 = st.columns(2)1007 new_ts = tc1.number_input("t_start", 1, t_max, st.session_state.t_start, key=f"ts_{st.session_state.temporal_sync_key}")1008 new_te = tc2.number_input("t_end", 1, t_max, st.session_state.t_end, key=f"te_{st.session_state.temporal_sync_key}")1009 if new_ts != st.session_state.t_start or new_te != st.session_state.t_end:1010 st.session_state.t_start = new_ts; st.session_state.t_end = new_te1011 st.session_state.temporal_sync_key += 1; st.rerun()1012 new_range = st.slider("tw", 1, t_max, (st.session_state.t_start, st.session_state.t_end),1013 key=f"tsl_{st.session_state.temporal_sync_key}", label_visibility="collapsed")1014 if new_range[0] != st.session_state.t_start or new_range[1] != st.session_state.t_end:1015 st.session_state.t_start = new_range[0]; st.session_state.t_end = new_range[1]1016 st.session_state.temporal_sync_key += 1; st.rerun()1017 1018 t_start, t_end = st.session_state.t_start, st.session_state.t_end1019 1020 if data is not None and t_idx is not None:1021 total_t = data.shape[t_idx]1022 tr_pct = (t_end - t_start + 1) / total_t * 1001023 pr_pct = max(0, total_t - t_end) / total_t * 1001024 st.markdown(f"""1025<div class="pct-row">1026 <div class="pct-box train">1027 <div class="pct-num t">{tr_pct:.0f}%</div>1028 <div class="pct-lbl">Training</div>1029 </div>1030 <div class="pct-box pred">1031 <div class="pct-num p">{pr_pct:.0f}%</div>1032 <div class="pct-lbl">Prediction</div>1033 </div>1034</div>""", unsafe_allow_html=True)1035 1036 if st.session_state.prev_t_start != t_start or st.session_state.prev_t_end != t_end:1037 add_log(f"Training window t=[{t_start}, {t_end}]")1038 st.session_state.prev_t_start = t_start; st.session_state.prev_t_end = t_end1039 1040 # ── ④ ALGORITHM ───────────────────────────────────────────────────────────1041 st.markdown('<div class="sec">④ Algorithm Configuration</div>', unsafe_allow_html=True)1042 dmd_method = st.radio("Algorithm", ["TT-DMD", "Classic DMD"], horizontal=True, key="dmd_method_select")1043 st.session_state.dmd_method = dmd_method1044 if st.session_state.prev_dmd_method != dmd_method:1045 add_log(f"Algorithm: {dmd_method}"); st.session_state.prev_dmd_method = dmd_method1046 1047 control_mode = st.radio("Truncation", ["Fixed Rank", "Energy Threshold (ε)"], horizontal=True, key="ctrl_mode")1048 if st.session_state.prev_control_mode != control_mode:1049 add_log(f"Truncation: {control_mode}"); st.session_state.prev_control_mode = control_mode1050 1051 if control_mode == "Fixed Rank":1052 lbl_r = "TT-rank (r)" if dmd_method == "TT-DMD" else "SVD rank (r)"1053 rmax = 80 if dmd_method == "TT-DMD" else 1001054 rank_val = st.slider(lbl_r, 2 if dmd_method=="TT-DMD" else 1, rmax, 10, key="rank_sl")1055 eps_val = None1056 if st.session_state.prev_rank != rank_val:1057 add_log(f"Rank r={rank_val}"); st.session_state.prev_rank = rank_val1058 else:1059 eps_val = st.select_slider("ε", [1e-2,1e-3,1e-4,1e-5,1e-6,1e-7], value=1e-5, key="eps_sl")1060 rank_val = None1061 if st.session_state.prev_eps != eps_val:1062 add_log(f"ε={eps_val:.0e}"); st.session_state.prev_eps = eps_val1063 1064 run_disabled = (data is None or t_idx is None)1065 if st.button(f"▶ Execute {dmd_method}", key="run_btn", use_container_width=True,1066 disabled=run_disabled, type="primary"):1067 st.session_state.compute_log = []1068 t_total = data.shape[t_idx]1069 add_log("══════════════════════════════════")1070 add_log(f"STARTING {dmd_method}")1071 add_log(f"Data {data.shape} {data.dtype}")1072 add_log(f"Train t=[{t_start},{t_end}] {(t_end-t_start+1)/t_total*100:.1f}%")1073 add_log(f"Trunc {'rank='+str(rank_val) if rank_val else 'eps='+str(eps_val)}")1074 add_log("══════════════════════════════════")1075 1076 with st.spinner("Computing…"):1077 try:1078 data_t = np.moveaxis(data, t_idx, -1)1079 add_log(f"Reshaped (t-last): {data_t.shape}")1080 1081 if dmd_method == "TT-DMD":1082 tt_ranks = [rank_val]*(data_t.ndim-1) if rank_val else None1083 epsilon = eps_val if eps_val else 1e-51084 add_log("Step 1/4 TT-SVD…")1085 Phi, Omega, b, Lambda, tt_model = run_tt_dmd(1086 data_t, t_start, t_end, tt_ranks, epsilon, log_fn=add_log)1087 add_log(f"TT-DMD done {Phi.shape[1]} modes")1088 else:1089 add_log("Step 1/4 Classic SVD-DMD…")1090 Phi, Omega, b, Lambda = run_classic_dmd(1091 data_t, t_start, t_end,1092 svd_rank=rank_val if rank_val else 0,1093 svd_rank_epsilon=eps_val, log_fn=add_log)1094 tt_model = None1095 add_log(f"Classic DMD done {len(Lambda)} modes")1096 1097 add_log("Step 2/4 Eigenvalue stability check…")1098 lambda_abs = np.abs(Lambda)1099 n_unstable = int(np.sum(lambda_abs > 1))1100 add_log(f"|λ| range [{np.min(lambda_abs):.6f}, {np.max(lambda_abs):.6f}]")1101 if n_unstable > 0:1102 st.session_state.lambda_clipped = True1103 add_log(f"⚠ {n_unstable} unstable → projected to unit circle")1104 Lambda = np.where(lambda_abs > 1, Lambda / lambda_abs, Lambda)1105 Omega = np.log(Lambda + 1e-14)1106 else:1107 st.session_state.lambda_clipped = False1108 add_log(f"✓ All {len(Lambda)} eigenvalues stable")1109 1110 add_log("Step 3/4 Prediction…")1111 n_t = data_t.shape[-1]1112 result_shape = tuple(list(data_t.shape[:-1]) + [n_t])1113 if dmd_method == "Classic DMD":1114 X_pred = classic_dmd_predict(Phi, Omega, b, np.arange(n_t), result_shape)1115 else:1116 X_pred = predict_tt_dmd_from_model(tt_model, np.arange(n_t))1117 recon = np.moveaxis(X_pred, -1, t_idx)1118 st.session_state.reconstruction = recon1119 st.session_state.dmd_results = {1120 'method': dmd_method, 'Phi': Phi, 'Omega': Omega,1121 'b': b, 'Lambda': Lambda,1122 'tt_model': tt_model if dmd_method=="TT-DMD" else None1123 }1124 1125 add_log("Step 4/4 Error analysis…")1126 pred_t = np.moveaxis(recon, t_idx, -1)1127 gt_t = np.moveaxis(data, t_idx, -1)1128 errs = np.array([1129 np.linalg.norm(pred_t[...,ti] - gt_t[...,ti]) /1130 (np.linalg.norm(gt_t[...,ti]) + 1e-14)1131 for ti in range(gt_t.shape[-1])1132 ])1133 st.session_state.errors = errs1134 add_log(f"Train err mean={np.mean(errs[:t_end]):.4e} max={np.max(errs[:t_end]):.4e}")1135 if t_end < n_t:1136 add_log(f"Pred err mean={np.mean(errs[t_end:]):.4e} max={np.max(errs[t_end:]):.4e}")1137 add_log("══════════════════════════════════")1138 add_log("✓ COMPUTATION COMPLETE")1139 add_log("══════════════════════════════════")1140 1141 except Exception as e:1142 add_log(f"ERROR: {e}")1143 import traceback; add_log(traceback.format_exc())1144 st.error(str(e))1145 1146 if st.session_state.lambda_clipped:1147 st.markdown('<div class="warn-box">⚠ Unstable eigenvalues (|λ|>1) projected onto unit circle for stability</div>',1148 unsafe_allow_html=True)1149 1150 # ── RESULTS STATS ─────────────────────────────────────────────────────────1151 dmd_r = st.session_state.dmd_results1152 if dmd_r is not None:1153 l = dmd_r['Lambda']1154 n_stable = int(np.sum(np.abs(l) <= 1))1155 n_modes = dmd_r['Phi'].shape[1]1156 errs = st.session_state.errors1157 tr_e = f"{np.mean(errs[:t_end]):.2e}" if errs is not None else "—"1158 pr_e = f"{np.mean(errs[t_end:]):.2e}" if errs is not None and t_end < len(errs) else "—"1159 1160 dmd_r_info = st.session_state.dmd_results1161 r_v_val = dmd_r_info['tt_model']['r_v'] if dmd_r_info.get('tt_model') else '—'1162 1163 st.markdown(f"""1164<div class="stat-row">1165 <div class="stat-cell">1166 <div class="stat-num" style="color:#f0f4ff">{n_modes}</div>1167 <div class="stat-lbl">Modes</div>1168 </div>1169 <div class="stat-cell">1170 <div class="stat-num" style="color:#06d6a0">{n_stable}</div>1171 <div class="stat-lbl">Stable λ</div>1172 </div>1173 <div class="stat-cell">1174 <div class="stat-num" style="color:#ffd166;font-size:1.3rem">{tr_e}</div>1175 <div class="stat-lbl">Train Err</div>1176 </div>1177 <div class="stat-cell">1178 <div class="stat-num" style="color:#ff6b35;font-size:1.3rem">{pr_e}</div>1179 <div class="stat-lbl">Pred Err</div>1180 </div>1181</div>""", unsafe_allow_html=True)1182 1183 if dmd_r_info.get('tt_model'):1184 rv = dmd_r_info['tt_model']['r_v']1185 rd = dmd_r_info['tt_model']['r_d']1186 st.markdown(f"""1187<div class="mcard-row">1188 <div class="mcard purple">1189 <div class="mcard-label">Latent TT rank r_v</div>1190 <div class="mcard-val">{rv}</div>1191 <div class="mcard-sub">SVD of latent dynamics</div>1192 </div>1193 <div class="mcard orange">1194 <div class="mcard-label">Temporal rank r_d</div>1195 <div class="mcard-val">{rd}</div>1196 <div class="mcard-sub">Last TT core</div>1197 </div>1198</div>""", unsafe_allow_html=True)1199 1200 # ── ⑤ EXPORT ──────────────────────────────────────────────────────────────