Skip to content

Implement option in WCS class to preserve the original units - #18338

Merged
astrofrog merged 37 commits into
astropy:mainfrom
astrofrog:wcs-units-c
Jul 25, 2025
Merged

astrofrog merged 37 commits into
astropy:mainfrom
astrofrog:wcs-units-c

Conversation

@astrofrog

@astrofrog astrofrog commented Jun 24, 2025 •

Copy link
Copy Markdown
Member

Description

A common issue users run into when using the astropy.wcs class is that WCSLIB will convert units to SI. For instance, celestial WCS in arcseconds will be converted to degrees, and spectral axes in GHz and nm will be converted to m. There isn't much we can do to stop WCSLIB from doing this - however I have realized that since we never expose the WCSLIB objects directly, but instead have our own C wrapper class, we can make that wrapper class remember any initial units and do on-the-fly conversions for all appropriate methods/properties.

As an example:

In [1]: from astropy.io import fits
   ...: 

In [2]: header = fits.Header()
   ...: header['CTYPE1'] = 'RA---TAN'
   ...: header['CTYPE2'] = 'DEC--TAN'
   ...: header['CUNIT1'] = 'arcsec'
   ...: header['CUNIT2'] = 'arcsec'
   ...: header['CRVAL1'] = 20
   ...: header['CRVAL2'] = 20
   ...: header['CRPIX1'] = 1
   ...: header['CRPIX2'] = 1
   ...: header['CDELT1'] = -1
   ...: header['CDELT2'] = 1
   ...: 

In [3]: from astropy.wcs import WCS
   ...: 

In [4]: wcs = WCS(header)

In [5]: wcs.wcs.cunit
Out[5]: ['deg', 'deg']

In [6]: wcs.wcs.crval
Out[6]: array([0.00555556, 0.00555556])

In [8]: wcs.all_pix2world(1, 1, 0)
Out[8]: [array(0.00527778), array(0.00583333)]

In [9]: wcs2 = WCS(header, preserve_units=True)

In [10]: wcs2.wcs.cunit
Out[10]: ['arcsec', 'arcsec']

In [11]: wcs2.wcs.crval
Out[11]: array([20., 20.])

In [13]: wcs2.all_pix2world(1, 1, 0)
Out[13]: [array(18.99999999), array(21.)]

This is not yet complete but I am putting it up already so that people who are interested can try it out.

Remaining TODOs:

  • Real-world testing
  • Fix setters
  • Extend support to headers with CD matrices
  • Check that we can dynamically change the units on the WCS and that things behave correctly
  • Add WCSAxes image test
  • Add programmatic test of creating WCS
  • Add test for to_header
  • Fix test_programmatic for non-explicit set() call
  • Check what happens if unitfix or set() are called multiple times
  • Decide how to deal with print_contents, __str__ and compare, unitfix
  • Add news fragment
  • Write documentation
  • Add entry to what's new

This will need to be rebased once #18231 is merged

  • By checking this box, the PR author has requested that maintainers do NOT use the "Squash and Merge" button. Maintainers should respect this when possible; however, the final decision is at the discretion of the maintainer that merges the PR.

@github-actions github-actions Bot added the wcs label Jun 24, 2025
@github-actions

Copy link
Copy Markdown
Contributor

Thank you for your contribution to Astropy! 🌌 This checklist is meant to remind the package maintainers who will review this pull request of some common things to look for.

  • Do the proposed changes actually accomplish desired goals?
  • Do the proposed changes follow the Astropy coding guidelines?
  • Are tests added/updated as required? If so, do they follow the Astropy testing guidelines?
  • Are docs added/updated as required? If so, do they follow the Astropy documentation guidelines?
  • Is rebase and/or squash necessary? If so, please provide the author with appropriate instructions. Also see instructions for rebase and squash.
  • Did the CI pass? If no, are the failures related? If you need to run daily and weekly cron jobs as part of the PR, please apply the "Extra CI" label. Codestyle issues can be fixed by the bot.
  • Is a change log needed? If yes, did the change log check pass? If no, add the "no-changelog-entry-needed" label. If this is a manual backport, use the "skip-changelog-checks" label unless special changelog handling is necessary.
  • Is this a big PR that makes a "What's new?" entry worthwhile and if so, is (1) a "what's new" entry included in this PR and (2) the "whatsnew-needed" label applied?
  • At the time of adding the milestone, if the milestone set requires a backport to release branch(es), apply the appropriate "backport-X.Y.x" label(s) before merge.

