Telescope Profiles#

Telescope profiles define how FITS keywords are mapped to canonical field names, what unit conversions to apply, and observatory metadata.

Profile Format#

Profiles are YAML files in the profiles/ directory:

name: ccat
version: 2

match_rules:
  - TELESCOP: "CCAT"
  - TELESCOP: "FYST"

keyword_map:
  mjd: MJD-OBS
  elevation: ELEVATIO
  azimuth: AZIMUTH
  signal_freq: OBSFREQ
  sobsmode: SOBSMODE
  otf_lon_base: LAMOFF
  otf_lon_pixel: PIXOFFX
  otf_lat_base: BETOFF
  otf_lat_pixel: PIXOFFY

unit_conversions:
  signal_freq: MHz_to_Hz
  elevation: deg_to_rad
  otf_lon: arcsec_to_deg

inject:
  observatory: CCAT
  site_latitude_deg: -22.9586
  site_longitude_deg: -67.7875
  site_altitude_m: 5612.0

defaults:
  freq_off: 0.0
  tcold: 77.0

atm_table:
  freq_min_ghz: 200.0
  freq_max_ghz: 1200.0
  freq_step_mhz: 1.0
  pwv_low_mm: 0.05
  pwv_high_mm: 0.50

Scan Metadata Passthrough#

The optional scan_metadata section maps snake_case attribute names to FITS keywords that are carried verbatim into L0 scan-group attributes and then propagated unchanged into L1 scan-group attributes by the calibration engine:

scan_metadata:
  mission_id: MISSN-ID
  obs_id: OBS_ID
  plan_id: PLANID
  aor_id: AOR_ID
  aot_id: AOT_ID

Unlike keyword_map (which feeds the calibration math via canonical fields), scan_metadata is pure identity / QA passthrough — the values never affect calibration. Values are read as strings (numeric headers are stringified) and keywords absent from a header are simply omitted. This lets downstream tools (e.g. SOFIA flight / ground-day grouping) read identifiers directly off L0/L1 instead of reparsing raw FITS, and lets new keywords be added with a YAML edit only — no Rust change. The sofia_upgreat profile folds in the kalibrate header set (user_sofia / housekeeping tags) plus operationally useful QA/atmosphere keywords (WVZ_STA, ZA_START, TAMBSOFI, …).

In addition, the L0 reader records a representative scan mjd (the first finite subscan MJD) and propagates it to L1 alongside telescope and date_obs.

Per-Pixel Focal-Plane Offsets#

Multi-pixel arrays (e.g. SOFIA upGREAT) place each receiver at a different focal-plane position. The converter stores these as pixel_offset_lon[R, A, S] / pixel_offset_lat[R, A, S] (degrees) so each pixel gets its correct sky footprint. How they are read from a header is selected by the optional pixel_offset field:

pixel_offset: kosma_versioned   # default: direct

Strategy

Behaviour

direct (default)

Read otf_lon_pixel / otf_lat_pixel (PIXOFFX/PIXOFFY) straight from the header. Correct for CCAT/FYST and any standard that writes the offset directly. Profiles that omit pixel_offset keep this behaviour.

kosma_versioned

KOSMA/SOFIA version-gated reconstruction. Needed for pre-FITSVERS-2.12 data, which has no PIXOFFX/PIXOFFY.

The kosma_versioned strategy mirrors the legacy reader (kalibrate buffers.cpp) and is gated on FITSVERS (parsed as integer major.minor, so 2.10 is version 2.10, not decimal 2.1):

  • FITSVERS > 2.11 → use PIXOFFX/PIXOFFY directly (same as direct).

  • 2.8 < FITSVERS 2.11 → rotate the active pixel’s offset relative to the reference pixel, RXDX REFRXDX / RXDY REFRXDY, into the map frame by POSANGLE + ANGLEDIF, applying SIGNRXDX / SIGNRXDY after rotation.

  • FITSVERS 2.8 → as above, but the sign is applied before rotation and the COSYDEL frame conventions flip the longitude/latitude sign.

This requires the legacy keywords to be present in keyword_map:

keyword_map:
  fits_version: FITSVERS
  rxdx: RXDX
  rxdy: RXDY
  refrxdx: REFRXDX
  refrxdy: REFRXDY
  signrxdx: SIGNRXDX
  signrxdy: SIGNRXDY
  posangle: POSANGLE
  anglediff: ANGLEDIF
  cosydel: COSYDEL

pixel_offset: kosma_versioned

A new metadata standard that encodes per-pixel offsets differently adds a new PixelOffsetStrategy variant in coords.rs — the keyword names stay in the profile, so no other wiring changes. The writer logs a warning if a multi-pixel scan ends up with every per-pixel offset exactly zero (a footprint collapse).

Note

Per-subscan rotation (issue #48). The KOSMA offset rotates with POSANGLE + ANGLEDIF, which changes per subscan as the array turns on sky, so the offset is stored per-subscan as pixel_offset[R, A, S] (L0 schema ≥ 2, L1 schema ≥ 1.3). The direct (PIXOFFX) path is the same: PIXOFFX is itself the pre-rotated offset, written once per subscan. The earlier [R, A] layout kept only the last subscan and applied its rotation to every dump — on real M51 OTF scan 013690 (POSANGLE sweeps 30°) that mis-placed ring pixels by up to ~17″, larger than the 14″ beam. Readers (cal-io, yoda) stay backward-compatible with old [R, A] stores by broadcasting them as constant across subscans.

Profile Auto-Detection#

When no --profile override is given, profiles are tested against the first FITS file’s header in alphabetical order. The first profile whose match_rules all match is selected. If none match, generic.yaml is used as fallback.

Embedded Profiles#

Profiles are embedded at compile time via include_str! in zarr-fits-core/src/embedded_profiles.rs. This means the binary works without an external profiles/ directory.

Current profiles:

  • ccat.yaml — CCAT/FYST telescope

  • sofia_upgreat.yaml — SOFIA/upGREAT airborne observatory

  • generic.yaml — fallback for unknown telescopes

Adding a New Telescope#

  1. Create profiles/<telescope_name>.yaml

  2. Define match_rules for auto-detection from FITS headers

  3. Map all required keywords in keyword_map

  4. Set inject fields for observatory metadata

  5. Add the file to embedded_profiles.rs:

    pub const NEW_TELESCOPE_YAML: &str =
        include_str!("../../../profiles/new_telescope.yaml");
    
    pub const ALL: &[(&str, &str)] = &[
        ("sofia_upgreat.yaml", SOFIA_UPGREAT_YAML),
        ("ccat.yaml", CCAT_YAML),
        ("new_telescope.yaml", NEW_TELESCOPE_YAML),  // ← add
        ("generic.yaml", GENERIC_YAML),
    ];
    
  6. Rebuild and test with sample FITS data

Implementation: zarr-fits-core/src/profile.rs