Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add abstract base class for channels #98

Merged
merged 38 commits into from
Nov 13, 2024
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
ac7b5f9
Add empty abstractions subpackage
namurphy Feb 13, 2023
f4b4607
Add AbstractChannel ABC
namurphy Feb 13, 2023
5fc22c1
Re-organize and add gain abstract method
namurphy Feb 13, 2023
4f57a1c
Add imports to __init__.py
namurphy Feb 13, 2023
75131c8
Expand docstrings
namurphy Feb 13, 2023
b52dfb0
Add changelog entry
namurphy Feb 13, 2023
64a60fb
Add nearly empty glossary
namurphy Feb 13, 2023
9fe1bef
Revert "Add changelog entry"
namurphy Feb 13, 2023
160a9cf
first implementation of channel ABC
wtbarnes Oct 3, 2023
5f4b730
clarify property names; add unit annotations
wtbarnes Dec 23, 2023
59e7485
add a test
wtbarnes Dec 23, 2023
919ee62
add some docstrings
wtbarnes Dec 23, 2023
a63a30a
test repr
wtbarnes Dec 23, 2023
8366568
changelog
wtbarnes Dec 23, 2023
b5e5957
fix pre-commit
wtbarnes Dec 23, 2023
4d7f5ba
more docstrings
wtbarnes Dec 23, 2023
8f58e56
add api docs
wtbarnes Sep 3, 2024
7fe5238
small api change to be consistent with topic guide
wtbarnes Sep 3, 2024
a415930
precommit
wtbarnes Sep 3, 2024
50c9776
add xarray dependency to pyproject
wtbarnes Sep 17, 2024
4c7c5b7
rename subpackage to response; temp response stub
wtbarnes Sep 18, 2024
21609b3
move temperature response method to sourcespectra
wtbarnes Sep 18, 2024
fa4dd2e
fix changelog refs
wtbarnes Sep 19, 2024
bcfe9ab
fix api docs for new subpackage name
wtbarnes Sep 19, 2024
9c02948
fix doc build
wtbarnes Sep 24, 2024
c36036a
pin min xarray version
wtbarnes Sep 24, 2024
c329174
use xarray for all operations
wtbarnes Sep 24, 2024
1591b8f
bump min xarray version
wtbarnes Sep 24, 2024
be5f3b6
try filtering np warnings from xarray
wtbarnes Sep 24, 2024
b08d214
Update sunkit_instruments/response/abstractions.py
wtbarnes Sep 25, 2024
5eb3f47
Update sunkit_instruments/response/thermal.py
wtbarnes Sep 25, 2024
ec03cab
Update sunkit_instruments/response/thermal.py
wtbarnes Sep 25, 2024
f4696fa
Update sunkit_instruments/response/thermal.py
wtbarnes Sep 25, 2024
c32a371
Update sunkit_instruments/response/thermal.py
wtbarnes Sep 25, 2024
6ccd9ef
Update sunkit_instruments/response/thermal.py
wtbarnes Sep 25, 2024
c73030f
code review feedback
wtbarnes Sep 25, 2024
e7b3bc3
Merge branch 'main' into add-abstract-channel
wtbarnes Oct 28, 2024
8733ba5
Merge branch 'main' into add-abstract-channel
nabobalis Nov 13, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions changelog/98.feature.1.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Added `sunkit_instruments.response.abstractions.AbstractChannel` to standardize an interface
for computing wavelength and temperature response functions.
3 changes: 3 additions & 0 deletions changelog/98.feature.2.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Added `sunkit_instruments.response.SourceSpectra` to provide a container for
spectra as a function of temperature and wavelength needed for computing temperature
response functions.
1 change: 1 addition & 0 deletions docs/code_ref/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ API Reference
lyra
rhessi
suvi
response
7 changes: 7 additions & 0 deletions docs/code_ref/response.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
========
Response
========

.. automodapi:: sunkit_instruments.response

.. automodapi:: sunkit_instruments.response.abstractions
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ authors = [
{ name = "The SunPy Community", email = "[email protected]" },
]
dependencies = [
"sunpy[map,net,timeseries,visualization]>=6.0.0"
"sunpy[map,net,timeseries,visualization]>=6.0.0",
"xarray",
]
dynamic = ["version"]

