analytics.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  1. """Analytics service for experience library and simulation results.
  2. Provides:
  3. - Experience library statistics (count, topology distribution, metric ranges)
  4. - Similar case retrieval (parameter distance matching)
  5. - Result trend analysis (parameter vs metric scatter/line data)
  6. - Pareto frontier (efficiency vs torque, losses vs torque)
  7. - Parameter sensitivity ranking (correlation-based)
  8. All source is ASCII.
  9. """
  10. from __future__ import annotations
  11. import math
  12. from typing import Any
  13. # ---------------------------------------------------------------------------
  14. # Metric definitions (for display and analysis)
  15. # ---------------------------------------------------------------------------
  16. METRIC_DEFS = {
  17. "tavg_nm": {"label": "Average Torque", "unit": "Nm", "higher_is_better": True},
  18. "ripple_pct": {"label": "Torque Ripple", "unit": "%", "higher_is_better": False},
  19. "efficiency_pct": {"label": "Efficiency", "unit": "%", "higher_is_better": True},
  20. "total_losses_w": {"label": "Total Losses", "unit": "W", "higher_is_better": False},
  21. "copper_loss_w": {"label": "Copper Loss", "unit": "W", "higher_is_better": False},
  22. "iron_loss_w": {"label": "Iron Loss", "unit": "W", "higher_is_better": False},
  23. "magnet_loss_w": {"label": "Magnet Loss", "unit": "W", "higher_is_better": False},
  24. "back_emf_v": {"label": "Back EMF", "unit": "V", "higher_is_better": True},
  25. "output_power_w": {"label": "Output Power", "unit": "W", "higher_is_better": True},
  26. "input_power_w": {"label": "Input Power", "unit": "W", "higher_is_better": True},
  27. "no_load_speed_rpm": {"label": "No-Load Speed", "unit": "rpm", "higher_is_better": True},
  28. }
  29. def get_metric_defs() -> list[dict[str, Any]]:
  30. """Return metric definitions for frontend display."""
  31. return [
  32. {"key": k, "label": v["label"], "unit": v["unit"], "higher_is_better": v["higher_is_better"]}
  33. for k, v in METRIC_DEFS.items()
  34. ]
  35. # ---------------------------------------------------------------------------
  36. # Experience library statistics
  37. # ---------------------------------------------------------------------------
  38. def compute_experience_stats(cases: list[dict[str, Any]]) -> dict[str, Any]:
  39. """Compute aggregate statistics from experience cases.
  40. Args:
  41. cases: List of experience case dicts (from _case_to_dict).
  42. Returns:
  43. Stats dict with total, topology distribution, metric ranges, param coverage.
  44. """
  45. total = len(cases)
  46. if total == 0:
  47. return {
  48. "total": 0,
  49. "topology_distribution": {},
  50. "metric_ranges": {},
  51. "param_coverage": {},
  52. "avg_rating": 0,
  53. }
  54. # Topology distribution
  55. topology_dist: dict[str, int] = {}
  56. for c in cases:
  57. topo = c.get("topology", "Unknown")
  58. topology_dist[topo] = topology_dist.get(topo, 0) + 1
  59. # Metric ranges
  60. metric_ranges: dict[str, dict[str, float]] = {}
  61. for c in cases:
  62. metrics = c.get("metrics", {}) or {}
  63. for key, val in metrics.items():
  64. if not isinstance(val, (int, float)) or math.isnan(val):
  65. continue
  66. if key not in metric_ranges:
  67. metric_ranges[key] = {"min": val, "max": val, "sum": 0.0, "count": 0}
  68. r = metric_ranges[key]
  69. r["min"] = min(r["min"], val)
  70. r["max"] = max(r["max"], val)
  71. r["sum"] += val
  72. r["count"] += 1
  73. # Compute averages
  74. for key, r in metric_ranges.items():
  75. r["avg"] = round(r["sum"] / r["count"], 4) if r["count"] > 0 else 0
  76. del r["sum"]
  77. # Parameter coverage (which params appear in how many cases)
  78. param_coverage: dict[str, int] = {}
  79. for c in cases:
  80. params = c.get("params", {}) or {}
  81. for key in params:
  82. param_coverage[key] = param_coverage.get(key, 0) + 1
  83. # Average rating
  84. ratings = [c.get("rating", 0) for c in cases if c.get("rating", 0) > 0]
  85. avg_rating = round(sum(ratings) / len(ratings), 2) if ratings else 0
  86. return {
  87. "total": total,
  88. "topology_distribution": topology_dist,
  89. "metric_ranges": metric_ranges,
  90. "param_coverage": param_coverage,
  91. "avg_rating": avg_rating,
  92. }
  93. # ---------------------------------------------------------------------------
  94. # Similar case retrieval
  95. # ---------------------------------------------------------------------------
  96. def find_similar_cases(
  97. target_params: dict[str, float],
  98. cases: list[dict[str, Any]],
  99. top_k: int = 5,
  100. tolerance: float = 0.3,
  101. ) -> list[dict[str, Any]]:
  102. """Find experience cases similar to target parameters.
  103. Uses normalized Euclidean distance on shared parameters.
  104. Only cases sharing at least one parameter with the target are considered.
  105. Args:
  106. target_params: Dict of parameter_name -> value.
  107. cases: List of experience case dicts.
  108. top_k: Number of top matches to return.
  109. tolerance: Maximum normalized distance to consider (0.0-1.0).
  110. Returns:
  111. List of similar cases with added 'similarity_score' field (0-1, higher=more similar).
  112. """
  113. if not target_params or not cases:
  114. return []
  115. # Compute value ranges for normalization
  116. all_params: dict[str, list[float]] = {}
  117. for c in cases:
  118. params = c.get("params", {}) or {}
  119. for key, val in params.items():
  120. if isinstance(val, (int, float)):
  121. all_params.setdefault(key, []).append(float(val))
  122. param_ranges: dict[str, float] = {}
  123. for key, vals in all_params.items():
  124. rng = max(vals) - min(vals) if len(vals) > 1 else 1.0
  125. param_ranges[key] = rng if rng > 0 else 1.0
  126. scored = []
  127. for c in cases:
  128. params = c.get("params", {}) or {}
  129. shared = [k for k in target_params if k in params]
  130. if not shared:
  131. continue
  132. # Normalized Euclidean distance
  133. dist_sq = 0.0
  134. for key in shared:
  135. tv = float(target_params[key])
  136. cv = float(params[key])
  137. rng = param_ranges.get(key, 1.0)
  138. dist_sq += ((tv - cv) / rng) ** 2
  139. dist = math.sqrt(dist_sq / len(shared))
  140. if dist > tolerance:
  141. continue
  142. similarity = max(0.0, 1.0 - dist)
  143. result = dict(c)
  144. result["similarity_score"] = round(similarity, 4)
  145. result["shared_params"] = shared
  146. scored.append(result)
  147. scored.sort(key=lambda x: x["similarity_score"], reverse=True)
  148. return scored[:top_k]
  149. # ---------------------------------------------------------------------------
  150. # Result trend analysis
  151. # ---------------------------------------------------------------------------
  152. def compute_trend_data(
  153. results: list[dict[str, Any]],
  154. param_key: str,
  155. metric_key: str,
  156. ) -> dict[str, Any]:
  157. """Compute trend data for a parameter vs metric scatter plot.
  158. Args:
  159. results: List of simulation result dicts (with params and metrics).
  160. param_key: Parameter name for X axis.
  161. metric_key: Metric name for Y axis.
  162. Returns:
  163. Dict with x_key, y_key, points (list of [x, y] sorted by x), and stats.
  164. """
  165. points = []
  166. for r in results:
  167. if r.get("status") != "OK":
  168. continue
  169. params = r.get("params", {}) or {}
  170. metrics = r.get("metrics", {}) or {}
  171. if param_key in params and metric_key in metrics:
  172. try:
  173. x = float(params[param_key])
  174. y = float(metrics[metric_key])
  175. if not math.isnan(x) and not math.isnan(y):
  176. points.append([x, y])
  177. except (ValueError, TypeError):
  178. continue
  179. points.sort(key=lambda p: p[0])
  180. # Basic stats
  181. if points:
  182. ys = [p[1] for p in points]
  183. stats = {
  184. "count": len(points),
  185. "min": round(min(ys), 4),
  186. "max": round(max(ys), 4),
  187. "avg": round(sum(ys) / len(ys), 4),
  188. }
  189. else:
  190. stats = {"count": 0, "min": 0, "max": 0, "avg": 0}
  191. return {
  192. "x_key": param_key,
  193. "y_key": metric_key,
  194. "x_label": METRIC_DEFS.get(param_key, {}).get("label", param_key),
  195. "y_label": METRIC_DEFS.get(metric_key, {}).get("label", metric_key),
  196. "y_unit": METRIC_DEFS.get(metric_key, {}).get("unit", ""),
  197. "points": points,
  198. "stats": stats,
  199. }
  200. # ---------------------------------------------------------------------------
  201. # Pareto frontier
  202. # ---------------------------------------------------------------------------
  203. def compute_pareto_frontier(
  204. results: list[dict[str, Any]],
  205. x_metric: str = "total_losses_w",
  206. y_metric: str = "efficiency_pct",
  207. ) -> dict[str, Any]:
  208. """Compute Pareto frontier for two metrics.
  209. A point is Pareto-optimal if no other point is better in both metrics
  210. (considering higher_is_better for each).
  211. Args:
  212. results: List of simulation result dicts.
  213. x_metric: Metric for X axis (typically losses, lower is better).
  214. y_metric: Metric for Y axis (typically efficiency, higher is better).
  215. Returns:
  216. Dict with all_points, pareto_points, and labels.
  217. """
  218. x_higher_better = METRIC_DEFS.get(x_metric, {}).get("higher_is_better", True)
  219. y_higher_better = METRIC_DEFS.get(y_metric, {}).get("higher_is_better", True)
  220. all_points = []
  221. for r in results:
  222. if r.get("status") != "OK":
  223. continue
  224. metrics = r.get("metrics", {}) or {}
  225. if x_metric in metrics and y_metric in metrics:
  226. try:
  227. x = float(metrics[x_metric])
  228. y = float(metrics[y_metric])
  229. if not math.isnan(x) and not math.isnan(y):
  230. all_points.append({"x": x, "y": y, "params": r.get("params", {})})
  231. except (ValueError, TypeError):
  232. continue
  233. # Find Pareto-optimal points
  234. pareto = []
  235. for i, p in enumerate(all_points):
  236. dominated = False
  237. for j, q in enumerate(all_points):
  238. if i == j:
  239. continue
  240. # q dominates p if q is better or equal in both, and strictly better in at least one
  241. x_better = (q["x"] >= p["x"]) if x_higher_better else (q["x"] <= p["x"])
  242. y_better = (q["y"] >= p["y"]) if y_higher_better else (q["y"] <= p["y"])
  243. x_strict = (q["x"] > p["x"]) if x_higher_better else (q["x"] < p["x"])
  244. y_strict = (q["y"] > p["y"]) if y_higher_better else (q["y"] < p["y"])
  245. if x_better and y_better and (x_strict or y_strict):
  246. dominated = True
  247. break
  248. if not dominated:
  249. pareto.append(p)
  250. # Sort pareto by x
  251. pareto.sort(key=lambda p: p["x"])
  252. return {
  253. "x_metric": x_metric,
  254. "y_metric": y_metric,
  255. "x_label": METRIC_DEFS.get(x_metric, {}).get("label", x_metric),
  256. "y_label": METRIC_DEFS.get(y_metric, {}).get("label", y_metric),
  257. "x_unit": METRIC_DEFS.get(x_metric, {}).get("unit", ""),
  258. "y_unit": METRIC_DEFS.get(y_metric, {}).get("unit", ""),
  259. "all_points": [{"x": p["x"], "y": p["y"]} for p in all_points],
  260. "pareto_points": [{"x": p["x"], "y": p["y"], "params": p["params"]} for p in pareto],
  261. "total_count": len(all_points),
  262. "pareto_count": len(pareto),
  263. }
  264. # ---------------------------------------------------------------------------
  265. # Parameter sensitivity ranking
  266. # ---------------------------------------------------------------------------
  267. def compute_sensitivity(
  268. results: list[dict[str, Any]],
  269. metric_key: str,
  270. ) -> list[dict[str, Any]]:
  271. """Compute parameter sensitivity ranking for a target metric.
  272. Uses Pearson correlation coefficient between each parameter and the metric.
  273. Absolute value indicates sensitivity strength; sign indicates direction.
  274. Args:
  275. results: List of simulation result dicts.
  276. metric_key: Target metric key.
  277. Returns:
  278. List of {param, correlation, abs_correlation, direction} sorted by abs correlation.
  279. """
  280. # Collect parameter values and metric values
  281. param_values: dict[str, list[float]] = {}
  282. metric_values: list[float] = []
  283. for r in results:
  284. if r.get("status") != "OK":
  285. continue
  286. metrics = r.get("metrics", {}) or {}
  287. if metric_key not in metrics:
  288. continue
  289. try:
  290. mv = float(metrics[metric_key])
  291. except (ValueError, TypeError):
  292. continue
  293. if math.isnan(mv):
  294. continue
  295. params = r.get("params", {}) or {}
  296. for key, val in params.items():
  297. try:
  298. pv = float(val)
  299. if not math.isnan(pv):
  300. param_values.setdefault(key, []).append(pv)
  301. except (ValueError, TypeError):
  302. continue
  303. metric_values.append(mv)
  304. n = len(metric_values)
  305. if n < 3:
  306. return []
  307. sensitivities = []
  308. for param, pvals in param_values.items():
  309. if len(pvals) != n:
  310. continue # Must have same number of samples
  311. # Pearson correlation
  312. mean_p = sum(pvals) / n
  313. mean_m = sum(metric_values) / n
  314. cov = sum((pvals[i] - mean_p) * (metric_values[i] - mean_m) for i in range(n))
  315. var_p = sum((p - mean_p) ** 2 for p in pvals)
  316. var_m = sum((m - mean_m) ** 2 for m in metric_values)
  317. if var_p == 0 or var_m == 0:
  318. corr = 0.0
  319. else:
  320. corr = cov / math.sqrt(var_p * var_m)
  321. direction = "positive" if corr >= 0 else "negative"
  322. sensitivities.append({
  323. "param": param,
  324. "correlation": round(corr, 4),
  325. "abs_correlation": round(abs(corr), 4),
  326. "direction": direction,
  327. "sample_count": n,
  328. })
  329. sensitivities.sort(key=lambda x: x["abs_correlation"], reverse=True)
  330. return sensitivities