Skip to main content

fpm_rs/experiment/
illumination.rs

1//! Illumination geometry, stable source calibration, and acquisition structure.
2//!
3//! Sources normally lie at negative sample `z`, the objective lies at positive
4//! `z`, and incident light propagates approximately along `+z`. Physical source
5//! positions are converted to propagation directions with
6//! `normalize([0, 0, 0] - source_position)`. The transverse propagation-vector
7//! components `(kx, ky)` are also the values used by the image-plane model: a
8//! positive component displaces the Fourier crop toward the corresponding
9//! positive Fourier-grid axis.
10
11use std::collections::BTreeMap;
12
13use serde::{Deserialize, Serialize};
14
15use crate::{Result, error::Error};
16
17use super::{Optics, PlanarLedArray, RotatingLedArc, SphericalLedArm, SphericalLedArray};
18
19/// `(source_index, intensity_weight)` for one incoherent illumination component.
20pub type SourceWeight = (usize, f64);
21/// Sparse acquisition-frame rows of source-indexed intensity weights.
22pub type MultiplexingMatrix = Vec<Vec<SourceWeight>>;
23
24/// Transverse propagation-vector components in radians per metre.
25///
26/// These are physical incident-wave components. Model compilation maps positive
27/// `kx` and `ky` to positive Fourier column and row displacement, respectively;
28/// no additional geometry-dependent sign change is applied.
29#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
30#[serde(deny_unknown_fields)]
31pub struct KVector {
32    /// Transverse angular spatial frequency along sample `x`, in radians per metre.
33    pub kx: f64,
34    /// Transverse angular spatial frequency along sample `y`, in radians per metre.
35    pub ky: f64,
36}
37
38impl KVector {
39    /// Creates a transverse vector `(kx, ky)` in radians per metre.
40    pub const fn new(kx: f64, ky: f64) -> Self {
41        Self { kx, ky }
42    }
43}
44
45/// Arbitrary physical source positions in sample coordinates, in metres.
46#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
47#[serde(deny_unknown_fields)]
48pub struct SourcePositionList {
49    positions_m: Vec<[f64; 3]>,
50}
51
52impl SourcePositionList {
53    /// Stores source positions in sample coordinates.
54    pub fn new(positions_m: Vec<[f64; 3]>) -> Self {
55        Self { positions_m }
56    }
57
58    /// Returns the number of physical sources.
59    pub fn source_count(&self) -> usize {
60        self.positions_m.len()
61    }
62
63    /// Borrows source positions as `(x, y, z)` values in metres.
64    pub fn positions_m(&self) -> &[[f64; 3]] {
65        &self.positions_m
66    }
67
68    /// Resolves positions to source-to-sample directions and transverse vectors.
69    pub fn resolve(&self, optics: &Optics) -> Result<ResolvedSources> {
70        ResolvedSources::from_positions(self.positions_m.clone(), optics)
71    }
72}
73
74/// Wavelength-independent incident propagation directions in sample coordinates.
75///
76/// Unit vectors are the canonical stored and serialized form. Directions must
77/// have non-negative `z`; the on-axis direction is `(0, 0, 1)`.
78#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
79#[serde(deny_unknown_fields)]
80pub struct DirectionList {
81    unit_vectors: Vec<[f64; 3]>,
82}
83
84impl DirectionList {
85    /// Validates and stores already normalized propagation directions.
86    pub fn from_unit_vectors(unit_vectors: Vec<[f64; 3]>) -> Result<Self> {
87        Self::from_vectors(unit_vectors, false)
88    }
89
90    /// Stores propagation vectors, optionally normalizing each input vector.
91    pub fn from_vectors(mut vectors: Vec<[f64; 3]>, normalize: bool) -> Result<Self> {
92        if vectors.is_empty() {
93            return Err(invalid(
94                "unit_vectors",
95                "at least one direction is required",
96            ));
97        }
98        for vector in &mut vectors {
99            if vector.iter().any(|value| !value.is_finite()) {
100                return Err(invalid("unit_vectors", "components must be finite"));
101            }
102            let norm = vector[0].hypot(vector[1]).hypot(vector[2]);
103            if !norm.is_finite() || norm <= 0.0 {
104                return Err(invalid(
105                    "unit_vectors",
106                    "vectors must have non-zero finite norm",
107                ));
108            }
109            if normalize {
110                for component in vector.iter_mut() {
111                    *component /= norm;
112                }
113            } else if (norm - 1.0).abs() > 128.0 * f64::EPSILON {
114                return Err(invalid("unit_vectors", "vectors must be normalized"));
115            }
116            if vector[2] < -128.0 * f64::EPSILON {
117                return Err(invalid(
118                    "unit_vectors",
119                    "directions must lie in the positive-z illumination hemisphere",
120                ));
121            }
122            vector[2] = vector[2].max(0.0);
123        }
124        Ok(Self {
125            unit_vectors: vectors,
126        })
127    }
128
129    /// Constructs directions from transverse direction cosines `(dx, dy)`.
130    pub fn from_direction_cosines(values: Vec<[f64; 2]>) -> Result<Self> {
131        Self::from_transverse_components(values, |value| value)
132    }
133
134    /// Constructs directions from independent component angles `(theta_x, theta_y)` in radians.
135    pub fn from_component_angles_radians(values: Vec<[f64; 2]>) -> Result<Self> {
136        Self::from_transverse_components(values, f64::sin)
137    }
138
139    /// Constructs directions from independent component angles in degrees.
140    pub fn from_component_angles_degrees(values: Vec<[f64; 2]>) -> Result<Self> {
141        Self::from_component_angles_radians(
142            values
143                .into_iter()
144                .map(|[x, y]| [x.to_radians(), y.to_radians()])
145                .collect(),
146        )
147    }
148
149    /// Constructs directions from polar angle `theta` and azimuth `phi`, in radians.
150    pub fn from_polar_angles_radians(values: Vec<[f64; 2]>) -> Result<Self> {
151        let vectors = values
152            .into_iter()
153            .map(|[theta, phi]| {
154                let (sin_theta, cos_theta) = theta.sin_cos();
155                let (sin_phi, cos_phi) = phi.sin_cos();
156                [sin_theta * cos_phi, sin_theta * sin_phi, cos_theta]
157            })
158            .collect();
159        Self::from_unit_vectors(vectors)
160    }
161
162    /// Constructs directions from polar angle `theta` and azimuth `phi`, in degrees.
163    pub fn from_polar_angles_degrees(values: Vec<[f64; 2]>) -> Result<Self> {
164        Self::from_polar_angles_radians(
165            values
166                .into_iter()
167                .map(|[theta, phi]| [theta.to_radians(), phi.to_radians()])
168                .collect(),
169        )
170    }
171
172    fn from_transverse_components(values: Vec<[f64; 2]>, map: impl Fn(f64) -> f64) -> Result<Self> {
173        let mut vectors = Vec::with_capacity(values.len());
174        for [x, y] in values {
175            if !x.is_finite() || !y.is_finite() {
176                return Err(invalid("directions", "components must be finite"));
177            }
178            let dx = map(x);
179            let dy = map(y);
180            let transverse_squared = dx.mul_add(dx, dy * dy);
181            if transverse_squared > 1.0 + 128.0 * f64::EPSILON {
182                return Err(invalid(
183                    "directions",
184                    "squared transverse direction magnitude cannot exceed one",
185                ));
186            }
187            vectors.push([dx, dy, (1.0 - transverse_squared).max(0.0).sqrt()]);
188        }
189        Self::from_unit_vectors(vectors)
190    }
191
192    /// Returns the number of directions.
193    pub fn source_count(&self) -> usize {
194        self.unit_vectors.len()
195    }
196
197    /// Borrows the canonical propagation unit vectors.
198    pub fn unit_vectors(&self) -> &[[f64; 3]] {
199        &self.unit_vectors
200    }
201
202    /// Allocates transverse direction cosines `(dx, dy)`.
203    pub fn direction_cosines(&self) -> Vec<[f64; 2]> {
204        self.unit_vectors.iter().map(|v| [v[0], v[1]]).collect()
205    }
206
207    /// Allocates independent component angles `(asin(dx), asin(dy))` in radians.
208    pub fn component_angles_rad(&self) -> Vec<[f64; 2]> {
209        self.unit_vectors
210            .iter()
211            .map(|v| [v[0].asin(), v[1].asin()])
212            .collect()
213    }
214
215    /// Allocates independent component angles in degrees.
216    pub fn component_angles_deg(&self) -> Vec<[f64; 2]> {
217        self.component_angles_rad()
218            .into_iter()
219            .map(|[x, y]| [x.to_degrees(), y.to_degrees()])
220            .collect()
221    }
222
223    /// Allocates polar `(theta, phi)` angles in radians.
224    pub fn polar_angles_rad(&self) -> Vec<[f64; 2]> {
225        self.unit_vectors
226            .iter()
227            .map(|v| [v[2].clamp(-1.0, 1.0).acos(), v[1].atan2(v[0])])
228            .collect()
229    }
230
231    /// Allocates polar `(theta, phi)` angles in degrees.
232    pub fn polar_angles_deg(&self) -> Vec<[f64; 2]> {
233        self.polar_angles_rad()
234            .into_iter()
235            .map(|[theta, phi]| [theta.to_degrees(), phi.to_degrees()])
236            .collect()
237    }
238
239    /// Resolves directions with the illumination wavenumber from `optics`.
240    pub fn resolve(&self, optics: &Optics) -> Result<ResolvedSources> {
241        optics.validate()?;
242        let k = optics.illumination_wavenumber();
243        let k_vectors = self
244            .unit_vectors
245            .iter()
246            .map(|direction| KVector::new(k * direction[0], k * direction[1]))
247            .collect();
248        Ok(ResolvedSources {
249            directions: self.unit_vectors.clone(),
250            k_vectors,
251            positions_m: None,
252        })
253    }
254}
255
256/// Explicit wavelength-dependent transverse vectors in radians per metre.
257///
258/// Resolving with different optics preserves `(kx, ky)`, not illumination angle.
259#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
260#[serde(deny_unknown_fields)]
261pub struct KVectorList {
262    k_vectors: Vec<KVector>,
263}
264
265impl KVectorList {
266    /// Stores source-order transverse vectors for validation during resolution.
267    pub fn new(k_vectors: Vec<KVector>) -> Self {
268        Self { k_vectors }
269    }
270
271    /// Returns the number of vectors.
272    pub fn source_count(&self) -> usize {
273        self.k_vectors.len()
274    }
275
276    /// Borrows transverse vectors in radians per metre.
277    pub fn k_vectors(&self) -> &[KVector] {
278        &self.k_vectors
279    }
280
281    /// Validates propagating compatibility and resolves positive-z directions.
282    pub fn resolve(&self, optics: &Optics) -> Result<ResolvedSources> {
283        validate_k_vectors(&self.k_vectors, optics)?;
284        let k = optics.illumination_wavenumber();
285        let directions = self
286            .k_vectors
287            .iter()
288            .map(|vector| {
289                let dx = vector.kx / k;
290                let dy = vector.ky / k;
291                [dx, dy, (1.0 - dx.mul_add(dx, dy * dy)).max(0.0).sqrt()]
292            })
293            .collect();
294        Ok(ResolvedSources {
295            directions,
296            k_vectors: self.k_vectors.clone(),
297            positions_m: None,
298        })
299    }
300}
301
302/// Serializable physical or direct source geometry.
303#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
304#[serde(tag = "kind", rename_all = "snake_case")]
305pub enum SourceGeometry {
306    /// Regular planar LED array.
307    #[serde(rename = "planar_led_array")]
308    PlanarArray(PlanarLedArray),
309    /// Fixed spherical LED array.
310    #[serde(rename = "spherical_led_array")]
311    SphericalArray(SphericalLedArray),
312    /// Mechanically moved spherical LED arm.
313    #[serde(rename = "spherical_led_arm")]
314    SphericalArm(SphericalLedArm),
315    /// LED arc sampled over commanded rotations.
316    #[serde(rename = "rotating_led_arc")]
317    RotatingArc(RotatingLedArc),
318    /// Arbitrary physical positions.
319    #[serde(rename = "source_position_list")]
320    Positions(SourcePositionList),
321    /// Canonical propagation directions.
322    #[serde(rename = "direction_list")]
323    Directions(DirectionList),
324    /// Direct transverse vectors.
325    #[serde(rename = "k_vector_list")]
326    KVectors(KVectorList),
327}
328
329impl SourceGeometry {
330    /// Returns the number of physical or direct sources before acquisition planning.
331    pub fn source_count(&self) -> usize {
332        match self {
333            Self::PlanarArray(value) => value.source_count(),
334            Self::SphericalArray(value) => value.source_count(),
335            Self::SphericalArm(value) => value.source_count(),
336            Self::RotatingArc(value) => value.source_count(),
337            Self::Positions(value) => value.source_count(),
338            Self::Directions(value) => value.source_count(),
339            Self::KVectors(value) => value.source_count(),
340        }
341    }
342
343    /// Atomically resolves geometry using the wavelength and media in `optics`.
344    pub fn resolve(&self, optics: &Optics) -> Result<ResolvedSources> {
345        match self {
346            Self::PlanarArray(value) => value.resolve(optics),
347            Self::SphericalArray(value) => value.resolve(optics),
348            Self::SphericalArm(value) => value.resolve(optics),
349            Self::RotatingArc(value) => value.resolve(optics),
350            Self::Positions(value) => value.resolve(optics),
351            Self::Directions(value) => value.resolve(optics),
352            Self::KVectors(value) => value.resolve(optics),
353        }
354    }
355}
356
357macro_rules! geometry_from {
358    ($type:ty, $variant:ident) => {
359        impl From<$type> for SourceGeometry {
360            fn from(value: $type) -> Self {
361                Self::$variant(value)
362            }
363        }
364    };
365}
366
367geometry_from!(PlanarLedArray, PlanarArray);
368geometry_from!(SphericalLedArray, SphericalArray);
369geometry_from!(SphericalLedArm, SphericalArm);
370geometry_from!(RotatingLedArc, RotatingArc);
371geometry_from!(SourcePositionList, Positions);
372geometry_from!(DirectionList, Directions);
373geometry_from!(KVectorList, KVectors);
374
375/// Inspectable geometry resolved with a particular [`Optics`] configuration.
376#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
377#[serde(deny_unknown_fields)]
378pub struct ResolvedSources {
379    directions: Vec<[f64; 3]>,
380    k_vectors: Vec<KVector>,
381    positions_m: Option<Vec<[f64; 3]>>,
382}
383
384impl ResolvedSources {
385    pub(crate) fn from_positions(positions_m: Vec<[f64; 3]>, optics: &Optics) -> Result<Self> {
386        optics.validate()?;
387        if positions_m.is_empty() {
388            return Err(invalid(
389                "positions_m",
390                "at least one source position is required",
391            ));
392        }
393        let mut directions = Vec::with_capacity(positions_m.len());
394        for position in &positions_m {
395            if position.iter().any(|value| !value.is_finite()) {
396                return Err(invalid("positions_m", "position components must be finite"));
397            }
398            let norm = position[0].hypot(position[1]).hypot(position[2]);
399            if !norm.is_finite() || norm <= 0.0 {
400                return Err(invalid(
401                    "positions_m",
402                    "no source may coincide with the sample origin",
403                ));
404            }
405            directions.push([
406                -position[0] / norm,
407                -position[1] / norm,
408                -position[2] / norm,
409            ]);
410        }
411        let k = optics.illumination_wavenumber();
412        let k_vectors = directions
413            .iter()
414            .map(|direction| KVector::new(k * direction[0], k * direction[1]))
415            .collect();
416        Ok(Self {
417            directions,
418            k_vectors,
419            positions_m: Some(positions_m),
420        })
421    }
422
423    /// Returns the source count.
424    pub fn source_count(&self) -> usize {
425        self.k_vectors.len()
426    }
427
428    /// Borrows source-order propagation unit vectors.
429    pub fn directions(&self) -> &[[f64; 3]] {
430        &self.directions
431    }
432
433    /// Borrows source-order transverse vectors in radians per metre.
434    pub fn k_vectors(&self) -> &[KVector] {
435        &self.k_vectors
436    }
437
438    /// Borrows physical positions in metres when the geometry defines them.
439    pub fn positions_m(&self) -> Option<&[[f64; 3]]> {
440        self.positions_m.as_deref()
441    }
442}
443
444/// Stable, source-indexed optical calibration independent of acquisition order.
445#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
446#[serde(deny_unknown_fields)]
447pub struct SourceCalibration {
448    relative_power: Option<Vec<f64>>,
449}
450
451impl SourceCalibration {
452    /// Creates a calibration with optional dimensionless relative source powers.
453    pub fn new(relative_power: Option<Vec<f64>>) -> Self {
454        Self { relative_power }
455    }
456
457    /// Creates the default unit-power calibration.
458    pub const fn unity() -> Self {
459        Self {
460            relative_power: None,
461        }
462    }
463
464    /// Borrows explicitly configured relative powers, if present.
465    pub fn relative_power(&self) -> Option<&[f64]> {
466        self.relative_power.as_deref()
467    }
468
469    fn resolve(&self, source_count: usize) -> Result<Vec<f64>> {
470        match &self.relative_power {
471            Some(values)
472                if values.len() != source_count
473                    || values
474                        .iter()
475                        .any(|value| !value.is_finite() || *value < 0.0) =>
476            {
477                Err(invalid(
478                    "relative_power",
479                    format!("must contain {source_count} finite non-negative values"),
480                ))
481            }
482            Some(values) => Ok(values.clone()),
483            None => Ok(vec![1.0; source_count]),
484        }
485    }
486}
487
488/// One source contribution to an acquired intensity frame.
489#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
490#[serde(deny_unknown_fields)]
491pub struct SourceContribution {
492    /// Physical source index.
493    pub source: usize,
494    /// Dimensionless, non-negative intensity weight.
495    pub intensity_weight: f64,
496}
497
498impl SourceContribution {
499    /// Creates a source-indexed intensity contribution.
500    pub const fn new(source: usize, intensity_weight: f64) -> Self {
501        Self {
502            source,
503            intensity_weight,
504        }
505    }
506}
507
508/// Sparse mutually incoherent source contributions and gain for one frame.
509#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
510#[serde(deny_unknown_fields)]
511pub struct IlluminationFrame {
512    /// Sparse source contributions. Acquisition construction canonicalizes them.
513    pub contributions: Vec<SourceContribution>,
514    /// Dimensionless, non-negative intensity gain applied to the summed frame.
515    pub gain: f64,
516}
517
518impl IlluminationFrame {
519    /// Creates a frame for validation and canonicalization by [`AcquisitionPlan`].
520    pub fn new(contributions: Vec<SourceContribution>, gain: f64) -> Self {
521        Self {
522            contributions,
523            gain,
524        }
525    }
526}
527
528/// Sparse source-to-frame acquisition structure.
529///
530/// Predicted intensity is `gain[f] * sum_s(weight[f,s] * power[s] * I_s)`.
531/// Source contributions are mutually incoherent; every multiplier acts on
532/// intensity rather than field amplitude and is not automatically normalized.
533#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
534#[serde(deny_unknown_fields)]
535pub struct AcquisitionPlan {
536    frames: Vec<IlluminationFrame>,
537}
538
539impl AcquisitionPlan {
540    /// Creates one unit-gain frame per source in natural order.
541    pub fn all_sources(source_count: usize) -> Result<Self> {
542        Self::sequential((0..source_count).collect())
543    }
544
545    /// Creates one unit-gain frame per listed source.
546    ///
547    /// The order may omit or repeat sources. Index range is checked when the
548    /// complete [`Illumination`] is resolved.
549    pub fn sequential(order: Vec<usize>) -> Result<Self> {
550        let frames = order
551            .into_iter()
552            .map(|source| IlluminationFrame::new(vec![SourceContribution::new(source, 1.0)], 1.0))
553            .collect();
554        Self::from_sparse(frames)
555    }
556
557    /// Canonicalizes sparse frames by merging duplicates and removing zero weights.
558    pub fn from_sparse(frames: Vec<IlluminationFrame>) -> Result<Self> {
559        if frames.is_empty() {
560            return Err(invalid(
561                "frames",
562                "at least one acquisition frame is required",
563            ));
564        }
565        let mut canonical = Vec::with_capacity(frames.len());
566        for (frame_index, frame) in frames.into_iter().enumerate() {
567            if !frame.gain.is_finite() || frame.gain < 0.0 {
568                return Err(invalid(
569                    "gain",
570                    "frame gains must be finite and non-negative",
571                ));
572            }
573            let mut merged = BTreeMap::<usize, f64>::new();
574            for contribution in frame.contributions {
575                if !contribution.intensity_weight.is_finite() || contribution.intensity_weight < 0.0
576                {
577                    return Err(invalid(
578                        "intensity_weight",
579                        "source weights must be finite and non-negative",
580                    ));
581                }
582                if contribution.intensity_weight != 0.0 {
583                    let value = merged.entry(contribution.source).or_insert(0.0);
584                    *value += contribution.intensity_weight;
585                    if !value.is_finite() {
586                        return Err(invalid("intensity_weight", "merged weight is not finite"));
587                    }
588                }
589            }
590            if merged.is_empty() {
591                return Err(invalid(
592                    "contributions",
593                    format!("frame {frame_index} is empty after canonicalization"),
594                ));
595            }
596            canonical.push(IlluminationFrame {
597                contributions: merged
598                    .into_iter()
599                    .map(|(source, intensity_weight)| {
600                        SourceContribution::new(source, intensity_weight)
601                    })
602                    .collect(),
603                gain: frame.gain,
604            });
605        }
606        Ok(Self { frames: canonical })
607    }
608
609    /// Converts a dense `(frames, sources)` matrix to canonical sparse storage.
610    pub fn from_dense(weights: Vec<Vec<f64>>) -> Result<Self> {
611        if weights.is_empty() {
612            return Err(invalid("weights", "at least one dense frame is required"));
613        }
614        let source_count = weights[0].len();
615        if source_count == 0 || weights.iter().any(|row| row.len() != source_count) {
616            return Err(invalid(
617                "weights",
618                "dense rows must have one common non-zero source count",
619            ));
620        }
621        Self::from_sparse(
622            weights
623                .into_iter()
624                .map(|row| {
625                    IlluminationFrame::new(
626                        row.into_iter()
627                            .enumerate()
628                            .map(|(source, weight)| SourceContribution::new(source, weight))
629                            .collect(),
630                        1.0,
631                    )
632                })
633                .collect(),
634        )
635    }
636
637    /// Returns the number of acquired frames.
638    pub fn frame_count(&self) -> usize {
639        self.frames.len()
640    }
641
642    /// Borrows canonical sparse frames.
643    pub fn frames(&self) -> &[IlluminationFrame] {
644        &self.frames
645    }
646
647    /// Allocates dense `(frames, sources)` row-major weights.
648    pub fn dense_weights(&self, source_count: usize) -> Result<Vec<Vec<f64>>> {
649        self.validate_indices(source_count)?;
650        let mut dense = vec![vec![0.0; source_count]; self.frames.len()];
651        for (frame_index, frame) in self.frames.iter().enumerate() {
652            for contribution in &frame.contributions {
653                dense[frame_index][contribution.source] = contribution.intensity_weight;
654            }
655        }
656        Ok(dense)
657    }
658
659    fn validate_indices(&self, source_count: usize) -> Result<()> {
660        if self
661            .frames
662            .iter()
663            .flat_map(|frame| &frame.contributions)
664            .any(|contribution| contribution.source >= source_count)
665        {
666            return Err(invalid(
667                "source",
668                format!("source indices must be less than {source_count}"),
669            ));
670        }
671        Ok(())
672    }
673}
674
675/// Complete serializable illumination configuration.
676#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
677#[serde(deny_unknown_fields)]
678pub struct Illumination {
679    geometry: SourceGeometry,
680    calibration: SourceCalibration,
681    acquisition: AcquisitionPlan,
682}
683
684impl Illumination {
685    /// Creates a complete configuration from separate source concepts.
686    pub const fn new(
687        geometry: SourceGeometry,
688        calibration: SourceCalibration,
689        acquisition: AcquisitionPlan,
690    ) -> Self {
691        Self {
692            geometry,
693            calibration,
694            acquisition,
695        }
696    }
697
698    /// Creates unit-power sequential acquisition for every geometry source.
699    pub fn from_geometry(geometry: impl Into<SourceGeometry>) -> Result<Self> {
700        let geometry = geometry.into();
701        let acquisition = AcquisitionPlan::all_sources(geometry.source_count())?;
702        Ok(Self::new(geometry, SourceCalibration::unity(), acquisition))
703    }
704
705    /// Borrows the physical or direct source geometry.
706    pub const fn geometry(&self) -> &SourceGeometry {
707        &self.geometry
708    }
709
710    /// Borrows the stable source calibration.
711    pub const fn calibration(&self) -> &SourceCalibration {
712        &self.calibration
713    }
714
715    /// Borrows the acquisition structure.
716    pub const fn acquisition(&self) -> &AcquisitionPlan {
717        &self.acquisition
718    }
719
720    /// Replaces the source geometry while retaining calibration and acquisition state.
721    pub fn with_geometry(mut self, geometry: impl Into<SourceGeometry>) -> Self {
722        self.geometry = geometry.into();
723        self
724    }
725
726    /// Replaces stable source-power calibration while retaining geometry and acquisition.
727    pub fn with_calibration(mut self, calibration: SourceCalibration) -> Self {
728        self.calibration = calibration;
729        self
730    }
731
732    /// Replaces sparse acquisition weights and frame gains while retaining source state.
733    pub fn with_acquisition(mut self, acquisition: AcquisitionPlan) -> Self {
734        self.acquisition = acquisition;
735        self
736    }
737
738    /// Atomically resolves geometry, calibration, acquisition weights, and gains.
739    pub fn resolve(&self, optics: &Optics) -> Result<ResolvedIllumination> {
740        let sources = self.geometry.resolve(optics)?;
741        let source_count = sources.source_count();
742        self.acquisition.validate_indices(source_count)?;
743        let source_power = self.calibration.resolve(source_count)?;
744        let frames = self
745            .acquisition
746            .frames
747            .iter()
748            .map(|frame| ResolvedFrame {
749                contributions: frame.contributions.clone(),
750                gain: frame.gain,
751            })
752            .collect();
753        Ok(ResolvedIllumination {
754            sources,
755            frames,
756            source_power,
757        })
758    }
759}
760
761/// Canonical sparse acquisition frame after complete illumination validation.
762#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
763#[serde(deny_unknown_fields)]
764pub struct ResolvedFrame {
765    contributions: Vec<SourceContribution>,
766    gain: f64,
767}
768
769impl ResolvedFrame {
770    /// Borrows source-indexed intensity contributions.
771    pub fn contributions(&self) -> &[SourceContribution] {
772        &self.contributions
773    }
774
775    /// Returns the explicit dimensionless frame gain.
776    pub const fn gain(&self) -> f64 {
777        self.gain
778    }
779}
780
781/// Complete illumination state resolved for one optical configuration.
782#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
783#[serde(deny_unknown_fields)]
784pub struct ResolvedIllumination {
785    sources: ResolvedSources,
786    frames: Vec<ResolvedFrame>,
787    source_power: Vec<f64>,
788}
789
790impl ResolvedIllumination {
791    /// Borrows resolved source geometry.
792    pub const fn sources(&self) -> &ResolvedSources {
793        &self.sources
794    }
795
796    /// Returns the number of physical sources.
797    pub fn source_count(&self) -> usize {
798        self.sources.source_count()
799    }
800
801    /// Returns the number of acquired frames.
802    pub fn frame_count(&self) -> usize {
803        self.frames.len()
804    }
805
806    /// Returns whether any frame combines more than one source.
807    pub fn is_multiplexed(&self) -> bool {
808        self.frames
809            .iter()
810            .any(|frame| frame.contributions.len() > 1)
811    }
812
813    /// Borrows canonical sparse frames.
814    pub fn frames(&self) -> &[ResolvedFrame] {
815        &self.frames
816    }
817
818    /// Borrows explicit source powers, including default unit values.
819    pub fn source_power(&self) -> &[f64] {
820        &self.source_power
821    }
822
823    /// Returns explicit frame gains, including default unit values.
824    pub fn frame_gains(&self) -> Vec<f64> {
825        self.frames.iter().map(|frame| frame.gain).collect()
826    }
827
828    /// Borrows resolved incident directions.
829    pub fn directions(&self) -> &[[f64; 3]] {
830        self.sources.directions()
831    }
832
833    /// Borrows physical source positions when available.
834    pub fn positions_m(&self) -> Option<&[[f64; 3]]> {
835        self.sources.positions_m()
836    }
837
838    /// Borrows transverse propagation vectors in radians per metre.
839    pub fn k_vectors(&self) -> &[KVector] {
840        self.sources.k_vectors()
841    }
842
843    /// Allocates acquisition weights shaped `(frame_count, source_count)`.
844    pub fn dense_weights(&self) -> Vec<Vec<f64>> {
845        let mut dense = vec![vec![0.0; self.source_count()]; self.frame_count()];
846        for (frame_index, frame) in self.frames.iter().enumerate() {
847            for contribution in &frame.contributions {
848                dense[frame_index][contribution.source] = contribution.intensity_weight;
849            }
850        }
851        dense
852    }
853
854    pub(crate) fn compiled_multiplexing_matrix(&self) -> MultiplexingMatrix {
855        self.frames
856            .iter()
857            .map(|frame| {
858                frame
859                    .contributions
860                    .iter()
861                    .map(|contribution| {
862                        (
863                            contribution.source,
864                            contribution.intensity_weight * self.source_power[contribution.source],
865                        )
866                    })
867                    .collect()
868            })
869            .collect()
870    }
871}
872
873fn validate_k_vectors(vectors: &[KVector], optics: &Optics) -> Result<()> {
874    optics.validate()?;
875    if vectors.is_empty() {
876        return Err(invalid("k_vectors", "at least one source is required"));
877    }
878    let maximum_transverse = optics.illumination_wavenumber() * (1.0 + 128.0 * f64::EPSILON);
879    if vectors.iter().any(|vector| {
880        !vector.kx.is_finite()
881            || !vector.ky.is_finite()
882            || vector.kx.hypot(vector.ky) > maximum_transverse
883    }) {
884        return Err(invalid(
885            "k_vectors",
886            "vectors must be finite and propagating at the configured vacuum wavelength and illumination refractive index",
887        ));
888    }
889    Ok(())
890}
891
892fn invalid(name: &'static str, reason: impl Into<String>) -> Error {
893    Error::InvalidParameter {
894        name,
895        reason: reason.into(),
896    }
897}