Coverage for backend/django/idaes_factory/idaes_factory.py: 96%

234 statements  

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

1import traceback 

2 

3from core.auxiliary.enums import SimulationObjectClass 

4from core.auxiliary.models import MLModel 

5from idaes_factory.adapters.ml_adapters import ( 

6 get_id_mappings, 

7 get_ml_properties, 

8 get_unitop_names, 

9) 

10from idaes_factory.adapters.property_package_adapter import PropertyPackageAdapter 

11from idaes_factory.adapters.generic_adapters import NumInletsAdapter, NumOutletsAdapter 

12from opentelemetry import trace 

13from CoreRoot import settings 

14from ahuora_builder_types.unit_model_schema import SolvedPropertyValueSchema, UnitModelSchema, ValueArgSchema 

15import dotenv 

16from typing import Any 

17from django.db import transaction 

18from ahuora_builder_types.scenario_schema import UnfixedVariableSchema, OptimizationSchema 

19from core.auxiliary.models.Scenario import Scenario, OptimizationDegreesOfFreedom 

20from core.auxiliary.property_state import ( 

21 validate_objective_property, 

22 validate_optimization_dof_property_value, 

23) 

24from core.exceptions import DetailedException 

25from flowsheetInternals.graphicData.models.groupingModel import Grouping 

26from flowsheetInternals.unitops.models.SimulationObject import SimulationObject 

27from flowsheetInternals.unitops.services.edit_operations.recorder import ( 

28 tracked_bulk_update, 

29) 

30from ahuora_builder_types import FlowsheetSchema 

31from core.auxiliary.models.PropertyInfo import PropertyInfo 

32from core.auxiliary.models.PropertyValue import PropertyValue 

33from core.auxiliary.models.Solution import Solution 

34from core.auxiliary.enums.unitsLibrary import units_library 

35from .adapters import arc_adapter 

36from .adapters.convert_expression import convert_expression 

37from .idaes_factory_context import IdaesFactoryContext, LiveSolveParams 

38from .queryset_lookup import get_value_object 

39from .unit_conversion import convert_value 

40from idaes_factory.unit_conversion.unit_conversion import can_convert 

41from core.auxiliary.models.Scenario import Scenario, SolverOptionEnum 

42from idaes_factory.build_hooks import IdaesBuildHookContext, run_before_context_load_hooks 

43 

44dotenv.load_dotenv() 

45 

46# Todo: replace these with literal types from the Compounds/PP library 

47Compound = str 

48PropertyPackage = str 

49 

50 

51class IdaesFactoryBuildException(DetailedException): 

52 pass 

53 

54 

55tracer = trace.get_tracer(settings.OPEN_TELEMETRY_TRACER_NAME) 

56 

57 

58class IdaesFactory: 

59 """ 

60 The IdaesFactory class is the core class for building 

61 a flowsheet (JSON schema) that can be sent to the IDAES 

62 solver, and for storing the results back in the database. 

63 """ 

64 

65 def __init__( 

66 self, 

67 group_id: int, 

68 scenario: Scenario | None = None, 

69 require_variables_fixed: bool = True, 

70 solve_index: int | None = None, 

71 

72 ) -> None: 

73 """Prepare a factory capable of serialising the requested flowsheet. 

74 

75 Args: 

76 group_id: Identifier of the flowsheet to serialise. 

77 scenario: Optional scenario providing solve configuration settings. 

78 require_variables_fixed: Whether adapters should enforce fixed variables. 

79 solve_index: Optional multi-steady-state index to bind to the context. 

80 """ 

81 

82 self.solve_index = solve_index 

83 self.scenario = scenario 

84 self._context_load_hooks_ran = False 

85 

86 if scenario is not None: 

87 is_dynamic = scenario.enable_dynamics 

88 step_size = scenario.simulation_length / \ 

89 float(scenario.num_time_steps) 

90 enable_rating = scenario.enable_rating 

91 

92 if scenario.enable_optimization: 

