From 05a275995e5fe6ecb78cae0aa7eefa7bb1775df8 Mon Sep 17 00:00:00 2001
From: Laura Batalha <5883822+lbatalha@users.noreply.github.com>
Date: Thu, 9 Jul 2026 17:38:25 +0100
Subject: [PATCH 1/7] feat: add FT2 mode support to PSKReporter/WSJT-X filter
panels
- Add 'FT2' to MODES arrays in PSKFilterManager and ActivateFilterManager
- Add FT2 to callsign.js MODES, detectMode comment detection, and DIGITAL_ISLANDS frequencies
- Add FT2 to VOACAP heatmap mode selector
---
src/components/ActivateFilterManager.jsx | 1 +
src/components/PSKFilterManager.jsx | 1 +
src/plugins/layers/useVOACAPHeatmap.js | 2 +-
src/utils/callsign.js | 14 +++++++++++++-
4 files changed, 16 insertions(+), 2 deletions(-)
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/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/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/callsign.js b/src/utils/callsign.js
index 6c0f87e1..aca4208f 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';
@@ -126,6 +127,17 @@ export const detectMode = (comment, freq) => {
{ mhz: 24.915, mode: 'FT8' },
{ mhz: 28.074, mode: 'FT8' },
{ mhz: 50.313, mode: 'FT8' },
+ // FT2 calling frequencies
+ { mhz: 1.842, mode: 'FT2' },
+ { mhz: 3.577, mode: 'FT2' },
+ { mhz: 7.077, mode: 'FT2' },
+ { mhz: 10.141, mode: 'FT2' },
+ { mhz: 14.077, mode: 'FT2' },
+ { mhz: 18.107, mode: 'FT2' },
+ { mhz: 21.077, mode: 'FT2' },
+ { mhz: 24.92, mode: 'FT2' },
+ { mhz: 28.077, mode: 'FT2' },
+ { mhz: 50.317, mode: 'FT2' },
// FT4 calling frequencies
{ mhz: 3.575, mode: 'FT4' },
{ mhz: 7.0475, mode: 'FT4' },
From cae50f901eb1c85497f59e24fb5916172f5f9d79 Mon Sep 17 00:00:00 2001
From: Laura Batalha <5883822+lbatalha@users.noreply.github.com>
Date: Thu, 9 Jul 2026 18:34:44 +0100
Subject: [PATCH 2/7] fix: correct FT2 calling frequencies in callsign and band
health modules
Use the official FT2 calling frequencies:
160m: 1.843, 80m: 3.578, 60m: 5.360, 40m: 7.052, 30m: 10.144,
20m: 14.084, 17m: 18.108, 15m: 21.144, 12m: 24.923, 10m: 28.184
---
src/hooks/useBandHealth.js | 11 +++++++++++
src/utils/callsign.js | 20 ++++++++++----------
2 files changed, 21 insertions(+), 10 deletions(-)
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/utils/callsign.js b/src/utils/callsign.js
index aca4208f..62fb5408 100644
--- a/src/utils/callsign.js
+++ b/src/utils/callsign.js
@@ -128,16 +128,16 @@ export const detectMode = (comment, freq) => {
{ mhz: 28.074, mode: 'FT8' },
{ mhz: 50.313, mode: 'FT8' },
// FT2 calling frequencies
- { mhz: 1.842, mode: 'FT2' },
- { mhz: 3.577, mode: 'FT2' },
- { mhz: 7.077, mode: 'FT2' },
- { mhz: 10.141, mode: 'FT2' },
- { mhz: 14.077, mode: 'FT2' },
- { mhz: 18.107, mode: 'FT2' },
- { mhz: 21.077, mode: 'FT2' },
- { mhz: 24.92, mode: 'FT2' },
- { mhz: 28.077, mode: 'FT2' },
- { mhz: 50.317, mode: '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 calling frequencies
{ mhz: 3.575, mode: 'FT4' },
{ mhz: 7.0475, mode: 'FT4' },
From 741913c0f78375f5f129356044a380fd8a115ccb Mon Sep 17 00:00:00 2001
From: Laura Batalha <5883822+lbatalha@users.noreply.github.com>
Date: Thu, 9 Jul 2026 19:41:19 +0100
Subject: [PATCH 3/7] feat: add FT2 to DX Cluster filter modes and rig band
plan digital modes
---
src/components/DXFilterManager.jsx | 2 +-
src/utils/bandPlan.js | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
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/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';
}
From 29e93c71c3aa7d3f619fb0896fc8b4aefed6c769 Mon Sep 17 00:00:00 2001
From: Laura Batalha <5883822+lbatalha@users.noreply.github.com>
Date: Fri, 10 Jul 2026 16:53:55 +0100
Subject: [PATCH 4/7] move to asymmetric window for spot mode calc
---
src/utils/callsign.js | 6 ++----
1 file changed, 2 insertions(+), 4 deletions(-)
diff --git a/src/utils/callsign.js b/src/utils/callsign.js
index 62fb5408..017d8ca3 100644
--- a/src/utils/callsign.js
+++ b/src/utils/callsign.js
@@ -114,7 +114,6 @@ export const detectMode = (comment, freq) => {
// 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;
const DIGITAL_ISLANDS = [
// FT8 calling frequencies
{ mhz: 1.84, mode: 'FT8' },
@@ -157,9 +156,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
From 00428f4fd15487410ba88b20b3a443feb41a38ae Mon Sep 17 00:00:00 2001
From: Laura Batalha <5883822+lbatalha@users.noreply.github.com>
Date: Tue, 28 Jul 2026 22:37:51 +0100
Subject: [PATCH 5/7] fix: add FT2=32 dB to MODE_ADVANTAGE_DB in both
propagation engines
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Without this, FT2 heatmaps rendered identically to SSB (0 dB advantage).
FT2 gets 32 dB — between FT4 (30) and FT8 (34).
---
server/utils/propagationPhysics.js | 1 +
src/utils/propagationAdjust.js | 1 +
2 files changed, 2 insertions(+)
diff --git a/server/utils/propagationPhysics.js b/server/utils/propagationPhysics.js
index e3bf9048..9e54ee26 100644
--- a/server/utils/propagationPhysics.js
+++ b/server/utils/propagationPhysics.js
@@ -59,6 +59,7 @@ const MODE_ADVANTAGE_DB = {
PSK31: 10,
FT8: 34,
FT4: 30,
+ FT2: 32,
WSPR: 41,
JS8: 37,
OLIVIA: 20,
diff --git a/src/utils/propagationAdjust.js b/src/utils/propagationAdjust.js
index 630fe752..6c871562 100644
--- a/src/utils/propagationAdjust.js
+++ b/src/utils/propagationAdjust.js
@@ -13,6 +13,7 @@ export const MODE_ADVANTAGE_DB = {
PSK31: 10,
FT8: 34,
FT4: 30,
+ FT2: 32,
WSPR: 41,
JS8: 37,
OLIVIA: 20,
From b25b93531a2f822aa664475fb17c346dfa8a04cd Mon Sep 17 00:00:00 2001
From: Laura Batalha <5883822+lbatalha@users.noreply.github.com>
Date: Tue, 28 Jul 2026 22:53:05 +0100
Subject: [PATCH 6/7] fix: add FT2 to balancePathsByMode regex in dxcluster.js
Change /\bFT[84]\b/ to /\bFT[842]\b/ so FT2 spots are classified
as digital skimmer traffic instead of voice/human spots. Without this,
FT2 skimmer spots consumed the human-spot reserve (25% of the display
window) instead of the digital skimmer bucket (50%).
---
server/routes/dxcluster.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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
}
From 301b44f6db4bb3c985ee102d0edcc0d451b36383 Mon Sep 17 00:00:00 2001
From: accius
Date: Tue, 28 Jul 2026 19:25:46 -0400
Subject: [PATCH 7/7] fix(ft2): correct sensitivity value, retire stale
comment, pin boundaries
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- MODE_ADVANTAGE_DB: FT2 32 → 25 in both mirror engines. The "better than
FT4" claim is about speed (3.75 s periods), not sensitivity — the ft2.it
technical document specs FT2's decode threshold at -12/-13 dB vs FT4's
-17.5 and FT8's -21, making it the least sensitive FTx mode.
- Replace the stale "24.911 was being missed" comment that argued for the
old ±5 kHz window: FTx audio passbands sit entirely above the dial, so
that spot was busted, and the asymmetric window is correct.
- Document the inherent 160m FT8/FT2 dial adjacency (1.843 = 1.840 + 3 kHz)
at the island entry.
- Add detectMode boundary tests: FT4/FT2 separation at 4 kHz spacing,
below-dial rejection, 160m adjacency, comment precedence, kHz input.
Co-Authored-By: Claude Fable 5
---
server/utils/propagationPhysics.js | 5 +++-
src/utils/callsign.js | 20 ++++++++++----
src/utils/callsign.test.js | 43 ++++++++++++++++++++++++++++++
src/utils/propagationAdjust.js | 5 +++-
4 files changed, 66 insertions(+), 7 deletions(-)
create mode 100644 src/utils/callsign.test.js
diff --git a/server/utils/propagationPhysics.js b/server/utils/propagationPhysics.js
index 9e54ee26..4fad1f15 100644
--- a/server/utils/propagationPhysics.js
+++ b/server/utils/propagationPhysics.js
@@ -59,7 +59,10 @@ const MODE_ADVANTAGE_DB = {
PSK31: 10,
FT8: 34,
FT4: 30,
- FT2: 32,
+ // 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/utils/callsign.js b/src/utils/callsign.js
index 017d8ca3..77fe297d 100644
--- a/src/utils/callsign.js
+++ b/src/utils/callsign.js
@@ -110,10 +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.
+ // 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,7 +130,13 @@ export const detectMode = (comment, freq) => {
{ mhz: 24.915, mode: 'FT8' },
{ mhz: 28.074, mode: 'FT8' },
{ mhz: 50.313, mode: 'FT8' },
- // FT2 calling frequencies
+ // 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' },
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 6c871562..28853799 100644
--- a/src/utils/propagationAdjust.js
+++ b/src/utils/propagationAdjust.js
@@ -13,7 +13,10 @@ export const MODE_ADVANTAGE_DB = {
PSK31: 10,
FT8: 34,
FT4: 30,
- FT2: 32,
+ // 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,