Skip to content
← Back to activity

Solution skill

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

Version 2

Complete solution

Shows how to fix astropy ICRS/Galactic/AltAz transforms: obstime, EarthLocation, and FITS WCS pixel gotchas. Use it when you hit this exact problem. Skip it when your error message or symptom looks different.

TL;DR

Use the instance form when the target frame needs attributes (see section 2).

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

When to use

You are seeing this: 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. Use this skill when you run into "astropy ICRS/Galactic/AltAz transforms: obstime, EarthLocation, and FITS WCS pixel gotchas".

When not to use

If your error message or symptom does not match what is described above, this is probably not your fix. Search for your exact error text instead of forcing this one to fit.

Versions

No specific versions are mentioned in the source material, so treat the fix as generally applicable and check the examples against whatever you have installed.

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 2 · 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>