phased-array-modeling 1.0.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.
@@ -0,0 +1,705 @@
1
+ """
2
+ Beamforming functions for phased arrays.
3
+
4
+ Includes amplitude tapering, null steering, and multiple simultaneous beams.
5
+ """
6
+
7
+ import numpy as np
8
+ from scipy import signal
9
+ from typing import Tuple, Optional, List, Union
10
+ from .geometry import ArrayGeometry
11
+ from .core import steering_vector
12
+
13
+ ArrayLike = Union[np.ndarray, float]
14
+
15
+
16
+ # ============== Amplitude Tapering ==============
17
+
18
+ def taylor_taper_1d(
19
+ n: int,
20
+ sidelobe_dB: float = -30.0,
21
+ nbar: int = 4
22
+ ) -> np.ndarray:
23
+ """
24
+ Generate 1D Taylor window for sidelobe control.
25
+
26
+ Parameters
27
+ ----------
28
+ n : int
29
+ Number of elements
30
+ sidelobe_dB : float
31
+ Desired peak sidelobe level in dB (negative)
32
+ nbar : int
33
+ Number of nearly equal-level sidelobes
34
+
35
+ Returns
36
+ -------
37
+ taper : ndarray
38
+ Amplitude taper weights (n,)
39
+ """
40
+ # Use scipy's Taylor window
41
+ return signal.windows.taylor(n, nbar=nbar, sll=-sidelobe_dB, norm=True)
42
+
43
+
44
+ def taylor_taper_2d(
45
+ Nx: int,
46
+ Ny: int,
47
+ sidelobe_dB: float = -30.0,
48
+ nbar: int = 4
49
+ ) -> np.ndarray:
50
+ """
51
+ Generate 2D Taylor window (separable product).
52
+
53
+ Parameters
54
+ ----------
55
+ Nx, Ny : int
56
+ Number of elements in x and y
57
+ sidelobe_dB : float
58
+ Desired peak sidelobe level in dB
59
+ nbar : int
60
+ Number of nearly equal-level sidelobes
61
+
62
+ Returns
63
+ -------
64
+ taper : ndarray
65
+ 2D amplitude taper (Nx, Ny), flattened row-major
66
+ """
67
+ tx = taylor_taper_1d(Nx, sidelobe_dB, nbar)
68
+ ty = taylor_taper_1d(Ny, sidelobe_dB, nbar)
69
+ taper_2d = np.outer(tx, ty)
70
+ return taper_2d.ravel()
71
+
72
+
73
+ def chebyshev_taper_1d(
74
+ n: int,
75
+ sidelobe_dB: float = -30.0
76
+ ) -> np.ndarray:
77
+ """
78
+ Generate 1D Dolph-Chebyshev window for equi-ripple sidelobes.
79
+
80
+ Parameters
81
+ ----------
82
+ n : int
83
+ Number of elements
84
+ sidelobe_dB : float
85
+ Desired sidelobe level in dB (negative)
86
+
87
+ Returns
88
+ -------
89
+ taper : ndarray
90
+ Amplitude taper weights
91
+ """
92
+ return signal.windows.chebwin(n, at=-sidelobe_dB)
93
+
94
+
95
+ def chebyshev_taper_2d(
96
+ Nx: int,
97
+ Ny: int,
98
+ sidelobe_dB: float = -30.0
99
+ ) -> np.ndarray:
100
+ """
101
+ Generate 2D Chebyshev window (separable product).
102
+
103
+ Parameters
104
+ ----------
105
+ Nx, Ny : int
106
+ Number of elements
107
+ sidelobe_dB : float
108
+ Desired sidelobe level in dB
109
+
110
+ Returns
111
+ -------
112
+ taper : ndarray
113
+ 2D taper, flattened
114
+ """
115
+ tx = chebyshev_taper_1d(Nx, sidelobe_dB)
116
+ ty = chebyshev_taper_1d(Ny, sidelobe_dB)
117
+ return np.outer(tx, ty).ravel()
118
+
119
+
120
+ def hamming_taper_1d(n: int) -> np.ndarray:
121
+ """Generate 1D Hamming window."""
122
+ return np.hamming(n)
123
+
124
+
125
+ def hamming_taper_2d(Nx: int, Ny: int) -> np.ndarray:
126
+ """Generate 2D Hamming window."""
127
+ return np.outer(np.hamming(Nx), np.hamming(Ny)).ravel()
128
+
129
+
130
+ def hanning_taper_1d(n: int) -> np.ndarray:
131
+ """Generate 1D Hanning (Hann) window."""
132
+ return np.hanning(n)
133
+
134
+
135
+ def hanning_taper_2d(Nx: int, Ny: int) -> np.ndarray:
136
+ """Generate 2D Hanning window."""
137
+ return np.outer(np.hanning(Nx), np.hanning(Ny)).ravel()
138
+
139
+
140
+ def cosine_taper_1d(n: int) -> np.ndarray:
141
+ """Generate 1D cosine (sine) window."""
142
+ return np.sin(np.pi * np.arange(n) / (n - 1))
143
+
144
+
145
+ def cosine_taper_2d(Nx: int, Ny: int) -> np.ndarray:
146
+ """Generate 2D cosine window."""
147
+ return np.outer(cosine_taper_1d(Nx), cosine_taper_1d(Ny)).ravel()
148
+
149
+
150
+ def cosine_on_pedestal_taper_1d(
151
+ n: int,
152
+ pedestal: float = 0.1
153
+ ) -> np.ndarray:
154
+ """
155
+ Generate 1D cosine-on-pedestal window.
156
+
157
+ Parameters
158
+ ----------
159
+ n : int
160
+ Number of elements
161
+ pedestal : float
162
+ Minimum amplitude at edges (0 to 1)
163
+
164
+ Returns
165
+ -------
166
+ taper : ndarray
167
+ Amplitude taper
168
+ """
169
+ t = np.linspace(0, np.pi, n)
170
+ return pedestal + (1 - pedestal) * np.sin(t)
171
+
172
+
173
+ def cosine_on_pedestal_taper_2d(
174
+ Nx: int,
175
+ Ny: int,
176
+ pedestal: float = 0.1
177
+ ) -> np.ndarray:
178
+ """Generate 2D cosine-on-pedestal window."""
179
+ tx = cosine_on_pedestal_taper_1d(Nx, pedestal)
180
+ ty = cosine_on_pedestal_taper_1d(Ny, pedestal)
181
+ return np.outer(tx, ty).ravel()
182
+
183
+
184
+ def gaussian_taper_1d(n: int, sigma: float = 0.4) -> np.ndarray:
185
+ """
186
+ Generate 1D Gaussian window.
187
+
188
+ Parameters
189
+ ----------
190
+ n : int
191
+ Number of elements
192
+ sigma : float
193
+ Standard deviation as fraction of array (typical: 0.3-0.5)
194
+
195
+ Returns
196
+ -------
197
+ taper : ndarray
198
+ """
199
+ x = np.linspace(-0.5, 0.5, n)
200
+ return np.exp(-0.5 * (x / sigma) ** 2)
201
+
202
+
203
+ def gaussian_taper_2d(Nx: int, Ny: int, sigma: float = 0.4) -> np.ndarray:
204
+ """Generate 2D Gaussian window."""
205
+ return np.outer(gaussian_taper_1d(Nx, sigma), gaussian_taper_1d(Ny, sigma)).ravel()
206
+
207
+
208
+ def compute_taper_efficiency(taper: np.ndarray) -> float:
209
+ """
210
+ Compute aperture efficiency for a taper.
211
+
212
+ Efficiency = (sum of weights)^2 / (N * sum of weights^2)
213
+ Uniform illumination gives 100% efficiency.
214
+
215
+ Parameters
216
+ ----------
217
+ taper : ndarray
218
+ Amplitude taper weights
219
+
220
+ Returns
221
+ -------
222
+ efficiency : float
223
+ Aperture efficiency (0 to 1)
224
+ """
225
+ n = len(taper)
226
+ return (np.sum(taper) ** 2) / (n * np.sum(taper ** 2))
227
+
228
+
229
+ def compute_taper_directivity_loss(taper: np.ndarray) -> float:
230
+ """
231
+ Compute directivity loss due to tapering in dB.
232
+
233
+ Parameters
234
+ ----------
235
+ taper : ndarray
236
+ Amplitude taper weights
237
+
238
+ Returns
239
+ -------
240
+ loss_dB : float
241
+ Directivity loss relative to uniform illumination (negative or zero)
242
+ """
243
+ efficiency = compute_taper_efficiency(taper)
244
+ return 10 * np.log10(efficiency)
245
+
246
+
247
+ def apply_taper_to_geometry(
248
+ geometry: ArrayGeometry,
249
+ taper_func: str = 'taylor',
250
+ Nx: Optional[int] = None,
251
+ Ny: Optional[int] = None,
252
+ **taper_kwargs
253
+ ) -> np.ndarray:
254
+ """
255
+ Apply an amplitude taper based on element positions.
256
+
257
+ For non-rectangular arrays, uses radial distance from center.
258
+
259
+ Parameters
260
+ ----------
261
+ geometry : ArrayGeometry
262
+ Array geometry
263
+ taper_func : str
264
+ 'taylor', 'chebyshev', 'hamming', 'hanning', 'gaussian', 'cosine'
265
+ Nx, Ny : int, optional
266
+ For rectangular arrays, specify dimensions
267
+ **taper_kwargs
268
+ Additional arguments for the taper function
269
+
270
+ Returns
271
+ -------
272
+ taper : ndarray
273
+ Amplitude weights for each element
274
+ """
275
+ n = geometry.n_elements
276
+
277
+ # If Nx, Ny provided, use 2D separable taper
278
+ if Nx is not None and Ny is not None:
279
+ if taper_func == 'taylor':
280
+ return taylor_taper_2d(Nx, Ny, **taper_kwargs)
281
+ elif taper_func == 'chebyshev':
282
+ return chebyshev_taper_2d(Nx, Ny, **taper_kwargs)
283
+ elif taper_func == 'hamming':
284
+ return hamming_taper_2d(Nx, Ny)
285
+ elif taper_func == 'hanning':
286
+ return hanning_taper_2d(Nx, Ny)
287
+ elif taper_func == 'gaussian':
288
+ sigma = taper_kwargs.get('sigma', 0.4)
289
+ return gaussian_taper_2d(Nx, Ny, sigma)
290
+ elif taper_func == 'cosine':
291
+ return cosine_taper_2d(Nx, Ny)
292
+
293
+ # For irregular arrays, use radial taper
294
+ r = np.sqrt(geometry.x**2 + geometry.y**2)
295
+ r_max = np.max(r) if np.max(r) > 0 else 1.0
296
+ r_norm = r / r_max # 0 at center, 1 at edge
297
+
298
+ if taper_func == 'gaussian':
299
+ sigma = taper_kwargs.get('sigma', 0.4)
300
+ return np.exp(-0.5 * (r_norm / sigma) ** 2)
301
+ elif taper_func == 'cosine':
302
+ return np.cos(np.pi * r_norm / 2)
303
+ elif taper_func == 'taylor':
304
+ # Approximate Taylor with a polynomial
305
+ sll = -taper_kwargs.get('sidelobe_dB', -30.0)
306
+ # Simple approximation: cosine-squared on pedestal
307
+ pedestal = 10 ** (-sll / 40)
308
+ return pedestal + (1 - pedestal) * np.cos(np.pi * r_norm / 2) ** 2
309
+ else:
310
+ # Default to uniform
311
+ return np.ones(n)
312
+
313
+
314
+ # ============== Null Steering ==============
315
+
316
+ def null_steering_projection(
317
+ geometry: ArrayGeometry,
318
+ k: float,
319
+ theta_main_deg: float,
320
+ phi_main_deg: float,
321
+ null_directions: List[Tuple[float, float]],
322
+ initial_weights: Optional[np.ndarray] = None
323
+ ) -> np.ndarray:
324
+ """
325
+ Compute weights with nulls using orthogonal projection.
326
+
327
+ Projects the desired weight vector onto the null-space of the
328
+ steering vectors for the null directions.
329
+
330
+ Parameters
331
+ ----------
332
+ geometry : ArrayGeometry
333
+ Array geometry
334
+ k : float
335
+ Wavenumber
336
+ theta_main_deg : float
337
+ Main beam theta in degrees
338
+ phi_main_deg : float
339
+ Main beam phi in degrees
340
+ null_directions : list of (theta_deg, phi_deg) tuples
341
+ Directions for placing nulls
342
+ initial_weights : ndarray, optional
343
+ Starting weights (default: uniform with steering)
344
+
345
+ Returns
346
+ -------
347
+ weights : ndarray
348
+ Complex weights with nulls placed
349
+ """
350
+ n = geometry.n_elements
351
+
352
+ # Initial steering weights
353
+ if initial_weights is None:
354
+ initial_weights = steering_vector(
355
+ k, geometry.x, geometry.y,
356
+ theta_main_deg, phi_main_deg,
357
+ geometry.z
358
+ )
359
+
360
+ if len(null_directions) == 0:
361
+ return initial_weights
362
+
363
+ # Build constraint matrix (columns are steering vectors for null directions)
364
+ C = np.zeros((n, len(null_directions)), dtype=complex)
365
+ for i, (theta_null, phi_null) in enumerate(null_directions):
366
+ C[:, i] = steering_vector(
367
+ k, geometry.x, geometry.y,
368
+ theta_null, phi_null,
369
+ geometry.z
370
+ )
371
+
372
+ # Project initial weights onto null space of C
373
+ # P_null = I - C(C^H C)^-1 C^H
374
+ try:
375
+ CTC_inv = np.linalg.inv(C.conj().T @ C)
376
+ P_null = np.eye(n) - C @ CTC_inv @ C.conj().T
377
+ weights = P_null @ initial_weights
378
+ except np.linalg.LinAlgError:
379
+ # If matrix is singular, use pseudoinverse
380
+ P_null = np.eye(n) - C @ np.linalg.pinv(C)
381
+ weights = P_null @ initial_weights
382
+
383
+ return weights
384
+
385
+
386
+ def null_steering_lcmv(
387
+ geometry: ArrayGeometry,
388
+ k: float,
389
+ constraints: List[Tuple[float, float, complex]],
390
+ noise_covariance: Optional[np.ndarray] = None
391
+ ) -> np.ndarray:
392
+ """
393
+ LCMV (Linearly Constrained Minimum Variance) beamformer.
394
+
395
+ Minimizes output power subject to linear constraints on response
396
+ in specified directions.
397
+
398
+ Parameters
399
+ ----------
400
+ geometry : ArrayGeometry
401
+ Array geometry
402
+ k : float
403
+ Wavenumber
404
+ constraints : list of (theta_deg, phi_deg, response) tuples
405
+ Each constraint specifies (direction, desired complex response)
406
+ Use response=1+0j for unity gain, response=0 for null
407
+ noise_covariance : ndarray, optional
408
+ N x N noise covariance matrix (default: identity)
409
+
410
+ Returns
411
+ -------
412
+ weights : ndarray
413
+ Complex LCMV weights
414
+ """
415
+ n = geometry.n_elements
416
+
417
+ if noise_covariance is None:
418
+ R = np.eye(n, dtype=complex)
419
+ else:
420
+ R = noise_covariance
421
+
422
+ # Build constraint matrix and response vector
423
+ n_constraints = len(constraints)
424
+ C = np.zeros((n, n_constraints), dtype=complex)
425
+ f = np.zeros(n_constraints, dtype=complex)
426
+
427
+ for i, (theta, phi, response) in enumerate(constraints):
428
+ C[:, i] = steering_vector(
429
+ k, geometry.x, geometry.y,
430
+ theta, phi, geometry.z
431
+ )
432
+ f[i] = response
433
+
434
+ # LCMV solution: w = R^-1 C (C^H R^-1 C)^-1 f
435
+ try:
436
+ R_inv = np.linalg.inv(R)
437
+ R_inv_C = R_inv @ C
438
+ weights = R_inv_C @ np.linalg.inv(C.conj().T @ R_inv_C) @ f
439
+ except np.linalg.LinAlgError:
440
+ # Use pseudoinverse if singular
441
+ R_inv = np.linalg.pinv(R)
442
+ R_inv_C = R_inv @ C
443
+ weights = R_inv_C @ np.linalg.pinv(C.conj().T @ R_inv_C) @ f
444
+
445
+ return weights
446
+
447
+
448
+ def compute_null_depth(
449
+ weights: np.ndarray,
450
+ geometry: ArrayGeometry,
451
+ k: float,
452
+ theta_deg: float,
453
+ phi_deg: float,
454
+ theta_main_deg: float,
455
+ phi_main_deg: float
456
+ ) -> float:
457
+ """
458
+ Compute null depth relative to main beam in dB.
459
+
460
+ Parameters
461
+ ----------
462
+ weights : ndarray
463
+ Array weights
464
+ geometry : ArrayGeometry
465
+ Array geometry
466
+ k : float
467
+ Wavenumber
468
+ theta_deg, phi_deg : float
469
+ Null direction
470
+ theta_main_deg, phi_main_deg : float
471
+ Main beam direction
472
+
473
+ Returns
474
+ -------
475
+ depth_dB : float
476
+ Null depth (negative, lower is deeper)
477
+ """
478
+ from .core import array_factor_vectorized
479
+
480
+ # Response at null
481
+ theta_null = np.array([[np.deg2rad(theta_deg)]])
482
+ phi_null = np.array([[np.deg2rad(phi_deg)]])
483
+ AF_null = array_factor_vectorized(
484
+ theta_null, phi_null,
485
+ geometry.x, geometry.y, weights, k, geometry.z
486
+ )
487
+
488
+ # Response at main beam
489
+ theta_main = np.array([[np.deg2rad(theta_main_deg)]])
490
+ phi_main = np.array([[np.deg2rad(phi_main_deg)]])
491
+ AF_main = array_factor_vectorized(
492
+ theta_main, phi_main,
493
+ geometry.x, geometry.y, weights, k, geometry.z
494
+ )
495
+
496
+ ratio = np.abs(AF_null) / np.abs(AF_main)
497
+ if ratio > 0:
498
+ return 20 * np.log10(ratio.item())
499
+ else:
500
+ return -200.0 # Very deep null
501
+
502
+
503
+ # ============== Multiple Simultaneous Beams ==============
504
+
505
+ def multi_beam_weights_superposition(
506
+ geometry: ArrayGeometry,
507
+ k: float,
508
+ beam_directions: List[Tuple[float, float]],
509
+ amplitudes: Optional[List[float]] = None,
510
+ tapers: Optional[List[np.ndarray]] = None
511
+ ) -> np.ndarray:
512
+ """
513
+ Compute weights for multiple simultaneous beams via superposition.
514
+
515
+ The weights are a weighted sum of individual steering vectors.
516
+ This creates multiple beams but with reduced gain per beam.
517
+
518
+ Parameters
519
+ ----------
520
+ geometry : ArrayGeometry
521
+ Array geometry
522
+ k : float
523
+ Wavenumber
524
+ beam_directions : list of (theta_deg, phi_deg) tuples
525
+ Directions for each beam
526
+ amplitudes : list of float, optional
527
+ Relative amplitude for each beam (default: equal)
528
+ tapers : list of ndarray, optional
529
+ Amplitude taper for each beam (default: uniform)
530
+
531
+ Returns
532
+ -------
533
+ weights : ndarray
534
+ Combined complex weights
535
+ """
536
+ n_beams = len(beam_directions)
537
+
538
+ if amplitudes is None:
539
+ amplitudes = [1.0] * n_beams
540
+
541
+ weights = np.zeros(geometry.n_elements, dtype=complex)
542
+
543
+ for i, (theta, phi) in enumerate(beam_directions):
544
+ sv = steering_vector(
545
+ k, geometry.x, geometry.y,
546
+ theta, phi, geometry.z
547
+ )
548
+
549
+ if tapers is not None and i < len(tapers):
550
+ sv = sv * tapers[i]
551
+
552
+ weights += amplitudes[i] * sv
553
+
554
+ return weights
555
+
556
+
557
+ def multi_beam_weights_orthogonal(
558
+ geometry: ArrayGeometry,
559
+ k: float,
560
+ beam_directions: List[Tuple[float, float]]
561
+ ) -> List[np.ndarray]:
562
+ """
563
+ Compute separate weight vectors for orthogonal digital beamforming.
564
+
565
+ Each beam has its own weight vector, allowing digital combination
566
+ with no loss per beam (requires N receive channels).
567
+
568
+ Parameters
569
+ ----------
570
+ geometry : ArrayGeometry
571
+ Array geometry
572
+ k : float
573
+ Wavenumber
574
+ beam_directions : list of (theta_deg, phi_deg) tuples
575
+ Directions for each beam
576
+
577
+ Returns
578
+ -------
579
+ weight_vectors : list of ndarray
580
+ List of complex weight vectors, one per beam
581
+ """
582
+ weight_vectors = []
583
+
584
+ for theta, phi in beam_directions:
585
+ weights = steering_vector(
586
+ k, geometry.x, geometry.y,
587
+ theta, phi, geometry.z
588
+ )
589
+ weight_vectors.append(weights)
590
+
591
+ return weight_vectors
592
+
593
+
594
+ def compute_beam_isolation(
595
+ weights_list: List[np.ndarray],
596
+ geometry: ArrayGeometry,
597
+ k: float,
598
+ beam_directions: List[Tuple[float, float]]
599
+ ) -> np.ndarray:
600
+ """
601
+ Compute isolation between multiple beams.
602
+
603
+ Parameters
604
+ ----------
605
+ weights_list : list of ndarray
606
+ Weight vector for each beam
607
+ geometry : ArrayGeometry
608
+ Array geometry
609
+ k : float
610
+ Wavenumber
611
+ beam_directions : list of (theta_deg, phi_deg)
612
+ Direction of each beam
613
+
614
+ Returns
615
+ -------
616
+ isolation_matrix : ndarray
617
+ N_beams x N_beams matrix of isolation in dB
618
+ Diagonal is 0 dB, off-diagonal is negative (isolation)
619
+ """
620
+ from .core import array_factor_vectorized
621
+
622
+ n_beams = len(weights_list)
623
+ isolation = np.zeros((n_beams, n_beams))
624
+
625
+ # Compute response of each beam's weights at each beam's direction
626
+ for i in range(n_beams):
627
+ for j in range(n_beams):
628
+ theta = np.array([[np.deg2rad(beam_directions[j][0])]])
629
+ phi = np.array([[np.deg2rad(beam_directions[j][1])]])
630
+
631
+ AF = array_factor_vectorized(
632
+ theta, phi,
633
+ geometry.x, geometry.y,
634
+ weights_list[i], k, geometry.z
635
+ )
636
+
637
+ AF_main = array_factor_vectorized(
638
+ np.array([[np.deg2rad(beam_directions[i][0])]]),
639
+ np.array([[np.deg2rad(beam_directions[i][1])]]),
640
+ geometry.x, geometry.y,
641
+ weights_list[i], k, geometry.z
642
+ )
643
+
644
+ if np.abs(AF_main) > 0:
645
+ ratio = np.abs(AF) / np.abs(AF_main)
646
+ isolation[i, j] = 20 * np.log10(ratio.item()) if ratio > 0 else -200
647
+ else:
648
+ isolation[i, j] = -200
649
+
650
+ return isolation
651
+
652
+
653
+ def monopulse_weights(
654
+ geometry: ArrayGeometry,
655
+ k: float,
656
+ theta0_deg: float,
657
+ phi0_deg: float,
658
+ mode: str = 'sum'
659
+ ) -> np.ndarray:
660
+ """
661
+ Compute monopulse tracking weights.
662
+
663
+ Parameters
664
+ ----------
665
+ geometry : ArrayGeometry
666
+ Array geometry
667
+ k : float
668
+ Wavenumber
669
+ theta0_deg : float
670
+ Nominal beam direction theta
671
+ phi0_deg : float
672
+ Nominal beam direction phi
673
+ mode : str
674
+ 'sum' - sum pattern (normal beam)
675
+ 'delta_az' - azimuth difference pattern
676
+ 'delta_el' - elevation difference pattern
677
+
678
+ Returns
679
+ -------
680
+ weights : ndarray
681
+ Complex weights for the specified mode
682
+ """
683
+ # Base steering vector
684
+ sv = steering_vector(
685
+ k, geometry.x, geometry.y,
686
+ theta0_deg, phi0_deg, geometry.z
687
+ )
688
+
689
+ if mode == 'sum':
690
+ return sv
691
+
692
+ elif mode == 'delta_az':
693
+ # Difference in azimuth: flip sign for half the array in x
694
+ sign = np.sign(geometry.x)
695
+ sign[sign == 0] = 1 # Handle elements at x=0
696
+ return sv * sign
697
+
698
+ elif mode == 'delta_el':
699
+ # Difference in elevation: flip sign for half the array in y
700
+ sign = np.sign(geometry.y)
701
+ sign[sign == 0] = 1
702
+ return sv * sign
703
+
704
+ else:
705
+ raise ValueError(f"Unknown monopulse mode: {mode}")