@github-actions

Copy link
Copy Markdown
Contributor

👋 Thank you for your draft pull request! Do you know that you can use [ci skip] or [skip ci] in your commit messages to skip running continuous integration tests until you are ready?

@astrofrog

Copy link
Copy Markdown
Member Author

I'm seeing a test failure in test_wtbarr_print which is a test unrelated to the changes here but what is happening is that the print_contents() call there includes some output from the new test I added in this PR, so somehow something isn't getting cleared in memory.

@Cadair

Cadair commented Jul 7, 2025

Copy link
Copy Markdown
Member

To illustrate why this PR is lovely, below is a WCSAxes plot with basically everything default:

Before:
Figure_1_without

After:
Figure_1_with

@astrofrog

Copy link
Copy Markdown
Member Author

@mcara - are you happy with this in general? If so, I will carry out extensive testing in different packages and can then write more tests and some documentation.

@mcara

mcara commented Jul 14, 2025

Copy link
Copy Markdown
Contributor

Since it was in the draft mode I thought it was not yet ready for review. I'll take a look.

@astrofrog

Copy link
Copy Markdown
Member Author

@mcara - yes just to be clear it works, it's just in draft mode because there are no docs yet.

@mcara

mcara commented Jul 14, 2025

Copy link
Copy Markdown
Contributor

@astrofrog While I review this, may I ask whether you had contacted Mark to see if this could be fixed in WCSLIB? It seems it would be a much more natural place for the fix as it would avoid unnecessary copying of Wcsprm structure. It seems unnatural/unexpected for WCSLIB to (kind of) arbitrarily convert units in the Wcsprm structure.

Section 2.3 of Paper I says:

2.3. Keyword value units
The original FITS paper (Wells et al. 1981) assumed that the units along each axis could be implied simply by the contents of the CTYPEi keyword. This has not turned out to be true in general. Therefore, we propose adding a new indexed, keyword CUNITi (character-valued) with which the units of CRVALi and CDELTi may be specified. Restrictions on the nature and range of units, if any, will be determined by the agreements applying to the specific axes. If they are not so limited, units should conform with the recommendations of the IAU Style Manual (McNally 1988).

Although in Paper II (section 1) it says:

Wells et al. (1981) established that all angles in FITS were to be measured in degrees and this has been entrenched by the AIPS convention and confirmed in the IAU-endorsed FITS standard (Hanisch et al. 2001). Paper I introduced the CUNITia keyword to define the units of CRVALia and CDELTia. Accordingly, we require CUNITia = 'deg' for the celestial CRVALia and CDELTia, whether given explicitly or not. Consequently, the (x; y) coordinates in the plane of projection are measured in degrees. For consistency, we use degree measure for native and celestial spherical coordinates and for all other angular measures in this paper.

This is confusing (to me). So I looked at the Hanisch et al. 2001 paper, section 5.3, and it says:

The units of all FITS header keyword values, with the exception of measurements of angles, should conform with the recommendations in the IAU Style Manual (McNally 1988). For angular measurements given as floating point values and specified with reserved keywords, degrees are the recommended units** (with the units, if specified, given as ’deg’).

recommended != required. I think it is worth asking why WCSLIB chose to behave like that and whether it is a bug and regardless of bug or not - can't this be handled there?

While I think it is worth contacting Mark Calabretta about this, I think we can go ahead and implement this here.

Comment thread astropy/wcs/src/astropy_wcs.c Outdated
Comment thread astropy/wcs/src/wcslib_wrap.c
Comment thread astropy/wcs/src/wcslib_wrap.c Outdated
Comment thread astropy/wcs/src/wcslib_wrap.c Outdated
Comment thread astropy/wcs/src/wcslib_wrap.c Outdated
Comment thread astropy/wcs/src/wcslib_wrap.c Outdated
// calling wcsp2s, but we need to call it ourselves using PyWcsprm_cset so that we can
// catch cases where the units might change if e.g. they are not in SI to start with.
/* Force a call to wcsset here*/
if (self->preserve_units && PyWcsprm_cset(self, 1)) return NULL;

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.

What is the performance cost for this for the case of a single coordinate transformation (a pair of coordinates) and for a large array of coordinates?

Again, { return NULL; }

@astrofrog astrofrog Jul 15, 2025 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed - I'll address the performance question shortly

