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

227 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2026-06-23 21:51 +0000

1import traceback 

2 

3from core.auxiliary.enums.unitOpData import SimulationObjectClass 

4from core.auxiliary.models import MLModel 

5from flowsheetInternals.unitops.config.objects.machine_learning_block_config import IDAdapter, MLPropetiesAdapter, unitopNamesAdapter, get_ml_properties, get_id_mappings, get_unitop_names 

6from idaes_factory.adapters.property_package_adapter import PropertyPackageAdapter 

7from idaes_factory.adapters.generic_adapters import NumInletsAdapter, NumOutletsAdapter 

8from opentelemetry import trace 

9from CoreRoot import settings 

10from ahuora_builder_types.unit_model_schema import SolvedPropertyValueSchema, UnitModelSchema, ValueArgSchema 

11import dotenv 

12from typing import Any 

13from django.db import transaction 

14from ahuora_builder_types.scenario_schema import UnfixedVariableSchema, OptimizationSchema 

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

16from core.auxiliary.property_state import ( 

17 validate_objective_property, 

18 validate_optimization_dof_property_value, 

19) 

20from core.exceptions import DetailedException 

21from flowsheetInternals.graphicData.models.groupingModel import Grouping 

22from flowsheetInternals.unitops.models.SimulationObject import SimulationObject 

23from ahuora_builder_types import FlowsheetSchema 

24from core.auxiliary.models.PropertyInfo import PropertyInfo 

25from core.auxiliary.models.PropertyValue import PropertyValue 

26from core.auxiliary.models.Solution import Solution 

27from core.auxiliary.enums.unitsLibrary import units_library 

28from .adapters import arc_adapter 

29from .adapters.convert_expression import convert_expression 

30from .idaes_factory_context import IdaesFactoryContext, LiveSolveParams 

31from .queryset_lookup import get_value_object 

32from .unit_conversion import convert_value 

33from idaes_factory.unit_conversion.unit_conversion import can_convert 

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

35from idaes_factory.build_hooks import IdaesBuildHookContext, run_before_context_load_hooks 

36 

37dotenv.load_dotenv() 

38 

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

40Compound = str 

41PropertyPackage = str 

42 

43 

44class IdaesFactoryBuildException(DetailedException): 

45 pass 

46 

47 

48tracer = trace.get_tracer(settings.OPEN_TELEMETRY_TRACER_NAME) 

49 

50 

51class IdaesFactory: 

52 """ 

53 The IdaesFactory class is the core class for building 

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

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

56 """ 

57 

58 def __init__( 

59 self, 

60 group_id: int, 

61 scenario: Scenario | None = None, 

62 require_variables_fixed: bool = True, 

63 solve_index: int | None = None, 

64 

65 ) -> None: 

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

67 

68 Args: 

69 group_id: Identifier of the flowsheet to serialise. 

70 scenario: Optional scenario providing solve configuration settings. 

71 require_variables_fixed: Whether adapters should enforce fixed variables. 

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

