Download app.py from bcvatust/ssb: direct link, hf CLI and curl.
- Browser
- Download file 21.4 kB
-
https://huggingface.co/spaces/bcvatust/ssb/resolve/main/app.py
- Command line
-
hf download hf://spaces/bcvatust/ssb/app.py
-
curl -L -o app.py https://huggingface.co/spaces/bcvatust/ssb/resolve/main/app.py
21.4 kB
| import gradio as gr | |
| import pandas as pd | |
| import numpy as np | |
| import plotly.graph_objects as go | |
| import os | |
| from datetime import datetime | |
| import warnings | |
| warnings.filterwarnings('ignore') | |
| # ============================================================================ | |
| # DATA LOADER: SolarDataLake | |
| # ============================================================================ | |
| class SolarDataLake: | |
| """Data ingestion engine that loads physics calculations and time series data.""" | |
| def __init__(self): | |
| print("\n" + "="*40) | |
| print("🚀 STARTING SOLAR AGENTIC SIMULATION") | |
| print("="*40) | |
| self.constants = self._load_assumptions() | |
| self.df = self._load_time_series() | |
| if self.df.empty: | |
| print("\n❌ CRITICAL: No data loaded. Please check file paths.") | |
| else: | |
| self.max_steps = len(self.df) | |
| self.current_step = 0 | |
| print(f"\n✅ SUCCESS: Simulation Ready with {self.max_steps} synchronized steps.") | |
| def _load_assumptions(self): | |
| """Load physics assumptions from Excel.""" | |
| filename = "Power Calculation_v1.xlsx" | |
| sheet_name = "Assumptions - Loss calculations" | |
| defaults = {'PLANNED_CAPACITY_KW': 6012.44, 'TEMP_COEFF': -0.00279, 'LOSS_FACTOR': 0.8197, 'STC_TEMP': 25.0} | |
| if not os.path.exists(filename): | |
| return defaults | |
| try: | |
| df = pd.read_excel(filename, sheet_name=sheet_name, header=1) | |
| def get_val(key): | |
| mask = df.iloc[:, 0].astype(str).str.contains(key, case=False, na=False) | |
| return float(df.loc[mask].iloc[0, 1]) if mask.any() else None | |
| cap = get_val('Planned Power') | |
| coeff = get_val('Temperature coefficient') | |
| loss = get_val('efficiency') | |
| stc = get_val('STC') | |
| return { | |
| 'PLANNED_CAPACITY_KW': cap if cap else defaults['PLANNED_CAPACITY_KW'], | |
| 'TEMP_COEFF': coeff if coeff else defaults['TEMP_COEFF'], | |
| 'LOSS_FACTOR': loss if loss else defaults['LOSS_FACTOR'], | |
| 'STC_TEMP': stc if stc else defaults['STC_TEMP'] | |
| } | |
| except Exception as e: | |
| print(f"Warning: Could not load assumptions: {e}") | |
| return defaults | |
| def _estimate_module_temp(self, gii): | |
| """Estimate module temperature from irradiance using linear model.""" | |
| # Model trained on Excel data: Temp = 20.46 + 0.0247 * GII | |
| return 20.46 + 0.0247 * gii | |
| def _load_time_series(self): | |
| """Load and align all time series data sources.""" | |
| # 1. LOAD PHYSICS DATA | |
| excel_file = "Power Calculation_v1.xlsx" | |
| calc_df = pd.DataFrame() | |
| if os.path.exists(excel_file): | |
| try: | |
| calc_df = pd.read_excel(excel_file, sheet_name="Energy_Power Calculation - Tabl", header=1) | |
| calc_df = calc_df.rename(columns={'Time (Hrs)': 'Time_Raw', 'GII (W/m2)': 'GII_Excel', 'Module Temp (oC)': 'Mod_Temp'}) | |
| calc_df = calc_df.dropna(subset=['Time_Raw']) | |
| calc_df['Temp_DT'] = pd.to_datetime(calc_df['Time_Raw']) | |
| calc_df['Timestamp'] = pd.to_datetime("2026-02-08 " + calc_df['Temp_DT'].dt.strftime('%H:%M:%S')) | |
| calc_df = calc_df.set_index('Timestamp')[['GII_Excel', 'Mod_Temp']] | |
| except Exception as e: | |
| print(f"Warning: Could not load physics data: {e}") | |
| pass | |
| # 2. LOAD SENSORS - Use ONLY 2-month data file | |
| def read_sensor(fname): | |
| if not os.path.exists(fname): | |
| return pd.DataFrame() | |
| try: | |
| df = pd.read_csv(fname) | |
| t_col = next((c for c in df.columns if "Time" in str(c)), None) | |
| if t_col: | |
| df['Timestamp'] = pd.to_datetime(df[t_col]) | |
| return df.set_index('Timestamp').drop(columns=[t_col]) | |
| except Exception as e: | |
| print(f"Warning: Could not load {fname}: {e}") | |
| return pd.DataFrame() | |
| # Use ONLY 2-month data for all information | |
| grid_df = read_sensor("ICOG Panel data_2 months.csv") | |
| if grid_df.empty: | |
| return pd.DataFrame() | |
| # 3. PROCESS 2-MONTH DATA | |
| master = grid_df.copy() | |
| # Rename columns for consistency | |
| master = master.rename(columns={ | |
| 'ICOG PANEL MFM - Irradiance (W/m2)': 'GII', | |
| 'ICOG PANEL MFM - Active Power (kW)': 'Act_Power_kW', | |
| 'ICOG PANEL MFM - OutputFrequency (Hz)': 'Grid_Freq' | |
| }) | |
| # Estimate module temperature from irradiance | |
| master['Mod_Temp'] = self._estimate_module_temp(master['GII']) | |
| # Add inverter temperature data (from Excel assumptions, using default values) | |
| # Since 2-month file doesn't have per-inverter temp, use estimated module temp | |
| master['Inv-2.1 - InverterTemperature (°C)'] = master['Mod_Temp'] - 0.5 # Slightly lower for inverter | |
| master['Inv-2.2 - InverterTemperature (°C)'] = master['Mod_Temp'] + 0.5 # Slightly higher for inverter | |
| return master.sort_index().fillna(0) | |
| def get_next_tick(self): | |
| """Get next time step data.""" | |
| if self.df.empty: | |
| return None, None | |
| if self.current_step >= self.max_steps: | |
| self.current_step = 0 | |
| row = self.df.iloc[self.current_step] | |
| timestamp = self.df.index[self.current_step] | |
| self.current_step += 1 | |
| return timestamp, row | |
| # ============================================================================ | |
| # POLICY ENGINE (simplified from claudecode_policy_engine.py) | |
| # ============================================================================ | |
| class PolicyEngine: | |
| """Simplified policy engine for command validation.""" | |
| def __init__(self): | |
| self.policies = { | |
| 'safety_envelope': { | |
| 'temperature': {'warning': 75, 'critical': 85}, | |
| 'frequency': {'min_safe': 49.5, 'max_safe': 50.2}, | |
| 'irradiance': {'min_generation': 50} | |
| }, | |
| 'lockouts': { | |
| 'hard': ['E205', 'E208'], # Ground fault, Isolation fault | |
| 'soft': ['E201', 'E202'] # Voltage, Frequency faults | |
| } | |
| } | |
| def validate_command(self, inverter_id, action, telemetry, active_faults): | |
| """Validate if a command can be executed.""" | |
| violations = [] | |
| # Check for active faults that create hard lockouts | |
| for fault in active_faults: | |
| if fault['code'] in self.policies['lockouts']['hard']: | |
| violations.append(f"Hard lockout: {fault['description']}") | |
| # Check grid conditions | |
| freq = telemetry.get('Grid_Freq', 50.0) | |
| if freq < self.policies['safety_envelope']['frequency']['min_safe']: | |
| violations.append(f"Grid frequency too low: {freq} Hz") | |
| if freq > self.policies['safety_envelope']['frequency']['max_safe']: | |
| violations.append(f"Grid frequency too high: {freq} Hz") | |
| return { | |
| 'allowed': len(violations) == 0, | |
| 'violations': violations, | |
| 'reason': 'All checks passed' if len(violations) == 0 else '; '.join(violations) | |
| } | |
| # ============================================================================ | |
| # ANOMALY DETECTOR (simplified from claudecode_anomaly_detector.py) | |
| # ============================================================================ | |
| class AnomalyDetector: | |
| """Detects anomalies in solar farm operations.""" | |
| def __init__(self, baseline_threshold=0.20): | |
| self.baseline_threshold = baseline_threshold | |
| self.anomalies = [] | |
| def detect_all_anomalies(self, row, theo_power): | |
| """Run all anomaly detection algorithms.""" | |
| anomalies = [] | |
| # Power underperformance detection | |
| act_power = row.get('Act_Power_kW', 0) | |
| if theo_power > 100 and act_power < (theo_power * (1 - self.baseline_threshold)): | |
| anomalies.append({ | |
| 'type': 'power_underperformance', | |
| 'severity': 'warning', | |
| 'confidence': 0.85, | |
| 'details': f'Actual {act_power:.1f}kW vs Theoretical {theo_power:.1f}kW' | |
| }) | |
| # Temperature anomaly detection | |
| temp_cols = [col for col in row.index if 'InverterTemperature' in str(col)] | |
| max_temp = row[temp_cols].max() if temp_cols else 0 | |
| if max_temp > 75: | |
| anomalies.append({ | |
| 'type': 'temperature_anomaly', | |
| 'severity': 'critical' if max_temp > 85 else 'warning', | |
| 'confidence': 0.95, | |
| 'details': f'Max temperature {max_temp:.1f}°C' | |
| }) | |
| # Grid anomaly detection | |
| freq = row.get('Grid_Freq', 50.0) | |
| if freq < 49.5 or freq > 50.2: | |
| anomalies.append({ | |
| 'type': 'grid_anomaly', | |
| 'severity': 'critical', | |
| 'confidence': 0.90, | |
| 'details': f'Grid frequency {freq:.2f} Hz' | |
| }) | |
| self.anomalies = anomalies | |
| return anomalies | |
| # ============================================================================ | |
| # INCIDENT RESPONSE AGENT (new - from claudecode documentation) | |
| # ============================================================================ | |
| class IncidentResponseAgent: | |
| """Handles fault recovery and incident response procedures.""" | |
| def __init__(self): | |
| self.procedures = { | |
| 'E201': {'name': 'Grid Voltage Fault', 'response': 'Check grid connections, verify voltage levels'}, | |
| 'E202': {'name': 'Grid Frequency Fault', 'response': 'Verify grid frequency stability'}, | |
| 'E205': {'name': 'Ground Fault', 'response': 'IMMEDIATE: Stop operation, inspect grounding'}, | |
| 'E208': {'name': 'Isolation Fault', 'response': 'IMMEDIATE: Stop operation, check insulation'}, | |
| 'E210': {'name': 'Overtemperature Fault', 'response': 'Check cooling system, reduce load'} | |
| } | |
| self.active_incidents = [] | |
| def handle_incident(self, fault_code, inverter_id, telemetry): | |
| """Handle an incident based on fault code.""" | |
| if fault_code not in self.procedures: | |
| return {'handled': False, 'procedure': 'Unknown fault code'} | |
| procedure = self.procedures[fault_code] | |
| incident = { | |
| 'fault_code': fault_code, | |
| 'inverter_id': inverter_id, | |
| 'procedure_name': procedure['name'], | |
| 'recommended_action': procedure['response'], | |
| 'timestamp': datetime.now().strftime('%H:%M:%S'), | |
| 'status': 'active' | |
| } | |
| self.active_incidents.append(incident) | |
| return incident | |
| # ============================================================================ | |
| # AGENT LOGIC | |
| # ============================================================================ | |
| class AgentOrchestrator: | |
| def __init__(self, lake): | |
| self.lake = lake | |
| self.inventory = {"Inverter Module": 2, "DC Fuses": 50} | |
| self.history_actual, self.history_theoretical, self.history_timestamps = [], [], [] | |
| # Store last agent decisions | |
| self.last_decision = { | |
| 'perf': "🟢 Nominal", | |
| 'maint': "✅ System Healthy", | |
| 'grid': "Stable", | |
| 'safety': "Normal (Max 0.0°C)", | |
| 'asset': "Inventory: OK", | |
| 'incident': "No active incidents", | |
| 'log': "" | |
| } | |
| # Initialize sub-agents | |
| self.policy_engine = PolicyEngine() | |
| self.anomaly_detector = AnomalyDetector() | |
| self.incident_response = IncidentResponseAgent() | |
| def process_frame(self): | |
| """Process one time step and return all agent decisions.""" | |
| ts, row = self.lake.get_next_tick() | |
| if ts is None: | |
| return "NODATA" | |
| c = self.lake.constants | |
| gii = row.get('GII', 0) | |
| t_mod = row.get('Mod_Temp', 25) | |
| # Physics calculation: P = Planned * (1 + temp_coeff * temp_diff) * (GII/1000) * efficiency | |
| theo_kw = (c['PLANNED_CAPACITY_KW'] * (1 + c['TEMP_COEFF'] * (t_mod - c['STC_TEMP'])) * (gii / 1000) * c['LOSS_FACTOR']) if gii > 10 else 0 | |
| # Get actual power from 2-month data (already in kW) | |
| act_kw = row.get('Act_Power_kW', 0) | |
| # Performance Agent | |
| perf_status = "🟢 Nominal" | |
| if theo_kw > 50 and (act_kw / theo_kw) < 0.85: | |
| perf_status = f"🔴 Low Efficiency ({(act_kw/theo_kw)*100:.1f}%)" | |
| self.history_timestamps.append(ts) | |
| self.history_actual.append(act_kw) | |
| self.history_theoretical.append(theo_kw) | |
| # Maintenance Agent | |
| i22 = row.get('Inv-2.2 - InverterTemperature (°C)', 0) | |
| i21 = row.get('Inv-2.1 - InverterTemperature (°C)', 0) | |
| maint_alert = "✅ System Healthy" | |
| if i21 > 500 and (i22 / i21) < 0.85: | |
| maint_alert = f"⚠️ FAULT: Inv-2.2 Mismatch (Ratio: {i22/i21:.2f})" | |
| # Grid Operations Agent | |
| freq = row.get('Grid_Freq', 50.0) | |
| grid_status = "Stable" | |
| if freq > 50.2: | |
| grid_status = "⚠️ High Freq (Curtailing)" | |
| elif freq < 49.8: | |
| grid_status = "⚠️ Low Freq (Boosting)" | |
| # Safety Agent | |
| temp_cols = [col for col in row.index if "InverterTemperature" in str(col)] | |
| max_t = row[temp_cols].max() if temp_cols else 0 | |
| safety_status = f"Normal (Max {max_t:.1f}°C)" | |
| if max_t > 45: | |
| safety_status = f"🔥 WARNING: High Temp ({max_t:.1f}°C)" | |
| # Asset Management Agent | |
| asset_msg = "Inventory: OK" | |
| if "FAULT" in maint_alert: | |
| asset_msg = "📦 ACTION: Reserved 1x Inverter Module" | |
| # Anomaly Detection Agent | |
| anomalies = self.anomaly_detector.detect_all_anomalies(row, theo_kw) | |
| anomaly_msg = "✅ No anomalies detected" | |
| if anomalies: | |
| anomaly_types = [a['type'] for a in anomalies] | |
| anomaly_msg = f"⚠️ {len(anomalies)} anomaly(s): {', '.join(anomaly_types)}" | |
| # Incident Response Agent | |
| incident_msg = "No active incidents" | |
| if anomalies: | |
| # Simulate incident handling for temperature anomalies | |
| for anomaly in anomalies: | |
| if anomaly['type'] == 'temperature_anomaly': | |
| incident = self.incident_response.handle_incident( | |
| 'E210', 'Inv-2.2', {'Grid_Freq': freq} | |
| ) | |
| incident_msg = f"🔥 Incident: {incident['procedure_name']} - {incident['recommended_action']}" | |
| # Policy Engine validation | |
| active_faults = [{'code': 'E210', 'description': 'Overtemperature'}] if 'High Temp' in safety_status else [] | |
| policy_check = self.policy_engine.validate_command('Inv-2.2', 'on', {'Grid_Freq': freq}, active_faults) | |
| policy_msg = "✅ Commands allowed" if policy_check['allowed'] else f"❌ {policy_check['reason']}" | |
| # Generate log message | |
| log = f"[{ts.strftime('%H:%M')}] Power:{act_kw:.0f}kW" | |
| if "FAULT" in maint_alert: | |
| log += " | CRITICAL: Inv-2.2" | |
| if "WARNING" in safety_status: | |
| log += " | SAFETY: Temp High" | |
| # Store last decisions for pause functionality | |
| self.last_decision = { | |
| 'perf': perf_status, | |
| 'maint': maint_alert, | |
| 'grid': grid_status, | |
| 'safety': safety_status, | |
| 'asset': asset_msg, | |
| 'anomaly': anomaly_msg, | |
| 'incident': incident_msg, | |
| 'policy': policy_msg, | |
| 'log': log | |
| } | |
| return ts, act_kw, theo_kw, perf_status, maint_alert, grid_status, safety_status, asset_msg, anomaly_msg, incident_msg, policy_msg, log | |
| # ============================================================================ | |
| # MAIN APPLICATION | |
| # ============================================================================ | |
| # Initialize | |
| lake = SolarDataLake() | |
| bot = AgentOrchestrator(lake) | |
| # Global state for play/pause control | |
| play_state = {"playing": True} | |
| def update_ui(): | |
| """Update UI with next simulation frame.""" | |
| if not hasattr(bot, 'lake') or bot.lake.df.empty: | |
| err = "Data Load Failed" | |
| return (err, None, "0", "0", err, err, err, err, err, err) | |
| # Only process frame if playing | |
| if play_state["playing"]: | |
| result = bot.process_frame() | |
| if result == "NODATA": | |
| return ("End of Stream", None, "0", "0", "End", "End", "End", "End", "End", "End", "End", "End", "End") | |
| ts, act, theo, perf, maint, grid, safety, asset, anomaly, incident, policy, log = result | |
| else: | |
| # When paused, return last values from stored decisions | |
| if len(bot.history_timestamps) == 0: | |
| return ("Paused", None, "0", "0", "Paused", "Paused", "Paused", "Paused", "Paused", "Paused", "Paused", "Paused", "Paused") | |
| ts = bot.history_timestamps[-1] | |
| act = bot.history_actual[-1] if bot.history_actual else 0 | |
| theo = bot.history_theoretical[-1] if bot.history_theoretical else 0 | |
| perf = bot.last_decision['perf'] | |
| maint = bot.last_decision['maint'] | |
| grid = bot.last_decision['grid'] | |
| safety = bot.last_decision['safety'] | |
| asset = bot.last_decision['asset'] | |
| anomaly = bot.last_decision['anomaly'] | |
| incident = bot.last_decision['incident'] | |
| policy = bot.last_decision['policy'] | |
| log = bot.last_decision['log'] | |
| # Create performance chart | |
| fig = go.Figure() | |
| if len(bot.history_timestamps) > 0: | |
| fig.add_trace(go.Scatter(x=bot.history_timestamps[-60:], y=bot.history_actual[-60:], name="Actual", line=dict(color='#00ff00'))) | |
| fig.add_trace(go.Scatter(x=bot.history_timestamps[-60:], y=bot.history_theoretical[-60:], name="Theoretical", line=dict(color='#0000ff', dash='dot'))) | |
| fig.update_layout(template="plotly_dark", margin=dict(l=10, r=10, t=30, b=10), height=250, title="Real-time Physics vs Reality") | |
| return (ts.strftime("%Y-%m-%d %H:%M:%S"), fig, f"{act:.2f} kW", f"{theo:.2f} kW", perf, maint, grid, safety, asset, anomaly, incident, policy, log) | |
| def toggle_play_pause(): | |
| """Toggle play/pause state.""" | |
| play_state["playing"] = not play_state["playing"] | |
| return "▶️ Play" if not play_state["playing"] else "⏸️ Pause" | |
| with gr.Blocks(theme=gr.themes.Glass(), title="Solar Agentic Control Center") as demo: | |
| gr.Markdown("# 🌞 Solar Farm Agentic Control Center") | |
| with gr.Row(): | |
| # === PLAY/PAUSE BUTTON === | |
| play_pause_btn = gr.Button("⏸️ Pause", variant="primary", size="sm") | |
| # === SPEED CONTROL SLIDER === | |
| speed_slider = gr.Slider(minimum=0.1, maximum=2, value=0.25, step=0.01, label="Update Speed (Seconds per Step) - Lower is Faster") | |
| time_box = gr.Label(label="Simulation Clock") | |
| with gr.Row(): | |
| kpi_1 = gr.Label(label="Actual Power") | |
| kpi_2 = gr.Label(label="Theoretical Power") | |
| chart_box = gr.Plot(label="Performance Agent Vision") | |
| gr.Markdown("### 🤖 Live Agent Decisions") | |
| with gr.Row(): | |
| with gr.Column(): | |
| gr.Markdown("**Performance**") | |
| out_perf = gr.Textbox(show_label=False) | |
| with gr.Column(): | |
| gr.Markdown("**Maintenance**") | |
| out_maint = gr.Textbox(show_label=False) | |
| with gr.Column(): | |
| gr.Markdown("**Grid Ops**") | |
| out_grid = gr.Textbox(show_label=False) | |
| with gr.Row(): | |
| with gr.Column(): | |
| gr.Markdown("**Safety**") | |
| out_safe = gr.Textbox(show_label=False) | |
| with gr.Column(): | |
| gr.Markdown("**Asset Mgmt**") | |
| out_asset = gr.Textbox(show_label=False) | |
| with gr.Row(): | |
| with gr.Column(): | |
| gr.Markdown("**Anomaly Detection**") | |
| out_anomaly = gr.Textbox(show_label=False) | |
| with gr.Column(): | |
| gr.Markdown("**Incident Response**") | |
| out_incident = gr.Textbox(show_label=False) | |
| with gr.Column(): | |
| gr.Markdown("**Policy Engine**") | |
| out_policy = gr.Textbox(show_label=False) | |
| gr.Markdown("**Incident Commander Log**") | |
| out_log = gr.Textbox(show_label=False) | |
| # === DYNAMIC TIMER === | |
| timer = gr.Timer(value=1) | |
| # Update timer interval when slider changes | |
| speed_slider.change(lambda x: gr.Timer(value=x), speed_slider, timer) | |
| # Play/Pause button functionality | |
| play_pause_btn.click( | |
| fn=toggle_play_pause, | |
| inputs=None, | |
| outputs=play_pause_btn | |
| ) | |
| timer.tick( | |
| fn=update_ui, | |
| inputs=None, | |
| outputs=[time_box, chart_box, kpi_1, kpi_2, out_perf, out_maint, out_grid, out_safe, out_asset, out_anomaly, out_incident, out_policy, out_log] | |
| ) | |
| if __name__ == "__main__": | |
| port = int(os.environ.get("PORT", 7860)) | |
| demo.launch( | |
| server_name="0.0.0.0", | |
| server_port=port, | |
| share=False # share=True won't work on Render | |
| ) | |