Coverage for backend/ahuora-builder/src/ahuora_builder/flowsheet_manager.py: 85%

345 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2026-07-22 05:22 +0000

1from collections import defaultdict, deque 

2import re 

3from pyomo.network import SequentialDecomposition, Port 

4from pyomo.environ import ( 

5 Component, 

6 ConcreteModel, 

7 TransformationFactory, 

8 SolverFactory, 

9 Block, 

10 Expression, 

11 Constraint, 

12 Objective, 

13 minimize, 

14 assert_optimal_termination, 

15 ScalarVar, 

16 units as pyunits, 

17 TerminationCondition 

18) 

19from pyomo.environ import assert_optimal_termination, check_optimal_termination 

20from pyomo.core.base.constraint import ScalarConstraint, IndexedConstraint 

21from idaes.core import FlowsheetBlock 

22from .model_statistics_optimizations import ( 

23 degrees_of_freedom, 

24 install_model_statistics_optimizations, 

25) 

26 

27install_model_statistics_optimizations() 

28 

29from idaes.core.util.model_statistics import report_statistics 

30from idaes.core.util.exceptions import InitializationError 

31import idaes.logger as idaeslog 

32from ahuora_builder_types import FlowsheetSchema 

33from ahuora_builder_types.flowsheet_schema import SolvedFlowsheetSchema, UnitDiagnosticsFinding 

34from .property_package_manager import PropertyPackageManager 

35from .port_manager import PortManager 

36from .arc_manager import ArcManager 

37from .tear_manager import TearManager 

38from .unit_model_manager import UnitModelManager 

39from .methods.adapter_library import AdapterLibrary 

40from .methods.adapter import ( 

41 serialize_properties_map, 

42 deactivate_fixed_guesses, 

43 deactivate_component, 

44 deactivate_components, 

45 add_corresponding_constraint, 

46) 

47from .methods.expression_parsing import parse_expression, ExpressionParsingError 

48from .timing import start_timing 

49from .methods.units_handler import get_value, get_attached_unit, check_units_equivalent 

50from .methods.scaling_suffix import sanitize_scaling_suffix 

51from .properties_manager import PropertiesManager 

52from .custom.energy.power_property_package import PowerParameterBlock 

53from .custom.energy.ac_property_package import acParameterBlock 

54from .custom.energy.transformer_property_package import transformerParameterBlock 

55from .custom.watertap.reverse_osmosis_0d import ReverseOsmosis0DData 

56from pyomo.core.base.units_container import units, _PyomoUnit 

57from idaes.core.util.model_serializer import StoreSpec, from_json, to_json 

58from .diagnostics.infeasibilities import get_top_infeasibilities 

59 

60# CSTR Imports from example flowsheet. 

61# To be used as placeholders until bespoke functions can be developed. 

62from .custom import hda_reaction as reaction_props 

63from .custom.hda_ideal_VLE import HDAParameterBlock 

64from ahuora_builder.properties_manager import PropertyComponent 

65from ahuora_property_packages.build_package import build_package 

66from .methods.change_detection import get_current_values, detect_changes, print_changes 

67from .methods.pyomo_expressions import dimensionless_objective_expression, sum_pyomo_expressions 

68 

69_log = idaeslog.getLogger(__name__) 

70 

71WATERTAP_SOLVER_WARNING = ( 

72 "Reverse Osmosis (0D) is a WaterTAP unit and should be solved with the " 

73 "Ipopt Watertap scenario solver. Plain Ipopt can converge to a locally " 

74 "infeasible point for this model even when the inputs are valid." 

75) 

76 

77 

78def _get_watertap_solver_diagnostics( 

79 *, 

80 solver_option: str | None, 

81 unit_models, 

82 properties_map: PropertiesManager, 

83) -> list[UnitDiagnosticsFinding]: 

84 """ 

85 Warn when a unit has a known dependency on the WaterTAP Ipopt wrapper. 

86 

87 This is intentionally solver-specific rather than part of the unit's 

88 ``diagnose`` method, because unit diagnostics do not know which scenario 

89 solver the user selected. 

90 """ 

91 if solver_option != "ipopt": 

92 return [] 

93 

94 diagnostics: list[UnitDiagnosticsFinding] = [] 

95 for unit_model in unit_models: 

