Skip to main content

sim_lib_interference_solve/
reduce.rs

1//! Detector rules for spatial field reduction.
2
3use std::ops::Range;
4
5use sim_lib_interference_core::SamplingCertificate;
6
7use crate::{
8    DetectorFootprint, GridDimensions, HostPhasorField, LossClass, Observable,
9    ProjectionCertificate, ProjectionError, ScalarProjection, ScalarSample, project,
10    projection::{allocate_samples, project_complex, validate_request},
11};
12
13/// The declared rule used to map source cells to target detector cells.
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub enum ReductionRule {
16    /// Preserve source detail; any smaller target is refused.
17    Detail,
18    /// Average the complex field over each footprint, then project it.
19    DetectorComplexMean,
20    /// Project amplitude at every source cell, then take its area mean.
21    DetectorScalarAreaMean,
22    /// Project squared magnitude at every source cell, then take its area mean.
23    DetectorMagnitudeSquaredAreaMean,
24}
25
26/// Reduces a phasor field onto a no-larger target grid using one named detector.
27///
28/// Each target axis is an integer partition of its source axis. The Cartesian
29/// product of those partitions covers every source cell exactly once, including
30/// odd dimensions and non-divisible target shapes. Detail mode accepts only the
31/// identity shape. Detector rules are deliberately paired with the observables
32/// whose semantics they preserve; wrapped phase angles are never averaged.
33#[allow(clippy::too_many_arguments)]
34pub fn reduce_for_view(
35    field: &HostPhasorField,
36    source_sampling_certificate: SamplingCertificate,
37    observable: Observable,
38    phase_floor: f64,
39    target_rows: usize,
40    target_columns: usize,
41    rule: ReductionRule,
42) -> Result<ScalarProjection, ProjectionError> {
43    validate_request(observable, phase_floor)?;
44    let source = GridDimensions::from_field(field);
45    let target = target_dimensions(target_rows, target_columns)?;
46    if target.rows > source.rows || target.columns > source.columns {
47        return Err(ProjectionError::TargetExceedsSource { source, target });
48    }
49    if rule == ReductionRule::Detail {
50        if target != source {
51            return Err(ProjectionError::DetailReductionRefused { source, target });
52        }
53        return project(field, source_sampling_certificate, observable, phase_floor);
54    }
55    require_compatible(observable, rule)?;
56
57    let cells = target
58        .rows
59        .checked_mul(target.columns)
60        .expect("a target no larger than an existing field must fit");
61    let mut samples = allocate_samples(cells)?;
62    let mut mask_count = 0;
63    for target_row in 0..target.rows {
64        let source_rows = axis_partition(target_row, source.rows, target.rows);
65        for target_column in 0..target.columns {
66            let source_columns = axis_partition(target_column, source.columns, target.columns);
67            let sample = reduce_cell(
68                field,
69                observable,
70                phase_floor,
71                source_rows.clone(),
72                source_columns,
73                target_row,
74                target_column,
75                rule,
76            )?;
77            mask_count += usize::from(sample == ScalarSample::Masked);
78            samples.push(sample);
79        }
80    }
81
82    Ok(ScalarProjection {
83        rows: target.rows,
84        columns: target.columns,
85        samples,
86        certificate: ProjectionCertificate {
87            source_dimensions: source,
88            target_dimensions: target,
89            footprint: DetectorFootprint {
90                min_rows: source.rows / target.rows,
91                max_rows: source.rows.div_ceil(target.rows),
92                min_columns: source.columns / target.columns,
93                max_columns: source.columns.div_ceil(target.columns),
94            },
95            observable,
96            phase_floor,
97            rule,
98            loss_class: if source == target {
99                LossClass::Lossless
100            } else {
101                LossClass::DetectorIntegration
102            },
103            source_sampling_certificate,
104            mask_count,
105        },
106    })
107}
108
109fn target_dimensions(rows: usize, columns: usize) -> Result<GridDimensions, ProjectionError> {
110    if rows == 0 {
111        return Err(ProjectionError::ZeroTargetDimension { axis: "rows" });
112    }
113    if columns == 0 {
114        return Err(ProjectionError::ZeroTargetDimension { axis: "columns" });
115    }
116    Ok(GridDimensions { rows, columns })
117}
118
119fn require_compatible(observable: Observable, rule: ReductionRule) -> Result<(), ProjectionError> {
120    let compatible = matches!(
121        (observable, rule),
122        (
123            Observable::Real
124                | Observable::Imaginary
125                | Observable::Phase
126                | Observable::Instant { .. },
127            ReductionRule::DetectorComplexMean
128        ) | (Observable::Amplitude, ReductionRule::DetectorScalarAreaMean)
129            | (
130                Observable::MagnitudeSquared,
131                ReductionRule::DetectorMagnitudeSquaredAreaMean
132            )
133    );
134    compatible
135        .then_some(())
136        .ok_or(ProjectionError::IncompatibleReduction { observable, rule })
137}
138
139fn axis_partition(index: usize, source: usize, target: usize) -> Range<usize> {
140    let base = source / target;
141    let remainder = source % target;
142    let start = index * base + index.min(remainder);
143    let length = base + usize::from(index < remainder);
144    start..start + length
145}
146
147#[allow(clippy::too_many_arguments)]
148fn reduce_cell(
149    field: &HostPhasorField,
150    observable: Observable,
151    phase_floor: f64,
152    source_rows: Range<usize>,
153    source_columns: Range<usize>,
154    target_row: usize,
155    target_column: usize,
156    rule: ReductionRule,
157) -> Result<ScalarSample, ProjectionError> {
158    let count = source_rows.len() * source_columns.len();
159    let scale = 1.0 / count as f64;
160    match rule {
161        ReductionRule::Detail => unreachable!("detail returns before detector traversal"),
162        ReductionRule::DetectorComplexMean => {
163            let (mut real_sum, mut real_correction) = (0.0, 0.0);
164            let (mut imaginary_sum, mut imaginary_correction) = (0.0, 0.0);
165            let first_index = source_rows.start * field.columns() + source_columns.start;
166            let first_real = field.real()[first_index];
167            let first_imaginary = field.imaginary()[first_index];
168            let mut constant = true;
169            for row in source_rows {
170                for column in source_columns.clone() {
171                    let index = row * field.columns() + column;
172                    constant &= field.real()[index].to_bits() == first_real.to_bits()
173                        && field.imaginary()[index].to_bits() == first_imaginary.to_bits();
174                    compensated_add(
175                        &mut real_sum,
176                        &mut real_correction,
177                        field.real()[index] * scale,
178                    );
179                    compensated_add(
180                        &mut imaginary_sum,
181                        &mut imaginary_correction,
182                        field.imaginary()[index] * scale,
183                    );
184                }
185            }
186            let (real, imaginary) = if constant {
187                (first_real, first_imaginary)
188            } else {
189                (
190                    real_sum + real_correction,
191                    imaginary_sum + imaginary_correction,
192                )
193            };
194            project_complex(
195                real,
196                imaginary,
197                observable,
198                phase_floor,
199                target_row,
200                target_column,
201            )
202        }
203        ReductionRule::DetectorScalarAreaMean | ReductionRule::DetectorMagnitudeSquaredAreaMean => {
204            let (mut sum, mut correction) = (0.0, 0.0);
205            let mut first_value: Option<f64> = None;
206            let mut constant = true;
207            for row in source_rows {
208                for column in source_columns.clone() {
209                    let index = row * field.columns() + column;
210                    let ScalarSample::Value(value) = project_complex(
211                        field.real()[index],
212                        field.imaginary()[index],
213                        observable,
214                        phase_floor,
215                        row,
216                        column,
217                    )?
218                    else {
219                        unreachable!("amplitude and squared magnitude are never masked");
220                    };
221                    if let Some(first) = first_value {
222                        constant &= value.to_bits() == first.to_bits();
223                    } else {
224                        first_value = Some(value);
225                    }
226                    compensated_add(&mut sum, &mut correction, value * scale);
227                }
228            }
229            let value = if constant {
230                first_value.expect("detector partitions are non-empty")
231            } else {
232                sum + correction
233            };
234            if value.is_finite() {
235                Ok(ScalarSample::Value(value))
236            } else {
237                Err(ProjectionError::NonFiniteSample {
238                    row: target_row,
239                    column: target_column,
240                    observable,
241                    value,
242                })
243            }
244        }
245    }
246}
247
248fn compensated_add(sum: &mut f64, correction: &mut f64, value: f64) {
249    let next = *sum + value;
250    *correction += if sum.abs() >= value.abs() {
251        (*sum - next) + value
252    } else {
253        (value - next) + *sum
254    };
255    *sum = next;
256}
257
258#[cfg(test)]
259#[path = "reduce_tests.rs"]
260mod tests;