emerge 0.4.7__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.
- emerge/__init__.py +14 -14
- emerge/_emerge/__init__.py +42 -0
- emerge/_emerge/bc.py +197 -0
- emerge/_emerge/coord.py +119 -0
- emerge/_emerge/cs.py +523 -0
- emerge/_emerge/dataset.py +36 -0
- emerge/_emerge/elements/__init__.py +19 -0
- emerge/_emerge/elements/femdata.py +212 -0
- emerge/_emerge/elements/index_interp.py +64 -0
- emerge/_emerge/elements/legrange2.py +172 -0
- emerge/_emerge/elements/ned2_interp.py +645 -0
- emerge/_emerge/elements/nedelec2.py +140 -0
- emerge/_emerge/elements/nedleg2.py +217 -0
- emerge/_emerge/geo/__init__.py +24 -0
- emerge/_emerge/geo/horn.py +107 -0
- emerge/_emerge/geo/modeler.py +449 -0
- emerge/_emerge/geo/operations.py +254 -0
- emerge/_emerge/geo/pcb.py +1244 -0
- emerge/_emerge/geo/pcb_tools/calculator.py +28 -0
- emerge/_emerge/geo/pcb_tools/macro.py +79 -0
- emerge/_emerge/geo/pmlbox.py +204 -0
- emerge/_emerge/geo/polybased.py +529 -0
- emerge/_emerge/geo/shapes.py +427 -0
- emerge/_emerge/geo/step.py +77 -0
- emerge/_emerge/geo2d.py +86 -0
- emerge/_emerge/geometry.py +510 -0
- emerge/_emerge/howto.py +214 -0
- emerge/_emerge/logsettings.py +5 -0
- emerge/_emerge/material.py +118 -0
- emerge/_emerge/mesh3d.py +730 -0
- emerge/_emerge/mesher.py +339 -0
- emerge/_emerge/mth/common_functions.py +33 -0
- emerge/_emerge/mth/integrals.py +71 -0
- emerge/_emerge/mth/optimized.py +357 -0
- emerge/_emerge/periodic.py +263 -0
- emerge/_emerge/physics/__init__.py +0 -0
- emerge/_emerge/physics/microwave/__init__.py +1 -0
- emerge/_emerge/physics/microwave/adaptive_freq.py +279 -0
- emerge/_emerge/physics/microwave/assembly/assembler.py +569 -0
- emerge/_emerge/physics/microwave/assembly/curlcurl.py +448 -0
- emerge/_emerge/physics/microwave/assembly/generalized_eigen.py +426 -0
- emerge/_emerge/physics/microwave/assembly/robinbc.py +433 -0
- emerge/_emerge/physics/microwave/microwave_3d.py +1150 -0
- emerge/_emerge/physics/microwave/microwave_bc.py +915 -0
- emerge/_emerge/physics/microwave/microwave_data.py +1148 -0
- emerge/_emerge/physics/microwave/periodic.py +82 -0
- emerge/_emerge/physics/microwave/port_functions.py +53 -0
- emerge/_emerge/physics/microwave/sc.py +175 -0
- emerge/_emerge/physics/microwave/simjob.py +147 -0
- emerge/_emerge/physics/microwave/sparam.py +138 -0
- emerge/_emerge/physics/microwave/touchstone.py +140 -0
- emerge/_emerge/plot/__init__.py +0 -0
- emerge/_emerge/plot/display.py +394 -0
- emerge/_emerge/plot/grapher.py +93 -0
- emerge/_emerge/plot/matplotlib/mpldisplay.py +264 -0
- emerge/_emerge/plot/pyvista/__init__.py +1 -0
- emerge/_emerge/plot/pyvista/display.py +931 -0
- emerge/_emerge/plot/pyvista/display_settings.py +24 -0
- emerge/_emerge/plot/simple_plots.py +551 -0
- emerge/_emerge/plot.py +225 -0
- emerge/_emerge/projects/__init__.py +0 -0
- emerge/_emerge/projects/_gen_base.txt +32 -0
- emerge/_emerge/projects/_load_base.txt +24 -0
- emerge/_emerge/projects/generate_project.py +40 -0
- emerge/_emerge/selection.py +596 -0
- emerge/_emerge/simmodel.py +444 -0
- emerge/_emerge/simulation_data.py +411 -0
- emerge/_emerge/solver.py +993 -0
- emerge/_emerge/system.py +54 -0
- emerge/cli.py +19 -0
- emerge/lib.py +1 -1
- emerge/plot.py +1 -1
- {emerge-0.4.7.dist-info → emerge-0.4.8.dist-info}/METADATA +1 -1
- emerge-0.4.8.dist-info/RECORD +78 -0
- emerge-0.4.8.dist-info/entry_points.txt +2 -0
- emerge-0.4.7.dist-info/RECORD +0 -9
- emerge-0.4.7.dist-info/entry_points.txt +0 -2
- {emerge-0.4.7.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
|
+
|