Coverage for fiqus/mesh_generators/MeshConductorAC_CC.py: 80%

485 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2026-01-09 01:49 +0000

1""" 

2MeshConductorAC_CC.py 

3 

42D mesh generator for the coated-conductor (CACCC) model. 

5 

6Assumptions / contract with GeometryConductorAC_CC 

7-------------------------------------------------- 

8- Geometry is axis-aligned: x = tape width, y = tape thickness. 

9- The imported .xao file must contain the physical groups expected by this 

10 module (e.g. HTS, optional HTS_Grooves, Substrate, SilverTop/Bottom, 

11 CopperTop/Bottom/Left/Right, Air, Air_Outer, Air_Inner, and boundary/interface 

12 curve groups). 

13 

14Meshing strategy 

15---------------- 

16- HTS: structured (transfinite) meshing. If HTS is striated, filaments are 

17 meshed as transfinite quads, while grooves can remain unstructured triangles 

18 (but with aligned boundary node counts). 

19- Copper: uniform target element size via a background field (single knob: 

20 copper_elem_scale). We intentionally avoid transfinite on copper to preserve 

21 uniform spacing. 

22- Air: optionally controlled by air_boundary_mesh_size_ratio on Air_Outer. 

23 Boundary sizing can act as a fallback when no background field is used. 

24""" 

25 

26 

27import os 

28import logging 

29from typing import Any, Dict, List, Optional 

30 

31import gmsh 

32 

33from fiqus.data import RegionsModelFiQuS # noqa: F401 (used later for regions) 

34from fiqus.utils.Utils import GmshUtils, FilesAndFolders # noqa: F401 

35from fiqus.data.RegionsModelFiQuS import RegionsModel # noqa: F401 

36from fiqus.data.DataConductor import CC, Conductor 

37 

38 

39from fiqus.mesh_generators.MeshConductorAC_Strand import Mesh as BaseMesh 

40 

41logger = logging.getLogger("FiQuS") 

42 

43occ = gmsh.model.occ 

44 

45 

46class Mesh(BaseMesh): 

47 """ 

48 Mesh generator for the CACCC (coated-conductor) 2D cross-section. 

49 This class reuses the conductor-AC base Mesh from 

50 MeshConductorAC_Strand. 

51 """ 

52 

53 def __init__(self, fdm, verbose: bool = True) -> None: 

54 """ 

55 Initialize the CACCC mesh generator. 

56 

57 Parameters 

58 ---------- 

59 fdm : object 

60 FiQuS data model instance. 

61 verbose : bool, optional 

62 If True, gmsh terminal output is enabled. Defaults to True. 

63 """ 

64 super().__init__(fdm, verbose) 

65 

66 # Shortcuts to CACCC-specific geometry and mesh sections. 

67 # These correspond to DataFiQuSConductorAC_CC.CACCCGeometry 

68 # and DataFiQuSConductorAC_CC.CACCCMesh. 

69 self.cc_geom = self.cacdm.geometry 

70 self.cc_mesh = self.cacdm.mesh 

71 

72 # Pull the CC strand from conductors 

73 conductor_dict = self.fdm.conductors 

74 selected_conductor_name = self.fdm.magnet.solve.conductor_name 

75 if selected_conductor_name in conductor_dict: 

76 selected_conductor: Conductor = conductor_dict[selected_conductor_name] 

77 else: 

78 raise ValueError( 

79 f"Conductor name: {selected_conductor_name} not present in the conductors section" 

80 ) 

81 strand = selected_conductor.strand 

82 

83 if not isinstance(strand, CC): 

84 raise TypeError( 

85 f"Expected strand type 'CC' for CACCC mesh, got {type(strand)}" 

86 ) 

87 

88 self.cc_strand = strand # CC instance (HTS_width, HTS_thickness, etc.) 

89 

90 # Helper dictionaries populated in generate_mesh(). 

91 # They will be reused in later steps (mesh size fields, regions, ...). 

92 self.physical_surfaces_by_name: Dict[str, Dict[str, Any]] = {} 

93 self.physical_curves_by_name: Dict[str, Dict[str, Any]] = {} 

94 self.physical_points_by_name: Dict[str, Dict[str, Any]] = {} 

95 

96 # HTS base mesh sizes (x and y) computed from geometry + mesh parameters. 

97 self.hts_base_size_x: Optional[float] = None 

98 self.hts_base_size_y: Optional[float] = None 

99 

100 # Canonical number of nodes along the tape width (x-direction), 

101 # used for HTS and later propagated to other layers. 

102 self.horizontal_nodes_x: Optional[int] = None 

103 

104 # Equivalent superconducting width (sum of filament widths only) 

105 # and corresponding reference horizontal element size. 

106 self.equiv_hts_filament_width: Optional[float] = None 

107 self.hts_ref_size_x_filaments: Optional[float] = None 

108 

109 # Horizontal element densities (elements per unit length) for 

110 # HTS filaments and grooves. 

111 self.hts_density_x_filaments: Optional[float] = None 

112 self.hts_density_x_grooves: Optional[float] = None 

113 

114 # Per-surface horizontal node counts (top/bottom HTS edges). 

115 # Keys are surface tags (2D entities). 

116 self.hts_nodes_x_filaments: Dict[int, int] = {} 

117 self.hts_nodes_x_grooves: Dict[int, int] = {} 

118 

119 # Approximate vertical discretisation per stacked layer 

120 # (Substrate, Silver*, CopperTop/Bottom, HTS). These are 

121 # derived from the HTS vertical reference size and the 

122 # per-layer *_elem_scale parameters. 

123 self.vertical_elems_by_layer: Dict[str, int] = {} 

124 self.layer_base_size_y: Dict[str, float] = {} 

125 

126 

127 # ------------------------------------------------------------------ # 

128 # Internal helpers: physical groups & mesh parameters 

129 # ------------------------------------------------------------------ # 

130 

131 def _collect_physical_groups(self) -> None: 

132 """ 

133 Populate helper dictionaries for physical groups (surfaces/curves/points). 

134 

135 This method scans all physical groups present in the current gmsh model 

136 and fills: 

137 - self.physical_surfaces_by_name (dim=2) 

138 - self.physical_curves_by_name (dim=1) 

139 - self.physical_points_by_name (dim=0) 

140 

141 Keys are the physical names (strings). Values store both: 

142 - phys_tag : used later in the .regions file / GetDP mappings 

143 - entities : used for meshing constraints (curve/surface tags) 

144 

145 { 

146 "dim": <int>, # 0, 1 or 2 

147 "phys_tag": <int>, # physical group tag 

148 "entities": [int], # list of entity tags (points/curves/surfaces) 

149 } 

150 

151 The method is idempotent and can be called multiple times after 

152 gmsh.model.occ.synchronize(). 

153 """ 

154 

155 self.physical_surfaces_by_name.clear() 

156 self.physical_curves_by_name.clear() 

157 self.physical_points_by_name.clear() 

158 

159 phys_groups = gmsh.model.getPhysicalGroups() 

160 

161 for dim, pg_tag in phys_groups: 

162 

