Coverage for fiqus/geom_generators/GeometryConductorAC_Strand_RutherfordCopy.py: 46%

791 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2025-12-19 01:48 +0000

1import os 

2import pickle 

3import logging 

4import numpy as np 

5import gmsh 

6 

7import fiqus.data.DataFiQuSConductor as geom 

8from fiqus.utils.Utils import GmshUtils 

9from fiqus.utils.Utils import FilesAndFolders as UFF 

10from abc import ABC, abstractmethod 

11from typing import (Dict, List) 

12from scipy.integrate import quad 

13 

14logger = logging.getLogger('FiQuS') 

15 

16 

17 

18### HELPER FUNCTIONS ### 

19 

20def cylindrical_to_cartesian(rad, angle, height): 

21 """ 

22 Convert cylindrical coordinates to Cartesian coordinates. 

23 

24 :return: A list of Cartesian coordinates [x, y, z]. 

25 :rtype: list 

26 """ 

27 return [rad*np.cos(angle), rad*np.sin(angle), height] 

28 

29def cartesian_to_cylindrical(x, y, z): 

30 """ 

31 Convert Cartesian coordinates to cylindrical coordinates. 

32 

33 :return: A list of cylindrical coordinates [rad, angle, z]. 

34 :rtype: list 

35 """ 

36 rad = np.sqrt(x**2 + y**2) 

37 angle = np.arctan2(y, x) 

38 return [rad, angle, z] 

39 

40def rotate_vector(vector, angle): 

41 """ 

42 Rotate a 3D vector in the xy-plane by a given angle. 

43 

44 :param vector: The 3D vector to rotate. It should be a list or numpy array of three numbers. 

45 :type vector: list or numpy.ndarray 

46 :param angle: The angle by which to rotate the vector, in radians. 

47 :type angle: float 

48 

49 :return: The rotated vector. 

50 :rtype: numpy.ndarray 

51 """ 

52 rotation_matrix = np.array([[np.cos(angle), -np.sin(angle), 0], [np.sin(angle), np.cos(angle), 0], [0, 0, 1]]) 

53 return np.matmul(rotation_matrix, vector) 

54 

55### END HELPER FUNCTIONS ### 

56 

57 

58### GEOMETRY ### 

59class Point: 

60 """ 

61 A class to represent a point in 3D space. 

62 

63 :cvar points_registry: A list of all points created. This is a class-level attribute. 

64 :vartype points_registry: list 

65 :cvar point_snap_tolerance: The tolerance for snapping points to existing points. This is a class-level attribute. 

66 :vartype point_snap_tolerance: float 

67 

68 :ivar pos: A numpy array representing the position of the point in 3D space. This is an instance-level attribute. 

69 :vartype pos: numpy.ndarray 

70 :ivar tag: A gmsh tag for the point, default is None. This is an instance-level attribute. 

71 :vartype tag: int, optional 

72 """ 

73 points_registry = [] # Global registry of points 

74 point_snap_tolerance = 1e-10 # Tolerance for snapping points to existing points 

75 def __init__(self, pos : List[float]): 

76 """ 

77 Constructs the attributes for the point object. 

78 

79 :param pos: A list of three floats representing the position of the point in 3D space. 

80 :type pos: List[float] 

81 """ 

82 self.pos = np.array(pos) 

83 self.tag = None 

84 

85 @classmethod 

86 def create_or_get(cls, pos : any): 

87 """ 

88 Creates a new point if no point with the given position exists, otherwise returns the existing one to assure unique points. 

89 

90 :param pos: The position of the point. 

91 :type pos: list 

92 

93 :return: A point object. 

94 :rtype: Point 

95 """ 

96 new_point = cls(pos) 

97 if new_point in cls.points_registry: 

98 return cls.points_registry[cls.points_registry.index(new_point)] 

99 

100 cls.points_registry.append(new_point) 

101 return new_point 

102 

103 def create_gmsh_instance(self, meshSize : float = 0, tag : int = -1): 

104 if self.tag == None: 

105 self.tag = gmsh.model.occ.addPoint(self.pos[0], self.pos[1], self.pos[2], meshSize, tag) 

106 else: 

107 pass 

108 

109 def __repr__(self) -> str: 

110 return f"Point({self.pos})" 

111 

112 def __eq__(self, o: object) -> bool: 

113 # Two points sharing the same position (within a margin of point_snap_tolerance) should be considered the same point. 

114 if isinstance(o, Point): 

115 return np.linalg.norm(np.array(self.pos) - np.array(o.pos)) < self.point_snap_tolerance 

116 return False 

117 

118 def __hash__(self) -> int: 

119 return hash(tuple(self.pos)) 

120 

121### CURVE ELEMENTS ### 

122 

123class Curve(ABC): 

124 """ 

125 Abstract base class for curves in 3D space. 

126 

127 :cvar curves_registry: A list of all curves created. This is a class-level attribute. 

128 :vartype curves_registry: list 

129 

130 :ivar P1: The start point of the curve. This is an instance-level attribute. 

131 :vartype P1: Point 

132 :ivar P2: The end point of the curve. This is an instance-level attribute. 

133 :vartype P2: Point 

134 :ivar points: A list of points used by the curve. This is an instance-level attribute. 

135 :vartype points: list 

136 :ivar tag: A tag for the curve. This is an instance-level attribute. 

137 :vartype tag: int 

138 """ 

139 curves_registry = [] 

140 def __init__(self) -> None: 

141 """ 

142 Constructs the attributes for the curve object. 

143 """ 

144 self.P1 : Point = None 

145 self.P2 : Point = None 

146 self.points = [] 

147 self.tag = None 

148 

149 # Curve.curves_registry.append(self) 

150 

151 def set_points(self, *points : Point): 

152 """ 

153 Sets the points used by the curve. 

154 

155 :param points: The points to set. 

156 :type points: Point 

157 """ 

158 self.points = list(points) 

159 

160 @abstractmethod 

161 def get_length(self): 

162 pass 

163 

164 @abstractmethod 

165 def create_gmsh_instance(self): 

166 pass 

167 

168 @classmethod 

169 @abstractmethod 

170 def create_or_get(cls, *points): 

171 pass 

172 

173 @classmethod 

174 def get_curve_from_tag(cls, tag): 

175 """ 

176 Returns the curve with the given tag. 

177 

178 :param tag: The tag of the curve. 

179 :type tag: int 

180 

181 :return: The curve with the given tag, or None if no such curve exists. 

182 :rtype: Curve or None 

183 """ 

184 for curve in cls.curves_registry: 

185 if curve.tag == tag: 

186 return curve 

187 return None 

188 

189 

190 @classmethod 

191 def get_closed_loops(cls, curves): 

192 """ 

193 Returns a list of lists of curves, where each list of curves defines a closed loop. 

194 The function assumes that no two closed loops share a curve and that chains of curves do not split into multiple chains. 

195 

196 :param curves: A list of curves. 

197 :type curves: list 

198 

199 :return: A list of lists of curves, where each list of curves defines a closed loop. 

200 :rtype: list 

201 """ 

202 closed_loops = [] 

203 

204 def get_curve_link(current_curve, curves, curve_link): 

205 # Recursive function to find a link of connected curves. 

206 # Curves is a list of curves to search through, not containing the current curve. 

207 # Curve_link is a list of curves that are connected to the current curve. 

208 # Curves are removed from 'curves' as they are added to the curve_link. 

209 # The function returns when no more curves are connected to the current link. 

210 for curve in curves: 

211 for point in [current_curve.P1, current_curve.P2]: 

212 if point in [curve.P1, curve.P2]: 

213 curve_link.append(curve) 

214 curves.remove(curve) 

215 return get_curve_link(curve, curves, curve_link) 

216 return curves, curve_link # Return the remaining curves and the curve link 

217 

218 while len(curves) > 0: 

219 curve0 = curves[0] 

220 curves.remove(curve0) 

221 curves, curve_link = get_curve_link(curve0, curves, [curve0]) 

222 

223 first_curve_points = set([curve_link[0].P1, curve_link[0].P2]) 

224 last_curve_points = set([curve_link[-1].P1, curve_link[-1].P2]) 

225 

226 # TODO: Have to add the case where there is only a single curve in the link. This should not be considered a closed loop. 

227 if len(curve_link)>2 and len(first_curve_points&last_curve_points) == 1: # Check if the last curve in the link is connected to the first curve in the link. If so, the link is a closed loop. 

228 closed_loops.append(curve_link) 

229 # If the link only contains two curves, the curves must be connected at both ends to form a closed loop. 

230 elif len(curve_link) == 2 and len(first_curve_points&last_curve_points) == 2: 

231 closed_loops.append(curve_link) 

232 

233 return closed_loops 

234 

235class CircleArc(Curve): 

236 """ 

237 A class to represent a circular arc, subclass of Curve. 

238 

239 :ivar P1: The first point of the arc. This is an instance-level attribute. 

240 :vartype P1: Point 

241 :ivar P2: The second point of the arc. This is an instance-level attribute. 

242 :vartype P2: Point 

243 :ivar C: The center of the arc. This is an instance-level attribute. 

244 :vartype C: Point 

245 """ 

246 def __init__(self, P1 : any, C : any, P2 : any) -> None: 

247 """ 

248 Constructs the attributes for the arc object. 

249 

250 :param P1: The start point of the arc. 

251 :type P1: list or Point 

252 :param P2: The end point of the arc. 

253 :type P2: list or Point 

254 :param C: The center of the arc. 

255 :type C: list or Point 

256 """ 

257 super().__init__() 

258 self.P1 = P1 if isinstance(P1, Point) else Point.create_or_get(P1) 

259 self.P2 = P2 if isinstance(P2, Point) else Point.create_or_get(P2) 

260 self.C = C if isinstance(C, Point) else Point.create_or_get(C) 

261 

262 self.set_points(self.P1, self.C, self.P2) 

263 

264 Curve.curves_registry.append(self) 

265 

266 def create_gmsh_instance(self, tag : int = -1): 

267 if self.tag == None: # If the curve has not been created yet 

268 self.P1.create_gmsh_instance() 

269 self.P2.create_gmsh_instance() 

270 self.C.create_gmsh_instance() 

271 self.tag = gmsh.model.occ.addCircleArc(self.P1.tag, self.C.tag, self.P2.tag, tag) 

272 

273 def get_length(self): 

274 radius = np.linalg.norm(self.P1.pos - self.C.pos) 

275 angle = np.arccos(np.dot(self.P1.pos - self.C.pos, self.P2.pos - self.C.pos)/(radius**2)) 

276 return radius*angle 

277 

278 @classmethod 

279 def create_or_get(cls, P1 : any, C : any, P2 : any): 

280 return cls(P1, C, P2) 

281 

282 def __repr__(self) -> str: 

283 return f"CircleArc({self.P1.pos}, {self.P2.pos})" 

284 

285class Line(Curve): 

286 """ 

287 A class to represent a line, subclass of Curve. 

288 

289 :ivar P1: the start point of the line 

290 :type P1: list or Point 

291 :ivar P2: the end point of the line 

292 :type P2: list or Point 

293 

294 """ 

295 def __init__(self, P1 : any, P2 : any) -> None: 

296 """ 

297 Constructs the attributes for the line object. 

298 P1 and P2 can be either 'Point' objects or lists of three floats representing the position of the point. 

299 

300 :param P1: the start point of the line 

301 :type P1: list or Point 

302 :param P2: the end point of the line 

303 :type P2: list or Point 

304 """ 

305 super().__init__() 

306 self.P1 = P1 if isinstance(P1, Point) else Point.create_or_get(P1) 

307 self.P2 = P2 if isinstance(P2, Point) else Point.create_or_get(P2) 

308 

309 self.set_points(self.P1, self.P2) 

310 

311 def get_length(self): 

312 return np.linalg.norm(self.P1.pos - self.P2.pos) 

313 

314 def get_furthest_point(self): 

315 """returns the line point which is further away from the origin""" 

316 if np.linalg.norm(self.P1.pos) > np.linalg.norm(self.P2.pos): 

317 return self.P1 

318 elif np.linalg.norm(self.P1.pos) < np.linalg.norm(self.P2.pos): 

319 return self.P2 

320 else: 

321 ValueError("cant determine furthest point. Dictance is equal") 

322 

323 def __repr__(self) -> str: 

