""" 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