Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
8c43c12
adding non linear spectral dispersion code
A-Derks Apr 30, 2026
cfd3d4e
adding chagelog
A-Derks Apr 30, 2026
2e45f28
linting
A-Derks Apr 30, 2026
de99978
Merge branch 'main' into non_linear_spectral_dispersion
A-Derks May 27, 2026
e8cadfd
renaming variables, adding better docstrongs
A-Derks Jun 1, 2026
a3c8938
making new class to hold grating function
A-Derks Jun 3, 2026
774fd73
Merge branch 'main' into non_linear_spectral_dispersion
A-Derks Jun 9, 2026
5893f99
moving generate_grating_spectral_transform stuff out of this repo. It…
A-Derks Jun 9, 2026
19a35c7
moving alpha_in and alpha_out computations here
A-Derks Jun 11, 2026
86d9515
refactoring models stuff into smaller functions
A-Derks Jun 11, 2026
7f9ba37
adding extra grism params
A-Derks Jun 15, 2026
3dd823f
Update grating spectral wrapper for new gwcs grating-mode inputs
A-Derks Jun 15, 2026
543f6cb
Restore composed alpha_out grating transforms in dkist against the up…
A-Derks Jun 16, 2026
67e66ae
Potential fix for pull request finding
A-Derks Jun 16, 2026
caef8c4
Potential fix for pull request finding
A-Derks Jun 16, 2026
5913d79
Merge branch 'main' into non_linear_spectral_dispersion
A-Derks Jun 16, 2026
f1aaae2
moved refracted_angle_sine_model guts to gwcs repo, now there is just…
A-Derks Jun 18, 2026
e49ed4f
Merge branch 'main' into non_linear_spectral_dispersion
A-Derks Jun 18, 2026
375cf78
moved refracted_angle_sine_model guts to gwcs repo, now there is just…
A-Derks Jun 18, 2026
368cb6b
removed computing of reference_refracted_angle and grism_parameter_pe…
A-Derks Jun 30, 2026
0540894
Merge branch 'main' into non_linear_spectral_dispersion
A-Derks Jun 30, 2026
c4dc59d
removed unused refracted_angle_sine_model, added documentation about …
A-Derks Jun 30, 2026
58e262c
Merge branch 'main' into non_linear_spectral_dispersion
A-Derks Jun 30, 2026
4633621
Merge branch 'non_linear_spectral_dispersion' of https://github.com/D…
A-Derks Jun 30, 2026
d566b1f
removed unused refracted_angle_sine_model, added documentation about …
A-Derks Jun 30, 2026
569b0a3
removing unnecessary addition to build_grating_spectral_transform doc…
A-Derks Jul 1, 2026
b102d5d
Merge branch 'main' into non_linear_spectral_dispersion
A-Derks Jul 6, 2026
2fc780b
Let us all be more tolerant of numbers very close to zero (#739)
SolarDrew Jul 7, 2026
0dbf7ee
Bump OpenAstronomy/github-actions-workflows/.github/workflows/publish…
dependabot[bot] Jul 7, 2026
24929e6
Forward ignore_version_mismatch for string, iterable and Results inpu…
astrofrog Jul 7, 2026
1f307bd
Bump actions/checkout from 6 to 7 (#732)
dependabot[bot] Jul 7, 2026
2dbe78c
Bump CodspeedHQ/action from 4.17.0 to 4.18.1 (#733)
dependabot[bot] Jul 7, 2026
436a055
[pre-commit.ci] pre-commit autoupdate (#740)
pre-commit-ci[bot] Jul 17, 2026
1f149ed
WavelengthFromGratingEquation --> WavelengthFromGrismEquaion (change …
A-Derks Jul 20, 2026
381cf27
Merge branch 'main' into non_linear_spectral_dispersion
A-Derks Aug 26, 2026
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
1 change: 1 addition & 0 deletions changelog/713.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added dkist.wcs.models.generate_grating_spectral_transform() for building FITS -GRA/-GRI spectral transforms from header-derived grating parameters.
108 changes: 107 additions & 1 deletion dkist/wcs/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@

import astropy.modeling.models as m
import astropy.units as u
from astropy.modeling import CompoundModel, Model, Parameter, separable
from astropy.modeling import CompoundModel, Model, Parameter, custom_model, separable
from astropy.utils.decorators import deprecated_renamed_argument
from gwcs.spectroscopy import WavelengthFromGratingEquation
Comment thread
A-Derks marked this conversation as resolved.
Outdated

Comment thread
A-Derks marked this conversation as resolved.
Outdated
from dkist.utils.decorators import deprecated
from dkist.utils.exceptions import DKISTDeprecationWarning
Expand All @@ -31,10 +32,115 @@
"VaryingCelestialTransform2D",
"VaryingCelestialTransform3D",
"generate_celestial_transform",
"generate_grating_spectral_transform",
"varying_celestial_transform_from_tables",
]


def _grating_equation_constants(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Type-hints, please

reference_wavelength,
grating_density,
spectral_order,
incident_angle,
refractive_index,
refractive_index_derivative,
out_of_plane_angle,
camera_angle,
):
alpha = incident_angle.to(u.rad)
epsilon = out_of_plane_angle.to(u.rad)
theta = camera_angle.to(u.rad)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If these are already Quantities, then I don't think we need to convert them to radians. In this function, we only ever do trig on them, not use them directly in algebra, so the conversion isn't necessary.


grism_parameter = (grating_density * spectral_order) / np.cos(epsilon)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Not to be That Guy, but Greisen already defines something called the "grism parameter" in Eq. 75, which is different than the definition here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

"grism constant" would be an OK alternative

adjusted_incident_angle_sine = (
refractive_index - (refractive_index_derivative * reference_wavelength).decompose()
) * np.sin(alpha)
wavelength_parameter = grism_parameter - refractive_index_derivative * np.sin(alpha)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why is this variable called wavelength_parameter? When reading through these functions this name caused me a lot of confusion because I thought it had something to do with reference_wavelength or just wavelength in general. The units are 1 / m, which doesn't feel like a wavelength to me.

Based on my comment down in generate_grating_spectral_transform I'd argue we don't actually need this variable outside of this function. Given that, we could sidestep any naming issue by just removing this variable altogether and writing out angle_slope with the other variables directly.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I agree with Arthur, but if you do want to name these parts of the grism equation (Greisen eq 70), then I would stick with names that imply units. You already do that with adjusted_incident_angle_sine, so you could name this part with something that includes ...inverse_wavelength.

reference_refracted_angle = np.arcsin(
(grism_parameter * reference_wavelength).decompose() - refractive_index * np.sin(alpha)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is the .decompose() call necessary here? It looks like the results of this calculation stay in Quantity land for the duration of their life.

)
angle_slope = wavelength_parameter / (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I would like to rename this. If we use the name "grism parameter" for Γ (i.e. rename Gm/cosϵ) then this is the slope of the grism parameter wrt wavelength and wrt the distance along the detector (ξ in the paper) scaled. Γ itself is dimensionless. I think grism_parameter_slope would be better. Or maybe grism_parameter_per_wavelength, which would make its use in _refracted_angle_sine_model more clear.

np.cos(reference_refracted_angle) * np.cos(theta) ** 2
)

return (
adjusted_incident_angle_sine,
wavelength_parameter,
reference_refracted_angle,
theta,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't think we need to return theta from this function. It's just a variable rename of the input parameter camera_angle, which (I think) just muddies the path of variables in generate_grating_spectral_transform. I.e., it makes it seem like theta is meaningfully different from camera_angle, which it isn't.

True, a .to(u.rad) has happened, but the return value is still a Quantity and so, e.g., np.sin will work correctly in any case.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

angle_slope,
)


def _refracted_angle_sine_model(
reference_pixel, dispersion, reference_refracted_angle, theta, angle_slope
):
@custom_model

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can you refactor this into class form, I think that with it like this it will be hard to add the required asdf schemas.

def refracted_angle_sine(pixel):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We need a docstring either on _refacted_angle_sine_model or refracted_angle_sine.

wavelength_offset = ((pixel - reference_pixel) * u.pix) * dispersion
output_angle = (
np.arctan(-np.tan(theta) + wavelength_offset * angle_slope)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Renaming angle_slope to something like grism_parameter_per_wavelength (see comment here) then makes this line clearer about what it is doing.

+ reference_refracted_angle
+ theta

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Here is where theta aka camera_angle does need to be in radians. If it isn't passed through, the conversion should happen here.

)
return np.sin(output_angle)

return refracted_angle_sine()


def generate_grating_spectral_transform(
Comment thread
A-Derks marked this conversation as resolved.
Outdated
Comment thread
A-Derks marked this conversation as resolved.
Outdated
reference_pixel,
reference_wavelength,
dispersion,
grating_density,
spectral_order,
incident_angle,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

type-hints, please

refractive_index=1 * u.one,
refractive_index_derivative=0 / u.m,
out_of_plane_angle=0 * u.deg,
camera_angle=0 * u.deg,
) -> CompoundModel:
"""
Generate a one-dimensional FITS ``-GRA``/``-GRI`` spectral transform.

This function handles the FITS grating/grism intermediate-angle terms and
delegates the final wavelength calculation to
``gwcs.spectroscopy.WavelengthFromGratingEquation``.
"""
Comment thread
eigenbrot marked this conversation as resolved.
(
adjusted_incident_angle_sine,
wavelength_parameter,
reference_refracted_angle,
theta,
angle_slope,
) = _grating_equation_constants(
reference_wavelength=reference_wavelength,
grating_density=grating_density,
spectral_order=spectral_order,
incident_angle=incident_angle,
refractive_index=refractive_index,
refractive_index_derivative=refractive_index_derivative,
out_of_plane_angle=out_of_plane_angle,
camera_angle=camera_angle,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It appears this function (_grating_equation_constants) exists to save a little bit of code repetition when computing some of the inputs to _refracted_angle_sine_model, alpha_in, and WavelengthFromGratingEquation, but, in doing so, it separates the inputs to generate_grating_spectral_transform from their actual usage and adds another layer of variable names and calculations. I think this added layer causes confusion (see my comments above) and obfuscates the flow of information through generate_grating_spectral_transform.

My suggestion would be to remove _grating_equation_constants entirely and rewrite _refracted_angle_sine_model to accept grating_density, spectral_order, refractive_index, and reference_wavelength. adjusted_incident_angle_sine could be computed directly in the function body.


refracted_angle_sine = _refracted_angle_sine_model(
reference_pixel=reference_pixel,
dispersion=dispersion,
reference_refracted_angle=reference_refracted_angle,
theta=theta,
angle_slope=angle_slope,
)
alpha_in = m.Const1D(amplitude=adjusted_incident_angle_sine)
wavelength_from_grating = WavelengthFromGratingEquation(
groove_density=wavelength_parameter / spectral_order,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If I'm reading the math right, I think groove_density = wavelength_parameter / spectral_order only in the case where refractive_index_derivative = 0 and out_of_plan_angle = 0. Let's just set groove_density to the input grating_density, which is the same thing.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

But here's the part I don't get. If groove density is just G, then where is the denominator in Greisen eq 70? I'm looking at step 3 on page 756: "Compute the wavelength \lambda from \Gamma via Eq. 70" That equation has the two numerator parts, an adjusted sin \alpha and a sin \gamma, which we pass in the return of this function. The denominator in eq 70 is what is called wavelength_parameter above. Are we supposed to scale the sin(angles) before passing them to the model?

spectral_order=spectral_order,
name="Spectral",
)

return m.Mapping((0, 0)) | (alpha_in & refracted_angle_sine) | wavelength_from_grating

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It looks like the model produced by WavelengthFromGratingEquation does expect the inputs to be the sine of the input and output angles. This is what is correctly written here, but the variable names make it look like we're passing a raw angle (alpha_in) and the sine of an angle (refracted_angle_sine). However we choose to name these variables we should use the same convention for both.

We might also consider referring to these angles as "input" and "output" instead of "alpha/incident" and "refracted" to be clear about the connection to the language used in WavelengthFrameGratingEquation, but I'm also fine sticking with "incident" and "refracted".

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

+1 to same name convention for both



Comment thread
A-Derks marked this conversation as resolved.
def generate_celestial_transform(
Comment thread
Copilot marked this conversation as resolved.
crpix: Iterable[float] | u.Quantity,
cdelt: Iterable[float] | u.Quantity,
Expand Down
34 changes: 33 additions & 1 deletion dkist/wcs/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@
from astropy.coordinates.matrix_utilities import rotation_matrix
from astropy.modeling import CompoundModel
from astropy.modeling.models import Tabular1D
from astropy.wcs import WCS

from dkist.wcs.models import (AsymmetricMapping, Ravel, Unravel, VaryingCelestialTransform,
VaryingCelestialTransform2D, VaryingCelestialTransform3D,
generate_celestial_transform, update_celestial_transform_parameters,
generate_celestial_transform, generate_grating_spectral_transform,
update_celestial_transform_parameters,
varying_celestial_transform_from_tables)


Expand Down Expand Up @@ -52,6 +54,36 @@ def test_generate_celestial_unitless():
assert u.allclose(shift1.offset, 0)


def test_generate_grating_spectral_transform():

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can you also add tests for just the new model in isolation? I also suspect this test should probably be in dkist-inventory and not here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added in there

header = {
"CTYPE1": "AWAV-GRA",
"CUNIT1": "nm",
"CRPIX1": 218,
"CRVAL1": 854.1738582455826,
"CDELT1": 0.0022975580183395555,
"PV1_0": 23000.0,
"PV1_1": 90,
"PV1_2": 65.696,
}
Comment thread
A-Derks marked this conversation as resolved.
transform = generate_grating_spectral_transform(
reference_pixel=header["CRPIX1"] - 1,
reference_wavelength=header["CRVAL1"] * u.nm,
dispersion=header["CDELT1"] * u.nm / u.pix,
grating_density=header["PV1_0"] / u.m,
spectral_order=header["PV1_1"] * u.one,
incident_angle=header["PV1_2"] * u.deg,
)

pixels = np.array([0, 100, 217, 300, 511], dtype=float)
expected = WCS(header).all_pix2world(pixels, 0)[0] * u.m

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

As mentioned on the related dkist-inventory PR, a disadvantage of all_pix2world is that the results are unit-less and require us to apply the assumed-correct unit to its output. (It also has this weird signature and output that require non-obvious 0 and [0]).

Instead, I’d suggest using WCS(header).spectral.pixel_to_world(pixels), which will have proper units based on the CUNIT key. Then expected.to_value(u.nm) will work directly without needing to assume anything in the meantime.

result = transform(pixels)

assert isinstance(transform, CompoundModel)
np.testing.assert_allclose(
result.to_value(u.nm), expected.to_value(u.nm), rtol=1e-10, atol=1e-10
)


def test_update_celestial():
trsfm = generate_celestial_transform(
crpix=[0, 0] * u.pix,
Expand Down
Loading