Coverage for backend/django/flowsheetInternals/graphicData/logic/make_group.py: 87%

181 statements  

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

1from typing import List 

2from core.auxiliary.enums import SimulationObjectClass 

3from core.auxiliary.enums import ConType 

4from flowsheetInternals.graphicData.models.groupingModel import Breadcrumbs, Grouping, GraphicObject 

5from flowsheetInternals.graphicData.models.portAnchorPlacementModel import PortAnchorPlacement 

6from flowsheetInternals.unitops.models.SimulationObject import SimulationObject 

7import random 

8from core.auxiliary.models.ObjectTypeCounter import ObjectTypeCounter 

9from django.db.models import Count, QuerySet, Prefetch 

10from flowsheetInternals.unitops.models.Port import Port 

11 

12 

13def make_group(contained_objects_id: List[int]) -> Grouping: 

14 """ 

15 Creates a group 

16  

17 Args: 

18 containedObjects_id (List[SimulationObject id]): Simulation objects to include in the group. 

19 is_abstract: (bool): This hasn't really been implemented yet, but the idea was to have 

20 groups shown fully in the parent, with modules hidden inside something. 

21 """ 

22 

23 # Ensure all unit operations share the same group 

24 simulation_objects = SimulationObject.objects.filter(pk__in=contained_objects_id).prefetch_related( 

25 Prefetch( 

26 "connectedPorts", 

27 queryset=Port.objects.select_related("unitOp").prefetch_related() 

28 ) 

29 ) 

30 unit_operations = ( 

31 simulation_objects.exclude(objectType=SimulationObjectClass.Stream) 

32 .exclude(objectType=SimulationObjectClass.Recycle) # exclude recycle block because we want to propagate them like streams 

33 .all() 

34 ) 

35 streams = simulation_objects.filter(objectType=SimulationObjectClass.Stream) 

36 group_ids = [g.id for g in simulation_objects.filter(objectType=SimulationObjectClass.Group)] 

37 unitop_ids = [uo.id for uo in unit_operations] 

38 

39 #Remove orphaned streams 

40 for stream in streams: 

41 stream_group_ids = [g.simulationObject.pk for g in stream.get_groups()] 

42 connected_unitop_ids = [p.unitOp.id for p in stream.connectedPorts.all()] 

43 #check for orphan and catch case where stream is between two packed groups 

44 if not bool(set(connected_unitop_ids).intersection(unitop_ids)) and not bool(set(stream_group_ids).intersection(group_ids)): 44 ↛ 45line 44 didn't jump to line 45 because the condition on line 44 was never true

45 contained_objects_id.remove(stream.id) 

46 

47 if not unit_operations.exists(): 47 ↛ 48line 47 didn't jump to line 48 because the condition on line 47 was never true

48 raise ValueError("No unit operations provided to determine group.") 

49 

50 # Get the common group from the unit operations 

51 parent_group: Grouping = unit_operations.first().graphicObject.last().group 

52 if any(unitop.graphicObject.last().group != parent_group for unitop in unit_operations): 52 ↛ 53line 52 didn't jump to line 53 because the condition on line 52 was never true

53 raise ValueError("All unit operations must belong to the same group.") 

54 

55 # Get all connections in current group within the contained objects 

56 simulation_object_ids = [obj.pk for obj in simulation_objects] 

57 selected_connections = [connection for connection in parent_group.get_connections() if 

58 connection.unitOp in simulation_object_ids] 

59 

60 # Find intermediate streams which were not part of the initial selection (hanging streams) 

61 no_duplicate_list = set() 

62 hanging_streams = [connection.stream for connection in selected_connections if 

63 connection.stream in no_duplicate_list or no_duplicate_list.add(connection.stream)] 

64 # Add hanging streams to contained objects if not already included 

65 for stream in hanging_streams: 

66 if stream not in contained_objects_id: 

67 contained_objects_id.append(stream) 

68 

69 # Include streams only if they belong to the same group 

70 for unitop in unit_operations: 

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

72 if port.stream and port.stream.id not in contained_objects_id: 

73 group_ids = [g.pk for g in port.stream.get_groups()] 

74 if parent_group.pk in group_ids: 74 ↛ 71line 74 didn't jump to line 71 because the condition on line 74 was always true

75 contained_objects_id.append(port.stream.id) 

76 

77 # Get inlet and outlet streams connected to groups 

78 in_out_streams : list[int] = SimulationObject.objects.annotate(port_count=Count('connectedPorts')).filter(pk__in=no_duplicate_list, port_count=1).values_list('pk', flat=True) 

79 