163 name = gmsh.model.getPhysicalName(dim, pg_tag) 

164 entity_tags: List[int] = gmsh.model.getEntitiesForPhysicalGroup(dim, pg_tag) 

165 

166 meta: Dict[str, Any] = { "dim": dim, "phys_tag": pg_tag, "entities": entity_tags } 

167 if dim == 2: 

168 self.physical_surfaces_by_name[name] = meta 

169 

170 elif dim == 1: 

171 self.physical_curves_by_name[name] = meta 

172 

173 elif dim == 0: 

174 self.physical_points_by_name[name] = meta 

175 

176 

177 

178 def _compute_hts_and_layer_mesh_parameters(self) -> None: 

179 """ 

180 Compute the HTS base mesh sizes and approximate vertical 

181 discretisation parameters for the stacked metal layers. 

182 

183 HTS: 

184 - Uses HTS_n_elem_width / HTS_n_elem_thickness to define 

185 base element sizes hts_base_size_x / hts_base_size_y. 

186 

187 Other layers (Substrate, Silver*, CopperTop/Bottom): 

188 - Derive a default element count from the HTS vertical 

189 reference size h_ref = hts_base_size_y and the layer 

190 thickness (from geometry bounding boxes). 

191 - Multiply that default count by the YAML *_elem_scale 

192 (substrate_elem_scale, silver_elem_scale, 

193 copper_elem_scale) when provided. 

194 - Store the final counts and corresponding base sizes in: 

195 * self.vertical_elems_by_layer[layer_name] 

196 * self.layer_base_size_y[layer_name] 

197 """ 

198 

199 # HTS geometry and mesh parameters 

200 s = self.cc_strand 

201 

202 W = s.HTS_width 

203 T_hts = s.HTS_thickness 

204 n_w = self.cc_mesh.HTS_n_elem_width 

205 n_t = self.cc_mesh.HTS_n_elem_thickness 

206 

207 # Canonical node count along width: used for HTS and for all 

208 # tape-internal horizontal boundaries. 

209 self.horizontal_nodes_x = int(n_w) + 1 

210 

211 # Base HTS element sizes. 

212 self.hts_base_size_x = W / float(n_w) 

213 self.hts_base_size_y = T_hts / float(n_t) 

214 

215 # Initialise per-layer containers 

216 self.vertical_elems_by_layer = {} 

217 self.layer_base_size_y = {} 

218 

219 # Register HTS itself as reference. 

220 self.vertical_elems_by_layer["HTS"] = int(n_t) 

221 self.layer_base_size_y["HTS"] = self.hts_base_size_y 

222 

223 # Reference vertical size from HTS. 

224 h_ref = self.hts_base_size_y 

225 

226 # Definition of stacked layers and which YAML scale to use. 

227 # CopperLeft/Right are not part of the vertical stack and are 

228 # deliberately ignored here. 

229 layer_definitions = { 

230 "Substrate": ("substrate_elem_scale", "substrate layer"), 

231 "SilverBottom": ("silver_elem_scale", "silver bottom layer"), 

232 "SilverTop": ("silver_elem_scale", "silver top layer"), 

233 "CopperBottom": ("copper_elem_scale", "copper bottom layer"), 

234 "CopperTop": ("copper_elem_scale", "copper top layer"), 

235 } 

236 

237 for layer_name, (scale_attr, layer_desc) in layer_definitions.items(): 

238 

239 meta = self.physical_surfaces_by_name.get(layer_name) 

240 

241 # Safety block for optional layers that may be missing. 

242 if meta is None: 

243 continue 

244 

245 surface_tags = list(meta.get("entities", [])) 

246 

247 

248 # Assumption: layers are axis-aligned (x = width, y = thickness). 

249 # We infer layer thickness from the y-extent of OCC bounding boxes. 

250 # 

251 # Note on *_elem_scale semantics: 

252 # *_elem_scale scales the *number of elements* (larger -> finer, 

253 # smaller -> coarser), not the characteristic length directly. 

254 # 

255 # Compute thickness in the stacking (y) direction from the union of 

256 # all surfaces in this layer. 

257 

258 min_y = float("inf") 

259 max_y = float("-inf") 

260 

261 for surf_tag in surface_tags: 

262 _, ymin, _, _, ymax, _ = gmsh.model.getBoundingBox(2, surf_tag) 

263 if ymin < min_y: 

264 min_y = ymin 

265 if ymax > max_y: 

266 max_y = ymax 

267 

268 thickness = max_y - min_y 

269 

270 # Default element count if we used the same vertical size 

271 # as the HTS: n_default ~ thickness / h_ref. 

272 n_default = max(1, int(round(thickness / h_ref))) 

273 

274 # Apply YAML scale factor on the element count, if provided. 

275 scale_value = getattr(self.cc_mesh, scale_attr, None) 

276 if scale_value is None or scale_value <= 0.0: 

277 n_layer = n_default 

278 scale_used = 1.0 

279 

280 else: 

281 n_layer = max(1, int(round(float(scale_value) * n_default))) 

282 scale_used = float(scale_value) 

283 

284 # Corresponding base element size in this layer. 

285 h_layer = thickness / float(n_layer) 

286 

287 # Store results 

288 self.vertical_elems_by_layer[layer_name] = n_layer 

289 self.layer_base_size_y[layer_name] = h_layer 

290 

291 

292 

293 def _apply_air_boundary_mesh_size(self) -> None: 

294 """ 

295 Set the air mesh size from the outer air boundary curve only 

296 (physical group 'Air_Outer'). 

297 

298 The size is: 

299 h_air = air_boundary_mesh_size_ratio * HTS_width 

300 

301 With MeshSizeExtendFromBoundary=1, this boundary size propagates 

302 naturally into the air region. 

303 

304 Note: 

305 - If a background mesh field is active, it may override point-based 

306 sizes in parts of the domain. We still set boundary sizing to keep 

307 behaviour robust as a fallback/default when fields are absent. 

308 """ 

309 

310 ratio = getattr(self.cc_mesh, "air_boundary_mesh_size_ratio", None) 

311 if ratio is None or float(ratio) <= 0.0: 

312 return 

313 

314 if getattr(self.cc_strand, "HTS_width", None) is None: 

315 raise ValueError("HTS_width is not defined; cannot compute air boundary size.") 

316 

317 h_air = float(ratio) * float(self.cc_strand.HTS_width) 

318 

319 meta = self.physical_curves_by_name.get("Air_Outer") 

320 if meta is None: 

321 logger.warning("[Mesh-CC] No 'Air_Outer' physical group found.") 

322 return 

323 

324 curve_tags = list(meta.get("entities", [])) 

325 

326 point_tags: list[int] = [] 

327 for ctag in curve_tags: 

328 down_tags, _ = gmsh.model.getAdjacencies(1, ctag) # points on curve 

329 point_tags.extend(down_tags) 

330 

331 point_tags = list(dict.fromkeys(point_tags)) 

332 

333 gmsh.model.mesh.setSize([(0, ptag) for ptag in point_tags], h_air) 

334 

335 # Make boundary sizing propagate into air 

