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