96 if isinstance(unit_model, ReverseOsmosis0DData): 

97 diagnostics.append( 

98 UnitDiagnosticsFinding( 

99 severity="warning", 

100 property_value_id=properties_map.get_id_by_component( 

101 unit_model.A_comp 

102 ), 

103 message=WATERTAP_SOLVER_WARNING, 

104 ) 

105 ) 

106 return diagnostics 

107 

108# from amplpy import modules 

109# Import required to allow the library to set the PATH and allow conopt to be found. 

110 

111 

112def build_flowsheet(dynamic=False,time_set=[0]): 

113 """ 

114 Builds a flowsheet block 

115 """ 

116 # create the model and the flowsheet block 

117 model = ConcreteModel() 

118 model.fs = FlowsheetBlock(dynamic=dynamic, time_set=time_set, time_units=units.s) 

119 model.fs.guess_vars = [] 

120 model.fs.controlled_vars = Block() 

121 # Always add the power property package (as there's only one and there's no different property package types) 

122 # TODO: should this be on-demand? 

123 model.fs.power_pp = PowerParameterBlock() 

124 model.fs.ac_pp = acParameterBlock() 

125 model.fs.tr_pp = transformerParameterBlock() 

126 # properties map: { id: pyomo variable or expression } 

127 # used to map symbols for a sympy expression 

128 model.fs.properties_map = PropertiesManager() 

129 # list of component constraints to add later 

130 model.fs.constraint_exprs = [] 

131 

132 # Placholder property packages for CSTR reactor. 

133 model.fs.BTHM_params = HDAParameterBlock() 

134 #Hard-coded peng-robinson package for reactor: 

135 model.fs.peng_robinson = build_package("peng-robinson",["benzene", "toluene", "hydrogen", "methane"], ["Liq","Vap"]) 

136 

137 # Reaction package for the HDA reaction 

138 model.fs.reaction_params = reaction_props.HDAReactionParameterBlock( 

139 property_package=model.fs.peng_robinson 

140 ) 

141 

142 return model 

143 

144 

145class FlowsheetManager: 

146 """ 

147 Manages the flowsheet, including the property packages, unit models, ports, arcs, and tears 

148 Includes methods to load, initialise, and solve the flowsheet 

149 """ 

150 

151 def __init__(self, schema: FlowsheetSchema) -> None: 

152 """ 

153 Stores all relevant information about the flowsheet, without actually loading it 

154 """ 

155 self.timing = start_timing() 

156 self.timing.add_timing("initialise_flowsheet_manager") 

157 

158 self.model = build_flowsheet(dynamic=schema.dynamic, time_set=schema.time_set) 

159 

160 self.schema = schema 

161 # Add property packages first, so that unit models can use them 

162 self.property_packages = PropertyPackageManager(self) 

163 # Add the port manager, so the unit models can register their ports 

164 self.ports = PortManager() 

165 # Add unit models 

166 self.unit_models = UnitModelManager(self) 

167 # Add arcs to connect the unit models together 

168 self.arcs = ArcManager(self) 

169 # set certain arcs as tears 

170 self.tears = TearManager(self) 

171 self.properties_map: PropertiesManager 

172 

173 def load(self) -> None: 

174 """ 

175 Parses the schema and loads the model 

176 """ 

177 self.timing.step_into("load_flowsheet") 

178 # Load property packages first, so that unit models can use them 

179 self.timing.add_timing("load_property_packages") 

180 self.property_packages.load() 

181 self.timing.step_into("load_unit_models") 

182 self.unit_models.load() 

183 self.timing.step_out() 

184 # no need to load ports seperately, they are loaded by the unit models 

185 # Load arcs to connect the unit models together 

186 self.arcs.load() 

187 # load any expressions 

188 self.load_specs() 

189 # if dynamics, apply finite difference transformation. 

190 if self.schema.dynamic: 

191 print("performing finite difference with", len(self.model.fs.time), "time steps") 

192 TransformationFactory("dae.finite_difference").apply_to( 

193 self.model.fs, 

194 nfe=len(self.model.fs.time)-1, # Number of finite elements to use for discretization. We aren't adding any extra steps as our constraints dont work for that. 

195 wrt=self.model.fs.time, 

196 scheme="BACKWARD" 

197 ) 

198 

199 self.properties_map = self.model.fs.properties_map 

