emerge 0.4.6__py3-none-any.whl → 0.4.8__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.

Potentially problematic release.


This version of emerge might be problematic. Click here for more details.

Files changed (80) hide show
  1. emerge/__init__.py +54 -0
  2. emerge/__main__.py +5 -0
  3. emerge/_emerge/__init__.py +42 -0
  4. emerge/_emerge/bc.py +197 -0
  5. emerge/_emerge/coord.py +119 -0
  6. emerge/_emerge/cs.py +523 -0
  7. emerge/_emerge/dataset.py +36 -0
  8. emerge/_emerge/elements/__init__.py +19 -0
  9. emerge/_emerge/elements/femdata.py +212 -0
  10. emerge/_emerge/elements/index_interp.py +64 -0
  11. emerge/_emerge/elements/legrange2.py +172 -0
  12. emerge/_emerge/elements/ned2_interp.py +645 -0
  13. emerge/_emerge/elements/nedelec2.py +140 -0
  14. emerge/_emerge/elements/nedleg2.py +217 -0
  15. emerge/_emerge/geo/__init__.py +24 -0
  16. emerge/_emerge/geo/horn.py +107 -0
  17. emerge/_emerge/geo/modeler.py +449 -0
  18. emerge/_emerge/geo/operations.py +254 -0
  19. emerge/_emerge/geo/pcb.py +1244 -0
  20. emerge/_emerge/geo/pcb_tools/calculator.py +28 -0
  21. emerge/_emerge/geo/pcb_tools/macro.py +79 -0
  22. emerge/_emerge/geo/pmlbox.py +204 -0
  23. emerge/_emerge/geo/polybased.py +529 -0
  24. emerge/_emerge/geo/shapes.py +427 -0
  25. emerge/_emerge/geo/step.py +77 -0
  26. emerge/_emerge/geo2d.py +86 -0
  27. emerge/_emerge/geometry.py +510 -0
  28. emerge/_emerge/howto.py +214 -0
  29. emerge/_emerge/logsettings.py +5 -0
  30. emerge/_emerge/material.py +118 -0
  31. emerge/_emerge/mesh3d.py +730 -0
  32. emerge/_emerge/mesher.py +339 -0
  33. emerge/_emerge/mth/common_functions.py +33 -0
  34. emerge/_emerge/mth/integrals.py +71 -0
  35. emerge/_emerge/mth/optimized.py +357 -0
  36. emerge/_emerge/periodic.py +263 -0
  37. emerge/_emerge/physics/__init__.py +0 -0
  38. emerge/_emerge/physics/microwave/__init__.py +1 -0
  39. emerge/_emerge/physics/microwave/adaptive_freq.py +279 -0
  40. emerge/_emerge/physics/microwave/assembly/assembler.py +569 -0
  41. emerge/_emerge/physics/microwave/assembly/curlcurl.py +448 -0
  42. emerge/_emerge/physics/microwave/assembly/generalized_eigen.py +426 -0
  43. emerge/_emerge/physics/microwave/assembly/robinbc.py +433 -0
  44. emerge/_emerge/physics/microwave/microwave_3d.py +1150 -0
  45. emerge/_emerge/physics/microwave/microwave_bc.py +915 -0
  46. emerge/_emerge/physics/microwave/microwave_data.py +1148 -0
  47. emerge/_emerge/physics/microwave/periodic.py +82 -0
  48. emerge/_emerge/physics/microwave/port_functions.py +53 -0
  49. emerge/_emerge/physics/microwave/sc.py +175 -0
  50. emerge/_emerge/physics/microwave/simjob.py +147 -0
  51. emerge/_emerge/physics/microwave/sparam.py +138 -0
  52. emerge/_emerge/physics/microwave/touchstone.py +140 -0
  53. emerge/_emerge/plot/__init__.py +0 -0
  54. emerge/_emerge/plot/display.py +394 -0
  55. emerge/_emerge/plot/grapher.py +93 -0
  56. emerge/_emerge/plot/matplotlib/mpldisplay.py +264 -0
  57. emerge/_emerge/plot/pyvista/__init__.py +1 -0
  58. emerge/_emerge/plot/pyvista/display.py +931 -0
  59. emerge/_emerge/plot/pyvista/display_settings.py +24 -0
  60. emerge/_emerge/plot/simple_plots.py +551 -0
  61. emerge/_emerge/plot.py +225 -0
  62. emerge/_emerge/projects/__init__.py +0 -0
  63. emerge/_emerge/projects/_gen_base.txt +32 -0
  64. emerge/_emerge/projects/_load_base.txt +24 -0
  65. emerge/_emerge/projects/generate_project.py +40 -0
  66. emerge/_emerge/selection.py +596 -0
  67. emerge/_emerge/simmodel.py +444 -0
  68. emerge/_emerge/simulation_data.py +411 -0
  69. emerge/_emerge/solver.py +993 -0
  70. emerge/_emerge/system.py +54 -0
  71. emerge/cli.py +19 -0
  72. emerge/lib.py +57 -0
  73. emerge/plot.py +1 -0
  74. emerge/pyvista.py +1 -0
  75. {emerge-0.4.6.dist-info → emerge-0.4.8.dist-info}/METADATA +1 -1
  76. emerge-0.4.8.dist-info/RECORD +78 -0
  77. emerge-0.4.8.dist-info/entry_points.txt +2 -0
  78. emerge-0.4.6.dist-info/RECORD +0 -4
  79. emerge-0.4.6.dist-info/entry_points.txt +0 -2
  80. {emerge-0.4.6.dist-info → emerge-0.4.8.dist-info}/WHEEL +0 -0