336 gmsh.option.setNumber("Mesh.MeshSizeFromPoints", 1) 

337 gmsh.option.setNumber("Mesh.MeshSizeExtendFromBoundary", 1) 

338 

339 logger.info("[Mesh-CC] Air outer boundary size set: h_air=%.6g (ratio=%.6g)", h_air, float(ratio)) 

340 

341 

342 

343 

344 def _apply_uniform_copper_mesh_field(self) -> None: 

345 """ 

346 Uniform copper sizing with a single knob, and *no* copper-to-air 

347 transition logic. 

348 

349 - Copper size: controlled by copper_elem_scale (relative to HTS base size). 

350 - Air size: optionally set as a constant target value (h_air) inside the 

351 background field, with an additional boundary-based air knob handled 

352 separately on 'Air_Outer'. 

353 - Background field: Min( constant_air , copper_inside_only ) 

354 

355 Result: 

356 - "Uniform copper" here means the *target size inside copper* is constant. 

357 Shared boundaries may still be influenced by transfinite node-count 

358 constraints from neighbouring layers. 

359 - Air is controlled by a coarse constant target size (or by boundary sizing 

360 if the field is not used / as a fallback). 

361 """ 

362 

363 copper_layers = ["CopperTop", "CopperBottom", "CopperLeft", "CopperRight"] 

364 

365 copper_surface_tags: list[int] = [] 

366 for name in copper_layers: 

367 copper_surface_tags.extend(self._get_layer_surface_tags(name)) 

368 copper_surface_tags = list(dict.fromkeys(copper_surface_tags)) 

369 

370 if not copper_surface_tags: 

371 logger.info("[Mesh-CC] No copper surfaces found; skipping copper mesh field.") 

372 return 

373 

374 # -------------------------------------------------------------- 

375 # Copper size from single knob: copper_elem_scale 

376 # -------------------------------------------------------------- 

377 scale_value = getattr(self.cc_mesh, "copper_elem_scale", None) 

378 if scale_value is None or float(scale_value) <= 0.0: 

379 scale_value = 1.0 

380 scale_value = float(scale_value) 

381 

382 if self.hts_base_size_x is None: 

383 raise RuntimeError( 

384 "hts_base_size_x not computed; cannot derive copper target size." 

385 ) 

386 

387 h_base = float(self.hts_base_size_x) 

388 h_cu = h_base / scale_value 

389 

390 # -------------------------------------------------------------- 

391 # Air size from outer-circle knob: air_boundary_mesh_size_ratio 

392 # -------------------------------------------------------------- 

393 ratio = getattr(self.cc_mesh, "air_boundary_mesh_size_ratio", None) 

394 if ratio is not None and getattr(self.cc_strand, "HTS_width", None) is not None: 

395 h_air = float(ratio) * float(self.cc_strand.HTS_width) 

396 else: 

397 # Fallback if knob is not present 

398 h_air = 20.0 * h_base 

399 

400 # -------------------------------------------------------------- 

401 # Field A: constant air size everywhere 

402 # -------------------------------------------------------------- 

403 f_air = gmsh.model.mesh.field.add("MathEval") 

404 gmsh.model.mesh.field.setString(f_air, "F", f"{h_air}") 

405 

406 # -------------------------------------------------------------- 

407 # Field B: constant size inside copper only (Restrict). 

408 # Restrict applies the copper size only inside the listed copper surfaces; 

409 # outside those surfaces it returns a very large value, so Min(air, restrict) 

410 # naturally selects the air target in the air domain. 

411 # -------------------------------------------------------------- 

412 f_cu_const = gmsh.model.mesh.field.add("MathEval") 

413 gmsh.model.mesh.field.setString(f_cu_const, "F", f"{h_cu}") 

414 

415 f_cu_inside = gmsh.model.mesh.field.add("Restrict") 

416 gmsh.model.mesh.field.setNumber(f_cu_inside, "InField", f_cu_const) 

417 gmsh.model.mesh.field.setNumbers(f_cu_inside, "SurfacesList", copper_surface_tags) 

418 

419 # -------------------------------------------------------------- 

420 # Background = min(air everywhere, copper inside) 

421 # -------------------------------------------------------------- 

422 f_min = gmsh.model.mesh.field.add("Min") 

423 gmsh.model.mesh.field.setNumbers(f_min, "FieldsList", [f_air, f_cu_inside]) 

424 gmsh.model.mesh.field.setAsBackgroundMesh(f_min) 

425 

426 # Let fine boundary sizes propagate into neighbouring domains (air grading). 

427 gmsh.option.setNumber("Mesh.MeshSizeFromPoints", 1) 

428 gmsh.option.setNumber("Mesh.MeshSizeExtendFromBoundary", 1) 

429 

430 logger.info( 

431 "[Mesh-CC] Air+copper constant background mesh: " 

432 "h_air=%.6g (ratio=%s), h_cu=%.6g (scale=%.6g, h_base=%.6g)", 

433 h_air, 

434 str(ratio), 

435 h_cu, 

436 scale_value, 

437 h_base, 

438 ) 

439 

440 

441 

442 

443 

444 

445 

446 

447 # ------------------------------------------------------------------ # 

448 # Internal helpers: access to physical surfaces 

449 # ------------------------------------------------------------------ # 

450 

451 def _get_layer_surface_tags(self, layer_name: str) -> List[int]: 

452 """ 

453 Return surface tags for a given 2D layer physical group. 

454 

455 Parameters 

456 ---------- 

457 layer_name : str 

458 Name of the 2D physical group, e.g. "Substrate", "SilverTop". 

459 

460 Returns 

461 ------- 

462 list of int 

463 List of surface tags  

464 """ 

465 meta = self.physical_surfaces_by_name.get(layer_name) 

466 if not meta: 

467 return [] 

468 return list(meta.get("entities", [])) 

469 

470 

471 

472 def _get_hts_surface_tags(self) -> List[int]: 

473 """ 

474 Return surface tags for HTS-related regions. 

475 

476 We treat: 

477 - "HTS" : all HTS filaments together (monolithic or striated). 

478 - "HTS_Grooves" : groove surfaces (if present, created as 

479 "HTS_Grooves" physical group by geometry). 

480 

481 as a single logical HTS layer for transfinite meshing. This 

482 ensures that filaments and grooves (if any) share a consistent 

483 structured discretisation. 

484 """ 

485 tags: List[int] = [] 

486 

487 meta_hts = self.physical_surfaces_by_name.get("HTS") 

488 if meta_hts: 

489 tags.extend(meta_hts.get("entities", [])) 

490 

491 meta_grooves = self.physical_surfaces_by_name.get("HTS_Grooves") 

492 if meta_grooves: 

493 tags.extend(meta_grooves.get("entities", [])) 

494 

495 # De-duplicate while preserving order. 

496 return list(dict.fromkeys(tags)) 

497 

498 

499 

500 def _apply_transfinite_to_hts(self) -> None: 