Expand Down
5 changes: 5 additions & 0 deletions sunkit_instruments/response/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""
A subpackage for computing instrument responses
"""

from sunkit_instruments.response.thermal import *
113 changes: 113 additions & 0 deletions sunkit_instruments/response/abstractions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
"""This module defines abstractions for computing instrument response."""
import abc

import astropy.units as u

__all__ = ["AbstractChannel"]


class AbstractChannel(abc.ABC):
"""
An abstract base class for defining instrument channels.

For all methods and properties defined here, see the
topic guide on instrument response for more information.
"""

@u.quantity_input
def wavelength_response(
self, obstime=None
) -> u.cm**2 * u.DN * u.steradian / (u.photon * u.pixel):
"""
Instrument response as a function of wavelength

The wavelength response is the effective area with
the conversion factors from photons to DN and steradians
to pixels.

Parameters
----------
obstime: any format parsed by `~sunpy.time.parse_time`, optional
If specified, this is used to compute the time-dependent
instrument degradation.
"""
area_eff = self.effective_area(obstime=obstime)
return (
area_eff
* self.energy_per_photon
* self.pixel_solid_angle
* self.camera_gain
/ self.energy_per_electron
)

@u.quantity_input
def effective_area(self, obstime=None) -> u.cm**2:
"""
Effective area as a function of wavelength.

The effective area is the geometrical collecting area
weighted by the mirror reflectance, filter transmittance,
quantum efficiency, and instrument degradation.

Parameters
----------
obstime: any format parsed by `sunpy.time.parse_time`, optional
If specified, this is used to compute the time-dependent
instrument degradation.
"""
return (
self.geometrical_area
* self.mirror_reflectance
* self.filter_transmittance
* self.effective_quantum_efficiency
* self.degradation(obstime=obstime)
)

@property
@u.quantity_input
def energy_per_photon(self) -> u.eV / u.photon:
return self.wavelength.to("eV", equivalencies=u.spectral()) / u.photon

@abc.abstractmethod
@u.quantity_input
def degradation(self, obstime=None): ...

@property
@abc.abstractmethod
@u.quantity_input
def geometrical_area(self) -> u.cm**2: ...

@property
@abc.abstractmethod
@u.quantity_input
def mirror_reflectance(self) -> u.dimensionless_unscaled: ...

@property
@abc.abstractmethod
@u.quantity_input
def filter_transmittance(self) -> u.dimensionless_unscaled: ...

@property
@abc.abstractmethod
@u.quantity_input
def effective_quantum_efficiency(self) -> u.dimensionless_unscaled: ...

@property
@abc.abstractmethod
@u.quantity_input
def camera_gain(self) -> u.DN / u.electron: ...

@property
@abc.abstractmethod
@u.quantity_input
def energy_per_electron(self) -> u.eV / u.electron: ...

@property
@abc.abstractmethod
@u.quantity_input
def pixel_solid_angle(self) -> u.steradian / u.pixel: ...

@property
@abc.abstractmethod
@u.quantity_input
def wavelength(self) -> u.Angstrom: ...
92 changes: 92 additions & 0 deletions sunkit_instruments/response/tests/test_channel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import numpy as np
import pytest

import astropy.units as u

from sunkit_instruments.response import SourceSpectra
from sunkit_instruments.response.abstractions import AbstractChannel


class TestChannel(AbstractChannel):
@property
@u.quantity_input
def wavelength(self) -> u.Angstrom:
return np.linspace(100, 200, 100) * u.AA

@u.quantity_input
def degradation(self, obstime=None) -> u.dimensionless_unscaled:
return 1.0

@property
@u.quantity_input
def geometrical_area(self) -> u.cm**2:
return 10 * u.cm**2

@property
@u.quantity_input
def mirror_reflectance(self) -> u.dimensionless_unscaled:
return np.exp(
-((self.wavelength - self.wavelength[0]) / self.wavelength[0]).decompose()
)

@property
@u.quantity_input
def filter_transmittance(self) -> u.dimensionless_unscaled:
return np.exp(
-((self.wavelength - 150 * u.AA) ** 2 / (1 * u.AA) ** 2).decompose()
)

@property
@u.quantity_input
def effective_quantum_efficiency(self) -> u.dimensionless_unscaled:
return np.ones(self.wavelength.shape)

@property
@u.quantity_input
def camera_gain(self) -> u.DN / u.electron:
return 2 * u.DN / u.electron

@property
@u.quantity_input
def energy_per_electron(self) -> u.eV / u.electron:
return 3.65 * u.eV / u.electron

@property
@u.quantity_input
def pixel_solid_angle(self) -> u.steradian / u.pixel:
return (1 * u.arcsec) ** 2 / u.pixel


@pytest.fixture
def fake_channel():
return TestChannel()


def test_effective_area(fake_channel):
assert isinstance(fake_channel.effective_area(), u.Quantity)


def test_wavelength_response(fake_channel):
assert isinstance(fake_channel.wavelength_response(), u.Quantity)


@pytest.fixture
def fake_spectra():
temperature = np.logspace(5, 8, 100) * u.K
density = 1e15 * u.cm ** (-3) * u.K / temperature
wavelength = np.linspace(50, 250, 1000) * u.AA
data = np.random.rand(*temperature.shape + wavelength.shape) * u.Unit(
"photon cm3 s-1 sr-1 Angstrom-1"
)
return SourceSpectra(temperature, wavelength, data, density=density)


def test_spectra_repr(fake_spectra):
assert isinstance(fake_spectra.__repr__(), str)


@pytest.mark.parametrize('obstime', [None, '2020-01-01'])
def test_temperature_response(fake_channel, fake_spectra, obstime):
temp_response = fake_spectra.temperature_response(fake_channel, obstime=obstime)
assert isinstance(temp_response, u.Quantity)
assert temp_response.shape == fake_spectra.temperature.shape
152 changes: 152 additions & 0 deletions sunkit_instruments/response/thermal.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
"""
Classes for computing the temperature response
"""
import xarray

import astropy.units as u

__all__ = ["SourceSpectra", "get_temperature_response"]


def get_temperature_response(channel, spectra, obstime=None):
"""
Calculate the temperature response function for a given instrument channel
and input spectra

