Skip to main content

secs_engine/mimo/
design.rs

1//! Port of SECS 2 `src/secs/mimo.py` (GPL-3.0), support ranges and guards.
2//! Normal equations exploit the Toeplitz blocks without allocating A.
3
4use secs_dsp::{
5    fft,
6    fir::firwin,
7    iir::{BType, Sos, butter, sosfilt},
8    interp, pairwise_sum,
9    response::hp2,
10    smooth::{Fraction, smooth_power},
11};
12
13use crate::{
14    C, Design, EngineError, Note, Spectra,
15    decay::{self, onset},
16    grid::design_freqs,
17    measurement::delay_response,
18};
19
20const FS_LOW: f64 = 600.0;
21const EARLY: usize = 36;
22
23use super::solve::solve;
24use crate::{EngineConfig, MeasurementSet};
25#[cfg(test)]
26use secs_compute::MapError;
27use secs_compute::{Context, MemoryError};
28
29#[derive(Debug, Clone, PartialEq)]
30pub struct MimoRequest {
31    pub fsiso: f64,
32    pub level_db: f64,
33    pub f_low: Option<f64>,
34    pub max_supports: Option<usize>,
35    pub exclude: Vec<String>,
36    pub groups: Option<Vec<String>>,
37    pub taps_low: usize,
38    pub seat_guard: bool,
39}
40
41impl Default for MimoRequest {
42    fn default() -> Self {
43        Self {
44            fsiso: 150.0,
45            level_db: -12.0,
46            f_low: None,
47            max_supports: None,
48            exclude: Vec::new(),
49            groups: None,
50            taps_low: 180,
51            seat_guard: false,
52        }
53    }
54}
55
56pub type SupportRanges = Vec<(String, Option<(f64, f64)>)>;
57pub type InputSupports = Vec<(String, Vec<(String, Vec<f64>)>)>;
58
59#[derive(Debug, Clone, PartialEq)]
60pub struct MimoDesign {
61    pub fs_low: f64,
62    pub ranges: SupportRanges,
63    pub supports: InputSupports,
64    pub level_db: Vec<(String, f64)>,
65    pub notes: Vec<Note>,
66}
67
68impl Default for MimoDesign {
69    fn default() -> Self {
70        Self {
71            fs_low: FS_LOW,
72            ranges: Vec::new(),
73            supports: Vec::new(),
74            level_db: Vec::new(),
75            notes: Vec::new(),
76        }
77    }
78}
79
80pub fn lowpass_fir(fc: f64, taps: usize) -> Result<Vec<f64>, EngineError> {
81    Ok(firwin(taps, fc, FS_LOW)?)
82}
83
84fn geomspace(start: f64, end: f64) -> Vec<f64> {
85    let step = (end.log10() - start.log10()) / 199.0;
86    let mut values: Vec<_> = (0..200)
87        .map(|k| 10f64.powf(start.log10() + k as f64 * step))
88        .collect();
89    values[0] = start;
90    values[199] = end;
91    values
92}
93
94/// Position-mean one-third-octave support ranges, in main then sub order.
95/// Long spectra must already include the sub's fixed and bundle filters.
96pub fn support_ranges(
97    design: &Design,
98    spectra: &Spectra,
99    fsiso: f64,
100    positions: &[u32],
101) -> Result<SupportRanges, EngineError> {
102    if positions.is_empty() || !fsiso.is_finite() || fsiso <= 0.0 {
103        return Err(EngineError::Input("invalid MIMO range inputs".into()));
104    }
105    let f = design_freqs();
106    let mut out = Vec::new();
107    for (role, sub) in design
108        .mains
109        .iter()
110        .map(|m| (m.role.as_str(), None))
111        .chain(design.chain.subs.iter().map(|s| (s.role.as_str(), Some(s))))
112    {
113        let Some(paths): Option<Vec<_>> = positions.iter().map(|&p| spectra.get(p, role)).collect()
114        else {
115            continue;
116        };
117        let mut power = vec![0.0; f.len()];
118        for h in paths {
119            if h.len() != f.len() {
120                return Err(EngineError::Input("invalid MIMO spectrum length".into()));
121            }
122            for (sum, h) in power.iter_mut().zip(h.iter()) {
123                let a = h.re.hypot(h.im);
124                *sum += a * a;
125            }
126        }
127        for p in &mut power {
128            *p /= positions.len() as f64;
129        }
130        let db: Vec<_> = smooth_power(f, &power, Fraction::Scalar(3.0))?
131            .iter()
132            .map(|p| 10.0 * (p + 1e-30).log10())
133            .collect();
134        let (lo_ref, hi_ref) = if sub.is_some() {
135            (30.0, 80.0)
136        } else {
137            (200.0, 1000.0)
138        };
139        let mut reference = decay::median(
140            &f.iter()
141                .zip(&db)
142                .filter(|(f, _)| (lo_ref..=hi_ref).contains(*f))
143                .map(|(_, &v)| v)
144                .collect::<Vec<_>>(),
145        );
146        let mut start = if sub.is_some() {
147            50.0
148        } else {
149            fsiso.min(200.0)
150        };
151        if let Some(s) = sub.filter(|s| s.hp_hz != 0.0 || s.lp_hz != 0.0) {
152            let lo = s.hp_hz.max(15.0);
153            let hi = (if s.lp_hz != 0.0 {
154                s.lp_hz.min(80.0)
155            } else {
156                80.0
157            })
158            .max(lo * 1.5);
159            let indices: Vec<_> = f
160                .iter()
161                .enumerate()
162                .filter(|(_, f)| (lo..=hi).contains(f))
163                .map(|(k, _)| k)
164                .collect();
165            let values: Vec<_> = indices.iter().map(|&k| db[k]).collect();
166            let k = indices[decay::first_max(&values)];
167            reference = db[k];
168            start = f[k];
169        }
170        let low = geomspace(start, 15.0)
171            .into_iter()
172            .find(|&v| interp(v, f, &db) < reference - 10.0)
173            .unwrap_or(15.0)
174            .max(if sub.is_some() { 20.0 } else { 50.0 });
175        let high = geomspace(start, fsiso)
176            .into_iter()
177            .find(|&v| interp(v, f, &db) < reference - 10.0)
178            .unwrap_or(fsiso);
179        out.push((
180            role.to_owned(),
181            (low < high.min(fsiso) * 0.8).then(|| {
182                (
183                    (low * 10.0).round_ties_even() / 10.0,
184                    (high * 10.0).round_ties_even() / 10.0,
185                )
186            }),
187        ));
188    }
189    Ok(out)
190}
191
192/// Arrival-aligned acoustic path with the fourth-order lower-edge protection.
193pub fn support_path(
194    mimo: &MimoDesign,
195    role: &str,
196    spectra: &Spectra,
197    position: u32,
198    align_delay_ms: &[(String, f64)],
199) -> Result<Vec<C>, EngineError> {
200    let h = spectra
201        .get(position, role)
202        .ok_or_else(|| EngineError::Input(format!("missing MIMO path {position}/{role}")))?;
203    let delay = align_delay_ms
204        .iter()
205        .find(|(r, _)| r == role)
206        .map_or(0.0, |(_, ms)| *ms);
207    let low = mimo
208        .ranges
209        .iter()
210        .find(|(r, _)| r == role)
211        .and_then(|(_, r)| *r)
212        .map_or(50.0, |r| r.0);
213    Ok(h.iter()
214        .zip(delay_response(delay))
215        .zip(hp2(design_freqs(), low))
216        .map(|((&h, d), hp)| h * d * (hp * hp))
217        .collect())
218}
219
220pub(crate) fn convolve(a: &[f64], b: &[f64]) -> Vec<f64> {
221    if a.is_empty() || b.is_empty() {
222        return Vec::new();
223    }
224    // NumPy's convolve swaps the operands when the second is longer, then
225    // accumulates the first operand in increasing index order for each output.
226    let (a, b) = if a.len() < b.len() { (b, a) } else { (a, b) };
227    (0..a.len() + b.len() - 1)
228        .map(|t| {
229            let mut sum = 0.0;
230            for k in t.saturating_sub(b.len() - 1)..=(t.min(a.len() - 1)) {
231                sum += a[k] * b[t - k];
232            }
233            sum
234        })
235        .collect()
236}
237
238/// One block pair `(j, jj)` of the weighted normal equations, summed over
239/// positions in order, and the right-hand side of `j` when `j == jj`.
240/// The first row/column are direct time sums; the diagonal recurrence handles
241/// the truncated last sample and the weight step at `onset + 36`.
242fn block_pair(
243    xs: &[Vec<f64>],
244    paths: &[Vec<Vec<f64>>],
245    (j, jj): (usize, usize),
246    taps: usize,
247    early_weight: f64,
248) -> (Vec<f64>, Vec<f64>) {
249    let mut total = vec![0.0; taps * taps];
250    let mut rhs = vec![0.0; if j == jj { taps } else { 0 }];
251    for (x, gs) in xs.iter().zip(paths) {
252        let n = x.len();
253        let e = (onset(x) + EARLY).min(n);
254        let c = |t: usize| if t < e { early_weight } else { 1.0 };
255        let (g, h) = (&gs[j], &gs[jj]);
256        for (k, b) in rhs.iter_mut().enumerate() {
257            for t in e.max(k)..n {
258                *b -= x[t] * g[t - k];
259            }
260        }
261        let mut block = vec![0.0; taps * taps];
262        for k in 0..taps {
263            for t in k..n {
264                block[k * taps] += c(t) * g[t - k] * h[t];
265            }
266        }
267        for l in 1..taps {
268            for t in l..n {
269                block[l] += c(t) * g[t] * h[t - l];
270            }
271        }
272        for k in 0..taps - 1 {
273            for l in 0..taps - 1 {
274                let mut v = block[k * taps + l] - c(n - 1) * g[n - 1 - k] * h[n - 1 - l];
275                if e < n && e > k.max(l) {
276                    v += (1.0 - early_weight) * g[e - 1 - k] * h[e - 1 - l];
277                }
278                block[(k + 1) * taps + l + 1] = v;
279            }
280        }
281        for (sum, v) in total.iter_mut().zip(block) {
282            *sum += v;
283        }
284    }
285    (total, rhs)
286}
287
288/// A block pair's index and its `(block, rhs)`.
289type PairBlock = ((usize, usize), (Vec<f64>, Vec<f64>));
290
291fn block_pairs(candidates: usize) -> Vec<(usize, usize)> {
292    (0..candidates)
293        .flat_map(|j| (j..candidates).map(move |jj| (j, jj)))
294        .collect()
295}
296
297/// Place the upper-triangle blocks; AᵀA is symmetric, and the opposite
298/// triangle reuses the same value instead of a second accumulation.
299fn place(blocks: Vec<PairBlock>, ata: &mut [f64], atb: &mut [f64], taps: usize) {
300    let dim = atb.len();
301    for ((j, jj), (block, rhs)) in blocks {
302        if j == jj {
303            atb[j * taps..(j + 1) * taps].copy_from_slice(&rhs);
304        }
305        for k in 0..taps {
306            for l in 0..taps {
307                if j != jj || k <= l {
308                    let (row, col) = (j * taps + k, jj * taps + l);
309                    ata[row * dim + col] = block[k * taps + l];
310                    ata[col * dim + row] = block[k * taps + l];
311                }
312            }
313        }
314    }
315}
316
317/// Weighted normal equations, position-major paths, row-major output.
318/// `early_weight=10` is the design system, `0` is candidate ranking.
319pub(crate) fn normal_equations(
320    xs: &[Vec<f64>],
321    paths: &[Vec<Vec<f64>>],
322    taps: usize,
323    early_weight: f64,
324) -> (Vec<f64>, Vec<f64>) {
325    let candidates = paths[0].len();
326    let dim = candidates * taps;
327    let (mut ata, mut atb) = (vec![0.0; dim * dim], vec![0.0; dim]);
328    for pair in block_pairs(candidates) {
329        let block = block_pair(xs, paths, pair, taps, early_weight);
330        place(vec![(pair, block)], &mut ata, &mut atb, taps);
331    }
332    (ata, atb)
333}
334
335/// [`normal_equations`] with the block pairs as a map over `ctx`. Each block
336/// keeps the position order of its sums, and placement adds nothing, so the
337/// result has the same bits for any thread count.
338pub(crate) fn normal_equations_mapped(
339    xs: &[Vec<f64>],
340    paths: &[Vec<Vec<f64>>],
341    taps: usize,
342    early_weight: f64,
343    ctx: &Context,
344) -> Result<(Vec<f64>, Vec<f64>), EngineError> {
345    check(ctx)?;
346    let candidates = paths[0].len();
347    let dim = candidates * taps;
348    let (mut ata, mut atb) = (vec![0.0; dim * dim], vec![0.0; dim]);
349    // Retain at most eight completed blocks alongside AᵀA. Each block keeps
350    // its original position/time sums; placement only copies disjoint entries.
351    for pairs in block_pairs(candidates).chunks(8) {
352        let blocks = ctx
353            .map(pairs.to_vec(), |pair| {
354                (pair, block_pair(xs, paths, pair, taps, early_weight))
355            })
356            .map_err(|_| EngineError::Cancelled)?;
357        place(blocks, &mut ata, &mut atb, taps);
358    }
359    Ok((ata, atb))
360}
361
362// Retain the direct form as an independent guard oracle and diagnostic entry.
363#[cfg(any(test, feature = "test-support"))]
364pub(crate) fn worst_band_db(
365    xs: &[Vec<f64>],
366    paths: &[Vec<Vec<f64>>],
367    supports: &[Vec<f64>],
368    fsiso: f64,
369) -> f64 {
370    let n = xs[0].len();
371    let nf = 2 * n;
372    let bins = secs_dsp::grid::freqs(nf, FS_LOW).expect("MIMO grid");
373    let mut bands = third_octave_bands(fsiso);
374    let refs: Vec<_> = xs.iter().map(Vec::as_slice).collect();
375    bands.extend(
376        decay::late_peaks(&refs, 6)
377            .iter()
378            .map(|&f| (f - 1.5, f + 1.5)),
379    );
380    let mut before = vec![0.0; bands.len()];
381    let mut after = before.clone();
382    for (x, gs) in xs.iter().zip(paths) {
383        let mut add = vec![0.0; n];
384        for (g, s) in gs.iter().zip(supports) {
385            for (a, v) in add.iter_mut().zip(convolve(g, s)) {
386                *a += v;
387            }
388        }
389        let e = (onset(x) + EARLY).min(n);
390        for (acc, addition) in [(&mut before, false), (&mut after, true)] {
391            let mut y = vec![0.0; nf];
392            for t in e..n {
393                y[t] = x[t] + if addition { add[t] } else { 0.0 };
394            }
395            let spectrum: Vec<_> = fft::rfft(&y)
396                .iter()
397                .map(|h| {
398                    let a = h.re.hypot(h.im);
399                    a * a
400                })
401                .collect();
402            for (total, &(lo, hi)) in acc.iter_mut().zip(&bands) {
403                let begin = bins.partition_point(|&f| f < lo);
404                let end = bins.partition_point(|&f| f < hi);
405                *total += pairwise_sum(&spectrum[begin..end]);
406            }
407        }
408    }
409    before
410        .iter()
411        .zip(after)
412        .filter(|(a, _)| **a > 0.0)
413        .map(|(a, b)| 10.0 * ((b + 1e-30) / a).log10())
414        .reduce(f64::max)
415        .unwrap_or(-99.0)
416}
417
418/// The guards' third-octave bands from 20 Hz while the upper edge stays below
419/// `fsiso`, as `(lo, hi)` in Hz (TD-75: the CPU guard, its direct-form oracle
420/// and the IIR guard share them).
421pub(crate) fn third_octave_bands(fsiso: f64) -> Vec<(f64, f64)> {
422    let stop = (fsiso / 20.0).log2() + 1e-9;
423    let edges: Vec<_> = (0..)
424        .map(|k| k as f64 / 3.0)
425        .take_while(|&v| v < stop)
426        .map(|v| 20.0 * 2f64.powf(v))
427        .collect();
428    edges.windows(2).map(|v| (v[0], v[1])).collect()
429}
430
431#[derive(Debug)]
432pub(crate) struct BandGuard {
433    n: usize,
434    starts: Vec<usize>,
435    hz: Vec<(f64, f64)>,
436    bands: Vec<(usize, usize)>,
437    before: Vec<f64>,
438    before_by_position: Option<Vec<Vec<f64>>>,
439}
440
441impl BandGuard {
442    pub(crate) fn new(xs: &[Vec<f64>], fsiso: f64, seat_guard: bool) -> Self {
443        let n = xs[0].len();
444        let bins = secs_dsp::grid::freqs(2 * n, FS_LOW).expect("MIMO grid");
445        let refs: Vec<_> = xs.iter().map(Vec::as_slice).collect();
446        let hz: Vec<_> = third_octave_bands(fsiso)
447            .into_iter()
448            .chain(
449                decay::late_peaks(&refs, 6)
450                    .into_iter()
451                    .map(|f| (f - 1.5, f + 1.5)),
452            )
453            .collect();
454        let bands = hz
455            .iter()
456            .map(|&(lo, hi)| {
457                (
458                    bins.partition_point(|&f| f < lo),
459                    bins.partition_point(|&f| f < hi),
460                )
461            })
462            .collect();
463        let mut guard = Self {
464            n,
465            starts: xs.iter().map(|x| (onset(x) + EARLY).min(n)).collect(),
466            hz,
467            bands,
468            before: Vec::new(),
469            before_by_position: None,
470        };
471        if seat_guard {
472            guard.before_by_position = Some(guard.position_energies(xs, true));
473        } else {
474            guard.before = guard.energies(xs, true);
475        }
476        guard
477    }
478
479    fn spectrum(&self, response: &[f64], start: usize, baseline: bool) -> Vec<f64> {
480        let mut window = vec![0.0; 2 * self.n];
481        for t in start..self.n {
482            // Preserve the original baseline's x + 0.0, including -0.0.
483            window[t] = if baseline {
484                response[t] + 0.0
485            } else {
486                response[t]
487            };
488        }
489        fft::rfft(&window)
490            .iter()
491            .map(|h| {
492                let a = h.re.hypot(h.im);
493                a * a
494            })
495            .collect()
496    }
497
498    fn energies(&self, responses: &[Vec<f64>], baseline: bool) -> Vec<f64> {
499        let mut totals = vec![0.0; self.bands.len()];
500        for (response, &start) in responses.iter().zip(&self.starts) {
501            let spectrum = self.spectrum(response, start, baseline);
502            for (total, &(begin, end)) in totals.iter_mut().zip(&self.bands) {
503                *total += pairwise_sum(&spectrum[begin..end]);
504            }
505        }
506        totals
507    }
508
509    fn position_energies(&self, responses: &[Vec<f64>], baseline: bool) -> Vec<Vec<f64>> {
510        responses
511            .iter()
512            .zip(&self.starts)
513            .map(|(response, &start)| {
514                let spectrum = self.spectrum(response, start, baseline);
515                self.bands
516                    .iter()
517                    .map(|&(begin, end)| pairwise_sum(&spectrum[begin..end]))
518                    .collect()
519            })
520            .collect()
521    }
522
523    pub(crate) fn evaluate(&self, ys: &[Vec<f64>]) -> f64 {
524        self.evaluate_band(ys).0
525    }
526
527    /// The worst late-energy change, its band in Hz, and measured-position index.
528    pub(crate) fn evaluate_band(
529        &self,
530        ys: &[Vec<f64>],
531    ) -> (f64, Option<(f64, f64)>, Option<usize>) {
532        if let Some(before) = &self.before_by_position {
533            let after = self.position_energies(ys, false);
534            let mut worst = -99.0;
535            let mut band = None;
536            let mut position = None;
537            for (p, (before, after)) in before.iter().zip(after).enumerate() {
538                for (k, (&a, b)) in before.iter().zip(after).enumerate() {
539                    let Some(change) = seat_change(a, b) else {
540                        continue;
541                    };
542                    if change > worst {
543                        worst = change;
544                        band = Some(self.hz[k]);
545                        position = Some(p);
546                    }
547                }
548            }
549            return (worst, band, position);
550        }
551
552        let changes: Vec<_> = self
553            .before
554            .iter()
555            .zip(self.energies(ys, false))
556            .map(|(a, b)| (*a > 0.0).then(|| 10.0 * ((b + 1e-30) / a).log10()))
557            .collect();
558        let worst = changes
559            .iter()
560            .flatten()
561            .copied()
562            .reduce(f64::max)
563            .unwrap_or(-99.0);
564        let band = changes
565            .iter()
566            .position(|change| *change == Some(worst))
567            .map(|k| self.hz[k]);
568        (worst, band, None)
569    }
570}
571
572/// Absolute modal-tail change, in the upstream 0.15 seconds-per-dB scale.
573/// Despite its name this guard does not compare fitted T60 slopes.
574pub fn worst_t60_change(
575    xs: &[Vec<f64>],
576    ys: &[Vec<f64>],
577    count: usize,
578) -> Result<f64, EngineError> {
579    T60Guard::new(xs, count, false).evaluate(ys)
580}
581
582#[derive(Debug)]
583struct TailBaseline {
584    frequency: f64,
585    positions: usize,
586    sos: Sos,
587    energy: f64,
588    energy_by_position: Option<Vec<f64>>,
589}
590
591/// Fixed original modal peaks and mode-specific tails, shared across guard
592/// trials. Each trial still discovers its own new peaks before taking the
593/// union, so reducing the support cannot hide a newly exposed mode.
594#[derive(Debug)]
595pub(crate) struct T60Guard<'a> {
596    xs: &'a [Vec<f64>],
597    count: usize,
598    original_modes: Vec<f64>,
599    starts: Vec<usize>,
600    baselines: Vec<TailBaseline>,
601    seat_guard: bool,
602}
603
604impl<'a> T60Guard<'a> {
605    pub(crate) fn new(xs: &'a [Vec<f64>], count: usize, seat_guard: bool) -> Self {
606        Self {
607            xs,
608            count,
609            original_modes: decay::late_peaks(
610                &xs.iter().map(Vec::as_slice).collect::<Vec<_>>(),
611                count,
612            ),
613            starts: xs.iter().map(|x| onset(x) + 180).collect(),
614            baselines: Vec::new(),
615            seat_guard,
616        }
617    }
618
619    pub(crate) fn evaluate(&mut self, ys: &[Vec<f64>]) -> Result<f64, EngineError> {
620        Ok(self.evaluate_mode(ys)?.0)
621    }
622
623    /// The worst tail change, its mode in Hz, and measured-position index.
624    pub(crate) fn evaluate_mode(
625        &mut self,
626        ys: &[Vec<f64>],
627    ) -> Result<(f64, Option<f64>, Option<usize>), EngineError> {
628        let mut modes: Vec<_> = self
629            .original_modes
630            .iter()
631            .copied()
632            .chain(decay::late_peaks(
633                &ys.iter().map(Vec::as_slice).collect::<Vec<_>>(),
634                self.count,
635            ))
636            .map(|f| (f * 10.0).round_ties_even() / 10.0)
637            .collect();
638        modes.sort_by(f64::total_cmp);
639        modes.dedup();
640        let positions = self.xs.len().min(ys.len());
641        let mut tails = Vec::new();
642        for fm in modes {
643            let index = self
644                .baselines
645                .iter()
646                .position(|entry| entry.frequency == fm && entry.positions == positions);
647            let index = if let Some(index) = index {
648                index
649            } else {
650                let sos = butter(2, &[(fm - 1.5).max(1.0), fm + 1.5], BType::Bandpass, FS_LOW)?;
651                let mut energy = 0.0;
652                let mut energy_by_position = self.seat_guard.then(Vec::new);
653                for (x, &t) in self.xs.iter().zip(&self.starts).take(positions) {
654                    let xf = sosfilt(&sos, x)?;
655                    let value = decay::energy(&xf[t.min(xf.len())..]);
656                    energy += value;
657                    if let Some(values) = &mut energy_by_position {
658                        values.push(value);
659                    }
660                }
661                self.baselines.push(TailBaseline {
662                    frequency: fm,
663                    positions,
664                    sos,
665                    energy,
666                    energy_by_position,
667                });
668                self.baselines.len() - 1
669            };
670            let baseline = &self.baselines[index];
671            if let Some(before) = &baseline.energy_by_position {
672                let mut change = -99.0;
673                let mut position = None;
674                for (p, ((y, &t), &a)) in ys
675                    .iter()
676                    .zip(&self.starts)
677                    .take(positions)
678                    .zip(before)
679                    .enumerate()
680                {
681                    let yf = sosfilt(&baseline.sos, y)?;
682                    let b = decay::energy(&yf[t.min(yf.len())..]);
683                    let Some(candidate) = seat_change(a, b) else {
684                        continue;
685                    };
686                    if candidate > change {
687                        change = candidate;
688                        position = Some(p);
689                    }
690                }
691                tails.push((fm, baseline.energy, change, position));
692            } else {
693                let mut after = 0.0;
694                for (y, &t) in ys.iter().zip(&self.starts).take(positions) {
695                    let yf = sosfilt(&baseline.sos, y)?;
696                    after += decay::energy(&yf[t.min(yf.len())..]);
697                }
698                let change = if baseline.energy > 0.0 {
699                    10.0 * ((after + 1e-30) / baseline.energy).log10()
700                } else {
701                    -99.0
702                };
703                tails.push((fm, baseline.energy, change, None));
704            }
705        }
706        let top = tails
707            .iter()
708            .map(|&(_, energy, _, _)| energy)
709            .fold(0.0, f64::max);
710        let mut worst: f64 = -99.0;
711        let mut mode = None;
712        let mut position = None;
713        for (fm, energy, change, candidate_position) in tails {
714            if energy > 0.0 && energy >= top * 10f64.powf(-20.0 / 10.0) && change > worst {
715                worst = change;
716                mode = Some(fm);
717                position = candidate_position;
718            }
719        }
720        Ok((worst * 0.15, mode, position))
721    }
722}
723
724#[cfg(test)]
725pub(crate) fn worst_t60_change_before(
726    xs: &[Vec<f64>],
727    ys: &[Vec<f64>],
728    count: usize,
729) -> Result<f64, EngineError> {
730    let refs =
731        |v: &[Vec<f64>]| decay::late_peaks(&v.iter().map(Vec::as_slice).collect::<Vec<_>>(), count);
732    let mut modes: Vec<_> = refs(xs)
733        .into_iter()
734        .chain(refs(ys))
735        .map(|f| (f * 10.0).round_ties_even() / 10.0)
736        .collect();
737    modes.sort_by(f64::total_cmp);
738    modes.dedup();
739    let mut tails = Vec::new();
740    for fm in modes {
741        let sos = butter(2, &[(fm - 1.5).max(1.0), fm + 1.5], BType::Bandpass, FS_LOW)?;
742        let (mut a, mut b) = (0.0, 0.0);
743        for (x, y) in xs.iter().zip(ys) {
744            let t = onset(x) + 180;
745            let xf = sosfilt(&sos, x)?;
746            let yf = sosfilt(&sos, y)?;
747            a += decay::energy(&xf[t.min(xf.len())..]);
748            b += decay::energy(&yf[t.min(yf.len())..]);
749        }
750        tails.push((a, b));
751    }
752    let top = tails.iter().map(|&(a, _)| a).fold(0.0, f64::max);
753    let mut worst: f64 = -99.0;
754    for (a, b) in tails {
755        if a > 0.0 && a >= top * 10f64.powf(-20.0 / 10.0) {
756            worst = worst.max(10.0 * ((b + 1e-30) / a).log10());
757        }
758    }
759    Ok(worst * 0.15)
760}
761
762#[derive(Debug, Clone, PartialEq)]
763pub(crate) struct LambdaStep {
764    pub mid: f64,
765    pub ratio: f64,
766    pub accepted: bool,
767}
768#[derive(Debug, Clone, PartialEq)]
769pub(crate) struct GuardStep {
770    pub scale: f64,
771    pub band_db: f64,
772    pub t60_change: f64,
773    /// Where `band_db` and `t60_change` were found (`BandGuard::evaluate_band`,
774    /// `T60Guard::evaluate_mode`).
775    pub band_hz: Option<(f64, f64)>,
776    pub mode_hz: Option<f64>,
777    pub band_position: Option<usize>,
778    pub tail_position: Option<usize>,
779}
780
781impl GuardStep {
782    fn passes(&self) -> bool {
783        self.band_db <= 1.0 && self.t60_change <= 0.15
784    }
785}
786
787/// The last rejected guard step as a note's cause; `None` when every step
788/// One position's energy change in dB for the seat guard (ADR 0036). Energy
789/// that appears where the baseline had none is a violation, not a skipped
790/// position; only a position with no energy before and after says nothing.
791pub(crate) fn seat_change(before: f64, after: f64) -> Option<f64> {
792    if before > 0.0 {
793        // The same floor on both sides: an unchanged tiny energy is 0 dB.
794        Some(10.0 * ((after + 1e-30) / (before + 1e-30)).log10())
795    } else if after > 0.0 {
796        Some(SEAT_VIOLATION_DB)
797    } else {
798        None
799    }
800}
801
802/// The change reported when a position gains energy it had none of.
803pub(crate) const SEAT_VIOLATION_DB: f64 = 99.0;
804
805/// passed (no reduction).
806pub(crate) fn guard_cause(
807    guards: &[GuardStep],
808    positions: &[u32],
809) -> Option<crate::notes::GuardCause> {
810    let step = guards.iter().rev().find(|step| !step.passes())?;
811    let at = |index: Option<usize>| index.and_then(|index| positions.get(index).copied());
812    Some(crate::notes::GuardCause {
813        rejected_db: 20.0 * step.scale.max(1e-9).log10(),
814        band_position: (step.band_db > 1.0)
815            .then(|| at(step.band_position))
816            .flatten(),
817        tail_position: (step.t60_change > 0.15)
818            .then(|| at(step.tail_position))
819            .flatten(),
820        band: (step.band_db > 1.0)
821            .then_some(step.band_hz)
822            .flatten()
823            .map(|(lo, hi)| (lo, hi, step.band_db)),
824        tail: (step.t60_change > 0.15)
825            .then_some(step.mode_hz)
826            .flatten()
827            .map(|hz| (hz, step.t60_change / 0.15)),
828    })
829}
830#[derive(Debug, Clone, PartialEq)]
831pub(crate) struct InputTrace {
832    pub candidates: Vec<String>,
833    pub ranking: Vec<(String, f64)>,
834    pub trajectory: Vec<LambdaStep>,
835    pub log_lambda: f64,
836    pub ratio: f64,
837    pub guards: Vec<GuardStep>,
838    pub guard_scale: f64,
839}
840#[derive(Debug)]
841pub(crate) struct InputResult {
842    pub supports: Vec<(String, Vec<f64>)>,
843    pub level_db: f64,
844    pub trace: InputTrace,
845}
846
847fn check(ctx: &Context) -> Result<(), EngineError> {
848    ctx.cancel().check().map_err(|_| EngineError::Cancelled)
849}
850
851pub(crate) fn rank_supports(
852    candidates: &[String],
853    xs: &[Vec<f64>],
854    paths: &[Vec<Vec<f64>>],
855    taps: usize,
856    ctx: &Context,
857) -> Result<Vec<(String, f64)>, EngineError> {
858    let late_input: Vec<_> = xs
859        .iter()
860        .flat_map(|x| {
861            let e = onset(x) + EARLY;
862            x.iter()
863                .enumerate()
864                .map(move |(t, &x)| if t >= e { x } else { 0.0 })
865        })
866        .collect();
867    let input_energy = decay::energy(&late_input);
868    let work = xs.iter().map(Vec::len).sum::<usize>() + 3 * taps;
869    let mut scored = Vec::new();
870    for (j, role) in candidates.iter().enumerate() {
871        check(ctx)?;
872        let candidate_paths: Vec<_> = paths.iter().map(|g| vec![g[j].clone()]).collect();
873        let (ata, atb) = normal_equations(xs, &candidate_paths, taps, 0.0);
874        let diagonal: Vec<_> = (0..taps).map(|k| ata[k * taps + k]).collect();
875        let lambda = 1e-2 * pairwise_sum(&diagonal) / taps as f64;
876        let u = solve(&ata, &atb, lambda, false)?;
877        let path_energy = candidate_paths.iter().map(|gs| decay::energy(&gs[0])).sum();
878        let (score, uncertainty) =
879            rank_score(input_energy, path_energy, &ata, &atb, &u, lambda, work);
880        // Cancellation and nonfinite arithmetic must not make a poor path
881        // look perfect. Keep the original residual evaluation in that case.
882        let direct = !score.is_finite() || !uncertainty.is_finite() || score <= 8.0 * uncertainty;
883        scored.push((
884            role.clone(),
885            if direct {
886                rank_score_direct(xs, paths, j, &u)
887            } else {
888                score
889            },
890            if direct { 0.0 } else { uncertainty },
891            u,
892        ));
893    }
894    // A tiny scalar roundoff must not change the discrete support ordering.
895    // Recompute both members of every overlapping score interval, before
896    // the stable sort, in the original time-domain accumulation order.
897    let mut direct = vec![false; scored.len()];
898    for i in 0..scored.len() {
899        for j in i + 1..scored.len() {
900            if (scored[i].1 - scored[j].1).abs() <= scored[i].2 + scored[j].2 {
901                direct[i] = true;
902                direct[j] = true;
903            }
904        }
905    }
906    let mut scores = Vec::with_capacity(scored.len());
907    for (j, ((role, score, _, u), direct)) in scored.into_iter().zip(direct).enumerate() {
908        check(ctx)?;
909        scores.push((
910            role,
911            if direct {
912                rank_score_direct(xs, paths, j, &u)
913            } else {
914                score
915            },
916        ));
917    }
918    scores.sort_by(|a, b| a.1.total_cmp(&b.1));
919    Ok(scores)
920}
921
922fn rank_score(
923    input_energy: f64,
924    path_energy: f64,
925    ata: &[f64],
926    atb: &[f64],
927    u: &[f64],
928    lambda: f64,
929    work: usize,
930) -> (f64, f64) {
931    let products: Vec<_> = u.iter().zip(atb).map(|(u, b)| u * b).collect();
932    let ub = pairwise_sum(&products);
933    let regularization = lambda * decay::energy(u);
934    let u_l1 = u.iter().map(|v| v.abs()).sum::<f64>();
935    // Full path energy also bounds cancellation in the Gram's sliding
936    // recurrence when the retained late-window block itself is tiny.
937    let mut magnitude = input_energy
938        + 2.0 * products.iter().map(|v| v.abs()).sum::<f64>()
939        + regularization.abs()
940        + path_energy * u_l1 * u_l1;
941    let mut row_products = vec![0.0; u.len()];
942    let mut corrections = Vec::with_capacity(u.len());
943    for (i, row) in ata.chunks_exact(u.len()).enumerate() {
944        for ((v, a), u) in row_products.iter_mut().zip(row).zip(u) {
945            *v = a * u;
946        }
947        let residual = (pairwise_sum(&row_products) + lambda * u[i]) - atb[i];
948        corrections.push(u[i] * residual);
949        magnitude += u[i].abs() * row_products.iter().map(|v| v.abs()).sum::<f64>();
950    }
951    // b = -Aᵀx, M = AᵀA. For a finite-precision solve the exact identity is
952    // ||Au+x||² = xᵀx - uᵀb - λuᵀu + uᵀ((M+λI)u-b).
953    // The O(taps²) residual correction is cheaper than the old O(P*N*taps)
954    // convolutions and does not assume the solver residual is exactly zero.
955    let score = ((input_energy - ub) - regularization) + pairwise_sum(&corrections);
956    // This deliberately conservative reevaluation trigger scales with Gram
957    // recurrences and dot products. It is not a certified error bound or a
958    // relaxation of any result-validation tolerance.
959    let uncertainty = 64.0 * f64::EPSILON * work.max(1) as f64 * magnitude;
960    (score, uncertainty)
961}
962
963pub(crate) fn rank_score_direct(
964    xs: &[Vec<f64>],
965    paths: &[Vec<Vec<f64>>],
966    candidate: usize,
967    u: &[f64],
968) -> f64 {
969    let mut residual = Vec::new();
970    for (x, gs) in xs.iter().zip(paths) {
971        let y = convolve(&gs[candidate], u);
972        let e = onset(x) + EARLY;
973        residual.extend(
974            x.iter()
975                .enumerate()
976                .map(|(t, &x)| if t >= e { y[t] + x } else { 0.0 }),
977        );
978    }
979    decay::energy(&residual)
980}
981
982/// The low-rate stage boundary used by the oracle: paths have lower-edge
983/// protection and arrival alignment, but not the common low-pass FIR yet.
984pub(crate) fn design_input(
985    xs: &[Vec<f64>],
986    raw_paths: &[Vec<Vec<f64>>],
987    direct: &[f64],
988    candidates: &[String],
989    req: &MimoRequest,
990    ctx: &Context,
991) -> Result<InputResult, EngineError> {
992    design_input_observed(xs, raw_paths, direct, candidates, req, ctx, &|_, _| {})
993}
994
995pub(crate) fn design_input_observed(
996    xs: &[Vec<f64>],
997    raw_paths: &[Vec<Vec<f64>>],
998    direct: &[f64],
999    candidates: &[String],
1000    req: &MimoRequest,
1001    ctx: &Context,
1002    observe: &dyn Fn(&str, std::time::Duration),
1003) -> Result<InputResult, EngineError> {
1004    design_input_accelerated(xs, raw_paths, direct, candidates, req, ctx, observe, None)
1005}
1006
1007#[allow(clippy::too_many_arguments)]
1008fn design_input_accelerated(
1009    xs: &[Vec<f64>],
1010    raw_paths: &[Vec<Vec<f64>>],
1011    direct: &[f64],
1012    candidates: &[String],
1013    req: &MimoRequest,
1014    ctx: &Context,
1015    observe: &dyn Fn(&str, std::time::Duration),
1016    acceleration: Option<&super::acceleration::RunAcceleration<'_>>,
1017) -> Result<InputResult, EngineError> {
1018    let started = std::time::Instant::now();
1019    let taps = req.taps_low;
1020    let lp = lowpass_fir(req.fsiso, 31)?;
1021    let n = xs[0].len();
1022    let all_paths: Vec<Vec<Vec<f64>>> = raw_paths
1023        .iter()
1024        .map(|gs| {
1025            gs.iter()
1026                .map(|g| {
1027                    let mut h = convolve(g, &lp);
1028                    h.truncate(n);
1029                    h
1030                })
1031                .collect()
1032        })
1033        .collect();
1034    observe("path_building", started.elapsed());
1035    let started = std::time::Instant::now();
1036    let ranking = if req
1037        .max_supports
1038        .is_some_and(|cap| cap > 0 && candidates.len() > cap)
1039    {
1040        rank_supports(candidates, xs, &all_paths, taps, ctx)?
1041    } else {
1042        Vec::new()
1043    };
1044    observe("ranking", started.elapsed());
1045    let selected: Vec<_> = if ranking.is_empty() {
1046        candidates.to_vec()
1047    } else {
1048        ranking
1049            .iter()
1050            .take(req.max_supports.unwrap())
1051            .map(|(r, _)| r.clone())
1052            .collect()
1053    };
1054    let indices: Vec<_> = selected
1055        .iter()
1056        .map(|r| candidates.iter().position(|v| v == r).unwrap())
1057        .collect();
1058    let select = |paths: &[Vec<Vec<f64>>]| {
1059        paths
1060            .iter()
1061            .map(|gs| indices.iter().map(|&j| gs[j].clone()).collect())
1062            .collect::<Vec<_>>()
1063    };
1064    design_selected(
1065        xs,
1066        &select(raw_paths),
1067        direct,
1068        &select(&all_paths),
1069        &selected,
1070        &ranking,
1071        req,
1072        ctx,
1073        observe,
1074        acceleration,
1075    )
1076}
1077
1078#[allow(clippy::too_many_arguments)]
1079fn design_selected(
1080    xs: &[Vec<f64>],
1081    raw_paths: &[Vec<Vec<f64>>],
1082    direct: &[f64],
1083    paths: &[Vec<Vec<f64>>],
1084    candidates: &[String],
1085    ranking: &[(String, f64)],
1086    req: &MimoRequest,
1087    ctx: &Context,
1088    observe: &dyn Fn(&str, std::time::Duration),
1089    acceleration: Option<&super::acceleration::RunAcceleration<'_>>,
1090) -> Result<InputResult, EngineError> {
1091    let taps = req.taps_low;
1092    let lp = lowpass_fir(req.fsiso, 31)?;
1093    let n = xs[0].len();
1094    let started = std::time::Instant::now();
1095    let (ata, atb) = normal_equations_mapped(xs, paths, taps, 10.0, ctx)?;
1096    observe("normal_equations", started.elapsed());
1097    let started = std::time::Instant::now();
1098    let dim = atb.len();
1099    if dim == 0 {
1100        return Err(EngineError::Input("no MIMO support candidates".into()));
1101    }
1102    let diagonal: Vec<_> = (0..dim).map(|k| ata[k * dim + k]).collect();
1103    let normalization = pairwise_sum(&diagonal) / dim as f64;
1104    let target = 10f64.powf(req.level_db / 10.0);
1105    let direct_energy = decay::energy(direct).max(1e-30);
1106    let result = super::acceleration::run_sweep(
1107        super::acceleration::RidgeProblem {
1108            ata: &ata,
1109            atb: &atb,
1110            lp: &lp,
1111            taps,
1112            normalization,
1113            target,
1114            direct_energy,
1115        },
1116        ctx.cancel(),
1117        acceleration,
1118    )?;
1119    let (u, ratio, hi, trajectory) = (result.u, result.ratio, result.log_lambda, result.trajectory);
1120    observe("sweep", started.elapsed());
1121    let started = std::time::Instant::now();
1122    let supports: Vec<_> = (0..candidates.len())
1123        .map(|k| convolve(&lp, &u[k * taps..(k + 1) * taps]))
1124        .collect();
1125    let mut scale = 1.0;
1126    let mut guards = Vec::new();
1127    let band_guard = BandGuard::new(xs, req.fsiso, req.seat_guard);
1128    let mut tail_guard = T60Guard::new(xs, 6, req.seat_guard);
1129    for step in 0..5 {
1130        check(ctx)?;
1131        let trial: Vec<Vec<_>> = supports
1132            .iter()
1133            .map(|s| s.iter().map(|v| v * scale).collect())
1134            .collect();
1135        let ys: Vec<_> = xs
1136            .iter()
1137            .zip(raw_paths)
1138            .map(|(x, gs)| {
1139                let mut add = vec![0.0; n];
1140                for (g, s) in gs.iter().zip(&trial) {
1141                    for (v, h) in add.iter_mut().zip(convolve(g, s)) {
1142                        *v += h;
1143                    }
1144                }
1145                x.iter().zip(add).map(|(x, a)| x + a).collect()
1146            })
1147            .collect();
1148        let (band_db, band_hz, band_position) = band_guard.evaluate_band(&ys);
1149        let (t60_change, mode_hz, tail_position) = tail_guard.evaluate_mode(&ys)?;
1150        let guard = GuardStep {
1151            scale,
1152            band_db,
1153            t60_change,
1154            band_hz,
1155            mode_hz,
1156            band_position,
1157            tail_position,
1158        };
1159        let passes = guard.passes();
1160        guards.push(guard);
1161        if passes {
1162            break;
1163        }
1164        scale = if step == 4 {
1165            0.0
1166        } else {
1167            scale * 10f64.powf(-3.0 / 20.0)
1168        };
1169    }
1170    observe("guard_steps", started.elapsed());
1171    Ok(InputResult {
1172        supports: if scale > 0.0 {
1173            candidates
1174                .iter()
1175                .cloned()
1176                .zip(
1177                    supports
1178                        .into_iter()
1179                        .map(|s| s.into_iter().map(|v| v * scale).collect()),
1180                )
1181                .collect()
1182        } else {
1183            Vec::new()
1184        },
1185        level_db: 10.0 * (ratio * scale * scale).max(1e-30).log10(),
1186        trace: InputTrace {
1187            candidates: candidates.to_vec(),
1188            ranking: ranking.to_vec(),
1189            trajectory,
1190            log_lambda: hi,
1191            ratio,
1192            guards,
1193            guard_scale: scale,
1194        },
1195    })
1196}
1197
1198pub fn design_mimo(
1199    design: &Design,
1200    measurements: &MeasurementSet,
1201    config: &EngineConfig,
1202    req: &MimoRequest,
1203    ctx: &Context,
1204) -> Result<MimoDesign, EngineError> {
1205    design_mimo_traced(design, measurements, config, req, ctx).map(|(out, _)| out)
1206}
1207
1208pub(crate) fn design_mimo_traced(
1209    design: &Design,
1210    measurements: &MeasurementSet,
1211    config: &EngineConfig,
1212    req: &MimoRequest,
1213    ctx: &Context,
1214) -> Result<(MimoDesign, Vec<(String, InputTrace)>), EngineError> {
1215    design_mimo_inputs(design, measurements, config, req, ctx, None)
1216}
1217
1218pub(crate) fn design_mimo_inputs(
1219    design: &Design,
1220    measurements: &MeasurementSet,
1221    config: &EngineConfig,
1222    req: &MimoRequest,
1223    ctx: &Context,
1224    input: Option<&str>,
1225) -> Result<(MimoDesign, Vec<(String, InputTrace)>), EngineError> {
1226    design_mimo_inputs_observed(design, measurements, config, req, ctx, input, &|_, _| {})
1227}
1228
1229#[allow(clippy::too_many_arguments)]
1230pub(crate) fn design_mimo_inputs_observed(
1231    design: &Design,
1232    measurements: &MeasurementSet,
1233    config: &EngineConfig,
1234    req: &MimoRequest,
1235    ctx: &Context,
1236    input: Option<&str>,
1237    observe: &(dyn Fn(&str, std::time::Duration) + Sync),
1238) -> Result<(MimoDesign, Vec<(String, InputTrace)>), EngineError> {
1239    check(ctx)?;
1240    let mut out = MimoDesign::default();
1241    let positions = &design.spectra.positions;
1242    if positions.len() < 3 {
1243        out.notes
1244            .push(Note::Mimo(crate::notes::MimoNote::InsufficientPositions {
1245                measured: positions.len(),
1246            }));
1247        return Ok((out, Vec::new()));
1248    }
1249    validate_request(req)?;
1250    let started = std::time::Instant::now();
1251    let prepared = super::prepared::Prepared::new(design, measurements, config, ctx)?;
1252    observe("preparation", started.elapsed());
1253    design_mimo_prepared(design, &prepared, req, ctx, input, observe)
1254}
1255
1256#[allow(clippy::too_many_arguments)]
1257pub(crate) fn design_mimo_prepared(
1258    design: &Design,
1259    prepared: &super::prepared::Prepared<'_>,
1260    req: &MimoRequest,
1261    ctx: &Context,
1262    input: Option<&str>,
1263    observe: &(dyn Fn(&str, std::time::Duration) + Sync),
1264) -> Result<(MimoDesign, Vec<(String, InputTrace)>), EngineError> {
1265    design_mimo_prepared_accelerated(design, prepared, req, ctx, input, observe, None)
1266}
1267
1268#[allow(clippy::too_many_arguments)]
1269pub(crate) fn design_mimo_prepared_accelerated(
1270    design: &Design,
1271    prepared: &super::prepared::Prepared<'_>,
1272    req: &MimoRequest,
1273    ctx: &Context,
1274    input: Option<&str>,
1275    observe: &(dyn Fn(&str, std::time::Duration) + Sync),
1276    acceleration: Option<&super::acceleration::RunAcceleration<'_>>,
1277) -> Result<(MimoDesign, Vec<(String, InputTrace)>), EngineError> {
1278    check(ctx)?;
1279    let mut out = MimoDesign::default();
1280    let positions = &design.spectra.positions;
1281    if positions.len() < 3 {
1282        out.notes
1283            .push(Note::Mimo(crate::notes::MimoNote::InsufficientPositions {
1284                measured: positions.len(),
1285            }));
1286        return Ok((out, Vec::new()));
1287    }
1288    validate_request(req)?;
1289    let spectra = &prepared.spectra;
1290    let started = std::time::Instant::now();
1291    out.ranges = support_ranges(design, spectra, req.fsiso, positions)?;
1292    if let Some(low) = req.f_low.filter(|v| *v != 0.0) {
1293        for (_, range) in &mut out.ranges {
1294            *range =
1295                range.and_then(|(lo, hi)| (lo.max(low) < hi * 0.8).then_some((lo.max(low), hi)));
1296        }
1297    }
1298    let usable: Vec<_> = out
1299        .ranges
1300        .iter()
1301        .filter(|(r, range)| {
1302            range.is_some()
1303                && !req.exclude.contains(r)
1304                && req.groups.as_ref().is_none_or(|groups| {
1305                    design
1306                        .groups
1307                        .iter()
1308                        .any(|(g, roles)| groups.contains(g) && roles.contains(r))
1309                })
1310        })
1311        .map(|(r, _)| r.clone())
1312        .collect();
1313    if !req.exclude.is_empty() {
1314        let mut excluded = req.exclude.clone();
1315        excluded.sort();
1316        out.notes
1317            .push(Note::Mimo(crate::notes::MimoNote::ExcludedSupports(
1318                excluded,
1319            )));
1320    }
1321    out.notes
1322        .push(Note::Mimo(crate::notes::MimoNote::SupportRanges(
1323            out.ranges.clone(),
1324        )));
1325    observe("ranges", started.elapsed());
1326    let started = std::time::Instant::now();
1327    let mains: Vec<_> = design
1328        .mains
1329        .iter()
1330        .filter(|m| input.is_none_or(|r| r == m.role))
1331        .collect();
1332    let roles: Vec<_> = usable
1333        .iter()
1334        .filter(|r| mains.iter().any(|m| m.role != **r))
1335        .collect();
1336    let pairs: Vec<_> = positions
1337        .iter()
1338        .flat_map(|&p| roles.iter().map(move |r| (p, *r)))
1339        .collect();
1340    let shared_bytes = (pairs.len()
1341        * (720 * 8 + std::mem::size_of::<Vec<f64>>() + 2 * std::mem::size_of::<(u32, &String)>()))
1342        as u64;
1343    let scratch_bytes = (32 * design_freqs().len() * 8) as u64;
1344    let largest_input = mains
1345        .iter()
1346        .map(|m| {
1347            input_bytes(
1348                positions.len(),
1349                usable.iter().filter(|r| **r != m.role).count(),
1350                req.taps_low,
1351            ) as u64
1352        })
1353        .max()
1354        .unwrap_or(0);
1355    // A retained reservation must not leave a worker waiting for bytes that
1356    // this same invocation cannot release until the worker has finished. The
1357    // preparation's spectra stay reserved underneath (TD-24).
1358    let retained = prepared.reserved.saturating_add(shared_bytes);
1359    let needed = retained.saturating_add(largest_input.max(scratch_bytes));
1360    if needed > ctx.budget().total() {
1361        return Err(EngineError::Input(
1362            MemoryError::TooLarge {
1363                requested: needed,
1364                total: ctx.budget().total(),
1365            }
1366            .to_string(),
1367        ));
1368    }
1369    let _shared_permit = ctx
1370        .budget()
1371        .acquire(shared_bytes, ctx.cancel())
1372        .map_err(memory_error)?;
1373    let low_paths = crate::memory::try_map(
1374        ctx,
1375        pairs.clone(),
1376        retained,
1377        |_| scratch_bytes,
1378        |(p, r)| {
1379            let h = support_path(&out, r, spectra, p, &prepared.align_delay_ms)?;
1380            Ok::<_, EngineError>(secs_dsp::lowrate::to_low(&h, design_freqs(), 720)?)
1381        },
1382    )?;
1383    observe("shared_paths", started.elapsed());
1384    let started = std::time::Instant::now();
1385    let results = crate::memory::try_map(
1386        ctx,
1387        mains,
1388        retained,
1389        |main| {
1390            input_bytes(
1391                positions.len(),
1392                usable.iter().filter(|r| **r != main.role).count(),
1393                req.taps_low,
1394            ) as u64
1395        },
1396        |main| {
1397            let candidates: Vec<_> = usable
1398                .iter()
1399                .filter(|r| **r != main.role)
1400                .cloned()
1401                .collect();
1402            let started = std::time::Instant::now();
1403            let mut xs = Vec::new();
1404            let mut paths = Vec::new();
1405            for &p in positions {
1406                check(ctx)?;
1407                let response =
1408                    crate::input_response(design, &main.role, spectra, p).ok_or_else(|| {
1409                        EngineError::Input(format!("missing MIMO input {p}/{}", main.role))
1410                    })?;
1411                xs.push(secs_dsp::lowrate::to_low(&response, design_freqs(), 720)?);
1412                paths.push(
1413                    candidates
1414                        .iter()
1415                        .map(|r| {
1416                            let index = pairs
1417                                .iter()
1418                                .position(|(pos, role)| *pos == p && *role == r)
1419                                .unwrap();
1420                            low_paths[index].clone()
1421                        })
1422                        .collect(),
1423                );
1424            }
1425            let direct = secs_dsp::lowrate::to_low(&main.response, design_freqs(), 720)?;
1426            observe("input_preparation", started.elapsed());
1427            let result = design_input_accelerated(
1428                &xs,
1429                &paths,
1430                &direct,
1431                &candidates,
1432                req,
1433                ctx,
1434                observe,
1435                acceleration,
1436            )?;
1437            Ok((main.role.clone(), result))
1438        },
1439    )?;
1440    observe("inputs_wall", started.elapsed());
1441    let mut traces = Vec::new();
1442    for (role, result) in results {
1443        let scale = result.trace.guard_scale;
1444        if let Some(cause) = guard_cause(&result.trace.guards, positions) {
1445            out.notes.push(Note::Mimo(if scale > 0.0 {
1446                crate::notes::MimoNote::GuardReduced {
1447                    role: role.clone(),
1448                    gain_db: 20.0 * scale.max(1e-9).log10(),
1449                    cause,
1450                }
1451            } else {
1452                crate::notes::MimoNote::GuardDisabled {
1453                    role: role.clone(),
1454                    cause,
1455                }
1456            }));
1457        }
1458        out.supports.push((role.clone(), result.supports));
1459        out.level_db.push((role.clone(), result.level_db));
1460        traces.push((role, result.trace));
1461    }
1462    Ok((out, traces))
1463}
1464
1465fn validate_request(req: &MimoRequest) -> Result<(), EngineError> {
1466    if !(0.0..300.0).contains(&req.fsiso)
1467        || req.fsiso == 0.0
1468        || !req.level_db.is_finite()
1469        || req.taps_low == 0
1470        || req.taps_low > 720
1471        || req.f_low.is_some_and(|f| !f.is_finite())
1472    {
1473        return Err(EngineError::Input("invalid MIMO request".into()));
1474    }
1475    Ok(())
1476}
1477
1478fn memory_error(error: MemoryError) -> EngineError {
1479    match error {
1480        MemoryError::Cancelled => EngineError::Cancelled,
1481        other => EngineError::Input(other.to_string()),
1482    }
1483}
1484
1485fn input_bytes(positions: usize, candidates: usize, taps: usize) -> usize {
1486    let dim = candidates * taps;
1487    8 * (4 * dim * dim
1488        + taps * taps
1489        + positions * 720 * (4 * candidates + 8)
1490        + 32 * design_freqs().len()
1491        + 16 * dim
1492        + 16384)
1493}
1494
1495// Pre-P18c per-input preparation, retained only as an independent bitwise oracle.
1496#[cfg(test)]
1497pub(crate) fn design_mimo_before(
1498    design: &Design,
1499    measurements: &MeasurementSet,
1500    config: &EngineConfig,
1501    req: &MimoRequest,
1502    ctx: &Context,
1503    input: Option<&str>,
1504) -> Result<(MimoDesign, Vec<(String, InputTrace)>), EngineError> {
1505    check(ctx)?;
1506    let mut out = MimoDesign::default();
1507    let positions = &design.spectra.positions;
1508    if positions.len() < 3 {
1509        out.notes
1510            .push(Note::Mimo(crate::notes::MimoNote::InsufficientPositions {
1511                measured: positions.len(),
1512            }));
1513        return Ok((out, Vec::new()));
1514    }
1515    if !(0.0..300.0).contains(&req.fsiso)
1516        || req.fsiso == 0.0
1517        || !req.level_db.is_finite()
1518        || req.taps_low == 0
1519        || req.taps_low > 720
1520        || req.f_low.is_some_and(|f| !f.is_finite())
1521    {
1522        return Err(EngineError::Input("invalid MIMO request".into()));
1523    }
1524    let prepared =
1525        crate::measurement::prepare(measurements, config, &crate::setup::Setup::new(config))?;
1526    let spectra =
1527        crate::export_view::export_view_prepared(design, measurements, &prepared, &|| false)?
1528            .spectra;
1529    out.ranges = support_ranges(design, &spectra, req.fsiso, positions)?;
1530    if let Some(low) = req.f_low.filter(|v| *v != 0.0) {
1531        for (_, range) in &mut out.ranges {
1532            *range =
1533                range.and_then(|(lo, hi)| (lo.max(low) < hi * 0.8).then_some((lo.max(low), hi)));
1534        }
1535    }
1536    let usable: Vec<_> = out
1537        .ranges
1538        .iter()
1539        .filter(|(r, range)| {
1540            range.is_some()
1541                && !req.exclude.contains(r)
1542                && req.groups.as_ref().is_none_or(|groups| {
1543                    design
1544                        .groups
1545                        .iter()
1546                        .any(|(g, roles)| groups.contains(g) && roles.contains(r))
1547                })
1548        })
1549        .map(|(r, _)| r.clone())
1550        .collect();
1551    if !req.exclude.is_empty() {
1552        let mut excluded = req.exclude.clone();
1553        excluded.sort();
1554        out.notes
1555            .push(Note::Mimo(crate::notes::MimoNote::ExcludedSupports(
1556                excluded,
1557            )));
1558    }
1559    out.notes
1560        .push(Note::Mimo(crate::notes::MimoNote::SupportRanges(
1561            out.ranges.clone(),
1562        )));
1563    let results = ctx
1564        .try_map(
1565            design
1566                .mains
1567                .iter()
1568                .filter(|m| input.is_none_or(|r| r == m.role))
1569                .collect(),
1570            |main| {
1571                let candidates: Vec<_> = usable
1572                    .iter()
1573                    .filter(|r| **r != main.role)
1574                    .cloned()
1575                    .collect();
1576                let dim = candidates.len() * req.taps_low;
1577                // Normal equations, Householder reduction, possible fallback factor,
1578                // and scratch (also covers the mapped blocks during construction),
1579                // plus path/guard/design-grid buffers. Reserve before allocating.
1580                let bytes = 8
1581                    * (4 * dim * dim
1582                        + req.taps_low * req.taps_low
1583                        + positions.len() * 720 * (4 * candidates.len() + 8)
1584                        + 32 * design_freqs().len()
1585                        + 16 * dim
1586                        + 16384);
1587                let _permit =
1588                    ctx.budget()
1589                        .acquire(bytes as u64, ctx.cancel())
1590                        .map_err(|e| match e {
1591                            MemoryError::Cancelled => EngineError::Cancelled,
1592                            other => EngineError::Input(other.to_string()),
1593                        })?;
1594                let mut xs = Vec::new();
1595                let mut paths = Vec::new();
1596                for &p in positions {
1597                    check(ctx)?;
1598                    let response = crate::input_response(design, &main.role, &spectra, p)
1599                        .ok_or_else(|| {
1600                            EngineError::Input(format!("missing MIMO input {p}/{}", main.role))
1601                        })?;
1602                    xs.push(secs_dsp::lowrate::to_low(&response, design_freqs(), 720)?);
1603                    let gs: Result<Vec<_>, EngineError> = candidates
1604                        .iter()
1605                        .map(|r| {
1606                            let h = support_path(&out, r, &spectra, p, &prepared.align_delay_ms)?;
1607                            Ok(secs_dsp::lowrate::to_low(&h, design_freqs(), 720)?)
1608                        })
1609                        .collect();
1610                    paths.push(gs?);
1611                }
1612                let direct = secs_dsp::lowrate::to_low(&main.response, design_freqs(), 720)?;
1613                let result = design_input(&xs, &paths, &direct, &candidates, req, ctx)?;
1614                Ok((main.role.clone(), result))
1615            },
1616        )
1617        .map_err(|e| match e {
1618            MapError::Cancelled => EngineError::Cancelled,
1619            MapError::Item { error, .. } => error,
1620        })?;
1621    let mut traces = Vec::new();
1622    for (role, result) in results {
1623        let scale = result.trace.guard_scale;
1624        if let Some(cause) = guard_cause(&result.trace.guards, positions) {
1625            out.notes.push(Note::Mimo(if scale > 0.0 {
1626                crate::notes::MimoNote::GuardReduced {
1627                    role: role.clone(),
1628                    gain_db: 20.0 * scale.max(1e-9).log10(),
1629                    cause,
1630                }
1631            } else {
1632                crate::notes::MimoNote::GuardDisabled {
1633                    role: role.clone(),
1634                    cause,
1635                }
1636            }));
1637        }
1638        out.supports.push((role.clone(), result.supports));
1639        out.level_db.push((role.clone(), result.level_db));
1640        traces.push((role, result.trace));
1641    }
1642    Ok((out, traces))
1643}