Spaces:
Sleeping
Sleeping
| """Parameter sensitivity analysis via central finite differences.""" | |
| from dataclasses import dataclass, field | |
| from typing import List, Dict, Optional | |
| import numpy as np | |
| from cryosim import predict | |
| from cryosim.calibration.params import ( | |
| CALIBRATION_PARAMS, PARAM_NAMES, get_nominal_values, apply_overrides, | |
| ) | |
| from cryosim.hardware.config import load_config | |
| from cryosim.engine.fast import ICV_open | |
| METRICS = ["mdot_kgpm", "mass_eff", "Tc_peak_K", "kWh_extend"] | |
| _NEAR_ZERO_THRESHOLD = 0.01 | |
| class SensitivityResult: | |
| """Result of sensitivity analysis. | |
| sensitivities: dict of {metric: {param: normalized_sensitivity}} | |
| where normalized_sensitivity = (Δmetric/metric) / (Δparam/param) | |
| i.e., % change in metric per % change in parameter. | |
| rankings: dict of {metric: [(param, sensitivity), ...]} sorted by |sensitivity| | |
| zero_sensitivity_notes: warnings about params with near-zero sensitivity | |
| """ | |
| param_names: List[str] | |
| metrics: List[str] | |
| sensitivities: Dict[str, Dict[str, float]] | |
| rankings: Dict[str, List[tuple]] | |
| hardware: str | |
| Pexit: float | |
| speed: float | |
| zero_sensitivity_notes: List[str] = field(default_factory=list) | |
| class MultiSensitivityResult: | |
| """Sensitivity analysis across multiple operating points.""" | |
| results: Dict[float, SensitivityResult] # Pexit -> result | |
| combined_rankings: Dict[str, List[tuple]] # metric -> [(param, max_abs_sensitivity)] | |
| zero_sensitivity_notes: List[str] # warnings about params with near-zero sensitivity | |
| def run_sensitivity( | |
| hardware: str = "old_icv", | |
| speed: float = 0.65, | |
| Pexit: float = 350.0, | |
| delta_frac: float = 0.05, | |
| Ptank: float = 7.0, | |
| Psat: float = 2.0, | |
| ) -> SensitivityResult: | |
| """Run sensitivity analysis on all 7 calibratable parameters. | |
| For each parameter, perturbs by ±delta_frac (default ±5%) and measures | |
| the change in each metric. Returns normalized sensitivities: | |
| (% change in metric) / (% change in param). | |
| Args: | |
| hardware: Base config name. | |
| speed: Speed fraction. | |
| Pexit: Discharge pressure [barg]. | |
| delta_frac: Fractional perturbation (0.05 = ±5%). | |
| Ptank: Inlet tank pressure [barg]. | |
| Psat: Saturation pressure [barg]. | |
| Returns: | |
| SensitivityResult with sensitivities and rankings. | |
| """ | |
| cfg = load_config(hardware) | |
| nominal = get_nominal_values(hardware) | |
| # Baseline prediction | |
| baseline = predict(hardware=hardware, Pexit=Pexit, speed=speed, Ptank=Ptank, Psat=Psat) | |
| baseline_metrics = { | |
| "mdot_kgpm": baseline.mdot_kgpm, | |
| "mass_eff": baseline.mass_eff, | |
| "Tc_peak_K": baseline.Tc_peak_K, | |
| "kWh_extend": baseline.kWh_extend, | |
| } | |
| sensitivities = {m: {} for m in METRICS} | |
| for i, pname in enumerate(PARAM_NAMES): | |
| info = CALIBRATION_PARAMS[pname] | |
| val = nominal[i] | |
| # Perturb ±delta_frac, clamped to bounds | |
| delta = max(abs(val) * delta_frac, 1e-8) | |
| val_lo = max(val - delta, info["low"]) | |
| val_hi = min(val + delta, info["high"]) | |
| actual_delta = val_hi - val_lo | |
| if actual_delta < 1e-12: | |
| for m in METRICS: | |
| sensitivities[m][pname] = 0.0 | |
| continue | |
| # Build overridden configs for low and high perturbations | |
| params_lo = list(nominal) | |
| params_lo[i] = val_lo | |
| params_hi = list(nominal) | |
| params_hi[i] = val_hi | |
| cfg_lo = apply_overrides(cfg, params_lo) | |
| cfg_hi = apply_overrides(cfg, params_hi) | |
| try: | |
| args_lo = cfg_lo.to_engine_args() | |
| args_hi = cfg_hi.to_engine_args() | |
| out_lo, hist_lo = ICV_open( | |
| Pexit_barg=Pexit, speed_f=speed, | |
| Ptank_barg=Ptank, Psat_barg=Psat, **args_lo, | |
| ) | |
| out_hi, hist_hi = ICV_open( | |
| Pexit_barg=Pexit, speed_f=speed, | |
| Ptank_barg=Ptank, Psat_barg=Psat, **args_hi, | |
| ) | |
| metrics_lo = { | |
| "mdot_kgpm": hist_lo["mdot_kgpm"], | |
| "mass_eff": float(out_lo[1, 0]), | |
| "Tc_peak_K": float(np.max(hist_lo["Tc_K"])), | |
| "kWh_extend": hist_lo["kWh_extend"], | |
| } | |
| metrics_hi = { | |
| "mdot_kgpm": hist_hi["mdot_kgpm"], | |
| "mass_eff": float(out_hi[1, 0]), | |
| "Tc_peak_K": float(np.max(hist_hi["Tc_K"])), | |
| "kWh_extend": hist_hi["kWh_extend"], | |
| } | |
| except Exception: | |
| for m in METRICS: | |
| sensitivities[m][pname] = 0.0 | |
| continue | |
| # Normalized sensitivity: (Δmetric/metric_baseline) / (Δparam/param_nominal) | |
| for m in METRICS: | |
| dm = metrics_hi[m] - metrics_lo[m] | |
| base_val = baseline_metrics[m] | |
| if abs(base_val) > 1e-12 and abs(val) > 1e-12: | |
| sensitivities[m][pname] = (dm / base_val) / (actual_delta / val) | |
| else: | |
| sensitivities[m][pname] = 0.0 | |
| # Build rankings (sorted by absolute sensitivity) | |
| rankings = {} | |
| for m in METRICS: | |
| ranked = sorted(sensitivities[m].items(), key=lambda x: abs(x[1]), reverse=True) | |
| rankings[m] = ranked | |
| return SensitivityResult( | |
| param_names=list(PARAM_NAMES), | |
| metrics=list(METRICS), | |
| sensitivities=sensitivities, | |
| rankings=rankings, | |
| hardware=hardware, | |
| Pexit=Pexit, | |
| speed=speed, | |
| ) | |
| def run_multi_sensitivity( | |
| hardware: str = "old_icv", | |
| speed: float = 0.65, | |
| pressures: Optional[List[float]] = None, | |
| delta_frac: float = 0.05, | |
| Ptank: float = 7.0, | |
| Psat: float = 2.0, | |
| ) -> MultiSensitivityResult: | |
| """Run sensitivity analysis at multiple operating points. | |
| Reveals pressure-dependent behaviour that single-point analysis misses. | |
| For example, dcv_leakKv may show zero sensitivity at 350 bar (below the | |
| flow cliff) but significant sensitivity at 500 bar. | |
| Args: | |
| hardware: Base config name. | |
| speed: Speed fraction. | |
| pressures: List of Pexit values to test. Default [200, 380, 500]. | |
| delta_frac: Fractional perturbation (0.05 = ±5%). | |
| Ptank: Inlet tank pressure [barg]. | |
| Psat: Saturation pressure [barg]. | |
| Returns: | |
| MultiSensitivityResult with per-pressure results, combined rankings, | |
| and diagnostic notes about near-zero sensitivities. | |
| """ | |
| if pressures is None: | |
| pressures = [200.0, 380.0, 500.0] | |
| results: Dict[float, SensitivityResult] = {} | |
| for P in pressures: | |
| results[P] = run_sensitivity( | |
| hardware=hardware, speed=speed, Pexit=P, | |
| delta_frac=delta_frac, Ptank=Ptank, Psat=Psat, | |
| ) | |
| # Combined rankings: for each param, take max |sensitivity| across pressures | |
| combined_rankings: Dict[str, List[tuple]] = {} | |
| for metric in METRICS: | |
| param_max: Dict[str, float] = {} | |
| for pname in PARAM_NAMES: | |
| max_abs = 0.0 | |
| for P in pressures: | |
| s = abs(results[P].sensitivities[metric].get(pname, 0.0)) | |
| if s > max_abs: | |
| max_abs = s | |
| param_max[pname] = max_abs | |
| ranked = sorted(param_max.items(), key=lambda x: x[1], reverse=True) | |
| combined_rankings[metric] = ranked | |
| # Zero-sensitivity notes | |
| notes: List[str] = [] | |
| for pname in PARAM_NAMES: | |
| # Collect max |sensitivity| across all metrics and all pressures | |
| per_pressure_max: Dict[float, float] = {} | |
| for P in pressures: | |
| max_across_metrics = max( | |
| abs(results[P].sensitivities[m].get(pname, 0.0)) | |
| for m in METRICS | |
| ) | |
| per_pressure_max[P] = max_across_metrics | |
| all_near_zero = all(v < _NEAR_ZERO_THRESHOLD for v in per_pressure_max.values()) | |
| if all_near_zero: | |
| notes.append( | |
| f"{pname} shows near-zero sensitivity across all tested pressures " | |
| f"— may not be identifiable from the data" | |
| ) | |
| else: | |
| # Check for pressure-dependent behavior: zero at some, significant at others | |
| zero_pressures = [P for P, v in per_pressure_max.items() if v < _NEAR_ZERO_THRESHOLD] | |
| sig_pressures = [P for P, v in per_pressure_max.items() if v >= _NEAR_ZERO_THRESHOLD] | |
| if zero_pressures and sig_pressures: | |
| zero_str = ", ".join(f"{p:.0f}" for p in zero_pressures) | |
| sig_str = ", ".join(f"{p:.0f}" for p in sig_pressures) | |
| notes.append( | |
| f"{pname} has near-zero sensitivity at {zero_str} bar " | |
| f"but significant sensitivity at {sig_str} bar " | |
| f"— pressure-dependent" | |
| ) | |
| return MultiSensitivityResult( | |
| results=results, | |
| combined_rankings=combined_rankings, | |
| zero_sensitivity_notes=notes, | |
| ) | |