diff --git a/server/routes/dxcluster.js b/server/routes/dxcluster.js index 52c1d610..c8a19ab5 100644 --- a/server/routes/dxcluster.js +++ b/server/routes/dxcluster.js @@ -1044,7 +1044,7 @@ module.exports = function (app, ctx) { const other = []; for (const p of paths) { const c = String(p.comment || '').toUpperCase(); - if (/\bFT[84]\b/.test(c)) ft8ft4.push(p); + if (/\bFT[842]\b/.test(c)) ft8ft4.push(p); else if (/\b(CW|RTTY|PSK)/.test(c)) other.push(p); else voice.push(p); // voice keywords or no mode marker — human spots } diff --git a/server/utils/propagationPhysics.js b/server/utils/propagationPhysics.js index e3bf9048..4fad1f15 100644 --- a/server/utils/propagationPhysics.js +++ b/server/utils/propagationPhysics.js @@ -59,6 +59,10 @@ const MODE_ADVANTAGE_DB = { PSK31: 10, FT8: 34, FT4: 30, + // FT2 trades sensitivity for speed: decode threshold is -12/-13 dB vs + // FT4's -17.5 and FT8's -21 (ft2.it technical document) — the LEAST + // sensitive of the FTx modes despite being the fastest. + FT2: 25, WSPR: 41, JS8: 37, OLIVIA: 20, diff --git a/src/components/ActivateFilterManager.jsx b/src/components/ActivateFilterManager.jsx index 7d594d78..3b21fbfe 100644 --- a/src/components/ActivateFilterManager.jsx +++ b/src/components/ActivateFilterManager.jsx @@ -22,6 +22,7 @@ const MODES = [ 'FST4', 'FST4W', 'FST4W-90', + 'FT2', 'FT4', 'FT8', 'HELL', diff --git a/src/components/DXFilterManager.jsx b/src/components/DXFilterManager.jsx index 293c5e5a..70c6d651 100644 --- a/src/components/DXFilterManager.jsx +++ b/src/components/DXFilterManager.jsx @@ -45,7 +45,7 @@ export const DXFilterManager = ({ filters, onFilterChange, isOpen, onClose, onCl '2m', '70cm', ]; - const modes = ['CW', 'SSB', 'FT8', 'FT4', 'RTTY', 'PSK', 'JT65', 'JS8', 'SSTV', 'AM', 'FM']; + const modes = ['CW', 'SSB', 'FT8', 'FT4', 'FT2', 'RTTY', 'PSK', 'JT65', 'JS8', 'SSTV', 'AM', 'FM']; // noinspection DuplicatedCode const toggleArrayItem = (key, item) => { diff --git a/src/components/PSKFilterManager.jsx b/src/components/PSKFilterManager.jsx index 951a08e2..5a113f51 100644 --- a/src/components/PSKFilterManager.jsx +++ b/src/components/PSKFilterManager.jsx @@ -22,6 +22,7 @@ const MODES = [ 'FST4', 'FST4W', 'FST4W-90', + 'FT2', 'FT4', 'FT8', 'HELL', diff --git a/src/hooks/useBandHealth.js b/src/hooks/useBandHealth.js index 0d7cda77..d477350d 100644 --- a/src/hooks/useBandHealth.js +++ b/src/hooks/useBandHealth.js @@ -62,6 +62,17 @@ const ISLANDS = [ { mhz: 21.074, mode: 'FT8' }, { mhz: 24.915, mode: 'FT8' }, { mhz: 28.074, mode: 'FT8' }, + // FT2 + { mhz: 1.843, mode: 'FT2' }, + { mhz: 3.578, mode: 'FT2' }, + { mhz: 5.36, mode: 'FT2' }, + { mhz: 7.052, mode: 'FT2' }, + { mhz: 10.144, mode: 'FT2' }, + { mhz: 14.084, mode: 'FT2' }, + { mhz: 18.108, mode: 'FT2' }, + { mhz: 21.144, mode: 'FT2' }, + { mhz: 24.923, mode: 'FT2' }, + { mhz: 28.184, mode: 'FT2' }, // FT4 { mhz: 3.575, mode: 'FT4' }, { mhz: 7.0475, mode: 'FT4' }, diff --git a/src/plugins/layers/useVOACAPHeatmap.js b/src/plugins/layers/useVOACAPHeatmap.js index 79802e46..cbdb85b2 100644 --- a/src/plugins/layers/useVOACAPHeatmap.js +++ b/src/plugins/layers/useVOACAPHeatmap.js @@ -213,7 +213,7 @@ export function useLayer({ map, enabled, opacity, locator }) { .map((g) => ``) .join(''); - const modeOptions = ['SSB', 'CW', 'FT8', 'FT4', 'RTTY', 'AM', 'FM'] + const modeOptions = ['SSB', 'CW', 'FT8', 'FT4', 'FT2', 'RTTY', 'AM', 'FM'] .map((m) => ``) .join(''); diff --git a/src/utils/bandPlan.js b/src/utils/bandPlan.js index 839df5ca..eccb1753 100644 --- a/src/utils/bandPlan.js +++ b/src/utils/bandPlan.js @@ -74,7 +74,7 @@ export const mapModeToRig = (mode, freq) => { if (m === 'DATA-USB' || m === 'DATA-LSB') return m; // Digital/data modes → DATA-USB or DATA-LSB based on band convention - const digitalModes = ['DATA', 'FT8', 'FT4', 'JS8', 'WSPR', 'JT65', 'JT9', 'PSK31', 'PSK63', 'RTTY', 'PKT']; + const digitalModes = ['DATA', 'FT8', 'FT4', 'FT2', 'JS8', 'WSPR', 'JT65', 'JT9', 'PSK31', 'PSK63', 'RTTY', 'PKT']; if (digitalModes.includes(m)) { return sb === 'USB' ? 'DATA-USB' : 'DATA-LSB'; } diff --git a/src/utils/callsign.js b/src/utils/callsign.js index 6c0f87e1..77fe297d 100644 --- a/src/utils/callsign.js +++ b/src/utils/callsign.js @@ -43,7 +43,7 @@ export const CONTINENTS = [ /** * Digital/Voice Modes */ -export const MODES = ['CW', 'SSB', 'FT8', 'FT4', 'RTTY', 'PSK', 'AM', 'FM']; +export const MODES = ['CW', 'SSB', 'FT8', 'FT4', 'FT2', 'RTTY', 'PSK', 'AM', 'FM']; /** * Get band from frequency (in kHz) @@ -93,6 +93,7 @@ export const detectMode = (comment, freq) => { const upper = comment.toUpperCase(); if (upper.includes('FT8')) return 'FT8'; if (upper.includes('FT4')) return 'FT4'; + if (upper.includes('FT2')) return 'FT2'; if (upper.includes('CW')) return 'CW'; if (upper.includes('SSB') || upper.includes('LSB') || upper.includes('USB')) return 'SSB'; if (upper.includes('RTTY')) return 'RTTY'; @@ -109,11 +110,14 @@ export const detectMode = (comment, freq) => { // Normalize to MHz (spots may arrive in kHz or MHz) const mhz = f > 1000 ? f / 1000 : f; - // Digital islands — narrow ±5 kHz windows around known calling frequencies - // DX cluster spots may report slightly off-frequency depending on the spotter's - // rig readout or the audio offset of the specific signal they clicked on. - // 3 kHz was too tight — 24.911 for 12m FT8 (dial 24.915) was being missed. - const TOLERANCE = 0.005; + // Digital islands — asymmetric windows around known calling (dial) frequencies. + // FTx signals occupy dial + 0–3 kHz (audio passband sits ABOVE the dial), so + // legitimate spots land in [dial, dial + ~3.1 kHz]; the small negative + // allowance covers rounded-down spot frequencies. A symmetric window cannot + // work here: FT2 dials sit only 4 kHz above FT4's, so ±5 kHz would swallow + // the neighbouring island. (An old note here defended ±5 kHz because a + // "24.911 FT8" spot was being missed — that was a busted spot; real FTx + // signals are never below the dial.) const DIGITAL_ISLANDS = [ // FT8 calling frequencies { mhz: 1.84, mode: 'FT8' }, @@ -126,6 +130,23 @@ export const detectMode = (comment, freq) => { { mhz: 24.915, mode: 'FT8' }, { mhz: 28.074, mode: 'FT8' }, { mhz: 50.313, mode: 'FT8' }, + // FT2 calling frequencies (tentative community QRGs — experimental mode, + // Decodium / WSJT-X Improved, not official WSJT-X) + // 160m: 1.843 sits exactly at the top of FT8 1.840's passband. FT8 is + // checked first, so a bare spot at exactly 1.8430 classifies as FT8 — + // inherent to the frequency plan. Real FT2 auto-spots carry "FT2" in the + // comment (matched before the islands) and audio offsets that push them + // past 1.8431, so only comment-less dial quotes hit the ambiguity. + { mhz: 1.843, mode: 'FT2' }, + { mhz: 3.578, mode: 'FT2' }, + { mhz: 5.36, mode: 'FT2' }, + { mhz: 7.052, mode: 'FT2' }, + { mhz: 10.144, mode: 'FT2' }, + { mhz: 14.084, mode: 'FT2' }, + { mhz: 18.108, mode: 'FT2' }, + { mhz: 21.144, mode: 'FT2' }, + { mhz: 24.923, mode: 'FT2' }, + { mhz: 28.184, mode: 'FT2' }, // FT4 calling frequencies { mhz: 3.575, mode: 'FT4' }, { mhz: 7.0475, mode: 'FT4' }, @@ -145,9 +166,8 @@ export const detectMode = (comment, freq) => { ]; for (const island of DIGITAL_ISLANDS) { - if (Math.abs(mhz - island.mhz) <= TOLERANCE) { - return island.mode; - } + const offset = mhz - island.mhz; + if (offset >= -0.0005 && offset <= 0.0031) return island.mode; } // Band plan segments — CW vs SSB by frequency range diff --git a/src/utils/callsign.test.js b/src/utils/callsign.test.js new file mode 100644 index 00000000..d4a01c34 --- /dev/null +++ b/src/utils/callsign.test.js @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import { detectMode } from './callsign.js'; + +// FTx signals occupy dial + 0–3 kHz (audio passband above the dial), so the +// island window is asymmetric: [dial − 0.5 kHz, dial + 3.1 kHz]. These tests +// pin the boundaries — especially FT4 vs FT2, whose dials are only 4 kHz +// apart, which is why a symmetric ±5 kHz window can never work. +describe('detectMode frequency islands (asymmetric window)', () => { + it('classifies spots in the audio passband above the dial', () => { + expect(detectMode(null, '14.074')).toBe('FT8'); // 20m FT8 dial itself + expect(detectMode(null, '14.077')).toBe('FT8'); // near top of passband + expect(detectMode(null, '14.081')).toBe('FT4'); // FT4 dial + 1 kHz + expect(detectMode(null, '14.085')).toBe('FT2'); // FT2 dial + 1 kHz + expect(detectMode(null, '24.916')).toBe('FT8'); // 12m FT8 dial + 1 kHz + expect(detectMode(null, '24.924')).toBe('FT2'); // 12m FT2 dial + 1 kHz + }); + + it('does not classify below-dial spots as FTx — signals are never below the dial', () => { + // 24.911 is 4 kHz below the 12m FT8 dial (24.915). The old ±5 kHz window + // matched it (see the retired comment in callsign.js), but such spots are + // busted: the FT8 audio passband sits entirely above the dial. + expect(detectMode(null, '24.911')).not.toBe('FT8'); + }); + + it('separates FT4 and FT2 islands only 4 kHz apart', () => { + expect(detectMode(null, '14.083')).toBe('FT4'); // FT4 dial + 3.0 kHz + expect(detectMode(null, '14.0835')).toBe('FT2'); // FT2 dial − 0.5 kHz (rounded-down allowance) + }); + + it('160m FT8/FT2 dial adjacency: FT8 wins bare dial quotes, comments win everything', () => { + // FT2's 1.843 dial sits exactly at the top of FT8 1.840's passband — the + // documented, inherent ambiguity. A bare spot at exactly the FT2 dial + // classifies FT8; anything above it is FT2; a comment overrides both. + expect(detectMode(null, '1.8430')).toBe('FT8'); + expect(detectMode(null, '1.8432')).toBe('FT2'); + expect(detectMode('FT2 -12dB from JN44 1489Hz', '1.843')).toBe('FT2'); + }); + + it('normalizes spots arriving in kHz', () => { + expect(detectMode(null, '14085')).toBe('FT2'); + }); +}); diff --git a/src/utils/propagationAdjust.js b/src/utils/propagationAdjust.js index 630fe752..28853799 100644 --- a/src/utils/propagationAdjust.js +++ b/src/utils/propagationAdjust.js @@ -13,6 +13,10 @@ export const MODE_ADVANTAGE_DB = { PSK31: 10, FT8: 34, FT4: 30, + // FT2 trades sensitivity for speed: decode threshold is -12/-13 dB vs + // FT4's -17.5 and FT8's -21 (ft2.it technical document) — the LEAST + // sensitive of the FTx modes despite being the fastest. + FT2: 25, WSPR: 41, JS8: 37, OLIVIA: 20,