Coverage for smartmdao / optimization.py: 100%

100 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-07-05 17:31 +0200

1import logging 

2from dataclasses import dataclass, field 

3from typing import Any, Callable, Dict, List, Literal, Optional, Protocol, Tuple, Type, Union 

4 

5from .core import Pipeline 

6 

7logger = logging.getLogger(__name__) 

8 

9class PipelineEvaluator: 

10 """ 

11 A generic, stateful bridge to interface the Pipeline with external optimizers  

12 (SciPy, OpenTURNS, PyOptSparse, etc.). 

13  

14 It caches the last evaluation to prevent redundant pipeline runs when optimizers 

15 request objectives and constraints independently for the same state. 

16 """ 

17 def __init__(self, 

18 pipeline: Pipeline, 

19 design_vars: List[str], 

20 constants: Dict[str, Any] = None): 

21 """ 

22 :param pipeline: The instantiated smartmdao. 

23 :param design_vars: Ordered list of variable names corresponding to the optimizer's input array `x`. 

24 :param constants: Optional dictionary of variables that remain fixed during optimization. 

25 """ 

26 self.pipeline = pipeline 

27 self.design_vars = design_vars 

28 self.constants = constants or {} 

29 

30 self.last_x = None 

31 self.last_results = None 

32 self.eval_count = 0 

33 

34 def evaluate(self, x) -> Dict[str, Any]: 

35 """Runs the pipeline if the design variables have changed.""" 

36 # Convert to tuple for hashable comparison 

37 x_tuple = tuple(x) 

38 

39 if self.last_x != x_tuple: 

40 self.eval_count += 1 

41 

42 # 1. Map the numeric array 'x' back to named variables 

43 inputs = dict(zip(self.design_vars, x)) 

44 

45 # 2. Inject constants 

46 inputs.update(self.constants) 

47 

48 # 3. Execute 

49 self.last_results = self.pipeline.run(**inputs) 

50 self.last_x = x_tuple 

51 

52 return self.last_results 

53 

54 def get_objective(self, output_name: str) -> Callable: 

55 """ 

56 Factory method that returns a callable objective function for the optimizer. 

57 """ 

58 def _objective(x): 

59 return self.evaluate(x)[output_name] 

60 return _objective 

61 

62 def get_constraint(self, output_name: str, multiplier: float = 1.0) -> Callable: 

63 """ 

64 Factory method that returns a callable constraint function. 

65 :param multiplier: Useful for flipping constraint signs. 

66 (e.g., SciPy expects f(x) >= 0. If pipeline outputs f(x) <= 0, use multiplier=-1.0) 

67 """ 

68 def _constraint(x): 

69 return multiplier * self.evaluate(x)[output_name] 

70 return _constraint 

71 

72 

73# ============================================================================== 

74# Backend-agnostic optimization: define the problem once, swap the solver. 

75# ============================================================================== 

76 

77@dataclass 

78class ConstraintSpec: 

79 """ 

80 One inequality or equality constraint on a named pipeline output. 

81 Both built-in backends follow the same convention: 'ineq' means 

82 h(x) >= 0 and 'eq' means h(x) == 0 - use `multiplier` to flip the sign 

83 of a discipline that was naturally written the other way around. 

84 """ 

85 name: str 

86 kind: Literal["ineq", "eq"] = "ineq" 

87 multiplier: float = 1.0 

88 

89 

90@dataclass 

91class OptimizationProblem: 

92 """ 

93 A backend-agnostic description of an optimization problem, built on top 

94 of an existing PipelineEvaluator. Any OptimizerBackend can consume this 

95 without knowing anything about SmartMDAO's Pipeline internals. 

96 """ 

97 evaluator: PipelineEvaluator 

98 initial_guess: List[float] 

99 bounds: Optional[List[Tuple[float, float]]] = None 

100 objective: str = "objective" 

101 constraints: List[ConstraintSpec] = field(default_factory=list) 

102 

103 

104@dataclass 

105class OptimizationResult: 

106 """ 

107 Normalized optimization outcome, common across every backend. 

108 `raw` keeps the underlying backend-specific result object (e.g. a 

109 scipy.optimize.OptimizeResult) for anyone who needs backend-specific detail. 

110 """ 

111 x: List[float] 

112 objective_value: float 

113 success: bool 

114 message: str 

115 state: Dict[str, Any] 

116 raw: Any = None 

117 

118 

119class OptimizerBackend(Protocol): 

120 """Interface every optimizer backend implements.""" 

121 def solve(self, problem: OptimizationProblem, **options: Any) -> OptimizationResult: 

122 ... 

123 

124 

125_BACKENDS: Dict[str, Type[OptimizerBackend]] = {} 

126 

127 

128def register_backend(name: str): 

129 """ 

130 Registers an OptimizerBackend under a short string key, so it becomes 

131 selectable via optimize(problem, backend=name) - the same "name it once, 

132 use it everywhere" pattern as @pipeline.step. Custom backends (PyOptSparse, 

133 an in-house solver, ...) can register themselves the exact same way. 

134 """ 

135 def decorator(cls: Type[OptimizerBackend]) -> Type[OptimizerBackend]: 

136 _BACKENDS[name] = cls 