501 """ 

502 Build a structured (transfinite) mesh for HTS filaments, and keep 

503 groove regions (HTS_Grooves) unstructured. 

504 

505 - Filaments ("HTS" physical group): 

506 * Horizontal edges: transfinite with 'Bump' distribution. 

507 * Vertical edges: uniform spacing. 

508 * Surfaces: transfinite + recombine -> quadrilateral mesh. 

509 

510 - Grooves ("HTS_Grooves" physical group): 

511 * Boundary curves still get transfinite node counts so that 

512 they align with neighbouring HTS and metal layers. 

513 * BUT we do NOT call setTransfiniteSurface / setRecombine, 

514 so the interior of grooves is meshed as unstructured triangles. 

515 """ 

516 # All HTS-related 2D surfaces (filaments + grooves). 

517 surface_tags = self._get_hts_surface_tags() 

518 

519 # ------------------------------------------------------------------ 

520 # Step 1: total filament width and reference Δx. 

521 # ------------------------------------------------------------------ 

522 filament_meta = self.physical_surfaces_by_name.get("HTS", {}) 

523 filament_surface_tags: List[int] = list( 

524 filament_meta.get("entities", []) 

525 ) 

526 

527 equiv_width_filaments = 0.0 

528 for surf_tag in filament_surface_tags: 

529 xmin, _, _, xmax, _, _ = gmsh.model.getBoundingBox(2, surf_tag) 

530 dx = xmax - xmin 

531 if dx > 0.0: 

532 equiv_width_filaments += dx 

533 

534 n_tot = float(self.cc_mesh.HTS_n_elem_width) 

535 

536 # Reset per-surface maps by default. 

537 self.hts_nodes_x_filaments = {} 

538 self.hts_nodes_x_grooves = {} 

539 

540 if equiv_width_filaments > 0.0 and n_tot > 0.0: 

541 # Reference horizontal cell size in superconducting material: 

542 # Δx_ref = W_SC / N_tot 

543 # where W_SC is the sum of all filament widths. 

544 h_ref_x = equiv_width_filaments / n_tot 

545 self.equiv_hts_filament_width = equiv_width_filaments 

546 self.hts_ref_size_x_filaments = h_ref_x 

547 

548 logger.debug( 

549 "[Mesh-CC] HTS equivalent filament width = %.6g, " 

550 "HTS reference horizontal size (filaments) = %.6g", 

551 equiv_width_filaments, 

552 h_ref_x, 

553 ) 

554 

555 # ------------------------------------------------------------------ 

556 # Step 2: horizontal element densities in filaments and grooves. 

557 # ------------------------------------------------------------------ 

558 # Elements per unit length in filaments: 

559 # λ_fil = N_tot / W_SC (≈ 1 / Δx_ref) 

560 lambda_fil = n_tot / equiv_width_filaments 

561 

562 # Groove density ratio (fewer elements in grooves). 

563 groove_ratio = getattr( 

564 self.cc_mesh, "HTS_groove_elem_density_ratio", None 

565 ) 

566 if groove_ratio is None or groove_ratio <= 0.0: 

567 groove_ratio = 0.25 

568 

569 lambda_groove = groove_ratio * lambda_fil 

570 

571 self.hts_density_x_filaments = lambda_fil 

572 self.hts_density_x_grooves = lambda_groove 

573 

574 logger.debug( 

575 "[Mesh-CC] HTS filament density (lambda_fil) = %.6g, " 

576 "groove density ratio = %.6g, " 

577 "groove density (lambda_groove) = %.6g", 

578 lambda_fil, 

579 groove_ratio, 

580 lambda_groove, 

581 ) 

582 

583 # ------------------------------------------------------------------ 

584 # Step 3: per-surface horizontal node counts. 

585 # ------------------------------------------------------------------ 

586 groove_min_elems = getattr( 

587 self.cc_mesh, "HTS_groove_min_elems", None 

588 ) 

589 if groove_min_elems is None or groove_min_elems < 1: 

590 groove_min_elems = 1 

591 

592 # --- Filaments: N_i ≈ λ_fil * width_i --- 

593 for surf_tag in filament_surface_tags: 

594 xmin, _, _, xmax, _, _ = gmsh.model.getBoundingBox(2, surf_tag) 

595 width = xmax - xmin 

596 if width <= 0.0: 

597 continue 

598 

599 n_elem_f = max(1, int(round(lambda_fil * width))) 

600 n_nodes_x_f = n_elem_f + 1 # nodes = elements + 1 

601 

602 self.hts_nodes_x_filaments[surf_tag] = n_nodes_x_f 

603 

604 logger.debug( 

605 "[Mesh-CC] HTS filament surface %d: width = %.6g, " 

606 "n_elem = %d, n_nodes_x = %d", 

607 surf_tag, 

608 width, 

609 n_elem_f, 

610 n_nodes_x_f, 

611 ) 

612 

613 # --- Grooves: fewer elements per unit length --- 

614 groove_meta = self.physical_surfaces_by_name.get( 

615 "HTS_Grooves", {} 

616 ) 

617 groove_surface_tags: List[int] = list( 

618 groove_meta.get("entities", []) 

619 ) 

620 

621 for surf_tag in groove_surface_tags: 

622 xmin, _, _, xmax, _, _ = gmsh.model.getBoundingBox(2, surf_tag) 

623 width = xmax - xmin 

624 if width <= 0.0: 

625 continue 

626 

627 n_elem_g = int(round(lambda_groove * width)) 

628 n_elem_g = max(groove_min_elems, n_elem_g) 

629 n_nodes_x_g = n_elem_g + 1 

630 

631 self.hts_nodes_x_grooves[surf_tag] = n_nodes_x_g 

632 

633 logger.debug( 

634 "[Mesh-CC] HTS groove surface %d: width = %.6g, " 

635 "n_elem = %d, n_nodes_x = %d", 

636 surf_tag, 

637 width, 

638 n_elem_g, 

639 n_nodes_x_g, 

640 ) 

641 

642 else: 

643 # Fallback: no HTS-specific filament logic. 

644 self.equiv_hts_filament_width = None 

645 self.hts_ref_size_x_filaments = None 

646 self.hts_density_x_filaments = None 

647 self.hts_density_x_grooves = None 

648 self.hts_nodes_x_filaments = {} 

649 self.hts_nodes_x_grooves = {} 

650 

651 # ------------------------------------------------------------------ 

652 # Step 4: Apply transfinite curves. 

653 # 

654 # - Horizontal curves (dx >= dy): 'Bump' with surf_n_nodes_x nodes. 

655 # - Vertical curves (dy > dx): uniform 'Progression' with n_nodes_y. 

656 # 

657 # Then: 

658 # - setTransfiniteSurface + Recombine ONLY for filament surfaces. 

659 # - Grooves: NO setTransfiniteSurface / NO Recombine -> unstructured. 

660 # ------------------------------------------------------------------ 

661 

662 # Global vertical node count (same for all HTS surfaces). 

663 n_t = self.cc_mesh.HTS_n_elem_thickness 

664 n_nodes_y = int(n_t) + 1 

665 

666 # Fallback horizontal node count if per-surface info is missing. 

667 n_w = self.cc_mesh.HTS_n_elem_width 

