Coverage for backend/django/flowsheetInternals/graphicData/services/auto_sort.py: 89%

117 statements  

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

1from dataclasses import dataclass 

2import shlex 

3from typing import Iterable 

4from django.db import transaction 

5from django.db.models import Q 

6import pydot 

7 

8from core.auxiliary.enums import ConType 

9from flowsheetInternals.graphicData.models.graphicObjectModel import GraphicObject 

10from flowsheetInternals.unitops.models.Port import Port 

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

12 tracked_bulk_update, 

13) 

14 

15 

16GRAPHVIZ_SCALE = 40 

17RECYCLE_VERTICAL_OFFSET = 80 

18RECYCLE_STACK_OFFSET = 40 

19 

20 

21@dataclass(frozen=True) 

22class Position: 

23 x: float 

24 y: float 

25 

26 

27def _graphic_sort_key(graphic_object: GraphicObject) -> tuple[float, float, int]: 

28 return ( 

29 float(graphic_object.y or 0), 

30 float(graphic_object.x or 0), 

31 graphic_object.simulationObject_id or 0, 

32 ) 

33 

34 

35def _get_recycle_tear_object_id(graphic_object: GraphicObject) -> int | None: 

36 simulation_object = getattr(graphic_object, "simulationObject", None) 

37 if getattr(simulation_object, "objectType", None) != "recycle": 

38 return None 

39 

40 try: 

41 recycle_data = simulation_object.recycleData 

42 except AttributeError: 

43 return None 

44 

45 return getattr(recycle_data, "tearObject_id", None) 

46 

47 

48def _split_recycles( 

49 graphic_objects: list[GraphicObject], 

50) -> tuple[list[GraphicObject], dict[int, list[int]]]: 

51 """ 

52 Connected recycle blocks are annotations on a tear stream, so place them 

53 relative to that stream instead of letting them influence the graph layout. 

54 """ 

55 visible_object_ids = {graphic.simulationObject_id for graphic in graphic_objects} 

56 graphviz_graphics = [] 

57 recycle_ids_by_tear_object_id = {} 

58 

59 for graphic in graphic_objects: 

60 tear_object_id = _get_recycle_tear_object_id(graphic) 

61 if tear_object_id is None or tear_object_id not in visible_object_ids: 

62 graphviz_graphics.append(graphic) 

63 continue 

64 

65 recycle_ids_by_tear_object_id.setdefault(tear_object_id, []).append( 

66 graphic.simulationObject_id 

67 ) 

68 

69 return graphviz_graphics, recycle_ids_by_tear_object_id 

70 

71 

72def _iter_connection_edges( 

73 ports: Iterable[Port], visible_object_ids: set[int] 

74) -> Iterable[tuple[int, int]]: 

75 for port in ports: 

76 if ( 

77 port.unitOp_id not in visible_object_ids 

78 or port.stream_id not in visible_object_ids 

79 ): 

80 continue 

81 

82 if port.direction == ConType.Outlet: 

83 yield port.unitOp_id, port.stream_id 

84 elif port.direction == ConType.Inlet: 84 ↛ 75line 84 didn't jump to line 75 because the condition on line 84 was always true

85 yield port.stream_id, port.unitOp_id 

86 

87 

88def _build_graph( 

89 graphic_objects: list[GraphicObject], 

90 ports: Iterable[Port], 

91) -> pydot.Dot: 

92 visible_object_ids = {graphic.simulationObject_id for graphic in graphic_objects} 

93 graph = pydot.Dot( 

94 graph_type="digraph", 

95 rankdir="LR", 

96 ranksep="2.0 equally", 

97 nodesep="2.0", 

98 ) 

99 graph.set_node_defaults( 

100 shape="box", 

101 fixedsize="true", 

102 width="1.5", 

103 height="1.0", 

104 ) 

105 graph.set_edge_defaults(weight="2") 

106 

107 for sort_index, graphic in enumerate(graphic_objects): 

108 object_id = str(graphic.simulationObject_id) 

109 graph.add_node( 

110 pydot.Node( 

111 object_id, 

112 label=object_id, 

113 sortv=str(sort_index), 

114 ) 

115 ) 

116 

117 for source, target in _iter_connection_edges(ports, visible_object_ids): 

118 graph.add_edge(pydot.Edge(str(source), str(target))) 

119 

120 return graph 

121 

122 

123def _parse_plain_positions(plain_output: str) -> dict[int, Position]: 

124 positions = {} 

125 for line in plain_output.splitlines(): 

126 parts = shlex.split(line) 

127 if not parts or parts[0] != "node": 

128 continue 

129 

130 object_id = int(parts[1]) 

131 positions[object_id] = Position( 

132 x=float(parts[2]) * GRAPHVIZ_SCALE, 

133 y=float(parts[3]) * GRAPHVIZ_SCALE, 

134 ) 

135 

136 return positions 

137 

138 

139def _layout_graph(graph: pydot.Dot) -> dict[int, Position]: 

140 plain_output = graph.create(format="plain", prog="dot").decode("utf-8") 

141 return _parse_plain_positions(plain_output) 

