File size: 17,173 Bytes
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
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
"""
LLM-Powered Cycle Analysis Module
Uses Claude API for variation analysis and cycle comparison.
Injects domain knowledge from CSH2 YAML knowledge base via DomainKnowledgeRetriever.
"""
import os
import re
import json
from typing import Dict, List, Optional
from core import config


# ── Output Sanitizer ──────────────────────────────────────────────
# Catches any remaining internal reference codes the LLM generates
# despite prompt instructions not to use them.

# Pattern matches FM-001, RC-003, RM-001, D1-D8, etc.
_INTERNAL_CODE_PATTERN = re.compile(
    r'\b(?:FM|RC|RM|D)-?\d{1,3}\b',
    re.IGNORECASE,
)


def sanitize_llm_output(text: str) -> str:
    """Remove internal reference codes from LLM output.

    Strips patterns like FM-001, RC-003, RM-001 that the engineering
    team finds confusing. The surrounding text (which describes the
    issue in plain language) is preserved.
    """
    if not text:
        return text
    # Remove the code pattern, plus optional surrounding parens/brackets
    # e.g., "(FM-001)" → "", "FM-001:" → ":"
    cleaned = re.sub(
        r'\s*[\(\[]*' + _INTERNAL_CODE_PATTERN.pattern + r'[\)\]]*\s*[:\-—]?\s*',
        ' ',
        text,
        flags=re.IGNORECASE,
    )
    # Clean up double spaces and leading/trailing whitespace per line
    cleaned = re.sub(r'  +', ' ', cleaned)
    return cleaned.strip()


# ── Shared Murphy System Prompt ─────────────────────────────────
# Extracted as a module-level constant so it can be reused by
# result_interpreter.py and any future LLM callers.

MURPHY_SYSTEM_PROMPT = """You are Murphy, the diagnostic intelligence layer for CSH2's cryogenic hydrogen pump platform. You are a senior cryogenic systems engineer with deep expertise in:

- Reciprocating cryogenic pump physics (triplex design, 60mm stroke, ~40mm bore, ~75.4 cm3 swept volume)
- Cryogenic fluid behavior (LH2 at 20.3K, LN2 at 77.4K, phase transitions, subcooling margins)
- High-pressure hydrogen systems (up to 900 bar, H35/H70/ccH2 dispensing)
- Seal integrity, check valve dynamics (ICV/DCV), and thermal management
- Time-series sensor diagnostics and fault signature recognition

When you identify a pattern in the data, explain WHY it matters thermodynamically — not just that a number is high or low. Describe any matching failure patterns by their plain-English name and physical mechanism. Never use internal reference codes like FM-001 or RC-001 — always describe issues in terms the engineering team can immediately understand (e.g., "DCV seat wear" not "FM-001").

Always cite specific measured values from the data to support your claims. If the data summary says peak pressure was 371 bar, say "371 bar" — do not round, estimate, or invent values.

Key system specifications:
- Drive: Variable Frequency Drive (VFD), 0-100% speed, max ~300 RPM
- Pressure targets: H35 (350 bar), H70 (700 bar), max rated 900+ bar
- Phase 1B results: 0.26 kWh/kg, 1.13-1.25 kg/min flow, 370 bar, 55K delivered temp, 100% uptime
- Testing phases: LN2 mechanical validation (Mar-Aug 2025), LH2 Phase 1A (early Sep), Phase 1B ccH2 fills (Sep 24-26)

Be direct, methodical, and honest about uncertainty. Distinguish between "the data shows X" and "X could indicate Y but we'd need Z to confirm." """


