Skip to main content

sim_lib_interference_solve/
verify.rs

1//! Metamorphic fixtures and complete reference-suite orchestration.
2
3use sim_lib_interference_core::{
4    Emitter, FieldAmplitude, Hertz, InterferenceProblem, MetresPerSecond, NepersPerMetre, Point3M,
5    PositiveMetres, Radians, SamplingPlane, SamplingPolicy, SamplingThresholds, ScalarMedium,
6    SourceSet, UnitVector3, WorkBudget,
7};
8
9use crate::{
10    HostPhasorField, ReferencePhasorSolver, ReferenceSolveError,
11    analytic::measure_analytic_fixtures,
12    helmholtz::{MAX_OBSERVED_ORDER, MIN_OBSERVED_ORDER, measure_helmholtz_fixture_matrix},
13    verification::{
14        MAX_ANALYTIC_RELATIVE_ERROR, MAX_METAMORPHIC_RELATIVE_ERROR, VerificationError,
15        VerificationReport,
16    },
17};
18
19const FIXTURE_FREQUENCY_HERTZ: f64 = 7.0;
20const FIXTURE_SPEED_METRES_PER_SECOND: f64 = 28.0;
21
22/// Runs the analytic, metamorphic, time-sign, and Helmholtz fixture matrix.
23///
24/// The suite covers point-only, plane-only, mixed, attenuating, and
25/// multi-source fields. It returns no report unless every relative error is
26/// bounded, source permutations are bit-identical, and every seven-point
27/// residual comparison has observed order in `[1.8, 2.2]`.
28pub fn verify_reference_solver() -> Result<VerificationReport, VerificationError> {
29    let analytic = measure_analytic_fixtures()?;
30    let metamorphic = measure_metamorphic_fixtures()?;
31    require_relative(
32        "analytic",
33        analytic.max_error(),
34        MAX_ANALYTIC_RELATIVE_ERROR,
35    )?;
36    for (check, measured) in [
37        ("point-reciprocity", metamorphic.reciprocity_relative),
38        ("linearity", metamorphic.linearity_relative),
39        ("rigid-motion", metamorphic.rigid_motion_relative),
40        ("global-phase", metamorphic.global_phase_relative),
41    ] {
42        require_relative(check, measured, MAX_METAMORPHIC_RELATIVE_ERROR)?;
43    }
44    if !metamorphic.source_permutation_identical {
45        return Err(VerificationError::SourcePermutationChanged);
46    }
47
48    let helmholtz = measure_helmholtz_fixture_matrix()?;
49    for named in &helmholtz {
50        if !(MIN_OBSERVED_ORDER..=MAX_OBSERVED_ORDER).contains(&named.measurement.observed_order) {
51            return Err(VerificationError::HelmholtzOrderOutOfRange {
52                fixture: named.name,
53                observed: named.measurement.observed_order,
54                minimum: MIN_OBSERVED_ORDER,
55                maximum: MAX_OBSERVED_ORDER,
56            });
57        }
58    }
59    let least_ideal_order = helmholtz
60        .iter()
61        .max_by(|left, right| {
62            (left.measurement.observed_order - 2.0)
63                .abs()
64                .total_cmp(&(right.measurement.observed_order - 2.0).abs())
65        })
66        .expect("the fixed Helmholtz fixture matrix is non-empty")
67        .measurement
68        .observed_order;
69
70    Ok(VerificationReport {
71        analytic_max_error: analytic.max_error(),
72        reciprocity_relative: metamorphic.reciprocity_relative,
73        linearity_relative: metamorphic.linearity_relative,
74        rigid_motion_relative: metamorphic.rigid_motion_relative,
75        global_phase_relative: metamorphic.global_phase_relative,
76        source_permutation_identical: metamorphic.source_permutation_identical,
77        helmholtz_observed_order: least_ideal_order,
78    })
79}
80
81fn require_relative(
82    check: &'static str,
83    measured: f64,
84    limit: f64,
85) -> Result<(), VerificationError> {
86    if measured.is_finite() && measured <= limit {
87        Ok(())
88    } else {
89        Err(VerificationError::RelativeErrorExceeded {
90            check,
91            measured,
92            limit,
93        })
94    }
95}
96
97#[derive(Clone, Copy, Debug, PartialEq)]
98pub(crate) struct MetamorphicMetrics {
99    pub(crate) reciprocity_relative: f64,
100    pub(crate) linearity_relative: f64,
101    pub(crate) rigid_motion_relative: f64,
102    pub(crate) global_phase_relative: f64,
103    pub(crate) source_permutation_identical: bool,
104}
105
106pub(crate) fn measure_metamorphic_fixtures() -> Result<MetamorphicMetrics, ReferenceSolveError> {
107    Ok(MetamorphicMetrics {
108        reciprocity_relative: point_reciprocity_error()?,
109        linearity_relative: linearity_error()?,
110        rigid_motion_relative: rigid_motion_error()?,
111        global_phase_relative: global_phase_error()?,
112        source_permutation_identical: source_permutation_identical()?,
113    })
114}
115
116fn point_reciprocity_error() -> Result<f64, ReferenceSolveError> {
117    let first = point(-0.75, 0.5, -1.25)?;
118    let second = point(1.25, -0.25, 0.75)?;
119    let from_first = problem(vec![point_source("first", first, 1.0, 0.0)?], 0.08)?;
120    let from_second = problem(vec![point_source("second", second, 1.0, 0.0)?], 0.08)?;
121    let first_to_second = solve(&from_first, &point_plane(second)?)?;
122    let second_to_first = solve(&from_second, &point_plane(first)?)?;
123
124    Ok(relative_complex_error(
125        first_to_second.cell(0, 0).unwrap(),
126        second_to_first.cell(0, 0).unwrap(),
127    ))
128}
129
130fn linearity_error() -> Result<f64, ReferenceSolveError> {
131    let sample_plane = fixture_plane()?;
132    let first_source = point_source("linear-point", point(-0.5, 0.25, -3.0)?, 2.0, 0.3)?;
133    let second_source = forward_plane(
134        "linear-plane",
135        point(0.0, 0.0, -1.0)?,
136        direction(0.0, 0.0, 1.0)?,
137        0.75,
138        -0.6,
139    )?;
140    let first = solve(&problem(vec![first_source.clone()], 0.02)?, &sample_plane)?;
141    let second = solve(&problem(vec![second_source.clone()], 0.02)?, &sample_plane)?;
142    let combined = solve(
143        &problem(vec![second_source, first_source], 0.02)?,
144        &sample_plane,
145    )?;
146
147    Ok(relative_field_sum_error(&combined, &first, &second))
148}
149
150fn rigid_motion_error() -> Result<f64, ReferenceSolveError> {
151    let sources = vec![
152        point_source("rigid-point", point(-1.0, 0.5, -4.0)?, 2.25, 0.4)?,
153        forward_plane(
154            "rigid-plane",
155            point(0.0, 0.0, -2.0)?,
156            direction(0.0, 0.0, 1.0)?,
157            0.8,
158            -0.7,
159        )?,
160    ];
161    let transformed_sources = sources
162        .iter()
163        .map(rigid_emitter)
164        .collect::<Result<Vec<_>, _>>()?;
165    let original_plane = fixture_plane()?;
166    let transformed_plane = rigid_plane(original_plane)?;
167    let original = solve(&problem(sources, 0.03)?, &original_plane)?;
168    let transformed = solve(&problem(transformed_sources, 0.03)?, &transformed_plane)?;
169
170    Ok(relative_field_error(&original, &transformed))
171}
172
173fn global_phase_error() -> Result<f64, ReferenceSolveError> {
174    let phase_shift = 0.625;
175    let sources = vec![
176        point_source("phase-point", point(-0.75, 0.25, -3.5)?, 1.5, 0.2)?,
177        forward_plane(
178            "phase-plane",
179            point(0.0, 0.0, -1.5)?,
180            direction(0.0, 0.0, 1.0)?,
181            0.9,
182            -0.4,
183        )?,
184    ];
185    let shifted_sources = sources
186        .iter()
187        .map(|source| phase_shifted_emitter(source, phase_shift))
188        .collect::<Result<Vec<_>, _>>()?;
189    let sample_plane = fixture_plane()?;
190    let original = solve(&problem(sources, 0.015)?, &sample_plane)?;
191    let shifted = solve(&problem(shifted_sources, 0.015)?, &sample_plane)?;
192
193    Ok(global_phase_field_error(&shifted, &original, phase_shift))
194}
195
196fn source_permutation_identical() -> Result<bool, ReferenceSolveError> {
197    let sample_plane = fixture_plane()?;
198    let solve_order = |order: [&str; 3]| -> Result<HostPhasorField, ReferenceSolveError> {
199        let sources = order
200            .into_iter()
201            .map(permutation_source)
202            .collect::<Result<Vec<_>, _>>()?;
203        solve(&problem(sources, 0.025)?, &sample_plane)
204    };
205    let baseline = solve_order(["point-a", "point-b", "plane"])?;
206    let reversed = solve_order(["plane", "point-b", "point-a"])?;
207    let rotated = solve_order(["point-b", "plane", "point-a"])?;
208
209    Ok(component_bits(&baseline) == component_bits(&reversed)
210        && component_bits(&baseline) == component_bits(&rotated))
211}
212
213fn permutation_source(id: &str) -> Result<Emitter, ReferenceSolveError> {
214    match id {
215        "point-a" => point_source("point-a", point(-0.5, 0.75, -3.0)?, 1.5, 0.1),
216        "point-b" => point_source("point-b", point(0.75, -0.25, -2.5)?, 0.75, -0.3),
217        "plane" => forward_plane(
218            "plane",
219            point(0.0, 0.0, -1.0)?,
220            direction(0.0, 0.0, 1.0)?,
221            0.5,
222            0.8,
223        ),
224        _ => unreachable!("the fixture lists only known source ids"),
225    }
226}
227
228fn solve(
229    problem: &InterferenceProblem,
230    plane: &SamplingPlane,
231) -> Result<HostPhasorField, ReferenceSolveError> {
232    ReferencePhasorSolver::new(
233        SamplingPolicy::Annotate,
234        SamplingThresholds::default(),
235        WorkBudget::default(),
236    )
237    .solve(problem, plane)
238    .map(|(field, _)| field)
239}
240
241fn problem(
242    sources: Vec<Emitter>,
243    attenuation: f64,
244) -> Result<InterferenceProblem, ReferenceSolveError> {
245    Ok(InterferenceProblem::new(
246        Hertz::new(FIXTURE_FREQUENCY_HERTZ)?,
247        ScalarMedium::new(
248            MetresPerSecond::new(FIXTURE_SPEED_METRES_PER_SECOND)?,
249            NepersPerMetre::new(attenuation)?,
250        ),
251        SourceSet::new(sources)?,
252        PositiveMetres::new(1.0e-6)?,
253    ))
254}
255
256fn point_source(
257    id: &str,
258    position: Point3M,
259    amplitude: f64,
260    phase: f64,
261) -> Result<Emitter, ReferenceSolveError> {
262    Ok(Emitter::Point {
263        id: id.to_owned(),
264        position,
265        amplitude_at_reference: FieldAmplitude::new(amplitude)?,
266        phase: Radians::new(phase)?,
267    })
268}
269
270fn forward_plane(
271    id: &str,
272    through: Point3M,
273    direction: UnitVector3,
274    amplitude: f64,
275    phase: f64,
276) -> Result<Emitter, ReferenceSolveError> {
277    Ok(Emitter::ForwardPlane {
278        id: id.to_owned(),
279        through,
280        direction,
281        amplitude: FieldAmplitude::new(amplitude)?,
282        phase: Radians::new(phase)?,
283    })
284}
285
286fn phase_shifted_emitter(source: &Emitter, shift: f64) -> Result<Emitter, ReferenceSolveError> {
287    Ok(match source {
288        Emitter::Point {
289            id,
290            position,
291            amplitude_at_reference,
292            phase,
293        } => Emitter::Point {
294            id: id.clone(),
295            position: *position,
296            amplitude_at_reference: *amplitude_at_reference,
297            phase: Radians::new(phase.get() + shift)?,
298        },
299        Emitter::ForwardPlane {
300            id,
301            through,
302            direction,
303            amplitude,
304            phase,
305        } => Emitter::ForwardPlane {
306            id: id.clone(),
307            through: *through,
308            direction: *direction,
309            amplitude: *amplitude,
310            phase: Radians::new(phase.get() + shift)?,
311        },
312    })
313}
314
315fn rigid_emitter(source: &Emitter) -> Result<Emitter, ReferenceSolveError> {
316    Ok(match source {
317        Emitter::Point {
318            id,
319            position,
320            amplitude_at_reference,
321            phase,
322        } => Emitter::Point {
323            id: id.clone(),
324            position: rigid_point(*position)?,
325            amplitude_at_reference: *amplitude_at_reference,
326            phase: *phase,
327        },
328        Emitter::ForwardPlane {
329            id,
330            through,
331            direction,
332            amplitude,
333            phase,
334        } => Emitter::ForwardPlane {
335            id: id.clone(),
336            through: rigid_point(*through)?,
337            direction: rigid_direction(*direction)?,
338            amplitude: *amplitude,
339            phase: *phase,
340        },
341    })
342}
343
344fn rigid_plane(plane: SamplingPlane) -> Result<SamplingPlane, ReferenceSolveError> {
345    SamplingPlane::new(
346        rigid_point(plane.origin())?,
347        rigid_direction(plane.u_axis())?,
348        rigid_direction(plane.v_axis())?,
349        plane.extent_u(),
350        plane.extent_v(),
351        plane.rows(),
352        plane.columns(),
353    )
354    .map_err(ReferenceSolveError::from)
355}
356
357fn rigid_point(value: Point3M) -> Result<Point3M, ReferenceSolveError> {
358    let [x, y, z] = value.coordinates_metres();
359    point(-y + 3.0, x - 2.0, z + 5.0)
360}
361
362fn rigid_direction(value: UnitVector3) -> Result<UnitVector3, ReferenceSolveError> {
363    let [x, y, z] = value.components();
364    direction(-y, x, z)
365}
366
367fn fixture_plane() -> Result<SamplingPlane, ReferenceSolveError> {
368    SamplingPlane::new(
369        point(-0.5, -0.75, 0.0)?,
370        direction(1.0, 0.0, 0.0)?,
371        direction(0.0, 1.0, 0.0)?,
372        PositiveMetres::new(1.0)?,
373        PositiveMetres::new(1.5)?,
374        3,
375        4,
376    )
377    .map_err(ReferenceSolveError::from)
378}
379
380fn point_plane(at: Point3M) -> Result<SamplingPlane, ReferenceSolveError> {
381    let [x, y, z] = at.coordinates_metres();
382    SamplingPlane::new(
383        point(x - 0.125, y - 0.125, z)?,
384        direction(1.0, 0.0, 0.0)?,
385        direction(0.0, 1.0, 0.0)?,
386        PositiveMetres::new(0.25)?,
387        PositiveMetres::new(0.25)?,
388        1,
389        1,
390    )
391    .map_err(ReferenceSolveError::from)
392}
393
394fn point(x: f64, y: f64, z: f64) -> Result<Point3M, ReferenceSolveError> {
395    Point3M::from_metres(x, y, z).map_err(ReferenceSolveError::from)
396}
397
398fn direction(x: f64, y: f64, z: f64) -> Result<UnitVector3, ReferenceSolveError> {
399    UnitVector3::new(x, y, z).map_err(ReferenceSolveError::from)
400}
401
402fn relative_complex_error(actual: (f64, f64), expected: (f64, f64)) -> f64 {
403    let difference = (actual.0 - expected.0).hypot(actual.1 - expected.1);
404    let scale = actual
405        .0
406        .hypot(actual.1)
407        .max(expected.0.hypot(expected.1))
408        .max(f64::MIN_POSITIVE);
409    difference / scale
410}
411
412fn relative_field_error(actual: &HostPhasorField, expected: &HostPhasorField) -> f64 {
413    actual
414        .real()
415        .iter()
416        .zip(actual.imaginary())
417        .zip(expected.real().iter().zip(expected.imaginary()))
418        .map(
419            |((&actual_real, &actual_imaginary), (&expected_real, &expected_imaginary))| {
420                relative_complex_error(
421                    (actual_real, actual_imaginary),
422                    (expected_real, expected_imaginary),
423                )
424            },
425        )
426        .fold(0.0, f64::max)
427}
428
429fn relative_field_sum_error(
430    actual: &HostPhasorField,
431    first: &HostPhasorField,
432    second: &HostPhasorField,
433) -> f64 {
434    (0..actual.len())
435        .map(|index| {
436            relative_complex_error(
437                (actual.real()[index], actual.imaginary()[index]),
438                (
439                    first.real()[index] + second.real()[index],
440                    first.imaginary()[index] + second.imaginary()[index],
441                ),
442            )
443        })
444        .fold(0.0, f64::max)
445}
446
447fn global_phase_field_error(
448    actual: &HostPhasorField,
449    original: &HostPhasorField,
450    phase_shift: f64,
451) -> f64 {
452    let cosine = phase_shift.cos();
453    let sine = phase_shift.sin();
454    (0..actual.len())
455        .map(|index| {
456            let real = original.real()[index];
457            let imaginary = original.imaginary()[index];
458            relative_complex_error(
459                (actual.real()[index], actual.imaginary()[index]),
460                (
461                    real * cosine - imaginary * sine,
462                    real * sine + imaginary * cosine,
463                ),
464            )
465        })
466        .fold(0.0, f64::max)
467}
468
469fn component_bits(field: &HostPhasorField) -> (Vec<u64>, Vec<u64>) {
470    (
471        field.real().iter().map(|value| value.to_bits()).collect(),
472        field
473            .imaginary()
474            .iter()
475            .map(|value| value.to_bits())
476            .collect(),
477    )
478}
479
480#[cfg(test)]
481mod tests {
482    use super::measure_metamorphic_fixtures;
483
484    #[test]
485    fn reciprocity_linearity_rigid_motion_phase_and_permutation_laws_hold() {
486        let metrics = measure_metamorphic_fixtures().unwrap();
487
488        assert!(metrics.reciprocity_relative <= 2.0e-15, "{metrics:?}");
489        assert!(metrics.linearity_relative <= 2.0e-15, "{metrics:?}");
490        assert!(metrics.rigid_motion_relative <= 2.0e-14, "{metrics:?}");
491        assert!(metrics.global_phase_relative <= 2.0e-15, "{metrics:?}");
492        assert!(metrics.source_permutation_identical, "{metrics:?}");
493    }
494}