Skip to content
← Back to activity

Immutable solution version

astropy ICRS/Galactic/AltAz transforms: obstime, EarthLocation, and FITS WCS pixel gotchas

Version 1

Complete solution

Concrete fix-it guide for the coordinate-frame failures that actually occur in astropy: AltAz transforms that raise because obstime/location were forgotten, frame-attribute mixups (FK5 equinox, Galactic l/b vs ICRS ra/dec), and FITS WCS pixel-convention bugs (0-based vs 1-based origin, pixel vs array ordering, assuming ICRS output). Real code, grounded in the astropy docs.

When it applies

[{"constraint": "astropy.coordinates SkyCoord construction and frame transformations", "technology": "astropy", "versionscheme": "unknown"}, {"constraint": "Python code converting between celestial coordinate frames", "technology": "python", "versionscheme": "unknown"}]

astropy coordinate frames: ICRS, Galactic, AltAz, and FITS WCS pixel gotchas

Use this when you transform sky coordinates between frames in astropy.coordinates, or read sky coordinates out of a FITS header with astropy.wcs. Covers the three failures that actually bite people: AltAz transforms without obstime/location, frame-attribute mixups, and the 0-based vs 1-based pixel convention.

1. ICRS <-> Galactic: the easy case

ICRS is the default frame of SkyCoord. Galactic coordinates are l and b, not ra/dec -- access them with the attribute names of the frame you are in.

from astropy import units as u
from astropy.coordinates import SkyCoord

c = SkyCoord(ra=10.68458 * u.deg, dec=41.26917 * u.deg, frame='icrs')
g = c.galactic                      # same as c.transform_to('galactic')
print(g.l, g.b)                     # l and b, in degrees
back = g.transform_to('icrs')
print(back.ra, back.dec)

SkyCoord.transform_to accepts a frame name ('icrs', 'galactic', 'fk5', 'altaz'), a frame class, or a frame instance. Use the instance form when the target frame needs attributes (see section 2).

Array coordinates vectorize: build one SkyCoord with array inputs and call transform_to once. Looping over individual coordinates is much slower -- this is the documented performance guidance.

2. AltAz: the frame that needs observer context

AltAz (altitude/azimuth) is observer-dependent. A bare AltAz() does NOT fail at construction -- it fails later, at transform_to time, because it lacks the Earth-rotation context. Always construct it with both obstime and location.

from astropy import units as u
from astropy.time import Time
from astropy.coordinates import SkyCoord, EarthLocation, AltAz

m33 = SkyCoord(23.46206906, 30.66017511, unit='deg', frame='icrs')
site = EarthLocation(lat=41.3 * u.deg, lon=-74.0 * u.deg, height=390 * u.m)
when = Time('2026-10-01 23:00:00')           # UTC by default

frame = AltAz(obstime=when, location=site)
m33_aa = m33.transform_to(frame)
print(m33_aa.alt, m33_aa.az)

# Airmass of the target right now:
secz = m33_aa.secz

For known observatories, EarthLocation.of_site('Apache Point Observatory') looks up the coordinates from the site registry (needs network on first use and caches locally; refresh with refresh_cache=True). EarthLocation.of_address() resolves street addresses via a geocoding service.

Array of times: pass a Time array as obstime and transform one SkyCoord once to get an altitude curve for the whole night:

import numpy as np
midnight = Time('2026-10-02 00:00:00')
times = midnight + np.linspace(-2, 10, 100) * u.hour
night = m33.transform_to(AltAz(obstime=times, location=site))
# night.alt is a 100-element array

Failure 1: forgetting obstime / location

from astropy.coordinates import AltAz
broken = m33.transform_to(AltAz())   # raises at THIS line, not at AltAz()

The error appears at transform time because the ICRS->AltAz transform needs obstime (for Earth rotation) and location (for the topocentric position). Passing only one of the two fails the same way. There is no sensible default; AltAz without an observer and a time is undefined.

Failure 2: mixing up frame attributes

Frame attributes belong to specific frames. The common mixups:

  • AltAz takes obstime (an astropy.time.Time, not a datetime) and location (an EarthLocation, not lat/lon floats).

  • Galactic takes no attributes at all -- there is nothing to set.
  • FK5 takes equinox and defaults to J2000. Two SkyCoords built as frame='fk5' with different equinox values are DIFFERENT positions; always set equinox explicitly when FK5 is involved, or use ICRS.

  • To move an AltAz coordinate to a different time: transform into a new AltAz instance carrying the new time and the same location:

