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,1150 @@
|
|
|
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 ...mesher import Mesher
|
|
19
|
+
from ...material import Material
|
|
20
|
+
from ...mesh3d import Mesh3D
|
|
21
|
+
from ...coord import Line
|
|
22
|
+
from ...elements.femdata import FEMBasis
|
|
23
|
+
from ...solver import DEFAULT_ROUTINE, SolveRoutine
|
|
24
|
+
from ...system import called_from_main_function
|
|
25
|
+
from ...selection import FaceSelection
|
|
26
|
+
from scipy.sparse.linalg import inv as sparse_inverse
|
|
27
|
+
from .microwave_bc import MWBoundaryConditionSet, PEC, ModalPort, LumpedPort, PortBC
|
|
28
|
+
from .microwave_data import MWData
|
|
29
|
+
from .assembly.assembler import Assembler
|
|
30
|
+
from .port_functions import compute_avg_power_flux
|
|
31
|
+
from .simjob import SimJob
|
|
32
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
33
|
+
import numpy as np
|
|
34
|
+
from loguru import logger
|
|
35
|
+
from typing import Callable, Literal
|
|
36
|
+
import time
|
|
37
|
+
import threading
|
|
38
|
+
import multiprocessing as mp
|
|
39
|
+
from cmath import sqrt as csqrt
|
|
40
|
+
|
|
41
|
+
class SimulationError(Exception):
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
def run_job_multi(job: SimJob) -> SimJob:
|
|
45
|
+
"""The job launcher for Multi-Processing environements
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
job (SimJob): The Simulation Job
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
SimJob: The solved SimJob
|
|
52
|
+
"""
|
|
53
|
+
routine = DEFAULT_ROUTINE.duplicate().configure('MP')
|
|
54
|
+
for A, b, ids, reuse in job.iter_Ab():
|
|
55
|
+
solution, report = routine.solve(A, b, ids, reuse, id=job.id)
|
|
56
|
+
job.submit_solution(solution, report)
|
|
57
|
+
return job
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _dimstring(data: list[float]) -> str:
|
|
61
|
+
"""A String formatter for dimensions in millimeters
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
data (list[float]): The list of floating point dimensions
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
str: The formatted string
|
|
68
|
+
"""
|
|
69
|
+
return '(' + ', '.join([f'{x*1000:.1f}mm' for x in data]) + ')'
|
|
70
|
+
|
|
71
|
+
def shortest_path(xyz1: np.ndarray, xyz2: np.ndarray, Npts: int) -> np.ndarray:
|
|
72
|
+
"""
|
|
73
|
+
Finds the pair of points (one from xyz1, one from xyz2) that are closest in Euclidean distance,
|
|
74
|
+
and returns a (3, Npts) array of points linearly interpolating between them.
|
|
75
|
+
|
|
76
|
+
Parameters:
|
|
77
|
+
xyz1 : np.ndarray of shape (3, N1)
|
|
78
|
+
xyz2 : np.ndarray of shape (3, N2)
|
|
79
|
+
Npts : int, number of points in the output path
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
np.ndarray of shape (3, Npts)
|
|
83
|
+
"""
|
|
84
|
+
# Compute pairwise distances (N1 x N2)
|
|
85
|
+
diffs = xyz1[:, :, np.newaxis] - xyz2[:, np.newaxis, :]
|
|
86
|
+
dists = np.linalg.norm(diffs, axis=0) # shape (N1, N2)
|
|
87
|
+
|
|
88
|
+
# Find indices of closest pair
|
|
89
|
+
i1, i2 = np.unravel_index(np.argmin(dists), dists.shape)
|
|
90
|
+
p1 = xyz1[:, i1]
|
|
91
|
+
p2 = xyz2[:, i2]
|
|
92
|
+
|
|
93
|
+
# Interpolate linearly between p1 and p2
|
|
94
|
+
t = np.linspace(0, 1, Npts)
|
|
95
|
+
path = (1 - t) * p1[:, np.newaxis] + t * p2[:, np.newaxis]
|
|
96
|
+
|
|
97
|
+
return path
|
|
98
|
+
|
|
99
|
+
class Microwave3D:
|
|
100
|
+
"""The Electrodynamics time harmonic physics class.
|
|
101
|
+
|
|
102
|
+
This class contains all physics dependent features to perform EM simuation in the time-harmonic
|
|
103
|
+
formulation.
|
|
104
|
+
|
|
105
|
+
"""
|
|
106
|
+
def __init__(self, mesher: Mesher, mwdata: MWData, order: int = 2):
|
|
107
|
+
self.frequencies: list[float] = []
|
|
108
|
+
self.current_frequency = 0
|
|
109
|
+
self.order: int = order
|
|
110
|
+
self.resolution: float = 1
|
|
111
|
+
|
|
112
|
+
self.mesher: Mesher = mesher
|
|
113
|
+
self.mesh: Mesh3D = None
|
|
114
|
+
|
|
115
|
+
self.assembler: Assembler = Assembler()
|
|
116
|
+
self.bc: MWBoundaryConditionSet = MWBoundaryConditionSet(None)
|
|
117
|
+
self.basis: FEMBasis = None
|
|
118
|
+
self.solveroutine: SolveRoutine = DEFAULT_ROUTINE
|
|
119
|
+
self.set_order(order)
|
|
120
|
+
self.cache_matrices: bool = True
|
|
121
|
+
|
|
122
|
+
## States
|
|
123
|
+
self._bc_initialized: bool = False
|
|
124
|
+
self.data: MWData = mwdata
|
|
125
|
+
|
|
126
|
+
## Data
|
|
127
|
+
self._params: dict[str, float] = dict()
|
|
128
|
+
self._simstart: int = 0
|
|
129
|
+
self._simend: int = 0
|
|
130
|
+
|
|
131
|
+
def reset_data(self):
|
|
132
|
+
self.data = MWData()
|
|
133
|
+
|
|
134
|
+
def reset(self):
|
|
135
|
+
self.bc.reset()
|
|
136
|
+
self.basis: FEMBasis = None
|
|
137
|
+
self.bc = MWBoundaryConditionSet(None)
|
|
138
|
+
self.solveroutine.reset()
|
|
139
|
+
|
|
140
|
+
def set_order(self, order: int) -> None:
|
|
141
|
+
"""Sets the order of the basis functions used. Currently only supports second order.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
order (int): The order to use.
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
ValueError: An error if a wrong order is used.
|
|
148
|
+
"""
|
|
149
|
+
if order not in (2,):
|
|
150
|
+
raise ValueError(f'Order {order} not supported. Only order-2 allowed.')
|
|
151
|
+
|
|
152
|
+
self.order = order
|
|
153
|
+
self.resolution = {1: 0.15, 2: 0.3}[order]
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def nports(self) -> int:
|
|
157
|
+
"""The number of ports in the physics.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
int: The number of ports
|
|
161
|
+
"""
|
|
162
|
+
return self.bc.count(PortBC)
|
|
163
|
+
|
|
164
|
+
def ports(self) -> list[PortBC]:
|
|
165
|
+
"""A list of all port boundary conditions.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
list[PortBC]: A list of all port boundary conditions
|
|
169
|
+
"""
|
|
170
|
+
return sorted(self.bc.oftype(PortBC), key=lambda x: x.number)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _initialize_bcs(self) -> None:
|
|
174
|
+
"""Initializes the boundary conditions to set PEC as all exterior boundaries.
|
|
175
|
+
"""
|
|
176
|
+
logger.debug('Initializing boundary conditions.')
|
|
177
|
+
|
|
178
|
+
tags = self.mesher.domain_boundary_face_tags
|
|
179
|
+
self.bc.PEC(FaceSelection(tags))
|
|
180
|
+
logger.info(f'Adding PEC boundary condition with tags {tags}.')
|
|
181
|
+
|
|
182
|
+
if self.mesher.periodic_cell is not None:
|
|
183
|
+
self.mesher.periodic_cell.generate_bcs()
|
|
184
|
+
for bc in self.mesher.periodic_cell.bcs:
|
|
185
|
+
self.bc.assign(bc)
|
|
186
|
+
|
|
187
|
+
def set_frequency(self, frequency: float | list[float] | np.ndarray ) -> None:
|
|
188
|
+
"""Define the frequencies for the frequency sweep
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
frequency (float | list[float] | np.ndarray): The frequency points.
|
|
192
|
+
"""
|
|
193
|
+
logger.info(f'Setting frequency as {frequency/1e6}MHz.')
|
|
194
|
+
if isinstance(frequency, (tuple, list, np.ndarray)):
|
|
195
|
+
self.frequencies = list(frequency)
|
|
196
|
+
else:
|
|
197
|
+
self.frequencies = [frequency]
|
|
198
|
+
|
|
199
|
+
self.mesher.max_size = self.resolution * 299792458 / max(self.frequencies)
|
|
200
|
+
self.mesher.min_size = 0.1 * self.mesher.max_size
|
|
201
|
+
|
|
202
|
+
logger.debug(f'Setting global mesh size range to: {self.mesher.min_size*1000:.3f}mm - {self.mesher.max_size*1000:.3f}mm')
|
|
203
|
+
|
|
204
|
+
def set_frequency_range(self, fmin: float, fmax: float, Npoints: int) -> None:
|
|
205
|
+
"""Set the frequency range using the np.linspace syntax
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
fmin (float): The starting frequency
|
|
209
|
+
fmax (float): The ending frequency
|
|
210
|
+
Npoints (int): The number of points
|
|
211
|
+
"""
|
|
212
|
+
self.set_frequency(np.linspace(fmin, fmax, Npoints))
|
|
213
|
+
|
|
214
|
+
def fdense(self, Npoints: int) -> np.ndarray:
|
|
215
|
+
if len(self.frequencies) == 1:
|
|
216
|
+
raise ValueError('Only 1 frequency point known. At least two need to be defined.')
|
|
217
|
+
fmin = min(self.frequencies)
|
|
218
|
+
fmax = max(self.frequencies)
|
|
219
|
+
return np.linspace(fmin, fmax, Npoints)
|
|
220
|
+
|
|
221
|
+
def set_resolution(self, resolution: float) -> None:
|
|
222
|
+
"""Define the simulation resolution as the fraction of the wavelength.
|
|
223
|
+
|
|
224
|
+
To define the wavelength as ¼λ, call .set_resolution(0.25)
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
resolution (float): The desired wavelength fraction.
|
|
228
|
+
|
|
229
|
+
"""
|
|
230
|
+
self.resolution = resolution
|
|
231
|
+
|
|
232
|
+
def set_conductivity_limit(self, condutivity: float) -> None:
|
|
233
|
+
"""Sets the limit of a material conductivity value beyond which
|
|
234
|
+
the assembler considers it PEC. By default this value is
|
|
235
|
+
set to 1·10⁷S/m which means copper conductivity is ignored.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
condutivity (float): The conductivity level in S/m
|
|
239
|
+
"""
|
|
240
|
+
if condutivity < 0:
|
|
241
|
+
raise ValueError('Conductivity values must be above 0. Ignoring assignment')
|
|
242
|
+
|
|
243
|
+
self.assembler.conductivity_limit = condutivity
|
|
244
|
+
|
|
245
|
+
def get_discretizer(self) -> Callable:
|
|
246
|
+
"""Returns a discretizer function that defines the maximum mesh size.
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
Callable: The discretizer function
|
|
250
|
+
"""
|
|
251
|
+
def disc(material: Material):
|
|
252
|
+
return 299792458/(max(self.frequencies) * np.real(material.neff))
|
|
253
|
+
return disc
|
|
254
|
+
|
|
255
|
+
def _initialize_field(self):
|
|
256
|
+
"""Initializes the physics basis to the correct FEMBasis object.
|
|
257
|
+
|
|
258
|
+
Currently it defaults to Nedelec2. Mixed basis are used for modal analysis.
|
|
259
|
+
This function does not have to be called by the user. Its automatically invoked.
|
|
260
|
+
"""
|
|
261
|
+
if self.basis is not None:
|
|
262
|
+
return
|
|
263
|
+
if self.order == 1:
|
|
264
|
+
raise NotImplementedError('Nedelec 1 is temporarily not supported')
|
|
265
|
+
from ...elements import Nedelec1
|
|
266
|
+
self.basis = Nedelec1(self.mesh)
|
|
267
|
+
elif self.order == 2:
|
|
268
|
+
from ...elements.nedelec2 import Nedelec2
|
|
269
|
+
self.basis = Nedelec2(self.mesh)
|
|
270
|
+
|
|
271
|
+
def _initialize_bc_data(self):
|
|
272
|
+
''' Initializes auxilliary required boundary condition information before running simulations.
|
|
273
|
+
'''
|
|
274
|
+
logger.debug('Initializing boundary conditions')
|
|
275
|
+
for port in self.bc.oftype(LumpedPort):
|
|
276
|
+
self.define_lumped_port_integration_points(port)
|
|
277
|
+
|
|
278
|
+
def define_lumped_port_integration_points(self, port: LumpedPort) -> None:
|
|
279
|
+
"""Sets the integration points on Lumped Port objects for voltage integration
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
port (LumpedPort): The LumpedPort object
|
|
283
|
+
|
|
284
|
+
Raises:
|
|
285
|
+
SimulationError: An error if there are no nodes associated with the port.
|
|
286
|
+
"""
|
|
287
|
+
logger.debug('Finding Lumped Port integration points')
|
|
288
|
+
field_axis = port.Vdirection.np
|
|
289
|
+
|
|
290
|
+
points = self.mesh.get_nodes(port.tags)
|
|
291
|
+
|
|
292
|
+
if points.size==0:
|
|
293
|
+
raise SimulationError(f'The lumped port {port} has no nodes associated with it')
|
|
294
|
+
xs = self.mesh.nodes[0,points]
|
|
295
|
+
ys = self.mesh.nodes[1,points]
|
|
296
|
+
zs = self.mesh.nodes[2,points]
|
|
297
|
+
|
|
298
|
+
dotprod = xs*field_axis[0] + ys*field_axis[1] + zs*field_axis[2]
|
|
299
|
+
|
|
300
|
+
start_id = points[np.argwhere(dotprod == np.min(dotprod))]
|
|
301
|
+
|
|
302
|
+
start = np.squeeze(np.mean(self.mesh.nodes[:,start_id],axis=1))
|
|
303
|
+
logger.info(f'Starting node = {_dimstring(start)}')
|
|
304
|
+
end = start + port.Vdirection.np*port.height
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
port.vintline = Line.from_points(start, end, 21)
|
|
308
|
+
|
|
309
|
+
logger.info(f'Ending node = {_dimstring(end)}')
|
|
310
|
+
port.voltage_integration_points = (start, end)
|
|
311
|
+
port.v_integration = True
|
|
312
|
+
|
|
313
|
+
def _compute_integration_line(self, group1: list[int], group2: list[int]) -> tuple[np.ndarray, np.ndarray]:
|
|
314
|
+
"""Computes an integration line for two node island groups by finding the closest two nodes.
|
|
315
|
+
|
|
316
|
+
This method is used for the modal TEM analysis to find an appropriate voltage integration path
|
|
317
|
+
by looking for the two closest points for the two conductor islands that where discovered.
|
|
318
|
+
|
|
319
|
+
Currently it defaults to 11 integration line points.
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
group1 (list[int]): The first island node group
|
|
323
|
+
group2 (list[int]): The second island node group
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
centers (np.ndarray): The center points of the line segments
|
|
327
|
+
dls (np.ndarray): The delta-path vectors for each line segment.
|
|
328
|
+
"""
|
|
329
|
+
nodes1 = self.mesh.nodes[:,group1]
|
|
330
|
+
nodes2 = self.mesh.nodes[:,group2]
|
|
331
|
+
path = shortest_path(nodes1, nodes2, 21)
|
|
332
|
+
centres = (path[:,1:] + path[:,:-1])/2
|
|
333
|
+
dls = path[:,1:] - path[:,:-1]
|
|
334
|
+
return centres, dls
|
|
335
|
+
|
|
336
|
+
def _find_tem_conductors(self, port: ModalPort, sigtri: np.ndarray) -> tuple[list[int], list[int]]:
|
|
337
|
+
''' Returns two lists of global node indices corresponding to the TEM port conductors.
|
|
338
|
+
|
|
339
|
+
This method is invoked during modal analysis with TEM modes. It looks at all edges
|
|
340
|
+
exterior to the boundary face triangulation and finds two small subsets of nodes that
|
|
341
|
+
lie on different exterior boundaries of the boundary face.
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
port (ModalPort): The modal port object.
|
|
345
|
+
|
|
346
|
+
Returns:
|
|
347
|
+
list[int]: A list of node integers of island 1.
|
|
348
|
+
list[int]: A list of node integers of island 2.
|
|
349
|
+
'''
|
|
350
|
+
|
|
351
|
+
logger.debug('Finding PEC TEM conductors')
|
|
352
|
+
pecs: list[PEC] = self.bc.oftype(PEC)
|
|
353
|
+
mesh = self.mesh
|
|
354
|
+
|
|
355
|
+
# Process all PEC Boundary Conditions
|
|
356
|
+
pec_edges = []
|
|
357
|
+
for pec in pecs:
|
|
358
|
+
face_tags = pec.tags
|
|
359
|
+
tri_ids = mesh.get_triangles(face_tags)
|
|
360
|
+
edge_ids = list(mesh.tri_to_edge[:,tri_ids].flatten())
|
|
361
|
+
pec_edges.extend(edge_ids)
|
|
362
|
+
|
|
363
|
+
# Process conductivity
|
|
364
|
+
for itri in mesh.get_triangles(port.tags):
|
|
365
|
+
if sigtri[itri] > 1e6:
|
|
366
|
+
edge_ids = list(mesh.tri_to_edge[:,itri].flatten())
|
|
367
|
+
pec_edges.extend(edge_ids)
|
|
368
|
+
|
|
369
|
+
pec_edges = set(pec_edges)
|
|
370
|
+
|
|
371
|
+
tri_ids = mesh.get_triangles(port.tags)
|
|
372
|
+
edge_ids = list(mesh.tri_to_edge[:,tri_ids].flatten())
|
|
373
|
+
|
|
374
|
+
pec_port = np.array([i for i in list(pec_edges) if i in set(edge_ids)])
|
|
375
|
+
|
|
376
|
+
pec_islands = mesh.find_edge_groups(pec_port)
|
|
377
|
+
|
|
378
|
+
self.basis._pec_islands = pec_islands
|
|
379
|
+
logger.debug(f'Found {len(pec_islands)} PEC islands.')
|
|
380
|
+
|
|
381
|
+
if len(pec_islands) != 2:
|
|
382
|
+
raise ValueError(f'Found {len(pec_islands)} PEC islands. Expected 2.')
|
|
383
|
+
|
|
384
|
+
groups = []
|
|
385
|
+
for island in pec_islands:
|
|
386
|
+
group = set()
|
|
387
|
+
for edge in island:
|
|
388
|
+
group.add(mesh.edges[0,edge])
|
|
389
|
+
group.add(mesh.edges[1,edge])
|
|
390
|
+
groups.append(sorted(list(group)))
|
|
391
|
+
|
|
392
|
+
group1 = groups[0]
|
|
393
|
+
group2 = groups[1]
|
|
394
|
+
|
|
395
|
+
return group1, group2
|
|
396
|
+
|
|
397
|
+
def _compute_modes(self, freq: float):
|
|
398
|
+
"""Compute the modal port modes for a given frequency. Used internally by the frequency domain study.
|
|
399
|
+
|
|
400
|
+
Args:
|
|
401
|
+
freq (float): The simulation frequency
|
|
402
|
+
"""
|
|
403
|
+
for bc in self.bc.oftype(ModalPort):
|
|
404
|
+
|
|
405
|
+
# If there is a port mode (at least one) and the port does not have mixed materials. No new analysis is needed
|
|
406
|
+
if not bc.mixed_materials and bc.initialized:
|
|
407
|
+
continue
|
|
408
|
+
|
|
409
|
+
self.modal_analysis(bc, 1, False, bc.TEM, freq=freq)
|
|
410
|
+
|
|
411
|
+
def modal_analysis(self,
|
|
412
|
+
port: ModalPort,
|
|
413
|
+
nmodes: int = 6,
|
|
414
|
+
direct: bool = True,
|
|
415
|
+
TEM: bool = False,
|
|
416
|
+
target_kz = None,
|
|
417
|
+
target_neff = None,
|
|
418
|
+
freq: float = None) -> None:
|
|
419
|
+
''' Execute a modal analysis on a given ModalPort boundary condition.
|
|
420
|
+
|
|
421
|
+
Parameters:
|
|
422
|
+
-----------
|
|
423
|
+
port : ModalPort
|
|
424
|
+
The port object to execute the analysis for.
|
|
425
|
+
direct : bool
|
|
426
|
+
Whether to use the direct solver (LAPACK) if True. Otherwise it uses the iterative
|
|
427
|
+
ARPACK solver. The ARPACK solver required an estimate for the propagation constant and is faster
|
|
428
|
+
for a large number of Degrees of Freedom.
|
|
429
|
+
TEM : bool = True
|
|
430
|
+
Whether to estimate the propagation constant assuming its a TEM transmisison line.
|
|
431
|
+
target_k0 : float
|
|
432
|
+
The expected propagation constant to find a mode for (direct = False).
|
|
433
|
+
target_neff : float
|
|
434
|
+
The expected effective mode index defined as kz/k0 (1.0 = free space, <1 = TE/TM, >1=slow wavees)
|
|
435
|
+
freq : float = None
|
|
436
|
+
The desired frequency at which the mode is solved. If None then it uses the lowest frequency of the provided range.
|
|
437
|
+
'''
|
|
438
|
+
T0 = time.time()
|
|
439
|
+
if self.bc._initialized is False:
|
|
440
|
+
raise SimulationError('Cannot run a modal analysis because no boundary conditions have been assigned.')
|
|
441
|
+
|
|
442
|
+
self._initialize_field()
|
|
443
|
+
self._initialize_bc_data()
|
|
444
|
+
|
|
445
|
+
logger.debug('Retreiving material properties.')
|
|
446
|
+
ertet = self.mesh.retreive(lambda mat,x,y,z: mat.fer3d_mat(x,y,z), self.mesher.volumes)
|
|
447
|
+
urtet = self.mesh.retreive(lambda mat,x,y,z: mat.fur3d_mat(x,y,z), self.mesher.volumes)
|
|
448
|
+
condtet = self.mesh.retreive(lambda mat,x,y,z: mat.cond, self.mesher.volumes)[0,0,:]
|
|
449
|
+
|
|
450
|
+
er = np.zeros((3,3,self.mesh.n_tris,), dtype=np.complex128)
|
|
451
|
+
ur = np.zeros((3,3,self.mesh.n_tris,), dtype=np.complex128)
|
|
452
|
+
cond = np.zeros((self.mesh.n_tris,), dtype=np.complex128)
|
|
453
|
+
|
|
454
|
+
for itri in range(self.mesh.n_tris):
|
|
455
|
+
itet = self.mesh.tri_to_tet[0,itri]
|
|
456
|
+
er[:,:,itri] = ertet[:,:,itet]
|
|
457
|
+
ur[:,:,itri] = urtet[:,:,itet]
|
|
458
|
+
cond[itri] = condtet[itet]
|
|
459
|
+
|
|
460
|
+
itri_port = self.mesh.get_triangles(port.tags)
|
|
461
|
+
|
|
462
|
+
ermean = np.mean(er[er>0].flatten()[itri_port])
|
|
463
|
+
urmean = np.mean(ur[ur>0].flatten()[itri_port])
|
|
464
|
+
ermax = np.max(er[:,:,itri_port].flatten())
|
|
465
|
+
urmax = np.max(ur[:,:,itri_port].flatten())
|
|
466
|
+
|
|
467
|
+
if freq is None:
|
|
468
|
+
freq = self.frequencies[0]
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
k0 = 2*np.pi*freq/299792458
|
|
472
|
+
kmax = k0*np.sqrt(ermax.real*urmax.real)
|
|
473
|
+
|
|
474
|
+
logger.info('Assembling BMA Matrices')
|
|
475
|
+
|
|
476
|
+
Amatrix, Bmatrix, solve_ids, nlf = self.assembler.assemble_bma_matrices(self.basis, er, ur, cond, k0, port, self.bc.boundary_conditions)
|
|
477
|
+
|
|
478
|
+
logger.debug(f'Total of {Amatrix.shape[0]} Degrees of freedom.')
|
|
479
|
+
logger.debug(f'Applied frequency: {freq/1e9:.2f}GHz')
|
|
480
|
+
logger.debug(f'K0 = {k0} rad/m')
|
|
481
|
+
|
|
482
|
+
F = -1
|
|
483
|
+
|
|
484
|
+
if target_neff is not None:
|
|
485
|
+
target_kz = k0*target_neff
|
|
486
|
+
|
|
487
|
+
if target_kz is None:
|
|
488
|
+
if TEM:
|
|
489
|
+
target_kz = ermean*urmean*1.1*k0
|
|
490
|
+
else:
|
|
491
|
+
target_kz = ermean*urmean*0.7*k0
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
logger.debug(f'Solving for {solve_ids.shape[0]} degrees of freedom.')
|
|
495
|
+
|
|
496
|
+
eigen_values, eigen_modes, report = self.solveroutine.eig_boundary(Amatrix, Bmatrix, solve_ids, nmodes, direct, target_kz, sign=-1)
|
|
497
|
+
|
|
498
|
+
logger.debug(f'Eigenvalues: {np.sqrt(F*eigen_values)} rad/m')
|
|
499
|
+
|
|
500
|
+
port._er = er
|
|
501
|
+
port._ur = ur
|
|
502
|
+
|
|
503
|
+
nmodes_found = eigen_values.shape[0]
|
|
504
|
+
|
|
505
|
+
for i in range(nmodes_found):
|
|
506
|
+
|
|
507
|
+
Emode = np.zeros((nlf.n_field,), dtype=np.complex128)
|
|
508
|
+
eigenmode = eigen_modes[:,i]
|
|
509
|
+
Emode[solve_ids] = np.squeeze(eigenmode)
|
|
510
|
+
Emode = Emode * np.exp(-1j*np.angle(np.max(Emode)))
|
|
511
|
+
|
|
512
|
+
beta = min(k0*np.sqrt(ermax*urmax), np.emath.sqrt(-eigen_values[i]))
|
|
513
|
+
#beta = np.emath.sqrt(eigen_values[i])
|
|
514
|
+
residuals = -1
|
|
515
|
+
|
|
516
|
+
portfE = nlf.interpolate_Ef(Emode)
|
|
517
|
+
portfH = nlf.interpolate_Hf(Emode, k0, ur, beta)
|
|
518
|
+
|
|
519
|
+
P = compute_avg_power_flux(nlf, Emode, k0, ur, beta)
|
|
520
|
+
|
|
521
|
+
mode = port.add_mode(Emode, portfE, portfH, beta, k0, residuals, TEM=TEM, freq=freq)
|
|
522
|
+
if mode is None:
|
|
523
|
+
continue
|
|
524
|
+
|
|
525
|
+
Efxy = Emode[:nlf.n_xy]
|
|
526
|
+
Efz = Emode[nlf.n_xy:]
|
|
527
|
+
Ez = np.max(np.abs(Efz))
|
|
528
|
+
Exy = np.max(np.abs(Efxy))
|
|
529
|
+
|
|
530
|
+
# Exy = np.max(np.max(Emode))
|
|
531
|
+
# Ez = 0
|
|
532
|
+
if Ez/Exy < 1e-3 and not TEM:
|
|
533
|
+
logger.debug('Low Ez/Et ratio detected, assuming TE mode')
|
|
534
|
+
mode.modetype = 'TE'
|
|
535
|
+
elif Ez/Exy > 1e-3 and not TEM:
|
|
536
|
+
logger.debug('High Ez/Et ratio detected, assuming TM mode')
|
|
537
|
+
mode.modetype = 'TM'
|
|
538
|
+
elif TEM:
|
|
539
|
+
G1, G2 = self._find_tem_conductors(port, sigtri=cond)
|
|
540
|
+
cs, dls = self._compute_integration_line(G1,G2)
|
|
541
|
+
mode.modetype='TEM'
|
|
542
|
+
Ex, Ey, Ez = portfE(cs[0,:], cs[1,:], cs[2,:])
|
|
543
|
+
voltage = np.sum(Ex*dls[0,:] + Ey*dls[1,:] + Ez*dls[2,:])
|
|
544
|
+
mode.Z0 = voltage**2/(2*P)
|
|
545
|
+
logger.debug(f'Port Z0 = {mode.Z0}')
|
|
546
|
+
|
|
547
|
+
mode.set_power(P*port._qmode(k0)**2)
|
|
548
|
+
|
|
549
|
+
port.sort_modes()
|
|
550
|
+
|
|
551
|
+
logger.info(f'Total of {port.nmodes} found')
|
|
552
|
+
|
|
553
|
+
T2 = time.time()
|
|
554
|
+
logger.info(f'Elapsed time = {(T2-T0):.2f} seconds.')
|
|
555
|
+
return None
|
|
556
|
+
|
|
557
|
+
def frequency_domain(self,
|
|
558
|
+
parallel: bool = False,
|
|
559
|
+
njobs: int = 2,
|
|
560
|
+
harddisc_threshold: int = None,
|
|
561
|
+
harddisc_path: str = 'EMergeSparse',
|
|
562
|
+
frequency_groups: int = -1,
|
|
563
|
+
multi_processing: bool = False,
|
|
564
|
+
automatic_modal_analysis: bool = True) -> MWData:
|
|
565
|
+
"""Executes a frequency domain study
|
|
566
|
+
|
|
567
|
+
The study is distributed over "njobs" workers.
|
|
568
|
+
As optional parameter you may set a harddisc_threshold as integer. This determines the maximum
|
|
569
|
+
number of degrees of freedom before which the jobs will be cahced to the harddisk. The
|
|
570
|
+
path that will be used to cache the sparse matrices can be specified.
|
|
571
|
+
Additionally the term frequency_groups may be specified. This number will define in how
|
|
572
|
+
many groups the matrices will be pre-computed before they are send to workers. This can minimize
|
|
573
|
+
the total amound of RAM memory used. For example with 11 frequencies in gruops of 4, the following
|
|
574
|
+
frequency indices will be precomputed and then solved: [[1,2,3,4],[5,6,7,8],[9,10,11]]
|
|
575
|
+
|
|
576
|
+
Args:
|
|
577
|
+
njobs (int, optional): The number of jobs. Defaults to 2.
|
|
578
|
+
harddisc_threshold (int, optional): The number of DOF limit. Defaults to None.
|
|
579
|
+
harddisc_path (str, optional): The cached matrix path name. Defaults to 'EMergeSparse'.
|
|
580
|
+
frequency_groups (int, optional): The number of frequency points in a solve group. Defaults to -1.
|
|
581
|
+
automatic_modal_analysis (bool, optional): Automatically compute port modes. Defaults to False.
|
|
582
|
+
multi_processing (bool, optional): Whether to use multiprocessing instead of multi-threaded (slower on most machines).
|
|
583
|
+
|
|
584
|
+
Raises:
|
|
585
|
+
SimulationError: An error associated witha a problem during the simulation.
|
|
586
|
+
|
|
587
|
+
Returns:
|
|
588
|
+
MWSimData: The dataset.
|
|
589
|
+
"""
|
|
590
|
+
|
|
591
|
+
self._simstart = time.time()
|
|
592
|
+
if self.bc._initialized is False:
|
|
593
|
+
raise SimulationError('Cannot run a modal analysis because no boundary conditions have been assigned.')
|
|
594
|
+
|
|
595
|
+
self._initialize_field()
|
|
596
|
+
self._initialize_bc_data()
|
|
597
|
+
|
|
598
|
+
er = self.mesh.retreive(lambda mat,x,y,z: mat.fer3d_mat(x,y,z), self.mesher.volumes)
|
|
599
|
+
ur = self.mesh.retreive(lambda mat,x,y,z: mat.fur3d_mat(x,y,z), self.mesher.volumes)
|
|
600
|
+
cond = self.mesh.retreive(lambda mat,x,y,z: mat.cond, self.mesher.volumes)[0,0,:]
|
|
601
|
+
|
|
602
|
+
### Does this move
|
|
603
|
+
logger.debug('Initializing frequency domain sweep.')
|
|
604
|
+
|
|
605
|
+
#### Port settings
|
|
606
|
+
all_ports = self.bc.oftype(PortBC)
|
|
607
|
+
|
|
608
|
+
##### FOR PORT SWEEP SET ALL ACTIVE TO FALSE. THIS SHOULD BE FIXED LATER
|
|
609
|
+
### COMPUTE WHICH TETS ARE CONNECTED TO PORT INDICES
|
|
610
|
+
|
|
611
|
+
for port in all_ports:
|
|
612
|
+
port.active=False
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
logger.info(f'Pre-assembling matrices of {len(self.frequencies)} frequency points.')
|
|
616
|
+
|
|
617
|
+
# Thread-local storage for per-thread resources
|
|
618
|
+
thread_local = threading.local()
|
|
619
|
+
|
|
620
|
+
## DEFINE SOLVE FUNCTIONS
|
|
621
|
+
def get_routine():
|
|
622
|
+
if not hasattr(thread_local, "routine"):
|
|
623
|
+
thread_local.routine = self.solveroutine.duplicate().configure('MT')
|
|
624
|
+
return thread_local.routine
|
|
625
|
+
|
|
626
|
+
def run_job(job: SimJob):
|
|
627
|
+
routine = get_routine()
|
|
628
|
+
for A, b, ids, reuse in job.iter_Ab():
|
|
629
|
+
solution, report = routine.solve(A, b, ids, reuse, id=job.id)
|
|
630
|
+
job.submit_solution(solution, report)
|
|
631
|
+
return job
|
|
632
|
+
|
|
633
|
+
def run_job_single(job: SimJob):
|
|
634
|
+
for A, b, ids, reuse in job.iter_Ab():
|
|
635
|
+
solution, report = self.solveroutine.solve(A, b, ids, reuse, id=job.id)
|
|
636
|
+
job.submit_solution(solution, report)
|
|
637
|
+
return job
|
|
638
|
+
|
|
639
|
+
## GROUP FREQUENCIES
|
|
640
|
+
# Each frequency group will be pre-assembled before submitting them to the parallel pool
|
|
641
|
+
freq_groups = []
|
|
642
|
+
if frequency_groups == -1:
|
|
643
|
+
freq_groups=[self.frequencies,]
|
|
644
|
+
else:
|
|
645
|
+
n = frequency_groups
|
|
646
|
+
freq_groups = [self.frequencies[i:i+n] for i in range(0, len(self.frequencies), n)]
|
|
647
|
+
|
|
648
|
+
results: list[SimJob] = []
|
|
649
|
+
|
|
650
|
+
## Single threaded
|
|
651
|
+
job_id = 1
|
|
652
|
+
|
|
653
|
+
self._compute_modes(sum(self.frequencies)/len(self.frequencies))
|
|
654
|
+
|
|
655
|
+
if not parallel:
|
|
656
|
+
# ITERATE OVER FREQUENCIES
|
|
657
|
+
freq_groups
|
|
658
|
+
for i_group, fgroup in enumerate(freq_groups):
|
|
659
|
+
logger.debug(f'Precomputing group {i_group}.')
|
|
660
|
+
jobs = []
|
|
661
|
+
## Assemble jobs
|
|
662
|
+
for ifreq, freq in enumerate(fgroup):
|
|
663
|
+
logger.debug(f'Simulation frequency = {freq/1e9:.3f} GHz')
|
|
664
|
+
if automatic_modal_analysis:
|
|
665
|
+
self._compute_modes(freq)
|
|
666
|
+
job = self.assembler.assemble_freq_matrix(self.basis, er, ur, cond,
|
|
667
|
+
self.bc.boundary_conditions,
|
|
668
|
+
freq,
|
|
669
|
+
cache_matrices=self.cache_matrices)
|
|
670
|
+
job.store_limit = harddisc_threshold
|
|
671
|
+
job.relative_path = harddisc_path
|
|
672
|
+
job.id = job_id
|
|
673
|
+
job_id += 1
|
|
674
|
+
jobs.append(job)
|
|
675
|
+
|
|
676
|
+
logger.info(f'Starting single threaded solve of {len(jobs)} jobs.')
|
|
677
|
+
group_results = [run_job_single(job) for job in jobs]
|
|
678
|
+
results.extend(group_results)
|
|
679
|
+
elif not multi_processing:
|
|
680
|
+
# MULTI THREADED
|
|
681
|
+
with ThreadPoolExecutor(max_workers=njobs) as executor:
|
|
682
|
+
# ITERATE OVER FREQUENCIES
|
|
683
|
+
for i_group, fgroup in enumerate(freq_groups):
|
|
684
|
+
logger.debug(f'Precomputing group {i_group}.')
|
|
685
|
+
jobs = []
|
|
686
|
+
## Assemble jobs
|
|
687
|
+
for freq in fgroup:
|
|
688
|
+
logger.debug(f'Simulation frequency = {freq/1e9:.3f} GHz')
|
|
689
|
+
if automatic_modal_analysis:
|
|
690
|
+
self._compute_modes(freq)
|
|
691
|
+
job = self.assembler.assemble_freq_matrix(self.basis, er, ur, cond,
|
|
692
|
+
self.bc.boundary_conditions,
|
|
693
|
+
freq,
|
|
694
|
+
cache_matrices=self.cache_matrices)
|
|
695
|
+
job.store_limit = harddisc_threshold
|
|
696
|
+
job.relative_path = harddisc_path
|
|
697
|
+
job.id = job_id
|
|
698
|
+
job_id += 1
|
|
699
|
+
jobs.append(job)
|
|
700
|
+
|
|
701
|
+
logger.info(f'Starting distributed solve of {len(jobs)} jobs with {njobs} threads.')
|
|
702
|
+
group_results = list(executor.map(run_job, jobs))
|
|
703
|
+
results.extend(group_results)
|
|
704
|
+
executor.shutdown()
|
|
705
|
+
else:
|
|
706
|
+
### MULTI PROCESSING
|
|
707
|
+
# Check for if __name__=="__main__" Guard
|
|
708
|
+
if not called_from_main_function():
|
|
709
|
+
raise SimulationError(
|
|
710
|
+
"Multiprocess support must be launched from your "
|
|
711
|
+
"if __name__ == '__main__' guard in the top-level script."
|
|
712
|
+
)
|
|
713
|
+
# Start parallel pool
|
|
714
|
+
with mp.Pool(processes=njobs) as pool:
|
|
715
|
+
for i_group, fgroup in enumerate(freq_groups):
|
|
716
|
+
logger.debug(f'Precomputing group {i_group}.')
|
|
717
|
+
jobs = []
|
|
718
|
+
# Assemble jobs
|
|
719
|
+
for freq in fgroup:
|
|
720
|
+
logger.debug(f'Simulation frequency = {freq/1e9:.3f} GHz')
|
|
721
|
+
if automatic_modal_analysis:
|
|
722
|
+
self._compute_modes(freq)
|
|
723
|
+
|
|
724
|
+
job = self.assembler.assemble_freq_matrix(
|
|
725
|
+
self.basis, er, ur, cond,
|
|
726
|
+
self.bc.boundary_conditions,
|
|
727
|
+
freq,
|
|
728
|
+
cache_matrices=self.cache_matrices
|
|
729
|
+
)
|
|
730
|
+
|
|
731
|
+
job.store_limit = harddisc_threshold
|
|
732
|
+
job.relative_path = harddisc_path
|
|
733
|
+
job.id = job_id
|
|
734
|
+
job_id += 1
|
|
735
|
+
jobs.append(job)
|
|
736
|
+
|
|
737
|
+
logger.info(
|
|
738
|
+
f'Starting distributed solve of {len(jobs)} jobs '
|
|
739
|
+
f'with {njobs} processes in parallel'
|
|
740
|
+
)
|
|
741
|
+
# Distribute taks
|
|
742
|
+
group_results = pool.map(run_job_multi, jobs)
|
|
743
|
+
results.extend(group_results)
|
|
744
|
+
|
|
745
|
+
thread_local.__dict__.clear()
|
|
746
|
+
logger.info('Solving complete')
|
|
747
|
+
|
|
748
|
+
### Compute S-parameters and return
|
|
749
|
+
self._post_process(results, er, ur, cond)
|
|
750
|
+
return self.data
|
|
751
|
+
|
|
752
|
+
def eigenmode(self, search_frequency: float,
|
|
753
|
+
nmodes: int = 6,
|
|
754
|
+
k0_limit: float = 1,
|
|
755
|
+
direct: bool = False,
|
|
756
|
+
deep_search: bool = False,
|
|
757
|
+
mode: Literal['LM','LR','SR','LI','SI']='LM') -> MWData:
|
|
758
|
+
"""Executes an eigenmode study
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
Args:
|
|
763
|
+
search_frequency (float): The frequency around which you would like to search
|
|
764
|
+
nmodes (int, optional): The number of jobs. Defaults to 6.
|
|
765
|
+
k0_limit (float): The lowest k0 value before which a mode is considered part of the null space. Defaults to 1e-3
|
|
766
|
+
Raises:
|
|
767
|
+
SimulationError: An error associated witha a problem during the simulation.
|
|
768
|
+
|
|
769
|
+
Returns:
|
|
770
|
+
MWSimData: The dataset.
|
|
771
|
+
"""
|
|
772
|
+
|
|
773
|
+
self._simstart = time.time()
|
|
774
|
+
if self.bc._initialized is False:
|
|
775
|
+
raise SimulationError('Cannot run a modal analysis because no boundary conditions have been assigned.')
|
|
776
|
+
|
|
777
|
+
self._initialize_field()
|
|
778
|
+
self._initialize_bc_data()
|
|
779
|
+
|
|
780
|
+
er = self.mesh.retreive(lambda mat,x,y,z: mat.fer3d_mat(x,y,z), self.mesher.volumes)
|
|
781
|
+
ur = self.mesh.retreive(lambda mat,x,y,z: mat.fur3d_mat(x,y,z), self.mesher.volumes)
|
|
782
|
+
cond = self.mesh.retreive(lambda mat,x,y,z: mat.cond, self.mesher.volumes)[0,0,:]
|
|
783
|
+
|
|
784
|
+
### Does this move
|
|
785
|
+
logger.debug('Initializing frequency domain sweep.')
|
|
786
|
+
|
|
787
|
+
logger.info(f'Pre-assembling matrices of {len(self.frequencies)} frequency points.')
|
|
788
|
+
|
|
789
|
+
job = self.assembler.assemble_eig_matrix(self.basis, er, ur, cond,
|
|
790
|
+
self.bc.boundary_conditions, search_frequency)
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
logger.info('Solving complete')
|
|
794
|
+
|
|
795
|
+
A, C, solve_ids = job.yield_AC()
|
|
796
|
+
|
|
797
|
+
target_k0 = 2*np.pi*search_frequency/299792458
|
|
798
|
+
|
|
799
|
+
eigen_values, eigen_modes, report = self.solveroutine.eig(A, C, solve_ids, nmodes, direct, target_k0, which=mode)
|
|
800
|
+
|
|
801
|
+
eigen_modes = job.fix_solutions(eigen_modes)
|
|
802
|
+
|
|
803
|
+
logger.debug(f'Eigenvalues: {np.sqrt(eigen_values)} rad/m')
|
|
804
|
+
|
|
805
|
+
nmodes_found = eigen_values.shape[0]
|
|
806
|
+
|
|
807
|
+
for i in range(nmodes_found):
|
|
808
|
+
|
|
809
|
+
Emode = np.zeros((self.basis.n_field,), dtype=np.complex128)
|
|
810
|
+
eig_k0 = np.sqrt(eigen_values[i])
|
|
811
|
+
if eig_k0 < k0_limit:
|
|
812
|
+
logger.debug(f'Ignoring mode due to low k0: {eig_k0} < {k0_limit}')
|
|
813
|
+
continue
|
|
814
|
+
eig_freq = eig_k0*299792458/(2*np.pi)
|
|
815
|
+
|
|
816
|
+
logger.debug(f'Found k0={eig_k0:.2f}, f0={eig_freq/1e9:.2f} GHz')
|
|
817
|
+
Emode = eigen_modes[:,i]
|
|
818
|
+
|
|
819
|
+
scalardata = self.data.scalar.new(freq=eig_freq, **self._params)
|
|
820
|
+
scalardata.k0 = eig_k0
|
|
821
|
+
scalardata.freq = eig_freq
|
|
822
|
+
|
|
823
|
+
fielddata = self.data.field.new(freq=eig_freq, **self._params)
|
|
824
|
+
fielddata.freq = eig_freq
|
|
825
|
+
fielddata._der = np.squeeze(er[0,0,:])
|
|
826
|
+
fielddata._dur = np.squeeze(ur[0,0,:])
|
|
827
|
+
fielddata._mode_field = Emode
|
|
828
|
+
fielddata.basis = self.basis
|
|
829
|
+
### Compute S-parameters and return
|
|
830
|
+
|
|
831
|
+
return self.data
|
|
832
|
+
|
|
833
|
+
def _post_process(self, results: list[SimJob], er: np.ndarray, ur: np.ndarray, cond: np.ndarray):
|
|
834
|
+
"""Compute the S-parameters after Frequency sweep
|
|
835
|
+
|
|
836
|
+
Args:
|
|
837
|
+
results (list[SimJob]): The set of simulation results
|
|
838
|
+
er (np.ndarray): The domain εᵣ
|
|
839
|
+
ur (np.ndarray): The domain μᵣ
|
|
840
|
+
cond (np.ndarray): The domain conductivity
|
|
841
|
+
"""
|
|
842
|
+
mesh = self.mesh
|
|
843
|
+
all_ports = self.bc.oftype(PortBC)
|
|
844
|
+
port_numbers = [port.port_number for port in all_ports]
|
|
845
|
+
all_port_tets = self.mesh.get_face_tets(*[port.tags for port in all_ports])
|
|
846
|
+
|
|
847
|
+
logger.info('Computing S-parameters')
|
|
848
|
+
|
|
849
|
+
ertri = np.zeros((3,3,self.mesh.n_tris), dtype=np.complex128)
|
|
850
|
+
urtri = np.zeros((3,3,self.mesh.n_tris), dtype=np.complex128)
|
|
851
|
+
condtri = np.zeros((self.mesh.n_tris,), dtype=np.complex128)
|
|
852
|
+
|
|
853
|
+
for itri in range(self.mesh.n_tris):
|
|
854
|
+
itet = self.mesh.tri_to_tet[0,itri]
|
|
855
|
+
ertri[:,:,itri] = er[:,:,itet]
|
|
856
|
+
urtri[:,:,itri] = ur[:,:,itet]
|
|
857
|
+
condtri[itri] = cond[itet]
|
|
858
|
+
|
|
859
|
+
for freq, job in zip(self.frequencies, results):
|
|
860
|
+
|
|
861
|
+
k0 = 2*np.pi*freq/299792458
|
|
862
|
+
|
|
863
|
+
scalardata = self.data.scalar.new(freq=freq, **self._params)
|
|
864
|
+
scalardata.k0 = k0
|
|
865
|
+
scalardata.freq = freq
|
|
866
|
+
scalardata.init_sp(port_numbers)
|
|
867
|
+
|
|
868
|
+
fielddata = self.data.field.new(freq=freq, **self._params)
|
|
869
|
+
fielddata.freq = freq
|
|
870
|
+
fielddata._der = np.squeeze(er[0,0,:])
|
|
871
|
+
fielddata._dur = np.squeeze(ur[0,0,:])
|
|
872
|
+
|
|
873
|
+
self.data.setreport(job.reports, freq=freq, **self._params)
|
|
874
|
+
|
|
875
|
+
logger.info(f'Post Processing simulation frequency = {freq/1e9:.3f} GHz')
|
|
876
|
+
|
|
877
|
+
# Recording port information
|
|
878
|
+
for active_port in all_ports:
|
|
879
|
+
fielddata.add_port_properties(active_port.port_number,
|
|
880
|
+
mode_number=active_port.mode_number,
|
|
881
|
+
k0 = k0,
|
|
882
|
+
beta = active_port.get_beta(k0),
|
|
883
|
+
Z0 = active_port.portZ0(k0),
|
|
884
|
+
Pout= active_port.power)
|
|
885
|
+
scalardata.add_port_properties(active_port.port_number,
|
|
886
|
+
mode_number=active_port.mode_number,
|
|
887
|
+
k0 = k0,
|
|
888
|
+
beta = active_port.get_beta(k0),
|
|
889
|
+
Z0 = active_port.portZ0(k0),
|
|
890
|
+
Pout= active_port.power)
|
|
891
|
+
|
|
892
|
+
# Set port as active and add the port mode to the forcing vector
|
|
893
|
+
active_port.active = True
|
|
894
|
+
|
|
895
|
+
solution = job._fields[active_port.port_number]
|
|
896
|
+
|
|
897
|
+
fielddata._fields = job._fields
|
|
898
|
+
fielddata.basis = self.basis
|
|
899
|
+
# Compute the S-parameters
|
|
900
|
+
# Define the field interpolation function
|
|
901
|
+
fieldf = self.basis.interpolate_Ef(solution, tetids=all_port_tets)
|
|
902
|
+
Pout = 0
|
|
903
|
+
|
|
904
|
+
# Active port power
|
|
905
|
+
logger.debug('Active ports:')
|
|
906
|
+
tris = mesh.get_triangles(active_port.tags)
|
|
907
|
+
tri_vertices = mesh.tris[:,tris]
|
|
908
|
+
pfield, pmode = self._compute_s_data(active_port, fieldf, tri_vertices, k0, ertri[:,:,tris], urtri[:,:,tris])
|
|
909
|
+
logger.debug(f' Field Amplitude = {np.abs(pfield):.3f}, Excitation = {np.abs(pmode):.2f}')
|
|
910
|
+
Pout = pmode
|
|
911
|
+
|
|
912
|
+
#Passive ports
|
|
913
|
+
logger.debug('Passive ports:')
|
|
914
|
+
for bc in all_ports:
|
|
915
|
+
tris = mesh.get_triangles(bc.tags)
|
|
916
|
+
tri_vertices = mesh.tris[:,tris]
|
|
917
|
+
pfield, pmode = self._compute_s_data(bc, fieldf,tri_vertices, k0, ertri[:,:,tris], urtri[:,:,tris])
|
|
918
|
+
logger.debug(f' Field amplitude = {np.abs(pfield):.3f}, Excitation= {np.abs(pmode):.2f}')
|
|
919
|
+
scalardata.write_S(bc.port_number, active_port.port_number, pfield/Pout)
|
|
920
|
+
active_port.active=False
|
|
921
|
+
|
|
922
|
+
fielddata.set_field_vector()
|
|
923
|
+
|
|
924
|
+
logger.info('Simulation Complete!')
|
|
925
|
+
self._simend = time.time()
|
|
926
|
+
logger.info(f'Elapsed time = {(self._simend-self._simstart):.2f} seconds.')
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
def _compute_s_data(self, bc: PortBC,
|
|
930
|
+
fieldfunction: Callable,
|
|
931
|
+
tri_vertices: np.ndarray,
|
|
932
|
+
k0: float,
|
|
933
|
+
erp: np.ndarray,
|
|
934
|
+
urp: np.ndarray,) -> tuple[complex, complex]:
|
|
935
|
+
""" Computes the S-parameter data for a given boundary condition and field function.
|
|
936
|
+
|
|
937
|
+
Args:
|
|
938
|
+
bc (PortBC): The port boundary condition
|
|
939
|
+
fieldfunction (Callable): The field function that interpolates the solution field.
|
|
940
|
+
tri_vertices (np.ndarray): The triangle vertex indices of the port face
|
|
941
|
+
k₀ (float): The simulation phase constant
|
|
942
|
+
erp (np.ndarray): The εᵣ of the port face triangles
|
|
943
|
+
urp (np.ndarray): The μᵣ of the port face triangles.
|
|
944
|
+
|
|
945
|
+
Returns:
|
|
946
|
+
tuple[complex, complex]: _description_
|
|
947
|
+
"""
|
|
948
|
+
from .sparam import sparam_field_power, sparam_mode_power
|
|
949
|
+
if bc.v_integration:
|
|
950
|
+
V = bc.vintline.line_integral(fieldfunction)
|
|
951
|
+
|
|
952
|
+
if bc.active:
|
|
953
|
+
a = bc.voltage
|
|
954
|
+
b = (V-bc.voltage)
|
|
955
|
+
else:
|
|
956
|
+
a = 0
|
|
957
|
+
b = V
|
|
958
|
+
|
|
959
|
+
a = a*csqrt(1/(2*bc.Z0))
|
|
960
|
+
b = b*csqrt(1/(2*bc.Z0))
|
|
961
|
+
|
|
962
|
+
return b, a
|
|
963
|
+
else:
|
|
964
|
+
if bc.modetype(k0) == 'TEM':
|
|
965
|
+
const = 1/(np.sqrt((urp[0,0,:] + urp[1,1,:] + urp[2,2,:])/(erp[0,0,:] + erp[1,1,:] + erp[2,2,:])))
|
|
966
|
+
if bc.modetype(k0) == 'TE':
|
|
967
|
+
const = 1/((urp[0,0,:] + urp[1,1,:] + urp[2,2,:])/3)
|
|
968
|
+
elif bc.modetype(k0) == 'TM':
|
|
969
|
+
const = 1/((erp[0,0,:] + erp[1,1,:] + erp[2,2,:])/3)
|
|
970
|
+
const = np.squeeze(const)
|
|
971
|
+
field_p = sparam_field_power(self.mesh.nodes, tri_vertices, bc, k0, fieldfunction, const)
|
|
972
|
+
mode_p = sparam_mode_power(self.mesh.nodes, tri_vertices, bc, k0, const)
|
|
973
|
+
return field_p, mode_p
|
|
974
|
+
|
|
975
|
+
# def frequency_domain_single(self, automatic_modal_analysis: bool = False) -> MWData:
|
|
976
|
+
# """Execute a frequency domain study without distributed frequency sweep.
|
|
977
|
+
|
|
978
|
+
# Args:
|
|
979
|
+
# automatic_modal_analysis (bool, optional): Automatically compute port modes. Defaults to False.
|
|
980
|
+
|
|
981
|
+
# Raises:
|
|
982
|
+
# SimulationError: _description_
|
|
983
|
+
|
|
984
|
+
# Returns:
|
|
985
|
+
# MWSimData: The Simulation data.
|
|
986
|
+
# """
|
|
987
|
+
# T0 = time.time()
|
|
988
|
+
# mesh = self.mesh
|
|
989
|
+
# if self.bc._initialized is False:
|
|
990
|
+
# raise SimulationError('Cannot run a modal analysis because no boundary conditions have been assigned.')
|
|
991
|
+
|
|
992
|
+
# self._initialize_field()
|
|
993
|
+
# self._initialize_bc_data()
|
|
994
|
+
|
|
995
|
+
# er = self.mesh.retreive(lambda mat,x,y,z: mat.fer3d_mat(x,y,z), self.mesher.volumes)
|
|
996
|
+
# ur = self.mesh.retreive(lambda mat,x,y,z: mat.fur3d_mat(x,y,z), self.mesher.volumes)
|
|
997
|
+
# cond = self.mesh.retreive(lambda mat,x,y,z: mat.cond, self.mesher.volumes)[0,0,:]
|
|
998
|
+
|
|
999
|
+
# ertri = np.zeros((3,3,self.mesh.n_tris), dtype=np.complex128)
|
|
1000
|
+
# urtri = np.zeros((3,3,self.mesh.n_tris), dtype=np.complex128)
|
|
1001
|
+
# condtri = np.zeros((self.mesh.n_tris,), dtype=np.complex128)
|
|
1002
|
+
|
|
1003
|
+
# for itri in range(self.mesh.n_tris):
|
|
1004
|
+
# itet = self.mesh.tri_to_tet[0,itri]
|
|
1005
|
+
# ertri[:,:,itri] = er[:,:,itet]
|
|
1006
|
+
# urtri[:,:,itri] = ur[:,:,itet]
|
|
1007
|
+
# condtri[itri] = cond[itet]
|
|
1008
|
+
|
|
1009
|
+
# #### Port settings
|
|
1010
|
+
|
|
1011
|
+
# all_ports = self.bc.oftype(PortBC)
|
|
1012
|
+
# port_numbers = [port.port_number for port in all_ports]
|
|
1013
|
+
|
|
1014
|
+
# ##### FOR PORT SWEEP SET ALL ACTIVE TO FALSE. THIS SHOULD BE FIXED LATER
|
|
1015
|
+
# ### COMPUTE WHICH TETS ARE CONNECTED TO PORT INDICES
|
|
1016
|
+
|
|
1017
|
+
# all_port_tets = []
|
|
1018
|
+
# for port in all_ports:
|
|
1019
|
+
# port.active=False
|
|
1020
|
+
|
|
1021
|
+
# all_port_tets = mesh.get_face_tets(*[port.tags for port in all_ports])
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
# logger.debug(f'Starting the simulation of {len(self.frequencies)} frequency points.')
|
|
1025
|
+
|
|
1026
|
+
# # ITERATE OVER FREQUENCIES
|
|
1027
|
+
# for freq in self.frequencies:
|
|
1028
|
+
# logger.info(f'Simulation frequency = {freq/1e9:.3f} GHz')
|
|
1029
|
+
|
|
1030
|
+
# # Assembling matrix problem
|
|
1031
|
+
# if automatic_modal_analysis:
|
|
1032
|
+
# self._compute_modes(freq)
|
|
1033
|
+
|
|
1034
|
+
# job = self.assembler.assemble_freq_matrix(self.basis, er, ur, cond, self.bc.boundary_conditions, freq, cache_matrices=self.cache_matrices)
|
|
1035
|
+
|
|
1036
|
+
# logger.debug(f'Routine: {self.solveroutine}')
|
|
1037
|
+
|
|
1038
|
+
# for A, b, ids, reuse in job.iter_Ab():
|
|
1039
|
+
# solution, report = self.solveroutine.solve(A, b, ids, reuse)
|
|
1040
|
+
# job.submit_solution(solution, report)
|
|
1041
|
+
|
|
1042
|
+
# self.data.setreport(job.reports, freq=freq, **self._params)
|
|
1043
|
+
|
|
1044
|
+
# k0 = 2*np.pi*freq/299792458
|
|
1045
|
+
|
|
1046
|
+
# scalardata = self.data.scalar.new(freq=freq, **self._params)
|
|
1047
|
+
# scalardata.init_sp(port_numbers)
|
|
1048
|
+
# scalardata.freq = freq
|
|
1049
|
+
# scalardata.k0 = k0
|
|
1050
|
+
|
|
1051
|
+
# fielddata = self.data.field.new(freq=freq, **self._params)
|
|
1052
|
+
# fielddata.freq = freq
|
|
1053
|
+
# fielddata.er = np.squeeze(er[0,0,:])
|
|
1054
|
+
# fielddata.ur = np.squeeze(ur[0,0,:])
|
|
1055
|
+
|
|
1056
|
+
# # Recording port information
|
|
1057
|
+
# for i, port in enumerate(all_ports):
|
|
1058
|
+
# fielddata.add_port_properties(port.port_number,
|
|
1059
|
+
# mode_number=port.mode_number,
|
|
1060
|
+
# k0 = k0,
|
|
1061
|
+
# beta = port.get_beta(k0),
|
|
1062
|
+
# Z0 = port.portZ0(k0),
|
|
1063
|
+
# Pout= port.power)
|
|
1064
|
+
|
|
1065
|
+
# for active_port in all_ports:
|
|
1066
|
+
|
|
1067
|
+
# active_port.active = True
|
|
1068
|
+
# solution = job._fields[active_port.port_number]
|
|
1069
|
+
|
|
1070
|
+
# fielddata._fields[active_port.port_number] = solution # TODO: THIS IS VERY FRAIL
|
|
1071
|
+
# fielddata.basis = self.basis
|
|
1072
|
+
|
|
1073
|
+
# # Compute the S-parameters
|
|
1074
|
+
# # Define the field interpolation function
|
|
1075
|
+
# fieldf = self.basis.interpolate_Ef(solution, tetids=all_port_tets)
|
|
1076
|
+
|
|
1077
|
+
# # Active port power
|
|
1078
|
+
# logger.debug('Active ports:')
|
|
1079
|
+
# tris = mesh.get_triangles(active_port.tags)
|
|
1080
|
+
# tri_vertices = mesh.tris[:,tris]
|
|
1081
|
+
# pfield, pmode = self._compute_s_data(active_port, fieldf, tri_vertices, k0, ertri[:,:,tris], urtri[:,:,tris])
|
|
1082
|
+
# logger.debug(f' Field Amplitude = {np.abs(pfield):.3f}, Excitation = {np.abs(pmode):.2f}')
|
|
1083
|
+
# Pout = pmode
|
|
1084
|
+
|
|
1085
|
+
# #Passive ports
|
|
1086
|
+
# logger.debug('Passive ports:')
|
|
1087
|
+
# for bc in all_ports:
|
|
1088
|
+
# tris = mesh.get_triangles(bc.tags)
|
|
1089
|
+
# tri_vertices = mesh.tris[:,tris]
|
|
1090
|
+
# pfield, pmode = self._compute_s_data(bc, fieldf, tri_vertices, k0, ertri[:,:,tris], urtri[:,:,tris])
|
|
1091
|
+
# logger.debug(f' Field amplitude = {np.abs(pfield):.3f}, Excitation= {np.abs(pmode):.2f}')
|
|
1092
|
+
# scalardata.write_S(bc.port_number, active_port.port_number, pfield/Pout)
|
|
1093
|
+
|
|
1094
|
+
# active_port.active=False
|
|
1095
|
+
|
|
1096
|
+
# fielddata.set_field_vector()
|
|
1097
|
+
|
|
1098
|
+
# logger.info('Simulation Complete!')
|
|
1099
|
+
# T2 = time.time()
|
|
1100
|
+
# logger.info(f'Elapsed time = {(T2-T0):.2f} seconds.')
|
|
1101
|
+
# return self.data
|
|
1102
|
+
## DEPRICATED
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
#
|
|
1107
|
+
# def eigenmode(self, mesh: Mesh3D, solver = None, num_sols: int = 6):
|
|
1108
|
+
# if solver is None:
|
|
1109
|
+
# logger.info('Defaulting to BiCGStab.')
|
|
1110
|
+
# solver = sparse.linalg.eigs
|
|
1111
|
+
|
|
1112
|
+
# if self.order == 1:
|
|
1113
|
+
# logger.info('Detected 1st order elements.')
|
|
1114
|
+
# from ...elements.nedelec1.assembly import assemble_eig_matrix
|
|
1115
|
+
# ft = FieldType.VEC_LIN
|
|
1116
|
+
|
|
1117
|
+
# elif self.order == 2:
|
|
1118
|
+
# logger.info('Detected 2nd order elements.')
|
|
1119
|
+
# from ...elements.nedelec2.assembly import assemble_eig_matrix_E
|
|
1120
|
+
# ft = FieldType.VEC_QUAD
|
|
1121
|
+
|
|
1122
|
+
# er = self.mesh.retreive(mesh.centers, lambda mat,x,y,z: mat.fer3d(x,y,z))
|
|
1123
|
+
# ur = self.mesh.retreive(mesh.centers, lambda mat,x,y,z: mat.fur3d(x,y,z))
|
|
1124
|
+
|
|
1125
|
+
# dataset = Dataset3D(mesh, self.frequencies, 0, ft)
|
|
1126
|
+
# dataset.er = er
|
|
1127
|
+
# dataset.ur = ur
|
|
1128
|
+
# logger.info('Solving eigenmodes.')
|
|
1129
|
+
|
|
1130
|
+
# f_target = self.frequencies[0]
|
|
1131
|
+
# sigma = (2 * np.pi * f_target / 299792458)**2
|
|
1132
|
+
|
|
1133
|
+
# A, B, solvenodes = assemble_eig_matrix(mesh, er, ur, self.boundary_conditions)
|
|
1134
|
+
|
|
1135
|
+
# A = A[np.ix_(solvenodes, solvenodes)]
|
|
1136
|
+
# B = B[np.ix_(solvenodes, solvenodes)]
|
|
1137
|
+
# #A = sparse.csc_matrix(A)
|
|
1138
|
+
# #B = sparse.csc_matrix(B)
|
|
1139
|
+
|
|
1140
|
+
# w, v = sparse.linalg.eigs(A, k=num_sols, M=B, sigma=sigma, which='LM')
|
|
1141
|
+
|
|
1142
|
+
# logger.info(f'Eigenvalues: {np.sqrt(w)*299792458/(2*np.pi) * 1e-9} GHz')
|
|
1143
|
+
|
|
1144
|
+
# Esol = np.zeros((num_sols, mesh.nfield), dtype=np.complex128)
|
|
1145
|
+
|
|
1146
|
+
# Esol[:, solvenodes] = v.T
|
|
1147
|
+
|
|
1148
|
+
# dataset.set_efield(Esol)
|
|
1149
|
+
|
|
1150
|
+
# self.basis = dataset
|