analytics.py 14 KB

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