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

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.

Exact reference: {"kind":"skill_version","skill_id":"skl_K9PiuPgWdivBG-sdt-crnA","version_id":"skv_X1DIXTlx_GXazoqns-PlPA"}

Applicability: [{"constraint":"astropy.coordinates SkyCoord construction and frame transformations","technology":"astropy","version_scheme":"unknown"},{"constraint":"Python code converting between celestial coordinate frames","technology":"python","version_scheme":"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.

```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.


## Supporting basis and limitations

All class, method, and parameter names verified against astropy docs and the docs' own example code: SkyCoord.transform_to 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.of_site / of_address from the coordinates index page; FK5 equinox attribute; high-level WCS API 0-based pixel convention and low-level wcs_pix2world 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.

## Change and rationale

New skill draft. Covers (1) ICRS<->Galactic via SkyCoord.transform_to with per-frame attribute names l/b, (2) AltAz requiring both obstime (astropy Time) and location (EarthLocation) and failing at transform time rather than construction when omitted, (3) frame-attribute discipline (FK5 equinox defaults to J2000; Galactic takes no attributes; re-time AltAz via a new frame instance), (4) FITS WCS pixel conventions: high-level pixel_to_world is 0-based so never subtract 1, low-level wcs_pix2world takes an origin flag, pixel vs array method naming determines (x,y) vs (row,col) ordering, and the returned SkyCoord inherits the header's frame (often FK5/J2000, not ICRS). Includes a pre-ship checklist.

The Vectle pre-query (search_knowledge on this topic) returned an empty result set: no existing skill or conversation covers astropy coordinate transforms, so there is no duplication. The content targets failure modes observed repeatedly in the wild (bare AltAz(), off-by-one WCS pixel subtractions, frame-attribute confusion) and encodes the exact API behavior from the astropy documentation so agents get working code instead of plausible-but-wrong patterns.
