1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
//! FT4-specific coarse-candidate stage — faithful port of WSJT-X
//! `getcandidates4.f90` + `ft4_baseline.f90`.
//!
//! Lives in `engine`, not `ft4`, alongside `engine::sync2d::ft4_sync_search`
//! for the same reason that function does: `engine` is compiled regardless
//! of which protocol features are enabled, and `engine::pipeline` needs to
//! call this unconditionally (gated only by a runtime `P::ID == Ft4`
//! check, matching the existing `ft4_sync_search` wiring) — a
//! `crate::ft4::*` reference from `engine` would force the `ft4` feature
//! on for every build.
//!
//! `engine::sync::coarse_sync` (used by every other protocol still on the
//! generic path) is a 2-D (freq × lag) Costas-array correlation search —
//! correct for FT8/FST4/etc, but a structurally different algorithm from
//! what WSJT-X actually does for FT4. `getcandidates4.f90` never searches
//! a lag/Δt dimension at all: it's a pure frequency-domain periodogram —
//! Nuttall-windowed FFT over *overlapping* `NFFT1 = 4×NSPS`-sample
//! segments stepped by one full symbol (`NSTEP=NSPS`, `ft4_params.f90:14`
//! — not the generic function's `NSTEP_PER_SYMBOL`-based fractional
//! step), time-averaged into `savg`, 15-bin boxcar-smoothed into `savsm`,
//! normalised by a 5-term-polynomial baseline (`ft4_baseline.f90`,
//! already a faithful, unit-tested port at
//! [`crate::engine::baseline::fit_baseline`]), and reduced to **one
//! candidate per local-max frequency peak** (parabolic sub-bin
//! interpolation). FT4's actual Δt determination happens entirely later,
//! in the already-faithful [`crate::engine::sync2d::ft4_sync_search`]
//! (an *absolute* full-window coherent search that ignores whatever
//! `dt_sec` a coarse candidate carries).
//!
//! Because of that, the generic function's up-to-8 lag-distinct
//! candidates per frequency bin are functionally redundant downstream for
//! FT4: each independently pays the full `ft4_sync_search` + LLR + BP +
//! OSD cost in `process_candidate_basic` and — for a real signal —
//! converges on the same refined position and decode outcome. Measured
//! on the WSJT-X FT4 golden WAV (`ft4_diag_candidate_cost_split`,
//! `tests/ft4_sweep.rs`): the generic search emits 2000 candidates across
//! only 440 distinct frequencies (4.5× redundancy), and the dominant
//! per-candidate cost by far is `ft4_sync_search` itself (5.09 s summed,
//! vs 1.46 s inferred LLR+BP+OSD, out of ~6.65 s total) — so a faithful
//! one-candidate-per-frequency port should cut wall-clock roughly by that
//! redundancy factor. See `~/.claude/plans/dapper-soaring-nest.md`.
//!
//! Note: `Ft4::SPECTRUM_WINDOW` is `Rectangular` (`ft4/mod.rs`) with a
//! doc comment explaining *why* — Nuttall was tried on the *generic*
//! `coarse_sync` (paired with its crude 40th-percentile floor, not the
//! real polynomial baseline) and reverted for misranking signal bins
//! against sidelobes on the synth-roundtrip tests. That's the
//! mismatched-pairing trap this module avoids: Nuttall windowing here is
//! paired with its *actual* WSJT-X counterpart, `fit_baseline`, not the
//! generic function's unrelated floor estimator.
//!
//! Deviation from WSJT-X, deliberate: `getcandidates4.f90` collects
//! candidates in frequency-scan order and stops once `maxcand` array
//! slots fill (a Fortran fixed-array convenience, not an intentional
//! ranking), then reorders only by nfqso-proximity. This module instead
//! sorts by score (falling back to `freq_hint` proximity, matching
//! `engine::sync::coarse_sync`'s own convention) before truncating to
//! `max_cand` — consistent with how every other caller in this pipeline
//! (dedup, sniper mode) already treats `SyncCandidate::score` as a
//! genuine ranking, not an FFI-array-fill leftover.
use vec;
use Vec;
use Complex;
use Float;
use fit_baseline;
use with_default_planner;
use ;
/// `NSPS` (samples/symbol at 12 kHz) — `ft4_params.f90:9`.
const NSPS: usize = 576;
/// `NFFT1 = 4×NSPS` — `ft4_params.f90:13`. Four-symbol-wide analysis
/// window (75% overlap at `NSTEP=NSPS`), giving 4× frequency
/// oversampling vs a single-symbol FFT.
const NFFT1: usize = NSPS * 4;
/// `NH1 = NFFT1/2` — positive-frequency bin count.
const NH1: usize = NFFT1 / 2;
/// `NSTEP=NSPS` — full-symbol coarse step (`ft4_params.f90:14`).
/// Deliberately *not* `Ft4::NSTEP_PER_SYMBOL` (that constant belongs to
/// the generic Costas-lag search this module replaces for FT4).
const NSTEP: usize = NSPS;
/// `df = 12000/NFFT1` — `getcandidates4.f90:27`.
const DF_HZ: f32 = 12_000.0 / NFFT1 as f32;
/// `f_offset = -1.5*12000/NSPS` — `getcandidates4.f90:51`.
const F_OFFSET_HZ: f32 = -1.5 * 12_000.0 / NSPS as f32;
/// WSJT-X hardcodes these regardless of caller `fa`/`fb`
/// (`getcandidates4.f90:45,47`).
const FREQ_HARD_MIN_HZ: f32 = 200.0;
const FREQ_HARD_MAX_HZ: f32 = 4910.0;
/// Time-averaged linear-power periodogram, length `NH1`. Matches
/// `getcandidates4.f90:29-38`: Nuttall-windowed `NFFT1`-point FFT over
/// `NSTEP`-strided, `NFFT1`-wide (overlapping) segments of raw 12 kHz
/// PCM, averaged across all segments.
/// FT4 coarse-candidate stage: one candidate per frequency-domain local
/// peak, faithful to `getcandidates4.f90`. Signature matches
/// `engine::sync::coarse_sync::<Ft4>` — a drop-in replacement at call
/// sites.
///
/// `dt_sec` on every returned candidate is `0.0` — genuinely unused
/// downstream: `engine::sync2d::ft4_sync_search` (called next in
/// `process_candidate_basic` for `P::ID == Ft4`) searches the absolute
/// time window regardless of the candidate's own `dt_sec`.