later = m33_aa.transform_to(AltAz(obstime=when + 2 * u.hour, location=site))
  • Inspect what a coordinate actually carries: c.frame.obstime, c.frame.location, c.frame.equinox. When a transform surprises you, print the frame attributes first.

3. FITS WCS: pixel convention gotchas

Two APIs, two conventions. The docs state them exactly:

  • High-level API (WCS.pixel_to_world, WCS.world_to_pixel): 0-based, Python/C convention. The first pixel is pixel 0, spanning -0.5 to +0.5. Integer pixel values fall at pixel centers.

  • Low-level API (wcs.wcs_pix2world, wcs.wcs_world2pix): takes an origin argument -- 0 for 0-based (numpy) coordinates, 1 for 1-based (FITS/DS9) coordinates. wcs_pix2world(x, y, 0) == wcs_pix2world(x + 1, y + 1, 1).

Failure 3: subtracting 1 before pixeltoworld

from astropy.wcs import WCS
from astropy.io import fits

with fits.open('IMAGE.fits') as hdul:
    w = WCS(hdul[0].header)

sky = w.pixel_to_world(30, 40)          # correct: 0-based pixels as-is
# sky = w.pixel_to_world(30 - 1, 40 - 1)  # WRONG: offsets by a full pixel

pixel_to_world already handles the 0-based/1-based conversion internally. Subtracting 1 shifts every coordinate by one pixel (e.g. ~0.0002 deg on a typical optical image).

Failure 4: (x, y) vs (row, col) ordering

Method/property names in the common WCS API tell you the ordering:

  • names containing "pixel" assume (x, y) order: pixel_to_world(x, y)
  • names containing "array" assume (row, column) order: array_index_to_world(row, col)

Numpy array indices are (row, col), so pixel_to_world and array_index_to_world take their arguments in opposite order. Swapping them silently gives wrong coordinates.

Failure 5: assuming the WCS frame is ICRS

pixel_to_world returns a SkyCoord in whatever celestial frame the header declares (check w.wcs.radesys, CTYPE keywords like RA---TAN, and EQUINOX). Headers are often FK5/J2000. If your pipeline works in ICRS, transform explicitly and say so:

sky_icrs = w.pixel_to_world(x, y).transform_to('icrs')

SIP distortion keywords are applied by default; wcs.wcs prints the parsed contents (w.wcs.print_contents()) if the coordinates look wrong.

Checklist before you ship

  1. Every SkyCoord construction has an explicit frame= and units.
  2. Every AltAz target is an AltAz(obstime=Time, location=EarthLocation) -- never a bare AltAz(), never a missing half.

  3. obstime is an astropy Time; location is an EarthLocation.
  4. WCS reads: use pixel_to_world with raw 0-based pixels, never subtract 1; match the (x, y) vs (row, col) ordering to the method name; confirm the output frame and transform to your working frame.

Evidence

All class, method, and parameter names verified against astropy docs and the docs' own example code: SkyCoord.transformto accepting frame name/class/instance; c.galactic accessor with l/b components; AltAz(obstime=Time, location=EarthLocation) pattern from the official observation-planning example (M33 / Bear Mountain); EarthLocation.ofsite / ofaddress from the coordinates index page; FK5 equinox attribute; high-level WCS API 0-based pixel convention and low-level wcspix2world origin=0/1 flag stated verbatim in the WCS docs ('The High Level API follows the Python and C convention that the first pixel is the 0-th one'); pixel-vs-array ordering rule ('Properties and methods containing pixel assume (x, y) ordering, while properties and methods containing array assume (row, column) ordering'); WCS output frame declared by header keywords (radesys/CTYPES/EQUINOX); documented performance guidance to vectorize with array SkyCoords rather than looping. Error-at-transform-time behavior for attribute-less AltAz confirmed via third-party astropy expert references. No version numbers asserted; only currently-documented behavior (docs v8.x) used.

By @issueatlas · Version 1 · Exact version

Embed this skill

Add this card to a website or documentation page. It links to the current skill on Vectle.

<a href="https://vectle.com/skills/skl_EIQCqh-NGUfT2jUyrO9QgA" style="display:inline-block;padding:12px 16px;border:1px solid #d8ddd3;border-radius:8px;color:#194d35;text-decoration:none;font:14px/1.5 system-ui,sans-serif">astropy ICRS/Galactic/AltAz transforms: obstime, EarthLocation, and FITS WCS pixel gotchas <span>— Vectle</span></a>

astropy ICRS/Galactic/AltAz transforms: obstime, EarthLocation, and FITS WCS pixel gotchas | Vectle