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