Finds the celestial coordinates of every pixel in an image — given a photo of the sky, it works out where the telescope was pointing, at what rotation and scale, and writes a full WCS (World Coordinate System) solution into the image's FITS header. Three solver backends run as a fallback chain: SASpro's In-House Gaia DR3 solver first, then ASTAP (seeded, then blind), and finally Astrometry.net (local solve-field or web API). Three operating modes: Active View, single File, and Batch directory processing.
The in-house Gaia DR3 solver's core idea: find rotation-, scale-, and parity-invariant triangle shapes shared between detected image stars and Gaia catalog stars projected through a seed WCS. Each triangle match is encoded as an invariant pair of side ratios:
These two numbers survive any similarity transform including reflections, so a wrong CROTA2 sign, a 90° rotator offset, or a mirrored optical train doesn't break them — vertex votes aggregate across matched triangles and RANSAC extracts the consensus transform. §14.7 derives the full procedure.
The dialog has four blocks top-to-bottom: the Mode selector (Active View / File / Batch), a shared Seeding & Constraints group that applies to every mode, the mode-specific controls page (file picker, batch I/O, etc.), and a status strip plus the Apply to View Bundle… / Start / Close buttons. A draggable preset handle in the bottom-left turns your current settings into a reusable shortcut (§12).
Solves the image in the currently-focused SASpro document window in-place. The WCS solution is merged into the document's existing header (acquisition metadata preserved; see §13 for the merge policy), and the header dock refreshes to show the new coordinates. This is the usual mode when you've just loaded a sub, want to solve it, and keep working with it.
Loads a single image file from disk (FITS, XISF, TIFF, PNG, JPEG), solves it, and prompts for a save-as path. Output is always a 32-bit float FITS with the merged header. Useful for solving images that aren't currently open in SASpro.
Loops over every image in an input folder, solves each, and saves <name>_plate_solved.fits into an output folder. Accepted extensions: .fits, .fit, .xisf, .tif, .tiff, .png, .jpg, .jpeg. Progress is logged in a scrolling text area; each file shows a ▶ when starting, a ✔ with the saved path on success, or a ❌ with the error on failure.
Every solver works better when it starts with a rough idea of where the telescope was pointed. The Seed mode dropdown picks where that initial guess comes from:
In Manual seed mode three fields are enabled:
HH:MM:SS[.s], decimal hours (0–24), or decimal degrees (0–360).±DD:MM:SS[.s] or decimal degrees (−90 to +90).Clicking Lookup… opens a dialog where you type an object name. The lookup searches two sources:
Name normalisation handles common inconsistencies — both M42 and M 42 work, both Sh2-155 and Sh2 155 work, both PK 164+31.1 and PN-G 164.8+31.1 work (PK designations auto-translate to PN-G form before searching). If multiple matches turn up, a picker lets you choose. The selected object's RA, Dec and alt-name (or type/magnitude if available) populate the RA/Dec fields and the seed mode auto-switches to Manual.
These two options only affect ASTAP; the in-house Gaia DR3 solver uses its own spiral search (§14.14) and Astrometry.net has its own interface. If you aren't running ASTAP in your fallback chain, you can leave both on Auto.
Which solvers run and in what order:
| Preference | What runs |
|---|---|
| Gaia DR3 → ASTAP → Astrometry.net (all) | The default four-pass fallback chain (see §7). Fastest overall for well-seeded data; most robust for difficult fields. |
| In-House Gaia DR3 only | Try the Gaia solver and stop. Requires a seed. Pick this when ASTAP catalogs aren't installed or you want the fastest in-house solve on good seeds. |
| ASTAP only | Try ASTAP seeded, then ASTAP blind. Classic workflow for people with ASTAP catalogs installed and no Gaia library. |
| Astrometry.net only | Try local solve-field if configured, else the web API. Slowest option; most useful when nothing else is available or for cross-checking a questionable solution. |
With Gaia DR3 → ASTAP → Astrometry.net (all) selected, SASpro tries up to four passes in sequence and stops at the first that succeeds:
-ra, -spd, -scale, -r and computed -fov arguments. Very fast when seed is accurate.-r 179 -fov 0 -z 0, no coordinates. Slower but can solve from nothing.solve-field if the path is configured, otherwise uploads to nova.astrometry.net via API. The slowest option; a lifeboat for fields the other solvers can't crack.Each pass's status is reported live in the dialog's status strip and (for headless scripted runs) in a floating status popup. The log records which pass actually succeeded.
When you click Start with Mode = Active View, SASpro grabs the current document's image array and its metadata, derives a seed header, runs the fallback chain, and merges the resulting WCS into the document's header. The merge preserves everything in your acquisition header (OBJECT, EXPTIME, GAIN, FILTER, DATE-OBS, OBSERVER, telescope details, etc.) and only overwrites the WCS/SIP cards — so you don't lose metadata by solving.
The document emits its changed signal and the header dock refreshes automatically to show the newly-written CTYPE1/CTYPE2, CRPIX1/CRPIX2, CRVAL1/CRVAL2, CD1_1…CD2_2, and (if a Gaia DR3 solve succeeded) SOLVRMS / SOLVP95 / SOLVNSTR cards reporting fit quality.
On success, a non-modal Plate Solve Successful popup shows:
Click Browse… to pick a file, then Start. SASpro loads the image via its shared image-I/O layer (legacy.load_image), extracts whatever header exists as the seed, runs the fallback chain, merges the WCS, and asks where to save a plate-solved FITS copy. The original file is never modified. On successful save, the solve-summary popup appears as in Active View mode.
Set Input directory and Output directory (both must exist) and click Start. SASpro iterates alphabetically through every image file in the input folder, applies the full fallback chain to each, and saves <basename>_plate_solved.fits into the output folder. The log pane shows progress per file:
▶ /path/to/image.fits — starting this file. ✔ saved: /output/image_plate_solved.fits — success. ❌ <error> — failure (continues with the next file).The Qt event loop is pumped between files so the dialog stays responsive and you can watch progress live. Batch doesn't currently support mid-run cancel — close the dialog to stop between files.
Click Apply to View Bundle… to solve every target in a saved SASpro View Bundle at once — both open sub-windows and on-disk files referenced by the bundle. A picker shows the bundles you've saved; selecting one and clicking OK runs the fallback chain on each target in turn, showing per-target status in the status strip and summarising any errors at the end. Open views get the WCS merged in-memory; file references get resaved to disk through save_image with their original format and bit depth preserved. Useful for solving a whole night's calibrated subs in one click.
Every control in the dialog (seed mode, manual RA/Dec/scale, search radius, FOV, solver preference) can be captured as a preset and dropped on the SASpro canvas as a reusable shortcut.
Grab the drag handle in the bottom-left of the dialog and drag it to the canvas — a shortcut icon appears, pre-loaded with your current settings. Dropping an image on the shortcut (or dropping the shortcut on a View Bundle chip) runs apply_plate_solve_to_doc() headlessly: no dialog, no progress bar, just the fallback chain running on the dropped target with the preset's settings.
Presets store nine fields: seed mode, manual RA, manual Dec, manual scale, search-radius mode, radius value, FOV mode, FOV value, and solver preference. The normalize_plate_solve_preset() helper validates and defaults each field so stale or partial preset dicts never crash the dialog.
Every success path produces one artefact: a FITS header containing both the original acquisition metadata and the solved WCS. The merge policy, implemented in _merge_wcs_into_base_header():
RA---TAN/DEC--TAN to RA---TAN-SIP/DEC--TAN-SIP.meta['pre_solve_header'] on the first solve of a document, so re-solving never loses the original acquisition cards even after repeated solves.The merged header is also parsed into an astropy.wcs.WCS object stored at meta['wcs'], and a WCS-only copy (just the celestial cards, no acquisition metadata) is stored at meta['wcs_header']. Downstream SASpro tools (SFCC, SSSC, catalogue overlays, mosaic alignment) read from these canonical fields.
The seeding, matching and WCS-fitting pipeline, in order.
Both ASTAP's -scale argument and the in-house Gaia solver's seed WCS need an initial pixel scale. SASpro tries five sources in priority order:
astropy.wcs.WCS and take proj_plane_pixel_scales, which handles CD, PC+CDELT, SIP all together. This is the preferred path.CD1_1 and CD2_1 (degrees/pixel):
sqrt(|det(CD)|) × 3600.PIXSCALE, SECPIX, SECPIX1, SECPIX2, SCALE, PLATESCL, PLTSCALE (any of these, if present, is already in arcsec/px).b is the mean binning factor. If both XPIXSZ and YPIXSZ are present, their mean is used.Every candidate is sanity-checked: values below 0.01 that look like they're in degrees get a ×3600 rescue; values above 1000"/px are rejected as implausible. Values between 0.01 and 1000 are accepted.
Covered in §13 above. The implementation lives in _merge_wcs_into_base_header(). Key invariants:
FILE_PATH, FITS_HEADER, BIT_DEPTH, WCS_HEADER, __HEADER_SNAPSHOT__, ORIGINAL_HEADER, PRE_SOLVE_HEADER) are stripped from any header before it's handed to FITS/WCS code — those names can hold Python objects or very long strings that trigger HIERARCH warnings or WCS failures.A_ORDER='2', CRPIX1='1024.0') can't trip astropy's string-vs-int comparisons.A_ORDER/B_ORDER, AP_ORDER/BP_ORDER) is auto-filled if only one of a pair is present.Given RA, Dec and pixel scale, the Gaia solver builds a TAN projection seed WCS centred at the given coordinates with CRPIX at image centre. Rotation is pulled from the first non-null of ANGLE, POSANGLE, CROTA2, CROTA1, OBJCTROT, ROTANG, ROT_ANGL, CAMROTAN. Parity (determinant sign) is tried both ways in the spiral search (§14.14).
The CD matrix for a rotated TAN projection with scale s and rotation θ:
for the det>0 branch, and the flipped parity equivalent for det<0. §14.14 explains why both are tried.
SASpro ships with two Gaia DR3 library formats, pulled from different ESA data products:
Install either (or both) via Settings → Gaia DR3 Library. If neither is installed, the Gaia solver refuses with a clear error message pointing to the installer.
The in-house Gaia solver detects stars in the input image via SEP (Barbary 2016), the Python port of Source Extractor. SEP is called through SASpro's shared _detect_stars_uniform helper, which does grid-balanced extraction so no cell of the image dominates the star list:
σ = 50, 25, 15, 10, 5, 3 in sequence. The first sigma that returns at least 1000 stars wins; if 3σ still gives fewer, whatever it found is used.2000/9 ≈ 222 brightest-first stars are kept per cell, preventing the brightest region of the image from monopolising the match._normalize_for_astap (which applies the shared mono/colour stretch for faint-star visibility), then collapse to float32 greyscale.For each seed candidate (centre, scale, parity — see §14.14), SASpro projects the seed WCS to find the four image corners' sky coordinates, pads the resulting RA/Dec bounding box by 0.1°, and queries the Gaia library for all catalog stars in that box.
Candidates are then de-duplicated by Gaia source_id, sorted by G-band magnitude (brightest first), projected to pixel coordinates through the seed WCS, filtered to a 50-pixel margin inside the image bounds, and grid-balanced the same way as the image stars (3×3 grid, up to 1000/9 per cell). Match-time runs with a dense bright-first catalog subset rather than the raw box query.
The core idea of the in-house solver, based on Groth (1986)'s classical star-pattern triangle method and refined following the astroalign (Beroiz, Cabral & Sanchez 2020) implementation style.
For every triple of stars (a, b, c), measure the three side lengths s_a, s_b, s_c (where side i is opposite vertex i), sort them:
and encode the triangle's shape as the two side ratios:
Why these two numbers: the triangle's absolute size depends on scale, its orientation depends on rotation, its handedness depends on parity — but the ratios of its sides depend on none of those. Any similarity transform (rotation + uniform scale + reflection) maps a triangle to a congruent triangle with the same two ratios. So if the same three stars appear in both the image and the catalog projection, their invariant pair is identical regardless of the seed WCS being rotated or mirrored or wrongly scaled.
Two filters: reject triangles with shortest side under 8 px (centroid noise dominates), and reject triangles whose longest/shortest ratio exceeds 8 (needle-thin triangles have poor invariant stability).
Both image-star and catalog-star triangle invariants are loaded into a cKDTree and image invariants are queried against the catalog tree with distance upper bound 0.05 (adimensional, since the invariants are ratios). Each matched image-triangle↔catalog-triangle pair implies three vertex correspondences.
For every matched triangle pair (T_s, T_r), cast a vote on each of the three vertex correspondences (V_s[a], V_r[a]) for a = 0, 1, 2. True correspondences accumulate votes across many triangles; coincidental matches don't. Correspondences with at least 2 votes are kept, in descending vote order, with duplicate assignments blocked (each image vertex and each catalog vertex can match only once).
From the voted correspondences, fit a 6-parameter affine transform via RANSAC:
300 iterations, 3-point minimal samples, 10-pixel inlier tolerance. The best inlier set must contain at least max(6, 15% of candidates) or the solve attempt is abandoned. The full-affine form (as opposed to similarity) is deliberate: it handles reflection (det(M) < 0) correctly, which matters because the Gaia solver tries both parities and lets the matcher decide which is right.
With the final (M_x, M_y) fit from the inlier consensus, transform every image star and query the full catalog KDTree at distance 0. An iterative tolerance tighten runs 3 cycles: refit affine on current inliers, re-query, shrink the tolerance toward 4× the observed median residual (bounded below by 2 px). This stops dense-field false nearest-neighbours from inflating the final RMS past the quality gate.
If triangle matching returns too few pairs or RANSAC fails, SASpro falls back to the Hough translation vote — a Generalized Hough Transform (Hough 1962, Ballard 1981) restricted to pure 2D translation. This is the method used by Tabur (2007) in his fast catalog-matching routine for CCD astrometry.
For every image star s_i and every catalog star c_j, compute the implied translation (c_j - s_i) and vote in a 2D accumulator binned at 2× the match tolerance. The peak bin reveals the dominant translation offset:
Translations outside half the image size are rejected (no sense voting for a shift that would place every star off-screen).
The vote is deliberately imperfect: when the seed WCS has residual rotation or scale error on top of the dominant translation, correspondences near the rotation centre vote in the peak bin but correspondences far from the centre drift into neighbouring bins. SASpro merges the 3×3 neighbourhood around the peak into one candidate pool, which gives the next step (similarity RANSAC) a noisier but broader set to work with.
Fit a 4-parameter similarity transform (rotation, uniform scale, translation) over the pool:
with 200 minimal 2-point samples and a tolerance of 2× the match tolerance. The recovered scale is sqrt(a² + b²) and rotation is atan2(b, a), which are logged for diagnostic purposes.
With the similarity-corrected positions, nearest-neighbour match against the catalog KDTree at 1.5 × match_tol_px, then do one final 6-parameter affine fit for inlier rejection. Returns the matched pair list the same way triangle matching does.
Both matchers use the classical RANSAC algorithm (Fischler & Bolles 1981):
Over N trials, sample the minimum number of correspondences needed to fit the model (3 for affine, 2 for similarity), fit, count inliers within tolerance τ, keep the fit with the most inliers. Refit on the best inlier set for the final transform. RANSAC avoids being dragged off by false correspondences the way a plain least-squares fit over the whole noisy pool would be.
Once matched pairs are in hand, SASpro uses astropy.wcs.utils.fit_wcs_from_points to fit a TAN (gnomonic) projection WCS through them. The projection point is set to the mean of the matched sky coordinates; the CD matrix emerges from the least-squares fit.
The gnomonic / TAN projection is the FITS WCS standard for most astronomical imaging and follows Greisen & Calabretta (2002). The forward (pixel → sky) transform in the simplest case:
centred on the reference point (CRVAL1, CRVAL2).
The initial TAN fit uses only the matched set from the triangle/Hough matcher. SASpro then iteratively refines it by re-matching against the catalog through the fitted WCS:
0.02 pixels equivalent arcseconds, iterate again; otherwise stop. Capped at 5 iterations.Each iteration either tightens the fit by picking up more real correspondences from the catalog's dense inner region, or exposes the current fit as being as good as it's going to get. The “0.02 px” convergence threshold is pixel-relative so it works equally on 0.3"/px long-focal-length rigs and 7"/px wide-field rigs.
Linear TAN cannot absorb the geometric distortion of wide-field lenses or fast astrographs. SASpro fits a SIP distortion polynomial on top of the TAN fit, following Shupe et al. (2005), with the polynomial order chosen by the number of matched pairs:
SIP adds correction polynomials to the pixel-space side of the transform:
with (u, v) the pixel offset from CRPIX, and A_{p,q}, B_{p,q} fitted coefficients up to order n. CTYPE1/CTYPE2 become RA---TAN-SIP/DEC--TAN-SIP when SIP is present.
SASpro fits SIP before the quality gate (§14.13), so a distorted wide-field frame that would fail the gate on linear TAN can still pass once SIP absorbs the distortion. The RMS used by the gate is the SIP RMS when SIP improves it, else TAN.
Before accepting a solve, SASpro checks fit quality. The RMS of final residuals (pair-wise angular separations):
and the 95th-percentile residual:
are computed where Δθ_i is the great-circle angular separation between the ith matched star's catalog position and the position the fitted WCS puts it at.
The gate is pixel-relative, not raw arcseconds, because a flat arcsecond threshold is wrong on both ends of the pixel-scale spectrum:
with k_px = 2.0 (two pixels equivalent) and floor_arcsec = 3.0". A fine-scale 0.5"/px rig gets a 3.0" limit (strict as it should be); a coarse 7"/px rig gets a 14" limit (sensible given the sampling). A flat 3.0" limit would wrongly fail a sub-pixel solve on the coarse rig.
Minimum pair count: 20. If the fit RMS exceeds RMS_max, or fewer than 20 pairs matched, the gate rejects and SASpro either retries the field with a different matcher (triangle ↔ Hough), excludes the previously-matched catalog stars and tries again, or falls back to the next solver in the chain.
Even when the acquisition header has RA/Dec, those values can be off by many arcminutes due to mount pointing error. SASpro's Gaia solver covers this by searching a spiral grid of seed centres around the nominal RA/Dec:
cos(Dec) correction on the RA offset.Expressed in sky coordinates with ring radius r and azimuth φ:
For each centre, SASpro also tries five scale variants (×1.00, ×0.95, ×1.05, ×0.90, ×1.10) and two parity variants (det>0 and det<0 CD matrices). The parity sweep is critical because camera flip is one of the most common reasons for a solve to silently fail — and nothing in the acquisition header normally tells SASpro whether the frame is flipped.
The search stops at the first (centre, scale, parity) combination that produces a matched pair set passing the quality gate. In practice the nominal centre + nominal scale + det<0 is right the vast majority of the time, but the spiral cover rescues the awkward cases without the user having to intervene.
Once a solve succeeds and the plate scale is known accurately, SASpro back-computes the true effective focal length from the physical pixel size and binning:
and overwrites the FOCALLEN card. This matters because the user-entered FOCALLEN often disagrees with reality (incorrect for the specific flattener/reducer in use, wrong for the actual spacing, or simply never updated after a scope swap). A HISTORY card logs the before/after values when they differ by at least 0.5 mm, so the original is still auditable. Sanity bounds: the recomputed value must fall in [1, 50000] mm to be accepted.
Passes 2 and 3 of the fallback chain run ASTAP, Han Kleijn's standalone plate-solving program. SASpro drives it as an external process via QProcess, writing a temporary single-channel 32-bit float FITS of the current image (passed through SASpro's star-visibility stretch so dim stars show) and reading back the sidecar .wcs file ASTAP writes next to the input.
Seeded invocation arguments (Pass 2):
where SPD = Dec + 90°, the radius comes from the Search Radius control (§5), and the FOV comes from the FOV control. Blind invocation (Pass 3) drops the seed arguments and uses -r 179 -fov 0 -z 0.
ASTAP's documented exit codes are decoded and reported in the log:
| Code | Meaning |
|---|---|
| 0 | Solution found. |
| 1 | No solution found within the search area/scale. |
| 2 | Not enough stars detected in the image. |
| 16 | Error reading the image file. |
| 17 | Error reading the star reference database (catalogs missing/misconfigured). |
| 32 | Not enough memory. |
| 33 | Error writing output files (temp directory permissions). |
Even on exit code 0, SASpro verifies the .wcs sidecar was actually written and contains CRVAL1/CTYPE1 — ASTAP occasionally exits normally without producing a solution, and treating that as success would propagate a broken WCS to downstream tools.
Pass 4 of the fallback chain runs Astrometry.net (Lang et al. 2010), either locally via solve-field or over the web API at nova.astrometry.net.
If the Astrometry.net path is set in Preferences (paths/solve_field or astrometry/solvefield_path), SASpro runs solve-field as an external process with --overwrite --no-remove-lines --cpulimit 300 --downsample 2 --write-wcs wcs. On success, SASpro reads the .wcs sidecar or the .new FITS and extracts the WCS header for merging.
If local solve-field isn't configured, SASpro falls back to the web API — the API key is read from Preferences (api/astrometry_key, with legacy keys auto-migrated). The sequence:
api/login with the API key, receive a session token.api/upload, receive a submission ID.api/submissions/<subid> every second until the job ID appears (max 15 minutes).api/jobs/<jobid>/calibration/ every 1.5 seconds until the solution dict appears with ra, dec, pixscale, orientation, parity (max 15 minutes).wcs_file/<jobid> (includes SIP terms when the solve produced them). Parse into a header.Every request has retry-with-exponential-backoff on 429, 500, 502, 503, 504 and connection/timeout errors (up to 5 retries, 0.5·2^(n-1) + jitter seconds between). File handles are rewound before each retry since requests consumes them on each POST attempt.
If the WCS FITS download fails, SASpro builds a plain TAN header from the calibration dict instead:
and symmetric entries for CD1_2, CD2_1, with s the pixel scale in degrees, θ the orientation (positive CCW from north), and p the parity sign (parity > 0 means the image was mirrored, so RA axis is negated to un-mirror). Downside: no SIP, so the fit is linear only.
.wcs sidecar); Astrometry.net is Lang et al. (2010, AJ 139:1782) with the web API at nova.astrometry.net and local solve-field both supported. Gaia DR3 catalog data is Gaia Collaboration et al. (2023, A&A 674:A1), delivered through SASpro's local astrometric-tier library (preferred) or the XP-spectral library (fallback, shared with SSSC). SEP (Barbary 2016, JOSS 1:58) provides star detection, deriving from Source Extractor (Bertin & Arnouts 1996, A&AS 117:393). Astropy provides FITS I/O, the WCS class, fit_wcs_from_points, proj_plane_pixel_scales, and SkyCoord. SciPy provides cKDTree / KDTree for nearest-neighbour matching and linalg.lstsq for affine fits. NumPy provides the array engine, PyQt6 provides the UI framework (including QProcess for driving external solvers), and requests provides HTTP transport for the Astrometry.net API.