93 # If we are doing optimization, we solve from the root 

94 group_id = scenario.flowsheet_state.root_grouping_id 

95 else: 

96 is_dynamic = False 

97 step_size = 1 # Just need a placeholder value. 

98 enable_rating = False 

99 

100 time_steps = list([int(i) * step_size for i in range(0, 

101 scenario.num_time_steps)]) if is_dynamic else [0] 

102 

103 self.flowsheet = FlowsheetSchema( 

104 group_id=group_id, 

105 dynamic=is_dynamic, 

106 time_set=time_steps, 

107 property_packages=[], 

108 unit_models=[], 

109 arcs=[], 

110 expressions=[], 

111 optimizations=[], 

112 is_rating_mode=enable_rating, 

113 disable_initialization=getattr( 

114 scenario, "disable_initialization", False), 

115 skip_initialization_for_units_with_initial_values=getattr( 

116 scenario, 

117 "skip_initialization_for_units_with_initial_values", 

118 False, 

119 ), 

120 solver_option=getattr(scenario, "solver_option", "ipopt"), 

121 ) 

122 

123 self._run_before_context_load_hooks_once(group_id, solve_index) 

124 

125 # factory context 

126 self.context = IdaesFactoryContext( 

127 group_id, 

128 require_variables_fixed=require_variables_fixed, 

129 solve_index=solve_index, 

130 time_steps=time_steps, 

131 time_step_size=step_size, 

132 scenario=scenario, 

133 ) 

134 

135 def _run_before_context_load_hooks_once(self, group_id: int, solve_index: int | None) -> None: 

136 """Run pre-load hooks exactly once for this factory instance.""" 

137 

138 if self._context_load_hooks_ran: 138 ↛ 139line 138 didn't jump to line 139 because the condition on line 138 was never true

139 return 

140 if self.scenario is not None: 

141 active_flowsheet = self.scenario.flowsheet_state.flowsheet 

142 else: 

143 active_flowsheet = ( 

144 Grouping.objects.select_related("flowsheet_state__flowsheet") 

145 .get(id=group_id) 

146 .flowsheet_state.flowsheet 

147 ) 

148 run_before_context_load_hooks( 

149 IdaesBuildHookContext( 

150 flowsheet=active_flowsheet, 

151 scenario=self.scenario, 

152 group_id=group_id, 

153 solve_index=solve_index, 

154 ) 

155 ) 

156 self._context_load_hooks_ran = True 

157 

158 # Updates the context to use a different solve index. 

159 # build() should be called after this to update the extracted flowsheet data. 

160 def use_with_solve_index(self, solve_index: int) -> None: 

161 """Rebind the factory to a different multi steady-state solve index. 

162 

163 Args: 

164 solve_index: Index of the solve configuration within the scenario. 

165 """ 

166 self.solve_index = solve_index 

167 self.context.update_solve_index(self.solve_index) 

168 

169 @tracer.start_as_current_span("build_flowsheet") 

170 def build(self): 

171 """Populate the flowsheet schema with units, arcs, expressions, and metadata. 

172 

173 Raises: 

174 IdaesFactoryBuildException: If any adapter fails during serialisation. 

175 """ 

176 try: 

177 self.setup_unit_models() 

178 self.create_arcs() 

179 self.add_property_packages() 

180 self.add_expressions() 

181 self.add_optimizations() 

182 self.check_dependencies() 

183 except Exception as e: 

184 raise IdaesFactoryBuildException(e, "idaes_factory_build") from e 

185 

186 def clear_flowsheet(self) -> None: 

187 """Reset the in-memory flowsheet while preserving configuration metadata.""" 

