| import streamlit as st |
| import os |
| import traceback |
|
|
| st.set_page_config( |
| page_title="SolarIQ · PV Forecast", |
| layout="wide", |
| page_icon="S", |
| initial_sidebar_state="collapsed" |
| ) |
|
|
| st.markdown(""" |
| <style> |
| @import url('https://fonts.googleapis.com/css2?family=Syne:wght@400;500;600;700;800&family=JetBrains+Mono:wght@300;400;500;600&family=DM+Sans:wght@300;400;500;600;700&display=swap'); |
| |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } |
| |
| html, body, [class*="css"] { |
| font-family: 'DM Sans', sans-serif; |
| background: #EAF4FB !important; |
| color: #1A2430; |
| } |
| |
| #MainMenu, footer, header, .stDeployButton { visibility: hidden; } |
| .block-container { padding: 0 !important; max-width: 100% !important; } |
| section[data-testid="stSidebar"] { display: none; } |
| |
| .stApp, .stApp > div, [data-testid="stAppViewContainer"], [data-testid="stMain"] { |
| background: #EAF4FB !important; |
| } |
| |
| [data-testid="stHeader"] { background: transparent !important; } |
| |
| body::before { |
| content: ""; |
| position: fixed; inset: 0; |
| background: |
| radial-gradient(ellipse 70% 50% at 0% 0%, rgba(160,210,245,0.28) 0%, transparent 55%), |
| radial-gradient(ellipse 55% 45% at 100% 100%, rgba(190,225,250,0.18) 0%, transparent 55%), |
| radial-gradient(ellipse 50% 40% at 60% 25%, rgba(210,235,255,0.12) 0%, transparent 60%); |
| pointer-events: none; z-index: 0; |
| } |
| |
| .pv-nav { |
| display: flex; align-items: center; gap: 16px; |
| padding: 0 48px; height: 68px; |
| background: rgba(255,255,255,0.96); |
| backdrop-filter: blur(20px); |
| border-bottom: 1px solid rgba(160,200,120,0.22); |
| position: sticky; top: 0; z-index: 200; |
| box-shadow: 0 1px 0 rgba(255,255,255,0.90), 0 4px 24px rgba(80,120,40,0.08); |
| } |
| .pv-logo-mark { |
| display: flex; align-items: center; justify-content: center; |
| width: 38px; height: 38px; |
| background: linear-gradient(135deg, #3A8A20, #7DC840); |
| border-radius: 11px; flex-shrink: 0; |
| box-shadow: 0 2px 10px rgba(58,138,32,0.32), 0 0 0 2px rgba(58,138,32,0.12); |
| transition: box-shadow 0.22s, transform 0.22s; |
| } |
| .pv-logo-mark:hover { transform: scale(1.07); box-shadow: 0 4px 18px rgba(58,138,32,0.44), 0 0 0 3px rgba(58,138,32,0.18); } |
| .pv-brand { line-height: 1.2; } |
| .pv-brand-name { font-family: 'Syne', sans-serif; font-size: 17px; font-weight: 800; color: #1A2318; letter-spacing: -0.4px; } |
| .pv-brand-sub { font-size: 10.5px; color: #7A9870; font-weight: 400; letter-spacing: 0.2px; } |
| .nav-divider { width: 1px; height: 28px; background: rgba(100,160,60,0.18); margin: 0 10px; flex-shrink: 0; } |
| .nav-links { display: flex; gap: 3px; } |
| .nav-link { |
| font-size: 13px; font-weight: 600; padding: 7px 16px; border-radius: 9px; |
| color: #7A9870; cursor: pointer; letter-spacing: 0.1px; |
| border: 1px solid transparent; transition: color 0.18s, background 0.18s, border-color 0.18s, transform 0.14s; |
| user-select: none; |
| } |
| .nav-link:hover { color: #2E7010; background: rgba(60,140,20,0.07); border-color: rgba(60,140,20,0.16); transform: translateY(-1px); } |
| .nav-link.active { color: #2E7010; background: rgba(60,140,20,0.10); border-color: rgba(60,140,20,0.22); } |
| .nav-right { margin-left: auto; display: flex; align-items: center; gap: 12px; } |
| .nav-model-tag { |
| font-size: 10.5px; font-weight: 700; color: #6A8860; |
| background: rgba(100,160,60,0.10); border: 1px solid rgba(100,160,60,0.22); |
| padding: 5px 12px; border-radius: 8px; letter-spacing: 0.3px; |
| font-family: 'JetBrains Mono', monospace; |
| transition: background 0.18s, color 0.18s; |
| } |
| .nav-model-tag:hover { background: rgba(100,160,60,0.18); color: #2A5010; } |
| .nav-live { |
| display: flex; align-items: center; gap: 7px; |
| background: rgba(30,160,80,0.08); border: 1px solid rgba(30,160,80,0.22); |
| color: #18924A; font-size: 10.5px; font-weight: 700; letter-spacing: 1.1px; |
| padding: 5px 14px; border-radius: 8px; text-transform: uppercase; |
| transition: background 0.18s; |
| } |
| .nav-live-dot { |
| width: 6px; height: 6px; border-radius: 50%; |
| background: #1DB954; box-shadow: 0 0 7px rgba(29,185,84,0.7); |
| animation: blink 1.8s ease-in-out infinite; flex-shrink: 0; |
| } |
| @keyframes blink { 0%,100% { opacity:1; } 50% { opacity:0.15; } } |
| |
| .section-label { |
| font-family: 'Syne', sans-serif; font-size: 10px; font-weight: 700; |
| letter-spacing: 2px; text-transform: uppercase; color: #6A9850; |
| display: flex; align-items: center; gap: 10px; margin-bottom: 14px; |
| } |
| .section-label::after { content: ""; flex: 1; height: 1px; background: linear-gradient(90deg, rgba(100,160,60,0.30), transparent); } |
| |
| .glass-card { |
| background: rgba(255,255,255,0.92); |
| backdrop-filter: blur(24px); |
| border: 1px solid rgba(160,200,120,0.28); |
| border-radius: 20px; padding: 26px; |
| box-shadow: 0 1px 0 rgba(255,255,255,0.98), 0 6px 28px rgba(60,100,30,0.08), inset 0 1px 0 rgba(255,255,255,0.98); |
| position: relative; overflow: hidden; |
| } |
| .glass-card::after { |
| content: ""; position: absolute; top: 0; left: 30px; right: 30px; height: 1px; |
| background: linear-gradient(90deg, transparent, rgba(100,180,60,0.45), transparent); |
| } |
| .card-title-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; } |
| .card-title-inner { display: flex; align-items: center; gap: 10px; } |
| .card-title-icon { |
| width: 34px; height: 34px; |
| background: linear-gradient(135deg, rgba(60,140,20,0.12), rgba(120,200,60,0.06)); |
| border: 1px solid rgba(60,140,20,0.20); border-radius: 10px; |
| display: flex; align-items: center; justify-content: center; |
| } |
| .card-title-text { font-family: 'Syne', sans-serif; font-size: 15px; font-weight: 700; color: #1A2318; letter-spacing: -0.3px; } |
| .card-badge { |
| font-size: 10.5px; font-weight: 600; letter-spacing: 0.4px; padding: 4px 12px; border-radius: 7px; |
| background: rgba(100,160,60,0.08); border: 1px solid rgba(100,160,60,0.20); color: #5A8040; |
| } |
| |
| .realtime-badge { |
| display: inline-flex; align-items: center; gap: 6px; |
| font-size: 10px; font-weight: 700; letter-spacing: 1.2px; text-transform: uppercase; |
| color: #18924A; background: rgba(30,160,80,0.09); border: 1px solid rgba(30,160,80,0.22); |
| padding: 4px 11px; border-radius: 7px; |
| } |
| .realtime-dot { |
| width: 5px; height: 5px; border-radius: 50%; |
| background: #1DB954; box-shadow: 0 0 5px rgba(29,185,84,0.6); |
| animation: blink 1.8s ease-in-out infinite; flex-shrink: 0; |
| } |
| |
| .field-group-label { |
| font-size: 9.5px; font-weight: 700; letter-spacing: 1.6px; text-transform: uppercase; |
| color: #7A9860; margin: 20px 0 9px; display: flex; align-items: center; gap: 8px; |
| } |
| .field-group-label::before { |
| content: ""; width: 14px; height: 2px; |
| background: linear-gradient(90deg, #3A8A20, #7DC840); border-radius: 2px; flex-shrink: 0; |
| } |
| |
| .stTextInput > div > div > input, |
| .stNumberInput > div > div > input, |
| .stDateInput > div > div > input, |
| .stTimeInput > div > div > input, |
| .stSelectbox > div > div > div { |
| background: rgba(240,248,235,0.80) !important; |
| border: 1px solid rgba(140,190,100,0.30) !important; |
| border-radius: 11px !important; color: #1A2318 !important; |
| font-family: 'DM Sans', sans-serif !important; font-size: 14px !important; |
| padding: 10px 13px !important; |
| transition: border-color 0.2s, box-shadow 0.2s, background 0.2s !important; |
| box-shadow: 0 1px 3px rgba(40,80,20,0.06) !important; |
| } |
| .stTextInput > div > div > input:focus, |
| .stNumberInput > div > div > input:focus { |
| border-color: rgba(60,140,20,0.55) !important; |
| background: #fff !important; |
| box-shadow: 0 0 0 3px rgba(60,140,20,0.10), 0 1px 4px rgba(40,80,20,0.08) !important; |
| outline: none !important; |
| } |
| .stTextInput label, .stNumberInput label, .stDateInput label, |
| .stTimeInput label, .stSelectbox label { |
| font-size: 11px !important; font-weight: 600 !important; letter-spacing: 0.6px !important; |
| color: #7A9860 !important; text-transform: uppercase !important; |
| font-family: 'DM Sans', sans-serif !important; |
| } |
| |
| .stButton > button { |
| width: 100% !important; |
| background: linear-gradient(135deg, #2E7010 0%, #4EA828 55%, #7DC840 100%) !important; |
| color: #fff !important; font-weight: 700 !important; font-size: 13px !important; |
| letter-spacing: 1.4px !important; border: none !important; border-radius: 12px !important; |
| padding: 14px 24px !important; cursor: pointer !important; |
| font-family: 'Syne', sans-serif !important; text-transform: uppercase !important; |
| transition: all 0.22s !important; |
| box-shadow: 0 4px 22px rgba(60,138,20,0.30), 0 1px 0 rgba(255,255,255,0.22) inset !important; |
| margin-top: 6px !important; |
| } |
| .stButton > button:hover { |
| background: linear-gradient(135deg, #215208 0%, #3A8A1A 100%) !important; |
| box-shadow: 0 8px 32px rgba(60,138,20,0.40), 0 1px 0 rgba(255,255,255,0.16) inset !important; |
| transform: translateY(-2px) !important; |
| } |
| .stButton > button:active { transform: translateY(0) !important; } |
| |
| .horizon-strip { display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px; margin-top: 14px; margin-bottom: 26px; } |
| .h-tile { |
| background: rgba(255,255,255,0.95); |
| border: 1px solid rgba(160,200,120,0.28); |
| border-radius: 16px; padding: 18px 12px 14px; text-align: center; |
| transition: all 0.24s cubic-bezier(.34,1.4,.64,1); cursor: default; position: relative; overflow: hidden; |
| box-shadow: 0 2px 8px rgba(60,100,30,0.06); |
| } |
| .h-tile::after { |
| content: ""; position: absolute; bottom: 0; left: 0; right: 0; height: 3px; |
| background: transparent; border-radius: 0 0 16px 16px; transition: background 0.22s; |
| } |
| .h-tile:hover { transform: translateY(-5px) scale(1.03); border-color: rgba(80,160,30,0.30); box-shadow: 0 10px 28px rgba(60,100,30,0.12); } |
| .h-tile:hover::after { background: linear-gradient(90deg, #3A8A20, #7DC840); } |
| .h-tile.prime { |
| background: linear-gradient(135deg, #2E7010 0%, #52B020 100%); |
| border-color: rgba(60,140,20,0.45); |
| box-shadow: 0 8px 28px rgba(60,140,20,0.30), inset 0 1px 0 rgba(255,255,255,0.18); |
| } |
| .h-tile.prime::after { background: rgba(255,255,255,0.22); } |
| .h-tile.prime:hover { box-shadow: 0 14px 36px rgba(60,140,20,0.42); } |
| .h-tile.prime .h-label { color: rgba(255,255,255,0.65); } |
| .h-tile.prime .h-val { color: #fff; } |
| .h-tile.prime .h-unit { color: rgba(255,255,255,0.60); } |
| .h-tile.prime .h-delta { color: rgba(255,255,255,0.65); } |
| .h-label { font-size: 9px; font-weight: 700; letter-spacing: 1.6px; text-transform: uppercase; color: #7A9860; margin-bottom: 9px; } |
| .h-val { font-family: 'JetBrains Mono', monospace; font-size: 25px; font-weight: 600; color: #1A2318; letter-spacing: -1px; line-height: 1; } |
| .h-unit { font-size: 11px; color: #7A9860; margin-left: 2px; } |
| .h-delta { font-size: 10px; margin-top: 7px; font-weight: 600; display: flex; align-items: center; justify-content: center; gap: 3px; } |
| .h-delta.up { color: #18924A; } |
| .h-delta.dn { color: #C04040; } |
| .h-delta.nu { color: #7A9860; } |
| |
| .stat-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-top: 16px; } |
| .stat-tile { |
| background: rgba(245,252,240,0.90); |
| border: 1px solid rgba(160,200,120,0.22); |
| border-radius: 12px; padding: 15px; text-align: center; |
| transition: background 0.18s, box-shadow 0.18s, transform 0.18s; |
| box-shadow: 0 1px 4px rgba(60,100,30,0.05); |
| } |
| .stat-tile:hover { background: #fff; box-shadow: 0 4px 14px rgba(60,100,30,0.10); transform: translateY(-2px); } |
| .stat-tile-label { font-size: 9px; font-weight: 700; letter-spacing: 1.4px; text-transform: uppercase; color: #7A9860; margin-bottom: 6px; } |
| .stat-tile-val { font-family: 'JetBrains Mono', monospace; font-size: 14.5px; font-weight: 500; color: #2A4020; } |
| |
| .model-info-bar { |
| display: flex; align-items: center; gap: 10px; |
| background: rgba(100,180,60,0.07); border: 1px solid rgba(100,180,60,0.18); |
| border-radius: 10px; padding: 10px 15px; margin-top: 14px; |
| font-size: 12px; color: #5A8040; |
| } |
| .model-info-bar strong { color: #2E7010; font-weight: 600; } |
| .model-info-dot { |
| width: 6px; height: 6px; border-radius: 50%; |
| background: #1DB954; box-shadow: 0 0 6px rgba(29,185,84,0.6); |
| animation: blink 2s infinite; flex-shrink: 0; |
| } |
| |
| .feature-debug-bar { |
| font-size: 11px; color: #5A8040; |
| background: rgba(100,180,60,0.07); border: 1px solid rgba(100,180,60,0.16); |
| border-radius: 8px; padding: 8px 13px; margin-top: 8px; |
| font-family: 'JetBrains Mono', monospace; |
| } |
| |
| .computing-bar { |
| display: flex; align-items: center; gap: 10px; |
| background: rgba(60,140,20,0.06); border: 1px solid rgba(60,140,20,0.18); |
| border-radius: 10px; padding: 10px 15px; margin-bottom: 14px; |
| font-size: 12px; color: #3A7010; font-weight: 600; |
| } |
| |
| .stSpinner > div { border-top-color: #3A8A20 !important; } |
| .stAlert { background: rgba(220,100,60,0.07) !important; border: 1px solid rgba(220,100,60,0.18) !important; border-radius: 12px !important; color: #9A4030 !important; } |
| div[data-testid="stExpander"] { background: rgba(245,252,240,0.90) !important; border: 1px solid rgba(160,200,120,0.22) !important; border-radius: 12px !important; } |
| .js-plotly-plot .plotly, .plot-container { background: transparent !important; } |
| |
| .empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 56px 24px; text-align: center; } |
| .empty-icon-wrap { |
| width: 68px; height: 68px; |
| background: linear-gradient(135deg, rgba(60,140,20,0.07), rgba(60,140,20,0.03)); |
| border: 2px dashed rgba(60,140,20,0.24); border-radius: 20px; |
| display: flex; align-items: center; justify-content: center; margin-bottom: 18px; |
| animation: pulse-border 3s ease-in-out infinite; |
| } |
| @keyframes pulse-border { |
| 0%, 100% { box-shadow: 0 0 0 0 rgba(60,140,20,0.10); } |
| 50% { box-shadow: 0 0 0 10px rgba(60,140,20,0.02); } |
| } |
| .empty-title { font-family: 'Syne', sans-serif; font-size: 15px; font-weight: 700; color: #7A9860; margin-bottom: 7px; } |
| .empty-sub { font-size: 13px; color: #9AB880; max-width: 240px; line-height: 1.65; } |
| </style> |
| """, unsafe_allow_html=True) |
|
|
| BASE_DIR = os.path.dirname(os.path.abspath(__file__)) |
|
|
| MODELS = { |
| "2025-26 Model": "2025_26_model", |
| "2024 Model": "2024_model", |
| "2023 Model": "2023_model", |
| "2016-17 Model": "2016_17_model", |
| } |
|
|
|
|
| def get_folder_path(model_key): |
| rel = MODELS[model_key] |
| abs_path = os.path.join(BASE_DIR, rel) |
| return abs_path if os.path.isdir(abs_path) else os.path.join(os.getcwd(), rel) |
|
|
|
|
| @st.cache_resource(max_entries=4) |
| def load_assets(folder_path): |
| import torch |
| import torch.nn as nn |
| import torch.nn.functional as F |
| import joblib |
| import json |
|
|
| class TFTBranch(nn.Module): |
| def __init__(self, input_dim, hidden_dim, num_horizons): |
| super().__init__() |
| self.lstm = nn.LSTM(input_dim, hidden_dim, num_layers=2, batch_first=True, dropout=0.2) |
| self.attention = nn.MultiheadAttention(embed_dim=hidden_dim, num_heads=4, batch_first=True, dropout=0.2) |
| self.dropout = nn.Dropout(0.2) |
| self.fc = nn.Linear(hidden_dim, num_horizons) |
|
|
| def forward(self, x): |
| lstm_out, _ = self.lstm(x) |
| lstm_out = self.dropout(lstm_out) |
| attn_out, attn_weights = self.attention(lstm_out, lstm_out, lstm_out) |
| attn_out = self.dropout(attn_out) |
| return self.fc(attn_out[:, -1, :]), attn_weights |
|
|
| class NBEATSBlock(nn.Module): |
| def __init__(self, input_size, theta_size, hidden_size): |
| super().__init__() |
| self.fc1 = nn.Linear(input_size, hidden_size) |
| self.fc2 = nn.Linear(hidden_size, hidden_size) |
| self.fc3 = nn.Linear(hidden_size, hidden_size) |
| self.fc4 = nn.Linear(hidden_size, hidden_size) |
| self.dropout = nn.Dropout(0.2) |
| self.theta_layer = nn.Linear(hidden_size, theta_size) |
| self.backcast_layer = nn.Linear(theta_size, input_size) |
| self.forecast_layer = nn.Linear(theta_size, theta_size) |
|
|
| def forward(self, x): |
| h = self.dropout(F.relu(self.fc1(x))) |
| h = self.dropout(F.relu(self.fc2(h))) |
| h = self.dropout(F.relu(self.fc3(h))) |
| h = self.dropout(F.relu(self.fc4(h))) |
| theta = self.theta_layer(h) |
| return self.backcast_layer(theta), self.forecast_layer(theta), theta |
|
|
| class NBEATSBranch(nn.Module): |
| def __init__(self, seq_len, num_horizons, hidden_size=128): |
| super().__init__() |
| self.trend_block = NBEATSBlock(seq_len, num_horizons, hidden_size) |
| self.seasonality_block = NBEATSBlock(seq_len, num_horizons, hidden_size) |
|
|
| def forward(self, x): |
| x_flat = x[:, :, 0] |
| backcast_t, forecast_t, _ = self.trend_block(x_flat) |
| _, forecast_s, _ = self.seasonality_block(x_flat - backcast_t) |
| return forecast_t + forecast_s, forecast_t, forecast_s |
|
|
| class HybridFusionModel(nn.Module): |
| def __init__(self, input_dim, seq_len, hidden_dim, num_horizons): |
| super().__init__() |
| self.tft = TFTBranch(input_dim, hidden_dim, num_horizons) |
| self.nbeats = NBEATSBranch(seq_len, num_horizons, hidden_dim) |
| self.attention_fusion = nn.Sequential( |
| nn.Linear(num_horizons * 2, hidden_dim), nn.ReLU(), |
| nn.Linear(hidden_dim, 2), nn.Softmax(dim=-1) |
| ) |
|
|
| def forward(self, x): |
| tft_out, tft_attn = self.tft(x) |
| nbeats_out, nbeats_trend, nbeats_season = self.nbeats(x) |
| weights = self.attention_fusion(torch.cat([tft_out, nbeats_out], dim=-1)) |
| w_tft = weights[:, 0].unsqueeze(1) |
| w_nbeats = weights[:, 1].unsqueeze(1) |
| return (tft_out * w_tft) + (nbeats_out * w_nbeats), tft_attn, nbeats_trend, nbeats_season |
|
|
| try: |
| with open(os.path.join(folder_path, "config.json")) as f: |
| config = json.load(f) |
| scaler = joblib.load(os.path.join(folder_path, "scaler.pkl")) |
| model_path = os.path.join(folder_path, "pytorch_model.bin") |
| model = HybridFusionModel( |
| config.get("input_dim", 12), config.get("seq_len", 144), |
| config.get("hidden_dim", 64), config.get("num_horizons", 5) |
| ) |
| try: |
| state_dict = torch.load(model_path, map_location="cpu", weights_only=True) |
| except Exception: |
| state_dict = torch.load(model_path, map_location="cpu", weights_only=False) |
| model.load_state_dict(state_dict, strict=False) |
| model.eval() |
| return model, scaler, config, None |
| except Exception: |
| return None, None, None, traceback.format_exc() |
|
|
|
|
| def get_all_feature_names(model_key): |
| folder_path = get_folder_path(model_key) |
| try: |
| import json |
| with open(os.path.join(folder_path, "config.json")) as f: |
| cfg = json.load(f) |
| input_dim = cfg.get("input_dim", 12) |
| return cfg.get("numeric_cols", [f"Feature_{i}" for i in range(input_dim)]) |
| except Exception: |
| return [f"Feature_{i}" for i in range(12)] |
|
|
|
|
| def build_full_input_row(user_inputs, all_feature_names): |
| row = {feat: 0.0 for feat in all_feature_names} |
| name_matched = 0 |
| for feat, val in user_inputs.items(): |
| if feat in row: |
| row[feat] = val |
| name_matched += 1 |
| if name_matched == 0: |
| user_vals = list(user_inputs.values()) |
| for i, feat in enumerate(all_feature_names[:len(user_vals)]): |
| row[feat] = user_vals[i] |
| return row, name_matched |
|
|
|
|
| def build_temporal_sequence(scaled_row, seq_len, user_inputs): |
| import numpy as np |
|
|
| ghi = user_inputs.get("101_DKA_WeatherStation_Global_Horizontal_Radiation", 0.0) |
| pv = user_inputs.get("241_DKA_Totals_PV_Active_Power", 0.0) |
| temp = user_inputs.get("101_DKA_WeatherStation_Weather_Temperature_Celsius", 25.0) |
| hum = user_inputs.get("101_DKA_WeatherStation_Weather_Relative_Humidity", 50.0) |
| wspd = user_inputs.get("101_DKA_WeatherStation_Wind_Speed", 0.0) |
|
|
| ghi_norm = min(ghi / 1400.0, 1.0) |
| pv_norm = min(pv / 5000.0, 1.0) |
| temp_norm = (temp + 10.0) / 65.0 |
| hum_norm = hum / 100.0 |
| wspd_norm = min(wspd / 25.0, 1.0) |
|
|
| seq = np.tile(scaled_row, (seq_len, 1)).copy() |
|
|
| t = np.linspace(0, 1, seq_len) |
|
|
| morning_ramp = np.clip(ghi_norm * np.sin(np.pi * t) * 0.35, 0, 0.35) |
| temp_drift = temp_norm * np.linspace(0, 0.12, seq_len) |
| cloud_ripple = hum_norm * 0.08 * np.sin(2 * np.pi * t * 3 + ghi_norm * np.pi) |
| wind_effect = wspd_norm * 0.05 * np.sin(2 * np.pi * t * 7 + pv_norm * 0.5) |
| baseline_shift = pv_norm * np.linspace(0, 0.15, seq_len) |
|
|
| radiation_signal = morning_ramp + cloud_ripple + wind_effect |
| power_signal = baseline_shift + temp_drift |
|
|
| num_features = seq.shape[1] |
| for i in range(num_features): |
| if i % 3 == 0: |
| seq[:, i] += radiation_signal |
| elif i % 3 == 1: |
| seq[:, i] += power_signal |
| else: |
| seq[:, i] += (radiation_signal + power_signal) * 0.5 |
|
|
| return seq |
|
|
|
|
| def compute_physics_adjusted_forecast(raw_forecast, user_inputs): |
| import numpy as np |
|
|
| ghi = user_inputs.get("101_DKA_WeatherStation_Global_Horizontal_Radiation", 0.0) |
| dhi = user_inputs.get("101_DKA_WeatherStation_Diffuse_Horizontal_Radiation", 0.0) |
| temp = user_inputs.get("101_DKA_WeatherStation_Weather_Temperature_Celsius", 25.0) |
| hum = user_inputs.get("101_DKA_WeatherStation_Weather_Relative_Humidity", 50.0) |
| wspd = user_inputs.get("101_DKA_WeatherStation_Wind_Speed", 0.0) |
| pv_now = user_inputs.get("241_DKA_Totals_PV_Active_Power", 0.0) |
| bess = user_inputs.get("239_DKA_Totals_BESS_State_of_Charge", 50.0) |
| rain = user_inputs.get("101_DKA_WeatherStation_Rainfall", 0.0) |
|
|
| irradiance_factor = ghi / 850.0 |
| diffuse_ratio = dhi / (ghi + 1e-6) |
| temp_derating = 1.0 - max(0.0, (temp - 25.0) * 0.004) |
| humidity_loss = 1.0 - (hum / 100.0) * 0.03 |
| wind_cooling = 1.0 + min(wspd / 15.0, 0.3) * 0.02 |
| rain_loss = 1.0 - min(rain / 10.0, 0.5) * 0.15 |
| bess_boost = 1.0 + (bess / 100.0) * 0.05 |
|
|
| current_efficiency = ( |
| irradiance_factor * temp_derating * humidity_loss * |
| wind_cooling * rain_loss * bess_boost |
| ) |
| current_efficiency = max(0.0, min(current_efficiency, 2.5)) |
|
|
| horizon_decay = np.array([1.0, 0.97, 0.93, 0.88, 0.82]) |
| horizon_decay = horizon_decay[:len(raw_forecast)] |
|
|
| irradiance_decay = np.array([1.0, 0.94, 0.85, 0.74, 0.62]) |
| irradiance_decay = irradiance_decay[:len(raw_forecast)] |
|
|
| diffuse_correction = 1.0 - diffuse_ratio * 0.15 |
| adjusted = ( |
| raw_forecast[:len(horizon_decay)] * |
| current_efficiency * |
| horizon_decay * |
| (irradiance_decay * (1 - diffuse_ratio) + diffuse_correction * diffuse_ratio) |
| ) |
|
|
| if pv_now > 0: |
| anchor_weight = np.linspace(0.40, 0.05, len(adjusted)) |
| adjusted = adjusted * (1 - anchor_weight) + pv_now * anchor_weight * current_efficiency |
|
|
| adjusted = np.clip(adjusted, 0.0, 5000.0) |
| return adjusted |
|
|
|
|
| def run_inference(selected_model, user_inputs): |
| import torch |
| import numpy as np |
| import pandas as pd |
|
|
| folder_path = get_folder_path(selected_model) |
| model, scaler, config, error = load_assets(folder_path) |
|
|
| if error: |
| return None, None, None, None, error |
|
|
| seq_len_cfg = config.get("seq_len", 144) |
| all_feature_names = get_all_feature_names(selected_model) |
| full_row, name_matched = build_full_input_row(user_inputs, all_feature_names) |
|
|
| input_df = pd.DataFrame([full_row]) |
| scaled_data = scaler.transform(input_df) |
|
|
| input_seq = build_temporal_sequence(scaled_data, seq_len_cfg, user_inputs) |
|
|
| input_tensor = torch.tensor(input_seq, dtype=torch.float32).unsqueeze(0) |
|
|
| with torch.no_grad(): |
| preds, _, _, _ = model(input_tensor) |
| raw_forecast = preds.detach().cpu().numpy().flatten() |
|
|
| adjusted_forecast = compute_physics_adjusted_forecast(raw_forecast, user_inputs) |
|
|
| return adjusted_forecast, config, seq_len_cfg, name_matched, None |
|
|
|
|
| def main(): |
| st.markdown(""" |
| <div class="pv-nav"> |
| <div class="pv-logo-mark"> |
| <svg width="18" height="18" viewBox="0 0 18 18" fill="none"> |
| <path d="M9 2L11 7H16L12 10.5L13.5 16L9 13L4.5 16L6 10.5L2 7H7L9 2Z" fill="#fff" fill-opacity="0.96"/> |
| </svg> |
| </div> |
| <div class="pv-brand"> |
| <div class="pv-brand-name">SolarIQ</div> |
| <div class="pv-brand-sub">PV Power Forecast Platform</div> |
| </div> |
| <div class="nav-divider"></div> |
| <div class="nav-links"> |
| <div class="nav-link active">Forecast</div> |
| <div class="nav-link">History</div> |
| <div class="nav-link">Analytics</div> |
| <div class="nav-link">Settings</div> |
| </div> |
| <div class="nav-right"> |
| <div class="nav-model-tag">TFT + N-BEATS</div> |
| <div class="nav-live"><div class="nav-live-dot"></div>Live</div> |
| </div> |
| </div> |
| <div style="height:30px"></div> |
| """, unsafe_allow_html=True) |
|
|
| left_col, right_col = st.columns([1, 1.75], gap="large") |
|
|
| with left_col: |
| st.markdown(""" |
| <div class="glass-card"> |
| <div class="card-title-row"> |
| <div class="card-title-inner"> |
| <div class="card-title-icon"> |
| <svg width="16" height="16" viewBox="0 0 16 16" fill="none"> |
| <rect x="1" y="4" width="14" height="9" rx="2" stroke="#3A8A20" stroke-width="1.4"/> |
| <path d="M5 4V3a3 3 0 016 0v1" stroke="#3A8A20" stroke-width="1.4"/> |
| <circle cx="8" cy="8.5" r="1.5" fill="#3A8A20"/> |
| </svg> |
| </div> |
| <span class="card-title-text">Input Parameters</span> |
| </div> |
| <span class="card-badge">Live Sync</span> |
| </div> |
| </div> |
| """, unsafe_allow_html=True) |
|
|
| st.markdown('<div class="field-group-label">Temporal Model</div>', unsafe_allow_html=True) |
| selected_model = st.selectbox("Active Model", list(MODELS.keys()), label_visibility="collapsed") |
|
|
| st.markdown('<div class="field-group-label">Timestamp</div>', unsafe_allow_html=True) |
| col_date, col_time = st.columns(2) |
| with col_date: |
| input_date = st.date_input("Date", label_visibility="visible") |
| with col_time: |
| input_time = st.time_input("Time", label_visibility="visible") |
|
|
| st.markdown('<div class="field-group-label">Solar Radiation</div>', unsafe_allow_html=True) |
| col_a, col_b = st.columns(2) |
| with col_a: |
| ghi_val = st.number_input("Global Horizontal (W/m2)", min_value=0.0, max_value=1400.0, value=850.0, format="%.1f") |
| with col_b: |
| dhi_val = st.number_input("Diffuse Horizontal (W/m2)", min_value=0.0, max_value=800.0, value=120.0, format="%.1f") |
|
|
| st.markdown('<div class="field-group-label">Ambient Conditions</div>', unsafe_allow_html=True) |
| col_c, col_d = st.columns(2) |
| with col_c: |
| temp_val = st.number_input("Temperature (C)", min_value=-10.0, max_value=55.0, value=31.2, format="%.1f") |
| with col_d: |
| hum_val = st.number_input("Rel. Humidity (%)", min_value=0.0, max_value=100.0, value=38.5, format="%.1f") |
|
|
| st.markdown('<div class="field-group-label">Wind and Precipitation</div>', unsafe_allow_html=True) |
| col_e, col_f, col_g = st.columns(3) |
| with col_e: |
| wspd_val = st.number_input("Wind Speed (m/s)", min_value=0.0, max_value=25.0, value=3.5, format="%.1f") |
| with col_f: |
| wdir_val = st.number_input("Direction (deg)", min_value=0.0, max_value=360.0, value=51.0, format="%.1f") |
| with col_g: |
| rain_val = st.number_input("Rainfall (mm)", min_value=0.0, max_value=50.0, value=0.0, format="%.1f") |
|
|
| st.markdown('<div class="field-group-label">Plant State</div>', unsafe_allow_html=True) |
| col_h, col_i = st.columns(2) |
| with col_h: |
| bess_val = st.number_input("Battery SoC (%)", min_value=0.0, max_value=100.0, value=72.0, format="%.1f") |
| with col_i: |
| pv_val = st.number_input("PV Active Power (kW)", min_value=0.0, max_value=5000.0, value=152.0, format="%.1f") |
|
|
| user_inputs = { |
| "101_DKA_WeatherStation_Global_Horizontal_Radiation": ghi_val, |
| "101_DKA_WeatherStation_Diffuse_Horizontal_Radiation": dhi_val, |
| "101_DKA_WeatherStation_Weather_Temperature_Celsius": temp_val, |
| "101_DKA_WeatherStation_Weather_Relative_Humidity": hum_val, |
| "101_DKA_WeatherStation_Wind_Speed": wspd_val, |
| "101_DKA_WeatherStation_Wind_Direction": wdir_val, |
| "101_DKA_WeatherStation_Rainfall": rain_val, |
| "239_DKA_Totals_BESS_State_of_Charge": bess_val, |
| "241_DKA_Totals_PV_Active_Power": pv_val, |
| } |
|
|
| st.markdown("<div style='height:4px'></div>", unsafe_allow_html=True) |
|
|
| st.markdown(""" |
| <div class="model-info-bar"> |
| <div class="model-info-dot"></div> |
| <span>Model ready · <strong>TFT + N-BEATS</strong> hybrid · Updates on every change</span> |
| </div> |
| """, unsafe_allow_html=True) |
|
|
| with right_col: |
| st.markdown(""" |
| <div class="glass-card" style="margin-bottom:0"> |
| <div class="card-title-row"> |
| <div class="card-title-inner"> |
| <div class="card-title-icon"> |
| <svg width="16" height="16" viewBox="0 0 16 16" fill="none"> |
| <polyline points="1,12 5,7 8,10 11,5 15,3" stroke="#3A8A20" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/> |
| <circle cx="15" cy="3" r="1.5" fill="#3A8A20"/> |
| </svg> |
| </div> |
| <span class="card-title-text">Forecast Results</span> |
| </div> |
| <div style="display:flex;align-items:center;gap:8px"> |
| <span class="card-badge">5 Horizons</span> |
| <span class="realtime-badge"><span class="realtime-dot"></span>Auto-updating</span> |
| </div> |
| </div> |
| </div> |
| """, unsafe_allow_html=True) |
|
|
| try: |
| with st.spinner("Computing forecast..."): |
| forecast, config, seq_len_cfg, name_matched, error = run_inference(selected_model, user_inputs) |
|
|
| if error: |
| st.error("Failed to load model assets.") |
| st.code(error) |
| else: |
| import plotly.graph_objects as go |
|
|
| if name_matched == 0: |
| st.markdown( |
| '<div class="feature-debug-bar">Feature names unmatched — using positional assignment for input mapping.</div>', |
| unsafe_allow_html=True |
| ) |
|
|
| horizon_labels = ["30 Min", "2 Hrs", "6 Hrs", "12 Hrs", "24 Hrs"] |
| horizon_labels = horizon_labels[:len(forecast)] |
| vals = forecast[:len(horizon_labels)] |
|
|
| cards_html = '<div class="horizon-strip">' |
| for i, (lbl, val) in enumerate(zip(horizon_labels, vals)): |
| prime = "prime" if i == 0 else "" |
| if i == 0: |
| delta_cls, delta_txt = "nu", "Nearest horizon" |
| elif val >= vals[i - 1]: |
| pct = abs((val - vals[i - 1]) / (vals[i - 1] + 1e-6) * 100) |
| delta_cls, delta_txt = "up", f"+ {pct:.1f}%" |
| else: |
| pct = abs((val - vals[i - 1]) / (vals[i - 1] + 1e-6) * 100) |
| delta_cls, delta_txt = "dn", f"- {pct:.1f}%" |
|
|
| cards_html += f""" |
| <div class="h-tile {prime}"> |
| <div class="h-label">{lbl}</div> |
| <div><span class="h-val">{val:.1f}</span><span class="h-unit">kW</span></div> |
| <div class="h-delta {delta_cls}">{delta_txt}</div> |
| </div>""" |
| cards_html += "</div>" |
| st.markdown(cards_html, unsafe_allow_html=True) |
|
|
| st.markdown('<div class="section-label">Forecast Trend</div>', unsafe_allow_html=True) |
|
|
| now_labels = ["Now"] + [f"+{lbl}" for lbl in horizon_labels] |
| chart_vals = [float(vals[0])] + [float(v) for v in vals] |
|
|
| fig = go.Figure() |
| fig.add_trace(go.Scatter( |
| x=now_labels, y=chart_vals, fill='tozeroy', |
| fillcolor='rgba(60,140,20,0.07)', line=dict(color='rgba(0,0,0,0)'), |
| showlegend=False, hoverinfo='skip' |
| )) |
| fig.add_trace(go.Scatter( |
| x=now_labels, y=[max(chart_vals) * 1.10] * len(now_labels), |
| fill='tonexty', fillcolor='rgba(60,140,20,0.022)', |
| line=dict(color='rgba(0,0,0,0)'), showlegend=False, hoverinfo='skip' |
| )) |
| fig.add_trace(go.Scatter( |
| x=now_labels, y=chart_vals, mode='lines+markers', name='Forecast', |
| line=dict(color='#3A8A20', width=2.8, shape='spline', smoothing=0.85), |
| marker=dict( |
| size=[15] + [9] * len(vals), |
| color=['#3A8A20'] + ['#FFFFFF'] * len(vals), |
| line=dict(color='#3A8A20', width=2.2), |
| ), |
| hovertemplate='<b>%{x}</b><br>%{y:.2f} kW<extra></extra>' |
| )) |
| fig.update_layout( |
| template='plotly_white', |
| paper_bgcolor='rgba(0,0,0,0)', |
| plot_bgcolor='rgba(0,0,0,0)', |
| margin=dict(l=10, r=10, t=14, b=10), |
| height=265, |
| showlegend=False, |
| hovermode='x unified', |
| font=dict(family='DM Sans, sans-serif', color='#7A9860'), |
| xaxis=dict( |
| showgrid=False, zeroline=False, |
| tickfont=dict(family='DM Sans', size=11, color='#7A9860'), |
| linecolor='rgba(100,160,60,0.14)', showline=True |
| ), |
| yaxis=dict( |
| showgrid=True, gridcolor='rgba(100,160,60,0.10)', zeroline=False, |
| tickfont=dict(family='JetBrains Mono', size=10, color='#7A9860'), |
| title_text='kW', |
| title_font=dict(size=11, color='#7A9860', family='DM Sans'), |
| tickformat='.0f' |
| ) |
| ) |
| st.plotly_chart(fig, use_container_width=True, config={'displayModeBar': False}) |
|
|
| st.markdown(f""" |
| <div class="stat-row"> |
| <div class="stat-tile"> |
| <div class="stat-tile-label">Architecture</div> |
| <div class="stat-tile-val">TFT + N-BEATS</div> |
| </div> |
| <div class="stat-tile"> |
| <div class="stat-tile-label">Active Inputs</div> |
| <div class="stat-tile-val">{name_matched if name_matched > 0 else len(user_inputs)} Features</div> |
| </div> |
| <div class="stat-tile"> |
| <div class="stat-tile-label">Lookback Window</div> |
| <div class="stat-tile-val">{seq_len_cfg} steps</div> |
| </div> |
| </div> |
| """, unsafe_allow_html=True) |
|
|
| st.markdown("<div style='height:14px'></div>", unsafe_allow_html=True) |
| with st.expander("Technical Model Configuration"): |
| st.json(config) |
|
|
| except Exception: |
| st.error("Error during inference:") |
| st.code(traceback.format_exc()) |
|
|
| st.markdown(""" |
| <div style="text-align:center;padding:36px 0 16px;font-size:10.5px;color:#8AAA70;letter-spacing:1.2px;font-family:'DM Sans',sans-serif;text-transform:uppercase"> |
| SolarIQ · Hybrid Fusion Model · TFT + N-BEATS · Renewable Energy AI |
| </div> |
| """, unsafe_allow_html=True) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
| else: |
| main() |