Skip to main content

sim_lib_interference_solve/
multitone.rs

1//! Certified composition of independently solved frequency components.
2//!
3//! A [`ToneStudy`] owns one complete coherent problem, the exact physical
4//! sampling plane, its solved phasor field, and the solver evidence that
5//! certifies the field. A [`MultiToneStudy`] accepts only those sealed studies
6//! and requires exact plane equality before any cellwise observation. Unlike
7//! frequencies are therefore never represented as one phasor field.
8
9use std::f64::consts::TAU;
10
11use sim_lib_interference_core::{Hertz, InterferenceProblem, SamplingCertificate, SamplingPlane};
12
13use crate::{
14    HostPhasorField, MultiToneError, ReferencePhasorSolver, SolveEvidence, complex::CompensatedSum,
15};
16
17/// A valid cross-frequency scalar observation.
18#[derive(Clone, Copy, Debug, PartialEq)]
19pub enum ToneCombination {
20    /// `sum(weight_i * |U_i|^2)` for mutually incoherent detection.
21    ///
22    /// This is a normalized squared-magnitude proxy, not impedance-derived
23    /// physical intensity.
24    IncoherentMagnitudeSquared,
25    /// `sum(weight_i * Re{U_i * exp(-i * omega_i * seconds)})`.
26    Instant {
27        /// One finite shared time in seconds.
28        seconds: f64,
29    },
30}
31
32/// Spatial and temporal sampling requirements for one frequency set.
33#[derive(Clone, Copy, Debug, PartialEq)]
34pub struct MultiToneSamplingRequirements {
35    highest_frequency: Hertz,
36    spatial_certificate: SamplingCertificate,
37    minimum_temporal_samples_per_second: f64,
38    maximum_temporal_step_seconds: f64,
39}
40
41impl MultiToneSamplingRequirements {
42    /// Returns the highest component frequency.
43    pub fn highest_frequency(self) -> Hertz {
44        self.highest_frequency
45    }
46
47    /// Returns the highest-frequency component's spatial certificate.
48    ///
49    /// Every lower-frequency component certificate remains available
50    /// separately through [`ToneCertificate::sampling_certificate`].
51    pub fn spatial_certificate(self) -> SamplingCertificate {
52        self.spatial_certificate
53    }
54
55    /// Returns the Nyquist floor `2 * highest_frequency`.
56    pub fn minimum_temporal_samples_per_second(self) -> f64 {
57        self.minimum_temporal_samples_per_second
58    }
59
60    /// Returns the largest Nyquist time step `1 / (2 * highest_frequency)`.
61    pub fn maximum_temporal_step_seconds(self) -> f64 {
62        self.maximum_temporal_step_seconds
63    }
64}
65
66/// Immutable provenance for one component of a multi-tone result.
67#[derive(Clone, Debug, PartialEq)]
68pub struct ToneCertificate {
69    frequency: Hertz,
70    weight: f64,
71    solve_evidence: SolveEvidence,
72}
73
74impl ToneCertificate {
75    fn from_study(study: &ToneStudy) -> Self {
76        Self {
77            frequency: study.frequency(),
78            weight: study.weight(),
79            solve_evidence: study.evidence().clone(),
80        }
81    }
82
83    /// Returns the component frequency.
84    pub fn frequency(&self) -> Hertz {
85        self.frequency
86    }
87
88    /// Returns the component's finite positive composition weight.
89    pub fn weight(&self) -> f64 {
90        self.weight
91    }
92
93    /// Borrows the complete evidence from the independent coherent solve.
94    pub fn solve_evidence(&self) -> &SolveEvidence {
95        &self.solve_evidence
96    }
97
98    /// Returns the component's original spatial sampling certificate.
99    pub fn sampling_certificate(&self) -> SamplingCertificate {
100        self.solve_evidence.preflight().sampling_certificate
101    }
102}
103
104/// Inseparable provenance for one complete multi-tone scalar result.
105#[derive(Clone, Debug, PartialEq)]
106pub struct MultiToneCertificate {
107    plane: SamplingPlane,
108    combination: ToneCombination,
109    sampling_requirements: MultiToneSamplingRequirements,
110    components: Vec<ToneCertificate>,
111}
112
113impl MultiToneCertificate {
114    /// Returns the exact shared physical plane.
115    pub fn plane(&self) -> SamplingPlane {
116        self.plane
117    }
118
119    /// Returns the scalar combination rule.
120    pub fn combination(&self) -> ToneCombination {
121        self.combination
122    }
123
124    /// Returns the highest-frequency spatial and temporal requirements.
125    pub fn sampling_requirements(&self) -> MultiToneSamplingRequirements {
126        self.sampling_requirements
127    }
128
129    /// Borrows every component's frequency, weight, and solve evidence.
130    pub fn components(&self) -> &[ToneCertificate] {
131        &self.components
132    }
133}
134
135/// A complete scalar observation of one certified multi-tone study.
136#[derive(Clone, Debug, PartialEq)]
137pub struct MultiToneProjection {
138    rows: usize,
139    columns: usize,
140    samples: Vec<f64>,
141    certificate: MultiToneCertificate,
142}
143
144impl MultiToneProjection {
145    /// Returns the number of rows.
146    pub fn rows(&self) -> usize {
147        self.rows
148    }
149
150    /// Returns the number of columns.
151    pub fn columns(&self) -> usize {
152        self.columns
153    }
154
155    /// Returns the number of finite row-major scalar samples.
156    pub fn len(&self) -> usize {
157        self.samples.len()
158    }
159
160    /// Returns whether the result contains no cells.
161    ///
162    /// A valid multi-tone projection is never empty because its checked
163    /// sampling plane has non-zero dimensions.
164    pub fn is_empty(&self) -> bool {
165        self.samples.is_empty()
166    }
167
168    /// Borrows the finite row-major scalar samples.
169    pub fn samples(&self) -> &[f64] {
170        &self.samples
171    }
172
173    /// Returns one scalar sample.
174    pub fn cell(&self, row: usize, column: usize) -> Option<f64> {
175        let index = row.checked_mul(self.columns)?.checked_add(column)?;
176        (row < self.rows && column < self.columns).then(|| self.samples[index])
177    }
178
179    /// Returns the exact observation rule used for this result.
180    pub fn combination(&self) -> ToneCombination {
181        self.certificate.combination()
182    }
183
184    /// Borrows the set requirements and every component solve certificate.
185    pub fn certificate(&self) -> &MultiToneCertificate {
186        &self.certificate
187    }
188}
189
190/// One positive-weight, independently certified coherent tone.
191#[derive(Clone, Debug, PartialEq)]
192pub struct ToneStudy {
193    problem: InterferenceProblem,
194    plane: SamplingPlane,
195    weight: f64,
196    field: HostPhasorField,
197    evidence: SolveEvidence,
198}
199
200impl ToneStudy {
201    /// Solves and seals one weighted frequency component.
202    ///
203    /// The weight is checked before propagation work. The returned study owns
204    /// the exact problem and plane used by the solver, so its field cannot be
205    /// separated from or paired with different sampling evidence.
206    pub fn solve(
207        problem: InterferenceProblem,
208        plane: SamplingPlane,
209        weight: f64,
210        solver: ReferencePhasorSolver,
211    ) -> Result<Self, MultiToneError> {
212        if !weight.is_finite() || weight <= 0.0 {
213            return Err(MultiToneError::InvalidWeight {
214                frequency_hz: problem.frequency.get(),
215                weight,
216            });
217        }
218        let (field, evidence) =
219            solver
220                .solve(&problem, &plane)
221                .map_err(|cause| MultiToneError::ComponentSolve {
222                    frequency_hz: problem.frequency.get(),
223                    cause: Box::new(cause),
224                })?;
225        Ok(Self {
226            problem,
227            plane,
228            weight,
229            field,
230            evidence,
231        })
232    }
233
234    /// Returns the exact coherent problem solved for this tone.
235    pub fn problem(&self) -> &InterferenceProblem {
236        &self.problem
237    }
238
239    /// Returns this tone's frequency.
240    pub fn frequency(&self) -> Hertz {
241        self.problem.frequency
242    }
243
244    /// Returns this tone's finite, strictly positive composition weight.
245    pub fn weight(&self) -> f64 {
246        self.weight
247    }
248
249    /// Returns the exact physical plane on which this tone was solved.
250    pub fn plane(&self) -> SamplingPlane {
251        self.plane
252    }
253
254    /// Borrows this tone's complete coherent phasor field.
255    pub fn field(&self) -> &HostPhasorField {
256        &self.field
257    }
258
259    /// Borrows the immutable evidence produced by this tone's solve.
260    pub fn evidence(&self) -> &SolveEvidence {
261        &self.evidence
262    }
263
264    /// Returns this component's sampling certificate.
265    pub fn sampling_certificate(&self) -> SamplingCertificate {
266        self.evidence.preflight().sampling_certificate
267    }
268}
269
270/// A non-empty set of certified tones sharing one exact physical plane.
271#[derive(Clone, Debug, PartialEq)]
272pub struct MultiToneStudy {
273    tones: Vec<ToneStudy>,
274    plane: SamplingPlane,
275    sampling_requirements: MultiToneSamplingRequirements,
276}
277
278impl MultiToneStudy {
279    /// Validates independently certified tones for cellwise composition.
280    ///
281    /// Geometry equality is exact: even equal dimensions are insufficient
282    /// when origins, axes, extents, or therefore physical sample centres
283    /// differ.
284    pub fn new(mut tones: Vec<ToneStudy>) -> Result<Self, MultiToneError> {
285        let Some(first) = tones.first() else {
286            return Err(MultiToneError::EmptyStudy);
287        };
288        let plane = first.plane();
289        tones.sort_by(|left, right| left.frequency().get().total_cmp(&right.frequency().get()));
290        for pair in tones.windows(2) {
291            if pair[0].frequency() == pair[1].frequency() {
292                return Err(MultiToneError::DuplicateFrequency {
293                    frequency_hz: pair[0].frequency().get(),
294                });
295            }
296        }
297        for tone in &tones {
298            if tone.plane() != plane {
299                return Err(MultiToneError::MismatchedPlane {
300                    frequency_hz: tone.frequency().get(),
301                    expected: Box::new(plane),
302                    actual: Box::new(tone.plane()),
303                });
304            }
305        }
306        let highest = tones.last().expect("non-empty study checked above");
307        let minimum_temporal_samples_per_second = 2.0 * highest.frequency().get();
308        if !minimum_temporal_samples_per_second.is_finite() {
309            return Err(MultiToneError::NonFiniteTemporalSamplingRequirement {
310                highest_frequency_hz: highest.frequency().get(),
311                samples_per_second: minimum_temporal_samples_per_second,
312            });
313        }
314        let sampling_requirements = MultiToneSamplingRequirements {
315            highest_frequency: highest.frequency(),
316            spatial_certificate: highest.sampling_certificate(),
317            minimum_temporal_samples_per_second,
318            maximum_temporal_step_seconds: 1.0 / minimum_temporal_samples_per_second,
319        };
320        Ok(Self {
321            tones,
322            plane,
323            sampling_requirements,
324        })
325    }
326
327    /// Borrows the certified component studies.
328    pub fn tones(&self) -> &[ToneStudy] {
329        &self.tones
330    }
331
332    /// Returns the exact physical plane shared by every component.
333    pub fn plane(&self) -> SamplingPlane {
334        self.plane
335    }
336
337    /// Returns requirements derived from the highest component frequency.
338    pub fn sampling_requirements(&self) -> MultiToneSamplingRequirements {
339        self.sampling_requirements
340    }
341
342    /// Combines independently solved tones only after projecting each one to
343    /// a real scalar at the requested shared time or detection rule.
344    ///
345    /// This method never constructs or adds cross-frequency complex values.
346    /// Tones are traversed in canonical ascending-frequency order and their
347    /// scalar contributions are accumulated with Neumaier compensation.
348    pub fn combine(
349        &self,
350        combination: ToneCombination,
351    ) -> Result<MultiToneProjection, MultiToneError> {
352        if let ToneCombination::Instant { seconds } = combination
353            && !seconds.is_finite()
354        {
355            return Err(MultiToneError::InvalidSeconds { seconds });
356        }
357
358        let cells = self.plane.cell_count();
359        let mut samples = Vec::new();
360        samples
361            .try_reserve_exact(cells)
362            .map_err(|_| MultiToneError::AllocationFailed { cells })?;
363        for index in 0..cells {
364            let row = index / self.plane.columns();
365            let column = index % self.plane.columns();
366            let mut accumulation = CompensatedSum::default();
367            for tone in &self.tones {
368                let value = component_scalar(tone, index, combination)?;
369                let contribution = tone.weight() * value;
370                if !contribution.is_finite() {
371                    return Err(MultiToneError::NonFiniteContribution {
372                        frequency_hz: tone.frequency().get(),
373                        row,
374                        column,
375                        value: contribution,
376                    });
377                }
378                accumulation.add(contribution);
379                if !accumulation.is_finite() {
380                    return Err(MultiToneError::NonFiniteAccumulation {
381                        row,
382                        column,
383                        value: accumulation.total(),
384                    });
385                }
386            }
387            samples.push(accumulation.total());
388        }
389        let mut components = Vec::new();
390        components
391            .try_reserve_exact(self.tones.len())
392            .map_err(|_| MultiToneError::CertificateAllocationFailed {
393                tones: self.tones.len(),
394            })?;
395        components.extend(self.tones.iter().map(ToneCertificate::from_study));
396        Ok(MultiToneProjection {
397            rows: self.plane.rows(),
398            columns: self.plane.columns(),
399            samples,
400            certificate: MultiToneCertificate {
401                plane: self.plane,
402                combination,
403                sampling_requirements: self.sampling_requirements,
404                components,
405            },
406        })
407    }
408}
409
410fn component_scalar(
411    tone: &ToneStudy,
412    index: usize,
413    combination: ToneCombination,
414) -> Result<f64, MultiToneError> {
415    let real = tone.field().real()[index];
416    let imaginary = tone.field().imaginary()[index];
417    match combination {
418        ToneCombination::IncoherentMagnitudeSquared => {
419            Ok(real.mul_add(real, imaginary * imaginary))
420        }
421        ToneCombination::Instant { seconds: 0.0 } => Ok(real),
422        ToneCombination::Instant { seconds } => {
423            let angular_time = TAU * tone.frequency().get() * seconds;
424            if !angular_time.is_finite() {
425                return Err(MultiToneError::NonFiniteAngularTime {
426                    frequency_hz: tone.frequency().get(),
427                    seconds,
428                    angular_time,
429                });
430            }
431            Ok(real * angular_time.cos() + imaginary * angular_time.sin())
432        }
433    }
434}
435
436#[cfg(test)]
437#[path = "multitone_tests.rs"]
438mod tests;