324 return f"Line from {tuple(self.P1.pos)} to {tuple(self.P2.pos)}" 

325 

326 @classmethod 

327 def create_or_get(cls, P1 : any, P2 : any): 

328 """ 

329 Creates a new line if it doesn't exist, or returns the existing one. 

330 

331 :param P1: the first point of the line 

332 :type P1: Point 

333 :param P2: the second point of the line 

334 :type P2: Point 

335 

336 :return: a line object 

337 :rtype: Line 

338 """ 

339 

340 new_line = cls(P1, P2) 

341 if new_line in cls.curves_registry: 

342 return cls.curves_registry[cls.curves_registry.index(new_line)] 

343 

344 cls.curves_registry.append(new_line) 

345 return new_line 

346 

347 @classmethod 

348 def is_colinear(cls, line1, line2): 

349 """ 

350 Checks if two lines are colinear. 

351 """ 

352 l1 = line1.P2.pos - line1.P1.pos 

353 l2 = line2.P2.pos - line2.P1.pos 

354 # Check if the lines are parallel 

355 if np.linalg.norm(np.cross(l1, l2)) < 1e-10: 

356 # Check if the lines are colinear 

357 if np.linalg.norm(np.cross(l1, line2.P1.pos - line1.P1.pos)) < 1e-10: 

358 return True 

359 return False 

360 

361 @classmethod 

362 def remove_from_registry(cls, lines): 

363 """ 

364 Removes a list of lines from the registry. 

365 

366 :param lines: A list of lines to remove. 

367 :type lines: list 

368 """ 

369 for line in lines: 

370 if line in cls.curves_registry: 

371 cls.curves_registry.remove(line) 

372 

373 def create_gmsh_instance(self, tag : int = -1): 

374 if self.tag == None: 

375 self.P1.create_gmsh_instance() 

376 self.P2.create_gmsh_instance() 

377 self.tag = gmsh.model.occ.addLine(self.P1.tag, self.P2.tag, tag) 

378 

379 def __eq__(self, o: object) -> bool: 

380 # Two Line-entities specified by the same two points should be considered equal lines. 

381 # Check if the other object is a line 

382 if isinstance(o, Line): 

383 # Check if the lines have the same points 

384 return (self.P1 == o.P1 and self.P2 == o.P2) or (self.P1 == o.P2 and self.P2 == o.P1) 

385 return False 

386 

387 def __hash__(self) -> int: 

388 return hash(frozenset([self.P1, self.P2])) # Frozenset is hashable and order-independent 

389 

390class EllipseArc(Curve): 

391 """ 

392 A class to represent an elliptical arc, subclass of Curve. 

393 

394 :ivar P1: The start point of the arc. This is an instance-level attribute. 

395 :vartype P1: Point 

396 :ivar P2: The end point of the arc. This is an instance-level attribute. 

397 :vartype P2: Point 

398 :ivar C: The center point of the arc. This is an instance-level attribute. 

399 :vartype C: Point 

400 :ivar M: The major axis point of the arc (point anywhere on the major axis). This is an instance-level attribute. 

401 :vartype M: Point 

402 """ 

403 def __init__(self, P_start : any, P_center : any, P_major : any, P_end : any) -> None: 

404 """ 

405 Initializes an elliptical arc object. 

406 

407 :param P_start: The start point of the arc. If not a Point instance, attempts to retrieve or create one. 

408 :type P_start: any 

409 :param P_end: The end point of the arc. If not a Point instance, attempts to retrieve or create one. 

410 :type P_end: any 

411 :param P_center: The center point of the arc. If not a Point instance, attempts to retrieve or create one. 

412 :type P_center: any 

413 :param P_major: The major axis point of the arc (point anywhere on the major axis). If not a Point instance, attempts to retrieve or create one. 

414 :type P_major: any 

415 

416 :rtype: None 

417 """ 

418 super().__init__() 

419 self.P1 = P_start if isinstance(P_start, Point) else Point.create_or_get(P_start) # Start point 

420 self.P2 = P_end if isinstance(P_end, Point) else Point.create_or_get(P_end) # End point 

421 self.C = P_center if isinstance(P_center, Point) else Point.create_or_get(P_center) # Center point 

422 self.M = P_major if isinstance(P_major, Point) else Point.create_or_get(P_major) # Major axis point (point anywhere on the major axis) 

423 

424 self.set_points(self.P1, self.C, self.M, self.P2) 

425 

426 Curve.curves_registry.append(self) 

427 # self.tag = None 

428 

429 def get_length(self): 

430 # Approximate the length of the elliptical arc 

431 # 1) Center the ellipse on the origin and rotate it so that the major axis is on the x-axis 

432 # a) center the ellipse 

433 P1 = self.P1.pos - self.C.pos 

434 P2 = self.P2.pos - self.C.pos 

435 M = self.M.pos - self.C.pos 

436 # b) rotate the ellipse 

437 angle = np.arctan2(M[1], M[0]) 

438 P1 = rotate_vector(P1, -angle) 

439 P2 = rotate_vector(P2, -angle) 

440 # c) calculate the semi-major and semi-minor axes 

441 x1, y1 = P1[0], P1[1] 

442 x2, y2 = P2[0], P2[1] 

443 

444 b = np.sqrt( (x2**2 * y1**2 - x1**2 * y2**2) / (x2**2 - x1**2) ) # semi-minor axis 

445 a = np.sqrt( (x2**2 * y1**2 - x1**2 * y2**2) / (y1**2 - y2**2) ) # semi-major axis 

446 

447 # 2) Calculate the length of the elliptical arc 

448 theta1 = np.arctan2(y1, x1) # angle of the start point 

449 theta2 = np.arctan2(y2, x2) # angle of the end point 

450 if theta2 < theta1: 

451 theta1, theta2 = theta2, theta1 

452 t = lambda theta: np.arctan( (a/b) * np.tan(theta) ) # Change of parameter from angle to t 

453 arc_length = quad(lambda theta: np.sqrt(a**2 * np.sin(t(theta))**2 + b**2 * np.cos(t(theta))**2), theta1, theta2)[0] # Calculate the arc length 

454 

455 return arc_length 

456 

457 

458 def create_gmsh_instance(self, tag : int = -1): 

459 if self.tag == None: 

460 self.P1.create_gmsh_instance() 

461 self.P2.create_gmsh_instance() 

462 self.C.create_gmsh_instance() 

463 self.M.create_gmsh_instance() 

464 self.tag = gmsh.model.occ.addEllipseArc(self.P1.tag, self.C.tag, self.M.tag, self.P2.tag, tag) 

465 

466 def __repr__(self) -> str: 

467 return f"EllipseArc({self.P1.pos}, {self.P2.pos})" 

468 

469 @classmethod 

470 def create_or_get(cls, P_start : any, P_center : any, P_major : any, P_end : any): 

471 return cls(P_start, P_center, P_major, P_end) 

472 

473 

474### SURFACE ELEMENTS ### 

475 

476class Surface: 

477 """ 

478 A class to represent a surface in 3D space. 

479 

480 :cvar surfaces_registry: A class-level attribute that keeps track of all created surfaces. 

481 :vartype surfaces_registry: list 

482 :cvar curve_loop_registry: A class-level attribute that keeps track of all curve loops. Each curve loop is stored as a dict with the keys 'curves' and 'tag'. This list is necessary when creating surfaces with holes. The curve-loops of the holes may already have been created when creating the surface of the hole, in which case we get the tag of the existing curve-loop instead of creating a new one. 

483 :vartype curve_loop_registry: list 

484 

485 :ivar boundary_curves: A list of Curve objects that define the closed outer boundary of the surface. This is an instance-level attribute. 

486 :vartype boundary_curves: list 

487 :ivar inner_boundary_curves: A list of lists of Curve objects. Each list of curves defines the closed boundary of a hole in the surface. This is an instance-level attribute. 

488 :vartype inner_boundary_curves: list 

489 :ivar curve_loop_tag: A unique identifier for the curve loop of the outer boundary, initially set to None. This is an instance-level attribute. 

490 :vartype curve_loop_tag: int 

491 :ivar inner_curve_loop_tags: A list of unique identifiers for the inner curve loops. Each tag corresponds to the curve-loop of a hole in the surface. This is an instance-level attribute. 

492 :vartype inner_curve_loop_tags: list 

493 :ivar surface_tag: A unique identifier for the surface, initially set to None. This is an instance-level attribute. 

494 :vartype surface_tag: int 

495 :ivar physical_boundary_tag: A unique identifier for the physical group of the boundary curves, initially set to None. This is an instance-level attribute. 

496 :vartype physical_boundary_tag: int 

497 :ivar physical_boundary_name: A name for the physical group of the boundary curves, initially set to None. This is an instance-level attribute. 

498 :vartype physical_boundary_name: str 

499 :ivar physical_inner_boundary_tags: A list of unique identifiers for the physical groups of the inner boundary curves. This is an instance-level attribute. 

500 :vartype physical_inner_boundary_tags: list 

501 :ivar physical_inner_boundary_names: A list of names for the physical groups of the inner boundary curves. This is an instance-level attribute. 

502 :vartype physical_inner_boundary_names: list 

503 :ivar physical_surface_tag: A unique identifier for the physical group of the surface, initially set to None. This is an instance-level attribute. 

504 :vartype physical_surface_tag: int 

505 :ivar physical_surface_name: A name for the physical group of the surface, initially set to None. This is an instance-level attribute. 

506 :vartype physical_surface_name: str 

507 :ivar material: The material-ID of the surface. Used to specify material properties in the .regions file, initially set to None. This is an instance-level attribute. 

508 :vartype material: any 

509 """ 

510 

511 surfaces_registry = [] 

512 curve_loop_registry = [] 

513 def __init__(self, boundary_curves : List[Curve] = [], inner_boundary_curves : List[List[Curve]] = []): 

514 """ 

515 Constructs the attributes for the surface object. 

516 

517 :param boundary_curves: A list of Curve objects that define the outer boundary of the surface. The curves must form a closed loop. 

518 :type boundary_curves: list[Curve] 

519 :param inner_boundary_curves: A list of lists of Curve objects. Each list of curves defines the boundary of a hole in the surface. 

520 :type inner_boundary_curves: list[list[Curve]] 

521 """ 

522 self.boundary_curves = boundary_curves 

523 self.inner_boundary_curves = inner_boundary_curves 

524 

525 self.curve_loop_tag = None 

526 self.inner_curve_loop_tags : List[int] = [] 

527 self.surface_tag = None 

528 

529 self.physical_boundary_tag = None 

530 self.physical_boundary_name = None 

531 self.physical_inner_boundary_tags = [] 

532 self.physical_inner_boundary_names = [] 

533 

534 self.physical_surface_tag = None 

535 self.physical_surface_name = None 

536 

537 self.material = None 

538 

539 Surface.surfaces_registry.append(self) 

540 

541 def create_gmsh_instance(self): 

542 if self.surface_tag == None: 

543 for curve in self.boundary_curves + sum(self.inner_boundary_curves, []): 

544 curve.create_gmsh_instance() 

545 

546 self.curve_loop_tag = self.create_or_get_curve_loop(self.boundary_curves) 

547 self.inner_curve_loop_tags = [self.create_or_get_curve_loop(curves) for curves in self.inner_boundary_curves] 

548 self.surface_tag = gmsh.model.occ.add_plane_surface([self.curve_loop_tag] + self.inner_curve_loop_tags) 

549 

550 def add_physical_boundary(self, name : str = ''): 

551 self.physical_boundary_tag = gmsh.model.add_physical_group(1, [curve.tag for curve in self.boundary_curves], name = name) 

552 self.physical_boundary_name = name 

553 

554 def add_physical_surface(self, name : str = ''): 

555 self.physical_surface_tag = gmsh.model.add_physical_group(2, [self.surface_tag], name = name) 

556 self.physical_surface_name = name 

557 

558 def get_circumference(self): 

559 return sum([curve.get_length() for curve in self.boundary_curves]) 

560 

561 

562 @classmethod 

563 def update_tags(cls, surfaces): 

