1use serde::{Deserialize, Serialize};
2
3use crate::error::{Error, Result};
4
5use super::{Optics, ResolvedSources};
6
7#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
15#[serde(deny_unknown_fields)]
16pub struct SphericalLedArray {
17 #[serde(rename = "polar_angles_rad")]
19 pub angles: Vec<(f64, f64)>,
20 #[serde(rename = "radius_m")]
22 pub radius: f64,
23 #[serde(rename = "center_offset_m")]
25 pub center_offset: (f64, f64, f64),
26 #[serde(rename = "rotation_rad")]
28 pub orientation_radians: (f64, f64, f64),
29 #[serde(rename = "angular_corrections_rad")]
31 pub angular_corrections: Option<Vec<(f64, f64)>>,
32}
33
34impl SphericalLedArray {
35 pub fn new(angles: Vec<(f64, f64)>, radius: f64) -> Self {
37 Self {
38 angles,
39 radius,
40 center_offset: (0.0, 0.0, 0.0),
41 orientation_radians: (0.0, 0.0, 0.0),
42 angular_corrections: None,
43 }
44 }
45
46 pub fn center_offset(mut self, offset: (f64, f64, f64)) -> Self {
48 self.center_offset = offset;
49 self
50 }
51
52 pub fn orientation_deg(mut self, degrees: (f64, f64, f64)) -> Self {
54 self.orientation_radians = (
55 degrees.0.to_radians(),
56 degrees.1.to_radians(),
57 degrees.2.to_radians(),
58 );
59 self
60 }
61
62 pub fn angular_corrections(mut self, corrections: Vec<(f64, f64)>) -> Self {
64 self.angular_corrections = Some(corrections);
65 self
66 }
67
68 pub fn source_count(&self) -> usize {
70 self.angles.len()
71 }
72
73 pub fn validate(&self) -> Result<()> {
75 validate_angle_list(&self.angles, "LED sphere angles")?;
76 validate_length(self.radius, "radius")?;
77 validate_pose(self.center_offset, self.orientation_radians)?;
78 if let Some(corrections) = &self.angular_corrections
79 && (corrections.len() != self.angles.len()
80 || corrections
81 .iter()
82 .any(|&(theta, phi)| !theta.is_finite() || !phi.is_finite()))
83 {
84 return Err(Error::InvalidParameter {
85 name: "angular_corrections",
86 reason: format!(
87 "must contain {} finite (delta_theta, delta_phi) pairs",
88 self.angles.len()
89 ),
90 });
91 }
92 Ok(())
93 }
94
95 fn natural_positions(&self) -> Result<Vec<[f64; 3]>> {
96 self.angles
97 .iter()
98 .enumerate()
99 .map(|(index, &(theta, phi))| {
100 let (delta_theta, delta_phi) = self
101 .angular_corrections
102 .as_ref()
103 .map_or((0.0, 0.0), |values| values[index]);
104 let direction = spherical_direction(theta + delta_theta, phi + delta_phi);
105 source_position(
106 scale(direction, -self.radius),
107 self.center_offset,
108 self.orientation_radians,
109 "LED sphere",
110 )
111 })
112 .collect()
113 }
114 pub fn resolve(&self, optics: &Optics) -> Result<ResolvedSources> {
116 self.validate()?;
117 ResolvedSources::from_positions(self.natural_positions()?, optics)
118 }
119}
120
121#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
129#[serde(deny_unknown_fields)]
130pub struct SphericalLedArm {
131 #[serde(rename = "commanded_angles_rad")]
133 pub commanded_angles: Vec<(f64, f64)>,
134 #[serde(rename = "arm_length_m")]
136 pub arm_length: f64,
137 #[serde(rename = "pivot_offset_m")]
139 pub pivot_offset: (f64, f64, f64),
140 #[serde(rename = "rotation_rad")]
142 pub orientation_radians: (f64, f64, f64),
143 #[serde(rename = "theta_zero_rad")]
145 pub theta_zero_radians: f64,
146 #[serde(rename = "phi_zero_rad")]
148 pub phi_zero_radians: f64,
149 pub theta_scale: f64,
151 pub phi_scale: f64,
153 #[serde(rename = "elevation_axis_tilt_rad")]
155 pub elevation_axis_tilt_radians: f64,
156 #[serde(rename = "theta_backlash_rad")]
158 pub theta_backlash_radians: f64,
159 #[serde(rename = "phi_backlash_rad")]
161 pub phi_backlash_radians: f64,
162}
163
164impl SphericalLedArm {
165 pub fn new(commanded_angles: Vec<(f64, f64)>, arm_length: f64) -> Self {
167 Self {
168 commanded_angles,
169 arm_length,
170 pivot_offset: (0.0, 0.0, 0.0),
171 orientation_radians: (0.0, 0.0, 0.0),
172 theta_zero_radians: 0.0,
173 phi_zero_radians: 0.0,
174 theta_scale: 1.0,
175 phi_scale: 1.0,
176 elevation_axis_tilt_radians: 0.0,
177 theta_backlash_radians: 0.0,
178 phi_backlash_radians: 0.0,
179 }
180 }
181
182 pub fn pivot_offset(mut self, offset: (f64, f64, f64)) -> Self {
184 self.pivot_offset = offset;
185 self
186 }
187
188 pub fn orientation_deg(mut self, degrees: (f64, f64, f64)) -> Self {
190 self.orientation_radians = (
191 degrees.0.to_radians(),
192 degrees.1.to_radians(),
193 degrees.2.to_radians(),
194 );
195 self
196 }
197
198 pub fn encoder_zero_deg(mut self, theta: f64, phi: f64) -> Self {
200 self.theta_zero_radians = theta.to_radians();
201 self.phi_zero_radians = phi.to_radians();
202 self
203 }
204
205 pub fn encoder_scale(mut self, theta: f64, phi: f64) -> Self {
207 self.theta_scale = theta;
208 self.phi_scale = phi;
209 self
210 }
211
212 pub fn elevation_axis_tilt_deg(mut self, degrees: f64) -> Self {
214 self.elevation_axis_tilt_radians = degrees.to_radians();
215 self
216 }
217
218 pub fn backlash_deg(mut self, theta: f64, phi: f64) -> Self {
220 self.theta_backlash_radians = theta.to_radians();
221 self.phi_backlash_radians = phi.to_radians();
222 self
223 }
224
225 pub fn source_count(&self) -> usize {
227 self.commanded_angles.len()
228 }
229
230 pub fn validate(&self) -> Result<()> {
233 validate_angle_list(&self.commanded_angles, "arm commanded_angles")?;
234 validate_length(self.arm_length, "arm_length")?;
235 validate_pose(self.pivot_offset, self.orientation_radians)?;
236 if !self.theta_zero_radians.is_finite()
237 || !self.phi_zero_radians.is_finite()
238 || !self.theta_scale.is_finite()
239 || self.theta_scale <= 0.0
240 || !self.phi_scale.is_finite()
241 || self.phi_scale <= 0.0
242 {
243 return Err(Error::InvalidParameter {
244 name: "arm encoder calibration",
245 reason: "zero offsets must be finite and scales must be finite and positive".into(),
246 });
247 }
248 if !self.elevation_axis_tilt_radians.is_finite()
249 || self.elevation_axis_tilt_radians.abs() >= std::f64::consts::FRAC_PI_2
250 {
251 return Err(Error::InvalidParameter {
252 name: "elevation_axis_tilt_radians",
253 reason: "must be finite with magnitude less than pi/2".into(),
254 });
255 }
256 if !self.theta_backlash_radians.is_finite()
257 || self.theta_backlash_radians < 0.0
258 || !self.phi_backlash_radians.is_finite()
259 || self.phi_backlash_radians < 0.0
260 {
261 return Err(Error::InvalidParameter {
262 name: "arm backlash",
263 reason: "backlash widths must be finite and non-negative".into(),
264 });
265 }
266 Ok(())
267 }
268
269 fn positions(&self) -> Result<Vec<[f64; 3]>> {
270 let theta_branches = backlash_branches(&self.commanded_angles, 0);
271 let phi_branches = backlash_branches(&self.commanded_angles, 1);
272 let (axis_sin, axis_cos) = self.elevation_axis_tilt_radians.sin_cos();
273 let elevation_axis = [0.0, axis_cos, axis_sin];
274
275 self.commanded_angles
276 .iter()
277 .enumerate()
278 .map(|(index, &(theta_command, phi_command))| {
279 let theta = self.theta_scale * theta_command
280 + self.theta_zero_radians
281 + 0.5 * self.theta_backlash_radians * theta_branches[index];
282 let phi = self.phi_scale * phi_command
283 + self.phi_zero_radians
284 + 0.5 * self.phi_backlash_radians * phi_branches[index];
285 let home = [0.0, 0.0, -self.arm_length];
286 let elevated = rotate_axis_angle(home, elevation_axis, theta);
287 let arm_position = rotate_z(elevated, phi);
288 source_position(
289 arm_position,
290 self.pivot_offset,
291 self.orientation_radians,
292 "spherical LED arm",
293 )
294 })
295 .collect()
296 }
297 pub fn resolve(&self, optics: &Optics) -> Result<ResolvedSources> {
299 self.validate()?;
300 ResolvedSources::from_positions(self.positions()?, optics)
301 }
302}
303
304#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
311#[serde(deny_unknown_fields)]
312pub struct RotatingLedArc {
313 #[serde(rename = "led_polar_angles_rad")]
315 pub led_thetas: Vec<f64>,
316 #[serde(rename = "rotation_angles_rad")]
318 pub rotation_angles: Vec<f64>,
319 #[serde(rename = "radius_m")]
321 pub radius: f64,
322 #[serde(rename = "axis_origin_offset_m")]
324 pub axis_origin_offset: (f64, f64, f64),
325 #[serde(rename = "axis_tilt_rad")]
327 pub axis_tilt_radians: (f64, f64),
328 #[serde(rename = "led_angular_corrections_rad")]
330 pub led_angular_corrections: Option<Vec<(f64, f64)>>,
331 #[serde(rename = "led_radial_offsets_m")]
333 pub led_radial_offsets: Option<Vec<f64>>,
334 #[serde(rename = "rotation_zero_rad")]
336 pub rotation_zero_radians: f64,
337 pub rotation_scale: f64,
339 #[serde(rename = "rotation_backlash_rad")]
341 pub rotation_backlash_radians: f64,
342}
343
344impl RotatingLedArc {
345 pub fn new(led_thetas: Vec<f64>, rotation_angles: Vec<f64>, radius: f64) -> Self {
348 Self {
349 led_thetas,
350 rotation_angles,
351 radius,
352 axis_origin_offset: (0.0, 0.0, 0.0),
353 axis_tilt_radians: (0.0, 0.0),
354 led_angular_corrections: None,
355 led_radial_offsets: None,
356 rotation_zero_radians: 0.0,
357 rotation_scale: 1.0,
358 rotation_backlash_radians: 0.0,
359 }
360 }
361
362 pub fn axis_origin_offset(mut self, offset: (f64, f64, f64)) -> Self {
364 self.axis_origin_offset = offset;
365 self
366 }
367
368 pub fn axis_tilt_deg(mut self, x: f64, y: f64) -> Self {
370 self.axis_tilt_radians = (x.to_radians(), y.to_radians());
371 self
372 }
373
374 pub fn led_angular_corrections(mut self, corrections: Vec<(f64, f64)>) -> Self {
376 self.led_angular_corrections = Some(corrections);
377 self
378 }
379
380 pub fn led_radial_offsets(mut self, offsets: Vec<f64>) -> Self {
382 self.led_radial_offsets = Some(offsets);
383 self
384 }
385
386 pub fn rotation_encoder_zero_deg(mut self, degrees: f64) -> Self {
388 self.rotation_zero_radians = degrees.to_radians();
389 self
390 }
391
392 pub fn rotation_encoder_scale(mut self, scale: f64) -> Self {
394 self.rotation_scale = scale;
395 self
396 }
397
398 pub fn rotation_backlash_deg(mut self, degrees: f64) -> Self {
400 self.rotation_backlash_radians = degrees.to_radians();
401 self
402 }
403
404 pub fn source_count(&self) -> usize {
406 self.led_thetas
407 .len()
408 .checked_mul(self.rotation_angles.len())
409 .unwrap_or(0)
410 }
411
412 pub fn validate(&self) -> Result<()> {
415 if self.led_thetas.is_empty()
416 || self.led_thetas.iter().any(|&theta| {
417 !theta.is_finite() || !(0.0..std::f64::consts::FRAC_PI_2).contains(&theta)
418 })
419 {
420 return Err(Error::InvalidParameter {
421 name: "led_thetas",
422 reason: "must contain at least one finite angle with 0 <= theta < pi/2".into(),
423 });
424 }
425 if self.rotation_angles.is_empty()
426 || self.rotation_angles.iter().any(|value| !value.is_finite())
427 {
428 return Err(Error::InvalidParameter {
429 name: "rotation_angles",
430 reason: "must contain at least one finite commanded azimuth".into(),
431 });
432 }
433 self.led_thetas
434 .len()
435 .checked_mul(self.rotation_angles.len())
436 .ok_or_else(|| Error::InvalidParameter {
437 name: "rotating LED arc",
438 reason: "source count overflows".into(),
439 })?;
440 validate_length(self.radius, "radius")?;
441 validate_pose(
442 self.axis_origin_offset,
443 (self.axis_tilt_radians.0, self.axis_tilt_radians.1, 0.0),
444 )?;
445 if self.axis_tilt_radians.0.abs() >= std::f64::consts::FRAC_PI_2
446 || self.axis_tilt_radians.1.abs() >= std::f64::consts::FRAC_PI_2
447 {
448 return Err(Error::InvalidParameter {
449 name: "axis_tilt_radians",
450 reason: "components must have magnitude less than pi/2".into(),
451 });
452 }
453 if let Some(corrections) = &self.led_angular_corrections
454 && (corrections.len() != self.led_thetas.len()
455 || corrections
456 .iter()
457 .enumerate()
458 .any(|(index, &(theta, phi))| {
459 !theta.is_finite()
460 || !phi.is_finite()
461 || !(0.0..std::f64::consts::FRAC_PI_2)
462 .contains(&(self.led_thetas[index] + theta))
463 }))
464 {
465 return Err(Error::InvalidParameter {
466 name: "led_angular_corrections",
467 reason: format!(
468 "must contain {} finite corrections that keep each LED on the quarter-circle cap",
469 self.led_thetas.len()
470 ),
471 });
472 }
473 if let Some(offsets) = &self.led_radial_offsets
474 && (offsets.len() != self.led_thetas.len()
475 || offsets
476 .iter()
477 .any(|offset| !offset.is_finite() || self.radius + offset <= 0.0))
478 {
479 return Err(Error::InvalidParameter {
480 name: "led_radial_offsets",
481 reason: format!(
482 "must contain {} finite offsets with positive corrected radii",
483 self.led_thetas.len()
484 ),
485 });
486 }
487 if !self.rotation_zero_radians.is_finite()
488 || !self.rotation_scale.is_finite()
489 || self.rotation_scale <= 0.0
490 {
491 return Err(Error::InvalidParameter {
492 name: "rotation encoder calibration",
493 reason: "zero must be finite and scale must be finite and positive".into(),
494 });
495 }
496 if !self.rotation_backlash_radians.is_finite() || self.rotation_backlash_radians < 0.0 {
497 return Err(Error::InvalidParameter {
498 name: "rotation_backlash_radians",
499 reason: "must be finite and non-negative".into(),
500 });
501 }
502 Ok(())
503 }
504
505 fn positions(&self) -> Result<Vec<[f64; 3]>> {
506 let branches = scalar_backlash_branches(&self.rotation_angles);
507 let mut positions = Vec::with_capacity(self.source_count());
508 for (rotation_index, &command) in self.rotation_angles.iter().enumerate() {
509 let rotation = self.rotation_scale * command
510 + self.rotation_zero_radians
511 + 0.5 * self.rotation_backlash_radians * branches[rotation_index];
512 for (led_index, &nominal_theta) in self.led_thetas.iter().enumerate() {
513 let (delta_theta, delta_phi) = self
514 .led_angular_corrections
515 .as_ref()
516 .map_or((0.0, 0.0), |values| values[led_index]);
517 let radius = self.radius
518 + self
519 .led_radial_offsets
520 .as_ref()
521 .map_or(0.0, |values| values[led_index]);
522 let local = scale(
523 spherical_direction(nominal_theta + delta_theta, delta_phi),
524 -radius,
525 );
526 let rotated = rotate_z(local, rotation);
527 positions.push(source_position(
528 rotated,
529 self.axis_origin_offset,
530 (self.axis_tilt_radians.0, self.axis_tilt_radians.1, 0.0),
531 "rotating LED arc",
532 )?);
533 }
534 }
535 Ok(positions)
536 }
537 pub fn resolve(&self, optics: &Optics) -> Result<ResolvedSources> {
539 self.validate()?;
540 ResolvedSources::from_positions(self.positions()?, optics)
541 }
542}
543
544fn validate_angle_list(angles: &[(f64, f64)], name: &'static str) -> Result<()> {
545 if angles.is_empty()
546 || angles.iter().any(|&(theta, phi)| {
547 !theta.is_finite()
548 || !(0.0..std::f64::consts::FRAC_PI_2).contains(&theta)
549 || !phi.is_finite()
550 })
551 {
552 return Err(Error::InvalidParameter {
553 name,
554 reason: "must contain at least one finite (theta, phi) pair with 0 <= theta < pi/2"
555 .into(),
556 });
557 }
558 Ok(())
559}
560
561fn validate_length(value: f64, name: &'static str) -> Result<()> {
562 if !value.is_finite() || value <= 0.0 {
563 return Err(Error::InvalidParameter {
564 name,
565 reason: "must be finite and positive".into(),
566 });
567 }
568 Ok(())
569}
570
571fn validate_pose(offset: (f64, f64, f64), orientation: (f64, f64, f64)) -> Result<()> {
572 if [
573 offset.0,
574 offset.1,
575 offset.2,
576 orientation.0,
577 orientation.1,
578 orientation.2,
579 ]
580 .into_iter()
581 .any(|value| !value.is_finite())
582 {
583 return Err(Error::InvalidParameter {
584 name: "spherical geometry pose",
585 reason: "offset and orientation components must be finite".into(),
586 });
587 }
588 Ok(())
589}
590
591fn spherical_direction(theta: f64, phi: f64) -> [f64; 3] {
592 let (sin_theta, cos_theta) = theta.sin_cos();
593 let (sin_phi, cos_phi) = phi.sin_cos();
594 [sin_theta * cos_phi, sin_theta * sin_phi, cos_theta]
595}
596
597fn source_position(
598 local: [f64; 3],
599 offset: (f64, f64, f64),
600 orientation: (f64, f64, f64),
601 geometry: &'static str,
602) -> Result<[f64; 3]> {
603 let rotated = rotate_pose(local, orientation);
604 let position = [
605 rotated[0] + offset.0,
606 rotated[1] + offset.1,
607 rotated[2] + offset.2,
608 ];
609 let norm =
610 (position[0] * position[0] + position[1] * position[1] + position[2] * position[2]).sqrt();
611 if !norm.is_finite() || norm <= 0.0 || position[2] >= 0.0 {
612 return Err(Error::InvalidParameter {
613 name: geometry,
614 reason: "every transformed source must be finite, distinct from the sample, and on the negative-z source hemisphere"
615 .into(),
616 });
617 }
618 Ok(position)
619}
620
621fn backlash_branches(commands: &[(f64, f64)], component: usize) -> Vec<f64> {
622 let mut branches = Vec::with_capacity(commands.len());
623 let mut branch = 0.0;
624 branches.push(branch);
625 for pair in commands.windows(2) {
626 let previous = if component == 0 { pair[0].0 } else { pair[0].1 };
627 let current = if component == 0 { pair[1].0 } else { pair[1].1 };
628 let delta = current - previous;
629 if delta != 0.0 {
630 branch = delta.signum();
631 }
632 branches.push(branch);
633 }
634 branches
635}
636
637fn scalar_backlash_branches(commands: &[f64]) -> Vec<f64> {
638 let mut branches = Vec::with_capacity(commands.len());
639 let mut branch = 0.0;
640 branches.push(branch);
641 for pair in commands.windows(2) {
642 let delta = pair[1] - pair[0];
643 if delta != 0.0 {
644 branch = delta.signum();
645 }
646 branches.push(branch);
647 }
648 branches
649}
650
651fn scale(vector: [f64; 3], scalar: f64) -> [f64; 3] {
652 [vector[0] * scalar, vector[1] * scalar, vector[2] * scalar]
653}
654
655fn rotate_pose(vector: [f64; 3], radians: (f64, f64, f64)) -> [f64; 3] {
656 let (sin_x, cos_x) = radians.0.sin_cos();
657 let after_x = [
658 vector[0],
659 cos_x * vector[1] - sin_x * vector[2],
660 sin_x * vector[1] + cos_x * vector[2],
661 ];
662 let (sin_y, cos_y) = radians.1.sin_cos();
663 let after_y = [
664 cos_y * after_x[0] + sin_y * after_x[2],
665 after_x[1],
666 -sin_y * after_x[0] + cos_y * after_x[2],
667 ];
668 rotate_z(after_y, radians.2)
669}
670
671fn rotate_z(vector: [f64; 3], radians: f64) -> [f64; 3] {
672 let (sin, cos) = radians.sin_cos();
673 [
674 cos * vector[0] - sin * vector[1],
675 sin * vector[0] + cos * vector[1],
676 vector[2],
677 ]
678}
679
680fn rotate_axis_angle(vector: [f64; 3], axis: [f64; 3], radians: f64) -> [f64; 3] {
681 let (sin, cos) = radians.sin_cos();
682 let dot = axis[0] * vector[0] + axis[1] * vector[1] + axis[2] * vector[2];
683 let cross = [
684 axis[1] * vector[2] - axis[2] * vector[1],
685 axis[2] * vector[0] - axis[0] * vector[2],
686 axis[0] * vector[1] - axis[1] * vector[0],
687 ];
688 [
689 vector[0] * cos + cross[0] * sin + axis[0] * dot * (1.0 - cos),
690 vector[1] * cos + cross[1] * sin + axis[1] * dot * (1.0 - cos),
691 vector[2] * cos + cross[2] * sin + axis[2] * dot * (1.0 - cos),
692 ]
693}