ode-pump-dashboard / sweep_analysis.py
pandeydigant31's picture
Upload folder using huggingface_hub
ed65aea verified
Raw
History Blame Contribute Delete
15.9 kB
"""Deterministic sweep analysis for cryogenic pump simulator results.
Provides sensitivity ranking, Pareto frontier computation, and text-based
insights for 1D and 2D parameter sweeps. No LLM needed -- pure numerical
analysis using finite differences, dominance checks, and feasibility filters.
Designed to consume sweep result dicts produced by SweepAnalyzer in
scenario_analyzer.py.
"""
import numpy as np
from typing import Dict, List, Optional
# ---------------------------------------------------------------------------
# 1. Sensitivity ranking
# ---------------------------------------------------------------------------
def sensitivity_ranking(
sweep_results: List[dict],
param_key: str = 'param_value',
metric: str = 'mdot_kgpm',
) -> Dict:
"""Compute finite-difference sensitivity of *metric* w.r.t. *param_key*.
Parameters
----------
sweep_results : list of dict
Each dict must contain at least *param_key* and *metric* keys.
Only rows where ``success`` is truthy (if present) and *metric*
is finite are used.
param_key : str
Name of the independent variable field (default ``'param_value'``).
metric : str
Name of the dependent variable field (default ``'mdot_kgpm'``).
Returns
-------
dict with keys:
mean_gradient – average |dy/dx| over all consecutive pairs
max_gradient – maximum |dy/dx|
direction – 'increasing', 'decreasing', or 'non-monotonic'
elasticity – median (dy/y) / (dx/x), or NaN if undefined
n_points – number of valid points used
"""
# Filter to successful, finite-metric rows
filtered = []
for r in sweep_results:
if not r.get('success', True):
continue
val = r.get(metric)
if val is None or (isinstance(val, float) and np.isnan(val)):
continue
pv = r.get(param_key)
if pv is None or (isinstance(pv, float) and np.isnan(pv)):
continue
filtered.append(r)
n = len(filtered)
if n < 2:
return {
'mean_gradient': float('nan'),
'max_gradient': float('nan'),
'direction': 'insufficient_data',
'elasticity': float('nan'),
'n_points': n,
}
# Sort by param value
filtered.sort(key=lambda r: r[param_key])
xs = np.array([r[param_key] for r in filtered], dtype=np.float64)
ys = np.array([r[metric] for r in filtered], dtype=np.float64)
dx = np.diff(xs)
dy = np.diff(ys)
# Gradients (skip zero-dx pairs)
valid = np.abs(dx) > 0.0
if not np.any(valid):
return {
'mean_gradient': float('nan'),
'max_gradient': float('nan'),
'direction': 'constant_param',
'elasticity': float('nan'),
'n_points': n,
}
grads = dy[valid] / dx[valid]
mean_gradient = float(np.mean(np.abs(grads)))
max_gradient = float(np.max(np.abs(grads)))
# Direction
if np.all(grads >= 0):
direction = 'increasing'
elif np.all(grads <= 0):
direction = 'decreasing'
else:
direction = 'non-monotonic'
# Elasticity: (dy/y_mid) / (dx/x_mid) for each consecutive pair
x_mid = 0.5 * (xs[:-1] + xs[1:])
y_mid = 0.5 * (ys[:-1] + ys[1:])
# Only compute where both midpoints are nonzero and dx is nonzero
e_valid = valid & (np.abs(x_mid) > 0.0) & (np.abs(y_mid) > 0.0)
if np.any(e_valid):
rel_dy = dy[e_valid] / y_mid[e_valid]
rel_dx = dx[e_valid] / x_mid[e_valid]
# Guard against zero rel_dx (shouldn't happen given valid filter, but be safe)
e_mask = np.abs(rel_dx) > 0.0
if np.any(e_mask):
elasticities = rel_dy[e_mask] / rel_dx[e_mask]
elasticity = float(np.median(elasticities))
else:
elasticity = float('nan')
else:
elasticity = float('nan')
return {
'mean_gradient': mean_gradient,
'max_gradient': max_gradient,
'direction': direction,
'elasticity': elasticity,
'n_points': n,
}
# ---------------------------------------------------------------------------
# 2. Pareto frontier
# ---------------------------------------------------------------------------
def pareto_frontier(
results: List[dict],
maximize: Optional[List[str]] = None,
minimize: Optional[List[str]] = None,
) -> List[dict]:
"""Find Pareto-optimal (non-dominated) points from sweep results.
Parameters
----------
results : list of dict
Each dict should contain the objective keys. Only rows with finite
values for all objectives are considered.
maximize : list of str or None
Objective keys to maximize. Defaults to ``['mdot_kgpm']``.
minimize : list of str or None
Objective keys to minimize. Defaults to ``[]``.
Returns
-------
list of dict
Subset of *results* that are Pareto-optimal. Order is preserved.
Notes
-----
A point *a* dominates *b* iff *a* is at least as good in ALL objectives
and strictly better in at least one. O(n^2) pairwise comparison -- fine
for typical sweep sizes (10-100 points).
"""
if maximize is None:
maximize = ['mdot_kgpm']
if minimize is None:
minimize = []
all_keys = list(maximize) + list(minimize)
if not all_keys:
return list(results)
# Filter to rows with finite values for all objectives
candidates = []
for r in results:
ok = True
for k in all_keys:
v = r.get(k)
if v is None or (isinstance(v, float) and np.isnan(v)):
ok = False
break
if ok:
candidates.append(r)
if not candidates:
return []
def _dominates(a: dict, b: dict) -> bool:
"""Return True if *a* dominates *b*."""
at_least_as_good = True
strictly_better = False
for k in maximize:
av, bv = a[k], b[k]
if av < bv:
at_least_as_good = False
return False
if av > bv:
strictly_better = True
for k in minimize:
av, bv = a[k], b[k]
if av > bv:
at_least_as_good = False
return False
if av < bv:
strictly_better = True
return at_least_as_good and strictly_better
frontier = []
for i, candidate in enumerate(candidates):
dominated = False
for j, other in enumerate(candidates):
if i == j:
continue
if _dominates(other, candidate):
dominated = True
break
if not dominated:
frontier.append(candidate)
return frontier
# ---------------------------------------------------------------------------
# 3. 1D sweep insights
# ---------------------------------------------------------------------------
def sweep_insights(sweep_result: dict) -> str:
"""Generate a plain-text summary of a 1D parameter sweep.
Parameters
----------
sweep_result : dict
Expected keys:
param_name – str, e.g. 'ICVport_mm'
results – list of dicts, each with at least:
param_value, mdot_kgpm, mass_eff, pc_peak_barg, success
Returns
-------
str
Multi-line plain text report.
"""
param_name = sweep_result.get('param_name', 'unknown')
results = sweep_result.get('results', [])
if not results:
return f"Sweep of {param_name}: no results."
# Separate successful runs
ok = [r for r in results if r.get('success', False)
and not np.isnan(r.get('mdot_kgpm', float('nan')))]
lines = [f"=== Sweep: {param_name} ({len(results)} points, {len(ok)} successful) ==="]
if not ok:
lines.append("No successful runs -- all points failed or returned NaN.")
return '\n'.join(lines)
# Sensitivity
sens_mdot = sensitivity_ranking(ok, metric='mdot_kgpm')
sens_eff = sensitivity_ranking(ok, metric='mass_eff')
lines.append("")
lines.append(f"Sensitivity (mdot): direction={sens_mdot['direction']}, "
f"mean |grad|={sens_mdot['mean_gradient']:.4g}, "
f"elasticity={sens_mdot['elasticity']:.3g}")
lines.append(f"Sensitivity (eff): direction={sens_eff['direction']}, "
f"mean |grad|={sens_eff['mean_gradient']:.4g}, "
f"elasticity={sens_eff['elasticity']:.3g}")
# Best flow
best_flow = max(ok, key=lambda r: r['mdot_kgpm'])
lines.append("")
lines.append(f"Best flow: {best_flow['mdot_kgpm']:.4f} kg/min "
f"at {param_name}={best_flow['param_value']:.4g}")
# Best efficiency
best_eff = max(ok, key=lambda r: r['mass_eff'])
lines.append(f"Best efficiency: {best_eff['mass_eff']:.4f} "
f"at {param_name}={best_eff['param_value']:.4g}")
# Pareto frontier (maximize mdot and efficiency simultaneously)
pf = pareto_frontier(ok, maximize=['mdot_kgpm', 'mass_eff'])
lines.append(f"Pareto frontier: {len(pf)} non-dominated point(s)")
# MAWP constraint violations
mawp_violations = [r for r in ok if r.get('pc_peak_barg', 0) >= 960]
if mawp_violations:
pct = 100.0 * len(mawp_violations) / len(ok)
worst = max(mawp_violations, key=lambda r: r['pc_peak_barg'])
lines.append(f"MAWP violations: {len(mawp_violations)}/{len(ok)} points "
f"({pct:.0f}%) exceed 960 barg "
f"(worst: {worst['pc_peak_barg']:.1f} barg "
f"at {param_name}={worst['param_value']:.4g})")
else:
lines.append("MAWP violations: none (all points below 960 barg)")
return '\n'.join(lines)
# ---------------------------------------------------------------------------
# 4. 2D sweep insights
# ---------------------------------------------------------------------------
def sweep_2d_insights(sr: dict) -> str:
"""Generate a plain-text summary of a 2D parameter sweep.
Parameters
----------
sr : dict
Expected keys:
param_x – str, name of X-axis parameter
param_y – str, name of Y-axis parameter
vals_x – 1D array-like, X parameter values
vals_y – 1D array-like, Y parameter values
grid – dict of 2D numpy arrays keyed by metric name.
Required: 'mdot_kgpm', 'mass_eff', 'pc_peak_barg'.
Optional: 'Tc_peak_K'.
Shape: (len(vals_y), len(vals_x)).
Returns
-------
str
Multi-line plain text report.
"""
param_x = sr.get('param_x', 'x')
param_y = sr.get('param_y', 'y')
vals_x = np.asarray(sr.get('vals_x', []))
vals_y = np.asarray(sr.get('vals_y', []))
grid = sr.get('grid', {})
lines = [f"=== 2D Sweep: {param_x} x {param_y} "
f"({len(vals_x)} x {len(vals_y)} = {len(vals_x) * len(vals_y)} points) ==="]
mdot_grid = grid.get('mdot_kgpm')
if mdot_grid is None:
lines.append("No mdot_kgpm grid found -- cannot analyse.")
return '\n'.join(lines)
mdot_grid = np.asarray(mdot_grid, dtype=np.float64)
finite_mask = np.isfinite(mdot_grid)
n_finite = int(np.sum(finite_mask))
n_total = mdot_grid.size
lines.append(f"Valid points: {n_finite}/{n_total}")
if n_finite == 0:
lines.append("All points failed or returned NaN.")
return '\n'.join(lines)
# Global best mdot
best_idx = np.unravel_index(np.nanargmax(mdot_grid), mdot_grid.shape)
best_mdot = float(mdot_grid[best_idx])
best_y = float(vals_y[best_idx[0]]) if best_idx[0] < len(vals_y) else float('nan')
best_x = float(vals_x[best_idx[1]]) if best_idx[1] < len(vals_x) else float('nan')
lines.append("")
lines.append(f"Global best mdot: {best_mdot:.4f} kg/min "
f"at {param_x}={best_x:.4g}, {param_y}={best_y:.4g}")
# Efficiency at best mdot
eff_grid = grid.get('mass_eff')
if eff_grid is not None:
eff_grid = np.asarray(eff_grid, dtype=np.float64)
eff_at_best = float(eff_grid[best_idx])
lines.append(f"Efficiency at best mdot: {eff_at_best:.4f}")
# Feasible region (pc_peak_barg < 960)
pc_grid = grid.get('pc_peak_barg')
if pc_grid is not None:
pc_grid = np.asarray(pc_grid, dtype=np.float64)
feasible = finite_mask & np.isfinite(pc_grid) & (pc_grid < 960.0)
n_feasible = int(np.sum(feasible))
lines.append("")
lines.append(f"Feasible region (pc < 960 barg): "
f"{n_feasible}/{n_finite} valid points "
f"({100.0 * n_feasible / max(n_finite, 1):.0f}%)")
if n_feasible > 0:
# Best feasible mdot
mdot_feasible = np.where(feasible, mdot_grid, np.nan)
feas_idx = np.unravel_index(np.nanargmax(mdot_feasible), mdot_feasible.shape)
feas_mdot = float(mdot_feasible[feas_idx])
feas_x = float(vals_x[feas_idx[1]]) if feas_idx[1] < len(vals_x) else float('nan')
feas_y = float(vals_y[feas_idx[0]]) if feas_idx[0] < len(vals_y) else float('nan')
lines.append(f"Best feasible mdot: {feas_mdot:.4f} kg/min "
f"at {param_x}={feas_x:.4g}, {param_y}={feas_y:.4g}")
else:
lines.append("No feasible points -- all exceed MAWP constraint.")
else:
lines.append("(pc_peak_barg grid not provided -- skipping feasibility check)")
# Dominant axis: which parameter has more impact on mdot?
lines.append("")
_dominant_axis(lines, mdot_grid, vals_x, vals_y, param_x, param_y, finite_mask)
return '\n'.join(lines)
def _dominant_axis(
lines: list,
mdot_grid: np.ndarray,
vals_x: np.ndarray,
vals_y: np.ndarray,
param_x: str,
param_y: str,
finite_mask: np.ndarray,
) -> None:
"""Append dominant-axis analysis to *lines* (in-place).
Compares the mean absolute range of mdot along each axis to determine
which parameter has more influence on flow rate.
"""
ny, nx = mdot_grid.shape
# Range of mdot along X for each Y row (holding Y constant, varying X)
range_along_x = []
for j in range(ny):
row = mdot_grid[j, :]
row_valid = row[finite_mask[j, :]]
if len(row_valid) >= 2:
range_along_x.append(float(np.max(row_valid) - np.min(row_valid)))
# Range of mdot along Y for each X column (holding X constant, varying Y)
range_along_y = []
for i in range(nx):
col = mdot_grid[:, i]
col_valid = col[finite_mask[:, i]]
if len(col_valid) >= 2:
range_along_y.append(float(np.max(col_valid) - np.min(col_valid)))
mean_range_x = float(np.mean(range_along_x)) if range_along_x else 0.0
mean_range_y = float(np.mean(range_along_y)) if range_along_y else 0.0
lines.append(f"Mean mdot range varying {param_x} (hold {param_y}): "
f"{mean_range_x:.4f} kg/min")
lines.append(f"Mean mdot range varying {param_y} (hold {param_x}): "
f"{mean_range_y:.4f} kg/min")
if mean_range_x > 0 or mean_range_y > 0:
if mean_range_x > mean_range_y * 1.1:
lines.append(f"Dominant axis: {param_x} "
f"({mean_range_x / max(mean_range_y, 1e-12):.1f}x more impact)")
elif mean_range_y > mean_range_x * 1.1:
lines.append(f"Dominant axis: {param_y} "
f"({mean_range_y / max(mean_range_x, 1e-12):.1f}x more impact)")
else:
lines.append("Dominant axis: roughly equal sensitivity to both parameters")
else:
lines.append("Dominant axis: insufficient data to determine")