Patrick Lidstone
Self-hosted

NOAA APT: a native weather-satellite line-scan fax decoder

A from-scratch reproduction spec for the in-repo NOAA APT decoder — the 2400 Hz AM subcarrier on a 137 MHz FM downlink recovered by Hilbert envelope detection, resampled to the 4160-word/s pixel clock, and sync-locked into a two-channel greyscale image by cross-correlating each line against the 1040 Hz sync-A burst.

Rafe project · app/radio/sat/apt.py (native, clean-room) · app/radio/satdecode.py (the live SatDump-driven path) · experimental


1. Abstract

Automatic Picture Transmission (APT) is the analog, low-resolution image broadcast that the NOAA-15/18/19 polar-orbiting weather satellites still radiate continuously on ~137 MHz. It is a line-scan facsimile: the spacecraft's AVHRR radiometer sweeps a mirror across the ground, and each scan becomes one horizontal line of an image that is painted, pixel by pixel, as the amplitude of a 2400 Hz audio subcarrier which in turn frequency-modulates the RF carrier. Because the picture lives on an audio subcarrier inside a narrowband FM signal, APT is the one weather-satellite mode a plain communications receiver can copy — you feed the receiver's FM discriminator audio to a decoder and it reconstructs the image, no wideband SDR required.

This repo carries two independent APT paths. The one documented here is the native, clean-room decoder in app/radio/sat/apt.py (2.5 KB of NumPy): it AM-demodulates the 2400 Hz subcarrier by taking the magnitude of the analytic signal (scipy.signal.hilbert), linearly resamples the envelope to the APT word rate of 4160 words/s, contrast-stretches it to 8-bit greys, and locks the 2080-word line grid by cross-correlating the pixel stream against the canonical sync-A pattern — 7 cycles of a 1040 Hz square wave — using a folded correlation that sums sync energy across every line to estimate one global line phase. The result is a rows × 2080 uint8 raster containing both video channels (A and B) side by side. A companion synth() builds a matching AM-on-2400 Hz test signal, and test_sat_apt.py round-trips a synthetic image clean, under additive noise, and through an arbitrary capture lead-in. The second path (app/radio/satdecode.py, summarised in §2.4 and §7) is the live one wired into the app: it shells out to SatDump's noaa_apt pipeline driving the LimeSDR. The novelty of the native decoder is not radiometric fidelity — it does none of the calibration a mature package does — but that the entire APT demod-and-sync problem reduces to an envelope detector, one np.interp, and one np.correlate, and still recovers a pixel-accurate image.


2. Background

2.1 APT is a scanning fax, not a data protocol

APT descends directly from radio facsimile. There is no packetisation, no error-correcting code, no digital framing on the air — nothing to "decode" in the sense the WSJT-X or fldigi modes are decoded. The satellite's AVHRR instrument nods a scan mirror across the sub-track at a fixed cadence; the brightness measured along one sweep is a line of the picture; and that line is transmitted as a slowly-varying amplitude. Sender and receiver need only agree on three things — the subcarrier frequency, the pixel (word) clock, and the line length and sync markers — and, exactly as with Feld Hell (§2.1 of feldhell.md), a picture appears. Legibility is a property of the human eye looking at the assembled raster, not of any bit being correct. That is why APT survives on cheap hardware and marginal signals: a noisy line is a speckled line, not a lost frame.

The AVHRR line rate is 2 lines per second. At 4160 words/s that is exactly 2080 words per line (4160 / 2080 = 2), where one word is one 8-bit greyscale pixel. A pass lasts ~12–15 minutes horizon-to-horizon, so a good pass yields on the order of 1500–1800 lines — a swath ~2900 km wide at roughly 4 km ground resolution per pixel.

2.2 The 2400 Hz AM subcarrier

The picture is not sent at baseband. The line-video signal amplitude-modulates a 2400 Hz audio subcarrier; that AM subcarrier then frequency-modulates the 137 MHz RF carrier. So the on-air signal is AM-on-FM, and reception is a two-stage demodulation:

  1. FM demodulation — done by the receiver. An ordinary NBFM discriminator (the IC-705, an RTL dongle in FM mode, anything) turns the 137 MHz signal back into the audio-band subcarrier. The decoder never sees RF; it starts from this discriminator audio. apt.py's docstring is explicit: "Decodable from a communications rx's FM audio (no wideband SDR)" (app/radio/sat/apt.py).
  2. AM demodulation — done by this decoder. The line brightness is the envelope of the 2400 Hz tone: a bright pixel is a large-amplitude burst of 2400 Hz, a black pixel is near-silence. Recovering the picture means envelope-detecting that subcarrier (§4).

