# 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.
```python
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`.
```python
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:
```python
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
```python
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 `SkyCoord`s 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:
```python
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 pixel_to_world
```python
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:
```python
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.