"""
Base sensor class for position, line-of-sight, geodetic conversion, and radiometric modeling.
This module defines the Sensor base class which provides a framework for representing
sensor platforms and their characteristics. Sensors support position queries, geodetic
coordinate conversion, radiometric calibration data (bias, gain, bad pixels), and
optional point spread function modeling.
"""
from astropy.coordinates import EarthLocation
from astropy import units
from dataclasses import dataclass, field
import h5py
import numpy as np
from numpy.typing import NDArray
import pandas as pd
from typing import Optional, Tuple
import uuid
[docs]
@dataclass
class Sensor:
"""
Base class for sensor position, line-of-sight, geodetic conversion, and radiometric modeling
The Sensor class provides a framework for representing sensor platforms and their
associated characteristics including projection polynomials and radiometric calibration data.
Attributes
----------
name : str
Unique name for this sensor. Used as the primary identifier.
bias_images : NDArray, optional
3D array of bias/dark frames with shape (num_bias_images, height, width).
bias_image_frames : NDArray, optional
1D array specifying frame ranges for each bias image.
uniformity_gain_images : NDArray, optional
3D array of flat-field/gain correction images.
uniformity_gain_image_frames : NDArray, optional
1D array specifying frame ranges for each uniformity gain image.
bad_pixel_masks : NDArray, optional
3D array of bad pixel masks.
bad_pixel_mask_frames : NDArray, optional
1D array specifying frame ranges for each bad pixel mask.
Notes
-----
- All positions are in Earth-Centered Earth-Fixed (ECEF) Cartesian coordinates
- Position units are kilometers
- Positions are represented as (3, N) arrays with x, y, z in each column
- PSF modeling is optional and can be used for fitting signal blobs to estimate irradiance
- Sensor names must be unique within a VISTA session
- Class variable _instance_count tracks the total number of Sensor instances created
Examples
--------
>>> # Subclass can implement get_positions
>>> class MySensor(Sensor):
... def get_positions(self, times):
... # Return sensor positions for given times
... return np.array([[x1, x2], [y1, y2], [z1, z2]])
"""
name: str
bias_images: Optional[NDArray] = None
bias_image_frames: Optional[NDArray] = None
uniformity_gain_images: Optional[NDArray] = None
uniformity_gain_image_frames: Optional[NDArray] = None
bad_pixel_masks: Optional[NDArray] = None
bad_pixel_mask_frames: Optional[NDArray] = None
uuid: str = field(init=None, default=None)
_added_imagery_uuids: list = field(init=None, default=None)
# Private class attributes
_imagery_frames_dataframe: pd.DataFrame = field(init=None, default=None)
# Class variable to track total number of sensor instances created
_instance_count: int = 0
[docs]
def __post_init__(self):
"""
Initialize sensor instance and increment global counter.
Generates a unique UUID for this sensor instance and initializes internal
data structures for tracking associated imagery.
"""
# Increment the class-level counter
Sensor._instance_count += 1
# Generate UUID if not set
if self.uuid is None:
self.uuid = uuid.uuid4()
self._added_imagery_uuids = []
self._imagery_frames_dataframe = pd.DataFrame({
"frames": pd.Series([], dtype=int),
"times": pd.Series([], dtype='datetime64[ns]')
})
[docs]
def __eq__(self, other):
"""Compare Sensors based on UUID"""
return hasattr(other, 'uuid') and (self.uuid == other.uuid)
[docs]
def get_imagery_frames_and_times(self) -> Tuple[NDArray, NDArray]:
"""
Get all unique imagery frames and corresponding times in increasing order.
Returns
-------
frames : NDArray[np.int_]
Sorted array of unique frame numbers
times : NDArray[np.datetime64]
Sorted array of corresponding timestamps
Notes
-----
This method aggregates frames and times from all imagery objects that have
been registered with this sensor via add_imagery().
"""
return self._imagery_frames_dataframe["frames"].to_numpy(), self._imagery_frames_dataframe["times"].to_numpy()
[docs]
def add_imagery(self, imagery):
"""
Register imagery with this sensor and update frame/time tracking.
Adds the imagery's frames and times to the sensor's internal registry
for coordinate conversion and time-to-frame mapping. Duplicate imagery
(by UUID) or imagery without times is ignored.
Parameters
----------
imagery : Imagery
Imagery object to register with this sensor
Notes
-----
- Only imagery with non-None times are tracked
- Duplicate frame/time pairs are automatically removed
- This method is typically called automatically when Imagery is created
"""
if (imagery.uuid in self._added_imagery_uuids) or (imagery.times is None):
return
self._imagery_frames_dataframe = pd.concat((
self._imagery_frames_dataframe,
pd.DataFrame({
"frames": imagery.frames,
"times": imagery.times
})
))
self._imagery_frames_dataframe = self._imagery_frames_dataframe.drop_duplicates()
[docs]
def get_positions(self, times: NDArray[np.datetime64]) -> NDArray[np.float64]:
"""
Return sensor positions in Cartesian ECEF coordinates for given times.
Parameters
----------
times : NDArray[np.datetime64]
Array of times for which to retrieve sensor positions
Returns
-------
NDArray[np.float64]
Sensor positions as (3, N) array where N is the number of times.
Each column contains [x, y, z] coordinates in ECEF frame (km).
Notes
-----
The default implementation returns None. Subclasses should override this method
"""
return None
[docs]
def model_psf(self, sigma: Optional[float] = None, size: Optional[int] = None) -> Optional[NDArray[np.float64]]:
"""
Model the sensor's point spread function (PSF).
This is an optional method that can be overridden by subclasses to provide
PSF modeling capability. The PSF can be used to fit signal pixel blobs in
imagery to estimate irradiance.
Parameters
----------
sigma : float, optional
Standard deviation parameter for PSF modeling (e.g., Gaussian width)
size : int, optional
Size of the PSF kernel to generate
Returns
-------
NDArray[np.float64] or None
2D array representing the point spread function, or None if not implemented
Notes
-----
The default implementation returns None. Subclasses should override this
method to provide specific PSF models (e.g., Gaussian, Airy disk, etc.).
"""
return None
[docs]
def can_geolocate(self) -> bool:
"""
Check if sensor can convert pixels to geodetic coordiantes and vice versa.
Notes
-----
The default implementation returns False. Subclasses can override this method.
Returns
-------
bool
True if sensor has both forward and reverse geolocation polynomials.
"""
return False
[docs]
def can_correct_bad_pixel(self) -> bool:
"""
Check if sensor has radiometric bad pixel masks.
Returns
-------
bool
True if sensor has radiometric bad pixel masks.
"""
return self.bad_pixel_masks is not None
[docs]
def can_correct_bias(self) -> bool:
"""
Check if sensor has bias images
Returns
-------
bool
True if sensor has bias images.
"""
return self.bias_images is not None
[docs]
def geodetic_to_pixel(self, frame: int, loc: EarthLocation) -> Tuple[np.ndarray, np.ndarray]:
"""
Convert geodetic coordinates to pixel coordinates using polynomial coefficients.
Parameters
----------
frame : int
Frame number for which to perform the conversion
loc : EarthLocation
Astropy EarthLocation object(s) containing geodetic coordinates
Returns
-------
rows : np.ndarray
Array of row pixel coordinates (NaN for base implementation)
columns : np.ndarray
Array of column pixel coordinates (NaN for base implementation)
Notes
-----
The default implementation returns NaN arrays. Subclasses should override
this method to provide geodetic-to-pixel conversion using their specific
projection model (e.g., polynomial coefficients).
"""
empty = np.empty_like(loc.x.value)
empty.fill(np.nan)
return empty, empty.copy()
[docs]
def pixel_to_geodetic(self, frame: int, rows: np.ndarray, columns: np.ndarray):
"""
Convert pixel coordinates to geodetic coordinates using polynomial coefficients.
Parameters
----------
frame : int
Frame number for which to perform the conversion
rows : np.ndarray
Array of row pixel coordinates
columns : np.ndarray
Array of column pixel coordinates
Returns
-------
EarthLocation
Astropy EarthLocation object(s) with geodetic coordinates
(zeros for base implementation)
Notes
-----
The default implementation returns EarthLocation with zero coordinates.
Subclasses should override this method to provide pixel-to-geodetic
conversion using their specific projection model.
"""
return EarthLocation.from_geodetic(
lon=np.zeros_like(rows) * units.deg,
lat=np.zeros_like(rows) * units.deg,
height=np.zeros_like(rows) * units.km
)
[docs]
def to_hdf5(self, group: h5py.Group):
"""
Save sensor radiometric calibration data to an HDF5 group.
Parameters
----------
group : h5py.Group
HDF5 group to write sensor data to (typically sensors/<sensor_uuid>/)
Notes
-----
This method writes radiometric calibration data to the HDF5 group:
- bias_images and bias_image_frames
- uniformity_gain_images and uniformity_gain_image_frames
- bad_pixel_masks and bad_pixel_mask_frames
Subclasses should call super().to_hdf5(group) and then add their own data.
"""
# Set sensor type and identification attributes
group.attrs['sensor_type'] = 'Sensor'
group.attrs['name'] = self.name
group.attrs['uuid'] = str(self.uuid)
# Create radiometric calibration subgroup
if (self.bias_images is not None or
self.uniformity_gain_images is not None or
self.bad_pixel_masks is not None):
radiometric_group = group.create_group('radiometric')
# Save bias images if present
if self.bias_images is not None:
radiometric_group.create_dataset('bias_images', data=self.bias_images)
radiometric_group.create_dataset('bias_image_frames', data=self.bias_image_frames)
# Save uniformity gain images if present
if self.uniformity_gain_images is not None:
radiometric_group.create_dataset('uniformity_gain_images', data=self.uniformity_gain_images)
radiometric_group.create_dataset('uniformity_gain_image_frames', data=self.uniformity_gain_image_frames)
# Save bad pixel masks if present
if self.bad_pixel_masks is not None:
radiometric_group.create_dataset('bad_pixel_masks', data=self.bad_pixel_masks)
radiometric_group.create_dataset('bad_pixel_mask_frames', data=self.bad_pixel_mask_frames)