Coverage for backend/django/flowsheetInternals/unitops/models/simulation_object_factory.py: 94%

273 statements  

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

1import itertools 

2from typing import List, Tuple 

3 

4from core.auxiliary.models.MLModel import MLModel 

5from core.auxiliary.models.Flowsheet import Flowsheet 

6from core.auxiliary.models.IndexedItem import IndexedItem 

7from core.auxiliary.models.PropertyValue import PropertyValue, PropertyValueIntermediate 

8from core.auxiliary.models.PropertyInfo import PropertyInfo 

9from core.auxiliary.models.RecycleData import RecycleData 

10from core.auxiliary.models.PropertySet import PropertySet 

11from core.auxiliary.enums import ConType, heat_exchange_ops 

12 

13from flowsheetInternals.unitops.models.SimulationObject import SimulationObject 

14from flowsheetInternals.unitops.models.Port import Port 

15from flowsheetInternals.graphicData.models.graphicObjectModel import GraphicObject 

16from flowsheetInternals.graphicData.models.groupingModel import Grouping 

17from flowsheetInternals.unitops.models.compound_propogation import update_compounds_on_add_stream 

18 

19from flowsheetInternals.unitops.config.config_methods import * 

20from flowsheetInternals.unitops.config.config_base import configuration 

21from common.config_types import * 

22from core.auxiliary.models.ObjectTypeCounter import ObjectTypeCounter 

23from core.auxiliary.views.ExtractSegmentDataFromFS import create_he_streams 

24 

25 

26class SimulationObjectFactory: 

27 def __init__(self) -> None: 

28 self._flowsheet: Flowsheet | None = None 

29 

30 self.simulation_objects: list[SimulationObject] = [] 

31 self.graphic_objects: list[GraphicObject] = [] 

32 self.property_sets: list[PropertySet] = [] 

33 self.property_infos: list[PropertyInfo] = [] 

34 self.property_values: list[PropertyValue] = [] 

35 self.ports: list[Port] = [] 

36 self.index_items: list[IndexedItem] = [] 

37 self.property_value_indexed_items: List[PropertyValueIntermediate] = [] 

38 

39 # conditionally created objects 

40 self.recycle_data: list[RecycleData] = [] 

41 

42 self._unitop: SimulationObject = None # the unitop of the object being created 

43 self._current_port: Port = None # the current port for the stream being created 

44 

45 self._idx_map: dict[int, SimulationObject] = {} # map of id to simulation object 

46 self._property_set_map: dict[int, list[PropertySet]] = {} # map of simulation object to property sets for the many-to-many relationship 

47 self._index_items_map: dict[int, dict[str, list[IndexedItem]]] = {} # map of simulation object to indexed items for the many-to-many relationship 

48 

49 # For storing old property data when replacing the gut 

50 self.old_properties: dict[str, any] = {} 

51 

52 @classmethod 

53 def create_simulation_object(cls, coordinates: dict[str, float] | None = None, createPropertySet: bool = True, flowsheet=None, parentGroup=None, **kwargs) -> SimulationObject: 

54 """ 

55 Creates a new unitop with attached ports, streams etc 

56 """ 

57 factory = SimulationObjectFactory() 

58 

59 # Create unitop 

60 # if parentGroup is provided, fetch it, else fallback to flowsheet.rootGrouping. 

61 if parentGroup: 

62 group = Grouping.objects.get(pk=parentGroup) 

63 else: 

64 if flowsheet: 64 ↛ 67line 64 didn't jump to line 67 because the condition on line 64 was always true

65 group = flowsheet.rootGrouping 

66 else: 

67 group = None # or raise an error if flowsheet is required 

68 factory._flowsheet = flowsheet 

69 object_type = kwargs.pop("objectType") 

70 if kwargs.get("schema") is not None: 

71 object_schema = kwargs.pop("schema") 

72 else: 

73 object_schema: ObjectType = configuration[object_type] 

74 

75 

76 if coordinates is None: 

77 coordinates = {"x": 0, "y": 0} 

78 

79 unitop = factory.create( 

80 object_type=object_type, 

81 object_schema=object_schema, 

82 coordinates=coordinates, 

83 flowsheet=flowsheet, 

84 createPropertySet=createPropertySet, 

85 parentGroup= group, 

86 ) 

87 

88 factory._unitop = unitop 

89 graphic_object = factory.graphic_objects[-1] 

90 

91 # count the number of inlet and outlet ports 

92 num_inlets = len([port for port in factory.ports if port.direction == ConType.Inlet]) 

93 

94 num_outlets = len([port for port in factory.ports 

95 if ((port.direction == ConType.Outlet) and 

96 (factory._unitop.schema.ports[port.key].makeStream==True))]) 