668 fallback_n_nodes_x = ( 

669 self.horizontal_nodes_x 

670 if self.horizontal_nodes_x is not None 

671 else int(n_w) + 1 

672 ) 

673 

674 # Bump coefficient for clustering along width. 

675 bump_coef = getattr(self.cc_mesh, "bump_coef", None) 

676 if bump_coef is None or bump_coef <= 0.0: 

677 bump_coef = 0.01 

678 

679 # Re-build sets of filament and groove surfaces for classification. 

680 filament_meta = self.physical_surfaces_by_name.get("HTS", {}) 

681 filament_surface_set = set(filament_meta.get("entities", [])) 

682 

683 groove_meta = self.physical_surfaces_by_name.get("HTS_Grooves", {}) 

684 groove_surface_set = set(groove_meta.get("entities", [])) 

685 

686 # Track all HTS boundary curves so other routines can skip them. 

687 self.hts_boundary_curves = set() 

688 

689 for surf_tag in surface_tags: 

690 # Horizontal node count for this specific surface. 

691 if surf_tag in self.hts_nodes_x_filaments: 

692 surf_n_nodes_x = self.hts_nodes_x_filaments[surf_tag] 

693 elif surf_tag in self.hts_nodes_x_grooves: 

694 surf_n_nodes_x = self.hts_nodes_x_grooves[surf_tag] 

695 else: 

696 surf_n_nodes_x = fallback_n_nodes_x 

697 

698 # Get boundary curves of this HTS region. 

699 boundary = gmsh.model.getBoundary( 

700 [(2, surf_tag)], oriented=False, recursive=False 

701 ) 

702 curve_tags = [c[1] for c in boundary if c[0] == 1] 

703 curve_tags = list(dict.fromkeys(curve_tags)) 

704 

705 for ctag in curve_tags: 

706 xmin, ymin, _, xmax, ymax, _ = gmsh.model.getBoundingBox(1, ctag) 

707 dx = abs(xmax - xmin) 

708 dy = abs(ymax - ymin) 

709 

710 # Mark as HTS boundary curve. 

711 self.hts_boundary_curves.add(ctag) 

712 

713 if dx >= dy: 

714 # Horizontal curve -> controls columns (along tape width). 

715 gmsh.model.mesh.setTransfiniteCurve( 

716 ctag, 

717 surf_n_nodes_x, 

718 "Bump", 

719 float(bump_coef), 

720 ) 

721 else: 

722 # Vertical curve -> controls rows (through thickness). 

723 gmsh.model.mesh.setTransfiniteCurve( 

724 ctag, 

725 n_nodes_y, 

726 "Progression", 

727 1.0, 

728 ) 

729 

730 # --- Surface-level transfinite / recombine behaviour --- 

731 if surf_tag in filament_surface_set: 

732 # Filaments: fully structured quads. 

733 gmsh.model.mesh.setTransfiniteSurface(surf_tag) 

734 gmsh.model.mesh.setRecombine(2, surf_tag) 

735 elif surf_tag in groove_surface_set: 

736 # Grooves: DO NOT set transfinite surface and DO NOT recombine. 

737 # The 2D mesher will generate an unstructured triangular mesh 

738 # inside, but boundary node distributions are still honoured. 

739 continue 

740 else: 

741 # Should not happen (all HTS surfaces are either filament 

742 # or groove), but keep a safe fallback. 

743 gmsh.model.mesh.setTransfiniteSurface(surf_tag) 

744 

745 

746 def _apply_horizontal_boundary_subdivisions_for_side_copper(self) -> None: 

747 """ 

748 Apply transfinite subdivisions along the tape width direction for 

749 CopperLeft / CopperRight so that their horizontal boundaries use the 

750 same column pattern as the HTS / stacked layers. 

751 """ 

752 

753 # Canonical horizontal node count along the tape width. 

754 n_nodes_horizontal = self.horizontal_nodes_x 

755 if n_nodes_horizontal is None or n_nodes_horizontal <= 1: 

756 # Fallback to HTS_n_elem_width + 1 if something went wrong. 

757 n_w = self.cc_mesh.HTS_n_elem_width 

758 n_nodes_horizontal = int(n_w) + 1 

759 

760 # Air surfaces: used to detect copper-air edges that we want 

761 # to keep coarser than the internal tape interfaces. 

762 air_surface_tags = set(self._get_layer_surface_tags("Air")) 

763 

764 layer_names = ["CopperLeft", "CopperRight"] 

765 

766 for layer_name in layer_names: 

767 surface_tags = self._get_layer_surface_tags(layer_name) 

768 

769 for surf_tag in surface_tags: 

770 boundary = gmsh.model.getBoundary( 

771 [(2, surf_tag)], oriented=False, recursive=False 

772 ) 

773 curve_tags = [c[1] for c in boundary if c[0] == 1] 

774 curve_tags = list(dict.fromkeys(curve_tags)) 

775 

776 for ctag in curve_tags: 

777 xmin, ymin, _, xmax, ymax, _ = gmsh.model.getBoundingBox(1, ctag) 

778 dx = abs(xmax - xmin) 

779 dy = abs(ymax - ymin) 

780 

781 # Only curves that run mainly along x (tape width). 

782 if dx >= dy: 

783 # Check if this horizontal edge touches air. 

784 higher_dim_tags, _ = gmsh.model.getAdjacencies(1, ctag) 

785 

786 if any(s in air_surface_tags for s in higher_dim_tags): 

787 # Copper–air edge: keep this much coarser than internal 

788 # tape interfaces to avoid over-refining the surrounding air. 

789 # Heuristic: ~2.5% of the internal column count. 

790 n_outer = max( 

791 2, int(round(0.025 * n_nodes_horizontal)) 

792 ) 

793 gmsh.model.mesh.setTransfiniteCurve( 

794 ctag, 

795 n_outer, 

796 "Progression", 

797 1.0, 

798 ) 

799 continue 

800 

801 # Internal horizontal interface: align with tape columns. 

802 gmsh.model.mesh.setTransfiniteCurve( 

803 ctag, 

804 n_nodes_horizontal, 

805 "Progression", 

806 1.0, 

807 ) 

808 

809 

810 def _apply_vertical_boundary_subdivisions_for_side_copper(self) -> None: 

811 """ 

812 Apply transfinite node counts to vertical boundary curves of 

813 CopperLeft / CopperRight that are adjacent to the Air region. 

814 """ 

815 # Requires _compute_hts_and_layer_mesh_parameters() to have run 

816 # (hts_base_size_y set). 

817 h_ref = self.hts_base_size_y 

818 

819 # YAML scale for copper; <= 0 or None -> use 1.0 (no extra scaling) 

820 scale_value = getattr(self.cc_mesh, "copper_elem_scale", None) 

821 if scale_value is None or scale_value <= 0.0: 

822 scale_value = 1.0 

823 

824 # Air surfaces, to detect copper-air vertical edges 

825 air_surface_tags = set(self._get_layer_surface_tags("Air")) 

826 

827 layer_names = ["CopperLeft", "CopperRight"] 

828 

829 for layer_name in layer_names: 

830 surface_tags = self._get_layer_surface_tags(layer_name) 