200 self.timing.step_out() 

201 

202 def load_specs(self) -> None: 

203 """ 

204 Loads expressions from the schema 

205 """ 

206 fs = self.model.fs 

207 specs = self.schema.expressions or [] 

208 

209 ## Build dependency tree and sort specs before loading. 

210 dependencies, result_ids = self.build_spec_dependency_tree(specs) 

211 sorted_result_ids = self.topological_sort(dependencies, result_ids) 

212 

213 # load the specs (expressions within specifications tab) 

214 for result_id in sorted_result_ids: 

215 spec_config = next(spec for spec in specs if spec["id"] == result_id) 

216 expression_str = spec_config["expression"] 

217 try: 

218 component_name = f"{spec_config['name']}_{spec_config['id']}" 

219 def expression_rule(blk, time_index): 

220 return parse_expression(expression_str, fs,time_index) 

221 

222 component = Expression(fs.time, rule=expression_rule) 

223 fs.add_component(component_name, component) 

224 fs.properties_map.add( 

225 spec_config["id"], component, component.name, unknown_units=True 

226 ) 

227 except ExpressionParsingError as e: 

228 raise ExpressionParsingError(f"{e} when parsing expression '{expression_str}' for {component_name}: ") 

229 

230 # load constraints (expressions for specific property infos) 

231 # can only handle equality constraints for now 

232 for component, expr_str, id in fs.constraint_exprs: 

233 # get the time index 

234 #for time_index in fs.time: 

235 if component.index_set().dimen != 0 and component.index_set() != fs.time: 235 ↛ 236line 235 didn't jump to line 236 because the condition on line 235 was never true

236 raise ExpressionParsingError(f"Cannot add constraint for {component}: only time-indexed components are supported.") 

237 try: 

238 def constraint_rule(blk, time_index): 

239 expression = parse_expression(expr_str, fs,time_index) 

240 # make sure the units of the expression are the same as the component 

241 u1, u2 = get_attached_unit(component), get_attached_unit(expression) 

242 if not check_units_equivalent(u1, u2): 242 ↛ 243line 242 didn't jump to line 243 because the condition on line 242 was never true

243 raise ValueError( 

244 f"Failed to add constraint for {component}: units do not match (expected {u1}, got {u2})" 

245 ) 

246 return pyunits.convert(component[time_index], to_units=u2) == expression 

247 c = Constraint(component.index_set(), rule= constraint_rule) 

248 name = f"equality_constraint_{id}" 

249 fs.add_component(name, c) 

250 add_corresponding_constraint(fs, c, id) 

251 except ExpressionParsingError as e: 

252 raise ExpressionParsingError(f"Failed to parse constraint expression '{expr_str}' for {component}: {expr_str}, error: {e}") 

253 

254 

255 def build_spec_dependency_tree(self, specs) -> tuple: 

256 """ 

257 Builds dependency tree for expressions based on the references in their respective expressions. 

258 """ 

259 dependencies = defaultdict( 

260 set 

261 ) # Maps an expression's result_id to a set of result_ids it depends on 

262 result_ids = set() # A set to track all result_ids 

263 

264 # Get a list of all result_id's. 

265 for spec in specs: 

266 result_id = spec["id"] 

267 result_ids.add(result_id) 

268 

269 for spec in specs: 

270 result_id = spec["id"] 

271 expression = spec["expression"] 

272 

273 # Find the result_ids that this expression depends on 

274 dependent_expressions = self.get_dependent_expressions( 

275 expression, result_ids 

276 ) 

277 

278 if dependent_expressions: 

279 # If the expression depends on another result_id, add dependency 

280 for id in dependent_expressions: 

281 dependencies[id].add(result_id) 

282 

283 return dependencies, result_ids 

284 

285 def get_dependent_expressions(self, expression: str, all_result_ids: set) -> list: 

286 """ 

287 Gets all result_ids referenced in the expression. 

288 """ 

289 # match result_ids starting with 'id_' followed by numbers 

290 ids = re.findall(r"\b(id_\d+)\b", expression) 

291 

292 # Filter the referenced_ids to only include those that are in all_result_ids 

293 valid_referenced_ids = [] 

294 for id in ids: 

295 # get the numeric part after "id_" and check if it's in the all_result_ids 

296 numeric_id = int(id[3:]) 

