From 5cccbbc0564e790ff86c04e767915ad2faf97a04 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Thu, 30 Oct 2025 12:12:41 -0600 Subject: [PATCH] Added `furst_optics.spectrographs.Spectrograph` class. --- furst_optics/__init__.py | 2 + furst_optics/sources/_sources.py | 2 +- furst_optics/spectrographs/__init__.py | 9 + furst_optics/spectrographs/_design.py | 68 ++++++ furst_optics/spectrographs/_spectrographs.py | 206 +++++++++++++++++++ 5 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 furst_optics/spectrographs/__init__.py create mode 100644 furst_optics/spectrographs/_design.py create mode 100644 furst_optics/spectrographs/_spectrographs.py diff --git a/furst_optics/__init__.py b/furst_optics/__init__.py index adb9ab5..9517d04 100644 --- a/furst_optics/__init__.py +++ b/furst_optics/__init__.py @@ -9,6 +9,7 @@ from . import feed_optics from . import gratings from . import detectors +from . import spectrographs __all__ = [ "typevars", @@ -18,4 +19,5 @@ "feed_optics", "gratings", "detectors", + "spectrographs", ] diff --git a/furst_optics/sources/_sources.py b/furst_optics/sources/_sources.py index 73e742e..df3c4d1 100644 --- a/furst_optics/sources/_sources.py +++ b/furst_optics/sources/_sources.py @@ -50,7 +50,7 @@ def surface(self) -> optika.surfaces.Surface: return optika.surfaces.Surface( name="solar disk", aperture=optika.apertures.CircularAperture( - radius=np.cos(self.radius), + radius=np.sin(self.radius), ), is_field_stop=True, transformation=self.transformation, diff --git a/furst_optics/spectrographs/__init__.py b/furst_optics/spectrographs/__init__.py new file mode 100644 index 0000000..d37495c --- /dev/null +++ b/furst_optics/spectrographs/__init__.py @@ -0,0 +1,9 @@ +""" +End-to-end models of the FURST optical system. +""" + +from ._spectrographs import Spectrograph + +__all__ = [ + "Spectrograph", +] diff --git a/furst_optics/spectrographs/_design.py b/furst_optics/spectrographs/_design.py new file mode 100644 index 0000000..e1a49fc --- /dev/null +++ b/furst_optics/spectrographs/_design.py @@ -0,0 +1,68 @@ +import astropy.units as u +import named_arrays as na +import optika +import furst_optics + +def design(): + + rowland_radius = 1 * u.m + + result = furst_optics.spectrographs.Spectrograph( + name="furst", + source=furst_optics.sources.SolarDisk(), + front_aperture=furst_optics.apertures.FrontAperture(), + feed_optic=furst_optics.feed_optics.FeedOptic( + radius=3 * u.mm, + aperture_subtent=1 * u.deg, + aperture_height=10 * u.mm, + material=optika.materials.Mirror(), + rowland_radius=rowland_radius, + # rowland_azimuth=na.linspace(10, 15, axis="angle", num=2) * u.deg, + rowland_azimuth=10 * u.deg, + ), + grating=furst_optics.gratings.Grating( + sag=optika.sags.SphericalSag( + radius=-2 * rowland_radius, + ), + width_clear=100 * u.mm, + material=optika.materials.Mirror(), + rulings=optika.rulings.Rulings( + spacing=1 * u.um, + diffraction_order=1, + ), + rowland_radius=rowland_radius, + rowland_azimuth=175 * u.deg, + ), + detector=furst_optics.detectors.Detector( + width_pixel=15 * u.um, + axis_pixel=na.Cartesian2dVectorArray( + x="detector_x", + y="detector_y", + ), + num_pixel=na.Cartesian2dVectorArray( + x=2048, + y=1024, + ), + rowland_radius=rowland_radius, + rowland_azimuth=5 * u.deg, + ), + grid_input=optika.vectors.ObjectVectorArray( + wavelength=na.linspace(-1, 1, axis="wavelength", num=1, centers=True), + field=na.Cartesian2dVectorLinearSpace( + start=-1, + stop=1, + axis=na.Cartesian2dVectorArray("fx", "fy"), + num=1, + centers=True, + ), + pupil=na.Cartesian2dVectorLinearSpace( + start=-1, + stop=1, + axis=na.Cartesian2dVectorArray("px", "py"), + num=1, + centers=True, + ) + ) + ) + + return result diff --git a/furst_optics/spectrographs/_spectrographs.py b/furst_optics/spectrographs/_spectrographs.py new file mode 100644 index 0000000..9adc18f --- /dev/null +++ b/furst_optics/spectrographs/_spectrographs.py @@ -0,0 +1,206 @@ +import dataclasses +import numpy as np +import astropy.units as u +import named_arrays as na +import optika +import furst_optics + +__all__ = [ + "Spectrograph", +] + + +@dataclasses.dataclass(eq=False, repr=False) +class Spectrograph( + optika.mixins.Printable, + optika.mixins.Rollable, + optika.mixins.Yawable, + optika.mixins.Pitchable, +): + """ + A generic model of the FURST instrument, + which includes the feed optics, grating, visible-blind filter, + and detector. + """ + + name: str + """ + The human-readable name of this optics model. + """ + + source: furst_optics.sources.AbstractSource + """ + The light source being observed by this instrument. + """ + + front_aperture: furst_optics.apertures.FrontAperture + """ + A model of the front aperture plate of the instrument. + """ + + feed_optic: furst_optics.feed_optics.FeedOptic + """ + A model of the feed optic array. + """ + + grating: furst_optics.gratings.Grating + """ + A model of the diffraction grating used to disperse light. + """ + + detector: furst_optics.detectors.Detector + """ + A model of the imaging sensor used to measure light + at the end of the optical system. + """ + + grid_input: optika.vectors.ObjectVectorArray + """ + A grid of samples in wavelength, pupil, and field position used + to trace rays through the optical system. + """ + + pitch: u.Quantity | na.AbstractScalar = 0 * u.deg + """ + Rotation about the vector perpendicular to the optic axis and the + vector normal to the optical table. + """ + + yaw: u.Quantity | na.AbstractScalar = 0 * u.deg + """ + Rotation of the instrument about the vector normal to + the FURST optical table. + """ + + roll: u.Quantity | na.AbstractScalar = 0 * u.deg + """ + Rotation of the instrument about the optic axis. + """ + + def angle_grating_input(self) -> na.AbstractScalar: + """ + The angle between the grating normal and the vector + pointing from the center of the grating + to the virtual image of the source inside the feed optic. + """ + feed_optic = self.feed_optic + grating = self.grating + position = na.Cartesian3dVectorArray() * u.mm + normal_surface = grating.sag.normal(position) + normal_rulings = grating.rulings.spacing_(position).normalized + transformation_grating = grating.transformation.inverse + transformation_feed_optic = feed_optic.transformation_image + transformation = transformation_grating @ transformation_feed_optic + x = na.Cartesian3dVectorArray() * u.mm + x = transformation(x) + return np.arctan2( + x @ normal_rulings, + x @ normal_surface, + ) + + def angle_grating_output(self, axis: str): + """ + The angles between the grating normal and the vectors + pointing from the center of the grating + to the edges of the detector. + + Parameters + ---------- + axis + An axis name for the array of points + around the edges of the detector. + """ + detector = self.detector.surface + grating = self.grating.surface + position = na.Cartesian3dVectorArray() * u.mm + normal_surface = grating.sag.normal(position) + normal_rulings = grating.rulings.spacing_(position).normalized + transformation = grating.transformation.inverse @ detector.transformation + wire = np.moveaxis( + a=detector.aperture.wire(), + source="wire", + destination=axis, + ) + wire = transformation(wire) + return np.arctan2( + wire @ normal_rulings, + wire @ normal_surface, + ) + + def _wavelength_test_grid( + self, + axis_output: str, + ) -> na.AbstractScalar: + """ + A grid of test wavelengths used to find the wavelength + range for each channel of FURST. + + Parameters + ---------- + axis_output + An axis name for the array of points + around the edges of the detector. + """ + position = na.Cartesian3dVectorArray() * u.mm + grating = self.grating.surface + m = grating.rulings.diffraction_order + d = grating.rulings.spacing_(position).length + a = self.angle_grating_input() + b = self.angle_grating_output(axis_output) + result = np.abs((np.sin(a) + np.sin(b)) * d / m) + return result.to(u.AA) + + @property + def wavelength_min(self) -> u.Quantity | na.AbstractScalar: + """The minimum wavelength for each channel.""" + axis = "_dummy" + wavelength = self._wavelength_test_grid(axis) + return wavelength.min(axis) + + @property + def wavelength_max(self) -> u.Quantity | na.AbstractScalar: + """The maximum wavelength for each channel.""" + axis = "_dummy" + wavelength = self._wavelength_test_grid(axis) + return wavelength.max(axis) + + def wavelength_physical( + self, + wavelength_normalized: u.Quantity | na.AbstractScalar, + ) -> na.AbstractScalar: + """ + Convert wavelength in normalized units (between -1 and +1), + to wavelength in physical units using :attr:`wavelength_min` + and :attr:`wavelength_max`. + + Parameters + ---------- + wavelength_normalized + An array of wavelengths in normalized units to convert. + """ + wavelength_min = self.wavelength_min + wavelength_max = self.wavelength_max + wavelength_range = wavelength_max - wavelength_min + result = wavelength_range * (wavelength_normalized + 1) / 2 + wavelength_min + return result + + @property + def system(self) -> optika.systems.SequentialSystem: + """ + This spectrograph expressed as an instance of + :class:`optika.systems.SequentialSystem`. + """ + grid = self.grid_input.explicit.copy_shallow() + grid.wavelength = self.wavelength_physical(grid.wavelength) + + return optika.systems.SequentialSystem( + surfaces=[ + self.front_aperture.surface, + self.feed_optic.surface, + self.grating.surface, + ], + object=self.source.surface, + sensor=self.detector.surface, + grid_input=grid, + transformation=self.transformation, + )