Skip to content
2 changes: 1 addition & 1 deletion server/routes/dxcluster.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
4 changes: 4 additions & 0 deletions server/utils/propagationPhysics.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/components/ActivateFilterManager.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const MODES = [
'FST4',
'FST4W',
'FST4W-90',
'FT2',
'FT4',
'FT8',
'HELL',
Expand Down
2 changes: 1 addition & 1 deletion src/components/DXFilterManager.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
1 change: 1 addition & 0 deletions src/components/PSKFilterManager.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const MODES = [
'FST4',
'FST4W',
'FST4W-90',
'FT2',
'FT4',
'FT8',
'HELL',
Expand Down
11 changes: 11 additions & 0 deletions src/hooks/useBandHealth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
2 changes: 1 addition & 1 deletion src/plugins/layers/useVOACAPHeatmap.js
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ export function useLayer({ map, enabled, opacity, locator }) {
.map((g) => `<option value="${g}" ${g === gridSize ? 'selected' : ''}>${g}°</option>`)
.join('');

const modeOptions = ['SSB', 'CW', 'FT8', 'FT4', 'RTTY', 'AM', 'FM']
const modeOptions = ['SSB', 'CW', 'FT8', 'FT4', 'FT2', 'RTTY', 'AM', 'FM']
.map((m) => `<option value="${m}" ${m === propMode ? 'selected' : ''}>${m}</option>`)
.join('');

Expand Down
2 changes: 1 addition & 1 deletion src/utils/bandPlan.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
Expand Down
38 changes: 29 additions & 9 deletions src/utils/callsign.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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';
Expand All @@ -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' },
Expand All @@ -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' },
Expand All @@ -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
Expand Down
43 changes: 43 additions & 0 deletions src/utils/callsign.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
4 changes: 4 additions & 0 deletions src/utils/propagationAdjust.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down