Spaces:
Sleeping
Sleeping
| """ | |
| Feature 5: LLM-Driven Cycle Comparison | |
| Side-by-side comparison of two testing cycles with AI narrative. | |
| Supports cross-month comparison — cycles loaded directly from cycle_periods DB table. | |
| """ | |
| import streamlit as st | |
| import pandas as pd | |
| from core.db_connector import get_db_connector | |
| from core import config | |
| from analysis.basic_analysis import DataAnalyzer | |
| from analysis.llm_analyzer import LLMCycleAnalyzer | |
| from ui.components import page_header, cycle_selector, metric_row, analysis_card, follow_up_section | |
| from ui.plotly_charts import create_comparison_chart | |
| from core.timezone import convert_df_timestamps_to_eastern | |
| page_header( | |
| "Cycle Comparison", | |
| "Compare two testing cycles side-by-side with AI-powered analysis of differences." | |
| ) | |
| db = get_db_connector() | |
| analyzer = DataAnalyzer() | |
| llm = LLMCycleAnalyzer() | |
| # ── Load all cycles directly from DB ──────────────────────────── | |
| def load_all_cycles(): | |
| return db.get_cycle_periods() | |
| def get_available_months(): | |
| return db.get_available_months() | |
| def get_cycle_months(): | |
| return db.get_cycle_months() | |
| all_cycles = load_all_cycles() | |
| sensor_months = get_available_months() | |
| cycle_months = get_cycle_months() | |
| available_months = sorted(set(sensor_months) | set(cycle_months)) | |
| if len(all_cycles) < 2: | |
| st.warning("Need at least 2 cycles in the database for comparison.") | |
| st.stop() | |
| # ── Cycle Selection with Independent Month Filters ────────────── | |
| def load_cycle_data(start_time, end_time): | |
| """Load and analyze a cycle's data""" | |
| detail_tags = [t for t in config.CYCLE_DETAIL_TAGS if db.get_tag_index(t) is not None] | |
| df_pivot = db.get_pivot_data(detail_tags, start_time, end_time) | |
| if df_pivot.empty: | |
| return None, None | |
| # Convert timestamps to Eastern for display | |
| df_pivot = convert_df_timestamps_to_eastern(df_pivot) | |
| stats = analyzer.analyze_cycle(df_pivot) | |
| return df_pivot, stats | |
| month_options = ["All Months"] + available_months | |
| col_a, col_b = st.columns(2) | |
| with col_a: | |
| st.subheader("Cycle A") | |
| month_a = st.selectbox("Filter by month", month_options, key="comp_month_a") | |
| if month_a == "All Months": | |
| filtered_a = all_cycles | |
| else: | |
| filtered_a = [c for c in all_cycles if (c.get('start_time_et') or c['start_time']).strftime('%Y-%m') == month_a] | |
| cycle_a = cycle_selector(filtered_a, key_prefix="comp_a") | |
| with col_b: | |
| st.subheader("Cycle B") | |
| month_b = st.selectbox("Filter by month", month_options, key="comp_month_b") | |
| if month_b == "All Months": | |
| filtered_b = all_cycles | |
| else: | |
| filtered_b = [c for c in all_cycles if (c.get('start_time_et') or c['start_time']).strftime('%Y-%m') == month_b] | |
| cycle_b = cycle_selector(filtered_b, key_prefix="comp_b") | |
| if cycle_a is None or cycle_b is None: | |
| st.stop() | |
| if cycle_a['cycle_id'] == cycle_b['cycle_id']: | |
| st.warning("Please select two different cycles to compare.") | |
| st.stop() | |
| # Load data for both cycles | |
| with st.spinner("Loading cycle data..."): | |
| df_a, stats_a = load_cycle_data(cycle_a['start_time'], cycle_a['end_time']) | |
| df_b, stats_b = load_cycle_data(cycle_b['start_time'], cycle_b['end_time']) | |
| if df_a is None or df_b is None: | |
| st.error("Could not load data for one or both cycles.") | |
| st.stop() | |
| st.divider() | |
| # Side-by-side metrics | |
| st.subheader("Key Metrics Comparison") | |
| metrics_table = [] | |
| def add_metric(label, key_path_a, key_path_b, fmt=".1f", unit=""): | |
| """Helper to extract nested dict values for comparison""" | |
| val_a = stats_a | |
| val_b = stats_b | |
| for key in key_path_a: | |
| val_a = val_a.get(key, {}) if isinstance(val_a, dict) else None | |
| for key in key_path_b: | |
| val_b = val_b.get(key, {}) if isinstance(val_b, dict) else None | |
| # Ensure final values are numeric, not residual dicts from missing keys | |
| if not isinstance(val_a, (int, float)): | |
| val_a = None | |
| if not isinstance(val_b, (int, float)): | |
| val_b = None | |
| if val_a is not None and val_b is not None: | |
| diff = val_a - val_b | |
| metrics_table.append({ | |
| 'Metric': label, | |
| f'Cycle {cycle_a["cycle_id"]}': f"{val_a:{fmt}} {unit}".strip(), | |
| f'Cycle {cycle_b["cycle_id"]}': f"{val_b:{fmt}} {unit}".strip(), | |
| 'Difference': f"{diff:+{fmt}} {unit}".strip(), | |
| }) | |
| add_metric("Duration (min)", ['time_range', 'duration_minutes'], ['time_range', 'duration_minutes'], ".0f", "min") | |
| add_metric("Peak Pressure", ['discharge_pressure', 'peak'], ['discharge_pressure', 'peak'], ".0f", "bar") | |
| add_metric("Avg Pressure", ['discharge_pressure', 'avg'], ['discharge_pressure', 'avg'], ".1f", "bar") | |
| add_metric("Compression Ratio (avg)", ['compression_ratio', 'avg'], ['compression_ratio', 'avg'], ".1f", "x") | |
| add_metric("Avg Flow", ['flow', 'avg_flow'], ['flow', 'avg_flow'], ".2f", "kg/min") | |
| add_metric("Peak TT110", ['temperature_TT110', 'peak'], ['temperature_TT110', 'peak'], ".1f", "K") | |
| add_metric("Peak TT130", ['temperature_TT130', 'peak'], ['temperature_TT130', 'peak'], ".1f", "K") | |
| if 'motor' in stats_a and 'motor' in stats_b: | |
| add_metric("Peak Motor Speed", ['motor', 'peak_speed'], ['motor', 'peak_speed'], ".0f", "RPM") | |
| if 'ramp_rate' in stats_a and 'ramp_rate' in stats_b: | |
| add_metric("Max Ramp Rate", ['ramp_rate', 'max'], ['ramp_rate', 'max'], ".1f", "bar/min") | |
| if metrics_table: | |
| st.dataframe(pd.DataFrame(metrics_table), use_container_width=True, hide_index=True) | |
| # Cycle metadata from DB (new fields) | |
| st.divider() | |
| st.subheader("Cycle Metadata (from DB)") | |
| meta_cols = st.columns(2) | |
| for meta_col, cycle, label in [(meta_cols[0], cycle_a, "A"), (meta_cols[1], cycle_b, "B")]: | |
| with meta_col: | |
| _et = (cycle.get('start_time_et') or cycle['start_time']).strftime('%b %d %H:%M') | |
| st.markdown(f"**Cycle {cycle['cycle_id']}** ({_et} ET)") | |
| m_cols = st.columns(2) | |
| m_cols[0].metric("Dispensed", f"{cycle.get('total_kg_dispensed', 0):.2f} kg") | |
| m_cols[1].metric("Pump Strokes", f"{cycle.get('total_pump_strokes', 0):.0f}") | |
| st.divider() | |
| # Comparison charts | |
| st.subheader("Overlaid Comparison Charts") | |
| # Find common tags | |
| common_tags = [t for t in df_a.columns if t in df_b.columns and t != 'timestamp'] | |
| chart_tags = [t for t in ['PT130', 'TT110', 'TT130', 'FT140', 'M130_Speed', 'MC130_VFD_Speed'] if t in common_tags] | |
| if chart_tags: | |
| selected_chart_tag = st.selectbox( | |
| "Select sensor to compare", | |
| chart_tags, | |
| key="comparison_tag", | |
| ) | |
| _et_a = (cycle_a.get('start_time_et') or cycle_a['start_time']).strftime('%b %d %H:%M') | |
| _et_b = (cycle_b.get('start_time_et') or cycle_b['start_time']).strftime('%b %d %H:%M') | |
| label_a = f"Cycle {cycle_a['cycle_id']} ({_et_a} ET)" | |
| label_b = f"Cycle {cycle_b['cycle_id']} ({_et_b} ET)" | |
| fig = create_comparison_chart(df_a, df_b, selected_chart_tag, label_a, label_b) | |
| st.plotly_chart(fig, use_container_width=True) | |
| st.divider() | |
| # LLM Comparison | |
| st.subheader("AI Comparison Analysis") | |
| if not llm.api_available: | |
| st.error("Claude API key not configured. Add ANTHROPIC_API_KEY to your .env file.") | |
| st.stop() | |
| cache_key = f"comparison_{cycle_a['cycle_id']}_{cycle_b['cycle_id']}" | |
| prompt_key = f"{cache_key}_prompt" | |
| followup_key = f"{cache_key}_followups" | |
| cached_result = st.session_state.get(cache_key) | |
| if cached_result: | |
| analysis_card("AI Comparison", cached_result) | |
| # Follow-up section | |
| original_prompt = st.session_state.get(prompt_key, "") | |
| follow_up_section( | |
| session_key=followup_key, | |
| llm_analyzer=llm, | |
| original_prompt=original_prompt, | |
| original_analysis=cached_result, | |
| ) | |
| if st.button("Re-analyze", key="reanalyze_comparison"): | |
| del st.session_state[cache_key] | |
| st.session_state.pop(prompt_key, None) | |
| st.session_state.pop(followup_key, None) | |
| st.rerun() | |
| else: | |
| if st.button("Compare with AI", type="primary", use_container_width=True): | |
| label_a = f"Cycle {cycle_a['cycle_id']} ({(cycle_a.get('start_time_et') or cycle_a['start_time']).strftime('%b %d')} ET)" | |
| label_b = f"Cycle {cycle_b['cycle_id']} ({(cycle_b.get('start_time_et') or cycle_b['start_time']).strftime('%b %d')} ET)" | |
| # Build and store the prompt for follow-up context | |
| knowledge_text = "" | |
| if llm.retriever: | |
| try: | |
| context = llm.retriever.get_context_for_comparison(stats_a, stats_b) | |
| knowledge_text = llm.retriever.format_for_prompt(context) | |
| except Exception: | |
| pass | |
| st.session_state[prompt_key] = llm._build_comparison_prompt( | |
| stats_a, stats_b, label_a, label_b, knowledge_text | |
| ) | |
| with st.spinner("Claude is comparing the two cycles..."): | |
| result = llm.compare_cycles(stats_a, stats_b, label_a, label_b) | |
| st.session_state[cache_key] = result | |
| st.rerun() | |