delphi-pump-analytics / pages /5_cycle_comparison.py
pandeydigant31's picture
Initial deploy: DELPHI Pump Analytics (renamed from MURPHY)
199bfa3 verified
Raw
History Blame Contribute Delete
9.05 kB
"""
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 ────────────────────────────
@st.cache_data(ttl=600)
def load_all_cycles():
return db.get_cycle_periods()
@st.cache_data(ttl=3600)
def get_available_months():
return db.get_available_months()
@st.cache_data(ttl=3600)
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 ──────────────
@st.cache_data(ttl=600)
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()