831 

832 for surf_tag in surface_tags: 

833 boundary = gmsh.model.getBoundary( [(2, surf_tag)], oriented=False, recursive=False ) 

834 curve_tags = [ c[1] for c in boundary if c[0] == 1 ] 

835 curve_tags = list(dict.fromkeys(curve_tags)) 

836 

837 for ctag in curve_tags: 

838 xmin, ymin, _, xmax, ymax, _ = gmsh.model.getBoundingBox(1, ctag) 

839 dx = abs(xmax - xmin) 

840 dy = abs(ymax - ymin) 

841 

842 # We only care about vertical curves here. 

843 if dy <= dx: 

844 continue 

845 

846 # Check if this vertical curve touches Air. 

847 higher_dim_tags, _ = gmsh.model.getAdjacencies(1, ctag) 

848 if not any(s in air_surface_tags for s in higher_dim_tags): 

849 # Internal copper-tape edge: stacked-layer logic handles it. 

850 continue 

851 

852 thickness = dy 

853 n_default = max(1, int(round(thickness / h_ref))) 

854 n_layer = max(1, int(round(scale_value * n_default))) 

855 n_nodes_vertical = n_layer + 1 

856 

857 gmsh.model.mesh.setTransfiniteCurve( 

858 ctag, 

859 n_nodes_vertical, 

860 "Progression", 

861 1.0, 

862 ) 

863 

864 

865 def _apply_vertical_boundary_subdivisions_to_non_hts_layers(self) -> None: 

866 """ 

867 Apply transfinite node counts to boundary curves of non-HTS, non-copper 

868 stacked layers (Substrate, Silver*), based on: 

869 - vertical_elems_by_layer[layer] for vertical curves (thickness dir), 

870 - self.horizontal_nodes_x for horizontal curves (width dir). 

871 

872 Copper is intentionally excluded here to preserve more uniform copper 

873 node spacing controlled by the background sizing field. 

874 """ 

875 

876 # Horizontal node count along the tape width. 

877 n_nodes_horizontal = self.horizontal_nodes_x 

878 

879 # Set of all HTS-related surfaces: used to avoid overriding 

880 # transfinite settings on HTS boundary curves. 

881 hts_surface_tags = set(self._get_hts_surface_tags()) 

882 

883 # Non-HTS stacked layers that we want to control in the vertical 

884 # (thickness) direction and in the horizontal (width) direction 

885 # via the canonical column count. 

886 layer_names = [ 

887 "Substrate", 

888 "SilverBottom", 

889 "SilverTop", 

890 ] 

891 

892 

893 for layer_name in layer_names: 

894 n_layer = self.vertical_elems_by_layer.get(layer_name) 

895 

896 if n_layer is None: 

897 continue 

898 

899 surface_tags = self._get_layer_surface_tags(layer_name) 

900 

901 # Number of nodes on vertical curves = number of elements + 1 

902 n_nodes_vertical = int(n_layer) + 1 

903 

904 for surf_tag in surface_tags: 

905 # Get boundary curves of this surface 

906 boundary = gmsh.model.getBoundary( [(2, surf_tag)], oriented=False, recursive=False ) 

907 curve_tags = [c[1] for c in boundary if c[0] == 1] 

908 curve_tags = list(dict.fromkeys(curve_tags)) 

909 

910 for ctag in curve_tags: 

911 # If this curve is shared with an HTS surface, 

912 # skip it so the HTS progression is preserved. 

913 higher_dim_tags, _ = gmsh.model.getAdjacencies(1, ctag) 

914 if any(s in hts_surface_tags for s in higher_dim_tags): 

915 logger.debug( 

916 "[Mesh-CC]'%s' curve %d touches HTS; " 

917 "skipping non-HTS transfinite constraint.", 

918 layer_name, 

919 ctag, 

920 ) 

921 continue 

922 

923 xmin, ymin, _, xmax, ymax, _ = gmsh.model.getBoundingBox(1, ctag) 

924 dx = abs(xmax - xmin) 

925 dy = abs(ymax - ymin) 

926 

927 if dy > dx: 

928 # Predominantly vertical curve: control thickness direction 

929 gmsh.model.mesh.setTransfiniteCurve( 

930 ctag, n_nodes_vertical, "Progression", 1.0 

931 ) 

932 else: 

933 # Predominantly horizontal curve: enforce common columns. 

934 # For SilverTop outer boundary, use a Bump distribution to mimic HTS clustering. 

935 if layer_name == "SilverTop": 

936 bump_coef = getattr(self.cc_mesh, "bump_coef", None) 

937 

938 if bump_coef is None or float(bump_coef) <= 0.0: 

939 # Fallback: reuse HTS bump coefficient (or default) 

940 bump_coef = getattr(self.cc_mesh, "bump_coef", None) 

941 

942 if bump_coef is None or float(bump_coef) <= 0.0: 

943 bump_coef = 0.01 

944 

945 gmsh.model.mesh.setTransfiniteCurve( 

946 ctag, n_nodes_horizontal, "Bump", float(bump_coef) 

947 ) 

948 else: 

949 gmsh.model.mesh.setTransfiniteCurve( 

950 ctag, n_nodes_horizontal, "Progression", 1.0 

951 ) 

952 

953 

954 

955 

956 def _apply_progression_on_substrate_vertical_sides(self) -> None: 

957 """ 

958 Apply a 'Progression' distribution on the vertical side boundary 

959 curves of the substrate, clustering nodes toward the HTS side (top in y). 

960 """ 

961 r = getattr(self.cc_mesh, "substrate_side_progression", None) 

962 if r is None: 

963 return 

964 

965 r = float(r) 

966 if r <= 1.0: 

967 return 

968 

969 n_layer = self.vertical_elems_by_layer.get("Substrate") 

970 if n_layer is None: 

971 return 

972 n_nodes_vertical = int(n_layer) + 1 

973 

974 substrate_surface_tags = self._get_layer_surface_tags("Substrate") 

975 if not substrate_surface_tags: 

976 return 

977 

978 # All HTS-related surfaces (HTS + grooves) to detect the top interface curve. 

979 hts_surface_tags = set(self._get_hts_surface_tags()) 

980 

981 # Collect candidate curves: substrate boundary curves that are vertical 

982 # and do NOT touch HTS (so we keep the HTS/substrate interface unchanged). 

983 candidate_curve_tags: list[int] = [] 

984 

985 for surf_tag in substrate_surface_tags: 

986 boundary = gmsh.model.getBoundary( 

987 [(2, surf_tag)], oriented=False, recursive=False 

988 ) 

989 curve_tags = [c[1] for c in boundary if c[0] == 1] 

990 curve_tags = list(dict.fromkeys(curve_tags)) 

991 

992 for ctag in curve_tags: 

993 # Skip curves that touch HTS surfaces (top interface). 

994 higher_dim_tags, _ = gmsh.model.getAdjacencies(1, ctag) 

995 if any(s in hts_surface_tags for s in higher_dim_tags): 

996 continue 

997 

998 xmin, ymin, _, xmax, ymax, _ = gmsh.model.getBoundingBox(1, ctag) 