73 """ 

74 

75 self.solve_index = solve_index 

76 self.scenario = scenario 

77 self._context_load_hooks_ran = False 

78 

79 if scenario is not None: 

80 is_dynamic = scenario.enable_dynamics 

81 step_size = scenario.simulation_length / \ 

82 float(scenario.num_time_steps) 

83 enable_rating = scenario.enable_rating 

84 

85 if scenario.enable_optimization: 

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

87 group_id = scenario.flowsheet.rootGrouping.id 

88 else: 

89 is_dynamic = False 

90 step_size = 1 # Just need a placeholder value. 

91 enable_rating = False 

92 

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

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

95 

96 self.flowsheet = FlowsheetSchema( 

97 group_id=group_id, 

98 dynamic=is_dynamic, 

99 time_set=time_steps, 

100 property_packages=[], 

101 unit_models=[], 

102 arcs=[], 

103 expressions=[], 

104 optimizations=[], 

105 is_rating_mode=enable_rating, 

106 disable_initialization=getattr( 

107 scenario, "disable_initialization", False), 

108 skip_initialization_for_units_with_initial_values=getattr( 

109 scenario, 

110 "skip_initialization_for_units_with_initial_values", 

111 False, 

112 ), 

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

114 ) 

115 

116 self._run_before_context_load_hooks_once(group_id, solve_index) 

117 

118 # factory context 

119 self.context = IdaesFactoryContext( 

120 group_id, 

121 require_variables_fixed=require_variables_fixed, 

122 solve_index=solve_index, 

123 time_steps=time_steps, 

124 time_step_size=step_size, 

125 scenario=scenario, 

126 ) 

127 

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

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

130 

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

132 return 

133 if self.scenario is not None: 

134 active_flowsheet = self.scenario.flowsheet 

135 else: 

136 active_flowsheet = ( 

137 Grouping.objects.select_related("flowsheet") 

138 .get(id=group_id) 

139 .flowsheet 

140 ) 

141 run_before_context_load_hooks( 

142 IdaesBuildHookContext( 

143 flowsheet=active_flowsheet, 

144 scenario=self.scenario, 

145 group_id=group_id, 

146 solve_index=solve_index, 

147 ) 

148 ) 

149 self._context_load_hooks_ran = True 

150 

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

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

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

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

155 

156 Args: 

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

158 """ 

159 self.solve_index = solve_index 

160 self.context.update_solve_index(self.solve_index) 

161 

162 @tracer.start_as_current_span("build_flowsheet") 

163 def build(self): 

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

165 

166 Raises: 

167 IdaesFactoryBuildException: If any adapter fails during serialisation. 

168 """ 

169 try: 

170 self.setup_unit_models() 

171 self.create_arcs() 

172 self.add_property_packages() 

173 self.add_expressions() 

174 self.add_optimizations() 

175 self.check_dependencies() 

176 except Exception as e: 

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

178 

179 def clear_flowsheet(self) -> None: 

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

181 self.flowsheet = FlowsheetSchema( 

182 group_id=self.flowsheet.group_id, 

183 dynamic=self.flowsheet.dynamic, 

184 time_set=self.flowsheet.time_set, 

185 property_packages=[], 

186 unit_models=[], 

187 arcs=[], 

188 expressions=[], 

189 optimizations=[], 

190 is_rating_mode=self.flowsheet.is_rating_mode, 

191 disable_initialization=self.flowsheet.disable_initialization, 

192 skip_initialization_for_units_with_initial_values=( 

193 self.flowsheet.skip_initialization_for_units_with_initial_values 

194 ), 

195 solver_option=self.flowsheet.solver_option 

196 ) 

197 

198 def add_property_packages(self) -> None: 

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

200 self.flowsheet.property_packages = self.context.property_packages 

201 

202 def setup_unit_models(self): 

203 """Serialise all unit operations.""" 

204 # add all unit models 

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

206 "energy_stream", "ac_stream", "humid_air_stream", "transformer_stream"} 

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

208 self.add_unit_model(unit_model) 

209 

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

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

212 return UnitModelSchema( 

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

214 type=SimulationObjectClass.MachineLearningBlock, 

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

216 args={ 

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

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

219 "ids": get_id_mappings(ml_model), 

220 "unitopNames": get_unitop_names(ml_model), 

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

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

223 }, 

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

225 ports={}, 

226 initial_values={} 

227 ) 

228 

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

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

231 

232 Args: 

233 unit_model: Simulation object to convert into IDAES schema. 

234 

235 Raises: 

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

237 """ 

238 try: 

239 adapter = unit_model.schema.idaes_adapter 

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

241 raise ValueError( 

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

243 ) 

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

245 self.flowsheet.unit_models.append(schema) 

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

247 

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

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

250 

251 except Exception as e: 

252 raise Exception( 

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

254 ) 

