Skip to main content

fpm_rs/experiment/
spherical.rs

1use serde::{Deserialize, Serialize};
2
3use crate::error::{Error, Result};
4
5use super::{IlluminationSource, KVector, Optics};
6
7/// Fixed LEDs mounted on a spherical surface around the sample.
8///
9/// Each angle is `(theta, phi)` in radians. `theta` is the polar angle from
10/// the positive optical (`z`) axis and `phi` is the azimuth from positive `x`
11/// toward positive `y`. The sphere pose is applied as `Rz * Ry * Rx`.
12#[derive(Clone, Debug, Serialize, Deserialize)]
13pub struct LEDSphere {
14    pub angles: Vec<(f64, f64)>,
15    pub radius: f64,
16    /// Sphere-centre displacement `(x, y, z)` from the sample, in metres.
17    pub center_offset: (f64, f64, f64),
18    /// Rigid mount rotation `(rx, ry, rz)` in radians.
19    pub orientation_radians: (f64, f64, f64),
20    /// Optional per-LED `(delta_theta, delta_phi)` placement corrections.
21    pub angular_corrections: Option<Vec<(f64, f64)>>,
22    pub wavelength_override: Option<f64>,
23    pub illumination_order: Option<Vec<usize>>,
24    pub intensity_weights: Option<Vec<f64>>,
25}
26
27impl LEDSphere {
28    pub fn new(angles: Vec<(f64, f64)>, radius: f64) -> Self {
29        Self {
30            angles,
31            radius,
32            center_offset: (0.0, 0.0, 0.0),
33            orientation_radians: (0.0, 0.0, 0.0),
34            angular_corrections: None,
35            wavelength_override: None,
36            illumination_order: None,
37            intensity_weights: None,
38        }
39    }
40
41    pub fn center_offset(mut self, offset: (f64, f64, f64)) -> Self {
42        self.center_offset = offset;
43        self
44    }
45
46    pub fn orientation_deg(mut self, degrees: (f64, f64, f64)) -> Self {
47        self.orientation_radians = (
48            degrees.0.to_radians(),
49            degrees.1.to_radians(),
50            degrees.2.to_radians(),
51        );
52        self
53    }
54
55    pub fn angular_corrections(mut self, corrections: Vec<(f64, f64)>) -> Self {
56        self.angular_corrections = Some(corrections);
57        self
58    }
59
60    pub fn wavelength_override(mut self, wavelength: f64) -> Self {
61        self.wavelength_override = Some(wavelength);
62        self
63    }
64
65    pub fn illumination_order(mut self, order: Vec<usize>) -> Self {
66        self.illumination_order = Some(order);
67        self
68    }
69
70    pub fn intensity_weights(mut self, weights: Vec<f64>) -> Self {
71        self.intensity_weights = Some(weights);
72        self
73    }
74
75    pub fn validate(&self) -> Result<()> {
76        validate_angle_list(&self.angles, "LED sphere angles")?;
77        validate_length(self.radius, "radius")?;
78        validate_pose(self.center_offset, self.orientation_radians)?;
79        if let Some(corrections) = &self.angular_corrections
80            && (corrections.len() != self.angles.len()
81                || corrections
82                    .iter()
83                    .any(|&(theta, phi)| !theta.is_finite() || !phi.is_finite()))
84        {
85            return Err(Error::InvalidParameter {
86                name: "angular_corrections",
87                reason: format!(
88                    "must contain {} finite (delta_theta, delta_phi) pairs",
89                    self.angles.len()
90                ),
91            });
92        }
93        validate_wavelength(self.wavelength_override)?;
94        validate_order(self.illumination_order.as_deref(), self.angles.len())?;
95        validate_weights(self.intensity_weights.as_deref(), self.angles.len())
96    }
97
98    fn natural_positions(&self) -> Result<Vec<[f64; 3]>> {
99        self.angles
100            .iter()
101            .enumerate()
102            .map(|(index, &(theta, phi))| {
103                let (delta_theta, delta_phi) = self
104                    .angular_corrections
105                    .as_ref()
106                    .map_or((0.0, 0.0), |values| values[index]);
107                let direction = spherical_direction(theta + delta_theta, phi + delta_phi);
108                source_position(
109                    scale(direction, self.radius),
110                    self.center_offset,
111                    self.orientation_radians,
112                    "LED sphere",
113                )
114            })
115            .collect()
116    }
117}
118
119impl IlluminationSource for LEDSphere {
120    fn k_vectors(&self, optics: &Optics) -> Result<Vec<KVector>> {
121        self.validate()?;
122        optics.validate()?;
123        let k = wavenumber(self.wavelength_override, optics);
124        let natural = self
125            .natural_positions()?
126            .into_iter()
127            .map(|position| position_to_k_vector(position, k))
128            .collect::<Vec<_>>();
129        Ok(reorder(natural, self.illumination_order.as_deref()))
130    }
131
132    fn frame_gains(&self) -> Result<Option<Vec<f64>>> {
133        self.validate()?;
134        Ok(self
135            .intensity_weights
136            .as_ref()
137            .map(|weights| reorder(weights.clone(), self.illumination_order.as_deref())))
138    }
139}
140
141/// A single LED moved over a spherical trajectory by an azimuth/elevation arm.
142///
143/// `commanded_angles` are `(theta, phi)` encoder commands in acquisition order.
144/// The kinematic model includes encoder affine errors, direction-dependent
145/// backlash, inner-axis non-orthogonality, arm-pivot displacement, and a rigid
146/// mount rotation. Azimuth commands should be unwrapped when crossing `+-pi` so
147/// that backlash direction is unambiguous.
148#[derive(Clone, Debug, Serialize, Deserialize)]
149pub struct SphericalLEDArm {
150    pub commanded_angles: Vec<(f64, f64)>,
151    pub arm_length: f64,
152    /// Arm-pivot displacement `(x, y, z)` from the sample, in metres.
153    pub pivot_offset: (f64, f64, f64),
154    /// Rigid mount rotation `(rx, ry, rz)` in radians.
155    pub orientation_radians: (f64, f64, f64),
156    pub theta_zero_radians: f64,
157    pub phi_zero_radians: f64,
158    pub theta_scale: f64,
159    pub phi_scale: f64,
160    /// Tilt of the elevation axis toward the azimuth axis, in radians.
161    pub elevation_axis_tilt_radians: f64,
162    /// Total separation between increasing and decreasing encoder branches.
163    pub theta_backlash_radians: f64,
164    /// Total separation between increasing and decreasing encoder branches.
165    pub phi_backlash_radians: f64,
166    pub wavelength_override: Option<f64>,
167    pub intensity_weights: Option<Vec<f64>>,
168}
169
170impl SphericalLEDArm {
171    pub fn new(commanded_angles: Vec<(f64, f64)>, arm_length: f64) -> Self {
172        Self {
173            commanded_angles,
174            arm_length,
175            pivot_offset: (0.0, 0.0, 0.0),
176            orientation_radians: (0.0, 0.0, 0.0),
177            theta_zero_radians: 0.0,
178            phi_zero_radians: 0.0,
179            theta_scale: 1.0,
180            phi_scale: 1.0,
181            elevation_axis_tilt_radians: 0.0,
182            theta_backlash_radians: 0.0,
183            phi_backlash_radians: 0.0,
184            wavelength_override: None,
185            intensity_weights: None,
186        }
187    }
188
189    pub fn pivot_offset(mut self, offset: (f64, f64, f64)) -> Self {
190        self.pivot_offset = offset;
191        self
192    }
193
194    pub fn orientation_deg(mut self, degrees: (f64, f64, f64)) -> Self {
195        self.orientation_radians = (
196            degrees.0.to_radians(),
197            degrees.1.to_radians(),
198            degrees.2.to_radians(),
199        );
200        self
201    }
202
203    pub fn encoder_zero_deg(mut self, theta: f64, phi: f64) -> Self {
204        self.theta_zero_radians = theta.to_radians();
205        self.phi_zero_radians = phi.to_radians();
206        self
207    }
208
209    pub fn encoder_scale(mut self, theta: f64, phi: f64) -> Self {
210        self.theta_scale = theta;
211        self.phi_scale = phi;
212        self
213    }
214
215    pub fn elevation_axis_tilt_deg(mut self, degrees: f64) -> Self {
216        self.elevation_axis_tilt_radians = degrees.to_radians();
217        self
218    }
219
220    pub fn backlash_deg(mut self, theta: f64, phi: f64) -> Self {
221        self.theta_backlash_radians = theta.to_radians();
222        self.phi_backlash_radians = phi.to_radians();
223        self
224    }
225
226    pub fn wavelength_override(mut self, wavelength: f64) -> Self {
227        self.wavelength_override = Some(wavelength);
228        self
229    }
230
231    pub fn intensity_weights(mut self, weights: Vec<f64>) -> Self {
232        self.intensity_weights = Some(weights);
233        self
234    }
235
236    pub fn validate(&self) -> Result<()> {
237        validate_angle_list(&self.commanded_angles, "arm commanded_angles")?;
238        validate_length(self.arm_length, "arm_length")?;
239        validate_pose(self.pivot_offset, self.orientation_radians)?;
240        if !self.theta_zero_radians.is_finite()
241            || !self.phi_zero_radians.is_finite()
242            || !self.theta_scale.is_finite()
243            || self.theta_scale <= 0.0
244            || !self.phi_scale.is_finite()
245            || self.phi_scale <= 0.0
246        {
247            return Err(Error::InvalidParameter {
248                name: "arm encoder calibration",
249                reason: "zero offsets must be finite and scales must be finite and positive".into(),
250            });
251        }
252        if !self.elevation_axis_tilt_radians.is_finite()
253            || self.elevation_axis_tilt_radians.abs() >= std::f64::consts::FRAC_PI_2
254        {
255            return Err(Error::InvalidParameter {
256                name: "elevation_axis_tilt_radians",
257                reason: "must be finite with magnitude less than pi/2".into(),
258            });
259        }
260        if !self.theta_backlash_radians.is_finite()
261            || self.theta_backlash_radians < 0.0
262            || !self.phi_backlash_radians.is_finite()
263            || self.phi_backlash_radians < 0.0
264        {
265            return Err(Error::InvalidParameter {
266                name: "arm backlash",
267                reason: "backlash widths must be finite and non-negative".into(),
268            });
269        }
270        validate_wavelength(self.wavelength_override)?;
271        validate_weights(
272            self.intensity_weights.as_deref(),
273            self.commanded_angles.len(),
274        )
275    }
276
277    fn positions(&self) -> Result<Vec<[f64; 3]>> {
278        let theta_branches = backlash_branches(&self.commanded_angles, 0);
279        let phi_branches = backlash_branches(&self.commanded_angles, 1);
280        let (axis_sin, axis_cos) = self.elevation_axis_tilt_radians.sin_cos();
281        let elevation_axis = [0.0, axis_cos, axis_sin];
282
283        self.commanded_angles
284            .iter()
285            .enumerate()
286            .map(|(index, &(theta_command, phi_command))| {
287                let theta = self.theta_scale * theta_command
288                    + self.theta_zero_radians
289                    + 0.5 * self.theta_backlash_radians * theta_branches[index];
290                let phi = self.phi_scale * phi_command
291                    + self.phi_zero_radians
292                    + 0.5 * self.phi_backlash_radians * phi_branches[index];
293                let home = [0.0, 0.0, self.arm_length];
294                let elevated = rotate_axis_angle(home, elevation_axis, theta);
295                let arm_position = rotate_z(elevated, phi);
296                source_position(
297                    arm_position,
298                    self.pivot_offset,
299                    self.orientation_radians,
300                    "spherical LED arm",
301                )
302            })
303            .collect()
304    }
305}
306
307impl IlluminationSource for SphericalLEDArm {
308    fn k_vectors(&self, optics: &Optics) -> Result<Vec<KVector>> {
309        self.validate()?;
310        optics.validate()?;
311        let k = wavenumber(self.wavelength_override, optics);
312        Ok(self
313            .positions()?
314            .into_iter()
315            .map(|position| position_to_k_vector(position, k))
316            .collect())
317    }
318
319    fn frame_gains(&self) -> Result<Option<Vec<f64>>> {
320        self.validate()?;
321        Ok(self.intensity_weights.clone())
322    }
323}
324
325/// A quarter-circle LED arc rotated about an axis nominally collinear with the
326/// optical axis.
327///
328/// Every LED is compiled at every commanded rotation angle. Sources are
329/// ordered rotation-major and then by `led_thetas`. The nominal arm lies in the
330/// `x-z` plane, with LED polar positions measured from positive `z`.
331#[derive(Clone, Debug, Serialize, Deserialize)]
332pub struct RotatingLEDArc {
333    /// Nominal LED polar positions along the quarter-circle arm, in radians.
334    pub led_thetas: Vec<f64>,
335    /// Commanded arm azimuths in physical movement order, in radians.
336    pub rotation_angles: Vec<f64>,
337    pub radius: f64,
338    /// Point on the rotation axis relative to the sample, in metres.
339    pub axis_origin_offset: (f64, f64, f64),
340    /// Rotation-axis tilt `(rx, ry)` away from the optical axis, in radians.
341    pub axis_tilt_radians: (f64, f64),
342    /// Per-LED `(delta_theta, delta_phi)` mounting corrections.
343    pub led_angular_corrections: Option<Vec<(f64, f64)>>,
344    /// Per-LED radial corrections to the nominal arm radius, in metres.
345    pub led_radial_offsets: Option<Vec<f64>>,
346    pub rotation_zero_radians: f64,
347    pub rotation_scale: f64,
348    /// Total separation between increasing and decreasing rotation branches.
349    pub rotation_backlash_radians: f64,
350    pub wavelength_override: Option<f64>,
351    /// Intrinsic intensity weight for each LED, repeated at every rotation.
352    pub led_intensity_weights: Option<Vec<f64>>,
353}
354
355impl RotatingLEDArc {
356    pub fn new(led_thetas: Vec<f64>, rotation_angles: Vec<f64>, radius: f64) -> Self {
357        Self {
358            led_thetas,
359            rotation_angles,
360            radius,
361            axis_origin_offset: (0.0, 0.0, 0.0),
362            axis_tilt_radians: (0.0, 0.0),
363            led_angular_corrections: None,
364            led_radial_offsets: None,
365            rotation_zero_radians: 0.0,
366            rotation_scale: 1.0,
367            rotation_backlash_radians: 0.0,
368            wavelength_override: None,
369            led_intensity_weights: None,
370        }
371    }
372
373    pub fn axis_origin_offset(mut self, offset: (f64, f64, f64)) -> Self {
374        self.axis_origin_offset = offset;
375        self
376    }
377
378    pub fn axis_tilt_deg(mut self, x: f64, y: f64) -> Self {
379        self.axis_tilt_radians = (x.to_radians(), y.to_radians());
380        self
381    }
382
383    pub fn led_angular_corrections(mut self, corrections: Vec<(f64, f64)>) -> Self {
384        self.led_angular_corrections = Some(corrections);
385        self
386    }
387
388    pub fn led_radial_offsets(mut self, offsets: Vec<f64>) -> Self {
389        self.led_radial_offsets = Some(offsets);
390        self
391    }
392
393    pub fn rotation_encoder_zero_deg(mut self, degrees: f64) -> Self {
394        self.rotation_zero_radians = degrees.to_radians();
395        self
396    }
397
398    pub fn rotation_encoder_scale(mut self, scale: f64) -> Self {
399        self.rotation_scale = scale;
400        self
401    }
402
403    pub fn rotation_backlash_deg(mut self, degrees: f64) -> Self {
404        self.rotation_backlash_radians = degrees.to_radians();
405        self
406    }
407
408    pub fn wavelength_override(mut self, wavelength: f64) -> Self {
409        self.wavelength_override = Some(wavelength);
410        self
411    }
412
413    pub fn led_intensity_weights(mut self, weights: Vec<f64>) -> Self {
414        self.led_intensity_weights = Some(weights);
415        self
416    }
417
418    pub fn source_count(&self) -> Result<usize> {
419        self.led_thetas
420            .len()
421            .checked_mul(self.rotation_angles.len())
422            .ok_or_else(|| Error::InvalidParameter {
423                name: "rotating LED arc",
424                reason: "source count overflows".into(),
425            })
426    }
427
428    pub fn validate(&self) -> Result<()> {
429        if self.led_thetas.is_empty()
430            || self.led_thetas.iter().any(|&theta| {
431                !theta.is_finite() || !(0.0..std::f64::consts::FRAC_PI_2).contains(&theta)
432            })
433        {
434            return Err(Error::InvalidParameter {
435                name: "led_thetas",
436                reason: "must contain at least one finite angle with 0 <= theta < pi/2".into(),
437            });
438        }
439        if self.rotation_angles.is_empty()
440            || self.rotation_angles.iter().any(|value| !value.is_finite())
441        {
442            return Err(Error::InvalidParameter {
443                name: "rotation_angles",
444                reason: "must contain at least one finite commanded azimuth".into(),
445            });
446        }
447        self.source_count()?;
448        validate_length(self.radius, "radius")?;
449        validate_pose(
450            self.axis_origin_offset,
451            (self.axis_tilt_radians.0, self.axis_tilt_radians.1, 0.0),
452        )?;
453        if self.axis_tilt_radians.0.abs() >= std::f64::consts::FRAC_PI_2
454            || self.axis_tilt_radians.1.abs() >= std::f64::consts::FRAC_PI_2
455        {
456            return Err(Error::InvalidParameter {
457                name: "axis_tilt_radians",
458                reason: "components must have magnitude less than pi/2".into(),
459            });
460        }
461        if let Some(corrections) = &self.led_angular_corrections
462            && (corrections.len() != self.led_thetas.len()
463                || corrections
464                    .iter()
465                    .enumerate()
466                    .any(|(index, &(theta, phi))| {
467                        !theta.is_finite()
468                            || !phi.is_finite()
469                            || !(0.0..std::f64::consts::FRAC_PI_2)
470                                .contains(&(self.led_thetas[index] + theta))
471                    }))
472        {
473            return Err(Error::InvalidParameter {
474                name: "led_angular_corrections",
475                reason: format!(
476                    "must contain {} finite corrections that keep each LED on the quarter-circle cap",
477                    self.led_thetas.len()
478                ),
479            });
480        }
481        if let Some(offsets) = &self.led_radial_offsets
482            && (offsets.len() != self.led_thetas.len()
483                || offsets
484                    .iter()
485                    .any(|offset| !offset.is_finite() || self.radius + offset <= 0.0))
486        {
487            return Err(Error::InvalidParameter {
488                name: "led_radial_offsets",
489                reason: format!(
490                    "must contain {} finite offsets with positive corrected radii",
491                    self.led_thetas.len()
492                ),
493            });
494        }
495        if !self.rotation_zero_radians.is_finite()
496            || !self.rotation_scale.is_finite()
497            || self.rotation_scale <= 0.0
498        {
499            return Err(Error::InvalidParameter {
500                name: "rotation encoder calibration",
501                reason: "zero must be finite and scale must be finite and positive".into(),
502            });
503        }
504        if !self.rotation_backlash_radians.is_finite() || self.rotation_backlash_radians < 0.0 {
505            return Err(Error::InvalidParameter {
506                name: "rotation_backlash_radians",
507                reason: "must be finite and non-negative".into(),
508            });
509        }
510        validate_wavelength(self.wavelength_override)?;
511        validate_weights(self.led_intensity_weights.as_deref(), self.led_thetas.len())
512    }
513
514    fn positions(&self) -> Result<Vec<[f64; 3]>> {
515        let branches = scalar_backlash_branches(&self.rotation_angles);
516        let mut positions = Vec::with_capacity(self.source_count()?);
517        for (rotation_index, &command) in self.rotation_angles.iter().enumerate() {
518            let rotation = self.rotation_scale * command
519                + self.rotation_zero_radians
520                + 0.5 * self.rotation_backlash_radians * branches[rotation_index];
521            for (led_index, &nominal_theta) in self.led_thetas.iter().enumerate() {
522                let (delta_theta, delta_phi) = self
523                    .led_angular_corrections
524                    .as_ref()
525                    .map_or((0.0, 0.0), |values| values[led_index]);
526                let radius = self.radius
527                    + self
528                        .led_radial_offsets
529                        .as_ref()
530                        .map_or(0.0, |values| values[led_index]);
531                let local = scale(
532                    spherical_direction(nominal_theta + delta_theta, delta_phi),
533                    radius,
534                );
535                let rotated = rotate_z(local, rotation);
536                positions.push(source_position(
537                    rotated,
538                    self.axis_origin_offset,
539                    (self.axis_tilt_radians.0, self.axis_tilt_radians.1, 0.0),
540                    "rotating LED arc",
541                )?);
542            }
543        }
544        Ok(positions)
545    }
546}
547
548impl IlluminationSource for RotatingLEDArc {
549    fn k_vectors(&self, optics: &Optics) -> Result<Vec<KVector>> {
550        self.validate()?;
551        optics.validate()?;
552        let k = wavenumber(self.wavelength_override, optics);
553        Ok(self
554            .positions()?
555            .into_iter()
556            .map(|position| position_to_k_vector(position, k))
557            .collect())
558    }
559
560    fn frame_gains(&self) -> Result<Option<Vec<f64>>> {
561        self.validate()?;
562        Ok(self.led_intensity_weights.as_ref().map(|weights| {
563            self.rotation_angles
564                .iter()
565                .flat_map(|_| weights.iter().copied())
566                .collect()
567        }))
568    }
569}
570
571fn validate_angle_list(angles: &[(f64, f64)], name: &'static str) -> Result<()> {
572    if angles.is_empty()
573        || angles.iter().any(|&(theta, phi)| {
574            !theta.is_finite()
575                || !(0.0..std::f64::consts::FRAC_PI_2).contains(&theta)
576                || !phi.is_finite()
577        })
578    {
579        return Err(Error::InvalidParameter {
580            name,
581            reason: "must contain at least one finite (theta, phi) pair with 0 <= theta < pi/2"
582                .into(),
583        });
584    }
585    Ok(())
586}
587
588fn validate_length(value: f64, name: &'static str) -> Result<()> {
589    if !value.is_finite() || value <= 0.0 {
590        return Err(Error::InvalidParameter {
591            name,
592            reason: "must be finite and positive".into(),
593        });
594    }
595    Ok(())
596}
597
598fn validate_pose(offset: (f64, f64, f64), orientation: (f64, f64, f64)) -> Result<()> {
599    if [
600        offset.0,
601        offset.1,
602        offset.2,
603        orientation.0,
604        orientation.1,
605        orientation.2,
606    ]
607    .into_iter()
608    .any(|value| !value.is_finite())
609    {
610        return Err(Error::InvalidParameter {
611            name: "spherical geometry pose",
612            reason: "offset and orientation components must be finite".into(),
613        });
614    }
615    Ok(())
616}
617
618fn validate_wavelength(wavelength: Option<f64>) -> Result<()> {
619    if wavelength.is_some_and(|value| !value.is_finite() || value <= 0.0) {
620        return Err(Error::InvalidParameter {
621            name: "wavelength_override",
622            reason: "must be finite and positive".into(),
623        });
624    }
625    Ok(())
626}
627
628fn validate_order(order: Option<&[usize]>, count: usize) -> Result<()> {
629    if let Some(order) = order {
630        let mut sorted = order.to_vec();
631        sorted.sort_unstable();
632        sorted.dedup();
633        if order.len() != count
634            || order.iter().any(|&index| index >= count)
635            || sorted.len() != count
636        {
637            return Err(Error::InvalidParameter {
638                name: "illumination_order",
639                reason: format!("must be a permutation of 0..{count}"),
640            });
641        }
642    }
643    Ok(())
644}
645
646fn validate_weights(weights: Option<&[f64]>, count: usize) -> Result<()> {
647    if weights.is_some_and(|values| {
648        values.len() != count
649            || values
650                .iter()
651                .any(|value| !value.is_finite() || *value <= 0.0)
652    }) {
653        return Err(Error::InvalidParameter {
654            name: "intensity_weights",
655            reason: format!("must contain {count} finite positive values"),
656        });
657    }
658    Ok(())
659}
660
661fn spherical_direction(theta: f64, phi: f64) -> [f64; 3] {
662    let (sin_theta, cos_theta) = theta.sin_cos();
663    let (sin_phi, cos_phi) = phi.sin_cos();
664    [sin_theta * cos_phi, sin_theta * sin_phi, cos_theta]
665}
666
667fn source_position(
668    local: [f64; 3],
669    offset: (f64, f64, f64),
670    orientation: (f64, f64, f64),
671    geometry: &'static str,
672) -> Result<[f64; 3]> {
673    let rotated = rotate_pose(local, orientation);
674    let position = [
675        rotated[0] + offset.0,
676        rotated[1] + offset.1,
677        rotated[2] + offset.2,
678    ];
679    let norm =
680        (position[0] * position[0] + position[1] * position[1] + position[2] * position[2]).sqrt();
681    if !norm.is_finite() || norm <= 0.0 || position[2] <= 0.0 {
682        return Err(Error::InvalidParameter {
683            name: geometry,
684            reason: "every transformed source must be finite, distinct from the sample, and on the positive-z illumination hemisphere"
685                .into(),
686        });
687    }
688    Ok(position)
689}
690
691fn position_to_k_vector(position: [f64; 3], k: f64) -> KVector {
692    let distance =
693        (position[0] * position[0] + position[1] * position[1] + position[2] * position[2]).sqrt();
694    KVector::new(k * position[0] / distance, k * position[1] / distance)
695}
696
697fn wavenumber(wavelength_override: Option<f64>, optics: &Optics) -> f64 {
698    std::f64::consts::TAU * optics.medium_index / wavelength_override.unwrap_or(optics.wavelength)
699}
700
701fn reorder<T: Copy>(values: Vec<T>, order: Option<&[usize]>) -> Vec<T> {
702    match order {
703        Some(indices) => indices.iter().map(|&index| values[index]).collect(),
704        None => values,
705    }
706}
707
708fn backlash_branches(commands: &[(f64, f64)], component: usize) -> Vec<f64> {
709    let mut branches = Vec::with_capacity(commands.len());
710    let mut branch = 0.0;
711    branches.push(branch);
712    for pair in commands.windows(2) {
713        let previous = if component == 0 { pair[0].0 } else { pair[0].1 };
714        let current = if component == 0 { pair[1].0 } else { pair[1].1 };
715        let delta = current - previous;
716        if delta != 0.0 {
717            branch = delta.signum();
718        }
719        branches.push(branch);
720    }
721    branches
722}
723
724fn scalar_backlash_branches(commands: &[f64]) -> Vec<f64> {
725    let mut branches = Vec::with_capacity(commands.len());
726    let mut branch = 0.0;
727    branches.push(branch);
728    for pair in commands.windows(2) {
729        let delta = pair[1] - pair[0];
730        if delta != 0.0 {
731            branch = delta.signum();
732        }
733        branches.push(branch);
734    }
735    branches
736}
737
738fn scale(vector: [f64; 3], scalar: f64) -> [f64; 3] {
739    [vector[0] * scalar, vector[1] * scalar, vector[2] * scalar]
740}
741
742fn rotate_pose(vector: [f64; 3], radians: (f64, f64, f64)) -> [f64; 3] {
743    let (sin_x, cos_x) = radians.0.sin_cos();
744    let after_x = [
745        vector[0],
746        cos_x * vector[1] - sin_x * vector[2],
747        sin_x * vector[1] + cos_x * vector[2],
748    ];
749    let (sin_y, cos_y) = radians.1.sin_cos();
750    let after_y = [
751        cos_y * after_x[0] + sin_y * after_x[2],
752        after_x[1],
753        -sin_y * after_x[0] + cos_y * after_x[2],
754    ];
755    rotate_z(after_y, radians.2)
756}
757
758fn rotate_z(vector: [f64; 3], radians: f64) -> [f64; 3] {
759    let (sin, cos) = radians.sin_cos();
760    [
761        cos * vector[0] - sin * vector[1],
762        sin * vector[0] + cos * vector[1],
763        vector[2],
764    ]
765}
766
767fn rotate_axis_angle(vector: [f64; 3], axis: [f64; 3], radians: f64) -> [f64; 3] {
768    let (sin, cos) = radians.sin_cos();
769    let dot = axis[0] * vector[0] + axis[1] * vector[1] + axis[2] * vector[2];
770    let cross = [
771        axis[1] * vector[2] - axis[2] * vector[1],
772        axis[2] * vector[0] - axis[0] * vector[2],
773        axis[0] * vector[1] - axis[1] * vector[0],
774    ];
775    [
776        vector[0] * cos + cross[0] * sin + axis[0] * dot * (1.0 - cos),
777        vector[1] * cos + cross[1] * sin + axis[1] * dot * (1.0 - cos),
778        vector[2] * cos + cross[2] * sin + axis[2] * dot * (1.0 - cos),
779    ]
780}