188 self.flowsheet = FlowsheetSchema( 

189 group_id=self.flowsheet.group_id, 

190 dynamic=self.flowsheet.dynamic, 

191 time_set=self.flowsheet.time_set, 

192 property_packages=[], 

193 unit_models=[], 

194 arcs=[], 

195 expressions=[], 

196 optimizations=[], 

197 is_rating_mode=self.flowsheet.is_rating_mode, 

198 disable_initialization=self.flowsheet.disable_initialization, 

199 skip_initialization_for_units_with_initial_values=( 

200 self.flowsheet.skip_initialization_for_units_with_initial_values 

201 ), 

202 solver_option=self.flowsheet.solver_option 

203 ) 

204 

205 def add_property_packages(self) -> None: 

206 """Attach any property packages collected during context loading.""" 

207 self.flowsheet.property_packages = self.context.property_packages 

208 

209 def setup_unit_models(self): 

210 """Serialise all unit operations.""" 

211 # add all unit models 

212 exclude = {"stream", "recycle", "specificationBlock", 

213 "energy_stream", "ac_stream", "humid_air_stream", "transformer_stream"} 

214 for unit_model in self.context.exclude_object_type(exclude): 

215 self.add_unit_model(unit_model) 

216 

217 def add_ml_model_properties(self, unit_model: SimulationObject, ml_model: MLModel) -> UnitModelSchema: 

218 """Serialise an attached ML model without creating independent ports.""" 

219 return UnitModelSchema( 

220 id=ml_model.pk*-1, # use negative ids for attached ML models to avoid conflicts with SimulationObject ids 

221 type=SimulationObjectClass.MachineLearningBlock, 

222 name=unit_model.componentName + str(ml_model.pk), 

223 args={ 

224 "property_package": PropertyPackageAdapter().serialise(self.context, unit_model), 

225 "model": ValueArgSchema(value=ml_model.surrogate_model), 

226 "ids": get_id_mappings(ml_model), 

227 "unitopNames": get_unitop_names(ml_model), 

228 "num_inlets": NumInletsAdapter().serialise(self.context, unit_model), 

229 "num_outlets": NumOutletsAdapter().serialise(self.context, unit_model), 

230 }, 

231 properties=get_ml_properties(self.context, ml_model), 

232 ports={}, 

233 initial_values={} 

234 ) 

235 

236 def add_unit_model(self, unit_model: SimulationObject) -> None: 

237 """Serialise and append a unit model using its registered adapter. 

238 

239 Args: 

240 unit_model: Simulation object to convert into IDAES schema. 

241 

242 Raises: 

243 Exception: If the adapter fails to serialise the unit model. 

244 """ 

245 try: 

246 adapter = unit_model.schema.idaes_adapter 

247 if adapter is None: 247 ↛ 248line 247 didn't jump to line 248 because the condition on line 247 was never true

248 raise ValueError( 

249 f"No IDAES adapter registered for object type {unit_model.objectType}" 

250 ) 

251 schema = adapter.serialise(self.context, unit_model) 

252 self.flowsheet.unit_models.append(schema) 

253 if unit_model.objectType != "machineLearningBlock" and unit_model.MLModels.exists(): 

254 

255 for ml_model in unit_model.MLModels.all(): 

256 self.flowsheet.unit_models.append(self.add_ml_model_properties(unit_model, ml_model)) 

257 

258 except Exception as e: 

259 raise Exception( 

260 f"Error adding unit model {unit_model.componentName} to the flowsheet: {e}" 

261 ) 

262 

263 def add_expressions(self) -> None: 

264 """Collect custom property expressions and expose them on the flowsheet.""" 

265 # expressions are stored in the property set of a group 

266 # eg. the global base flowsheet object 

267 simulation_object: SimulationObject 

268 for simulation_object in self.context.exclude_object_type({"machineLearningBlock"}): 

269 # skip machine learning blocks, their properties are handled differently. We still need to support them in future. 

270 

271 properties = simulation_object.properties 

272 prop: PropertyInfo 

273 for prop in properties.ContainedProperties.all(): 

274 if prop.key in simulation_object.schema.properties: 

275 # This is a default property, we have already processed it. 

276 # We only want to capture custom properties 

277 continue 

