Spaces:
Sleeping
Sleeping
| """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") | |