564 """ 

565 Updates the tags of model entities of dimension lower than 2. 

566 

567 When saving the geometry as a .brep file, these tags may be (seemingly arbitrarily) changed. This method ensures that the tags are consistent and correctly assigned. 

568 

569 Steps: 

570 1) Find the tags of all outer boundary curves of every surface. 

571 2) Divide the boundary curves into groups. Each group of curves are either on the boundary of a single surface or on the intersection of the same multiple surfaces. 

572 3) Update the tags of the curves in the curve groups. The tags are assigned not to their corresponding curve but to any curve in the group. 

573 4) Update the tags of the points. Points which are in multiple curve-groups are assigned to a new group. Point tags are assigned based on the groups they are in. 

574 

575 :param surfaces: A list of Surface objects to update the tags for. 

576 :type surfaces: list[Surface] 

577 """ 

578 

579 gmsh.model.occ.synchronize() 

580 #1) Find the tags of all outer boundary curves of every surface 

581 #1.1) Find the inner surfaces of each surface with holes 

582 surfaces_inner_surface_indices = [] # List of lists of indices of surfaces. Each list of indices corresponds to the surfaces that are inside the surface with the same index. 

583 for surface in surfaces: 

584 inner_surface_indices = [] 

585 if surface.inner_boundary_curves: # If the surface has holes 

586 for inner_boundary in surface.inner_boundary_curves: 

587 for surface in surfaces: # Loop through all surfaces to find the surfaces that are inside the outer surface. 

588 if set(inner_boundary) & set(surface.boundary_curves): # If the two sets of curves have any common curves, the inner surface is inside the outer surface. 

589 inner_surface_indices.append(surfaces.index(surface)) # Add the index of the inner surface to the list of inner surfaces. 

590 surfaces_inner_surface_indices.append(inner_surface_indices) 

591 

592 #1.2) Find the tags of the outer boundary curves of each surface by finding the boundary of the surface (inner and outer) and removing the boundary curves of the inner surfaces. 

593 surfaces_outer_boundary_tags = [set([abs(tag) for dim, tag in gmsh.model.get_boundary([(2, surface.surface_tag)])]) for surface in surfaces] 

594 for surface_i in range(len(surfaces)): 

595 if surfaces[surface_i].inner_boundary_curves: 

596 surface_inner_surfaces_boundary_tags = set.union(*[surfaces_outer_boundary_tags[i] for i in surfaces_inner_surface_indices[surface_i]]) # The boundary tags of the inner surfaces 

597 surfaces_outer_boundary_tags[surface_i] = surfaces_outer_boundary_tags[surface_i] - surface_inner_surfaces_boundary_tags 

598 

599 #2) Divide the boundary curves into groups. Each group of curves are either on the boundary of a single surface or on the intersection of multiple surfaces, but not both. 

600 # We also find the tags of the curves in each group. 

601 surface_outer_boundary_curves = [set(surface.boundary_curves) for surface in surfaces] 

602 

603 curve_groups = [] # List of sets of curves. Each set of curves is a group, defined as curves which lie only on the boundary of a single surface or on the intersection of multiple surfaces. 

604 curve_group_tags = [] # List of sets of tags. Each set of tags corresponds to the set of curves in curve_groups. 

605 for i, si_curves in enumerate(surface_outer_boundary_curves): 

606 for j, sj_curves in enumerate(surface_outer_boundary_curves[i+1:]): 

607 j += i+1 

608 common_curves = si_curves & sj_curves 

609 if common_curves: 

610 curve_groups.append(common_curves) # Add the common curves to the list of curve groups 

611 si_curves -= common_curves # Remove the common curves from the surface boundary curves 

612 sj_curves -= common_curves # Remove the common curves from the surface boundary curves 

613 

614 curve_group_tags.append(surfaces_outer_boundary_tags[i] & surfaces_outer_boundary_tags[j]) # Add the tags of the common curves to the list of curve group tags 

615 surfaces_outer_boundary_tags[i] -= curve_group_tags[-1] # Remove the tags of the common curves from the surface boundary tags 

616 surfaces_outer_boundary_tags[j] -= curve_group_tags[-1] # Remove the tags of the common curves from the surface boundary tags 

617 

618 curve_groups.append(si_curves) # Add the remaining curves to the list of curve groups 

619 curve_group_tags.append(surfaces_outer_boundary_tags[i]) # Add the tags of the remaining curves to the list of curve group tags 

620 

621 #3) Update the tags of the curves in the curve groups 

622 # The tags are assigned not to their corresponding curve but to any curve in the group. 

623 for group, group_tags in zip(curve_groups, curve_group_tags): 

624 for curve, tag in zip(group, group_tags): 

625 curve.tag = tag 

626 

627 #4) We have now updated the tags of all curves. Next we update the tags of the points. 

628 

629 #4.1) Get all points in each group of curves and the tags of the points in each group of curves 

630 curve_groups_points = [set([point for curve in group for point in [curve.P1, curve.P2]]) for group in curve_groups] 

631 curve_groups_point_tags = [set(sum([list(gmsh.model.get_adjacencies(1, tag)[1]) for tag in group_tags], [])) for group_tags in curve_group_tags] 

632 

633 #4.2) Points which are in multiple curve-groups are assigned to a new group. 

634 # These points will be removed from the groups they are in and assigned to the new group, based on the groups they are in. 

635 # Iterate trough all points and check which groups they are in. Points in same groups will be assigned to a new group. 

636 all_points = set.union(*curve_groups_points) 

637 point_new_groups = {} # Dictionary with keys as tuples of indices of the groups the point is in, and values as lists of points in the same groups. 

638 for point in all_points: 

639 groups_point_is_in = [i for i, group in enumerate(curve_groups_points) if point in group] 

640 # If the point is in multiple groups, remove the point from all groups 

641 if len(groups_point_is_in) > 1: 

642 for i in groups_point_is_in: 

643 curve_groups_points[i].remove(point) # Remove the point from all groups, as it will be assigned to a new group. 

644 # Sort the groups the point is in, make it a tuple and use it as a key in a dictionary. The value is a list of all points in the same groups. 

645 point_new_groups[tuple(sorted(groups_point_is_in))] = point_new_groups.get(tuple(sorted(groups_point_is_in)), []) + [point] 

646 

647 #4.3) Update the tags of the points in the new groups 

648 # Get the tags of all points in each group of points as the boundary of the group of curves 

649 for group_indices, points in point_new_groups.items(): 

650 # The tags of the points in the new group is the intersection of the tags of the points in the groups the point is in. 

651 point_tags = set.intersection(*[curve_groups_point_tags[i] for i in group_indices]) 

652 

653 # Update the tags of the points in the group 

654 for point, point_tag in zip(points, point_tags): 

655 point.tag = point_tag 

656 

657 # Remove the tags of the points in the new group from the tags of the points in the groups the point was in before it was assigned to the new group. 

658 for i in group_indices: 

659 curve_groups_point_tags[i] -= point_tags 

660 

661 #4.4) Update the tags of points in the remaining groups 'curve_groups_points' 

662 for group, group_tags in zip(curve_groups_points, curve_groups_point_tags): 

663 for point, tag in zip(group, group_tags): 

664 point.tag = tag 

665 

666 

667 

668 

669 @classmethod 

670 def remove_from_registry(cls, surfaces): 

671 for surface in surfaces: 

672 cls.surfaces_registry.remove(surface) 

673 

674 @classmethod 

675 def create_or_get_curve_loop(cls, curves): 

676 """ 

677 Creates a curve loop if it does not already exist, otherwise returns the existing curve loop. 

678 

679 A curve loop is a sequence of curves that form a closed loop. This method checks if a curve loop with the given curves already exists in the curve_loop_registry.  

680 If it does, the method returns the tag of the existing curve loop.  

681 If it doesn't, the method creates a new curve loop, adds it to the curve_loop_registry, and returns its tag. 

682 

683 :param curves: A list of Curve objects that define the curve loop. 

684 :type curves: list[Curve] 

685 

686 :return: The tag of the curve loop. 

687 :rtype: int 

688 """ 

689 for curve_loop in cls.curve_loop_registry: 

690 if curve_loop['curves'] == set(curves): 

691 return curve_loop['tag'] 

692 

693 tag = gmsh.model.occ.addCurveLoop([curve.tag for curve in curves]) 

694 cls.curve_loop_registry.append({'tag': tag, 'curves': set(curves)}) 

695 return tag 

696 

697 @classmethod 

698 def replace_overlapping_edges(cls): 

699 """ 

700 Replaces overlapping boundary-curves of type 'Line' across all surfaces. 

701 

702 If multiple surface boundaries contain lines which are overlapping, the overlapping lines are replaced by the fraction 

703 of the line which is unique to the surface as well as the fraction of the line which is shared with the other surface. 

704 

705 Steps: 

706 1) Sort all existing lines into groups of lines which are colinear. 

707 2) For each group of colinear lines, make a list of all the unique points used, sorted by their location on the colinear line. 

708 3) For each line of each surface, replace the line by fragments of the line, defined by the points in the list from step 2. 

709 """ 

710 #1) Sort all existing lines into groups of lines which are colinear 

711 lines = [line for line in Curve.curves_registry if type(line) == Line] 

712 if len(lines) == 0: # If there are no lines, skip the rest of the function 

713 return 

714 colinear_groups = [[lines[0]]] 

715 for line in lines[1:]: 

716 for group in colinear_groups: 

717 if Line.is_colinear(line, group[0]): 

718 group.append(line) 

719 break 

720 else: 

721 colinear_groups.append([line]) 

722 

723 #2) For each group of colinear lines, make a list of all the unique points used, sorted by their location on the colinear line 

724 colinear_groups_points = [] 

725 for group in colinear_groups: 

726 points = [] 

727 for line in group: 

728 points.append(line.P1) 

729 points.append(line.P2) 

730 points = list(set(points)) # Remove duplicates 

731 angle = np.arctan2((line.P2.pos-line.P1.pos)[1], (line.P2.pos-line.P1.pos)[0]) # Angle of the lines with respect to the x-axis 

732 positions = [p.pos - points[0].pos for p in points] # Move the points so that the colinear line passes through the origin 

733 positions = [rotate_vector(p, -angle)[0] for p in positions] # Rotate the points so that the lines are parallel to the x-axis, making it a 1D problem. 

734 points = np.array(points)[np.argsort(positions)].tolist() # Sort the points by their position along the colinear line 

735 colinear_groups_points.append(points) 

736 

737 #3) For each line for each surface, replace the line by fragments of the line, defined by the points in the list from step 2  

738 for area in cls.surfaces_registry: # For each rectangle 

739 for l, line in enumerate(area.boundary_curves): # For each line 

740 for i, group in enumerate(colinear_groups): # Find the group of colinear lines that the line belongs to 

741 if line in group: 

742 if len(group) == 1: # If the line is not colinear with any other line, skip it 

743 break 

744 # Find the points that define the line fragments 

745 points = colinear_groups_points[i] 

746 line_point1_index = points.index(line.P1) 

747 line_point2_index = points.index(line.P2) 

748 if line_point1_index > line_point2_index: 

749 # If the points orientation differs from the line orientation, reverse the list of points 

750 # The points are sorted by their position along the colinear line, which is not necessarily the same as the orientation of the line. 

751 # The orientation of the fragments must match the orientation of the line. 

752 points.reverse() 

753 line_point1_index = points.index(line.P1) 

754 line_point2_index = points.index(line.P2) 

755 line_fragment_points = points[line_point1_index:line_point2_index+1] # The points that define the line fragments 

756 

757 # Create the line fragments 

758 line_fragments = [Line.create_or_get(line_fragment_points[i], line_fragment_points[i+1]) for i in range(len(line_fragment_points)-1)] 

759 

760 if len(line_fragments) > 1: # If the line is split into multiple fragments, remove the old line and insert the new fragments into the boundary curves of the surface. 

761 Line.remove_from_registry([line]) # Remove the old line from the lines-registry. 

762 for fragment in line_fragments: 

763 area.boundary_curves.insert(area.boundary_curves.index(line), fragment) # Insert the new line fragments into the boundary curves of the surface. 

764 area.boundary_curves.remove(line) # Remove the original line from the boundary curves of the surface. 

765 

766 @classmethod 

767 def set_correct_boundary_orientation(self, surfaces): 

768 """ 

769 When creating surfaces with holes, the boundaries must have a specific orientation to ensure that the surfaces are correctly defined. 

770 This method sets the correct orientation of the boundaries of the surfaces. 

771 The orientation of the boundary is determined by the orientation of the curve-loop, which is determined by the orientation of the first curve in the loop. 

772 We therefore must ensure that the first curve of the curve-loop is oriented correctly. 

773 """ 

