Spaces:
Sleeping
Sleeping
| """ | |
| PHI-Arc Engine PHM Digital Twin Monitoring System | |
| Hugging Face Spaces entry point: src/streamlit_app.py | |
| """ | |
| import subprocess | |
| import sys | |
| import os | |
| # --- AUTO-INSTALL MISSING PACKAGES --- | |
| REQUIRED_PACKAGES = { | |
| "streamlit": "streamlit>=1.28.0", | |
| "numpy": "numpy>=1.24.0", | |
| "pandas": "pandas>=2.0.0", | |
| "matplotlib": "matplotlib>=3.7.0", | |
| "plotly": "plotly>=5.15.0", | |
| "fpdf": "fpdf2>=2.7.0", | |
| "docx": "python-docx>=0.8.11", | |
| "PIL": "Pillow>=10.0.0", | |
| } | |
| def ensure_packages(): | |
| missing = [] | |
| for module, package in REQUIRED_PACKAGES.items(): | |
| try: | |
| __import__(module) | |
| except ImportError: | |
| missing.append(package) | |
| if missing: | |
| print(f"[PHI-Arc PHM] Installing missing packages: {missing}") | |
| subprocess.check_call([sys.executable, "-m", "pip", "install", *missing]) | |
| print("[PHI-Arc PHM] Packages installed successfully.") | |
| ensure_packages() | |
| # --- END AUTO-INSTALLER --- | |
| import streamlit as st | |
| import numpy as np | |
| import pandas as pd | |
| import matplotlib | |
| matplotlib.use('Agg') | |
| import matplotlib.pyplot as plt | |
| import plotly.express as px | |
| import plotly.graph_objects as go | |
| from plotly.subplots import make_subplots | |
| from datetime import datetime | |
| import io | |
| sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) | |
| from engines.turboprop import TPE331Engine | |
| from engines.turbofan import TurbofanEngine | |
| from engines.turbojet import TurbojetEngine | |
| from engines.ramjet import RamjetEngine | |
| from engines.scramjet import ScramjetEngine | |
| from engines.rocket import RocketEngine | |
| from reports.pdf_generator import generate_pdf_report | |
| from reports.docx_generator import generate_docx_report | |
| from reports.csv_exporter import generate_csv_report, generate_trend_csv | |
| # ==================== PAGE CONFIG ==================== | |
| st.set_page_config( | |
| page_title="PHI-Arc Engine PHM | Digital Twin", | |
| page_icon="🚀", | |
| layout="wide", | |
| initial_sidebar_state="collapsed" | |
| ) | |
| def load_css(): | |
| try: | |
| css_path = os.path.join(os.path.dirname(__file__), "assets", "style.css") | |
| with open(css_path) as f: | |
| st.markdown(f"<style>{f.read()}</style>", unsafe_allow_html=True) | |
| except Exception: | |
| pass | |
| load_css() | |
| ENGINE_REGISTRY = { | |
| "Turboprop (TPE331-25)": {"class": TPE331Engine, "icon": "🛩️", "desc": "Single-shaft turboprop"}, | |
| "Turbofan (CFM-LEAP Class)": {"class": TurbofanEngine, "icon": "✈️", "desc": "High-bypass turbofan"}, | |
| "Turbojet (J85-GE Class)": {"class": TurbojetEngine, "icon": "🚀", "desc": "Military turbojet"}, | |
| "Ramjet (RJ43 Class)": {"class": RamjetEngine, "icon": "⚡", "desc": "Supersonic ramjet"}, | |
| "Scramjet (X-51A Class)": {"class": ScramjetEngine, "icon": "🔥", "desc": "Hypersonic scramjet"}, | |
| "Rocket (RS-25 Class)": {"class": RocketEngine, "icon": "🌌", "desc": "Liquid propellant rocket"} | |
| } | |
| if 'diagnosis' not in st.session_state: | |
| st.session_state.diagnosis = None | |
| if 'engine_instance' not in st.session_state: | |
| st.session_state.engine_instance = None | |
| if 'trend_data' not in st.session_state: | |
| st.session_state.trend_data = None | |
| if 'selected_engine' not in st.session_state: | |
| st.session_state.selected_engine = "Turboprop (TPE331-25)" | |
| # ==================== HEADER ==================== | |
| st.markdown(""" | |
| <div style="background: linear-gradient(135deg, #0a1628 0%, #1a365d 50%, #0f172a 100%); | |
| border-radius: 12px; padding: 1.5rem 2rem; margin-bottom: 1.5rem; | |
| border: 1px solid rgba(56, 189, 248, 0.2); box-shadow: 0 4px 20px rgba(0,0,0,0.3);"> | |
| <h1 style="color: #38bdf8; font-size: 1.8rem; font-weight: 700; margin: 0;">PHI-Arc Engine PHM</h1> | |
| <p style="color: #94a3b8; font-size: 0.9rem; margin-top: 0.25rem;"> | |
| Digital Twin Monitoring System - Prognostics & Health Management for MRO Technicians | |
| </p> | |
| <span style="display: inline-block; background: rgba(56,189,248,0.15); color: #38bdf8; | |
| padding: 0.25rem 0.75rem; border-radius: 20px; font-size: 0.75rem; | |
| font-weight: 600; border: 1px solid rgba(56,189,248,0.3); margin-top: 0.5rem;"> | |
| Multi-Engine - Physics-Informed - Certification-Linked | |
| </span> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # ==================== ENGINE SELECTOR ==================== | |
| st.markdown('<h3 style="color: #38bdf8; font-size: 1.1rem; font-weight: 600; border-left: 3px solid #38bdf8; padding-left: 0.75rem; margin: 1.5rem 0 1rem 0;">Select Engine Type</h3>', unsafe_allow_html=True) | |
| cols = st.columns(6) | |
| engine_keys = list(ENGINE_REGISTRY.keys()) | |
| for i, (name, info) in enumerate(ENGINE_REGISTRY.items()): | |
| with cols[i]: | |
| selected = st.session_state.selected_engine == name | |
| border_color = "#38bdf8" if selected else "rgba(148,163,184,0.1)" | |
| bg_color = "linear-gradient(145deg, #1e293b, #0c4a6e)" if selected else "linear-gradient(145deg, #1e293b, #0f172a)" | |
| st.markdown(f""" | |
| <div style="background: {bg_color}; border: 1px solid {border_color}; | |
| border-radius: 10px; padding: 1rem; text-align: center;"> | |
| <div style="font-size: 2rem; margin-bottom: 0.5rem;">{info['icon']}</div> | |
| <div style="color: #e2e8f0; font-weight: 600; font-size: 0.85rem;">{name.split('(')[0].strip()}</div> | |
| <div style="color: #64748b; font-size: 0.7rem; margin-top: 0.25rem;">{info['desc']}</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| if st.button(f"Select", key=f"btn_{i}", use_container_width=True): | |
| st.session_state.selected_engine = name | |
| st.session_state.diagnosis = None | |
| st.rerun() | |
| selected_engine = st.session_state.selected_engine | |
| engine_class = ENGINE_REGISTRY[selected_engine]["class"] | |
| engine = engine_class() | |
| st.session_state.engine_instance = engine | |
| # ==================== INPUT PANEL ==================== | |
| st.markdown('<h3 style="color: #38bdf8; font-size: 1.1rem; font-weight: 600; border-left: 3px solid #38bdf8; padding-left: 0.75rem; margin: 1.5rem 0 1rem 0;">Engine Parameters</h3>', unsafe_allow_html=True) | |
| input_fields = engine.get_input_fields() | |
| flight_conditions = {} | |
| measured_params = {} | |
| flight_keys = ["altitude", "mach", "shaft_power", "thrust", "antiice", "fuel_type", "chamber_pressure", "of_ratio"] | |
| col1, col2 = st.columns(2) | |
| with col1: | |
| st.subheader("Flight / Operating Conditions") | |
| for field in input_fields: | |
| if field["name"] in flight_keys: | |
| key = field["name"] | |
| if field["type"] == "number": | |
| val = st.number_input( | |
| f"{field['label']} ({field.get('unit', '')})", | |
| value=float(field["default"]), | |
| key=f"fc_{key}" | |
| ) | |
| flight_conditions[key] = val | |
| elif field["type"] == "select": | |
| val = st.selectbox( | |
| field["label"], | |
| options=field["options"], | |
| index=field["options"].index(field["default"]) if field["default"] in field["options"] else 0, | |
| key=f"fc_{key}" | |
| ) | |
| flight_conditions[key] = val | |
| elif field["type"] == "checkbox": | |
| val = st.checkbox(field["label"], value=field["default"], key=f"fc_{key}") | |
| flight_conditions[key] = val | |
| with col2: | |
| st.subheader("Measured Telemetry (ECU / FDR / Ground Test)") | |
| for field in input_fields: | |
| if field["name"] not in flight_keys: | |
| key = field["name"] | |
| val = st.number_input( | |
| f"{field['label']} ({field.get('unit', '')})", | |
| value=float(field["default"]), | |
| key=f"mv_{key}" | |
| ) | |
| measured_params[key] = val | |
| # Unit conversions | |
| if "altitude" in flight_conditions: | |
| flight_conditions["altitude_m"] = flight_conditions["altitude"] * 0.3048 | |
| if "shaft_power" in flight_conditions: | |
| flight_conditions["shaft_power_w"] = flight_conditions["shaft_power"] * 745.7 | |
| if "thrust" in flight_conditions and selected_engine != "Rocket (RS-25 Class)": | |
| flight_conditions["thrust_n"] = flight_conditions["thrust"] * 1000 | |
| if "chamber_pressure" in flight_conditions: | |
| flight_conditions["chamber_pressure_mpa"] = flight_conditions["chamber_pressure"] | |
| if "FF" in measured_params: | |
| measured_params["FF"] = measured_params["FF"] / 3600.0 | |
| # ==================== DIAGNOSE BUTTON ==================== | |
| st.divider() | |
| col_btn1, col_btn2, col_btn3 = st.columns([1, 1, 1]) | |
| with col_btn1: | |
| if st.button("🔍 RUN DIAGNOSIS", type="primary", use_container_width=True): | |
| with st.spinner("Computing physics-informed baseline and fault signatures..."): | |
| try: | |
| baseline = engine.compute_healthy_baseline(flight_conditions) | |
| fault_sigs = engine.compute_fault_signatures(baseline, flight_conditions) | |
| diagnosis = engine.classify_fault(measured_params, baseline, fault_sigs) | |
| st.session_state.diagnosis = diagnosis | |
| st.success("Diagnosis complete!") | |
| except Exception as e: | |
| st.error(f"Diagnosis error: {str(e)}") | |
| with col_btn2: | |
| uploaded_csv = st.file_uploader("📁 Upload Trend CSV", type=["csv"], key="trend_uploader") | |
| if uploaded_csv is not None: | |
| try: | |
| df = pd.read_csv(uploaded_csv) | |
| st.session_state.trend_data = df | |
| st.info(f"Loaded {len(df)} cycles") | |
| except Exception as e: | |
| st.error(f"CSV Error: {e}") | |
| with col_btn3: | |
| if st.session_state.diagnosis: | |
| timestamp = datetime.now().strftime("%Y-%m-%d %H:%M UTC") | |
| diag = st.session_state.diagnosis | |
| try: | |
| pdf_bytes = generate_pdf_report( | |
| selected_engine, engine.engine_type, diag, | |
| flight_conditions, measured_params, | |
| engine.cert_standards, timestamp | |
| ) | |
| st.download_button( | |
| label="📄 PDF Report", | |
| data=pdf_bytes, | |
| file_name=f"PHI_Arc_PHM_{engine.engine_type}_{datetime.now().strftime('%Y%m%d_%H%M')}.pdf", | |
| mime="application/pdf", | |
| use_container_width=True | |
| ) | |
| except Exception as e: | |
| st.error(f"PDF generation failed: {e}") | |
| try: | |
| docx_bytes = generate_docx_report( | |
| selected_engine, engine.engine_type, diag, | |
| flight_conditions, measured_params, | |
| engine.cert_standards, timestamp | |
| ) | |
| st.download_button( | |
| label="📝 DOCX Report", | |
| data=docx_bytes, | |
| file_name=f"PHI_Arc_PHM_{engine.engine_type}_{datetime.now().strftime('%Y%m%d_%H%M')}.docx", | |
| mime="application/vnd.openxmlformats-officedocument.wordprocessingml.document", | |
| use_container_width=True | |
| ) | |
| except Exception as e: | |
| st.error(f"DOCX generation failed: {e}") | |
| try: | |
| csv_bytes = generate_csv_report(diag, selected_engine, timestamp) | |
| st.download_button( | |
| label="📊 CSV Data", | |
| data=csv_bytes, | |
| file_name=f"PHI_Arc_PHM_{engine.engine_type}_{datetime.now().strftime('%Y%m%d_%H%M')}.csv", | |
| mime="text/csv", | |
| use_container_width=True | |
| ) | |
| except Exception as e: | |
| st.error(f"CSV generation failed: {e}") | |
| # ==================== RESULTS DISPLAY ==================== | |
| if st.session_state.diagnosis: | |
| diag = st.session_state.diagnosis | |
| baseline = diag["base"] | |
| scores = diag["scores"] | |
| status = diag["status"] | |
| st.divider() | |
| # Status Banner | |
| status_colors = { | |
| "HEALTHY": "#22c55e", | |
| "INDETERMINATE": "#eab308", | |
| "FAULT_DETECTED": "#ef4444" | |
| } | |
| status_color = status_colors.get(status, "#64748b") | |
| st.markdown(f""" | |
| <div style="background: linear-gradient(135deg, {status_color}22, {status_color}11); | |
| border: 1px solid {status_color}; border-radius: 12px; padding: 1.5rem; margin-bottom: 1rem;"> | |
| <h3 style="color: {status_color}; margin: 0; font-size: 1.3rem;">System Status: {status}</h3> | |
| <p style="color: #94a3b8; margin: 0.5rem 0 0 0; font-size: 0.9rem;"> | |
| Physics-informed baseline computed for current flight conditions | |
| </p> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # ==================== DYNAMIC PARAMETER SETUP (EARLY) ==================== | |
| param_labels = engine.get_parameter_labels() | |
| param_units = engine.get_parameter_units() | |
| param_keys = list(param_units.keys()) | |
| n_params = min(len(param_keys), 4) | |
| # Build generic baseline values dict (no hardcoded egt_h, ff_h, etc.) | |
| baseline_vals = {k: baseline.get(k, 0) for k in param_keys} | |
| # ==================== EXACT MATLAB-STYLE TELEMETRY OUTPUT ==================== | |
| st.markdown('<h3 style="color: #38bdf8; font-size: 1.1rem; font-weight: 600; border-left: 3px solid #38bdf8; padding-left: 0.75rem; margin: 1.5rem 0 1rem 0;">Computed Healthy Baseline & Telemetry</h3>', unsafe_allow_html=True) | |
| # Build telemetry display dynamically | |
| telemetry_lines = [] | |
| for i, k in enumerate(param_keys[:4]): | |
| val = baseline_vals.get(k, 0) | |
| unit = param_units.get(k, "") | |
| label = param_labels[i] if i < len(param_labels) else k | |
| telemetry_lines.append(f"{label} = {val:.2f} {unit}") | |
| # Also get measured values | |
| measured_lines = [] | |
| for i, k in enumerate(param_keys[:4]): | |
| val = measured_params.get(k, baseline_vals.get(k, 0)) | |
| unit = param_units.get(k, "") | |
| label = param_labels[i] if i < len(param_labels) else k | |
| measured_lines.append(f"{label} = {val:.2f} {unit}") | |
| col_t1, col_t2 = st.columns(2) | |
| with col_t1: | |
| st.markdown("**Healthy Baseline**") | |
| st.code("\n".join(telemetry_lines), language="text") | |
| with col_t2: | |
| st.markdown("**Measured Telemetry (Your Input)**") | |
| st.code("\n".join(measured_lines), language="text") | |
| # Parameter Deviations Table (generic, no hardcoded keys) | |
| st.markdown("**Parameter Deviations from Healthy Baseline**") | |
| dev_data = [] | |
| if scores: | |
| for fi in range(min(5, len(scores))): | |
| f = scores[fi]["f"] | |
| fv = diag["faults"][fi][2] if fi < len(diag["faults"]) else {} | |
| row = {"Fault": f["name"]} | |
| for k in param_keys[:4]: | |
| unit = param_units.get(k, "") | |
| label = k | |
| base_v = baseline_vals.get(k, 0) | |
| fault_v = fv.get(k, base_v) | |
| row[f"d{label} ({unit})"] = f"{fault_v - base_v:+.2f}" | |
| dev_data.append(row) | |
| if dev_data: | |
| st.dataframe(pd.DataFrame(dev_data), use_container_width=True) | |
| # Threshold Summary (generic) | |
| st.markdown("**Threshold Summary**") | |
| egt_warn = getattr(engine, 'EGT_WARN', 520) | |
| egt_maint = getattr(engine, 'EGT_MAX_CONT', 535) | |
| st.code(f"""EGT Warning : >= {egt_warn:.0f} deg C | EGT Maintenance : >= {egt_maint:.0f} deg C | |
| FF Warning : +3% above baseline | FF Maintenance : +6% | |
| N1 Warning : -1.5% below baseline | N1 Maintenance : -3.0% | |
| CDP Warning : -3% below baseline | CDP Maintenance : -6%""", language="text") | |
| # ==================== METRIC CARDS ==================== | |
| st.markdown('<h3 style="color: #38bdf8; font-size: 1.1rem; font-weight: 600; border-left: 3px solid #38bdf8; padding-left: 0.75rem; margin: 1.5rem 0 1rem 0;">Baseline vs Measured Comparison</h3>', unsafe_allow_html=True) | |
| metric_cols = st.columns(n_params) | |
| for i in range(n_params): | |
| key = param_keys[i] | |
| base_val = baseline.get(key, 0) | |
| meas_val = measured_params.get(key, base_val) | |
| unit = param_units.get(key, "") | |
| label = param_labels[i] if i < len(param_labels) else key | |
| delta = meas_val - base_val | |
| delta_pct = (delta / base_val * 100) if base_val != 0 else 0 | |
| color = "#ef4444" if delta > 0 else "#22c55e" | |
| with metric_cols[i]: | |
| st.markdown(f""" | |
| <div style="background: linear-gradient(135deg, #1e293b, #0f172a); border-radius: 10px; | |
| padding: 1rem; border: 1px solid rgba(148,163,184,0.1);"> | |
| <div style="font-size: 0.75rem; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em;">{label}</div> | |
| <div style="font-size: 1.5rem; font-weight: 700; color: #e2e8f0;">{meas_val:.2f} <span style="font-size:0.7rem;color:#64748b">{unit}</span></div> | |
| <div style="font-size: 0.8rem; color: {color};">{'+' if delta > 0 else ''}{delta:.2f} ({delta_pct:+.1f}%)</div> | |
| <div style="font-size: 0.7rem; color: #475569; margin-top: 0.25rem;">Baseline: {base_val:.2f}</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # ==================== FAULT RANKINGS ==================== | |
| st.markdown('<h3 style="color: #38bdf8; font-size: 1.1rem; font-weight: 600; border-left: 3px solid #38bdf8; padding-left: 0.75rem; margin: 1.5rem 0 1rem 0;">Fault Signature Match Results</h3>', unsafe_allow_html=True) | |
| if scores: | |
| top = scores[0] | |
| tier_names = ["Healthy / Within Limits", "Advisory", "Warning", "Critical / Maintenance Required"] | |
| for s in scores[:5]: | |
| tier = tier_names[s["lvl"]] if s["lvl"] < 4 else "Unknown" | |
| color = s["f"]["color"] | |
| st.markdown(f""" | |
| <div style="margin-bottom: 0.5rem;"> | |
| <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.25rem;"> | |
| <span style="font-weight: 600; color: #e2e8f0;">{s['f']['name']}</span> | |
| <span style="color: {color}; font-weight: 700;">{tier} - {s['sev']*100:.1f}% match</span> | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| st.progress(float(s["sev"]), text=f"{s['f']['id']} - {s['f']['leading']}") | |
| # Primary Diagnosis | |
| st.markdown('<h3 style="color: #38bdf8; font-size: 1.1rem; font-weight: 600; border-left: 3px solid #38bdf8; padding-left: 0.75rem; margin: 1.5rem 0 1rem 0;">Primary Diagnosis & Maintenance Decision</h3>', unsafe_allow_html=True) | |
| col_d1, col_d2 = st.columns(2) | |
| with col_d1: | |
| st.markdown(f""" | |
| **Fault:** {top['f']['name']} | |
| **ATA Reference:** {top['f']['ata']} | |
| **Leading Indicator:** {top['f']['leading']} | |
| **Confidence:** {top['sev']*100:.1f}% | |
| """) | |
| st.markdown("**Mechanism:**") | |
| st.info(top['f']['mechanism']) | |
| with col_d2: | |
| st.markdown("**Recommended Maintenance Actions:**") | |
| actions = top['f']['actions'] | |
| tiers = ["Advisory", "Warning", "Critical"] | |
| colors = ["#eab308", "#f97316", "#ef4444"] | |
| for i, act in enumerate(actions): | |
| st.markdown(f'<span style="color: {colors[i]}; font-weight: 600;">[{tiers[i]}]</span> {act}', unsafe_allow_html=True) | |
| st.markdown("**Certification Requirements:**") | |
| cert_reqs = top['f'].get('cert_requirements', engine.cert_standards) | |
| for req in cert_reqs: | |
| st.markdown(f"- {req}") | |
| # ==================== ALL 4 MATLAB-STYLE FIGURES ==================== | |
| st.markdown('<h3 style="color: #38bdf8; font-size: 1.1rem; font-weight: 600; border-left: 3px solid #38bdf8; padding-left: 0.75rem; margin: 1.5rem 0 1rem 0;">Diagnostic Charts</h3>', unsafe_allow_html=True) | |
| tab1, tab2, tab3, tab4 = st.tabs([ | |
| "Fig 1: Steady-State Comparison", | |
| "Fig 2: Trends F1 & F2 (Compressor/Turbine)", | |
| "Fig 3: Trends F3, F4 & F5 (Nozzle/Bleed/Bearing)", | |
| "Fig 4: Fault Signature Matrix" | |
| ]) | |
| params = param_keys[:4] | |
| labels = param_labels[:4] | |
| healthy_vals = [baseline.get(p, 0) for p in params] | |
| user_vals = [measured_params.get(p, baseline.get(p, 0)) for p in params] | |
| # ---- FIGURE 1: Steady-State Bar Comparison ---- | |
| with tab1: | |
| try: | |
| fig1, axes1 = plt.subplots(2, 2, figsize=(14, 10)) | |
| fig1.suptitle( | |
| f"{selected_engine.split('(')[0].strip()} Engine Health Monitoring - Steady-State Parameter Comparison\n" | |
| f"Level 3 Fault Scenarios vs Healthy Baseline | {selected_engine}\n" | |
| f"Source: {getattr(engine, 'cert_standards', ['Certified'])[0]}", | |
| fontsize=12, fontweight='bold', color='white', y=0.98 | |
| ) | |
| x_labels = ['Healthy', 'F1 Comp.\nFouling', 'F2 Turb.\nErosion', | |
| 'F3 Fuel\nNozzle', 'F4 Bleed\nValve', 'F5 Bearing\nWear'] | |
| n_bars = 6 | |
| bar_x = np.arange(n_bars) | |
| palette = [ | |
| [0.22, 0.65, 0.30], # Healthy - green | |
| [0.95, 0.60, 0.07], # F1 - amber | |
| [0.90, 0.20, 0.10], # F2 - red | |
| [0.60, 0.10, 0.80], # F3 - purple | |
| [0.10, 0.45, 0.90], # F4 - blue | |
| [0.30, 0.65, 0.75], # F5 - teal | |
| ] | |
| for idx, (param, label) in enumerate(zip(params, labels)): | |
| ax = axes1.flat[idx] | |
| vals = [healthy_vals[idx]] | |
| for fi in range(min(5, len(diag["faults"]))): | |
| vals.append(diag["faults"][fi][2].get(param, healthy_vals[idx])) | |
| vals.append(user_vals[idx]) | |
| plot_palette = palette + [[0.10, 0.10, 0.10]] | |
| plot_labels = x_labels + ['Your\nReading'] | |
| bars = ax.bar(range(len(vals)), vals, edgecolor='black', linewidth=0.7) | |
| for k, bar in enumerate(bars): | |
| bar.set_color(plot_palette[k]) | |
| ax.set_xticks(range(len(vals))) | |
| ax.set_xticklabels(plot_labels, rotation=25, ha='right', fontsize=8) | |
| ax.set_ylabel(f"{label} ({param_units.get(param, '')})", fontsize=9, color='white') | |
| ax.set_title(f"{label}", fontweight='bold', color='white', fontsize=10) | |
| ax.tick_params(colors='white') | |
| ax.xaxis.label.set_color('white') | |
| ax.yaxis.label.set_color('white') | |
| ax.set_facecolor('#0f172a') | |
| for spine in ax.spines.values(): | |
| spine.set_color('#334155') | |
| ax.grid(axis='y', alpha=0.3, color='#334155') | |
| if idx == 0 and hasattr(engine, 'EGT_WARN'): | |
| ax.axhline(engine.EGT_WARN, color='gold', linestyle='--', linewidth=1.5, label='Warn') | |
| ax.axhline(engine.EGT_MAX_CONT if hasattr(engine, 'EGT_MAX_CONT') else 535, color='red', linestyle='-', linewidth=1.8, label='Limit') | |
| ax.legend(fontsize=7, loc='upper left') | |
| fig1.patch.set_facecolor('#0f172a') | |
| plt.tight_layout(rect=[0, 0, 1, 0.94]) | |
| st.pyplot(fig1) | |
| st.markdown(""" | |
| <div style="background: #1e293b; border-radius: 8px; padding: 1rem; margin-top: 0.5rem; font-size: 0.85rem; color: #94a3b8;"> | |
| <strong>DIAGNOSTIC KEY:</strong> F1: CDP down + EGT up (fouling) | F2: EGT up-up + CDP~0 (erosion) | | |
| F3: FF up-up + EGT up (nozzle) | F4: CDP down-down + N1 up (bleed valve) | F5: FF up-up + N1~0 (bearing) | |
| </div> | |
| """, unsafe_allow_html=True) | |
| except Exception as e: | |
| st.error(f"Fig 1 error: {e}") | |
| # ---- FIGURE 2: Trends F1 & F2 ---- | |
| with tab2: | |
| try: | |
| if st.session_state.trend_data is not None: | |
| df = st.session_state.trend_data | |
| st.write(f"Trend data: {len(df)} cycles loaded") | |
| fig2, axes2 = plt.subplots(2, 2, figsize=(14, 10)) | |
| fig2.suptitle( | |
| f"{selected_engine.split('(')[0].strip()} Health Parameter Trends - F1: Compressor Fouling vs F2: Turbine Erosion\n" | |
| f"Fault Onset at Cycle 20 - 100 Flight Cycle Simulation", | |
| fontsize=12, fontweight='bold', color='white', y=0.98 | |
| ) | |
| trend_params = params[:4] if len(params) >= 4 else params + ['EGT', 'FF', 'N1', 'CDP'][:4-len(params)] | |
| ylabels_t = [f"{labels[i]} ({param_units.get(params[i], '')})" if i < len(labels) else params[i] for i in range(4)] | |
| base_val_arr = [baseline_vals.get(p, 0) for p in trend_params] | |
| fault_colors = { | |
| 0: [0.95, 0.60, 0.07], # F1 - amber | |
| 1: [0.90, 0.20, 0.10], # F2 - red | |
| } | |
| N_cycles = 100 | |
| ONSET_CY = 20 | |
| t = np.arange(1, N_cycles + 1) | |
| for p_idx, (param, ylabel) in enumerate(zip(trend_params, ylabels_t)): | |
| ax = axes2.flat[p_idx] | |
| ax.set_facecolor('#0f172a') | |
| for spine in ax.spines.values(): | |
| spine.set_color('#334155') | |
| ax.tick_params(colors='white') | |
| ax.xaxis.label.set_color('white') | |
| ax.yaxis.label.set_color('white') | |
| ax.set_title(f"{ylabel} Trend - F1 vs F2", fontweight='bold', color='white', fontsize=10) | |
| ax.set_ylabel(ylabel, fontsize=9, color='white') | |
| ax.set_xlabel('Flight Cycles', fontsize=9, color='white') | |
| ax.axhline(base_val_arr[p_idx], color=[0.22, 0.65, 0.30], linestyle=':', linewidth=1.5, label='Healthy') | |
| for fi in [0, 1]: | |
| if fi < len(scores): | |
| base_v = base_val_arr[p_idx] | |
| l3_v = diag["faults"][fi][2].get(param, base_v) if fi < len(diag["faults"]) else base_v | |
| y_trend = np.full(N_cycles, base_v) | |
| if ONSET_CY <= N_cycles: | |
| y_trend[ONSET_CY-1:] = np.linspace(base_v, l3_v, N_cycles - ONSET_CY + 1) | |
| np.random.seed(42) | |
| noise = np.random.randn(N_cycles) * (base_v * 0.01) | |
| y_trend = y_trend + noise | |
| ax.plot(t, y_trend, '-', color=fault_colors[fi], linewidth=2.0, | |
| label=scores[fi]['f']['name'] if fi < len(scores) else f"F{fi+1}") | |
| ax.axvline(ONSET_CY, color='white', linestyle='--', linewidth=1.2, alpha=0.7, label='Fault Onset') | |
| if p_idx == 0 and hasattr(engine, 'EGT_WARN'): | |
| ax.axhline(engine.EGT_WARN, color='gold', linestyle='--', linewidth=1.5, alpha=0.7, label='Warning') | |
| ax.axhline(engine.EGT_MAX_CONT if hasattr(engine, 'EGT_MAX_CONT') else 535, color='red', linestyle='-', linewidth=1.8, alpha=0.7, label='Maint. Limit') | |
| ax.fill_between([1, N_cycles], engine.EGT_WARN, engine.EGT_MAX_CONT if hasattr(engine, 'EGT_MAX_CONT') else 535, | |
| alpha=0.08, color='yellow') | |
| ax.fill_between([1, N_cycles], engine.EGT_MAX_CONT if hasattr(engine, 'EGT_MAX_CONT') else 535, | |
| (engine.EGT_MAX_CONT if hasattr(engine, 'EGT_MAX_CONT') else 535) + 80, | |
| alpha=0.08, color='red') | |
| ax.legend(loc='best', fontsize=7) | |
| ax.grid(True, alpha=0.25, color='#334155') | |
| ax.set_xlim(1, N_cycles) | |
| fig2.patch.set_facecolor('#0f172a') | |
| plt.tight_layout(rect=[0, 0, 1, 0.94]) | |
| st.pyplot(fig2) | |
| except Exception as e: | |
| st.error(f"Fig 2 error: {e}") | |
| # ---- FIGURE 3: Trends F3, F4, F5 ---- | |
| with tab3: | |
| try: | |
| fig3, axes3 = plt.subplots(2, 2, figsize=(14, 10)) | |
| fig3.suptitle( | |
| f"{selected_engine.split('(')[0].strip()} Health Parameter Trends - F3: Fuel Nozzle | F4: Bleed Valve | F5: Bearing Wear\n" | |
| f"Fault Onset at Cycle 20 - 100 Flight Cycle Simulation", | |
| fontsize=12, fontweight='bold', color='white', y=0.98 | |
| ) | |
| fault_colors_3 = { | |
| 2: [0.60, 0.10, 0.80], # F3 - purple | |
| 3: [0.10, 0.45, 0.90], # F4 - blue | |
| 4: [0.30, 0.65, 0.75], # F5 - teal | |
| } | |
| for p_idx, (param, ylabel) in enumerate(zip(trend_params, ylabels_t)): | |
| ax = axes3.flat[p_idx] | |
| ax.set_facecolor('#0f172a') | |
| for spine in ax.spines.values(): | |
| spine.set_color('#334155') | |
| ax.tick_params(colors='white') | |
| ax.xaxis.label.set_color('white') | |
| ax.yaxis.label.set_color('white') | |
| ax.set_title(f"{ylabel} Trend - F3/F4/F5", fontweight='bold', color='white', fontsize=10) | |
| ax.set_ylabel(ylabel, fontsize=9, color='white') | |
| ax.set_xlabel('Flight Cycles', fontsize=9, color='white') | |
| ax.axhline(base_val_arr[p_idx], color=[0.22, 0.65, 0.30], linestyle=':', linewidth=1.5, label='Healthy') | |
| for fi in [2, 3, 4]: | |
| if fi < len(scores): | |
| base_v = base_val_arr[p_idx] | |
| l3_v = diag["faults"][fi][2].get(param, base_v) if fi < len(diag["faults"]) else base_v | |
| y_trend = np.full(N_cycles, base_v) | |
| if ONSET_CY <= N_cycles: | |
| y_trend[ONSET_CY-1:] = np.linspace(base_v, l3_v, N_cycles - ONSET_CY + 1) | |
| np.random.seed(42 + fi) | |
| noise = np.random.randn(N_cycles) * (base_v * 0.01) | |
| y_trend = y_trend + noise | |
| ax.plot(t, y_trend, '-', color=fault_colors_3[fi], linewidth=2.0, | |
| label=scores[fi]['f']['name'] if fi < len(scores) else f"F{fi+1}") | |
| ax.axvline(ONSET_CY, color='white', linestyle='--', linewidth=1.2, alpha=0.7, label='Fault Onset') | |
| if p_idx == 0 and hasattr(engine, 'EGT_WARN'): | |
| ax.axhline(engine.EGT_WARN, color='gold', linestyle='--', linewidth=1.5, alpha=0.7, label='Warning') | |
| ax.axhline(engine.EGT_MAX_CONT if hasattr(engine, 'EGT_MAX_CONT') else 535, color='red', linestyle='-', linewidth=1.8, alpha=0.7, label='Maint. Limit') | |
| ax.fill_between([1, N_cycles], engine.EGT_WARN, engine.EGT_MAX_CONT if hasattr(engine, 'EGT_MAX_CONT') else 535, | |
| alpha=0.08, color='yellow') | |
| ax.legend(loc='best', fontsize=7) | |
| ax.grid(True, alpha=0.25, color='#334155') | |
| ax.set_xlim(1, N_cycles) | |
| fig3.patch.set_facecolor('#0f172a') | |
| plt.tight_layout(rect=[0, 0, 1, 0.94]) | |
| st.pyplot(fig3) | |
| except Exception as e: | |
| st.error(f"Fig 3 error: {e}") | |
| # ---- FIGURE 4: Fault Signature Matrix (Heatmap) ---- | |
| with tab4: | |
| try: | |
| nF = min(5, len(scores)) | |
| nP = len(params) | |
| matrix = np.zeros((nF + 1, nP)) | |
| for fi in range(nF): | |
| for pi, p in enumerate(params): | |
| base_val = baseline.get(p, 1) | |
| if base_val == 0: | |
| base_val = 1 | |
| if hasattr(engine, 'EGT_REDLINE') and pi == 0: | |
| denom = engine.EGT_REDLINE - base_val | |
| else: | |
| denom = base_val * 0.10 | |
| if denom == 0: | |
| denom = 1 | |
| matrix[fi, pi] = (diag["faults"][fi][2].get(p, base_val) - base_val) / denom | |
| for pi, p in enumerate(params): | |
| matrix[nF, pi] = diag["user_sig"][pi] if pi < len(diag["user_sig"]) else 0 | |
| row_labels = [s['f']['name'] for s in scores[:nF]] + ['YOUR READING'] | |
| fig4, ax4 = plt.subplots(figsize=(10, 6)) | |
| ax4.set_facecolor('#0f172a') | |
| fig4.patch.set_facecolor('#0f172a') | |
| im = ax4.imshow(matrix, cmap='RdBu_r', vmin=-1.5, vmax=1.5, aspect='auto') | |
| for fi in range(nF + 1): | |
| for pi in range(nP): | |
| val = matrix[fi, pi] | |
| text_color = 'white' if abs(val) > 0.5 else 'black' | |
| ax4.text(pi, fi, f"{val:+.2f}", ha='center', va='center', | |
| color=text_color, fontweight='bold', fontsize=10) | |
| ax4.set_xticks(range(nP)) | |
| ax4.set_xticklabels(labels, color='white', fontsize=11) | |
| ax4.set_yticks(range(nF + 1)) | |
| ax4.set_yticklabels(row_labels, color='white', fontsize=10) | |
| ax4.set_xlabel('Health Parameter', fontsize=11, color='white') | |
| ax4.set_ylabel('Fault Scenario', fontsize=11, color='white') | |
| ax4.set_title( | |
| f"{selected_engine.split('(')[0].strip()} Fault Signature Matrix - Normalised Parameter Deviations (Level 3)\n" | |
| f"Colour: Blue = Below Baseline (down) | Red = Above Baseline (up)\n" | |
| f"Use this matrix to differentiate fault modes from sensor data", | |
| fontsize=11, fontweight='bold', color='white', pad=20 | |
| ) | |
| cbar = plt.colorbar(im, ax=ax4, label='Normalised Deviation from Healthy Baseline') | |
| cbar.ax.yaxis.label.set_color('white') | |
| cbar.ax.tick_params(colors='white') | |
| ax4.set_xticks(np.arange(nP) - 0.5, minor=True) | |
| ax4.set_yticks(np.arange(nF + 1) - 0.5, minor=True) | |
| ax4.grid(which='minor', color='#334155', linestyle='-', linewidth=0.5) | |
| plt.tight_layout() | |
| st.pyplot(fig4) | |
| st.markdown(""" | |
| <div style="background: #1e293b; border-radius: 8px; padding: 1rem; margin-top: 0.5rem; font-size: 0.85rem; color: #94a3b8;"> | |
| <strong>DIAGNOSTIC KEY:</strong> F1: CDP down + EGT up (fouling) | F2: EGT up-up + CDP~0 (erosion) | | |
| F3: FF up-up + EGT up (nozzle) | F4: CDP down-down + N1 up (bleed valve) | F5: FF up-up + N1~0 (bearing) | |
| </div> | |
| """, unsafe_allow_html=True) | |
| except Exception as e: | |
| st.error(f"Fig 4 error: {e}") | |
| # ==================== FOOTER ==================== | |
| st.markdown(""" | |
| <div style="text-align: center; padding: 2rem 1rem; margin-top: 3rem; | |
| border-top: 1px solid rgba(148,163,184,0.1); color: #64748b; font-size: 0.8rem;"> | |
| <strong style="color: #38bdf8;">PHI-Arc Engine PHM Digital Twin</strong> - | |
| Physics-Informed Prognostics for Turbofan, Turbojet, Turboprop, Ramjet, Scramjet & Rocket.<br> | |
| Built for MRO technicians and engine health monitoring professionals.<br> | |
| <em>© 2024 PHI-Arc Systems. All rights reserved.</em> | |
| </div> | |
| """, unsafe_allow_html=True) | |