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

267 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2025-11-06 23:27 +0000

1import itertools 

2from typing import List, Tuple 

3 

4from core.auxiliary.models.Flowsheet import Flowsheet 

5from core.auxiliary.models.IndexedItem import IndexedItem 

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

7from core.auxiliary.models.PropertyInfo import PropertyInfo 

8from core.auxiliary.models.RecycleData import RecycleData 

9from core.auxiliary.models.PropertySet import PropertySet 

10from core.auxiliary.enums import ConType, heat_exchange_ops 

11 

12from flowsheetInternals.unitops.models.SimulationObject import SimulationObject 

13from flowsheetInternals.unitops.models.Port import Port 

14from flowsheetInternals.graphicData.models.graphicObjectModel import GraphicObject 

15from flowsheetInternals.graphicData.models.groupingModel import Grouping 

16from flowsheetInternals.propertyPackages.models.SimulationObjectPropertyPackages import SimulationObjectPropertyPackages 

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.property_packages: list[SimulationObjectPropertyPackages] = [] 

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

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

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

39 

40 # conditionally created objects 

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

42 

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

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

45 

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

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

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

49 

50 

51 @classmethod 

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

53 """ 

54 Creates a new unitop with attached ports, streams etc 

55 """ 

56 factory = SimulationObjectFactory() 

57 

58 # Create unitop 

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

60 if parentGroup: 

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

62 else: 

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

64 group = flowsheet.rootGrouping 

65 else: 

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

67 factory._flowsheet = flowsheet 

68 object_type = kwargs.pop("objectType") 

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

70 object_schema = kwargs.pop("schema") 

71 else: 

72 object_schema: ObjectType = configuration[object_type] 

73 

74 

75 if coordinates is None: 

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

77 

78 unitop = factory.create( 

79 object_type=object_type, 

80 object_schema=object_schema, 

81 coordinates=coordinates, 

82 flowsheet=flowsheet, 

83 createPropertySet=createPropertySet, 

84 parentGroup= group, 

85 ) 

86 

87 factory._unitop = unitop 

88 graphic_object = factory.graphic_objects[-1] 

89 

90 # count the number of inlet and outlet ports 

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

92 

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

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

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

96 

97 

98 

99 

100 inlet_index = 0 

101 outlet_index = 0 

102 

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

104 for port in factory.ports: 

105 factory._current_port = port 

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

107 if port_schema.makeStream is False: 

108 continue 

109 stream_type = port_schema.streamType 

110 

111 stream_schema = configuration[stream_type] 

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

113 if port.direction == ConType.Inlet: 

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

115 inlet_index += 1 

116 else: 

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

118 outlet_index += 1 

119 

120 stream_name = port.default_stream_name(factory._unitop) 

121 stream = factory.create( 

122 object_type=stream_type, 

123 object_schema=stream_schema, 

124 coordinates=coordinates, 

125 flowsheet=flowsheet, 

126 componentName=stream_name, 

127 parentGroup=group, 

128 ) 

129 

130 port.stream = stream 

131 

132 # save all created objects 

133 factory.perform_bulk_create() 

134 

135 if unitop.objectType in heat_exchange_ops: 

136 create_he_streams(unitop, group) 

137 

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

139 inlet_streams = [] 

140 outlet_streams = [] 

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

142 if port.stream: 

143 if port.direction == ConType.Inlet: 

144 inlet_streams.append(port.stream) 

145 else: 

146 outlet_streams.append(port.stream) 

147 from flowsheetInternals.graphicData.logic.make_group import propagate_streams 

148 propagate_streams(inlet_streams, ConType.Inlet) 

149 propagate_streams(outlet_streams, ConType.Outlet) 

150 

151 return unitop 

152 

153 

154 @classmethod 

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

156 """ 

157 Creates a new stream attached to the specified port. 

158 """ 

159 factory = SimulationObjectFactory() 

160 factory._current_port = port 

161 factory._unitop = port.unitOp 

162 factory._flowsheet = factory._unitop.flowsheet 

163 

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

165 # E.g energy_stream, stream, etc 

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

167 object_schema: ObjectType = configuration[object_type] 

168 

169 # get the coordinates for the stream 

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

171 

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

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

174 

175 # create the stream 

176 stream = factory.create( 

177 object_type=object_type, 

178 object_schema=object_schema, 

179 coordinates=coordinates, 

180 flowsheet=factory._unitop.flowsheet, 

181 parentGroup=group, 

182 ) 

183 factory.perform_bulk_create() 

184 

185 # attach the stream to the port 

186 port.stream = stream 

187 port.save() 

188 

189 #populate the stream with compounds 

190 update_compounds_on_add_stream(port, stream) 

191 

192 return stream 

193 

194 

195 @classmethod 

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

197 """ 

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

199 """ 

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

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

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

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

204 

205 