Parameters
----------
channel: `~sunkit_instruments.`
spectra: `~sunkit_instruments.response.SourceSpectra`
obstime: any format parsed by `sunpy.time.parse_time`, optional
"""
return spectra.temperature, spectra.temperature_response(channel, obstime=obstime)

Check warning on line 22 in sunkit_instruments/response/thermal.py

View check run for this annotation

Codecov / codecov/patch

sunkit_instruments/response/thermal.py#L22

Added line #L22 was not covered by tests


class SourceSpectra:
"""
Source spectra as a function of temperature and wavelength.

The source spectra describes how a plasma emits (under the optically-thin
assumption) as a function of both temperature and wavelength. This source
spectra is typically computed using a database like CHIANTI by summing the
emission spectra of many ions as well as the continuum emission. For more
information, see the `topic guide on instrument response <>`_.

Parameters
----------
temperature: `~astropy.units.Quantity`
1D array describing the variation along the temperature axis
wavelength: `~astropy.units.Quantity`
1D array describing the variation along the wavelength axis
spectra: `~astropy.units.Quantity`
Source spectra as a 2D array. The first axis should correspond to temperature and the
second axis should correspond to wavelength.
density: `~astropy.units.Quantity`, optional
1D array describing the variation in density along the temperature axis. It is assumed
that temperature and density are dependent.
meta: `dict`, optional
Any optional metadata to attach to the spectra, e.g. abundance model, CHIANTI version
"""

@u.quantity_input
def __init__(
self,
temperature: u.K,
wavelength: u.Angstrom,
spectra: u.photon * u.cm**3 / (u.s * u.Angstrom * u.steradian),
density: u.cm ** (-3) = None,
meta=None,
):
self.meta = meta
coords = {
"temperature": xarray.Variable(
"temperature",
temperature.value,
attrs={"unit": temperature.unit.to_string()},
),
"wavelength": xarray.Variable(
"wavelength",
wavelength.value,
attrs={"unit": wavelength.unit.to_string()},
),
}
if density is not None:
coords["density"] = xarray.Variable(
"temperature", density.value, attrs={"unit": density.unit.to_string()}
)
self._da = xarray.DataArray(
spectra.data,
dims=["temperature", "wavelength"],
coords=coords,
attrs={"unit": spectra.unit.to_string(), **self.meta},
)

def __repr__(self):
return self._da.__repr__()

def __str__(self):
return self._da.__str__()

Check warning on line 88 in sunkit_instruments/response/thermal.py

View check run for this annotation

Codecov / codecov/patch

sunkit_instruments/response/thermal.py#L88

Added line #L88 was not covered by tests

def _repr_html_(self):
return self._da._repr_html_()

Check warning on line 91 in sunkit_instruments/response/thermal.py

View check run for this annotation

Codecov / codecov/patch

sunkit_instruments/response/thermal.py#L91

Added line #L91 was not covered by tests

@property
def meta(self):
return self._meta

@meta.setter
def meta(self, x):
if x is None:
self._meta = {}
elif isinstance(x, dict):
self._mata = x

Check warning on line 102 in sunkit_instruments/response/thermal.py

View check run for this annotation

Codecov / codecov/patch

sunkit_instruments/response/thermal.py#L101-L102

Added lines #L101 - L102 were not covered by tests
else:
raise TypeError(f'Unsupported metadata type {type(x)}')

Check warning on line 104 in sunkit_instruments/response/thermal.py

View check run for this annotation

Codecov / codecov/patch

sunkit_instruments/response/thermal.py#L104

Added line #L104 was not covered by tests

@property
@u.quantity_input
def temperature(self) -> u.K:
return u.Quantity(self._da.temperature.data, self._da.temperature.attrs["unit"])

@property
@u.quantity_input
def wavelength(self) -> u.Angstrom:
return u.Quantity(self._da.wavelength.data, self._da.wavelength.attrs["unit"])

@property
@u.quantity_input
def data(self) -> u.photon * u.cm**3 / (u.s * u.Angstrom * u.steradian):
return u.Quantity(self._da.data, self._da.attrs["unit"])

Check warning on line 119 in sunkit_instruments/response/thermal.py

View check run for this annotation

Codecov / codecov/patch

sunkit_instruments/response/thermal.py#L119

Added line #L119 was not covered by tests

@u.quantity_input
def temperature_response(
self, channel, obstime=None
) -> u.cm**5 * u.DN / (u.pixel * u.s):
"""
Temperature response function for a given instrument channel.