80 # Add inlet and outlet streams to contained objects if not already included 

81 for stream in in_out_streams: 

82 if stream not in contained_objects_id: 

83 contained_objects_id.append(stream) 

84 

85 # Re-filter simulation objects after appending streams 

86 simulation_objects = SimulationObject.objects.filter(pk__in=contained_objects_id) 

87 

88 flowsheet_state = simulation_objects.first().flowsheet_state 

89 

90 idx_for_type = ObjectTypeCounter.next_for(flowsheet_state, "module") 

91 componentName = f"Module {idx_for_type}" 

92 

93 # Create the new group 

94 simulation_object = simulation_objects.first() 

95 new_group = Grouping.create( 

96 flowsheet_state, 

97 parent_group, 

98 componentName=componentName, 

99 ) 

100 new_group.update_internal_graphic_objects([simObj.graphicObject.first() for simObj in simulation_objects]) 

101 new_group.set_group_size() 

102 

103 inlet_streams = [] 

104 outlet_streams = [] 

105 

106 # handle streams for all simulation objects 

107 for simulation_object in simulation_objects: 

108 

109 # propagate streams connected to one port 

110 if simulation_object.is_stream() and simulation_object.connectedPorts.count() == 1: 

111 if simulation_object.connectedPorts.all()[0].direction == ConType.Inlet: 

112 inlet_streams.append(simulation_object) 

113 else: 

114 outlet_streams.append(simulation_object) 

115 

116 # handle intermediate streams connected to the module 

117 if simulation_object.is_stream() and simulation_object.connectedPorts.count() == 2: 

118 propagate_intermediate_streams(simulation_object, contained_objects_id) 

119 

120 propagate_streams(inlet_streams, ConType.Inlet) 

121 propagate_streams(outlet_streams, ConType.Outlet) 

122 

123 # update zone of stream data entries 

124 for unitop in unit_operations: 

125 for stream_data_entry in unitop.StreamDataEntries.all(): 

126 stream_data_entry.group = new_group 

127 stream_data_entry.save() 

128 

129 return new_group 

130 

131 

132def create_group_boundary_port_anchor_placements(group: Grouping) -> None: 

133 """ 

134 Creates default anchor placements for ports rendered on a packed group's boundary. 

135 

136 A port can be shown on its real unit operation inside the group, or on the 

137 group object in the parent layer. Those are separate rendered endpoints, so 

138 each needs its own placement row. 

139 """ 

140 from flowsheetInternals.unitops.models.simulation_object_factory import ( 

141 SimulationObjectFactory, 

142 ) 

143 

144 parent_group = group.get_parent_group() 

145 if parent_group is None: 145 ↛ 146line 145 didn't jump to line 146 because the condition on line 145 was never true

146 return 

147 

148 group_graphic = group.get_graphic_object() 

149 if group_graphic is None: 149 ↛ 150line 149 didn't jump to line 150 because the condition on line 149 was never true

150 return 

151 

152 parent_graphics = parent_group.graphicObjects.select_related( 

153 "simulationObject", 

154 "simulationObject__grouping", 

155 ).prefetch_related( 

156 Prefetch( 

157 "simulationObject__connectedPorts", 

158 queryset=Port.objects.select_related("unitOp", "stream"), 

159 ) 

160 ) 

161 boundary_connections = [ 

162 connection 

163 for connection in parent_group.get_connections_for_graphics(parent_graphics) 

164 if ( 

165 connection.unitOp == group.simulationObject.id 

166 and connection.anchor_endpoint_kind 

167 == PortAnchorPlacement.EndpointKind.GroupBoundary 

168 ) 

169 ] 

170 

171 changed = False 

172 for direction, anchor_side in ( 

173 (ConType.Inlet, "left"), 

174 (ConType.Outlet, "right"), 

175 ): 

176 connections = sorted( 

177 [ 

178 connection 

179 for connection in boundary_connections 

180 if connection.direction == direction 

181 ], 

182 key=lambda connection: connection.port, 

183 ) 

184 num_ports = len(connections) 

185 for port_index, connection in enumerate(connections): 

186 defaults = { 

187 "flowsheet_state": group.flowsheet_state, 

188 "anchor_side": anchor_side, 

189 "anchor_index": SimulationObjectFactory.default_anchor_index( 

190 port_index, 

191 num_ports, 

192 ), 

193 } 

194 placement, created = PortAnchorPlacement.objects.get_or_create( 

195 port_id=connection.port, 

196 grouping=parent_group, 

197 graphicObject=group_graphic, 

198 endpointKind=PortAnchorPlacement.EndpointKind.GroupBoundary, 

199 defaults=defaults, 

200 ) 

