Skip to main content

convolve_rs/
common_beam.rs

1//! The smallest beam a set of beams can be convolved to, as radio_beam finds
2//! it: the CASA formula for two beams, otherwise a minimum-volume ellipse
3//! around points sampled on every beam (Khachiyan's algorithm).
4use 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
27/// The smallest beam every beam in `beams` can be convolved to.
28///
29/// `tolerance`, `nsamps` and `epsilon` are as in
30/// `radio_beam.Beams.common_beam(method='pts')`.
31///
32/// ```
33/// use convolve_rs::{Beam, common_beam};
34///
35/// let b1 = Beam::from_arcsec(10.0, 8.0, 30.0)?;
36/// let b2 = Beam::from_arcsec(12.0, 6.0, 60.0)?;
37/// let common = common_beam(&[b1, b2], 1e-4, 200, 5e-4)?;
38///
39/// assert!(common.area_sr() >= b1.area_sr());
40/// assert!(common.area_sr() >= b2.area_sr());
41/// assert!(common.major_arcsec() >= 12.0);
42/// # Ok::<(), Box<dyn std::error::Error>>(())
43/// ```
44pub 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
75/// The common beam of two beams by the CASA `ia.commonbeam` formula. The
76/// result is not checked; [`common_beam`] checks it.
77pub 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 the small beam is circular the minor axis is the circle radius.
97    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    // Perpendicular beams: the axes are the two major axes.
109    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    // Work in the frame where the large beam is circular.
124    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    // CASA sets the minor axis to the geometric mean of the majors.
132    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
145/// Scale an ellipse (arcsec axes, PA in radians) by `(x_scale, y_scale)`, as
146/// radio_beam's `transform_ellipse`. Returns `(major, minor, pa_rad)`.
147fn 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
196/// The minimum-volume ellipse around `nsamps` points on each beam. If it does
197/// not contain every beam, the points are pushed out by a growing `epsilon`
198/// and the fit repeated, as radio_beam does.
199pub 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        // radio_beam's rotation matrix convention: ((sin θ, cos θ), (cos θ, -sin θ)).
216        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
240/// Sample `nsamps` points on the edge of each beam's ellipse (scaled by `1 + epsilon`).
241fn 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
259/// 2D convex hull via Graham scan. Returns hull vertices.
260fn 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
302/// Khachiyan's minimum-volume enclosing ellipse of 2D points, following
303/// radio_beam's `getMinVolEllipse`. Returns the semi-axes and the rotation
304/// matrix whose columns are the axes.
305fn 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(); // (N, 3)
313
314    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        // V = Q.T * diag(u) * Q  (3x3 matrix)
322        let v = matmul_qt_diag_q(&q, &u); // 3x3
323        let v_inv = mat3_inv(v)?;
324
325        // M[i] = q[i].T * V_inv * q[i]
326        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    // A = inv(P.T * diag(u) * P - center*center.T) / d  (2x2)
365    let ptdp = matmul_pt_diag_p(pts, &u); // 2x2
366    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    // A is symmetric positive definite, so its SVD is its eigendecomposition.
377    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
387/// Q.T * diag(u) * Q where Q is (N, 3). Returns 3x3.
388fn 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
400/// P.T * diag(u) * P where P is (N, 2). Returns 2x2.
401fn 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
413/// Quadratic form x.T * M * x for 3-vector x and 3x3 matrix M.
414fn 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
424/// Invert a 3x3 matrix (Cramer's rule).
425fn 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
454/// Invert a symmetric 2x2 matrix.
455fn 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
471/// Eigenvalues and eigenvectors (as columns) of a symmetric 2x2 matrix.
472fn symmetric_2x2_eig(m: [[f64; 2]; 2]) -> ([f64; 2], [[f64; 2]; 2]) {
473    let a = m[0][0];
474    let b = m[0][1]; // = m[1][0] since symmetric
475    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    // Columns are eigenvectors, as from radio_beam's SVD.
495    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
516/// True if every beam in `beams` can be convolved up to `large_beam`.
517pub 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    /// A target whose major axis equals an input's major axis still contains
544    /// it; the kernel is one-dimensional.
545    #[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    /// The CASA two-beam solution is only used if it contains both inputs.
570    #[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            // The CASA formula gives 19.6255"x8.0238" here, which does
575            // not contain the first beam.
576            (
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}