999 dx = abs(xmax - xmin) 

1000 dy = abs(ymax - ymin) 

1001 

1002 # Only vertical-ish curves (substrate left/right sides). 

1003 if dy <= dx: 

1004 continue 

1005 

1006 candidate_curve_tags.append(ctag) 

1007 

1008 candidate_curve_tags = list(dict.fromkeys(candidate_curve_tags)) 

1009 if not candidate_curve_tags: 

1010 logger.info( 

1011 "[Mesh-CC] No substrate vertical side curves found for Progression." 

1012 ) 

1013 return 

1014 

1015 # Apply progression with refinement toward *top end* (larger y). 

1016 for ctag in candidate_curve_tags: 

1017 endpoints = gmsh.model.getBoundary( 

1018 [(1, ctag)], oriented=True, recursive=False 

1019 ) 

1020 point_tags = [p[1] for p in endpoints if p[0] == 0] 

1021 

1022 if len(point_tags) < 2: 

1023 continue 

1024 

1025 p_start = point_tags[0] 

1026 p_end = point_tags[-1] 

1027 

1028 _, y0, _ = gmsh.model.getValue(0, p_start, []) 

1029 _, y1, _ = gmsh.model.getValue(0, p_end, []) 

1030 

1031 # Progression behaviour: 

1032 # - ratio > 1 -> smallest segments near start 

1033 # - ratio < 1 -> smallest segments near end 

1034 # 

1035 # Note: refinement direction depends on curve orientation. 

1036 # We infer which endpoint is "top" by comparing point y-coordinates 

1037 # and invert the progression ratio accordingly. 

1038 # 

1039 # We want smallest segments near the *top* (higher y). 

1040 if y0 >= y1: 

1041 prog = r 

1042 else: 

1043 prog = 1.0 / r 

1044 

1045 gmsh.model.mesh.setTransfiniteCurve( 

1046 ctag, 

1047 n_nodes_vertical, 

1048 "Progression", 

1049 float(prog), 

1050 ) 

1051 

1052 

1053 

1054 

1055 # ------------------------------------------------------------------ # 

1056 # Public API 

1057 # ------------------------------------------------------------------ # 

1058 

1059 def generate_mesh(self, geom_folder: str) -> None: 

1060 

1061 geo_unrolled_path = os.path.join(geom_folder, f"{self.magnet_name}.xao") 

1062 

1063 # The .xao file is produced by GeometryConductorAC_CC and must contain the 

1064 # expected physical group names used throughout this meshing routine 

1065 # (HTS, optional HTS_Grooves, metal layers, Air, Air_Outer, etc.). 

1066 gmsh.open(geo_unrolled_path) 

1067 occ.synchronize() 

1068 

1069 logger.debug("[Mesh-CC] OCC model synchronized after loading geometry file.") 

1070 

1071 # Collect all physical groups into helper dictionaries that will 

1072 # be reused in later steps (mesh fields, regions, ...). 

1073 self._collect_physical_groups() 

1074 

1075 # HTS base mesh sizes and vertical element counts. 

1076 self._compute_hts_and_layer_mesh_parameters() 

1077 

1078 # Transfinite meshing for HTS (filaments + grooves or monolithic). 

1079 self._apply_transfinite_to_hts() 

1080 

1081 # Apply transfinite constraints only to non-copper stacked layers 

1082 # (Substrate, Silver*) so we do not distort copper node spacing. 

1083 self._apply_vertical_boundary_subdivisions_to_non_hts_layers() 

1084 

1085 # Refine substrate vertical sides near the HTS side using Progression. 

1086 # This overrides the uniform 'Progression, 1.0' set above for those curves. 

1087 self._apply_progression_on_substrate_vertical_sides() 

1088 

1089 

1090 # Copper is handled by a uniform size field (no transfinite on copper curves). 

1091 self._apply_uniform_copper_mesh_field() 

1092 

1093 

1094 

1095 # ----------------------------------------------------------------- 

1096 # Global mesh size scaling and actual 2D mesh generation 

1097 # ----------------------------------------------------------------- 

1098 scaling_global = self.cc_mesh.scaling_global 

1099 

1100 # MeshSizeFactor scales characteristic lengths (point sizes, background 

1101 # field values, etc.). Transfinite constraints are node *counts* and are 

1102 # not affected by this factor. 

1103 gmsh.option.setNumber("Mesh.MeshSizeFactor", float(scaling_global)) 

1104 

1105 # Boundary-based air sizing knob (fallback/default behaviour). 

1106 # Note: a background field (if active) can override sizes in parts of the 

1107 # domain; this still provides a robust air sizing rule when fields are not used. 

1108 self._apply_air_boundary_mesh_size() 

1109 

1110 # ----------------------------------------------------------------- 

1111 # Generate 1D first to "freeze" transfinite curve discretisations, 

1112 # then generate 2D. This avoids Gmsh re-meshing/compacting 1D curves 

1113 # when strong background fields are present. 

1114 # ----------------------------------------------------------------- 

1115 gmsh.model.mesh.generate(1) 

1116 gmsh.model.mesh.generate(2) 

1117 

1118 # Generate cohomology basis functions (cuts) for transport current handling with h-phi-formulation 

1119 self.generate_cuts() 

1120 

1121 # Generate region file for easy access to physical groups from, e.g., the .pro template 

1122 self.generate_regions_file() 

1123 

1124 # Save the mesh to <magnet_name>.msh and optionally launch the GUI. 

1125 gui = getattr(self.fdm.run, "launch_gui", False) 

1126 self.save_mesh(gui=gui) 

1127 

1128 logger.info( "[Mesh-CC] Mesh generation and saving complete. Mesh file: '%s'.", self.mesh_file ) 

1129 

1130 

1131 # ----------------------------------------------------------------- 

1132 # Generation of .regions file 

1133 # ----------------------------------------------------------------- 

1134 

1135 def generate_regions_file(self) -> None: 

1136 """ 

1137 Construct the .regions YAML file for the 2D CACCC conductor model. 

1138 """ 

1139 # Initialise the RegionsModel container 

1140 rm = RegionsModel() 

1141 

1142 # Powered - HTS 

1143 rm.powered["HTS"] = RegionsModelFiQuS.Powered() 

1144 powered = rm.powered["HTS"] 

1145 

1146 # Initialise list-valued fields 

1147 for fld in ("vol", "surf", "surf_th", "surf_in", "surf_out", "curve", "surf_insul"): 

1148 getattr(powered, fld).names = [] 

1149 getattr(powered, fld).numbers = [] 

1150 

1151 # Terminal volume placeholders (not used in 2D but required structurally) 

1152 powered.vol_in = RegionsModelFiQuS.Region(name=None, number=None) 

1153 powered.vol_out = RegionsModelFiQuS.Region(name=None, number=None) 

1154 

1155 # Convention: for 2D models we store surface physical tags in the "vol" 

1156 # fields to keep the RegionsModel structure compatible with 3D templates 

1157 # and existing .pro logic. 

1158 if "HTS" in self.physical_surfaces_by_name: 

