Re-Energy / src /streamlit_app.py
rocky250's picture
Update src/streamlit_app.py
d5c10e9 verified
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 &nbsp;&middot;&nbsp; <strong>TFT + N-BEATS</strong> hybrid &nbsp;&middot;&nbsp; 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 &nbsp;&middot;&nbsp; Hybrid Fusion Model &nbsp;&middot;&nbsp; TFT + N-BEATS &nbsp;&middot;&nbsp; Renewable Energy AI
</div>
""", unsafe_allow_html=True)
if __name__ == "__main__":
main()
else:
main()