target_match/optics.rs
1//! Plate scale and field-of-view geometry.
2//!
3//! A [`Field`] is the angular extent of a frame, built from full [`Optics`], from
4//! a directly supplied pixel scale, or from a directly supplied field of view. It
5//! is binning-aware (effective pixel = pixel size × binning, per axis) and
6//! axis-independent (x and y are handled separately). A [`RadiusPolicy`] turns a
7//! field into a search radius.
8
9use skymath::Angle;
10
11use crate::error::{Error, Result};
12
13/// Exact number of arcseconds in one radian (supersedes the rounded `206.265`).
14pub const ARCSEC_PER_RADIAN: f64 = skymath::ARCSEC_PER_RADIAN;
15/// Arcseconds per degree.
16pub const ARCSEC_PER_DEGREE: f64 = 3600.0;
17/// Fallback search radius when a field of view cannot be derived (5°).
18pub const DEFAULT_FALLBACK_RADIUS: Angle = Angle::from_radians(5.0 * core::f64::consts::PI / 180.0);
19
20/// Full optical train: focal length, per-axis pixel size, per-axis binning, and
21/// sensor pixel counts.
22///
23/// Pass to [`Field::from_optics`] to derive a [`Field`].
24///
25/// # Example
26///
27/// ```
28/// use target_match::{Field, Optics};
29///
30/// let field = Field::from_optics(Optics {
31/// focal_mm: 800.0,
32/// pixel_um: (3.76, 3.76),
33/// binning: (1, 1),
34/// pixels: (6248, 4176),
35/// })
36/// .unwrap();
37/// assert!((field.width().degrees() - 1.683).abs() < 1e-2);
38/// ```
39#[derive(Debug, Clone, Copy, PartialEq)]
40#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
41pub struct Optics {
42 /// Focal length, millimetres.
43 pub focal_mm: f64,
44 /// Pixel size in micrometres, `(x, y)`.
45 pub pixel_um: (f64, f64),
46 /// Binning factor, `(x, y)` (1 = unbinned).
47 pub binning: (u32, u32),
48 /// Sensor pixel counts, `(naxis1, naxis2)`.
49 pub pixels: (u32, u32),
50}
51
52/// How a search radius is derived from a [`Field`].
53///
54/// Passed to [`Field::radius`], or embedded in a [`Constraint`](crate::Constraint)
55/// via [`Constraint::within`](crate::Constraint::within).
56///
57/// # Example
58///
59/// ```
60/// use skymath::Angle;
61/// use target_match::{Field, RadiusPolicy};
62///
63/// let field = Field::from_fov(Angle::from_degrees(2.0), Angle::from_degrees(1.0)).unwrap();
64/// let circumscribed = field.radius(RadiusPolicy::Circumscribed);
65/// let inscribed = field.radius(RadiusPolicy::Inscribed);
66/// assert!(circumscribed.degrees() > inscribed.degrees(), "circumscribed bounds the whole frame");
67/// ```
68#[derive(Debug, Clone, Copy, PartialEq)]
69#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
70pub enum RadiusPolicy {
71 /// Half the diagonal — the circle that circumscribes the whole frame (default).
72 Circumscribed,
73 /// Half the shorter side — the circle inscribed within the frame.
74 Inscribed,
75 /// A multiplier applied to the circumscribed radius.
76 Multiplier(f64),
77 /// An explicit radius, ignoring the field extent.
78 Explicit(Angle),
79}
80
81/// The angular extent of a frame.
82///
83/// Feed a `Field` to [`Constraint::within`](crate::Constraint::within) (circular
84/// membership sized by a [`RadiusPolicy`]) or
85/// [`Constraint::frame`](crate::Constraint::frame) (rectangular membership) to
86/// build a search [`Constraint`](crate::Constraint).
87///
88/// # Example
89///
90/// ```
91/// use target_match::{Field, Optics, RadiusPolicy};
92///
93/// let field = Field::from_optics(Optics {
94/// focal_mm: 800.0,
95/// pixel_um: (3.76, 3.76),
96/// binning: (1, 1),
97/// pixels: (6248, 4176),
98/// })
99/// .unwrap();
100///
101/// assert!(field.width().degrees() > 0.0);
102/// assert!(field.height().degrees() > 0.0);
103/// assert!(field.diagonal().degrees() > field.width().degrees());
104/// assert!(field.pixel_scale().is_some(), "derived from optics, so plate scale is known");
105/// let _radius = field.radius(RadiusPolicy::Circumscribed);
106/// ```
107#[derive(Debug, Clone, Copy, PartialEq)]
108#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
109pub struct Field {
110 fov: (Angle, Angle),
111 pixel_scale: Option<(f64, f64)>, // arcsec/px, per axis; None for a direct-FOV field
112}
113
114impl Field {
115 /// Derive a field from full optics.
116 ///
117 /// Effective pixel size = pixel size × binning (per axis); pixel scale
118 /// (arcsec/px) = effective pixel size (mm) / focal length (mm) × arcsec/radian;
119 /// field extent = pixel scale × pixel count.
120 ///
121 /// # Example
122 ///
123 /// ```
124 /// use target_match::{Field, Optics};
125 ///
126 /// let field = Field::from_optics(Optics {
127 /// focal_mm: 800.0,
128 /// pixel_um: (3.76, 3.76),
129 /// binning: (1, 1),
130 /// pixels: (6248, 4176),
131 /// })
132 /// .unwrap();
133 /// let (sx, _) = field.pixel_scale().unwrap();
134 /// assert!((sx - 0.969).abs() < 1e-2, "≈0.969 arcsec/px");
135 /// ```
136 ///
137 /// # Errors
138 /// [`Error::InvalidOptics`] if any input is non-positive or non-finite.
139 pub fn from_optics(o: Optics) -> Result<Self> {
140 let focal = finite_positive(o.focal_mm, "focal length")?;
141 let (px, py) = (
142 finite_positive(o.pixel_um.0, "pixel size x")?,
143 finite_positive(o.pixel_um.1, "pixel size y")?,
144 );
145 let (bx, by) = (
146 positive_count(o.binning.0, "binning x")?,
147 positive_count(o.binning.1, "binning y")?,
148 );
149 let (nx, ny) = (
150 positive_count(o.pixels.0, "naxis1")?,
151 positive_count(o.pixels.1, "naxis2")?,
152 );
153 // arcsec/px = eff_pixel_um / 1000 (→ mm) / focal_mm × arcsec/radian
154 let scale_x = (px * bx) / 1000.0 / focal * ARCSEC_PER_RADIAN;
155 let scale_y = (py * by) / 1000.0 / focal * ARCSEC_PER_RADIAN;
156 Ok(Self {
157 fov: (
158 Angle::from_arcseconds(scale_x * nx),
159 Angle::from_arcseconds(scale_y * ny),
160 ),
161 pixel_scale: Some((scale_x, scale_y)),
162 })
163 }
164
165 /// Build a field from a directly supplied pixel scale (arcsec/px, per axis)
166 /// and sensor pixel counts.
167 ///
168 /// # Example
169 ///
170 /// ```
171 /// use target_match::Field;
172 ///
173 /// let field = Field::from_pixel_scale((0.9694, 0.9694), (6248, 4176)).unwrap();
174 /// assert_eq!(field.pixel_scale(), Some((0.9694, 0.9694)));
175 /// assert!((field.width().degrees() - 1.683).abs() < 1e-2);
176 /// ```
177 ///
178 /// # Errors
179 /// [`Error::InvalidOptics`] if any input is non-positive or non-finite.
180 pub fn from_pixel_scale(scale_arcsec_px: (f64, f64), pixels: (u32, u32)) -> Result<Self> {
181 let sx = finite_positive(scale_arcsec_px.0, "pixel scale x")?;
182 let sy = finite_positive(scale_arcsec_px.1, "pixel scale y")?;
183 let nx = positive_count(pixels.0, "naxis1")?;
184 let ny = positive_count(pixels.1, "naxis2")?;
185 Ok(Self {
186 fov: (
187 Angle::from_arcseconds(sx * nx),
188 Angle::from_arcseconds(sy * ny),
189 ),
190 pixel_scale: Some((sx, sy)),
191 })
192 }
193
194 /// Build a field directly from its angular width and height (no optics; pixel
195 /// scale is unknown).
196 ///
197 /// # Example
198 ///
199 /// ```
200 /// use skymath::Angle;
201 /// use target_match::Field;
202 ///
203 /// let field = Field::from_fov(Angle::from_degrees(2.0), Angle::from_degrees(1.0)).unwrap();
204 /// assert_eq!(field.width().degrees(), 2.0);
205 /// assert!(field.pixel_scale().is_none(), "no optics, so no plate scale");
206 /// ```
207 ///
208 /// # Errors
209 /// [`Error::InvalidOptics`] if width or height is non-positive or non-finite.
210 pub fn from_fov(width: Angle, height: Angle) -> Result<Self> {
211 finite_positive(width.degrees(), "field width")?;
212 finite_positive(height.degrees(), "field height")?;
213 Ok(Self {
214 fov: (width, height),
215 pixel_scale: None,
216 })
217 }
218
219 /// Field width (x extent).
220 #[must_use]
221 pub fn width(self) -> Angle {
222 self.fov.0
223 }
224 /// Field height (y extent).
225 #[must_use]
226 pub fn height(self) -> Angle {
227 self.fov.1
228 }
229 /// Diagonal field of view.
230 #[must_use]
231 pub fn diagonal(self) -> Angle {
232 Angle::from_degrees(self.fov.0.degrees().hypot(self.fov.1.degrees()))
233 }
234 /// Per-axis pixel scale (arcsec/px), if this field was built with a scale.
235 #[must_use]
236 pub fn pixel_scale(self) -> Option<(f64, f64)> {
237 self.pixel_scale
238 }
239
240 /// Compute a search radius from this field under `policy`.
241 #[must_use]
242 pub fn radius(self, policy: RadiusPolicy) -> Angle {
243 match policy {
244 RadiusPolicy::Circumscribed => Angle::from_degrees(self.diagonal().degrees() / 2.0),
245 RadiusPolicy::Inscribed => {
246 Angle::from_degrees(self.fov.0.degrees().min(self.fov.1.degrees()) / 2.0)
247 }
248 RadiusPolicy::Multiplier(m) => Angle::from_degrees(self.diagonal().degrees() / 2.0 * m),
249 RadiusPolicy::Explicit(a) => a,
250 }
251 }
252}
253
254fn finite_positive(v: f64, what: &str) -> Result<f64> {
255 if v.is_finite() && v > 0.0 {
256 Ok(v)
257 } else {
258 Err(Error::InvalidOptics(format!(
259 "{what} must be finite and > 0, got {v}"
260 )))
261 }
262}
263
264fn positive_count(v: u32, what: &str) -> Result<f64> {
265 if v >= 1 {
266 Ok(f64::from(v))
267 } else {
268 Err(Error::InvalidOptics(format!("{what} must be >= 1")))
269 }
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275
276 fn approx(a: f64, b: f64, eps: f64) -> bool {
277 (a - b).abs() < eps
278 }
279
280 fn asi2600_800mm() -> Optics {
281 Optics {
282 focal_mm: 800.0,
283 pixel_um: (3.76, 3.76),
284 binning: (1, 1),
285 pixels: (6248, 4176),
286 }
287 }
288
289 #[test]
290 fn from_optics_matches_hand_calc() {
291 let f = Field::from_optics(asi2600_800mm()).unwrap();
292 let (sx, sy) = f.pixel_scale().unwrap();
293 assert!(approx(sx, 0.9694, 1e-3), "scale {sx}");
294 assert!(approx(sy, 0.9694, 1e-3));
295 assert!(
296 approx(f.width().degrees(), 1.683, 5e-3),
297 "w {}",
298 f.width().degrees()
299 );
300 assert!(
301 approx(f.height().degrees(), 1.125, 5e-3),
302 "h {}",
303 f.height().degrees()
304 );
305 // radius = circumscribed = half diagonal ≈ 1.012°
306 assert!(approx(
307 f.radius(RadiusPolicy::Circumscribed).degrees(),
308 1.012,
309 5e-3
310 ));
311 }
312
313 #[test]
314 fn binning_doubles_scale_and_fov() {
315 let mut o = asi2600_800mm();
316 o.binning = (2, 2);
317 let f = Field::from_optics(o).unwrap();
318 let (sx, _) = f.pixel_scale().unwrap();
319 assert!(approx(sx, 2.0 * 0.9694, 2e-3), "binned scale {sx}");
320 // Holding the (binned) pixel count, ×2 binning ⇒ ×2 scale ⇒ ×2 field (SC-009).
321 assert!(
322 approx(f.width().degrees(), 2.0 * 1.683, 1e-2),
323 "w {}",
324 f.width().degrees()
325 );
326 }
327
328 #[test]
329 fn binning_fov_doubles() {
330 let base = Field::from_optics(asi2600_800mm()).unwrap();
331 let mut o = asi2600_800mm();
332 o.binning = (2, 2);
333 let binned = Field::from_optics(o).unwrap();
334 assert!(approx(
335 binned.width().degrees(),
336 2.0 * base.width().degrees(),
337 1e-6
338 ));
339 }
340
341 #[test]
342 fn from_fov_and_pixel_scale_paths() {
343 let direct =
344 Field::from_fov(Angle::from_degrees(1.683), Angle::from_degrees(1.125)).unwrap();
345 assert!(direct.pixel_scale().is_none());
346 assert!(approx(
347 direct.radius(RadiusPolicy::Circumscribed).degrees(),
348 1.012,
349 5e-3
350 ));
351
352 let by_scale = Field::from_pixel_scale((0.9694, 0.9694), (6248, 4176)).unwrap();
353 assert!(approx(by_scale.width().degrees(), 1.683, 5e-3));
354 assert_eq!(by_scale.pixel_scale(), Some((0.9694, 0.9694)));
355 }
356
357 #[test]
358 fn radius_policies() {
359 let f = Field::from_fov(Angle::from_degrees(2.0), Angle::from_degrees(1.0)).unwrap();
360 assert!(approx(
361 f.radius(RadiusPolicy::Inscribed).degrees(),
362 0.5,
363 1e-9
364 )); // half min side
365 let circ = f.radius(RadiusPolicy::Circumscribed).degrees();
366 assert!(approx(circ, (2.0_f64.hypot(1.0)) / 2.0, 1e-9));
367 assert!(approx(
368 f.radius(RadiusPolicy::Multiplier(2.0)).degrees(),
369 circ * 2.0,
370 1e-9
371 ));
372 assert!(approx(
373 f.radius(RadiusPolicy::Explicit(Angle::from_degrees(3.0)))
374 .degrees(),
375 3.0,
376 1e-9
377 ));
378 }
379
380 #[test]
381 fn rejects_bad_optics() {
382 let mut o = asi2600_800mm();
383 o.focal_mm = 0.0;
384 assert!(matches!(
385 Field::from_optics(o).unwrap_err(),
386 Error::InvalidOptics(_)
387 ));
388 let mut o2 = asi2600_800mm();
389 o2.pixel_um = (-1.0, 3.76);
390 assert!(matches!(
391 Field::from_optics(o2).unwrap_err(),
392 Error::InvalidOptics(_)
393 ));
394 let mut o3 = asi2600_800mm();
395 o3.pixels = (0, 4176);
396 assert!(matches!(
397 Field::from_optics(o3).unwrap_err(),
398 Error::InvalidOptics(_)
399 ));
400 assert!(matches!(
401 Field::from_fov(Angle::from_degrees(0.0), Angle::from_degrees(1.0)).unwrap_err(),
402 Error::InvalidOptics(_)
403 ));
404 }
405
406 #[test]
407 fn fallback_radius_is_five_degrees() {
408 assert!(approx(DEFAULT_FALLBACK_RADIUS.degrees(), 5.0, 1e-9));
409 }
410}