Comment thread astropy/wcs/tests/test_wcs.py Outdated
Comment thread astropy/wcs/src/astropy_wcs.c Outdated
@mcara

mcara commented Jul 15, 2025

Copy link
Copy Markdown
Contributor

I also wonder how this works with the -TAB non-linear algorithm (see section 6 in Paper III - spectral). Maybe add an example using -TAB?

@astrofrog

astrofrog commented Jul 15, 2025 •

Copy link
Copy Markdown
Member Author

recommended != required. I think it is worth asking why WCSLIB chose to behave like that and whether it is a bug and regardless of bug or not - can't this be handled there?

I was in touch with Mark last year about this and this is what he said:

Correct - internally, WCSLIB only deals with SI units plus degrees. It would overly complicate matters to try to do otherwise.

Applications programmers see it when composing world coordinates to be transformed to pixel coordinates, or in the result of transforming pixel to world coordinates - I believe the documentation is clear enough on this. However, I feel applications programmers could protect higher-level users (i.e. the users of the applications) by doing unit conversions for them using the tools provided, namely wcsunitse().

This PR does pretty much that - we actually use wcsunitse in this PR to determine the correct scaling of units etc. I have followed up with Mark to see if he agrees that the approach in this PR is the right one, but at least from what he said before I don't think he would envisage changing WCSLIB.

In any case, thanks for the review! I will try and see if I can address these and wrap it up today or tomorrow.

@astrofrog

astrofrog commented Jul 15, 2025 •

Copy link
Copy Markdown
Member Author

What is the performance cost for this for the case of a single coordinate transformation (a pair of coordinates) and for a large array of coordinates?

Using the 5-D WCS from the test added in this PR (wcs_default is without preserve_units=True, and wcs_preserve is with), and doing a lot of repeated measurements, I get:

Before this PR

wcs_default.all_pix2world(2, 3, 8, 1, 2, 0)        0.014 +/- 0.002 ms
wcs_default.all_pix2world(pixel_coords, 0)               1.012 +/- 0.076 ms
wcs_default.all_world2pix(2, 3, 8, 1, 2, 0)        0.028 +/- 0.008 ms
wcs_default.all_world2pix(world_coords_si, 0)               1.178 +/- 0.146 ms

With this PR

wcs_default.all_pix2world(2, 3, 8, 1, 2, 0)        0.016 +/- 0.001 ms
wcs_default.all_pix2world(pixel_coords, 0)         1.128 +/- 0.060 ms
wcs_default.all_world2pix(2, 3, 8, 1, 2, 0)        0.019 +/- 0.002 ms
wcs_default.all_world2pix(world_coords_si, 0)      1.208 +/- 0.028 ms
wcs_preserve.all_pix2world(2, 3, 8, 1, 2, 0)       0.029 +/- 0.003 ms
wcs_preserve.all_pix2world(pixel_coords, 0)        1.234 +/- 0.001 ms
wcs_preserve.all_world2pix(2, 3, 8, 1, 2, 0)       0.037 +/- 0.004 ms
wcs_preserve.all_world2pix(world_coords_native, 0) 0.271 +/- 0.028 ms

Time times for wcs_default are consistent within errors, so no performance impact for the default case.

With preserve units, it seems pix2world is a little slower for pix2world, which is to be expected, but I am a bit surprised by world2pix actually being faster. The results are the same between wcs_default and wcs_preserve, so this is very puzzling!

Script to reproduce: https://gist.github.com/astrofrog/a0c88718992029198b79245114469d81

…as far

as the user is concerned rather than have them be converted to SI (for example
converting nm to m, GHz to Hz, and arcsec to deg).
@astrofrog

astrofrog commented Jul 21, 2025 •

Copy link
Copy Markdown
Member Author

@mcara - I spent some time today working on making sure we can assign units to WCS.wcs.cunit, and I have something that seems to work. I have added a few tests for different scenarios. I've also made it so that if preserve_units=True, and WCS.wcs.set() has been called, users can't modify individual units eg WCS.wcs.cunit[1] in-place since that is difficult to deal with otherwise, and better to just have an explicit error. This won't break any existing usage since it only applies to when preserve_units=True.

In any case, I was wondering if you could take this for another spin and see if you are happy with the behavior under different circumstances?

@astrofrog

Copy link
Copy Markdown
Member Author

@mhvk thank you for taking a look at this! Regarding the implementation i would prefer for now to keep the code paths reasonably separate for preserve_units False or True - mainly to avoid introducing bugs for regular users of the default WCS class - but we could always try and tighten up the implementation once it's had a good amount of real world testing?