255 

256 def add_expressions(self) -> None: 

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

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

259 # eg. the global base flowsheet object 

260 simulation_object: SimulationObject 

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

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

263 

264 properties = simulation_object.properties 

265 prop: PropertyInfo 

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

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

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

269 # We only want to capture custom properties 

270 continue 

271 if prop.formula_incomplete: 

272 # Incomplete generated formulas stay visible in the UI but 

273 # cannot be represented in the IDAES expression payload. 

274 continue 

275 property_value = get_value_object(prop) 

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

277 continue 

278 self.context.add_property_value_dependency(property_value) 

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

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

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

282 continue 

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

284 self.add_managed_expressions() 

285 

286 def add_managed_expressions(self) -> None: 

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

288 

289 if not self.context.has_loaded_managed_properties(): 

290 return 

291 

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

293 properties = ( 

294 PropertyInfo.objects.filter( 

295 flowsheet=self._active_flowsheet(), 

296 managed=True, 

297 formula_incomplete=False, 

298 values__formula__isnull=False, 

299 ) 

300 .prefetch_related("values") 

301 .distinct() 

302 ) 

303 for prop in properties: 

304 property_value = get_value_object(prop) 

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

306 continue 

307 if property_value.id in existing_expression_ids: 

308 continue 

309 self._add_expression(prop, property_value) 

310 existing_expression_ids.add(property_value.id) 

311 

312 def _add_expression( 

313 self, 

314 prop: PropertyInfo, 

315 property_value: PropertyValue, 

316 *, 

317 track_dependency: bool = True, 

318 ) -> None: 

319 if track_dependency: 

320 self.context.add_property_value_dependency(property_value) 

321 self.flowsheet.expressions.append( 

322 { 

323 "id": property_value.id, 

324 "name": prop.displayName, 

325 "expression": convert_expression( 

326 property_value.formula, 

327 self.context, 

328 property_value, 

329 ), 

330 } 

331 ) 

332 

333 def _active_flowsheet(self): 

334 """Return the flowsheet being serialized, with or without a scenario.""" 

335 

336 if self.scenario is not None: 

337 return self.scenario.flowsheet 

338 return self.context.group.flowsheet 

339 

340 def add_optimizations(self) -> None: 

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

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

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

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

345 optimization = self.context.scenario 

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

347 # no optimization to add 

348 return 

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

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

351 raise ValueError( 

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

353 objective = optimization.objective 

354 validate_objective_property(objective) 

355 objective_value = get_value_object(objective) 

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

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

358 

359 degrees_of_freedom = [] 

360 degree_of_freedom: OptimizationDegreesOfFreedom 

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

362 validate_optimization_dof_property_value(degree_of_freedom.propertyValue) 

363 property_value_id = degree_of_freedom.propertyValue_id 

364 

365 dof_schema = UnfixedVariableSchema( 

366 id=property_value_id, 

367 lower_bound=degree_of_freedom.lower_bound, 

368 upper_bound=degree_of_freedom.upper_bound 

369 ) 

370 degrees_of_freedom.append(dof_schema) 

371 

372 self.flowsheet.optimizations.append(OptimizationSchema( 

373 objective=objective_value.id, 

374 sense=sense, 

375 unfixed_variables=degrees_of_freedom, 

376 )) 

377 

378 def check_dependencies(self) -> None: 

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

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

381 # No need to check since we are serialising everything 

382 return 

383 serialised_property_values = self.context.serialised_property_values 

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

385 if dependency not in serialised_property_values: 

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

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

388 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}.") 

389 

390 def create_arcs(self): 

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

