1use crate::beam::{Beam, BeamError};
5use thiserror::Error;
6
7const DEG2RAD: f64 = std::f64::consts::PI / 180.0;
8
9#[derive(Debug, Error)]
10pub enum CommonBeamError {
11 #[error("no beams provided")]
12 NoBeams,
13 #[error("all beams are invalid or flagged")]
14 AllFlagged,
15 #[error("Khachiyan algorithm did not converge after {0} iterations")]
16 NoConvergence(usize),
17 #[error("no common beam found that contains every input (epsilon reached {0:.2e})")]
18 NotFound(f64),
19 #[error("singular matrix in the minimum-volume ellipse fit")]
20 Singular,
21 #[error("nsamps must be at least 1")]
22 NoSamples,
23 #[error("beam error: {0}")]
24 Beam(#[from] BeamError),
25}
26
27pub fn common_beam(
45 beams: &[Beam],
46 tolerance: f64,
47 nsamps: usize,
48 epsilon: f64,
49) -> Result<Beam, CommonBeamError> {
50 if beams.is_empty() {
51 return Err(CommonBeamError::NoBeams);
52 }
53 if nsamps == 0 {
54 return Err(CommonBeamError::NoSamples);
55 }
56 if beams.len() == 1 {
57 return Ok(beams[0]);
58 }
59
60 let largest = largest_beam(beams);
61 if fits_in_beam(beams, &largest) {
62 return Ok(largest);
63 }
64
65 if beams.len() == 2
66 && let Ok(b) = find_commonbeam_between(&beams[0], &beams[1])
67 && fits_in_beam(beams, &b)
68 {
69 return Ok(b);
70 }
71
72 common_manybeams_mve(beams, tolerance, nsamps, epsilon)
73}
74
75pub fn find_commonbeam_between(beam1: &Beam, beam2: &Beam) -> Result<Beam, CommonBeamError> {
78 if beam1.approx_eq(beam2) {
79 return Ok(*beam1);
80 }
81
82 let (large_beam, small_beam) = if beam1.area_sr() >= beam2.area_sr() {
83 (beam1, beam2)
84 } else {
85 (beam2, beam1)
86 };
87
88 if large_beam.contains(small_beam) {
89 return Ok(*large_beam);
90 }
91
92 let large_major = large_beam.major_arcsec();
93 let large_minor = large_beam.minor_arcsec();
94 let small_major = small_beam.major_arcsec();
95
96 if small_beam.is_circular(1e-6) {
98 let beam = Beam::from_arcsec(large_major, small_major, large_beam.pa_deg())?;
99 return Ok(beam);
100 }
101
102 let pa_diff_rad = ((small_beam.pa_deg() - large_beam.pa_deg()) * DEG2RAD
103 + std::f64::consts::FRAC_PI_2
104 + std::f64::consts::PI)
105 .rem_euclid(std::f64::consts::PI)
106 - std::f64::consts::FRAC_PI_2;
107
108 if (pa_diff_rad.abs() - std::f64::consts::FRAC_PI_2).abs() < 1e-9 {
110 let (major, minor) = if large_major >= small_major {
111 (large_major, small_major)
112 } else {
113 (small_major, large_major)
114 };
115 let pa = if large_major >= small_major {
116 large_beam.pa_deg()
117 } else {
118 small_beam.pa_deg()
119 };
120 return Beam::from_arcsec(major, minor, pa).map_err(Into::into);
121 }
122
123 let major_comb = (large_major * small_major).sqrt();
125 let p = major_comb / large_major;
126 let q = major_comb / large_minor;
127
128 let (trans_maj_sc, _trans_min_sc, trans_pa_sc) =
129 transform_ellipse_arcsec(small_major, small_beam.minor_arcsec(), pa_diff_rad, p, q);
130
131 let trans_min_sc = major_comb;
133
134 let (trans_maj_unsc, trans_min_unsc, trans_pa_unsc) =
135 transform_ellipse_arcsec(trans_maj_sc, trans_min_sc, trans_pa_sc, 1.0 / p, 1.0 / q);
136
137 let final_pa_deg = trans_pa_unsc.to_degrees() + large_beam.pa_deg();
138
139 let eps = 100.0 * f64::EPSILON;
140 let beam = Beam::from_arcsec(trans_maj_unsc + eps, trans_min_unsc + eps, final_pa_deg)?;
141
142 Ok(beam)
143}
144
145fn transform_ellipse_arcsec(
148 major: f64,
149 minor: f64,
150 pa: f64,
151 x_scale: f64,
152 y_scale: f64,
153) -> (f64, f64, f64) {
154 let cospa = pa.cos();
155 let sinpa = pa.sin();
156 let cos2pa = cospa * cospa;
157 let sin2pa = sinpa * sinpa;
158 let major2 = major * major;
159 let minor2 = minor * minor;
160
161 let a = cos2pa / major2 + sin2pa / minor2;
162 let b = -2.0 * cospa * sinpa * (1.0 / major2 - 1.0 / minor2);
163 let c = sin2pa / major2 + cos2pa / minor2;
164
165 let x2 = x_scale * x_scale;
166 let y2 = y_scale * y_scale;
167
168 let r = a / x2;
169 let s = b * b / (4.0 * x2 * y2);
170 let t = c / y2;
171
172 let udiff = r - t;
173 let f1 = udiff * udiff + 4.0 * s;
174 let f2 = f1.sqrt() * udiff.abs();
175
176 let j1 = (f2 + f1) / (2.0 * f1);
177 let j2 = (f1 - f2) / (2.0 * f1);
178
179 let k1 = (j1 * (r + t) - t) / (2.0 * j1 - 1.0);
180 let k2 = (j2 * (r + t) - t) / (2.0 * j2 - 1.0);
181
182 let c1 = 1.0 / k1.sqrt();
183 let c2 = 1.0 / k2.sqrt();
184
185 let pa_sign = if pa >= 0.0 { 1.0 } else { -1.0 };
186
187 if (c1 - c2).abs() < f64::EPSILON {
188 (1.0 / c1, 1.0 / c1, 0.0)
189 } else if c1 > c2 {
190 (c1, c2, pa_sign * j1.sqrt().acos())
191 } else {
192 (c2, c1, pa_sign * j2.sqrt().acos())
193 }
194}
195
196pub fn common_manybeams_mve(
200 beams: &[Beam],
201 tolerance: f64,
202 nsamps: usize,
203 epsilon: f64,
204) -> Result<Beam, CommonBeamError> {
205 let max_iter = 10;
206 let max_epsilon = 1e-3_f64;
207 let mut eps = epsilon;
208
209 for step in 0..=max_iter {
210 let all_pts = collect_ellipse_points(beams, nsamps, eps);
211 let hull_pts = convex_hull_2d(&all_pts);
212
213 let (radii, rotation) = min_vol_ellipse(&hull_pts, tolerance)?;
214
215 let pa = (-rotation[0][0]).atan2(rotation[1][0]);
217 let pa = if pa.abs() == std::f64::consts::PI {
218 0.0
219 } else {
220 pa
221 };
222
223 let (major_deg, minor_deg) = if radii[0] >= radii[1] {
224 (radii[0], radii[1])
225 } else {
226 (radii[1], radii[0])
227 };
228 let com_beam = Beam::new(major_deg, minor_deg, pa.to_degrees())?;
229 if fits_in_beam(beams, &com_beam) {
230 return Ok(com_beam);
231 }
232
233 if step < max_iter {
234 eps += (step as f64 + 1.0) * (max_epsilon - eps) / max_iter as f64;
235 }
236 }
237 Err(CommonBeamError::NotFound(eps))
238}
239
240fn collect_ellipse_points(beams: &[Beam], nsamps: usize, epsilon: f64) -> Vec<[f64; 2]> {
242 let mut pts = Vec::with_capacity(beams.len() * nsamps);
243 for beam in beams {
244 let bpa = beam.pa_deg() * DEG2RAD;
245 let major = beam.major_deg() * (1.0 + epsilon);
246 let minor = beam.minor_deg() * (1.0 + epsilon);
247 for k in 0..nsamps {
248 let phi = 2.0 * std::f64::consts::PI * k as f64 / nsamps as f64;
249 let x = major * phi.cos();
250 let y = minor * phi.sin();
251 let xr = x * bpa.cos() - y * bpa.sin();
252 let yr = x * bpa.sin() + y * bpa.cos();
253 pts.push([xr, yr]);
254 }
255 }
256 pts
257}
258
259fn convex_hull_2d(pts: &[[f64; 2]]) -> Vec<[f64; 2]> {
261 if pts.len() <= 3 {
262 return pts.to_vec();
263 }
264
265 let pivot = pts
266 .iter()
267 .enumerate()
268 .min_by(|(_, a), (_, b)| a[1].total_cmp(&b[1]).then(a[0].total_cmp(&b[0])))
269 .map(|(i, _)| i)
270 .unwrap();
271
272 let pivot_pt = pts[pivot];
273
274 let mut sorted: Vec<[f64; 2]> = pts.iter().filter(|&&p| p != pivot_pt).cloned().collect();
275
276 sorted.sort_by(|a, b| {
277 let angle_a = (a[1] - pivot_pt[1]).atan2(a[0] - pivot_pt[0]);
278 let angle_b = (b[1] - pivot_pt[1]).atan2(b[0] - pivot_pt[0]);
279 angle_a.total_cmp(&angle_b)
280 });
281
282 let mut hull: Vec<[f64; 2]> = vec![pivot_pt];
283 for &p in &sorted {
284 while hull.len() > 1 {
285 let n = hull.len();
286 let cross = cross2d(hull[n - 2], hull[n - 1], p);
287 if cross <= 0.0 {
288 hull.pop();
289 } else {
290 break;
291 }
292 }
293 hull.push(p);
294 }
295 hull
296}
297
298fn cross2d(o: [f64; 2], a: [f64; 2], b: [f64; 2]) -> f64 {
299 (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
300}
301
302fn min_vol_ellipse(
306 pts: &[[f64; 2]],
307 tolerance: f64,
308) -> Result<([f64; 2], [[f64; 2]; 2]), CommonBeamError> {
309 let n = pts.len();
310 let d = 2_usize;
311
312 let q: Vec<[f64; 3]> = pts.iter().map(|p| [p[0], p[1], 1.0]).collect(); let mut u = vec![1.0 / n as f64; n];
315
316 let max_iter = 100_000;
317 let mut err = 1.0_f64;
318 let mut iter = 0;
319
320 while err > tolerance {
321 let v = matmul_qt_diag_q(&q, &u); let v_inv = mat3_inv(v)?;
324
325 let m: Vec<f64> = q.iter().map(|qi| quadratic_form_3(qi, &v_inv)).collect();
327
328 let j = m
329 .iter()
330 .enumerate()
331 .max_by(|(_, a), (_, b)| a.total_cmp(b))
332 .map(|(i, _)| i)
333 .unwrap();
334 let maximum = m[j];
335
336 let step = (maximum - d as f64 - 1.0) / ((d as f64 + 1.0) * (maximum - 1.0));
337 let new_u: Vec<f64> = u.iter().map(|&ui| (1.0 - step) * ui).collect();
338 err = new_u
339 .iter()
340 .zip(u.iter())
341 .map(|(a, b)| (a - b).powi(2))
342 .sum::<f64>()
343 .sqrt();
344 u = new_u;
345 u[j] += step;
346
347 iter += 1;
348 if iter >= max_iter {
349 return Err(CommonBeamError::NoConvergence(max_iter));
350 }
351 }
352
353 let center = [
354 pts.iter()
355 .zip(u.iter())
356 .map(|(p, &ui)| p[0] * ui)
357 .sum::<f64>(),
358 pts.iter()
359 .zip(u.iter())
360 .map(|(p, &ui)| p[1] * ui)
361 .sum::<f64>(),
362 ];
363
364 let ptdp = matmul_pt_diag_p(pts, &u); let cc = [
367 [center[0] * center[0], center[0] * center[1]],
368 [center[1] * center[0], center[1] * center[1]],
369 ];
370 let inner = [
371 [ptdp[0][0] - cc[0][0], ptdp[0][1] - cc[0][1]],
372 [ptdp[1][0] - cc[1][0], ptdp[1][1] - cc[1][1]],
373 ];
374 let a = mat2_scale(mat2_inv(inner)?, 1.0 / d as f64);
375
376 let (eigenvalues, eigenvectors) = symmetric_2x2_eig(a);
378
379 let radii = [
380 (1.0 / eigenvalues[0].abs().sqrt()) * (1.0 + tolerance),
381 (1.0 / eigenvalues[1].abs().sqrt()) * (1.0 + tolerance),
382 ];
383
384 Ok((radii, eigenvectors))
385}
386
387fn matmul_qt_diag_q(q: &[[f64; 3]], u: &[f64]) -> [[f64; 3]; 3] {
389 let mut v = [[0.0_f64; 3]; 3];
390 for (qi, &ui) in q.iter().zip(u.iter()) {
391 for r in 0..3 {
392 for c in 0..3 {
393 v[r][c] += qi[r] * ui * qi[c];
394 }
395 }
396 }
397 v
398}
399
400fn matmul_pt_diag_p(p: &[[f64; 2]], u: &[f64]) -> [[f64; 2]; 2] {
402 let mut m = [[0.0_f64; 2]; 2];
403 for (pi, &ui) in p.iter().zip(u.iter()) {
404 for r in 0..2 {
405 for c in 0..2 {
406 m[r][c] += pi[r] * ui * pi[c];
407 }
408 }
409 }
410 m
411}
412
413fn quadratic_form_3(x: &[f64; 3], m: &[[f64; 3]; 3]) -> f64 {
415 let mut acc = 0.0_f64;
416 for r in 0..3 {
417 for c in 0..3 {
418 acc += x[r] * m[r][c] * x[c];
419 }
420 }
421 acc
422}
423
424fn mat3_inv(m: [[f64; 3]; 3]) -> Result<[[f64; 3]; 3], CommonBeamError> {
426 let det = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
427 - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
428 + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]);
429
430 if det.abs() < f64::EPSILON {
431 return Err(CommonBeamError::Singular);
432 }
433 let inv_det = 1.0 / det;
434
435 Ok([
436 [
437 inv_det * (m[1][1] * m[2][2] - m[1][2] * m[2][1]),
438 inv_det * (m[0][2] * m[2][1] - m[0][1] * m[2][2]),
439 inv_det * (m[0][1] * m[1][2] - m[0][2] * m[1][1]),
440 ],
441 [
442 inv_det * (m[1][2] * m[2][0] - m[1][0] * m[2][2]),
443 inv_det * (m[0][0] * m[2][2] - m[0][2] * m[2][0]),
444 inv_det * (m[0][2] * m[1][0] - m[0][0] * m[1][2]),
445 ],
446 [
447 inv_det * (m[1][0] * m[2][1] - m[1][1] * m[2][0]),
448 inv_det * (m[0][1] * m[2][0] - m[0][0] * m[2][1]),
449 inv_det * (m[0][0] * m[1][1] - m[0][1] * m[1][0]),
450 ],
451 ])
452}
453
454fn mat2_inv(m: [[f64; 2]; 2]) -> Result<[[f64; 2]; 2], CommonBeamError> {
456 let det = m[0][0] * m[1][1] - m[0][1] * m[1][0];
457 if det.abs() < f64::EPSILON {
458 return Err(CommonBeamError::Singular);
459 }
460 let inv_det = 1.0 / det;
461 Ok([
462 [inv_det * m[1][1], -inv_det * m[0][1]],
463 [-inv_det * m[1][0], inv_det * m[0][0]],
464 ])
465}
466
467fn mat2_scale(m: [[f64; 2]; 2], s: f64) -> [[f64; 2]; 2] {
468 [[m[0][0] * s, m[0][1] * s], [m[1][0] * s, m[1][1] * s]]
469}
470
471fn symmetric_2x2_eig(m: [[f64; 2]; 2]) -> ([f64; 2], [[f64; 2]; 2]) {
473 let a = m[0][0];
474 let b = m[0][1]; let c = m[1][1];
476
477 let trace = a + c;
478 let det = a * c - b * b;
479 let disc = ((trace / 2.0).powi(2) - det).max(0.0).sqrt();
480
481 let lam1 = trace / 2.0 + disc;
482 let lam2 = trace / 2.0 - disc;
483
484 let (v1, v2) = if b.abs() > f64::EPSILON {
485 let v1 = normalise([lam1 - c, b]);
486 let v2 = normalise([lam2 - c, b]);
487 (v1, v2)
488 } else if a >= c {
489 ([1.0, 0.0], [0.0, 1.0])
490 } else {
491 ([0.0, 1.0], [1.0, 0.0])
492 };
493
494 let rotation = [[v1[0], v2[0]], [v1[1], v2[1]]];
496
497 ([lam1, lam2], rotation)
498}
499
500fn normalise(v: [f64; 2]) -> [f64; 2] {
501 let len = (v[0] * v[0] + v[1] * v[1]).sqrt();
502 if len < f64::EPSILON {
503 return v;
504 }
505 [v[0] / len, v[1] / len]
506}
507
508pub fn largest_beam(beams: &[Beam]) -> Beam {
509 beams
510 .iter()
511 .max_by(|a, b| a.area_sr().total_cmp(&b.area_sr()))
512 .copied()
513 .unwrap()
514}
515
516pub fn fits_in_beam(beams: &[Beam], large_beam: &Beam) -> bool {
518 beams.iter().all(|b| large_beam.contains(b))
519}
520
521#[cfg(test)]
522mod tests {
523 use super::*;
524
525 #[test]
526 fn test_common_beam_identical() {
527 let b = Beam::new(10.0 / 3600.0, 8.0 / 3600.0, 30.0).unwrap();
528 let result = common_beam(&[b, b], 1e-4, 200, 5e-4).unwrap();
529 assert!(result.approx_eq(&b) || result.major_deg() >= b.major_deg());
530 }
531
532 #[test]
533 fn test_common_beam_two_different() {
534 let b1 = Beam::new(10.0 / 3600.0, 8.0 / 3600.0, 30.0).unwrap();
535 let b2 = Beam::new(12.0 / 3600.0, 6.0 / 3600.0, 60.0).unwrap();
536 let result = common_beam(&[b1, b2], 1e-4, 200, 5e-4).unwrap();
537 assert!(
538 result.major_deg() >= b1.major_deg().max(b2.major_deg())
539 || fits_in_beam(&[b1, b2], &result)
540 );
541 }
542
543 #[test]
546 fn test_equal_major_axis_fits() {
547 let target = Beam::from_arcsec(20.0, 20.0, 0.0).unwrap();
548 for pa in [0.0, 37.0, 90.0, -45.0] {
549 let b = Beam::from_arcsec(20.0, 10.0, pa).unwrap();
550 assert!(fits_in_beam(&[b], &target), "PA {pa}");
551 }
552 let common = common_beam(
553 &[target, Beam::from_arcsec(20.0, 10.0, 0.0).unwrap()],
554 1e-4,
555 200,
556 5e-4,
557 )
558 .unwrap();
559 assert!(common.approx_eq(&target), "got {common}");
560 }
561
562 #[test]
563 fn test_zero_samples_is_an_error() {
564 let b = Beam::from_arcsec(10.0, 8.0, 0.0).unwrap();
565 let c = Beam::from_arcsec(9.0, 9.0, 0.0).unwrap();
566 assert!(common_beam(&[b, c, b], 1e-4, 0, 5e-4).is_err());
567 }
568
569 #[test]
571 fn test_two_beam_result_contains_inputs() {
572 for (a, b) in [
573 ((10.0, 8.0, 30.0), (12.0, 6.0, 60.0)),
574 (
577 (17.134150314045915, 7.278818100904679, -4.1357805889849),
578 (17.230792491993967, 4.380913928098483, 14.758744881833891),
579 ),
580 ] {
581 let b1 = Beam::from_arcsec(a.0, a.1, a.2).unwrap();
582 let b2 = Beam::from_arcsec(b.0, b.1, b.2).unwrap();
583 let common = common_beam(&[b1, b2], 1e-4, 200, 5e-4).unwrap();
584 assert!(fits_in_beam(&[b1, b2], &common), "{common}");
585 }
586 }
587}