137 return cls 

138 return decorator 

139 

140 

141def optimize( 

142 problem: OptimizationProblem, 

143 backend: Union[str, OptimizerBackend] = "scipy", 

144 **options: Any, 

145) -> OptimizationResult: 

146 """ 

147 Runs `problem` through the given backend and returns a normalized result. 

148 `backend` can be a registered name ('scipy', 'openturns', ...) or any 

149 object implementing OptimizerBackend directly (bring your own optimizer). 

150 """ 

151 if isinstance(backend, str): 

152 try: 

153 backend_cls = _BACKENDS[backend] 

154 except KeyError: 

155 raise ValueError( 

156 f"Unknown optimizer backend '{backend}'. Registered backends: {sorted(_BACKENDS)}." 

157 ) from None 

158 backend = backend_cls() 

159 

160 logger.info(f"Running optimization with backend '{type(backend).__name__}'.") 

161 return backend.solve(problem, **options) 

162 

163 

164@register_backend("scipy") 

165class ScipyBackend: 

166 """ 

167 Bridges to scipy.optimize.minimize. Defaults to SLSQP, since it natively 

168 supports bounds plus inequality/equality constraints - the most common case. 

169 Extra keyword arguments are forwarded as-is to `minimize()` (e.g. `tol=1e-6` 

170 or `options={"disp": True}`). 

171 """ 

172 def solve(self, problem: OptimizationProblem, method: str = "SLSQP", **options: Any) -> OptimizationResult: 

173 from scipy.optimize import minimize 

174 

175 constraints = [ 

176 { 

177 "type": c.kind, 

178 "fun": problem.evaluator.get_constraint(c.name, multiplier=c.multiplier), 

179 } 

180 for c in problem.constraints 

181 ] 

182 

183 result = minimize( 

184 problem.evaluator.get_objective(problem.objective), 

185 problem.initial_guess, 

186 method=method, 

187 bounds=problem.bounds, 

188 constraints=constraints, 

189 **options, 

190 ) 

191 

192 x_opt = [float(v) for v in result.x] 

193 return OptimizationResult( 

194 x=x_opt, 

195 objective_value=float(result.fun), 

196 success=bool(result.success), 

197 message=str(result.message), 

198 state=problem.evaluator.evaluate(x_opt), 

199 raw=result, 

200 ) 

201 

202 

203@register_backend("openturns") 

204class OpenTURNSBackend: 

205 """ 

206 Bridges to OpenTURNS' OptimizationAlgorithm classes. Defaults to Cobyla, 

207 a derivative-free algorithm that handles inequality/equality constraints 

208 without requiring gradients. `method` is looked up as a class name on the 

209 `openturns` module (e.g. "Cobyla", "SLSQP", "AbdoRackwitz", ...), so any 

210 algorithm OpenTURNS ships is selectable without adding a branch here. 

211 """ 

212 def solve( 

213 self, 

214 problem: OptimizationProblem, 

215 method: str = "Cobyla", 

216 max_iterations: int = 1000, 

217 **options: Any, 

218 ) -> OptimizationResult: 

219 import openturns as ot 

220 

221 dim = len(problem.initial_guess) 

222 objective_fn = ot.PythonFunction( 

223 dim, 1, lambda x: [problem.evaluator.get_objective(problem.objective)(x)] 

224 ) 

225 ot_problem = ot.OptimizationProblem(objective_fn) 

226 

227 if problem.bounds: 

228 lower = [b[0] for b in problem.bounds] 

229 upper = [b[1] for b in problem.bounds] 

230 ot_problem.setBounds(ot.Interval(lower, upper)) 

231 

232 ineq = [c for c in problem.constraints if c.kind == "ineq"] 

233 eq = [c for c in problem.constraints if c.kind == "eq"] 

234 

235 if ineq: 

236 ot_problem.setInequalityConstraint( 

237 ot.PythonFunction( 

238 dim, len(ineq), 

239 lambda x, _cs=ineq: [problem.evaluator.get_constraint(c.name, c.multiplier)(x) for c in _cs], 

240 ) 

241 ) 

242 if eq: 

243 ot_problem.setEqualityConstraint( 

244 ot.PythonFunction( 

245 dim, len(eq), 

246 lambda x, _cs=eq: [problem.evaluator.get_constraint(c.name, c.multiplier)(x) for c in _cs], 

247 ) 

248 ) 

249 

250 algo = getattr(ot, method)(ot_problem) 

251 algo.setStartingPoint(problem.initial_guess) 

252 algo.setMaximumIterationNumber(max_iterations) 

253 for key, value in options.items(): 

254 getattr(algo, f"set{key}")(value) 

255 algo.run() 

256 

257 ot_result = algo.getResult() 

258 x_opt = list(ot_result.getOptimalPoint()) 

259 

260 return OptimizationResult( 

261 x=x_opt, 

262 objective_value=float(ot_result.getOptimalValue()[0]), 

263 success=True, # OpenTURNS raises on failure rather than reporting a boolean. 

264 message=f"Completed after {ot_result.getIterationNumber()} iteration(s).", 

265 state=problem.evaluator.evaluate(x_opt), 

266 raw=ot_result, 

267 )