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