206 def perform_bulk_create(self) -> None: 

207 """ 

208 Saves all created objects to the database. 

209 """ 

210 SimulationObject.objects.bulk_create(self.simulation_objects) 

211 GraphicObject.objects.bulk_create(self.graphic_objects) 

212 PropertySet.objects.bulk_create(self.property_sets) 

213 PropertyInfo.objects.bulk_create(self.property_infos) 

214 PropertyValue.objects.bulk_create(self.property_values) 

215 SimulationObjectPropertyPackages.objects.bulk_create(self.property_packages) 

216 Port.objects.bulk_create(self.ports) 

217 RecycleData.objects.bulk_create(self.recycle_data) 

218 IndexedItem.objects.bulk_create(self.index_items) 

219 PropertyValueIntermediate.objects.bulk_create(self.property_value_indexed_items) 

220 

221 def create( 

222 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 

223 ) -> SimulationObject: 

224 """ 

225 Creates a new simulation object 

226 does not save the instance to the database. 

227  

228 @param object_type: The type of object to create 

229 @param object_schema: The schema of the object 

230 @param coordinates: The coordinates of the object 

231 @param flowsheet: The flowsheet owner of the object 

232  

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

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

235 """ 

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

237 idx_for_type = ObjectTypeCounter.next_for(flowsheet, object_type) 

238 else: 

239 last_sim_obj = SimulationObject.objects.last() 

240 if last_sim_obj is None: 

241 id = 1 

242 else: 

243 last_sim_obj = SimulationObject.objects.last() 

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

245 

246 if componentName is None: 

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

248 

249 elif "stream" not in object_type and componentName != object_schema.displayName: 249 ↛ 250line 249 didn't jump to line 250 because the condition on line 249 was never true

250 componentName = componentName 

251 else: 

252 componentName = f"{componentName}{idx_for_type}" 

253 

254 

255 # create simulation object 

256 fields = { 

257 "objectType": object_type, 

258 "componentName": componentName, 

259 } 

260 instance = SimulationObject(**fields) 

261 self.simulation_objects.append(instance) 

262 idx = len(self._idx_map) 

263 self._idx_map[idx] = instance 

264 self._property_set_map[idx] = [] 

265 

266 # create index items 

267 self._index_items_map[idx] = {} 

268 for index_set in object_schema.indexSets: 

269 self.create_indexed_items(instance, index_set, idx) 

270 

271 graphic_object_schema = object_schema.graphicObject 

272 

273 graphicObject = GraphicObject( 

274 simulationObject=instance, 

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

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

277 width = graphic_object_schema.width, 

278 height = graphic_object_schema.height, 

279 visible=True, 

280 group=parentGroup, 

281 flowsheet=flowsheet 

282 ) 

283 self.graphic_objects.append(graphicObject) 

284 

285 # If flowsheetKey is given, set flowsheet 

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

287 instance.flowsheet = flowsheet 

288 

289 

290 # Create property sets 

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

292 self.create_property_set(object_schema, idx) 

293 

294 # Create ports 

295 ports_schema = object_schema.ports or {} 

296 # replace any many=True ports with multiple ports 

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

298 if port_dict.many: 

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

300 for i in range(port_dict.default): 

301 self.ports.append(Port( 

302 key=key, 

303 index=i, 

304 direction=port_dict.type, 

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

306 unitOp=instance, 

307 flowsheet=flowsheet 

308 )) 

309 else: 

310 self.ports.append(Port( 

311 key=key, 

312 direction=port_dict.type, 

313 displayName=port_dict.displayName, 

314 unitOp=instance, 

315 flowsheet=flowsheet 

316 )) 

317 

318 # Create property package slots 

319 property_packages = object_schema.propertyPackagePorts 

320 if property_packages is not None: 

321 for slot in property_packages.keys(): 

322 if slot != "__none__": # special case for connecting together streams that don't actually have a property package. 322 ↛ 321line 322 didn't jump to line 321 because the condition on line 322 was always true

323 simulationObjectPropertyPackage = SimulationObjectPropertyPackages( 

324 simulationObject=instance, 

325 name=slot, 

326 propertyPackage="helmholtz", 

327 flowsheet=flowsheet 

328 ) 

329 self.property_packages.append(simulationObjectPropertyPackage) 

330 

331 # conditionally create attached objects based on object type 

332 if object_type == "recycle": 

333 self.recycle_data.append(RecycleData( 

334 simulationObject=instance, 

335 flowsheet=flowsheet 

336 )) 

337 

338 return instance 

339 

340 def replace_the_gut(self, instance: SimulationObject): 

341 self.simulation_objects.append(instance) 

342 self._unitop = instance 

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

344 

345 object_schema = configuration[instance.objectType] 

346 idx = len(self._idx_map) 

347 self._idx_map[idx] = instance 