278 if prop.formula_incomplete: 

279 # Incomplete generated formulas stay visible in the UI but 

280 # cannot be represented in the IDAES expression payload. 

281 continue 

282 property_value = get_value_object(prop) 

283 if property_value is None: 283 ↛ 284line 283 didn't jump to line 284 because the condition on line 283 was never true

284 continue 

285 self.context.add_property_value_dependency(property_value) 

286 if property_value.formula in (None, ""): 

287 # Custom properties can also be plain user-entered values. 

288 # Only formula-backed custom properties are builder expressions. 

289 continue 

290 self._add_expression(prop, property_value, track_dependency=False) 

291 self.add_managed_expressions() 

292 

293 def add_managed_expressions(self) -> None: 

294 """Serialize complete managed formulas that live outside the active group.""" 

295 

296 if not self.context.has_loaded_managed_properties(): 

297 return 

298 

299 existing_expression_ids = {expression["id"] for expression in self.flowsheet.expressions} 

300 properties = ( 

301 PropertyInfo.objects.filter( 

302 flowsheet_state=self._active_flowsheet_state(), 

303 managed=True, 

304 formula_incomplete=False, 

305 values__formula__isnull=False, 

306 ) 

307 .prefetch_related("values") 

308 .distinct() 

309 ) 

310 for prop in properties: 

311 property_value = get_value_object(prop) 

312 if property_value is None or property_value.formula in (None, ""): 312 ↛ 313line 312 didn't jump to line 313 because the condition on line 312 was never true

313 continue 

314 if property_value.id in existing_expression_ids: 

315 continue 

316 self._add_expression(prop, property_value) 

317 existing_expression_ids.add(property_value.id) 

318 

319 def _add_expression( 

320 self, 

321 prop: PropertyInfo, 

322 property_value: PropertyValue, 

323 *, 

324 track_dependency: bool = True, 

325 ) -> None: 

326 if track_dependency: 

327 self.context.add_property_value_dependency(property_value) 

328 self.flowsheet.expressions.append( 

329 { 

330 "id": property_value.id, 

331 "name": prop.displayName, 

332 "expression": convert_expression( 

333 property_value.formula, 

334 self.context, 

335 property_value, 

336 ), 

337 } 

338 ) 

339 

340 def _active_flowsheet_state(self): 

341 """Return the concrete state being serialized.""" 

342 

343 if self.scenario is not None: 

344 return self.scenario.flowsheet_state 

345 return self.context.group.flowsheet_state 

346 

347 def add_optimizations(self) -> None: 

348 """Serialise scenario-level optimisation settings onto the flowsheet.""" 

349 # This method was originally written to return multiple optimisations. 

350 # this doesn't make sense, but idaes_service hasn't been updated to only expect one. 

351 # so for now, it sets optimisations to an array with one item 

352 optimization = self.context.scenario 

353 if optimization is None or optimization.enable_optimization is False: 

354 # no optimization to add 

355 return 

356 sense = "minimize" if optimization.minimize else "maximize" 

357 if optimization.objective is None: 357 ↛ 358line 357 didn't jump to line 358 because the condition on line 357 was never true

358 raise ValueError( 

359 "Please set an objective for the optimization to minimize or maximize.") 

360 objective = optimization.objective 

361 validate_objective_property(objective) 

362 objective_value = get_value_object(objective) 

363 if objective_value is None: 363 ↛ 364line 363 didn't jump to line 364 because the condition on line 363 was never true

364 raise ValueError("The selected objective does not have a property value.") 

365 

366 degrees_of_freedom = [] 

367 degree_of_freedom: OptimizationDegreesOfFreedom 

368 for degree_of_freedom in optimization.degreesOfFreedom.all(): 

369 validate_optimization_dof_property_value(degree_of_freedom.propertyValue) 

370 property_value_id = degree_of_freedom.propertyValue_id 

371 