97 

98 

99 

100 

101 inlet_index = 0 

102 outlet_index = 0 

103 

104 if kwargs.get("create_attached_streams", True): 

105 for port in factory.ports: 

106 factory._current_port = port 

107 port_schema = factory._unitop.schema.ports[port.key] 

108 if port_schema.makeStream is False: 

109 continue 

110 stream_type = port_schema.streamType 

111 

112 stream_schema = configuration[stream_type] 

113 # evenly space the ports along the sides of the unitop  

114 if port.direction == ConType.Inlet: 

115 coordinates = port.default_stream_position(factory._unitop, graphic_object, inlet_index, num_inlets) 

116 inlet_index += 1 

117 else: 

118 coordinates = port.default_stream_position(factory._unitop, graphic_object, outlet_index, num_outlets) 

119 outlet_index += 1 

120 

121 stream_name = port.default_stream_name(factory._unitop) 

122 stream = factory.create( 

123 object_type=stream_type, 

124 object_schema=stream_schema, 

125 coordinates=coordinates, 

126 flowsheet=flowsheet, 

127 componentName=stream_name, 

128 parentGroup=group, 

129 ) 

130 

131 port.stream = stream 

132 

133 # save all created objects 

134 factory.perform_bulk_create() 

135 

136 if unitop.objectType in heat_exchange_ops: 

137 create_he_streams(unitop, group) 

138 

139 # Propagate streams to other groups for the newly created unitop 

140 inlet_streams = [] 

141 outlet_streams = [] 

142 for port in unitop.ports.all(): 

143 if port.stream: 

144 if port.direction == ConType.Inlet: 

145 inlet_streams.append(port.stream) 

146 else: 

147 outlet_streams.append(port.stream) 

148 from flowsheetInternals.graphicData.logic.make_group import propagate_streams 

149 propagate_streams(inlet_streams, ConType.Inlet) 

150 propagate_streams(outlet_streams, ConType.Outlet) 

151 

152 # create the attached ML model object if it's a machine learning block 

153 if unitop.objectType == "machineLearningBlock": 

154 MLModel.objects.create( 

155 simulationObject=unitop, 

156 flowsheet=flowsheet, 

157 ) 

158 

159 return unitop 

160 

161 

162 @classmethod 

163 def create_stream_at_port(cls, port: Port) -> SimulationObject: 

164 """ 

165 Creates a new stream attached to the specified port. 

166 """ 

167 factory = SimulationObjectFactory() 

168 factory._current_port = port 

169 factory._unitop = port.unitOp 

170 factory._flowsheet = factory._unitop.flowsheet 

171 

172 # Figure out what type of stream to create based on port config 

173 # E.g energy_stream, stream, etc 

174 object_type = factory._unitop.schema.ports[port.key].streamType 

175 object_schema: ObjectType = configuration[object_type] 

176 

177 # get the coordinates for the stream 

178 coordinates = cls.default_stream_position(factory._unitop, port) 

179 

180 #get the group of the unitop that the stream is attached to 

181 group = factory._unitop.graphicObject.last().group 

182 

183 # create the stream 

184 stream = factory.create( 

185 object_type=object_type, 

186 object_schema=object_schema, 

187 coordinates=coordinates, 

188 flowsheet=factory._unitop.flowsheet, 

189 parentGroup=group, 

190 ) 

191 factory.perform_bulk_create() 

192 

193 # attach the stream to the port 

194 port.stream = stream 

195 port.save() 

196 

197 #populate the stream with compounds 

198 update_compounds_on_add_stream(port, stream) 

199 

200 return stream 

201 

202 

203 @classmethod 

204 def default_stream_position(cls, unitop: SimulationObject, port: Port) -> dict[str, float]: 

205 """ 

206 Returns the default position for a stream attached to the specified port. 

207 """ 

208 graphic_object = unitop.graphicObject.last() # the unit op only has one graphic object so this is okay. 

209 attached_ports = unitop.ports.filter(direction=port.direction) 

210 port_index = list(attached_ports).index(port) 

211 return port.default_stream_position(unitop, graphic_object, port_index, attached_ports.count()) 

212 

213 

214 def perform_bulk_create(self) -> None: 

215 """ 

216 Saves all created objects to the database. 

217 """ 

218 SimulationObject.objects.bulk_create(self.simulation_objects) 

219 GraphicObject.objects.bulk_create(self.graphic_objects) 

220 PropertySet.objects.bulk_create(self.property_sets) 

221 PropertyInfo.objects.bulk_create(self.property_infos) 