392 streams = self.context.filter_object_type( 

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

394 for stream in streams: 

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

396 

397 if arc_schema is not None: 

398 self.flowsheet.arcs.append(arc_schema) 

399 

400 

401# noinspection PyUnreachableCode 

402def store_properties_schema( 

403 properties_schema: list[SolvedPropertyValueSchema] | None, 

404 flowsheet_id: int, 

405 scenario_id: int | None = None, 

406 solve_index: int | None = None 

407) -> None: 

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

409 

410 Args: 

411 properties_schema: Collection of property payloads returned by IDAES. 

412 flowsheet_id: Identifier of the flowsheet whose properties were solved. 

413 scenario_id: Optional scenario identifier associated with the solve. 

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

415 """ 

416 if not properties_schema: 

417 return 

418 properties_schema = [ 

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

420 for prop in properties_schema 

421 ] 

422 # create a id->property map 

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

424 property_values = PropertyValue.objects.filter(id__in=ids).select_related( 

425 "property" 

426 ) 

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

428 

429 property_values = [] 

430 property_infos = [] 

431 dynamic_results = [] 

432 

433 for prop_schema in properties_schema: 

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

435 if prop is None: 435 ↛ 436line 435 didn't jump to line 436 because the condition on line 435 was never true

436 raise Exception( 

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

438 

439 property_info: PropertyInfo = prop.property 

440 updated_value = prop_schema.value 

441 

442 updated_value = prop_schema.value 

443 

444 from_unit = prop_schema.unit 

445 

446 is_multi_steady_state = solve_index is not None 

447 is_dynamics = scenario_id != None and isinstance( 

448 updated_value, list) and len(updated_value) > 1 

449 

450 # If we're doing MSS or dynamics, we need to create associated dynamic results. 

451 if is_multi_steady_state or is_dynamics: 

452 dynamic_result = Solution( 

453 property=prop, 

454 flowsheet_id=flowsheet_id, 

455 solve_index=solve_index, 

456 scenario_id=scenario_id 

457 ) 

458 

459 # If we're dealing with dynamics, the updated_value is a list of values. 

460 # Otherwise, it's a single scalar value. 

461 dynamic_result.values = (updated_value if isinstance(updated_value, list) 

462 else [updated_value]) 

463 dynamic_results.append(dynamic_result) 

464 continue 

465 

466 # else we can continue as normal 

467 if prop_schema.unknown_units and not can_convert( 

468 from_unit, property_info.unit 

469 ): 

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

471 # default to "unknown" with a custom unit 

472 property_info.unitType = "unknown" 

473 property_info.unit = from_unit 

474 to_unit = from_unit 

475 # try to find the unit_type by looping through all 

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

477 # to see if it can be converted 

478 for unit_type in units_library.keys(): 

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

480 if can_convert(from_unit, default_unit): 

481 # update the unitType 

482 property_info.unitType = unit_type 

483 property_info.unit = default_unit 

484 to_unit = default_unit 

485 break 

486 property_infos.append(property_info) 

487 else: 

488 to_unit = property_info.unit 

489 

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

491 if isinstance(updated_value, list): 

492 val = updated_value[0] 

493 else: 

494 val = updated_value 

495 

496 new_value = convert_value(val, from_unit=from_unit, to_unit=to_unit) 

497 prop.value = new_value 

498 property_values.append(prop) 

499 

500 with transaction.atomic(): 

501 # save the property values 

502 PropertyValue.objects.bulk_update(property_values, ["value"]) 

503 Solution.objects.bulk_create( 

504 dynamic_results, 

505 update_conflicts=True, 

506 update_fields=["values"], 

507 unique_fields=["pk"], 

508 ) 

509 

510 # save the property infos 

511 PropertyInfo.objects.bulk_update(property_infos, ["unitType", "unit"]) 

512 

513 

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

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

516 

517 Args: 

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

519 """ 

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

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

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

523 )} 

524 

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

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

527 if simulation_object is None: 

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

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

530 # initial values back onto. 

531 continue 

532 initial_values = unit_model 

533 simulation_object.initial_values = initial_values 

534 

535 SimulationObject.objects.bulk_update( 

536 simulation_objects.values(), ["initial_values"])