fiqus 2025.11.0__py3-none-any.whl → 2026.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. fiqus/MainFiQuS.py +9 -0
  2. fiqus/data/DataConductor.py +112 -3
  3. fiqus/data/DataFiQuS.py +4 -3
  4. fiqus/data/DataFiQuSConductorAC_CC.py +345 -0
  5. fiqus/data/DataFiQuSConductorAC_Rutherford.py +569 -0
  6. fiqus/data/DataFiQuSConductorAC_Strand.py +3 -3
  7. fiqus/data/DataFiQuSHomogenizedConductor.py +478 -0
  8. fiqus/geom_generators/GeometryConductorAC_CC.py +1906 -0
  9. fiqus/geom_generators/GeometryConductorAC_Rutherford.py +706 -0
  10. fiqus/geom_generators/GeometryConductorAC_Strand_RutherfordCopy.py +1848 -0
  11. fiqus/geom_generators/GeometryHomogenizedConductor.py +183 -0
  12. fiqus/getdp_runners/RunGetdpConductorAC_CC.py +123 -0
  13. fiqus/getdp_runners/RunGetdpConductorAC_Rutherford.py +200 -0
  14. fiqus/getdp_runners/RunGetdpHomogenizedConductor.py +178 -0
  15. fiqus/mains/MainConductorAC_CC.py +148 -0
  16. fiqus/mains/MainConductorAC_Rutherford.py +76 -0
  17. fiqus/mains/MainHomogenizedConductor.py +112 -0
  18. fiqus/mesh_generators/MeshConductorAC_CC.py +1305 -0
  19. fiqus/mesh_generators/MeshConductorAC_Rutherford.py +235 -0
  20. fiqus/mesh_generators/MeshConductorAC_Strand_RutherfordCopy.py +718 -0
  21. fiqus/mesh_generators/MeshHomogenizedConductor.py +229 -0
  22. fiqus/post_processors/PostProcessAC_CC.py +65 -0
  23. fiqus/post_processors/PostProcessAC_Rutherford.py +142 -0
  24. fiqus/post_processors/PostProcessHomogenizedConductor.py +114 -0
  25. fiqus/pro_templates/combined/CAC_CC_template.pro +542 -0
  26. fiqus/pro_templates/combined/CAC_Rutherford_template.pro +1742 -0
  27. fiqus/pro_templates/combined/HomogenizedConductor_template.pro +1663 -0
  28. {fiqus-2025.11.0.dist-info → fiqus-2026.1.0.dist-info}/METADATA +9 -12
  29. {fiqus-2025.11.0.dist-info → fiqus-2026.1.0.dist-info}/RECORD +36 -13
  30. tests/test_geometry_generators.py +40 -0
  31. tests/test_mesh_generators.py +76 -0
  32. tests/test_solvers.py +137 -0
  33. /fiqus/pro_templates/combined/{ConductorAC_template.pro → CAC_Strand_template.pro} +0 -0
  34. {fiqus-2025.11.0.dist-info → fiqus-2026.1.0.dist-info}/LICENSE.txt +0 -0
  35. {fiqus-2025.11.0.dist-info → fiqus-2026.1.0.dist-info}/WHEEL +0 -0
  36. {fiqus-2025.11.0.dist-info → fiqus-2026.1.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1305 @@
1
+ """
2
+ MeshConductorAC_CC.py
3
+
4
+ 2D mesh generator for the coated-conductor (CACCC) model.
5
+
6
+ Assumptions / 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
+
14
+ Meshing 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
+
27
+ import os
28
+ import logging
29
+ from typing import Any, Dict, List, Optional
30
+
31
+ import gmsh
32
+
33
+ from fiqus.data import RegionsModelFiQuS # noqa: F401 (used later for regions)
34
+ from fiqus.utils.Utils import GmshUtils, FilesAndFolders # noqa: F401
35
+ from fiqus.data.RegionsModelFiQuS import RegionsModel # noqa: F401
36
+ from fiqus.data.DataConductor import CC, Conductor
37
+
38
+
39
+ from fiqus.mesh_generators.MeshConductorAC_Strand import Mesh as BaseMesh
40
+
41
+ logger = logging.getLogger("FiQuS")
42
+
43
+ occ = gmsh.model.occ
44
+
45
+
46
+ class 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