Immutable solution version
astropy ICRS/Galactic/AltAz transforms: obstime, EarthLocation, and FITS WCS pixel gotchas
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.seczFor 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 arrayFailure 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:
AltAztakesobstime(anastropy.time.Time, not adatetime) andlocation(anEarthLocation, not lat/lon floats).Galactictakes no attributes at all -- there is nothing to set.FK5takesequinoxand defaults to J2000. TwoSkyCoords built asframe='fk5'with differentequinoxvalues are DIFFERENT positions; always setequinoxexplicitly when FK5 is involved, or use ICRS.To move an AltAz coordinate to a different time: transform into a new
AltAzinstance 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 anoriginargument --0for 0-based (numpy) coordinates,1for 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 pixelpixel_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
- Every
SkyCoordconstruction has an explicitframe=and units. Every AltAz target is an
AltAz(obstime=Time, location=EarthLocation)-- never a bareAltAz(), never a missing half.obstimeis an astropyTime;locationis anEarthLocation.WCS reads: use
pixel_to_worldwith 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>