372 dof_schema = UnfixedVariableSchema( 

373 id=property_value_id, 

374 lower_bound=degree_of_freedom.lower_bound, 

375 upper_bound=degree_of_freedom.upper_bound 

376 ) 

377 degrees_of_freedom.append(dof_schema) 

378 

379 self.flowsheet.optimizations.append(OptimizationSchema( 

380 objective=objective_value.id, 

381 sense=sense, 

382 unfixed_variables=degrees_of_freedom, 

383 )) 

384 

385 def check_dependencies(self) -> None: 

386 """Verify that all property value dependencies are serialised""" 

387 if self.scenario is not None and self.scenario.enable_optimization is True: 

388 # No need to check since we are serialising everything 

389 return 

390 serialised_property_values = self.context.serialised_property_values 

391 for dependency, prop_values in self.context.property_value_dependencies.items(): 

392 if dependency not in serialised_property_values: 

393 dependency_prop_value = PropertyValue.objects.get(id=dependency) 

394 prop_info_list_str = ", ".join([f"{prop_value.get_simulation_object().componentName}/{prop_value.property.displayName}" for prop_value in prop_values]) 

395 raise Exception(f"Dependency property {dependency_prop_value.get_simulation_object().componentName}/{dependency_prop_value.property.displayName} is not serialised, but is required by properties {prop_info_list_str}.") 

396 

397 def create_arcs(self): 

398 """Serialise stream-like objects into arc connections for the flowsheet.""" 

399 serialised_port_ids = { 

400 port.id 

401 for unit_model in self.flowsheet.unit_models 

402 for port in unit_model.ports.values() 

403 } 

404 streams = self.context.filter_object_type( 

405 {"stream", "energy_stream", "ac_stream", "humid_air_stream"}) 

406 for stream in streams: 

407 arc_schema = arc_adapter.create_arc(self.context, stream) 

408 

409 if arc_schema is not None: 

410 if ( 

411 arc_schema.source not in serialised_port_ids 

412 or arc_schema.destination not in serialised_port_ids 

413 ): 

414 continue 

415 self.flowsheet.arcs.append(arc_schema) 

416 

417 

418def _convert_solved_value( 

419 value: float | list[float], 

420 *, 

421 from_unit: str, 

422 to_unit: str, 

423) -> float | list[float]: 

424 """Convert scalar and time-series results into the persisted property unit.""" 

425 if isinstance(value, list): 

426 return [ 

427 convert_value(item, from_unit=from_unit, to_unit=to_unit) 

428 for item in value 

429 ] 

430 return convert_value(value, from_unit=from_unit, to_unit=to_unit) 

431 

432 

433# noinspection PyUnreachableCode 

434def store_properties_schema( 

435 properties_schema: list[SolvedPropertyValueSchema] | None, 

436 flowsheet_state_id: int, 

437 scenario_id: int | None = None, 

438 solve_index: int | None = None 

439) -> None: 

440 """Persist solved property values and dynamic results to the database. 

441 

442 Args: 

443 properties_schema: Collection of property payloads returned by IDAES. 

444 flowsheet_state_id: Identifier of the state whose properties were solved. 

445 scenario_id: Optional scenario identifier associated with the solve. 

446 solve_index: Multi-steady-state index for the stored values, if any. 

447 """ 

448 if not properties_schema: 

449 return 

450 properties_schema = [ 

451 SolvedPropertyValueSchema(**prop) if isinstance(prop, dict) else prop 

452 for prop in properties_schema 

453 ] 

454 # create a id->property map 

455 ids = [prop.id for prop in properties_schema] 

456 property_values = PropertyValue.objects.filter( 

457 id__in=ids, 

458 flowsheet_state_id=flowsheet_state_id, 

459 ).select_related("property") 

460 prop_map = {prop.id: prop for prop in property_values} 

461 

462 property_values = [] 

463 property_infos = [] 

464 dynamic_results = [] 

465 

466 for prop_schema in properties_schema: 

467 prop = prop_map.get(prop_schema.id, None) 

