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,915 @@
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
+ import numpy as np
20
+ from loguru import logger
21
+ from typing import Callable, Literal
22
+ from ...selection import Selection, FaceSelection
23
+ from ...cs import CoordinateSystem, Axis, GCS
24
+ from ...coord import Line
25
+ from ...geometry import GeoSurface, GeoObject, GeoPolygon
26
+ from dataclasses import dataclass
27
+ from collections import defaultdict
28
+ from ...bc import BoundaryCondition, BoundaryConditionSet, Periodic
29
+ from ...geo import XYPolygon, XYPlate
30
+ from ...periodic import PeriodicCell, HexCell, RectCell, Alignment
31
+
32
+ class MWBoundaryConditionSet(BoundaryConditionSet):
33
+
34
+ def __init__(self, periodic_cell: PeriodicCell):
35
+ super().__init__()
36
+
37
+ self.PEC: type[PEC] = self._construct_bc(PEC)
38
+ self.PMC: type[PMC] = self._construct_bc(PMC)
39
+ self.AbsorbingBoundary: type[AbsorbingBoundary] = self._construct_bc(AbsorbingBoundary)
40
+ self.ModalPort: type[ModalPort] = self._construct_bc(ModalPort)
41
+ self.LumpedPort: type[LumpedPort] = self._construct_bc(LumpedPort)
42
+ self.LumpedElement: type[LumpedElement] = self._construct_bc(LumpedElement)
43
+ self.RectangularWaveguide: type[RectangularWaveguide] = self._construct_bc(RectangularWaveguide)
44
+ self.Periodic: type[Periodic] = self._construct_bc(Periodic)
45
+ self.FloquetPort: type[FloquetPort] = self._construct_bc(FloquetPort)
46
+
47
+ self._cell: PeriodicCell = None
48
+
49
+ def floquet_port(self, poly: GeoSurface, port_number: int) -> FloquetPort:
50
+ if self._cell is None:
51
+ raise ValueError('Periodic cel must be defined for this simulation.')
52
+ if isinstance(self._cell, RectCell):
53
+ port = self.FloquetPort(poly, port_number)
54
+ port.width = self._cell.width
55
+ port.height = self._cell.height
56
+ elif isinstance(self._cell, HexCell):
57
+ port = self.FloquetPort(poly, port_number)
58
+ port.area = 1.0
59
+ self._cell._ports.append(port)
60
+ return port
61
+
62
+
63
+
64
+ class PEC(BoundaryCondition):
65
+
66
+ def __init__(self,
67
+ face: FaceSelection | GeoSurface):
68
+ """The general perfect electric conductor boundary condition.
69
+
70
+ The physics compiler will by default always turn all exterior faces into a PEC.
71
+
72
+ Args:
73
+ face (FaceSelection | GeoSurface): The boundary surface
74
+ """
75
+ super().__init__(face)
76
+
77
+ class PMC(BoundaryCondition):
78
+ pass
79
+
80
+ class RobinBC(BoundaryCondition):
81
+
82
+ _include_stiff: bool = False
83
+ _include_mass: bool = False
84
+ _include_force: bool = False
85
+
86
+ def __init__(self, selection: GeoSurface | Selection):
87
+ """A Generalization of any boundary condition of the third kind (Robin).
88
+
89
+ This should not be created directly. A robin boundary condition is the generalized type behind
90
+ port boundaries, radiation boundaries etc. Since all boundary conditions of the thrid kind (Robin)
91
+ are assembled the same, this class is used during assembly.
92
+
93
+ Args:
94
+ selection (GeoSurface | Selection): The boundary surface.
95
+ """
96
+ super().__init__(selection)
97
+ self.v_integration: bool = False
98
+ self.vintline: Line = None
99
+
100
+ def get_basis(self) -> np.ndarray:
101
+ return None
102
+
103
+ def get_inv_basis(self) -> np.ndarray:
104
+ return None
105
+
106
+ def get_beta(self, k0) -> float:
107
+ raise NotImplementedError('get_beta not implemented for Port class')
108
+
109
+ def get_gamma(self, k0) -> float:
110
+ raise NotImplementedError('get_gamma not implemented for Port class')
111
+
112
+ def get_Uinc(self, k0) -> np.ndarray:
113
+ raise NotImplementedError('get_Uinc not implemented for Port class')
114
+
115
+ class PortBC(RobinBC):
116
+ Zvac: float = 376.730313412
117
+ def __init__(self, face: FaceSelection | GeoSurface):
118
+ """(DO NOT USE) A generalization of the Port boundary condition.
119
+
120
+ DO NOT USE THIS TO DEFINE PORTS. This class is only indeded for
121
+ class inheritance and type checking.
122
+
123
+ Args:
124
+ face (FaceSelection | GeoSurface): The port face
125
+ """
126
+ super().__init__(face)
127
+ self.port_number: int = None
128
+ self.cs: CoordinateSystem = None
129
+ self.selected_mode: int = 0
130
+ self.Z0 = None
131
+ self.active: bool = False
132
+
133
+ def get_basis(self) -> np.ndarray:
134
+ return self.cs._basis
135
+
136
+ def get_inv_basis(self) -> np.ndarray:
137
+ return self.cs._basis_inv
138
+
139
+ def portZ0(self, k0: float = None) -> complex:
140
+ """Returns the port characteristic impedance given a phase constant
141
+
142
+ Args:
143
+ k0 (float): The phase constant
144
+
145
+ Returns:
146
+ complex: The port impedance
147
+ """
148
+ return self.Z0
149
+
150
+ def modetype(self, k0: float) -> Literal['TEM','TE','TM']:
151
+ return 'TEM'
152
+
153
+ def Zmode(self, k0: float) -> float:
154
+ if self.modetype(k0)=='TEM':
155
+ return self.Zvac
156
+ elif self.modetype(k0)=='TE':
157
+ return k0*299792458/self.get_beta(k0) * 4*np.pi*1e-7
158
+ elif self.modetype(k0)=='TM':
159
+ return self.get_beta(k0)/(k0*299792458*8.854187818814*1e-12)
160
+ else:
161
+ return ValueError(f'Port mode type should be TEM, TE or TM but instead is {self.modetype(k0)}')
162
+
163
+ def _qmode(self, k0: float) -> float:
164
+ """Computes a mode amplitude correction factor.
165
+ The total output power of a port as a function of the field amplitude is not constant.
166
+ For TE and TM modes the output power depends on the mode impedance. This factor corrects
167
+ the mode output power to 1W by scaling the E-field appropriately.
168
+
169
+ Args:
170
+ k0 (float): The phase constant of the simulation
171
+
172
+ Returns:
173
+ float: The mode amplitude correction factor.
174
+ """
175
+ return np.sqrt(self.Zmode(k0)/376.73031341259)
176
+
177
+ @property
178
+ def mode_number(self) -> int:
179
+ return self.selected_mode + 1
180
+
181
+ def get_beta(self, k0) -> float:
182
+ ''' Return the out of plane propagation constant. βz.'''
183
+ return k0
184
+
185
+ def get_gamma(self, k0):
186
+ """Computes the γ-constant for matrix assembly. This constant is required for the Robin boundary condition.
187
+
188
+ Args:
189
+ k0 (float): The free space propagation constant.
190
+
191
+ Returns:
192
+ complex: The γ-constant
193
+ """
194
+ return 1j*self.get_beta(k0)
195
+
196
+ def port_mode_3d(self,
197
+ xs: np.ndarray,
198
+ ys: np.ndarray,
199
+ k0: float,
200
+ which: Literal['E','H'] = 'E') -> np.ndarray:
201
+ raise NotImplementedError('port_mode_3d not implemented for Port class')
202
+
203
+ def port_mode_3d_global(self,
204
+ x_global: np.ndarray,
205
+ y_global: np.ndarray,
206
+ z_global: np.ndarray,
207
+ k0: float,
208
+ which: Literal['E','H'] = 'E') -> np.ndarray:
209
+ xl, yl, _ = self.cs.in_local_cs(x_global, y_global, z_global)
210
+ Ex, Ey, Ez = self.port_mode_3d(xl, yl, k0)
211
+ Exg, Eyg, Ezg = self.cs.in_global_basis(Ex, Ey, Ez)
212
+ return np.array([Exg, Eyg, Ezg])
213
+
214
+ class AbsorbingBoundary(RobinBC):
215
+
216
+ _include_stiff: bool = True
217
+ _include_mass: bool = True
218
+ _include_force: bool = False
219
+
220
+ def __init__(self,
221
+ face: FaceSelection | GeoSurface,
222
+ order: int = 1,
223
+ origin: tuple = None):
224
+ """Creates an AbsorbingBoundary condition.
225
+
226
+ Currently only a first order boundary condition is possible. Second order will be supported later.
227
+ The absorbing boundary is effectively a port boundary condition (Robin) with an assumption on
228
+ the out-of-plane phase constant. For now it always assumes the free-space propagation (normal).
229
+
230
+ Args:
231
+ face (FaceSelection | GeoSurface): The absorbing boundary face(s)
232
+ order (int, optional): The order (only 1 is supported). Defaults to 1.
233
+ origin (tuple, optional): The radiation origin. Defaults to None.
234
+ """
235
+ super().__init__(face)
236
+
237
+ self.order: int = order
238
+ self.origin: tuple = origin
239
+ self.cs: CoordinateSystem = GCS
240
+
241
+ def get_basis(self) -> np.ndarray:
242
+ return np.eye(3)
243
+
244
+ def get_beta(self, k0) -> float:
245
+ ''' Return the out of plane propagation constant. βz.'''
246
+ return k0
247
+
248
+ def get_gamma(self, k0):
249
+ """Computes the γ-constant for matrix assembly. This constant is required for the Robin boundary condition.
250
+
251
+ Args:
252
+ k0 (float): The free space propagation constant.
253
+
254
+ Returns:
255
+ complex: The γ-constant
256
+ """
257
+ return 1j*self.get_beta(k0)
258
+
259
+ def get_Uinc(self, x_local, y_local, k0) -> np.ndarray:
260
+ return np.zeros((3, len(x_local)), dtype=np.complex128)
261
+
262
+ @dataclass
263
+ class PortMode:
264
+ modefield: np.ndarray
265
+ E_function: Callable
266
+ H_function: Callable
267
+ k0: float
268
+ beta: float
269
+ residual: float
270
+ energy: float = None
271
+ norm_factor: float = 1
272
+ freq: float = None
273
+ neff: float = None
274
+ TEM: bool = None
275
+ Z0: float = None
276
+ polarity: float = 1
277
+ modetype: Literal['TEM','TE','TM'] = 'TEM'
278
+
279
+ def __post_init__(self):
280
+ self.neff = self.beta/self.k0
281
+ self.energy = np.mean(np.abs(self.modefield)**2)
282
+
283
+ def __str__(self):
284
+ return f'PortMode(k0={self.k0}, beta={self.beta}, neff={self.neff}, energy={self.energy})'
285
+
286
+ def set_power(self, power: complex) -> None:
287
+ self.norm_factor = np.sqrt(1/np.abs(power))
288
+ logger.info(f'Setting port mode amplitude to: {self.norm_factor} ')
289
+
290
+ class FloquetPort(PortBC):
291
+ _include_stiff: bool = True
292
+ _include_mass: bool = False
293
+ _include_force: bool = True
294
+
295
+ def __init__(self,
296
+ face: FaceSelection | GeoSurface,
297
+ port_number: int,
298
+ cs: CoordinateSystem = None,
299
+ power: float = 1.0,
300
+ er: float = 1.0):
301
+ super().__init__(face)
302
+ self.port_number: int= port_number
303
+ self.active: bool = True
304
+ self.power: float = power
305
+ self.type: str = 'TE'
306
+ self._field_amplitude: np.ndarray = None
307
+ self.mode: tuple[int,int] = (1,0)
308
+ self.cs: CoordinateSystem = cs
309
+ self.scan_theta: float = 0
310
+ self.scan_phi: float = 0
311
+ self.pol_s: complex = 1.0
312
+ self.pol_p: complex = 0.0
313
+ self.Zdir: Axis = -1
314
+ self.area: float = 1
315
+ if self.cs is None:
316
+ self.cs = GCS
317
+
318
+ def portZ0(self, k0: float = None) -> complex:
319
+ return 376.73031341259
320
+
321
+ def get_amplitude(self, k0: float) -> float:
322
+ return 1.0
323
+
324
+ def get_beta(self, k0: float) -> float:
325
+ ''' Return the out of plane propagation constant. βz.'''
326
+ return k0*np.cos(self.scan_theta)
327
+
328
+ def get_gamma(self, k0: float):
329
+ """Computes the γ-constant for matrix assembly. This constant is required for the Robin boundary condition.
330
+
331
+ Args:
332
+ k0 (float): The free space propagation constant.
333
+
334
+ Returns:
335
+ complex: The γ-constant
336
+ """
337
+ return 1j*self.get_beta(k0)
338
+
339
+ def get_Uinc(self, x_local: np.ndarray, y_local: np.ndarray, k0: float) -> np.ndarray:
340
+ return -2*1j*self.get_beta(k0)*self.port_mode_3d(x_local, y_local, k0)
341
+
342
+ def port_mode_3d(self,
343
+ x_local: np.ndarray,
344
+ y_local: np.ndarray,
345
+ k0: float,
346
+ which: Literal['E','H'] = 'E') -> np.ndarray:
347
+ ''' Compute the port mode E-field in local coordinates (XY) + Z out of plane.'''
348
+
349
+ kx = k0*np.sin(self.scan_theta)*np.cos(self.scan_phi)
350
+ ky = k0*np.sin(self.scan_theta)*np.sin(self.scan_phi)
351
+ kz = k0*np.cos(self.scan_theta)
352
+ phi = np.exp(-1j*(x_local*kx + y_local*ky))
353
+
354
+ P = self.pol_p
355
+ S = self.pol_s
356
+
357
+ E0 = self.get_amplitude(k0)*np.sqrt(2*376.73031341259/(self.area))
358
+ Ex = E0*(-S*np.sin(self.scan_phi) - P*np.cos(self.scan_theta)*np.cos(self.scan_phi))*phi
359
+ Ey = E0*(S*np.cos(self.scan_phi) - P*np.cos(self.scan_theta)*np.sin(self.scan_phi))*phi
360
+ Ez = E0*(-P*E0*np.sin(self.scan_theta))*phi
361
+ Exyz = np.array([Ex, Ey, Ez])
362
+ return Exyz
363
+
364
+ def port_mode_3d_global(self,
365
+ x_global: np.ndarray,
366
+ y_global: np.ndarray,
367
+ z_global: np.ndarray,
368
+ k0: float,
369
+ which: Literal['E','H'] = 'E') -> np.ndarray:
370
+ '''Compute the port mode field for global xyz coordinates.'''
371
+ xl, yl, _ = self.cs.in_local_cs(x_global, y_global, z_global)
372
+ Ex, Ey, Ez = self.port_mode_3d(xl, yl, k0)
373
+ Exg, Eyg, Ezg = self.cs.in_global_basis(Ex, Ey, Ez)
374
+ return np.array([Exg, Eyg, Ezg])
375
+
376
+ class ModalPort(PortBC):
377
+
378
+ _include_stiff: bool = True
379
+ _include_mass: bool = False
380
+ _include_force: bool = True
381
+
382
+ def __init__(self,
383
+ face: FaceSelection | GeoSurface,
384
+ port_number: int,
385
+ active: bool = False,
386
+ cs: CoordinateSystem = None,
387
+ power: float = 1,
388
+ TEM: bool = False,
389
+ mixed_materials: bool = False):
390
+ """Generes a ModalPort boundary condition for a port that requires eigenmode solutions for the mode.
391
+
392
+ The boundary condition requires a FaceSelection (or GeoSurface related) object for the face and a port
393
+ number.
394
+ If the face coordinate system is not provided a local coordinate system will be derived automatically
395
+ by finding the plane that spans the face nodes with minimial out-of-plane error.
396
+
397
+ All modal ports require the execution of a .modal_analysis() by the physics class to define
398
+ the port mode.
399
+
400
+ Args:
401
+ face (FaceSelection, GeoSurface): The port mode face
402
+ port_number (int): The port number as an integer
403
+ active (bool, optional): Whether the port is set active. Defaults to False.
404
+ cs (CoordinateSystem, optional): The local coordinate system of the port face. Defaults to None.
405
+ power (float, optional): The radiated power. Defaults to 1.
406
+ TEM (bool, optional): Wether the mode should be considered as a TEM mode. Defaults to False
407
+ mixed_materials (bool, optional): Wether the port consists of multiple different dielectrics. This requires
408
+ A recalculation of the port mode at every frequency
409
+ """
410
+ super().__init__(face)
411
+
412
+ self.port_number: int= port_number
413
+ self.active: bool = active
414
+ self.power: float = power
415
+ self.cs: CoordinateSystem = cs
416
+
417
+ self.selected_mode: int = 0
418
+ self.modes: dict[float, list[PortMode]] = defaultdict(list)
419
+
420
+ self.TEM: bool = TEM
421
+ self.mixed_materials: bool = mixed_materials
422
+ self.initialized: bool = False
423
+ self._first_k0: float = None
424
+ self._last_k0: float = None
425
+
426
+ if self.cs is None:
427
+ logger.info('Constructing coordinate system from normal port')
428
+ self.cs = Axis(self.selection.normal).construct_cs()
429
+
430
+ self._er: np.ndarray = None
431
+ self._ur: np.ndarray = None
432
+
433
+ def portZ0(self, k0: float) -> complex:
434
+ return self.get_mode(k0).Z0
435
+
436
+ def modetype(self, k0: float) -> Literal['TEM','TE','TM']:
437
+ return self.get_mode(k0).modetype
438
+
439
+ @property
440
+ def nmodes(self) -> int:
441
+ return len(self.modes[self._last_k0])
442
+
443
+ def sort_modes(self) -> None:
444
+ """Sorts the port modes based on total energy
445
+ """
446
+ for k0, modes in self.modes.items():
447
+ self.modes[k0] = sorted(modes, key=lambda m: m.energy, reverse=True)
448
+
449
+ def get_mode(self, k0: float, i=None) -> PortMode:
450
+ """Returns a given mode solution in the form of a PortMode object.
451
+
452
+ Args:
453
+ i (_type_, optional): The mode solution number. Defaults to None.
454
+
455
+ Returns:
456
+ PortMode: The requested PortMode object
457
+ """
458
+ if i is None:
459
+ i = self.selected_mode
460
+ return self.modes[min(self.modes.keys(), key=lambda k: abs(k - k0))][i]
461
+
462
+ def global_field_function(self, k0: float = 0, which: Literal['E','H'] = 'E') -> Callable:
463
+ ''' The field function used to compute the E-field.
464
+ This field-function is defined in global coordinates (not local coordinates).'''
465
+ mode = self.get_mode(k0)
466
+ if which == 'E':
467
+ return lambda x,y,z: mode.norm_factor * self._qmode(k0) * mode.E_function(x,y,z)*mode.polarity
468
+ else:
469
+ return lambda x,y,z: mode.norm_factor * self._qmode(k0) * mode.H_function(x,y,z)*mode.polarity
470
+
471
+ def clear_modes(self) -> None:
472
+ """Clear all port mode data"""
473
+ self.modes: dict[float, list[PortMode]] = defaultdict(list)
474
+ self.initialized = False
475
+
476
+ def add_mode(self,
477
+ field: np.ndarray,
478
+ E_function: Callable,
479
+ H_function: Callable,
480
+ beta: float,
481
+ k0: float,
482
+ residual: float,
483
+ TEM: bool,
484
+ freq: float) -> PortMode:
485
+ """Add a mode function to the ModalPort
486
+
487
+ Args:
488
+ field (np.ndarray): The field value array
489
+ E_function (Callable): The E-field callable
490
+ H_function (Callable): The H-field callable
491
+ beta (float): The out-of-plane propagation constant
492
+ k0 (float): The free space phase constant
493
+ residual (float): The solution residual
494
+ TEM (bool): Whether its a TEM mode
495
+ freq (float): The frequency of the port mode
496
+
497
+ Returns:
498
+ PortMode: The port mode object.
499
+ """
500
+ mode = PortMode(field, E_function, H_function, k0, beta, residual, TEM=TEM, freq=freq)
501
+ if mode.energy < 1e-4:
502
+ logger.debug(f'Ignoring mode due to a low mode energy: {mode.energy}')
503
+ return None
504
+ self.modes[k0].append(mode)
505
+ self.initialized = True
506
+
507
+ self._last_k0 = k0
508
+ if self._first_k0 is None:
509
+ self._first_k0 = k0
510
+ else:
511
+ ref_field = self.get_mode(self._first_k0, -1).modefield
512
+ polarity = np.sign(np.sum(field*ref_field).real)
513
+ logger.debug(f'Mode polarity = {polarity}')
514
+ mode.polarity = polarity
515
+
516
+ return mode
517
+
518
+ def get_basis(self) -> np.ndarray:
519
+ return self.cs._basis
520
+
521
+ def get_beta(self, k0: float) -> float:
522
+ mode = self.get_mode(k0)
523
+ if mode.TEM:
524
+ beta = mode.beta/mode.k0 * k0
525
+ else:
526
+ freq = k0*299792458/(2*np.pi)
527
+ beta = np.sqrt(mode.beta**2 + k0**2 * (1-((mode.freq/freq)**2)))
528
+ return beta
529
+
530
+ def get_gamma(self, k0: float):
531
+ return 1j*self.get_beta(k0)
532
+
533
+ def get_Uinc(self, x_local, y_local, k0) -> np.ndarray:
534
+ return -2*1j*self.get_beta(k0)*self.port_mode_3d(x_local, y_local, k0)
535
+
536
+ def port_mode_3d(self,
537
+ x_local: np.ndarray,
538
+ y_local: np.ndarray,
539
+ k0: float,
540
+ which: Literal['E','H'] = 'E') -> np.ndarray:
541
+ x_global, y_global, z_global = self.cs.in_global_cs(x_local, y_local, 0*x_local)
542
+
543
+ Egxyz = self.port_mode_3d_global(x_global,y_global,z_global,k0,which=which)
544
+
545
+ Ex, Ey, Ez = self.cs.in_local_basis(Egxyz[0,:], Egxyz[1,:], Egxyz[2,:])
546
+
547
+ Exyz = np.array([Ex, Ey, Ez])
548
+ return Exyz
549
+
550
+ def port_mode_3d_global(self,
551
+ x_global: np.ndarray,
552
+ y_global: np.ndarray,
553
+ z_global: np.ndarray,
554
+ k0: float,
555
+ which: Literal['E','H'] = 'E') -> np.ndarray:
556
+ Ex, Ey, Ez = self.global_field_function(k0, which)(x_global,y_global,z_global)
557
+ Exyz = np.array([Ex, Ey, Ez])
558
+ return Exyz
559
+
560
+ class RectangularWaveguide(PortBC):
561
+
562
+ _include_stiff: bool = True
563
+ _include_mass: bool = False
564
+ _include_force: bool = True
565
+
566
+ def __init__(self,
567
+ face: FaceSelection | GeoSurface,
568
+ port_number: int,
569
+ active: bool = False,
570
+ cs: CoordinateSystem = None,
571
+ dims: tuple[float, float] = None,
572
+ power: float = 1):
573
+ """Creates a rectangular waveguide as a port boundary condition.
574
+
575
+ Currently the Rectangular waveguide only supports TE0n modes. The mode field
576
+ is derived analytically. The local face coordinate system and dimensions can be provided
577
+ manually. If not provided the class will attempt to derive the local coordinate system and
578
+ face dimensions itself. It always orients the longest edge along the local X-direction.
579
+ The information on the derived coordiante system will be shown in the DEBUG level logs.
580
+
581
+ Args:
582
+ face (FaceSelection, GeoSurface): The port boundary face selection
583
+ port_number (int): The port number
584
+ active (bool, optional): Ther the port is active. Defaults to False.
585
+ cs (CoordinateSystem, optional): The local coordinate system. Defaults to None.
586
+ dims (tuple[float, float], optional): The port face. Defaults to None.
587
+ power (float): The port power. Default to 1.
588
+ """
589
+ super().__init__(face)
590
+
591
+ self.port_number: int= port_number
592
+ self.active: bool = active
593
+ self.power: float = power
594
+ self.type: str = 'TE'
595
+ self._field_amplitude: np.ndarray = None
596
+ self.mode: tuple[int,int] = (1,0)
597
+ self.cs: CoordinateSystem = cs
598
+
599
+ if dims is None:
600
+ logger.info("Determining port face based on selection")
601
+ cs, (width, height) = face.rect_basis()
602
+ self.cs = cs
603
+ self.dims = (width, height)
604
+ logger.debug(f'Port CS: {self.cs}')
605
+ logger.debug(f'Detected port {self.port_number} size = {width*1000:.1f} mm x {height*1000:.1f} mm')
606
+
607
+ if self.cs is None:
608
+ logger.info('Constructing coordinate system from normal port')
609
+ self.cs = Axis(self.selection.normal).construct_cs()
610
+ else:
611
+ self.cs: CoordinateSystem = cs
612
+
613
+ def portZ0(self, k0: float = None) -> complex:
614
+ return k0*299792458 * 4*np.pi*1e-7/self.get_beta(k0)
615
+
616
+ def modetype(self, k0):
617
+ return self.type
618
+
619
+ def get_amplitude(self, k0: float) -> float:
620
+ Zte = 376.73031341259
621
+ amplitude= np.sqrt(self.power*4*Zte/(self.dims[0]*self.dims[1]))
622
+ return amplitude
623
+
624
+ def port_mode_2d(self, xs: np.ndarray, ys: np.ndarray, k0: float) -> tuple[np.ndarray, float]:
625
+ x0 = xs[0]
626
+ y0 = ys[0]
627
+ x1 = xs[-1]
628
+ y1 = ys[-1]
629
+ xc = 0.5*(x0+x1)
630
+ yc = 0.5*(y0+y1)
631
+ a = np.sqrt((x1-x0)**2 + (y1-y0)**2)
632
+
633
+ logger.debug(f'Detected port {self.port_number} width = {a*1000:.1f} mm')
634
+ ds = np.sqrt((xs-xc)**2 + (ys-yc)**2)
635
+ return self.amplitude*np.cos(ds*np.pi/a), np.sqrt(k0**2 - (np.pi/a)**2)
636
+
637
+ def get_beta(self, k0: float) -> float:
638
+ ''' Return the out of plane propagation constant. βz.'''
639
+ width=self.dims[0]
640
+ height=self.dims[1]
641
+ beta = np.sqrt(k0**2 - (np.pi*self.mode[0]/width)**2 - (np.pi*self.mode[1]/height)**2)
642
+ return beta
643
+
644
+ def get_gamma(self, k0: float):
645
+ """Computes the γ-constant for matrix assembly. This constant is required for the Robin boundary condition.
646
+
647
+ Args:
648
+ k0 (float): The free space propagation constant.
649
+
650
+ Returns:
651
+ complex: The γ-constant
652
+ """
653
+ return 1j*self.get_beta(k0)
654
+
655
+ def get_Uinc(self, x_local: np.ndarray, y_local: np.ndarray, k0: float) -> np.ndarray:
656
+ return -2*1j*self.get_beta(k0)*self.port_mode_3d(x_local, y_local, k0)
657
+
658
+ def port_mode_3d(self,
659
+ x_local: np.ndarray,
660
+ y_local: np.ndarray,
661
+ k0: float,
662
+ which: Literal['E','H'] = 'E') -> np.ndarray:
663
+ ''' Compute the port mode E-field in local coordinates (XY) + Z out of plane.'''
664
+
665
+ width = self.dims[0]
666
+ height = self.dims[1]
667
+
668
+ E = self.get_amplitude(k0)*np.cos(np.pi*self.mode[0]*(x_local)/width)*np.cos(np.pi*self.mode[1]*(y_local)/height)
669
+ Ex = 0*E
670
+ Ey = E
671
+ Ez = 0*E
672
+ Exyz = self._qmode(k0) * np.array([Ex, Ey, Ez])
673
+ return Exyz
674
+
675
+ def port_mode_3d_global(self,
676
+ x_global: np.ndarray,
677
+ y_global: np.ndarray,
678
+ z_global: np.ndarray,
679
+ k0: float,
680
+ which: Literal['E','H'] = 'E') -> np.ndarray:
681
+ '''Compute the port mode field for global xyz coordinates.'''
682
+ xl, yl, _ = self.cs.in_local_cs(x_global, y_global, z_global)
683
+ Ex, Ey, Ez = self.port_mode_3d(xl, yl, k0)
684
+ Exg, Eyg, Ezg = self.cs.in_global_basis(Ex, Ey, Ez)
685
+ return np.array([Exg, Eyg, Ezg])
686
+
687
+ class LumpedPort(PortBC):
688
+
689
+ _include_stiff: bool = True
690
+ _include_mass: bool = False
691
+ _include_force: bool = True
692
+
693
+ def __init__(self,
694
+ face: FaceSelection | GeoSurface,
695
+ port_number: int,
696
+ width: float = None,
697
+ height: float = None,
698
+ direction: Axis = None,
699
+ Idirection: Axis = None,
700
+ active: bool = False,
701
+ power: float = 1,
702
+ Z0: float = 50):
703
+ """Generates a lumped power boundary condition.
704
+
705
+ The lumped port boundary condition assumes a uniform E-field along the "direction" axis.
706
+ The port with and height must be provided manually in meters. The height is the size
707
+ in the "direction" axis along which the potential is imposed. The width dimension
708
+ is orthogonal to that. For a rectangular face its the width and for a cyllindrical face
709
+ its the circumpherance.
710
+
711
+ Args:
712
+ face (FaceSelection, GeoSurface): The port surface
713
+ port_number (int): The port number
714
+ width (float): The port width (meters).
715
+ height (float): The port height (meters).
716
+ direction (Axis): The port direction as an Axis object (em.Axis(..) or em.ZAX)
717
+ active (bool, optional): Whether the port is active. Defaults to False.
718
+ power (float, optional): The port output power. Defaults to 1.
719
+ Z0 (float, optional): The port impedance. Defaults to 50.
720
+ """
721
+ super().__init__(face)
722
+
723
+ if width is None:
724
+ if not isinstance(face, GeoObject):
725
+ raise ValueError(f'The width, height and direction must be defined. Information cannot be extracted from {face}')
726
+ width, height, direction, Idirection = face._data('width','height','vdir', 'idir')
727
+ if width is None or height is None or direction is None:
728
+ raise ValueError(f'The width, height and direction could not be extracted from {face}')
729
+
730
+ logger.debug(f'Lumped port: width={1000*width:.1f}mm, height={1000*height:.1f}mm, direction={direction}')
731
+ self.port_number: int= port_number
732
+ self.active: bool = active
733
+
734
+ self.power: float = power
735
+ self.Z0: float = Z0
736
+
737
+ self._field_amplitude: np.ndarray = None
738
+ self.width: float = width
739
+ self.height: float = height
740
+ self.Vdirection: Axis = direction
741
+ self.Idirection: Axis = Idirection
742
+ self.type = 'TEM'
743
+
744
+ logger.info('Constructing coordinate system from normal port')
745
+ self.cs = Axis(self.selection.normal).construct_cs()
746
+
747
+ self.vintline: Line = None
748
+ self.v_integration = True
749
+ self.iintline: Line = None
750
+
751
+ @property
752
+ def surfZ(self) -> float:
753
+ """The surface sheet impedance for the lumped port
754
+
755
+ Returns:
756
+ float: The surface sheet impedance
757
+ """
758
+ return self.Z0*self.width/self.height
759
+
760
+ @property
761
+ def voltage(self) -> float:
762
+ """The Port voltage required for the provided output power (time average)
763
+
764
+ Returns:
765
+ float: The port voltage
766
+ """
767
+ return np.sqrt(2*self.power*self.Z0)
768
+
769
+ def get_basis(self) -> np.ndarray:
770
+ return self.cs._basis
771
+
772
+ def get_beta(self, k0: float) -> float:
773
+ ''' Return the out of plane propagation constant. βz.'''
774
+
775
+ return k0
776
+
777
+ def get_gamma(self, k0: float) -> complex:
778
+ """Computes the γ-constant for matrix assembly. This constant is required for the Robin boundary condition.
779
+
780
+ Args:
781
+ k0 (float): The free space propagation constant.
782
+
783
+ Returns:
784
+ complex: The γ-constant
785
+ """
786
+ return 1j*k0*376.730313412/self.surfZ
787
+
788
+ def get_Uinc(self, x_local, y_local, k0) -> np.ndarray:
789
+ Emag = -1j*2*k0 * self.voltage/self.height * (376.730313412/self.surfZ)
790
+ return Emag*self.port_mode_3d(x_local, y_local, k0)
791
+
792
+ def port_mode_3d(self,
793
+ x_local: np.ndarray,
794
+ y_local: np.ndarray,
795
+ k0: float,
796
+ which: Literal['E','H'] = 'E') -> np.ndarray:
797
+ ''' Compute the port mode E-field in local coordinates (XY) + Z out of plane.'''
798
+
799
+ px, py, pz = self.cs.in_local_basis(*self.Vdirection.np)
800
+
801
+ Ex = px*np.ones_like(x_local)
802
+ Ey = py*np.ones_like(x_local)
803
+ Ez = pz*np.ones_like(x_local)
804
+ Exyz = np.array([Ex, Ey, Ez])
805
+ return Exyz
806
+
807
+ def port_mode_3d_global(self,
808
+ x_global: np.ndarray,
809
+ y_global: np.ndarray,
810
+ z_global: np.ndarray,
811
+ k0: float,
812
+ which: Literal['E','H'] = 'E') -> np.ndarray:
813
+ """Computes the port-mode field in global coordinates.
814
+
815
+ The mode field will be evaluated at x,y,z coordinates but projected onto the local 2D coordinate system.
816
+ Additionally, the "which" parameter may be used to request the H-field. This parameter is not always supported.
817
+
818
+ Args:
819
+ x_global (np.ndarray): The X-coordinate
820
+ y_global (np.ndarray): The Y-coordinate
821
+ z_global (np.ndarray): The Z-coordinate
822
+ k0 (float): The free space propagation constant
823
+ which (Literal["E","H"], optional): Which field to return. Defaults to 'E'.
824
+
825
+ Returns:
826
+ np.ndarray: The E-field in (3,N) indexing.
827
+ """
828
+ xl, yl, _ = self.cs.in_local_cs(x_global, y_global, z_global)
829
+ Ex, Ey, Ez = self.port_mode_3d(xl, yl, k0)
830
+ Exg, Eyg, Ezg = self.cs.in_global_basis(Ex, Ey, Ez)
831
+ return np.array([Exg, Eyg, Ezg])
832
+
833
+
834
+ class LumpedElement(RobinBC):
835
+
836
+ _include_stiff: bool = True
837
+ _include_mass: bool = False
838
+ _include_force: bool = False
839
+
840
+ def __init__(self,
841
+ face: FaceSelection | GeoSurface,
842
+ impedance_function: Callable = None,
843
+ width: float = None,
844
+ height: float = None,
845
+ ):
846
+ """Generates a lumped power boundary condition.
847
+
848
+ The lumped port boundary condition assumes a uniform E-field along the "direction" axis.
849
+ The port with and height must be provided manually in meters. The height is the size
850
+ in the "direction" axis along which the potential is imposed. The width dimension
851
+ is orthogonal to that. For a rectangular face its the width and for a cyllindrical face
852
+ its the circumpherance.
853
+
854
+ Args:
855
+ face (FaceSelection, GeoSurface): The port surface
856
+ port_number (int): The port number
857
+ width (float): The port width (meters).
858
+ height (float): The port height (meters).
859
+ direction (Axis): The port direction as an Axis object (em.Axis(..) or em.ZAX)
860
+ active (bool, optional): Whether the port is active. Defaults to False.
861
+ power (float, optional): The port output power. Defaults to 1.
862
+ Z0 (float, optional): The port impedance. Defaults to 50.
863
+ """
864
+ super().__init__(face)
865
+
866
+ if width is None:
867
+ if not isinstance(face, GeoObject):
868
+ raise ValueError(f'The width, height and direction must be defined. Information cannot be extracted from {face}')
869
+ width, height, impedance_function = face._data('width','height','func')
870
+ if width is None or height is None or impedance_function is None:
871
+ raise ValueError(f'The width, height and impedance function could not be extracted from {face}')
872
+
873
+ logger.debug(f'Lumped port: width={1000*width:.1f}mm, height={1000*height:.1f}mm')
874
+
875
+ self.Z0: Callable = impedance_function
876
+
877
+ self._field_amplitude: np.ndarray = None
878
+ self.width: float = width
879
+ self.height: float = height
880
+
881
+ logger.info('Constructing coordinate system from normal port')
882
+ self.cs = Axis(self.selection.normal).construct_cs()
883
+
884
+ self.vintline: Line = None
885
+ self.v_integration = True
886
+ self.iintline: Line = None
887
+
888
+ def surfZ(self, k0: float) -> float:
889
+ """The surface sheet impedance for the lumped port
890
+
891
+ Returns:
892
+ float: The surface sheet impedance
893
+ """
894
+ Z0 = self.Z0(k0*299792458/(2*np.pi))*self.width/self.height
895
+ return Z0
896
+
897
+
898
+ def get_basis(self) -> np.ndarray:
899
+ return self.cs._basis
900
+
901
+ def get_beta(self, k0: float) -> float:
902
+ ''' Return the out of plane propagation constant. βz.'''
903
+
904
+ return k0
905
+
906
+ def get_gamma(self, k0: float) -> complex:
907
+ """Computes the γ-constant for matrix assembly. This constant is required for the Robin boundary condition.
908
+
909
+ Args:
910
+ k0 (float): The free space propagation constant.
911
+
912
+ Returns:
913
+ complex: The γ-constant
914
+ """
915
+ return 1j*k0*376.730313412/self.surfZ(k0)