Choosing 2400 Hz is deliberate: it sits comfortably inside a 3 kHz voice passband, and at the 4160 word/s pixel rate the double-sideband AM occupies roughly 2400 ± 2080 Hz ≈ 0.3–4.5 kHz — the audio bandwidth an FM satellite receiver passes. The subcarrier completes 2400 / 4160 ≈ 0.577 cycles per pixel, so several audio samples cover each cycle and the envelope is well defined.

2.3 The two video channels A and B

Each 2080-word line is two independent video frames transmitted back-to-back: channel A (words 0–1039) and channel B (words 1040–2079). Each frame is one AVHRR spectral band. In the usual daytime configuration channel A is a visible / near-IR band and channel B is a thermal-IR band; at night both are typically thermal-IR. Each frame is not just image pixels — it is a self-describing little structure of its own:

  one frame (1040 words) = sync | space marker | 909 image pixels | telemetry

so a full line is [syncA | spaceA | videoA(909) | telA] [syncB | spaceB | videoB(909) | telB]. The sync distinguishes the two channels: channel A is prefixed by a 1040 Hz burst, channel B by an 832 Hz burst, so a decoder can tell which half of the line it is looking at from the sync tone alone. The telemetry columns at the end of each frame are a slow greyscale staircase (the calibration "wedges", §5.5) that a full decoder uses to convert raw greys into radiometric values. The exact word budget is given in §3.

2.4 Two decode paths in Rafe

It is worth being clear which code does what, because both exist:

path file how it works wired into the app?
native (this doc) app/radio/sat/apt.py pure-NumPy Hilbert AM demod + sync-A correlation, from FM audio no — exercised by test_sat_apt.py
live (SatDump) app/radio/satdecode.py shells out to satdump live noaa_apt driving the LimeSDR from RF yes — SatDecoder, via manager.sat_decode

The package docstring frames the native family as "a focused native pipeline reusing this repo's DSP/FEC … Not a SatDump clone" (app/radio/sat/__init__.py). The live UI button for a NOAA pass ultimately runs SatDump (§7.2); the native decoder is the from-scratch reference implementation that proves the mode can be recovered with nothing but the audio and a dozen lines of DSP. Everything from §3 onward describes the native decoder unless stated otherwise.


3. Signal and line structure — the exact numbers

The three primitives the whole decoder is built on are module constants:

quantity symbol value source
pixel / word rate WORD_RATE 4160 words/s apt.py
words per line LINE_WORDS 2080 apt.py
AM subcarrier SUBCARRIER 2400.0 Hz apt.py
lines per second 4160/2080 = 2 derived
words per frame (½ line) 2080/2 = 1040 derived
image pixels per line (A+B) 2×909 = 1818 §2.3 / real spec

One line is two frames; one frame is 1040 words laid out as follows. These sub-field widths are the APT specification values (the native decoder models only the sync explicitly — see the note below):

field channel A channel B words what it is
sync sync A sync B 39 7-cycle square-wave burst (1040 Hz A / 832 Hz B)
space marker black white 47 channel-space + minute/scan markers
video image A image B 909 the 8-bit greyscale line-half
telemetry wedges A wedges B 45 16-step calibration staircase
frame total 1040 39 + 47 + 909 + 45
line total 2080 two frames = LINE_WORDS

39 + 47 + 909 + 45 = 1040, and 2 × 1040 = 2080 = LINE_WORDS — the arithmetic closes exactly. The sync-A burst is 7 cycles of a 1040 Hz square wave: at 4160 words/s a 1040 Hz cycle is 4160/1040 = 4 words, so 7 cycles span 28 words, padded to 39 by a 4-word lead-in and a 7-word tail (§6.1). Sync B is 7 cycles of 832 Hz, i.e. 4160/832 = 5 words per cycle → 35 words, similarly padded.

Implementation note. The native decoder hard-codes only the sync-A pattern (SYNC_A, apt.py) and treats a line as the flat 2080-word slab beginning at the sync-A phase. It does not carve out the space/video/ telemetry sub-fields, does not define a SYNC_B template, and does not split A from B into separate images — channel B simply occupies the right half of every output row (words 1040–2079). The sub-field table above is the real-signal structure a downstream consumer (or a future extension) would use; it is documented here because it is what the 2080 words contain, not what the code currently parses.


4. Demodulation, step by step