class LLMCycleAnalyzer:
    """Claude-powered analysis for testing cycle interpretation.

    Integrates DomainKnowledgeRetriever to inject CSH2-specific failure modes,
    reasoning chains, and operational context into prompts.
    """

    def __init__(self):
        self.api_key = config.ANTHROPIC_API_KEY
        self.api_available = bool(self.api_key and self.api_key != 'your_api_key_here')
        self.model = "claude-sonnet-4-20250514"
        self._client = None
        self._retriever = None

    @property
    def client(self):
        if self._client is None and self.api_available:
            import anthropic
            self._client = anthropic.Anthropic(api_key=self.api_key)
        return self._client

    @property
    def retriever(self):
        """Lazy-load the domain knowledge retriever. Returns None on failure."""
        if self._retriever is None:
            try:
                from analysis.knowledge_retriever import DomainKnowledgeRetriever
                self._retriever = DomainKnowledgeRetriever()
            except Exception:
                self._retriever = None
        return self._retriever

    def _build_system_prompt(self) -> str:
        """Build the system prompt establishing Murphy's identity and expertise."""
        return MURPHY_SYSTEM_PROMPT

    def analyze_cycle_variations(
        self,
        cycle_stats: Dict,
        plateaus: Dict[str, List[Dict]],
        transitions: List[Dict] = None,
    ) -> str:
        """
        Feature 4: Explain variations and stabilizations in a single testing cycle.

        Args:
            cycle_stats: Dict from DataAnalyzer.analyze_cycle()
            plateaus: Dict mapping tag names to lists of plateau periods
            transitions: Optional list of notable transitions

        Returns:
            LLM analysis text
        """
        if not self.api_available:
            return "LLM analysis unavailable. Add ANTHROPIC_API_KEY to .env file."

        # Retrieve domain knowledge based on data patterns
        knowledge_text = ""
        if self.retriever:
            try:
                context = self.retriever.get_context_for_cycle(cycle_stats, plateaus)
                knowledge_text = self.retriever.format_for_prompt(context)
            except Exception:
                knowledge_text = ""

        prompt = self._build_variation_prompt(cycle_stats, plateaus, transitions, knowledge_text)

        try:
            message = self.client.messages.create(
                model=self.model,
                max_tokens=2000,
                system=[
                    {
                        "type": "text",
                        "text": self._build_system_prompt(),
                        "cache_control": {"type": "ephemeral"},
                    }
                ],
                messages=[{"role": "user", "content": prompt}],
            )
            return sanitize_llm_output(message.content[0].text)
        except Exception as e:
            return f"Analysis failed: {e}"

    def compare_cycles(
        self,
        cycle_a_stats: Dict,
        cycle_b_stats: Dict,
        cycle_a_label: str = "Cycle A",
        cycle_b_label: str = "Cycle B",
    ) -> str:
        """
        Feature 5: Compare two testing cycles and explain differences.

        Args:
            cycle_a_stats: Dict from DataAnalyzer.analyze_cycle() for cycle A
            cycle_b_stats: Dict from DataAnalyzer.analyze_cycle() for cycle B
            cycle_a_label: Display label for cycle A
            cycle_b_label: Display label for cycle B

        Returns:
            LLM comparison text
        """
        if not self.api_available:
            return "LLM analysis unavailable. Add ANTHROPIC_API_KEY to .env file."

        # Retrieve domain knowledge based on both cycles
        knowledge_text = ""
        if self.retriever:
            try:
                context = self.retriever.get_context_for_comparison(cycle_a_stats, cycle_b_stats)
                knowledge_text = self.retriever.format_for_prompt(context)
            except Exception:
                knowledge_text = ""

        prompt = self._build_comparison_prompt(
            cycle_a_stats, cycle_b_stats, cycle_a_label, cycle_b_label, knowledge_text
        )

        try:
            message = self.client.messages.create(
                model=self.model,
                max_tokens=2500,
                system=[
                    {
                        "type": "text",
                        "text": self._build_system_prompt(),
                        "cache_control": {"type": "ephemeral"},
                    }
                ],
                messages=[{"role": "user", "content": prompt}],
            )
            return sanitize_llm_output(message.content[0].text)
        except Exception as e:
            return f"Comparison analysis failed: {e}"

    def follow_up(
        self,
        conversation_history: List[Dict],
        follow_up_question: str,
    ) -> str:
        """Multi-turn follow-up on a previous analysis.

        Args:
            conversation_history: List of {role, content} message dicts
                representing the prior conversation (user prompt + assistant
                analysis + any prior follow-ups).
            follow_up_question: The engineer's follow-up question.

        Returns:
            LLM follow-up response text.
        """
        if not self.api_available:
            return "LLM analysis unavailable. Add ANTHROPIC_API_KEY to .env file."

        messages = list(conversation_history) + [
            {"role": "user", "content": follow_up_question},
        ]

        try:
            message = self.client.messages.create(
                model=self.model,
                max_tokens=1500,
                system=[
                    {
                        "type": "text",
                        "text": self._build_system_prompt(),
                        "cache_control": {"type": "ephemeral"},
                    }
                ],
                messages=messages,
            )
            return sanitize_llm_output(message.content[0].text)
        except Exception as e:
            return f"Follow-up failed: {e}"

    def _build_variation_prompt(
        self,
        stats: Dict,
        plateaus: Dict[str, List[Dict]],
        transitions: List[Dict] = None,
        knowledge_text: str = "",
    ) -> str:
        """Build the prompt for single-cycle variation analysis"""

        # Format cycle summary
        time_range = stats.get('time_range', {})
        duration = time_range.get('duration_minutes', 0)

        summary_lines = [f"Duration: {duration:.1f} minutes"]

        if 'discharge_pressure' in stats:
            dp = stats['discharge_pressure']
            summary_lines.append(f"Peak Discharge Pressure: {dp['peak']:.1f} bar")
            summary_lines.append(f"Average Discharge Pressure: {dp['avg']:.1f} bar")
            summary_lines.append(f"Initial Pressure: {dp['initial']:.1f} bar -> Final: {dp['final']:.1f} bar")

        if 'compression_ratio' in stats:
            cr = stats['compression_ratio']
            summary_lines.append(f"Compression Ratio: {cr['min']:.1f}x to {cr['max']:.1f}x (avg: {cr['avg']:.1f}x)")

        for key in ['temperature_TT110', 'temperature_TT130']:
            if key in stats:
                t = stats[key]
                tag = key.replace('temperature_', '')
                summary_lines.append(f"{tag}: {t['min']:.1f} K to {t['peak']:.1f} K (avg: {t['avg']:.1f} K)")

        if 'flow' in stats:
            f = stats['flow']
            summary_lines.append(f"Average Flow: {f['avg_flow']:.2f} kg/min, Max: {f['max_flow']:.2f} kg/min")
            if 'total_mass_kg' in f:
                summary_lines.append(f"Total Mass Delivered: {f['total_mass_kg']:.2f} kg")

        if 'ramp_rate' in stats:
            r = stats['ramp_rate']
            summary_lines.append(f"Pressure Ramp Rate: avg {r['avg']:.1f} bar/min, max {r['max']:.1f} bar/min")

        if 'motor' in stats:
            m = stats['motor']
            summary_lines.append(f"Motor Peak Speed: {m['peak_speed']:.0f} RPM, Avg: {m['avg_speed']:.0f} RPM")

        cycle_summary = "\n".join(f"  - {line}" for line in summary_lines)

        # Format plateaus
        plateau_lines = []
        for tag, periods in plateaus.items():
            if periods:
                plateau_lines.append(f"\n  {tag} plateaus ({len(periods)} detected):")
                for i, p in enumerate(periods[:5], 1):
                    plateau_lines.append(
                        f"    {i}. Value: {p['value']:.2f} | "
                        f"Duration: {p['duration_minutes']:.1f} min | "
                        f"From {p['start'].strftime('%H:%M:%S')} to {p['end'].strftime('%H:%M:%S')}"
                    )
        plateau_text = "\n".join(plateau_lines) if plateau_lines else "  No significant plateaus detected."

        # Format performance vs targets
        perf_lines = []
        pvt = stats.get('performance_vs_targets', {})
        if 'pressure' in pvt:
            p = pvt['pressure']
            status = "MET" if p['meets_phase1'] else "NOT MET"
            perf_lines.append(f"  - Phase 1 pressure target (500 bar): {status} (achieved {p['achieved']:.0f} bar)")
            status_h70 = "MET" if p['meets_h70'] else "NOT MET"
            perf_lines.append(f"  - H70 pressure target (700 bar): {status_h70}")
        perf_text = "\n".join(perf_lines) if perf_lines else "  No targets evaluated."

        prompt = f"""Analyze this testing cycle and explain the observed variations and stabilizations:

CYCLE SUMMARY:
{cycle_summary}

DETECTED PLATEAUS (constant-value periods):
{plateau_text}

PERFORMANCE vs TARGETS:
{perf_text}"""

        if knowledge_text:
            prompt += f"""

DOMAIN KNOWLEDGE (use this to inform your analysis — describe any matching patterns by name and mechanism):
{knowledge_text}"""

        prompt += """

Provide a concise engineering analysis (3-5 paragraphs) covering:
1. What physical processes likely caused the observed pressure/temperature variations
2. Why plateaus (stabilizations) occurred at the specific values detected
3. Any diagnostic concerns — describe matching failure patterns by name and physical mechanism, and walk through the diagnostic reasoning
4. Recommendations for the next cycle or operational improvements

Use specific values from the data — cite exact numbers, do not round or estimate. If sensor trustworthiness notes are provided, factor them into your interpretation (e.g., TT110 reads pipe wall not fluid, FT140 needs density correction for LH2). Never use internal codes like FM-XXX or RC-XXX."""

        return prompt

    def _build_comparison_prompt(
        self,
        a: Dict,
        b: Dict,
        label_a: str,
        label_b: str,
        knowledge_text: str = "",
    ) -> str:
        """Build the prompt for cycle comparison"""

        def summarize(stats: Dict, label: str) -> str:
            lines = [f"{label}:"]
            tr = stats.get('time_range', {})
            lines.append(f"  Duration: {tr.get('duration_minutes', 0):.1f} min")

            if 'discharge_pressure' in stats:
                dp = stats['discharge_pressure']
                lines.append(f"  Peak Pressure: {dp['peak']:.1f} bar, Avg: {dp['avg']:.1f} bar")

            if 'compression_ratio' in stats:
                cr = stats['compression_ratio']
                lines.append(f"  Compression Ratio: {cr['avg']:.1f}x (range: {cr['min']:.1f}x - {cr['max']:.1f}x)")

            for key in ['temperature_TT110', 'temperature_TT130']:
                if key in stats:
                    t = stats[key]
                    tag = key.replace('temperature_', '')
                    lines.append(f"  {tag}: {t['min']:.1f} - {t['peak']:.1f} K (avg {t['avg']:.1f} K)")

            if 'flow' in stats:
                f = stats['flow']
                lines.append(f"  Flow: avg {f['avg_flow']:.2f} kg/min, max {f['max_flow']:.2f} kg/min")
                if 'total_mass_kg' in f:
                    lines.append(f"  Total Mass: {f['total_mass_kg']:.2f} kg")

            if 'ramp_rate' in stats:
                r = stats['ramp_rate']
                lines.append(f"  Ramp Rate: avg {r['avg']:.1f}, max {r['max']:.1f} bar/min")

            if 'motor' in stats:
                m = stats['motor']
                lines.append(f"  Motor: peak {m['peak_speed']:.0f} RPM, avg {m['avg_speed']:.0f} RPM")

            return "\n".join(lines)

        summary_a = summarize(a, label_a)
        summary_b = summarize(b, label_b)

        prompt = f"""Compare these two testing cycles and provide an engineering analysis:

{summary_a}

{summary_b}"""

        if knowledge_text:
            prompt += f"""

DOMAIN KNOWLEDGE (use this to inform your comparison — describe any matching patterns by name and mechanism):
{knowledge_text}"""

        prompt += """

Provide a concise comparison (3-5 paragraphs) covering:
1. Key differences between the two cycles (pressure, temperature, flow, duration)
2. Likely physical or operational reasons for the differences — describe matching failure patterns by name and physical mechanism
3. Which cycle performed better and why (considering efficiency, stability, and Phase 1B benchmarks)
4. Any concerning trends or improvements noted between cycles
5. Recommendations based on the comparison, with diagnostic reasoning

Be technically precise. Cite exact values from both cycles — do not round or estimate. If sensor trustworthiness notes are provided, factor them into your interpretation. Never use internal codes like FM-XXX or RC-XXX."""

        return prompt