348 self._property_set_map[idx] = [] 

349 

350 # create index items 

351 self._index_items_map[idx] = {} 

352 for index_set in object_schema.indexSets: 

353 self.create_indexed_items(instance, index_set, idx) 

354 

355 self.create_property_set(object_schema, idx) 

356 

357 

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

359 """ 

360 Creates a new SimulationObjectPropertySet instance with the specified schema. 

361 """ 

362 defaults = { 

363 'compoundMode': "", 

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

365 } 

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

367 if schema.type == "composition": 

368 defaults['compoundMode'] = "MassFraction" 

369 

370 # Create SimulationObjectPropertySet 

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

372 

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

374 # Create PropertyInfo objects 

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

376 

377 # set access based on the schema 

378 self.set_properties_access( 

379 config=object_schema, 

380 properties=property_pairs, 

381 idx=idx, 

382 ) 

383 

384 self.property_sets.append(instance) 

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

386 

387 return instance 

388 

389 

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

391 """ 

392 Creates PropertyInfo objects based on the specified schema. 

393 """ 

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

395 

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

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

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

399 # flowsheet = self._flowsheet 

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

401 fields = get_property_fields(key, prop, property_set) 

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

403 res.append((new_property_info, new_property_values)) 

404 

405 return res 

406 

407 

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

409 value = fields.pop("value") 

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

411 self.property_infos.append(property_info) 

412 # Create a property value object with this value 

413 if index_sets == None: 

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

415 self.property_values.append(property_value) 

416 return property_info, [property_value] 

417 else: 

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

419 property_values = [] 

420 for indexes in combinations: 

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

422 for indexed_item in indexes: 

423 self.property_value_indexed_items.append( 

424 PropertyValueIntermediate(propertyvalue=property_value, indexeditem=indexed_item) 

425 ) 

426 property_values.append(property_value) 

427 self.property_values.extend(property_values) 

428 return property_info, property_values 

429 

430 

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

432 """ 

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

434 (taking one item from each index set). 

435 """ 

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

437 return [] 

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

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

440 

441 

442 def set_properties_access( 

443 self, 

444 config: ObjectType, 

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

446 idx: int, 

447 ) -> None: 

448 

449 if ( 

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

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

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

453 ): 

454 # disable outlet/intermediate stream properties 

455 for propertyInfo, propVals in properties: 

456 for property_value in propVals: 

457 property_value.enabled = False 

458 else: 

459 index_map_items = self._index_items_map[idx] 

460 # TODO: Refactor this. 

461 # not disabled if it is a state variable 

462 for prop, propVals in properties: 

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

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

465 if config_prop.indexSets: 

466 first_index_type = config_prop.indexSets[0] 

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

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

469 last_item = None 

470 else: 

471 last_item = index_map_items[first_index_type][-1] 

472 # last_item is only needed on index sets. 

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

474 for index, property_value in enumerate(propVals): 

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

476 group = config_prop.propertySetGroup 

477 else: 

478 group = "default" 

479 

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

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

482 property_value.enabled = True 

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

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

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

486 indexed_item_links = [item for item in self.property_value_indexed_items 

487 if item.propertyvalue == property_value] 

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

489 is_last_item = last_item in indexed_items 

490 

491 if is_last_item: 

492 property_value.enabled = False 

493 else: 

494 property_value.enabled = True 

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

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

497 property_value.enabled = prop.key in state_vars 

498 else: # eg. All 

499 property_value.enabled = True 

500 

501 

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

503 """ 

504 Creates IndexedItem instances for the specified index set. 

505 """ 

506 items = [] 

507 

508 outlet_name = instance.schema.splitter_fraction_name 

509 match index_set: 

510 case "splitter_fraction": 

511 items = [] 

512 # create indexed items for the splitter_fraction index set 

513 items.append(IndexedItem( 

514 owner=instance, 

515 key="outlet_1", 

516 displayName= outlet_name + " 1", 

517 type=index_set, 

518 flowsheet=self._flowsheet 

519 )) 

520 items.append(IndexedItem( 

521 owner=instance, 

522 key="outlet_2", 

523 displayName= outlet_name + " 2", 

524 type=index_set, 

525 flowsheet=self._flowsheet 

526 )) 

527 

528 case "phase": 

529 # create indexed items for the phase index set 

530 items.append(IndexedItem( 

531 owner=instance, 

532 key="Liq", 

533 displayName="Liquid", 

534 type=index_set, 

535 flowsheet=self._flowsheet 

536 )) 

537 items.append(IndexedItem( 

538 owner=instance, 

539 key="Vap", 

540 displayName="Vapor", 

541 type=index_set, 

542 flowsheet=self._flowsheet 

543 )) 

544 

545 case "compound": 

546 pass # have to wait for user to select compounds 

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

548 self.index_items.extend(items) 

549 

550 

551 

552