1159 meta = self.physical_surfaces_by_name["HTS"] 

1160 powered.vol.names = ["HTS"] 

1161 powered.vol.numbers = [meta["phys_tag"]] 

1162 

1163 # HTS boundary curves 

1164 for edge_name in ["HTS_Upper", "HTS_Lower", "HTS_LeftEdge", "HTS_RightEdge"]: 

1165 if edge_name in self.physical_curves_by_name: 

1166 meta = self.physical_curves_by_name[edge_name] 

1167 powered.surf_th.names.append(edge_name) 

1168 powered.surf_th.numbers.append(meta["phys_tag"]) 

1169 

1170 

1171 # Induced - Substrate + Stabilizer layers 

1172 rm.induced["Stabilizer"] = RegionsModelFiQuS.Induced() 

1173 induced = rm.induced["Stabilizer"] 

1174 

1175 # Initialise all induced list-valued fields 

1176 for fld in ("vol", "surf_th", "surf_in", "surf_out", "cochain"): 

1177 getattr(induced, fld).names = [] 

1178 getattr(induced, fld).numbers = [] 

1179 

1180 # Metallic layer volumes (2D surfaces) 

1181 metal_layers = [ 

1182 "Substrate", 

1183 "SilverTop", "SilverBottom", 

1184 "CopperTop", "CopperBottom", 

1185 "CopperLeft", "CopperRight", 

1186 ] 

1187 

1188 for layer in metal_layers: 

1189 if layer in self.physical_surfaces_by_name: 

1190 meta = self.physical_surfaces_by_name[layer] 

1191 induced.vol.names.append(layer) 

1192 induced.vol.numbers.append(meta["phys_tag"]) 

1193 

1194 # Outer air boundary shared with the stabilizer 

1195 if "Air_Outer" in self.physical_curves_by_name: 

1196 meta = self.physical_curves_by_name["Air_Outer"] 

1197 induced.surf_out.names = ["Air_Outer"] 

1198 induced.surf_out.numbers = [meta["phys_tag"]] 

1199 

1200 # Metallic layer boundary edges 

1201 edge_suffixes = ["Upper", "Lower", "LeftEdge", "RightEdge"] 

1202 

1203 for layer in metal_layers: 

1204 for suffix in edge_suffixes: 

1205 name = f"{layer}_{suffix}" 

1206 if name in self.physical_curves_by_name: 

1207 meta = self.physical_curves_by_name[name] 

1208 induced.surf_th.names.append(name) 

1209 induced.surf_th.numbers.append(meta["phys_tag"]) 

1210 

1211 # Explicit interface curves between layers 

1212 interface_groups = [ 

1213 "Buffer Layer", 

1214 "HTS_Silver_Interface", 

1215 "HTS_CopperTop_Interface", 

1216 "HTS_CopperLeft_Interface", 

1217 "HTS_CopperRight_Interface", 

1218 "SilverTop_CopperTop_Interface", 

1219 "SilverBottom_CopperBottom_Interface", 

1220 "Substrate_SilverBottom_Interface", 

1221 "Substrate_CopperBottom_Interface", 

1222 "Substrate_CopperLeft_Interface", 

1223 "Substrate_CopperRight_Interface", 

1224 ] 

1225 

1226 for name in interface_groups: 

1227 if name in self.physical_curves_by_name: 

1228 meta = self.physical_curves_by_name[name] 

1229 induced.surf_th.names.append(name) 

1230 induced.surf_th.numbers.append(meta["phys_tag"]) 

1231 

1232 

1233 # Air region 

1234 if "Air" in self.physical_surfaces_by_name: 

1235 meta = self.physical_surfaces_by_name["Air"] 

1236 rm.air.vol.name = "Air" 

1237 rm.air.vol.number = meta["phys_tag"] 

1238 

1239 if "Air_Outer" in self.physical_curves_by_name: 

1240 meta = self.physical_curves_by_name["Air_Outer"] 

1241 rm.air.surf.name = "Air_Outer" 

1242 rm.air.surf.number = meta["phys_tag"] 

1243 

1244 if "Air_Inner" in self.physical_curves_by_name: 

1245 meta = self.physical_curves_by_name["Air_Inner"] 

1246 rm.air.line.name = "Air_Inner" 

1247 rm.air.line.number = meta["phys_tag"] 

1248 

1249 if "Gauging_point" in self.physical_points_by_name: 

1250 meta = self.physical_points_by_name["Gauging_point"] 

1251 rm.air.point.names = ["Gauging_point"] 

1252 rm.air.point.numbers = [meta["phys_tag"]] 

1253 

1254 # Cut for transport current 

1255 if "Cut" in self.physical_curves_by_name: 

1256 meta = self.physical_curves_by_name["Cut"] 

1257 rm.air.cochain.names = ["Cut"] 

1258 rm.air.cochain.numbers = [meta["phys_tag"]] 

1259 

1260 

1261 # Write the regions file and log 

1262 FilesAndFolders.write_data_to_yaml(self.regions_file, rm.model_dump()) 

1263 logger.info(f"[Mesh-CC] Regions file written to: {self.regions_file}") 

1264 

1265 

1266 def generate_cuts(self) -> None: 

1267 """ 

1268 Generate cohomology basis functions for the CACCC model. 

1269 """ 

1270 # Transport current cut 

1271 air_inner = self.physical_curves_by_name["Air_Inner"] 

1272 air_surf = self.physical_surfaces_by_name["Air"] 

1273 

1274 # Request one 1D cut cycle (homology) and the corresponding 1D cohomology 

1275 # basis inside the Air surface. This supports transport-current handling in 

1276 # h-phi formulations. 

1277 gmsh.model.mesh.addHomologyRequest( 

1278 "Homology", domainTags=[air_inner["phys_tag"]], dims=[1] 

1279 ) 

1280 gmsh.model.mesh.addHomologyRequest( 

1281 "Cohomology", domainTags=[air_surf["phys_tag"]], dims=[1] 

1282 ) 

1283 

1284 # computeHomology() returns tags of generated chains/physical groups. 

1285 # The indexing below follows Gmsh's returned ordering for our request pair. 

1286 cuts = gmsh.model.mesh.computeHomology() 

1287 

1288 # Rearrange so that positive currents have deterministic orientation. 

1289 gmsh.model.mesh.clearHomologyRequests() 

1290 gmsh.plugin.setString( 

1291 "HomologyPostProcessing", 

1292 "PhysicalGroupsOfOperatedChains", 

1293 str(cuts[1][1]), 

1294 ) 

1295 gmsh.plugin.setString( 

1296 "HomologyPostProcessing", 

1297 "PhysicalGroupsOfOperatedChains2", 

1298 str(cuts[0][1]), 

1299 ) 

1300 gmsh.plugin.run("HomologyPostProcessing") 

1301 

1302 # Store the physical group tag for later use (e.g. writing to the regions file). 

1303 # The "+1" matches the physical group id created by the post-processing plugin. 

1304 meta: Dict[str, Any] = {"dim": 1, "phys_tag": cuts[1][1] + 1} 

1305 self.physical_curves_by_name["Cut"] = meta