# opticks Models and analysis tools for optical system engineering
#
# Copyright (C) Egemen Imre
#
# Licensed under GNU GPL v3.0. See LICENSE.md for more info.
from pathlib import Path
from typing import Iterable
import numpy as np
from astropy.units import Quantity
from opticks import u
from opticks.imaging_model.detector import Detector
from opticks.imaging_model.optics import Optics
from opticks.imaging_model.rw_electronics import RWElectronics
[docs]
class Imager:
"""
Class containing generic imager parameters.
An Imager is made up of two necessary and one optional part:
Optics, Detector and Read-out / Write Electronics (Optional)
Parameters
----------
optics_data : Optics
Optics data object
detector_data : Detector
Detector data object
rw_electronics_data : RWElectronics
Read-out/Write Electronics data object (optional)
"""
def __init__(
self,
optics_data: Optics,
detector_data: Detector,
rw_electronics_data: RWElectronics | None = None,
):
self.optics = optics_data
self.detector = detector_data
if rw_electronics_data:
self.rw_electronics = rw_electronics_data
else:
self.rw_electronics = None
[docs]
@classmethod
def from_yaml_file(
cls,
optics_data: Path,
detector_data: Path,
rw_electronics_data: Path | None = None,
) -> "Imager":
"""
Initialises an Imager from components in YAML files.
Parameters
----------
optics_data : Path
Filepath containing optics design data file (YAML)
detector_data : Path
Filepath containing detector design data file (YAML)
rw_electronics_data : Path
Filepath containing read-write electronics design data file (YAML)
Returns
-------
Imager
Imager object from the input data
"""
optics = Optics.from_yaml_file(optics_data)
detector = Detector.from_yaml_file(detector_data)
rw_electronics = None
if rw_electronics_data:
rw_electronics = RWElectronics.from_yaml_file(rw_electronics_data)
# return a new instance of Imager
return cls(optics, detector, rw_electronics)
[docs]
@classmethod
def from_yaml_text(
cls,
optics_data: str,
detector_data: str,
rw_electronics_data: str | None = None,
) -> "Imager":
"""
Initialises an Imager from components in YAML text.
Parameters
----------
optics_data : str
Text containing optics design data (YAML)
detector_data : str
Text containing detector design data (YAML)
rw_electronics_data : str
Text containing read-write electronics design data (YAML)
Returns
-------
Imager
Imager object from the input data
"""
optics = Optics.from_yaml_text(optics_data)
detector = Detector.from_yaml_text(detector_data)
rw_electronics = None
if rw_electronics_data:
rw_electronics = RWElectronics.from_yaml_text(rw_electronics_data)
# return a new instance of Imager
return cls(optics, detector, rw_electronics)
# ---------- begin modelling functions ----------
[docs]
@u.quantity_input(wavelength="length")
def q_factor(
self,
wavelength: Quantity,
band_id: str,
with_binning=True,
):
"""
Computes the Q Factor.
Q = wavelength x f_number / pixel pitch
Parameters
----------
wavelength : Quantity | ArrayLike[Quantity]
Wavelength at which Q is computed
band_id : str
band ID (to select the correct band or channel)
with_binning : bool, optional
Return the value with binning or not
Returns
-------
float
Q factor value
"""
# select the correct channel
channel = self.detector.channels[band_id]
pixel_pitch = channel.pixel_pitch(with_binning)
q = (wavelength * self.optics.f_number / pixel_pitch).decompose() # type: ignore[union-attr]
return q.value
[docs]
def horizontal_fov(self, band_id: str) -> Quantity:
"""
Computes the full field of view in the horizontal direction.
Used pixels only.
Parameters
----------
band_id : str
band ID (to select the correct band or channel)
Returns
-------
Quantity
Horizontal FOV angle
"""
# select the correct channel
channel = self.detector.get_channel(band_id)
return 2 * np.arctan(
(
(
channel.pixel_pitch(with_binning=False)
* channel.horizontal_pixels
/ 2.0
)
/ self.optics.focal_length
)
).to(u.deg, copy=False)
[docs]
def vertical_fov(self, band_id: str) -> Quantity:
"""
Computes the full field of view in the vertical direction.
For a pushbroom detector, this nominally corresponds to "read blocks"
(to compensate for the slow detector readout rate) multiplied
by TDI stages. For a "full-frame" detector, this corresponds to the
entire detector area in the vertical direction.
Parameters
----------
band_id : str
band ID (to select the correct band or channel)
Returns
-------
Quantity
Vertical FOV angle
"""
# select the correct channel
channel = self.detector.get_channel(band_id)
return 2 * np.arctan(
(
(
channel.pixel_pitch(with_binning=False)
* channel.vertical_pixels
/ 2.0
)
/ self.optics.focal_length
)
).to(u.deg, copy=False)
[docs]
def ifov(self, band_id: str, with_binning=True) -> Quantity:
"""
Computes the average instantaneous field of view per effective pixel
(works in vertical and horizontal).
The IFoV is computed simply as the FoV divided by the number of
(binned or unbinned) pixels.
Parameters
----------
band_id : str
band ID (to select the correct band or channel)
with_binning : bool, optional
Return the value with binning or not
Returns
-------
Quantity
IFOV angle
"""
# select the correct channel
channel = self.detector.get_channel(band_id)
# horizontal pixels with binning included
horiz_pixels = (
channel.pixel_count_line(with_binning).to(u.pixel, copy=False).value
)
return (self.horizontal_fov(band_id) / horiz_pixels).to(u.mdeg, copy=False)
[docs]
def pix_solid_angle(self, band_id: str, with_binning=True) -> Quantity:
"""
Pixel solid angle (of a pyramid).
Parameters
----------
band_id : str
band ID (to select the correct band or channel)
with_binning : bool, optional
Return the value with binning or not
Returns
-------
Quantity
Pixel solid angle in steradians
"""
pix_solid_angle = 4 * np.arcsin(
np.sin(self.ifov(band_id, with_binning) / 2.0)
* np.sin(self.ifov(band_id, with_binning) / 2.0)
)
# correct the unit from rad to sr (or rad**2)
return (pix_solid_angle * u.rad).to(u.steradian, copy=False)
[docs]
def data_write_rate(
self,
band_id: str | Iterable[str],
with_binning: bool = True,
with_compression: bool = True,
with_read_blocks: bool = True,
) -> Quantity:
"""
Data write rate with or without compression.
For a pushbroom only a single line is computed. For a full-frame,
the entire frame is computed.
If a list of channels is given, then the returned result is the sum of all
the requested channels.
Note that the unused pixels are also read, this assumes that the
detector does not have ROI functionality.
Parameters
----------
band_id : str or Iterable[str]
band ID (to select the correct band or channel)
with_binning : bool, optional
Return the value with binning or not
with_compression : bool
Return the value with compression or not
with_read_blocks : bool
Return the value with read blocks or not (valid for pushbroom only)
Returns
-------
Quantity
Pixel read rate with or without binning (Mbit/s)
"""
# TDI data is processed but not written unless raw data is needed
with_tdi = False
# data rate after encoding
enc_data_rate = (
self.detector.pix_read_rate(
band_id, with_binning, with_tdi, with_read_blocks
)
* self.rw_electronics.pixel_encoding # type: ignore[union-attr]
)
# data rate after compression and other processing
if with_compression:
process_output_data_rate = (
enc_data_rate / self.rw_electronics.compression_ratio # type: ignore[union-attr]
)
else:
process_output_data_rate = enc_data_rate
# data rate after overheads
write_data_rate = process_output_data_rate * (
1 + self.rw_electronics.data_write_overhead # type: ignore[union-attr]
)
return write_data_rate.to("Mbit/s") # type: ignore[union-attr]