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