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(""" """, 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("""