The temperature response function describes the sensitivity of an imaging
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe just a general question - could this not be extended for non-imaging instruments? i.e. so not per pixel? how is this planned to deal with these cases?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a great question. I would hope that yes it could be extended to non-imaging instruments as well. What would the units be in that case? Also, do you have an example of a non-imaging instrument that computes a temperature response? Is it common to compute temperature responses for GOES XRS?

instrument as a function of temperature. The temperature response is
calculated by integrating the source spectra over the wavelength dimension,
weighted by the wavelength response of the instrument.

Parameters
----------
channel: `~sunkit_instruments.response.abstractions.AbstractChannel`
The relevant instrument channel object used to compute the wavelength
response function.
obstime: any format parsed by `sunpy.time.parse_time`, optional
A time of a particular observation. This is used to calculated any
time-dependent instrument degradation.
"""
spec_interp = self._da.interp(
wavelength=channel.wavelength.to_value(self.wavelength.unit),
kwargs={'bounds_error':False, 'fill_value':0.0},
)
wave_response = channel.wavelength_response(obstime=obstime)
spec_interp_weighted = spec_interp * wave_response.to_value()
temp_response = spec_interp_weighted.integrate(coord='wavelength')
final_unit = (u.Unit(spec_interp.unit)
* u.Unit(wave_response.unit)
* u.Unit(spec_interp_weighted.wavelength.unit))
return u.Quantity(temp_response.data, final_unit)
Loading