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,1906 @@
1
+ """
2
+ GeometryConductorAC_CC.py
3
+
4
+ 2D rectangular stack geometry for a striated coated-conductor model.
5
+
6
+ Strategy
7
+ -------------------
8
+ - Build a stack of rectangular layers aligned along x (width) and y (thickness):
9
+ HTS
10
+ Substrate
11
+ optional Silver (top / bottom)
12
+ optional Copper (top / bottom / left / right)
13
+ - Each layer is created as a Gmsh OpenCASCADE rectangle (surface).
14
+ - For the HTS layer we optionally striate the superconductor into
15
+ multiple filaments separated by grooves, using rectangle cuts.
16
+ - After all layers are built:
17
+ - We create a circular air region around the stack.
18
+ - We classify boundary edges of each layer into Upper / Lower /
19
+ LeftEdge / RightEdge using only bounding boxes (robust to OCC ops).
20
+ - We create a consistent set of 2D/1D physical groups for
21
+ FiQuS/GetDP (materials + interfaces + outer air boundary).
22
+
23
+ surface_tag = OCC surface entity (dim=2)
24
+ curve_tag = boundary curve entity (dim=1)
25
+ """
26
+
27
+ import logging
28
+ import math
29
+ import os
30
+ from types import SimpleNamespace
31
+ from typing import Dict, Iterable, List, Optional, Tuple
32
+
33
+ import gmsh
34
+
35
+ import fiqus.data.DataFiQuSConductor as geom # TODO
36
+ from fiqus.data.DataFiQuS import FDM
37
+ from fiqus.utils.Utils import GmshUtils, FilesAndFolders
38
+ from fiqus.data.DataConductor import CC, Conductor
39
+
40
+ logger = logging.getLogger("FiQuS")
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Small validation helpers
44
+ # ---------------------------------------------------------------------------
45
+
46
+ def _as_float(value, *, name: str) -> float:
47
+ try:
48
+ out = float(value)
49
+ except (TypeError, ValueError) as exc:
50
+ raise ValueError(f"{name} must be a real number, got {value!r}") from exc
51
+ if not math.isfinite(out):
52
+ raise ValueError(f"{name} must be finite, got {out!r}")
53
+ return out
54
+
55
+
56
+ def _require_positive(value, *, name: str) -> float:
57
+ v = _as_float(value, name=name)
58
+ if v <= 0.0:
59
+ raise ValueError(f"{name} must be > 0, got {v:g}")
60
+ return v
61
+
62
+
63
+ def _require_non_negative_optional(value, *, name: str) -> float:
64
+ """
65
+ Optional thickness rule:
66
+ - None => treated as 0 (layer absent)
67
+ - >= 0 => ok
68
+ - < 0 => hard error
69
+ """
70
+ if value is None:
71
+ return 0.0
72
+ v = _as_float(value, name=name)
73
+ if v < 0.0:
74
+ raise ValueError(f"{name} must be >= 0, got {v:g}")
75
+ return v
76
+
77
+
78
+ def _require_int_ge(value, *, name: str, min_value: int) -> Optional[int]:
79
+ if value is None:
80
+ return None
81
+ try:
82
+ iv = int(value)
83
+ except (TypeError, ValueError) as exc:
84
+ raise ValueError(f"{name} must be an integer, got {value!r}") from exc
85
+ if iv < min_value:
86
+ raise ValueError(f"{name} must be >= {min_value}, got {iv}")
87
+ return iv
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # Base class: axis-aligned rectangular layer
92
+ # ---------------------------------------------------------------------------
93
+
94
+ class _RectLayerBase:
95
+ """
96
+ Common base for all axis-aligned rectangular layers (HTS, substrate,
97
+ silver, copper).
98
+
99
+ Responsibilities
100
+ ----------------
101
+ - Store the main OCC surface (surface_tag).
102
+ - Maintain lists of boundary curves (curve_tags) and points (point_tags).
103
+ - Classify boundary curves into semantic edges:
104
+ "Upper", "Lower", "LeftEdge", "RightEdge"
105
+ based solely on bounding boxes (robust after OCC boolean ops).
106
+ """
107
+
108
+ def __init__(self) -> None:
109
+ self.surface_tag: Optional[int] = None
110
+ self.curve_tags: List[int] = []
111
+ self.point_tags: List[int] = []
112
+ self.edge_tags: Dict[str, List[int]] = {}
113
+
114
+ # Basic OCC -> topology refresh
115
+
116
+ def _refresh_from_surface(self) -> None:
117
+ """
118
+ Refresh curve_tags and point_tags from the current surface_tag.
119
+ This should be called after any OCC operation (cut, fuse, etc.)
120
+ that may re-tag curves/points.
121
+ """
122
+ if self.surface_tag is None:
123
+ raise RuntimeError(
124
+ f"{self.__class__.__name__} has no surface_tag to refresh from."
125
+ )
126
+
127
+ boundary = gmsh.model.getBoundary( [(2, self.surface_tag)], oriented=False, recursive=False )
128
+ self.curve_tags = [c[1] for c in boundary]
129
+
130
+ pts: List[int] = []
131
+ for c in self.curve_tags:
132
+ ends = gmsh.model.getBoundary( [(1, c)], oriented=False, recursive=False )
133
+ pts.extend([p[1] for p in ends])
134
+
135
+ # De-duplicate while preserving order
136
+ self.point_tags = list(dict.fromkeys(pts))
137
+
138
+ # Edge classification
139
+
140
+ def _classify_edges_from_bbox(self, w: float, t: float, cx: float, cy: float ) -> None:
141
+ """
142
+ Classify boundary curves into:
143
+ Upper / Lower / LeftEdge / RightEdge
144
+
145
+ Strategy
146
+ --------
147
+ - For each boundary curve, read its bounding box.
148
+ - Decide whether the curve is "horizontal" (dx >= dy) or
149
+ "vertical" (dy > dx).
150
+ - Among all horizontal curves:
151
+ - Those with y close to the maximum are "Upper".
152
+ - Those with y close to the minimum are "Lower".
153
+ - Among all vertical curves:
154
+ - Those with x close to the minimum are "LeftEdge".
155
+ - Those with x close to the maximum are "RightEdge".
156
+ - Tolerance is 5% of the global span in x/y for that set.
157
+
158
+ This is robust against most OCC operations as long as the
159
+ rectangle is still axis-aligned overall.
160
+ OCC fragment/cut operations can split edges into multiple curves and reorder
161
+ tags. Using curve bounding boxes lets us recover "Upper/Lower/Left/Right" as
162
+ long as the overall layer remains axis-aligned.
163
+ """
164
+ horiz: List[Tuple[int, float]] = [] # (curve_tag, y_mid)
165
+ vert: List[Tuple[int, float]] = [] # (curve_tag, x_mid)
166
+
167
+ for c in self.curve_tags:
168
+ xmin, ymin, _, xmax, ymax, _ = gmsh.model.getBoundingBox(1, c)
169
+ dx = abs(xmax - xmin)
170
+ dy = abs(ymax - ymin)
171
+
172
+ if dx >= dy:
173
+ # Mostly horizontal
174
+ ymid = 0.5 * (ymin + ymax)
175
+ horiz.append((c, ymid))
176
+ else:
177
+ # Mostly vertical
178
+ xmid = 0.5 * (xmin + xmax)
179
+ vert.append((c, xmid))
180
+
181
+ if not horiz or not vert:
182
+ raise RuntimeError(
183
+ f"Could not find both horizontal and vertical edges for "
184
+ f"surface {self.surface_tag}; curves={self.curve_tags}"
185
+ )
186
+
187
+ # Horizontal: Upper / Lower
188
+ ys = [ym for _, ym in horiz]
189
+ y_min = min(ys)
190
+ y_max = max(ys)
191
+ span_y = max(y_max - y_min, 1e-12) # avoid zero span
192
+ tol_y = 0.05 * span_y # 5% band near extremes
193
+
194
+ upper = [c for c, ym in horiz if (y_max - ym) <= tol_y]
195
+ lower = [c for c, ym in horiz if (ym - y_min) <= tol_y]
196
+
197
+ # Vertical: Left / Right
198
+ xs = [xm for _, xm in vert]
199
+ x_min = min(xs)
200
+ x_max = max(xs)
201
+ span_x = max(x_max - x_min, 1e-12)
202
+ tol_x = 0.05 * span_x
203
+
204
+ left = [c for c, xm in vert if (xm - x_min) <= tol_x]
205
+ right = [c for c, xm in vert if (x_max - xm) <= tol_x]
206
+
207
+ edge_tags: Dict[str, List[int]] = {}
208
+ if upper:
209
+ edge_tags["Upper"] = upper
210
+ if lower:
211
+ edge_tags["Lower"] = lower
212
+ if left:
213
+ edge_tags["LeftEdge"] = left
214
+ if right:
215
+ edge_tags["RightEdge"] = right
216
+
217
+ missing = {"Upper", "Lower", "LeftEdge", "RightEdge"} - set(edge_tags)
218
+ if missing:
219
+ raise RuntimeError(
220
+ f"Edges not fully classified: {missing}; "
221
+ f"curves={self.curve_tags}, horiz={horiz}, vert={vert}"
222
+ )
223
+
224
+ self.edge_tags = edge_tags
225
+
226
+ # ---- public refresh API -----------------------------------------------
227
+
228
+ def refresh_topology(self, w: float, t: float, cx: float, cy: float) -> None:
229
+ """
230
+ Re-pull curves/points from the current surface and re-classify edges.
231
+
232
+ Subclasses that use multiple surfaces (e.g. striated HTS) can
233
+ override this method but should call _refresh_from_surface and
234
+ _classify_edges_from_bbox at some point.
235
+ """
236
+ self._refresh_from_surface()
237
+ self._classify_edges_from_bbox(w, t, cx, cy)
238
+
239
+
240
+ # ---------------------------------------------------------------------------
241
+ # HTS layer: optional striation into filaments + grooves
242
+ # ---------------------------------------------------------------------------
243
+
244
+ class HTSLayer(_RectLayerBase):
245
+ """
246
+ 2D HTS layer, optionally striated into multiple filaments.
247
+
248
+ Geometric parameters
249
+ --------------------
250
+ - HTS_thickness, HTS_width
251
+ - HTS_center_x, HTS_center_y
252
+
253
+ Striation model
254
+ ---------------
255
+ If (n_striations > 1 and striation_w > 0):
256
+ - We build a full-width HTS rectangle.
257
+ - We build (N - 1) vertical groove rectangles.
258
+ - We OCC-cut the grooves out of the HTS.
259
+ - The remaining islands are HTS filaments.
260
+ - Grooves are kept as separate surfaces.
261
+
262
+ Bookkeeping
263
+ -----------
264
+ - self.surface_tag:
265
+ - monolithic HTS: the only HTS surface
266
+ - striated HTS: representative filament tag (for legacy code)
267
+ - self.filament_tags: list of all HTS filament surface IDs
268
+ - self.groove_tags: list of all groove surface IDs
269
+ """
270
+
271
+ def __init__(
272
+ self,
273
+ HTS_thickness: float,
274
+ HTS_width: float,
275
+ HTS_center_x: float,
276
+ HTS_center_y: float,
277
+ number_of_filaments: Optional[int] = None,
278
+ gap_between_filaments: Optional[float] = None,
279
+ ) -> None:
280
+ super().__init__()
281
+
282
+ self.HTS_thickness = float(HTS_thickness)
283
+ self.HTS_width = float(HTS_width)
284
+ self.HTS_center_x = float(HTS_center_x)
285
+ self.HTS_center_y = float(HTS_center_y)
286
+
287
+ # Striation parameters
288
+ self.n_striations = (
289
+ int(number_of_filaments) if number_of_filaments is not None else None
290
+ )
291
+ self.striation_w = (
292
+ float(gap_between_filaments)
293
+ if gap_between_filaments is not None
294
+ else None
295
+ )
296
+
297
+ # After cutting
298
+ self.filament_tags: List[int] = [] # HTS islands
299
+ self.groove_tags: List[int] = [] # Groove surfaces
300
+
301
+
302
+
303
+
304
+ def _refresh_from_surface(self) -> None:
305
+ """
306
+ Refresh boundary curves/points for the HTS layer.
307
+
308
+ Notes on striated HTS
309
+ ---------------------
310
+ When striation is enabled, the HTS is represented by multiple OCC surfaces
311
+ (one per filament). For boundary and interface physical groups we treat the
312
+ HTS as the *union of filaments* and recover its boundary by collecting the
313
+ boundaries of all filament surfaces.
314
+
315
+ This deliberately ignores groove surfaces: grooves are separate regions and
316
+ must not be part of the HTS conductor boundary.
317
+
318
+ Fallback
319
+ --------
320
+ If HTS is monolithic (no filaments), we fall back to the base implementation
321
+ using self.surface_tag.
322
+ """
323
+
324
+ if self.filament_tags:
325
+ curves: List[int] = []
326
+
327
+ for s in self.filament_tags:
328
+ boundary = gmsh.model.getBoundary( [(2, s)], oriented=False, recursive=False )
329
+ curves.extend( [c[1] for c in boundary] )
330
+
331
+ self.curve_tags = list(dict.fromkeys(curves))
332
+
333
+ pts: List[int] = []
334
+ for c in self.curve_tags:
335
+ ends = gmsh.model.getBoundary( [(1, c)], oriented=False, recursive=False )
336
+ pts.extend( [p[1] for p in ends] )
337
+ self.point_tags = list(dict.fromkeys(pts))
338
+ else:
339
+ super()._refresh_from_surface()
340
+
341
+
342
+
343
+ def build_HTS(self) -> int:
344
+ """
345
+ Build a 2D rectangular HTS layer centered at (HTS_center_x, HTS_center_y).
346
+
347
+ If striation parameters are valid, cut the HTS into multiple
348
+ filaments by removing narrow vertical grooves.
349
+
350
+ Returns
351
+ -------
352
+ int
353
+ A representative HTS surface tag (for backward compatibility).
354
+ """
355
+ x0 = self.HTS_center_x - self.HTS_width / 2.0
356
+ y0 = self.HTS_center_y - self.HTS_thickness / 2.0
357
+
358
+ # Base full-width HTS rectangle
359
+ base_tag = gmsh.model.occ.addRectangle( x0, y0, 0.0, self.HTS_width, self.HTS_thickness )
360
+ self.surface_tag = base_tag # initial monolithic tag
361
+
362
+ grooves_occ: List[Tuple[int, int]] = [] # [(2, tag), ...]
363
+ self.groove_tags = []
364
+ self.filament_tags = []
365
+
366
+ # Decide whether to striate
367
+ if (
368
+ self.n_striations is not None
369
+ and self.n_striations > 1
370
+ and self.striation_w is not None
371
+ and self.striation_w > 0.0
372
+ ):
373
+ N = self.n_striations
374
+ gw = self.striation_w
375
+
376
+ total_groove = gw * (N - 1)
377
+ if total_groove >= self.HTS_width:
378
+ logger.warning(
379
+ "[Geom-CC] Requested HTS striations do not fit in HTS width; "
380
+ "skipping striation (N=%d, gw=%.3g, W=%.3g).",
381
+ N,
382
+ gw,
383
+ self.HTS_width,
384
+ )
385
+ else:
386
+ # Ensure:
387
+ # N * filament_width + (N - 1) * gw = HTS_width
388
+ filament_w = (self.HTS_width - total_groove) / N
389
+
390
+ x_left = x0
391
+ for i in range(N - 1):
392
+ # Position of groove i between filament i and i+1
393
+ xg = x_left + (i + 1) * filament_w + i * gw
394
+ gtag = gmsh.model.occ.addRectangle( xg, y0, 0.0, gw, self.HTS_thickness )
395
+ grooves_occ.append((2, gtag))
396
+ self.groove_tags.append(gtag)
397
+
398
+ # Cut grooves out of HTS:
399
+ # removeObject=True -> base HTS replaced by its parts
400
+ # removeTool=False -> keep the groove rectangles as surfaces
401
+ cut_objs, _ = gmsh.model.occ.cut( [(2, base_tag)], grooves_occ, removeObject=True, removeTool=False )
402
+
403
+ if not cut_objs:
404
+ raise RuntimeError("[Geom-CC] HTS striation cut produced no surfaces.")
405
+
406
+ self.filament_tags = [e[1] for e in cut_objs]
407
+ # Representative tag (for legacy code paths)
408
+ self.surface_tag = self.filament_tags[0]
409
+
410
+ gmsh.model.occ.synchronize()
411
+ self.refresh_topology()
412
+
413
+ return self.surface_tag
414
+
415
+
416
+ def refresh_topology(self) -> None:
417
+ """
418
+ Refresh HTS topology and edge classification using global
419
+ HTS bounding box parameters.
420
+ """
421
+ super().refresh_topology(
422
+ self.HTS_width,
423
+ self.HTS_thickness,
424
+ self.HTS_center_x,
425
+ self.HTS_center_y,
426
+ )
427
+
428
+ # Physical groups
429
+
430
+ def create_physical_groups(self, name_prefix: str = "HTS") -> Dict[str, int]:
431
+ """
432
+ Create physical groups for the HTS layer, its filaments, grooves,
433
+ and edges.
434
+
435
+ 2D groups
436
+ ---------
437
+ - name_prefix:
438
+ all HTS filaments together (monolithic or striated).
439
+ - name_prefix_Filament_i:
440
+ individual HTS filaments (ordered left-to-right).
441
+ - name_prefix_Grooves:
442
+ all grooves together (if any).
443
+ - name_prefix_Groove_i:
444
+ individual groove surfaces (ordered left-to-right).
445
+
446
+ 1D edge groups
447
+ --------------
448
+ - name_prefix_Upper
449
+ - name_prefix_Lower
450
+ - name_prefix_LeftEdge
451
+ - name_prefix_RightEdge
452
+ """
453
+ if self.surface_tag is None and not self.filament_tags:
454
+ raise RuntimeError("build_HTS() must be called before create_physical_groups().")
455
+
456
+ out: Dict[str, int] = {}
457
+
458
+ # Helper for left-to-right ordering
459
+ def x_center_surf(tag: int) -> float:
460
+ xmin, _, _, xmax, _, _ = gmsh.model.getBoundingBox(2, tag)
461
+ return 0.5 * (xmin + xmax)
462
+
463
+ # Surfaces: all filaments together (striation-aware)
464
+ if self.filament_tags:
465
+ surf_tags = self.filament_tags
466
+ else:
467
+ surf_tags = [self.surface_tag]
468
+
469
+ pg = gmsh.model.addPhysicalGroup(2, surf_tags)
470
+ gmsh.model.setPhysicalName(2, pg, name_prefix)
471
+ out[name_prefix] = pg
472
+
473
+ # Per-filament surface groups
474
+ if self.filament_tags:
475
+ for idx, s in enumerate( sorted(self.filament_tags, key=x_center_surf), start=1 ):
476
+ pg_f = gmsh.model.addPhysicalGroup(2, [s])
477
+ name_f = f"{name_prefix}_Filament_{idx}"
478
+ gmsh.model.setPhysicalName(2, pg_f, name_f)
479
+ out[name_f] = pg_f
480
+
481
+ # Groove surface groups
482
+ if self.groove_tags:
483
+ # All grooves together
484
+ pg_all_g = gmsh.model.addPhysicalGroup(2, self.groove_tags)
485
+ name_all_g = f"{name_prefix}_Grooves"
486
+ gmsh.model.setPhysicalName(2, pg_all_g, name_all_g)
487
+ out[name_all_g] = pg_all_g
488
+
489
+ # Individual grooves: left -> right
490
+ for idx, s in enumerate( sorted(self.groove_tags, key=x_center_surf), start=1 ):
491
+ pg_g = gmsh.model.addPhysicalGroup(2, [s])
492
+ name_g = f"{name_prefix}_Groove_{idx}"
493
+ gmsh.model.setPhysicalName(2, pg_g, name_g)
494
+ out[name_g] = pg_g
495
+
496
+ # Edge groups (union over all filaments)
497
+ for name, tags in self.edge_tags.items():
498
+ edge_pg = gmsh.model.addPhysicalGroup(1, tags)
499
+ gmsh.model.setPhysicalName(1, edge_pg, f"{name_prefix}_{name}")
500
+ out[f"{name_prefix}_{name}"] = edge_pg
501
+
502
+ return out
503
+
504
+
505
+ # ---------------------------------------------------------------------------
506
+ # Substrate layer: underneath HTS
507
+ # ---------------------------------------------------------------------------
508
+
509
+ class SubstrateLayer(_RectLayerBase):
510
+ """
511
+ 2D rectangular substrate, placed directly underneath the HTS layer.
512
+
513
+ - Shares a flat interface with the HTS bottom.
514
+ - Same width and x-center as HTS.
515
+ """
516
+
517
+ def __init__(self, substrate_thickness: float) -> None:
518
+ super().__init__()
519
+
520
+ self.substrate_thickness = float(substrate_thickness)
521
+ self.width: Optional[float] = None
522
+ self.center_x: Optional[float] = None
523
+ self.center_y: Optional[float] = None
524
+
525
+ def build_substrate(self, hts: HTSLayer) -> int:
526
+ """
527
+ Place the substrate directly under the provided HTS layer
528
+ (touching at the HTS bottom).
529
+
530
+ Parameters
531
+ ----------
532
+ hts : HTSLayer
533
+ The HTS layer that must already be built.
534
+
535
+ Returns
536
+ -------
537
+ int
538
+ The OCC surface tag of the substrate.
539
+ """
540
+ if hts.surface_tag is None:
541
+ raise RuntimeError(
542
+ "HTS layer must be built before building the substrate."
543
+ )
544
+
545
+ w = hts.HTS_width
546
+ t = hts.HTS_thickness
547
+ cx = hts.HTS_center_x
548
+ cy = hts.HTS_center_y - t / 2.0 - self.substrate_thickness / 2.0
549
+
550
+ self.width = w
551
+ self.center_x = cx
552
+ self.center_y = cy
553
+
554
+ x0 = cx - w / 2.0
555
+ y0 = cy - self.substrate_thickness / 2.0
556
+
557
+ self.surface_tag = gmsh.model.occ.addRectangle(x0, y0, 0.0, w, self.substrate_thickness)
558
+
559
+ gmsh.model.occ.synchronize()
560
+ self.refresh_topology()
561
+ return self.surface_tag
562
+
563
+ def refresh_topology(self) -> None:
564
+ if self.width is None or self.center_x is None or self.center_y is None:
565
+ raise RuntimeError("Substrate geometry not yet initialized.")
566
+ super().refresh_topology(
567
+ self.width,
568
+ self.substrate_thickness,
569
+ self.center_x,
570
+ self.center_y,
571
+ )
572
+
573
+ def create_physical_groups(self, name_prefix: str = "Substrate") -> Dict[str, int]:
574
+ """
575
+ Create physical groups for the substrate layer and its edges.
576
+ """
577
+ if self.surface_tag is None:
578
+ raise RuntimeError("build_substrate() must be called before create_physical_groups().")
579
+
580
+ out: Dict[str, int] = {}
581
+
582
+ pg = gmsh.model.addPhysicalGroup(2, [self.surface_tag])
583
+ gmsh.model.setPhysicalName(2, pg, name_prefix)
584
+ out[name_prefix] = pg
585
+
586
+ for name, tags in self.edge_tags.items():
587
+ edge_pg = gmsh.model.addPhysicalGroup(1, tags)
588
+ gmsh.model.setPhysicalName(1, edge_pg, f"{name_prefix}_{name}")
589
+ out[f"{name_prefix}_{name}"] = edge_pg
590
+
591
+ return out
592
+
593
+
594
+ # ---------------------------------------------------------------------------
595
+ # Silver layers: thin stabilizer above HTS / below substrate
596
+ # ---------------------------------------------------------------------------
597
+
598
+ class SilverTopLayer(_RectLayerBase):
599
+ """
600
+ Top silver layer placed directly above HTS (and directly below CopperTop).
601
+
602
+ This layer:
603
+ - Has same width and x-center as HTS.
604
+ - Touches the HTS upper surface (or can be removed via config).
605
+ """
606
+
607
+ def __init__(self, thickness: float) -> None:
608
+ super().__init__()
609
+ self.thickness = float(thickness)
610
+ self.width: Optional[float] = None
611
+ self.center_x: Optional[float] = None
612
+ self.center_y: Optional[float] = None
613
+
614
+
615
+ def build_over(self, hts: HTSLayer) -> int:
616
+ """
617
+ Place the top silver layer directly above the HTS layer.
618
+ """
619
+ if hts.surface_tag is None:
620
+ raise RuntimeError("HTS must be built before the top Silver layer.")
621
+
622
+ w = hts.HTS_width
623
+ cx = hts.HTS_center_x
624
+ cy = hts.HTS_center_y + hts.HTS_thickness / 2.0 + self.thickness / 2.0
625
+
626
+ self.width = w
627
+ self.center_x = cx
628
+ self.center_y = cy
629
+
630
+ x0 = cx - w / 2.0
631
+ y0 = cy - self.thickness / 2.0
632
+
633
+ self.surface_tag = gmsh.model.occ.addRectangle( x0, y0, 0.0, w, self.thickness )
634
+
635
+ gmsh.model.occ.synchronize()
636
+ self.refresh_topology()
637
+ return self.surface_tag
638
+
639
+ def refresh_topology(self) -> None:
640
+ if self.width is None or self.center_x is None or self.center_y is None:
641
+ raise RuntimeError("SilverTop geometry not yet initialized.")
642
+ super().refresh_topology(
643
+ self.width,
644
+ self.thickness,
645
+ self.center_x,
646
+ self.center_y,
647
+ )
648
+
649
+ def create_physical_groups(self, name_prefix: str = "SilverTop") -> Dict[str, int]:
650
+ """
651
+ Create physical groups for the top silver layer and its edges.
652
+ """
653
+ if self.surface_tag is None:
654
+ raise RuntimeError("build_over() must be called before creating PGs.")
655
+
656
+ out: Dict[str, int] = {}
657
+
658
+ pg = gmsh.model.addPhysicalGroup(2, [self.surface_tag])
659
+ gmsh.model.setPhysicalName(2, pg, name_prefix)
660
+ out[name_prefix] = pg
661
+
662
+ for name, tags in self.edge_tags.items():
663
+ epg = gmsh.model.addPhysicalGroup(1, tags)
664
+ gmsh.model.setPhysicalName(1, epg, f"{name_prefix}_{name}")
665
+ out[f"{name_prefix}_{name}"] = epg
666
+
667
+ return out
668
+
669
+
670
+ class SilverBottomLayer(_RectLayerBase):
671
+ """
672
+ Bottom silver layer placed directly under the substrate
673
+ (and directly above CopperBottom).
674
+ """
675
+
676
+ def __init__(self, thickness: float) -> None:
677
+ super().__init__()
678
+ self.thickness = float(thickness)
679
+ self.width: Optional[float] = None
680
+ self.center_x: Optional[float] = None
681
+ self.center_y: Optional[float] = None
682
+
683
+
684
+ def build_under(self, sub: SubstrateLayer) -> int:
685
+ """
686
+ Place SilverBottom directly under the Substrate layer (touching
687
+ at the interface).
688
+ """
689
+ if sub.surface_tag is None:
690
+ raise RuntimeError("Substrate must be built before the bottom Silver layer.")
691
+
692
+ w = sub.width
693
+ cx = sub.center_x
694
+ cy = sub.center_y - sub.substrate_thickness / 2.0 - self.thickness / 2.0
695
+
696
+ if w is None or cx is None or cy is None:
697
+ raise RuntimeError("Substrate geometry not yet initialized.")
698
+
699
+ self.width = w
700
+ self.center_x = cx
701
+ self.center_y = cy
702
+
703
+ x0 = cx - w / 2.0
704
+ y0 = cy - self.thickness / 2.0
705
+
706
+ self.surface_tag = gmsh.model.occ.addRectangle(x0, y0, 0.0, w, self.thickness)
707
+
708
+ gmsh.model.occ.synchronize()
709
+ self.refresh_topology()
710
+ return self.surface_tag
711
+
712
+ def refresh_topology(self) -> None:
713
+ if self.width is None or self.center_x is None or self.center_y is None:
714
+ raise RuntimeError("SilverBottom geometry not yet initialized.")
715
+ super().refresh_topology(
716
+ self.width,
717
+ self.thickness,
718
+ self.center_x,
719
+ self.center_y,
720
+ )
721
+
722
+ def create_physical_groups(self, name_prefix: str = "SilverBottom") -> Dict[str, int]:
723
+ """
724
+ Create physical groups for the bottom silver layer and its edges.
725
+ """
726
+ if self.surface_tag is None:
727
+ raise RuntimeError("build_under() must be called before creating PGs.")
728
+
729
+ out: Dict[str, int] = {}
730
+
731
+ pg = gmsh.model.addPhysicalGroup(2, [self.surface_tag])
732
+ gmsh.model.setPhysicalName(2, pg, name_prefix)
733
+ out[name_prefix] = pg
734
+
735
+ for name, tags in self.edge_tags.items():
736
+ epg = gmsh.model.addPhysicalGroup(1, tags)
737
+ gmsh.model.setPhysicalName(1, epg, f"{name_prefix}_{name}")
738
+ out[f"{name_prefix}_{name}"] = epg
739
+
740
+ return out
741
+
742
+
743
+ # ---------------------------------------------------------------------------
744
+ # Copper layers: bottom / top / left / right
745
+ # ---------------------------------------------------------------------------
746
+
747
+ class CopperBottomLayer(_RectLayerBase):
748
+ """
749
+ Lower 2D copper layer placed directly under a base layer:
750
+
751
+ - Under SilverBottom if present;
752
+ - Otherwise directly under Substrate.
753
+
754
+ This is handled in the geometry builder (Generate_geometry).
755
+ """
756
+
757
+ def __init__(self, thickness: float) -> None:
758
+ super().__init__()
759
+ self.thickness = float(thickness)
760
+ self.width: Optional[float] = None
761
+ self.center_x: Optional[float] = None
762
+ self.center_y: Optional[float] = None
763
+
764
+ def build_under(self, base_layer: _RectLayerBase) -> int:
765
+ """
766
+ Place CopperBottom directly under the given base_layer.
767
+
768
+ base_layer can be:
769
+ - SubstrateLayer
770
+ - SilverBottomLayer
771
+ """
772
+ if base_layer.surface_tag is None:
773
+ raise RuntimeError("Base layer must be built before CopperBottom.")
774
+
775
+ if isinstance(base_layer, SubstrateLayer):
776
+ w = base_layer.width
777
+ cx = base_layer.center_x
778
+ cy_base = base_layer.center_y
779
+ t_base = base_layer.substrate_thickness
780
+
781
+ elif isinstance(base_layer, SilverBottomLayer):
782
+ w = base_layer.width
783
+ cx = base_layer.center_x
784
+ cy_base = base_layer.center_y
785
+ t_base = base_layer.thickness
786
+
787
+ else:
788
+ raise TypeError(
789
+ "CopperBottomLayer.build_under() expected SubstrateLayer or "
790
+ f"SilverBottomLayer, got {type(base_layer)!r}."
791
+ )
792
+
793
+ if w is None or cx is None or cy_base is None:
794
+ raise RuntimeError("Base layer geometry not yet initialized.")
795
+
796
+ # Center of CopperBottom: just below the base layer
797
+ cy = cy_base - t_base / 2.0 - self.thickness / 2.0
798
+
799
+ self.width = w
800
+ self.center_x = cx
801
+ self.center_y = cy
802
+
803
+ x0 = cx - w / 2.0
804
+ y0 = cy - self.thickness / 2.0
805
+
806
+ self.surface_tag = gmsh.model.occ.addRectangle(x0, y0, 0.0, w, self.thickness)
807
+
808
+ gmsh.model.occ.synchronize()
809
+ self.refresh_topology()
810
+ return self.surface_tag
811
+
812
+ def refresh_topology(self) -> None:
813
+ if self.width is None or self.center_x is None or self.center_y is None:
814
+ raise RuntimeError("CopperBottom geometry not yet initialized.")
815
+ super().refresh_topology(
816
+ self.width,
817
+ self.thickness,
818
+ self.center_x,
819
+ self.center_y,
820
+ )
821
+
822
+ def create_physical_groups(self, name_prefix: str = "CopperBottom") -> Dict[str, int]:
823
+ """
824
+ Create physical groups for the lower copper layer and its edges.
825
+ """
826
+ if self.surface_tag is None:
827
+ raise RuntimeError("build_under() must be called before create_physical_groups().")
828
+
829
+ out: Dict[str, int] = {}
830
+
831
+ pg = gmsh.model.addPhysicalGroup(2, [self.surface_tag])
832
+ gmsh.model.setPhysicalName(2, pg, name_prefix)
833
+ out[name_prefix] = pg
834
+
835
+ for name, tags in self.edge_tags.items():
836
+ edge_pg = gmsh.model.addPhysicalGroup(1, tags)
837
+ gmsh.model.setPhysicalName(1, edge_pg, f"{name_prefix}_{name}")
838
+ out[f"{name_prefix}_{name}"] = edge_pg
839
+
840
+ return out
841
+
842
+
843
+ class CopperTopLayer(_RectLayerBase):
844
+ """
845
+ Top 2D copper layer placed directly above:
846
+
847
+ - SilverTop layer if present, otherwise
848
+ - directly above the HTS layer.
849
+ """
850
+
851
+ def __init__(self, thickness: float) -> None:
852
+ super().__init__()
853
+ self.thickness = float(thickness)
854
+ self.width: Optional[float] = None
855
+ self.center_x: Optional[float] = None
856
+ self.center_y: Optional[float] = None
857
+
858
+ def build_over(self, base_layer: _RectLayerBase) -> int:
859
+ """
860
+ Place CopperTop directly above `base_layer`.
861
+
862
+ base_layer can be:
863
+ - HTSLayer
864
+ - SilverTopLayer
865
+ """
866
+ if base_layer.surface_tag is None:
867
+ raise RuntimeError("Base layer must be built before CopperTop.")
868
+
869
+ # Case 1: HTS as base
870
+ if isinstance(base_layer, HTSLayer):
871
+ w = base_layer.HTS_width
872
+ cx = base_layer.HTS_center_x
873
+ cy_base = base_layer.HTS_center_y
874
+ t_base = base_layer.HTS_thickness
875
+
876
+ # Case 2: SilverTop as base
877
+ elif isinstance(base_layer, SilverTopLayer):
878
+ w = base_layer.width
879
+ cx = base_layer.center_x
880
+ cy_base = base_layer.center_y
881
+ t_base = base_layer.thickness
882
+
883
+ else:
884
+ raise TypeError(
885
+ "CopperTopLayer.build_over() expected HTSLayer or "
886
+ f"SilverTopLayer, got {type(base_layer)!r}."
887
+ )
888
+
889
+ if w is None or cx is None or cy_base is None:
890
+ raise RuntimeError("Base layer geometry not yet initialized.")
891
+
892
+ cy = cy_base + t_base / 2.0 + self.thickness / 2.0
893
+
894
+ self.width = w
895
+ self.center_x = cx
896
+ self.center_y = cy
897
+
898
+ x0 = cx - w / 2.0
899
+ y0 = cy - self.thickness / 2.0
900
+
901
+ self.surface_tag = gmsh.model.occ.addRectangle(x0, y0, 0.0, w, self.thickness)
902
+
903
+ gmsh.model.occ.synchronize()
904
+ self.refresh_topology()
905
+ return self.surface_tag
906
+
907
+ def refresh_topology(self) -> None:
908
+ if self.width is None or self.center_x is None or self.center_y is None:
909
+ raise RuntimeError("CopperTop geometry not yet initialized.")
910
+ super().refresh_topology(
911
+ self.width,
912
+ self.thickness,
913
+ self.center_x,
914
+ self.center_y,
915
+ )
916
+
917
+ def create_physical_groups(self, name_prefix: str = "CopperTop") -> Dict[str, int]:
918
+ """
919
+ Create physical groups for the top copper layer and its edges.
920
+ """
921
+ if self.surface_tag is None:
922
+ raise RuntimeError("build_over() must be called before create_physical_groups().")
923
+
924
+ out: Dict[str, int] = {}
925
+
926
+ pg = gmsh.model.addPhysicalGroup(2, [self.surface_tag])
927
+ gmsh.model.setPhysicalName(2, pg, name_prefix)
928
+ out[name_prefix] = pg
929
+
930
+ for name, tags in self.edge_tags.items():
931
+ edge_pg = gmsh.model.addPhysicalGroup(1, tags)
932
+ gmsh.model.setPhysicalName(1, edge_pg, f"{name_prefix}_{name}")
933
+ out[f"{name_prefix}_{name}"] = edge_pg
934
+
935
+ return out
936
+
937
+
938
+ class CopperLeftLayer(_RectLayerBase):
939
+ """
940
+ Left copper shim, placed directly to the left of the HTS + Substrate
941
+ (and optionally CopperBottom/CopperTop if present).
942
+
943
+ Height is from:
944
+ bottom of (CopperBottom or Substrate)
945
+ to
946
+ top of (CopperTop or HTS)
947
+ """
948
+
949
+ def __init__(self, thickness: float) -> None:
950
+ super().__init__()
951
+ self.thickness = float(thickness)
952
+ self.width = self.thickness # Horizontal size
953
+ self.height: Optional[float] = None
954
+ self.center_x: Optional[float] = None
955
+ self.center_y: Optional[float] = None
956
+
957
+ def build_left_of(
958
+ self,
959
+ hts: HTSLayer,
960
+ sub: SubstrateLayer,
961
+ cu_bottom: Optional["CopperBottomLayer"] = None,
962
+ cu_top: Optional["CopperTopLayer"] = None,
963
+ ) -> int:
964
+ """
965
+ Build CopperLeft against the left edges of HTS and Substrate,
966
+ extending vertically to cover the full bottom/top stack.
967
+ """
968
+ if hts.surface_tag is None or sub.surface_tag is None:
969
+ raise RuntimeError("HTS and Substrate must be built before CopperLeft.")
970
+
971
+ if sub.width is None or sub.center_x is None or sub.center_y is None:
972
+ raise RuntimeError("Substrate geometry not yet initialized.")
973
+
974
+ # Vertical limits
975
+ if cu_top is not None:
976
+ y_top = cu_top.center_y + cu_top.thickness / 2.0
977
+ else:
978
+ y_top = hts.HTS_center_y + hts.HTS_thickness / 2.0
979
+
980
+ if cu_bottom is not None:
981
+ y_bot = cu_bottom.center_y - cu_bottom.thickness / 2.0
982
+ else:
983
+ y_bot = sub.center_y - sub.substrate_thickness / 2.0
984
+
985
+ self.height = y_top - y_bot
986
+ if self.height <= 0.0:
987
+ raise RuntimeError(f"CopperLeft height <= 0 (y_top={y_top}, y_bot={y_bot}).")
988
+
989
+ # Horizontal placement: flush with leftmost face of HTS/Substrate
990
+ x_left_face = min(
991
+ hts.HTS_center_x - hts.HTS_width / 2.0,
992
+ sub.center_x - sub.width / 2.0,
993
+ )
994
+
995
+ self.center_x = x_left_face - self.thickness / 2.0
996
+ self.center_y = 0.5 * (y_top + y_bot)
997
+
998
+ x0 = self.center_x - self.thickness / 2.0
999
+ y0 = self.center_y - self.height / 2.0
1000
+
1001
+ self.surface_tag = gmsh.model.occ.addRectangle(x0, y0, 0.0, self.thickness, self.height)
1002
+
1003
+ gmsh.model.occ.synchronize()
1004
+ self._refresh_from_surface()
1005
+ self._classify_edges_from_bbox(
1006
+ self.thickness,
1007
+ self.height,
1008
+ self.center_x,
1009
+ self.center_y,
1010
+ )
1011
+
1012
+ return self.surface_tag
1013
+
1014
+ def refresh_topology(self) -> None:
1015
+ if (
1016
+ self.height is None
1017
+ or self.center_x is None
1018
+ or self.center_y is None
1019
+ ):
1020
+ raise RuntimeError("CopperLeft geometry not yet initialized.")
1021
+ self._refresh_from_surface()
1022
+ self._classify_edges_from_bbox(
1023
+ self.thickness,
1024
+ self.height,
1025
+ self.center_x,
1026
+ self.center_y,
1027
+ )
1028
+
1029
+ def create_physical_groups(self, name_prefix: str = "CopperLeft") -> Dict[str, int]:
1030
+ """
1031
+ Create physical groups for the left copper shim and its edges.
1032
+ """
1033
+ if self.surface_tag is None:
1034
+ raise RuntimeError("build_left_of() must be called before creating PGs.")
1035
+
1036
+ out: Dict[str, int] = {}
1037
+
1038
+ pg = gmsh.model.addPhysicalGroup(2, [self.surface_tag])
1039
+ gmsh.model.setPhysicalName(2, pg, name_prefix)
1040
+ out[name_prefix] = pg
1041
+
1042
+ for name, tags in self.edge_tags.items():
1043
+ edge_pg = gmsh.model.addPhysicalGroup(1, tags)
1044
+ gmsh.model.setPhysicalName(1, edge_pg, f"{name_prefix}_{name}")
1045
+ out[f"{name_prefix}_{name}"] = edge_pg
1046
+
1047
+ return out
1048
+
1049
+
1050
+ class CopperRightLayer(_RectLayerBase):
1051
+ """
1052
+ Right copper shim, symmetric counterpart of CopperLeftLayer.
1053
+ """
1054
+
1055
+ def __init__(self, thickness: float) -> None:
1056
+ super().__init__()
1057
+ self.thickness = float(thickness)
1058
+ self.width = self.thickness
1059
+ self.height: Optional[float] = None
1060
+ self.center_x: Optional[float] = None
1061
+ self.center_y: Optional[float] = None
1062
+
1063
+ def build_right_of(
1064
+ self,
1065
+ hts: HTSLayer,
1066
+ sub: SubstrateLayer,
1067
+ cu_bottom: Optional["CopperBottomLayer"] = None,
1068
+ cu_top: Optional["CopperTopLayer"] = None,
1069
+ ) -> int:
1070
+ """
1071
+ Build CopperRight against the right edges of HTS and Substrate.
1072
+ """
1073
+ if hts.surface_tag is None or sub.surface_tag is None:
1074
+ raise RuntimeError("HTS and Substrate must be built before CopperRight.")
1075
+
1076
+ if sub.width is None or sub.center_x is None or sub.center_y is None:
1077
+ raise RuntimeError("Substrate geometry not yet initialized.")
1078
+
1079
+ if cu_top is not None:
1080
+ y_top = cu_top.center_y + cu_top.thickness / 2.0
1081
+ else:
1082
+ y_top = hts.HTS_center_y + hts.HTS_thickness / 2.0
1083
+
1084
+ if cu_bottom is not None:
1085
+ y_bot = cu_bottom.center_y - cu_bottom.thickness / 2.0
1086
+ else:
1087
+ y_bot = sub.center_y - sub.substrate_thickness / 2.0
1088
+
1089
+ self.height = y_top - y_bot
1090
+ if self.height <= 0.0:
1091
+ raise RuntimeError(f"CopperRight height <= 0 (y_top={y_top}, y_bot={y_bot}).")
1092
+
1093
+ x_right_face = max(
1094
+ hts.HTS_center_x + hts.HTS_width / 2.0,
1095
+ sub.center_x + sub.width / 2.0,
1096
+ )
1097
+
1098
+ self.center_x = x_right_face + self.thickness / 2.0
1099
+ self.center_y = 0.5 * (y_top + y_bot)
1100
+
1101
+ x0 = self.center_x - self.thickness / 2.0
1102
+ y0 = self.center_y - self.height / 2.0
1103
+
1104
+ self.surface_tag = gmsh.model.occ.addRectangle(x0, y0, 0.0, self.thickness, self.height)
1105
+
1106
+ gmsh.model.occ.synchronize()
1107
+ self._refresh_from_surface()
1108
+ self._classify_edges_from_bbox(
1109
+ self.thickness,
1110
+ self.height,
1111
+ self.center_x,
1112
+ self.center_y,
1113
+ )
1114
+
1115
+ return self.surface_tag
1116
+
1117
+ def refresh_topology(self) -> None:
1118
+ if (
1119
+ self.height is None
1120
+ or self.center_x is None
1121
+ or self.center_y is None
1122
+ ):
1123
+ raise RuntimeError("CopperRight geometry not yet initialized.")
1124
+ self._refresh_from_surface()
1125
+ self._classify_edges_from_bbox(
1126
+ self.thickness,
1127
+ self.height,
1128
+ self.center_x,
1129
+ self.center_y,
1130
+ )
1131
+
1132
+ def create_physical_groups(self, name_prefix: str = "CopperRight") -> Dict[str, int]:
1133
+ """
1134
+ Create physical groups for the right copper shim and its edges.
1135
+ """
1136
+ if self.surface_tag is None:
1137
+ raise RuntimeError("build_right_of() must be called before creating PGs.")
1138
+
1139
+ out: Dict[str, int] = {}
1140
+
1141
+ pg = gmsh.model.addPhysicalGroup(2, [self.surface_tag])
1142
+ gmsh.model.setPhysicalName(2, pg, name_prefix)
1143
+ out[name_prefix] = pg
1144
+
1145
+ for name, tags in self.edge_tags.items():
1146
+ edge_pg = gmsh.model.addPhysicalGroup(1, tags)
1147
+ gmsh.model.setPhysicalName(1, edge_pg, f"{name_prefix}_{name}")
1148
+ out[f"{name_prefix}_{name}"] = edge_pg
1149
+
1150
+ return out
1151
+
1152
+
1153
+ # ---------------------------------------------------------------------------
1154
+ # High-level geometry builder
1155
+ # ---------------------------------------------------------------------------
1156
+
1157
+ class Generate_geometry:
1158
+ """
1159
+ Generate a 2D geometry of the CACCC model and save as .brep / .xao.
1160
+
1161
+ Responsibilities
1162
+ ----------------
1163
+ - Initialize a Gmsh OCC model for the given magnet_name.
1164
+ - Instantiate and build each layer:
1165
+ HTS, Substrate, optional SilverTop / SilverBottom,
1166
+ optional CopperTop / CopperBottom / CopperLeft / CopperRight.
1167
+ - Create an enclosing circular air region and cut out all solids.
1168
+ - Refresh topology and create physical groups:
1169
+ - material regions (2D)
1170
+ - layer edges (1D)
1171
+ - inter-layer interfaces (1D)
1172
+ - air outer boundary (1D)
1173
+ - Write:
1174
+ <magnet_name>.brep
1175
+ <magnet_name>.xao
1176
+ """
1177
+
1178
+ def __init__(
1179
+ self,
1180
+ fdm: FDM,
1181
+ inputs_folder_path: str,
1182
+ verbose: bool = True,
1183
+ *,
1184
+ initialize_gmsh: bool = True,
1185
+ create_model: bool = True,
1186
+ create_physical_groups: Optional[bool] = None,
1187
+ wipe_physical_groups: Optional[bool] = None,
1188
+ write_files: Optional[bool] = None,
1189
+ clear_gmsh_on_finalize: Optional[bool] = None,
1190
+ external_gu: Optional[GmshUtils] = None,
1191
+ ) -> None:
1192
+
1193
+ self.fdm = fdm
1194
+ self.inputs_folder_path = inputs_folder_path
1195
+
1196
+ self.model_folder = os.path.join(os.getcwd())
1197
+ self.magnet_name = fdm.general.magnet_name
1198
+
1199
+ self.model_file = os.path.join(self.model_folder, f"{self.magnet_name}.brep")
1200
+ self.xao_file = os.path.join(self.model_folder, f"{self.magnet_name}.xao")
1201
+
1202
+ self.verbose = verbose
1203
+ self.gu = external_gu if external_gu is not None else GmshUtils(self.model_folder, self.verbose)
1204
+
1205
+ # When embedded in another builder (e.g. TSTC 2D stack),
1206
+ # we must not re-initialize Gmsh, and must not create a new model.
1207
+ self._create_model = bool(create_model)
1208
+
1209
+
1210
+ embedded = not self._create_model
1211
+
1212
+ # Standalone defaults (embedded=False): behave like before.
1213
+ # Embedded defaults (embedded=True): do NOT clear / wipe / write unless requested.
1214
+ self._create_physical_groups = (
1215
+ bool(create_physical_groups)
1216
+ if create_physical_groups is not None
1217
+ else (not embedded)
1218
+ )
1219
+ self._wipe_physical_groups = (
1220
+ bool(wipe_physical_groups)
1221
+ if wipe_physical_groups is not None
1222
+ else (not embedded)
1223
+ )
1224
+ self._write_files = (
1225
+ bool(write_files)
1226
+ if write_files is not None
1227
+ else (not embedded)
1228
+ )
1229
+ self._clear_gmsh_on_finalize = (
1230
+ bool(clear_gmsh_on_finalize)
1231
+ if clear_gmsh_on_finalize is not None
1232
+ else (not embedded)
1233
+ )
1234
+
1235
+
1236
+ conductor_dict = self.fdm.conductors
1237
+
1238
+ if not conductor_dict:
1239
+ raise KeyError(
1240
+ "fdm.conductors is empty: cannot build CC geometry. "
1241
+ "Expected at least one conductor entry."
1242
+ )
1243
+
1244
+ # Preferred legacy key (many FiQuS models use this)
1245
+ selected_conductor_name = self.fdm.magnet.solve.conductor_name
1246
+ if selected_conductor_name in conductor_dict:
1247
+ selected_conductor: Conductor = conductor_dict[selected_conductor_name]
1248
+ else:
1249
+ raise ValueError(
1250
+ f"Conductor name: {selected_conductor_name} not present in the conductors section"
1251
+ )
1252
+
1253
+ if initialize_gmsh:
1254
+ self.gu.initialize(verbosity_Gmsh=fdm.run.verbosity_Gmsh)
1255
+
1256
+ # OCC warnings (0 = silenced in terminal)
1257
+ gmsh.option.setNumber("General.Terminal", 0)
1258
+ gmsh.option.setNumber("General.Verbosity", 0)
1259
+
1260
+ # All built layers are stored here (by name)
1261
+ self.layers: Dict[str, object] = {}
1262
+ self._model_ready = False
1263
+
1264
+
1265
+ # Reference center for this CC cross-section (used for placement and air centering)
1266
+ self._geom_center_x: float = 0.0
1267
+ self._geom_center_y: float = 0.0
1268
+
1269
+
1270
+ # the strand is a Union[Round, Rectangular, CC, Homogenized]
1271
+ strand = selected_conductor.strand
1272
+ if not isinstance(strand, CC):
1273
+ raise TypeError(
1274
+ f"Expected strand type 'CC' for CACCC geometry, got {type(strand)}"
1275
+ )
1276
+
1277
+ # Store this CC strand so all generate_* methods can use it
1278
+ self.cc_strand = strand
1279
+ s = self.cc_strand # shorthand
1280
+
1281
+ # ------------------------------------------------------------------
1282
+ # Geometry sanity checks (enforced here, not in magnet.geometry model)
1283
+ # ------------------------------------------------------------------
1284
+ # Mandatory base layer dimensions must be > 0
1285
+ _require_positive(
1286
+ s.HTS_width,
1287
+ name=f"conductors.{selected_conductor_name}.strand.HTS_width",
1288
+ )
1289
+ _require_positive(
1290
+ s.HTS_thickness,
1291
+ name=f"conductors.{selected_conductor_name}.strand.HTS_thickness",
1292
+ )
1293
+ _require_positive(
1294
+ s.substrate_thickness,
1295
+ name=f"conductors.{selected_conductor_name}.strand.substrate_thickness",
1296
+ )
1297
+
1298
+ # Striation parameters
1299
+ n_fil = _require_int_ge(
1300
+ getattr(s, "number_of_filaments", None),
1301
+ name=f"conductors.{selected_conductor_name}.strand.number_of_filaments",
1302
+ min_value=1,
1303
+ )
1304
+ gap = getattr(s, "gap_between_filaments", None)
1305
+ gap_v = _require_non_negative_optional(
1306
+ gap,
1307
+ name=f"conductors.{selected_conductor_name}.strand.gap_between_filaments",
1308
+ )
1309
+
1310
+ if n_fil is not None and n_fil > 1:
1311
+ if gap is None or gap_v <= 0.0:
1312
+ raise ValueError(
1313
+ "HTS striation requested (number_of_filaments > 1) but "
1314
+ "gap_between_filaments is missing or <= 0."
1315
+ )
1316
+
1317
+ total_groove = gap_v * (n_fil - 1)
1318
+ hts_w = float(s.HTS_width)
1319
+ if total_groove >= hts_w:
1320
+ raise ValueError(
1321
+ f"Total groove width {total_groove:g} >= HTS_width {hts_w:g}. "
1322
+ "Reduce gap_between_filaments or number_of_filaments."
1323
+ )
1324
+
1325
+ # ------------------------------------------------------------------
1326
+ # Layer inclusion logic (thickness-based only, but negatives are errors)
1327
+ # ------------------------------------------------------------------
1328
+ ag_top = _require_non_negative_optional(
1329
+ getattr(getattr(s, "silver_thickness", None), "top", None),
1330
+ name=f"conductors.{selected_conductor_name}.strand.silver_thickness.top",
1331
+ )
1332
+ ag_bot = _require_non_negative_optional(
1333
+ getattr(getattr(s, "silver_thickness", None), "bottom", None),
1334
+ name=f"conductors.{selected_conductor_name}.strand.silver_thickness.bottom",
1335
+ )
1336
+
1337
+ cu_top = _require_non_negative_optional(
1338
+ getattr(getattr(s, "copper_thickness", None), "top", None),
1339
+ name=f"conductors.{selected_conductor_name}.strand.copper_thickness.top",
1340
+ )
1341
+ cu_bot = _require_non_negative_optional(
1342
+ getattr(getattr(s, "copper_thickness", None), "bottom", None),
1343
+ name=f"conductors.{selected_conductor_name}.strand.copper_thickness.bottom",
1344
+ )
1345
+ cu_left = _require_non_negative_optional(
1346
+ getattr(getattr(s, "copper_thickness", None), "left", None),
1347
+ name=f"conductors.{selected_conductor_name}.strand.copper_thickness.left",
1348
+ )
1349
+ cu_right = _require_non_negative_optional(
1350
+ getattr(getattr(s, "copper_thickness", None), "right", None),
1351
+ name=f"conductors.{selected_conductor_name}.strand.copper_thickness.right",
1352
+ )
1353
+
1354
+ self._use_silver_top = ag_top > 0.0
1355
+ self._use_silver_bottom = ag_bot > 0.0
1356
+
1357
+ self._use_copper_top = cu_top > 0.0
1358
+ self._use_copper_bottom = cu_bot > 0.0
1359
+ self._use_copper_left = cu_left > 0.0
1360
+ self._use_copper_right = cu_right > 0.0
1361
+
1362
+
1363
+
1364
+
1365
+ # Internal helpers
1366
+ def _ensure_model(self) -> None:
1367
+ """
1368
+ Ensure there is an active Gmsh model.
1369
+
1370
+ In embedded mode (create_model=False), we assume the caller already did:
1371
+ gmsh.model.add(...)
1372
+ """
1373
+ if self._model_ready:
1374
+ return
1375
+
1376
+ if getattr(self, "_create_model", True):
1377
+ gmsh.model.add(self.magnet_name)
1378
+
1379
+ self._model_ready = True
1380
+
1381
+
1382
+ # Layer builders
1383
+ def generate_HTS_layer(self, center_x: float = 0.0, center_y: float = 0.0) -> None:
1384
+ """
1385
+ Build a single HTS layer (2D) centered at (center_x, center_y).
1386
+
1387
+ Striation parameters (from CC strand):
1388
+ - number_of_filaments
1389
+ - gap_between_filaments
1390
+ """
1391
+ self._ensure_model()
1392
+
1393
+ # Save reference center so other steps (e.g. air disk) can follow placement.
1394
+ self._geom_center_x = float(center_x)
1395
+ self._geom_center_y = float(center_y)
1396
+
1397
+ s = self.cc_strand # CC instance
1398
+
1399
+ layer = HTSLayer(
1400
+ HTS_thickness=float(s.HTS_thickness),
1401
+ HTS_width=float(s.HTS_width),
1402
+ HTS_center_x=float(center_x),
1403
+ HTS_center_y=float(center_y),
1404
+ number_of_filaments=s.number_of_filaments,
1405
+ gap_between_filaments=s.gap_between_filaments,
1406
+ )
1407
+
1408
+ layer.build_HTS()
1409
+ self.layers["HTS"] = layer
1410
+
1411
+
1412
+
1413
+
1414
+ def generate_silver_top_layer(self) -> None:
1415
+ """
1416
+ Build the SilverTop layer directly above HTS (if enabled).
1417
+ """
1418
+ self._ensure_model()
1419
+
1420
+ if not self._use_silver_top:
1421
+ logger.info("[Geom-CC] Skipping SilverTop layer (thickness == 0).")
1422
+ return
1423
+
1424
+ if "HTS" not in self.layers:
1425
+ raise RuntimeError("HTS must be built before top Silver.")
1426
+
1427
+ s = self.cc_strand
1428
+
1429
+ Ag = SilverTopLayer(thickness=float(s.silver_thickness.top))
1430
+ Ag.build_over(self.layers["HTS"])
1431
+ self.layers["SilverTop"] = Ag
1432
+
1433
+
1434
+
1435
+ def generate_substrate_layer(self) -> None:
1436
+ """
1437
+ Build the substrate layer directly under the HTS (shared interface).
1438
+ """
1439
+ self._ensure_model()
1440
+
1441
+ if "HTS" not in self.layers:
1442
+ raise RuntimeError(
1443
+ "HTS layer must be built before substrate. "
1444
+ "Call generate_HTS_layer() first."
1445
+ )
1446
+
1447
+ hts = self.layers["HTS"]
1448
+ s = self.cc_strand
1449
+
1450
+ substrate = SubstrateLayer(substrate_thickness=float(s.substrate_thickness))
1451
+ substrate.build_substrate(hts)
1452
+ self.layers["Substrate"] = substrate
1453
+
1454
+
1455
+ def generate_silver_bottom_layer(self) -> None:
1456
+ """
1457
+ Build the bottom Silver layer directly under the Substrate
1458
+ (shared interface), if enabled.
1459
+ """
1460
+ self._ensure_model()
1461
+
1462
+ if not self._use_silver_bottom:
1463
+ logger.info("[Geom-CC] Skipping SilverBottom layer (config disabled).")
1464
+ return
1465
+
1466
+ if "Substrate" not in self.layers:
1467
+ raise RuntimeError("Substrate must be built before bottom Silver.")
1468
+
1469
+ sub = self.layers["Substrate"]
1470
+ s = self.cc_strand
1471
+
1472
+ AgB = SilverBottomLayer(thickness=float(s.silver_thickness.bottom))
1473
+ AgB.build_under(sub)
1474
+ self.layers["SilverBottom"] = AgB
1475
+
1476
+
1477
+
1478
+ def generate_copper_bottom_layer(self) -> None:
1479
+ """
1480
+ Build CopperBottom directly under the bottom-most central layer:
1481
+
1482
+ - Under SilverBottom if it exists,
1483
+ - Otherwise directly under Substrate.
1484
+
1485
+ (Side shims are handled separately.)
1486
+ """
1487
+ self._ensure_model()
1488
+
1489
+ if not self._use_copper_bottom:
1490
+ logger.info("[Geom-CC] Skipping CopperBottom layer (config disabled).")
1491
+ return
1492
+
1493
+ s = self.cc_strand
1494
+
1495
+ if "Substrate" not in self.layers:
1496
+ raise RuntimeError("Substrate must be built before CopperBottom.")
1497
+
1498
+ base_layer = self.layers.get("SilverBottom", None)
1499
+ if base_layer is None:
1500
+ base_layer = self.layers["Substrate"]
1501
+
1502
+ cuL = CopperBottomLayer(thickness=float(s.copper_thickness.bottom))
1503
+ cuL.build_under(base_layer)
1504
+ self.layers["CopperBottom"] = cuL
1505
+
1506
+
1507
+ def generate_copper_top_layer(self) -> None:
1508
+ """
1509
+ Build CopperTop directly above HTS (or above SilverTop if present).
1510
+ """
1511
+ self._ensure_model()
1512
+
1513
+ if not self._use_copper_top:
1514
+ logger.info("[Geom-CC] Skipping CopperTop layer (config disabled).")
1515
+ return
1516
+
1517
+ s = self.cc_strand
1518
+
1519
+ if "HTS" not in self.layers:
1520
+ raise RuntimeError("HTS must be built before CopperTop.")
1521
+
1522
+ hts = self.layers["HTS"]
1523
+ cuT = CopperTopLayer(thickness=float(s.copper_thickness.top))
1524
+ silver_top = self.layers.get("SilverTop", None)
1525
+
1526
+ if silver_top is not None:
1527
+ cuT.build_over(silver_top)
1528
+ else:
1529
+ cuT.build_over(hts)
1530
+
1531
+ self.layers["CopperTop"] = cuT
1532
+
1533
+
1534
+
1535
+ def generate_copper_left_layer(self) -> None:
1536
+ """
1537
+ Build CopperLeft against the left edges of HTS + Substrate,
1538
+ extended vertically to cover the whole stack (CuBottom / CuTop if present).
1539
+ """
1540
+ self._ensure_model()
1541
+
1542
+ if not self._use_copper_left:
1543
+ logger.info("[Geom-CC] Skipping CopperLeft layer (config disabled).")
1544
+ return
1545
+
1546
+ s = self.cc_strand
1547
+
1548
+ if "HTS" not in self.layers or "Substrate" not in self.layers:
1549
+ raise RuntimeError("Build HTS and Substrate before CopperLeft.")
1550
+
1551
+ cuL = CopperLeftLayer(thickness=float(s.copper_thickness.left))
1552
+
1553
+ cuL.build_left_of(
1554
+ self.layers["HTS"],
1555
+ self.layers["Substrate"],
1556
+ cu_bottom=self.layers.get("CopperBottom"),
1557
+ cu_top=self.layers.get("CopperTop"),
1558
+ )
1559
+
1560
+ self.layers["CopperLeft"] = cuL
1561
+
1562
+
1563
+ def generate_copper_right_layer(self) -> None:
1564
+ """
1565
+ Build CopperRight against the right edges of HTS + Substrate,
1566
+ extended vertically to cover the whole stack (CuBottom / CuTop if present).
1567
+ """
1568
+ self._ensure_model()
1569
+
1570
+ if not self._use_copper_right:
1571
+ logger.info("[Geom-CC] Skipping CopperRight layer (config disabled).")
1572
+ return
1573
+
1574
+ s = self.cc_strand
1575
+
1576
+ if "HTS" not in self.layers or "Substrate" not in self.layers:
1577
+ raise RuntimeError("Build HTS and Substrate before CopperRight.")
1578
+
1579
+ cuR = CopperRightLayer(thickness=float(s.copper_thickness.right))
1580
+
1581
+ cuR.build_right_of(
1582
+ self.layers["HTS"],
1583
+ self.layers["Substrate"],
1584
+ cu_bottom=self.layers.get("CopperBottom"),
1585
+ cu_top=self.layers.get("CopperTop"),
1586
+ )
1587
+
1588
+ self.layers["CopperRight"] = cuR
1589
+
1590
+
1591
+ # Air region
1592
+
1593
+ def generate_air_region(self) -> None:
1594
+ g = self.fdm.magnet.geometry
1595
+ R = _require_positive(g.air_radius, name="magnet.geometry.air_radius")
1596
+
1597
+ # -------------------------------
1598
+ # 1) Collect all inner surfaces
1599
+ # -------------------------------
1600
+ inner: List[int] = []
1601
+ for L in self.layers.values():
1602
+ if hasattr(L, "filament_tags") and getattr(L, "filament_tags"):
1603
+ inner.extend(getattr(L, "filament_tags"))
1604
+
1605
+ if hasattr(L, "groove_tags") and getattr(L, "groove_tags"):
1606
+ inner.extend(getattr(L, "groove_tags"))
1607
+
1608
+ elif getattr(L, "surface_tag", None) is not None:
1609
+ tag = getattr(L, "surface_tag")
1610
+
1611
+ if tag not in inner:
1612
+ inner.append(tag)
1613
+
1614
+ # Defensive: collapse exact duplicates before any boolean ops
1615
+ gmsh.model.occ.removeAllDuplicates()
1616
+ gmsh.model.occ.synchronize()
1617
+
1618
+ # -----------------------------------------------------
1619
+ # 2) Enforce conformity inside the conductor stack
1620
+ # (split all touching rectangles so they share edges)
1621
+ # -----------------------------------------------------
1622
+ # Fragmenting the conductor stack first enforces conformal interfaces:
1623
+ # touching layers/filaments share identical curve entities, which makes
1624
+ # interface physical groups and meshing constraints reliable.
1625
+ all_rects = [(2, s) for s in inner]
1626
+ frag_conduct, _ = gmsh.model.occ.fragment(all_rects, [])
1627
+ gmsh.model.occ.synchronize()
1628
+
1629
+
1630
+ # Rebuild 'inner' from the fragment result (their tags can change)
1631
+ inner = [ent[1] for ent in frag_conduct]
1632
+
1633
+ # ------------------------------------
1634
+ # 3) Create the Air disk
1635
+ # ------------------------------------
1636
+ # Robust air centering:
1637
+ # compute the bbox of the *actual* conductor surfaces and center the air on that.
1638
+ xmin = float("inf")
1639
+ ymin = float("inf")
1640
+ xmax = float("-inf")
1641
+ ymax = float("-inf")
1642
+
1643
+ for s_tag in inner:
1644
+ bxmin, bymin, _, bxmax, bymax, _ = gmsh.model.getBoundingBox(2, int(s_tag))
1645
+ xmin = min(xmin, float(bxmin))
1646
+ ymin = min(ymin, float(bymin))
1647
+ xmax = max(xmax, float(bxmax))
1648
+ ymax = max(ymax, float(bymax))
1649
+
1650
+ if not (xmin < xmax and ymin < ymax):
1651
+ # Fallback (should not happen unless inner is empty / invalid)
1652
+ cx = float(getattr(self, "_geom_center_x", 0.0))
1653
+ cy = float(getattr(self, "_geom_center_y", 0.0))
1654
+ else:
1655
+ cx = 0.5 * (xmin + xmax)
1656
+ cy = 0.5 * (ymin + ymax)
1657
+
1658
+ disk = gmsh.model.occ.addDisk(cx, cy, 0.0, R, R)
1659
+
1660
+
1661
+ # Fragment(disk, inner) splits the disk into multiple surfaces:
1662
+ # one is the exterior air region, others may be trapped pockets.
1663
+ # The largest disk-derived surface is taken as the physical Air domain.
1664
+ out_dimtags, out_map = gmsh.model.occ.fragment(
1665
+ [(2, disk)],
1666
+ [(2, s) for s in inner],
1667
+ )
1668
+ gmsh.model.occ.synchronize()
1669
+
1670
+
1671
+ # out_map[0] corresponds to the object list = [(2, disk)]
1672
+ disk_pieces = out_map[0] if out_map and out_map[0] else []
1673
+ if not disk_pieces:
1674
+ raise RuntimeError("Air fragment produced no disk-derived pieces (unexpected).")
1675
+
1676
+ # Pick the largest disk-derived piece as the Air region
1677
+ air_ent = max(
1678
+ disk_pieces,
1679
+ key=lambda e: float(gmsh.model.occ.getMass(e[0], e[1])),
1680
+ )
1681
+ air_tag = int(air_ent[1])
1682
+
1683
+ self.layers["Air"] = SimpleNamespace(surface_tag=air_tag)
1684
+
1685
+
1686
+
1687
+ # Topology + physical groups
1688
+
1689
+ def _refresh_all(self) -> None:
1690
+ """
1691
+ After OCC cleanup, update curve/point lists and edge classification
1692
+ for all rectangular layers that implement refresh_topology().
1693
+ """
1694
+ for L in self.layers.values():
1695
+ if hasattr(L, "refresh_topology"):
1696
+ L.refresh_topology() # type: ignore[call-arg]
1697
+
1698
+
1699
+ def _create_all_physical_groups(self, name_prefix: str = "") -> None:
1700
+ """
1701
+ Create all material + interface + air physical groups.
1702
+
1703
+ If name_prefix != "":
1704
+ all physical group names become: "<name_prefix>_<OriginalName>"
1705
+ and spaces in the original name are replaced by underscores.
1706
+ """
1707
+ prefix = str(name_prefix).strip()
1708
+
1709
+ # Embedded mode: do not wipe physical groups by default, because other builders
1710
+ # may have already created groups in the same Gmsh model.
1711
+ # Use name_prefix to avoid naming collisions across repeated CC instances.
1712
+ if self._wipe_physical_groups:
1713
+ gmsh.model.removePhysicalGroups()
1714
+ elif not prefix:
1715
+ raise ValueError(
1716
+ "name_prefix must be non-empty when wipe_physical_groups=False "
1717
+ "(otherwise physical group names will collide)."
1718
+ )
1719
+
1720
+
1721
+
1722
+ prefix = str(name_prefix).strip()
1723
+
1724
+ def _pref(name: str) -> str:
1725
+ if not prefix:
1726
+ return name
1727
+ safe = str(name).replace(" ", "_")
1728
+ return f"{prefix}_{safe}"
1729
+
1730
+ # Layer physical groups (2D + edges)
1731
+ if "HTS" in self.layers:
1732
+ self.layers["HTS"].create_physical_groups(_pref("HTS")) # type: ignore[arg-type]
1733
+
1734
+ if "Substrate" in self.layers:
1735
+ self.layers["Substrate"].create_physical_groups(_pref("Substrate")) # type: ignore[arg-type]
1736
+
1737
+ if "SilverTop" in self.layers:
1738
+ self.layers["SilverTop"].create_physical_groups(_pref("SilverTop")) # type: ignore[arg-type]
1739
+
1740
+ if "SilverBottom" in self.layers:
1741
+ self.layers["SilverBottom"].create_physical_groups(_pref("SilverBottom")) # type: ignore[arg-type]
1742
+
1743
+ if "CopperBottom" in self.layers:
1744
+ self.layers["CopperBottom"].create_physical_groups(_pref("CopperBottom")) # type: ignore[arg-type]
1745
+
1746
+ if "CopperTop" in self.layers:
1747
+ self.layers["CopperTop"].create_physical_groups(_pref("CopperTop")) # type: ignore[arg-type]
1748
+
1749
+ if "CopperLeft" in self.layers:
1750
+ self.layers["CopperLeft"].create_physical_groups(_pref("CopperLeft")) # type: ignore[arg-type]
1751
+
1752
+ if "CopperRight" in self.layers:
1753
+ self.layers["CopperRight"].create_physical_groups(_pref("CopperRight")) # type: ignore[arg-type]
1754
+
1755
+ # Interfaces (1D)
1756
+ # Interface convention:
1757
+ # We define each interface using the boundary curves of ONE of the adjacent
1758
+ # layers (typically the "lower" layer's upper edge or vice versa). Because we
1759
+ # fragment the stack earlier, both sides should reference the same curve IDs.
1760
+ # Interfaces (1D)
1761
+
1762
+ if "Substrate" in self.layers and "SilverBottom" in self.layers:
1763
+ iface = self.layers["Substrate"].edge_tags["Lower"] # type: ignore[index]
1764
+ pg_if = gmsh.model.addPhysicalGroup(1, iface)
1765
+ gmsh.model.setPhysicalName(1, pg_if, _pref("Substrate_SilverBottom_Interface"))
1766
+
1767
+ if "SilverBottom" in self.layers and "CopperBottom" in self.layers:
1768
+ iface = self.layers["SilverBottom"].edge_tags["Lower"] # type: ignore[index]
1769
+ pg_if = gmsh.model.addPhysicalGroup(1, iface)
1770
+ gmsh.model.setPhysicalName(1, pg_if, _pref("SilverBottom_CopperBottom_Interface"))
1771
+
1772
+ if (
1773
+ "Substrate" in self.layers
1774
+ and "CopperBottom" in self.layers
1775
+ and "SilverBottom" not in self.layers
1776
+ ):
1777
+ iface = self.layers["CopperBottom"].edge_tags["Upper"] # type: ignore[index]
1778
+ pg_if = gmsh.model.addPhysicalGroup(1, iface)
1779
+ gmsh.model.setPhysicalName(1, pg_if, _pref("Substrate_CopperBottom_Interface"))
1780
+
1781
+ if "HTS" in self.layers and "Substrate" in self.layers:
1782
+ iface = self.layers["Substrate"].edge_tags["Upper"] # type: ignore[index]
1783
+ pg_if = gmsh.model.addPhysicalGroup(1, iface)
1784
+ gmsh.model.setPhysicalName(1, pg_if, _pref("Buffer Layer"))
1785
+
1786
+ if "HTS" in self.layers and "SilverTop" in self.layers:
1787
+ iface = self.layers["HTS"].edge_tags["Upper"] # type: ignore[index]
1788
+ pg_if = gmsh.model.addPhysicalGroup(1, iface)
1789
+ gmsh.model.setPhysicalName(1, pg_if, _pref("HTS_Silver_Interface"))
1790
+
1791
+ if "SilverTop" in self.layers and "CopperTop" in self.layers:
1792
+ iface = self.layers["SilverTop"].edge_tags["Upper"] # type: ignore[index]
1793
+ pg_if = gmsh.model.addPhysicalGroup(1, iface)
1794
+ gmsh.model.setPhysicalName(1, pg_if, _pref("SilverTop_CopperTop_Interface"))
1795
+
1796
+ if (
1797
+ "HTS" in self.layers
1798
+ and "CopperTop" in self.layers
1799
+ and "SilverTop" not in self.layers
1800
+ ):
1801
+ iface = self.layers["HTS"].edge_tags["Upper"] # type: ignore[index]
1802
+ pg_if = gmsh.model.addPhysicalGroup(1, iface)
1803
+ gmsh.model.setPhysicalName(1, pg_if, _pref("HTS_CopperTop_Interface"))
1804
+
1805
+ if "CopperLeft" in self.layers and "HTS" in self.layers:
1806
+ iface = self.layers["HTS"].edge_tags["LeftEdge"] # type: ignore[index]
1807
+ pg_if = gmsh.model.addPhysicalGroup(1, iface)
1808
+ gmsh.model.setPhysicalName(1, pg_if, _pref("HTS_CopperLeft_Interface"))
1809
+
1810
+ if "CopperLeft" in self.layers and "Substrate" in self.layers:
1811
+ iface = self.layers["Substrate"].edge_tags["LeftEdge"] # type: ignore[index]
1812
+ pg_if = gmsh.model.addPhysicalGroup(1, iface)
1813
+ gmsh.model.setPhysicalName(1, pg_if, _pref("Substrate_CopperLeft_Interface"))
1814
+
1815
+ if "CopperRight" in self.layers and "HTS" in self.layers:
1816
+ iface = self.layers["HTS"].edge_tags["RightEdge"] # type: ignore[index]
1817
+ pg_if = gmsh.model.addPhysicalGroup(1, iface)
1818
+ gmsh.model.setPhysicalName(1, pg_if, _pref("HTS_CopperRight_Interface"))
1819
+
1820
+ if "CopperRight" in self.layers and "Substrate" in self.layers:
1821
+ iface = self.layers["Substrate"].edge_tags["RightEdge"] # type: ignore[index]
1822
+ pg_if = gmsh.model.addPhysicalGroup(1, iface)
1823
+ gmsh.model.setPhysicalName(1, pg_if, _pref("Substrate_CopperRight_Interface"))
1824
+
1825
+ # Air (surface + outer/inner boundary + inner boundary + one point for gauging)
1826
+ if "Air" in self.layers:
1827
+ air_tag = self.layers["Air"].surface_tag # type: ignore[attr-defined]
1828
+
1829
+ pg_s = gmsh.model.addPhysicalGroup(2, [air_tag])
1830
+ gmsh.model.setPhysicalName(2, pg_s, _pref("Air"))
1831
+
1832
+ b = gmsh.model.getBoundary([(2, air_tag)], oriented=False, recursive=False)
1833
+ cand = [c[1] for c in b]
1834
+
1835
+ outer_curves: List[int] = []
1836
+ for ctag in cand:
1837
+ try:
1838
+ _, up = gmsh.model.getAdjacencies(1, ctag)
1839
+ if len(up) == 1 and up[0] == air_tag:
1840
+ outer_curves.append(ctag)
1841
+ except Exception:
1842
+ pass
1843
+
1844
+ if not outer_curves:
1845
+ lengths = [(ctag, gmsh.model.occ.getMass(1, ctag)) for ctag in cand]
1846
+ max_len = max((L for _, L in lengths), default=0.0)
1847
+ if max_len > 0.0:
1848
+ outer_curves = [
1849
+ ctag for ctag, L in lengths
1850
+ if abs(L - max_len) <= 1e-9 * max_len
1851
+ ]
1852
+
1853
+ pg_c = gmsh.model.addPhysicalGroup(1, outer_curves)
1854
+ gmsh.model.setPhysicalName(1, pg_c, _pref("Air_Outer"))
1855
+
1856
+ inner_curves = [ctag for ctag in cand if ctag not in outer_curves]
1857
+ pg_c = gmsh.model.addPhysicalGroup(1, inner_curves)
1858
+ gmsh.model.setPhysicalName(1, pg_c, _pref("Air_Inner"))
1859
+
1860
+
1861
+ # One point on the air boundary for gauging the scalar magnetic potential phi
1862
+ adj = gmsh.model.getAdjacencies(1, outer_curves[0])
1863
+ point = adj[1][0] # downward, [1], to get dimension 0 and first element to grab a single point tag
1864
+ pg_c = gmsh.model.addPhysicalGroup(0, [point])
1865
+ gmsh.model.setPhysicalName(0, pg_c, _pref("Gauging_point"))
1866
+
1867
+
1868
+
1869
+ # Finalize
1870
+ def finalize_and_write(self, name_prefix: str = "") -> None:
1871
+ """
1872
+ Perform final OCC synchronize, refresh topology, recreate all
1873
+ physical groups, and write .brep and .xao.
1874
+ """
1875
+ gmsh.model.occ.removeAllDuplicates()
1876
+ gmsh.model.occ.synchronize()
1877
+
1878
+ # Refresh topology after final OCC operations.
1879
+ self._refresh_all()
1880
+
1881
+ # Create physical groups BEFORE writing .xao so they are embedded.
1882
+ if self._create_physical_groups:
1883
+ self._create_all_physical_groups(name_prefix=name_prefix)
1884
+
1885
+ # Only write files if requested (standalone default: True).
1886
+ if self._write_files:
1887
+ gmsh.write(self.model_file)
1888
+ gmsh.write(self.xao_file)
1889
+
1890
+ if self.fdm.run.launch_gui and self._create_model:
1891
+ self.gu.launch_interactive_GUI()
1892
+ elif self._clear_gmsh_on_finalize:
1893
+ gmsh.clear()
1894
+
1895
+
1896
+
1897
+
1898
+
1899
+ # Loading an existing geometry
1900
+ def load_geometry(self, gui: bool = False) -> None:
1901
+ """
1902
+ Load an existing .brep geometry and optionally launch the GUI.
1903
+ """
1904
+ gmsh.open(self.model_file)
1905
+ if gui:
1906
+ self.gu.launch_interactive_GUI()