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
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
//! FST4's real SNR formula — port of `get_candidates_fst4.f90`'s
//! baseline extraction (not its CLEAN candidate-detection loop) and
//! `fst4_decode.f90:585-621`'s `xsnr` formula (issue #255).
//!
//! Wired into `fst4/decode.rs`'s `GenericPipelineProtocol::snr_db`
//! override. Verified against a real local `jt9 -7 -d3` build's own
//! probed values (`WSJT-X/samples/FST4+FST4W/210115_0058.wav`,
//! `SNRAUDIT_FST4_PROBE`/`SNRAUDIT_FST4_BM` instrumentation added to
//! `fst4_decode.f90` for this investigation, not committed there —
//! issue #255's stated verification discipline):
//!
//! | candidate | jt9 `xsnr` | this module's `xsnr` |
//! |-----------|------------|-----------------------|
//! | N5TM | -6.90 dB | -8.61 dB |
//! | K9KFR | 16.14 dB | 16.82 dB |
//!
//! Getting there took two corrections past the naive port:
//!
//! 1. **RMS-normalisation mismatch.** This crate's shared pipeline
//! RMS-normalises the downsampled baseband before
//! [`crate::engine::llr::symbol_spectra`] ever runs (needed for
//! `compute_llr`'s scale calibration elsewhere, issue #18);
//! WSJT-X's own FST4 path never does this. Left as-is, `xsig` came
//! out ~constant across both real signals despite their ~23 dB
//! real SNR difference. Fixed by [`fst4_raw_cs`] rebuilding `cs`
//! fresh from `fft_cache` without that normalisation step, rather
//! than trying to algebraically undo it on the already-normalised
//! version — see its own doc comment for why an earlier "multiply
//! by the mean pre-normalisation power" attempt wasn't quite right
//! either (it's algebraically exact only when the fine-refine
//! frequency offset is negligible, which happened to be true for
//! both of this file's candidates but isn't guaranteed in general).
//! 2. **Downsample scale convention mismatch.** Even with (1) fixed,
//! both candidates' `xsig` were still short by a *consistent*
//! ~100-115× in power — confirmed via a deeper `SNRAUDIT_FST4_BM`
//! probe comparing individual `s4(tone,symbol)` values directly
//! (not just the final `xsnr`), which is what separated "one
//! missing constant factor" from the false impression a coarser,
//! dB-space-only comparison first gave (issue #255's earlier
//! investigation pass mis-read this as a *non-constant* residual
//! gap — an artifact of comparing highly nonlinear final `dB`
//! values instead of the underlying linear-scale quantities).
//! Root cause: `downsample_cached`'s `fac = 1/sqrt(fft1_size·
//! fft2_size)` pre-scale vs. WSJT-X's own `fst4_downsample`'s
//! `c1 = c1/nfft2` — different normalisation conventions for
//! otherwise-equivalent unnormalised-IDFT downsamples. Worked out
//! analytically (not fitted) to an exact `NDOWN`-in-power
//! correction — see [`fst4_snr_db`]'s own doc comment for the
//! derivation — which lines up with the empirical 100-115× (vs.
//! `NDOWN=108` for FST4-60) to within the residual `fft1_size`
//! padding difference between the two implementations.
//!
//! Both real off-air signals above are FST4-60 — the only real-signal
//! FST4 sample WAV available locally
//! (`WSJT-X/samples/FST4+FST4W/210115_0058.wav`). Fst4s15/30/120/300
//! share this exact formula and the same `NDOWN`-in-power derivation
//! (§2 above doesn't special-case FST4-60's own `NDOWN`; it's a
//! function of each sub-mode's own `NDOWN`/`fft1_size`/`fft2_size`),
//! but haven't themselves been checked against a real `jt9` decode —
//! worth a real-WAV cross-check for at least one other sub-mode if
//! one ever turns up.
//!
//! ## What's ported, what isn't (Phase 4a vs 4b)
//!
//! WSJT-X's `get_candidates_fst4` computes a *whole-band* noise
//! baseline once per decode (`[nfa,nfb]`, run through the same
//! percentile+polyfit machinery as [`crate::engine::baseline`], see
//! [`crate::engine::baseline::BaselineParams::FST4`]), then uses the
//! CLEAN algorithm (iterative peak-find + subtract) to enumerate
//! candidates, recording each one's `sbase(iploc)` as
//! `candidates(icand,5)`.
//!
//! This module ports the baseline math but **not** the CLEAN
//! candidate search — this crate already has its own coarse-candidate
//! search ([`crate::engine::sync::coarse_sync`] /
//! [`crate::engine::sync2d::fst4_sync_search`]), and per the issue
//! #255 plan, replacing that with a from-scratch CLEAN port is a
//! second, larger, independent piece of work (issue #255 §4b) not
//! undertaken here. Instead, [`fst4_baseline_lin`] fits the same
//! percentile+polyfit baseline over a **local window** around the
//! specific candidate frequency our own search already found, rather
//! than the whole `[nfa,nfb]` band WSJT-X fits once and reuses for
//! every candidate. This is a legitimate simplification, not a
//! shortcut that changes the answer: the polynomial only ever
//! describes the *local* noise-floor trend near the point it's
//! evaluated at (`NSEG=8` segments, `NTERMS=3` — a low-order fit with
//! no long-range memory), so fitting it over a captured local span
//! instead of the full user-selected band gives the same baseline
//! value at the candidate's own bin. Revisit with a real 4b CLEAN
//! port if accuracy against ground truth (real `jt9`, golden WAVs)
//! turns out to need the whole-band version specifically.
extern crate alloc;
use vec;
use Vec;
use Complex;
use Float;
use crate;
use crate;
/// Half-width (Hz) of the local frequency window used to fit the
/// noise baseline around one candidate — the Phase-4a simplification
/// described in this module's doc comment. Wide enough to give
/// `BaselineParams::FST4`'s `NSEG=8` segments plenty of points
/// (hundreds to thousands of low-resolution bins per side across
/// every wired sub-mode) while staying well inside WSJT-X's own
/// typical `[nfa,nfb]` user-selected search band (order 1-5 kHz).
const LOCAL_BASELINE_HALF_WIDTH_HZ: f32 = 400.0;
/// Modulation index — fixed at 1 for every FST4 sub-mode this crate
/// wires (`fst4_decode.f90`'s `data hmod/1/`, never reassigned for
/// sub-mode A; B/C/E variants with other `hmod` values aren't
/// implemented here, see `fst4/mod.rs`'s module doc).
const HMOD: i64 = 1;
/// `candidates(icand,5)`-equivalent: the linear noise baseline at
/// `cand_freq_hz`, fit from `fft_cache` (WSJT-X `c_bigfft` — the same
/// whole-slot big FFT already computed for downsampling) the same way
/// [`crate::engine::baseline::fit_baseline_with`] does, using
/// `get_candidates_fst4.f90`'s specific pre-processing: bin the raw
/// FFT into a `df2 = tone_spacing_hz/2` low-resolution power spectrum
/// `s(i)`, then a 4-tap comb (CCF) `s2(i) = s(i-3h)+s(i-h)+s(i+h)+s(i+3h)`
/// (`h` = [`HMOD`]) before the percentile+polyfit baseline fit itself.
///
/// Returns `None` if the local window doesn't fit inside `fft_cache`
/// (near a band edge) or doesn't have enough points for the fit —
/// callers should fall back to [`crate::engine::llr::compute_snr_db`]
/// in that case, same as any other protocol without a ported formula.
pub
/// `xsig = Σ s4(itone(i),i)` (`fst4_decode.f90:592-595`) — power (not
/// amplitude) at the decoded tone, summed across every symbol
/// (sync + data, `NN=160`). `cs`/`itone` are
/// [`crate::engine::llr::symbol_spectra`]`::<P>` /
/// `encode_tones_for_snr::<P>` output — the same already-computed
/// per-symbol spectra and reconstructed full tone sequence every
/// other `GenericPipelineProtocol::snr_db` override reads from
/// [`crate::engine::pipeline::SnrCtx`].
///
/// `* 1000.0` on each component undoes `symbol_spectra`'s own
/// `/1000` scale (`engine::llr::symbol_spectra`'s doc comment) before
/// squaring — the same "un-scale trick" FT8's own
/// `compute_xsig_wsjtx` uses (`ft8/decode_block/process_candidates.rs`),
/// needed because that scale is this crate's own numeric-range
/// convenience with no WSJT-X counterpart, not present in
/// `get_fst4_bitmetrics.f90`'s `cs(itone,k)=sum(csymb*conjg(ci(:,itone)))`.
/// Recomputes `cs` fresh, **without**
/// `engine::pipeline::process_candidate_basic_impl`'s RMS-normalisation
/// step (`cd0 = cd0 / sqrt(sum2)`, matching WSJT-X `ft4_decode.f90:
/// 231-232` — applied uniformly by this crate's generic pipeline for
/// every `GenericPipelineProtocol` implementor, needed for
/// `compute_llr`'s `LLR_SCALE` calibration, issue #18). `SnrCtx::cs`
/// is downstream of that normalisation, so its absolute scale carries
/// **no** SNR information — every candidate's downsampled baseband
/// ends up at ~unit RMS regardless of how weak or strong the real
/// signal was.
///
/// WSJT-X's own FST4 path does *not* apply this normalisation before
/// computing bitmetrics/`xsig` (`fst4_decode.f90`'s `cframe=c2(is0:iend)`
/// is a bare slice, no `sum2`/RMS step — confirmed by reading the
/// source; contrast `ft4_decode.f90:231-232`, which does normalise,
/// same as this crate's shared pipeline). So a WSJT-X-faithful `xsig`
/// needs a `cs` built the same way: downsample fresh (no RMS step),
/// frequency-shift to the **fine-refined** frequency
/// (`fst4_decode.f90`'s bitmetrics input is downsampled at `fc_synced`,
/// not the coarse candidate frequency `get_candidates_fst4.f90`'s
/// baseline is keyed by — a real distinction in the original, not
/// sloppiness here), then run [`crate::engine::llr::symbol_spectra`]
/// at the exact same `i_start` the real (normalised) `cs` used.
///
/// Recomputing from `fft_cache`/`ds_cfg`/`cand_freq_hz`/
/// `refined_freq_hz`/`i_start` (deterministic, same inputs
/// `process_candidate_basic_impl` used) rather than threading a second
/// `cs` array through `SnrCtx` keeps this entirely inside FST4's own
/// protocol-owned module instead of doubling the generic engine's
/// per-candidate allocation for every protocol, not just this one.
///
/// An earlier version of this function instead multiplied the
/// *already-normalised* `xsig` by the downsampled baseband's mean
/// power as an algebraic undo — a single scalar multiply applied
/// uniformly across the whole array, so mathematically exact whenever
/// `refined_freq_hz == cand_freq_hz` (both this module's two real test
/// candidates happened to land close enough to that for the two
/// versions to produce byte-identical output). Rebuilding `cs` from
/// scratch at the correct frequency, as this version does, is more
/// principled for candidates with a larger fine-refine correction
/// (matching WSJT-X's own distinction between `fc_synced` and the
/// coarse candidate frequency, see above) even though it didn't turn
/// out to be what closed this module's real residual gap — that was a
/// separate downsample-scale-convention mismatch, see the module doc
/// comment.
/// FST4's real SNR formula (`fst4_decode.f90:592-621`):
///
/// ```text
/// xsig = Σ s4(itone(i),i) ! over NN=160 symbols
/// base = candidates(icand,5) ! noise baseline at candidate bin
/// arg = snr_calfac·xsig/base - 1.0
/// xsnr = 10·log10(arg) + 10·log10(1.46/2500) + 10·log10(8200/nsps) if arg > 0
/// = -99.9 otherwise
/// ```
///
/// `snr_calfac` is per sub-mode (`fst4_decode.f90`'s `select case
/// (ntrperiod)`: 800/600/430/390/340 for 15/30/60/120/300 — see the
/// `fst4_submode!` invocations in `fst4/mod.rs`). `NSPS` is read from
/// `P` directly ([`crate::engine::ModulationParams::NSPS`]).
///
/// Falls back to the WSJT-X `-99.9` sentinel (an explicit "not a real
/// SNR" marker in the original, not this crate's own invention) both
/// when `arg <= 0.0` and when [`fst4_baseline_lin`] can't fit a
/// baseline at all (candidate too close to a band edge for this
/// module's local-window simplification — see its own doc comment).
///
/// `itone` (from `encode_tones_for_snr::<P>`, same as every other
/// `GenericPipelineProtocol::snr_db` override reads via `SnrCtx`) is
/// the only piece of `SnrCtx` this function still needs — `cs` itself
/// is *not* used; [`fst4_raw_cs`] rebuilds the version this formula
/// actually needs instead (see its own doc comment for why).
///
/// See this module's own doc comment for the real `jt9` ground-truth
/// comparison this was verified against, including the derivation of
/// this function's own `NDOWN`-in-power downsample-scale correction
/// on `xsig` below.
pub
/// Welch segment length for [`fst4_ddc_snr_db`]. 512 bins across the
/// refine baseband's 111.111 Hz gives 0.217 Hz resolution, and a
/// 60 s FST4-60 baseband (6667 samples) yields 13 segments to average
/// — enough that the noise percentile below is stable rather than
/// reading one realisation's luck.
const WELCH_N: usize = 512;
/// SNR from the refined baseband alone — no whole-slot FFT, no symbol
/// spectra, no decode (issue #307/#346).
///
/// **Why this exists when `fst4_snr_db_from_cs` already does not need
/// the big FFT**: that one takes its noise reference from the
/// non-signal tones of the signal's own comb, and a strong tone leaks
/// into its neighbours. Measured on the FST4-60 golden, whose two
/// signals are 25.4 dB apart, it reproduced only 11-14 dB of that
/// separation regardless of which quantile of the off-tone bins was
/// used as the reference (tried 1/4, 1/8, 1/16 — the quantile moved
/// the offset and left the compression). A noise reference inside the
/// signal's own comb cannot work.
///
/// This one takes it from **outside the signal's spectrum but inside
/// the same buffer**. `wideband_refine_recenter` produces a 111.111 Hz
/// baseband centred on the candidate, of which FST4-60's four tones
/// occupy ~12 Hz — so ~90% of the band is signal-free and is measured
/// in exactly the units the signal is measured in. Nothing has to be
/// calibrated between two transforms, which is the trap the whole-slot
/// formula's port fell into twice (see this module's own doc comment).
///
/// The arithmetic, with the baseband RMS-normalised upstream so its
/// mean power is 1:
///
/// - `n0` = a low quantile of the Welch periodogram over the
/// signal-free bins, i.e. noise power per bin;
/// - `n0 * WELCH_N` = noise power across the whole refine band, so
/// `1 / (n0 * WELCH_N) - 1` = S/N in that band;
/// - referring to WSJT-X's 2500 Hz convention costs
/// `10*log10(111.111 / 2500)`.
///
/// Returns `None` when the band looks like noise alone.
pub