Skip to main content

scirs2_signal/beamforming/
subarray.rs

1//! Reduced-dimension STAP via subarray processing
2//!
3//! Full-dimension STAP requires inverting an `N*M x N*M` covariance matrix,
4//! which is computationally expensive and requires many training samples
5//! (the Reed-Mallet-Brennan rule requires `L >= 2*N*M` i.i.d. samples).
6//!
7//! Subarray-based methods reduce the dimensionality by partitioning the
8//! spatial and/or temporal domains into overlapping subarrays/sub-pulses,
9//! then processing within each subarray before combining.
10//!
11//! Provides:
12//! - [`jdl_stap`]: Joint Domain Localized (JDL) processing
13//! - [`factored_stap`]: Factored (separable) STAP
14//! - [`dimension_reduction_ratio`]: Compare full vs reduced dimensions
15//!
16//! Pure Rust, no unwrap(), snake_case naming.
17
18use crate::beamforming::array::{inner_product_conj, invert_hermitian_matrix, mat_vec_mul};
19use crate::beamforming::stap::{space_time_steering_vector, STAPConfig, STAPResult};
20use crate::error::{SignalError, SignalResult};
21use scirs2_core::numeric::Complex64;
22use std::f64::consts::PI;
23
24// ---------------------------------------------------------------------------
25// Configuration
26// ---------------------------------------------------------------------------
27
28/// Configuration for subarray-based reduced-dimension STAP
29#[derive(Debug, Clone)]
30pub struct SubarrayConfig {
31    /// Number of spatial subarrays
32    pub spatial_subarrays: usize,
33    /// Number of Doppler taps (temporal sub-pulses) per subarray
34    pub temporal_taps: usize,
35    /// Overlap between adjacent spatial subarrays (in elements)
36    pub overlap: usize,
37}
38
39impl Default for SubarrayConfig {
40    fn default() -> Self {
41        Self {
42            spatial_subarrays: 4,
43            temporal_taps: 4,
44            overlap: 1,
45        }
46    }
47}
48
49// ---------------------------------------------------------------------------
50// JDL STAP
51// ---------------------------------------------------------------------------
52
53/// Joint Domain Localized (JDL) STAP processing
54///
55/// JDL pre-filters the data using spatial beamforming (DFT beams) and
56/// temporal filtering (Doppler bins), then applies adaptive processing
57/// in a reduced-dimension space. This dramatically reduces the number
58/// of degrees of freedom and the required training data.
59///
60/// The processing flow is:
61/// 1. Form spatial beams (DFT across elements for each pulse)
62/// 2. Form Doppler bins (DFT across pulses for each beam)
63/// 3. Select a local processing region around the target angle/Doppler
64/// 4. Estimate covariance in the reduced domain
65/// 5. Apply adaptive weights in the reduced domain
66///
67/// # Arguments
68///
69/// * `data` - Cell under test `[n_elements][n_pulses]`
70/// * `training_data` - Training cells `[L][n_elements][n_pulses]`
71/// * `target_angle` - Look direction in radians
72/// * `target_doppler` - Normalised Doppler frequency
73/// * `stap_config` - STAP configuration
74/// * `subarray_config` - Subarray configuration
75pub fn jdl_stap(
76    data: &[Vec<Complex64>],
77    training_data: &[Vec<Vec<Complex64>>],
78    target_angle: f64,
79    target_doppler: f64,
80    stap_config: &STAPConfig,
81    subarray_config: &SubarrayConfig,
82) -> SignalResult<STAPResult> {
83    let n = stap_config.n_elements;
84    let m = stap_config.n_pulses;
85    let n_sub = subarray_config.spatial_subarrays;
86    let n_taps = subarray_config.temporal_taps;
87
88    validate_subarray_config(n, m, subarray_config)?;
89
90    // Reduced dimension
91    let reduced_dim = n_sub * n_taps;
92
93    // Step 1-2: Transform data to beam-Doppler domain (reduced)
94    let x_reduced =
95        transform_to_beam_doppler(data, n, m, target_angle, target_doppler, subarray_config)?;
96
97    // Transform training data
98    let mut training_reduced = Vec::with_capacity(training_data.len());
99    for cell in training_data {
100        let t_reduced =
101            transform_to_beam_doppler(cell, n, m, target_angle, target_doppler, subarray_config)?;
102        training_reduced.push(t_reduced);
103    }
104
105    // Step 3: Estimate covariance in reduced domain
106    let mut r_reduced = vec![vec![Complex64::new(0.0, 0.0); reduced_dim]; reduced_dim];
107    let n_training = training_reduced.len();
108    if n_training == 0 {
109        return Err(SignalError::ValueError(
110            "Training data must not be empty".to_string(),
111        ));
112    }
113
114    for t_vec in &training_reduced {
115        for i in 0..reduced_dim {
116            for j in 0..reduced_dim {
117                r_reduced[i][j] += t_vec[i] * t_vec[j].conj();
118            }
119        }
120    }
121
122    let scale = 1.0 / n_training as f64;
123    for row in &mut r_reduced {
124        for val in row.iter_mut() {
125            *val *= scale;
126        }
127    }
128
129    // Diagonal loading
130    let trace_val: f64 = (0..reduced_dim).map(|i| r_reduced[i][i].re).sum();
131    let loading = 1e-4 * trace_val / reduced_dim as f64;
132    for i in 0..reduced_dim {
133        r_reduced[i][i] += Complex64::new(loading, 0.0);
134    }
135
136    // Step 4: Steering vector in reduced domain
137    let s_reduced =
138        reduced_steering_vector(target_angle, target_doppler, subarray_config, stap_config)?;
139
140    // Step 5: Compute weights in reduced domain
141    let r_inv = invert_hermitian_matrix(&r_reduced)?;
142    let r_inv_s = mat_vec_mul(&r_inv, &s_reduced);
143    let denom = inner_product_conj(&s_reduced, &r_inv_s);
144
145    if denom.norm() < 1e-20 {
146        return Err(SignalError::ComputationError(
147            "JDL STAP: denominator near zero".to_string(),
148        ));
149    }
150
151    let w_reduced: Vec<Complex64> = r_inv_s.iter().map(|&v| v / denom).collect();
152
153    // Output power in reduced domain
154    let rw = mat_vec_mul(&r_reduced, &w_reduced);
155    let output_power = inner_product_conj(&w_reduced, &rw).re;
156
157    // Map back to full-dimension weights for the result
158    let full_weights = expand_weights_to_full(
159        &w_reduced,
160        n,
161        m,
162        target_angle,
163        target_doppler,
164        subarray_config,
165    )?;
166
167    // SINR improvement estimate
168    let sinr_improvement = if denom.re > 1.0 {
169        10.0 * denom.re.log10()
170    } else {
171        0.0
172    };
173
174    Ok(STAPResult {
175        weights: full_weights,
176        output_power: output_power.max(0.0),
177        sinr_improvement,
178        clutter_rank: reduced_dim.min(n * m),
179    })
180}
181
182// ---------------------------------------------------------------------------
183// Factored STAP
184// ---------------------------------------------------------------------------
185
186/// Factored STAP: separate spatial and temporal processing
187///
188/// Decomposes the joint space-time problem into two independent stages:
189/// 1. Spatial beamforming (per pulse)
190/// 2. Temporal (Doppler) filtering (per spatial beam output)
191///
192/// The overall weight vector approximates the Kronecker product:
193/// `w approx w_temporal kron w_spatial`
194///
195/// This reduces the N*M-dimensional problem to an N-dimensional spatial
196/// problem and an M-dimensional temporal problem.
197///
198/// # Arguments
199///
200/// * `data` - Cell under test `[n_elements][n_pulses]`
201/// * `training_data` - Training cells `[L][n_elements][n_pulses]`
202/// * `target_angle` - Look direction in radians
203/// * `target_doppler` - Normalised Doppler frequency
204/// * `config` - STAP configuration
205pub fn factored_stap(
206    data: &[Vec<Complex64>],
207    training_data: &[Vec<Vec<Complex64>>],
208    target_angle: f64,
209    target_doppler: f64,
210    config: &STAPConfig,
211) -> SignalResult<STAPResult> {
212    let n = config.n_elements;
213    let m = config.n_pulses;
214
215    if data.len() != n {
216        return Err(SignalError::DimensionMismatch(format!(
217            "Data has {} elements, expected {}",
218            data.len(),
219            n
220        )));
221    }
222    if training_data.is_empty() {
223        return Err(SignalError::ValueError(
224            "Training data must not be empty".to_string(),
225        ));
226    }
227
228    // Stage 1: Spatial processing
229    // Estimate spatial covariance from training data (average over pulses)
230    let mut r_spatial = vec![vec![Complex64::new(0.0, 0.0); n]; n];
231    let mut spatial_count = 0usize;
232
233    for cell in training_data {
234        if cell.len() != n {
235            continue;
236        }
237        for p in 0..m {
238            // Extract spatial snapshot for pulse p
239            let mut x_spatial = Vec::with_capacity(n);
240            let mut valid = true;
241            for k in 0..n {
242                if p < cell[k].len() {
243                    x_spatial.push(cell[k][p]);
244                } else {
245                    valid = false;
246                    break;
247                }
248            }
249            if !valid {
250                continue;
251            }
252
253            for i in 0..n {
254                for j in 0..n {
255                    r_spatial[i][j] += x_spatial[i] * x_spatial[j].conj();
256                }
257            }
258            spatial_count += 1;
259        }
260    }
261
262    if spatial_count == 0 {
263        return Err(SignalError::ComputationError(
264            "No valid spatial snapshots in training data".to_string(),
265        ));
266    }
267
268    let spatial_scale = 1.0 / spatial_count as f64;
269    for row in &mut r_spatial {
270        for val in row.iter_mut() {
271            *val *= spatial_scale;
272        }
273    }
274
275    // Spatial loading
276    let trace_s: f64 = (0..n).map(|i| r_spatial[i][i].re).sum();
277    let load_s = 1e-4 * trace_s / n as f64;
278    for i in 0..n {
279        r_spatial[i][i] += Complex64::new(load_s, 0.0);
280    }
281
282    // Spatial steering vector
283    let spatial_phase = 2.0 * PI * config.element_spacing * target_angle.sin();
284    let s_spatial: Vec<Complex64> = (0..n)
285        .map(|k| {
286            let phase = spatial_phase * k as f64;
287            Complex64::new(phase.cos(), phase.sin())
288        })
289        .collect();
290
291    // Spatial weights via MVDR
292    let r_s_inv = invert_hermitian_matrix(&r_spatial)?;
293    let r_s_inv_a = mat_vec_mul(&r_s_inv, &s_spatial);
294    let denom_s = inner_product_conj(&s_spatial, &r_s_inv_a);
295
296    if denom_s.norm() < 1e-20 {
297        return Err(SignalError::ComputationError(
298            "Factored STAP: spatial denominator near zero".to_string(),
299        ));
300    }
301
302    let w_spatial: Vec<Complex64> = r_s_inv_a.iter().map(|&v| v / denom_s).collect();
303
304    // Stage 2: Temporal processing
305    // Apply spatial weights to get beam output for each pulse, then estimate
306    // temporal covariance
307
308    let mut r_temporal = vec![vec![Complex64::new(0.0, 0.0); m]; m];
309    let mut temporal_count = 0usize;
310
311    for cell in training_data {
312        if cell.len() != n {
313            continue;
314        }
315        // Form beam output: y[p] = w_spatial^H * x[:,p]
316        let mut y = Vec::with_capacity(m);
317        let mut valid = true;
318        for p in 0..m {
319            let mut beam_out = Complex64::new(0.0, 0.0);
320            for k in 0..n {
321                if p < cell[k].len() {
322                    beam_out += w_spatial[k].conj() * cell[k][p];
323                } else {
324                    valid = false;
325                    break;
326                }
327            }
328            if !valid {
329                break;
330            }
331            y.push(beam_out);
332        }
333        if !valid || y.len() != m {
334            continue;
335        }
336
337        for i in 0..m {
338            for j in 0..m {
339                r_temporal[i][j] += y[i] * y[j].conj();
340            }
341        }
342        temporal_count += 1;
343    }
344
345    if temporal_count == 0 {
346        return Err(SignalError::ComputationError(
347            "No valid temporal snapshots in training data".to_string(),
348        ));
349    }
350
351    let temporal_scale = 1.0 / temporal_count as f64;
352    for row in &mut r_temporal {
353        for val in row.iter_mut() {
354            *val *= temporal_scale;
355        }
356    }
357
358    // Temporal loading
359    let trace_t: f64 = (0..m).map(|i| r_temporal[i][i].re).sum();
360    let load_t = 1e-4 * trace_t / m as f64;
361    for i in 0..m {
362        r_temporal[i][i] += Complex64::new(load_t, 0.0);
363    }
364
365    // Temporal steering vector (Doppler)
366    let temporal_phase = 2.0 * PI * target_doppler;
367    let s_temporal: Vec<Complex64> = (0..m)
368        .map(|p| {
369            let phase = temporal_phase * p as f64;
370            Complex64::new(phase.cos(), phase.sin())
371        })
372        .collect();
373
374    // Temporal weights via MVDR
375    let r_t_inv = invert_hermitian_matrix(&r_temporal)?;
376    let r_t_inv_b = mat_vec_mul(&r_t_inv, &s_temporal);
377    let denom_t = inner_product_conj(&s_temporal, &r_t_inv_b);
378
379    if denom_t.norm() < 1e-20 {
380        return Err(SignalError::ComputationError(
381            "Factored STAP: temporal denominator near zero".to_string(),
382        ));
383    }
384
385    let w_temporal: Vec<Complex64> = r_t_inv_b.iter().map(|&v| v / denom_t).collect();
386
387    // Combine: w_st = w_temporal kron w_spatial
388    let mut weights = Vec::with_capacity(n * m);
389    for p in 0..m {
390        for k in 0..n {
391            weights.push(w_temporal[p] * w_spatial[k]);
392        }
393    }
394
395    // Output power: apply combined weights to cell under test
396    // x_st vectorised, then P = |w^H x|^2
397    let mut x_st = Vec::with_capacity(n * m);
398    for p in 0..m {
399        for k in 0..n {
400            if p < data[k].len() {
401                x_st.push(data[k][p]);
402            } else {
403                x_st.push(Complex64::new(0.0, 0.0));
404            }
405        }
406    }
407
408    let beam_output = inner_product_conj(&weights, &x_st);
409    let output_power = beam_output.norm_sqr();
410
411    // SINR improvement estimate
412    let sinr_s = denom_s.re;
413    let sinr_t = denom_t.re;
414    let sinr_improvement = if sinr_s > 0.0 && sinr_t > 0.0 {
415        10.0 * (sinr_s * sinr_t).log10()
416    } else {
417        0.0
418    };
419
420    Ok(STAPResult {
421        weights,
422        output_power,
423        sinr_improvement,
424        clutter_rank: n + m, // approximate for factored approach
425    })
426}
427
428// ---------------------------------------------------------------------------
429// Dimension reduction ratio
430// ---------------------------------------------------------------------------
431
432/// Compute the dimension reduction ratio: full STAP dimension / reduced dimension
433///
434/// Full dimension: `N * M` (n_elements * n_pulses)
435/// Reduced dimension: `spatial_subarrays * temporal_taps`
436///
437/// A ratio > 1 indicates dimension reduction.
438pub fn dimension_reduction_ratio(stap: &STAPConfig, subarray: &SubarrayConfig) -> f64 {
439    let full_dim = stap.n_elements * stap.n_pulses;
440    let reduced_dim = subarray.spatial_subarrays * subarray.temporal_taps;
441
442    if reduced_dim == 0 {
443        return f64::INFINITY;
444    }
445
446    full_dim as f64 / reduced_dim as f64
447}
448
449// ---------------------------------------------------------------------------
450// Helpers
451// ---------------------------------------------------------------------------
452
453/// Validate subarray configuration against STAP configuration
454fn validate_subarray_config(
455    n_elements: usize,
456    n_pulses: usize,
457    config: &SubarrayConfig,
458) -> SignalResult<()> {
459    if config.spatial_subarrays == 0 {
460        return Err(SignalError::ValueError(
461            "Number of spatial subarrays must be positive".to_string(),
462        ));
463    }
464    if config.temporal_taps == 0 {
465        return Err(SignalError::ValueError(
466            "Number of temporal taps must be positive".to_string(),
467        ));
468    }
469    if config.spatial_subarrays > n_elements {
470        return Err(SignalError::ValueError(format!(
471            "Number of spatial subarrays ({}) exceeds number of elements ({})",
472            config.spatial_subarrays, n_elements
473        )));
474    }
475    if config.temporal_taps > n_pulses {
476        return Err(SignalError::ValueError(format!(
477            "Number of temporal taps ({}) exceeds number of pulses ({})",
478            config.temporal_taps, n_pulses
479        )));
480    }
481    Ok(())
482}
483
484/// Transform data from element-pulse domain to beam-Doppler domain (reduced)
485///
486/// Applies spatial DFT beams and temporal DFT bins, then selects a
487/// local region around the target angle/Doppler.
488fn transform_to_beam_doppler(
489    data: &[Vec<Complex64>],
490    n_elements: usize,
491    n_pulses: usize,
492    target_angle: f64,
493    target_doppler: f64,
494    config: &SubarrayConfig,
495) -> SignalResult<Vec<Complex64>> {
496    let n_sub = config.spatial_subarrays;
497    let n_taps = config.temporal_taps;
498    let reduced_dim = n_sub * n_taps;
499
500    if data.len() != n_elements {
501        return Err(SignalError::DimensionMismatch(format!(
502            "Data has {} elements, expected {}",
503            data.len(),
504            n_elements
505        )));
506    }
507
508    // Compute spatial DFT beams
509    // Beam b = sum_k x_k * exp(-j*2*pi*b*k/N) for b = 0..N-1
510    // Select n_sub beams centred around the target spatial frequency
511    // Normalised spatial frequency u = d * sin(angle), DFT bin = u * N (mod N)
512    let target_spatial_freq = target_angle.sin() * 0.5;
513    let centre_beam = (target_spatial_freq * n_elements as f64)
514        .round()
515        .rem_euclid(n_elements as f64) as i64;
516
517    let mut beams = Vec::with_capacity(n_sub * n_pulses);
518    for sub_idx in 0..n_sub {
519        let beam_idx_raw = centre_beam - (n_sub as i64 / 2) + sub_idx as i64;
520        let beam_idx = beam_idx_raw.rem_euclid(n_elements as i64) as usize;
521
522        for p in 0..n_pulses {
523            let mut beam_val = Complex64::new(0.0, 0.0);
524            for k in 0..n_elements {
525                if p < data[k].len() {
526                    let phase = -2.0 * PI * (beam_idx as f64) * (k as f64) / (n_elements as f64);
527                    let twiddle = Complex64::new(phase.cos(), phase.sin());
528                    beam_val += data[k][p] * twiddle;
529                }
530            }
531            beams.push(beam_val);
532        }
533    }
534
535    // Compute temporal DFT bins for each beam
536    // Select n_taps Doppler bins centred around the target Doppler
537    // DFT Doppler bin = f_d * M (mod M)
538    let centre_doppler = (target_doppler * n_pulses as f64)
539        .round()
540        .rem_euclid(n_pulses as f64) as i64;
541
542    let mut result = Vec::with_capacity(reduced_dim);
543    for sub_idx in 0..n_sub {
544        for tap_idx in 0..n_taps {
545            let dop_idx_raw = centre_doppler - (n_taps as i64 / 2) + tap_idx as i64;
546            let dop_idx = dop_idx_raw.rem_euclid(n_pulses as i64) as usize;
547
548            let mut bin_val = Complex64::new(0.0, 0.0);
549            for p in 0..n_pulses {
550                let beam_sample = beams[sub_idx * n_pulses + p];
551                let phase = -2.0 * PI * (dop_idx as f64) * (p as f64) / (n_pulses as f64);
552                let twiddle = Complex64::new(phase.cos(), phase.sin());
553                bin_val += beam_sample * twiddle;
554            }
555            result.push(bin_val);
556        }
557    }
558
559    Ok(result)
560}
561
562/// Compute steering vector in the reduced beam-Doppler domain
563fn reduced_steering_vector(
564    target_angle: f64,
565    target_doppler: f64,
566    subarray_config: &SubarrayConfig,
567    stap_config: &STAPConfig,
568) -> SignalResult<Vec<Complex64>> {
569    let n_sub = subarray_config.spatial_subarrays;
570    let n_taps = subarray_config.temporal_taps;
571    let n = stap_config.n_elements;
572    let m = stap_config.n_pulses;
573    let reduced_dim = n_sub * n_taps;
574
575    let target_spatial_freq = target_angle.sin() * 0.5;
576    let centre_beam = (target_spatial_freq * n as f64)
577        .round()
578        .rem_euclid(n as f64) as i64;
579    let centre_doppler = (target_doppler * m as f64).round().rem_euclid(m as f64) as i64;
580
581    // The steering vector in the DFT domain is a set of delta-like responses
582    // at the target angle/Doppler. For the local region we use the DFT of
583    // the original steering vector.
584    let spatial_phase = 2.0 * PI * stap_config.element_spacing * target_angle.sin();
585    let temporal_phase = 2.0 * PI * target_doppler;
586
587    let mut s_reduced = Vec::with_capacity(reduced_dim);
588    for sub_idx in 0..n_sub {
589        let beam_idx_raw = centre_beam - (n_sub as i64 / 2) + sub_idx as i64;
590        let beam_idx = beam_idx_raw.rem_euclid(n as i64) as usize;
591
592        // Spatial DFT of steering vector at this beam index
593        let mut s_beam = Complex64::new(0.0, 0.0);
594        for k in 0..n {
595            let sv_phase = spatial_phase * k as f64;
596            let sv_k = Complex64::new(sv_phase.cos(), sv_phase.sin());
597            let dft_phase = -2.0 * PI * (beam_idx as f64) * (k as f64) / (n as f64);
598            let twiddle = Complex64::new(dft_phase.cos(), dft_phase.sin());
599            s_beam += sv_k * twiddle;
600        }
601
602        for tap_idx in 0..n_taps {
603            let dop_idx_raw = centre_doppler - (n_taps as i64 / 2) + tap_idx as i64;
604            let dop_idx = dop_idx_raw.rem_euclid(m as i64) as usize;
605
606            // Temporal DFT of steering vector at this Doppler bin
607            let mut s_dop = Complex64::new(0.0, 0.0);
608            for p in 0..m {
609                let sv_phase_t = temporal_phase * p as f64;
610                let sv_p = Complex64::new(sv_phase_t.cos(), sv_phase_t.sin());
611                let dft_phase_t = -2.0 * PI * (dop_idx as f64) * (p as f64) / (m as f64);
612                let twiddle_t = Complex64::new(dft_phase_t.cos(), dft_phase_t.sin());
613                s_dop += sv_p * twiddle_t;
614            }
615
616            s_reduced.push(s_beam * s_dop);
617        }
618    }
619
620    // Ensure the steering vector has non-trivial energy.
621    // Normalise so that ||s||^2 = reduced_dim (similar to full-dim convention).
622    let norm_sq: f64 = s_reduced.iter().map(|v| v.norm_sqr()).sum();
623    if norm_sq > 1e-30 {
624        let scale = (reduced_dim as f64 / norm_sq).sqrt();
625        for v in &mut s_reduced {
626            *v *= scale;
627        }
628    } else {
629        // Fallback: uniform steering vector when DFT gives near-zero
630        let val = Complex64::new(1.0, 0.0);
631        for v in &mut s_reduced {
632            *v = val;
633        }
634    }
635
636    Ok(s_reduced)
637}
638
639/// Expand reduced-dimension weights back to full N*M dimension
640fn expand_weights_to_full(
641    w_reduced: &[Complex64],
642    n_elements: usize,
643    n_pulses: usize,
644    target_angle: f64,
645    target_doppler: f64,
646    config: &SubarrayConfig,
647) -> SignalResult<Vec<Complex64>> {
648    let n_sub = config.spatial_subarrays;
649    let n_taps = config.temporal_taps;
650    let full_dim = n_elements * n_pulses;
651
652    let target_spatial_freq = target_angle.sin() * 0.5;
653    let centre_beam = (target_spatial_freq * n_elements as f64)
654        .round()
655        .rem_euclid(n_elements as f64) as i64;
656    let centre_doppler = (target_doppler * n_pulses as f64)
657        .round()
658        .rem_euclid(n_pulses as f64) as i64;
659
660    // Inverse DFT: map from beam-Doppler back to element-pulse domain
661    let mut w_full = vec![Complex64::new(0.0, 0.0); full_dim];
662
663    for sub_idx in 0..n_sub {
664        let beam_idx_raw = centre_beam - (n_sub as i64 / 2) + sub_idx as i64;
665        let beam_idx = beam_idx_raw.rem_euclid(n_elements as i64) as usize;
666
667        for tap_idx in 0..n_taps {
668            let dop_idx_raw = centre_doppler - (n_taps as i64 / 2) + tap_idx as i64;
669            let dop_idx = dop_idx_raw.rem_euclid(n_pulses as i64) as usize;
670
671            let w_val = w_reduced[sub_idx * n_taps + tap_idx];
672
673            // Inverse spatial DFT
674            for k in 0..n_elements {
675                let phase_s = 2.0 * PI * (beam_idx as f64) * (k as f64) / (n_elements as f64);
676                let twiddle_s = Complex64::new(phase_s.cos(), phase_s.sin());
677
678                // Inverse temporal DFT
679                for p in 0..n_pulses {
680                    let phase_t = 2.0 * PI * (dop_idx as f64) * (p as f64) / (n_pulses as f64);
681                    let twiddle_t = Complex64::new(phase_t.cos(), phase_t.sin());
682
683                    let idx = p * n_elements + k;
684                    w_full[idx] += w_val * twiddle_s * twiddle_t;
685                }
686            }
687        }
688    }
689
690    // Normalise
691    let norm_factor = 1.0 / ((n_elements * n_pulses) as f64);
692    for w in &mut w_full {
693        *w *= norm_factor;
694    }
695
696    Ok(w_full)
697}
698
699// ---------------------------------------------------------------------------
700// Tests
701// ---------------------------------------------------------------------------
702
703#[cfg(test)]
704mod tests {
705    use super::*;
706
707    fn make_training_data(
708        n_elements: usize,
709        n_pulses: usize,
710        n_cells: usize,
711    ) -> Vec<Vec<Vec<Complex64>>> {
712        let mut training = Vec::with_capacity(n_cells);
713        for cell_idx in 0..n_cells {
714            let mut snapshot = vec![vec![Complex64::new(0.0, 0.0); n_pulses]; n_elements];
715            for k in 0..n_elements {
716                for p in 0..n_pulses {
717                    let seed = (cell_idx * 1000 + k * 37 + p * 13) as f64;
718                    let re = (seed * 0.618033988749).fract() - 0.5;
719                    let im = (seed * 0.414213562373).fract() - 0.5;
720                    snapshot[k][p] = Complex64::new(re, im);
721                }
722            }
723            training.push(snapshot);
724        }
725        training
726    }
727
728    #[test]
729    fn test_jdl_dimension_less_than_full() {
730        let stap_config = STAPConfig {
731            n_elements: 8,
732            n_pulses: 8,
733            ..STAPConfig::default()
734        };
735        let sub_config = SubarrayConfig {
736            spatial_subarrays: 3,
737            temporal_taps: 3,
738            overlap: 1,
739        };
740
741        let full_dim = stap_config.n_elements * stap_config.n_pulses; // 64
742        let reduced_dim = sub_config.spatial_subarrays * sub_config.temporal_taps; // 9
743
744        assert!(
745            reduced_dim < full_dim,
746            "Reduced dim {} should be < full dim {}",
747            reduced_dim,
748            full_dim
749        );
750
751        let ratio = dimension_reduction_ratio(&stap_config, &sub_config);
752        assert!(ratio > 1.0, "Ratio should be > 1, got {}", ratio);
753        assert!((ratio - (64.0 / 9.0)).abs() < 1e-10);
754    }
755
756    #[test]
757    fn test_jdl_stap_basic() {
758        let stap_config = STAPConfig {
759            n_elements: 4,
760            n_pulses: 4,
761            ..STAPConfig::default()
762        };
763        let sub_config = SubarrayConfig {
764            spatial_subarrays: 2,
765            temporal_taps: 2,
766            overlap: 0,
767        };
768
769        let n = stap_config.n_elements;
770        let m = stap_config.n_pulses;
771        let training = make_training_data(n, m, 30);
772        let data = vec![vec![Complex64::new(1.0, 0.0); m]; n];
773
774        let result = jdl_stap(&data, &training, 0.0, 0.0, &stap_config, &sub_config)
775            .expect("JDL STAP should succeed");
776
777        assert_eq!(result.weights.len(), n * m);
778        assert!(
779            result.output_power >= 0.0,
780            "Output power should be non-negative"
781        );
782        assert!(result.output_power.is_finite());
783    }
784
785    #[test]
786    fn test_factored_stap_output_power_positive() {
787        let config = STAPConfig {
788            n_elements: 4,
789            n_pulses: 4,
790            element_spacing: 0.5,
791            ..STAPConfig::default()
792        };
793
794        let n = config.n_elements;
795        let m = config.n_pulses;
796        let training = make_training_data(n, m, 30);
797        let data = vec![vec![Complex64::new(1.0, 0.0); m]; n];
798
799        let result = factored_stap(&data, &training, 0.1, 0.05, &config)
800            .expect("Factored STAP should succeed");
801
802        assert_eq!(result.weights.len(), n * m);
803        assert!(
804            result.output_power > 0.0,
805            "Output power should be positive, got {}",
806            result.output_power
807        );
808        assert!(result.output_power.is_finite());
809    }
810
811    #[test]
812    fn test_dimension_reduction_ratio_correct() {
813        let stap = STAPConfig {
814            n_elements: 8,
815            n_pulses: 16,
816            ..STAPConfig::default()
817        };
818        let sub = SubarrayConfig {
819            spatial_subarrays: 4,
820            temporal_taps: 4,
821            overlap: 1,
822        };
823
824        let ratio = dimension_reduction_ratio(&stap, &sub);
825        // full = 128, reduced = 16, ratio = 8.0
826        assert!((ratio - 8.0).abs() < 1e-10, "Expected 8.0, got {}", ratio);
827    }
828
829    #[test]
830    fn test_subarray_validation() {
831        assert!(validate_subarray_config(
832            4,
833            4,
834            &SubarrayConfig {
835                spatial_subarrays: 0,
836                temporal_taps: 2,
837                overlap: 0,
838            }
839        )
840        .is_err());
841
842        assert!(validate_subarray_config(
843            4,
844            4,
845            &SubarrayConfig {
846                spatial_subarrays: 2,
847                temporal_taps: 0,
848                overlap: 0,
849            }
850        )
851        .is_err());
852
853        assert!(validate_subarray_config(
854            4,
855            4,
856            &SubarrayConfig {
857                spatial_subarrays: 5,
858                temporal_taps: 2,
859                overlap: 0,
860            }
861        )
862        .is_err());
863    }
864
865    #[test]
866    fn test_factored_stap_validation() {
867        let config = STAPConfig {
868            n_elements: 4,
869            n_pulses: 4,
870            ..STAPConfig::default()
871        };
872
873        // Empty training data
874        let data = vec![vec![Complex64::new(1.0, 0.0); 4]; 4];
875        let empty_training: Vec<Vec<Vec<Complex64>>> = vec![];
876        assert!(factored_stap(&data, &empty_training, 0.0, 0.0, &config).is_err());
877
878        // Wrong data dimensions
879        let wrong_data = vec![vec![Complex64::new(1.0, 0.0); 4]; 2]; // 2 elements, expected 4
880        let training = make_training_data(4, 4, 10);
881        assert!(factored_stap(&wrong_data, &training, 0.0, 0.0, &config).is_err());
882    }
883}