468 if prop is None: 

469 raise Exception( 

470 f"Property {prop_schema.id} not found in the database.") 

471 

472 property_info: PropertyInfo = prop.property 

473 updated_value = prop_schema.value 

474 from_unit = prop_schema.unit 

475 

476 if prop_schema.unknown_units and not can_convert( 

477 from_unit, property_info.unit 

478 ): 

479 # we don't know the category of unit_type this unit is in! 

480 # default to "unknown" with a custom unit 

481 property_info.unitType = "unknown" 

482 property_info.unit = from_unit 

483 to_unit = from_unit 

484 # try to find the unit_type by looping through all 

485 # the units library and checking the first unit in the unit_type 

486 # to see if it can be converted 

487 for unit_type in units_library.keys(): 

488 default_unit = units_library[unit_type][0]["value"] 

489 if can_convert(from_unit, default_unit): 

490 # update the unitType 

491 property_info.unitType = unit_type 

492 property_info.unit = default_unit 

493 to_unit = default_unit 

494 break 

495 property_infos.append(property_info) 

496 else: 

497 to_unit = property_info.unit 

498 

499 converted_value = _convert_solved_value( 

500 updated_value, 

501 from_unit=from_unit, 

502 to_unit=to_unit, 

503 ) 

504 

505 is_multi_steady_state = solve_index is not None 

506 is_dynamics = scenario_id is not None and isinstance( 

507 converted_value, list) and len(converted_value) > 1 

508 

509 # Scenario consumers use PropertyInfo.unit as their display and costing 

510 # unit, so Solution values must be normalised before they are persisted. 

511 if is_multi_steady_state or is_dynamics: 

512 dynamic_result = Solution( 

513 property=prop, 

514 flowsheet_state_id=flowsheet_state_id, 

515 solve_index=solve_index, 

516 scenario_id=scenario_id 

517 ) 

518 dynamic_result.values = ( 

519 converted_value 

520 if isinstance(converted_value, list) 

521 else [converted_value] 

522 ) 

523 dynamic_results.append(dynamic_result) 

524 continue 

525 

526 # TODO: better handling of multi-dimensional indexed properties. 

527 if isinstance(converted_value, list): 

528 new_value = converted_value[0] 

529 else: 

530 new_value = converted_value 

531 prop.value = new_value 

532 property_values.append(prop) 

533 

534 with transaction.atomic(): 

535 # save the property values 

536 tracked_bulk_update(PropertyValue.objects, property_values, ["value"]) 

537 Solution.objects.bulk_create( 

538 dynamic_results, 

539 update_conflicts=True, 

540 update_fields=["values"], 

541 unique_fields=["pk"], 

542 ) 

543 

544 # save the property infos 

545 tracked_bulk_update( 

546 PropertyInfo.objects, 

547 property_infos, 

548 ["unitType", "unit"], 

549 ) 

550 

551 

552def save_all_initial_values(unit_models: dict[str, Any]) -> None: 

553 """Persist initial values returned from IDAES for each unit model. 

554 

555 Args: 

556 unit_models: Mapping of unit model ids to serialised initial value payloads. 

557 """ 

558 simulation_objects = {unit_op.id: unit_op for unit_op in (SimulationObject.objects 

559 .filter(id__in=unit_models.keys()) 

560 .only("id", "initial_values") 

561 )} 

562 

563 for unit_model_id, unit_model in unit_models.items(): 

564 simulation_object = simulation_objects.get(int(unit_model_id)) 

565 if simulation_object is None: 

566 # Attached ML surrogate sidecars are emitted with MLModel ids, not 

567 # SimulationObject ids, so they have no flowsheet object to persist 

568 # initial values back onto. 

569 continue 

570 initial_values = unit_model 

571 simulation_object.initial_values = initial_values 

572 

573 tracked_bulk_update( 

574 SimulationObject.objects, 

575 simulation_objects.values(), 

576 ["initial_values"], 

577 )