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::distortion::{Pair, Refined, StarGrid, best_linear, max_departure_px, refine};
22use super::spiral::SpiralSearch;
23
24/// Which pattern-matching algorithm to use in the catalog spiral loop.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
26pub enum SolveMethod {
27    /// ASTAP-style 5-ratio quad matching with `vote_filter` (default).
28    #[default]
29    Quads,
30    /// TETRA 2-ratio triangle matching with bijective filter.
31    Tetra,
32}
33
34/// How much sky the spiral search reads around each position (ASTAP's `-speed`).
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36pub enum SearchSpeed {
37    /// Size the catalogue window by the image's star count: twice the field for an
38    /// image with fewer than 35 stars, falling to the field itself above 140.
39    #[default]
40    Auto,
41    /// Always read a window twice the field, so neighbouring spiral positions
42    /// overlap and a field that straddles two of them is still seen whole. Four
43    /// times the catalogue stars per position, so slower; it helps images with
44    /// many stars that the auto window only just misses.
45    Slow,
46}
47
48/// Parameters for [`solve_image`].
49#[derive(Debug, Clone)]
50pub struct SolveParams {
51    /// Approximate RA of image centre (radians, hint only).
52    pub ra_hint: f64,
53    /// Approximate DEC of image centre (radians, hint only).
54    pub dec_hint: f64,
55    /// Image field of view along its longer side (radians). Used as the spiral step
56    /// size, and, with the image's size, for the pixel scale a solution is expected
57    /// to have.
58    pub fov: f64,
59    /// Maximum search radius from the hint position (radians).
60    pub search_radius: f64,
61    /// Quad ratio matching tolerance (ASTAP default ≈ 0.007).
62    pub quad_tolerance: f64,
63    /// Minimum HFD for valid stars (pixels).
64    pub hfd_min: f64,
65    /// Maximum number of image stars to detect.
66    pub max_stars: usize,
67    /// Path to the catalog directory.
68    pub db_path: PathBuf,
69    /// Catalog name prefix (e.g. `"d20"`, `"d80"`).
70    pub db_name: String,
71    /// Pixel binning factor applied before solving (1 = none, 2 = 2×2, ...).
72    /// WCS output is scaled back to original image pixel coordinates.
73    pub binning: usize,
74    /// Pattern-matching algorithm for the catalog spiral.
75    pub method: SolveMethod,
76    /// Catalogue window per spiral position.
77    pub speed: SearchSpeed,
78    /// Worker threads for the spiral search. 0 = one per available core.
79    ///
80    /// Spiral positions are independent, so they are evaluated a batch at a time
81    /// across this many threads. Results are identical to the serial search: within
82    /// a batch the lowest spiral index still wins, so the first position that
83    /// verifies is the one returned, exactly as before.
84    pub threads: usize,
85}
86
87/// Iterative sigma-clipping: fit plate constants, reject pairs with large residuals,
88/// re-fit until stable or fewer than `min_count` pairs remain.
89///
90/// First pass uses a 10-pixel absolute threshold (in catalog arcsec) to cut the
91/// large residuals of false pattern matches. Subsequent passes apply
92/// `sigma × rms` clipping until the set is stable.
93fn sigma_clip_pairs(
94    mut img_pos: Vec<(f64, f64)>,
95    mut cat_pos: Vec<(f64, f64)>,
96    sigma: f64,
97    min_count: usize,
98) -> PairedPositions {
99    let mut first_pass = true;
100    for _ in 0..10 {
101        if img_pos.len() < min_count.max(3) {
102            break;
103        }
104        // Unchecked: the first fit is made on the contaminated set, and gross
105        // outliers can skew it past solve_plate_constants' similarity check even though
106        // clipping them is exactly what would fix it.
107        let Ok(plate) = fit_affine(&img_pos, &cat_pos) else {
108            break;
109        };
110        let residuals: Vec<f64> = img_pos
111            .iter()
112            .zip(cat_pos.iter())
113            .map(|(&(xi, yi), &(xc, yc))| {
114                let xp = plate.a * xi + plate.b * yi + plate.c;
115                let yp = plate.d * xi + plate.e * yi + plate.f;
116                ((xp - xc).powi(2) + (yp - yc).powi(2)).sqrt()
117            })
118            .collect();
119        let rms = (residuals.iter().map(|r| r * r).sum::<f64>() / residuals.len() as f64).sqrt();
120        let threshold = if first_pass {
121            first_pass = false;
122            // 10 px in catalog-arcsec: generous cut for large FP residuals on first pass.
123            let cdelt = (plate.a.powi(2) + plate.d.powi(2)).sqrt();
124            // Gross outliers drag a least-squares fit towards themselves and inflate
125            // every residual, so a fixed cut can keep them. The median residual is
126            // not moved by a minority of outliers: allow 3 sigma of it (1.4826 x the
127            // median absolute residual estimates sigma) when that is larger.
128            let mut sorted = residuals.clone();
129            sorted.sort_unstable_by(f64::total_cmp);
130            let median = sorted[sorted.len() / 2];
131            (10.0 * cdelt).max(10.0).max(3.0 * 1.4826 * median)
132        } else {
133            sigma * rms
134        };
135        let before = img_pos.len();
136        let mut new_img = Vec::with_capacity(before);
137        let mut new_cat = Vec::with_capacity(before);
138        for ((&ip, &cp), &r) in img_pos.iter().zip(cat_pos.iter()).zip(residuals.iter()) {
139            if r <= threshold {
140                new_img.push(ip);
141                new_cat.push(cp);
142            }
143        }
144        if new_img.len() == before {
145            break; // stable — no more outliers
146        }
147        img_pos = new_img;
148        cat_pos = new_cat;
149    }
150    (img_pos, cat_pos)
151}
152
153/// Fit the plate to the matched pattern centroids, sigma-clipping them first.
154///
155/// Returns the plate and the number of pairs it was fitted to, or `None` if fewer
156/// than `min_count` survive the clipping or the fit is not a similarity.
157///
158/// The clipping matters for both methods. A few wrong patterns that land in the
159/// winning vote cell drag the unweighted fit far enough that the similarity check
160/// refuses it, and the position is abandoned although most patterns agree. On the
161/// Coalsack (`obj_coalsack`) the first position, the right one, kept 18 quads whose
162/// plain fit was refused; clipped, the fit verified 91 stars. With no wrong pairs
163/// the clipping removes little, and the star-level refit that follows makes the
164/// difference immaterial.
165fn fit_pattern_pairs(
166    img_pos: Vec<(f64, f64)>,
167    cat_pos: Vec<(f64, f64)>,
168    min_count: usize,
169) -> Option<(PlateConstants, usize)> {
170    let (img_pos, cat_pos) = sigma_clip_pairs(img_pos, cat_pos, 3.0, min_count);
171    if img_pos.len() < min_count {
172        return None;
173    }
174    let plate = solve_plate_constants(&img_pos, &cat_pos).ok()?;
175    Some((plate, img_pos.len()))
176}
177
178/// Minimum number of individually matched stars required to believe a solution.
179///
180/// Correct solves typically match 200-375 stars, so this is deliberately loose;
181/// its job is to reject the handful-of-coincidences case. Together with
182/// `MIN_VERIFY_SPREAD` it separates two otherwise identical-looking results: M31 at
183/// 2 degrees (22 stars, spread 0.221, rms 0.65", rotation wrong by 1.56 degrees)
184/// from the Dec -88 field (46 stars, spread 0.207, rms 0.66", correct to 2.3").
185const MIN_VERIFIED_STARS: usize = 30;
186/// Fewest verified stars accepted for an image with `nrstars_image` detections:
187/// [`MIN_VERIFIED_STARS`], relaxed to 15% of the detections for a sparse image, but
188/// never below 10. Unchanged from 200 detections up.
189///
190/// A sparse frame (a short exposure, a narrow band, a small field) cannot match 30
191/// stars when it shows only 40. Below 30 the solution must also pass
192/// [`Acceptance::accepts`]' scale and residual checks.
193fn min_verified_stars(nrstars_image: usize) -> usize {
194    nrstars_image
195        .saturating_mul(15)
196        .div_ceil(100)
197        .clamp(10, MIN_VERIFIED_STARS)
198}
199/// Below [`MIN_VERIFIED_STARS`] matched stars, the fitted pixel scale must be
200/// within this fraction of the one the hint implies.
201const RELAXED_SCALE_TOL: f64 = 0.10;
202/// Below [`MIN_VERIFIED_STARS`] matched stars, the largest star-level rms, in
203/// pixels. The genuine relaxed solves on the corpus have 0.16-0.46 px; the false
204/// positive the relaxed count alone lets through (`ls2_25`, 12 stars) has 2.9 px,
205/// and a scale 1.36 times the hint's.
206const RELAXED_MAX_RMS_PX: f64 = 0.5;
207
208/// When a verified plate is believed.
209struct Acceptance {
210    /// Fewest matched stars ([`min_verified_stars`]).
211    min_stars: usize,
212    /// The pixel scale the hint implies, arcsec per (binned) pixel.
213    expected_scale: f64,
214}
215
216impl Acceptance {
217    fn new(nrstars_image: usize, params: &SolveParams, img: &crate::types::ImageBuffer) -> Self {
218        Self {
219            min_stars: min_verified_stars(nrstars_image),
220            expected_scale: params.fov.to_degrees() * 3600.0
221                / img.width.max(img.height).max(1) as f64,
222        }
223    }
224
225    /// Enough stars, spread over the frame; and if fewer than
226    /// [`MIN_VERIFIED_STARS`], the hint's pixel scale and a sub-half-pixel fit.
227    ///
228    /// A handful of chance coincidences can be fitted by some plate at some scale;
229    /// they are not fitted at the scale the optics give, to a fraction of a pixel.
230    fn accepts(&self, v: &Verified, spread: f64) -> bool {
231        if v.n() < self.min_stars || spread < MIN_VERIFY_SPREAD {
232            return false;
233        }
234        if v.n() >= MIN_VERIFIED_STARS {
235            return true;
236        }
237        let p = &v.plate;
238        let scale = (p.a * p.e - p.b * p.d).abs().sqrt();
239        let ok = (scale / self.expected_scale - 1.0).abs() <= RELAXED_SCALE_TOL
240            && v.rms <= RELAXED_MAX_RMS_PX * scale;
241        log::info!(
242            "{} stars verified, scale {:.4}\"/px against {:.4} expected, residual {:.2} px: {}",
243            v.n(),
244            scale,
245            self.expected_scale,
246            v.rms / scale,
247            if ok { "accepted" } else { "refused" }
248        );
249        ok
250    }
251}
252
253/// Match radii (pixels) used by successive verification passes, coarse to fine.
254const VERIFY_RADII: [f64; 3] = [6.0, 3.0, 2.0];
255/// Minimum spread of the matched stars, as a fraction of the image half-diagonal.
256///
257/// A count threshold alone is not enough: matches clustered in one part of the
258/// frame (the core of a bright galaxy, say) pin the position but leave rotation
259/// and scale essentially free. M31 at 2 degrees passed with 22 matched stars and
260/// a 1.56-degree rotation error, which is 154" at the field corners.
261const MIN_VERIFY_SPREAD: f64 = 0.20;
262
263/// A verified plate: the re-fitted plate constants, the per-star RMS in arcsec,
264/// and the star pairs the fit was made from.
265struct Verified {
266    plate: PlateConstants,
267    rms: f64,
268    /// Detected star positions, pixels of the solved (binned) image, 0-based.
269    img_pos: Vec<(f64, f64)>,
270    /// The catalogue star each was paired with, in standard coordinates (arcsec)
271    /// about the plane the plate maps into.
272    cat_pos: Vec<(f64, f64)>,
273}
274
275impl Verified {
276    /// Number of individually matched stars.
277    fn n(&self) -> usize {
278        self.img_pos.len()
279    }
280}
281
282/// Project the catalogue onto the image with a candidate plate solution, match
283/// individual stars, and re-fit on those matches.
284///
285/// The quad matcher only ever produces quad *centroids*, so the plate fit is built
286/// from a handful of averaged positions and nothing ever checks that the individual
287/// stars agree. This does that check: invert the plate to map every catalogue star
288/// into pixel space, pair each with the nearest detected star, re-fit on the pairs,
289/// and repeat with a shrinking radius.
290///
291/// Returns the refined plate with its matched pairs, or `None` if the plate is
292/// degenerate or `accept` refuses the result (too few stars agree, the matches
293/// are too clustered, or a sparse match is at the wrong scale or fits loosely).
294fn verify_and_refit(
295    img_stars: &StarList,
296    cat_stars: &StarList,
297    plate: &PlateConstants,
298    img_w: usize,
299    img_h: usize,
300    accept: &Acceptance,
301) -> Option<Verified> {
302    if img_stars.is_empty() || cat_stars.is_empty() {
303        return None;
304    }
305
306    // Uniform grid over the detected stars for nearest-neighbour lookup.
307    let grid = StarGrid::new(img_stars, VERIFY_RADII[0])?;
308
309    let mut current = plate.clone();
310    // The last pass that fitted, with the spread of its matches.
311    let mut best: Option<(Verified, f64)> = None;
312
313    for &radius in &VERIFY_RADII {
314        let det = current.a * current.e - current.b * current.d;
315        if det.abs() < 1e-12 {
316            return None;
317        }
318        let r2 = radius * radius;
319
320        let mut img_pos: Vec<(f64, f64)> = Vec::new();
321        let mut cat_pos: Vec<(f64, f64)> = Vec::new();
322        let mut used = vec![false; img_stars.len()];
323
324        for cs in &cat_stars.0 {
325            // Invert  xi = a*x + b*y + c ;  eta = d*x + e*y + f
326            let dx = cs.x - current.c;
327            let dy = cs.y - current.f;
328            let px = (current.e * dx - current.b * dy) / det;
329            let py = (-current.d * dx + current.a * dy) / det;
330            if !grid.near(px, py, radius) {
331                continue;
332            }
333            if let Some(i) = grid.nearest(px, py, r2, &used) {
334                used[i] = true; // one-to-one: a detected star backs at most one catalogue star
335                img_pos.push(grid.pos(i));
336                cat_pos.push((cs.x, cs.y));
337            }
338        }
339
340        if img_pos.len() < 4 {
341            break;
342        }
343        let Ok(refined) = solve_plate_constants(&img_pos, &cat_pos) else {
344            break;
345        };
346        let mut sq = 0.0;
347        for (&(xi, yi), &(xc, yc)) in img_pos.iter().zip(cat_pos.iter()) {
348            let xp = refined.a * xi + refined.b * yi + refined.c;
349            let yp = refined.d * xi + refined.e * yi + refined.f;
350            sq += (xp - xc).powi(2) + (yp - yc).powi(2);
351        }
352        let rms = (sq / img_pos.len() as f64).sqrt();
353        // Matches clustered in one corner leave rotation free.
354        let spread = spread_of(&img_pos, img_w, img_h);
355        log::debug!(
356            "verify: {} stars, spread {:.3}, rms {:.2}\"",
357            img_pos.len(),
358            spread,
359            rms
360        );
361
362        current = refined.clone();
363        best = Some((
364            Verified {
365                plate: refined,
366                rms,
367                img_pos,
368                cat_pos,
369            },
370            spread,
371        ));
372    }
373
374    best.filter(|(v, spread)| accept.accepts(v, *spread))
375        .map(|(v, _)| v)
376}
377
378/// The most image stars the database can match in this field: its density
379/// ([`crate::catalog::database_density`]) times the image's area, or `-s` if that
380/// is smaller or the density is unknown. ASTAP's "database limit".
381///
382/// The area is `fov²` scaled by the aspect ratio, `fov` being the long side.
383fn density_star_limit(params: &SolveParams, img: &crate::types::ImageBuffer) -> usize {
384    let Some(density) = crate::catalog::database_density(&params.db_name) else {
385        return params.max_stars;
386    };
387    let fov_deg = params.fov.to_degrees();
388    let (w, h) = (img.width as f64, img.height as f64);
389    let area = fov_deg * fov_deg * w.min(h) / w.max(h).max(1.0);
390    let cap = (density * area).round();
391    if cap < params.max_stars as f64 {
392        cap as usize
393    } else {
394        params.max_stars
395    }
396}
397
398/// Everything a spiral position needs that does not change between positions.
399struct SpiralCtx<'a> {
400    params: &'a SolveParams,
401    img: &'a crate::types::ImageBuffer,
402    stars: &'a StarList,
403    img_quads: &'a crate::types::QuadList,
404    img_tris: &'a crate::quads::TriangleList,
405    nrstars_image: usize,
406    /// The most image stars worth using: `-s`, or fewer if the database cannot hold
407    /// that many in the field ([`density_star_limit`]).
408    star_limit: usize,
409    nrstars_required: usize,
410    oversize: f64,
411    min_quads: usize,
412    step_size: f64,
413    accept: Acceptance,
414    /// Long side over short side of the image.
415    aspect: f64,
416}
417
418/// A spiral position that produced a verified solution.
419struct PositionOutcome {
420    idx: usize,
421    ra_db: f64,
422    dec_db: f64,
423    sep_deg: f64,
424    verified: Verified,
425    n_matched: usize,
426    n_raw: usize,
427    mag_limit: f64,
428    /// The field is distorted beyond what the model can follow: the search
429    /// stops here, without a solution.
430    refused: bool,
431}
432
433/// Result of trying one spiral position: the angular distance if the catalogue was
434/// actually read there (for the ASTAP-style progress line), and the solution if one
435/// verified.
436struct PositionTry {
437    sep_deg: Option<f64>,
438    outcome: Option<PositionOutcome>,
439}
440
441impl PositionTry {
442    const NONE: Self = Self {
443        sep_deg: None,
444        outcome: None,
445    };
446}
447
448/// Evaluate a single spiral position. Pure with respect to `ctx`, so positions can
449/// be run concurrently.
450fn try_position(ctx: &SpiralCtx<'_>, idx: usize, sx: i32, sy: i32) -> PositionTry {
451    let params = ctx.params;
452    let step_size = ctx.step_size;
453
454    let dec_db_raw = params.dec_hint + step_size * sy as f64;
455    let (dec_db, flip) = if dec_db_raw > PI / 2.0 {
456        (PI - dec_db_raw, PI)
457    } else if dec_db_raw < -PI / 2.0 {
458        (-PI - dec_db_raw, PI)
459    } else {
460        (dec_db_raw, 0.0)
461    };
462
463    let extra = if dec_db > 0.0 {
464        step_size * 0.5
465    } else {
466        -step_size * 0.5
467    };
468    let ra_offset = step_size * sx as f64 / (dec_db - extra).cos();
469    if ra_offset > PI / 2.0 + step_size * 0.5 || ra_offset < -PI / 2.0 {
470        return PositionTry::NONE;
471    }
472
473    let ra_db = (flip + params.ra_hint + ra_offset).rem_euclid(2.0 * PI);
474    let sep = ang_sep(ra_db, dec_db, params.ra_hint, params.dec_hint);
475    if sep > params.search_radius + step_size / 2.0 {
476        return PositionTry::NONE;
477    }
478
479    // Any read failure (a missing tile included) counts as "nothing catalogued
480    // here"; `solve_image` has already checked that the database exists at all.
481    let cat_raw = match read_catalog_stars(
482        &params.db_path,
483        &params.db_name,
484        ra_db,
485        dec_db,
486        params.fov * ctx.oversize,
487        ctx.nrstars_required,
488    ) {
489        Ok(v) if !v.is_empty() => v,
490        Ok(_) | Err(_) => return PositionTry::NONE,
491    };
492
493    let sep_deg = sep.to_degrees();
494    let mag_limit = cat_raw
495        .iter()
496        .map(|s| s.mag)
497        .fold(f64::NEG_INFINITY, f64::max);
498    log::info!(
499        "Search {}, [{},{}], position: {}  Down to magn {:.1}  {} database stars  {} database quads to compare.",
500        idx,
501        sx,
502        sy,
503        format_radec(ra_db, dec_db),
504        mag_limit,
505        cat_raw.len(),
506        cat_raw.len(),
507    );
508
509    let mut cat_stars: Vec<Star> = cat_raw
510        .iter()
511        .map(|s| {
512            let (x, y) = equatorial_standard(ra_db, dec_db, s.ra, s.dec, 1.0);
513            Star {
514                x,
515                y,
516                snr: 1.0,
517                hfd: 2.0,
518            }
519        })
520        .collect();
521    cat_stars.sort_unstable_by(|a, b| a.x.total_cmp(&b.x));
522    let cat_star_list = StarList(cat_stars);
523
524    let failed = PositionTry {
525        sep_deg: Some(sep_deg),
526        outcome: None,
527    };
528
529    let (img_pos, cat_pos, n_raw) = match params.method {
530        SolveMethod::Quads => {
531            let mut cat_quads = build_quads_presorted(&cat_star_list, ctx.nrstars_image);
532            if ctx.nrstars_image < ctx.star_limit {
533                add_density_matched_quads(ctx, &cat_raw, ra_db, dec_db, &mut cat_quads);
534            }
535            if cat_quads.is_empty() {
536                return failed;
537            }
538            crate::quads::r#match::sort_catalog_quads(&mut cat_quads);
539            let raw = find_matches_sorted(ctx.img_quads, &cat_quads, params.quad_tolerance);
540            let n_raw = raw.len();
541            log::info!("Found {n_raw} references");
542            let mut filtered = vote_filter(ctx.img_quads, &cat_quads, &raw, params.quad_tolerance);
543            if filtered.len() < ctx.min_quads {
544                let (by_scale, _) = filter_by_scale(&raw, params.quad_tolerance);
545                if by_scale.len() > filtered.len() {
546                    filtered = by_scale;
547                }
548            }
549            if filtered.len() < ctx.min_quads {
550                return failed;
551            }
552            let (ip, cp) = extract_star_pairs(ctx.img_quads, &cat_quads, &filtered);
553            (ip, cp, n_raw)
554        }
555        SolveMethod::Tetra => {
556            let cat_tris = build_triangles(&cat_star_list);
557            if cat_tris.is_empty() {
558                return failed;
559            }
560            let tol = params.quad_tolerance * TETRA_TOL_FACTOR;
561            let raw = find_triangle_matches(ctx.img_tris, &cat_tris, tol);
562            let n_raw = raw.len();
563            log::info!("Found {n_raw} triangle references");
564            let biject = bijective_filter(&raw, ctx.img_tris, &cat_tris);
565            let (filtered, _) = filter_triangles_by_scale(&biject, params.quad_tolerance);
566            if filtered.len() < ctx.min_quads {
567                return failed;
568            }
569            let (ip, cp) = extract_triangle_pairs(ctx.img_tris, &cat_tris, &filtered);
570            (ip, cp, n_raw)
571        }
572    };
573
574    // The matched patterns, kept as seeds for the distortion model: wherever in
575    // the frame they fall, they are correspondences whatever the plate does there.
576    let seeds = Seeds {
577        img: img_pos.clone(),
578        cat: cat_pos.clone(),
579        ra: ra_db,
580        dec: dec_db,
581    };
582    let Some((plate, n_matched)) = fit_pattern_pairs(img_pos, cat_pos, ctx.min_quads) else {
583        return failed;
584    };
585
586    let found = |verified, ra_db, dec_db, refused| PositionTry {
587        sep_deg: Some(sep_deg),
588        outcome: Some(PositionOutcome {
589            idx,
590            ra_db,
591            dec_db,
592            sep_deg,
593            verified,
594            n_matched,
595            n_raw,
596            mag_limit,
597            refused,
598        }),
599    };
600
601    let Some(verified) = verify_and_refit(
602        ctx.stars,
603        &cat_star_list,
604        &plate,
605        ctx.img.width,
606        ctx.img.height,
607        &ctx.accept,
608    ) else {
609        log::info!("Verification failed at this position; continuing search.");
610        if n_matched >= STRONG_VOTE
611            && let Some((verified, ra_c, dec_c)) =
612                second_chance(ctx, &cat_raw, &seeds, &plate, ra_db, dec_db)
613        {
614            return found(verified, ra_c, dec_c, false);
615        }
616        return failed;
617    };
618    log::info!(
619        "Verified {} stars against the catalogue, residual {:.2}\"",
620        verified.n(),
621        verified.rms
622    );
623
624    let (verified, ra_db, dec_db) = recentre(ctx, &cat_raw, verified, ra_db, dec_db);
625    match model_distortion(ctx, &cat_raw, &seeds, verified, ra_db, dec_db) {
626        Modelled::Linear(v) => found(v, ra_db, dec_db, false),
627        Modelled::Distorted(v, ra_c, dec_c) => found(v, ra_c, dec_c, false),
628        Modelled::Refused(v) => found(v, ra_db, dec_db, true),
629    }
630}
631
632/// Pattern pairs a position must keep, after clipping, for [`second_chance`].
633///
634/// A wrong position rarely keeps more than a handful; the two corpus images the
635/// second chance solves kept 60 and more.
636const STRONG_VOTE: usize = 50;
637
638/// Catalogue stars in standard coordinates about `(ra, dec)`, brightest first.
639fn project(cat_raw: &[CatalogStar], ra: f64, dec: f64) -> StarList {
640    StarList(
641        cat_raw
642            .iter()
643            .map(|s| {
644                let (x, y) = equatorial_standard(ra, dec, s.ra, s.dec, 1.0);
645                Star {
646                    x,
647                    y,
648                    snr: 1.0,
649                    hfd: 2.0,
650                }
651            })
652            .collect(),
653    )
654}
655
656/// The matched pattern centroids of a position, in the plane about `(ra, dec)`.
657struct Seeds {
658    img: Vec<(f64, f64)>,
659    cat: Vec<(f64, f64)>,
660    ra: f64,
661    dec: f64,
662}
663
664impl Seeds {
665    /// The pairs with their catalogue side moved to the plane about `(ra, dec)`.
666    fn in_plane(&self, ra: f64, dec: f64) -> Vec<Pair> {
667        self.img
668            .iter()
669            .zip(&self.cat)
670            .map(|(&i, &(x, y))| {
671                if ra == self.ra && dec == self.dec {
672                    return (i, (x, y));
673                }
674                let (sra, sdec) = standard_equatorial(self.ra, self.dec, x, y, 1.0);
675                (i, equatorial_standard(ra, dec, sra, sdec, 1.0))
676            })
677            .collect()
678    }
679}
680
681/// Fit the distortion model ([`refine`]) from a linear plate in the plane about
682/// `(ra, dec)`.
683fn fit_distortion(
684    ctx: &SpiralCtx<'_>,
685    cat_raw: &[CatalogStar],
686    seeds: &Seeds,
687    plate: &PlateConstants,
688    ra: f64,
689    dec: f64,
690) -> Option<Refined> {
691    // The spiral position's catalogue window need not cover the image: with the
692    // hint a third of a field off, a third of the frame has no catalogue stars,
693    // and the model cannot reach it. So read the catalogue again about the image
694    // centre, wide enough for its corners at any rotation, at the same density.
695    // One read per solve (or per strong position that failed to verify).
696    let (w, h) = (ctx.img.width as f64, ctx.img.height as f64);
697    let (xs, ys) = (
698        plate.a * (w - 1.0) * 0.5 + plate.b * (h - 1.0) * 0.5 + plate.c,
699        plate.d * (w - 1.0) * 0.5 + plate.e * (h - 1.0) * 0.5 + plate.f,
700    );
701    let (ra_c, dec_c) = standard_equatorial(ra, dec, xs, ys, 1.0);
702    let window = w.hypot(h) / w.max(h);
703    let cat = match read_catalog_stars(
704        &ctx.params.db_path,
705        &ctx.params.db_name,
706        ra_c,
707        dec_c,
708        ctx.params.fov * window,
709        (ctx.params.max_stars as f64 * window * window).round() as usize,
710    ) {
711        Ok(v) if !v.is_empty() => project(&v, ra, dec),
712        _ => project(cat_raw, ra, dec),
713    };
714    let grid = StarGrid::new(ctx.stars, VERIFY_RADII[0])?;
715    let r = refine(
716        &grid,
717        &cat,
718        &seeds.in_plane(ra, dec),
719        plate,
720        ctx.img.width,
721        ctx.img.height,
722        VERIFY_RADII[VERIFY_RADII.len() - 1],
723    )?;
724    log::info!(
725        "Distortion model: {} terms, {} stars within {} px, rms {:.2} px, F {:.1} over linear, {} of 9 cells",
726        r.model.n_terms,
727        r.img_pos.len(),
728        VERIFY_RADII[VERIFY_RADII.len() - 1],
729        r.rms / r.model.scale(),
730        r.f_linear,
731        r.cells
732    );
733    Some(r)
734}
735
736/// The linear plate closest to `r`'s model over the frame ([`best_linear`]), in
737/// the tangent plane at the image centre, with the model's star pairs carried into
738/// that plane. Returns it as a verification record, with the new tangent point.
739fn linear_from_model(
740    ctx: &SpiralCtx<'_>,
741    r: &Refined,
742    ra: f64,
743    dec: f64,
744) -> Option<(Verified, f64, f64)> {
745    let (w, h) = (ctx.img.width, ctx.img.height);
746    let (xs, ys) = r
747        .model
748        .apply((w as f64 - 1.0) * 0.5, (h as f64 - 1.0) * 0.5);
749    let (ra_c, dec_c) = standard_equatorial(ra, dec, xs, ys, 1.0);
750    // The plate of the model's plane is not linear in the centre's; carry the
751    // model across point by point.
752    let moved = |(x, y): (f64, f64)| {
753        let (sra, sdec) = standard_equatorial(ra, dec, x, y, 1.0);
754        equatorial_standard(ra_c, dec_c, sra, sdec, 1.0)
755    };
756    let plate = best_linear(|x, y| moved(r.model.apply(x, y)), w, h)?;
757    let cat_pos = r.cat_pos.iter().map(|&p| moved(p)).collect();
758    Some((
759        Verified {
760            plate,
761            rms: r.rms,
762            img_pos: r.img_pos.clone(),
763            cat_pos,
764        },
765        ra_c,
766        dec_c,
767    ))
768}
769
770/// What [`model_distortion`] made of a verified position.
771enum Modelled {
772    /// No distortion worth reporting: the verified plate, unchanged.
773    Linear(Verified),
774    /// The linear plate closest to the distortion model, with its tangent point.
775    Distorted(Verified, f64, f64),
776    /// Strong distortion the model cannot follow over the frame: the linear plate
777    /// would be wrong at the edges, so the solve is refused.
778    Refused(Verified),
779}
780
781/// F statistic of the distortion model over a linear plate, on the final pairs,
782/// needed for it to change the reported plate.
783///
784/// On the corpus, survey images with no distortion reach F = 4–24 (catalogue and
785/// centroid systematics a cubic can fit); every real distortion that changes a
786/// result is above 30 except one 2.2° TESS crop (F = 7). See test-images.md §7.9.
787const MIN_REPORT_F: f64 = 30.0;
788
789/// A distortion model changes the reported plate only if the verified plate is
790/// this far (pixels) from it somewhere in the frame. ZTF's real distortion of under
791/// a pixel (F up to 47) stays below it, and those solves are unchanged.
792const MIN_DEPARTURE_PX: f64 = 1.0;
793
794/// When the model cannot be used (its pairs leave part of the frame empty), a
795/// cubic fitted to the wide-radius pairs at least this significant ...
796const REFUSE_F: f64 = 100.0;
797/// ... and this far (pixels) from the verified plate where it has stars means the
798/// linear plate is a fit to part of a strongly distorted field: refuse it. No solve
799/// on the corpus comes near (largest F 21 at ≥ 2 px); fields with 4 px or more of
800/// distortion and a third of the frame empty all reach both.
801const REFUSE_DEPARTURE_PX: f64 = 3.0;
802
803/// After a linear plate verified, fit the distortion model and decide what to
804/// report.
805///
806/// The model replaces the verified plate when it is significant
807/// ([`MIN_REPORT_F`]), its pairs cover the frame (all nine cells of a 3×3 grid for
808/// a cubic, seven for a quadratic), it moves some part of the frame by
809/// [`MIN_DEPARTURE_PX`] or more, and it pairs at least 90% as many stars as the
810/// plate. Otherwise the verified plate stands, unless the field is visibly and
811/// strongly distorted where it has stars ([`REFUSE_F`], [`REFUSE_DEPARTURE_PX`]).
812fn model_distortion(
813    ctx: &SpiralCtx<'_>,
814    cat_raw: &[CatalogStar],
815    seeds: &Seeds,
816    verified: Verified,
817    ra: f64,
818    dec: f64,
819) -> Modelled {
820    let Some(r) = fit_distortion(ctx, cat_raw, seeds, &verified.plate, ra, dec) else {
821        return Modelled::Linear(verified);
822    };
823    let (w, h) = (ctx.img.width, ctx.img.height);
824    let departure = max_departure_px(&r.model, &verified.plate, w, h);
825    log::info!(
826        "Distortion: verified plate departs {departure:.2} px from the model; {} stars against {} verified",
827        r.img_pos.len(),
828        verified.n(),
829    );
830    let min_cells = if r.model.n_terms == 10 { 9 } else { 7 };
831    let usable = r.model.n_terms > 3
832        && r.cells >= min_cells
833        && r.f_linear >= MIN_REPORT_F
834        && r.img_pos.len() * 10 >= verified.n() * 9;
835    if usable {
836        if departure >= MIN_DEPARTURE_PX
837            && let Some((v, ra_c, dec_c)) = linear_from_model(ctx, &r, ra, dec)
838        {
839            log::info!("Reporting the linear plate closest to the distortion model.");
840            return Modelled::Distorted(v, ra_c, dec_c);
841        }
842        return Modelled::Linear(verified);
843    }
844    let (wide_f, wide_dep) = r.unmodelled();
845    log::info!("Where the stars are: a cubic with F {wide_f:.1}, {wide_dep:.2} px from the plate.");
846    if wide_f >= REFUSE_F && wide_dep >= REFUSE_DEPARTURE_PX {
847        log::info!(
848            "The field is distorted by {wide_dep:.1} px where it has stars, and the distortion \
849             cannot be modelled over the whole frame: refusing a linear solution."
850        );
851        return Modelled::Refused(verified);
852    }
853    Modelled::Linear(verified)
854}
855
856/// Spread of matched stars about their centroid, as a fraction of the image
857/// half-diagonal.
858fn spread_of(img_pos: &[(f64, f64)], img_w: usize, img_h: usize) -> f64 {
859    let n = img_pos.len() as f64;
860    let mx = img_pos.iter().map(|p| p.0).sum::<f64>() / n;
861    let my = img_pos.iter().map(|p| p.1).sum::<f64>() / n;
862    let var = img_pos
863        .iter()
864        .map(|&(x, y)| (x - mx) * (x - mx) + (y - my) * (y - my))
865        .sum::<f64>()
866        / n;
867    let half_diag = 0.5 * ((img_w * img_w + img_h * img_h) as f64).sqrt();
868    var.sqrt() / half_diag
869}
870
871/// A position whose patterns agree strongly but whose linear plate did not verify:
872/// fit the distortion model from the pattern plate and verify that instead, by
873/// the same acceptance rules. A strongly distorted field can leave too few stars
874/// within 2 pixels of any linear plate (`s_tess_b_pincush`, before the model,
875/// verified 11 at the right position and then accepted a neighbour's wrong plate).
876fn second_chance(
877    ctx: &SpiralCtx<'_>,
878    cat_raw: &[CatalogStar],
879    seeds: &Seeds,
880    plate: &PlateConstants,
881    ra: f64,
882    dec: f64,
883) -> Option<(Verified, f64, f64)> {
884    log::info!("Strong pattern match: retrying verification with a distortion model.");
885    let r = fit_distortion(ctx, cat_raw, seeds, plate, ra, dec)?;
886    // As for a reported model: it must reach the frame it will be extrapolated to.
887    if r.cells < if r.model.n_terms == 10 { 9 } else { 7 } {
888        log::info!("The distortion model's stars do not cover the frame.");
889        return None;
890    }
891    let probe = Verified {
892        plate: r.model.linear_part(),
893        rms: r.rms,
894        img_pos: r.img_pos.clone(),
895        cat_pos: r.cat_pos.clone(),
896    };
897    let spread = spread_of(&r.img_pos, ctx.img.width, ctx.img.height);
898    if !ctx.accept.accepts(&probe, spread) {
899        log::info!("The distortion model did not verify either.");
900        return None;
901    }
902    log::info!(
903        "Verified {} stars with the distortion model.",
904        r.img_pos.len()
905    );
906    linear_from_model(ctx, &r, ra, dec)
907}
908
909/// How many times denser than the image the catalogue read must be before
910/// [`add_density_matched_quads`] adds anything.
911///
912/// Every image the added quads solve on the corpus had a catalogue at least 3.7
913/// times denser than itself (the density-matched count at most 0.27 of the read);
914/// below 2.5 the full-depth quads already share the image's neighbourhoods closely
915/// enough, and the extra quads made a failing search on a 159-star image 50% slower.
916const DENSITY_MATCH_MIN_RATIO: f64 = 2.5;
917
918/// Add the quads of a catalogue star list as dense as the image's.
919///
920/// The catalogue is read to the depth `-s` asks for, so when the image yields fewer
921/// stars than that the catalogue is denser than the image, and its quads join
922/// neighbours the image never detected. The window's brightest
923/// `n · oversize² · long/short` catalogue stars have the image's density (the
924/// window is square, `oversize` fields of the long side across), so their quads are
925/// built over the same neighbourhoods as the image's. They are added to the
926/// full-depth quads, not substituted: reading only that depth loses more images
927/// than it gains, those whose faint detections are real (test-images.md §7.8).
928/// Verification still runs against the full-depth list.
929///
930/// Only when the catalogue read is at least [`DENSITY_MATCH_MIN_RATIO`] times
931/// denser than the image: the extra quads are matched at every spiral position, so
932/// on a search that fails everywhere they cost what they add to the quad count.
933fn add_density_matched_quads(
934    ctx: &SpiralCtx<'_>,
935    cat_raw: &[CatalogStar],
936    ra_db: f64,
937    dec_db: f64,
938    cat_quads: &mut crate::types::QuadList,
939) {
940    let k = (ctx.nrstars_image as f64 * ctx.oversize * ctx.oversize * ctx.aspect).round() as usize;
941    if k < 5 || (k as f64) * DENSITY_MATCH_MIN_RATIO > cat_raw.len() as f64 {
942        return;
943    }
944    // `cat_raw` is brightest first.
945    let mut sub: Vec<Star> = cat_raw[..k]
946        .iter()
947        .map(|s| {
948            let (x, y) = equatorial_standard(ra_db, dec_db, s.ra, s.dec, 1.0);
949            Star {
950                x,
951                y,
952                snr: 1.0,
953                hfd: 2.0,
954            }
955        })
956        .collect();
957    sub.sort_unstable_by(|a, b| a.x.total_cmp(&b.x));
958    let extra = build_quads_presorted(&StarList(sub), ctx.nrstars_image);
959    // A quad of the same four stars can come out of both lists: count it once.
960    // Centroid and size to a milliarcsecond identify it.
961    let key = |q: &crate::types::Quad| {
962        (
963            (q.center_x * 1000.0).round() as i64,
964            (q.center_y * 1000.0).round() as i64,
965            (q.d1 * 1000.0).round() as i64,
966        )
967    };
968    let seen: std::collections::HashSet<_> = cat_quads.0.iter().map(key).collect();
969    let before = cat_quads.len();
970    cat_quads
971        .0
972        .extend(extra.0.into_iter().filter(|q| !seen.contains(&key(q))));
973    log::info!(
974        "{} more database quads from its {k} brightest stars, the image's density.",
975        cat_quads.len() - before
976    );
977}
978
979/// Refit a verified plate in the tangent plane at the image centre.
980///
981/// The plate constants are a linear map from pixels to the tangent plane at the
982/// spiral position the catalogue was projected about. Pixels map linearly onto a
983/// tangent plane only at the optical axis, so away from it the fit absorbs the
984/// projection's curvature as a rotation and shear, which grow with the distance
985/// from the field and with declination. Star-level RMS stays small, because the fit
986/// is good *in that plane*, but the CD matrix derived from it is wrong at the image
987/// centre: with the hint 0.3 fields off, most corpus solves were out by 5-1600" at
988/// the corners.
989///
990/// So once a position verifies, move the tangent point to the image centre, pair
991/// stars as the verified plate predicts them, fit those pairs in the new plane,
992/// and verify again. Twice, since the centre moves slightly with
993/// the new fit. If a pass fails to verify, the previous solution is kept: this can
994/// only improve a solve, never lose one.
995fn recentre(
996    ctx: &SpiralCtx<'_>,
997    cat_raw: &[CatalogStar],
998    mut verified: Verified,
999    mut ra_db: f64,
1000    mut dec_db: f64,
1001) -> (Verified, f64, f64) {
1002    let (w, h) = (ctx.img.width as f64, ctx.img.height as f64);
1003    let (cx, cy) = ((w - 1.0) * 0.5, (h - 1.0) * 0.5);
1004    let apply =
1005        |p: &PlateConstants, x: f64, y: f64| (p.a * x + p.b * y + p.c, p.d * x + p.e * y + p.f);
1006
1007    for _ in 0..2 {
1008        let plate = &verified.plate;
1009        let (xs, ys) = apply(plate, cx, cy);
1010        // Already centred to well under a milliarcsecond: nothing to gain.
1011        if xs.hypot(ys) < 1e-3 {
1012            break;
1013        }
1014        let (ra0, dec0) = standard_equatorial(ra_db, dec_db, xs, ys, 1.0);
1015
1016        // Starting plate for the new tangent plane. Mapping one tangent plane onto
1017        // another is far from linear over a wide field (a 10-degree field 3 degrees
1018        // off moves by ~65" under a straight-line fit), so rather than carry the
1019        // plate across, pair stars exactly as the verified plate predicts them and
1020        // fit those pairs against their positions in the new plane.
1021        let det = plate.a * plate.e - plate.b * plate.d;
1022        if det.abs() < 1e-12 {
1023            break;
1024        }
1025        let r2 = VERIFY_RADII[0] * VERIFY_RADII[0];
1026        let mut used = vec![false; ctx.stars.len()];
1027        let mut img_pos = Vec::new();
1028        let mut new_pos = Vec::new();
1029        let mut cat = Vec::with_capacity(cat_raw.len());
1030        for s in cat_raw {
1031            let (nx, ny) = equatorial_standard(ra0, dec0, s.ra, s.dec, 1.0);
1032            cat.push(Star {
1033                x: nx,
1034                y: ny,
1035                snr: 1.0,
1036                hfd: 2.0,
1037            });
1038            let (ox, oy) = equatorial_standard(ra_db, dec_db, s.ra, s.dec, 1.0);
1039            let (dx, dy) = (ox - plate.c, oy - plate.f);
1040            let px = (plate.e * dx - plate.b * dy) / det;
1041            let py = (-plate.d * dx + plate.a * dy) / det;
1042            let nearest = ctx
1043                .stars
1044                .0
1045                .iter()
1046                .enumerate()
1047                .filter(|&(i, _)| !used[i])
1048                .map(|(i, st)| (i, (st.x - px).powi(2) + (st.y - py).powi(2)))
1049                .filter(|&(_, d2)| d2 < r2)
1050                .min_by(|a, b| a.1.total_cmp(&b.1));
1051            if let Some((i, _)) = nearest {
1052                used[i] = true;
1053                img_pos.push((ctx.stars.0[i].x, ctx.stars.0[i].y));
1054                new_pos.push((nx, ny));
1055            }
1056        }
1057        let Ok(guess) = solve_plate_constants(&img_pos, &new_pos) else {
1058            break;
1059        };
1060        let cat = StarList(cat);
1061        let Some(v) = verify_and_refit(
1062            ctx.stars,
1063            &cat,
1064            &guess,
1065            ctx.img.width,
1066            ctx.img.height,
1067            &ctx.accept,
1068        ) else {
1069            log::info!("Re-centring on the image centre did not verify; keeping the fit.");
1070            break;
1071        };
1072        log::info!(
1073            "Re-centred on the image centre: verified {} stars, residual {:.2}\"",
1074            v.n(),
1075            v.rms
1076        );
1077        (verified, ra_db, dec_db) = (v, ra0, dec0);
1078    }
1079    (verified, ra_db, dec_db)
1080}
1081
1082/// Solve the WCS for an image against an ASTAP star database.
1083///
1084/// Walks a square spiral out from the hint in steps of one field of view, and
1085/// returns the first position whose quad match survives star-by-star verification.
1086/// If `params.binning > 1`, `img` is taken to be the binned image and the returned
1087/// CRPIX/CD/CDELT are scaled back to the unbinned pixel grid.
1088///
1089/// All progress is emitted via the `log` crate at INFO level — callers install
1090/// whichever logger backend they need (file, stderr, both, or none).
1091///
1092/// # Errors
1093///
1094/// - [`ArcsecError::InvalidParameter`] if `fov` is not positive and finite, or
1095///   `search_radius` is negative or not finite.
1096/// - [`ArcsecError::CatalogNotFound`] if `db_path` holds no database called `db_name`.
1097/// - [`ArcsecError::InsufficientStars`] if fewer than 5 stars are detected.
1098/// - [`ArcsecError::InsufficientQuads`] if no spiral position yields a verified match.
1099pub fn solve_image(img: &crate::types::ImageBuffer, params: &SolveParams) -> Result<WcsSolution> {
1100    // The spiral steps by one FOV out to the search radius, so a zero, negative or
1101    // NaN FOV would make the step count infinite (and saturate to i32::MAX).
1102    if !(params.fov.is_finite() && params.fov > 0.0) {
1103        return Err(ArcsecError::InvalidParameter(format!(
1104            "field of view must be positive, got {} rad",
1105            params.fov
1106        )));
1107    }
1108    if !(params.search_radius.is_finite() && params.search_radius >= 0.0) {
1109        return Err(ArcsecError::InvalidParameter(format!(
1110            "search radius must be non-negative, got {} rad",
1111            params.search_radius
1112        )));
1113    }
1114
1115    // Check the database up front. Every spiral position swallows a missing-file
1116    // error as "nothing catalogued here", so without this a wrong -d/-D reads
1117    // nothing everywhere and surfaces as InsufficientQuads - exit 1, "no
1118    // solution" - when the image is fine and the database is the problem.
1119    if !crate::catalog::catalog_present(&params.db_path, &params.db_name) {
1120        return Err(ArcsecError::CatalogNotFound(params.db_path.clone()));
1121    }
1122
1123    // --- Phase A: star detection ---
1124    let bg = get_background(img, params.max_stars);
1125    log::info!("Start finding stars");
1126    let (stars, stars_raw) = find_stars_with_background(
1127        img,
1128        &bg,
1129        params.hfd_min,
1130        params.max_stars,
1131        img.width,
1132        img.height,
1133    );
1134    log::info!(
1135        "{} stars found of the requested {}. Background value is {:.0}. \
1136         Detection level used {:.0} above background. Star level is {:.0} above background. \
1137         Noise level is {:.0}",
1138        stars_raw,
1139        params.max_stars,
1140        bg.mean,
1141        bg.star_level,
1142        bg.star_level,
1143        bg.noise,
1144    );
1145    if stars_raw > params.max_stars {
1146        log::info!("Selecting the {} brightest stars only.", params.max_stars);
1147    }
1148
1149    // Detection is not trimmed to a fraction. Stars beyond `-s` are faint enough to
1150    // be absent from the catalog, which once corrupted 3-NN quads badly enough to
1151    // justify dropping all but the brightest half; quad redundancy and star-level
1152    // verification absorb that now, and halving the list halved the quad count.
1153    // The catalog still reads the full requested depth.
1154    //
1155    // It is trimmed to what the database can hold, though (ASTAP's "database
1156    // limit"). A database is density-limited, so a small field holds only so many
1157    // catalogue stars, however many the image shows: 973 detections on a 0.21°
1158    // field (`rnd_080`) against ~100 catalogue stars build quads from stars the
1159    // catalogue has never heard of, and the field cannot match.
1160    let star_limit = density_star_limit(params, img);
1161    let mut stars = stars;
1162    if stars.len() > star_limit {
1163        stars.0.sort_by(|a, b| b.snr.total_cmp(&a.snr));
1164        stars.0.truncate(star_limit);
1165        log::info!(
1166            "Database limit for this field is {star_limit} stars; using the {star_limit} brightest."
1167        );
1168    }
1169
1170    let nrstars_image = stars.len();
1171    if nrstars_image < 5 {
1172        return Err(ArcsecError::InsufficientStars {
1173            found: nrstars_image,
1174            required: 5,
1175        });
1176    }
1177
1178    // --- Phase B: image pattern building ---
1179    let img_quads = build_quads(&stars, nrstars_image);
1180    let nr_quads = img_quads.len();
1181
1182    let img_tris = if params.method == SolveMethod::Tetra {
1183        build_triangles(&stars)
1184    } else {
1185        crate::quads::TriangleList::default()
1186    };
1187
1188    let patterns_empty = match params.method {
1189        SolveMethod::Quads => nr_quads == 0,
1190        SolveMethod::Tetra => img_tris.is_empty(),
1191    };
1192    if patterns_empty {
1193        return Err(ArcsecError::InsufficientQuads {
1194            found: 0,
1195            required: 3,
1196        });
1197    }
1198
1199    let min_quads: usize = 3 + nrstars_image / 140;
1200
1201    let oversize: f64 = match params.speed {
1202        SearchSpeed::Auto if nrstars_image < 35 => 2.0,
1203        SearchSpeed::Auto if nrstars_image > 140 => 1.0,
1204        SearchSpeed::Auto => 2.0 * (35.0 / nrstars_image as f64).sqrt(),
1205        // As ASTAP, never more than one database tile: a larger window could reach
1206        // past the neighbouring tile, which the tile lookup does not cover.
1207        SearchSpeed::Slow => {
1208            let max_fov_deg = match crate::catalog::detect_layout(&params.db_path, &params.db_name)
1209            {
1210                CatalogLayout::Areas1476 => 5.142_857_143_f64,
1211                CatalogLayout::Areas290 => 9.53,
1212                CatalogLayout::AllSky001 => 180.0,
1213            };
1214            2.0_f64.min(max_fov_deg.to_radians() / params.fov).max(1.0)
1215        }
1216    };
1217
1218    // Use the full catalog depth regardless of how many image stars we trimmed.
1219    let nrstars_required = (params.max_stars as f64 * oversize * oversize).round() as usize;
1220    let step_size = params.fov;
1221    let fov_deg = step_size.to_degrees();
1222    let max_distance = (params.search_radius / step_size + 2.0) as i32;
1223
1224    log::info!(
1225        "{} stars, {} quads selected in the image. {} database stars, {} database quads required \
1226         for the {:.2}d square search window. Step size {:.2}d. Oversize {:.2}",
1227        nrstars_image,
1228        nr_quads,
1229        nrstars_required,
1230        nrstars_required,
1231        fov_deg * oversize,
1232        fov_deg,
1233        oversize,
1234    );
1235
1236    // --- Phase C: spiral search ---
1237    //
1238    // Spiral positions are independent, so they are evaluated a batch at a time
1239    // across a thread pool. Semantics are unchanged from the serial search: within a
1240    // batch the lowest spiral index wins, and batches are processed in order, so the
1241    // position returned is exactly the one the serial loop would have returned. The
1242    // only cost is evaluating the rest of a batch after its first success.
1243    let ctx = SpiralCtx {
1244        params,
1245        img,
1246        stars: &stars,
1247        img_quads: &img_quads,
1248        img_tris: &img_tris,
1249        nrstars_image,
1250        star_limit,
1251        nrstars_required,
1252        oversize,
1253        min_quads,
1254        step_size,
1255        accept: Acceptance::new(nrstars_image, params, img),
1256        aspect: img.width.max(img.height) as f64 / img.width.min(img.height).max(1) as f64,
1257    };
1258
1259    let n_threads = if params.threads > 0 {
1260        params.threads
1261    } else {
1262        crate::max_threads()
1263    }
1264    .clamp(1, 64);
1265
1266    let positions: Vec<(i32, i32)> = SpiralSearch::new(max_distance).collect();
1267    let mut step_distances: Vec<f64> = Vec::new();
1268
1269    let mut winner: Option<PositionOutcome> = None;
1270    let mut start_idx = 0usize;
1271    while start_idx < positions.len() && winner.is_none() {
1272        // The first position is the hint itself and usually solves outright, so try it
1273        // on its own: spawning a pool for it would cost more than it saves.
1274        let batch_len = if start_idx == 0 {
1275            1
1276        } else {
1277            n_threads.min(positions.len() - start_idx)
1278        };
1279        let batch = &positions[start_idx..start_idx + batch_len];
1280
1281        let tries: Vec<PositionTry> = if n_threads == 1 || batch.len() == 1 {
1282            batch
1283                .iter()
1284                .enumerate()
1285                .map(|(k, &(sx, sy))| try_position(&ctx, start_idx + k, sx, sy))
1286                .collect()
1287        } else {
1288            std::thread::scope(|scope| {
1289                let handles: Vec<_> = batch
1290                    .iter()
1291                    .enumerate()
1292                    .map(|(k, &(sx, sy))| {
1293                        let ctx = &ctx;
1294                        scope.spawn(move || try_position(ctx, start_idx + k, sx, sy))
1295                    })
1296                    .collect();
1297                handles
1298                    .into_iter()
1299                    // A dead worker must not read as "nothing matched here":
1300                    // the spiral would move on and the solve would fail for a
1301                    // reason with no trace anywhere.
1302                    .map(|h| h.join().unwrap_or_else(|e| std::panic::resume_unwind(e)))
1303                    .collect()
1304            })
1305        };
1306
1307        for t in tries {
1308            if let Some(d) = t.sep_deg {
1309                step_distances.push(d);
1310            }
1311            if let Some(o) = t.outcome
1312                && winner.as_ref().is_none_or(|w| o.idx < w.idx)
1313            {
1314                winner = Some(o);
1315            }
1316        }
1317
1318        start_idx += batch_len;
1319    }
1320
1321    if let Some(o) = winner.as_ref().filter(|o| o.refused) {
1322        log::info!(
1323            "No solution: the field at search position {} is too distorted for a linear plate.",
1324            o.idx
1325        );
1326        return Err(ArcsecError::InsufficientQuads {
1327            found: 0,
1328            required: min_quads,
1329        });
1330    }
1331    if let Some(o) = winner {
1332        log::info!(
1333            "{} of {} patterns selected matching within {:.3} tolerance.",
1334            o.n_matched,
1335            o.n_raw,
1336            params.quad_tolerance,
1337        );
1338
1339        let v = o.verified;
1340        let mut wcs = derive_wcs(o.ra_db, o.dec_db, &v.plate, img.width, img.height);
1341        // The verified pairs, on the original image's pixel grid: a binned pixel
1342        // centre at 0-based `x` is at `(x + 0.5) * b + 0.5` in unbinned FITS pixels.
1343        let b = params.binning.max(1) as f64;
1344        wcs.matched_stars = v
1345            .img_pos
1346            .iter()
1347            .zip(&v.cat_pos)
1348            .map(|(&(x, y), &(sx, sy))| {
1349                let (ra, dec) = standard_equatorial(o.ra_db, o.dec_db, sx, sy, 1.0);
1350                MatchedStar {
1351                    x: (x + 0.5) * b + 0.5,
1352                    y: (y + 0.5) * b + 0.5,
1353                    ra,
1354                    dec,
1355                }
1356            })
1357            .collect();
1358        if params.binning > 1 {
1359            let b = params.binning as f64;
1360            wcs.crpix1 = (wcs.crpix1 - 0.5) * b + 0.5;
1361            wcs.crpix2 = (wcs.crpix2 - 0.5) * b + 0.5;
1362            wcs.cd1_1 /= b;
1363            wcs.cd1_2 /= b;
1364            wcs.cd2_1 /= b;
1365            wcs.cd2_2 /= b;
1366            wcs.cdelt1 /= b;
1367            wcs.cdelt2 /= b;
1368        }
1369        wcs.residual_rms = v.rms;
1370        wcs.stars_matched = v.n();
1371        wcs.raw_matches = o.n_raw;
1372        wcs.plate = v.plate;
1373        wcs.mag_limit = o.mag_limit;
1374        wcs.search_dist_deg = o.sep_deg;
1375        wcs.step_distances = step_distances;
1376        return Ok(wcs);
1377    }
1378
1379    Err(ArcsecError::InsufficientQuads {
1380        found: 0,
1381        required: min_quads,
1382    })
1383}
1384
1385/// Format RA (radians) as `astap_cli` prints it: `"HH: MM  SS.S"`, each field at
1386/// least two digits (ASTAP's `prepare_ra(ra, ': ')`).
1387#[must_use]
1388pub fn format_ra(ra_rad: f64) -> String {
1389    // Round once, at the printed precision, and only then split into fields.
1390    // Splitting first and letting `{:.1}` round the seconds printed 59.96 s as
1391    // "60.0" without carrying into the minutes (and 23:59:59.96 as "23: 59  60.0").
1392    // ASTAP carries, but prints 23:59:59.96 as "24: 00  00.0"; this wraps to 00h.
1393    const TENTHS_PER_DAY: f64 = 24.0 * 36_000.0;
1394    let ra_tenths = ((ra_rad.to_degrees() / 15.0 * 36_000.0)
1395        .round()
1396        .rem_euclid(TENTHS_PER_DAY)) as u64;
1397    let h = ra_tenths / 36_000;
1398    let m = ra_tenths / 600 % 60;
1399    let s = ra_tenths % 600 / 10;
1400    let tenths = ra_tenths % 10;
1401    format!("{h:02}: {m:02}  {s:02}.{tenths}")
1402}
1403
1404/// Format Dec (radians) as `astap_cli` prints it: `"±DDd MM  SS"`, each field at
1405/// least two digits (ASTAP's `prepare_dec(dec, 'd ')`).
1406#[must_use]
1407pub fn format_dec(dec_rad: f64) -> String {
1408    let dec_deg = dec_rad.to_degrees();
1409    let sign = if dec_deg < 0.0 { '-' } else { '+' };
1410    let dec_secs = (dec_deg.abs() * 3600.0).round() as u64;
1411    let dd = dec_secs / 3600;
1412    let dm = dec_secs / 60 % 60;
1413    let ds = dec_secs % 60;
1414    format!("{sign}{dd:02}d {dm:02}  {ds:02}")
1415}
1416
1417/// Format RA and Dec (radians) as `astap_cli`'s `Solution found:` line does:
1418/// `"HH: MM  SS.S ±DDd MM  SS"`. (Its `Start position:` line puts a comma between
1419/// the two; see [`format_ra`] and [`format_dec`].)
1420#[must_use]
1421pub fn format_radec(ra_rad: f64, dec_rad: f64) -> String {
1422    format!("{} {}", format_ra(ra_rad), format_dec(dec_rad))
1423}
1424
1425#[cfg(test)]
1426mod tests {
1427    use super::*;
1428    use crate::math::coords::{ang_sep, standard_equatorial};
1429    use crate::test_support::{
1430        Rng, SkySpec, SkyStar, TempDir, TruthWcs, random_sky, render, write_001_db, write_290_db,
1431        write_1476_db,
1432    };
1433    use crate::types::{ImageBuffer, PlateConstants};
1434    use crate::wcs::output::derive_wcs;
1435    use core::f64::consts::PI;
1436
1437    fn deg(d: f64) -> f64 {
1438        d * PI / 180.0
1439    }
1440
1441    fn make_test_scene(
1442        n_stars: usize,
1443        ra_center: f64,
1444        dec_center: f64,
1445        cdelt_arcsec: f64,
1446        width: usize,
1447        height: usize,
1448    ) -> (ImageBuffer, Vec<(f64, f64)>, PlateConstants) {
1449        let mut data = vec![100.0f32; width * height];
1450        let mut catalog_sky: Vec<(f64, f64)> = Vec::new();
1451        let stars_per_row = (n_stars as f64).sqrt().ceil() as usize;
1452        let spacing = 40.0;
1453        let cx = (width as f64 - 1.0) / 2.0;
1454        let cy = (height as f64 - 1.0) / 2.0;
1455        let a = cdelt_arcsec;
1456        let c = -a * cx;
1457        let e = cdelt_arcsec;
1458        let f_offset = -e * cy;
1459        let plate = PlateConstants {
1460            a,
1461            b: 0.0,
1462            c,
1463            d: 0.0,
1464            e,
1465            f: f_offset,
1466        };
1467        let mut count = 0;
1468        'outer: for row in 0..stars_per_row {
1469            for col in 0..stars_per_row {
1470                if count >= n_stars {
1471                    break 'outer;
1472                }
1473                let px = 20.0 + col as f64 * spacing;
1474                let py = 20.0 + row as f64 * spacing;
1475                if px >= width as f64 - 20.0 || py >= height as f64 - 20.0 {
1476                    continue;
1477                }
1478                let x_std = a * px + c;
1479                let y_std = e * py + f_offset;
1480                let (ra, dec) = standard_equatorial(ra_center, dec_center, x_std, y_std, 1.0);
1481                catalog_sky.push((ra, dec));
1482                let sigma = 2.0;
1483                let amp = 30000.0f32;
1484                for dy in -8i32..=8 {
1485                    for dx in -8i32..=8 {
1486                        let x = (px as i32 + dx) as usize;
1487                        let y = (py as i32 + dy) as usize;
1488                        if x < width && y < height {
1489                            let r2 = (dx * dx + dy * dy) as f64 / (2.0 * sigma * sigma);
1490                            data[y * width + x] += amp * (-r2).exp() as f32;
1491                        }
1492                    }
1493                }
1494                count += 1;
1495            }
1496        }
1497        let img = ImageBuffer {
1498            data,
1499            width,
1500            height,
1501        };
1502        (img, catalog_sky, plate)
1503    }
1504
1505    #[test]
1506    fn derive_wcs_recovers_position() {
1507        let ra_center = deg(45.0);
1508        let dec_center = deg(30.0);
1509        let (img, _cat, plate) = make_test_scene(16, ra_center, dec_center, 2.0, 300, 300);
1510        let wcs = derive_wcs(ra_center, dec_center, &plate, img.width, img.height);
1511        let sep_arcsec = ang_sep(wcs.ra0, wcs.dec0, ra_center, dec_center) * (180.0 / PI * 3600.0);
1512        assert!(sep_arcsec < 0.5, "centre offset = {sep_arcsec} arcsec");
1513    }
1514
1515    #[test]
1516    fn spiral_covers_origin_first() {
1517        assert_eq!(SpiralSearch::new(5).next(), Some((0, 0)));
1518    }
1519
1520    #[test]
1521    fn oversize_formula_limits() {
1522        for n in [10, 35, 70, 140, 200] {
1523            let ov: f64 = if n < 35 {
1524                2.0
1525            } else if n > 140 {
1526                1.0
1527            } else {
1528                2.0 * (35.0 / n as f64).sqrt()
1529            };
1530            assert!((1.0..=2.0).contains(&ov), "oversize={ov} for n={n}");
1531        }
1532    }
1533
1534    #[test]
1535    fn format_radec_carries_rounded_seconds() {
1536        // 1h 59m 59.97s must round up to 2h 00m 00.0s, not print "60.0" seconds.
1537        let ra = deg((1.0 + 59.0 / 60.0 + 59.97 / 3600.0) * 15.0);
1538        // +10° 59' 59.7" rounds to +11° 00' 00".
1539        let dec = deg(10.0 + 59.0 / 60.0 + 59.7 / 3600.0);
1540        assert_eq!(format_radec(ra, dec), "02: 00  00.0 +11d 00  00");
1541        // RA just short of 24h wraps to 0h.
1542        let s = format_radec(deg(359.999_999_9), deg(-0.5));
1543        assert_eq!(s, "00: 00  00.0 -00d 30  00");
1544        // An ordinary value is unchanged by the rewrite.
1545        assert_eq!(
1546            format_radec(deg((5.0 + 35.0 / 60.0 + 17.3 / 3600.0) * 15.0), deg(-5.39)),
1547            "05: 35  17.3 -05d 23  24"
1548        );
1549    }
1550
1551    /// Byte-for-byte what `astap_cli` prints (checked against 2026.07.30):
1552    /// `Start position: 04: 20  00.0, +35d 00  00` and
1553    /// `Solution found: 04: 20  00.0 +35d 00  00`. Every field is at least two
1554    /// digits wide, as ASTAP's `LeadingZero` makes it.
1555    #[test]
1556    fn ra_and_dec_are_formatted_as_astap_cli_prints_them() {
1557        let ra = deg(65.0); // 4h 20m
1558        let dec = deg(35.0);
1559        assert_eq!(format_ra(ra), "04: 20  00.0");
1560        assert_eq!(format_dec(dec), "+35d 00  00");
1561        assert_eq!(format_radec(ra, dec), "04: 20  00.0 +35d 00  00");
1562        assert_eq!(
1563            format_radec(
1564                deg((13.0 + 7.0 / 60.0 + 9.25 / 3600.0) * 15.0),
1565                -deg(89.0 + 1.0 / 60.0 + 2.0 / 3600.0)
1566            ),
1567            "13: 07  09.3 -89d 01  02"
1568        );
1569        assert_eq!(format_dec(deg(-0.0001)), "-00d 00  00");
1570    }
1571
1572    #[test]
1573    fn solve_image_rejects_a_non_positive_fov() {
1574        let img = ImageBuffer::new(64, 64);
1575        let params = SolveParams {
1576            ra_hint: 0.0,
1577            dec_hint: 0.0,
1578            fov: 0.0,
1579            search_radius: 0.1,
1580            quad_tolerance: 0.007,
1581            hfd_min: 1.5,
1582            max_stars: 500,
1583            db_path: std::path::PathBuf::from("/nonexistent"),
1584            db_name: "d50".into(),
1585            binning: 1,
1586            method: SolveMethod::Quads,
1587            threads: 1,
1588            speed: SearchSpeed::Auto,
1589        };
1590        assert!(matches!(
1591            solve_image(&img, &params),
1592            Err(ArcsecError::InvalidParameter(_))
1593        ));
1594    }
1595
1596    // ── Plate-fit helpers ─────────────────────────────────────────────────────
1597
1598    /// A known similarity transform (pixels → catalogue arcsec), with a flip.
1599    fn known_plate() -> PlateConstants {
1600        let (s, r) = (3.2_f64, 0.61_f64);
1601        PlateConstants {
1602            a: -s * r.cos(),
1603            b: s * r.sin(),
1604            c: 640.0,
1605            d: s * r.sin(),
1606            e: s * r.cos(),
1607            f: -512.0,
1608        }
1609    }
1610
1611    fn apply(p: &PlateConstants, (x, y): (f64, f64)) -> (f64, f64) {
1612        (p.a * x + p.b * y + p.c, p.d * x + p.e * y + p.f)
1613    }
1614
1615    fn plate_close(p: &PlateConstants, q: &PlateConstants, tol: f64) -> bool {
1616        [
1617            (p.a, q.a),
1618            (p.b, q.b),
1619            (p.c, q.c),
1620            (p.d, q.d),
1621            (p.e, q.e),
1622            (p.f, q.f),
1623        ]
1624        .iter()
1625        .all(|(u, v)| (u - v).abs() <= tol)
1626    }
1627
1628    /// The acceptance rule for a well-populated image, for the plate of
1629    /// [`known_plate`].
1630    const STRICT: Acceptance = Acceptance {
1631        min_stars: MIN_VERIFIED_STARS,
1632        expected_scale: 3.2,
1633    };
1634
1635    fn star_at(x: f64, y: f64) -> Star {
1636        Star {
1637            x,
1638            y,
1639            snr: 50.0,
1640            hfd: 2.5,
1641        }
1642    }
1643
1644    /// 40 exact pairs under `known_plate`, then five pairs whose catalogue side is
1645    /// displaced by `outlier(k)`.
1646    fn pairs_with_outliers(outlier: impl Fn(usize, (f64, f64)) -> (f64, f64)) -> PairedPositions {
1647        let plate = known_plate();
1648        let mut rng = Rng::new(7);
1649        let mut img = Vec::new();
1650        let mut cat = Vec::new();
1651        for _ in 0..40 {
1652            let p = (rng.range(0.0, 500.0), rng.range(0.0, 500.0));
1653            img.push(p);
1654            cat.push(apply(&plate, p));
1655        }
1656        for k in 0..5 {
1657            let p = (rng.range(0.0, 500.0), rng.range(0.0, 500.0));
1658            img.push(p);
1659            cat.push(outlier(k, apply(&plate, p)));
1660        }
1661        (img, cat)
1662    }
1663
1664    #[test]
1665    fn sigma_clip_pairs_rejects_outliers_and_keeps_the_rest() {
1666        // Five wrong pairings, each ~100" (30 px) from where the plate puts them.
1667        let (img, cat) = pairs_with_outliers(|k, (x, y)| {
1668            let a = k as f64 * 1.3;
1669            (x + 100.0 * a.cos(), y + 100.0 * a.sin())
1670        });
1671        let (ci, cc) = sigma_clip_pairs(img, cat, 3.0, 3);
1672        assert_eq!(ci.len(), 40, "all and only the true pairs survive");
1673        let fit = solve_plate_constants(&ci, &cc).unwrap();
1674        assert!(plate_close(&fit, &known_plate(), 1e-6), "{fit:?}");
1675    }
1676
1677    /// `sigma_clip_pairs` gives up as soon as a fit fails, and the first fit is made
1678    /// on the contaminated set. Five gross outliers in 45 pairs are enough to skew
1679    /// that fit past the similarity check in `solve_plate_constants`
1680    /// (`BadSolution`), so nothing is clipped and all 45 come
1681    /// back. `try_position` would then refit the same contaminated set, fail the
1682    /// same check, and abandon a position whose 40 good pairs would have solved it.
1683    /// So the clipper's first fit must be unchecked.
1684    #[test]
1685    fn sigma_clip_pairs_rejects_gross_outliers() {
1686        let (img, cat) =
1687            pairs_with_outliers(|k, _| (1000.0 + 150.0 * k as f64, -900.0 + 70.0 * k as f64));
1688        assert!(matches!(
1689            solve_plate_constants(&img, &cat),
1690            Err(ArcsecError::BadSolution { .. })
1691        ));
1692        let (ci, _) = sigma_clip_pairs(img, cat, 3.0, 3);
1693        assert_eq!(ci.len(), 40, "the five gross outliers should be clipped");
1694    }
1695
1696    /// The pattern-pair fit of both methods: a set whose plain fit is refused
1697    /// (gross outliers skew it off a similarity) still yields the true plate, from
1698    /// the 40 good pairs; one with too few good pairs left yields nothing.
1699    #[test]
1700    fn fit_pattern_pairs_recovers_a_plate_the_plain_fit_refuses() {
1701        let (img, cat) =
1702            pairs_with_outliers(|k, _| (1000.0 + 150.0 * k as f64, -900.0 + 70.0 * k as f64));
1703        assert!(solve_plate_constants(&img, &cat).is_err());
1704        let (plate, n) = fit_pattern_pairs(img.clone(), cat.clone(), 3).expect("clipped fit");
1705        assert_eq!(n, 40);
1706        assert!(plate_close(&plate, &known_plate(), 1e-6), "{plate:?}");
1707        // Clean pairs: nothing to clip, the same plate as a plain fit.
1708        let (plate, n) = fit_pattern_pairs(img[..40].to_vec(), cat[..40].to_vec(), 3).unwrap();
1709        assert_eq!(n, 40);
1710        assert!(plate_close(&plate, &known_plate(), 1e-6));
1711        // More pairs demanded than survive the clipping.
1712        assert!(fit_pattern_pairs(img, cat, 41).is_none());
1713    }
1714
1715    #[test]
1716    fn sigma_clip_pairs_leaves_too_few_pairs_alone() {
1717        let img = vec![(0.0, 0.0), (1.0, 0.0)];
1718        let cat = vec![(5.0, 5.0), (9.0, 9.0)];
1719        let (ci, cc) = sigma_clip_pairs(img.clone(), cat.clone(), 3.0, 3);
1720        assert_eq!((ci, cc), (img, cat));
1721    }
1722
1723    #[test]
1724    fn verify_and_refit_recovers_the_plate_from_a_rough_guess() {
1725        let truth = known_plate();
1726        let mut rng = Rng::new(11);
1727        let mut img_stars = Vec::new();
1728        let mut cat_stars = Vec::new();
1729        for _ in 0..60 {
1730            let (x, y) = (rng.range(5.0, 395.0), rng.range(5.0, 295.0));
1731            img_stars.push(star_at(x, y));
1732            let (cx, cy) = apply(&truth, (x, y));
1733            cat_stars.push(star_at(cx, cy));
1734        }
1735        // Catalogue stars that fall outside the frame must be ignored, not paired.
1736        for k in 0..20 {
1737            let (cx, cy) = apply(&truth, (-300.0 - 10.0 * k as f64, 900.0));
1738            cat_stars.push(star_at(cx, cy));
1739        }
1740        // Start 2 px and a little rotation away from the truth.
1741        let mut rough = truth.clone();
1742        rough.c += 2.0 * truth.a;
1743        rough.f += 2.0 * truth.e;
1744        rough.b += 0.01;
1745        let v = verify_and_refit(
1746            &StarList(img_stars),
1747            &StarList(cat_stars),
1748            &rough,
1749            400,
1750            300,
1751            &STRICT,
1752        )
1753        .expect("a correct plate must verify");
1754        assert_eq!(v.n(), 60);
1755        assert_eq!(v.cat_pos.len(), 60);
1756        assert!(v.rms < 1e-6, "rms {}", v.rms);
1757        assert!(plate_close(&v.plate, &truth, 1e-6), "{:?}", v.plate);
1758        // Each pair is a star and its own catalogue entry.
1759        for (&(x, y), &(cx, cy)) in v.img_pos.iter().zip(&v.cat_pos) {
1760            let (px, py) = apply(&truth, (x, y));
1761            assert!((px - cx).hypot(py - cy) < 1e-6);
1762        }
1763    }
1764
1765    #[test]
1766    fn verify_and_refit_rejects_too_few_or_clustered_matches() {
1767        let truth = known_plate();
1768        let mut rng = Rng::new(12);
1769        let build = |pts: &[(f64, f64)]| {
1770            let img = StarList(pts.iter().map(|&(x, y)| star_at(x, y)).collect());
1771            let cat = StarList(
1772                pts.iter()
1773                    .map(|&p| apply(&truth, p))
1774                    .map(|(x, y)| star_at(x, y))
1775                    .collect(),
1776            );
1777            (img, cat)
1778        };
1779
1780        // 20 well-spread stars: fewer than MIN_VERIFIED_STARS.
1781        let few: Vec<_> = (0..20)
1782            .map(|_| (rng.range(0.0, 400.0), rng.range(0.0, 300.0)))
1783            .collect();
1784        let (img, cat) = build(&few);
1785        assert!(verify_and_refit(&img, &cat, &truth, 400, 300, &STRICT).is_none());
1786
1787        // 80 stars, all in one 40-pixel corner: rotation is unconstrained.
1788        let clustered: Vec<_> = (0..80)
1789            .map(|_| (rng.range(0.0, 40.0), rng.range(0.0, 40.0)))
1790            .collect();
1791        let (img, cat) = build(&clustered);
1792        assert!(verify_and_refit(&img, &cat, &truth, 400, 300, &STRICT).is_none());
1793
1794        // The same 80 spread over the frame pass.
1795        let spread: Vec<_> = (0..80)
1796            .map(|_| (rng.range(0.0, 400.0), rng.range(0.0, 300.0)))
1797            .collect();
1798        let (img, cat) = build(&spread);
1799        assert!(verify_and_refit(&img, &cat, &truth, 400, 300, &STRICT).is_some());
1800
1801        // Degenerate inputs.
1802        let empty = StarList::default();
1803        assert!(verify_and_refit(&empty, &cat, &truth, 400, 300, &STRICT).is_none());
1804        let mut singular = truth.clone();
1805        singular.a = 0.0;
1806        singular.b = 0.0;
1807        assert!(verify_and_refit(&img, &cat, &singular, 400, 300, &STRICT).is_none());
1808    }
1809
1810    // ── End-to-end solves against synthetic catalogues ────────────────────────
1811
1812    #[derive(Clone, Copy)]
1813    enum Db {
1814        Areas1476,
1815        Areas290,
1816        AllSky001,
1817    }
1818
1819    /// A rendered field and the database it was drawn from.
1820    struct Scene {
1821        dir: TempDir,
1822        img: ImageBuffer,
1823        truth: TruthWcs,
1824        /// Every star drawn, in and around the frame.
1825        sky: Vec<SkyStar>,
1826    }
1827
1828    /// Render ~`n_in_frame` stars through `truth` and write the surrounding sky
1829    /// (six fields wide, so offset hints still find their stars) as a database.
1830    fn scene(truth: TruthWcs, db: Db, n_in_frame: usize, seed: u64) -> Scene {
1831        let mut rng = Rng::new(seed);
1832        let scale_deg = truth.cd[1].hypot(truth.cd[3]);
1833        let (w_deg, h_deg) = (
1834            truth.width as f64 * scale_deg,
1835            truth.height as f64 * scale_deg,
1836        );
1837        let side = 6.0 * w_deg.max(h_deg);
1838        let sky = random_sky(
1839            &mut rng,
1840            &SkySpec {
1841                ra0: truth.ra0,
1842                dec0: truth.dec0,
1843                side_deg: side,
1844                n: (n_in_frame as f64 * side * side / (w_deg * h_deg)) as usize,
1845                min_sep_deg: 12.0 * scale_deg,
1846                mag_lo: 10.0,
1847                mag_hi: 14.5,
1848            },
1849        );
1850        // A PSF of ~1.3 px on a 5"/px frame, scaled so binned frames stay sampled.
1851        let sigma = 1.3 * 5.0 / (scale_deg * 3600.0);
1852        let img = render(
1853            &truth,
1854            &sky,
1855            sigma.max(1.3),
1856            1000.0,
1857            8.0,
1858            30_000.0,
1859            &mut rng,
1860        );
1861        let dir = TempDir::new("solve");
1862        match db {
1863            Db::Areas1476 => write_1476_db(dir.path(), "t50", &sky),
1864            Db::Areas290 => write_290_db(dir.path(), "t50", &sky),
1865            Db::AllSky001 => write_001_db(dir.path(), "t50", &sky),
1866        }
1867        Scene {
1868            dir,
1869            img,
1870            truth,
1871            sky,
1872        }
1873    }
1874
1875    /// Parameters that fail every check that needs a database.
1876    fn params_for_blank() -> SolveParams {
1877        SolveParams {
1878            ra_hint: 0.0,
1879            dec_hint: 0.0,
1880            fov: deg(1.0),
1881            search_radius: 0.0,
1882            quad_tolerance: 0.007,
1883            hfd_min: 1.5,
1884            max_stars: 500,
1885            db_path: std::path::PathBuf::from("/nonexistent"),
1886            db_name: "d50".into(),
1887            binning: 1,
1888            method: SolveMethod::Quads,
1889            threads: 1,
1890            speed: SearchSpeed::Auto,
1891        }
1892    }
1893
1894    fn params_for(s: &Scene, ra_hint: f64, dec_hint: f64) -> SolveParams {
1895        SolveParams {
1896            ra_hint,
1897            dec_hint,
1898            fov: (s.truth.height as f64 * s.truth.cd[1].hypot(s.truth.cd[3])).to_radians(),
1899            search_radius: deg(2.0),
1900            quad_tolerance: 0.007,
1901            hfd_min: 1.5,
1902            max_stars: 500,
1903            db_path: s.dir.path().to_path_buf(),
1904            db_name: "t50".into(),
1905            binning: 1,
1906            method: SolveMethod::Quads,
1907            threads: 1,
1908            speed: SearchSpeed::Auto,
1909        }
1910    }
1911
1912    fn assert_solved(s: &Scene, wcs: &WcsSolution, tol_arcsec: f64) {
1913        let err = s.truth.max_error_arcsec(wcs);
1914        assert!(
1915            err < tol_arcsec,
1916            "worst centre/corner error {err:.3}\" (matched {}, rms {:.3})",
1917            wcs.stars_matched,
1918            wcs.residual_rms
1919        );
1920        assert!(wcs.stars_matched >= 10);
1921        // Star-level residual under a third of a pixel.
1922        let scale_arcsec = s.truth.cd[1].hypot(s.truth.cd[3]) * 3600.0;
1923        assert!(
1924            wcs.residual_rms < 0.3 * scale_arcsec,
1925            "rms {}",
1926            wcs.residual_rms
1927        );
1928        assert!(wcs.raw_matches > 0);
1929        assert_matches_agree(wcs, 0.3, 1.0);
1930        assert!(wcs.mag_limit > 10.0 && wcs.mag_limit <= 14.5);
1931        assert!(
1932            wcs.cdelt1 < 0.0 && wcs.cdelt2 > 0.0,
1933            "CDELT sign convention"
1934        );
1935    }
1936
1937    /// The verified pairs agree with the solution: RMS under `rms_px` pixels, and
1938    /// none further off than the final verification radius (`binning` pixels each).
1939    fn assert_matches_agree(wcs: &WcsSolution, rms_px: f64, binning: f64) {
1940        assert_eq!(wcs.matched_stars.len(), wcs.stars_matched);
1941        assert!(wcs.sip.is_none(), "solve_image never fits SIP");
1942        let tan = crate::wcs::TanWcs::from(wcs);
1943        let mut sq = 0.0;
1944        for m in &wcs.matched_stars {
1945            let (x, y) = tan.sky_to_pixel(m.ra, m.dec).unwrap();
1946            let d = (x - m.x).hypot(y - m.y);
1947            assert!(
1948                d < VERIFY_RADII[VERIFY_RADII.len() - 1] * binning,
1949                "pair at ({:.2},{:.2}) projects to ({x:.2},{y:.2})",
1950                m.x,
1951                m.y
1952            );
1953            sq += d * d;
1954        }
1955        let rms = (sq / wcs.matched_stars.len() as f64).sqrt();
1956        assert!(rms < rms_px, "pair rms {rms} px");
1957    }
1958
1959    #[test]
1960    fn solves_a_1476_database_from_an_offset_hint() {
1961        let truth = TruthWcs::new(deg(84.3), deg(-5.2), 5.0, 23.0, false, 400, 320);
1962        let s = scene(truth, Db::Areas1476, 130, 1);
1963        // Hint roughly one field away in each axis: the spiral has to move.
1964        let mut p = params_for(&s, deg(84.3 + 0.6), deg(-5.2 - 0.45));
1965        p.threads = 4;
1966        let wcs = solve_image(&s.img, &p).expect("solve");
1967        assert_solved(&s, &wcs, 1.0);
1968        assert!(wcs.search_dist_deg > 0.1, "solved at the hint itself?");
1969        assert!(wcs.step_distances.len() > 1);
1970        // The pixel scale and rotation come back too.
1971        assert!((wcs.cdelt2 * 3600.0 - 5.0).abs() < 0.01, "{}", wcs.cdelt2);
1972        assert!((wcs.crota2 - 23.0).abs() < 0.05, "crota2 {}", wcs.crota2);
1973    }
1974
1975    #[test]
1976    fn solves_a_mirrored_image_on_a_290_database() {
1977        let truth = TruthWcs::new(deg(201.0), deg(47.5), 6.0, 160.0, true, 360, 360);
1978        let s = scene(truth, Db::Areas290, 120, 2);
1979        let wcs = solve_image(&s.img, &params_for(&s, truth.ra0, truth.dec0)).expect("solve");
1980        assert_solved(&s, &wcs, 1.0);
1981        assert!(wcs.search_dist_deg < 1e-9, "should solve at the hint");
1982        // A mirrored image has det(CD) > 0.
1983        assert!(wcs.cd1_1 * wcs.cd2_2 - wcs.cd1_2 * wcs.cd2_1 > 0.0);
1984    }
1985
1986    #[test]
1987    fn solves_across_ra_zero_with_an_all_sky_001_database() {
1988        // The field straddles RA 0h, so its catalogue stars sit either side of 2π.
1989        let truth = TruthWcs::new(deg(0.05), deg(21.0), 5.0, -70.0, false, 360, 300);
1990        let s = scene(truth, Db::AllSky001, 120, 3);
1991        let wcs = solve_image(&s.img, &params_for(&s, truth.ra0, truth.dec0)).expect("solve");
1992        assert_solved(&s, &wcs, 1.0);
1993    }
1994
1995    #[test]
1996    fn solves_across_ra_zero_with_a_1476_database() {
1997        let truth = TruthWcs::new(deg(359.97), deg(-33.0), 5.0, 95.0, false, 360, 300);
1998        let s = scene(truth, Db::Areas1476, 120, 4);
1999        let wcs = solve_image(&s.img, &params_for(&s, truth.ra0, truth.dec0)).expect("solve");
2000        assert_solved(&s, &wcs, 1.0);
2001    }
2002
2003    #[test]
2004    fn solves_a_field_near_the_celestial_pole() {
2005        let truth = TruthWcs::new(deg(40.0), deg(88.9), 5.0, 10.0, false, 360, 300);
2006        let s = scene(truth, Db::Areas1476, 120, 5);
2007        let wcs = solve_image(&s.img, &params_for(&s, truth.ra0, truth.dec0)).expect("solve");
2008        assert_solved(&s, &wcs, 1.0);
2009    }
2010
2011    /// The plate constants are fitted in the tangent plane of the spiral position
2012    /// that matched (`ra_db`, `dec_db`), but `derive_wcs` then moves CRVAL to the
2013    /// image centre and keeps the CD matrix unchanged, as though the two tangent
2014    /// planes were the same. They are not, and the error grows linearly with the
2015    /// distance between the matched spiral position and the true field centre.
2016    ///
2017    /// Measured on this 1.5° × 1.25° field at 15"/px: worst-corner error 0.35" with
2018    /// the hint on the centre, 7.5" at 0.2° off, 14.5" at 0.4°, 21" at 0.6° (1.4 px),
2019    /// while the star-level RMS stays at 0.7-0.85" throughout — the verification
2020    /// cannot see it, because it runs in the same (offset) tangent plane. Spiral
2021    /// positions land up to half a step (half a field) from the truth, and a blind
2022    /// estimate can be a whole field off, so this is well inside normal use. 5" is
2023    /// the corner error `scripts/benchmark.py` counts as a false positive.
2024    #[test]
2025    fn accuracy_does_not_depend_on_the_hint_offset() {
2026        let truth = TruthWcs::new(deg(150.0), deg(30.0), 15.0, 20.0, false, 360, 300);
2027        let s = scene(truth, Db::Areas1476, 120, 21);
2028        let off = 0.4;
2029        let p = params_for(&s, deg(150.0 + off / deg(30.0).cos()), deg(30.0 + off));
2030        let wcs = solve_image(&s.img, &p).expect("solve");
2031        assert!(wcs.search_dist_deg < 1e-9, "solved at the hint");
2032        let err = s.truth.max_error_arcsec(&wcs);
2033        assert!(
2034            err < 5.0,
2035            "worst corner error {err:.2}\" with a {off}° hint offset"
2036        );
2037    }
2038
2039    #[test]
2040    fn solves_with_the_tetra_method() {
2041        let truth = TruthWcs::new(deg(150.0), deg(2.0), 5.0, 45.0, false, 360, 300);
2042        let s = scene(truth, Db::Areas1476, 110, 6);
2043        let mut p = params_for(&s, truth.ra0, truth.dec0);
2044        p.method = SolveMethod::Tetra;
2045        let wcs = solve_image(&s.img, &p).expect("solve");
2046        assert_solved(&s, &wcs, 1.0);
2047    }
2048
2049    #[test]
2050    fn slow_speed_solves_from_an_offset_hint() {
2051        let truth = TruthWcs::new(deg(84.3), deg(-5.2), 5.0, 23.0, false, 400, 320);
2052        let s = scene(truth, Db::Areas1476, 130, 1);
2053        let mut p = params_for(&s, deg(84.3 + 0.6), deg(-5.2 - 0.45));
2054        p.speed = SearchSpeed::Slow;
2055        let wcs = solve_image(&s.img, &p).expect("solve");
2056        assert_solved(&s, &wcs, 1.0);
2057    }
2058
2059    #[test]
2060    fn binned_solve_is_reported_on_the_unbinned_pixel_grid() {
2061        // Render at full resolution, then solve the 2×2-binned frame.
2062        let truth = TruthWcs::new(deg(10.0), deg(40.0), 2.5, 30.0, false, 720, 600);
2063        let s = scene(truth, Db::Areas1476, 120, 7);
2064        let binned = s.img.bin_image(2);
2065        assert_eq!((binned.width, binned.height), (360, 300));
2066        let mut p = params_for(&s, truth.ra0, truth.dec0);
2067        p.binning = 2;
2068        let wcs = solve_image(&binned, &p).expect("solve");
2069        // crpix is the centre of the unbinned frame, and the scale is unbinned.
2070        assert!((wcs.crpix1 - 360.5).abs() < 1e-9, "crpix1 {}", wcs.crpix1);
2071        assert!((wcs.crpix2 - 300.5).abs() < 1e-9, "crpix2 {}", wcs.crpix2);
2072        assert!((wcs.cdelt2 * 3600.0 - 2.5).abs() < 0.01, "{}", wcs.cdelt2);
2073        let err = s.truth.max_error_arcsec(&wcs);
2074        assert!(err < 2.0, "worst corner error {err:.3}\"");
2075        // The pairs are on the unbinned grid too: one binned pixel is two of these.
2076        assert_matches_agree(&wcs, 0.6, 2.0);
2077    }
2078
2079    #[test]
2080    fn the_star_limit_is_the_database_density_times_the_field_area() {
2081        let params = |fov_deg: f64, db: &str, max_stars: usize| SolveParams {
2082            fov: deg(fov_deg),
2083            max_stars,
2084            db_name: db.into(),
2085            ..params_for_blank()
2086        };
2087        let square = ImageBuffer::new(200, 200);
2088        let wide = ImageBuffer::new(400, 200);
2089        // d80 on a 0.2° square field: 8000 × 0.04 = 320 stars.
2090        assert_eq!(density_star_limit(&params(0.2, "d80", 500), &square), 320);
2091        // The same long side on a 2:1 frame covers half the area.
2092        assert_eq!(density_star_limit(&params(0.2, "d80", 500), &wide), 160);
2093        // Never more than -s.
2094        assert_eq!(density_star_limit(&params(1.0, "d80", 500), &square), 500);
2095        assert_eq!(density_star_limit(&params(0.2, "d80", 100), &square), 100);
2096        // g05 (500/deg²) binds only below ~1°, w08 (1/deg²) on all but the widest.
2097        assert_eq!(density_star_limit(&params(0.8, "g05", 500), &square), 320);
2098        assert_eq!(density_star_limit(&params(20.0, "w08", 500), &wide), 200);
2099        // Unknown density: -s.
2100        assert_eq!(density_star_limit(&params(0.1, "v17", 500), &square), 500);
2101    }
2102
2103    /// A field showing far more stars than the database holds there: with every
2104    /// detection the image quads are built from stars the catalogue does not have,
2105    /// and it does not solve; capped at the database's density it does.
2106    #[test]
2107    fn a_frame_deeper_than_the_database_solves_at_the_database_limit() {
2108        // 0.5° x 0.42° at 3"/px: 0.21 deg², ~450 stars in the frame.
2109        let truth = TruthWcs::new(deg(250.0), deg(36.0), 3.0, 12.0, false, 600, 500);
2110        let s = scene(truth, Db::Areas1476, 450, 31);
2111        // The database holds only the brightest 200 per square degree (the
2112        // scene's sky is 3° on a side), ~42 of them in the frame.
2113        let mut sky = s.sky.clone();
2114        sky.sort_by(|a, b| a.mag.total_cmp(&b.mag));
2115        sky.truncate(200 * 9);
2116        write_1476_db(s.dir.path(), "t02", &sky);
2117        // The same stars under a name whose density is unknown, so no limit.
2118        write_1476_db(s.dir.path(), "t17", &sky);
2119
2120        let mut p = params_for(&s, truth.ra0, truth.dec0);
2121        p.fov = (600.0 * 3.0 / 3600.0_f64).to_radians();
2122        p.search_radius = 0.0;
2123        p.db_name = "t17".into();
2124        assert!(
2125            solve_image(&s.img, &p).is_err(),
2126            "every detection: should not match"
2127        );
2128        p.db_name = "t02".into();
2129        let wcs = solve_image(&s.img, &p).expect("solve at the database limit");
2130        assert!(s.truth.max_error_arcsec(&wcs) < 2.0);
2131    }
2132
2133    #[test]
2134    fn min_verified_stars_relaxes_only_for_sparse_images() {
2135        for (n, want) in [
2136            (0, 10),
2137            (5, 10),
2138            (66, 10),
2139            (67, 11),
2140            (100, 15),
2141            (193, 29),
2142            (194, 30),
2143            (200, 30),
2144            (500, 30),
2145            (usize::MAX, 30),
2146        ] {
2147            assert_eq!(min_verified_stars(n), want, "{n} detections");
2148        }
2149    }
2150
2151    /// Below 30 matches a solution must be at the hint's scale, to half a pixel.
2152    #[test]
2153    fn a_sparse_match_must_have_the_expected_scale_and_a_tight_fit() {
2154        let truth = known_plate(); // 3.2"/px
2155        let verified = |n: usize, rms_px: f64, scale: f64| {
2156            let mut plate = truth.clone();
2157            for c in [&mut plate.a, &mut plate.b, &mut plate.d, &mut plate.e] {
2158                *c *= scale;
2159            }
2160            Verified {
2161                plate,
2162                rms: rms_px * 3.2 * scale,
2163                img_pos: vec![(0.0, 0.0); n],
2164                cat_pos: vec![(0.0, 0.0); n],
2165            }
2166        };
2167        let accept = Acceptance {
2168            min_stars: 12,
2169            expected_scale: 3.2,
2170        };
2171        // Enough stars: scale and residual are not looked at.
2172        assert!(accept.accepts(&verified(30, 3.9, 1.36), 0.5));
2173        // Sparse, right scale, tight fit.
2174        assert!(accept.accepts(&verified(12, 0.3, 1.0), 0.5));
2175        assert!(accept.accepts(&verified(20, 0.49, 1.09), 0.5));
2176        assert!(accept.accepts(&verified(20, 0.49, 0.91), 0.5));
2177        // Sparse and wrong: scale 10% off, a loose fit, too few, too clustered.
2178        assert!(!accept.accepts(&verified(20, 0.3, 1.11), 0.5));
2179        assert!(!accept.accepts(&verified(20, 0.3, 0.89), 0.5));
2180        assert!(!accept.accepts(&verified(29, 0.51, 1.0), 0.5));
2181        assert!(!accept.accepts(&verified(11, 0.1, 1.0), 0.5));
2182        assert!(!accept.accepts(&verified(20, 0.1, 1.0), 0.1));
2183        // The false positive the relaxed count alone lets through (ls2_25).
2184        assert!(!accept.accepts(&verified(12, 2.9, 1.36), 0.5));
2185    }
2186
2187    /// A frame showing only its ~20 brightest stars against a deep catalogue: it
2188    /// solves with fewer than 30 matches when the hint's scale is right, and is
2189    /// refused when the scale the hint implies is 20% off.
2190    #[test]
2191    fn a_sparse_frame_solves_at_the_hint_scale_only() {
2192        let truth = TruthWcs::new(deg(30.0), deg(-12.0), 5.0, 40.0, false, 360, 300);
2193        let s = scene(truth, Db::Areas1476, 150, 41);
2194        let mut bright = s.sky.clone();
2195        bright.sort_by(|a, b| a.mag.total_cmp(&b.mag));
2196        let bright: Vec<SkyStar> = bright
2197            .into_iter()
2198            .filter(|st| {
2199                s.truth
2200                    .sky_to_pixel(st.ra, st.dec)
2201                    .is_some_and(|(x, y)| (5.0..355.0).contains(&x) && (5.0..295.0).contains(&y))
2202            })
2203            .take(22)
2204            .collect();
2205        let mut rng = Rng::new(42);
2206        let img = render(&s.truth, &bright, 1.3, 1000.0, 8.0, 30_000.0, &mut rng);
2207        let mut p = params_for(&s, truth.ra0, truth.dec0);
2208        p.fov = (360.0 * 5.0 / 3600.0_f64).to_radians(); // the long side
2209        p.search_radius = 0.0;
2210        let wcs = solve_image(&img, &p).expect("sparse solve");
2211        assert!(
2212            wcs.stars_matched < MIN_VERIFIED_STARS,
2213            "{}",
2214            wcs.stars_matched
2215        );
2216        assert!(s.truth.max_error_arcsec(&wcs) < 2.0);
2217
2218        p.fov *= 1.2;
2219        assert!(matches!(
2220            solve_image(&img, &p),
2221            Err(ArcsecError::InsufficientQuads { .. })
2222        ));
2223    }
2224
2225    /// A shallow frame, ~40 stars, against a catalogue twelve times deeper: the
2226    /// image's quads join neighbours the deep catalogue's do not, and only the
2227    /// density-matched catalogue quads match them (without them this does not
2228    /// solve).
2229    #[test]
2230    fn a_shallow_frame_matches_the_density_matched_catalogue_quads() {
2231        let truth = TruthWcs::new(deg(140.0), deg(55.0), 5.0, -25.0, true, 360, 300);
2232        let s = scene(truth, Db::Areas1476, 500, 51);
2233        let mut bright = s.sky.clone();
2234        bright.sort_by(|a, b| a.mag.total_cmp(&b.mag));
2235        let in_frame = |st: &SkyStar| {
2236            s.truth
2237                .sky_to_pixel(st.ra, st.dec)
2238                .is_some_and(|(x, y)| (0.0..360.0).contains(&x) && (0.0..300.0).contains(&y))
2239        };
2240        let n_frame = bright.iter().filter(|st| in_frame(st)).count();
2241        bright.truncate(bright.len() * 40 / n_frame.max(1));
2242        let mut rng = Rng::new(52);
2243        let img = render(&s.truth, &bright, 1.3, 1000.0, 8.0, 30_000.0, &mut rng);
2244        let mut p = params_for(&s, truth.ra0, truth.dec0);
2245        p.fov = (360.0 * 5.0 / 3600.0_f64).to_radians();
2246        p.search_radius = 0.0;
2247        let wcs = solve_image(&img, &p).expect("shallow solve");
2248        assert!(s.truth.max_error_arcsec(&wcs) < 2.0);
2249    }
2250
2251    /// A 1024 × 768 field at 10"/px with `corner_px` of radial distortion at the
2252    /// corners (positive: pincushion).
2253    fn distorted_scene(corner_px: f64, seed: u64) -> Scene {
2254        let truth = TruthWcs::new(deg(84.3), deg(-5.2), 10.0, 23.0, false, 1024, 768)
2255            .with_corner_distortion(corner_px);
2256        scene(truth, Db::Areas1476, 300, seed)
2257    }
2258
2259    /// Worst error (arcsec) of a solution with its SIP terms against the truth,
2260    /// over the centre, the corners and two edge midpoints.
2261    fn sip_error_arcsec(s: &Scene, wcs: &WcsSolution) -> f64 {
2262        let tan = crate::wcs::TanWcs::from(wcs);
2263        let (w, h) = (s.truth.width as f64 - 1.0, s.truth.height as f64 - 1.0);
2264        let mut worst: f64 = 0.0;
2265        for (fx, fy) in [
2266            (0.5, 0.5),
2267            (0.0, 0.0),
2268            (1.0, 0.0),
2269            (0.0, 1.0),
2270            (1.0, 1.0),
2271            (0.5, 0.0),
2272            (0.0, 0.5),
2273        ] {
2274            let (x, y) = (w * fx, h * fy);
2275            let (ra_t, dec_t) = s.truth.pixel_to_sky(x, y);
2276            let (ra_s, dec_s) = tan.pixel_to_sky(x + 1.0, y + 1.0);
2277            let sep = crate::test_support::separation(ra_t, dec_t, ra_s, dec_s);
2278            worst = worst.max(sep.to_degrees() * 3600.0);
2279        }
2280        worst
2281    }
2282
2283    #[test]
2284    fn a_distorted_field_reports_the_best_linear_plate_over_the_frame() {
2285        // 30 px of pincushion at the corners. The 2 px verification keeps only the
2286        // stars a linear plate fits, around the centre, and a plate fitted to them
2287        // alone is ~190" out at the corners; the best linear plate over the frame
2288        // is ~103" out, and that is what should be reported.
2289        for (hint_ra, hint_dec) in [(84.3, -5.2), (84.3 + 0.9, -5.2 - 0.7)] {
2290            let s = distorted_scene(30.0, 7);
2291            let wcs =
2292                solve_image(&s.img, &params_for(&s, deg(hint_ra), deg(hint_dec))).expect("solve");
2293            let floor = s.truth.linear_floor_arcsec();
2294            let err = s.truth.max_error_arcsec(&wcs);
2295            assert!(floor > 80.0, "floor {floor:.1}\"");
2296            assert!(
2297                err < floor + 5.0,
2298                "corner error {err:.1}\" against a linear floor of {floor:.1}\""
2299            );
2300            assert!(wcs.sip.is_none(), "solve_image never fits SIP");
2301            // The model's pairs reach the corners, so --sip can follow the distortion.
2302            assert!(wcs.stars_matched > 150, "{} stars", wcs.stars_matched);
2303            let mut with_sip = wcs.clone();
2304            with_sip.sip = crate::wcs::fit_sip(&wcs, 1024, 768);
2305            assert!(with_sip.sip.is_some(), "the distortion is significant");
2306            let sip_err = sip_error_arcsec(&s, &with_sip);
2307            assert!(sip_err < 3.0, "SIP error {sip_err:.2}\"");
2308        }
2309    }
2310
2311    /// [`distorted_scene`] with the right third of the frame blanked to the
2312    /// background, as a nebula or a dark cloud would leave it.
2313    fn part_empty_scene(corner_px: f64) -> Scene {
2314        let mut s = distorted_scene(corner_px, 7);
2315        let w = s.img.width;
2316        for y in 0..s.img.height {
2317            for x in (2 * w / 3)..w {
2318                s.img.data[y * w + x] = 1000.0;
2319            }
2320        }
2321        s
2322    }
2323
2324    #[test]
2325    fn strong_distortion_that_cannot_be_modelled_over_the_frame_is_refused() {
2326        // 30 px of barrel distortion, and stars in only two thirds of the frame: a
2327        // cubic cannot be trusted over the empty third, and the linear plate the
2328        // verified stars give is ~230" out at the corners against a floor of
2329        // ~124". Reporting it would be a false positive.
2330        let s = part_empty_scene(30.0);
2331        let r = solve_image(&s.img, &params_for(&s, deg(84.3), deg(-5.2)));
2332        assert!(
2333            matches!(r, Err(ArcsecError::InsufficientQuads { .. })),
2334            "{:?}",
2335            r.map(|w| s.truth.max_error_arcsec(&w))
2336        );
2337    }
2338
2339    #[test]
2340    fn an_undistorted_field_with_an_empty_third_still_solves() {
2341        let s = part_empty_scene(0.0);
2342        let wcs = solve_image(&s.img, &params_for(&s, deg(84.3), deg(-5.2))).expect("solve");
2343        assert_solved(&s, &wcs, 1.0);
2344    }
2345
2346    #[test]
2347    fn mild_distortion_is_modelled_too() {
2348        // 3 px at the corners: a linear plate fitted to the verified stars is
2349        // already close to the floor, but not as close as the best one.
2350        let s = distorted_scene(3.0, 11);
2351        let wcs = solve_image(&s.img, &params_for(&s, deg(84.3), deg(-5.2))).expect("solve");
2352        let floor = s.truth.linear_floor_arcsec();
2353        let err = s.truth.max_error_arcsec(&wcs);
2354        assert!(
2355            err < floor + 2.0,
2356            "corner error {err:.1}\" against a floor of {floor:.1}\""
2357        );
2358    }
2359
2360    #[test]
2361    fn a_field_absent_from_the_catalogue_does_not_solve() {
2362        // The image shows one random sky, the database holds a different one at the
2363        // same place: nothing may verify, however many quads happen to match.
2364        let truth = TruthWcs::new(deg(120.0), deg(-40.0), 5.0, 0.0, false, 360, 300);
2365        let s = scene(truth, Db::Areas1476, 120, 8);
2366        let decoy = TempDir::new("decoy");
2367        let mut rng = Rng::new(99);
2368        let other = random_sky(
2369            &mut rng,
2370            &SkySpec {
2371                ra0: truth.ra0,
2372                dec0: truth.dec0,
2373                side_deg: 3.0,
2374                n: 4000,
2375                min_sep_deg: 0.015,
2376                mag_lo: 10.0,
2377                mag_hi: 14.5,
2378            },
2379        );
2380        write_1476_db(decoy.path(), "t50", &other);
2381        let mut p = params_for(&s, truth.ra0, truth.dec0);
2382        p.db_path = decoy.path().to_path_buf();
2383        p.search_radius = deg(0.5);
2384        match solve_image(&s.img, &p) {
2385            Err(ArcsecError::InsufficientQuads { found: 0, required }) => {
2386                assert!(required >= 3);
2387            }
2388            other => panic!("expected InsufficientQuads, got {other:?}"),
2389        }
2390    }
2391
2392    #[test]
2393    fn a_corrupt_catalogue_tile_is_skipped_not_fatal() {
2394        let truth = TruthWcs::new(deg(120.0), deg(-40.0), 5.0, 0.0, false, 360, 300);
2395        let s = scene(truth, Db::Areas1476, 120, 9);
2396        // Declare an unsupported record size in every tile.
2397        for entry in std::fs::read_dir(s.dir.path()).unwrap() {
2398            let path = entry.unwrap().path();
2399            let mut bytes = std::fs::read(&path).unwrap();
2400            bytes[109] = 7;
2401            std::fs::write(&path, bytes).unwrap();
2402        }
2403        let mut p = params_for(&s, truth.ra0, truth.dec0);
2404        p.search_radius = 0.0;
2405        assert!(matches!(
2406            solve_image(&s.img, &p),
2407            Err(ArcsecError::InsufficientQuads { .. })
2408        ));
2409    }
2410
2411    #[test]
2412    fn a_blank_frame_reports_insufficient_stars() {
2413        let dir = TempDir::new("blank");
2414        write_1476_db(dir.path(), "t50", &[]);
2415        let mut rng = Rng::new(3);
2416        let img = ImageBuffer {
2417            data: (0..200 * 200)
2418                .map(|_| (1000.0 + 5.0 * rng.gauss()) as f32)
2419                .collect(),
2420            width: 200,
2421            height: 200,
2422        };
2423        let p = SolveParams {
2424            ra_hint: 0.0,
2425            dec_hint: 0.0,
2426            fov: deg(0.3),
2427            search_radius: deg(1.0),
2428            quad_tolerance: 0.007,
2429            hfd_min: 1.5,
2430            max_stars: 500,
2431            db_path: dir.path().to_path_buf(),
2432            db_name: "t50".into(),
2433            binning: 1,
2434            method: SolveMethod::Quads,
2435            threads: 1,
2436            speed: SearchSpeed::Auto,
2437        };
2438        match solve_image(&img, &p) {
2439            Err(ArcsecError::InsufficientStars { found, required: 5 }) => assert!(found < 5),
2440            other => panic!("expected InsufficientStars, got {other:?}"),
2441        }
2442    }
2443
2444    #[test]
2445    fn a_missing_database_is_reported_before_any_detection() {
2446        let dir = TempDir::new("nodb");
2447        let p = SolveParams {
2448            ra_hint: 0.0,
2449            dec_hint: 0.0,
2450            fov: deg(1.0),
2451            search_radius: deg(1.0),
2452            quad_tolerance: 0.007,
2453            hfd_min: 1.5,
2454            max_stars: 500,
2455            db_path: dir.path().to_path_buf(),
2456            db_name: "d50".into(),
2457            binning: 1,
2458            method: SolveMethod::Quads,
2459            threads: 1,
2460            speed: SearchSpeed::Auto,
2461        };
2462        match solve_image(&ImageBuffer::new(64, 64), &p) {
2463            Err(ArcsecError::CatalogNotFound(path)) => assert_eq!(path, dir.path()),
2464            other => panic!("expected CatalogNotFound, got {other:?}"),
2465        }
2466    }
2467
2468    #[test]
2469    fn solve_image_rejects_a_bad_search_radius_or_fov() {
2470        let base = SolveParams {
2471            ra_hint: 0.0,
2472            dec_hint: 0.0,
2473            fov: deg(1.0),
2474            search_radius: 0.1,
2475            quad_tolerance: 0.007,
2476            hfd_min: 1.5,
2477            max_stars: 500,
2478            db_path: std::path::PathBuf::from("/nonexistent"),
2479            db_name: "d50".into(),
2480            binning: 1,
2481            method: SolveMethod::Quads,
2482            threads: 1,
2483            speed: SearchSpeed::Auto,
2484        };
2485        let img = ImageBuffer::new(64, 64);
2486        for (fov, radius) in [
2487            (f64::NAN, 0.1),
2488            (-1.0, 0.1),
2489            (f64::INFINITY, 0.1),
2490            (0.01, -0.1),
2491            (0.01, f64::NAN),
2492            (0.01, f64::INFINITY),
2493        ] {
2494            let p = SolveParams {
2495                fov,
2496                search_radius: radius,
2497                ..base.clone()
2498            };
2499            assert!(
2500                matches!(solve_image(&img, &p), Err(ArcsecError::InvalidParameter(_))),
2501                "fov {fov}, radius {radius}"
2502            );
2503        }
2504    }
2505
2506    #[test]
2507    fn format_radec_roundtrip() {
2508        let s = format_radec(deg(160.875), deg(-59.524));
2509        assert!(s.contains("10:"), "RA hours: {s}");
2510        assert!(s.contains('-'), "dec sign: {s}");
2511    }
2512}