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,1148 @@
|
|
|
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
|
+
from ...simulation_data import BaseDataset, DataContainer
|
|
20
|
+
from ...elements.femdata import FEMBasis
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
import numpy as np
|
|
23
|
+
from typing import Literal, Any
|
|
24
|
+
from loguru import logger
|
|
25
|
+
from .adaptive_freq import SparamModel
|
|
26
|
+
from ...cs import Axis, _parse_axis
|
|
27
|
+
from ...selection import FaceSelection
|
|
28
|
+
from ...geometry import GeoSurface
|
|
29
|
+
from ...mesh3d import Mesh3D
|
|
30
|
+
|
|
31
|
+
EMField = Literal[
|
|
32
|
+
"er", "ur", "freq", "k0",
|
|
33
|
+
"_Spdata", "_Spmapping", "_field", "_basis",
|
|
34
|
+
"Nports", "Ex", "Ey", "Ez",
|
|
35
|
+
"Hx", "Hy", "Hz",
|
|
36
|
+
"mode", "beta",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
EPS0 = 8.854187818814e-12
|
|
40
|
+
MU0 = 1.2566370612720e-6
|
|
41
|
+
|
|
42
|
+
def arc_on_plane(ref_dir, normal, angle_range_deg, num_points=100):
|
|
43
|
+
"""
|
|
44
|
+
Generate theta/phi coordinates of an arc on a plane.
|
|
45
|
+
|
|
46
|
+
Parameters
|
|
47
|
+
----------
|
|
48
|
+
ref_dir : tuple (dx, dy, dz)
|
|
49
|
+
Reference direction (angle zero) lying in the plane.
|
|
50
|
+
normal : tuple (nx, ny, nz)
|
|
51
|
+
Plane normal vector.
|
|
52
|
+
angle_range_deg : tuple (deg_start, deg_end)
|
|
53
|
+
Start and end angle of the arc in degrees.
|
|
54
|
+
num_points : int
|
|
55
|
+
Number of points along the arc.
|
|
56
|
+
|
|
57
|
+
Returns
|
|
58
|
+
-------
|
|
59
|
+
theta : ndarray
|
|
60
|
+
Array of theta angles (radians).
|
|
61
|
+
phi : ndarray
|
|
62
|
+
Array of phi angles (radians).
|
|
63
|
+
"""
|
|
64
|
+
d = np.array(ref_dir, dtype=float)
|
|
65
|
+
n = np.array(normal, dtype=float)
|
|
66
|
+
|
|
67
|
+
# Normalize normal
|
|
68
|
+
n = n / np.linalg.norm(n)
|
|
69
|
+
|
|
70
|
+
# Project d into the plane
|
|
71
|
+
d_proj = d - np.dot(d, n) * n
|
|
72
|
+
if np.linalg.norm(d_proj) < 1e-12:
|
|
73
|
+
raise ValueError("Reference direction is parallel to the normal vector.")
|
|
74
|
+
|
|
75
|
+
e1 = d_proj / np.linalg.norm(d_proj)
|
|
76
|
+
e2 = np.cross(n, e1)
|
|
77
|
+
|
|
78
|
+
# Generate angles along the arc
|
|
79
|
+
angles_deg = np.linspace(angle_range_deg[0], angle_range_deg[1], num_points)
|
|
80
|
+
angles_rad = np.deg2rad(angles_deg)
|
|
81
|
+
|
|
82
|
+
# Create unit vectors along the arc
|
|
83
|
+
vectors = np.outer(np.cos(angles_rad), e1) + np.outer(np.sin(angles_rad), e2)
|
|
84
|
+
|
|
85
|
+
# Convert to spherical angles
|
|
86
|
+
ux, uy, uz = vectors[:,0], vectors[:,1], vectors[:,2]
|
|
87
|
+
|
|
88
|
+
theta = np.arccos(uz) # theta = arcsin(z)
|
|
89
|
+
phi = np.arctan2(uy, ux) # phi = atan2(y, x)
|
|
90
|
+
|
|
91
|
+
return theta, phi
|
|
92
|
+
|
|
93
|
+
def renormalise_s(S: np.ndarray,
|
|
94
|
+
Zn: np.ndarray,
|
|
95
|
+
Z0: complex | float = 50) -> np.ndarray:
|
|
96
|
+
S = np.asarray(S, dtype=complex)
|
|
97
|
+
Zn = np.asarray(Zn, dtype=complex)
|
|
98
|
+
N = S.shape[1]
|
|
99
|
+
if S.shape[1:3] != (N, N):
|
|
100
|
+
raise ValueError("S must have shape (M, N, N) with same N on both axes")
|
|
101
|
+
if Zn.shape[1] != N:
|
|
102
|
+
raise ValueError("Zn must be a length-N vector")
|
|
103
|
+
|
|
104
|
+
# Constant matrices that do not depend on frequency
|
|
105
|
+
|
|
106
|
+
W0_inv_sc = 1 / np.sqrt(Z0) # scalar because Z0 is common
|
|
107
|
+
I_N = np.eye(N, dtype=complex)
|
|
108
|
+
|
|
109
|
+
M = S.shape[0]
|
|
110
|
+
S0 = np.empty_like(S)
|
|
111
|
+
|
|
112
|
+
for k in range(M):
|
|
113
|
+
Wref = np.diag(np.sqrt(Zn[k,:])) # √Zn on the diagonal
|
|
114
|
+
Sk = S[k, :, :]
|
|
115
|
+
|
|
116
|
+
# Z = Wref (I + S) (I – S)⁻¹ Wref
|
|
117
|
+
Zk = Wref @ (I_N + Sk) @ np.linalg.inv(I_N - Sk) @ Wref
|
|
118
|
+
|
|
119
|
+
# A = W0⁻¹ Z W0⁻¹ → because W0 = √Z0·I → A = Z / Z0
|
|
120
|
+
Ak = Zk * (W0_inv_sc ** 2) # same as Zk / Z0
|
|
121
|
+
|
|
122
|
+
# S0 = (A – I)(A + I)⁻¹
|
|
123
|
+
S0[k, :, :] = (Ak - I_N) @ np.linalg.inv(Ak + I_N)
|
|
124
|
+
|
|
125
|
+
return S0
|
|
126
|
+
|
|
127
|
+
def generate_ndim(
|
|
128
|
+
outer_data: dict[str, list[float]],
|
|
129
|
+
inner_data: list[float],
|
|
130
|
+
outer_labels: tuple[str, ...]
|
|
131
|
+
) -> np.ndarray:
|
|
132
|
+
"""
|
|
133
|
+
Generates an N-dimensional grid of values from flattened data, and returns each axis array plus the grid.
|
|
134
|
+
|
|
135
|
+
Parameters
|
|
136
|
+
----------
|
|
137
|
+
outer_data : dict of {label: flat list of coordinates}
|
|
138
|
+
Each key corresponds to one axis label, and the list contains coordinate values for each point.
|
|
139
|
+
inner_data : list of float
|
|
140
|
+
Flattened list of data values corresponding to each set of coordinates.
|
|
141
|
+
outer_labels : tuple of str
|
|
142
|
+
Order of axes (keys of outer_data) which defines the dimension order in the output array.
|
|
143
|
+
|
|
144
|
+
Returns
|
|
145
|
+
-------
|
|
146
|
+
*axes : np.ndarray
|
|
147
|
+
One 1D array for each axis, containing the sorted unique coordinates for that dimension,
|
|
148
|
+
in the order specified by outer_labels.
|
|
149
|
+
grid : np.ndarray
|
|
150
|
+
N-dimensional array of shape (n1, n2, ..., nN), where ni is the number of unique
|
|
151
|
+
values along the i-th axis. Missing points are filled with np.nan.
|
|
152
|
+
"""
|
|
153
|
+
# Convert inner data to numpy array
|
|
154
|
+
values = np.asarray(inner_data)
|
|
155
|
+
|
|
156
|
+
# Determine unique sorted coordinates for each axis
|
|
157
|
+
axes = [np.unique(np.asarray(outer_data[label])) for label in outer_labels]
|
|
158
|
+
grid_shape = tuple(axis.size for axis in axes)
|
|
159
|
+
|
|
160
|
+
# Initialize grid with NaNs
|
|
161
|
+
grid = np.full(grid_shape, np.nan, dtype=values.dtype)
|
|
162
|
+
|
|
163
|
+
# Build coordinate arrays for each axis
|
|
164
|
+
coords = [np.asarray(outer_data[label]) for label in outer_labels]
|
|
165
|
+
|
|
166
|
+
# Map coordinates to indices in the grid for each axis
|
|
167
|
+
idxs = [np.searchsorted(axes[i], coords[i]) for i in range(len(axes))]
|
|
168
|
+
|
|
169
|
+
# Assign values into the grid
|
|
170
|
+
grid[tuple(idxs)] = values
|
|
171
|
+
|
|
172
|
+
# Return each axis array followed by the grid
|
|
173
|
+
return (*axes, grid)
|
|
174
|
+
|
|
175
|
+
@dataclass
|
|
176
|
+
class Sparam:
|
|
177
|
+
"""
|
|
178
|
+
S-parameter matrix indexed by arbitrary port/mode labels (ints or floats).
|
|
179
|
+
Internally stores a square numpy array; externally uses your mapping
|
|
180
|
+
to translate (port1, port2) → (i, j).
|
|
181
|
+
"""
|
|
182
|
+
def __init__(self, port_nrs: list[int | float]) -> None:
|
|
183
|
+
# build label → index map
|
|
184
|
+
self.map: dict[int | float, int] = {label: idx
|
|
185
|
+
for idx, label in enumerate(port_nrs)}
|
|
186
|
+
n = len(port_nrs)
|
|
187
|
+
# zero‐initialize the S‐parameter matrix
|
|
188
|
+
self.arry: np.ndarray = np.zeros((n, n), dtype=np.complex128)
|
|
189
|
+
|
|
190
|
+
def get(self, port1: int | float, port2: int | float) -> complex:
|
|
191
|
+
"""
|
|
192
|
+
Return the S-parameter S(port1, port2).
|
|
193
|
+
Raises KeyError if either port1 or port2 is not in the mapping.
|
|
194
|
+
"""
|
|
195
|
+
try:
|
|
196
|
+
i = self.map[port1]
|
|
197
|
+
j = self.map[port2]
|
|
198
|
+
except KeyError as e:
|
|
199
|
+
raise KeyError(f"Port/mode {e.args[0]!r} not found in mapping") from None
|
|
200
|
+
return self.arry[i, j]
|
|
201
|
+
|
|
202
|
+
def set(self, port1: int | float, port2: int | float, value: complex) -> None:
|
|
203
|
+
"""
|
|
204
|
+
Set the S-parameter S(port1, port2) = value.
|
|
205
|
+
Raises KeyError if either port1 or port2 is not in the mapping.
|
|
206
|
+
"""
|
|
207
|
+
try:
|
|
208
|
+
i = self.map[port1]
|
|
209
|
+
j = self.map[port2]
|
|
210
|
+
except KeyError as e:
|
|
211
|
+
raise KeyError(f"Port/mode {e.args[0]!r} not found in mapping") from None
|
|
212
|
+
self.arry[i, j] = value
|
|
213
|
+
|
|
214
|
+
# allow S(param1, param2) → complex, as before
|
|
215
|
+
def __call__(self, port1: int | float, port2: int | float) -> complex:
|
|
216
|
+
return self.get(port1, port2)
|
|
217
|
+
|
|
218
|
+
# allow array‐style access: S[1, 1] → complex
|
|
219
|
+
def __getitem__(self, key: tuple[int | float, int | float]) -> complex:
|
|
220
|
+
port1, port2 = key
|
|
221
|
+
return self.get(port1, port2)
|
|
222
|
+
|
|
223
|
+
# allow array‐style setting: S[1, 2] = 0.3 + 0.1j
|
|
224
|
+
def __setitem__(
|
|
225
|
+
self,
|
|
226
|
+
key: tuple[int | float, int | float],
|
|
227
|
+
value: complex
|
|
228
|
+
) -> None:
|
|
229
|
+
port1, port2 = key
|
|
230
|
+
self.set(port1, port2, value)
|
|
231
|
+
|
|
232
|
+
@dataclass
|
|
233
|
+
class PortProperties:
|
|
234
|
+
port_number: int | None = None
|
|
235
|
+
k0: float | None= None
|
|
236
|
+
beta: float | None = None
|
|
237
|
+
Z0: float | None = None
|
|
238
|
+
Pout: float | None = None
|
|
239
|
+
mode_number: int = 1
|
|
240
|
+
|
|
241
|
+
class MWData:
|
|
242
|
+
scalar: BaseDataset[MWScalar, MWScalarNdim]
|
|
243
|
+
field: BaseDataset[MWField, None]
|
|
244
|
+
|
|
245
|
+
def __init__(self):
|
|
246
|
+
self.scalar = BaseDataset[MWScalar, MWScalarNdim](MWScalar, MWScalarNdim, True)
|
|
247
|
+
self.field = BaseDataset[MWField, None](MWField, None, False)
|
|
248
|
+
self.sim: DataContainer = DataContainer()
|
|
249
|
+
|
|
250
|
+
def setreport(self, report, **vars):
|
|
251
|
+
self.sim.new(**vars)['report'] = report
|
|
252
|
+
|
|
253
|
+
@dataclass
|
|
254
|
+
class FarfieldData:
|
|
255
|
+
E: np.ndarray
|
|
256
|
+
H: np.ndarray
|
|
257
|
+
theta: np.ndarray
|
|
258
|
+
phi: np.ndarray
|
|
259
|
+
|
|
260
|
+
def surfplot(self,
|
|
261
|
+
polarization: Literal['Ex','Ey','Ez','Etheta','Ephi','normE'],
|
|
262
|
+
isotropic: bool = True, dB: bool = False, dBfloor: float = -30, rmax: float = None,
|
|
263
|
+
offset: tuple[float, float, float] = (0,0,0)) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
|
264
|
+
"""Returns the parameters to be used as positional arguments for the display.add_surf() function.
|
|
265
|
+
|
|
266
|
+
Example:
|
|
267
|
+
>>> model.display.add_surf(*dataset.field[n].farfield_3d(...).surfplot())
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
polarization ('Ex','Ey','Ez','Etheta','Ephi','normE'): What quantity to plot
|
|
271
|
+
isotropic (bool, optional): Whether to look at the ratio with isotropic antennas. Defaults to True.
|
|
272
|
+
dB (bool, optional): Whether to plot in dB's. Defaults to False.
|
|
273
|
+
dBfloor (float, optional): The dB value to take as R=0. Defaults to -10.
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: The X, Y, Z, F values
|
|
277
|
+
"""
|
|
278
|
+
if polarization == "Ex":
|
|
279
|
+
F = self.E[0,:]
|
|
280
|
+
elif polarization == "Ey":
|
|
281
|
+
F = self.E[1,:]
|
|
282
|
+
elif polarization == "Ez":
|
|
283
|
+
F = self.E[2,:]
|
|
284
|
+
elif polarization == "normE":
|
|
285
|
+
F = np.sqrt(np.abs(self.E[0,:])**2 + np.abs(self.E[1,:])**2 + np.abs(self.E[2,:])**2)
|
|
286
|
+
elif polarization == "Etheta":
|
|
287
|
+
thx = -np.cos(self.theta)*np.cos(self.phi)
|
|
288
|
+
thy = -np.cos(self.theta)*np.sin(self.phi)
|
|
289
|
+
thz = np.sin(self.theta)
|
|
290
|
+
F = np.abs(thx*self.E[0,:] + thy*self.E[1,:] + thz*self.E[2,:])
|
|
291
|
+
elif polarization == "Ephi":
|
|
292
|
+
phx = -np.sin(self.phi)
|
|
293
|
+
phy = np.cos(self.phi)
|
|
294
|
+
phz = np.zeros_like(self.theta)
|
|
295
|
+
F = np.abs(phx*self.E[0,:] + phy*self.E[1,:] + phz*self.E[2,:])
|
|
296
|
+
else:
|
|
297
|
+
logger.warning('Defaulting to normE')
|
|
298
|
+
F = np.sqrt(np.abs(self.E[0,:])**2 + np.abs(self.E[1,:])**2 + np.abs(self.E[2,:])**2)
|
|
299
|
+
if isotropic:
|
|
300
|
+
F = F/np.sqrt(376.730313412/(2*np.pi))
|
|
301
|
+
if dB:
|
|
302
|
+
F = 20*np.log10(np.clip(np.abs(F), a_min=10**(dBfloor/20), a_max = 1e9))-dBfloor
|
|
303
|
+
if rmax is not None:
|
|
304
|
+
F = rmax * F/np.max(F)
|
|
305
|
+
xs = F*np.sin(self.theta)*np.cos(self.phi) + offset[0]
|
|
306
|
+
ys = F*np.sin(self.theta)*np.sin(self.phi) + offset[1]
|
|
307
|
+
zs = F*np.cos(self.theta) + offset[2]
|
|
308
|
+
|
|
309
|
+
return xs, ys, zs, F
|
|
310
|
+
|
|
311
|
+
@dataclass
|
|
312
|
+
class EHField:
|
|
313
|
+
x: np.ndarray
|
|
314
|
+
y: np.ndarray
|
|
315
|
+
z: np.ndarray
|
|
316
|
+
Ex: np.ndarray
|
|
317
|
+
Ey: np.ndarray
|
|
318
|
+
Ez: np.ndarray
|
|
319
|
+
Hx: np.ndarray
|
|
320
|
+
Hy: np.ndarray
|
|
321
|
+
Hz: np.ndarray
|
|
322
|
+
freq: float
|
|
323
|
+
er: np.ndarray
|
|
324
|
+
ur: np.ndarray
|
|
325
|
+
|
|
326
|
+
@property
|
|
327
|
+
def k0(self) -> float:
|
|
328
|
+
return self.freq*2*np.pi/299792458
|
|
329
|
+
|
|
330
|
+
@property
|
|
331
|
+
def Px(self) -> np.ndarray:
|
|
332
|
+
return EPS0*(self.er-1)*self.Ex
|
|
333
|
+
|
|
334
|
+
@property
|
|
335
|
+
def Py(self) -> np.ndarray:
|
|
336
|
+
return EPS0*(self.er-1)*self.Ey
|
|
337
|
+
|
|
338
|
+
@property
|
|
339
|
+
def Pz(self) -> np.ndarray:
|
|
340
|
+
return EPS0*(self.er-1)*self.Ez
|
|
341
|
+
|
|
342
|
+
@property
|
|
343
|
+
def Dx(self) -> np.ndarray:
|
|
344
|
+
return self.Ex*self.er
|
|
345
|
+
|
|
346
|
+
@property
|
|
347
|
+
def Dy(self) -> np.ndarray:
|
|
348
|
+
return self.Et*self.er
|
|
349
|
+
|
|
350
|
+
@property
|
|
351
|
+
def Dz(self) -> np.ndarray:
|
|
352
|
+
return self.Ez*self.er
|
|
353
|
+
|
|
354
|
+
@property
|
|
355
|
+
def Bx(self) -> np.ndarray:
|
|
356
|
+
return self.Hx/self.ur
|
|
357
|
+
|
|
358
|
+
@property
|
|
359
|
+
def By(self) -> np.ndarray:
|
|
360
|
+
return self.Hy/self.ur
|
|
361
|
+
|
|
362
|
+
@property
|
|
363
|
+
def Bz(self) -> np.ndarray:
|
|
364
|
+
return self.Hz/self.ur
|
|
365
|
+
|
|
366
|
+
@property
|
|
367
|
+
def Emat(self) -> np.ndarray:
|
|
368
|
+
return np.array([self.Ex, self.Ey, self.Ez])
|
|
369
|
+
|
|
370
|
+
@property
|
|
371
|
+
def Hmat(self) -> np.ndarray:
|
|
372
|
+
return np.array([self.Hx, self.Hy, self.Hz])
|
|
373
|
+
|
|
374
|
+
@property
|
|
375
|
+
def Pmat(self) -> np.ndarray:
|
|
376
|
+
return np.array([self.Px, self.Py, self.Pz])
|
|
377
|
+
|
|
378
|
+
@property
|
|
379
|
+
def Bmat(self) -> np.ndarray:
|
|
380
|
+
return np.array([self.Bx, self.By, self.Bz])
|
|
381
|
+
|
|
382
|
+
@property
|
|
383
|
+
def Dmat(self) -> np.ndarray:
|
|
384
|
+
return np.array([self.Dx, self.Dy, self.Dz])
|
|
385
|
+
|
|
386
|
+
@property
|
|
387
|
+
def EH(self) -> tuple[np.ndarray, np.ndarray]:
|
|
388
|
+
''' Return the electric and magnetic field as a tuple of numpy arrays '''
|
|
389
|
+
return np.array([self.Ex, self.Ey, self.Ez]), np.array([self.Hx, self.Hy, self.Hz])
|
|
390
|
+
|
|
391
|
+
@property
|
|
392
|
+
def E(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
393
|
+
''' Return the electric field as a tuple of numpy arrays '''
|
|
394
|
+
return self.Ex, self.Ey, self.Ez
|
|
395
|
+
|
|
396
|
+
@property
|
|
397
|
+
def Sx(self) -> np.ndarray:
|
|
398
|
+
return self.Ey*self.Hz - self.Ez*self.Hy
|
|
399
|
+
|
|
400
|
+
@property
|
|
401
|
+
def Sy(self) -> np.ndarray:
|
|
402
|
+
return self.Ez*self.Hx - self.Ex*self.Hz
|
|
403
|
+
|
|
404
|
+
@property
|
|
405
|
+
def Sz(self) -> np.ndarray:
|
|
406
|
+
return self.Ex*self.Hy - self.Ey*self.Hx
|
|
407
|
+
|
|
408
|
+
@property
|
|
409
|
+
def B(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
410
|
+
''' Return the magnetic field as a tuple of numpy arrays '''
|
|
411
|
+
return self.Bx, self.By, self.Bz
|
|
412
|
+
|
|
413
|
+
@property
|
|
414
|
+
def P(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
415
|
+
''' Return the polarization field as a tuple of numpy arrays '''
|
|
416
|
+
return self.Px, self.Py, self.Pz
|
|
417
|
+
|
|
418
|
+
@property
|
|
419
|
+
def D(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
420
|
+
''' Return the electric displacement field as a tuple of numpy arrays '''
|
|
421
|
+
return self.Bx, self.By, self.Bz
|
|
422
|
+
|
|
423
|
+
@property
|
|
424
|
+
def H(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
425
|
+
''' Return the magnetic field as a tuple of numpy arrays '''
|
|
426
|
+
return self.Hx, self.Hy, self.Hz
|
|
427
|
+
|
|
428
|
+
@property
|
|
429
|
+
def S(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
430
|
+
''' Return the poynting vector field as a tuple of numpy arrays '''
|
|
431
|
+
return self.Sx, self.Sy, self.Sz
|
|
432
|
+
|
|
433
|
+
@property
|
|
434
|
+
def normE(self) -> np.ndarray:
|
|
435
|
+
"""The complex norm of the E-field
|
|
436
|
+
"""
|
|
437
|
+
return np.sqrt(np.abs(self.Ex)**2 + np.abs(self.Ey)**2 + np.abs(self.Ez)**2)
|
|
438
|
+
|
|
439
|
+
@property
|
|
440
|
+
def normH(self) -> np.ndarray:
|
|
441
|
+
"""The complex norm of the H-field"""
|
|
442
|
+
return np.sqrt(np.abs(self.Hx)**2 + np.abs(self.Hy)**2 + np.abs(self.Hz)**2)
|
|
443
|
+
|
|
444
|
+
@property
|
|
445
|
+
def normP(self) -> np.ndarray:
|
|
446
|
+
"""The complex norm of the P-field
|
|
447
|
+
"""
|
|
448
|
+
return np.sqrt(np.abs(self.Px)**2 + np.abs(self.Py)**2 + np.abs(self.Pz)**2)
|
|
449
|
+
|
|
450
|
+
@property
|
|
451
|
+
def normB(self) -> np.ndarray:
|
|
452
|
+
"""The complex norm of the B-field
|
|
453
|
+
"""
|
|
454
|
+
return np.sqrt(np.abs(self.Bx)**2 + np.abs(self.By)**2 + np.abs(self.Bz)**2)
|
|
455
|
+
|
|
456
|
+
@property
|
|
457
|
+
def normD(self) -> np.ndarray:
|
|
458
|
+
"""The complex norm of the D-field
|
|
459
|
+
"""
|
|
460
|
+
return np.sqrt(np.abs(self.Dx)**2 + np.abs(self.Dy)**2 + np.abs(self.Dz)**2)
|
|
461
|
+
|
|
462
|
+
@property
|
|
463
|
+
def normS(self) -> np.ndarray:
|
|
464
|
+
"""The complex norm of the S-field
|
|
465
|
+
"""
|
|
466
|
+
return np.sqrt(np.abs(self.Sx)**2 + np.abs(self.Sy)**2 + np.abs(self.Sz)**2)
|
|
467
|
+
|
|
468
|
+
def vector(self, field: Literal['E','H'], metric: Literal['real','imag','complex'] = 'real') -> tuple[np.ndarray, np.ndarray,np.ndarray,np.ndarray,np.ndarray,np.ndarray]:
|
|
469
|
+
"""Returns the X,Y,Z,Fx,Fy,Fz data to be directly cast into plot functions.
|
|
470
|
+
|
|
471
|
+
The field can be selected by a string literal. The metric of the complex vector field by the metric.
|
|
472
|
+
For animations, make sure to always use the complex metric.
|
|
473
|
+
|
|
474
|
+
Args:
|
|
475
|
+
field ('E','H'): The field to return
|
|
476
|
+
metric ([]'real','imag','complex'], optional): the metric to impose on the field. Defaults to 'real'.
|
|
477
|
+
|
|
478
|
+
Returns:
|
|
479
|
+
tuple[np.ndarray,...]: The X,Y,Z,Fx,Fy,Fz arrays
|
|
480
|
+
"""
|
|
481
|
+
Fx, Fy, Fz = getattr(self, field)
|
|
482
|
+
|
|
483
|
+
if metric=='real':
|
|
484
|
+
Fx, Fy, Fz = Fx.real, Fy.real, Fz.real
|
|
485
|
+
elif metric=='imag':
|
|
486
|
+
Fx, Fy, Fz = Fx.imag, Fy.imag, Fz.imag
|
|
487
|
+
|
|
488
|
+
return self.x, self.y, self.z, Fx, Fy, Fz
|
|
489
|
+
|
|
490
|
+
def scalar(self, field: Literal['Ex','Ey','Ez','Hx','Hy','Hz','normE','normH'], metric: Literal['abs','real','imag','complex'] = 'real') -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
|
491
|
+
"""Returns the data X, Y, Z, Field based on the interpolation
|
|
492
|
+
|
|
493
|
+
For animations, make sure to select the complex metric.
|
|
494
|
+
|
|
495
|
+
Args:
|
|
496
|
+
field (str): The field to plot
|
|
497
|
+
metric (str, optional): The metric to impose on the plot. Defaults to 'real'.
|
|
498
|
+
|
|
499
|
+
Returns:
|
|
500
|
+
(X,Y,Z,Field): The coordinates plus field scalar
|
|
501
|
+
"""
|
|
502
|
+
field = getattr(self, field)
|
|
503
|
+
if metric=='abs':
|
|
504
|
+
field = np.abs(field)
|
|
505
|
+
elif metric=='real':
|
|
506
|
+
field = field.real
|
|
507
|
+
elif metric=='imag':
|
|
508
|
+
field = field.imag
|
|
509
|
+
elif metric=='complex':
|
|
510
|
+
field = field
|
|
511
|
+
return self.x, self.y, self.z, field
|
|
512
|
+
|
|
513
|
+
class _EHSign:
|
|
514
|
+
"""A small class to manage the sign of field components when computing the far-field with Stratton-Chu
|
|
515
|
+
"""
|
|
516
|
+
def __init__(self):
|
|
517
|
+
self.Ex = 1
|
|
518
|
+
self.Ey = 1
|
|
519
|
+
self.Ez = 1
|
|
520
|
+
self.Hx = 1
|
|
521
|
+
self.Hy = 1
|
|
522
|
+
self.Hz = 1
|
|
523
|
+
|
|
524
|
+
def fE(self):
|
|
525
|
+
self.Ex = -1*self.Ex
|
|
526
|
+
self.Ey = -1*self.Ey
|
|
527
|
+
self.Ez = -1*self.Ez
|
|
528
|
+
|
|
529
|
+
def fH(self):
|
|
530
|
+
self.Hx = -1*self.Hx
|
|
531
|
+
self.Hy = -1*self.Hy
|
|
532
|
+
self.Hz = -1*self.Hz
|
|
533
|
+
|
|
534
|
+
def fX(self):
|
|
535
|
+
self.Ex = -1*self.Ex
|
|
536
|
+
self.Hx = -1*self.Hx
|
|
537
|
+
|
|
538
|
+
def fY(self):
|
|
539
|
+
self.Ey = -1*self.Ey
|
|
540
|
+
self.Hy = -1*self.Hy
|
|
541
|
+
|
|
542
|
+
def fZ(self):
|
|
543
|
+
self.Ez = -1*self.Ez
|
|
544
|
+
self.Hz = -1*self.Hz
|
|
545
|
+
|
|
546
|
+
def apply(self, symmetry: str):
|
|
547
|
+
f, c = symmetry
|
|
548
|
+
if f=='E':
|
|
549
|
+
self.fE()
|
|
550
|
+
elif f=='H':
|
|
551
|
+
self.fH()
|
|
552
|
+
|
|
553
|
+
if c=='x':
|
|
554
|
+
self.fX()
|
|
555
|
+
elif c=='y':
|
|
556
|
+
self.fY()
|
|
557
|
+
elif c=='z':
|
|
558
|
+
self.fZ()
|
|
559
|
+
|
|
560
|
+
def flip_field(self, E: tuple, H: tuple):
|
|
561
|
+
Ex, Ey, Ez = E
|
|
562
|
+
Hx, Hy, Hz = H
|
|
563
|
+
return (Ex*self.Ex, Ey*self.Ey, Ez*self.Ez), (Hx*self.Hx, Hy*self.Hy, Hz*self.Hz)
|
|
564
|
+
|
|
565
|
+
class MWField:
|
|
566
|
+
|
|
567
|
+
def __init__(self):
|
|
568
|
+
self._der: np.ndarray = None
|
|
569
|
+
self._dur: np.ndarray = None
|
|
570
|
+
self.freq: float = None
|
|
571
|
+
self.basis: FEMBasis = None
|
|
572
|
+
self._fields: dict[int, np.ndarray] = dict()
|
|
573
|
+
self._mode_field: np.ndarray = None
|
|
574
|
+
self.excitation: dict[int, complex] = dict()
|
|
575
|
+
self.Nports: int = None
|
|
576
|
+
self.port_modes: list[PortProperties] = []
|
|
577
|
+
self.Ex: np.ndarray = None
|
|
578
|
+
self.Ey: np.ndarray = None
|
|
579
|
+
self.Ez: np.ndarray = None
|
|
580
|
+
self.Hx: np.ndarray = None
|
|
581
|
+
self.Hy: np.ndarray = None
|
|
582
|
+
self.Hz: np.ndarray = None
|
|
583
|
+
self.er: np.ndarray = None
|
|
584
|
+
self.ur: np.ndarray = None
|
|
585
|
+
|
|
586
|
+
def add_port_properties(self,
|
|
587
|
+
port_number: int,
|
|
588
|
+
mode_number: int,
|
|
589
|
+
k0: float,
|
|
590
|
+
beta: float,
|
|
591
|
+
Z0: float,
|
|
592
|
+
Pout: float) -> None:
|
|
593
|
+
self.port_modes.append(PortProperties(port_number=port_number,
|
|
594
|
+
mode_number=mode_number,
|
|
595
|
+
k0 = k0,
|
|
596
|
+
beta=beta,
|
|
597
|
+
Z0=Z0,
|
|
598
|
+
Pout=Pout))
|
|
599
|
+
|
|
600
|
+
@property
|
|
601
|
+
def mesh(self) -> Mesh3D:
|
|
602
|
+
return self.basis.mesh
|
|
603
|
+
|
|
604
|
+
@property
|
|
605
|
+
def k0(self) -> float:
|
|
606
|
+
return self.freq*2*np.pi/299792458
|
|
607
|
+
|
|
608
|
+
@property
|
|
609
|
+
def _field(self) -> np.ndarray:
|
|
610
|
+
if self._mode_field is not None:
|
|
611
|
+
return self._mode_field
|
|
612
|
+
return sum([self.excitation[mode.port_number]*self._fields[mode.port_number] for mode in self.port_modes])
|
|
613
|
+
|
|
614
|
+
def set_field_vector(self) -> None:
|
|
615
|
+
"""Defines the default excitation coefficients for the current dataset"""
|
|
616
|
+
self.excitation = {key: 0.0 for key in self._fields.keys()}
|
|
617
|
+
self.excitation[self.port_modes[0].port_number] = 1.0 + 0j
|
|
618
|
+
|
|
619
|
+
@property
|
|
620
|
+
def EH(self) -> tuple[np.ndarray, np.ndarray]:
|
|
621
|
+
''' Return the electric and magnetic field as a tuple of numpy arrays '''
|
|
622
|
+
return np.array([self.Ex, self.Ey, self.Ez]), np.array([self.Hx, self.Hy, self.Hz])
|
|
623
|
+
|
|
624
|
+
@property
|
|
625
|
+
def E(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
626
|
+
''' Return the electric field as a tuple of numpy arrays '''
|
|
627
|
+
return self.Ex, self.Ey, self.Ez
|
|
628
|
+
|
|
629
|
+
@property
|
|
630
|
+
def normE(self) -> np.ndarray:
|
|
631
|
+
"""The complex norm of the E-field
|
|
632
|
+
"""
|
|
633
|
+
return np.sqrt(np.abs(self.Ex)**2 + np.abs(self.Ey)**2 + np.abs(self.Ez)**2)
|
|
634
|
+
|
|
635
|
+
@property
|
|
636
|
+
def normH(self) -> np.ndarray:
|
|
637
|
+
"""The complex norm of the H-field"""
|
|
638
|
+
return np.sqrt(np.abs(self.Hx)**2 + np.abs(self.Hy)**2 + np.abs(self.Hz)**2)
|
|
639
|
+
|
|
640
|
+
@property
|
|
641
|
+
def Emat(self) -> np.ndarray:
|
|
642
|
+
return np.array([self.Ex, self.Ey, self.Ez])
|
|
643
|
+
|
|
644
|
+
@property
|
|
645
|
+
def Hmat(self) -> np.ndarray:
|
|
646
|
+
return np.array([self.Hx, self.Hy, self.Hz])
|
|
647
|
+
|
|
648
|
+
@property
|
|
649
|
+
def H(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
650
|
+
''' Return the magnetic field as a tuple of numpy arrays '''
|
|
651
|
+
return self.Hx, self.Hy, self.Hz
|
|
652
|
+
|
|
653
|
+
def interpolate(self, xs: np.ndarray, ys: np.ndarray, zs: np.ndarray) -> EHField:
|
|
654
|
+
''' Interpolate the dataset in the provided xs, ys, zs values'''
|
|
655
|
+
shp = xs.shape
|
|
656
|
+
xf = xs.flatten()
|
|
657
|
+
yf = ys.flatten()
|
|
658
|
+
zf = zs.flatten()
|
|
659
|
+
Ex, Ey, Ez = self.basis.interpolate(self._field, xf, yf, zf)
|
|
660
|
+
self.Ex = Ex.reshape(shp)
|
|
661
|
+
self.Ey = Ey.reshape(shp)
|
|
662
|
+
self.Ez = Ez.reshape(shp)
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
constants = 1/ (-1j*2*np.pi*self.freq*(self._dur*4*np.pi*1e-7) )
|
|
666
|
+
Hx, Hy, Hz = self.basis.interpolate_curl(self._field, xf, yf, zf, constants)
|
|
667
|
+
ids = self.basis.interpolate_index(xf, yf, zf)
|
|
668
|
+
self.er = self._der[ids].reshape(shp)
|
|
669
|
+
self.ur = self._dur[ids].reshape(shp)
|
|
670
|
+
self.Hx = Hx.reshape(shp)
|
|
671
|
+
self.Hy = Hy.reshape(shp)
|
|
672
|
+
self.Hz = Hz.reshape(shp)
|
|
673
|
+
|
|
674
|
+
self._x = xs
|
|
675
|
+
self._y = ys
|
|
676
|
+
self._z = zs
|
|
677
|
+
return EHField(xs, ys, zs, self.Ex, self.Ey, self.Ez, self.Hx, self.Hy, self.Hz, self.freq, self.er, self.ur)
|
|
678
|
+
|
|
679
|
+
def cutplane(self,
|
|
680
|
+
ds: float,
|
|
681
|
+
x: float=None,
|
|
682
|
+
y: float=None,
|
|
683
|
+
z: float=None) -> EHField:
|
|
684
|
+
xb, yb, zb = self.basis.bounds
|
|
685
|
+
xs = np.linspace(xb[0], xb[1], int((xb[1]-xb[0])/ds))
|
|
686
|
+
ys = np.linspace(yb[0], yb[1], int((yb[1]-yb[0])/ds))
|
|
687
|
+
zs = np.linspace(zb[0], zb[1], int((zb[1]-zb[0])/ds))
|
|
688
|
+
if x is not None:
|
|
689
|
+
Y,Z = np.meshgrid(ys, zs)
|
|
690
|
+
X = x*np.ones_like(Y)
|
|
691
|
+
if y is not None:
|
|
692
|
+
X,Z = np.meshgrid(xs, zs)
|
|
693
|
+
Y = y*np.ones_like(X)
|
|
694
|
+
if z is not None:
|
|
695
|
+
X,Y = np.meshgrid(xs, ys)
|
|
696
|
+
Z = z*np.ones_like(Y)
|
|
697
|
+
return self.interpolate(X,Y,Z)
|
|
698
|
+
|
|
699
|
+
def grid(self, ds: float) -> EHField:
|
|
700
|
+
"""Interpolate a uniform grid sampled at ds
|
|
701
|
+
|
|
702
|
+
Args:
|
|
703
|
+
ds (float): the sampling distance
|
|
704
|
+
|
|
705
|
+
Returns:
|
|
706
|
+
This object
|
|
707
|
+
"""
|
|
708
|
+
xb, yb, zb = self.basis.bounds
|
|
709
|
+
xs = np.linspace(xb[0], xb[1], int((xb[1]-xb[0])/ds))
|
|
710
|
+
ys = np.linspace(yb[0], yb[1], int((yb[1]-yb[0])/ds))
|
|
711
|
+
zs = np.linspace(zb[0], zb[1], int((zb[1]-zb[0])/ds))
|
|
712
|
+
X, Y, Z = np.meshgrid(xs, ys, zs)
|
|
713
|
+
return self.interpolate(X,Y,Z)
|
|
714
|
+
|
|
715
|
+
def vector(self, field: Literal['E','H'], metric: Literal['real','imag','complex'] = 'real') -> tuple[np.ndarray, np.ndarray,np.ndarray,np.ndarray,np.ndarray,np.ndarray]:
|
|
716
|
+
"""Returns the X,Y,Z,Fx,Fy,Fz data to be directly cast into plot functions.
|
|
717
|
+
|
|
718
|
+
The field can be selected by a string literal. The metric of the complex vector field by the metric.
|
|
719
|
+
For animations, make sure to always use the complex metric.
|
|
720
|
+
|
|
721
|
+
Args:
|
|
722
|
+
field ('E','H'): The field to return
|
|
723
|
+
metric ([]'real','imag','complex'], optional): the metric to impose on the field. Defaults to 'real'.
|
|
724
|
+
|
|
725
|
+
Returns:
|
|
726
|
+
tuple[np.ndarray,...]: The X,Y,Z,Fx,Fy,Fz arrays
|
|
727
|
+
"""
|
|
728
|
+
if field=='E':
|
|
729
|
+
Fx, Fy, Fz = self.Ex, self.Ey, self.Ez
|
|
730
|
+
elif field=='H':
|
|
731
|
+
Fx, Fy, Fz = self.Hx, self.Hy, self.Hz
|
|
732
|
+
|
|
733
|
+
if metric=='real':
|
|
734
|
+
Fx, Fy, Fz = Fx.real, Fy.real, Fz.real
|
|
735
|
+
elif metric=='imag':
|
|
736
|
+
Fx, Fy, Fz = Fx.imag, Fy.imag, Fz.imag
|
|
737
|
+
|
|
738
|
+
return self._x, self._y, self._z, Fx, Fy, Fz
|
|
739
|
+
|
|
740
|
+
def scalar(self, field: Literal['Ex','Ey','Ez','Hx','Hy','Hz','normE','normH'], metric: Literal['abs','real','imag','complex'] = 'real') -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
|
741
|
+
"""Returns the data X, Y, Z, Field based on the interpolation
|
|
742
|
+
|
|
743
|
+
For animations, make sure to select the complex metric.
|
|
744
|
+
|
|
745
|
+
Args:
|
|
746
|
+
field (str): The field to plot
|
|
747
|
+
metric (str, optional): The metric to impose on the plot. Defaults to 'real'.
|
|
748
|
+
|
|
749
|
+
Returns:
|
|
750
|
+
(X,Y,Z,Field): The coordinates plus field scalar
|
|
751
|
+
"""
|
|
752
|
+
field = getattr(self, field)
|
|
753
|
+
if metric=='abs':
|
|
754
|
+
field = np.abs(field)
|
|
755
|
+
elif metric=='real':
|
|
756
|
+
field = field.real
|
|
757
|
+
elif metric=='imag':
|
|
758
|
+
field = field.imag
|
|
759
|
+
elif metric=='complex':
|
|
760
|
+
field = field
|
|
761
|
+
return self._x, self._y, self._z, field
|
|
762
|
+
|
|
763
|
+
def farfield_2d(self,ref_direction: tuple[float,float,float] | Axis,
|
|
764
|
+
plane_normal: tuple[float,float,float] | Axis,
|
|
765
|
+
faces: FaceSelection | GeoSurface,
|
|
766
|
+
ang_range: tuple[float, float] = (-180, 180),
|
|
767
|
+
Npoints: int = 201,
|
|
768
|
+
origin: tuple[float, float, float] = None,
|
|
769
|
+
syms: list[Literal['Ex','Ey','Ez', 'Hx','Hy','Hz']] = None) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
770
|
+
"""Compute the farfield electric and magnetic field defined by a circle.
|
|
771
|
+
|
|
772
|
+
Args:
|
|
773
|
+
ref_direction (tuple[float,float,float] | Axis): The direction for angle=0
|
|
774
|
+
plane_normal (tuple[float,float,float] | Axis): The rotation axis of the angular cutplane
|
|
775
|
+
faces (FaceSelection | GeoSurface): The faces to integrate over
|
|
776
|
+
ang_range (tuple[float, float], optional): The angular rage limits. Defaults to (-180, 180).
|
|
777
|
+
Npoints (int, optional): The number of angular points. Defaults to 201.
|
|
778
|
+
origin (tuple[float, float, float], optional): The farfield origin. Defaults to (0,0,0).
|
|
779
|
+
syms (list[Literal['Ex','Ey','Ez','Hx','Hy','Hz']], optional): E and H-plane symmetry planes where Ex is E-symmetry in x=0. Defaults to []
|
|
780
|
+
|
|
781
|
+
Returns:
|
|
782
|
+
tuple[np.ndarray, np.ndarray, np.ndarray]: _description_
|
|
783
|
+
"""
|
|
784
|
+
refdir = _parse_axis(ref_direction).np
|
|
785
|
+
plane_normal = _parse_axis(plane_normal).np
|
|
786
|
+
theta, phi = arc_on_plane(refdir, plane_normal, ang_range, Npoints)
|
|
787
|
+
E,H = self.farfield(theta, phi, faces, origin, syms = syms)
|
|
788
|
+
angs = np.linspace(*ang_range, Npoints)*np.pi/180
|
|
789
|
+
return angs, E ,H
|
|
790
|
+
|
|
791
|
+
def farfield_3d(self,
|
|
792
|
+
faces: FaceSelection | GeoSurface,
|
|
793
|
+
thetas: np.ndarray = None,
|
|
794
|
+
phis: np.ndarray = None,
|
|
795
|
+
origin: tuple[float, float, float] = None,
|
|
796
|
+
syms: list[Literal['Ex','Ey','Ez', 'Hx','Hy','Hz']] = None) -> FarfieldData:
|
|
797
|
+
"""Compute the farfield in a 3D angular grid
|
|
798
|
+
|
|
799
|
+
If thetas and phis are not provided, they default to a sample space of 2 degrees.
|
|
800
|
+
|
|
801
|
+
Args:
|
|
802
|
+
faces (FaceSelection | GeoSurface): The integration faces
|
|
803
|
+
thetas (np.ndarray, optional): The 1D array of theta values. Defaults to None.
|
|
804
|
+
phis (np.ndarray, optional): A 1D array of phi values. Defaults to None.
|
|
805
|
+
origin (tuple[float, float, float], optional): The boundary normal alignment origin. Defaults to (0,0,0).
|
|
806
|
+
syms (list[Literal['Ex','Ey','Ez','Hx','Hy','Hz']], optional): E and H-plane symmetry planes where Ex is E-symmetry in x=0. Defaults to []
|
|
807
|
+
Returns:
|
|
808
|
+
tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: The 2D theta, phi, E and H matrices.
|
|
809
|
+
"""
|
|
810
|
+
if thetas is None:
|
|
811
|
+
thetas = np.linspace(0,np.pi, 91)
|
|
812
|
+
if phis is None:
|
|
813
|
+
phis = np.linspace(-np.pi, np.pi, 181)
|
|
814
|
+
|
|
815
|
+
T,P = np.meshgrid(thetas, phis)
|
|
816
|
+
|
|
817
|
+
E, H = self.farfield(T.flatten(), P.flatten(), faces, origin, syms=syms)
|
|
818
|
+
E = E.reshape((3, ) + T.shape)
|
|
819
|
+
H = H.reshape((3, ) + T.shape)
|
|
820
|
+
|
|
821
|
+
return FarfieldData(E, H, T, P)
|
|
822
|
+
|
|
823
|
+
def farfield(self, theta: np.ndarray,
|
|
824
|
+
phi: np.ndarray,
|
|
825
|
+
faces: FaceSelection | GeoSurface,
|
|
826
|
+
origin: tuple[float, float, float] = None,
|
|
827
|
+
syms: list[Literal['Ex','Ey','Ez', 'Hx','Hy','Hz']] = None) -> tuple[np.ndarray, np.ndarray]:
|
|
828
|
+
"""Compute the farfield at the provided theta/phi coordinates
|
|
829
|
+
|
|
830
|
+
Args:
|
|
831
|
+
theta (np.ndarray): The Theta coordinates as (N,) 1D Array
|
|
832
|
+
phi (np.ndarray): The Phi coordinates as (N,) 1D Array
|
|
833
|
+
faces (FaceSelection | GeoSurface): the faces to use as integration boundary
|
|
834
|
+
origin (tuple[float, float, float], optional): The surface normal origin. Defaults to (0,0,0).
|
|
835
|
+
syms (list[Literal['Ex','Ey','Ez','Hx','Hy','Hz']], optional): E and H-plane symmetry planes where Ex is E-symmetry in x=0. Defaults to []
|
|
836
|
+
|
|
837
|
+
Returns:
|
|
838
|
+
tuple[np.ndarray, np.ndarray]: The E and H field as (3,N) arrays
|
|
839
|
+
"""
|
|
840
|
+
if syms is None:
|
|
841
|
+
syms = []
|
|
842
|
+
|
|
843
|
+
from .sc import stratton_chu
|
|
844
|
+
surface = self.basis.mesh.boundary_surface(faces.tags, origin)
|
|
845
|
+
field = self.interpolate(*surface.exyz)
|
|
846
|
+
|
|
847
|
+
Eff,Hff = stratton_chu(field.E, field.H, surface, theta, phi, self.k0)
|
|
848
|
+
|
|
849
|
+
if len(syms)==0:
|
|
850
|
+
return Eff, Hff
|
|
851
|
+
|
|
852
|
+
if len(syms)==1:
|
|
853
|
+
perms = ((syms[0], '##', '##'),)
|
|
854
|
+
elif len(syms)==2:
|
|
855
|
+
s1, s2 = syms
|
|
856
|
+
perms = ((s1, '##', '##'), (s2, '##', '##'), (s1, s2, '##'))
|
|
857
|
+
elif len(syms)==3:
|
|
858
|
+
s1, s2, s3 = syms
|
|
859
|
+
perms = ((s1, '##', '##'), (s2, '##', '##'), (s3, '##', '##'), (s1, s2, '##'), (s1, s3, '##'), (s2, s3, '##'), (s1, s2, s3))
|
|
860
|
+
|
|
861
|
+
for s1, s2, s3 in perms:
|
|
862
|
+
surf = surface.copy()
|
|
863
|
+
ehf = _EHSign()
|
|
864
|
+
ehf.apply(s1)
|
|
865
|
+
ehf.apply(s2)
|
|
866
|
+
ehf.apply(s3)
|
|
867
|
+
Ef, Hf = ehf.flip_field(field.E, field.H)
|
|
868
|
+
surf.flip(s1[1])
|
|
869
|
+
surf.flip(s2[1])
|
|
870
|
+
surf.flip(s3[1])
|
|
871
|
+
E2, H2 = stratton_chu(Ef, Hf, surf, theta, phi, self.k0)
|
|
872
|
+
Eff = Eff + E2
|
|
873
|
+
Hff = Hff + H2
|
|
874
|
+
|
|
875
|
+
return Eff, Hff
|
|
876
|
+
|
|
877
|
+
class MWScalar:
|
|
878
|
+
"""The MWDataSet class stores solution data of FEM Time Harmonic simulations.
|
|
879
|
+
"""
|
|
880
|
+
_fields: list[str] = ['freq','k0','Sp','beta','Pout','Z0']
|
|
881
|
+
_copy: list[str] = ['_portmap','_portnumbers','port_modes']
|
|
882
|
+
|
|
883
|
+
def __init__(self):
|
|
884
|
+
self.freq: float = None
|
|
885
|
+
self.k0: float = None
|
|
886
|
+
self.Sp: np.ndarray = None
|
|
887
|
+
self.beta: np.ndarray = None
|
|
888
|
+
self.Z0: np.ndarray = None
|
|
889
|
+
self.Pout: np.ndarray = None
|
|
890
|
+
self._portmap: dict[int, float|int] = dict()
|
|
891
|
+
self._portnumbers: list[int | float] = []
|
|
892
|
+
self.port_modes: list[PortProperties] = []
|
|
893
|
+
|
|
894
|
+
def init_sp(self, portnumbers: list[int | float]) -> None:
|
|
895
|
+
"""Initialize the S-parameter dataset with the given number of ports."""
|
|
896
|
+
self._portnumbers = portnumbers
|
|
897
|
+
i = 0
|
|
898
|
+
for n in portnumbers:
|
|
899
|
+
self._portmap[n] = i
|
|
900
|
+
i += 1
|
|
901
|
+
|
|
902
|
+
self.Sp = np.zeros((i,i), dtype=np.complex128)
|
|
903
|
+
self.Z0 = np.zeros((i,), dtype=np.complex128)
|
|
904
|
+
self.Pout = np.zeros((i,), dtype=np.float64)
|
|
905
|
+
self.beta = np.zeros((i,), dtype=np.complex128)
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def write_S(self, i1: int | float, i2: int | float, value: complex) -> None:
|
|
909
|
+
self.Sp[self._portmap[i1], self._portmap[i2]] = value
|
|
910
|
+
|
|
911
|
+
def S(self, i1: int, i2: int) -> complex:
|
|
912
|
+
"""Return the S-parameter corresponding to the given set of indices:
|
|
913
|
+
|
|
914
|
+
S11 = obj.S(1,1)
|
|
915
|
+
|
|
916
|
+
Args:
|
|
917
|
+
i1 (int): The first port index
|
|
918
|
+
i2 (int): The second port index
|
|
919
|
+
|
|
920
|
+
Returns:
|
|
921
|
+
complex: The S-parameter
|
|
922
|
+
"""
|
|
923
|
+
return self.Sp[self._portmap[i1], self._portmap[i2]]
|
|
924
|
+
|
|
925
|
+
def add_port_properties(self,
|
|
926
|
+
port_number: int,
|
|
927
|
+
mode_number: int,
|
|
928
|
+
k0: float,
|
|
929
|
+
beta: float,
|
|
930
|
+
Z0: float,
|
|
931
|
+
Pout: float) -> None:
|
|
932
|
+
i = self._portmap[port_number]
|
|
933
|
+
self.beta[i] = beta
|
|
934
|
+
self.Z0[i] = Z0
|
|
935
|
+
self.Pout[i] = Pout
|
|
936
|
+
|
|
937
|
+
class MWScalarNdim:
|
|
938
|
+
_fields: list[str] = ['freq','k0','Sp','beta','Pout','Z0']
|
|
939
|
+
_copy: list[str] = ['_portmap','_portnumbers']
|
|
940
|
+
|
|
941
|
+
def __init__(self):
|
|
942
|
+
self.freq: np.ndarray = None
|
|
943
|
+
self.k0: np.ndarray = None
|
|
944
|
+
self.Sp: np.ndarray = None
|
|
945
|
+
self.beta: np.ndarray = None
|
|
946
|
+
self.Z0: np.ndarray = None
|
|
947
|
+
self.Pout: np.ndarray = None
|
|
948
|
+
self._portmap: dict[int, float|int] = dict()
|
|
949
|
+
self._portnumbers: list[int | float] = []
|
|
950
|
+
|
|
951
|
+
def S(self, i1: int, i2: int) -> np.ndarray:
|
|
952
|
+
return self.Sp[...,self._portmap[i1], self._portmap[i2]]
|
|
953
|
+
|
|
954
|
+
def model_S(self, i: int, j: int,
|
|
955
|
+
freq: np.ndarray,
|
|
956
|
+
Npoles: int | Literal['auto'] = 'auto',
|
|
957
|
+
inc_real: bool = False,
|
|
958
|
+
maxpoles: int = 30) -> np.ndarray:
|
|
959
|
+
"""Returns an S-parameter model object at a dense frequency range.
|
|
960
|
+
This method uses vector fitting inside the datasets frequency points to determine a model for the linear system.
|
|
961
|
+
|
|
962
|
+
Args:
|
|
963
|
+
i (int): The first S-parameter index
|
|
964
|
+
j (int): The second S-parameter index
|
|
965
|
+
freq (np.ndarray): The frequency sample points
|
|
966
|
+
Npoles (int | 'auto', optional): The number of poles to use (approx 2x divice order). Defaults to 10.
|
|
967
|
+
inc_real (bool, optional): Wether to allow for a real-pole. Defaults to False.
|
|
968
|
+
|
|
969
|
+
Returns:
|
|
970
|
+
SparamModel: The SparamModel object
|
|
971
|
+
"""
|
|
972
|
+
return SparamModel(self.freq, self.S(i,j), n_poles=Npoles, inc_real=inc_real, maxpoles=maxpoles)(freq)
|
|
973
|
+
|
|
974
|
+
def model_Smat(self, frequencies: np.ndarray,
|
|
975
|
+
Npoles: int = 10,
|
|
976
|
+
inc_real: bool = False) -> np.ndarray:
|
|
977
|
+
"""Generates a full S-parameter matrix on the provided frequency points using the Vector Fitting algorithm.
|
|
978
|
+
|
|
979
|
+
This function output can be used directly with the .save_matrix() method.
|
|
980
|
+
|
|
981
|
+
Args:
|
|
982
|
+
frequencies (np.ndarray): The sample frequencies
|
|
983
|
+
Npoles (int, optional): The number of poles to fit. Defaults to 10.
|
|
984
|
+
inc_real (bool, optional): Wether allow for a real pole. Defaults to False.
|
|
985
|
+
|
|
986
|
+
Returns:
|
|
987
|
+
np.ndarray: The (Nf,Np,Np) S-parameter matrix
|
|
988
|
+
"""
|
|
989
|
+
Nports = len(self.datasets[0].excitation)
|
|
990
|
+
nfreq = frequencies.shape[0]
|
|
991
|
+
|
|
992
|
+
Smat = np.zeros((nfreq,Nports,Nports), dtype=np.complex128)
|
|
993
|
+
|
|
994
|
+
for i in self._portnumbers:
|
|
995
|
+
for j in self._portnumbers:
|
|
996
|
+
S = self.model_S(i,j,frequencies, Npoles=Npoles, inc_real=inc_real)
|
|
997
|
+
Smat[:,i-1,j-1] = S
|
|
998
|
+
|
|
999
|
+
return Smat
|
|
1000
|
+
|
|
1001
|
+
def export_touchstone(self,
|
|
1002
|
+
filename: str,
|
|
1003
|
+
Z0ref: float = None,
|
|
1004
|
+
format: Literal['RI','MA','DB'] = 'RI',
|
|
1005
|
+
custom_comments: list[str] = None,
|
|
1006
|
+
funit: Literal['HZ','KHZ','MHZ','GHZ'] = 'GHZ'):
|
|
1007
|
+
"""Export the S-parameter data to a touchstone file
|
|
1008
|
+
|
|
1009
|
+
This function assumes that all ports are numbered in sequence 1,2,3,4... etc with
|
|
1010
|
+
no missing ports. Otherwise it crashes. Will be update/improved soon with more features.
|
|
1011
|
+
|
|
1012
|
+
Additionally, one may provide a reference impedance. If this argument is provided, a port impedance renormalization
|
|
1013
|
+
will be performed to that common impedance.
|
|
1014
|
+
|
|
1015
|
+
Args:
|
|
1016
|
+
filename (str): The File name
|
|
1017
|
+
Z0ref (float): The reference impedance to normalize to. Defaults to None
|
|
1018
|
+
format (Literal[DB, RI, MA]): The dataformat used in the touchstone file.
|
|
1019
|
+
custom_comments : list[str], optional. List of custom comment strings to add to the touchstone file header.
|
|
1020
|
+
Each string will be prefixed with "! " automatically.
|
|
1021
|
+
"""
|
|
1022
|
+
|
|
1023
|
+
logger.info(f'Exporting S-data to {filename}')
|
|
1024
|
+
Nports = len(self._portmap)
|
|
1025
|
+
freqs = self.freq
|
|
1026
|
+
|
|
1027
|
+
Smat = np.zeros((len(freqs),Nports,Nports), dtype=np.complex128)
|
|
1028
|
+
|
|
1029
|
+
for i in range(1,Nports+1):
|
|
1030
|
+
for j in range(1,Nports+1):
|
|
1031
|
+
S = self.S(i,j)
|
|
1032
|
+
Smat[:,i-1,j-1] = S
|
|
1033
|
+
|
|
1034
|
+
self.save_smatrix(filename, Smat, freqs, format=format, Z0ref=Z0ref, custom_comments=custom_comments, funit=funit)
|
|
1035
|
+
|
|
1036
|
+
def save_smatrix(self,
|
|
1037
|
+
filename: str,
|
|
1038
|
+
Smatrix: np.ndarray,
|
|
1039
|
+
frequencies: np.ndarray,
|
|
1040
|
+
Z0ref: float = None,
|
|
1041
|
+
format: Literal['RI','MA','DB'] = 'RI',
|
|
1042
|
+
custom_comments: list[str] = None,
|
|
1043
|
+
funit: Literal['HZ','KHZ','MHZ','GHZ'] = 'GHZ') -> None:
|
|
1044
|
+
"""Save an S-parameter matrix to a touchstone file.
|
|
1045
|
+
|
|
1046
|
+
Additionally, a reference impedance may be supplied. In this case, a port renormalization will be performed on the S-matrix.
|
|
1047
|
+
|
|
1048
|
+
Args:
|
|
1049
|
+
filename (str): The filename
|
|
1050
|
+
Smatrix (np.ndarray): The S-parameter matrix with shape (Nfreq, Nport, Nport)
|
|
1051
|
+
frequencies (np.ndarray): The frequencies with size (Nfreq,)
|
|
1052
|
+
Z0ref (float, optional): An optional reference impedance to normalize to. Defaults to None.
|
|
1053
|
+
format (Literal["RI","MA",'DB], optional): The S-parameter format. Defaults to 'RI'.
|
|
1054
|
+
custom_comments : list[str], optional. List of custom comment strings to add to the touchstone file header.
|
|
1055
|
+
Each string will be prefixed with "! " automatically.
|
|
1056
|
+
"""
|
|
1057
|
+
from .touchstone import generate_touchstone
|
|
1058
|
+
|
|
1059
|
+
if Z0ref is not None:
|
|
1060
|
+
Z0s = self.Z0
|
|
1061
|
+
logger.debug(f'Renormalizing impedances {Z0s}Ω to {Z0ref}Ω')
|
|
1062
|
+
Smatrix = renormalise_s(Smatrix, Z0s, Z0ref)
|
|
1063
|
+
|
|
1064
|
+
|
|
1065
|
+
generate_touchstone(filename, frequencies, Smatrix, format, custom_comments, funit)
|
|
1066
|
+
|
|
1067
|
+
logger.info('Export complete!')
|
|
1068
|
+
|
|
1069
|
+
# class MWSimData(SimData[MWConstants]):
|
|
1070
|
+
# """The MWSimData class contains all EM simulation data from a Time Harmonic simulation
|
|
1071
|
+
# along all sweep axes.
|
|
1072
|
+
# """
|
|
1073
|
+
# datatype: type = MWConstants
|
|
1074
|
+
# def __init__(self):
|
|
1075
|
+
# super().__init__()
|
|
1076
|
+
# self._injections = dict()
|
|
1077
|
+
# self._axis = 'freq'
|
|
1078
|
+
|
|
1079
|
+
# def __getitem__(self, field: EMField) -> np.ndarray:
|
|
1080
|
+
# return getattr(self, field)
|
|
1081
|
+
|
|
1082
|
+
# @property
|
|
1083
|
+
# def mesh(self) -> Mesh3D:
|
|
1084
|
+
# """Returns the relevant mesh object for this dataset assuming they are all the same.
|
|
1085
|
+
|
|
1086
|
+
# Returns:
|
|
1087
|
+
# Mesh3D: The mesh object.
|
|
1088
|
+
# """
|
|
1089
|
+
# return self.datasets[0].basis.mesh
|
|
1090
|
+
|
|
1091
|
+
# def howto(self) -> None:
|
|
1092
|
+
# """To access data in the MWSimData class use the .ax method to extract properties selected
|
|
1093
|
+
# along an access of global variables. The axes are all global properties that the MWDataSets manage.
|
|
1094
|
+
|
|
1095
|
+
# For example the following would return all S(2,1) parameters along the frequency axis.
|
|
1096
|
+
|
|
1097
|
+
# >>> freq, S21 = dataset.ax('freq').S(2,1)
|
|
1098
|
+
|
|
1099
|
+
# Alternatively, one can manually select any solution indexed in order of generation using.
|
|
1100
|
+
|
|
1101
|
+
# >>> S21 = dataset.item(3).S(2,1)
|
|
1102
|
+
|
|
1103
|
+
# To find the E or H fields at any coordinate, one can use the Dataset's .interpolate method.
|
|
1104
|
+
# This method returns the same Dataset object after which the computed fields can be accessed.
|
|
1105
|
+
|
|
1106
|
+
# >>> Ex = dataset.item(3).interpolate(xs,ys,zs).Ex
|
|
1107
|
+
|
|
1108
|
+
# Lastly, to find the solutions for a given frequency or other value, you can also just call the dataset
|
|
1109
|
+
# class:
|
|
1110
|
+
|
|
1111
|
+
# >>> Ex, Ey, Ez = dataset(freq=1e9).interpolate(xs,ys,zs).E
|
|
1112
|
+
|
|
1113
|
+
# """
|
|
1114
|
+
|
|
1115
|
+
# def select(self, **axes: EMField) -> MWSimData:
|
|
1116
|
+
# """Takes the provided axis points and constructs a new dataset only for those axes values
|
|
1117
|
+
|
|
1118
|
+
# Returns:
|
|
1119
|
+
# MWSimData: The new dataset
|
|
1120
|
+
# """
|
|
1121
|
+
# newdata = MWSimData()
|
|
1122
|
+
# for dataset in self.datasets:
|
|
1123
|
+
# if dataset.equals(**axes):
|
|
1124
|
+
# newdata.datasets.append(dataset)
|
|
1125
|
+
# return newdata
|
|
1126
|
+
|
|
1127
|
+
# def ax(self, *field: EMField) -> MWConstants:
|
|
1128
|
+
# """Return a MWDataSet proxy object that you can request properties for along a provided axis.
|
|
1129
|
+
|
|
1130
|
+
# The MWSimData class contains a list of MWDataSet objects. Any global variable like .freq of the
|
|
1131
|
+
# MWDataSet object can be used as inner-axes after which the outer axis can be selected as if
|
|
1132
|
+
# you are extract a single one.
|
|
1133
|
+
|
|
1134
|
+
# Args:
|
|
1135
|
+
# field (EMField): The global field variable to select the data along
|
|
1136
|
+
|
|
1137
|
+
# Returns:
|
|
1138
|
+
# MWDataSet: An MWDataSet object (actually a proxy for)
|
|
1139
|
+
|
|
1140
|
+
# Example:
|
|
1141
|
+
# The following will select all S11 parameters along the frequency axis:
|
|
1142
|
+
|
|
1143
|
+
# >>> freq, S11 = dataset.ax('freq').S(1,1)
|
|
1144
|
+
|
|
1145
|
+
# """
|
|
1146
|
+
# # find the real DataSet
|
|
1147
|
+
# return _DataSetProxy(field, self.datasets)
|
|
1148
|
+
|