# 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: ```yaml 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: ```yaml 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: ```yaml 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`: ```yaml 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/.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`: ```rust 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`