[feat](trx-rs): map VDES positions and restore burst gating

Publish decoded VDES positions into the map and revert the VDES burst detector to its original gating thresholds.

Co-authored-by: Stan Grams <sjg@haxx.space>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-03-03 01:27:33 +01:00
parent c454f2218d
commit 67c7b5d1d3
4 changed files with 126 additions and 20 deletions
+6 -19
View File
@@ -20,9 +20,8 @@ use num_complex::Complex;
use trx_core::decode::VdesMessage; use trx_core::decode::VdesMessage;
const VDES_SYMBOL_RATE: f32 = 76_800.0; const VDES_SYMBOL_RATE: f32 = 76_800.0;
const MIN_BURST_MS: f32 = 1.5; const MIN_BURST_MS: f32 = 2.0;
const BURST_END_MS: f32 = 0.35; const BURST_END_MS: f32 = 0.4;
const MAX_BURST_MS: f32 = 45.0;
const MIN_BURST_SYMBOLS: usize = 64; const MIN_BURST_SYMBOLS: usize = 64;
const TER_MCS1_100_BURST_SYMBOLS: usize = 1_984; const TER_MCS1_100_BURST_SYMBOLS: usize = 1_984;
const TER_MCS1_100_RAMP_SYMBOLS: usize = 32; const TER_MCS1_100_RAMP_SYMBOLS: usize = 32;
@@ -36,10 +35,10 @@ const TER_MCS1_100_SYNC_BITS: &[u8; TER_MCS1_100_SYNC_SYMBOLS] = b"1111110011010
const PI4_QPSK_DIBITS: [u8; 4] = [0b00, 0b01, 0b11, 0b10]; const PI4_QPSK_DIBITS: [u8; 4] = [0b00, 0b01, 0b11, 0b10];
const MIN_SYNC_CANDIDATE_SCORE: f32 = 0.20; const MIN_SYNC_CANDIDATE_SCORE: f32 = 0.20;
const MIN_SYNC_PARSE_SCORE: f32 = 0.50; const MIN_SYNC_PARSE_SCORE: f32 = 0.50;
const BURST_TRIGGER_NOISE_MULT: f32 = 3.0; const BURST_TRIGGER_NOISE_MULT: f32 = 8.0;
const BURST_TRIGGER_FLOOR: f32 = 1.0e-10; const BURST_TRIGGER_FLOOR: f32 = 2.0e-4;
const BURST_SUSTAIN_NOISE_MULT: f32 = 1.15; const BURST_SUSTAIN_NOISE_MULT: f32 = 3.0;
const BURST_SUSTAIN_FLOOR: f32 = 1.0e-11; const BURST_SUSTAIN_FLOOR: f32 = 1.2e-4;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct VdesDecoder { pub struct VdesDecoder {
@@ -74,8 +73,6 @@ impl VdesDecoder {
((self.sample_rate * (MIN_BURST_MS / 1000.0)).round() as usize).max(16); ((self.sample_rate * (MIN_BURST_MS / 1000.0)).round() as usize).max(16);
let quiet_limit = let quiet_limit =
((self.sample_rate * (BURST_END_MS / 1000.0)).round() as u32).max(4); ((self.sample_rate * (BURST_END_MS / 1000.0)).round() as u32).max(4);
let max_burst_samples =
((self.sample_rate * (MAX_BURST_MS / 1000.0)).round() as usize).max(min_burst_samples);
for &sample in samples { for &sample in samples {
let power = sample.norm_sqr(); let power = sample.norm_sqr();
@@ -99,16 +96,6 @@ impl VdesDecoder {
self.quiet_run = 0; self.quiet_run = 0;
} }
if self.burst_samples.len() >= max_burst_samples {
if let Some(msg) = self.finalize_burst(channel) {
out.push(msg);
}
self.in_burst = false;
self.quiet_run = 0;
self.burst_samples.clear();
continue;
}
if self.quiet_run >= quiet_limit { if self.quiet_run >= quiet_limit {
if self.burst_samples.len() >= min_burst_samples { if self.burst_samples.len() >= min_burst_samples {
if let Some(msg) = self.finalize_burst(channel) { if let Some(msg) = self.finalize_burst(channel) {
@@ -3139,10 +3139,11 @@ let aprsRadioPath = null;
const stationMarkers = new Map(); const stationMarkers = new Map();
const locatorMarkers = new Map(); const locatorMarkers = new Map();
const mapMarkers = new Set(); const mapMarkers = new Set();
const mapFilter = { ais: true, aprs: true, ft8: true, wspr: true }; const mapFilter = { ais: true, vdes: true, aprs: true, ft8: true, wspr: true };
const APRS_TRACK_MAX_POINTS = 64; const APRS_TRACK_MAX_POINTS = 64;
const AIS_TRACK_MAX_POINTS = 64; const AIS_TRACK_MAX_POINTS = 64;
const aisMarkers = new Map(); const aisMarkers = new Map();
const vdesMarkers = new Map();
let selectedAisTrackMmsi = null; let selectedAisTrackMmsi = null;
function syncAprsReceiverMarker() { function syncAprsReceiverMarker() {
@@ -3205,6 +3206,17 @@ window.clearMapMarkersByType = function(type) {
return; return;
} }
if (type === "vdes") {
vdesMarkers.forEach((entry) => {
if (entry && entry.marker) {
if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap);
mapMarkers.delete(entry.marker);
}
});
vdesMarkers.clear();
return;
}
if (type === "ft8" || type === "wspr") { if (type === "ft8" || type === "wspr") {
const prefix = `${type}:`; const prefix = `${type}:`;
for (const [key, entry] of locatorMarkers.entries()) { for (const [key, entry] of locatorMarkers.entries()) {
@@ -3311,6 +3323,19 @@ function initAprsMap() {
{ className: "aprs-radio-path", weight: 2, interactive: false } { className: "aprs-radio-path", weight: 2, interactive: false }
).addTo(aprsMap); ).addTo(aprsMap);
} }
return;
}
if (marker._vdesKey) {
const entry = vdesMarkers.get(String(marker._vdesKey));
if (!entry || !entry.msg) return;
e.popup.setContent(buildVdesPopupHtml(entry.msg));
if (serverLat != null && serverLon != null) {
aprsRadioPath = L.polyline(
[[serverLat, serverLon], [ll.lat, ll.lng]],
{ className: "aprs-radio-path", weight: 2, interactive: false }
).addTo(aprsMap);
}
} }
}); });
@@ -3334,6 +3359,7 @@ function initAprsMap() {
applyMapFilter(); applyMapFilter();
const aisFilter = document.getElementById("map-filter-ais"); const aisFilter = document.getElementById("map-filter-ais");
const vdesFilter = document.getElementById("map-filter-vdes");
const aprsFilter = document.getElementById("map-filter-aprs"); const aprsFilter = document.getElementById("map-filter-aprs");
const ft8Filter = document.getElementById("map-filter-ft8"); const ft8Filter = document.getElementById("map-filter-ft8");
const wsprFilter = document.getElementById("map-filter-wspr"); const wsprFilter = document.getElementById("map-filter-wspr");
@@ -3349,6 +3375,12 @@ function initAprsMap() {
} }
}); });
} }
if (vdesFilter) {
vdesFilter.addEventListener("change", () => {
mapFilter.vdes = vdesFilter.checked;
applyMapFilter();
});
}
if (aprsFilter) { if (aprsFilter) {
aprsFilter.addEventListener("change", () => { aprsFilter.addEventListener("change", () => {
mapFilter.aprs = aprsFilter.checked; mapFilter.aprs = aprsFilter.checked;
@@ -3528,6 +3560,43 @@ function buildAisPopupHtml(msg) {
`</div>`; `</div>`;
} }
function buildVdesPopupHtml(msg) {
const age = formatTimeAgo(msg?.ts_ms);
const distKm = (serverLat != null && serverLon != null && msg?.lat != null && msg?.lon != null)
? haversineKm(serverLat, serverLon, msg.lat, msg.lon)
: null;
const distStr = distKm != null
? (distKm < 1 ? `${Math.round(distKm * 1000)} m` : `${distKm.toFixed(1)} km`)
: null;
const meta = [
age,
distStr,
msg?.message_label ? escapeMapHtml(msg.message_label) : null,
Number.isFinite(msg?.link_id) ? `LID ${Number(msg.link_id)}` : null,
].filter(Boolean).join(" &middot; ");
let rows = "";
if (distStr) rows += `<tr><td class="aprs-popup-label">Range</td><td>${distStr} from TRX</td></tr>`;
rows += `<tr><td class="aprs-popup-label">Type</td><td>${escapeMapHtml(String(msg?.message_type ?? "--"))}</td></tr>`;
if (Number.isFinite(msg?.source_id)) rows += `<tr><td class="aprs-popup-label">Source</td><td>${escapeMapHtml(String(msg.source_id))}</td></tr>`;
if (Number.isFinite(msg?.destination_id)) rows += `<tr><td class="aprs-popup-label">Dest</td><td>${escapeMapHtml(String(msg.destination_id))}</td></tr>`;
if (msg?.lat != null && msg?.lon != null) rows += `<tr><td class="aprs-popup-label">Pos</td><td>${msg.lat.toFixed(5)}, ${msg.lon.toFixed(5)}</td></tr>`;
if (Number.isFinite(msg?.sync_score)) rows += `<tr><td class="aprs-popup-label">Sync</td><td>${(Number(msg.sync_score) * 100).toFixed(0)}%</td></tr>`;
if (msg?.fec_state) rows += `<tr><td class="aprs-popup-label">FEC</td><td>${escapeMapHtml(String(msg.fec_state))}</td></tr>`;
const info = [
msg?.vessel_name,
msg?.callsign,
msg?.destination,
msg?.payload_preview,
].filter(Boolean).map(escapeMapHtml).join(" · ");
const title = escapeMapHtml(msg?.vessel_name || msg?.callsign || "VDES Position");
return `<div class="aprs-popup">` +
`<div class="aprs-popup-call">${title}</div>` +
(meta ? `<div class="aprs-popup-meta">${meta}</div>` : "") +
(rows ? `<table class="aprs-popup-table">${rows}</table>` : "") +
(info ? `<div class="aprs-popup-info">${info}</div>` : "") +
`</div>`;
}
function aprsPositionsEqual(a, b) { function aprsPositionsEqual(a, b) {
if (!a || !b) return false; if (!a || !b) return false;
return Math.abs(a[0] - b[0]) < 0.000001 && Math.abs(a[1] - b[1]) < 0.000001; return Math.abs(a[0] - b[0]) < 0.000001 && Math.abs(a[1] - b[1]) < 0.000001;
@@ -3538,6 +3607,15 @@ function aisPositionsEqual(a, b) {
return Math.abs(a[0] - b[0]) < 0.000001 && Math.abs(a[1] - b[1]) < 0.000001; return Math.abs(a[0] - b[0]) < 0.000001 && Math.abs(a[1] - b[1]) < 0.000001;
} }
function vdesMarkerKey(msg) {
if (Number.isFinite(msg?.source_id)) return `src:${Number(msg.source_id)}`;
if (Number.isFinite(msg?.mmsi) && Number(msg.mmsi) > 0) return `mmsi:${Number(msg.mmsi)}`;
if (msg?.lat != null && msg?.lon != null) {
return `pos:${Number(msg.lat).toFixed(4)}:${Number(msg.lon).toFixed(4)}:${Number(msg?.message_type ?? 0)}`;
}
return null;
}
function ensureAprsTrack(call, entry) { function ensureAprsTrack(call, entry) {
if (!aprsMap || !entry || !Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) return; if (!aprsMap || !entry || !Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) return;
if (entry.track) { if (entry.track) {
@@ -3719,6 +3797,40 @@ window.aisMapAddVessel = function(msg) {
applyMapFilter(); applyMapFilter();
}; };
window.vdesMapAddPoint = function(msg) {
if (msg == null || msg.lat == null || msg.lon == null) return;
const key = vdesMarkerKey(msg);
if (!key) return;
if (!aprsMap) initAprsMap();
const popupHtml = buildVdesPopupHtml(msg);
const existing = vdesMarkers.get(key);
if (existing) {
existing.msg = msg;
if (existing.marker) {
existing.marker.setLatLng([msg.lat, msg.lon]);
existing.marker.setPopupContent(popupHtml);
}
return;
}
const entry = {
marker: null,
msg,
};
vdesMarkers.set(key, entry);
if (!aprsMap) return;
const marker = L.circleMarker([msg.lat, msg.lon], {
radius: 5,
color: "#5c394f",
fillColor: "#c46392",
fillOpacity: 0.82,
}).addTo(aprsMap).bindPopup(popupHtml);
marker.__trxType = "vdes";
marker._vdesKey = key;
entry.marker = marker;
mapMarkers.add(marker);
applyMapFilter();
};
function maidenheadToBounds(grid) { function maidenheadToBounds(grid) {
if (!grid || grid.length < 4) return null; if (!grid || grid.length < 4) return null;
const g = grid.toUpperCase(); const g = grid.toUpperCase();
@@ -3754,6 +3866,7 @@ function applyMapFilter() {
const type = marker.__trxType; const type = marker.__trxType;
const visible = const visible =
(type === "ais" && mapFilter.ais) || (type === "ais" && mapFilter.ais) ||
(type === "vdes" && mapFilter.vdes) ||
(type === "aprs" && mapFilter.aprs) || (type === "aprs" && mapFilter.aprs) ||
(type === "ft8" && mapFilter.ft8) || (type === "ft8" && mapFilter.ft8) ||
(type === "wspr" && mapFilter.wspr); (type === "wspr" && mapFilter.wspr);
@@ -560,6 +560,7 @@
<div id="tab-map" class="tab-panel" style="display:none;"> <div id="tab-map" class="tab-panel" style="display:none;">
<div class="map-controls"> <div class="map-controls">
<label><input type="checkbox" id="map-filter-ais" checked /> AIS</label> <label><input type="checkbox" id="map-filter-ais" checked /> AIS</label>
<label><input type="checkbox" id="map-filter-vdes" checked /> VDES</label>
<label><input type="checkbox" id="map-filter-aprs" checked /> APRS</label> <label><input type="checkbox" id="map-filter-aprs" checked /> APRS</label>
<label><input type="checkbox" id="map-filter-ft8" checked /> FT8</label> <label><input type="checkbox" id="map-filter-ft8" checked /> FT8</label>
<label><input type="checkbox" id="map-filter-wspr" checked /> WSPR</label> <label><input type="checkbox" id="map-filter-wspr" checked /> WSPR</label>
@@ -244,6 +244,8 @@ window.onServerVdes = function(msg) {
message_type: msg.message_type, message_type: msg.message_type,
bit_len: msg.bit_len, bit_len: msg.bit_len,
raw_bytes: msg.raw_bytes, raw_bytes: msg.raw_bytes,
lat: msg.lat,
lon: msg.lon,
vessel_name: msg.vessel_name, vessel_name: msg.vessel_name,
callsign: msg.callsign, callsign: msg.callsign,
destination: msg.destination, destination: msg.destination,
@@ -263,6 +265,9 @@ window.onServerVdes = function(msg) {
fec_state: msg.fec_state, fec_state: msg.fec_state,
ts_ms: msg.ts_ms, ts_ms: msg.ts_ms,
}); });
if (msg.lat != null && msg.lon != null && window.vdesMapAddPoint) {
window.vdesMapAddPoint(msg);
}
}; };
updateVdesSummary(); updateVdesSummary();