result_analyst.py 19 KB

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