774 # It seems to work to just set all the boundaries to have the same orientation. Very simple and does not require any complex logic. 

775 for surface in surfaces: 

776 outer_boundary_points = sum([[curve.P1, curve.P2] for curve in surface.boundary_curves], []) 

777 mean_point = np.mean([point.pos for point in outer_boundary_points], axis=0) # Mean point of the boundary (Center of mass) 

778 mean_to_P1 = surface.boundary_curves[0].P1.pos - mean_point # Vector from the mean point to the first point of the boundary curve 

779 mean_to_P2 = surface.boundary_curves[0].P2.pos - mean_point # Vector from the mean point to the second point of the boundary curve 

780 

781 # Using the two vectors we can check the orientation of the first curve in the loop, based on the sign of the determinant of the matrix formed by the two vectors. 

782 if np.linalg.det(np.column_stack((mean_to_P1[:-1], mean_to_P2[:-1]))) < 0: 

783 # If the determinant is negative we reverse the orientation so that it is positive (counter-clockwise orientation) 

784 surface.boundary_curves[0].P1, surface.boundary_curves[0].P2 = surface.boundary_curves[0].P2, surface.boundary_curves[0].P1 

785 

786class Disk(Surface): 

787 """ 

788 A class to represent a disk. Inherits from the Surface class. 

789 

790 :ivar rad: The radius of the disk. This is an instance-level attribute. 

791 :vartype rad: float 

792 :ivar partitions: The number of partitions to divide the boundary of the disk into when generating boundary curves. This is an instance-level attribute. 

793 :vartype partitions: int 

794 :ivar center_point: The center point of the disk. Can be a position ([x, y, z]) or a 'Point' object. This is an instance-level attribute. 

795 :vartype center_point: Point 

796 :ivar physicalEdgePointTag: A unique identifier for the physical group of the edge point. Used in pro-template for fixing phi=0 in the outer matrix boundary as well as on the filament boundaries. This is an instance-level attribute. 

797 :vartype physicalEdgePointTag: int 

798 """ 

799 def __init__(self, center_point : any, rad : float, partitions : int = 4): 

800 """ 

801 Constructs the attributes for the disk object. 

802 

803 :param center_point: The center point of the disk. Can be a position ([x, y, z]) or a 'Point' object. 

804 :type center_point: any 

805 :param rad: The radius of the disk. 

806 :type rad: float 

807 :param partitions: The number of partitions to divide the boundary of the disk into when generating boundary curves, defaults to 4 

808 :type partitions: int, optional 

809 """ 

810 super().__init__() 

811 self.rad = rad 

812 self.partitions = partitions 

813 

814 self.center_point = center_point if isinstance(center_point, Point) else Point.create_or_get(center_point) 

815 self.boundary_curves = self.generate_boundary_curves() 

816 self.inner_boundary_curves = [] 

817 

818 self.physicalEdgePointTag = None # Used in pro-template for fixing phi=0 in the outer matrix boundary as well as on the filament boundaries. 

819 

820 def generate_boundary_curves(self): 

821 """ 

822 Generates the boundary curves for the disk. 

823 

824 This method divides the boundary of the disk into 'partitions' number of segments and creates a CircleArc for each segment. 

825 

826 :return: A list of CircleArc objects that define the boundary of the disk. 

827 :rtype: list 

828 """ 

829 edgePoints = [np.array(self.center_point.pos) + np.array(cylindrical_to_cartesian(self.rad, 2*np.pi * n/self.partitions + np.pi + cartesian_to_cylindrical(self.center_point.pos[0], self.center_point.pos[1], self.center_point.pos[2])[1], 0)) for n in range(self.partitions)] 

830 boundary_curves = [CircleArc(edgePoints[n], self.center_point, edgePoints[(n+1)%self.partitions]) for n in range(self.partitions)] 

831 return boundary_curves 

832 

833class Rectangle(Surface): 

834 """ 

835 A class to represent a Rectangle surface. Inherits from the Surface class. 

836 

837 :ivar width: The width of the rectangle. This is an instance-level attribute. 

838 :vartype width: float 

839 :ivar height: The height of the rectangle. This is an instance-level attribute. 

840 :vartype height: float 

841 :ivar center_point: The center point of the Rectangle. Can be a position ([x, y, z]) or a 'Point' object. This is an instance-level attribute. 

842 :vartype center_point: Point 

843 """ 

844 def __init__(self, center_point : any, width : float, height : float): 

845 """ 

846 Constructs the attributes for the Rectangle object. 

847 """ 

848 super().__init__() 

849 self.width = width 

850 self.height = height 

851 

852 self.center_point = center_point if isinstance(center_point, Point) else Point.create_or_get(center_point) 

853 self.boundary_curves = self.generate_boundary_lines() 

854 self.inner_boundary_curves = [] 

855 

856 def generate_boundary_lines(self): 

857 """ 

858 Generates the boundary lines for the Rectangle. 

859 

860 :return: A list of Line objects that define the closed loop boundary of the Rectangle. 

861 :rtype: list 

862 """ 

863 

864 edgePoints = np.array(self.center_point.pos) + (np.array([self.width/2.0, -self.height/2.0, 0]), np.array([self.width/2.0, self.height/2.0, 0]), np.array([-self.width/2.0, self.height/2.0, 0]), np.array([-self.width/2.0, -self.height/2.0, 0])) 

865 boundary_curves = [Line(edgePoints[n], edgePoints[(n+1)%4]) for n in range(4)] 

866 return boundary_curves 

867 

868class Square(Surface): 

869 """ 

870 A class to represent a Square surface. Inherits from the Surface class. 

871 

872 :ivar rad: The radius of the biggest circle fitting inside the square(can be interpreted as the smallest boundary distance to center). This is an instance-level attribute. 

873 :vartype rad: float 

874 :ivar partitions: The number of partitions to divide the boundary of the square into when generating the boundary curves. This is an instance-level attribute. 

875 :vartype partitions: int 

876 :ivar center_point: The center point of the Square. Can be a position ([x, y, z]) or a 'Point' object. This is an instance-level attribute. 

877 :vartype center_point: Point 

878 """ 

879 def __init__(self, center_point : any, rad : float, partitions : int = 4): 

880 """ 

881 Constructs the attributes for the Square object. 

882 

883 :param center_point: The center point of the Square. Can be a position ([x, y, z]) or a 'Point' object. 

884 :type center_point: any 

885 :param rad: The radius of a circle fitting inside the Square. 

886 :type rad: float 

887 :param partitions: The number of partitions to divide the boundary of the Square into when generating the boundary curves, defaults to 4 

888 :type partitions: int, optional 

889 """ 

890 super().__init__() 

891 self.rad = rad 

892 self.partitions = partitions 

893 

894 self.center_point = center_point if isinstance(center_point, Point) else Point.create_or_get(center_point) 

895 self.boundary_curves = self.generate_boundary_lines() 

896 self.inner_boundary_curves = [] 

897 

898 def generate_boundary_lines(self): 

899 """ 

900 Generates the boundary lines for the Square. 

901 

902 This method divides the boundary of the square into 'partitions' number of Lines. 

903 

904 :return: A list of Line objects that define the closed loop boundary of the Square. 

905 :rtype: list 

906 """ 

907 if self.partitions != 4: 

908 raise ValueError( 

909 f"FiQuS does not support a square air boundary with partition count: {self.partitions}!" 

910 ) 

911 

912 edgePoints = np.array(self.center_point.pos) + (np.array([self.rad, -self.rad, 0]), np.array([self.rad, self.rad, 0]), np.array([-self.rad, self.rad, 0]), np.array([-self.rad, -self.rad, 0])) 

913 boundary_curves = [Line(edgePoints[n], edgePoints[(n+1)%(self.partitions)]) for n in range(self.partitions)] 

914 return boundary_curves 

915 

916class Semicircle(Surface): 

917 """ 

918 A class to represent a Semicircle surface (aligned on the right side of y-axis). Inherits from the Surface class. 

919 

920 :ivar offset_x: The offset of the semicircle diameter center in the negative x direction. This is an instance-level attribute. 

921 :vartype offset_x: float 

922 :ivar rad: The radius of the biggest circle fitting inside the square(can be interpreted as the smallest boundary distance to center). This is an instance-level attribute. 

923 :vartype rad: float 

924 :ivar partitions: The number of partitions to divide the boundary of the square into when generating the boundary curves. This is an instance-level attribute. 

925 :vartype partitions: int 

926 :ivar center_point: The center point of the Square. Can be a position ([x, y, z]) or a 'Point' object. This is an instance-level attribute. 

927 :vartype center_point: Point 

928 """ 

929 # this is used in the meshing process to force the gmsh cohomology cut on the diameter of the semicircle domain 

930 physical_cohomology_subdomain = None 

931 def __init__(self, center_point : any, offset_x : float, rad : float, partitions : int = 4): 

932 """ 

933 Constructs the attributes for the Square object. 

934 

935 :param center_point: The center point of the Square. Can be a position ([x, y, z]) or a 'Point' object. 

936 :type center_point: any 

937 :param offset_x: The offset of the semicircle diameter center in the negative x direction. This is an instance-level attribute. 

938 :type offset_x: float 

939 :param rad: The radius of the Semicircle. 

940 :type rad: float 

941 :param partitions: The number of partitions to divide the boundary of the Semicircle into when generating the boundary curves, defaults to 4. 

942 :type partitions: int, optional 

943 """ 

944 super().__init__() 

945 self.offset_x = offset_x 

946 self.rad = rad 

947 self.partitions = partitions 

948 

949 self.center_point = center_point if isinstance(center_point, Point) else Point.create_or_get(center_point) 

950 self.boundary_curves = self.generate_boundary_lines() 

951 self.inner_boundary_curves = [] 

952 

953 def generate_boundary_lines(self): 

954 """ 

955 Generates the boundary lines for the Square. 

956 

957 This method divides the boundary of the semicircle into 'partitions' number of Lines. 

958 

959 :return: A list of Line objects that define the closed loop boundary of the Semicircle. 

960 :rtype: list 

961 """ 

962 if self.partitions != 4: 

963 raise ValueError( 

964 f"FiQuS does not support a semicircle air boundary with partition count: {self.partitions}!" 

965 ) 

966 if self.offset_x > self.rad: 

967 raise ValueError( 

968 f"FiQuS does not support a semicircle with center offset bigger than radius!" 

969 ) 

970 

971 edgePoints = np.array(self.center_point.pos) + (np.array([-self.offset_x, -self.rad, 0]), np.array([self.rad-self.offset_x, 0, 0]), np.array([-self.offset_x, self.rad, 0]), np.array([-self.offset_x, 0, 0])) 

972 boundary_curves = [] 

973 for n in range(self.partitions): 

974 if n < (self.partitions/2): 

975 boundary_curves.append(CircleArc(edgePoints[n], [-self.offset_x, 0, 0], edgePoints[n+1])) 

976 else: 

977 boundary_curves.append(Line(edgePoints[n], edgePoints[(n+1)%(self.partitions)])) 

978 return boundary_curves 

979 

980 def add_physical_boundary(self, name : str = ''): 

981 """  

982 This method extends the ususal procedure defined in 'Surface'.  

983 It generates an additional cohomology subdomain physical group and stores its tag as additional attribute """ 

984 self.physical_boundary_tag = gmsh.model.add_physical_group(1, [curve.tag for curve in self.boundary_curves], name = name) 

985 self.physical_boundary_name = name 

986 # additional procedure - add the arc of semicircle boundary as cohomology subdomain 

987 _, lines = gmsh.model.get_adjacencies(2, self.surface_tag) 

988 self.physical_cohomology_subdomain = gmsh.model.add_physical_group(1, lines[0:int(len(self.boundary_curves)/2)], name = name + ' subdomain') 

989 

990class SquareSection(Surface): 

