@tscircuit/cli 0.1.2287 → 0.1.2288
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.
- package/dist/cli/main.js +3950 -7
- package/dist/cli/python/flux.py +99 -0
- package/dist/cli/python/mesh.py +485 -0
- package/dist/cli/python/mesh_multilayer.py +376 -0
- package/dist/cli/python/requirements.txt +4 -0
- package/dist/cli/python/sample.py +352 -0
- package/dist/cli/python/surface_sample.py +88 -0
- package/dist/lib/index.js +10 -6
- package/package.json +11 -7
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Integrate a specified copper cross-section from completed Palace fields."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import math
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from sample import sample_case
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def check_flux(case, specification):
|
|
13
|
+
# Reuse the audited importer at denser, explicitly specified sampling points.
|
|
14
|
+
# No FEM solve or source renormalization is changed by this quadrature check.
|
|
15
|
+
if (
|
|
16
|
+
not isinstance(specification["rows"], int)
|
|
17
|
+
or specification["rows"] < 1
|
|
18
|
+
or not math.isfinite(specification["expectedRealAmps"])
|
|
19
|
+
or specification["expectedRealAmps"] == 0
|
|
20
|
+
):
|
|
21
|
+
raise ValueError(
|
|
22
|
+
"Flux check requires positive rows and nonzero finite expected current"
|
|
23
|
+
)
|
|
24
|
+
spacing = (specification["yMax"] - specification["yMin"]) / specification["rows"]
|
|
25
|
+
if spacing <= 0 or not specification["xColumns"]:
|
|
26
|
+
raise ValueError("Flux quadrature requires positive spacing and x columns")
|
|
27
|
+
with tempfile.TemporaryDirectory(prefix="palace-flux-", dir=case) as directory:
|
|
28
|
+
temporary = Path(directory)
|
|
29
|
+
for name in [
|
|
30
|
+
"circuit.json",
|
|
31
|
+
"model.json",
|
|
32
|
+
"palace.json",
|
|
33
|
+
"palace.log",
|
|
34
|
+
"mesh.msh",
|
|
35
|
+
"postpro",
|
|
36
|
+
]:
|
|
37
|
+
(temporary / name).symlink_to(case / name)
|
|
38
|
+
grid = {
|
|
39
|
+
"columns": len(specification["xColumns"]),
|
|
40
|
+
"rows": specification["rows"],
|
|
41
|
+
"cellWidth": 1,
|
|
42
|
+
"cellHeight": spacing,
|
|
43
|
+
"points": [
|
|
44
|
+
{"x": x, "y": specification["yMin"] + (row + 0.5) * spacing}
|
|
45
|
+
for x in specification["xColumns"]
|
|
46
|
+
for row in range(specification["rows"])
|
|
47
|
+
],
|
|
48
|
+
}
|
|
49
|
+
(temporary / "sample-grid.json").write_text(json.dumps(grid))
|
|
50
|
+
sample_case(temporary)
|
|
51
|
+
reference = json.loads((temporary / "reference.json").read_text())
|
|
52
|
+
cross_sections = []
|
|
53
|
+
for x in specification["xColumns"]:
|
|
54
|
+
samples = [sample for sample in reference["samples"] if sample["x"] == x]
|
|
55
|
+
cross_sections.append(
|
|
56
|
+
{
|
|
57
|
+
"x": x,
|
|
58
|
+
"real": sum(
|
|
59
|
+
sample["sheetCurrentXReal"] * spacing for sample in samples
|
|
60
|
+
),
|
|
61
|
+
"imag": sum(
|
|
62
|
+
sample["sheetCurrentXImag"] * spacing for sample in samples
|
|
63
|
+
),
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
mean = sum(
|
|
67
|
+
complex(section["real"], section["imag"]) for section in cross_sections
|
|
68
|
+
) / len(cross_sections)
|
|
69
|
+
expected = specification["expectedRealAmps"]
|
|
70
|
+
report = {
|
|
71
|
+
"method": "midpoint integration in y, 5-point Gauss integration through copper thickness",
|
|
72
|
+
"specification": specification,
|
|
73
|
+
"sampleSpacingMm": spacing,
|
|
74
|
+
"frequencyHz": reference["frequencyHz"],
|
|
75
|
+
"femOrder": reference["femOrder"],
|
|
76
|
+
"provenance": reference["provenance"],
|
|
77
|
+
"crossSections": cross_sections,
|
|
78
|
+
"meanRealAmps": mean.real,
|
|
79
|
+
"meanImagAmps": mean.imag,
|
|
80
|
+
"relativeComplexBalanceError": abs(mean - expected) / abs(expected),
|
|
81
|
+
}
|
|
82
|
+
(case / "flux-check.json").write_text(
|
|
83
|
+
json.dumps(report, indent=2, allow_nan=False) + "\n"
|
|
84
|
+
)
|
|
85
|
+
print(json.dumps(report, indent=2))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def main():
|
|
89
|
+
parser = argparse.ArgumentParser()
|
|
90
|
+
parser.add_argument("case")
|
|
91
|
+
parser.add_argument("specification")
|
|
92
|
+
args = parser.parse_args()
|
|
93
|
+
check_flux(
|
|
94
|
+
Path(args.case).resolve(), json.loads(Path(args.specification).read_text())
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
if __name__ == "__main__":
|
|
99
|
+
main()
|
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
"""Generate a conforming 3D Gmsh mesh. All dimensions are millimetres.
|
|
2
|
+
No field solver is implemented here: Palace solves the driven Maxwell system.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import gmsh
|
|
10
|
+
from shapely.geometry import LineString, Polygon, Point
|
|
11
|
+
from shapely.ops import unary_union
|
|
12
|
+
from shapely.geometry.polygon import orient
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def points(outline):
|
|
16
|
+
return [(p["x"], p["y"]) for p in outline]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def add_ring(coordinates, z):
|
|
20
|
+
vertices = [gmsh.model.occ.addPoint(x, y, z) for x, y in list(coordinates)[:-1]]
|
|
21
|
+
edges = [
|
|
22
|
+
gmsh.model.occ.addLine(vertices[i], vertices[(i + 1) % len(vertices)])
|
|
23
|
+
for i in range(len(vertices))
|
|
24
|
+
]
|
|
25
|
+
return gmsh.model.occ.addCurveLoop(edges)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def add_polygon(polygon, z):
|
|
29
|
+
# OCC assigns holes by ring order and expects matching wire winding here.
|
|
30
|
+
# Shapely gives interior rings the opposite winding; passing those unchanged
|
|
31
|
+
# adds their area and creates overlapping interfaces in drilled boards.
|
|
32
|
+
polygon = orient(polygon, sign=1)
|
|
33
|
+
rings = [add_ring(polygon.exterior.coords, z)]
|
|
34
|
+
rings.extend(add_ring(list(ring.coords)[::-1], z) for ring in polygon.interiors)
|
|
35
|
+
return (2, gmsh.model.occ.addPlaneSurface(rings))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def polygons(shape):
|
|
39
|
+
if shape.geom_type == "Polygon":
|
|
40
|
+
return [shape]
|
|
41
|
+
if shape.geom_type == "MultiPolygon":
|
|
42
|
+
return list(shape.geoms)
|
|
43
|
+
raise ValueError("Expected nonempty copper polygons")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def via_shapes(model):
|
|
47
|
+
vias = model.get("groundVias", [])
|
|
48
|
+
outlines = model["geometry"]["cutouts"][-len(vias):] if vias else []
|
|
49
|
+
result = []
|
|
50
|
+
for via, outline in zip(vias, outlines):
|
|
51
|
+
hole = Polygon(points(outline))
|
|
52
|
+
ratio = (via["holeDiameter"] / 2 + via["platingThickness"]) / (via["holeDiameter"] / 2)
|
|
53
|
+
outer = Polygon([(via["x"] + (x - via["x"]) * ratio,
|
|
54
|
+
via["y"] + (y - via["y"]) * ratio)
|
|
55
|
+
for x, y in hole.exterior.coords])
|
|
56
|
+
result.append((hole, outer))
|
|
57
|
+
return result
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def port_definition(model, index, legacy):
|
|
61
|
+
if model.get("ports"):
|
|
62
|
+
return model["ports"][index]
|
|
63
|
+
endpoint, _ = legacy
|
|
64
|
+
return {"signal": endpoint, "reference": endpoint,
|
|
65
|
+
"referenceLayer": "bottom", "resistance": model["portResistance"]}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def port_direction(model, port):
|
|
69
|
+
dx = port["reference"]["x"] - port["signal"]["x"]
|
|
70
|
+
dy = port["reference"]["y"] - port["signal"]["y"]
|
|
71
|
+
dz = port["referenceZ"] - port["signalZ"] if model.get("multilayer") else (-model["layerSeparation"] if port["referenceLayer"] == "bottom" else 0)
|
|
72
|
+
length = (dx * dx + dy * dy + dz * dz) ** 0.5
|
|
73
|
+
if length < 1e-9:
|
|
74
|
+
raise ValueError("Signal and reference terminals coincide")
|
|
75
|
+
return [dx / length, dy / length, dz / length]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def port_corners(model, port, legacy):
|
|
79
|
+
endpoint, neighbor = legacy
|
|
80
|
+
signal, reference = port["signal"], port["reference"]
|
|
81
|
+
dx, dy = reference["x"] - signal["x"], reference["y"] - signal["y"]
|
|
82
|
+
if abs(dx) + abs(dy) < 1e-9:
|
|
83
|
+
dx, dy = neighbor["x"] - endpoint["x"], neighbor["y"] - endpoint["y"]
|
|
84
|
+
length = (dx * dx + dy * dy) ** 0.5
|
|
85
|
+
tx, ty = -dy / length * model["portWidth"] / 2, dx / length * model["portWidth"] / 2
|
|
86
|
+
z = model["layerSeparation"]
|
|
87
|
+
rz = z if port["referenceLayer"] == "top" else 0
|
|
88
|
+
return [(signal["x"] - tx, signal["y"] - ty, z),
|
|
89
|
+
(signal["x"] + tx, signal["y"] + ty, z),
|
|
90
|
+
(reference["x"] + tx, reference["y"] + ty, rz),
|
|
91
|
+
(reference["x"] - tx, reference["y"] - ty, rz)]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def add_port(port, model, legacy, signals, ground, ground_pads, board):
|
|
95
|
+
corners = port_corners(model, port, legacy)
|
|
96
|
+
signal_contact = LineString([corner[:2] for corner in corners[:2]])
|
|
97
|
+
reference_contact = LineString([corner[:2] for corner in corners[2:]])
|
|
98
|
+
if not signals.covers(signal_contact):
|
|
99
|
+
raise ValueError("Port aperture leaves signal copper")
|
|
100
|
+
if port["referenceLayer"] == "top":
|
|
101
|
+
if not ground_pads.covers(reference_contact):
|
|
102
|
+
raise ValueError("Top reference aperture leaves its ground pad")
|
|
103
|
+
patch = Polygon([corner[:2] for corner in corners]).difference(unary_union([signals, ground_pads]))
|
|
104
|
+
if patch.geom_type != "Polygon" or patch.is_empty or len(patch.interiors):
|
|
105
|
+
raise ValueError("Top port needs one clear gap between signal and reference pads")
|
|
106
|
+
if not board.covers(patch):
|
|
107
|
+
raise ValueError("Port crosses a drilled hole, cutout or board edge")
|
|
108
|
+
# Each end of this aperture must touch the intended conductor. A signal
|
|
109
|
+
# crossed along the gap splits it and is rejected above.
|
|
110
|
+
if patch.boundary.intersection(signals.boundary).length < model["portWidth"] * 0.9 or patch.boundary.intersection(ground_pads.boundary).length < model["portWidth"] * 0.9:
|
|
111
|
+
raise ValueError("Port gap does not contact both terminals")
|
|
112
|
+
return add_polygon(patch, model["layerSeparation"])
|
|
113
|
+
if not ground.covers(reference_contact):
|
|
114
|
+
raise ValueError("Port aperture leaves ground copper")
|
|
115
|
+
footprint = Polygon([corner[:2] for corner in corners])
|
|
116
|
+
if footprint.area > 1e-12 and not board.covers(footprint):
|
|
117
|
+
raise ValueError("Port crosses a drilled hole, cutout or board edge")
|
|
118
|
+
vertices = [gmsh.model.occ.addPoint(*corner) for corner in corners]
|
|
119
|
+
edges = [gmsh.model.occ.addLine(vertices[i], vertices[(i + 1) % 4]) for i in range(4)]
|
|
120
|
+
return (2, gmsh.model.occ.addPlaneSurface([gmsh.model.occ.addCurveLoop(edges)]))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def generate_mesh(model, destination):
|
|
124
|
+
if model.get("multilayer"):
|
|
125
|
+
from mesh_multilayer import generate_multilayer_mesh
|
|
126
|
+
return generate_multilayer_mesh(model, destination)
|
|
127
|
+
geometry = model["geometry"]
|
|
128
|
+
surface_impedance = model["copperModel"] == "surface_impedance_copper"
|
|
129
|
+
board = Polygon(points(geometry["boardOutline"]))
|
|
130
|
+
for cutout in geometry["cutouts"]:
|
|
131
|
+
board = board.difference(Polygon(points(cutout)))
|
|
132
|
+
ground = unary_union(
|
|
133
|
+
[
|
|
134
|
+
Polygon(points(region["outer"]), [points(hole) for hole in region["holes"]])
|
|
135
|
+
for region in geometry["groundRegions"]
|
|
136
|
+
]
|
|
137
|
+
).intersection(board)
|
|
138
|
+
if not board.is_valid or not ground.is_valid:
|
|
139
|
+
raise ValueError("Invalid PCB/ground polygon")
|
|
140
|
+
pads = [Polygon(points(outline)) for outline in model["topPads"]]
|
|
141
|
+
ground_pad_shapes = [Polygon(points(outline)) for outline in model.get("topGroundPads", [])]
|
|
142
|
+
ground_pads = unary_union(ground_pad_shapes)
|
|
143
|
+
undrilled_board = Polygon(points(geometry["boardOutline"]))
|
|
144
|
+
for pad in ground_pad_shapes:
|
|
145
|
+
if not undrilled_board.covers(pad):
|
|
146
|
+
raise ValueError("Ground pad leaves substrate")
|
|
147
|
+
signals = []
|
|
148
|
+
ports = []
|
|
149
|
+
for signal in geometry["signals"]:
|
|
150
|
+
route = [
|
|
151
|
+
point
|
|
152
|
+
for index, point in enumerate(signal["route"])
|
|
153
|
+
if index == 0
|
|
154
|
+
or (point["x"], point["y"])
|
|
155
|
+
!= (signal["route"][index - 1]["x"], signal["route"][index - 1]["y"])
|
|
156
|
+
]
|
|
157
|
+
strips = [
|
|
158
|
+
LineString([(start["x"], start["y"]), (end["x"], end["y"])]).buffer(
|
|
159
|
+
start["width"] / 2, cap_style="square", join_style="mitre"
|
|
160
|
+
)
|
|
161
|
+
for start, end in zip(route, route[1:])
|
|
162
|
+
]
|
|
163
|
+
trace = unary_union(strips)
|
|
164
|
+
connected_pads = [pad for pad in pads if pad.intersects(trace)]
|
|
165
|
+
trace = unary_union([trace, *connected_pads])
|
|
166
|
+
if not board.covers(trace):
|
|
167
|
+
raise ValueError("Signal copper leaves the substrate")
|
|
168
|
+
for other in signals:
|
|
169
|
+
if other.intersects(trace):
|
|
170
|
+
raise ValueError("Top signal copper overlaps another signal")
|
|
171
|
+
signals.append(trace)
|
|
172
|
+
ports.extend([(route[0], route[1]), (route[-1], route[-2])])
|
|
173
|
+
if any(not any(pad.intersects(signal) for signal in signals) for pad in pads):
|
|
174
|
+
raise ValueError(
|
|
175
|
+
"Unconnected top SMT pads are not supported by this reference model"
|
|
176
|
+
)
|
|
177
|
+
if not ground_pads.is_empty and unary_union(signals).intersects(ground_pads):
|
|
178
|
+
raise ValueError("Signal and ground pad copper overlap")
|
|
179
|
+
gmsh.initialize()
|
|
180
|
+
try:
|
|
181
|
+
gmsh.model.add("return-current")
|
|
182
|
+
height = model["layerSeparation"]
|
|
183
|
+
substrate_shape = board
|
|
184
|
+
for _, outer in via_shapes(model):
|
|
185
|
+
substrate_shape = substrate_shape.difference(outer)
|
|
186
|
+
substrate_surfaces = [add_polygon(polygon, 0) for polygon in polygons(substrate_shape)]
|
|
187
|
+
substrate = [
|
|
188
|
+
entity
|
|
189
|
+
for surface in substrate_surfaces
|
|
190
|
+
for entity in gmsh.model.occ.extrude([surface], 0, 0, height)
|
|
191
|
+
if entity[0] == 3
|
|
192
|
+
]
|
|
193
|
+
min_x, min_y, max_x, max_y = board.bounds
|
|
194
|
+
padding = model["airPadding"]
|
|
195
|
+
air = (
|
|
196
|
+
3,
|
|
197
|
+
gmsh.model.occ.addBox(
|
|
198
|
+
min_x - padding,
|
|
199
|
+
min_y - padding,
|
|
200
|
+
-padding,
|
|
201
|
+
max_x - min_x + 2 * padding,
|
|
202
|
+
max_y - min_y + 2 * padding,
|
|
203
|
+
height + 2 * padding,
|
|
204
|
+
),
|
|
205
|
+
)
|
|
206
|
+
ground_surfaces = [add_polygon(polygon, 0) for polygon in polygons(ground)]
|
|
207
|
+
top_ground_surfaces = [
|
|
208
|
+
add_polygon(polygon, height)
|
|
209
|
+
for pad in ground_pad_shapes
|
|
210
|
+
for polygon in polygons(pad.intersection(board))
|
|
211
|
+
]
|
|
212
|
+
signal_surfaces = [
|
|
213
|
+
add_polygon(polygon, height)
|
|
214
|
+
for signal in signals
|
|
215
|
+
for polygon in polygons(signal)
|
|
216
|
+
]
|
|
217
|
+
copper = [
|
|
218
|
+
entity
|
|
219
|
+
for surface in ground_surfaces
|
|
220
|
+
for entity in gmsh.model.occ.extrude(
|
|
221
|
+
[surface], 0, 0, -model["copperThickness"]
|
|
222
|
+
)
|
|
223
|
+
if entity[0] == 3
|
|
224
|
+
]
|
|
225
|
+
copper += [
|
|
226
|
+
entity
|
|
227
|
+
for surface in [*signal_surfaces, *top_ground_surfaces]
|
|
228
|
+
for entity in gmsh.model.occ.extrude(
|
|
229
|
+
[surface], 0, 0, model["copperThickness"]
|
|
230
|
+
)
|
|
231
|
+
if entity[0] == 3
|
|
232
|
+
]
|
|
233
|
+
for hole, outer in via_shapes(model):
|
|
234
|
+
annulus = add_polygon(outer.difference(hole), -model["copperThickness"])
|
|
235
|
+
copper.extend(entity for entity in gmsh.model.occ.extrude([annulus], 0, 0,
|
|
236
|
+
height + 2 * model["copperThickness"]) if entity[0] == 3)
|
|
237
|
+
if model.get("groundVias") or surface_impedance:
|
|
238
|
+
copper, _ = gmsh.model.occ.fuse(copper[:1], copper[1:])
|
|
239
|
+
port_surfaces = [add_port(port_definition(model, index, legacy), model, legacy,
|
|
240
|
+
unary_union(signals), ground, ground_pads, board)
|
|
241
|
+
for index, legacy in enumerate(ports)]
|
|
242
|
+
if model.get("groundVias") or surface_impedance:
|
|
243
|
+
# Volumes already provide these copper interfaces. Coplanar
|
|
244
|
+
# marking sheets with different via-hole rings can duplicate facets.
|
|
245
|
+
ground_surfaces = []
|
|
246
|
+
top_ground_surfaces = []
|
|
247
|
+
signal_surfaces = []
|
|
248
|
+
tools = [
|
|
249
|
+
*substrate,
|
|
250
|
+
*copper,
|
|
251
|
+
*ground_surfaces,
|
|
252
|
+
*top_ground_surfaces,
|
|
253
|
+
*signal_surfaces,
|
|
254
|
+
*port_surfaces,
|
|
255
|
+
]
|
|
256
|
+
_, mapping = gmsh.model.occ.fragment([air], tools)
|
|
257
|
+
gmsh.model.occ.synchronize()
|
|
258
|
+
substrate_tags = {
|
|
259
|
+
tag
|
|
260
|
+
for descendants in mapping[1 : 1 + len(substrate)]
|
|
261
|
+
for dim, tag in descendants
|
|
262
|
+
if dim == 3
|
|
263
|
+
}
|
|
264
|
+
copper_tags = {
|
|
265
|
+
tag
|
|
266
|
+
for descendants in mapping[
|
|
267
|
+
1 + len(substrate) : 1 + len(substrate) + len(copper)
|
|
268
|
+
]
|
|
269
|
+
for dim, tag in descendants
|
|
270
|
+
if dim == 3
|
|
271
|
+
}
|
|
272
|
+
substrate_tags -= copper_tags
|
|
273
|
+
air_tags = (
|
|
274
|
+
{tag for dim, tag in mapping[0] if dim == 3} - substrate_tags - copper_tags
|
|
275
|
+
)
|
|
276
|
+
if not surface_impedance:
|
|
277
|
+
gmsh.model.addPhysicalGroup(3, sorted(copper_tags), 3, "copper")
|
|
278
|
+
gmsh.model.addPhysicalGroup(3, sorted(air_tags), 1, "air")
|
|
279
|
+
gmsh.model.addPhysicalGroup(3, sorted(substrate_tags), 2, "substrate")
|
|
280
|
+
offset = 1 + len(substrate) + len(copper)
|
|
281
|
+
ground_tags = sorted(
|
|
282
|
+
{
|
|
283
|
+
tag
|
|
284
|
+
for descendants in mapping[offset : offset + len(ground_surfaces) + len(top_ground_surfaces)]
|
|
285
|
+
for dim, tag in descendants
|
|
286
|
+
if dim == 2
|
|
287
|
+
}
|
|
288
|
+
)
|
|
289
|
+
offset += len(ground_surfaces) + len(top_ground_surfaces)
|
|
290
|
+
signal_tags = sorted(
|
|
291
|
+
{
|
|
292
|
+
tag
|
|
293
|
+
for descendants in mapping[offset : offset + len(signal_surfaces)]
|
|
294
|
+
for dim, tag in descendants
|
|
295
|
+
if dim == 2
|
|
296
|
+
}
|
|
297
|
+
)
|
|
298
|
+
offset += len(signal_surfaces)
|
|
299
|
+
if model.get("groundVias") or surface_impedance:
|
|
300
|
+
copper_boundaries = {tag for dim, tag in gmsh.model.getBoundary([(3, tag) for tag in copper_tags], combined=False, oriented=False) if dim == 2}
|
|
301
|
+
if surface_impedance:
|
|
302
|
+
exterior = {tag for dim, tag in gmsh.model.getBoundary([(3, tag) for tag in copper_tags], combined=True, oriented=False) if dim == 2}
|
|
303
|
+
active = {tag for dim, tag in gmsh.model.getBoundary([(3, tag) for tag in air_tags | substrate_tags], combined=False, oriented=False) if dim == 2}
|
|
304
|
+
copper_boundaries = exterior & active
|
|
305
|
+
if not copper_boundaries:
|
|
306
|
+
raise ValueError("Surface impedance has no exposed conductor interfaces")
|
|
307
|
+
for tag in sorted(copper_boundaries):
|
|
308
|
+
x, y, z = gmsh.model.occ.getCenterOfMass(2, tag)
|
|
309
|
+
if ((height - 1e-7 <= z <= height + model["copperThickness"] + 1e-7) if surface_impedance else abs(z - height) < 1e-7) and unary_union(signals).covers(Point(x, y)):
|
|
310
|
+
signal_tags.append(tag)
|
|
311
|
+
else:
|
|
312
|
+
ground_tags.append(tag)
|
|
313
|
+
gmsh.model.addPhysicalGroup(2, ground_tags, 11, "ground")
|
|
314
|
+
if signal_tags:
|
|
315
|
+
gmsh.model.addPhysicalGroup(2, signal_tags, 12, "signals")
|
|
316
|
+
port_tags = []
|
|
317
|
+
for index, descendants in enumerate(mapping[offset:]):
|
|
318
|
+
tags = [tag for dim, tag in descendants if dim == 2]
|
|
319
|
+
if not tags or set(tags).intersection(port_tags):
|
|
320
|
+
raise ValueError("Lumped port apertures overlap or have no meshed surface")
|
|
321
|
+
gmsh.model.addPhysicalGroup(2, tags, 21 + index, f"port-{index + 1}")
|
|
322
|
+
port_tags.extend(tags)
|
|
323
|
+
if surface_impedance and set(port_tags).intersection(ground_tags + signal_tags):
|
|
324
|
+
raise ValueError("Lumped port aperture overlaps a conductor impedance face")
|
|
325
|
+
outer = [
|
|
326
|
+
tag
|
|
327
|
+
for dim, tag in gmsh.model.getBoundary(
|
|
328
|
+
[(3, tag) for tag in sorted(air_tags | substrate_tags | copper_tags)],
|
|
329
|
+
combined=True,
|
|
330
|
+
oriented=False,
|
|
331
|
+
)
|
|
332
|
+
if dim == 2
|
|
333
|
+
]
|
|
334
|
+
gmsh.model.addPhysicalGroup(2, outer, 13, "outer-air")
|
|
335
|
+
copper_volume = sum(gmsh.model.occ.getMass(3, tag) for tag in copper_tags)
|
|
336
|
+
if surface_impedance:
|
|
337
|
+
# Preserve all conductor interfaces and their physical attributes;
|
|
338
|
+
# remove only Cu volume entities from the field-solve domain.
|
|
339
|
+
gmsh.model.occ.remove([(3, tag) for tag in copper_tags], recursive=False)
|
|
340
|
+
gmsh.model.occ.synchronize()
|
|
341
|
+
copper_edges = sorted(
|
|
342
|
+
{
|
|
343
|
+
tag
|
|
344
|
+
for dim, tag in gmsh.model.getBoundary(
|
|
345
|
+
[(2, tag) for tag in [*ground_tags, *signal_tags, *port_tags]],
|
|
346
|
+
combined=False,
|
|
347
|
+
oriented=False,
|
|
348
|
+
)
|
|
349
|
+
if dim == 1
|
|
350
|
+
}
|
|
351
|
+
)
|
|
352
|
+
distance = gmsh.model.mesh.field.add("Distance")
|
|
353
|
+
gmsh.model.mesh.field.setNumbers(distance, "CurvesList", copper_edges)
|
|
354
|
+
gmsh.model.mesh.field.setNumber(distance, "Sampling", 100)
|
|
355
|
+
threshold = gmsh.model.mesh.field.add("Threshold")
|
|
356
|
+
gmsh.model.mesh.field.setNumber(threshold, "InField", distance)
|
|
357
|
+
gmsh.model.mesh.field.setNumber(
|
|
358
|
+
threshold, "SizeMin", min(model["meshSize"] / 3, model["portWidth"] / 2)
|
|
359
|
+
)
|
|
360
|
+
gmsh.model.mesh.field.setNumber(threshold, "SizeMax", model["meshSize"])
|
|
361
|
+
gmsh.model.mesh.field.setNumber(threshold, "DistMin", 0.05)
|
|
362
|
+
gmsh.model.mesh.field.setNumber(threshold, "DistMax", 1.5)
|
|
363
|
+
gmsh.model.mesh.field.setAsBackgroundMesh(threshold)
|
|
364
|
+
gmsh.option.setNumber("Mesh.MeshSizeExtendFromBoundary", 0)
|
|
365
|
+
gmsh.option.setNumber("Mesh.MeshSizeFromPoints", 0)
|
|
366
|
+
gmsh.option.setNumber("Mesh.MeshSizeFromCurvature", 0)
|
|
367
|
+
gmsh.option.setNumber("Mesh.Algorithm3D", 1)
|
|
368
|
+
gmsh.option.setNumber("General.NumThreads", 1)
|
|
369
|
+
gmsh.option.setNumber("Mesh.MshFileVersion", 2.2)
|
|
370
|
+
gmsh.model.mesh.generate(3)
|
|
371
|
+
gmsh.write(str(destination / "mesh.msh"))
|
|
372
|
+
summary = {
|
|
373
|
+
"nodes": len(gmsh.model.mesh.getNodes()[0]),
|
|
374
|
+
"tetrahedra": sum(len(tags) for tags in gmsh.model.mesh.getElements(3)[1]),
|
|
375
|
+
"groundAttribute": 11,
|
|
376
|
+
"signalAttribute": 12,
|
|
377
|
+
"portAttributes": list(range(21, 21 + len(ports))),
|
|
378
|
+
"copperSolidVolumeMm3": copper_volume,
|
|
379
|
+
}
|
|
380
|
+
if surface_impedance:
|
|
381
|
+
summary["copperModel"] = model["copperModel"]
|
|
382
|
+
summary["conductivityBoundaryAttributes"] = [11, 12]
|
|
383
|
+
summary["surfaceImpedance"] = model["surfaceImpedance"]
|
|
384
|
+
(destination / "mesh-summary.json").write_text(
|
|
385
|
+
json.dumps(summary, indent=2) + "\n"
|
|
386
|
+
)
|
|
387
|
+
finally:
|
|
388
|
+
gmsh.finalize()
|
|
389
|
+
return len(ports)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def create_configuration(model, port_count):
|
|
393
|
+
ports = [
|
|
394
|
+
{
|
|
395
|
+
"Index": index + 1,
|
|
396
|
+
"Attributes": [21 + index],
|
|
397
|
+
"Direction": port_direction(model, model["ports"][index]) if model.get("ports") else "-Z",
|
|
398
|
+
"R": model["ports"][index]["resistance"] if model.get("ports") else model["portResistance"],
|
|
399
|
+
**({"Excitation": index + 1} if index % 2 == 0 else {}),
|
|
400
|
+
}
|
|
401
|
+
for index in range(port_count)
|
|
402
|
+
]
|
|
403
|
+
configuration = {
|
|
404
|
+
"Problem": {"Type": "Driven", "Verbose": 2, "Output": "postpro"},
|
|
405
|
+
"Model": {
|
|
406
|
+
"Mesh": "mesh.msh",
|
|
407
|
+
"L0": 1e-3,
|
|
408
|
+
"Lc": 1.0,
|
|
409
|
+
"CrackInternalBoundaryElements": False,
|
|
410
|
+
},
|
|
411
|
+
"Domains": {
|
|
412
|
+
"Materials": [
|
|
413
|
+
{"Attributes": [1], "Permittivity": 1.0, "Permeability": 1.0},
|
|
414
|
+
{
|
|
415
|
+
"Attributes": [2],
|
|
416
|
+
"Permittivity": model["substratePermittivity"],
|
|
417
|
+
"Permeability": 1.0,
|
|
418
|
+
"LossTan": model["substrateLossTangent"],
|
|
419
|
+
},
|
|
420
|
+
{
|
|
421
|
+
"Attributes": [3],
|
|
422
|
+
"Permittivity": 1.0,
|
|
423
|
+
"Permeability": 1.0,
|
|
424
|
+
"Conductivity": model["copperConductivity"],
|
|
425
|
+
},
|
|
426
|
+
]
|
|
427
|
+
},
|
|
428
|
+
"Boundaries": {
|
|
429
|
+
"Absorbing": {"Attributes": [13], "Order": 1},
|
|
430
|
+
"LumpedPort": ports,
|
|
431
|
+
},
|
|
432
|
+
"Solver": {
|
|
433
|
+
"Order": model["order"],
|
|
434
|
+
"Device": "CPU",
|
|
435
|
+
"Driven": {
|
|
436
|
+
"Samples": [
|
|
437
|
+
{
|
|
438
|
+
"Type": "Point",
|
|
439
|
+
"Freq": [model["frequencyHz"] / 1e9],
|
|
440
|
+
"SaveStep": 1,
|
|
441
|
+
}
|
|
442
|
+
]
|
|
443
|
+
},
|
|
444
|
+
"Linear": {
|
|
445
|
+
"Type": "SuperLU",
|
|
446
|
+
"KSPType": "GMRES",
|
|
447
|
+
"Tol": 1e-9,
|
|
448
|
+
"MaxIts": 200,
|
|
449
|
+
"ComplexCoarseSolve": True,
|
|
450
|
+
"PCMatShifted": False,
|
|
451
|
+
},
|
|
452
|
+
},
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if model.get("multilayer"):
|
|
456
|
+
configuration["Domains"]["Materials"] = [
|
|
457
|
+
configuration["Domains"]["Materials"][0],
|
|
458
|
+
configuration["Domains"]["Materials"][-1],
|
|
459
|
+
*[{"Attributes": [layer["attribute"]], "Permittivity": layer["dielectricConstant"],
|
|
460
|
+
"Permeability": 1.0, "LossTan": layer["lossTangent"]}
|
|
461
|
+
for layer in model["multilayer"]["stackup"]["dielectrics"]]
|
|
462
|
+
]
|
|
463
|
+
if model["copperModel"] == "surface_impedance_copper":
|
|
464
|
+
configuration["Domains"]["Materials"] = configuration["Domains"]["Materials"][:2]
|
|
465
|
+
configuration["Boundaries"]["Conductivity"] = [{
|
|
466
|
+
"Attributes": [11, 12], "Conductivity": model["copperConductivity"],
|
|
467
|
+
"Permeability": 1.0, "External": True,
|
|
468
|
+
}]
|
|
469
|
+
return configuration
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def main():
|
|
473
|
+
parser = argparse.ArgumentParser()
|
|
474
|
+
parser.add_argument("model")
|
|
475
|
+
args = parser.parse_args()
|
|
476
|
+
source = Path(args.model).resolve()
|
|
477
|
+
model = json.loads(source.read_text())
|
|
478
|
+
count = generate_mesh(model, source.parent)
|
|
479
|
+
(source.parent / "palace.json").write_text(
|
|
480
|
+
json.dumps(create_configuration(model, count), indent=2) + "\n"
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
if __name__ == "__main__":
|
|
485
|
+
main()
|