Skip to main content

arcsec_core/pipeline/
solver.rs

1//! Full plate-solving pipeline.
2
3use core::f64::consts::PI;
4use std::path::PathBuf;
5
6use crate::catalog::CatalogStar;
7use crate::catalog::read_catalog_stars;
8use crate::detection::get_background;
9use crate::detection::stars::find_stars_with_background;
10use crate::error::{ArcsecError, Result};
11use crate::math::coords::{ang_sep, equatorial_standard, standard_equatorial};
12use crate::math::lsq::{fit_affine, solve_plate_constants};
13use crate::quads::{
14    TETRA_TOL_FACTOR, bijective_filter, build_quads, build_quads_presorted, build_triangles,
15    extract_star_pairs, extract_triangle_pairs, filter_by_scale, filter_triangles_by_scale,
16    find_matches_sorted, find_triangle_matches, vote_filter,
17};
18use crate::types::{PairedPositions, PlateConstants, Star, StarList, WcsSolution};
19use crate::wcs::output::derive_wcs;
20
21use super::spiral::SpiralSearch;
22
23/// Which pattern-matching algorithm to use in the catalog spiral loop.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25pub enum SolveMethod {
26    /// ASTAP-style 5-ratio quad matching with `vote_filter` (default).
27    #[default]
28    Quads,
29    /// TETRA 2-ratio triangle matching with bijective filter.
30    Tetra,
31}
32
33/// Parameters for [`solve_image`].
34#[derive(Debug, Clone)]
35pub struct SolveParams {
36    /// Approximate RA of image centre (radians, hint only).
37    pub ra_hint: f64,
38    /// Approximate DEC of image centre (radians, hint only).
39    pub dec_hint: f64,
40    /// Image field of view (square side, radians). Used as the spiral step size.
41    pub fov: f64,
42    /// Maximum search radius from the hint position (radians).
43    pub search_radius: f64,
44    /// Quad ratio matching tolerance (ASTAP default ≈ 0.007).
45    pub quad_tolerance: f64,
46    /// Minimum HFD for valid stars (pixels).
47    pub hfd_min: f64,
48    /// Maximum number of image stars to detect.
49    pub max_stars: usize,
50    /// Path to the catalog directory.
51    pub db_path: PathBuf,
52    /// Catalog name prefix (e.g. `"d20"`, `"d80"`).
53    pub db_name: String,
54    /// Pixel binning factor applied before solving (1 = none, 2 = 2×2, ...).
55    /// WCS output is scaled back to original image pixel coordinates.
56    pub binning: usize,
57    /// Pattern-matching algorithm for the catalog spiral.
58    pub method: SolveMethod,
59    /// Worker threads for the spiral search. 0 = one per available core.
60    ///
61    /// Spiral positions are independent, so they are evaluated a batch at a time
62    /// across this many threads. Results are identical to the serial search: within
63    /// a batch the lowest spiral index still wins, so the first position that
64    /// verifies is the one returned, exactly as before.
65    pub threads: usize,
66}
67
68/// Iterative sigma-clipping: fit plate constants, reject pairs with large residuals,
69/// re-fit until stable or fewer than `min_count` pairs remain.
70///
71/// First pass uses a 10-pixel absolute threshold (in catalog arcsec) to cut the
72/// large residuals of false-positive triangle matches. Subsequent passes apply
73/// `sigma × rms` clipping until the set is stable.
74fn sigma_clip_pairs(
75    mut img_pos: Vec<(f64, f64)>,
76    mut cat_pos: Vec<(f64, f64)>,
77    sigma: f64,
78    min_count: usize,
79) -> PairedPositions {
80    let mut first_pass = true;
81    for _ in 0..10 {
82        if img_pos.len() < min_count.max(3) {
83            break;
84        }
85        // Unchecked: the first fit is made on the contaminated set, and gross
86        // outliers can skew it past solve_plate_constants' scale check even though
87        // clipping them is exactly what would fix it.
88        let Ok(plate) = fit_affine(&img_pos, &cat_pos) else {
89            break;
90        };
91        let residuals: Vec<f64> = img_pos
92            .iter()
93            .zip(cat_pos.iter())
94            .map(|(&(xi, yi), &(xc, yc))| {
95                let xp = plate.a * xi + plate.b * yi + plate.c;
96                let yp = plate.d * xi + plate.e * yi + plate.f;
97                ((xp - xc).powi(2) + (yp - yc).powi(2)).sqrt()
98            })
99            .collect();
100        let rms = (residuals.iter().map(|r| r * r).sum::<f64>() / residuals.len() as f64).sqrt();
101        let threshold = if first_pass {
102            first_pass = false;
103            // 10 px in catalog-arcsec: generous cut for large FP residuals on first pass.
104            let cdelt = (plate.a.powi(2) + plate.d.powi(2)).sqrt();
105            // Gross outliers drag a least-squares fit towards themselves and inflate
106            // every residual, so a fixed cut can keep them. The median residual is
107            // not moved by a minority of outliers: allow 3 sigma of it (1.4826 x the
108            // median absolute residual estimates sigma) when that is larger.
109            let mut sorted = residuals.clone();
110            sorted.sort_unstable_by(f64::total_cmp);
111            let median = sorted[sorted.len() / 2];
112            (10.0 * cdelt).max(10.0).max(3.0 * 1.4826 * median)
113        } else {
114            sigma * rms
115        };
116        let before = img_pos.len();
117        let mut new_img = Vec::with_capacity(before);
118        let mut new_cat = Vec::with_capacity(before);
119        for ((&ip, &cp), &r) in img_pos.iter().zip(cat_pos.iter()).zip(residuals.iter()) {
120            if r <= threshold {
121                new_img.push(ip);
122                new_cat.push(cp);
123            }
124        }
125        if new_img.len() == before {
126            break; // stable — no more outliers
127        }
128        img_pos = new_img;
129        cat_pos = new_cat;
130    }
131    (img_pos, cat_pos)
132}
133
134/// Minimum number of individually matched stars required to believe a solution.
135///
136/// Correct solves typically match 200-375 stars, so this is deliberately loose;
137/// its job is to reject the handful-of-coincidences case. Together with
138/// `MIN_VERIFY_SPREAD` it separates two otherwise identical-looking results: M31 at
139/// 2 degrees (22 stars, spread 0.221, rms 0.65", rotation wrong by 1.56 degrees)
140/// from the Dec -88 field (46 stars, spread 0.207, rms 0.66", correct to 2.3").
141const MIN_VERIFIED_STARS: usize = 30;
142/// Match radii (pixels) used by successive verification passes, coarse to fine.
143const VERIFY_RADII: [f64; 3] = [6.0, 3.0, 2.0];
144/// Minimum spread of the matched stars, as a fraction of the image half-diagonal.
145///
146/// A count threshold alone is not enough: matches clustered in one part of the
147/// frame (the core of a bright galaxy, say) pin the position but leave rotation
148/// and scale essentially free. M31 at 2 degrees passed with 22 matched stars and
149/// a 1.56-degree rotation error, which is 154" at the field corners.
150const MIN_VERIFY_SPREAD: f64 = 0.20;
151
152/// One verification pass: the re-fitted plate, the number of matched stars, the
153/// per-star RMS in arcsec, and the spatial spread of the matches.
154type VerifyPass = (PlateConstants, usize, f64, f64);
155
156/// Project the catalogue onto the image with a candidate plate solution, match
157/// individual stars, and re-fit on those matches.
158///
159/// The quad matcher only ever produces quad *centroids*, so the plate fit is built
160/// from a handful of averaged positions and nothing ever checks that the individual
161/// stars agree. This does that check: invert the plate to map every catalogue star
162/// into pixel space, pair each with the nearest detected star, re-fit on the pairs,
163/// and repeat with a shrinking radius.
164///
165/// Returns `(refined_plate, n_matched_stars, rms_arcsec)`, or `None` if the plate is
166/// degenerate, too few stars agree, or the matches are too clustered.
167fn verify_and_refit(
168    img_stars: &StarList,
169    cat_stars: &StarList,
170    plate: &PlateConstants,
171    img_w: usize,
172    img_h: usize,
173) -> Option<(PlateConstants, usize, f64)> {
174    if img_stars.is_empty() || cat_stars.is_empty() {
175        return None;
176    }
177
178    // Uniform grid over the detected stars for nearest-neighbour lookup.
179    let (mut min_x, mut min_y) = (f64::INFINITY, f64::INFINITY);
180    let (mut max_x, mut max_y) = (f64::NEG_INFINITY, f64::NEG_INFINITY);
181    for st in &img_stars.0 {
182        min_x = min_x.min(st.x);
183        max_x = max_x.max(st.x);
184        min_y = min_y.min(st.y);
185        max_y = max_y.max(st.y);
186    }
187    if !(min_x.is_finite() && min_y.is_finite() && max_x > min_x && max_y > min_y) {
188        return None;
189    }
190    let cell = VERIFY_RADII[0].max(1.0);
191    let nx = (((max_x - min_x) / cell).ceil() as usize + 1).max(1);
192    let ny = (((max_y - min_y) / cell).ceil() as usize + 1).max(1);
193    let mut grid: Vec<Vec<u32>> = vec![Vec::new(); nx * ny];
194    for (i, st) in img_stars.0.iter().enumerate() {
195        let gx = ((st.x - min_x) / cell) as usize;
196        let gy = ((st.y - min_y) / cell) as usize;
197        grid[gy.min(ny - 1) * nx + gx.min(nx - 1)].push(i as u32);
198    }
199
200    let mut current = plate.clone();
201    let mut best: Option<VerifyPass> = None;
202
203    for &radius in &VERIFY_RADII {
204        let det = current.a * current.e - current.b * current.d;
205        if det.abs() < 1e-12 {
206            return None;
207        }
208        let r2 = radius * radius;
209
210        let mut img_pos: Vec<(f64, f64)> = Vec::new();
211        let mut cat_pos: Vec<(f64, f64)> = Vec::new();
212        let mut used = vec![false; img_stars.len()];
213
214        for cs in &cat_stars.0 {
215            // Invert  xi = a*x + b*y + c ;  eta = d*x + e*y + f
216            let dx = cs.x - current.c;
217            let dy = cs.y - current.f;
218            let px = (current.e * dx - current.b * dy) / det;
219            let py = (-current.d * dx + current.a * dy) / det;
220            if px < min_x - radius
221                || px > max_x + radius
222                || py < min_y - radius
223                || py > max_y + radius
224            {
225                continue;
226            }
227
228            let gx = (((px - min_x) / cell) as isize).clamp(0, nx as isize - 1);
229            let gy = (((py - min_y) / cell) as isize).clamp(0, ny as isize - 1);
230            let mut best_i: Option<usize> = None;
231            let mut best_d2 = r2;
232            for oy in -1isize..=1 {
233                for ox in -1isize..=1 {
234                    let cx = gx + ox;
235                    let cy = gy + oy;
236                    if cx < 0 || cy < 0 || cx >= nx as isize || cy >= ny as isize {
237                        continue;
238                    }
239                    for &i in &grid[cy as usize * nx + cx as usize] {
240                        let i = i as usize;
241                        if used[i] {
242                            continue;
243                        }
244                        let st = &img_stars.0[i];
245                        let d2 = (st.x - px) * (st.x - px) + (st.y - py) * (st.y - py);
246                        if d2 < best_d2 {
247                            best_d2 = d2;
248                            best_i = Some(i);
249                        }
250                    }
251                }
252            }
253            if let Some(i) = best_i {
254                used[i] = true; // one-to-one: a detected star backs at most one catalogue star
255                img_pos.push((img_stars.0[i].x, img_stars.0[i].y));
256                cat_pos.push((cs.x, cs.y));
257            }
258        }
259
260        if img_pos.len() < 4 {
261            break;
262        }
263        let Ok(refined) = solve_plate_constants(&img_pos, &cat_pos) else {
264            break;
265        };
266        let mut sq = 0.0;
267        for (&(xi, yi), &(xc, yc)) in img_pos.iter().zip(cat_pos.iter()) {
268            let xp = refined.a * xi + refined.b * yi + refined.c;
269            let yp = refined.d * xi + refined.e * yi + refined.f;
270            sq += (xp - xc).powi(2) + (yp - yc).powi(2);
271        }
272        let rms = (sq / img_pos.len() as f64).sqrt();
273        // Spread of the matched stars about their own centroid, as a fraction of the
274        // image half-diagonal. Matches clustered in one corner leave rotation free.
275        let n = img_pos.len() as f64;
276        let mx = img_pos.iter().map(|p| p.0).sum::<f64>() / n;
277        let my = img_pos.iter().map(|p| p.1).sum::<f64>() / n;
278        let var = img_pos
279            .iter()
280            .map(|&(x, y)| (x - mx) * (x - mx) + (y - my) * (y - my))
281            .sum::<f64>()
282            / n;
283        let half_diag = 0.5 * ((img_w * img_w + img_h * img_h) as f64).sqrt();
284        let spread = var.sqrt() / half_diag;
285        log::debug!(
286            "verify: {} stars, spread {:.3}, rms {:.2}\"",
287            img_pos.len(),
288            spread,
289            rms
290        );
291
292        best = Some((refined.clone(), img_pos.len(), rms, spread));
293        current = refined;
294    }
295
296    best.filter(|&(_, n, _, spread)| n >= MIN_VERIFIED_STARS && spread >= MIN_VERIFY_SPREAD)
297        .map(|(p, n, r, _)| (p, n, r))
298}
299
300/// Everything a spiral position needs that does not change between positions.
301struct SpiralCtx<'a> {
302    params: &'a SolveParams,
303    img: &'a crate::types::ImageBuffer,
304    stars: &'a StarList,
305    img_quads: &'a crate::types::QuadList,
306    img_tris: &'a crate::quads::TriangleList,
307    nrstars_image: usize,
308    nrstars_required: usize,
309    oversize: f64,
310    min_quads: usize,
311    step_size: f64,
312}
313
314/// A spiral position that produced a verified solution.
315struct PositionOutcome {
316    idx: usize,
317    ra_db: f64,
318    dec_db: f64,
319    sep_deg: f64,
320    plate: PlateConstants,
321    n_verified: usize,
322    rms: f64,
323    n_matched: usize,
324    n_raw: usize,
325    mag_limit: f64,
326}
327
328/// Result of trying one spiral position: the angular distance if the catalogue was
329/// actually read there (for the ASTAP-style progress line), and the solution if one
330/// verified.
331struct PositionTry {
332    sep_deg: Option<f64>,
333    outcome: Option<PositionOutcome>,
334}
335
336impl PositionTry {
337    const NONE: Self = Self {
338        sep_deg: None,
339        outcome: None,
340    };
341}
342
343/// Evaluate a single spiral position. Pure with respect to `ctx`, so positions can
344/// be run concurrently.
345fn try_position(ctx: &SpiralCtx<'_>, idx: usize, sx: i32, sy: i32) -> PositionTry {
346    let params = ctx.params;
347    let step_size = ctx.step_size;
348
349    let dec_db_raw = params.dec_hint + step_size * sy as f64;
350    let (dec_db, flip) = if dec_db_raw > PI / 2.0 {
351        (PI - dec_db_raw, PI)
352    } else if dec_db_raw < -PI / 2.0 {
353        (-PI - dec_db_raw, PI)
354    } else {
355        (dec_db_raw, 0.0)
356    };
357
358    let extra = if dec_db > 0.0 {
359        step_size * 0.5
360    } else {
361        -step_size * 0.5
362    };
363    let ra_offset = step_size * sx as f64 / (dec_db - extra).cos();
364    if ra_offset > PI / 2.0 + step_size * 0.5 || ra_offset < -PI / 2.0 {
365        return PositionTry::NONE;
366    }
367
368    let ra_db = (flip + params.ra_hint + ra_offset).rem_euclid(2.0 * PI);
369    let sep = ang_sep(ra_db, dec_db, params.ra_hint, params.dec_hint);
370    if sep > params.search_radius + step_size / 2.0 {
371        return PositionTry::NONE;
372    }
373
374    // Any read failure (a missing tile included) counts as "nothing catalogued
375    // here"; `solve_image` has already checked that the database exists at all.
376    let cat_raw = match read_catalog_stars(
377        &params.db_path,
378        &params.db_name,
379        ra_db,
380        dec_db,
381        params.fov * ctx.oversize,
382        ctx.nrstars_required,
383    ) {
384        Ok(v) if !v.is_empty() => v,
385        Ok(_) | Err(_) => return PositionTry::NONE,
386    };
387
388    let sep_deg = sep.to_degrees();
389    let mag_limit = cat_raw
390        .iter()
391        .map(|s| s.mag)
392        .fold(f64::NEG_INFINITY, f64::max);
393    log::info!(
394        "Search {}, [{},{}], position: {}  Down to magn {:.1}  {} database stars  {} database quads to compare.",
395        idx,
396        sx,
397        sy,
398        format_radec(ra_db, dec_db),
399        mag_limit,
400        cat_raw.len(),
401        cat_raw.len(),
402    );
403
404    let mut cat_stars: Vec<Star> = cat_raw
405        .iter()
406        .map(|s| {
407            let (x, y) = equatorial_standard(ra_db, dec_db, s.ra, s.dec, 1.0);
408            Star {
409                x,
410                y,
411                snr: 1.0,
412                hfd: 2.0,
413            }
414        })
415        .collect();
416    cat_stars.sort_unstable_by(|a, b| a.x.total_cmp(&b.x));
417    let cat_star_list = StarList(cat_stars);
418
419    let failed = PositionTry {
420        sep_deg: Some(sep_deg),
421        outcome: None,
422    };
423
424    let (img_pos, cat_pos, n_matched, n_raw) = match params.method {
425        SolveMethod::Quads => {
426            let mut cat_quads = build_quads_presorted(&cat_star_list, ctx.nrstars_image);
427            if cat_quads.is_empty() {
428                return failed;
429            }
430            crate::quads::r#match::sort_catalog_quads(&mut cat_quads);
431            let raw = find_matches_sorted(ctx.img_quads, &cat_quads, params.quad_tolerance);
432            let n_raw = raw.len();
433            log::info!("Found {n_raw} references");
434            let mut filtered = vote_filter(ctx.img_quads, &cat_quads, &raw, params.quad_tolerance);
435            if filtered.len() < ctx.min_quads {
436                let (by_scale, _) = filter_by_scale(&raw, params.quad_tolerance);
437                if by_scale.len() > filtered.len() {
438                    filtered = by_scale;
439                }
440            }
441            if filtered.len() < ctx.min_quads {
442                return failed;
443            }
444            let (ip, cp) = extract_star_pairs(ctx.img_quads, &cat_quads, &filtered);
445            (ip, cp, filtered.len(), n_raw)
446        }
447        SolveMethod::Tetra => {
448            let cat_tris = build_triangles(&cat_star_list);
449            if cat_tris.is_empty() {
450                return failed;
451            }
452            let tol = params.quad_tolerance * TETRA_TOL_FACTOR;
453            let raw = find_triangle_matches(ctx.img_tris, &cat_tris, tol);
454            let n_raw = raw.len();
455            log::info!("Found {n_raw} triangle references");
456            let biject = bijective_filter(&raw, ctx.img_tris, &cat_tris);
457            let (filtered, _) = filter_triangles_by_scale(&biject, params.quad_tolerance);
458            if filtered.len() < ctx.min_quads {
459                return failed;
460            }
461            let (ip, cp) = extract_triangle_pairs(ctx.img_tris, &cat_tris, &filtered);
462            let (ip, cp) = sigma_clip_pairs(ip, cp, 3.0, ctx.min_quads);
463            if ip.len() < ctx.min_quads {
464                return failed;
465            }
466            let n_clean = ip.len();
467            (ip, cp, n_clean, n_raw)
468        }
469    };
470
471    let Ok(plate) = solve_plate_constants(&img_pos, &cat_pos) else {
472        return failed;
473    };
474
475    let Some((plate, n_verified, rms)) = verify_and_refit(
476        ctx.stars,
477        &cat_star_list,
478        &plate,
479        ctx.img.width,
480        ctx.img.height,
481    ) else {
482        log::info!("Verification failed at this position; continuing search.");
483        return failed;
484    };
485    log::info!("Verified {n_verified} stars against the catalogue, residual {rms:.2}\"");
486
487    let (plate, ra_db, dec_db, n_verified, rms) =
488        recentre(ctx, &cat_raw, plate, ra_db, dec_db, n_verified, rms);
489
490    PositionTry {
491        sep_deg: Some(sep_deg),
492        outcome: Some(PositionOutcome {
493            idx,
494            ra_db,
495            dec_db,
496            sep_deg,
497            plate,
498            n_verified,
499            rms,
500            n_matched,
501            n_raw,
502            mag_limit,
503        }),
504    }
505}
506
507/// Refit a verified plate in the tangent plane at the image centre.
508///
509/// The plate constants are a linear map from pixels to the tangent plane at the
510/// spiral position the catalogue was projected about. Pixels map linearly onto a
511/// tangent plane only at the optical axis, so away from it the fit absorbs the
512/// projection's curvature as a rotation and shear, which grow with the distance
513/// from the field and with declination. Star-level RMS stays small, because the fit
514/// is good *in that plane*, but the CD matrix derived from it is wrong at the image
515/// centre: with the hint 0.3 fields off, most corpus solves were out by 5-1600" at
516/// the corners.
517///
518/// So once a position verifies, move the tangent point to the image centre, pair
519/// stars as the verified plate predicts them, fit those pairs in the new plane,
520/// and verify again. Twice, since the centre moves slightly with
521/// the new fit. If a pass fails to verify, the previous solution is kept: this can
522/// only improve a solve, never lose one.
523fn recentre(
524    ctx: &SpiralCtx<'_>,
525    cat_raw: &[CatalogStar],
526    mut plate: PlateConstants,
527    mut ra_db: f64,
528    mut dec_db: f64,
529    mut n_verified: usize,
530    mut rms: f64,
531) -> (PlateConstants, f64, f64, usize, f64) {
532    let (w, h) = (ctx.img.width as f64, ctx.img.height as f64);
533    let (cx, cy) = ((w - 1.0) * 0.5, (h - 1.0) * 0.5);
534    let apply =
535        |p: &PlateConstants, x: f64, y: f64| (p.a * x + p.b * y + p.c, p.d * x + p.e * y + p.f);
536
537    for _ in 0..2 {
538        let (xs, ys) = apply(&plate, cx, cy);
539        // Already centred to well under a milliarcsecond: nothing to gain.
540        if xs.hypot(ys) < 1e-3 {
541            break;
542        }
543        let (ra0, dec0) = standard_equatorial(ra_db, dec_db, xs, ys, 1.0);
544
545        // Starting plate for the new tangent plane. Mapping one tangent plane onto
546        // another is far from linear over a wide field (a 10-degree field 3 degrees
547        // off moves by ~65" under a straight-line fit), so rather than carry the
548        // plate across, pair stars exactly as the verified plate predicts them and
549        // fit those pairs against their positions in the new plane.
550        let det = plate.a * plate.e - plate.b * plate.d;
551        if det.abs() < 1e-12 {
552            break;
553        }
554        let r2 = VERIFY_RADII[0] * VERIFY_RADII[0];
555        let mut used = vec![false; ctx.stars.len()];
556        let mut img_pos = Vec::new();
557        let mut new_pos = Vec::new();
558        let mut cat = Vec::with_capacity(cat_raw.len());
559        for s in cat_raw {
560            let (nx, ny) = equatorial_standard(ra0, dec0, s.ra, s.dec, 1.0);
561            cat.push(Star {
562                x: nx,
563                y: ny,
564                snr: 1.0,
565                hfd: 2.0,
566            });
567            let (ox, oy) = equatorial_standard(ra_db, dec_db, s.ra, s.dec, 1.0);
568            let (dx, dy) = (ox - plate.c, oy - plate.f);
569            let px = (plate.e * dx - plate.b * dy) / det;
570            let py = (-plate.d * dx + plate.a * dy) / det;
571            let nearest = ctx
572                .stars
573                .0
574                .iter()
575                .enumerate()
576                .filter(|&(i, _)| !used[i])
577                .map(|(i, st)| (i, (st.x - px).powi(2) + (st.y - py).powi(2)))
578                .filter(|&(_, d2)| d2 < r2)
579                .min_by(|a, b| a.1.total_cmp(&b.1));
580            if let Some((i, _)) = nearest {
581                used[i] = true;
582                img_pos.push((ctx.stars.0[i].x, ctx.stars.0[i].y));
583                new_pos.push((nx, ny));
584            }
585        }
586        let Ok(guess) = solve_plate_constants(&img_pos, &new_pos) else {
587            break;
588        };
589        let cat = StarList(cat);
590        let Some((p, n, r)) =
591            verify_and_refit(ctx.stars, &cat, &guess, ctx.img.width, ctx.img.height)
592        else {
593            log::info!("Re-centring on the image centre did not verify; keeping the fit.");
594            break;
595        };
596        log::info!("Re-centred on the image centre: verified {n} stars, residual {r:.2}\"");
597        (plate, ra_db, dec_db, n_verified, rms) = (p, ra0, dec0, n, r);
598    }
599    (plate, ra_db, dec_db, n_verified, rms)
600}
601
602/// Solve the WCS for an image against an ASTAP star database.
603///
604/// Walks a square spiral out from the hint in steps of one field of view, and
605/// returns the first position whose quad match survives star-by-star verification.
606/// If `params.binning > 1`, `img` is taken to be the binned image and the returned
607/// CRPIX/CD/CDELT are scaled back to the unbinned pixel grid.
608///
609/// All progress is emitted via the `log` crate at INFO level — callers install
610/// whichever logger backend they need (file, stderr, both, or none).
611///
612/// # Errors
613///
614/// - [`ArcsecError::InvalidParameter`] if `fov` is not positive and finite, or
615///   `search_radius` is negative or not finite.
616/// - [`ArcsecError::CatalogNotFound`] if `db_path` holds no database called `db_name`.
617/// - [`ArcsecError::InsufficientStars`] if fewer than 5 stars are detected.
618/// - [`ArcsecError::InsufficientQuads`] if no spiral position yields a verified match.
619pub fn solve_image(img: &crate::types::ImageBuffer, params: &SolveParams) -> Result<WcsSolution> {
620    // The spiral steps by one FOV out to the search radius, so a zero, negative or
621    // NaN FOV would make the step count infinite (and saturate to i32::MAX).
622    if !(params.fov.is_finite() && params.fov > 0.0) {
623        return Err(ArcsecError::InvalidParameter(format!(
624            "field of view must be positive, got {} rad",
625            params.fov
626        )));
627    }
628    if !(params.search_radius.is_finite() && params.search_radius >= 0.0) {
629        return Err(ArcsecError::InvalidParameter(format!(
630            "search radius must be non-negative, got {} rad",
631            params.search_radius
632        )));
633    }
634
635    // Check the database up front. Every spiral position swallows a missing-file
636    // error as "nothing catalogued here", so without this a wrong -d/-D reads
637    // nothing everywhere and surfaces as InsufficientQuads - exit 1, "no
638    // solution" - when the image is fine and the database is the problem.
639    if !crate::catalog::catalog_present(&params.db_path, &params.db_name) {
640        return Err(ArcsecError::CatalogNotFound(params.db_path.clone()));
641    }
642
643    // --- Phase A: star detection ---
644    let bg = get_background(img, params.max_stars);
645    log::info!("Start finding stars");
646    let (stars, stars_raw) = find_stars_with_background(
647        img,
648        &bg,
649        params.hfd_min,
650        params.max_stars,
651        img.width,
652        img.height,
653    );
654    log::info!(
655        "{} stars found of the requested {}. Background value is {:.0}. \
656         Detection level used {:.0} above background. Star level is {:.0} above background. \
657         Noise level is {:.0}",
658        stars_raw,
659        params.max_stars,
660        bg.mean,
661        bg.star_level,
662        bg.star_level,
663        bg.noise,
664    );
665    if stars_raw > params.max_stars {
666        log::info!("Selecting the {} brightest stars only.", params.max_stars);
667    }
668
669    // Detection is not trimmed. Stars beyond `-s` are faint enough to be absent
670    // from the catalog, which once corrupted 3-NN quads badly enough to justify
671    // dropping all but the brightest half; quad redundancy and star-level
672    // verification absorb that now, and halving the list halved the quad count.
673    // The catalog still reads the full requested depth.
674
675    let nrstars_image = stars.len();
676    if nrstars_image < 5 {
677        return Err(ArcsecError::InsufficientStars {
678            found: nrstars_image,
679            required: 5,
680        });
681    }
682
683    // --- Phase B: image pattern building ---
684    let img_quads = build_quads(&stars, nrstars_image);
685    let nr_quads = img_quads.len();
686
687    let img_tris = if params.method == SolveMethod::Tetra {
688        build_triangles(&stars)
689    } else {
690        crate::quads::TriangleList::default()
691    };
692
693    let patterns_empty = match params.method {
694        SolveMethod::Quads => nr_quads == 0,
695        SolveMethod::Tetra => img_tris.is_empty(),
696    };
697    if patterns_empty {
698        return Err(ArcsecError::InsufficientQuads {
699            found: 0,
700            required: 3,
701        });
702    }
703
704    let min_quads: usize = 3 + nrstars_image / 140;
705
706    let oversize: f64 = if nrstars_image < 35 {
707        2.0
708    } else if nrstars_image > 140 {
709        1.0
710    } else {
711        2.0 * (35.0 / nrstars_image as f64).sqrt()
712    };
713
714    // Use the full catalog depth regardless of how many image stars we trimmed.
715    let nrstars_required = (params.max_stars as f64 * oversize * oversize).round() as usize;
716    let step_size = params.fov;
717    let fov_deg = step_size.to_degrees();
718    let max_distance = (params.search_radius / step_size + 2.0) as i32;
719
720    log::info!(
721        "{} stars, {} quads selected in the image. {} database stars, {} database quads required \
722         for the {:.2}d square search window. Step size {:.2}d. Oversize {:.2}",
723        nrstars_image,
724        nr_quads,
725        nrstars_required,
726        nrstars_required,
727        fov_deg * oversize,
728        fov_deg,
729        oversize,
730    );
731
732    // --- Phase C: spiral search ---
733    //
734    // Spiral positions are independent, so they are evaluated a batch at a time
735    // across a thread pool. Semantics are unchanged from the serial search: within a
736    // batch the lowest spiral index wins, and batches are processed in order, so the
737    // position returned is exactly the one the serial loop would have returned. The
738    // only cost is evaluating the rest of a batch after its first success.
739    let ctx = SpiralCtx {
740        params,
741        img,
742        stars: &stars,
743        img_quads: &img_quads,
744        img_tris: &img_tris,
745        nrstars_image,
746        nrstars_required,
747        oversize,
748        min_quads,
749        step_size,
750    };
751
752    let n_threads = if params.threads > 0 {
753        params.threads
754    } else {
755        crate::max_threads()
756    }
757    .clamp(1, 64);
758
759    let positions: Vec<(i32, i32)> = SpiralSearch::new(max_distance).collect();
760    let mut step_distances: Vec<f64> = Vec::new();
761
762    let mut winner: Option<PositionOutcome> = None;
763    let mut start_idx = 0usize;
764    while start_idx < positions.len() && winner.is_none() {
765        // The first position is the hint itself and usually solves outright, so try it
766        // on its own: spawning a pool for it would cost more than it saves.
767        let batch_len = if start_idx == 0 {
768            1
769        } else {
770            n_threads.min(positions.len() - start_idx)
771        };
772        let batch = &positions[start_idx..start_idx + batch_len];
773
774        let tries: Vec<PositionTry> = if n_threads == 1 || batch.len() == 1 {
775            batch
776                .iter()
777                .enumerate()
778                .map(|(k, &(sx, sy))| try_position(&ctx, start_idx + k, sx, sy))
779                .collect()
780        } else {
781            std::thread::scope(|scope| {
782                let handles: Vec<_> = batch
783                    .iter()
784                    .enumerate()
785                    .map(|(k, &(sx, sy))| {
786                        let ctx = &ctx;
787                        scope.spawn(move || try_position(ctx, start_idx + k, sx, sy))
788                    })
789                    .collect();
790                handles
791                    .into_iter()
792                    // A dead worker must not read as "nothing matched here":
793                    // the spiral would move on and the solve would fail for a
794                    // reason with no trace anywhere.
795                    .map(|h| h.join().unwrap_or_else(|e| std::panic::resume_unwind(e)))
796                    .collect()
797            })
798        };
799
800        for t in tries {
801            if let Some(d) = t.sep_deg {
802                step_distances.push(d);
803            }
804            if let Some(o) = t.outcome
805                && winner.as_ref().is_none_or(|w| o.idx < w.idx)
806            {
807                winner = Some(o);
808            }
809        }
810
811        start_idx += batch_len;
812    }
813
814    if let Some(o) = winner {
815        log::info!(
816            "{} of {} patterns selected matching within {:.3} tolerance.",
817            o.n_matched,
818            o.n_raw,
819            params.quad_tolerance,
820        );
821
822        let mut wcs = derive_wcs(o.ra_db, o.dec_db, &o.plate, img.width, img.height);
823        if params.binning > 1 {
824            let b = params.binning as f64;
825            wcs.crpix1 = (wcs.crpix1 - 0.5) * b + 0.5;
826            wcs.crpix2 = (wcs.crpix2 - 0.5) * b + 0.5;
827            wcs.cd1_1 /= b;
828            wcs.cd1_2 /= b;
829            wcs.cd2_1 /= b;
830            wcs.cd2_2 /= b;
831            wcs.cdelt1 /= b;
832            wcs.cdelt2 /= b;
833        }
834        wcs.residual_rms = o.rms;
835        wcs.stars_matched = o.n_verified;
836        wcs.raw_matches = o.n_raw;
837        wcs.plate = o.plate;
838        wcs.mag_limit = o.mag_limit;
839        wcs.search_dist_deg = o.sep_deg;
840        wcs.step_distances = step_distances;
841        return Ok(wcs);
842    }
843
844    Err(ArcsecError::InsufficientQuads {
845        found: 0,
846        required: min_quads,
847    })
848}
849
850/// Format RA (radians) and Dec (radians) as ASTAP-style `"HH: MM  SS.S ±DDd MM  SS"`.
851#[must_use]
852pub fn format_radec(ra_rad: f64, dec_rad: f64) -> String {
853    // Round once, at the printed precision, and only then split into fields.
854    // Splitting first and letting `{:.1}` round the seconds printed 59.96 s as
855    // "60.0" without carrying into the minutes (and 23:59:59.96 as "23: 59  60.0").
856    const TENTHS_PER_DAY: f64 = 24.0 * 36_000.0;
857    let ra_tenths = ((ra_rad.to_degrees() / 15.0 * 36_000.0)
858        .round()
859        .rem_euclid(TENTHS_PER_DAY)) as u64;
860    let h = ra_tenths / 36_000;
861    let m = ra_tenths / 600 % 60;
862    let s = (ra_tenths % 600) as f64 / 10.0;
863
864    let dec_deg = dec_rad.to_degrees();
865    let sign = if dec_deg < 0.0 { '-' } else { '+' };
866    let dec_secs = (dec_deg.abs() * 3600.0).round() as u64;
867    let dd = dec_secs / 3600;
868    let dm = dec_secs / 60 % 60;
869    let ds = dec_secs % 60;
870
871    format!("{h}: {m:02}  {s:.1} {sign}{dd}d {dm:02}  {ds}")
872}
873
874#[cfg(test)]
875mod tests {
876    use super::*;
877    use crate::math::coords::{ang_sep, standard_equatorial};
878    use crate::test_support::{
879        Rng, SkySpec, TempDir, TruthWcs, random_sky, render, write_001_db, write_290_db,
880        write_1476_db,
881    };
882    use crate::types::{ImageBuffer, PlateConstants};
883    use crate::wcs::output::derive_wcs;
884    use core::f64::consts::PI;
885
886    fn deg(d: f64) -> f64 {
887        d * PI / 180.0
888    }
889
890    fn make_test_scene(
891        n_stars: usize,
892        ra_center: f64,
893        dec_center: f64,
894        cdelt_arcsec: f64,
895        width: usize,
896        height: usize,
897    ) -> (ImageBuffer, Vec<(f64, f64)>, PlateConstants) {
898        let mut data = vec![100.0f32; width * height];
899        let mut catalog_sky: Vec<(f64, f64)> = Vec::new();
900        let stars_per_row = (n_stars as f64).sqrt().ceil() as usize;
901        let spacing = 40.0;
902        let cx = (width as f64 - 1.0) / 2.0;
903        let cy = (height as f64 - 1.0) / 2.0;
904        let a = cdelt_arcsec;
905        let c = -a * cx;
906        let e = cdelt_arcsec;
907        let f_offset = -e * cy;
908        let plate = PlateConstants {
909            a,
910            b: 0.0,
911            c,
912            d: 0.0,
913            e,
914            f: f_offset,
915        };
916        let mut count = 0;
917        'outer: for row in 0..stars_per_row {
918            for col in 0..stars_per_row {
919                if count >= n_stars {
920                    break 'outer;
921                }
922                let px = 20.0 + col as f64 * spacing;
923                let py = 20.0 + row as f64 * spacing;
924                if px >= width as f64 - 20.0 || py >= height as f64 - 20.0 {
925                    continue;
926                }
927                let x_std = a * px + c;
928                let y_std = e * py + f_offset;
929                let (ra, dec) = standard_equatorial(ra_center, dec_center, x_std, y_std, 1.0);
930                catalog_sky.push((ra, dec));
931                let sigma = 2.0;
932                let amp = 30000.0f32;
933                for dy in -8i32..=8 {
934                    for dx in -8i32..=8 {
935                        let x = (px as i32 + dx) as usize;
936                        let y = (py as i32 + dy) as usize;
937                        if x < width && y < height {
938                            let r2 = (dx * dx + dy * dy) as f64 / (2.0 * sigma * sigma);
939                            data[y * width + x] += amp * (-r2).exp() as f32;
940                        }
941                    }
942                }
943                count += 1;
944            }
945        }
946        let img = ImageBuffer {
947            data,
948            width,
949            height,
950        };
951        (img, catalog_sky, plate)
952    }
953
954    #[test]
955    fn derive_wcs_recovers_position() {
956        let ra_center = deg(45.0);
957        let dec_center = deg(30.0);
958        let (img, _cat, plate) = make_test_scene(16, ra_center, dec_center, 2.0, 300, 300);
959        let wcs = derive_wcs(ra_center, dec_center, &plate, img.width, img.height);
960        let sep_arcsec = ang_sep(wcs.ra0, wcs.dec0, ra_center, dec_center) * (180.0 / PI * 3600.0);
961        assert!(sep_arcsec < 0.5, "centre offset = {sep_arcsec} arcsec");
962    }
963
964    #[test]
965    fn spiral_covers_origin_first() {
966        assert_eq!(SpiralSearch::new(5).next(), Some((0, 0)));
967    }
968
969    #[test]
970    fn oversize_formula_limits() {
971        for n in [10, 35, 70, 140, 200] {
972            let ov: f64 = if n < 35 {
973                2.0
974            } else if n > 140 {
975                1.0
976            } else {
977                2.0 * (35.0 / n as f64).sqrt()
978            };
979            assert!((1.0..=2.0).contains(&ov), "oversize={ov} for n={n}");
980        }
981    }
982
983    #[test]
984    fn format_radec_carries_rounded_seconds() {
985        // 1h 59m 59.97s must round up to 2h 00m 00.0s, not print "60.0" seconds.
986        let ra = deg((1.0 + 59.0 / 60.0 + 59.97 / 3600.0) * 15.0);
987        // +10° 59' 59.7" rounds to +11° 00' 00".
988        let dec = deg(10.0 + 59.0 / 60.0 + 59.7 / 3600.0);
989        assert_eq!(format_radec(ra, dec), "2: 00  0.0 +11d 00  0");
990        // RA just short of 24h wraps to 0h.
991        let s = format_radec(deg(359.999_999_9), deg(-0.5));
992        assert!(s.starts_with("0: 00  0.0 -0d 30  0"), "{s}");
993        // An ordinary value is unchanged by the rewrite.
994        assert_eq!(
995            format_radec(deg((5.0 + 35.0 / 60.0 + 17.3 / 3600.0) * 15.0), deg(-5.39)),
996            "5: 35  17.3 -5d 23  24"
997        );
998    }
999
1000    #[test]
1001    fn solve_image_rejects_a_non_positive_fov() {
1002        let img = ImageBuffer::new(64, 64);
1003        let params = SolveParams {
1004            ra_hint: 0.0,
1005            dec_hint: 0.0,
1006            fov: 0.0,
1007            search_radius: 0.1,
1008            quad_tolerance: 0.007,
1009            hfd_min: 1.5,
1010            max_stars: 500,
1011            db_path: std::path::PathBuf::from("/nonexistent"),
1012            db_name: "d50".into(),
1013            binning: 1,
1014            method: SolveMethod::Quads,
1015            threads: 1,
1016        };
1017        assert!(matches!(
1018            solve_image(&img, &params),
1019            Err(ArcsecError::InvalidParameter(_))
1020        ));
1021    }
1022
1023    // ── Plate-fit helpers ─────────────────────────────────────────────────────
1024
1025    /// A known similarity transform (pixels → catalogue arcsec), with a flip.
1026    fn known_plate() -> PlateConstants {
1027        let (s, r) = (3.2_f64, 0.61_f64);
1028        PlateConstants {
1029            a: -s * r.cos(),
1030            b: s * r.sin(),
1031            c: 640.0,
1032            d: s * r.sin(),
1033            e: s * r.cos(),
1034            f: -512.0,
1035        }
1036    }
1037
1038    fn apply(p: &PlateConstants, (x, y): (f64, f64)) -> (f64, f64) {
1039        (p.a * x + p.b * y + p.c, p.d * x + p.e * y + p.f)
1040    }
1041
1042    fn plate_close(p: &PlateConstants, q: &PlateConstants, tol: f64) -> bool {
1043        [
1044            (p.a, q.a),
1045            (p.b, q.b),
1046            (p.c, q.c),
1047            (p.d, q.d),
1048            (p.e, q.e),
1049            (p.f, q.f),
1050        ]
1051        .iter()
1052        .all(|(u, v)| (u - v).abs() <= tol)
1053    }
1054
1055    fn star_at(x: f64, y: f64) -> Star {
1056        Star {
1057            x,
1058            y,
1059            snr: 50.0,
1060            hfd: 2.5,
1061        }
1062    }
1063
1064    /// 40 exact pairs under `known_plate`, then five pairs whose catalogue side is
1065    /// displaced by `outlier(k)`.
1066    fn pairs_with_outliers(outlier: impl Fn(usize, (f64, f64)) -> (f64, f64)) -> PairedPositions {
1067        let plate = known_plate();
1068        let mut rng = Rng::new(7);
1069        let mut img = Vec::new();
1070        let mut cat = Vec::new();
1071        for _ in 0..40 {
1072            let p = (rng.range(0.0, 500.0), rng.range(0.0, 500.0));
1073            img.push(p);
1074            cat.push(apply(&plate, p));
1075        }
1076        for k in 0..5 {
1077            let p = (rng.range(0.0, 500.0), rng.range(0.0, 500.0));
1078            img.push(p);
1079            cat.push(outlier(k, apply(&plate, p)));
1080        }
1081        (img, cat)
1082    }
1083
1084    #[test]
1085    fn sigma_clip_pairs_rejects_outliers_and_keeps_the_rest() {
1086        // Five wrong pairings, each ~100" (30 px) from where the plate puts them.
1087        let (img, cat) = pairs_with_outliers(|k, (x, y)| {
1088            let a = k as f64 * 1.3;
1089            (x + 100.0 * a.cos(), y + 100.0 * a.sin())
1090        });
1091        let (ci, cc) = sigma_clip_pairs(img, cat, 3.0, 3);
1092        assert_eq!(ci.len(), 40, "all and only the true pairs survive");
1093        let fit = solve_plate_constants(&ci, &cc).unwrap();
1094        assert!(plate_close(&fit, &known_plate(), 1e-6), "{fit:?}");
1095    }
1096
1097    /// `sigma_clip_pairs` gives up as soon as a fit fails, and the first fit is made
1098    /// on the contaminated set. Five gross outliers in 45 pairs are enough to skew
1099    /// that fit past the 10% x/y scale check in `solve_plate_constants`
1100    /// (`BadSolution`, ratio 1.135 here), so nothing is clipped and all 45 come
1101    /// back. In `try_position` the Tetra path then refits the same contaminated set,
1102    /// fails the same check, and abandons a position whose 40 good pairs would
1103    /// have solved it. The clipper does not work in exactly the case it exists for.
1104    #[test]
1105    fn sigma_clip_pairs_rejects_gross_outliers() {
1106        let (img, cat) =
1107            pairs_with_outliers(|k, _| (1000.0 + 150.0 * k as f64, -900.0 + 70.0 * k as f64));
1108        assert!(matches!(
1109            solve_plate_constants(&img, &cat),
1110            Err(ArcsecError::BadSolution { .. })
1111        ));
1112        let (ci, _) = sigma_clip_pairs(img, cat, 3.0, 3);
1113        assert_eq!(ci.len(), 40, "the five gross outliers should be clipped");
1114    }
1115
1116    #[test]
1117    fn sigma_clip_pairs_leaves_too_few_pairs_alone() {
1118        let img = vec![(0.0, 0.0), (1.0, 0.0)];
1119        let cat = vec![(5.0, 5.0), (9.0, 9.0)];
1120        let (ci, cc) = sigma_clip_pairs(img.clone(), cat.clone(), 3.0, 3);
1121        assert_eq!((ci, cc), (img, cat));
1122    }
1123
1124    #[test]
1125    fn verify_and_refit_recovers_the_plate_from_a_rough_guess() {
1126        let truth = known_plate();
1127        let mut rng = Rng::new(11);
1128        let mut img_stars = Vec::new();
1129        let mut cat_stars = Vec::new();
1130        for _ in 0..60 {
1131            let (x, y) = (rng.range(5.0, 395.0), rng.range(5.0, 295.0));
1132            img_stars.push(star_at(x, y));
1133            let (cx, cy) = apply(&truth, (x, y));
1134            cat_stars.push(star_at(cx, cy));
1135        }
1136        // Catalogue stars that fall outside the frame must be ignored, not paired.
1137        for k in 0..20 {
1138            let (cx, cy) = apply(&truth, (-300.0 - 10.0 * k as f64, 900.0));
1139            cat_stars.push(star_at(cx, cy));
1140        }
1141        // Start 2 px and a little rotation away from the truth.
1142        let mut rough = truth.clone();
1143        rough.c += 2.0 * truth.a;
1144        rough.f += 2.0 * truth.e;
1145        rough.b += 0.01;
1146        let (refined, n, rms) =
1147            verify_and_refit(&StarList(img_stars), &StarList(cat_stars), &rough, 400, 300)
1148                .expect("a correct plate must verify");
1149        assert_eq!(n, 60);
1150        assert!(rms < 1e-6, "rms {rms}");
1151        assert!(plate_close(&refined, &truth, 1e-6), "{refined:?}");
1152    }
1153
1154    #[test]
1155    fn verify_and_refit_rejects_too_few_or_clustered_matches() {
1156        let truth = known_plate();
1157        let mut rng = Rng::new(12);
1158        let build = |pts: &[(f64, f64)]| {
1159            let img = StarList(pts.iter().map(|&(x, y)| star_at(x, y)).collect());
1160            let cat = StarList(
1161                pts.iter()
1162                    .map(|&p| apply(&truth, p))
1163                    .map(|(x, y)| star_at(x, y))
1164                    .collect(),
1165            );
1166            (img, cat)
1167        };
1168
1169        // 20 well-spread stars: fewer than MIN_VERIFIED_STARS.
1170        let few: Vec<_> = (0..20)
1171            .map(|_| (rng.range(0.0, 400.0), rng.range(0.0, 300.0)))
1172            .collect();
1173        let (img, cat) = build(&few);
1174        assert!(verify_and_refit(&img, &cat, &truth, 400, 300).is_none());
1175
1176        // 80 stars, all in one 40-pixel corner: rotation is unconstrained.
1177        let clustered: Vec<_> = (0..80)
1178            .map(|_| (rng.range(0.0, 40.0), rng.range(0.0, 40.0)))
1179            .collect();
1180        let (img, cat) = build(&clustered);
1181        assert!(verify_and_refit(&img, &cat, &truth, 400, 300).is_none());
1182
1183        // The same 80 spread over the frame pass.
1184        let spread: Vec<_> = (0..80)
1185            .map(|_| (rng.range(0.0, 400.0), rng.range(0.0, 300.0)))
1186            .collect();
1187        let (img, cat) = build(&spread);
1188        assert!(verify_and_refit(&img, &cat, &truth, 400, 300).is_some());
1189
1190        // Degenerate inputs.
1191        let empty = StarList::default();
1192        assert!(verify_and_refit(&empty, &cat, &truth, 400, 300).is_none());
1193        let mut singular = truth.clone();
1194        singular.a = 0.0;
1195        singular.b = 0.0;
1196        assert!(verify_and_refit(&img, &cat, &singular, 400, 300).is_none());
1197    }
1198
1199    // ── End-to-end solves against synthetic catalogues ────────────────────────
1200
1201    #[derive(Clone, Copy)]
1202    enum Db {
1203        Areas1476,
1204        Areas290,
1205        AllSky001,
1206    }
1207
1208    /// A rendered field and the database it was drawn from.
1209    struct Scene {
1210        dir: TempDir,
1211        img: ImageBuffer,
1212        truth: TruthWcs,
1213    }
1214
1215    /// Render ~`n_in_frame` stars through `truth` and write the surrounding sky
1216    /// (six fields wide, so offset hints still find their stars) as a database.
1217    fn scene(truth: TruthWcs, db: Db, n_in_frame: usize, seed: u64) -> Scene {
1218        let mut rng = Rng::new(seed);
1219        let scale_deg = truth.cd[1].hypot(truth.cd[3]);
1220        let (w_deg, h_deg) = (
1221            truth.width as f64 * scale_deg,
1222            truth.height as f64 * scale_deg,
1223        );
1224        let side = 6.0 * w_deg.max(h_deg);
1225        let sky = random_sky(
1226            &mut rng,
1227            &SkySpec {
1228                ra0: truth.ra0,
1229                dec0: truth.dec0,
1230                side_deg: side,
1231                n: (n_in_frame as f64 * side * side / (w_deg * h_deg)) as usize,
1232                min_sep_deg: 12.0 * scale_deg,
1233                mag_lo: 10.0,
1234                mag_hi: 14.5,
1235            },
1236        );
1237        // A PSF of ~1.3 px on a 5"/px frame, scaled so binned frames stay sampled.
1238        let sigma = 1.3 * 5.0 / (scale_deg * 3600.0);
1239        let img = render(
1240            &truth,
1241            &sky,
1242            sigma.max(1.3),
1243            1000.0,
1244            8.0,
1245            30_000.0,
1246            &mut rng,
1247        );
1248        let dir = TempDir::new("solve");
1249        match db {
1250            Db::Areas1476 => write_1476_db(dir.path(), "t50", &sky),
1251            Db::Areas290 => write_290_db(dir.path(), "t50", &sky),
1252            Db::AllSky001 => write_001_db(dir.path(), "t50", &sky),
1253        }
1254        Scene { dir, img, truth }
1255    }
1256
1257    fn params_for(s: &Scene, ra_hint: f64, dec_hint: f64) -> SolveParams {
1258        SolveParams {
1259            ra_hint,
1260            dec_hint,
1261            fov: (s.truth.height as f64 * s.truth.cd[1].hypot(s.truth.cd[3])).to_radians(),
1262            search_radius: deg(2.0),
1263            quad_tolerance: 0.007,
1264            hfd_min: 1.5,
1265            max_stars: 500,
1266            db_path: s.dir.path().to_path_buf(),
1267            db_name: "t50".into(),
1268            binning: 1,
1269            method: SolveMethod::Quads,
1270            threads: 1,
1271        }
1272    }
1273
1274    fn assert_solved(s: &Scene, wcs: &WcsSolution, tol_arcsec: f64) {
1275        let err = s.truth.max_error_arcsec(wcs);
1276        assert!(
1277            err < tol_arcsec,
1278            "worst centre/corner error {err:.3}\" (matched {}, rms {:.3})",
1279            wcs.stars_matched,
1280            wcs.residual_rms
1281        );
1282        assert!(wcs.stars_matched >= MIN_VERIFIED_STARS);
1283        // Star-level residual under a third of a pixel.
1284        let scale_arcsec = s.truth.cd[1].hypot(s.truth.cd[3]) * 3600.0;
1285        assert!(
1286            wcs.residual_rms < 0.3 * scale_arcsec,
1287            "rms {}",
1288            wcs.residual_rms
1289        );
1290        assert!(wcs.raw_matches > 0);
1291        assert!(wcs.mag_limit > 10.0 && wcs.mag_limit <= 14.5);
1292        assert!(
1293            wcs.cdelt1 < 0.0 && wcs.cdelt2 > 0.0,
1294            "CDELT sign convention"
1295        );
1296    }
1297
1298    #[test]
1299    fn solves_a_1476_database_from_an_offset_hint() {
1300        let truth = TruthWcs::new(deg(84.3), deg(-5.2), 5.0, 23.0, false, 400, 320);
1301        let s = scene(truth, Db::Areas1476, 130, 1);
1302        // Hint roughly one field away in each axis: the spiral has to move.
1303        let mut p = params_for(&s, deg(84.3 + 0.6), deg(-5.2 - 0.45));
1304        p.threads = 4;
1305        let wcs = solve_image(&s.img, &p).expect("solve");
1306        assert_solved(&s, &wcs, 1.0);
1307        assert!(wcs.search_dist_deg > 0.1, "solved at the hint itself?");
1308        assert!(wcs.step_distances.len() > 1);
1309        // The pixel scale and rotation come back too.
1310        assert!((wcs.cdelt2 * 3600.0 - 5.0).abs() < 0.01, "{}", wcs.cdelt2);
1311        assert!((wcs.crota2 - 23.0).abs() < 0.05, "crota2 {}", wcs.crota2);
1312    }
1313
1314    #[test]
1315    fn solves_a_mirrored_image_on_a_290_database() {
1316        let truth = TruthWcs::new(deg(201.0), deg(47.5), 6.0, 160.0, true, 360, 360);
1317        let s = scene(truth, Db::Areas290, 120, 2);
1318        let wcs = solve_image(&s.img, &params_for(&s, truth.ra0, truth.dec0)).expect("solve");
1319        assert_solved(&s, &wcs, 1.0);
1320        assert!(wcs.search_dist_deg < 1e-9, "should solve at the hint");
1321        // A mirrored image has det(CD) > 0.
1322        assert!(wcs.cd1_1 * wcs.cd2_2 - wcs.cd1_2 * wcs.cd2_1 > 0.0);
1323    }
1324
1325    #[test]
1326    fn solves_across_ra_zero_with_an_all_sky_001_database() {
1327        // The field straddles RA 0h, so its catalogue stars sit either side of 2π.
1328        let truth = TruthWcs::new(deg(0.05), deg(21.0), 5.0, -70.0, false, 360, 300);
1329        let s = scene(truth, Db::AllSky001, 120, 3);
1330        let wcs = solve_image(&s.img, &params_for(&s, truth.ra0, truth.dec0)).expect("solve");
1331        assert_solved(&s, &wcs, 1.0);
1332    }
1333
1334    #[test]
1335    fn solves_across_ra_zero_with_a_1476_database() {
1336        let truth = TruthWcs::new(deg(359.97), deg(-33.0), 5.0, 95.0, false, 360, 300);
1337        let s = scene(truth, Db::Areas1476, 120, 4);
1338        let wcs = solve_image(&s.img, &params_for(&s, truth.ra0, truth.dec0)).expect("solve");
1339        assert_solved(&s, &wcs, 1.0);
1340    }
1341
1342    #[test]
1343    fn solves_a_field_near_the_celestial_pole() {
1344        let truth = TruthWcs::new(deg(40.0), deg(88.9), 5.0, 10.0, false, 360, 300);
1345        let s = scene(truth, Db::Areas1476, 120, 5);
1346        let wcs = solve_image(&s.img, &params_for(&s, truth.ra0, truth.dec0)).expect("solve");
1347        assert_solved(&s, &wcs, 1.0);
1348    }
1349
1350    /// The plate constants are fitted in the tangent plane of the spiral position
1351    /// that matched (`ra_db`, `dec_db`), but `derive_wcs` then moves CRVAL to the
1352    /// image centre and keeps the CD matrix unchanged, as though the two tangent
1353    /// planes were the same. They are not, and the error grows linearly with the
1354    /// distance between the matched spiral position and the true field centre.
1355    ///
1356    /// Measured on this 1.5° × 1.25° field at 15"/px: worst-corner error 0.35" with
1357    /// the hint on the centre, 7.5" at 0.2° off, 14.5" at 0.4°, 21" at 0.6° (1.4 px),
1358    /// while the star-level RMS stays at 0.7-0.85" throughout — the verification
1359    /// cannot see it, because it runs in the same (offset) tangent plane. Spiral
1360    /// positions land up to half a step (half a field) from the truth, and a blind
1361    /// estimate can be a whole field off, so this is well inside normal use. 5" is
1362    /// the corner error `scripts/benchmark.py` counts as a false positive.
1363    #[test]
1364    fn accuracy_does_not_depend_on_the_hint_offset() {
1365        let truth = TruthWcs::new(deg(150.0), deg(30.0), 15.0, 20.0, false, 360, 300);
1366        let s = scene(truth, Db::Areas1476, 120, 21);
1367        let off = 0.4;
1368        let p = params_for(&s, deg(150.0 + off / deg(30.0).cos()), deg(30.0 + off));
1369        let wcs = solve_image(&s.img, &p).expect("solve");
1370        assert!(wcs.search_dist_deg < 1e-9, "solved at the hint");
1371        let err = s.truth.max_error_arcsec(&wcs);
1372        assert!(
1373            err < 5.0,
1374            "worst corner error {err:.2}\" with a {off}° hint offset"
1375        );
1376    }
1377
1378    #[test]
1379    fn solves_with_the_tetra_method() {
1380        let truth = TruthWcs::new(deg(150.0), deg(2.0), 5.0, 45.0, false, 360, 300);
1381        let s = scene(truth, Db::Areas1476, 110, 6);
1382        let mut p = params_for(&s, truth.ra0, truth.dec0);
1383        p.method = SolveMethod::Tetra;
1384        let wcs = solve_image(&s.img, &p).expect("solve");
1385        assert_solved(&s, &wcs, 1.0);
1386    }
1387
1388    #[test]
1389    fn binned_solve_is_reported_on_the_unbinned_pixel_grid() {
1390        // Render at full resolution, then solve the 2×2-binned frame.
1391        let truth = TruthWcs::new(deg(10.0), deg(40.0), 2.5, 30.0, false, 720, 600);
1392        let s = scene(truth, Db::Areas1476, 120, 7);
1393        let binned = s.img.bin_image(2);
1394        assert_eq!((binned.width, binned.height), (360, 300));
1395        let mut p = params_for(&s, truth.ra0, truth.dec0);
1396        p.binning = 2;
1397        let wcs = solve_image(&binned, &p).expect("solve");
1398        // crpix is the centre of the unbinned frame, and the scale is unbinned.
1399        assert!((wcs.crpix1 - 360.5).abs() < 1e-9, "crpix1 {}", wcs.crpix1);
1400        assert!((wcs.crpix2 - 300.5).abs() < 1e-9, "crpix2 {}", wcs.crpix2);
1401        assert!((wcs.cdelt2 * 3600.0 - 2.5).abs() < 0.01, "{}", wcs.cdelt2);
1402        let err = s.truth.max_error_arcsec(&wcs);
1403        assert!(err < 2.0, "worst corner error {err:.3}\"");
1404    }
1405
1406    #[test]
1407    fn a_field_absent_from_the_catalogue_does_not_solve() {
1408        // The image shows one random sky, the database holds a different one at the
1409        // same place: nothing may verify, however many quads happen to match.
1410        let truth = TruthWcs::new(deg(120.0), deg(-40.0), 5.0, 0.0, false, 360, 300);
1411        let s = scene(truth, Db::Areas1476, 120, 8);
1412        let decoy = TempDir::new("decoy");
1413        let mut rng = Rng::new(99);
1414        let other = random_sky(
1415            &mut rng,
1416            &SkySpec {
1417                ra0: truth.ra0,
1418                dec0: truth.dec0,
1419                side_deg: 3.0,
1420                n: 4000,
1421                min_sep_deg: 0.015,
1422                mag_lo: 10.0,
1423                mag_hi: 14.5,
1424            },
1425        );
1426        write_1476_db(decoy.path(), "t50", &other);
1427        let mut p = params_for(&s, truth.ra0, truth.dec0);
1428        p.db_path = decoy.path().to_path_buf();
1429        p.search_radius = deg(0.5);
1430        match solve_image(&s.img, &p) {
1431            Err(ArcsecError::InsufficientQuads { found: 0, required }) => {
1432                assert!(required >= 3);
1433            }
1434            other => panic!("expected InsufficientQuads, got {other:?}"),
1435        }
1436    }
1437
1438    #[test]
1439    fn a_corrupt_catalogue_tile_is_skipped_not_fatal() {
1440        let truth = TruthWcs::new(deg(120.0), deg(-40.0), 5.0, 0.0, false, 360, 300);
1441        let s = scene(truth, Db::Areas1476, 120, 9);
1442        // Declare an unsupported record size in every tile.
1443        for entry in std::fs::read_dir(s.dir.path()).unwrap() {
1444            let path = entry.unwrap().path();
1445            let mut bytes = std::fs::read(&path).unwrap();
1446            bytes[109] = 7;
1447            std::fs::write(&path, bytes).unwrap();
1448        }
1449        let mut p = params_for(&s, truth.ra0, truth.dec0);
1450        p.search_radius = 0.0;
1451        assert!(matches!(
1452            solve_image(&s.img, &p),
1453            Err(ArcsecError::InsufficientQuads { .. })
1454        ));
1455    }
1456
1457    #[test]
1458    fn a_blank_frame_reports_insufficient_stars() {
1459        let dir = TempDir::new("blank");
1460        write_1476_db(dir.path(), "t50", &[]);
1461        let mut rng = Rng::new(3);
1462        let img = ImageBuffer {
1463            data: (0..200 * 200)
1464                .map(|_| (1000.0 + 5.0 * rng.gauss()) as f32)
1465                .collect(),
1466            width: 200,
1467            height: 200,
1468        };
1469        let p = SolveParams {
1470            ra_hint: 0.0,
1471            dec_hint: 0.0,
1472            fov: deg(0.3),
1473            search_radius: deg(1.0),
1474            quad_tolerance: 0.007,
1475            hfd_min: 1.5,
1476            max_stars: 500,
1477            db_path: dir.path().to_path_buf(),
1478            db_name: "t50".into(),
1479            binning: 1,
1480            method: SolveMethod::Quads,
1481            threads: 1,
1482        };
1483        match solve_image(&img, &p) {
1484            Err(ArcsecError::InsufficientStars { found, required: 5 }) => assert!(found < 5),
1485            other => panic!("expected InsufficientStars, got {other:?}"),
1486        }
1487    }
1488
1489    #[test]
1490    fn a_missing_database_is_reported_before_any_detection() {
1491        let dir = TempDir::new("nodb");
1492        let p = SolveParams {
1493            ra_hint: 0.0,
1494            dec_hint: 0.0,
1495            fov: deg(1.0),
1496            search_radius: deg(1.0),
1497            quad_tolerance: 0.007,
1498            hfd_min: 1.5,
1499            max_stars: 500,
1500            db_path: dir.path().to_path_buf(),
1501            db_name: "d50".into(),
1502            binning: 1,
1503            method: SolveMethod::Quads,
1504            threads: 1,
1505        };
1506        match solve_image(&ImageBuffer::new(64, 64), &p) {
1507            Err(ArcsecError::CatalogNotFound(path)) => assert_eq!(path, dir.path()),
1508            other => panic!("expected CatalogNotFound, got {other:?}"),
1509        }
1510    }
1511
1512    #[test]
1513    fn solve_image_rejects_a_bad_search_radius_or_fov() {
1514        let base = SolveParams {
1515            ra_hint: 0.0,
1516            dec_hint: 0.0,
1517            fov: deg(1.0),
1518            search_radius: 0.1,
1519            quad_tolerance: 0.007,
1520            hfd_min: 1.5,
1521            max_stars: 500,
1522            db_path: std::path::PathBuf::from("/nonexistent"),
1523            db_name: "d50".into(),
1524            binning: 1,
1525            method: SolveMethod::Quads,
1526            threads: 1,
1527        };
1528        let img = ImageBuffer::new(64, 64);
1529        for (fov, radius) in [
1530            (f64::NAN, 0.1),
1531            (-1.0, 0.1),
1532            (f64::INFINITY, 0.1),
1533            (0.01, -0.1),
1534            (0.01, f64::NAN),
1535            (0.01, f64::INFINITY),
1536        ] {
1537            let p = SolveParams {
1538                fov,
1539                search_radius: radius,
1540                ..base.clone()
1541            };
1542            assert!(
1543                matches!(solve_image(&img, &p), Err(ArcsecError::InvalidParameter(_))),
1544                "fov {fov}, radius {radius}"
1545            );
1546        }
1547    }
1548
1549    #[test]
1550    fn format_radec_roundtrip() {
1551        let s = format_radec(deg(160.875), deg(-59.524));
1552        assert!(s.contains("10:"), "RA hours: {s}");
1553        assert!(s.contains('-'), "dec sign: {s}");
1554    }
1555}