I would like to try and add a ScienceState to control the default behavior, but would prefer to keep this for a separate PR.

@mhvk

mhvk commented Jul 21, 2025

Copy link
Copy Markdown
Contributor

I would like to try and add a ScienceState to control the default behavior, but would prefer to keep this for a separate PR.

Certainly fine, though I was thinking more about a configuration item. But given that, perhaps all the more reason to discuss separately.

@astrofrog
astrofrog requested a review from mcara July 24, 2025 08:59

@mcara mcara left a comment

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.

Masterclass

@astrofrog

Copy link
Copy Markdown
Member Author

Thanks for the review @mcara! I'll go ahead and merge, and will try and address @mhvk's suggestion regarding configuration in a follow-up PR.

@astrofrog
astrofrog merged commit 35e5d1c into astropy:main Jul 25, 2025

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.

Oooo... does this also simplify unit handling for Jdaviz downstream? cc @kecnry

@Cadair

Cadair commented Jul 25, 2025

Copy link
Copy Markdown
Member

Thanks for doing this @astrofrog this is something which has been a pain point for a long time.

@pllim

pllim commented Jul 25, 2025

Copy link
Copy Markdown
Member

I dare say this might be a killer feature for the next release. 😆 😭

@Cadair

Cadair commented Jul 25, 2025

Copy link
Copy Markdown
Member

I'm sure if we turned it on by default nothing would break.

@pllim

pllim commented Jul 25, 2025

Copy link
Copy Markdown
Member

nothing would break

Is that sarcasm? 😉

@mcara

mcara commented Jul 25, 2025

Copy link
Copy Markdown
Contributor

I think it would be important to have a method/property on the WCS object like is_preserving_units for downstream code to test which behavior is the object using. I forgot to mention this when reviewing this. Would it be possible to add this in a follow-up PR, @astrofrog ?

@astrofrog

Copy link
Copy Markdown
Member Author

Yes of course!

@astrofrog

Copy link
Copy Markdown
Member Author

@mcara - just to check, would it be better to simply have a WCS.preserve_units property? The reason I ask is because often a pattern is that things passed as kwargs to classes can then be accessed as properties, so there is some symmetry:

>>> wcs = WCS(..., preserve_units=True)
>>> wcs.preserve_units
True

Or would you prefer a method for this?

@mhvk @Cadair if you have opinions feel free to chime in!

@Cadair

Cadair commented Jul 25, 2025

Copy link
Copy Markdown
Member

+1 property which can't be set.

@Cadair

Cadair commented Jul 25, 2025

Copy link
Copy Markdown
Member

nothing would break

Is that sarcasm? 😉

From me?! Never.

@mcara

mcara commented Jul 25, 2025

Copy link
Copy Markdown
Contributor

I like property

@mcara

mcara commented Jul 28, 2025

Copy link
Copy Markdown
Contributor

For the record, drizzlepac regression tests all passed: https://github.com/spacetelescope/RegressionTests/actions/runs/16503552056

@Cadair

Cadair commented Jul 28, 2025

Copy link
Copy Markdown
Member

With the default behaviour or opting into preserve units? 😉

@mcara

mcara commented Jul 28, 2025

Copy link
Copy Markdown
Contributor

With the default behaviour or opting into preserve units? 😉

Default as drizzlepac is not yet aware of preserve_units and likely it won't ever use this since, I believe, HST WCS for all instruments use SI (but I may be wrong).

@Cadair

Cadair commented Jul 28, 2025

Copy link
Copy Markdown
Member

If we get a global switch, it'd certainly be good to check to see that there's no unexpected behaviour, even with SI units.

@mcara

mcara commented Jul 28, 2025

Copy link
Copy Markdown
Contributor

I think a global switch is bound to have consequences on other packages. For example, a package that is expecting RA & DEC to be in degrees will not be aware that, due to a global switch, it now gets angles in arcsec (for example).

@Cadair

Cadair commented Jul 29, 2025

Copy link
Copy Markdown
Member

Yeah, for sure, but it would be good to experiment with finding out where those assumptions are. Including inside astropy itself.

@astrofrog

Copy link
Copy Markdown
Member Author

@mcara @Cadair lets discuss in #18463 ?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

APE 14: pixel_to_world_values ignores CUNIT WCS updating units should emit warning (CUNITn is not respected)

5 participants