297 if numeric_id in all_result_ids: 

298 valid_referenced_ids.append(numeric_id) 

299 

300 return valid_referenced_ids 

301 

302 def topological_sort(self, dependencies: dict, result_ids: set) -> list: 

303 """ 

304 Performs topological sorting on the specification dependency tree. 

305 """ 

306 # Track teh in-degree count for all expressions (edges coming into it) 

307 in_degree = defaultdict(int) 

308 

309 # Count dependencies for each expression 

310 for result_id in result_ids: 

311 for dep in dependencies[result_id]: 

312 in_degree[dep] += 1 

313 

314 # Initialise the queue with result_ids that have no dependencies (in-degree 0) 

315 dequeue = deque( 

316 [result_id for result_id in result_ids if in_degree[result_id] == 0] 

317 ) 

318 

319 sorted_result_ids = [] 

320 

321 while dequeue: 

322 result_id = dequeue.popleft() 

323 sorted_result_ids.append(result_id) 

324 

325 # loop thtough and decrement each dependent expression's in_degree, and append it to the deque if it has an in-degree of 0. 

326 for dependent_result_id in dependencies[result_id]: 

327 in_degree[dependent_result_id] -= 1 

328 if in_degree[dependent_result_id] == 0: 

329 dequeue.append(dependent_result_id) 

330 

331 # If there are any result_ids left with non-zero in-degree, a cycle exists so error. 

332 if len(sorted_result_ids) != len(result_ids): 332 ↛ 333line 332 didn't jump to line 333 because the condition on line 332 was never true

333 raise ValueError( 

334 "Cycle detected in the dependency graph. Check an expression does not reference itself!" 

335 ) 

336 

337 return sorted_result_ids 

338 

339 def initialise(self) -> None: 

340 """ 

341 Expands the arcs and initialises the model 

342 """ 

343 # check if initialisation is disabled for this scenario 

344 if getattr(self.schema, "disable_initialization", False): 

345 # if disable initialisation is set to True, then we don't need to initialise the model 

346 _log.info("Initialisation is disabled for this scenario.") 

347 

348 # We need to "expand_arcs" to make them a bidirection link that actually imposes constraints on the model. 

349 TransformationFactory("network.expand_arcs").apply_to(self.model) 

350 

351 self.timing.step_into("initialise_model") 

352 

353 # load tear guesses (including var/constraint unfixing & deactivation and/or equality constraint deactivation) 

354 self.tears.load() 

355 

356 tears = self.tears._tears 

357 

358 def init_unit(unit): 

359 if getattr(self.schema, "disable_initialization", False): 359 ↛ 360line 359 didn't jump to line 360 because the condition on line 359 was never true

360 return 

361 if ( 

362 getattr( 

363 self.schema, 

364 "skip_initialization_for_units_with_initial_values", 

365 False, 

366 ) 

367 and self.unit_models.has_initial_values(unit) 

368 ): 

369 _log.info( 

370 f"Skipping initialization for unit {unit} because initial values exist." 

371 ) 

372 return 

373 

374 _log.info("Initializing unit %s", unit) 

375 self.timing.add_timing(f"init_{unit.name}") 

376 try: 

377 #unit.display() 

378 unit.initialize(outlvl=idaeslog.INFO) 

379 #unit.report() 

380 except InitializationError as e: 

381 # A unit initializer is a warm-start procedure. Even when its 

382 # temporary subproblem is locally infeasible, the final iterate 

383 # can still improve the starting point for the full flowsheet. 

384 _log.warning( 

385 "Unit initialization did not converge for %s; continuing " 

386 "with its current values: %s", 

387 unit.name, 

388 e, 

389 exc_info=True, 

390 ) 

391 except Exception as e: 

392 details = ( 

393 f"Unit initialization failed for {unit.name}: " 

394 f"{type(e).__name__}: {e}" 

395 ) 

396 _log.exception(details) 

397 raise RuntimeError(details) from e 

398 

399 self.timing.add_timing("setup_sequential_decomposition") 

400 # Use SequentialDecomposition to initialise the model 

401 seq = SequentialDecomposition( 

402 run_first_pass=True, 

403 ) 

404 seq.set_tear_set(tears) 

405 # use create_graph to get the order of sequential decomposition, and also to 

406 # find any units that are not connected to the sequential decomposition 

