xnft 0.1.0__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.
Files changed (120) hide show
  1. xnft/__init__.py +46 -0
  2. xnft/algorithms/__init__.py +0 -0
  3. xnft/algorithms/planar_near_field_transform.py +455 -0
  4. xnft/antennas/aperture/__init__.py +0 -0
  5. xnft/antennas/aperture/rectangular.py +231 -0
  6. xnft/antennas/probes/__init__.py +0 -0
  7. xnft/antennas/probes/probes.py +121 -0
  8. xnft/antennas/probes/standard_probes.py +42 -0
  9. xnft/antennas/wire/__init__.py +0 -0
  10. xnft/antennas/wire/dipole.py +192 -0
  11. xnft/calibration/gain.py +207 -0
  12. xnft/common/__init__.py +6 -0
  13. xnft/common/enums.py +132 -0
  14. xnft/common/plugins.py +25 -0
  15. xnft/common/registry.py +325 -0
  16. xnft/common/types.py +223 -0
  17. xnft/gui/__init__.py +0 -0
  18. xnft/gui/app.py +597 -0
  19. xnft/gui/dialogs/__init__.py +0 -0
  20. xnft/gui/dialogs/analysis/__init__.py +0 -0
  21. xnft/gui/dialogs/analysis/new_planar_nft_dialog.py +326 -0
  22. xnft/gui/dialogs/calibrations/__init__.py +0 -0
  23. xnft/gui/dialogs/calibrations/new_gain_calibration_dialog.py +250 -0
  24. xnft/gui/dialogs/common.py +169 -0
  25. xnft/gui/dialogs/export_data_dialog.py +239 -0
  26. xnft/gui/dialogs/export_data_directory_dialog.py +204 -0
  27. xnft/gui/dialogs/fields/__init__.py +0 -0
  28. xnft/gui/dialogs/fields/new_field_measurement_dialog.py +554 -0
  29. xnft/gui/dialogs/probes/__init__.py +0 -0
  30. xnft/gui/dialogs/probes/new_probe_dialog.py +327 -0
  31. xnft/gui/dialogs/project/__init__.py +0 -0
  32. xnft/gui/dialogs/project/new_project_dialog.py +152 -0
  33. xnft/gui/dialogs/reports/__init__.py +0 -0
  34. xnft/gui/dialogs/reports/new_field_report_dialog.py +198 -0
  35. xnft/gui/dialogs/reports/new_trace_dialog.py +728 -0
  36. xnft/gui/models/__init__.py +0 -0
  37. xnft/gui/models/project_tree_model.py +323 -0
  38. xnft/gui/tasks/__init__.py +0 -0
  39. xnft/gui/tasks/bridge.py +60 -0
  40. xnft/gui/tasks/worker.py +40 -0
  41. xnft/gui/utils.py +81 -0
  42. xnft/gui/views/__init__.py +0 -0
  43. xnft/gui/views/mdi_area.py +159 -0
  44. xnft/gui/views/project_tree_view.py +172 -0
  45. xnft/loaders/__init__.py +19 -0
  46. xnft/loaders/fields/__init__.py +0 -0
  47. xnft/loaders/fields/analytic.py +261 -0
  48. xnft/loaders/fields/base.py +15 -0
  49. xnft/loaders/fields/hfss.py +337 -0
  50. xnft/loaders/fields/nsi.py +5 -0
  51. xnft/loaders/gain/.gitkeep +0 -0
  52. xnft/loaders/probes/.gitkeep +0 -0
  53. xnft/project/__init__.py +0 -0
  54. xnft/project/io.py +92 -0
  55. xnft/project/manager.py +41 -0
  56. xnft/project/nodes/__init__.py +0 -0
  57. xnft/project/nodes/analysis.py +220 -0
  58. xnft/project/nodes/calibrations.py +114 -0
  59. xnft/project/nodes/coordinates.py +80 -0
  60. xnft/project/nodes/fields.py +219 -0
  61. xnft/project/nodes/model.py +34 -0
  62. xnft/project/nodes/node.py +568 -0
  63. xnft/project/nodes/probes.py +105 -0
  64. xnft/project/nodes/project.py +101 -0
  65. xnft/project/nodes/reports.py +377 -0
  66. xnft/reports/__init__.py +7 -0
  67. xnft/reports/data_writers.py +45 -0
  68. xnft/reports/plot/functions/domains.py +42 -0
  69. xnft/reports/plot/functions/field_components.py +202 -0
  70. xnft/reports/plot/functions/field_quantities.py +62 -0
  71. xnft/reports/plot/functions/functions.py +89 -0
  72. xnft/reports/plot/functions/normalizations.py +43 -0
  73. xnft/reports/plot/functions/scales.py +49 -0
  74. xnft/reports/plot/helpers/coordinate_transforms.py +34 -0
  75. xnft/reports/plot/helpers/helpers.py +71 -0
  76. xnft/reports/plot/helpers/trace_parameters.py +49 -0
  77. xnft/reports/plot/line_plot.py +525 -0
  78. xnft/reports/plot/surface_plot.py +846 -0
  79. xnft/reports/table/.gitkeep +0 -0
  80. xnft/resources/calibration/NSI-MI_ANT-SGA-26.5-40.cal +21 -0
  81. xnft/resources/icons/array.svg +26 -0
  82. xnft/resources/icons/calibration.svg +36 -0
  83. xnft/resources/icons/calibration2.svg +33 -0
  84. xnft/resources/icons/coordinates.svg +69 -0
  85. xnft/resources/icons/fields.svg +23 -0
  86. xnft/resources/icons/function.svg +1 -0
  87. xnft/resources/icons/model.svg +21 -0
  88. xnft/resources/icons/plot.svg +32 -0
  89. xnft/resources/icons/probe.svg +21 -0
  90. xnft/resources/icons/xnft.icns +0 -0
  91. xnft/resources/icons/xnft.ico +0 -0
  92. xnft/resources/icons/xnft.iconset/icon_128x128.png +0 -0
  93. xnft/resources/icons/xnft.iconset/icon_128x128@2x.png +0 -0
  94. xnft/resources/icons/xnft.iconset/icon_16x16.png +0 -0
  95. xnft/resources/icons/xnft.iconset/icon_16x16@2x.png +0 -0
  96. xnft/resources/icons/xnft.iconset/icon_256x256.png +0 -0
  97. xnft/resources/icons/xnft.iconset/icon_256x256@2x.png +0 -0
  98. xnft/resources/icons/xnft.iconset/icon_32x32.png +0 -0
  99. xnft/resources/icons/xnft.iconset/icon_32x32@2x.png +0 -0
  100. xnft/resources/icons/xnft.iconset/icon_512x512.png +0 -0
  101. xnft/resources/icons/xnft.iconset/icon_512x512@2x.png +0 -0
  102. xnft/resources/icons/xnft.png +0 -0
  103. xnft/resources/icons/xnft.svg +30 -0
  104. xnft/resources/icons/xnft_flat.png +0 -0
  105. xnft/resources/icons/xnft_flat_192.png +0 -0
  106. xnft/resources/logging.all.yaml +19 -0
  107. xnft/resources/logging.dev.yaml +19 -0
  108. xnft/resources/logos/emrg_logo.svg +194 -0
  109. xnft/resources/logos/emrg_logo_vector.svg +33 -0
  110. xnft/resources/logos/lemma_logo.png +0 -0
  111. xnft/resources/logos/lemma_logo.svg +1 -0
  112. xnft/resources/logos/lemma_logo_transparent.png +0 -0
  113. xnft/resources/logos/lemma_logo_v2.png +0 -0
  114. xnft/resources/logos/lemma_logo_v2.svg +8 -0
  115. xnft/resources/paths.py +13 -0
  116. xnft-0.1.0.dist-info/METADATA +241 -0
  117. xnft-0.1.0.dist-info/RECORD +120 -0
  118. xnft-0.1.0.dist-info/WHEEL +4 -0
  119. xnft-0.1.0.dist-info/entry_points.txt +2 -0
  120. xnft-0.1.0.dist-info/licenses/LICENSE +9 -0