991 """ 

992 A class to represent a square surface intersected with the quarter of a circle (used as surface in the 'periodic_square' model geometry). Inherits from the Surface class. 

993 

994 :ivar rad: The max radius of a circle fitting inside the initial Square, which is then intersected by intersection_curve. This is an instance-level attribute. 

995 :vartype rad: float 

996 :ivar intersection_curve: The Curve intersecting the square(defined by exact 2 point on the square boundary). This is an instance-level attribute. 

997 :vartype intersection_curve: Curve object 

998 :ivar partitions: The number of partitions to divide the resulting boundary of the SquareSection into when generating boundary curves. This is an instance-level attribute. 

999 :vartype partitions: int 

1000 :ivar center_point: The center point based on which the square section is extruded outwards. Can be a position ([x, y, z]) or a 'Point' object. This is an instance-level attribute. 

1001 :vartype center_point: Point 

1002 """ 

1003 def __init__(self, center_point : any, rad : float, intersection_curve : Curve, partitions : int = 5): 

1004 """ 

1005 Constructs the attributes for the SquareSection object. 

1006 

1007 :param center_point: The center point of extrusion for the SquareSection. Can be a position ([x, y, z]) or a 'Point' object. 

1008 :type center_point: any 

1009 :param rad: The max radius of a circle fitting inside the Square before intersection. 

1010 :type rad: float 

1011 :param partitions: The number of partitions to divide the boundary of the SquareSection into when generating boundary curves, should allways be 5 

1012 :type partitions: int, optional 

1013 """ 

1014 super().__init__() 

1015 self.rad = rad 

1016 self.intersection_curve = intersection_curve 

1017 self.partitions = partitions 

1018 

1019 self.center_point = center_point if isinstance(center_point, Point) else Point.create_or_get(center_point) 

1020 self.outer_boundary_curves : List[Curve] = [] 

1021 self.cut_curves : List[Curve] = [] 

1022 self.boundary_curves = self.generate_boundary_lines() 

1023 

1024 def generate_boundary_lines(self): 

1025 """ 

1026 Generates the boundary lines for the SquareSection. 

1027 

1028 This method divides the boundary of the square into 'partitions' number of segments and creates a line for each segment. 

1029 

1030 :return: A list of line objects that define the boundary of the IntersectedSquare. 

1031 :rtype: list 

1032 """ 

1033 if self.partitions != 5: 

1034 raise ValueError( 

1035 f"FiQuS does not support a SquareSections with partition count: {self.partitions}!" 

1036 ) 

1037 if not self.center_point.pos[0] == self.center_point.pos[1] == self.center_point.pos[2] == 0: 

1038 logger.info(self.center_point.pos) 

1039 raise ValueError( 

1040 f"FiQuS does not support a SquareSections with center extrusion point: {self.center_point}!" 

1041 ) 

1042 

1043 # extrude intersection curves to the outside 

1044 line1 = Line.create_or_get(self.intersection_curve.P2, Point.create_or_get(self.intersection_curve.P2.pos/np.linalg.norm(self.intersection_curve.P1.pos)*self.rad)) 

1045 line4 = Line.create_or_get(self.intersection_curve.P1, Point.create_or_get(self.intersection_curve.P1.pos/np.linalg.norm(self.intersection_curve.P1.pos)*self.rad)) 

1046 self.cut_curves = [line1, line4] 

1047 # close IntersectedSquare of with outer boundary lines 

1048 line2 = Line.create_or_get(line1.get_furthest_point(), Point.create_or_get(line1.get_furthest_point().pos+line4.get_furthest_point().pos)) 

1049 line3 = Line.create_or_get(line4.get_furthest_point(), line2.get_furthest_point()) 

1050 self.outer_boundary_curves = [line2, line3] 

1051 

1052 boundary_curves = [self.intersection_curve, line1, line2, line3, line4] 

1053 return boundary_curves 

1054 

1055 

1056class Composite(): 

1057 """ 

1058 A helper class to handle a composite surface made up by multiple smaller surfaces. 

1059 

1060 :ivar sections: A List of connected Surfaces which create a Composite regime. 

1061 :vartype sections: List of Surfaces 

1062 """ 

1063 def __init__(self, sections : List[Surface]): 

1064 """ 

1065 Constructs the attributes for the Composite object. 

1066 

1067 :type center_point: any 

1068 :param sections: A List of Surface Sections which make up the Composite. 

1069 :type sections: List of Surfaces 

1070 

1071 """ 

1072 super().__init__() 

1073 

1074 self.sections = sections 

1075 

1076 self.physical_surface_tag = None 

1077 self.physical_surface_name = None 

1078 self.physical_boundary_tag = None 

1079 self.physical_boundary_name = None 

1080 self.strand_bnd_physicalEdgePointTag = None 

1081 

1082 self.physical_inner_boundary_tags = [] 

1083 self.physical_inner_boundary_names = [] 

1084 

1085 self.physical_cuts = [] 

1086 self.inner_boundary_curves = [] 

1087 self.boundary_curves = self.generate_boundary_lines() 

1088 

1089 def generate_boundary_lines(self): 

1090 """ 

1091 Generates the boundary lines for the composite surface, made up by the section surfaces given on initialization. 

1092 

1093 :return: A list of line objects that define the (outer) boundary of the composition. 

1094 :rtype: list 

1095 """ 

1096 boundary_curves = [] 

1097 for section in self.sections: 

1098 boundary_curves.extend(section.outer_boundary_curves) 

1099 return boundary_curves 

1100 

1101 def add_physical_surface(self, name : str = ''): 

1102 """ 

1103 Generates a physical surface group containing all the section surfaces. 

1104 """ 

1105 self.physical_surface_tag = gmsh.model.add_physical_group(2, [section.surface_tag for section in self.sections], name = name) 

1106 self.physical_surface_name = name 

1107 

1108 @abstractmethod 

1109 def add_physical_cuts(self, name): 

1110 pass 

1111 

1112 @abstractmethod 

1113 def add_physical_boundaries(self, name): 

1114 pass 

1115 

1116class CompositeSquare(Composite): 

1117 """ 

1118 A class to represent the Composite Square structure of the air surface in the 'periodic_square' model. Inherits general functionality from 'Composite'. 

1119 """ 

1120 # special boundary tags 

1121 physical_left_boundary_tag = None 

1122 physical_right_boundary_tag = None 

1123 physical_top_boundary_tag = None 

1124 physical_bottom_boundary_tag = None 

1125 

1126 def add_physical_cuts(self, name : str = ''): 

1127 """ 

1128 Generates the two physical groups for the CompositeSquare air domain in the 'periodic_square' model. 

1129 Cuts are used in getDP to imprint a source field in OmegaCC. 

1130 """ 

1131 # generate physical cuts 

1132 self.physical_cuts = [] 

1133 vertical_cuts_tags = [-self.sections[2].cut_curves[0].tag, self.sections[1].cut_curves[1].tag] 

1134 self.physical_cuts.append(gmsh.model.add_physical_group(1, vertical_cuts_tags, name = name + " vertical")) 

1135 vertical_boundary_tags = [self.sections[3].intersection_curve.tag, self.sections[0].intersection_curve.tag] 

1136 self.physical_cuts.append(gmsh.model.add_physical_group(1, vertical_boundary_tags, name = name + " vertical boundary")) 

1137 

1138 horizontal_cuts_tags = [-self.sections[0].cut_curves[1].tag, self.sections[1].cut_curves[0].tag] 

1139 self.physical_cuts.append(gmsh.model.add_physical_group(1, horizontal_cuts_tags, name = name + " horizontal")) 

1140 horizontal_boundary_tags = [self.sections[0].intersection_curve.tag, self.sections[1].intersection_curve.tag] 

1141 self.physical_cuts.append(gmsh.model.add_physical_group(1, horizontal_boundary_tags, name = name + " horizontal boundary")) 

1142 

1143 def add_physical_boundaries(self, name : str = ''): 

1144 """ 

1145 Generates direction specific physical boundary groups for the the composite square and stores the group tags as additional attributes. 

1146 """ 

1147 # add complete boundary closed loop 

1148 self.physical_boundary_tag = gmsh.model.add_physical_group(1, [curve.tag for curve in self.boundary_curves], name = name) 

1149 self.physical_boundary_name = name 

1150 # here we actually have to get the line tags based on the sub surfaces because dim1 object-tags are not preserved in gmsh :(  

1151 _, lines_sec0 = gmsh.model.get_adjacencies(2, self.sections[0].surface_tag) 

1152 _, lines_sec1 = gmsh.model.get_adjacencies(2, self.sections[1].surface_tag) 

1153 _, lines_sec2 = gmsh.model.get_adjacencies(2, self.sections[2].surface_tag) 

1154 _, lines_sec3 = gmsh.model.get_adjacencies(2, self.sections[3].surface_tag) 

1155 

1156 self.physical_left_boundary_tag = gmsh.model.add_physical_group(1, [lines_sec3[3], lines_sec0[2]], name = name + " left") 

1157 self.physical_bottom_boundary_tag = gmsh.model.add_physical_group(1, [lines_sec0[3], lines_sec1[2]], name = name + " bottom") 

1158 self.physical_right_boundary_tag = gmsh.model.add_physical_group(1, [lines_sec1[3], lines_sec2[2]], name = name + " right") 

1159 self.physical_top_boundary_tag = gmsh.model.add_physical_group(1, [lines_sec2[3], lines_sec3[2]], name = name + " top") 

1160 

1161 # set gauge point on boundary 

1162 self.strand_bnd_physicalEdgePointTag = gmsh.model.addPhysicalGroup(0, [self.sections[2].outer_boundary_curves[0].points[1].tag]) 

1163 

1164class Hexagon(Surface): 

1165 """ 

1166 A class to represent a hexagon. Inherits from the Surface class. 

1167 

1168 :ivar rad: The radius of the hexagon. 

1169 :vartype rad: float 

1170 :ivar center_point: The center point of the hexagon. Can be a position ([x, y, z]) or a 'Point' object. 

1171 :vartype center_point: Point 

1172 :ivar rotation: The rotation of the hexagon in radians. 

1173 :vartype rotation: float 

1174 """ 

1175 def __init__(self, center_point : any, rad : float, rotation : float = 0): 

1176 """ 

1177 Constructs the attributes for the hexagon object. 

1178 

1179 :param center_point: The center point of the hexagon. Can be a position ([x, y, z]) or a 'Point' object. 

1180 :type center_point: any 

1181 :param rad: The radius of the hexagon. 

1182 :type rad: float 

1183 :param rotation: The rotation of the hexagon in radians. 

1184 :type rotation: float 

1185 """ 

1186 super().__init__() 

1187 self.rad = rad 

1188 self.center_point = center_point if isinstance(center_point, Point) else Point.create_or_get(center_point) 

1189 self.rotation = rotation 

1190 self.boundary_curves = self.generate_boundary_curves() 

1191 self.inner_boundary_curves = [] 

1192 

1193 def generate_boundary_curves(self): 

1194 """ 

1195 Generates the boundary curves for the hexagon. 

1196 

1197 This method creates the boundary of the hexagon by rotating a vector from the center-point to the first edge-point. 

1198 

1199 :return: A list of Line objects that define the boundary of the hexagon. 

1200 :rtype: list 

1201 """ 

1202 edgePoints = [np.array(self.center_point.pos) + rotate_vector(np.array([self.rad, 0, 0]), 2*np.pi * n/6 + self.rotation) for n in range(6)] 

1203 boundary_curves = [Line.create_or_get(edgePoints[n], edgePoints[(n+1)%6]) for n in range(6)] 

1204 return boundary_curves 

1205 

1206### END GEOMETRY ### 

1207 

1208 

1209class TwistedStrand: 

1210 """ 

1211 A class to represent a 2D cross section of a twisted strand. 

1212 

1213 :ivar filaments: Each list of surfaces represent six filaments in the same layer. 

1214 :vartype filaments: list[list[Surface]] 

1215 :ivar Matrix: List of surfaces corresponding to the matrix partitions. 

1216 :vartype Matrix: list[Surface] 

1217 :ivar Air: A surface representing the air region. 

1218 :vartype Air: Surface 

1219 """ 

1220 def __init__(self) -> None: 

1221 """ 

1222 Initializes the TwistedStrand object. 

1223 """ 

1224 self.filaments : List[List[Surface]] = [] 

1225 self.filament_holes : List[List[Surface]] = [] # If the filaments have holes (only for certain geometries obtained via YAML files) 

1226 self.matrix : List[Surface] = [] # Inner, middle, outer  

1227 self.air : List[Surface] = [] # one or more air surfaces 

