File size: 9,214 Bytes
199bfa3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d7ed706
199bfa3
 
 
 
 
 
 
 
d7ed706
199bfa3
 
 
d7ed706
 
 
 
199bfa3
d7ed706
199bfa3
 
d7ed706
199bfa3
 
 
 
 
d7ed706
199bfa3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d7ed706
 
 
 
 
199bfa3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
"""
Reusable UI components for CSH2 Web Dashboard — HUD Theme
"""
import streamlit as st
import pandas as pd
from typing import List, Dict, Optional


def page_header(title: str, subtitle: str = ""):
    """Render a HUD-style page header with gradient accent lines"""
    subtitle_html = f'<div class="hud-subtitle">{subtitle}</div>' if subtitle else ''
    st.markdown(f"""
    <div class="hud-page-header">
        <div class="hud-header-line"></div>
        <h1>{title}</h1>
        {subtitle_html}
        <div class="hud-header-line"></div>
    </div>
    """, unsafe_allow_html=True)


def metric_row(metrics: List[Dict]):
    """
    Render a row of HUD-style metric cards.

    metrics: list of dicts with keys: label, value, unit, delta (optional), status (optional)
    status can be: 'nominal', 'warning', 'alarm' (defaults to no special styling)
    """
    cols = st.columns(len(metrics))
    for col, m in zip(cols, metrics):
        with col:
            display_value = f"{m['value']}"
            if m.get('unit'):
                display_value = f"{m['value']} {m['unit']}"
            st.metric(
                label=m['label'],
                value=display_value,
                delta=m.get('delta'),
            )


def analysis_card(title: str, content: str):
    """Render an LLM analysis result in a HUD terminal-style card"""
    st.markdown(f"""
    <div class="analysis-card">
        <h4>// {title.upper()}</h4>
        <div>
            {content.replace(chr(10), '<br>')}
        </div>
    </div>
    """, unsafe_allow_html=True)


def system_status_footer(sensor_count: int, data_start: str, data_end: str, connected: bool = True):
    """Render a HUD-style system status panel in the sidebar"""
    conn_color = 'var(--status-nominal)' if connected else 'var(--status-alarm)'
    conn_text = 'CONNECTED' if connected else 'OFFLINE'
    st.markdown(f"""
    <div style="
        border-top: 1px solid var(--border);
        padding-top: var(--space-md);
        margin-top: var(--space-md);
        font-family: var(--font-mono);
        font-size: 0.7rem;
        color: var(--text-muted);
        letter-spacing: 0.5px;
    ">
        <div style="margin-bottom: var(--space-xs);">
            <span class="hud-indicator" style="background-color: {conn_color}; box-shadow: 0 0 6px {conn_color};"></span>
            <span style="color: {conn_color};">{conn_text}</span>
        </div>
        <div>SENSORS: {sensor_count}</div>
        <div>DATA: {data_start}{data_end}</div>
        <div style="margin-top: 6px; color: var(--text-dim);">CSH2 DELPHI v1.0</div>
    </div>
    """, unsafe_allow_html=True)


def cycle_selector(cycles: List[Dict], key_prefix: str = "cycle") -> Optional[Dict]:
    """
    Render a cycle selector dropdown.

    cycles: list of dicts with keys: cycle_id, start_time, end_time, duration_minutes, peak_pressure
    Returns selected cycle dict or None
    """
    if not cycles:
        st.info("No testing cycles detected for this period.")
        return None

    options = []
    for c in cycles:
        # Use Eastern display time if available, fall back to start_time
        display_time = c.get('start_time_et') or c['start_time']
        start_str = display_time.strftime('%b %d %H:%M') + " ET"
        duration = f"{c['duration_minutes']:.0f} min"
        peak = f"{c.get('peak_pressure', 0):.0f} bar" if c.get('peak_pressure') else "N/A"
        options.append(f"Cycle {c['cycle_id']} | {start_str} | {duration} | Peak (PT130): {peak}")

    selected_idx = st.selectbox(
        "Select a testing cycle",
        range(len(options)),
        format_func=lambda i: options[i],
        key=f"{key_prefix}_selector",
    )

    return cycles[selected_idx] if selected_idx is not None else None


