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,