1228 self.air_composition : Composite = None # Composite structure of multiple air (if more than one air surface is defined) 

1229 self.domain_cut : int = None 

1230 

1231 def create_filament_center_points(self, N_points : int, filament_radius : float, outer_boundary_radius : float, inner_boundary_radius : float, circular_filament_distribution = True): 

1232 """ 

1233 Creates the center-points of N_points filaments. The points are distributed in layers---the first layer containing 6 points, the second 12, the third 18, etc---and groups of 

1234 6 points satisfying rotational symmetry when rotated by pi/3. The first layer is composed of one group, the second of two groups, etc. 

1235 

1236 :param N_points: The number of points to create. 

1237 :type N_points: int 

1238 :param filament_radius: The radius of the filament. 

1239 :type filament_radius: float 

1240 :param outer_boundary_radius: The radius of the outer boundary. 

1241 :type outer_boundary_radius: float 

1242 :param inner_boundary_radius: The radius of the inner boundary. 

1243 :type inner_boundary_radius: float 

1244 :param circular_filament_distribution: If True, points are distributed in a circular pattern. If False, points are distributed in a hexagonal pattern. Defaults to True. 

1245 :type circular_filament_distribution: bool, optional 

1246 

1247 :return: A list of lists of lists of points representing the filament center-points for each filament in each group. Each point is a list of three coordinates [x, y, z] and each list of points represents a group of 6 points satisfying rotational symmetry. 

1248 :rtype: list[list[list[float]]] 

1249 """ 

1250 # The hexagonal grid can be created by transforming the cartesian coordinate grid to a hexagonal grid.  

1251 # Point centers are created as unit vectors in the hexagonal grid, and then transformed to cartesian coordinates. 

1252 # This function takes as input a vector in the hexagonal grid and returns its distance from the origin in the cartesian coordinate system. 

1253 point_relative_magnitude = lambda v : np.sqrt(v[0]**2 + 2*v[0]*v[1]*np.cos(np.pi/3) + v[1]**2) 

1254 def create_first_hexant_points(N_points_per_hexant, outer_inner_boundary_ratio, possible_next_points = np.array([[1,0]]), points = np.empty((0,2))): 

1255 """ 

1256 Recursive algorithm used to create the first hexant of an hexagonal point grid. These points can subsequently be rotated by n*pi/3 (for n in [1,5]) radians to create the next points. 

1257 The function returns the points in the first sextant of the matrix, sorted by distance from the origin. 

1258 

1259 The points are created in an iterative manner, starting from the points closest to the origin and moving outwards.  

1260 Selecting the next point is done either by selecting the point with the smallest distance from the origin (giving a circular distribution), or by prioritizing points in the  

1261 same layer as the previous point, choosing the point closest to the 30 degree angle (giving an hexagonal distribution). If no points are available in the same layer, the next layer is considered. 

1262 Choosing points by distance from the origin results in a more circular distribution of points, while choosing points by angle results in a hexagonal distribution of points. 

1263 """ 

1264 

1265 if circular_filament_distribution: 

1266 # The next point is the one with the smallest distance from the origin 

1267 next_point = min(possible_next_points, key=point_relative_magnitude) 

1268 

1269 else: 

1270 #1) Filter possible next points to include only points in the lowest not full layer. 

1271 next_points_in_same_layer = possible_next_points[possible_next_points.sum(axis=1) == possible_next_points.sum(axis=1).min()] 

1272 #2) Choose the next point as the one with an angle closest to 30 degrees. 

1273 next_point = min(next_points_in_same_layer, key=lambda v : np.abs(v[1]-v[0])) 

1274 

1275 possible_next_points = np.delete(possible_next_points, np.where(np.all(possible_next_points == next_point, axis=1)), axis=0) # Remove the selected point from possible_next_points 

1276 points = np.append(points, np.array([next_point]), axis=0) # Add the selected point to points 

1277 points = sorted(points, key=lambda p: point_relative_magnitude(p)) # Sort the points by their distance from the origin 

1278 

1279 new_possible_next_points = np.array([next_point + np.array([1,0]), next_point + np.array([0,1])]) # The possible next points from the current point 

1280 possible_next_points = np.unique( np.concatenate( (new_possible_next_points, possible_next_points) ), axis = 0 ) # Add the new possible next points to the list of possible next points 

1281 

1282 

1283 if len(points) == N_points_per_hexant: 

1284 # Check if the outer-inner boundary ratio is satisfied. 

1285 # The outermost points are always placed at the outer boundary, if the outer-inner boundary ratio is not satisfied, the innermost points must be within the inner boundary. 

1286 # The innermost point is then removed and replaced by a point in the outermost layer. 

1287 if point_relative_magnitude(points[-1]) / point_relative_magnitude(points[0]) <= outer_inner_boundary_ratio: 

1288 return points 

1289 else: 

1290 # N_points_per_hexant += 1 # Removed: it increases the number of filaments. 

1291 points = np.delete(points, 0, axis=0) 

1292 return create_first_hexant_points(N_points_per_hexant, outer_inner_boundary_ratio, possible_next_points, points) 

1293 

1294 else: 

1295 return create_first_hexant_points(N_points_per_hexant, outer_inner_boundary_ratio, possible_next_points, points) 

1296 

1297 outer_boundary_radius -= filament_radius*1.1 

1298 if inner_boundary_radius != 0: 

1299 inner_boundary_radius += filament_radius*1.1 

1300 

1301 outer_inner_boundary_ratio = outer_boundary_radius / inner_boundary_radius if inner_boundary_radius != 0 else 1e10 

1302 if N_points % 6 == 1 and inner_boundary_radius == 0: 

1303 N_points -= 1 

1304 points = [[[0,0,0]]] 

1305 else: 

1306 points = [] 

1307 

1308 if N_points != 0: 