@@ -0,0 +1,1244 @@
1
+ # EMerge is an open source Python based FEM EM simulation module.
2
+ # Copyright (C) 2025 Robert Fennis.
3
+
4
+ # This program is free software; you can redistribute it and/or
5
+ # modify it under the terms of the GNU General Public License
6
+ # as published by the Free Software Foundation; either version 2
7
+ # of the License, or (at your option) any later version.
8
+
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU General Public License for more details.
13
+
14
+ # You should have received a copy of the GNU General Public License
15
+ # along with this program; if not, see
16
+ # <https://www.gnu.org/licenses/>.
17
+
18
+ from __future__ import annotations
19
+
20
+ import numpy as np
21
+ from scipy.optimize import root, fmin
22
+ import gmsh
23
+
24
+ from ..cs import CoordinateSystem, GCS, Axis
25
+ from ..geometry import GeoPolygon, GeoVolume, GeoSurface
26
+ from ..material import Material, AIR, COPPER
27
+ from .shapes import Box, Plate, Cyllinder
28
+ from .polybased import XYPolygon
29
+ from .operations import change_coordinate_system
30
+ from .pcb_tools.macro import parse_macro
31
+ from .pcb_tools.calculator import PCBCalculator
32
+ from loguru import logger
33
+ from typing import Literal, Callable
34
+ from dataclasses import dataclass
35
+ import math
36
+
37
+
38
+ SizeNames = Literal['0402','0603','1005','1608','2012','3216','3225','4532','5025','6332']
39
+ _SMD_SIZE_DICT = {x: (float(x[:2])*0.05, float(x[2:])*0.1) for x in ['0402','0603','1005','1608','2012','3216','3225','4532','5025','6332']}
40
+
41
+ def approx(a,b):
42
+ return abs(a-b) < 1e-8
43
+
44
+ def normalize(vector: np.ndarray) -> np.ndarray:
45
+ norm = np.linalg.norm(vector)
46
+ if norm == 0:
47
+ return vector
48
+ return vector / norm
49
+
50
+ def _rot_mat(angle):
51
+ ang = -angle * np.pi/180
52
+ return np.array([[np.cos(ang), -np.sin(ang)], [np.sin(ang), np.cos(ang)]])
53
+
54
+ class RouteException(Exception):
55
+ pass
56
+
57
+ class PCBPoly:
58
+
59
+ def __init__(self,
60
+ xs: list[float],
61
+ ys: list[float],
62
+ z: float = 0,
63
+ material: Material = COPPER):
64
+ self.xs: list[float] = xs
65
+ self.ys: list[float] = ys
66
+ self.z: float = z
67
+ self.material: Material = material
68
+
69
+ @property
70
+ def xys(self) -> list[tuple[float, float]]:
71
+ return list([(x,y) for x,y in zip(self.xs, self.ys)])
72
+
73
+ @dataclass
74
+ class Via:
75
+ x: float
76
+ y: float
77
+ z1: float
78
+ z2: float
79
+ radius: float
80
+ segments: int
81
+
82
+ class RouteElement:
83
+
84
+ def __init__(self):
85
+ self.width: float = None
86
+ self.x: float = None
87
+ self.y: float = None
88
+ self.direction: np.ndarray = None
89
+ self.dirright: np.ndarray = None
90
+
91
+ @property
92
+ def nr(self) -> tuple[float,float]:
93
+ return (self.x + self.dirright[0]*self.width/2, self.y + self.dirright[1]*self.width/2)
94
+
95
+ @property
96
+ def nl(self) -> tuple[float,float]:
97
+ return (self.x - self.dirright[0]*self.width/2, self.y - self.dirright[1]*self.width/2)
98
+
99
+ @property
100
+ def right(self) -> list[tuple[float, float]]:
101
+ pass
102
+
103
+ @property
104
+ def left(self) -> list[tuple[float, float]]:
105
+ pass
106
+
107
+ def __eq__(self, other: RouteElement) -> bool:
108
+ return approx(self.x, other.x) and approx(self.y, other.y) and (1-abs(np.sum(self.direction*other.direction)))<1e-8
109
+
110
+ class StripLine(RouteElement):
111
+
112
+ def __init__(self,
113
+ x: float,
114
+ y: float,
115
+ width: float,
116
+ direction: tuple[float, float]):
117
+ self.x = x
118
+ self.y = y
119
+ self.width = width
120
+ self.direction = normalize(np.array(direction))
121
+ self.dirright = np.array([self.direction[1], -self.direction[0]])
122
+
123
+ def __str__(self) -> str:
124
+ return f'StripLine[{self.x},{self.y},w={self.width},d=({self.direction})]'
125
+ @property
126
+ def right(self) -> list[tuple[float, float]]:
127
+ return [(self.x + self.width/2 * self.dirright[0], self.y + self.width/2 * self.dirright[1])]
128
+
129
+ @property
130
+ def left(self) -> list[tuple[float, float]]:
131
+ return [(self.x - self.width/2 * self.dirright[0], self.y - self.width/2 * self.dirright[1])]
132
+
133
+ class StripTurn(RouteElement):
134
+
135
+ def __init__(self,
136
+ x: float,
137
+ y: float,
138
+ width: float,
139
+ direction: tuple[float, float],
140
+ angle: float,
141
+ corner_type: str = 'round',
142
+ champher_distance: float = None):
143
+ self.xold: float = x
144
+ self.yold: float = y
145
+ self.width: float = width
146
+ self.old_direction: np.ndarray = normalize(np.array(direction))
147
+ self.direction: np.ndarray = _rot_mat(angle) @ self.old_direction
148
+ self.angle: float = angle
149
+ self.corner_type: str = corner_type
150
+ self.dirright: np.ndarray = np.array([self.old_direction[1], -self.old_direction[0]])
151
+
152
+ if champher_distance is None:
153
+ self.champher_distance: float = 0.75 * self.width*np.tan(np.abs(angle)/2*np.pi/180)
154
+ else:
155
+ self.champher_distance: float = champher_distance
156
+
157
+ turnvec = _rot_mat(angle) @ self.dirright * self.width/2
158
+
159
+ if angle > 0:
160
+ self.x = x + width/2 * self.dirright[0] - turnvec[0]
161
+ self.y = y + width/2 * self.dirright[1] - turnvec[1]
162
+ else:
163
+ self.x = x - width/2 * self.dirright[0] + turnvec[0]
164
+ self.y = y - width/2 * self.dirright[1] + turnvec[1]
165
+
166
+ def __str__(self) -> str:
167
+ return f'StripTurn[{self.x},{self.y},w={self.width},d=({self.direction})]'
168
+
169
+ @property
170
+ def right(self) -> list[tuple[float, float]]:
171
+ if self.angle > 0:
172
+ return []
173
+
174
+ #turning left
175
+ xl = self.xold - self.width/2 * self.dirright[0]
176
+ yl = self.yold - self.width/2 * self.dirright[1]
177
+ xr = self.xold + self.width/2 * self.dirright[0]
178
+ yr = self.yold + self.width/2 * self.dirright[1]
179
+
180
+ dist = min(self.width*np.sqrt(2), self.width * np.tan(np.abs(self.angle)/2*np.pi/180))
181
+
182
+ dcorner = self.width*(_rot_mat(self.angle) @ self.dirright)
183
+
184
+ xend = xl + dcorner[0]
185
+ yend = yl + dcorner[1]
186
+
187
+ out = [(xend, yend)]
188
+
189
+ if self.corner_type == 'champher':
190
+ dist = max(0.0, dist - self.champher_distance)
191
+
192
+ if dist==0:
193
+ return out
194
+
195
+ x1 = xr + dist * self.old_direction[0]
196
+ y1 = yr + dist * self.old_direction[1]
197
+
198
+ if self.corner_type == 'square':
199
+ return [(x1, y1), (xend, yend)]
200
+ if self.corner_type == 'champher':
201
+ x2 = xend - dist * self.direction[0]
202
+ y2 = yend - dist * self.direction[1]
203
+
204
+ return [(x1, y1), (x2, y2), (xend, yend)]
205
+
206
+ @property
207
+ def left(self) -> list[tuple[float, float]]:
208
+ if self.angle < 0:
209
+ return []
210
+
211
+ #turning right
212
+ xl = self.xold - self.width/2 * self.dirright[0]
213
+ yl = self.yold - self.width/2 * self.dirright[1]
214
+ xr = self.xold + self.width/2 * self.dirright[0]
215
+ yr = self.yold + self.width/2 * self.dirright[1]
216
+
217
+ dist = min(self.width*np.sqrt(2), self.width * np.tan(np.abs(self.angle)/2*np.pi/180))
218
+
219
+ dcorner = self.width*(_rot_mat(self.angle) @ -self.dirright)
220
+
221
+ xend = xr + dcorner[0]
222
+ yend = yr + dcorner[1]
223
+
224
+ out = [(xend, yend)]
225
+
226
+ if self.corner_type == 'champher':
227
+ dist =max(0.0, dist - self.champher_distance)
228
+
229
+ if dist==0:
230
+ return out
231
+
232
+ x1 = xl + dist * self.old_direction[0]
233
+ y1 = yl + dist * self.old_direction[1]
234
+
235
+ if self.corner_type == 'square':
236
+ return [(xend, yend), (x1, y1)]
237
+ if self.corner_type == 'champher':
238
+ x2 = xend - dist * self.direction[0]
239
+ y2 = yend - dist * self.direction[1]
240
+
241
+ return [(xend, yend), (x2, y2), (x1, y1)]
242
+
243
+ class StripPath:
244
+
245
+ def __init__(self, pcb: PCBLayouter):
246
+ self.pcb: PCBLayouter = pcb
247
+ self.path: list[RouteElement] = []
248
+ self.z: float = 0
249
+
250
+ def _has(self, element: RouteElement) -> bool:
251
+ if element in self.path:
252
+ return True
253
+ return False
254
+
255
+ @property
256
+ def xs(self) -> list[float]:
257
+ return [elem.x for elem in self.path]
258
+
259
+ @property
260
+ def ys(self) -> list[float]:
261
+ return [elem.y for elem in self.path]
262
+
263
+ @property
264
+ def start(self) -> RouteElement:
265
+ """ The start of the stripline. """
266
+ return self.path[0]
267
+
268
+ @property
269
+ def end(self) -> RouteElement:
270
+ """ The end of the stripline """
271
+ return self.path[-1]
272
+
273
+ def _check_loops(self) -> None:
274
+ if self.path[0]==self.path[-1]:
275
+ raise RouteException('Loops are currently not supported. To fix this problem, implement a single .cut() call before a .straight() call to break the loop.')
276
+ return None
277
+
278
+ def init(self,
279
+ x: float,
280
+ y: float,
281
+ width: float,
282
+ direction: tuple[float, float],
283
+ z: float = 0) -> StripPath:
284
+ """ Initializes the StripPath object for routing. """
285
+ self.path.append(StripLine(x, y, width, direction))
286
+ self.z = z
287
+ return self
288
+
289
+ def _add_element(self, element: RouteElement) -> StripPath:
290
+ """ Adds the provided RouteElement to the path. """
291
+ self.path.append(element)
292
+ self._check_loops()
293
+ return self
294
+
295
+ def straight(self, distance:
296
+ float, width: float = None,
297
+ dx: float = 0,
298
+ dy: float = 0) -> StripPath:
299
+ """Add A straight section to the stripline.
300
+
301
+ Adds a straight section with a length determined by "distance". Optionally, a
302
+ different "width" can be provided. The start of the straight section will be
303
+ at the end of the last section. The optional dx, dy arguments can be used to offset
304
+ the starting coordinate of the straight segment.
305
+
306
+ Args:
307
+ distance (float): The length of the stripline
308
+ width (float, optional): The width of the stripline. Defaults to None.
309
+ dx (float, optional): An x-direction offset. Defaults to 0.
310
+ dy (float, optional): A y-direction offset. Defaults to 0.
311
+
312
+ Returns:
313
+ StripPath: The current StripPath object.
314
+ """
315
+
316
+ x = self.end.x + dx
317
+ y = self.end.y + dy
318
+
319
+ dx_2, dy_2 = self.end.direction
320
+ x1 = x + distance * dx_2
321
+ y1 = y + distance * dy_2
322
+
323
+ if width is not None:
324
+ if width != self.end.width:
325
+ self._add_element(StripLine(x, y, width, (dx_2, dy_2)))
326
+
327
+ self._add_element(StripLine(x1, y1, self.end.width, (dx_2, dy_2)))
328
+ return self
329
+
330
+ def taper(self, distance: float,
331
+ width: float) -> StripPath:
332
+ """Add A taper section to the stripline.
333
+
334
+ Adds a taper section with a length determined by "distance". Optionally, a
335
+ different "width" can be provided. The start of the straight section will be
336
+ at the end of the last section. The optional dx, dy arguments can be used to offset
337
+ the starting coordinate of the straight segment.
338
+
339
+ Args:
340
+ distance (float): The length of the stripline
341
+ width (float, optional): The width of the stripline. Defaults to None.
342
+ dx (float, optional): An x-direction offset. Defaults to 0.
343
+ dy (float, optional): A y-direction offset. Defaults to 0.
344
+
345
+ Returns:
346
+ StripPath: The current StripPath object.
347
+ """
348
+
349
+ x = self.end.x
350
+ y = self.end.y
351
+
352
+ dx_2, dy_2 = self.end.direction
353
+ x1 = x + distance * dx_2
354
+ y1 = y + distance * dy_2
355
+
356
+ self._add_element(StripLine(x1, y1, width, (dx_2, dy_2)))
357
+
358
+ return self
359
+
360
+ def turn(self, angle: float,
361
+ width: float = None,
362
+ corner_type: Literal['champher','square'] = 'champher') -> StripPath:
363
+ """Adds a turn to the strip path.
364
+
365
+ The angle is specified in degrees. The width of the turn will be the same as the last segment.
366
+ optionally, a different width may be provided.
367
+ By default, all corners will be cut using the "champher" type. Other options are not yet provided.
368
+
369
+ Args:
370
+ angle (float): The turning angle
371
+ width (float, optional): The stripline width. Defaults to None.
372
+ corner_type (str, optional): The corner type. Defaults to 'champher'.
373
+
374
+ Returns:
375
+ StripPath: The current StripPath object
376
+ """
377
+ x, y = self.end.x, self.end.y
378
+ dx, dy = self.end.direction
379
+
380
+ if width is not None:
381
+ if width != self.end.width:
382
+ self._add_element(StripLine(x, y, width, (dx, dy)))
383
+ else:
384
+ width=self.end.width
385
+ self._add_element(StripTurn(x, y, width, (dx, dy), angle, corner_type))
386
+ return self
387
+
388
+ def store(self, name: str) -> StripPath:
389
+ """ Store the current x,y coordinate labeled in the PCB object.
390
+
391
+ The stored coordinate can be accessed by calling the .load() method on the PCBRouter class.
392
+
393
+ Args:
394
+ name (str): The coordinate label
395
+
396
+ Returns:
397
+ StripPath: The current StripPath object.
398
+ """
399
+ self.pcb.store(name, self.end.x, self.end.y)
400
+ self.pcb.stored_striplines[name] = self.end
401
+ return self
402
+
403
+ def split(self,
404
+ direction: tuple[float, float] = None,
405
+ width: float = None) -> StripPath:
406
+ """Split the current path in N new paths given by a new departure direction
407
+
408
+ Args:
409
+ directions (list[tuple[float, float]]): a list of directions example: [(1,0),(-1,0)]
410
+ widths (list[float], optional): The width for each new path. Defaults to None.
411
+
412
+ Returns:
413
+ list[StripPath]: A list of new StripPath objects
414
+ """
415
+ if width is None:
416
+ width = self.end.width
417
+ if direction is None:
418
+ direction = self.end.direction
419
+ x = self.end.x
420
+ y = self.end.y
421
+ z = self.z
422
+ paths = self.pcb.new(x,y,width, direction, z)
423
+ self.pcb._checkpoint = self
424
+ return paths
425
+
426
+ def lumped_element(self, impedance_function: Callable, size: SizeNames | tuple) -> StripPath:
427
+ """Adds a lumped element to the PCB.
428
+
429
+ The first argument should be the impedance function as function of frequency. For a capacitor this would be:
430
+ Z(f) = 1/(j2πfC).
431
+ The second argument specifies the size of the element (length x width) as a tuple or it can be a string for a
432
+ package. For example "0402". The size of the lumped component does not inlcude the footprint.
433
+
434
+ For example a 0602 pacakge has timensions: length=0.6mm, width=0.3mm. The actual length of the component
435
+ not overlapping with the solder pad is 0.3mm (always half) so the component size added is 0.3mm x 0.3mm.
436
+
437
+ After creation, the trace continues after the lumped component.
438
+
439
+ You can add the components to your model as following:
440
+
441
+ >>> lumped_elements = pcb.lumped_elments
442
+ for le in lumped_elements:
443
+ model.mw.bc.LumpedElement(le)
444
+
445
+ The impedance function and geometry is automatically passed on with the lumped element added.
446
+
447
+ Args:
448
+ impedance_function (Callable): A function that computes the component impedance as a function of frequency.
449
+ size (SizeNames | tuple): The dimensions of the lumped element on PCB.
450
+
451
+ Returns:
452
+ StripPath: The same strip path object
453
+ """
454
+ if size in _SMD_SIZE_DICT:
455
+ length, width = _SMD_SIZE_DICT[size]
456
+ else:
457
+ length, width = size
458
+
459
+ dx, dy = self.end.direction
460
+ x, y = self.end.x, self.end.y
461
+ rx, ry = self.end.dirright
462
+ wh = width/2
463
+ xs = np.array([x+rx*wh, x+rx*wh+length*dx, x-rx*wh+length*dx, x-rx*wh])*self.pcb.unit
464
+ ys = np.array([y+ry*wh, y+ry*wh+length*dy, y-ry*wh+length*dy, y-ry*wh])*self.pcb.unit
465
+ poly = XYPolygon(xs, ys)
466
+
467
+ self.pcb._lumped_element(poly, impedance_function, width, length)
468
+ return self.pcb.new(x+dx*length, y+dy*length, self.end.width, self.end.direction, self.z)
469
+
470
+
471
+ def cut(self) -> StripPath:
472
+ """Split the current path in N new paths given by a new departure direction
473
+
474
+ Args:
475
+ directions (list[tuple[float, float]]): a list of directions example: [(1,0),(-1,0)]
476
+ widths (list[float], optional): The width for each new path. Defaults to None.
477
+
478
+ Returns:
479
+ list[StripPath]: A list of new StripPath objects
480
+ """
481
+ width = self.end.width
482
+ direction = self.end.direction
483
+ x = self.end.x
484
+ y = self.end.y
485
+ z = self.z
486
+ paths = self.pcb.new(x,y,width, direction, z)
487
+ return paths
488
+
489
+ def stub(self, direction: tuple[float, float],
490
+ width: float,
491
+ length: float,
492
+ mirror: bool = False) -> StripPath:
493
+ """ Add a single rectangular strip line section at the current coordinate"""
494
+ self.pcb.new(self.end.x, self.end.y, width, direction, self.z).straight(length)
495
+ if mirror:
496
+ self.pcb.new(self.end.x, self.end.y, width, (-direction[0], -direction[1]), self.z).straight(length)
497
+ return self
498
+
499
+ def merge(self) -> StripPath:
500
+ """Continue at the last point where .split() is called"""
501
+ if self.pcb._checkpoint is None:
502
+ raise RouteException('No checkpoint known. Make sure to call .check() first')
503
+ return self.pcb._checkpoint
504
+
505
+ def via(self,
506
+ znew: float,
507
+ radius: float,
508
+ proceed: bool = True,
509
+ direction: tuple[float, float] = None,
510
+ width: float = None,
511
+ extra: float = None,
512
+ segments: int = 6) -> StripPath:
513
+ """Adds a via to the circuit
514
+
515
+ If proceed is set to True, a new StripPath will be started. The width and direction properties
516
+ will be inherited from the current one if not specified.
517
+ The extra parameter specifies how much extra stripline is added beyond the current point and before
518
+ the new segment to include the via. If not specifies it defaults to width/2.
519
+
520
+ Args:
521
+ znew (float): The new Z-height for the stripline
522
+ radius (float): The via radius
523
+ proceed (bool, optional): Wether to continue with a new trace. Defaults to True.
524
+ direction (tuple[float, float], optional): The new direction. Defaults to None.
525
+ width (float, optional): The new width. Defaults to None.
526
+ extra (float, optional): How much extra stripline to add around the via. Defaults to None.
527
+ segments (int, optional): The number of via polygon sections. Defaults to 6.
528
+
529
+ Returns:
530
+ StripPath: The new StripPath object
531
+ """
532
+
533
+ if extra is None:
534
+ extra = self.end.width/2
535
+ x, y = self.end.x, self.end.y
536
+ z1 = self.z
537
+ z2 = znew
538
+ if extra > 0:
539
+ self.straight(extra)
540
+ self.pcb.vias.append(Via(x,y,z1,z2,radius, segments))
541
+ if proceed:
542
+ if width is None:
543
+ width = self.end.width
544
+ if direction is None:
545
+ direction = self.end.direction
546
+ dx = direction[0]*extra
547
+ dy = direction[1]*extra
548
+ return self.pcb.new(x-dx, y-dy, width, direction, z2)
549
+ return self
550
+
551
+ def short(self) -> StripPath:
552
+ self.via(self.pcb.z(1), self.end.width/3, False)
553
+ return self
554
+
555
+ def jump(self,
556
+ dx: float = None,
557
+ dy: float = None,
558
+ width: float = None,
559
+ direction: tuple[float, float] = None,
560
+ gap: float = None,
561
+ side: Literal['left','right'] = None,
562
+ reverse: float = None) -> StripPath:
563
+ """Add an unconnected jump to the currenet stripline.
564
+
565
+ The last stripline path will be terminated and a new one will be started based on the
566
+ displacement provided by dx and dy. The new path will proceed in the same direction or
567
+ another one based ont the "direction" argument.
568
+ An alternative one can define a "gap", "side" and "reverse" argument. The stripline
569
+ will make a lateral jump ensuring a gap between the current and new line. The direction
570
+ of the jump is either "left" or "right" as seen from the direction of the stripline.
571
+ The reverse argument is a distance by which the stripline moves back.
572
+
573
+ Args:
574
+ dx (float, optional): The jumps dx distance. Defaults to None.
575
+ dy (float, optional): The jumps dy distance. Defaults to None.
576
+ width (float, optional): The new stripline width. Defaults to None.
577
+ direction (tuple[float, float], optional): The new stripline direction. Defaults to None.
578
+ gap (float, optional): The gap between the current and next stripline. Defaults to None.
579
+ side (Literal[left, right], optional): The lateral jump direction. Defaults to None.
580
+ reverse (float, optional): How much to move back if a lateral jump is made. Defaults to None.
581
+
582
+ Example:
583
+ The current example would yield a coupled line filter parallel jump.
584
+ >>> StripPath.jump(gap=1, side="left", reverse=quarter_wavelength).straight(...)
585
+
586
+ Returns:
587
+ StripPath: The new StripPath object
588
+ """
589
+ if width is None:
590
+ width = self.end.width
591
+ if direction is None:
592
+ direction = self.end.direction
593
+ else:
594
+ direction = np.array(direction)
595
+
596
+ ending = self.end
597
+
598
+ if gap is not None and side is not None and reverse is not None:
599
+ Q = 1
600
+ if side=='left':
601
+ Q = -1
602
+ x = ending.x - reverse*ending.direction[0] + Q*ending.dirright[0]*(width/2 + ending.width/2 + gap)
603
+ y = ending.y - reverse*ending.direction[1] + Q*ending.dirright[1]*(width/2 + ending.width/2 + gap)
604
+ else:
605
+ x = ending.x + dx
606
+ y = ending.y + dy
607
+ return self.pcb.new(x, y, width, direction)
608
+
609
+ def to(self, dest: tuple[float, float],
610
+ arrival_dir: tuple[float, float] = None,
611
+ arrival_margin: float = None,
612
+ angle_step: float = 90):
613
+ """
614
+ Extend the path from current end point to dest (x, y).
615
+ Optionally ensure arrival in arrival_dir after a straight segment of arrival_margin.
616
+ Turns are quantized to multiples of angle_step (divisor of 360, <=90).
617
+ """
618
+ # Validate angle_step
619
+ if 360 % angle_step != 0 or angle_step > 90 or angle_step <= 0:
620
+ raise ValueError(f"angle_step must be a positive divisor of 360 <= 90, got {angle_step}")
621
+
622
+ # Current state
623
+ x0, y0 = self.end.x, self.end.y
624
+ vx, vy = self.end.direction # unit heading
625
+ tx, ty = dest
626
+
627
+ # Compute unit arrival direction
628
+ if arrival_dir is not None:
629
+ adx, ady = arrival_dir
630
+ mag = math.hypot(adx, ady)
631
+ if mag == 0:
632
+ raise ValueError("arrival_dir must be non-zero")
633
+ ux, uy = adx/mag, ady/mag
634
+ else:
635
+ # if no arrival_dir, just head to point
636
+ ux, uy = 0.0, 0.0
637
+ arrival_margin += self.end.width
638
+ # Compute base point: destination minus arrival margin
639
+ bx = tx - ux * arrival_margin
640
+ by = ty - uy * arrival_margin
641
+
642
+ # Parametric search along negative arrival direction
643
+ # we seek t >= 0 such that angle from (vx,vy) to (bx - x0 - ux*t, by - y0 - uy*t)
644
+ # quantizes exactly to a multiple of angle_step
645
+ atol = angle_step/10
646
+ dtol = 0.01
647
+ t = 0.0
648
+ max_t = math.hypot(bx - x0, by - y0) + arrival_margin + 1e-3
649
+ dt = max_t / 1000.0 # resolution of search
650
+ found = False
651
+ desired_q = None
652
+ cand_dx = cand_dy = 0.0
653
+
654
+ while t <= max_t:
655
+ # candidate intercept point
656
+ cx = bx - ux * t
657
+ cy = by - uy * t
658
+ dx = cx - x0
659
+ dy = cy - y0
660
+ if abs(dx) < dtol and abs(dy) < dtol:
661
+ # reached start; skip
662
+ t += dt
663
+ continue
664
+ # compute angle
665
+ cross = vx*dy - vy*dx
666
+ dot = vx*dx + vy*dy
667
+ ang = math.degrees(math.atan2(cross, dot))
668
+ # quantize
669
+ q_ang = math.ceil(ang / angle_step) * angle_step
670
+ if abs(ang - q_ang) <= atol:
671
+ found = True
672
+ desired_q = q_ang
673
+ break
674
+ t += dt
675
+
676
+ if not found:
677
+ raise RuntimeError("Could not find an intercept angle matching quantization")
678
+
679
+ # 1) Perform initial quantized turn
680
+ if abs(desired_q) > atol:
681
+ self.turn(-desired_q)
682
+ x0 = self.end.x
683
+ y0 = self.end.y
684
+ # compute new heading vector after turn
685
+ theta = math.radians(desired_q)
686
+ nvx = math.cos(theta) * vx - math.sin(theta) * vy
687
+ nvy = math.sin(theta) * vx + math.cos(theta) * vy
688
+
689
+ # 2) Compute exact intercept distance via line intersection:
690
+ # Solve: (x0,y0) + s*(nvx,nvy) = (bx,by) - t*(ux,uy)
691
+ # Unknowns s,t (we reuse t from above as initial guess, but solve fresh):
692
+ tol_dist = 1e-6
693
+ A11, A12 = nvx, ux
694
+ A21, A22 = nvy, uy
695
+ B1 = bx - x0
696
+ B2 = by - y0
697
+ det = A11 * A22 - A12 * A21
698
+ if abs(det) < tol_dist:
699
+ raise RuntimeError("Initial heading parallel to arrival line, no unique intercept")
700
+ s = (B1 * A22 - B2 * A12) / det
701
+ t_exact = (A11 * B2 - A21 * B1) / det
702
+ if s < -tol_dist or (arrival_dir is not None and t_exact < -tol_dist):
703
+ raise RuntimeError("Computed intercept lies behind start or before arrival point")
704
+
705
+
706
+ # 3) Turn into arrival direction (if provided)
707
+
708
+ # we need to rotate from current heading (vx,vy) by desired_q to (nvx,nvy)
709
+ theta = math.radians(desired_q)
710
+ nvx = math.cos(theta)*vx - math.sin(theta)*vy
711
+ nvy = math.sin(theta)*vx + math.cos(theta)*vy
712
+ # target heading is (ux,uy)
713
+ cross2 = nvx*uy - nvy*ux
714
+ dot2 = nvx*ux + nvy*uy
715
+ back_ang = math.degrees(math.atan2(cross2, dot2))
716
+
717
+ backoff = math.tan(abs(back_ang)*np.pi/360)*self.end.width/2
718
+ self.straight(s - backoff)
719
+
720
+
721
+ self.turn(-back_ang)
722
+
723
+ x0 = self.end.x
724
+ y0 = self.end.y
725
+ D = math.hypot(tx-x0, ty-y0)
726
+ # 4) Final straight into destination by arrival_margin + t
727
+ self.straight(D)
728
+
729
+
730
+ return self
731
+
732
+ def macro(self, path: str, width: float = None, start_dir: tuple[float, float] = None) -> StripPath:
733
+ """Parse an EMerge macro command string
734
+
735
+ The start direction by default is the abslute current heading. If a specified heading is provided
736
+ the macro language will assume that as the current heading and generate commands accordingly.
737
+
738
+ The language is specified by a symbol plus a number.
739
+ Symbols:
740
+ - = X: Move X units forward
741
+ - \> X: Turn to right and move X forward
742
+ - < X: Turn to left and move X forward
743
+ - v X: Turn to down and move X forward
744
+ - ^ X: Turn to up and move X forward
745
+ - T X,Y: Taper X forward to width Y
746
+ - \ X: Turn relative right 90 degrees and X forward
747
+ - / X: Turn relative left 90 degrees and X forward
748
+
749
+ (*) All commands X can also be provided as X,Y to change the width
750
+
751
+ Args:
752
+ path (str): The path command string
753
+ width (float, optional): The width to start width. Defaults to None.
754
+ start_dir (tuple[float, float], optional): The start direction to assume. Defaults to None.
755
+
756
+ Example:
757
+ >>> my_pcb.macro("= 5 v 4,1.2 > 5 ^ 2 > 3 T 4, 2.1")
758
+
759
+ Returns:
760
+ StripPath: The strippath object
761
+ """
762
+ if start_dir is None:
763
+ start_dir = self.end.direction
764
+ if width is None:
765
+ width = self.end.width
766
+ for instr in parse_macro(path, width, start_dir):
767
+ getattr(self, instr.instr)(*instr.args, **instr.kwargs)
768
+ return self
769
+
770
+ def __call__(self, element_nr: int) -> RouteElement:
771
+ if element_nr >= len(self.path):
772
+ self.path.append(RouteElement())
773
+ return self.path[element_nr]
774
+
775
+ class PCBLayouter:
776
+ def __init__(self,
777
+ thickness: float,
778
+ unit: float = 0.001,
779
+ cs: CoordinateSystem = None,
780
+ material: Material = AIR,
781
+ layers: int = 2,
782
+ ):
783
+
784
+ self.thickness: float = thickness
785
+ self._zs: np.ndarray = np.linspace(-self.thickness, 0, layers)
786
+ self.material: Material = material
787
+ self.width: float = None
788
+ self.length: float = None
789
+ self.origin: np.ndarray = None
790
+ self.paths: list[StripPath] = []
791
+ self.polies: list[PCBPoly] = []
792
+
793
+ self.lumped_ports: list[StripLine] = []
794
+ self.lumped_elements: list[GeoPolygon] = []
795
+
796
+ self.unit = unit
797
+
798
+ self.cs: CoordinateSystem = cs
799
+ if self.cs is None:
800
+ self.cs = GCS
801
+
802
+ self.traces: list[GeoPolygon] = []
803
+ self.ports: list[GeoPolygon] = []
804
+ self.vias: list[Via] = []
805
+
806
+ self.xs: list[float] = []
807
+ self.ys: list[float] = []
808
+ self.zs: list[float] = []
809
+
810
+ self.stored_coords: dict[str,tuple[float, float]] = dict()
811
+ self.stored_striplines: dict[str, StripLine] = dict()
812
+ self._checkpoint: StripPath = None
813
+
814
+ self.calc: PCBCalculator = PCBCalculator(self.thickness, self._zs, self.material, self.unit)
815
+
816
+ @property
817
+ def trace(self) -> GeoPolygon:
818
+ tags = []
819
+ for trace in self.traces:
820
+ tags.extend(trace.tags)
821
+ return GeoPolygon(tags)
822
+
823
+
824
+ @property
825
+ def all_objects(self) -> list[GeoPolygon]:
826
+ return self.traces + self.ports
827
+
828
+
829
+ def z(self, layer: int) -> float:
830
+ """Returns the z-height of the given layer number counter from 1 (bottom) to N (top)
831
+
832
+ Args:
833
+ layer (int): The layer number (1 to N)
834
+
835
+ Returns:
836
+ float: the z-height
837
+ """
838
+ return self._zs[layer-1]
839
+
840
+ def _get_z(self, element: RouteElement) -> float:
841
+ """Return the z-height of a given Route Element
842
+
843
+ Args:
844
+ element (RouteElement): The requested route element
845
+
846
+ Returns:
847
+ float: The z-height.
848
+ """
849
+ for path in self.paths:
850
+ if path._has(element):
851
+ return path.z
852
+ return None
853
+
854
+ def store(self, name: str, x: float, y:float):
855
+ """Store the x,y coordinate pair one label provided by name
856
+
857
+ Args:
858
+ name (str): The corodinate label name
859
+ x (float): The x-coordinate
860
+ y (float): The y-coordinate
861
+ """
862
+ self.stored_coords[name] = (x,y)
863
+
864
+ def load(self, name: str) -> tuple[float, float] | StripLine:
865
+ """Acquire the x,y, coordinate associated with the label name.
866
+
867
+ Args:
868
+ name (str): The name of the x,y coordinate
869
+
870
+ """
871
+ if name in self.stored_striplines and name in self.stored_coords:
872
+ logger.warning(f'There is both a coordinate and stripline under the name {name}.')
873
+ return self.stored_striplines[name]
874
+ elif name in self.stored_striplines:
875
+ return self.stored_striplines[name]
876
+ elif name in self.stored_coords:
877
+ return self.stored_coords[name]
878
+ else:
879
+ raise ValueError(f'There is no stripline or coordinate under the name of {name}')
880
+
881
+ def __call__(self, path_nr: int) -> StripPath:
882
+ if path_nr >= len(self.paths):
883
+ self.paths.append(StripPath())
884
+ return self.paths[path_nr]
885
+
886
+ def determine_bounds(self,
887
+ leftmargin: float = 0,
888
+ topmargin: float = 0,
889
+ rightmargin: float = 0,
890
+ bottommargin: float = 0):
891
+ """Defines the rectangular boundary of the PCB.
892
+
893
+ Args:
894
+ leftmargin (float, optional): The left margin. Defaults to 0.
895
+ topmargin (float, optional): The top margin. Defaults to 0.
896
+ rightmargin (float, optional): The right margin. Defaults to 0.
897
+ bottommargin (float, optional): The bottom margin. Defaults to 0.
898
+ """
899
+ if len(self.xs) == 0:
900
+ raise ValueError('PCB path is not compiled. Compile before defining boundaries.')
901
+ minx = min(self.xs)
902
+ maxx = max(self.xs)
903
+ miny = min(self.ys)
904
+ maxy = max(self.ys)
905
+ ml = leftmargin
906
+ mt = topmargin
907
+ mr = rightmargin
908
+ mb = bottommargin
909
+ self.width = (maxx - minx + mr + ml)
910
+ self.length = (maxy - miny + mt + mb)
911
+ self.origin = np.array([-ml+minx, -mb+miny, 0])
912
+
913
+ def set_bounds(self,
914
+ xmin: float,
915
+ ymin: float,
916
+ xmax: float,
917
+ ymax: float) -> None:
918
+ """Define the bounds of the PCB
919
+
920
+ Args:
921
+ xmin (float): The minimum x-coordinate
922
+ ymin (float): The minimum y-coordinate
923
+ xmax (float): The maximum x-coordinate
924
+ ymax (float): The maximum y-coordinate
925
+ """
926
+ self.origin = np.array([xmin, ymin, 0])
927
+ self.width = xmax-xmin
928
+ self.length = ymax-ymin
929
+
930
+ def plane(self,
931
+ z: float,
932
+ width: float = None,
933
+ height: float = None,
934
+ origin: tuple[float, float] = None,
935
+ alignment: Literal['corner','center'] = 'corner') -> GeoSurface:
936
+ """Generates a generic rectangular plate in the XY grid.
937
+ If no size is provided, it defaults to the entire PCB size assuming that the bounds are determined.
938
+
939
+ Args:
940
+ z (float): The Z-height for the plate.
941
+ width (float, optional): The width of the plate. Defaults to None.
942
+ height (float, optional): The height of the plate. Defaults to None.
943
+ origin (tuple[float, float], optional): The origin of the plate. Defaults to None.
944
+ alignment (['corner','center], optional): The alignment of the plate. Defaults to 'corner'.
945
+
946
+ Returns:
947
+ GeoSurface: _description_
948
+ """
949
+ if width is None or height is None or origin is None:
950
+ width = self.width
951
+ height = self.length
952
+ origin = (self.origin[0]*self.unit, self.origin[1]*self.unit)
953
+ origin = origin + (z*self.unit, )
954
+
955
+ if alignment == 'center':
956
+ origin = (origin[0] - width*self.unit/2, origin[1]-height*self.unit/2, origin[2])
957
+
958
+ plane = Plate(origin, (width*self.unit, 0, 0), (0, height*self.unit, 0))
959
+ plane = change_coordinate_system(plane, self.cs)
960
+ return plane
961
+
962
+ def gen_pcb(self,
963
+ split_z: bool = True,
964
+ layer_tolerance: float = 1e-6,
965
+ merge: bool = True) -> GeoVolume:
966
+ """Generate the PCB Block object
967
+
968
+ Returns:
969
+ GeoVolume: The PCB Block
970
+ """
971
+ x0, y0, z0 = self.origin*self.unit
972
+
973
+ if split_z:
974
+ zvalues = sorted(list(set(self.zs + [-self.thickness, 0.0])))
975
+ zvalues_isolated = [zvalues[0],]
976
+ for z in zvalues[1:]:
977
+ if (z-zvalues_isolated[-1]) <= layer_tolerance:
978
+ continue
979
+ zvalues_isolated.append(z)
980
+ boxes = []
981
+ for z1, z2 in zip(zvalues_isolated[:-1],zvalues_isolated[1:]):
982
+ h = z2-z1
983
+ box = Box(self.width*self.unit,
984
+ self.length*self.unit,
985
+ h*self.unit,
986
+ position=(x0, y0, z0+z1*self.unit))
987
+ box.material = self.material
988
+ box = change_coordinate_system(box, self.cs)
989
+ boxes.append(box)
990
+ if merge:
991
+ return GeoVolume.merged(boxes)
992
+ return boxes
993
+
994
+ box = Box(self.width*self.unit,
995
+ self.length*self.unit,
996
+ self.thickness*self.unit,
997
+ position=(x0,y0,z0-self.thickness*self.unit))
998
+ box.material = self.material
999
+ box = change_coordinate_system(box, self.cs)
1000
+ return box
1001
+
1002
+ def gen_air(self, height: float) -> GeoVolume:
1003
+ """Generate the Air Block object
1004
+
1005
+ This requires that the width, depth and origin are deterimed. This
1006
+ can either be done manually or via the .determine_boudns() method.
1007
+
1008
+ Returns:
1009
+ GeoVolume: The PCB Block
1010
+ """
1011
+ x0, y0, z0 = self.origin*self.unit
1012
+ box = Box(self.width*self.unit,
1013
+ self.length*self.unit,
1014
+ height*self.unit,
1015
+ position=(x0,y0,z0))
1016
+ box = change_coordinate_system(box, self.cs)
1017
+ return box
1018
+
1019
+ def new(self,
1020
+ x: float,
1021
+ y: float,
1022
+ width: float,
1023
+ direction: tuple[float, float],
1024
+ z: float = 0) -> StripPath:
1025
+ """Start a new trace
1026
+
1027
+ The trace is started at the provided x,y, coordinates with a width "width".
1028
+ The direction must be provided as an (dx,dy) vector provided as tuple.
1029
+
1030
+ Args:
1031
+ x (float): The starting X-coordinate (local)
1032
+ y (float): The starting Y-coordinate (local)
1033
+ width (float): The (micro)-stripline width
1034
+ direction (tuple[float, float]): The direction.
1035
+
1036
+ Returns:
1037
+ StripPath: A StripPath object that can be extended with method chaining.
1038
+
1039
+ Example:
1040
+ >>> PCB.new(...).straight(...).turn(...).straight(...) etc.
1041
+
1042
+ """
1043
+ path = StripPath(self)
1044
+ path.init(x, y, width, direction, z=z)
1045
+ self.paths.append(path)
1046
+ return path
1047
+
1048
+ def lumped_port(self, stripline: StripLine, z_ground: float = None) -> GeoPolygon:
1049
+ """Generate a lumped-port object to be created.
1050
+
1051
+ Args:
1052
+ stripline (StripLine): _description_
1053
+ """
1054
+
1055
+ xy1 = stripline.right[0]
1056
+ xy2 = stripline.left[0]
1057
+ z = self._get_z(stripline)
1058
+ if z_ground is None:
1059
+ z_ground = -self.thickness
1060
+ height = z-z_ground
1061
+ x1, y1, z1 = self.cs.in_global_cs(xy1[0]*self.unit, xy1[1]*self.unit, z*self.unit - height*self.unit)
1062
+ x2, y2, z2 = self.cs.in_global_cs(xy1[0]*self.unit, xy1[1]*self.unit, z*self.unit )
1063
+ x3, y3, z3 = self.cs.in_global_cs(xy2[0]*self.unit, xy2[1]*self.unit, z*self.unit )
1064
+ x4, y4, z4 = self.cs.in_global_cs(xy2[0]*self.unit, xy2[1]*self.unit, z*self.unit - height*self.unit)
1065
+
1066
+ ptag1 = gmsh.model.occ.addPoint(x1, y1, z1)
1067
+ ptag2 = gmsh.model.occ.addPoint(x2, y2, z2)
1068
+ ptag3 = gmsh.model.occ.addPoint(x3, y3, z3)
1069
+ ptag4 = gmsh.model.occ.addPoint(x4, y4, z4)
1070
+
1071
+ ltag1 = gmsh.model.occ.addLine(ptag1, ptag2)
1072
+ ltag2 = gmsh.model.occ.addLine(ptag2, ptag3)
1073
+ ltag3 = gmsh.model.occ.addLine(ptag3, ptag4)
1074
+ ltag4 = gmsh.model.occ.addLine(ptag4, ptag1)
1075
+
1076
+ ltags = [ltag1, ltag2, ltag3, ltag4]
1077
+
1078
+ tag_wire = gmsh.model.occ.addWire(ltags)
1079
+ planetag = gmsh.model.occ.addPlaneSurface([tag_wire,])
1080
+ poly = GeoPolygon([planetag,])
1081
+ poly._aux_data['width'] = stripline.width*self.unit
1082
+ poly._aux_data['height'] = height*self.unit
1083
+ poly._aux_data['vdir'] = self.cs.zax
1084
+ poly._aux_data['idir'] = Axis(self.cs.xax.np*stripline.dirright[0] + self.cs.yax.np*stripline.dirright[1])
1085
+
1086
+ return poly
1087
+
1088
+ def _lumped_element(self, poly: XYPolygon, function: Callable, width: float, length: float) -> None:
1089
+
1090
+ geopoly = poly._finalize(self.cs)
1091
+ geopoly._aux_data['func'] = function
1092
+ geopoly._aux_data['width'] = width
1093
+ geopoly._aux_data['height'] = length
1094
+ self.lumped_elements.append(geopoly)
1095
+
1096
+
1097
+ def modal_port(self,
1098
+ point: StripLine,
1099
+ height: float,
1100
+ width_multiplier: float = 5.0,
1101
+ ) -> GeoSurface:
1102
+ """Generate a wave-port as a GeoSurface.
1103
+
1104
+ The port is placed at the coordinate of the provided stripline. The width
1105
+ is determined as a multiple of the stripline width. The height will be
1106
+ extended to the air height from the bottom of the PCB unless a different height is specified.
1107
+
1108
+ Args:
1109
+ point (StripLine): The location of the port.
1110
+ width_multiplier (float, optional): The width of the port in stripline widths. Defaults to 5.0.
1111
+ height (float, optional): The height of the port. Defaults to None.
1112
+
1113
+ Returns:
1114
+ GeoSurface: The GeoSurface object that can be used for the waveguide.
1115
+ """
1116
+
1117
+ height = (self.thickness + height)
1118
+
1119
+ ds = point.dirright
1120
+ x0 = point.x - ds[0]*point.width*width_multiplier/2
1121
+ y0 = point.y - ds[1]*point.width*width_multiplier/2
1122
+ z0 = - self.thickness
1123
+ ax1 = np.array([ds[0], ds[1], 0])*self.unit*point.width*width_multiplier
1124
+ ax2 = np.array([0,0,1])*height*self.unit
1125
+
1126
+ plate = Plate(np.array([x0,y0,z0])*self.unit, ax1, ax2)
1127
+ plate = change_coordinate_system(plate, self.cs)
1128
+ return plate
1129
+
1130
+ def generate_vias(self, merge=False) -> list[Cyllinder] | Cyllinder:
1131
+ """Generates the via objects.
1132
+
1133
+ Args:
1134
+ merge (bool, optional): Whether to merge the result into a final object. Defaults to False.
1135
+
1136
+ Returns:
1137
+ list[Cyllinder] | Cyllinder: Either al ist of cylllinders or a single one (merge=True)
1138
+ """
1139
+ vias = []
1140
+ for via in self.vias:
1141
+ x0 = via.x*self.unit
1142
+ y0 = via.y*self.unit
1143
+ z0 = via.z1*self.unit
1144
+ xg, yg, zg = self.cs.in_global_cs(x0, y0, z0)
1145
+ cs = CoordinateSystem(self.cs.xax, self.cs.yax, self.cs.zax, np.array([xg, yg, zg]))
1146
+ cyl = Cyllinder(via.radius*self.unit, (via.z2-via.z1)*self.unit, cs, via.segments)
1147
+ cyl.material = COPPER
1148
+ vias.append(cyl)
1149
+ if merge:
1150
+
1151
+ return GeoVolume.merged(vias)
1152
+ return vias
1153
+
1154
+ def add_poly(self,
1155
+ xs: list[float],
1156
+ ys: list[float],
1157
+ z: float = 0,
1158
+ material: Material = COPPER):
1159
+ """Add a custom polygon to the PCB
1160
+
1161
+ Args:
1162
+ xs (list[float]): A list of x-coordinates
1163
+ ys (list[float]): A list of y-coordinates
1164
+ z (float, optional): The z-height. Defaults to 0.
1165
+ material (Material, optional): The material. Defaults to COPPER.
1166
+ """
1167
+ self.polies.append(PCBPoly(xs, ys, z, material))
1168
+
1169
+ def _gen_poly(self, xys: list[tuple[float, float]], z: float) -> GeoPolygon:
1170
+ """ Generates a GeoPoly out of a list of (x,y) coordinate tuples"""
1171
+ ptags = []
1172
+ for x,y in xys:
1173
+ px, py, pz = self.cs.in_global_cs(x*self.unit, y*self.unit, z*self.unit)
1174
+ ptags.append(gmsh.model.occ.addPoint(px, py, pz))
1175
+
1176
+ ltags = []
1177
+ for t1, t2 in zip(ptags[:-1], ptags[1:]):
1178
+ ltags.append(gmsh.model.occ.addLine(t1, t2))
1179
+ ltags.append(gmsh.model.occ.addLine(ptags[-1], ptags[0]))
1180
+
1181
+ tag_wire = gmsh.model.occ.addWire(ltags)
1182
+ planetag = gmsh.model.occ.addPlaneSurface([tag_wire,])
1183
+ poly = GeoPolygon([planetag,])
1184
+ return poly
1185
+
1186
+ def compile_paths(self, merge: bool = False) -> list[GeoPolygon] | GeoSurface:
1187
+ """Compiles the striplines and returns a list of polygons or asingle one.
1188
+
1189
+ The Z=0 argument determines the height of the striplines. Z=0 corresponds to the top of
1190
+ the PCB.
1191
+
1192
+ Args:
1193
+ merge (bool, optional): Whether to merge the Polygons into a single. Defaults to False.
1194
+
1195
+ Returns:
1196
+ list[Polygon] | GeoSurface: The output stripline polygons possibly merged if merge = True.
1197
+ """
1198
+ polys = []
1199
+ allx = []
1200
+ ally = []
1201
+
1202
+ for path in self.paths:
1203
+ z = path.z
1204
+ self.zs.append(z)
1205
+ xys = []
1206
+ for elemn in path.path:
1207
+ xys.extend(elemn.right)
1208
+ for element in path.path[::-1]:
1209
+ xys.extend(element.left)
1210
+
1211
+ xm, ym = xys[0]
1212
+ xys2 = [(xm,ym),]
1213
+
1214
+ for x,y in xys[1:]:
1215
+ if ((x-xm)**2 + (y-ym)**2)>1e-6:
1216
+ xys2.append((x,y))
1217
+ xm, ym = x, y
1218
+ allx.append(x)
1219
+ ally.append(y)
1220
+
1221
+ poly = self._gen_poly(xys2, z)
1222
+ poly.material = COPPER
1223
+ polys.append(poly)
1224
+
1225
+ for pcbpoly in self.polies:
1226
+ self.zs.append(pcbpoly.z)
1227
+ poly = self._gen_poly(pcbpoly.xys, pcbpoly.z)
1228
+ poly.material = pcbpoly.material
1229
+ polys.append(poly)
1230
+
1231
+ self.xs = allx
1232
+ self.ys = ally
1233
+
1234
+ self.traces = polys
1235
+ if merge:
1236
+ tags = []
1237
+ for p in polys:
1238
+ tags.extend(p.tags)
1239
+ if p.material != COPPER:
1240
+ logger.warning(f'Merging a polygon with material {p.material} into a single polygon that will be COPPER.')
1241
+ polys = GeoSurface(tags)
1242
+ polys.material = COPPER
1243
+ return polys
1244
+