222 PropertyValue.objects.bulk_create(self.property_values) 

223 Port.objects.bulk_create(self.ports) 

224 RecycleData.objects.bulk_create(self.recycle_data) 

225 IndexedItem.objects.bulk_create(self.index_items) 

226 PropertyValueIntermediate.objects.bulk_create(self.property_value_indexed_items) 

227 

228 def create( 

229 self, object_type: str, object_schema: ObjectType, coordinates: dict[str, float], flowsheet: Flowsheet | None = None, parentGroup: Grouping | None = None, componentName: str | None = None, createPropertySet: bool = True 

230 ) -> SimulationObject: 

231 """ 

232 Creates a new simulation object 

233 does not save the instance to the database. 

234  

235 @param object_type: The type of object to create 

236 @param object_schema: The schema of the object 

237 @param coordinates: The coordinates of the object 

238 @param flowsheet: The flowsheet owner of the object 

239  

240 @return: The created simulation object, graphic object, property sets, property infos, property packages, and ports. 

241 this is so that the caller can create multiple objects at once and do a bulk_create 

242 """ 

243 if flowsheet is not None: 243 ↛ 246line 243 didn't jump to line 246 because the condition on line 243 was always true

244 idx_for_type = ObjectTypeCounter.next_for(flowsheet, object_type) 

245 else: 

246 last_sim_obj = SimulationObject.objects.last() 

247 if last_sim_obj is None: 

248 id = 1 

249 else: 

250 last_sim_obj = SimulationObject.objects.last() 

251 idx_for_type = (last_sim_obj.id + 1) if last_sim_obj else 1 

252 

253 if componentName is None: 

254 componentName = f"{object_schema.displayType}{idx_for_type}" 

255 

256 elif "stream" not in object_type and componentName != object_schema.displayName: 

257 componentName = componentName 

258 else: 

259 componentName = f"{componentName}{idx_for_type}" 

260 

261 

262 # create simulation object 

263 fields = { 

264 "objectType": object_type, 

265 "componentName": componentName, 

266 } 

267 instance = SimulationObject(**fields) 

268 self.simulation_objects.append(instance) 

269 idx = len(self._idx_map) 

270 self._idx_map[idx] = instance 

271 self._property_set_map[idx] = [] 

272 

273 # create index items 

274 self._index_items_map[idx] = {} 

275 for index_set in object_schema.indexSets: 

276 self.create_indexed_items(instance, index_set, idx) 

277 

278 graphic_object_schema = object_schema.graphicObject 

279 

280 graphicObject = GraphicObject( 

281 simulationObject=instance, 

282 x=coordinates["x"] - graphic_object_schema.width / 2, # center the object horizontally 

283 y=coordinates["y"] - graphic_object_schema.height / 2, # center the object vertically 

284 width = graphic_object_schema.width, 

285 height = graphic_object_schema.height, 

286 visible=True, 

287 group=parentGroup, 

288 flowsheet=flowsheet 

289 ) 

290 self.graphic_objects.append(graphicObject) 

291 

292 # If flowsheetKey is given, set flowsheet 

293 if flowsheet is not None: 293 ↛ 298line 293 didn't jump to line 298 because the condition on line 293 was always true

294 instance.flowsheet = flowsheet 

295 

296 

297 # Create property sets 

298 if createPropertySet: 298 ↛ 302line 298 didn't jump to line 302 because the condition on line 298 was always true

299 self.create_property_set(object_schema, idx) 

300 

301 # Create ports 

302 ports_schema = object_schema.ports or {} 

303 # replace any many=True ports with multiple ports 

304 for key, port_dict in ports_schema.items(): 

305 if port_dict.many: 

306 # replace this key value pair with a list of ports up to the default provided 

307 for i in range(port_dict.default): 

308 self.ports.append(Port( 

309 key=key, 

310 index=i, 

311 direction=port_dict.type, 

312 displayName=port_dict.displayName + f" {i+1}", 

313 unitOp=instance, 

314 flowsheet=flowsheet 

315 )) 

316 else: 

317 self.ports.append(Port( 

318 key=key, 

319 direction=port_dict.type, 

320 displayName=port_dict.displayName, 

321 unitOp=instance, 

322 flowsheet=flowsheet 

323 )) 

324 

325 # conditionally create attached objects based on object type 

326 if object_type == "recycle": 

327 self.recycle_data.append(RecycleData( 

328 simulationObject=instance, 

329 flowsheet=flowsheet 

330 )) 

331 

332 return instance 

333 

334 def store_old_properties(self, instance: SimulationObject): 