The decoder's front end is _am_words(audio, fs) (apt.py) followed by the greyscale map _to_px (apt.py). Input is real FM-discriminator audio at any sample rate fs (the tests use 48 000 Hz, test_sat_apt.py).

4.1 FM is already done

By the time audio reaches decode(), the 137 MHz FM carrier has been discriminated by the receiver. What remains in the audio is the 2400 Hz AM subcarrier whose envelope is the line video. Nothing in apt.py touches RF or FM — the decoder is a pure audio-in image-out function.

4.2 AM envelope detection via the Hilbert transform

env = np.abs(hilbert(np.asarray(audio, float)))       # AM envelope

scipy.signal.hilbert(x) returns the analytic signal x + j·ℋ{x}, whose magnitude is the instantaneous envelope of a bandpass signal. For an AM tone a(t)·cos(2π·2400·t) with a(t) ≥ 0, |analytic| = a(t) — the 2400 Hz carrier is removed exactly and the line-video amplitude drops out directly, with no explicit low-pass filter. This is the textbook complex-envelope detector; the classical analog equivalent (rectify the tone, then low-pass) would give the same a(t) but with ripple that a filter must remove. The Hilbert route is both cleaner and one line of code, which is why it is used here (and identically in the Feld Hell tone detector — the same envelope trick recurs across the repo's AM/OOK modes).

4.3 Resample the envelope to the word rate

n = int(len(env) * WORD_RATE / fs)
return np.interp(np.arange(n) * fs / WORD_RATE,
                 np.arange(len(env)), env)

The envelope is at the audio rate (48 kHz). The picture lives on the 4160 word/s pixel clock, so the envelope is linearly resampled to WORD_RATE: n = len(env)·4160/fs output words, each sampled from env at fractional position k·fs/4160. np.interp does the linear interpolation. After this step the array is one value per APT pixel: this is the entire re-clocking of the signal to the word grid. (Linear interpolation is a deliberately cheap resampler — no polyphase anti-alias filter — which is adequate because the 1/99 percentile stretch that follows is robust to small interpolation error and the 2-lines/s redundancy hides the rest; §8 lists the trade-off.)

4.4 Greyscale mapping (contrast stretch)

def _to_px(words):
    lo, hi = np.percentile(words, [1, 99])
    return np.clip((words - lo) / (hi - lo + 1e-9) * 255, 0, 255)

Envelope amplitudes are arbitrary (they depend on receiver AF gain), so the word stream is normalised to 0–255 by a robust min/max: the 1st and 99th percentiles set black and white, everything is affinely mapped between them, and np.clip saturates the tails. Using percentiles rather than the raw min/max makes the stretch immune to a handful of noise spikes. The +1e-9 guards against a flat (constant) input. Note this is a display normalisation, not radiometric calibration — it does not use the telemetry wedges (§5.5, §8).


5. Decode, step by step — line synchronisation and assembly

decode(audio, fs) (apt.py) turns the demodulated, word-clocked, 0–255 pixel stream into a rows × 2080 image whose rows are aligned on sync A.

5.1 The sync-A matched template

px  = _to_px(_am_words(audio, fs))
pat = SYNC_A - SYNC_A.mean()

SYNC_A (§6.1) is the 39-word canonical channel-A sync burst as pixel values (0 = black, 255 = white). Subtracting its mean makes it a zero-DC matched filter, so the correlation that follows responds to the sync shape (the 1040 Hz alternation) and not to the average brightness of the line.

5.2 Cross-correlation against the pixel stream

corr = np.correlate(px - px.mean(), pat, "valid")

The demeaned pixel stream is cross-correlated with the demeaned sync template. corr[i] is large exactly where a sync-A burst begins in the stream, and small in image data (which has no 1040 Hz structure). This is a standard matched-filter line-start detector: the sync burst is the known pattern, and correlation peaks mark every line boundary. Demeaning both sides makes the peak invariant to overall gain and DC offset.

5.3 Folded correlation → one global line phase

A single line's sync peak can be corrupted by noise, so instead of trusting any one peak the decoder folds the correlation over the line period and sums:

nlines = len(px) // LINE_WORDS
scores = np.zeros(LINE_WORDS)
for o in range(LINE_WORDS):
    idx = o + np.arange(nlines) * LINE_WORDS
    idx = idx[idx < len(corr)]
    scores[o] = corr[idx].sum()
off = int(np.argmax(scores))

For each candidate phase o in 0 … 2079, it sums the correlation at that phase across every line (o, o+2080, o+2·2080, …). The phase whose summed sync energy is greatest is the true line start. This is a coherent average of the sync correlation over the whole pass: even if any one line's sync is buried in noise, the sum over hundreds of lines has a sharp maximum. off is the single, global word offset at which channel-A sync sits in every line. (If fewer than one full line is present, decode returns an empty 0 × 2080 image early, apt.py.)

5.4 Row assembly and the A/B split

rows = []
for k in range(nlines):
    s = off + k * LINE_WORDS
    if s + LINE_WORDS <= len(px):
        rows.append(px[s:s + LINE_WORDS])
return np.array(rows, np.uint8)

Starting at off, the stream is sliced into consecutive 2080-word rows; each becomes one image line. The output is nlines × 2080 uint8. Because every row is aligned so that word 0 is channel-A sync, the two video channels fall at fixed columns in the raster:

  • channel A occupies words 0 – 1039 (sync A, space, 909-px image A, telemetry A);
  • channel B occupies words 1040 – 2079 (sync B, space, 909-px image B, telemetry B).

The 909-px image of channel A sits at roughly columns 86–994 within its frame (after the 39-word sync + 47-word space) and channel B's image at the same offsets 1040 words later. The decoder returns both channels in one raster; it does not physically separate them — a viewer simply reads the left and right halves as the two bands, and any per-channel crop is a downstream concern.

5.5 Greyscale and the telemetry / calibration wedges

Each output pixel is already the 0–255 grey from §4.4. A fully calibrated APT decoder would go further and read the telemetry wedges — the 45-word column block at the end of each frame carries a 16-step modulation staircase (8 grey reference levels from black to white plus sensor/channel-ID wedges), repeating every 128 lines. Averaging those wedges over the frame gives the true black and white references and the channel identity, which is what maps raw greys onto calibrated albedo/brightness-temperature. The native decoder does none of this: _to_px's percentile stretch is its only greyscale normalisation, and the wedges pass through as ordinary pixels. Telemetry-wedge calibration is listed as future work (§8); the structure is documented in §3 so it can be added without re-deriving the frame layout.

5.6 The synthesiser (test-signal generator)

synth(image, fs, noise=0.0) (apt.py) is the exact inverse used to manufacture test audio:

words = np.asarray(image, float).reshape(-1) / 255.0
n_out = int(words.size * fs / WORD_RATE)
idx   = np.clip((np.arange(n_out) * WORD_RATE / fs).astype(int),
                0, words.size - 1)
up    = words[idx]                              # sample-and-hold
n     = np.arange(up.size)
sig   = (0.5 + 0.5 * up) * np.cos(2*np.pi*SUBCARRIER*n/fs)  # AM
if noise:
    sig = sig + np.random.default_rng(0).standard_normal(sig.size) * noise
return sig

The rows × 2080 image is flattened row-major to a 0–1 word stream, sample-and-hold upsampled from 4160 word/s to fs, and used to amplitude-modulate a 2400 Hz cosine. The modulation law (0.5 + 0.5·up) maps black→0.5 and white→1.0 of carrier amplitude — a 50 %-depth AM with a retained carrier, so even black pixels keep a half-amplitude 2400 Hz tone (matching real APT, where black is a small but non-zero subcarrier). Optional Gaussian noise (seeded RNG for reproducibility) models a marginal channel. This is exactly the signal _am_words expects, which is why the round-trip tests (§7.3) recover the image.


6. Constants and tables

6.1 The sync-A pattern (verbatim)

# sync A: a lead-in then 7 cycles of the 1040 Hz square wave (4 words/cycle)
SYNC_A = np.array([0, 0, 0, 0] + [255, 255, 0, 0] * 7 + [0] * 7, float)

Expanded, that is 39 words:

segment words value role
lead-in 4 0 0 0 0 black run before the burst
burst 28 (255 255 0 0) × 7 7 cycles, 4 words each → 1040 Hz square wave
tail 7 0 … 0 black run into the space marker
total 39 = the sync-A field width (§3)

Each 255 255 0 0 cycle is two white words then two black words: a 4-word period at 4160 words/s is exactly 4160/4 = 1040 Hz. Seven such cycles are the "7-cycle 1040 Hz pulse train" the APT spec calls sync A. SYNC_A is stored as float pixel values so it can be demeaned and correlated directly (§5.1). The array doubles as ground truth in the tests, which prepend it to every synthetic line (test_sat_apt.py).

6.2 Sync B and the other sub-fields (real spec, not coded)

The native decoder does not define these, but they complete the picture:

field value words note
sync B 7 cycles of 832 Hz 39 4160/832 = 5 words/cycle → 35 + padding; marks channel B
space marker black (A) / white (B) 47 channel space + minute/scan-line markers
telemetry wedges 16-step staircase 45 greyscale calibration + channel ID, 128-line cycle
video 8-bit greys 909 the image line-half
parameter value source
word (pixel) rate 4160 words/s apt.py
line length 2080 words apt.py
line rate 2 lines/s derived
AM subcarrier 2400 Hz apt.py
image pixels per channel 909 real spec / §3
test sample rate 48 000 Hz test_sat_apt.py
NOAA-15 downlink 137.620 MHz satellites.py
NOAA-18 downlink 137.9125 MHz satellites.py
NOAA-19 downlink 137.100 MHz satellites.py

All three NOAA birds are flagged "decode": "apt" in the curated satellite table (app/radio/satellites.py), so a click on a NOAA pass tunes 137 MHz FM and launches an APT decode.


7. Interoperability and validation

7.1 Versus wxtoimg / noaa-apt

The de-facto reference decoders are wxtoimg (the long-standing closed-source tool) and noaa-apt (Zbychu T.'s open-source Rust decoder). The native decoder's signal model is identical to theirs at the front end — a 2400 Hz AM subcarrier, 2080-word lines, sync-A at 1040 Hz — so a WAV that wxtoimg or noaa-apt decodes contains exactly the signal apt.py expects, and vice versa. Where the native decoder stops short of those tools:

  • no per-line resampling / PLL. wxtoimg and noaa-apt continuously re-estimate the sample-rate error (soundcard clock offset + residual Doppler) and resync every line. apt.py computes one global line phase (§5.3) and assumes the word rate is constant across the pass — fine for a synthetic signal at the exact rate, but a real recording drifts by a fraction of a pixel per line and would shear/curve without per-line resync (§8).
  • no radiometric calibration. No telemetry-wedge reading (§5.5), no false- colour, no thermal-IR temperature mapping, no map projection/geo-referencing.
  • no channel separation or enhancement (histogram equalisation, sharpening, precipitation overlays).

The native decoder is therefore best read as the reference core — the demod and line-sync that every APT decoder must get right — not as a wxtoimg replacement.

7.2 The live path: SatDump (satdecode.py)

The APT decode the app actually runs for a real pass is the SatDump path, so it is worth summarising the wiring:

  • SatDecoder (app/radio/satdecode.py) is constructed in the radio manager (app/radio/manager.py) and driven by manager.sat_decode(...) (manager.py), which is reached from the UI/WebSocket command in app/main.py.
  • For decode == "apt" it maps to SatDump pipeline noaa_apt (satdecode.py) and samples the SDR at 1 000 000 Hz (satdecode.py).
  • It launches satdump live noaa_apt <outdir> --source <SATDUMP_SOURCE> --samplerate 1000000 --frequency <hz> --general_gain <g> (satdecode.py), where SATDUMP_SOURCE defaults to "limesdr" (satdecode.py) — the LimeSDR Mini on the prod box, because the IC-705 cannot receive the wideband products.
  • It streams SatDump's stdout, forwarding SNR/lock/progress lines to the UI as status (satdecode.py), and on exit harvests the produced .png/.jpg image products from the output directory (satdecode.py).

So: native apt.py = clean-room, FM-audio-fed, no external binary, test-only; satdecode.py = SatDump-fed, SDR/RF, the live image the operator sees.

7.3 The tests (test_sat_apt.py)

Three tests exercise the native decoder end to end, all at FS = 48000 (test_sat_apt.py). Each builds a synthetic image whose every row starts with the real SYNC_A burst followed by a per-row greyscale ramp (_test_image, test_sat_apt.py):

test signal assertion
test_apt_roundtrip_clean decode(synth(img)) shape is ≥ h−2 × 2080 and mean per-row abs error < 10 (near pixel-exact)
test_apt_roundtrip_noisy synth(img, noise=0.03) recovered-vs-original row correlation > 0.9
test_apt_line_sync_phase 3000-sample zero lead-in prepended still line-synced, correlation > 0.85

The clean round-trip confirms demod + resample + sync + assembly are jointly pixel-accurate; the noisy test confirms the folded correlation (§5.3) locks under additive Gaussian noise; and the phase test confirms that an arbitrary capture offset (3000 samples of silence before the signal starts) is absorbed — the decoder finds the true line phase regardless of where in the buffer the image begins, which is exactly the real-world "you start recording mid-pass" case. _best_row_shift tolerates a ±2-line vertical slip when comparing, and _row_corr is the flattened-raster correlation.


8. Limitations

An honest inventory of what the native decoder does not do — mostly by design, since the live product path is SatDump:

  • No per-line clock recovery. One global line phase (§5.3) assumes a constant, exact 4160-word/s rate. A real recording has soundcard clock error and residual Doppler (a few tens of Hz of rate offset), so lines slowly walk — the image would slant/tear without per-line resync or a resampling PLL. This is the single biggest gap versus wxtoimg/noaa-apt on live audio.
  • No radiometric / telemetry-wedge calibration. _to_px is a display-only 1/99 percentile stretch (§4.4); the 16-step calibration wedges are ignored (§5.5). No thermal-IR temperature mapping, no albedo calibration, no false colour.
  • No channel splitting, projection, or enhancement. Channels A and B come out side by side in one raster (§5.4); there is no geo-referencing, map overlay, histogram equalisation, or precipitation product.
  • Linear-interpolation resample. np.interp (§4.3) has no anti-alias filter; it is adequate for the tests but a polyphase resampler would be cleaner on real captures.
  • Sync B unused. Only sync A (1040 Hz) is templated; sync B (832 Hz) is not defined, so the decoder cannot independently confirm the channel-B boundary — it relies on the fixed 1040-word offset within the line.
  • No AGC / fading tracking. A global percentile stretch does not follow a signal that fades across a pass; per-block normalisation would help.
  • No Doppler pre-correction, no pass scheduling inside apt.py — those live in the satellite-tracking layer (satellites.py) and the SatDump path.

None of these are architectural: the demod-and-sync core is correct and complete; the missing pieces (per-line resync, wedge calibration, channel split) are bounded additions on top of the rows × 2080 raster this decoder already produces.


9. Implementation and reproduction

file role
app/radio/sat/apt.py the native decoder: _am_words, _to_px, decode, synth
app/radio/sat/__init__.py native satellite package docstring (clean-room intent)
app/radio/satdecode.py the live SatDump-driven SatDecoder (RF/SDR path)
app/radio/satellites.py curated NOAA-15/18/19 downlinks + decode:"apt" flags
test_sat_apt.py round-trip, noisy, and phase-offset tests

Runtime API — the whole native decoder is two public functions:

from app.radio.sat import apt
import numpy as np

# RX: FM-discriminator audio (any sample rate) -> rows x 2080 uint8 image
img = apt.decode(fm_audio, fs)          # rows aligned on sync A; A|B side by side

# test/TX: rows x 2080 uint8 image -> AM-on-2400 Hz audio at fs
audio = apt.synth(img, fs, noise=0.0)   # (0.5 + 0.5*px) AM of a 2400 Hz cosine

To reproduce the whole mode from scratch you need only §3's three constants, the 39-word SYNC_A of §6.1, and five DSP steps: (1) |hilbert| envelope-detect the audio; (2) np.interp it to 4160 word/s; (3) percentile-stretch to 0–255; (4) demean and np.correlate against SYNC_A, then fold the correlation over the 2080-word period to find the line phase; (5) slice into 2080-word rows. Everything is NumPy + one SciPy call (scipy.signal.hilbert); there is no learned table, no FEC, and no external binary.


10. References

  1. NOAA KLM User's Guide (NOAA/NESDIS), §4 "APT" — the authoritative APT signal description: 2080 words/line, 2 lines/s, 2400 Hz AM subcarrier, sync-A 1040 Hz / sync-B 832 Hz, the space/telemetry frame structure, and the 16-wedge calibration staircase.
  2. NOAA-15/18/19 AVHRR/3 instrument documentation — the scan geometry, ~4 km APT resolution, and the visible/near-IR + thermal-IR channel assignment behind video channels A and B.
  3. wxtoimg — the long-standing reference APT decoder (calibration, projection, enhancement) the native core is measured against (§7.1).
  4. noaa-apt (Martín Bernardi, open-source Rust) — a modern open reference implementing the per-line resampling and sync tracking apt.py omits (§8).
  5. SatDump — the SDR toolkit whose noaa_apt pipeline is the app's live decode path (app/radio/satdecode.py, §7.2).
  6. In-repo companions: feldhell.md (the same Hilbert-envelope OOK/AM detector on a scanning-fax mode) and ../rvqvoice.md (the depth/structure benchmark for these specs).