def tag_multiselect(db_connector, key: str = "tags", default_group: str = None) -> List[str]:
    """
    Render a tag multi-select with group filtering.

    Selecting a sensor group filters the available options to only show
    sensors in that group. "All Sensors" shows every tag in the database.

    Returns list of selected tag names.
    """
    from core.config import SENSOR_GROUPS

    group_names = ["All Sensors"] + list(SENSOR_GROUPS.keys())

    def _clear_selection():
        """Reset the multiselect when group changes to avoid stale selections."""
        st.session_state.pop(f"{key}_select", None)

    selected_group = st.selectbox(
        "Sensor Group",
        group_names,
        key=f"{key}_group",
        on_change=_clear_selection,
    )

    all_tags = db_connector.get_all_tag_names()

    if selected_group == "All Sensors":
        available_tags = all_tags
        default_tags = []
    else:
        group_tags = SENSOR_GROUPS.get(selected_group, [])
        available_tags = [t for t in group_tags if t in all_tags]
        default_tags = available_tags  # Pre-select all sensors in the chosen group

    selected_tags = st.multiselect(
        "Select Sensors",
        options=available_tags,
        default=default_tags,
        key=f"{key}_select",
    )

    return selected_tags


def date_range_picker(key: str = "dates"):
    """Render start/end date and time pickers. Returns (start_datetime, end_datetime)."""
    from datetime import datetime, time, timedelta

    col1, col2 = st.columns(2)
    with col1:
        start_date = st.date_input(
            "Start Date",
            value=datetime(2025, 9, 25),
            key=f"{key}_start_date",
        )
        start_time = st.time_input("Start Time", value=time(0, 0), key=f"{key}_start_time")

    with col2:
        end_date = st.date_input(
            "End Date",
            value=datetime(2025, 9, 25),
            key=f"{key}_end_date",
        )
        end_time = st.time_input("End Time", value=time(23, 59), key=f"{key}_end_time")

    start_dt = datetime.combine(start_date, start_time)
    end_dt = datetime.combine(end_date, end_time)

    return start_dt, end_dt


def no_data_message():
    """Show a styled no-data message"""
    st.warning("No data found for the selected sensors and time range. Try adjusting your filters.")


def follow_up_section(
    session_key: str,
    llm_analyzer,
    original_prompt: str,
    original_analysis: str,
    max_turns: int = 3,
):
    """Render a follow-up Q&A section below an analysis card.

    Maintains a multi-turn conversation thread stored in
    ``st.session_state[session_key]`` as a list of ``{role, content}`` dicts.

    Args:
        session_key: Unique session state key for this conversation thread.
        llm_analyzer: An ``LLMCycleAnalyzer`` instance with ``follow_up()`` method.
        original_prompt: The original user prompt that generated the analysis.
        original_analysis: The original assistant analysis text.
        max_turns: Maximum follow-up exchanges (default 3).
    """
    # Initialize conversation history if needed
    if session_key not in st.session_state:
        st.session_state[session_key] = []

    followups = st.session_state[session_key]

    # Display existing follow-up thread
    if followups:
        for msg in followups:
            if msg["role"] == "user":
                st.markdown(f"""
                <div style="background: var(--bg-elevated); border-left: 3px solid var(--info);
                            padding: var(--space-sm) var(--space-md); margin: var(--space-sm) 0;
                            border-radius: var(--radius);
                            font-family: var(--font-body); font-size: 0.85rem; color: var(--text-secondary);">
                    <strong style="color: var(--info); font-family: var(--font-mono);">You:</strong> {msg['content']}
                </div>
                """, unsafe_allow_html=True)
            else:
                analysis_card("Murphy Follow-up", msg["content"])

    # Show input if under max turns
    turn_count = len([m for m in followups if m["role"] == "user"])
    if turn_count >= max_turns:
        st.caption(f"Maximum {max_turns} follow-up questions reached.")
        return

    # Follow-up input
    input_col, btn_col = st.columns([5, 1])
    with input_col:
        followup_q = st.text_input(
            "Ask a follow-up question",
            placeholder="e.g. 'What about the temperature trend?' or 'Explain the plateau at 370 bar'",
            key=f"{session_key}_input",
            label_visibility="collapsed",
        )
    with btn_col:
        followup_btn = st.button(
            "Ask",
            type="primary",
            use_container_width=True,
            key=f"{session_key}_btn",
        )

    if followup_btn and followup_q:
        # Build full conversation history for the API call
        conversation = [
            {"role": "user", "content": original_prompt},
            {"role": "assistant", "content": original_analysis},
        ] + followups

        with st.spinner("Murphy is thinking..."):
            response = llm_analyzer.follow_up(conversation, followup_q)

        # Append to thread
        followups.append({"role": "user", "content": followup_q})
        followups.append({"role": "assistant", "content": response})
        st.session_state[session_key] = followups
        st.rerun()