xnft/__init__.py ADDED
@@ -0,0 +1,46 @@
1
+ """
2
+ xNFT - Antenna Measurement Software
3
+
4
+ Copyright 2025-2026 LEmmA, Electromagnetics Research Group, Michigan State University
5
+ """
6
+
7
+ import logging
8
+ import logging.config
9
+ import sys
10
+ from importlib.metadata import version
11
+ from importlib.resources import files
12
+ from pathlib import Path
13
+
14
+ import yaml
15
+
16
+ logging.getLogger(__name__).addHandler(logging.NullHandler())
17
+
18
+ __version__ = version("xnft")
19
+
20
+
21
+ def configure_logging(config_name="logging.all.yaml"):
22
+
23
+ # Load base config
24
+ config_path = files("xnft.resources") / config_name
25
+ with config_path.open("r", encoding="utf-8") as f:
26
+ config = yaml.safe_load(f)
27
+
28
+ if getattr(sys, "frozen", False):
29
+ log_dir = Path.home() / ".xnft"
30
+ log_dir.mkdir(exist_ok=True)
31
+ log_file = log_dir / "xnft.log"
32
+
33
+ config["handlers"]["file"] = {
34
+ "class": "logging.FileHandler",
35
+ "level": "DEBUG",
36
+ "filename": str(log_file),
37
+ "formatter": "dev",
38
+ "encoding": "utf-8",
39
+ }
40
+
41
+ config["root"]["handlers"].append("file")
42
+
43
+ logging.config.dictConfig(config)
44
+
45
+
46
+ configure_logging()
File without changes
@@ -0,0 +1,455 @@
1
+ """
2
+ Field transform algorithms.
3
+
4
+ Copyright 2025-2026 LEmmA, Electromagnetics Research Group, Michigan State University.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import time
11
+ import warnings
12
+ from collections.abc import Callable
13
+
14
+ import finufft
15
+ import numpy as np
16
+ import scipy.constants as spc
17
+ import xarray as xr
18
+ from quantiphy import Quantity
19
+ from scipy.signal import windows
20
+
21
+ from ..antennas.probes.probes import MeasurementProbe
22
+
23
+ # from numpy.typing import ArrayLike
24
+ from ..common.enums import CoordinateSystemType, FieldType
25
+ from ..common.registry import registry
26
+ from ..common.types import FieldDataset
27
+ from ..project.nodes.calibrations import GainCalibrationNode
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ def calibrate_gain(
33
+ field: xr.Dataset, gain_calibration: GainCalibrationNode
34
+ ) -> xr.Dataset:
35
+ """Apply gain calibrations to field."""
36
+ result_field = field.copy(deep=True)
37
+
38
+ gain_cal_table = gain_calibration.gain_cal_ref.data
39
+ gain_cal_meas_field = gain_calibration.gain_cal_field.data
40
+
41
+ logger.debug(f"{gain_cal_table=}")
42
+
43
+ # Configure gain cal interpolation across frequencies
44
+ gain_reference_interp = gain_cal_table.interp(
45
+ frequency=field.frequency, # NOTE: frequency may be a vector of frequencies
46
+ method="linear",
47
+ )
48
+
49
+ # Take gain of measured aut field over phi/theta dimensions (max per frequency)
50
+ gain_meas_aut = 10 * np.log10(
51
+ ((np.abs(field["E"]) ** 2).sum(dim="component")).max(dim=("theta", "phi"))
52
+ )
53
+
54
+ logger.debug(f"{gain_meas_aut=}")
55
+
56
+ # Take gain of reference antenna measured field over phi/theta dimensions (max per frequency)
57
+ gain_meas_ref = 10 * np.log10(
58
+ ((np.abs(gain_cal_meas_field["E"]) ** 2).sum(dim="component")).max(
59
+ dim=("theta", "phi")
60
+ )
61
+ )
62
+
63
+ logger.debug(f"{gain_meas_ref=}")
64
+
65
+ # Correction value to add to our AUT measured field (in dB)
66
+ gain_correction_db = gain_reference_interp - gain_meas_ref
67
+ logger.debug(f"{gain_correction_db=}")
68
+
69
+ gain_correction = 10 ** (gain_correction_db / 20)
70
+
71
+ # logger.debug(f"{gain_correction=}")
72
+
73
+ # Convert from power gain back to linear amplitude gain
74
+ result_field["E"] = field["E"] * gain_correction
75
+
76
+ logger.debug(f"{result_field=}")
77
+
78
+ return result_field
79
+
80
+
81
+ def planar_near_field_transform_t3(
82
+ theta_grid_target,
83
+ phi_grid_target,
84
+ k0,
85
+ win,
86
+ near_field_x_scaled,
87
+ near_field_y_scaled,
88
+ x_scale,
89
+ y_scale,
90
+ e_x,
91
+ e_y,
92
+ z_val,
93
+ r_far_field: float | None = None,
94
+ probe_correction: MeasurementProbe | None = None,
95
+ ):
96
+ r"""
97
+ Compute the planar near-to-far field transform for a single frequency
98
+
99
+ Parameters
100
+ ----------
101
+ theta_grid_target : ArrayLike
102
+ 2D grid of theta sample points for output.
103
+ phi_grid_target : ArrayLike
104
+ 2D phi of theta sample points for output.
105
+ k0 : float
106
+ Wavenumber to compute output for
107
+ win : ArrayLike
108
+ 2D windowing function to apply to input field.
109
+ near_field_x_scaled : ArrayLike
110
+ Displacement along x-axis, normalized from -pi/2,pi/2 for NUFFT
111
+ near_field_y_scaled : ArrayLike
112
+ Displacement along y-axis, normalized from -pi/2,pi/2 for NUFFT
113
+ x_scale : float
114
+ Scaling factor for x-component for NUFFT
115
+ y_scale : float
116
+ Scaling factor for x-component for NUFFT
117
+ e_x : ArrayLike
118
+ E-field x-component
119
+ e_y : ArrayLike
120
+ E-field y-component
121
+ z_val : float
122
+ Z-height above AUT samples collected at (in meters)
123
+ r_far_field : float or None, optional
124
+ Far-field radius compute transform at, by default None
125
+ probe_correction : MeasurementProbe or None, optional
126
+ Probe correction fields to use, if any, by default None
127
+
128
+ Returns
129
+ -------
130
+ Tuple[ArrayLike]
131
+ Tuple of $E_\\mathrm{phi}$ and $E_\\mathrm{theta}$ fields
132
+
133
+ """
134
+ # TODO: Add a type-2 NUFFT implementation for improved performance (GPU won't be effective with type-3)
135
+
136
+ # Create target sample locations in k-space based on sample points in
137
+ # (theta, phi)
138
+ kx_target = k0 * np.sin(theta_grid_target) * np.cos(phi_grid_target)
139
+ ky_target = k0 * np.sin(theta_grid_target) * np.sin(phi_grid_target)
140
+
141
+ kx_target_scaled = (kx_target / x_scale).ravel()
142
+ ky_target_scaled = (ky_target / y_scale).ravel()
143
+
144
+ # Compute NUFFT
145
+ precision = 1e-12
146
+ nthreads = 0
147
+ plan = finufft.Plan(
148
+ nufft_type=3,
149
+ n_modes_or_dim=2,
150
+ eps=precision,
151
+ nthreads=nthreads,
152
+ dtype="complex128",
153
+ )
154
+
155
+ plan.setpts(
156
+ x=near_field_x_scaled.astype(np.float64),
157
+ y=near_field_y_scaled.astype(np.float64),
158
+ s=kx_target_scaled.astype(np.float64),
159
+ t=ky_target_scaled.astype(np.float64),
160
+ )
161
+
162
+ field_values_x = (win * e_x).ravel().astype(np.complex128)
163
+ field_values_y = (win * e_y).ravel().astype(np.complex128)
164
+
165
+ target_shape = theta_grid_target.shape
166
+ fe_x = plan.execute(field_values_x).reshape(target_shape)
167
+ fe_y = plan.execute(field_values_y).reshape(target_shape)
168
+
169
+ if probe_correction:
170
+ # Apply corrections in k-space
171
+ fe_mat = np.stack([fe_x, fe_y], axis=-1)
172
+
173
+ # reception_mat_inv is in (theta, phi)
174
+ reception_mat_inv = probe_correction.get_reception_matrix(
175
+ theta_vec=theta_grid_target[:, 0],
176
+ phi_vec=phi_grid_target[0, :],
177
+ wavenumber=k0,
178
+ )[0] # remove frequency axis [0] that is always of size 1
179
+
180
+ # logger.debug(f"{reception_mat_inv.shape=}")
181
+ # logger.debug(f"{fe_mat.shape=}")
182
+
183
+ # Perform matrix multiplication of R^-1 matrix and E-field
184
+ # Probe compensation matrix include transform to spherical
185
+ e_mat = (reception_mat_inv @ fe_mat[..., np.newaxis])[..., 0]
186
+
187
+ # Add propagation phase to probe
188
+ # TODO: make this correction optional
189
+ e_mat *= np.exp(1j * k0 * z_val * np.cos(theta_grid_target[..., np.newaxis]))
190
+
191
+ else:
192
+ e_theta = fe_x * np.cos(phi_grid_target) + fe_y * np.sin(phi_grid_target)
193
+ e_phi = np.cos(theta_grid_target) * (
194
+ -fe_x * np.sin(phi_grid_target) + fe_y * np.cos(phi_grid_target)
195
+ )
196
+ e_mat = np.stack([e_theta, e_phi], axis=-1)
197
+
198
+ # Apply propagation constants
199
+ # Phase correction term such that phase at measured radius is constant
200
+ if r_far_field is not None:
201
+ greens_func = np.exp(-1j * k0 * r_far_field) / r_far_field
202
+ c0 = 1j * k0 / (2.0 * np.pi) * greens_func
203
+ e_mat *= c0
204
+
205
+ return e_mat
206
+
207
+
208
+ def planar_near_field_transform(
209
+ near_field: xr.Dataset,
210
+ frequencies: np.ndarray | list[float] | None = None,
211
+ probe_correction: MeasurementProbe | None = None,
212
+ gain_calibration: GainCalibrationNode | None = None,
213
+ dphi: float = 1.0,
214
+ dtheta: float = 1.0,
215
+ window: Callable = windows.boxcar,
216
+ r_far_field=None, # meters
217
+ z_displacement=None,
218
+ ) -> xr.Dataset:
219
+ """
220
+ Compute the near to far-field transform via non-uniform fast Fourier transform (NUFFT).
221
+
222
+ The NUFFT performed here uses the fiNUFFT package [1]_, [2]_, [3]_. The plnar near-to-far field transform
223
+ implementation proceeds from Ch 12.9 [4]_ and the derivation included in the documentation.
224
+
225
+ Parameters
226
+ ----------
227
+ near_field : xr.Dataset
228
+ Planar near-field measured values to transform to far-field.
229
+ frequencies : np.ndarray | List[float] | None
230
+ Frequencies in Hertz to perform the near-to-far field transform at. If the provided frequency is not
231
+ found in the `near_field` input, the nearest frequency present will be used instead and a warning will
232
+ be raised to the user.
233
+ probe_correction : MeasurementProbe or None, optional
234
+ Probe correction fields to use, if any, by default None
235
+ dphi : int, optional
236
+ Phi step size, by default 1.0 degrees
237
+ dtheta : int, optional
238
+ Theta step size, by default 1.0 degrees
239
+ window : Callable, optional
240
+ Window generating function used to spatially weight input sample points before transforming, by default
241
+ scipy.signal.windows.boxcar. Further windows can be found in the scipy.signal.windows module.
242
+ r_far_field : int, optional
243
+ Distance in meters to return the far field values at, by default 1000. This value is arbitrary, but may
244
+ be helpful for comparing phase values derived from various sources.
245
+
246
+ Returns
247
+ -------
248
+ xr.Dataset
249
+ Far-field transformed fields stored in an xarray DataSet.
250
+
251
+ References
252
+ ----------
253
+ .. [1] fiNUFFT Mathematical definitions of transforms. Online: https://finufft.readthedocs.io/en/latest/math.html
254
+
255
+ .. [2] A. H. Barnett, J. F. Magland, and L. af Klinteberg, A parallel non-uniform fast Fourier transform library
256
+ based on an “exponential of semicircle” kernel. SIAM J. Sci. Comput. 41(5), C479-C504 (2019).
257
+ Online: https://arxiv.org/abs/1808.06736
258
+
259
+ .. [3] A. H. Barnett, Aliasing error of the exp(𝛽√1-z2) kernel in the nonuniform fast Fourier transform. Appl.
260
+ Comput. Harmon. Anal. 51, 1-16 (2021). Online: https://arxiv.org/abs/2001.09405
261
+
262
+ .. [4] C. A. Balanis, Antenna theory: analysis and design, 3. ed. Hoboken, N.J: Wiley-Interscience, 2005.
263
+
264
+ """
265
+ near_field_e = near_field["E"]
266
+
267
+ freq = near_field_e.frequency
268
+
269
+ # Normalize once
270
+ if freq.ndim == 0:
271
+ freq_values = np.atleast_1d(freq.item())
272
+ else:
273
+ freq_values = freq.values
274
+
275
+ logger.debug(
276
+ "Available frequencies: %s",
277
+ ", ".join(str(Quantity(x, "Hz")) for x in freq_values),
278
+ )
279
+
280
+ if frequencies is None:
281
+ frequencies = freq_values
282
+
283
+ # Compute maximum valid elevation angle to be used in computation (limited by sample locations and aperture sizes)
284
+ aut_size = near_field.attrs["aperture_size"]
285
+
286
+ # If a probe is specified, get probe size for valid angle computation, otherwise assume zero size
287
+ if probe_correction is not None:
288
+ probe_size = (
289
+ probe_correction.source.x**2 + probe_correction.source.y**2
290
+ ) ** 0.5
291
+ else:
292
+ probe_size = 0
293
+
294
+ logger.debug(f"AUT Size: {Quantity(aut_size, units='m')}")
295
+ logger.debug(f"Probe Size: {Quantity(probe_size, units='m')}")
296
+
297
+ # Conservative values
298
+ min_x_displacement = min(np.abs(np.min(near_field.x)), np.max(near_field.x))
299
+ min_y_displacement = min(np.abs(np.min(near_field.y)), np.max(near_field.y))
300
+
301
+ if z_displacement is not None:
302
+ max_z_displacement = z_displacement
303
+ else:
304
+ max_z_displacement = np.max(np.abs(near_field.z))
305
+
306
+ logger.debug(f"min_x_displacement: {Quantity(min_x_displacement, units='m')}")
307
+ logger.debug(f"min_y_displacement: {Quantity(min_y_displacement, units='m')}")
308
+ logger.debug(f"max_z_displacement: {Quantity(max_z_displacement, units='m')}")
309
+
310
+ max_aperture_displacement = min(min_x_displacement, min_y_displacement) - (
311
+ aut_size / 2
312
+ ) # - (probe_size / 2)
313
+ logger.debug(
314
+ f"max_aperture_displacement: {Quantity(max_aperture_displacement, units='m')}",
315
+ )
316
+
317
+ max_theta = np.atan2(max_aperture_displacement, max_z_displacement).values.squeeze()
318
+ logger.debug(f"Max. Theta: {Quantity(max_theta * 180 / np.pi)}°")
319
+
320
+ # Compute uniform Theta, Phi grid for target field points
321
+ theta_target = np.linspace(
322
+ -np.pi / 2,
323
+ np.pi / 2,
324
+ round(np.pi / np.deg2rad(dtheta)) + 1,
325
+ endpoint=True,
326
+ )
327
+ phi_target = np.linspace(
328
+ -np.pi / 2,
329
+ np.pi / 2,
330
+ round(np.pi / np.deg2rad(dphi)) + 1,
331
+ endpoint=True,
332
+ )
333
+
334
+ # Trim theta
335
+ theta_target = theta_target[
336
+ (theta_target >= -max_theta) & (theta_target <= max_theta)
337
+ ]
338
+
339
+ theta_grid_target, phi_grid_target = np.meshgrid(
340
+ theta_target,
341
+ phi_target,
342
+ indexing="ij",
343
+ )
344
+
345
+ # Generate windowing function (rectangular by default)
346
+ win_x = window(near_field_e.x.size)
347
+ win_y = window(near_field_e.y.size)
348
+
349
+ # Normalize window
350
+ win = np.sqrt(np.outer(win_x, win_y))
351
+
352
+ # Scale source sample locations along aperture to input of finufft in the range [-pi/2, pi/2)
353
+ x_scale = (
354
+ 2.0 * np.pi / (np.max(near_field_e.x.values) - np.min(near_field_e.x.values))
355
+ )
356
+ y_scale = (
357
+ 2.0 * np.pi / (np.max(near_field_e.y.values) - np.min(near_field_e.y.values))
358
+ )
359
+
360
+ near_field_yy, near_field_xx = np.meshgrid(
361
+ near_field_e.y.values,
362
+ near_field_e.x.values,
363
+ )
364
+
365
+ near_field_x_scaled = (x_scale * near_field_xx).ravel()
366
+ near_field_y_scaled = (y_scale * near_field_yy).ravel()
367
+
368
+ # Start performance timer
369
+ t_start = time.perf_counter()
370
+
371
+ # Generate parallel function call arguments
372
+ args_list = []
373
+ z_val = near_field_e.coords["z"].item() # should only have one z-height value
374
+
375
+ # Ensure frequencies is an array (if scalar)
376
+ frequencies = np.array(frequencies)
377
+
378
+ for freq in np.nditer(frequencies):
379
+ f_idx = np.argmin(np.abs(freq_values - freq))
380
+ nearest_freq = freq_values[f_idx]
381
+
382
+ if nearest_freq != freq:
383
+ warnings.warn(
384
+ "Provided frequency {:} not in available frequencies, using nearest frequency {:} instead".format(
385
+ Quantity(freq, "Hz"),
386
+ Quantity(nearest_freq, "Hz"),
387
+ ),
388
+ )
389
+ freq = nearest_freq
390
+
391
+ wavelength = spc.c / freq
392
+ k0 = 2.0 * np.pi / wavelength
393
+
394
+ # Load E-field data from dataset
395
+ # Dimensions (x, y, z, freq)
396
+ if near_field_e.frequency.ndim == 1:
397
+ e_x = near_field_e.sel(component="x", frequency=freq, z=z_val).values
398
+ e_y = near_field_e.sel(component="y", frequency=freq, z=z_val).values
399
+ else:
400
+ e_x = near_field_e.sel(component="x", z=z_val).values
401
+ e_y = near_field_e.sel(component="y", z=z_val).values
402
+
403
+ args_list.append(
404
+ {
405
+ "theta_grid_target": theta_grid_target,
406
+ "phi_grid_target": phi_grid_target,
407
+ "k0": k0,
408
+ "win": win,
409
+ "near_field_x_scaled": near_field_x_scaled,
410
+ "near_field_y_scaled": near_field_y_scaled,
411
+ "x_scale": x_scale,
412
+ "y_scale": y_scale,
413
+ "e_x": e_x,
414
+ "e_y": e_y,
415
+ "z_val": z_val,
416
+ "r_far_field": r_far_field,
417
+ "probe_correction": probe_correction,
418
+ },
419
+ )
420
+
421
+ # Call planar near-field transform in parallel
422
+ # NOTE: it's faster to not run in parallel (Observed on macOS;
423
+ # performance appears much slower on other platforms, unsure if parallel would be faster)
424
+ # e_mat_list = Parallel(n_jobs=-1, backend="multiprocessing", verbose=10)(
425
+ # delayed(planar_near_field_transform_t3)(**args) for args in args_list
426
+ # )
427
+ e_mat_list = [planar_near_field_transform_t3(**args) for args in args_list]
428
+
429
+ # Print ellapsed time
430
+ elapsed_time = Quantity(time.perf_counter() - t_start, "s")
431
+ logger.debug(f"Far-field transform: {elapsed_time}")
432
+
433
+ # Generate field output
434
+ # Reorder output to: (component, theta, phi, frequency)
435
+ e_field = np.transpose(e_mat_list, (3, 1, 2, 0))
436
+
437
+ fields = FieldDataset.create(
438
+ e_field=e_field,
439
+ spatial_coords={
440
+ "theta": theta_target,
441
+ "phi": phi_target,
442
+ },
443
+ frequency=frequencies,
444
+ coordinate_system=CoordinateSystemType.SPHERICAL,
445
+ field_type=FieldType.FAR,
446
+ data_source=registry.data_sources.get("xnft"),
447
+ aperture_size=near_field.attrs["aperture_size"],
448
+ gain_calibration=gain_calibration,
449
+ )
450
+
451
+ # Compute gain calibration if provided
452
+ if gain_calibration is not None:
453
+ fields = calibrate_gain(fields, gain_calibration)
454
+
455
+ return fields
File without changes