emerge 0.4.7__py3-none-any.whl → 0.4.9__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

Files changed (78) hide show
  1. emerge/__init__.py +14 -14
  2. emerge/_emerge/__init__.py +42 -0
  3. emerge/_emerge/bc.py +197 -0
  4. emerge/_emerge/coord.py +119 -0
  5. emerge/_emerge/cs.py +523 -0
  6. emerge/_emerge/dataset.py +36 -0
  7. emerge/_emerge/elements/__init__.py +19 -0
  8. emerge/_emerge/elements/femdata.py +212 -0
  9. emerge/_emerge/elements/index_interp.py +64 -0
  10. emerge/_emerge/elements/legrange2.py +172 -0
  11. emerge/_emerge/elements/ned2_interp.py +645 -0
  12. emerge/_emerge/elements/nedelec2.py +140 -0
  13. emerge/_emerge/elements/nedleg2.py +217 -0
  14. emerge/_emerge/geo/__init__.py +24 -0
  15. emerge/_emerge/geo/horn.py +107 -0
  16. emerge/_emerge/geo/modeler.py +449 -0
  17. emerge/_emerge/geo/operations.py +254 -0
  18. emerge/_emerge/geo/pcb.py +1244 -0
  19. emerge/_emerge/geo/pcb_tools/calculator.py +28 -0
  20. emerge/_emerge/geo/pcb_tools/macro.py +79 -0
  21. emerge/_emerge/geo/pmlbox.py +204 -0
  22. emerge/_emerge/geo/polybased.py +529 -0
  23. emerge/_emerge/geo/shapes.py +427 -0
  24. emerge/_emerge/geo/step.py +77 -0
  25. emerge/_emerge/geo2d.py +86 -0
  26. emerge/_emerge/geometry.py +510 -0
  27. emerge/_emerge/howto.py +214 -0
  28. emerge/_emerge/logsettings.py +5 -0
  29. emerge/_emerge/material.py +118 -0
  30. emerge/_emerge/mesh3d.py +730 -0
  31. emerge/_emerge/mesher.py +339 -0
  32. emerge/_emerge/mth/common_functions.py +33 -0
  33. emerge/_emerge/mth/integrals.py +71 -0
  34. emerge/_emerge/mth/optimized.py +357 -0
  35. emerge/_emerge/periodic.py +263 -0
  36. emerge/_emerge/physics/__init__.py +0 -0
  37. emerge/_emerge/physics/microwave/__init__.py +1 -0
  38. emerge/_emerge/physics/microwave/adaptive_freq.py +279 -0
  39. emerge/_emerge/physics/microwave/assembly/assembler.py +569 -0
  40. emerge/_emerge/physics/microwave/assembly/curlcurl.py +448 -0
  41. emerge/_emerge/physics/microwave/assembly/generalized_eigen.py +426 -0
  42. emerge/_emerge/physics/microwave/assembly/robinbc.py +433 -0
  43. emerge/_emerge/physics/microwave/microwave_3d.py +1150 -0
  44. emerge/_emerge/physics/microwave/microwave_bc.py +915 -0
  45. emerge/_emerge/physics/microwave/microwave_data.py +1148 -0
  46. emerge/_emerge/physics/microwave/periodic.py +82 -0
  47. emerge/_emerge/physics/microwave/port_functions.py +53 -0
  48. emerge/_emerge/physics/microwave/sc.py +175 -0
  49. emerge/_emerge/physics/microwave/simjob.py +147 -0
  50. emerge/_emerge/physics/microwave/sparam.py +138 -0
  51. emerge/_emerge/physics/microwave/touchstone.py +140 -0
  52. emerge/_emerge/plot/__init__.py +0 -0
  53. emerge/_emerge/plot/display.py +394 -0
  54. emerge/_emerge/plot/grapher.py +93 -0
  55. emerge/_emerge/plot/matplotlib/mpldisplay.py +264 -0
  56. emerge/_emerge/plot/pyvista/__init__.py +1 -0
  57. emerge/_emerge/plot/pyvista/display.py +931 -0
  58. emerge/_emerge/plot/pyvista/display_settings.py +24 -0
  59. emerge/_emerge/plot/simple_plots.py +551 -0
  60. emerge/_emerge/plot.py +225 -0
  61. emerge/_emerge/projects/__init__.py +0 -0
  62. emerge/_emerge/projects/_gen_base.txt +32 -0
  63. emerge/_emerge/projects/_load_base.txt +24 -0
  64. emerge/_emerge/projects/generate_project.py +40 -0
  65. emerge/_emerge/selection.py +596 -0
  66. emerge/_emerge/simmodel.py +444 -0
  67. emerge/_emerge/simulation_data.py +411 -0
  68. emerge/_emerge/solver.py +993 -0
  69. emerge/_emerge/system.py +54 -0
  70. emerge/cli.py +19 -0
  71. emerge/lib.py +1 -1
  72. emerge/plot.py +1 -1
  73. {emerge-0.4.7.dist-info → emerge-0.4.9.dist-info}/METADATA +7 -6
  74. emerge-0.4.9.dist-info/RECORD +78 -0
  75. emerge-0.4.9.dist-info/entry_points.txt +2 -0
  76. emerge-0.4.7.dist-info/RECORD +0 -9
  77. emerge-0.4.7.dist-info/entry_points.txt +0 -2
  78. {emerge-0.4.7.dist-info → emerge-0.4.9.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
+