142 

143 

144def compute_auto_sort_positions( 

145 graphic_objects: Iterable[GraphicObject], 

146 ports: Iterable[Port], 

147) -> dict[int, Position]: 

148 sorted_graphics = sorted( 

149 [ 

150 graphic 

151 for graphic in graphic_objects 

152 if graphic.simulationObject_id is not None 

153 ], 

154 key=_graphic_sort_key, 

155 ) 

156 if not sorted_graphics: 156 ↛ 157line 156 didn't jump to line 157 because the condition on line 156 was never true

157 return {} 

158 

159 graphviz_graphics, recycle_ids_by_tear_object_id = _split_recycles(sorted_graphics) 

160 if not graphviz_graphics: 160 ↛ 161line 160 didn't jump to line 161 because the condition on line 160 was never true

161 return {} 

162 

163 graphviz_positions = _layout_graph(_build_graph(graphviz_graphics, ports)) 

164 if not graphviz_positions: 164 ↛ 165line 164 didn't jump to line 165 because the condition on line 164 was never true

165 return {} 

166 

167 min_graphviz_x = min(position.x for position in graphviz_positions.values()) 

168 max_graphviz_y = max(position.y for position in graphviz_positions.values()) 

169 anchor_x = min(float(graphic.x or 0) for graphic in sorted_graphics) 

170 anchor_y = min(float(graphic.y or 0) for graphic in sorted_graphics) 

171 

172 positions = { 

173 object_id: Position( 

174 x=round(anchor_x + position.x - min_graphviz_x, 2), 

175 y=round(anchor_y + max_graphviz_y - position.y, 2), 

176 ) 

177 for object_id, position in graphviz_positions.items() 

178 } 

179 

180 for tear_object_id, recycle_object_ids in recycle_ids_by_tear_object_id.items(): 

181 tear_position = positions.get(tear_object_id) 

182 if tear_position is None: 182 ↛ 183line 182 didn't jump to line 183 because the condition on line 182 was never true

183 continue 

184 

185 for stack_index, recycle_object_id in enumerate(sorted(recycle_object_ids)): 

186 positions[recycle_object_id] = Position( 

187 x=tear_position.x, 

188 y=tear_position.y - RECYCLE_VERTICAL_OFFSET - stack_index * RECYCLE_STACK_OFFSET, 

189 ) 

190 

191 return positions 

192 

193 

194def auto_sort(flowsheet_id: int, group_id: int): 

195 graphic_objects = list( 

196 GraphicObject.objects.filter( 

197 group_id=group_id, 

198 visible=True, 

199 simulationObject__is_deleted=False, 

200 ) 

201 .select_related("simulationObject", "simulationObject__recycleData") 

202 .only( 

203 "id", 

204 "x", 

205 "y", 

206 "group_id", 

207 "simulationObject_id", 

208 "simulationObject__id", 

209 "simulationObject__objectType", 

210 "simulationObject__recycleData__id", 

211 "simulationObject__recycleData__tearObject_id", 

212 ) 

213 ) 

214 object_ids = { 

215 graphic_object.simulationObject_id 

216 for graphic_object in graphic_objects 

217 if graphic_object.simulationObject_id is not None 

218 } 

219 

220 ports = Port.objects.none() 

221 if object_ids: 221 ↛ 227line 221 didn't jump to line 227 because the condition on line 221 was always true

222 ports = Port.objects.filter( 

223 Q(unitOp_id__in=object_ids) | Q(stream_id__in=object_ids), 

224 flowsheet_state__flowsheet_id=flowsheet_id, 

225 ).only("id", "direction", "unitOp_id", "stream_id") 

226 

227 next_positions = compute_auto_sort_positions(graphic_objects, ports) 

228 changed_graphics = [] 

229 moved_objects = [] 

230 

231 for graphic_object in graphic_objects: 

232 object_id = graphic_object.simulationObject_id 

233 if object_id is None or object_id not in next_positions: 233 ↛ 234line 233 didn't jump to line 234 because the condition on line 233 was never true

234 continue 

235 

236 old_position = { 

237 "x": float(graphic_object.x or 0), 

238 "y": float(graphic_object.y or 0), 

239 } 

240 new_position = { 

241 "x": next_positions[object_id].x, 

242 "y": next_positions[object_id].y, 

243 } 

244 if old_position == new_position: 244 ↛ 245line 244 didn't jump to line 245 because the condition on line 244 was never true

245 continue 

246 

247 graphic_object.x = new_position["x"] 

248 graphic_object.y = new_position["y"] 

249 changed_graphics.append(graphic_object) 

250 moved_objects.append( 

251 { 

252 "objectId": object_id, 

253 "graphicObjectId": graphic_object.id, 

254 "oldPosition": old_position, 

255 "newPosition": new_position, 

256 } 

257 ) 

258 

259 with transaction.atomic(): 

260 if changed_graphics: 260 ↛ 267line 260 didn't jump to line 267

261 tracked_bulk_update( 

262 GraphicObject.objects, 

263 changed_graphics, 

264 ["x", "y"], 

265 ) 

266 

267 return moved_objects