| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410 |
- """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 -> <repo>/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
|