| from fastapi import FastAPI, HTTPException |
| from fastapi.responses import HTMLResponse, JSONResponse |
| from pydantic import BaseModel |
| from typing import List |
| import numpy as np |
| import os |
| import joblib |
| from datetime import datetime |
|
|
| app = FastAPI(title="York Chiller Energy Optimizer") |
|
|
| |
| |
| |
|
|
| model = None |
| feature_cols = None |
| MODEL_READY = False |
|
|
| try: |
| if os.path.exists("retrained_best_model.pkl"): |
| model = joblib.load("retrained_best_model.pkl") |
| feature_cols = joblib.load("retrained_feature_cols.pkl") |
| MODEL_READY = True |
| print(f"✅ Model loaded: {type(model).__name__}") |
| print(f"✅ Features: {len(feature_cols)}") |
| else: |
| print("⚠️ Model files not found, using fallback mode") |
| except Exception as e: |
| print(f"⚠️ Could not load model: {e}") |
|
|
| |
| |
| |
|
|
| def create_features(hour: int, minute: int, day_of_week: int, month: int, recent_power: List[float]) -> dict: |
| """Create features for model prediction""" |
| features = {} |
| |
| |
| features['hour'] = hour |
| features['minute'] = minute |
| features['dayofweek'] = day_of_week |
| features['day'] = 1 |
| features['month'] = month |
| features['year'] = 2025 |
| |
| |
| features['sin_hour'] = np.sin(2 * np.pi * hour / 24) |
| features['cos_hour'] = np.cos(2 * np.pi * hour / 24) |
| features['sin_month'] = np.sin(2 * np.pi * month / 12) |
| features['cos_month'] = np.cos(2 * np.pi * month / 12) |
| |
| |
| features['is_weekend'] = 1 if day_of_week >= 5 else 0 |
| features['is_business_hours'] = 1 if 8 <= hour <= 18 else 0 |
| |
| |
| if len(recent_power) < 150: |
| last_val = recent_power[-1] if recent_power else 50 |
| recent_power = recent_power + [last_val] * (150 - len(recent_power)) |
| |
| |
| lags = [1, 2, 3, 6, 12, 24, 48, 96, 144] |
| for lag in lags: |
| if len(recent_power) > lag: |
| features[f'power_lag_{lag}'] = float(recent_power[-lag]) |
| else: |
| features[f'power_lag_{lag}'] = float(recent_power[-1]) if recent_power else 50.0 |
| |
| |
| windows = [6, 12, 24, 48, 96, 144] |
| for window in windows: |
| if len(recent_power) >= window: |
| window_data = recent_power[-window:] |
| features[f'power_rolling_mean_{window}'] = float(np.mean(window_data)) |
| features[f'power_rolling_std_{window}'] = float(np.std(window_data)) |
| features[f'power_rolling_min_{window}'] = float(np.min(window_data)) |
| features[f'power_rolling_max_{window}'] = float(np.max(window_data)) |
| else: |
| default = float(recent_power[-1]) if recent_power else 50.0 |
| features[f'power_rolling_mean_{window}'] = default |
| features[f'power_rolling_std_{window}'] = 10.0 |
| features[f'power_rolling_min_{window}'] = default * 0.8 |
| features[f'power_rolling_max_{window}'] = default * 1.2 |
| |
| |
| if len(recent_power) >= 2: |
| features['power_change_1min'] = float(recent_power[-1] - recent_power[-2]) |
| features['power_change_5min'] = float(recent_power[-1] - recent_power[-6]) if len(recent_power) >= 6 else 0 |
| features['power_change_15min'] = float(recent_power[-1] - recent_power[-16]) if len(recent_power) >= 16 else 0 |
| else: |
| features['power_change_1min'] = 0.0 |
| features['power_change_5min'] = 0.0 |
| features['power_change_15min'] = 0.0 |
| |
| features['power_acceleration'] = 0.0 |
| features['power_ratio_to_mean_24'] = 1.0 |
| |
| return features |
|
|
| |
| |
| |
|
|
| def predict_power(hour: int, minute: int, day_of_week: int, month: int, recent_power: List[float]) -> float: |
| """Predict power using model or fallback""" |
| |
| if MODEL_READY and feature_cols: |
| try: |
| features = create_features(hour, minute, day_of_week, month, recent_power) |
| input_array = [] |
| for feat in feature_cols: |
| input_array.append(features.get(feat, 0.0)) |
| X_input = np.array(input_array).reshape(1, -1) |
| prediction = float(model.predict(X_input)[0]) |
| return max(0, min(500, prediction)) |
| except Exception as e: |
| print(f"Model prediction error: {e}") |
| |
| |
| if 8 <= hour <= 18: |
| base = 120 + 30 * np.sin((hour - 12) * np.pi / 12) |
| elif hour < 6 or hour > 22: |
| base = 30 |
| else: |
| base = 70 |
| |
| if len(recent_power) >= 6: |
| trend = np.mean(recent_power[-3:]) - np.mean(recent_power[-6:-3]) |
| base += trend * 0.3 |
| |
| return max(10, min(400, base)) |
|
|
| |
| |
| |
|
|
| def get_recommendations(predicted_power: float, hour: int, day_of_week: int) -> List[dict]: |
| recommendations = [] |
| is_weekend = day_of_week >= 5 |
| |
| |
| if predicted_power < 45: |
| recommendations.append({ |
| "variable": "Chiller Staging", |
| "current": "2+ chillers running", |
| "recommended": "🔴 TURN OFF all chillers", |
| "saving_kw": round(predicted_power * 0.3, 1), |
| "priority": "HIGH" |
| }) |
| elif predicted_power < 120: |
| recommendations.append({ |
| "variable": "Chiller Staging", |
| "current": "2+ chillers running", |
| "recommended": "🟢 RUN 1 chiller", |
| "saving_kw": round(predicted_power * 0.12, 1), |
| "priority": "HIGH" |
| }) |
| elif predicted_power < 250: |
| recommendations.append({ |
| "variable": "Chiller Staging", |
| "current": "1 chiller (overloaded)", |
| "recommended": "🟡 RUN 2 chillers", |
| "saving_kw": round(predicted_power * 0.08, 1), |
| "priority": "MEDIUM" |
| }) |
| else: |
| recommendations.append({ |
| "variable": "Chiller Staging", |
| "current": "2 chillers (overloaded)", |
| "recommended": "🟡 RUN 3+ chillers", |
| "saving_kw": round(predicted_power * 0.05, 1), |
| "priority": "MEDIUM" |
| }) |
| |
| |
| optimal_speed = 35 + (predicted_power / 300) * 50 |
| optimal_speed = min(90, max(30, optimal_speed)) |
| |
| if optimal_speed < 60: |
| action = f"🔵 REDUCE to {optimal_speed:.0f}% speed" |
| saving = 8 |
| else: |
| action = f"🔵 INCREASE to {optimal_speed:.0f}% speed" |
| saving = 5 |
| |
| recommendations.append({ |
| "variable": "Chilled Water Pump (VFD)", |
| "current": "85% speed", |
| "recommended": action, |
| "saving_kw": saving, |
| "priority": "HIGH" if abs(optimal_speed - 85) > 20 else "MEDIUM" |
| }) |
| |
| |
| if predicted_power < 50: |
| recommendations.append({ |
| "variable": "Cooling Tower Fans", |
| "current": "100% speed", |
| "recommended": "🔴 TURN OFF", |
| "saving_kw": 12, |
| "priority": "HIGH" |
| }) |
| elif predicted_power < 120: |
| recommendations.append({ |
| "variable": "Cooling Tower Fans", |
| "current": "100% speed", |
| "recommended": "🟡 RUN at 40% speed", |
| "saving_kw": 7, |
| "priority": "MEDIUM" |
| }) |
| else: |
| recommendations.append({ |
| "variable": "Cooling Tower Fans", |
| "current": "Fixed speed", |
| "recommended": "🟡 RUN at 65-85% speed", |
| "saving_kw": 4, |
| "priority": "MEDIUM" |
| }) |
| |
| |
| if (hour < 6 or hour > 22 or is_weekend) and predicted_power < 60: |
| recommendations.append({ |
| "variable": "Equipment Schedule", |
| "current": "Running", |
| "recommended": "🔴 CONSIDER SHUTDOWN", |
| "saving_kw": round(predicted_power, 1), |
| "priority": "HIGH" |
| }) |
| |
| return recommendations |
|
|
| |
| |
| |
|
|
| class PredictionRequest(BaseModel): |
| hour: int |
| minute: int |
| day_of_week: int |
| month: int |
| recent_power: List[float] |
|
|
| |
| |
| |
|
|
| HTML_DASHBOARD = """ |
| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>York Chiller Energy Optimizer</title> |
| <style> |
| * { margin: 0; padding: 0; box-sizing: border-box; } |
| body { |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; |
| background: linear-gradient(135deg, #0f0c29 0%, #302b63 50%, #24243e 100%); |
| min-height: 100vh; |
| padding: 20px; |
| } |
| .container { max-width: 1400px; margin: 0 auto; } |
| |
| /* Header */ |
| .header { text-align: center; margin-bottom: 30px; } |
| .header h1 { color: white; font-size: 2.5rem; margin-bottom: 10px; } |
| .header h1 span { color: #00d4ff; } |
| .header p { color: rgba(255,255,255,0.7); font-size: 1.1rem; } |
| |
| /* Status Bar */ |
| .status-bar { |
| background: rgba(255,255,255,0.1); |
| border-radius: 12px; |
| padding: 12px 20px; |
| margin-bottom: 20px; |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| flex-wrap: wrap; |
| } |
| .status-indicator { display: flex; align-items: center; gap: 10px; } |
| .status-dot { |
| width: 12px; height: 12px; border-radius: 50%; |
| background: #ff4757; display: inline-block; |
| } |
| .status-dot.online { background: #2ecc71; animation: pulse 2s infinite; } |
| @keyframes pulse { |
| 0% { opacity: 1; transform: scale(1); } |
| 50% { opacity: 0.5; transform: scale(1.2); } |
| 100% { opacity: 1; transform: scale(1); } |
| } |
| .model-badge { |
| background: rgba(0,212,255,0.2); |
| padding: 4px 12px; |
| border-radius: 20px; |
| font-size: 12px; |
| color: #00d4ff; |
| } |
| |
| /* Cards */ |
| .card { |
| background: white; |
| border-radius: 20px; |
| padding: 28px; |
| margin-bottom: 24px; |
| box-shadow: 0 20px 60px rgba(0,0,0,0.3); |
| } |
| .card h2 { color: #1a1a2e; margin-bottom: 20px; font-size: 1.5rem; } |
| |
| /* Forms */ |
| .form-grid { |
| display: grid; |
| grid-template-columns: repeat(3, 1fr); |
| gap: 20px; |
| margin-bottom: 20px; |
| } |
| .form-group { margin-bottom: 20px; } |
| .form-group label { |
| display: block; |
| margin-bottom: 8px; |
| font-weight: 600; |
| color: #4a5568; |
| } |
| .form-group input, .form-group select { |
| width: 100%; |
| padding: 12px; |
| border: 2px solid #e2e8f0; |
| border-radius: 10px; |
| font-size: 14px; |
| transition: all 0.2s; |
| } |
| .form-group input:focus, .form-group select:focus { |
| outline: none; |
| border-color: #00d4ff; |
| box-shadow: 0 0 0 3px rgba(0,212,255,0.1); |
| } |
| textarea { |
| width: 100%; |
| padding: 12px; |
| border: 2px solid #e2e8f0; |
| border-radius: 10px; |
| font-family: 'Courier New', monospace; |
| resize: vertical; |
| } |
| textarea:focus { outline: none; border-color: #00d4ff; } |
| small { display: block; margin-top: 5px; color: #718096; font-size: 12px; } |
| |
| /* Button */ |
| .btn-predict { |
| width: 100%; |
| padding: 16px; |
| background: linear-gradient(135deg, #00d4ff 0%, #0099cc 100%); |
| color: white; |
| border: none; |
| border-radius: 12px; |
| font-size: 16px; |
| font-weight: 600; |
| cursor: pointer; |
| transition: all 0.2s; |
| } |
| .btn-predict:hover { transform: translateY(-2px); box-shadow: 0 5px 20px rgba(0,212,255,0.4); } |
| .btn-predict:disabled { opacity: 0.6; cursor: not-allowed; } |
| |
| /* Results */ |
| .prediction-box { |
| text-align: center; |
| padding: 30px; |
| background: linear-gradient(135deg, #00d4ff 0%, #0099cc 100%); |
| border-radius: 16px; |
| color: white; |
| margin-bottom: 20px; |
| } |
| .prediction-box .value { font-size: 56px; font-weight: bold; } |
| .savings-box { |
| text-align: center; |
| padding: 20px; |
| background: #e8f5e9; |
| border-radius: 16px; |
| margin-bottom: 20px; |
| } |
| .savings-box .value { font-size: 36px; font-weight: bold; color: #2e7d32; } |
| |
| /* Recommendations */ |
| .recommendation { |
| padding: 16px; |
| margin-bottom: 12px; |
| border-radius: 12px; |
| border-left: 4px solid; |
| transition: all 0.2s; |
| } |
| .recommendation:hover { transform: translateX(5px); } |
| .recommendation.high { background: #ffebee; border-left-color: #f44336; } |
| .recommendation.medium { background: #fff3e0; border-left-color: #ff9800; } |
| .recommendation.low { background: #e8f5e9; border-left-color: #4caf50; } |
| .recommendation-header { |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| margin-bottom: 10px; |
| flex-wrap: wrap; |
| } |
| .recommendation-title { font-weight: bold; font-size: 16px; } |
| .recommendation-priority { |
| padding: 4px 12px; |
| border-radius: 20px; |
| font-size: 11px; |
| font-weight: bold; |
| text-transform: uppercase; |
| } |
| .high .recommendation-priority { background: #f44336; color: white; } |
| .medium .recommendation-priority { background: #ff9800; color: white; } |
| .low .recommendation-priority { background: #4caf50; color: white; } |
| .recommendation-saving { |
| margin-top: 8px; |
| font-weight: bold; |
| color: #2e7d32; |
| } |
| |
| /* Footer */ |
| .footer { text-align: center; padding: 20px; color: rgba(255,255,255,0.5); font-size: 14px; } |
| |
| /* Loading */ |
| .loading { |
| display: inline-block; |
| width: 20px; |
| height: 20px; |
| border: 3px solid rgba(255,255,255,0.3); |
| border-radius: 50%; |
| border-top-color: white; |
| animation: spin 1s ease-in-out infinite; |
| margin-right: 10px; |
| } |
| @keyframes spin { to { transform: rotate(360deg); } } |
| |
| @media (max-width: 768px) { |
| .form-grid { grid-template-columns: 1fr; gap: 15px; } |
| .header h1 { font-size: 1.8rem; } |
| .prediction-box .value { font-size: 40px; } |
| } |
| </style> |
| </head> |
| <body> |
| <div class="container"> |
| <div class="header"> |
| <h1>❄️ York Chiller <span>Energy Optimizer</span></h1> |
| <p>AI-powered prediction and optimization for York chiller systems</p> |
| </div> |
| |
| <div class="status-bar"> |
| <div class="status-indicator"> |
| <div class="status-dot" id="statusDot"></div> |
| <span id="statusText">Checking API...</span> |
| </div> |
| <div class="model-badge" id="modelBadge">Loading...</div> |
| </div> |
| |
| <div class="card"> |
| <h2>Input Parameters</h2> |
| <div class="form-grid"> |
| <div class="form-group"> |
| <label> Hour (0-23)</label> |
| <input type="number" id="hour" min="0" max="23" value="14"> |
| </div> |
| <div class="form-group"> |
| <label> Day of Week</label> |
| <select id="dayofweek"> |
| <option value="0">Monday</option><option value="1">Tuesday</option> |
| <option value="2" selected>Wednesday</option><option value="3">Thursday</option> |
| <option value="4">Friday</option><option value="5">Saturday</option> |
| <option value="6">Sunday</option> |
| </select> |
| </div> |
| <div class="form-group"> |
| <label> Month</label> |
| <select id="month"> |
| <option value="1">January</option><option value="2">February</option> |
| <option value="3">March</option><option value="4">April</option> |
| <option value="5">May</option><option value="6">June</option> |
| <option value="7" selected>July</option><option value="8">August</option> |
| <option value="9">September</option><option value="10">October</option> |
| <option value="11">November</option><option value="12">December</option> |
| </select> |
| </div> |
| </div> |
| <div class="form-group"> |
| <label>⚡ Recent Power Readings (kW)</label> |
| <textarea id="powerHistory" rows="3" placeholder="Enter comma-separated values...">45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55</textarea> |
| <small>Last 10-30 readings at 1-minute intervals (comma-separated)</small> |
| </div> |
| <button class="btn-predict" onclick="makePrediction()">🔮 Predict & Optimize</button> |
| </div> |
| |
| <div id="results" style="display: none;"></div> |
| |
| <div class="footer"> |
| <p>Powered by Random Forest AI </p> |
| </div> |
| </div> |
| |
| <script> |
| async function checkHealth() { |
| try { |
| const response = await fetch('/health'); |
| if (response.ok) { |
| const data = await response.json(); |
| document.getElementById('statusDot').classList.add('online'); |
| document.getElementById('statusText').textContent = 'API Online'; |
| document.getElementById('modelBadge').innerHTML = ` ${data.model_type || 'AI Model'} | Active`; |
| } else { |
| throw new Error('API not responding'); |
| } |
| } catch (error) { |
| document.getElementById('statusDot').classList.remove('online'); |
| document.getElementById('statusText').textContent = 'API Offline'; |
| document.getElementById('modelBadge').innerHTML = ' Fallback Mode'; |
| } |
| } |
| |
| async function makePrediction() { |
| const hour = parseInt(document.getElementById('hour').value); |
| const dayofweek = parseInt(document.getElementById('dayofweek').value); |
| const month = parseInt(document.getElementById('month').value); |
| const powerText = document.getElementById('powerHistory').value; |
| const recentPower = powerText.split(',').map(x => parseFloat(x.trim())).filter(x => !isNaN(x)); |
| |
| if (recentPower.length < 3) { |
| alert('Please enter at least 3 power readings'); |
| return; |
| } |
| |
| const btn = document.querySelector('.btn-predict'); |
| const originalText = btn.innerHTML; |
| btn.innerHTML = '<div class="loading"></div> Predicting...'; |
| btn.disabled = true; |
| |
| try { |
| const response = await fetch('/api/predict', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ |
| hour: hour, |
| minute: 0, |
| day_of_week: dayofweek, |
| month: month, |
| recent_power: recentPower |
| }) |
| }); |
| |
| const data = await response.json(); |
| |
| if (data.success) { |
| displayResults(data); |
| } else { |
| alert('Error: ' + (data.error || 'Unknown error')); |
| } |
| } catch (error) { |
| alert('Error: ' + error.message); |
| } finally { |
| btn.innerHTML = originalText; |
| btn.disabled = false; |
| } |
| } |
| |
| function displayResults(data) { |
| const resultsDiv = document.getElementById('results'); |
| |
| let recommendationsHtml = ''; |
| for (const rec of data.recommendations) { |
| const priorityClass = rec.priority.toLowerCase(); |
| recommendationsHtml += ` |
| <div class="recommendation ${priorityClass}"> |
| <div class="recommendation-header"> |
| <span class="recommendation-title">${rec.variable}</span> |
| <span class="recommendation-priority">${rec.priority}</span> |
| </div> |
| <div><strong>Current:</strong> ${rec.current}</div> |
| <div><strong>Recommended:</strong> ${rec.recommended}</div> |
| <div class="recommendation-saving"> Saving: ${rec.saving_kw} kW</div> |
| </div> |
| `; |
| } |
| |
| resultsDiv.innerHTML = ` |
| <div class="card"> |
| <div class="prediction-box"> |
| <div> Predicted Power</div> |
| <div class="value">${data.predicted_power_kw.toFixed(1)} <span style="font-size: 20px;">kW</span></div> |
| </div> |
| <div class="savings-box"> |
| <div> Total Potential Savings</div> |
| <div class="value">${data.total_saving_kw.toFixed(1)} <span style="font-size: 16px;">kW</span></div> |
| </div> |
| <h2 style="margin-top: 20px;"> Optimization Recommendations</h2> |
| ${recommendationsHtml} |
| <div style="margin-top: 20px; padding: 12px; background: #f0f2f5; border-radius: 8px; font-size: 12px;"> |
| Model: ${data.model_info.type} | ${data.model_info.loaded ? 'Active' : 'Fallback Mode'} |
| </div> |
| </div> |
| `; |
| resultsDiv.style.display = 'block'; |
| resultsDiv.scrollIntoView({ behavior: 'smooth' }); |
| } |
| |
| // Check health on load and every 30 seconds |
| checkHealth(); |
| setInterval(checkHealth, 30000); |
| </script> |
| </body> |
| </html> |
| """ |
|
|
| |
| |
| |
|
|
| @app.get("/") |
| async def root(): |
| """Serve the dashboard""" |
| return HTMLResponse(content=HTML_DASHBOARD) |
|
|
| @app.post("/api/predict") |
| async def predict(request: PredictionRequest): |
| """Make prediction and return JSON""" |
| try: |
| predicted_power = predict_power( |
| request.hour, request.minute, |
| request.day_of_week, request.month, |
| request.recent_power |
| ) |
| recommendations = get_recommendations(predicted_power, request.hour, request.day_of_week) |
| total_saving = sum(r["saving_kw"] for r in recommendations) |
| |
| return { |
| "success": True, |
| "predicted_power_kw": round(predicted_power, 1), |
| "total_saving_kw": round(total_saving, 1), |
| "recommendations": recommendations, |
| "model_info": { |
| "loaded": MODEL_READY, |
| "type": "Random Forest" if MODEL_READY else "Fallback Predictor", |
| "features": len(feature_cols) if feature_cols else 0 |
| } |
| } |
| except Exception as e: |
| return {"success": False, "error": str(e)} |
|
|
| @app.get("/health") |
| async def health(): |
| """Health check""" |
| return { |
| "status": "healthy", |
| "model_type": "Random Forest" if MODEL_READY else "Fallback", |
| "timestamp": datetime.now().isoformat() |
| } |
|
|
| if __name__ == "__main__": |
| import uvicorn |
| uvicorn.run(app, host="0.0.0.0", port=7860) |