407 G = seq.create_graph(self.model) 

408 order = seq.calculation_order(G) 

409 seq_blocks = [] 

410 for o in order: 

411 # o is a list of "levels" of the graph, 

412 # all items in the first level have to no dependencies, the next level only depends on the previous levels. 

413 for b in o: 

414 seq_blocks.append(b) 

415 _log.info("Order of initialisation: %s", [blk.name for blk in seq_blocks]) 

416 # set all the tear guesses before running the decomposition 

417 for arc in tears: 

418 port = arc.destination 

419 # guesses used are initial values for each var 

420 guesses = {} 

421 guesses = {key: get_value(var) for key, var in port.vars.items()} 

422 _log.info("Guess for %s: %s", port, guesses) 

423 

424 self.timing.step_into("run_sequential_decomposition") 

425 

426 # sequential decomposition completes when all vars across port 

427 # equalities are within tol of each other 

428 seq.options["tol"] = 1e-2 

429 seq.options["solve_tears"] = False 

430 seq.run(self.model, init_unit) 

431 

432 self.timing.step_out() 

433 self.timing.add_timing("initialise_disconnected_units") 

434 # Initialise any unit model that is not connected to the sequential decomposition 

435 for blk in self.model.fs.component_data_objects( 

436 Block, descend_into=False, active=True 

437 ): 

438 ports = list(blk.component_objects(Port, descend_into=False)) 

439 if len(ports) == 0: 

440 continue # if the block has no ports, then it is not a unit model 

441 if blk in seq_blocks: 

442 continue # already initialised by sequential decomposition 

443 init_unit(blk) 

444 

445 # unfix guess vars 

446 deactivate_fixed_guesses(self.model.fs.guess_vars) 

447 

448 self.timing.step_out() 

449 

450 def serialise(self) -> SolvedFlowsheetSchema: 

451 self.timing.add_timing("serialise_model") 

452 

453 initial_values = {} 

454 for unit_model_id, unit_model in self.unit_models._unit_models.items(): 

455 initial_values[str(unit_model_id)] = to_json(unit_model, return_dict=True, wts=StoreSpec.value()) 

456 

457 solved_flowsheet = SolvedFlowsheetSchema( 

458 group_id=self.schema.group_id, 

459 properties=serialize_properties_map(self.model.fs), 

460 initial_values=initial_values 

461 ) 

462 

463 return solved_flowsheet 

464 

465 def report_statistics(self) -> None: 

466 """ 

467 Reports statistics about the model 

468 """ 

469 report_statistics(self.model) 

470 

471 def diagnose_problems(self) -> None: 

472 from idaes.core.util import DiagnosticsToolbox 

473 

474 _log.info("=== DIAGNOSTICS ===") 

475 

476 report_statistics(self.model) 

477 dt = DiagnosticsToolbox(self.model) 

478 dt.report_structural_issues() 

479 dt.display_overconstrained_set() 

480 dt.display_underconstrained_set() 

481 #dt.display_components_with_inconsistent_units() 

482 try: 

483 dt.compute_infeasibility_explanation() 

484 except Exception as e: 

485 _log.warning( 

486 "Error computing infeasibility explanation: %s", 

487 e, 

488 ) 

489 try: 

490 dt.report_numerical_issues() 

491 except Exception as e: 

492 _log.warning("Error reporting numerical issues: %s", e) 

493 try: 

494 dt.display_near_parallel_constraints() 

495 except Exception as e: 

496 _log.warning("Error displaying near parallel constraints: %s", e) 

497 

498 dt.display_variables_at_or_outside_bounds() 

499 dt.display_variables_near_bounds() 

500 try: 

501 dt.display_constraints_with_extreme_jacobians() 

502 except Exception as e: 

503 _log.warning("Error displaying constraints with extreme jacobians: %s", e) 

504 dt.display_constraints_with_large_residuals() 

505 _log.info("=== END DIAGNOSTICS ===") 

506 

507 def get_unit_diagnostics(self): 

508 diagnostics: list[tuple[Component, str]] = [] 

509 for unit_model in self.unit_models._unit_models.values(): 

510 if hasattr(unit_model, "diagnose"): 

511 try: 

512 results : list[tuple[Component, str]] = unit_model.diagnose() 

513 diagnostics.extend(results) 