201 changed = changed or created 

202 if not created and ( 202 ↛ 205line 202 didn't jump to line 205 because the condition on line 202 was never true

203 placement.anchor_side is None or placement.anchor_index is None 

204 ): 

205 placement.anchor_side = defaults["anchor_side"] 

206 placement.anchor_index = defaults["anchor_index"] 

207 placement.save(update_fields=["anchor_side", "anchor_index"]) 

208 changed = True 

209 

210 if changed and hasattr(parent_group, "_cached_connections"): 210 ↛ 211line 210 didn't jump to line 211 because the condition on line 210 was never true

211 delattr(parent_group, "_cached_connections") 

212 

213 

214def get_group_visible_from_parent( 

215 unit_op: SimulationObject, 

216 parent_group: Grouping, 

217) -> Grouping | None: 

218 """ 

219 Return the packed group that represents a unit op in an ancestor layer. 

220 

221 A unit operation is rendered directly in its own group, but in ancestor 

222 groups it is rendered through the nearest child group boundary. 

223 """ 

224 group = unit_op.get_group() 

225 while group is not None: 

226 if group.get_parent_group() == parent_group: 

227 return group 

228 group = group.get_parent_group() 

229 

230 return None 

231 

232 

233def propagate_streams(streams: List[SimulationObject], direction: ConType): 

234 """ 

235 Propagates a stream's graphic objects to all parent groups of its connected unit operations. 

236 

237 Args: 

238 simulation_object (SimulationObject): The stream to propagate. 

239 """ 

240 

241 if direction == ConType.Inlet: 

242 x_offset = -1 

243 else: 

244 x_offset = 2 

245 groups_needing_boundary_anchors = {} 

246 for simulation_object in streams: 

247 if(len(streams) <= 2): 

248 y_offset = (0.5 + streams.index(simulation_object)) / len(streams) 

249 y_gap = -12.5 

250 # space out the streams if there are too many of them 

251 else: 

252 y_offset = 0.5 * streams.index(simulation_object) 

253 y_gap = -12.5 * (2 * len(streams) - 5) 

254 

255 if not simulation_object.is_stream(): 255 ↛ 256line 255 didn't jump to line 256 because the condition on line 255 was never true

256 pass 

257 

258 # make sure the stream is connected to one port (0 and 2 or more should not propagate) 

259 connected_ports: QuerySet[Port]= simulation_object.connectedPorts.all() 

260 if len(connected_ports) != 1: 260 ↛ 261line 260 didn't jump to line 261 because the condition on line 260 was never true

261 pass 

262 

263 # get the original graphic object for reference 

264 original_stream_graphic_object : GraphicObject = simulation_object.graphicObject.first() 

265 if not original_stream_graphic_object: 265 ↛ 266line 265 didn't jump to line 266 because the condition on line 265 was never true

266 pass 

267 

268 # propagate to all parent groups of the connected unit operation 

269 unit_op : SimulationObject = connected_ports[0].unitOp 

270 for parent_group in unit_op.get_parent_groups(): 

271 visible_group = get_group_visible_from_parent(unit_op, parent_group) 

272 if visible_group is not None: 

273 groups_needing_boundary_anchors[visible_group.pk] = visible_group 

274 

275 if not simulation_object.graphicObject.filter(group=parent_group).exists(): 

276 

277 module_graphic_object = original_stream_graphic_object.group.get_graphic_object() 

278 

279 GraphicObject.objects.create( 

280 flowsheet_state=simulation_object.flowsheet_state, 

281 simulationObject=simulation_object, 

282 width=original_stream_graphic_object.width, 

283 height=original_stream_graphic_object.height, 

284 x=module_graphic_object.x -12.5 + x_offset*module_graphic_object.width, 

285 y=module_graphic_object.y + y_gap + y_offset*module_graphic_object.height, 

286 group=parent_group, 

287 ) 

288 

289 for group in groups_needing_boundary_anchors.values(): 

290 create_group_boundary_port_anchor_placements(group) 

291 

292 

293def propagate_intermediate_streams(simulation_object: SimulationObject, contained_objects_id: List[int]): 

294 """ 

295 Handles the special case where an intermediate stream is connected to a module. 

296 

297 Args: 

298 simulation_object (SimulationObject): The intermediate stream to propagate. 

299 contained_objects_id (List[int]): The IDs of objects being abstracted. 

300 """ 

301 if not simulation_object.is_stream(): 301 ↛ 302line 301 didn't jump to line 302 because the condition on line 301 was never true