335 """ 

336 Stores the old properties of the stream. 

337 Used when switching stream types to keep properties that exist in both schema. 

338 Prevents losing user input in those properties. 

339 e.g. If Molar Flow has a value, switching to Humid Air won't remove that value. 

340 """ 

341 self.old_properties = {} 

342 if instance.properties: 342 ↛ exitline 342 didn't return from function 'store_old_properties' because the condition on line 342 was always true

343 for prop_info in instance.properties.containedProperties.all(): 

344 try: 

345 self.old_properties[prop_info.key] = prop_info.get_value_bulk() 

346 except ValueError: 

347 self.old_properties[prop_info.key] = None 

348 

349 def replace_the_gut(self, instance: SimulationObject): 

350 self.simulation_objects.append(instance) 

351 self._unitop = instance 

352 self._current_port = instance.connectedPorts.first() 

353 

354 object_schema = configuration[instance.objectType] 

355 idx = len(self._idx_map) 

356 self._idx_map[idx] = instance 

357 self._property_set_map[idx] = [] 

358 

359 # create index items 

360 self._index_items_map[idx] = {} 

361 for index_set in object_schema.indexSets: 

362 self.create_indexed_items(instance, index_set, idx) 

363 

364 self.create_property_set(object_schema, idx) 

365 

366 

367 def create_property_set(self, object_schema: ObjectType, idx: int) -> PropertySet: 

368 """ 

369 Creates a new SimulationObjectPropertySet instance with the specified schema. 

370 """ 

371 defaults = { 

372 'compoundMode': "", 

373 "simulationObject": self._idx_map[idx], 

374 } 

375 for schema in object_schema.propertySetGroups.values(): 

376 if schema.type == "composition": 

377 defaults['compoundMode'] = "MassFraction" 

378 

379 # Create SimulationObjectPropertySet 

380 instance = PropertySet(**defaults, flowsheet=self._flowsheet) 

381 

382 if object_schema.properties != {}: 382 ↛ 393line 382 didn't jump to line 393 because the condition on line 382 was always true

383 # Create PropertyInfo objects 

384 property_pairs = self.create_property_infos(instance, object_schema.properties, idx) 

385 

386 # set access based on the schema 

387 self.set_properties_access( 

388 config=object_schema, 

389 properties=property_pairs, 

390 idx=idx, 

391 ) 

392 

393 self.property_sets.append(instance) 

394 self._property_set_map[idx].append(instance) 

395 

396 return instance 

397 

398 

399 def create_property_infos(self, property_set: PropertySet, schema: PropertiesType, idx) -> List[Tuple[PropertyInfo, List[PropertyValue]]]: 

400 """ 

401 Creates PropertyInfo objects based on the specified schema. 

402 """ 

403 res: List[Tuple[PropertyInfo, List[PropertyValue]]] = [] 

404 

405 # if schema.type == "composition": 

406 # # the composition property set has no property infos, since these are compounds selected by the user 

407 # # TODO: Initialise the properties based on the compounds upstream (if any) 

408 # flowsheet = self._flowsheet 

409 for key, prop in schema.items(): 

410 fields = get_property_fields(key, prop, property_set) 

411 

412 if self.old_properties: 

413 # If property in the schema is also in the old properties, keep the value 

414 if key in self.old_properties.keys(): 

415 fields["value"] = self.old_properties[key] 

416 

417 new_property_info, new_property_values = self.create_property_info(idx, prop.indexSets, **fields) 

418 res.append((new_property_info, new_property_values)) 

419 

420 return res 

421 

422 

423 def create_property_info(self, idx, index_sets=None, **fields): 

424 value = fields.pop("value") 

425 property_info = PropertyInfo(**fields, flowsheet=self._flowsheet) 

426 self.property_infos.append(property_info) 

427 # Create a property value object with this value 

428 if index_sets == None: 

429 property_value = PropertyValue(value=value, property=property_info, flowsheet=self._flowsheet) 

430 self.property_values.append(property_value) 

431 return property_info, [property_value] 

432 else: 

433 combinations = self.get_combinations(self._index_items_map[idx], index_sets) 

434 property_values = [] 

435 for indexes in combinations: 

436 property_value = PropertyValue(value=value, property=property_info, flowsheet=self._flowsheet) 

437 for indexed_item in indexes: 

438 self.property_value_indexed_items.append( 

439 PropertyValueIntermediate(propertyvalue=property_value, indexeditem=indexed_item) 

440 ) 

441 property_values.append(property_value) 

442 self.property_values.extend(property_values) 

443 return property_info, property_values 

444 

445 

446 def get_combinations(self, index_items_map: dict[str, list[IndexedItem]], index_sets=[]) -> list[list[IndexedItem]]: 