514 # The unit diagnostics methods may be unreliable, as each unit model has different methods. 

515 # So we wrap in a try-except to avoid the whole diagnostics process failing if one unit model's diagnostics fails. 

516 except Exception as e: 

517 _log.warning("Error Diagnosing %s: %s", unit_model, e) 

518 return diagnostics 

519 

520 def get_unit_diagnostics_report(self) -> list[UnitDiagnosticsFinding]: 

521 """Gets the diagnostics, but finds the property value ID for each diagnostic based on the component it references.""" 

522 diagnostics = self.get_unit_diagnostics() 

523 diagnostics_report = [ 

524 UnitDiagnosticsFinding( 

525 severity="error", 

526 property_value_id=self.properties_map.get_id_by_component(component), 

527 message=message) 

528 for component, message in diagnostics 

529 ] 

530 diagnostics_report.extend( 

531 _get_watertap_solver_diagnostics( 

532 solver_option=self.schema.solver_option, 

533 unit_models=self.unit_models._unit_models.values(), 

534 properties_map=self.properties_map, 

535 ) 

536 ) 

537 diagnostics_report.extend( 

538 [UnitDiagnosticsFinding(property_value_id=id, 

539 message=f"High Infeasibility: {infeasibility:.4f}") 

540 for id, infeasibility in 

541 get_top_infeasibilities(self.properties_map, threshold=1e-3) 

542 ] 

543 ) 

544 

545 return diagnostics_report 

546 

547 def degrees_of_freedom(self) -> int: 

548 """ 

549 Returns the degrees of freedom of the model 

550 """ 

551 return int(degrees_of_freedom(self.model)) 

552 

553 def check_model_valid(self) -> None: 

554 """ 

555 Checks if the model is valid by checking the 

556 degrees of freedom. Will raise an exception if 

557 the model is not valid. 

558 """ 

559 self.timing.add_timing("check_model_valid") 

560 

561 degrees_of_freedom = self.degrees_of_freedom() 

562 if degrees_of_freedom != 0: 562 ↛ 564line 562 didn't jump to line 564 because the condition on line 562 was never true

563 #self.model.display() # prints the vars/constraints for debugging 

564 raise Exception( 

565 f"Degrees of freedom is not 0. Degrees of freedom: {degrees_of_freedom}" 

566 ) 

567 

568 def solve(self): 

569 """ 

570 Solves the model. Uses petsc_dae_by_time_element for the petsc solver 

571 (dynamic only), or a standard SolverFactory solver otherwise. 

572 """ 

573 self.timing.add_timing("solve_model") 

574 _log.info("=== Starting Solve ===") 

575 

576 if self.schema.solver_option == "petsc": 576 ↛ 577line 576 didn't jump to line 577 because the condition on line 576 was never true

577 from idaes.core.solvers import petsc as idaes_petsc 

578 

579 if not self.schema.dynamic: 

580 raise ValueError("The PETSc solver is only supported for dynamic flowsheets.") 

581 result = idaes_petsc.petsc_dae_by_time_element( 

582 self.model, 

583 time=self.model.fs.time, 

584 ts_options={ 

585 "--ts_type": "beuler", 

586 "--ts_dt": 1, 

587 "--ts_monitor": "", 

588 "--ts_save_trajectory": 1, 

589 }, 

590 ) 

591 

592 opt = SolverFactory(self.schema.solver_option) 

593 # opt.options["max_iter"] = 5000 

594 

595 opt.options["max_iter"] = 1000 

596 try: 

597 starting_values = get_current_values(self.properties_map) 

598 res = opt.solve(self.model, tee=True) 

599 updated_values = get_current_values(self.properties_map) 

600 changes = detect_changes(updated_values, starting_values) 

601 print_changes(changes, self.properties_map) 

602 

603 success = check_optimal_termination(res) 

604 return success 

605 except ValueError as e: 

606 if str(e).startswith("No variables appear"): 606 ↛ 610line 606 didn't jump to line 610 because the condition on line 606 was always true

607 # https://github.com/Pyomo/pyomo/pull/3445 

608 return True 

609 else: 

610 raise e 

611 

612 def optimize(self) -> None: 

613 if self.schema.optimizations is None or self.schema.optimizations == []: 

614 return 

615 

616 self.timing.add_timing("optimize_model") 