302 return 

303 

304 # make sure the stream is an intermediate stream (connected to exactly two ports) 

305 connected_ports = simulation_object.connectedPorts.all() 

306 if len(connected_ports) != 2: 306 ↛ 307line 306 didn't jump to line 307 because the condition on line 306 was never true

307 return 

308 

309 # Count the number of connections to contained objects (unitops or modules) 

310 connections_to_contained_objects = 0 

311 for port in connected_ports: 

312 # Check if the connected unitop is in the contained objects 

313 if port.unitOp.id in contained_objects_id: 

314 connections_to_contained_objects += 1 

315 

316 # get the original graphic object for reference 

317 original_graphic_object = simulation_object.graphicObject.last() 

318 if not original_graphic_object: 318 ↛ 319line 318 didn't jump to line 319 because the condition on line 318 was never true

319 return 

320 

321 

322 def groups_to_add_intermediate(): 

323 """ 

324 Check if both ends of the intermediate stream are visible in the given group. 

325 If they are, return the groups to which the intermediate stream should be added. 

326 """ 

327 port1, port2 = connected_ports 

328 

329 def get_group_path(group): 

330 path = [] 

331 while group: 

332 path.append(group) 

333 group = group.get_parent_group() 

334 return path # [leaf, ..., root] 

335 

336 path1 = get_group_path(port1.unitOp.get_group()) 

337 path2 = get_group_path(port2.unitOp.get_group()) 

338 

339 # The LCA is the first group in path1 that is also in path2 

340 lca = next((group for group in path1 if group in path2), None) 

341 if lca is None: 341 ↛ 342line 341 didn't jump to line 342 because the condition on line 341 was never true

342 return [] 

343 

344 # Collect all groups from path1 up to and including the LCA 

345 groups = [] 

346 for group in path1: 346 ↛ 351line 346 didn't jump to line 351 because the loop on line 346 didn't complete

347 groups.append(group) 

348 if group == lca: 

349 break 

350 # Collect all groups from path2 up to but not including the LCA (avoid duplicates) 

351 for group in path2: 351 ↛ 356line 351 didn't jump to line 356 because the loop on line 351 didn't complete

352 if group == lca: 

353 break 

354 if group not in groups: 354 ↛ 351line 354 didn't jump to line 351 because the condition on line 354 was always true

355 groups.append(group) 

356 return groups 

357 

358 

359 # we can use the groups_to_add_intermediate function to determine which groups to add the stream to 

360 # we get the groups to put the intermediate streams so just add it to those groups 

361 simulation_object.graphicObject.all().delete() 

362 groups_to_add = groups_to_add_intermediate() 

363 for group in groups_to_add: 

364 if not simulation_object.graphicObject.filter(group=group).exists(): 364 ↛ 363line 364 didn't jump to line 363 because the condition on line 364 was always true

365 GraphicObject.objects.create( 

366 flowsheet_state=simulation_object.flowsheet_state, 

367 simulationObject=simulation_object, 

368 width=original_graphic_object.width, 

369 height=original_graphic_object.height, 

370 x=original_graphic_object.x, 

371 y=original_graphic_object.y, 

372 group=group 

373 ) 

374 

375 # Creates recycle graphic objects in this group iteration if stream has a recycle connection 

376 if simulation_object.has_recycle_connection: 

377 # Get the recycle associated with this stream 

378 recycle = simulation_object.recycleConnection 

379 

380 # Prevent duplicating the recycle graphic object in the same group 

381 if recycle.simulationObject.graphicObject.filter(group=group).exists(): 

382 continue 

383 

384 # Set graphic objects for the recycle block in the same groups as the stream 

385 default_graphic = recycle.simulationObject.graphicObject.last() 

386 default_width = default_graphic.width if default_graphic else 32 

387 default_height = default_graphic.height if default_graphic else 32\ 

388 

389 # Make sure a stream graphic exists in this group 

390 stream_graphic = simulation_object.graphicObject.filter(group=group).last() 

391 if stream_graphic is None: 391 ↛ 392line 391 didn't jump to line 392 because the condition on line 391 was never true

392 continue 

393 

394 # Recreate the recycle graphic object 

395 GraphicObject.objects.create( 

396 flowsheet_state=simulation_object.flowsheet_state, 

397 simulationObject=recycle.simulationObject, 

398 width=default_width, 

399 height=default_height, 

400 x=stream_graphic.x + (stream_graphic.width - default_width) / 2, 

401 y=stream_graphic.y + stream_graphic.height + 30, 

402 group=group, 

403 )