447 """ 

448 Returns all possible combinations of indexed items for the specified index sets 

449 (taking one item from each index set). 

450 """ 

451 if index_items_map == {}: 451 ↛ 452line 451 didn't jump to line 452 because the condition on line 451 was never true

452 return [] 

453 indexes = [value for key, value in index_items_map.items() if key in index_sets] 

454 return list(itertools.product(*indexes)) 

455 

456 

457 def set_properties_access( 

458 self, 

459 config: ObjectType, 

460 properties: List[Tuple[PropertyInfo, List[PropertyValue]]], 

461 idx: int, 

462 ) -> None: 

463 if ( 

464 self.simulation_objects[-1].is_stream() 

465 and self._unitop.objectType != "recycle" 

466 and self._current_port and self._current_port.direction == ConType.Outlet 

467 ): 

468 # disable outlet/intermediate stream properties 

469 for propertyInfo, propVals in properties: 

470 for property_value in propVals: 

471 property_value.enabled = False 

472 else: 

473 index_map_items = self._index_items_map[idx] 

474 # TODO: Refactor this. 

475 # not disabled if it is a state variable 

476 for prop, propVals in properties: 

477 config_prop = config.properties.get(prop.key) 

478 # # figure out the number of items in the first index set 

479 if config_prop.indexSets: 

480 first_index_type = config_prop.indexSets[0] 

481 if first_index_type in index_map_items: 481 ↛ 488line 481 didn't jump to line 488 because the condition on line 481 was always true

482 if len(index_map_items[first_index_type]) == 0: 

483 last_item = None 

484 else: 

485 last_item = index_map_items[first_index_type][-1] 

486 # last_item is only needed on index sets. 

487 # it should be fine if it's undefined otherwise. 

488 for index, property_value in enumerate(propVals): 

489 if config_prop: 489 ↛ 492line 489 didn't jump to line 492 because the condition on line 489 was always true

490 group = config_prop.propertySetGroup 

491 else: 

492 group = "default" 

493 

494 config_group = config.propertySetGroups.get(group, None) 

495 if config_group is None: 495 ↛ 496line 495 didn't jump to line 496 because the condition on line 495 was never true

496 property_value.enabled = True 

497 elif config_group.type == "exceptLast" or config_prop.sumToOne: 

498 # TODO: Deprecate exceptLast in favor of sumToOne. See phase_seperator_config 

499 # Instead of using len_last_item, just use the last item in the index set 

500 indexed_item_links = [item for item in self.property_value_indexed_items 

501 if item.propertyvalue == property_value] 

502 indexed_items = [item.indexeditem for item in indexed_item_links] 

503 is_last_item = last_item in indexed_items 

504 

505 if is_last_item: 

506 property_value.enabled = False 

507 else: 

508 property_value.enabled = True 

509 elif config_group.type == "stateVars": 509 ↛ 513line 509 didn't jump to line 513 because the condition on line 509 was always true

510 state_vars = getattr(config_group, "stateVars") or () 

511 property_value.enabled = prop.key in state_vars 

512 else: # eg. All 

513 property_value.enabled = True 

514 

515 

516 def create_indexed_items(self, instance: SimulationObject, index_set: str, idx: int) -> None: 

517 """ 

518 Creates IndexedItem instances for the specified index set. 

519 """ 

520 items = [] 

521 

522 outlet_name = instance.schema.splitter_fraction_name 

523 match index_set: 

524 case "splitter_fraction": 

525 items = [] 

526 # create indexed items for the splitter_fraction index set 

527 items.append(IndexedItem( 

528 owner=instance, 

529 key="outlet_1", 

530 displayName= outlet_name + " 1", 

531 type=index_set, 

532 flowsheet=self._flowsheet 

533 )) 

534 items.append(IndexedItem( 

535 owner=instance, 

536 key="outlet_2", 

537 displayName= outlet_name + " 2", 

538 type=index_set, 

539 flowsheet=self._flowsheet 

540 )) 

541 

542 case "phase": 

543 # create indexed items for the phase index set 

544 items.append(IndexedItem( 

545 owner=instance, 

546 key="Liq", 

547 displayName="Liquid", 

548 type=index_set, 

549 flowsheet=self._flowsheet 

550 )) 

551 items.append(IndexedItem( 

552 owner=instance, 

553 key="Vap", 

554 displayName="Vapor", 

555 type=index_set, 

556 flowsheet=self._flowsheet 

557 )) 

558 

559 case "compound": 

560 pass # have to wait for user to select compounds 

561 self._index_items_map[idx][index_set] = items 

562 self.index_items.extend(items) 

563 

564 

565