result_analyst.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519
  1. """AI Result Analyst + Multi-Fidelity Calibration + Confidence Grading (P3-M4).
  2. Analyzes simulation results using Kimi k3 model, with:
  3. - Multi-fidelity result calibration (L0-L4 bias correction)
  4. - Confidence grade assessment (A-D)
  5. - Six convergence criteria checks
  6. - Natural language result interpretation
  7. """
  8. import json
  9. import math
  10. from pathlib import Path
  11. from typing import Dict, List, Optional, Any, Tuple
  12. from datetime import datetime
  13. from ..config import PROMPTS_DIR
  14. from ..metrics_constants import METRIC_DIRECTIONS
  15. from ..services.ai_client import get_kimi_client
  16. # Fidelity level definitions (per third-party review)
  17. FIDELITY_LEVELS = {
  18. "L0": {"name": "Analytic Pre-screen", "bias_pct": 20.0, "cost": 0.01},
  19. "L1": {"name": "Magnetic Equivalent Circuit", "bias_pct": 15.0, "cost": 0.1},
  20. "L2": {"name": "2D FEA (Maxwell)", "bias_pct": 8.0, "cost": 1.0},
  21. "L3": {"name": "3D FEA (Motor-CAD)", "bias_pct": 3.0, "cost": 5.0},
  22. "L4": {"name": "3D Transient + Thermal Coupled", "bias_pct": 1.0, "cost": 20.0},
  23. }
  24. class ConvergenceChecker:
  25. """Six convergence criteria checker (per third-party review)."""
  26. @staticmethod
  27. def check_objective_stability(values: List[float], window: int = 5, threshold: float = 0.01) -> Dict[str, Any]:
  28. """Criterion 1: Objective function change rate < threshold."""
  29. if len(values) < window:
  30. return {"passed": False, "reason": "insufficient_data", "value": None}
  31. recent = values[-window:]
  32. if max(recent) == 0:
  33. return {"passed": True, "reason": "zero_objective", "value": 0.0}
  34. change_rate = abs(max(recent) - min(recent)) / abs(max(recent))
  35. return {"passed": change_rate < threshold, "reason": "change_rate", "value": round(change_rate, 4)}
  36. @staticmethod
  37. def check_optimum_stability(locations: List[Dict[str, float]], window: int = 5, threshold: float = 0.1) -> Dict[str, Any]:
  38. """Criterion 2: Optimum location stability."""
  39. if len(locations) < window:
  40. return {"passed": False, "reason": "insufficient_data", "value": None}
  41. recent = locations[-window:]
  42. # Calculate average distance between consecutive optimum locations
  43. distances = []
  44. for i in range(1, len(recent)):
  45. dist_sq = 0.0
  46. for key in recent[i]:
  47. if key in recent[i-1]:
  48. dist_sq += (recent[i][key] - recent[i-1][key]) ** 2
  49. distances.append(math.sqrt(dist_sq))
  50. if not distances:
  51. return {"passed": True, "reason": "single_location", "value": 0.0}
  52. avg_dist = sum(distances) / len(distances)
  53. return {"passed": avg_dist < threshold, "reason": "location_drift", "value": round(avg_dist, 4)}
  54. @staticmethod
  55. def check_surrogate_error(predictions: List[float], actuals: List[float], threshold: float = 0.05) -> Dict[str, Any]:
  56. """Criterion 3: Surrogate model prediction error < 5%."""
  57. if len(predictions) < 3 or len(predictions) != len(actuals):
  58. return {"passed": False, "reason": "insufficient_data", "value": None}
  59. errors = []
  60. for p, a in zip(predictions, actuals):
  61. if abs(a) > 1e-10:
  62. errors.append(abs(p - a) / abs(a))
  63. if not errors:
  64. return {"passed": False, "reason": "zero_actuals", "value": None}
  65. avg_error = sum(errors) / len(errors)
  66. return {"passed": avg_error < threshold, "reason": "avg_relative_error", "value": round(avg_error, 4)}
  67. @staticmethod
  68. def check_constraint_satisfaction(feasible_count: int, total_count: int, threshold: float = 0.95) -> Dict[str, Any]:
  69. """Criterion 4: Constraint satisfaction rate > 95%."""
  70. if total_count == 0:
  71. return {"passed": False, "reason": "no_data", "value": None}
  72. rate = feasible_count / total_count
  73. return {"passed": rate > threshold, "reason": "feasibility_rate", "value": round(rate, 4)}
  74. @staticmethod
  75. def check_sample_density(n_samples: int, n_dimensions: int, threshold: int = 10) -> Dict[str, Any]:
  76. """Criterion 5: Sample density sufficient (>10 points per dimension)."""
  77. if n_dimensions == 0:
  78. return {"passed": False, "reason": "no_dimensions", "value": None}
  79. density = n_samples / n_dimensions
  80. return {"passed": density >= threshold, "reason": "points_per_dimension", "value": round(density, 1)}
  81. @staticmethod
  82. def check_physical_consistency(metrics: Dict[str, float], bounds: Dict[str, Tuple[float, float]]) -> Dict[str, Any]:
  83. """Criterion 6: Physical consistency check."""
  84. violations = []
  85. for key, (low, high) in bounds.items():
  86. if key in metrics:
  87. value = metrics[key]
  88. if value < low or value > high:
  89. violations.append(f"{key}={value} outside [{low}, {high}]")
  90. return {"passed": len(violations) == 0, "reason": "physical_bounds", "violations": violations}
  91. class MultiFidelityCalibrator:
  92. """Multi-fidelity result calibration (L0-L4 bias correction).
  93. Per third-party review: different fidelity levels have known biases.
  94. Low-fidelity results are corrected using calibration factors derived
  95. from paired high-low fidelity comparisons.
  96. """
  97. def __init__(self):
  98. # Default calibration factors (will be refined with actual data)
  99. # Format: {metric: {fidelity: correction_factor}}
  100. self.calibration_factors = {
  101. "tavg_nm": {"L0": 1.15, "L1": 1.10, "L2": 1.05, "L3": 1.01, "L4": 1.00},
  102. "efficiency_pct": {"L0": 0.95, "L1": 0.97, "L2": 0.99, "L3": 0.995, "L4": 1.00},
  103. "total_losses_w": {"L0": 0.85, "L1": 0.90, "L2": 0.95, "L3": 0.98, "L4": 1.00},
  104. }
  105. self.calibration_data: List[Dict[str, Any]] = []
  106. def calibrate(self, metric: str, value: float, fidelity: str) -> Dict[str, Any]:
  107. """Calibrate a low-fidelity result to high-fidelity equivalent.
  108. Args:
  109. metric: Metric name (tavg_nm, efficiency_pct, etc.)
  110. value: Raw value from simulation
  111. fidelity: Fidelity level (L0-L4)
  112. Returns:
  113. Dict with calibrated value, correction factor, and uncertainty
  114. """
  115. factors = self.calibration_factors.get(metric, {})
  116. factor = factors.get(fidelity, 1.0)
  117. calibrated_value = value * factor
  118. # Uncertainty based on fidelity level bias
  119. fidelity_info = FIDELITY_LEVELS.get(fidelity, {"bias_pct": 10.0})
  120. uncertainty_pct = fidelity_info["bias_pct"]
  121. uncertainty = abs(calibrated_value) * uncertainty_pct / 100.0
  122. return {
  123. "metric": metric,
  124. "raw_value": value,
  125. "fidelity": fidelity,
  126. "calibrated_value": round(calibrated_value, 4),
  127. "correction_factor": factor,
  128. "uncertainty_pct": uncertainty_pct,
  129. "uncertainty_abs": round(uncertainty, 4),
  130. "calibrated": fidelity != "L4",
  131. }
  132. def update_calibration(self, low_fidelity_results: Dict[str, float],
  133. high_fidelity_results: Dict[str, float],
  134. low_fidelity: str, high_fidelity: str = "L4") -> None:
  135. """Update calibration factors using paired low-high fidelity results.
  136. Args:
  137. low_fidelity_results: Results from low-fidelity simulation
  138. high_fidelity_results: Results from high-fidelity simulation
  139. low_fidelity: Low fidelity level
  140. high_fidelity: High fidelity level (reference)
  141. """
  142. for metric, low_value in low_fidelity_results.items():
  143. if metric in high_fidelity_results and high_fidelity_results[metric] != 0:
  144. factor = high_fidelity_results[metric] / low_value
  145. if metric not in self.calibration_factors:
  146. self.calibration_factors[metric] = {}
  147. # Exponential moving average update
  148. old_factor = self.calibration_factors[metric].get(low_fidelity, 1.0)
  149. new_factor = 0.7 * old_factor + 0.3 * factor
  150. self.calibration_factors[metric][low_fidelity] = round(new_factor, 4)
  151. self.calibration_data.append({
  152. "timestamp": datetime.now().isoformat(),
  153. "low_fidelity": low_fidelity,
  154. "high_fidelity": high_fidelity,
  155. "metrics": list(low_fidelity_results.keys()),
  156. })
  157. class ConfidenceGrader:
  158. """Confidence grade assessor (A-D per third-party review)."""
  159. @staticmethod
  160. def grade(
  161. fidelity: str,
  162. n_samples: int,
  163. n_dimensions: int,
  164. convergence_passed: int,
  165. total_criteria: int = 6,
  166. anomalies: int = 0,
  167. ) -> Dict[str, Any]:
  168. """Assign confidence grade A-D based on multiple factors.
  169. Args:
  170. fidelity: Simulation fidelity level (L0-L4)
  171. n_samples: Number of simulation samples
  172. n_dimensions: Number of scan dimensions
  173. convergence_passed: Number of convergence criteria passed
  174. total_criteria: Total number of convergence criteria
  175. anomalies: Number of detected anomalies
  176. Returns:
  177. Dict with grade, score, and reasoning
  178. """
  179. score = 0.0
  180. reasons = []
  181. # Fidelity contribution (40%)
  182. fidelity_scores = {"L4": 40, "L3": 32, "L2": 24, "L1": 12, "L0": 4}
  183. fidelity_score = fidelity_scores.get(fidelity, 0)
  184. score += fidelity_score
  185. reasons.append(f"fidelity={fidelity} ({fidelity_score}/40)")
  186. # Sample density contribution (25%)
  187. if n_dimensions > 0:
  188. density = n_samples / n_dimensions
  189. density_score = min(25, density * 2.5)
  190. score += density_score
  191. reasons.append(f"sample_density={density:.1f} pts/dim ({density_score:.1f}/25)")
  192. # Convergence contribution (25%)
  193. conv_score = (convergence_passed / total_criteria) * 25 if total_criteria > 0 else 0
  194. score += conv_score
  195. reasons.append(f"convergence={convergence_passed}/{total_criteria} ({conv_score:.1f}/25)")
  196. # Anomaly penalty (10%)
  197. anomaly_penalty = min(10, anomalies * 3)
  198. score -= anomaly_penalty
  199. if anomalies > 0:
  200. reasons.append(f"anomalies={anomalies} (-{anomaly_penalty})")
  201. # Assign grade
  202. if score >= 85:
  203. grade = "A"
  204. elif score >= 70:
  205. grade = "B"
  206. elif score >= 50:
  207. grade = "C"
  208. else:
  209. grade = "D"
  210. return {
  211. "grade": grade,
  212. "score": round(score, 1),
  213. "max_score": 100,
  214. "reasons": reasons,
  215. "fidelity": fidelity,
  216. "n_samples": n_samples,
  217. "n_dimensions": n_dimensions,
  218. "convergence_passed": convergence_passed,
  219. "anomalies": anomalies,
  220. }
  221. class AIResultAnalyst:
  222. """AI-powered simulation result analyst."""
  223. def __init__(self):
  224. self.ai_client = get_kimi_client()
  225. self.calibrator = MultiFidelityCalibrator()
  226. self.convergence = ConvergenceChecker()
  227. self.grader = ConfidenceGrader()
  228. self._prompt_template = None
  229. def _load_prompt(self) -> str:
  230. if self._prompt_template is None:
  231. prompt_path = PROMPTS_DIR / "result_analysis" / "analyze.txt"
  232. if prompt_path.exists():
  233. with open(prompt_path, "r", encoding="utf-8") as f:
  234. self._prompt_template = f.read()
  235. else:
  236. self._prompt_template = "\u4f60\u662f\u7535\u673a\u4eff\u771f\u7ed3\u679c\u5206\u6790\u4e13\u5bb6\u3002\u5206\u6790\u63d0\u4f9b\u7684\u4eff\u771f\u6570\u636e\uff0c\u8f93\u51faJSON\u683c\u5f0f\u7684\u5206\u6790\u62a5\u544a\u3002"
  237. return self._prompt_template
  238. def analyze(
  239. self,
  240. results: List[Dict[str, Any]],
  241. targets: Optional[Dict[str, float]] = None,
  242. fidelity: str = "L3",
  243. scan_parameters: Optional[List[str]] = None,
  244. ) -> Dict[str, Any]:
  245. """Analyze simulation results with AI and quantitative methods.
  246. Args:
  247. results: List of simulation result dicts
  248. targets: Target values for key metrics
  249. fidelity: Simulation fidelity level
  250. scan_parameters: List of scanned parameter names
  251. Returns:
  252. Comprehensive analysis report
  253. """
  254. if not results:
  255. return {"error": "No results to analyze"}
  256. # Quantitative analysis
  257. quantitative = self._quantitative_analysis(results, targets, fidelity, scan_parameters)
  258. # AI analysis
  259. ai_analysis = {}
  260. if self.ai_client.is_configured:
  261. ai_analysis = self._ai_analysis(results, targets, quantitative)
  262. # Combine
  263. return {
  264. "summary": ai_analysis.get("summary", quantitative.get("summary", "")),
  265. "quantitative": quantitative,
  266. "ai_analysis": ai_analysis,
  267. "confidence": quantitative.get("confidence", {}),
  268. "convergence": quantitative.get("convergence", {}),
  269. "calibration": quantitative.get("calibration", {}),
  270. "analyzed_at": datetime.now().isoformat(),
  271. "n_results": len(results),
  272. "fidelity": fidelity,
  273. }
  274. def _quantitative_analysis(
  275. self,
  276. results: List[Dict[str, Any]],
  277. targets: Optional[Dict[str, float]],
  278. fidelity: str,
  279. scan_parameters: Optional[List[str]],
  280. ) -> Dict[str, Any]:
  281. """Perform quantitative analysis without AI."""
  282. # Extract key metrics
  283. metrics_list = []
  284. for r in results:
  285. metric = {}
  286. for key in ["tavg_nm", "efficiency_pct", "total_losses_w", "winding_temp_c", "magnet_temp_c"]:
  287. if key in r:
  288. metric[key] = float(r[key])
  289. if metric:
  290. metrics_list.append(metric)
  291. # Calculate statistics
  292. stats = {}
  293. for key in set().union(*(m.keys() for m in metrics_list)) if metrics_list else []:
  294. values = [m[key] for m in metrics_list if key in m]
  295. if values:
  296. stats[key] = {
  297. "min": round(min(values), 4),
  298. "max": round(max(values), 4),
  299. "mean": round(sum(values) / len(values), 4),
  300. "std": round(math.sqrt(sum((v - sum(values)/len(values))**2 for v in values) / len(values)), 4) if len(values) > 1 else 0,
  301. "n": len(values),
  302. }
  303. # Target comparison (C1 fix: use explicit direction map, not substring match)
  304. target_comparison = {}
  305. if targets:
  306. for metric, target in targets.items():
  307. if metric in stats:
  308. direction = METRIC_DIRECTIONS.get(metric, "higher")
  309. if direction == "lower":
  310. actual = stats[metric]["min"]
  311. passed = actual <= target
  312. else:
  313. actual = stats[metric]["max"]
  314. passed = actual >= target
  315. target_comparison[metric] = {
  316. "target": target,
  317. "best_achieved": actual,
  318. "margin": round(actual - target, 4),
  319. "status": "pass" if passed else "fail",
  320. "direction": direction,
  321. }
  322. # Calibration
  323. calibration = {}
  324. for metric in ["tavg_nm", "efficiency_pct", "total_losses_w"]:
  325. if metric in stats:
  326. calibration[metric] = self.calibrator.calibrate(metric, stats[metric]["mean"], fidelity)
  327. # Convergence checks
  328. convergence = {}
  329. objective_values = [m.get("tavg_nm", 0) for m in metrics_list if "tavg_nm" in m]
  330. convergence["objective_stability"] = self.convergence.check_objective_stability(objective_values)
  331. feasible_count = sum(1 for r in results if r.get("feasible", True))
  332. convergence["constraint_satisfaction"] = self.convergence.check_constraint_satisfaction(
  333. feasible_count, len(results)
  334. )
  335. n_dims = len(scan_parameters) if scan_parameters else 1
  336. convergence["sample_density"] = self.convergence.check_sample_density(len(results), n_dims)
  337. # Physical consistency
  338. physical_bounds = {
  339. "efficiency_pct": (0, 100),
  340. "tavg_nm": (0, 1000),
  341. "winding_temp_c": (-50, 300),
  342. }
  343. # C1 fix: use METRIC_DIRECTIONS for best-point selection
  344. best_metrics = {}
  345. for key in stats:
  346. direction = METRIC_DIRECTIONS.get(key, "higher")
  347. if direction == "lower":
  348. best_metrics[key] = stats[key]["min"]
  349. else:
  350. best_metrics[key] = stats[key]["max"]
  351. convergence["physical_consistency"] = self.convergence.check_physical_consistency(best_metrics, physical_bounds)
  352. # C2 fix: criterion 2 - optimum location stability
  353. # Extract best point's params from each sliding window of results
  354. optimum_locations = []
  355. window_size = min(5, len(metrics_list))
  356. for i in range(0, len(metrics_list) - window_size + 1):
  357. window = metrics_list[i:i + window_size]
  358. # Find best point in window by tavg_nm (or first available metric)
  359. best_in_window = max(window, key=lambda m: m.get("tavg_nm", m.get("efficiency_pct", 0)))
  360. # Use params if available, otherwise use metrics as location proxy
  361. loc = {k: v for k, v in best_in_window.items() if isinstance(v, (int, float))}
  362. optimum_locations.append(loc)
  363. convergence["optimum_stability"] = self.convergence.check_optimum_stability(optimum_locations)
  364. # C2 fix: criterion 3 - surrogate error (not applicable without surrogate predictions)
  365. # Mark as not_applicable rather than silently skipping
  366. convergence["surrogate_error"] = {
  367. "passed": True,
  368. "reason": "not_applicable",
  369. "value": None,
  370. "note": "No surrogate model predictions available; criterion skipped.",
  371. }
  372. # Count passed criteria (only count applicable criteria)
  373. applicable = {k: v for k, v in convergence.items()
  374. if isinstance(v, dict) and v.get("reason") != "not_applicable"}
  375. passed = sum(1 for v in applicable.values() if v.get("passed", False))
  376. total = len(applicable)
  377. convergence["summary"] = {
  378. "passed": passed,
  379. "total": total,
  380. "status": "converged" if passed >= total - 1 else "insufficient",
  381. }
  382. # Confidence grade
  383. anomalies = len(convergence["physical_consistency"].get("violations", []))
  384. confidence = self.grader.grade(
  385. fidelity=fidelity,
  386. n_samples=len(results),
  387. n_dimensions=n_dims,
  388. convergence_passed=passed,
  389. anomalies=anomalies,
  390. )
  391. # Summary
  392. best_torque = stats.get("tavg_nm", {}).get("max", "N/A")
  393. best_efficiency = stats.get("efficiency_pct", {}).get("max", "N/A")
  394. summary = f"Analyzed {len(results)} points. Best torque={best_torque}Nm, best efficiency={best_efficiency}%. Confidence: {confidence['grade']}."
  395. return {
  396. "summary": summary,
  397. "statistics": stats,
  398. "target_comparison": target_comparison,
  399. "calibration": calibration,
  400. "convergence": convergence,
  401. "confidence": confidence,
  402. }
  403. def _ai_analysis(
  404. self,
  405. results: List[Dict[str, Any]],
  406. targets: Optional[Dict[str, float]],
  407. quantitative: Dict[str, Any],
  408. ) -> Dict[str, Any]:
  409. """Call AI for natural language analysis."""
  410. # Prepare condensed data for AI
  411. condensed = {
  412. "n_points": len(results),
  413. "statistics": quantitative.get("statistics", {}),
  414. "targets": targets or {},
  415. "best_results": [],
  416. }
  417. # Include best 5 results
  418. sorted_results = sorted(
  419. results,
  420. key=lambda r: r.get("tavg_nm", 0),
  421. reverse=True,
  422. )[:5]
  423. condensed["best_results"] = sorted_results
  424. user_message = f"\u5206\u6790\u4ee5\u4e0b\u8f74\u5411\u78c1\u901a\u7535\u673a\u4eff\u771f\u7ed3\u679c\uff1a\n{json.dumps(condensed, ensure_ascii=False, indent=2)}"
  425. try:
  426. result = self.ai_client.chat_json(
  427. messages=[{"role": "user", "content": user_message}],
  428. system_prompt=self._load_prompt(),
  429. max_tokens=3000,
  430. )
  431. parsed = result.get("parsed_json", {})
  432. if not parsed:
  433. raw = result.get("raw_content", result.get("content", ""))
  434. # Try to extract JSON
  435. try:
  436. start = raw.find("{")
  437. end = raw.rfind("}") + 1
  438. if start >= 0 and end > start:
  439. parsed = json.loads(raw[start:end])
  440. except (json.JSONDecodeError, Exception):
  441. parsed = {"summary": raw[:500]}
  442. return parsed
  443. except Exception as e:
  444. return {"error": f"AI analysis failed: {str(e)}", "summary": quantitative.get("summary", "")}
  445. # Global singletons
  446. _analyst: Optional[AIResultAnalyst] = None
  447. def get_result_analyst() -> AIResultAnalyst:
  448. """Get or create global AIResultAnalyst singleton."""
  449. global _analyst
  450. if _analyst is None:
  451. _analyst = AIResultAnalyst()
  452. return _analyst