"""Analytics service for experience library and simulation results. Provides: - Experience library statistics (count, topology distribution, metric ranges) - Similar case retrieval (parameter distance matching) - Result trend analysis (parameter vs metric scatter/line data) - Pareto frontier (efficiency vs torque, losses vs torque) - Parameter sensitivity ranking (correlation-based) All source is ASCII. """ from __future__ import annotations import math import os import sys from typing import Any # --------------------------------------------------------------------------- # Metric definitions (for display and analysis) # --------------------------------------------------------------------------- # Single source of truth: src/afmcore/metrics.py METRIC_DEFINITIONS. This # module must NOT keep its own copy (historical drift: an 11-metric # electromagnetic-only list silently dropped thermal + structural metrics). # Make src/afmcore importable, then derive the display dict below. _ANALYTICS_DIR = os.path.dirname( os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ) # web/backend/app/services/analytics.py -> /src _SRC_DIR = os.path.normpath(os.path.join(_ANALYTICS_DIR, "..", "..", "src")) if _SRC_DIR not in sys.path and os.path.isdir(_SRC_DIR): sys.path.insert(0, _SRC_DIR) from afmcore.metrics import METRIC_DEFINITIONS # noqa: E402 # direction "neutral" maps to True to preserve the historical pareto behavior # (neutral metrics are not optimization targets but were never excluded here). _DIRECTION_TO_BETTER = {"higher": True, "lower": False, "neutral": True} METRIC_DEFS = { m["key"]: { "label": m["label"], "unit": m["unit"], "higher_is_better": _DIRECTION_TO_BETTER.get(m.get("direction"), True), } for m in METRIC_DEFINITIONS } def get_metric_defs() -> list[dict[str, Any]]: """Return metric definitions for frontend display.""" return [ {"key": k, "label": v["label"], "unit": v["unit"], "higher_is_better": v["higher_is_better"]} for k, v in METRIC_DEFS.items() ] # --------------------------------------------------------------------------- # Experience library statistics # --------------------------------------------------------------------------- def compute_experience_stats(cases: list[dict[str, Any]]) -> dict[str, Any]: """Compute aggregate statistics from experience cases. Args: cases: List of experience case dicts (from _case_to_dict). Returns: Stats dict with total, topology distribution, metric ranges, param coverage. """ total = len(cases) if total == 0: return { "total": 0, "topology_distribution": {}, "metric_ranges": {}, "param_coverage": {}, "avg_rating": 0, } # Topology distribution topology_dist: dict[str, int] = {} for c in cases: topo = c.get("topology", "Unknown") topology_dist[topo] = topology_dist.get(topo, 0) + 1 # Metric ranges metric_ranges: dict[str, dict[str, float]] = {} for c in cases: metrics = c.get("metrics", {}) or {} for key, val in metrics.items(): if not isinstance(val, (int, float)) or math.isnan(val): continue if key not in metric_ranges: metric_ranges[key] = {"min": val, "max": val, "sum": 0.0, "count": 0} r = metric_ranges[key] r["min"] = min(r["min"], val) r["max"] = max(r["max"], val) r["sum"] += val r["count"] += 1 # Compute averages for key, r in metric_ranges.items(): r["avg"] = round(r["sum"] / r["count"], 4) if r["count"] > 0 else 0 del r["sum"] # Parameter coverage (which params appear in how many cases) param_coverage: dict[str, int] = {} for c in cases: params = c.get("params", {}) or {} for key in params: param_coverage[key] = param_coverage.get(key, 0) + 1 # Average rating ratings = [c.get("rating", 0) for c in cases if c.get("rating", 0) > 0] avg_rating = round(sum(ratings) / len(ratings), 2) if ratings else 0 return { "total": total, "topology_distribution": topology_dist, "metric_ranges": metric_ranges, "param_coverage": param_coverage, "avg_rating": avg_rating, } # --------------------------------------------------------------------------- # Similar case retrieval # --------------------------------------------------------------------------- def find_similar_cases( target_params: dict[str, float], cases: list[dict[str, Any]], top_k: int = 5, tolerance: float = 0.3, ) -> list[dict[str, Any]]: """Find experience cases similar to target parameters. Uses normalized Euclidean distance on shared parameters. Only cases sharing at least one parameter with the target are considered. Args: target_params: Dict of parameter_name -> value. cases: List of experience case dicts. top_k: Number of top matches to return. tolerance: Maximum normalized distance to consider (0.0-1.0). Returns: List of similar cases with added 'similarity_score' field (0-1, higher=more similar). """ if not target_params or not cases: return [] # Compute value ranges for normalization all_params: dict[str, list[float]] = {} for c in cases: params = c.get("params", {}) or {} for key, val in params.items(): if isinstance(val, (int, float)): all_params.setdefault(key, []).append(float(val)) param_ranges: dict[str, float] = {} for key, vals in all_params.items(): rng = max(vals) - min(vals) if len(vals) > 1 else 1.0 param_ranges[key] = rng if rng > 0 else 1.0 scored = [] for c in cases: params = c.get("params", {}) or {} shared = [k for k in target_params if k in params] if not shared: continue # Normalized Euclidean distance dist_sq = 0.0 for key in shared: tv = float(target_params[key]) cv = float(params[key]) rng = param_ranges.get(key, 1.0) dist_sq += ((tv - cv) / rng) ** 2 dist = math.sqrt(dist_sq / len(shared)) if dist > tolerance: continue similarity = max(0.0, 1.0 - dist) result = dict(c) result["similarity_score"] = round(similarity, 4) result["shared_params"] = shared scored.append(result) scored.sort(key=lambda x: x["similarity_score"], reverse=True) return scored[:top_k] # --------------------------------------------------------------------------- # Result trend analysis # --------------------------------------------------------------------------- def compute_trend_data( results: list[dict[str, Any]], param_key: str, metric_key: str, ) -> dict[str, Any]: """Compute trend data for a parameter vs metric scatter plot. Args: results: List of simulation result dicts (with params and metrics). param_key: Parameter name for X axis. metric_key: Metric name for Y axis. Returns: Dict with x_key, y_key, points (list of [x, y] sorted by x), and stats. """ points = [] for r in results: if r.get("status") != "OK": continue params = r.get("params", {}) or {} metrics = r.get("metrics", {}) or {} if param_key in params and metric_key in metrics: try: x = float(params[param_key]) y = float(metrics[metric_key]) if not math.isnan(x) and not math.isnan(y): points.append([x, y]) except (ValueError, TypeError): continue points.sort(key=lambda p: p[0]) # Basic stats if points: ys = [p[1] for p in points] stats = { "count": len(points), "min": round(min(ys), 4), "max": round(max(ys), 4), "avg": round(sum(ys) / len(ys), 4), } else: stats = {"count": 0, "min": 0, "max": 0, "avg": 0} return { "x_key": param_key, "y_key": metric_key, "x_label": METRIC_DEFS.get(param_key, {}).get("label", param_key), "y_label": METRIC_DEFS.get(metric_key, {}).get("label", metric_key), "y_unit": METRIC_DEFS.get(metric_key, {}).get("unit", ""), "points": points, "stats": stats, } # --------------------------------------------------------------------------- # Pareto frontier # --------------------------------------------------------------------------- def compute_pareto_frontier( results: list[dict[str, Any]], x_metric: str = "total_losses_w", y_metric: str = "efficiency_pct", ) -> dict[str, Any]: """Compute Pareto frontier for two metrics. A point is Pareto-optimal if no other point is better in both metrics (considering higher_is_better for each). Args: results: List of simulation result dicts. x_metric: Metric for X axis (typically losses, lower is better). y_metric: Metric for Y axis (typically efficiency, higher is better). Returns: Dict with all_points, pareto_points, and labels. """ x_higher_better = METRIC_DEFS.get(x_metric, {}).get("higher_is_better", True) y_higher_better = METRIC_DEFS.get(y_metric, {}).get("higher_is_better", True) all_points = [] for r in results: if r.get("status") != "OK": continue metrics = r.get("metrics", {}) or {} if x_metric in metrics and y_metric in metrics: try: x = float(metrics[x_metric]) y = float(metrics[y_metric]) if not math.isnan(x) and not math.isnan(y): all_points.append({"x": x, "y": y, "params": r.get("params", {})}) except (ValueError, TypeError): continue # Find Pareto-optimal points pareto = [] for i, p in enumerate(all_points): dominated = False for j, q in enumerate(all_points): if i == j: continue # q dominates p if q is better or equal in both, and strictly better in at least one x_better = (q["x"] >= p["x"]) if x_higher_better else (q["x"] <= p["x"]) y_better = (q["y"] >= p["y"]) if y_higher_better else (q["y"] <= p["y"]) x_strict = (q["x"] > p["x"]) if x_higher_better else (q["x"] < p["x"]) y_strict = (q["y"] > p["y"]) if y_higher_better else (q["y"] < p["y"]) if x_better and y_better and (x_strict or y_strict): dominated = True break if not dominated: pareto.append(p) # Sort pareto by x pareto.sort(key=lambda p: p["x"]) return { "x_metric": x_metric, "y_metric": y_metric, "x_label": METRIC_DEFS.get(x_metric, {}).get("label", x_metric), "y_label": METRIC_DEFS.get(y_metric, {}).get("label", y_metric), "x_unit": METRIC_DEFS.get(x_metric, {}).get("unit", ""), "y_unit": METRIC_DEFS.get(y_metric, {}).get("unit", ""), "all_points": [{"x": p["x"], "y": p["y"]} for p in all_points], "pareto_points": [{"x": p["x"], "y": p["y"], "params": p["params"]} for p in pareto], "total_count": len(all_points), "pareto_count": len(pareto), } # --------------------------------------------------------------------------- # Parameter sensitivity ranking # --------------------------------------------------------------------------- def compute_sensitivity( results: list[dict[str, Any]], metric_key: str, ) -> list[dict[str, Any]]: """Compute parameter sensitivity ranking for a target metric. Uses Pearson correlation coefficient between each parameter and the metric. Absolute value indicates sensitivity strength; sign indicates direction. Args: results: List of simulation result dicts. metric_key: Target metric key. Returns: List of {param, correlation, abs_correlation, direction} sorted by abs correlation. """ # Collect parameter values and metric values param_values: dict[str, list[float]] = {} metric_values: list[float] = [] for r in results: if r.get("status") != "OK": continue metrics = r.get("metrics", {}) or {} if metric_key not in metrics: continue try: mv = float(metrics[metric_key]) except (ValueError, TypeError): continue if math.isnan(mv): continue params = r.get("params", {}) or {} for key, val in params.items(): try: pv = float(val) if not math.isnan(pv): param_values.setdefault(key, []).append(pv) except (ValueError, TypeError): continue metric_values.append(mv) n = len(metric_values) if n < 3: return [] sensitivities = [] for param, pvals in param_values.items(): if len(pvals) != n: continue # Must have same number of samples # Pearson correlation mean_p = sum(pvals) / n mean_m = sum(metric_values) / n cov = sum((pvals[i] - mean_p) * (metric_values[i] - mean_m) for i in range(n)) var_p = sum((p - mean_p) ** 2 for p in pvals) var_m = sum((m - mean_m) ** 2 for m in metric_values) if var_p == 0 or var_m == 0: corr = 0.0 else: corr = cov / math.sqrt(var_p * var_m) direction = "positive" if corr >= 0 else "negative" sensitivities.append({ "param": param, "correlation": round(corr, 4), "abs_correlation": round(abs(corr), 4), "direction": direction, "sample_count": n, }) sensitivities.sort(key=lambda x: x["abs_correlation"], reverse=True) return sensitivities