617 _log.info("=== Starting Optimization ===") 

618 

619 # ipopt doesn't support multiple objectives, so we need to create 

620 # a single objective expression. 

621 # this is done by summing all objectives in the model, adding 

622 # or subtracting based on the sense (minimize or maximize) 

623 objective_terms = [] 

624 for schema in self.schema.optimizations: 

625 # get the expression component to optimize 

626 # TODO: This is assuming optimisation is run on a steady-state simulation. We need to change how this works to handle dynamics. 

627 # For now, just hardcoding time_index=0 

628 objective_component = self.model.fs.properties_map.get_component(schema.objective) 

629 # the objective component should be a scalar in non-dynamic models 

630 # in a dynamic model it'll be indexed across all time steps 

631 # We sum up all time steps so each time step is weighted equally. 

632 objective = dimensionless_objective_expression( 

633 sum_pyomo_expressions(objective_component.values()) 

634 ) 

635 # add or subtract the objective based on the sense 

636 sense = schema.sense 

637 if sense == "minimize": 

638 objective_terms.append(objective) 

639 else: 

640 objective_terms.append(-objective) 

641 

642 

643 

644 # unfix relevant vars (add to degrees of freedom) 

645 for dof_info in schema.unfixed_variables: 

646 id = dof_info.id # Id of the propertyValue for this degree of freedom 

647 var: PropertyComponent = self.model.fs.properties_map.get(id) 

648 

649 

650 # TODO: may need to handle deactivating constraints, 

651 # for expressions that are constrained (instead of state vars) 

652 c = var.corresponding_constraint 

653 if c is not None: 

654 # TODO: better typing for constraints 

655 if isinstance(c, ScalarConstraint) or isinstance(c, IndexedConstraint): 655 ↛ 656line 655 didn't jump to line 656 because the condition on line 655 was never true

656 c.deactivate() 

657 else: 

658 deactivate_components(c) 

659 else: 

660 for i in var.component.values(): 

661 if isinstance(i, ScalarVar): 661 ↛ 662line 661 didn't jump to line 662 because the condition on line 661 was never true

662 i.unfix() 

663 # Because if not, it is ExpressionData, meaning it is already an expression and doesn't need to be unfixed. (we've already checked if there is a constraint for it above too.) 

664 

665 

666 # TODO: set attributes for upper and lower bounds of property infos. i.e. use propertyinfo id. 

667 # Var is either a Variable or Expression 

668 # set the minimum or maximum bounds for this variable if they are enabled 

669 #self.model.upper_bound_12 = Constraint(expr= var <= upper_bound_value ) 

670 

671 upper_bound = dof_info.upper_bound 

672 lower_bound = dof_info.lower_bound 

673 

674 c = var.component 

675 

676 if upper_bound is not None: 676 ↛ 682line 676 didn't jump to line 682 because the condition on line 676 was always true

677 def upper_bound_rule(model,index): 

678 return c[index] <= upper_bound 

679 upper_bound_constraint = Constraint(c.index_set(),rule=upper_bound_rule) 

680 setattr(self.model,"upper_bound_" + str(id), upper_bound_constraint) 

681 

682 if lower_bound is not None: 682 ↛ 645line 682 didn't jump to line 645 because the condition on line 682 was always true

683 def lower_bound_rule(model,index): 

684 return c[index] >= lower_bound 

685 lower_bound_constraint = Constraint(c.index_set(),rule=lower_bound_rule) 

686 setattr(self.model,"lower_bound_" + str(id), lower_bound_constraint) 

687 

688 

689 # add the objective to the model 

690 objective_expr = sum_pyomo_expressions(objective_terms) 

691 self.model.objective = Objective(expr=objective_expr, sense=minimize) 

692 

693 # solve the model with the objective 

694 opt = SolverFactory(self.schema.solver_option) 

695 

696 if self.schema.solver_option != "conopt": 696 ↛ 699line 696 didn't jump to line 699 because the condition on line 696 was always true

697 opt.options["max_iter"] = 1000 

698 

699 try: 

700 res = opt.solve(self.model, tee=True) 

701 assert_optimal_termination(res) 

702 except ValueError as e: 

703 if str(e).startswith("No variables appear"): 

704 # https://github.com/Pyomo/pyomo/pull/3445 

705 pass 

706 else: 

707 raise e