1309 groups_of_rotational_symmetry_first_points = create_first_hexant_points(N_points//6, outer_inner_boundary_ratio) # The first hexant of points, sorted by distance from the origin 

1310 

1311 R = outer_boundary_radius / point_relative_magnitude(groups_of_rotational_symmetry_first_points[-1]) # Scaling factor to place points at the correct distance from the origin 

1312 cart_to_hex_transformation = np.array([[R, R*np.cos(np.pi/3)], [0, R*np.sin(np.pi/3)]]) # Transformation matrix from cartesian to hexagonal coordinates 

1313 

1314 # Rotate the points to create the other hexants of points 

1315 for point in groups_of_rotational_symmetry_first_points: 

1316 transformed_point = np.matmul(cart_to_hex_transformation, point) # Transform the point from the cartesian coordinate system to the hexagonal coordinate system 

1317 point_group = [] 

1318 rotation_angle = 0 

1319 for hexagon_side in range(0, 6): 

1320 rotation_matrix = np.array([[np.cos(rotation_angle), -np.sin(rotation_angle)], [np.sin(rotation_angle), np.cos(rotation_angle)]]) 

1321 

1322 rotated_point = np.matmul(rotation_matrix, transformed_point) 

1323 point_group.append(list(rotated_point)+[0]) 

1324 

1325 rotation_angle += np.pi/3 

1326 points.append(point_group) 

1327 return points 

1328 

1329 def create_geometry(self, filament_radius : float, hexagonal_filaments : bool, N_filaments : int, matrix_inner_radius : float, matrix_middle_radius : float, matrix_outer_radius : float, circular_filament_distribution : bool = False, hole_radius : float = 0.0, hexagonal_holes: bool = False): 

1330 """ 

1331 Creates the full geometry of the strand cross-section. 

1332 

1333 This method generates the geometry by creating a grid of points that represent the center points of the filaments.  

1334 It then creates the filaments at these points. The filaments can be either hexagonal or circular, depending on the `hexagonal_filaments` parameter.  

1335 Finally, it creates the matrix that contains the filaments. The matrix is divided into an inner, middle, and outer area. The middle section contains the filaments, and the inner and outer areas are 'empty'. 

1336 

1337 :param filament_radius: The radius of the filaments. 

1338 :type filament_radius: float 

1339 :param hexagonal_filaments: If True, the filaments are hexagonal. If False, the filaments are circular. 

1340 :type hexagonal_filaments: bool 

1341 :param N_filaments: The number of filaments to create. 

1342 :type N_filaments: int 

1343 :param matrix_inner_radius: The radius of the inner area of the matrix. 

1344 :type matrix_inner_radius: float 

1345 :param matrix_middle_radius: The radius of the middle area of the matrix. This is where the filaments are located. 

1346 :type matrix_middle_radius: float 

1347 :param matrix_outer_radius: The radius of the outer area of the matrix. 

1348 :type matrix_outer_radius: float 

1349 :param circular_filament_distribution: If True, the filaments are distributed in a circular pattern. If False, the filaments are distributed in a hexagonal pattern. Defaults to False. 

1350 :type circular_filament_distribution: bool, optional 

1351 :param hole_radius: The radius of the filaments holes. 

1352 :type hole_radius: float, optional 

1353 """ 

1354 # 1) Create the point-grid representing the center points of the filaments 

1355 # 1.1) Get the point positions 

1356 filament_centers = self.create_filament_center_points(N_filaments, filament_radius, matrix_middle_radius, matrix_inner_radius, circular_filament_distribution) 

1357 # 1.2) Create the center points 

1358 center_points = [] 

1359 for layer in filament_centers: 

1360 layer_points = [] 

1361 for pos in layer: 

1362 P = Point.create_or_get(pos) 

1363 layer_points.append(P) 

1364 center_points.append(layer_points) 

1365 

1366 # 2) Create the filaments  

1367 filaments = [] 

1368 holes = [] 

1369 for layer_n in range(len(center_points)): 

1370 layer_filaments = [] 

1371 layer_holes = [] 

1372 for point_i in range(len(center_points[layer_n])): 

1373 if hexagonal_filaments: 

1374 filament = Hexagon(center_points[layer_n][point_i], filament_radius, np.pi/6) 

1375 else: 

1376 filament = Disk(center_points[layer_n][point_i], filament_radius) 

1377 if hole_radius: 

1378 if hexagonal_holes: 

1379 hole = Hexagon(center_points[layer_n][point_i], hole_radius, np.pi / 6) 

1380 else: 

1381 hole = Disk(center_points[layer_n][point_i], hole_radius) 

1382 layer_holes.append(hole) 

1383 filament.inner_boundary_curves = [hole.boundary_curves] 

1384 layer_filaments.append(filament) 

1385 filaments.append(layer_filaments) 

1386 if hole_radius: 

1387 holes.append(layer_holes) 

1388 self.filaments = filaments 

1389 if hole_radius: 

1390 self.filament_holes = holes 

1391 # 3) Create the matrix 

1392 # The matrix will be divided into an inner-, middle- and outer area. The middle section contains the filaments and the inner and outer areas are 'empty'. 

1393 # No inner section will be made if the matrix inner_radius is 0. 

1394 if matrix_inner_radius != 0: 

1395 inner_section = Disk([0,0,0], matrix_inner_radius) 

1396 middle_section = Disk([0,0,0], matrix_middle_radius) # Middle section 

1397 middle_section.inner_boundary_curves.append(inner_section.boundary_curves) 

1398 for layer in self.filaments: 

1399 for filament in layer: 

1400 middle_section.inner_boundary_curves.append(filament.boundary_curves) 

1401 else: 

1402 middle_section = Disk([0,0,0], matrix_middle_radius) # Middle section 

1403 for layer in self.filaments: 

1404 for filament in layer: 

1405 middle_section.inner_boundary_curves.append(filament.boundary_curves) 

1406 

1407 outer_section = Disk([0,0,0], matrix_outer_radius) 

1408 outer_section.inner_boundary_curves.append(middle_section.boundary_curves) 

1409 

1410 self.matrix = [middle_section, outer_section] if matrix_inner_radius == 0 else [inner_section, middle_section, outer_section] 

1411 

1412 def add_air(self, rad : str, coil_rad : str, type : str): 

1413 # The air region is defined as the region from the matrix outer boundary to the radius 'rad'. The air radius must be greater than the matrix radius. 

1414 def determine_strand_boundary_single_air_domain(matrix): 

1415 """ 

1416 This function finds the combined outer boundary of the strand geometry, which is the inner boundary of the air region. 

1417 The outer boundary of the strand geometry is is not necessarily the outer boundary of the matrix, as the outer matrix partition  

1418 may not fully contain the full strand (as with a WIRE-IN-CHANNEL geometry). 

1419 """ 

1420 strand_outer_boundary = set(matrix[0].boundary_curves) # Start with the boundary of the inner matrix partition 

1421 for i, matrix_partition in enumerate(matrix[:-1]): # Loop over the matrix partitions 

1422 next_matrix_partition = matrix[i+1] 

1423 

1424 inner_partition_boundary = set(matrix_partition.boundary_curves) 

1425 next_partition_boundary = set(next_matrix_partition.boundary_curves) 

1426 

1427 if inner_partition_boundary & next_partition_boundary: # If the inner and outer partition boundaries share some curves 

1428 strand_outer_boundary = strand_outer_boundary ^ next_partition_boundary # Get the combined boundary of the inner and outer partition boundaries 

1429 else: 

1430 strand_outer_boundary = next_partition_boundary # If the inner and outer partition boundaries do not share any curves, the outer boundary is simply the boundary of the outer partition. 

1431 

1432 strand_outer_boundary = Curve.get_closed_loops(list(strand_outer_boundary))[0] # Simply used to sort the curves in the outer boundary into a correct order which can be used to create a closed loop. 

1433 return strand_outer_boundary 

1434 

1435 if type == 'strand_only': # circle w. natural boundary 

1436 self.air.append(Disk([0,0,0], rad)) 

1437 air_inner_boundaries = determine_strand_boundary_single_air_domain(self.matrix) 

1438 self.air[0].inner_boundary_curves.extend([air_inner_boundaries]) 

1439 elif type == 'coil': # offset semicircle w. natural boundary 

1440 self.air.append(Semicircle([0,0,0], coil_rad, rad)) 

1441 air_inner_boundaries = determine_strand_boundary_single_air_domain(self.matrix) 

1442 self.air[0].inner_boundary_curves.extend([air_inner_boundaries]) 

1443 elif type == 'periodic_square': 

1444 outer_matrix_curves = self.matrix[-1].boundary_curves # use matrix boundaries to initialize segmented air sections 

1445 self.air = [SquareSection([0,0,0], rad, outer_matrix_curves[0]), 

1446 SquareSection([0,0,0], rad, outer_matrix_curves[1]), 

1447 SquareSection([0,0,0], rad, outer_matrix_curves[2]), 

1448 SquareSection([0,0,0], rad, outer_matrix_curves[3])] 

1449 self.air_composition = CompositeSquare(self.air) 

1450 self.air_composition.inner_boundary_curves.extend([self.matrix[-1].boundary_curves]) 

1451 else: 

1452 raise ValueError( 

1453 f"FiQuS does not support type: {type} with coil radius: {coil_rad}!" 

1454 ) 

1455 

1456 def update_tags(self): 

1457 """ 

1458 When the geometry is loaded from a .brep file, the tags of entities with dimensions lower than the highest dimension are not preserved and may change unpredictably. 

1459 This function updates the tags of the points, curves and surfaces in the geometry to ensure that they are consistent with the current gmsh model. 

1460 """ 

1461 surfaces = sum(self.filaments, []) + sum(self.filament_holes, []) + self.matrix + self.air 

1462 

1463 Surface.update_tags(surfaces) 

1464 

1465 def create_gmsh_instance(self): 

1466 """ 

1467 Creates the gmsh instances of the geometry. 

1468 """ 

1469 surfaces = sum(self.filaments, []) + sum(self.filament_holes, []) + self.matrix + self.air 

1470 

1471 Surface.set_correct_boundary_orientation(surfaces) 

1472 

1473 for surface in surfaces: 

1474 surface.create_gmsh_instance() 

1475 

1476 def add_physical_groups(self): 

1477 """ 

1478 Creates all physical groups. 

1479 """ 

1480 # Filaments: Add physical boundary and surface 

1481 for layer_n in range(len(self.filaments)): 

1482 for filament_i in range(len(self.filaments[layer_n])): 

1483 self.filaments[layer_n][filament_i].add_physical_boundary(f"Boundary: Filament {filament_i} in layer {layer_n}") 

1484 self.filaments[layer_n][filament_i].add_physical_surface(f"Surface: Filament {filament_i} in layer {layer_n}") 

1485 

1486 self.filaments[layer_n][filament_i].physicalEdgePointTag = gmsh.model.addPhysicalGroup(0, [self.filaments[layer_n][filament_i].boundary_curves[0].points[0].tag]) 

1487 

1488 # Add physical surface for the filament holes 

1489 for layer_n in range(len(self.filament_holes)): 

1490 for filament_i in range(len(self.filament_holes[layer_n])): 

1491 self.filament_holes[layer_n][filament_i].add_physical_boundary(f"Boundary: Filament hole {filament_i} in layer {layer_n}") 

1492 self.filament_holes[layer_n][filament_i].add_physical_surface(f"Surface: Filament hole {filament_i} in layer {layer_n}") 

1493 

1494 self.filament_holes[layer_n][filament_i].physicalEdgePointTag = gmsh.model.addPhysicalGroup(0, [self.filament_holes[layer_n][filament_i].boundary_curves[0].points[0].tag]) 

1495 

1496 # Matrix: Add physical boundary and surface for each partition 

1497 for i, matrix_partition in enumerate(self.matrix): 

1498 matrix_partition.add_physical_boundary(f"Boundary: Matrix partition {i}") 

1499 matrix_partition.add_physical_surface(f"Surface: Matrix partition {i}") 

1500 

1501 # Air: Add physical boundary and surfaces 

1502 for i, air_partition in enumerate(self.air): 

1503 air_partition.add_physical_boundary(f"Boundary: Air partition {i}") 

1504 air_partition.add_physical_surface(f"Surface: Air partition {i}") 

1505 

1506 if self.air_composition: 

1507 # Cut: Add physical cuts, boundaries and the composite surface 

1508 self.air_composition.add_physical_boundaries(f"Boundary: Air") 

1509 self.air_composition.add_physical_surface(f"Surface: Air") 

1510 

1511 self.air_composition.add_physical_cuts(f"Cut: Air") 

1512 

1513 self.air_composition.physical_inner_boundary_tags.append(gmsh.model.addPhysicalGroup(1, [curve.tag for curve in self.air_composition.inner_boundary_curves[0]], name = f"InnerBoundary: Air")) 

1514 self.air_composition.physical_inner_boundary_names.append(f"InnerBoundary: Air") 

1515 self.air_composition.strand_bnd_physicalEdgePointTag = gmsh.model.addPhysicalGroup(0, [self.air_composition.inner_boundary_curves[0][0].points[0].tag]) 

1516 else: 

1517 # Add inner boundary 

1518 self.air[0].physical_inner_boundary_tags.append(gmsh.model.addPhysicalGroup(1, [curve.tag for curve in self.air[0].inner_boundary_curves[0]], name = f"InnerBoundary: Air")) 

1519 self.air[0].physical_inner_boundary_names.append(f"InnerBoundary: Air") 

1520 self.air[0].strand_bnd_physicalEdgePointTag = gmsh.model.addPhysicalGroup(0, [self.air[0].inner_boundary_curves[0][0].points[0].tag]) 

1521 

1522 # TEST add a physical group CUT through whole domain 

1523 # tag = Line.create_or_get(self.air[0].center_point, self.air[0].boundary_curves[0].P1) 

1524 # self.domain_cut = gmsh.model.add_physical_group(1, [tag], name = "Domain cut") 

1525 

1526 def save(self, save_file): 

1527 # This function saves the geometry class to a pickle file.  

1528 # The geometry class is used again during meshing. 

1529 with open(save_file, "wb") as geom_save_file: 

1530 pickle.dump(self, geom_save_file) 

1531 logger.info(f"Geometry saved to file {save_file}") 

1532 

1533 def write_geom_to_yaml(self, file_path): 

1534 # This function writes the geometry to a yaml file.  

1535 # The yaml file contains the coordinates of the points, the type of the curves and the indices of the points that make up the curves and the indices of the curves that make up the areas. 

1536 # Note: Only the strands are written to the yaml file. The air region is not included. 

1537 Conductor = geom.Conductor() # Create a data model for the conductor data 

1538 

1539 # Add Materials to the 'Solution' section of the data model 

1540 # 1) Find all unique materials used in the geometry 

1541 # materials = set([surface.material for surface in Surface.surfaces_registry if surface.material is not None]) 

1542 # # Sort the materials into two groups by their type. All materials with the same type are grouped together. 

1543 # material_groups = {material_type: [material for material in materials if material.type == material_type] for material_type in set([material.type for material in materials])} 

1544 # # 2) Add all unique materials to the data model, represented by a string with the material type and index in the material group 

1545 # for material_type, material_group in material_groups.items(): 

1546 # for i, material in enumerate(material_group): 

1547 # material_name = f"{material_type}_{i}" 

1548 # Conductor.Solution.Materials[material_name] = material 

1549 

1550 surfaces = list(set(sum(self.filaments, []) + self.matrix)) # Combine all the filaments and the matrix to get all the surfaces which should be written. The air region is not included. 

1551 curves = list(set(sum([surface.boundary_curves for surface in surfaces], []))) # Extract all the boundary curves from the surfaces 

1552 points = list(set(sum([curve.points for curve in curves], []))) # Extract all the points from the curves 

1553 

1554 

1555 # Populate the points dictionary with coordinates of each point 

1556 # for p, point in enumerate(points): 

1557 # Conductor.Geometry.Points[p] = geom.Point(Coordinates=point.pos.tolist()) #{"Coordinates": point.pos.tolist()} 

1558 for p, point in enumerate(Point.points_registry): 

1559 if point in points: 

1560 Conductor.Geometry.Points[p] = geom.Point(Coordinates=point.pos.tolist()) 

1561 

1562 # Populate the curves dictionary with type of each curve and indices of its points 

1563 # for c, curve in enumerate(curves): 

1564 # curve_points = [Point.points_registry.index(point) for point in curve.points] 

1565 # Conductor.Geometry.Curves[c] = geom.Curve( 

1566 # Type=curve.__class__.__name__, 

1567 # Points=curve_points 

1568 # ) 

1569 for c, curve in enumerate(Curve.curves_registry): 

1570 if curve in curves: 

1571 curve_points = [Point.points_registry.index(point) for point in curve.points] 

1572 Conductor.Geometry.Curves[c] = geom.Curve( 

1573 Type=curve.__class__.__name__, 

1574 Points=curve_points 

1575 ) 

1576 

1577 

1578 # Populate the surfaces dictionary with material, boundary curves and inner boundary curves of each surface 

1579 for a, surface in enumerate(Surface.surfaces_registry): 

1580 if surface in surfaces: 

1581 surface_boundary_curves = [Curve.curves_registry.index(curve) for curve in surface.boundary_curves] 

1582 surface_inner_boundary_curves = [[Curve.curves_registry.index(curve) for curve in inner_boundary] for inner_boundary in surface.inner_boundary_curves] 

1583 

1584 if surface in self.matrix: # Add dummy values for writing the matrix surfaces to the data model 

1585 surface.layer = None 

1586 surface.layer_index = None 

1587 

1588 elif surface in sum(self.filaments, []): # Add dummy values for writing the filament surfaces to the data model 

1589 for l, layer in enumerate(self.filaments): 

1590 if surface in layer: 

1591 surface.layer = l 

1592 surface.layer_index = layer.index(surface) 

1593 break 

1594 

1595 # Name the material based on its type and index in the material groups 

1596 # if surface.material is None: 

1597 # material_name = None 

1598 # else: 

1599 # material_type = surface.material.Type 

1600 # material_index = material_groups[material_type].index(surface.material) 

1601 # material_name = f"{material_type}_{material_index}" 

1602 material_name = surface.material 

1603 

1604 Conductor.Geometry.Areas[a] = geom.Area( 

1605 Material=material_name, 

1606 Boundary=surface_boundary_curves, 

1607 InnerBoundaries=surface_inner_boundary_curves, 

1608 Layer=surface.layer, 

1609 LayerIndex=surface.layer_index 

1610 ) 

1611 

1612 

1613 # Write the data model to a yaml file 

1614 UFF.write_data_to_yaml(file_path, Conductor.model_dump()) 

1615 

1616 @classmethod 

1617 def read_geom_from_yaml(cls, file_path): 

1618 """ 

1619 This function loads a geometry from a yaml file and returns a TwistedStrand object. 

1620 The yaml file contains all points, curves and surfaces which define the geometry. 

1621 - Points are defined by their position vector and can be referenced by an integer ID. 

1622 : Position [x, y, z] 

1623 - Curves are defined by their type (e.g. Line, CircleArc, etc.) and the ID of the points that make up the curves. Curves are referenced by an integer ID as well. 

1624 : Type ('Line', 'CircleArc', etc.) 

1625 : Points ([1,2,3]), list of point-ID defining the curve. 

1626 - Surfaces are defined by material, outer boundary, inner boundaries and layer and layer-index if the surface is a strand.  

1627 : Material ('Cu', 'NbTi', 'Nb3Sn', etc.) 

1628 : Outer boundary ([2,3,4,5...]), list of curve-ID defining the outer boundary closed loop. 

1629 : Inner boundaries ([[1,2,3], [4,5,6], ... ]), list of list of curve-IDs defining closed loops. 

1630 : Layer (0, 1, 2, ...), the layer of a filament. None if the surface is part of the matrix. 

1631 : LayerIndex (0, 1, 2, ...), the index of the filament in the layer. None if the surface is part of the matrix. 

1632 

1633 :param file_path: The full path to the yaml file. 

1634 :type file_path: str 

1635 :param gmsh_curve_convention: If True, the curves are created using the gmsh convention for defining curves. Determines the order of the points in the curves. Defaults to False. This is a temporary solution. In the future, the order of the points in the curves will be updated to fit the gmsh convention. 

1636 

1637 """ 

1638 Conductor = UFF.read_data_from_yaml(file_path, geom.Conductor) 

1639 

1640 # 1) Create the points 

1641 for point in Conductor.Geometry.Points.values(): 

1642 Point.create_or_get(point.Coordinates) 

1643 

1644 # 2) Create the curves 

1645 for curve in Conductor.Geometry.Curves.values(): 

1646 curve_type = curve.Type 

1647 points = [Point.points_registry[p] for p in curve.Points] 

1648 

1649 

1650 c = globals()[curve_type].create_or_get(*points) # Create the curve of the specified type 

1651 

1652 # TODO: To be added. 

1653 # c.contact = curve.Contact  

1654 # c.thickness = curve.Thickness 

1655 # c.material = curve.Material 

1656 

1657 

1658 # 3) Create the surfaces 

1659 # TODO: area.boundary_material and boundary_thickness are not yet used. 

1660 strand = cls() 

1661 layers = max([area.Layer for area in Conductor.Geometry.Areas.values() if area.Layer is not None]) + 1 # The number of layers in the strand 

1662 strand.filaments = [[None for i in range(6)] for j in range(layers)] # Initialize the filaments list 

1663 strand.filament_holes = [[None for i in range(6)] for j in range(layers)] # Initialize the filament holes list 

1664 

1665 for area_index, area_dm in Conductor.Geometry.Areas.items(): 

1666 boundary_curves = [Curve.curves_registry[c] for c in area_dm.Boundary] 

1667 inner_boundary_curves = [[Curve.curves_registry[c] for c in inner_boundary] for inner_boundary in area_dm.InnerBoundaries] 

1668 surface = Surface(boundary_curves, inner_boundary_curves) 

1669 if area_dm.Material: # If the material is provided 

1670 surface.material = area_dm.Material 

1671 

1672 if area_dm.Layer is None: 

1673 # It is either a matrix partition or it is a hole in a filament.  

1674 # We check if it is a hole in a filament by checking if the area outer boundary is in the inner boundary of a filament. 

1675 is_hole = False 

1676 for other_area in Conductor.Geometry.Areas.values(): # Loop over all areas to check if the area is a hole in a filament 

1677 if other_area.Layer is not None: # If the other area is a filament 

1678 if area_dm.Boundary in other_area.InnerBoundaries: # If the area is a hole in the other filament 

1679 # boundary_curves[0].P1, boundary_curves[0].P2 = boundary_curves[0].P2, boundary_curves[0].P1 # Reverse the order of the boundary curve points to get the correct orientation 

1680 layer = other_area.Layer 

1681 layer_index = other_area.LayerIndex 

1682 strand.filament_holes[layer][layer_index] = surface 

1683 is_hole = True 

1684 break 

1685 

1686 if not is_hole: # If it is not a hole, it is a matrix partition 

1687 strand.matrix.append(surface) 

1688 

1689 else: 

1690 strand.filaments[area_dm.Layer][area_dm.LayerIndex] = surface 

1691 

1692 # Remove None values from the filaments list 

1693 strand.filaments = [[filament for filament in layer if filament is not None] for layer in strand.filaments] 

1694 strand.filament_holes = [[hole for hole in layer if hole is not None] for layer in strand.filament_holes] 

1695 

1696 

1697 # Sort the matrix partitions from inner to outer based on the outermost point of the boundary curves of the partitions 

1698 strand.matrix = sorted(strand.matrix, key=lambda surface: max([max([np.linalg.norm(point.pos) for point in curve.points]) for curve in surface.boundary_curves])) 

1699 

1700 return strand 

1701 

1702class Geometry: 

1703 """ 

1704 Class to generate the ConductorAC Strand geometry. 

1705 

1706 This class is responsible for generating the geometry of the twisted strand.  

1707 It can either load a geometry from a YAML file or create the model from scratch.  

1708 The geometry is saved to a .brep file and the geometry class is saved as a pickle file. If specified, the geometry representation can also be saved to a YAML file. 

1709 

1710 :ivar fdm: The fiqus inputs data model. 

1711 :vartype fdm: object 

1712 :ivar cacdm: The magnet section of the fiqus inputs data model. 

1713 :vartype cacdm: object 

1714 :ivar inputs_folder: The full path to the folder with input files, i.e., conductor and STEP files. 

1715 :vartype inputs_folder: str 

1716 :ivar geom_folder: The full path to the current working directory. 

1717 :vartype geom_folder: str 

1718 :ivar magnet_name: The name of the magnet. 

1719 :vartype magnet_name: str 

1720 :ivar geom_file: The full path to the .brep file where the geometry will be saved. 

1721 :vartype geom_file: str 

1722 :ivar verbose: If True, more information is printed in the Python console. 

1723 :vartype verbose: bool 

1724 :ivar gu: An instance of the GmshUtils class. 

1725 :vartype gu: object 

1726 """ 

1727 

1728 def __init__(self, fdm, inputs_folder_path, verbose=True): 

1729 """ 

1730 Initializes the Geometry class. 

1731 

1732 :param fdm: The fiqus data model. 

1733 :type fdm: object 

1734 :param inputs_folder_path: The full path to the folder with input files, i.e., conductor and STEP files. 

1735 :type inputs_folder_path: str 

1736 :param verbose: If True, more information is printed in the Python console. Defaults to True. 

1737 :type verbose: bool, optional 

1738 """ 

1739 self.fdm = fdm 

1740 self.cacdm = fdm.magnet 

1741 self.inputs_folder = inputs_folder_path 

1742 self.geom_folder = os.path.join(os.getcwd()) 

1743 self.magnet_name = fdm.general.magnet_name 

1744 self.geom_file = os.path.join(self.geom_folder, f'{self.magnet_name}.brep') 

1745 self.verbose = verbose 

1746 self.gu = GmshUtils(self.geom_folder, self.verbose) 

1747 self.gu.initialize(verbosity_Gmsh=fdm.run.verbosity_Gmsh) 

1748 

1749 # To see the surfaces in a better way in GUI: 

1750 gmsh.option.setNumber("Geometry.SurfaceType", 2) 

1751 

1752 def generate_strand_geometry(self, gui=False): 

1753 """ 

1754 Generates the geometry of a strand based on the settings specified in the input data model. 

1755 The geometry can either be loaded from a YAML file or created from scratch. After creation, the geometry 

1756 can be saved to a YAML file. The method also creates gmsh instances, adds physical groups to the geometry, 

1757 writes the geometry to a .brep file, and saves the geometry-class to a pickle file. If `gui` is True, it 

1758 launches an interactive GUI. 

1759 

1760 :param gui: If True, launches an interactive GUI after generating the geometry. Default is False. 

1761 :type gui: bool, optional 

1762 

1763 :return: None 

1764 """ 

1765 logger.info("Generating geometry") 

1766 #0) Clear the registries. Used when generating reference files for tests. 

1767 if Point.points_registry: # If the points registry is not empty, clear it. 

1768 Point.points_registry.clear() 

1769 if Curve.curves_registry: # If the curves registry is not empty, clear it. 

1770 Curve.curves_registry.clear() 

1771 if Surface.surfaces_registry: # If the surfaces registry is not empty, clear it. 

1772 Surface.surfaces_registry.clear() 

1773 if Surface.curve_loop_registry: # If the curve loop registry is not empty, clear it. 

1774 Surface.curve_loop_registry.clear() 

1775 

1776 # 1) Either load the geometry from a yaml file or create the model from scratch 

1777 if self.cacdm.geometry.io_settings.load.load_from_yaml: 

1778 CAC = TwistedStrand.read_geom_from_yaml(os.path.join(self.inputs_folder, self.cacdm.geometry.io_settings.load.filename)) 

1779 else: 

1780 CAC = TwistedStrand() 

1781 strand = self.fdm.conductors[self.cacdm.solve.conductor_name].strand 

1782 if strand.filament_hole_diameter: 

1783 filament_hole_radius = strand.filament_hole_diameter/2 

1784 if strand.filament_hole_diameter >= strand.filament_diameter: 

1785 raise ValueError( 

1786 f"Invalid strand geometry: filament_hole_diameter ({strand.filament_hole_diameter}) " 

1787 f"must be smaller than filament_diameter ({strand.filament_diameter})." 

1788 ) 

1789 else: 

1790 filament_hole_radius = 0.0 

1791 CAC.create_geometry( 

1792 strand.filament_diameter/2, 

1793 self.cacdm.geometry.hexagonal_filaments, 

1794 strand.number_of_filaments, 

1795 strand.diameter_core/2, 

1796 strand.diameter_filamentary/2, 

1797 strand.diameter/2, 

1798 self.cacdm.geometry.filament_circular_distribution, 

1799 filament_hole_radius, 

1800 self.cacdm.geometry.hexagonal_holes 

1801 ) 

1802 

1803 CAC.add_air(self.cacdm.geometry.air_radius, self.cacdm.geometry.coil_radius, self.cacdm.geometry.type) 

1804 CAC.create_gmsh_instance() 

1805 # 2) Save the geometry to a yaml file if specified 

1806 if self.cacdm.geometry.io_settings.save.save_to_yaml: 

1807 filename = self.cacdm.geometry.io_settings.save.filename 

1808 CAC.write_geom_to_yaml(os.path.join(self.geom_folder, filename)) 

1809 

1810 if self.cacdm.geometry.rotate_angle: 

1811 dimTags = gmsh.model.occ.getEntities(dim=-1) 

1812 gmsh.model.occ.rotate(dimTags, 0.0, 0.0, 0.0, 0, 0, 1, self.cacdm.geometry.rotate_angle * np.pi / 180) 

1813 

1814 gmsh.model.occ.synchronize() 

1815 

1816 # Add physical groups to the geometry 

1817 CAC.add_physical_groups() 

1818 

1819 logger.info("Writing geometry") 

1820 gmsh.write(self.geom_file) # Write the geometry to a .brep file 

1821 CAC.save(os.path.join(self.geom_folder, f'{self.magnet_name}.pkl')) # Save the geometry-class to a pickle file 

1822 

1823 if gui: 

1824 self.gu.launch_interactive_GUI() 

1825 else: 

1826 if gmsh.isInitialized(): 

1827 gmsh.clear() 

1828 gmsh.finalize() 

1829 

1830 def load_conductor_geometry(self, gui=False): 

1831 """ 

1832 Loads geometry from .brep file. 

1833 """ 

1834 

1835 logger.info("Loading geometry") 

1836 

1837 gmsh.clear() 

1838 gmsh.model.occ.importShapes(self.geom_file, format="brep") 

1839 gmsh.model.occ.synchronize() 

1840 

1841 if gui: 

1842 self.gu.launch_interactive_GUI() 

1843 

1844 

1845 

1846 

1847 

1848