Spaces:
Sleeping
Sleeping
| """Constraint-aware hardware design optimization. | |
| Finds optimal hardware parameters that maximize (or minimize) a target metric | |
| subject to constraints on other metrics. | |
| Provides three optimizers: | |
| - optimize(): L-BFGS-B single start (fast, may get trapped near flow cliff) | |
| - multi_start_optimize(): LHS-sampled multi-start L-BFGS-B (better global coverage) | |
| - differential_evolution_optimize(): DE global optimizer (most robust for cliff regions) | |
| """ | |
| from dataclasses import dataclass | |
| from typing import Dict, List, Optional | |
| import numpy as np | |
| from scipy.optimize import minimize, differential_evolution | |
| from scipy.stats import qmc | |
| from cryosim.calibration.params import ( | |
| PARAM_NAMES, get_bounds, get_nominal_values, apply_overrides, | |
| ) | |
| from cryosim.hardware.config import load_config | |
| from cryosim.engine.fast import ICV_open | |
| def _extract_metrics(out, hist): | |
| return { | |
| "mdot_kgpm": hist["mdot_kgpm"], | |
| "mass_eff": float(out[1, 0]), | |
| "Tc_peak_K": float(np.max(hist["Tc_K"])), | |
| "kWh_extend": hist["kWh_extend"], | |
| "pc_peak_barg": float(np.max(hist["pc"])), | |
| } | |
| def _parse_constraint(s: str): | |
| s = s.strip() | |
| if s.startswith(">="): | |
| return ">=", float(s[2:]) | |
| elif s.startswith("<="): | |
| return "<=", float(s[2:]) | |
| elif s.startswith(">"): | |
| return ">", float(s[1:]) | |
| elif s.startswith("<"): | |
| return "<", float(s[1:]) | |
| raise ValueError(f"Cannot parse constraint: '{s}'. Use '>X' or '<X'.") | |
| def _check_constraint(value, op, threshold): | |
| if op in (">", ">="): | |
| return max(0.0, threshold - value) | |
| return max(0.0, value - threshold) | |
| # ---- Metric-name resolution --------------------------------------------------- | |
| _METRIC_MAP = { | |
| "mdot": "mdot_kgpm", | |
| "mass_eff": "mass_eff", | |
| "kWh": "kWh_extend", | |
| "Tc_peak": "Tc_peak_K", | |
| "stall_pressure": "stall_pressure", | |
| "fill_time": "fill_time", | |
| } | |
| def _resolve_target(target: str): | |
| """Parse target string into (direction, metric_key). | |
| Returns: | |
| (direction, metric_key) where direction is -1.0 for max, +1.0 for min. | |
| """ | |
| if target.startswith("max_"): | |
| metric_name = target[4:] | |
| direction = -1.0 | |
| elif target.startswith("min_"): | |
| metric_name = target[4:] | |
| direction = 1.0 | |
| else: | |
| raise ValueError(f"Target must start with 'max_' or 'min_', got '{target}'") | |
| target_metric = _METRIC_MAP.get(metric_name, metric_name) | |
| return direction, target_metric | |
| # ---- Fill-level helpers ------------------------------------------------------- | |
| def _stall_pressure_with_overrides(cfg_override, speed, Ptank, Psat): | |
| """Compute stall pressure using a modified config. | |
| Evaluates the single-cycle engine at 25 pressures from 50-950 bar and | |
| interpolates the 0.05 kg/min crossing. | |
| """ | |
| args = cfg_override.to_engine_args() | |
| pressures = np.linspace(50, 950, 25) | |
| flows = np.zeros(len(pressures)) | |
| for i, P in enumerate(pressures): | |
| try: | |
| out, hist = ICV_open( | |
| Pexit_barg=float(P), speed_f=speed, | |
| Ptank_barg=Ptank, Psat_barg=Psat, **args, | |
| ) | |
| flows[i] = max(0.0, hist["mdot_kgpm"]) | |
| except Exception: | |
| flows[i] = 0.0 | |
| # Find where flow drops below 0.05 kg/min | |
| threshold = 0.05 | |
| for i in range(len(pressures) - 1): | |
| if flows[i] >= threshold and flows[i + 1] < threshold: | |
| frac = (threshold - flows[i]) / (flows[i + 1] - flows[i]) | |
| return float(pressures[i] + frac * (pressures[i + 1] - pressures[i])) | |
| # Never crossed: either always above or always below | |
| if flows[-1] >= threshold: | |
| return float(pressures[-1]) | |
| return 0.0 | |
| def _fill_time_proxy_with_overrides(cfg_override, speed, target_bar, Ptank, Psat): | |
| """Estimate fill time using stall pressure as a proxy. | |
| A proper fill-time estimate would integrate 1/mdot(P) over the pressure | |
| range, but that requires ~25 engine evaluations per objective call which | |
| is too expensive inside an optimizer. Instead we use stall pressure as a | |
| proxy: higher stall pressure means the pump can sustain flow to higher | |
| pressures, which dominates fill time. The proxy value is negated stall | |
| pressure so that minimizing fill_time is equivalent to maximizing stall | |
| pressure. If the pump cannot reach the target pressure, returns a large | |
| penalty value (1e6). | |
| Returns: | |
| Proxy fill time value (lower is better). | |
| """ | |
| stall_p = _stall_pressure_with_overrides(cfg_override, speed, Ptank, Psat) | |
| if stall_p < target_bar: | |
| return 1e6 # unreachable — huge penalty | |
| return -stall_p # proxy: higher stall pressure -> lower (better) fill time | |
| # ---- Objective builder -------------------------------------------------------- | |
| def _build_objective( | |
| cfg, direction, target_metric, parsed_constraints, penalty_weight, | |
| speed, Pexit, Ptank, Psat, | |
| ): | |
| """Build a closure that evaluates the objective for a parameter vector. | |
| Returns: | |
| (objective_fn, n_evals_counter) where n_evals_counter is a mutable list [count]. | |
| """ | |
| n_evals = [0] | |
| def objective(x): | |
| n_evals[0] += 1 | |
| override_cfg = apply_overrides(cfg, list(x)) | |
| # --- fill-level objectives --- | |
| if target_metric == "stall_pressure": | |
| try: | |
| stall_p = _stall_pressure_with_overrides(override_cfg, speed, Ptank, Psat) | |
| except Exception: | |
| return 1e6 | |
| obj = direction * stall_p | |
| # No per-cycle metrics for constraints when using stall objective | |
| for cmetric, (op, threshold) in parsed_constraints.items(): | |
| obj += penalty_weight * _check_constraint(0.0, op, threshold) ** 2 | |
| return obj | |
| if target_metric == "fill_time": | |
| try: | |
| proxy = _fill_time_proxy_with_overrides( | |
| override_cfg, speed, Pexit, Ptank, Psat, | |
| ) | |
| except Exception: | |
| return 1e6 | |
| obj = direction * proxy | |
| return obj | |
| # --- single-cycle objectives --- | |
| args = override_cfg.to_engine_args() | |
| try: | |
| out, hist = ICV_open( | |
| Pexit_barg=Pexit, speed_f=speed, | |
| Ptank_barg=Ptank, Psat_barg=Psat, **args, | |
| ) | |
| metrics = _extract_metrics(out, hist) | |
| except Exception: | |
| return 1e6 | |
| obj = direction * metrics.get(target_metric, 0.0) | |
| for cmetric, (op, threshold) in parsed_constraints.items(): | |
| violation = _check_constraint(metrics.get(cmetric, 0.0), op, threshold) | |
| obj += penalty_weight * violation ** 2 | |
| return obj | |
| return objective, n_evals | |
| def _evaluate_final(cfg, x, speed, Pexit, Ptank, Psat, target_metric): | |
| """Run the final evaluation to get metrics and target value for the result.""" | |
| final_cfg = apply_overrides(cfg, list(x)) | |
| if target_metric == "stall_pressure": | |
| try: | |
| stall_p = _stall_pressure_with_overrides(final_cfg, speed, Ptank, Psat) | |
| return {"stall_pressure": stall_p}, stall_p | |
| except Exception: | |
| return {}, 0.0 | |
| if target_metric == "fill_time": | |
| try: | |
| stall_p = _stall_pressure_with_overrides(final_cfg, speed, Ptank, Psat) | |
| proxy = -stall_p | |
| return {"fill_time_proxy": proxy, "stall_pressure": stall_p}, proxy | |
| except Exception: | |
| return {}, 0.0 | |
| final_args = final_cfg.to_engine_args() | |
| try: | |
| out, hist = ICV_open( | |
| Pexit_barg=Pexit, speed_f=speed, | |
| Ptank_barg=Ptank, Psat_barg=Psat, **final_args, | |
| ) | |
| final_metrics = _extract_metrics(out, hist) | |
| except Exception: | |
| final_metrics = {} | |
| return final_metrics, final_metrics.get(target_metric, 0.0) | |
| class OptimizationResult: | |
| optimal_values: List[float] | |
| param_names: List[str] | |
| optimal_metrics: Dict[str, float] | |
| target: str | |
| target_value: float | |
| constraints: Dict[str, str] | |
| constraints_satisfied: bool | |
| base_hardware: str | |
| n_evals: int | |
| converged: bool | |
| def __repr__(self): | |
| status = "OK" if self.constraints_satisfied else "VIOLATED" | |
| return ( | |
| f"OptimizationResult({self.target}={self.target_value:.4f}, " | |
| f"constraints={status}, {self.n_evals} evals)" | |
| ) | |
| def optimize( | |
| hardware: str = "old_icv", | |
| target: str = "max_mdot", | |
| speed: float = 0.65, | |
| Pexit: float = 500.0, | |
| constraints: Optional[Dict[str, str]] = None, | |
| Ptank: float = 7.0, | |
| Psat: float = 2.0, | |
| maxiter: int = 50, | |
| maxfun: Optional[int] = None, | |
| penalty_weight: float = 1000.0, | |
| ) -> OptimizationResult: | |
| """Find optimal hardware parameters subject to constraints using L-BFGS-B. | |
| A single-start local optimizer. For problems near the flow cliff | |
| discontinuity, consider ``multi_start_optimize`` or | |
| ``differential_evolution_optimize`` which are more robust to local minima. | |
| Minimum recommended ``maxiter`` is 30. | |
| Args: | |
| hardware: Base config name. | |
| target: "max_mdot", "max_mass_eff", "min_kWh", "min_Tc_peak", | |
| "max_stall_pressure", "min_fill_time" | |
| speed, Pexit: Operating conditions. | |
| constraints: e.g. {"Tc_peak_K": "<200", "mass_eff": ">0.1"} | |
| maxiter: Max optimizer iterations. | |
| maxfun: Max function evaluations (default: maxiter * 15). | |
| penalty_weight: Penalty multiplier for constraint violations. | |
| """ | |
| constraints = constraints or {} | |
| cfg = load_config(hardware) | |
| direction, target_metric = _resolve_target(target) | |
| parsed_constraints = {} | |
| for cmetric, cstr in constraints.items(): | |
| op, val = _parse_constraint(cstr) | |
| parsed_constraints[cmetric] = (op, val) | |
| effective_maxfun = maxfun or maxiter * 15 | |
| objective, n_evals = _build_objective( | |
| cfg, direction, target_metric, parsed_constraints, penalty_weight, | |
| speed, Pexit, Ptank, Psat, | |
| ) | |
| x0 = get_nominal_values(hardware) | |
| bounds = get_bounds() | |
| result = minimize( | |
| objective, x0=x0, method="L-BFGS-B", bounds=bounds, | |
| options={"maxiter": maxiter, "maxfun": effective_maxfun, "ftol": 1e-8}, | |
| ) | |
| final_metrics, target_value = _evaluate_final( | |
| cfg, result.x, speed, Pexit, Ptank, Psat, target_metric, | |
| ) | |
| all_satisfied = True | |
| for cmetric, (op, threshold) in parsed_constraints.items(): | |
| if _check_constraint(final_metrics.get(cmetric, 0.0), op, threshold) > 1e-6: | |
| all_satisfied = False | |
| return OptimizationResult( | |
| optimal_values=list(result.x), | |
| param_names=list(PARAM_NAMES), | |
| optimal_metrics=final_metrics, | |
| target=target, | |
| target_value=target_value, | |
| constraints=constraints, | |
| constraints_satisfied=all_satisfied, | |
| base_hardware=hardware, | |
| n_evals=n_evals[0], | |
| converged=result.success, | |
| ) | |
| def multi_start_optimize( | |
| hardware: str = "old_icv", | |
| target: str = "max_mdot", | |
| speed: float = 0.65, | |
| Pexit: float = 500.0, | |
| constraints: Optional[Dict[str, str]] = None, | |
| Ptank: float = 7.0, | |
| Psat: float = 2.0, | |
| n_starts: int = 5, | |
| maxiter: int = 30, | |
| seed: Optional[int] = None, | |
| ) -> OptimizationResult: | |
| """Multi-start L-BFGS-B optimization with Latin Hypercube Sampling. | |
| Generates ``n_starts`` starting points spread across the parameter space | |
| via LHS, runs L-BFGS-B from each, and returns the best result. | |
| This is more robust than single-start ``optimize()`` near the flow cliff | |
| discontinuity where L-BFGS-B tends to get trapped. | |
| Args: | |
| hardware: Base config name. | |
| target: "max_mdot", "max_mass_eff", "min_kWh", "min_Tc_peak", | |
| "max_stall_pressure", "min_fill_time" | |
| speed, Pexit: Operating conditions. | |
| constraints: e.g. {"Tc_peak_K": "<200", "mass_eff": ">0.1"} | |
| n_starts: Number of starting points to sample. | |
| maxiter: Max L-BFGS-B iterations per start. | |
| seed: Random seed for reproducibility. | |
| """ | |
| constraints = constraints or {} | |
| cfg = load_config(hardware) | |
| direction, target_metric = _resolve_target(target) | |
| parsed_constraints = {} | |
| for cmetric, cstr in constraints.items(): | |
| op, val = _parse_constraint(cstr) | |
| parsed_constraints[cmetric] = (op, val) | |
| bounds = get_bounds() | |
| n_params = len(bounds) | |
| effective_maxfun = maxiter * 15 | |
| # Generate LHS starting points across parameter bounds | |
| sampler = qmc.LatinHypercube(d=n_params, seed=seed) | |
| samples = sampler.random(n=n_starts) | |
| lower = np.array([b[0] for b in bounds]) | |
| upper = np.array([b[1] for b in bounds]) | |
| start_points = qmc.scale(samples, lower, upper) | |
| best_result = None | |
| best_obj = 1e6 | |
| total_evals = 0 | |
| for i in range(n_starts): | |
| objective, n_evals = _build_objective( | |
| cfg, direction, target_metric, parsed_constraints, 1000.0, | |
| speed, Pexit, Ptank, Psat, | |
| ) | |
| result = minimize( | |
| objective, x0=start_points[i], method="L-BFGS-B", bounds=bounds, | |
| options={"maxiter": maxiter, "maxfun": effective_maxfun, "ftol": 1e-8}, | |
| ) | |
| total_evals += n_evals[0] | |
| if result.fun < best_obj: | |
| best_obj = result.fun | |
| best_result = result | |
| if best_result is None: | |
| # Fallback: shouldn't happen unless n_starts=0 | |
| best_result = minimize( | |
| lambda x: 1e6, x0=get_nominal_values(hardware), | |
| method="L-BFGS-B", bounds=bounds, options={"maxiter": 1}, | |
| ) | |
| total_evals = 0 | |
| final_metrics, target_value = _evaluate_final( | |
| cfg, best_result.x, speed, Pexit, Ptank, Psat, target_metric, | |
| ) | |
| all_satisfied = True | |
| for cmetric, (op, threshold) in parsed_constraints.items(): | |
| if _check_constraint(final_metrics.get(cmetric, 0.0), op, threshold) > 1e-6: | |
| all_satisfied = False | |
| return OptimizationResult( | |
| optimal_values=list(best_result.x), | |
| param_names=list(PARAM_NAMES), | |
| optimal_metrics=final_metrics, | |
| target=target, | |
| target_value=target_value, | |
| constraints=constraints, | |
| constraints_satisfied=all_satisfied, | |
| base_hardware=hardware, | |
| n_evals=total_evals, | |
| converged=best_result.success, | |
| ) | |
| def differential_evolution_optimize( | |
| hardware: str = "old_icv", | |
| target: str = "max_mdot", | |
| speed: float = 0.65, | |
| Pexit: float = 500.0, | |
| constraints: Optional[Dict[str, str]] = None, | |
| Ptank: float = 7.0, | |
| Psat: float = 2.0, | |
| maxiter: int = 30, | |
| seed: Optional[int] = None, | |
| penalty_weight: float = 1000.0, | |
| ) -> OptimizationResult: | |
| """Global optimization via Differential Evolution. | |
| Uses ``scipy.optimize.differential_evolution`` which maintains a population | |
| of candidate solutions and is much less likely to get trapped by the flow | |
| cliff discontinuity than gradient-based methods. | |
| Args: | |
| hardware: Base config name. | |
| target: "max_mdot", "max_mass_eff", "min_kWh", "min_Tc_peak", | |
| "max_stall_pressure", "min_fill_time" | |
| speed, Pexit: Operating conditions. | |
| constraints: e.g. {"Tc_peak_K": "<200", "mass_eff": ">0.1"} | |
| maxiter: Max DE generations. | |
| seed: Random seed for reproducibility. | |
| penalty_weight: Penalty multiplier for constraint violations. | |
| """ | |
| constraints = constraints or {} | |
| cfg = load_config(hardware) | |
| direction, target_metric = _resolve_target(target) | |
| parsed_constraints = {} | |
| for cmetric, cstr in constraints.items(): | |
| op, val = _parse_constraint(cstr) | |
| parsed_constraints[cmetric] = (op, val) | |
| objective, n_evals = _build_objective( | |
| cfg, direction, target_metric, parsed_constraints, penalty_weight, | |
| speed, Pexit, Ptank, Psat, | |
| ) | |
| bounds = get_bounds() | |
| result = differential_evolution( | |
| objective, bounds=bounds, maxiter=maxiter, seed=seed, | |
| tol=1e-8, polish=True, | |
| ) | |
| final_metrics, target_value = _evaluate_final( | |
| cfg, result.x, speed, Pexit, Ptank, Psat, target_metric, | |
| ) | |
| all_satisfied = True | |
| for cmetric, (op, threshold) in parsed_constraints.items(): | |
| if _check_constraint(final_metrics.get(cmetric, 0.0), op, threshold) > 1e-6: | |
| all_satisfied = False | |
| return OptimizationResult( | |
| optimal_values=list(result.x), | |
| param_names=list(PARAM_NAMES), | |
| optimal_metrics=final_metrics, | |
| target=target, | |
| target_value=target_value, | |
| constraints=constraints, | |
| constraints_satisfied=all_satisfied, | |
| base_hardware=hardware, | |
| n_evals=n_evals[0], | |
| converged=result.success, | |
| ) | |