[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:
@@ -20,9 +20,8 @@ use num_complex::Complex;
|
||||
use trx_core::decode::VdesMessage;
|
||||
|
||||
const VDES_SYMBOL_RATE: f32 = 76_800.0;
|
||||
const MIN_BURST_MS: f32 = 1.5;
|
||||
const BURST_END_MS: f32 = 0.35;
|
||||
const MAX_BURST_MS: f32 = 45.0;
|
||||
const MIN_BURST_MS: f32 = 2.0;
|
||||
const BURST_END_MS: f32 = 0.4;
|
||||
const MIN_BURST_SYMBOLS: usize = 64;
|
||||
const TER_MCS1_100_BURST_SYMBOLS: usize = 1_984;
|
||||
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 MIN_SYNC_CANDIDATE_SCORE: f32 = 0.20;
|
||||
const MIN_SYNC_PARSE_SCORE: f32 = 0.50;
|
||||
const BURST_TRIGGER_NOISE_MULT: f32 = 3.0;
|
||||
const BURST_TRIGGER_FLOOR: f32 = 1.0e-10;
|
||||
const BURST_SUSTAIN_NOISE_MULT: f32 = 1.15;
|
||||
const BURST_SUSTAIN_FLOOR: f32 = 1.0e-11;
|
||||
const BURST_TRIGGER_NOISE_MULT: f32 = 8.0;
|
||||
const BURST_TRIGGER_FLOOR: f32 = 2.0e-4;
|
||||
const BURST_SUSTAIN_NOISE_MULT: f32 = 3.0;
|
||||
const BURST_SUSTAIN_FLOOR: f32 = 1.2e-4;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VdesDecoder {
|
||||
@@ -74,8 +73,6 @@ impl VdesDecoder {
|
||||
((self.sample_rate * (MIN_BURST_MS / 1000.0)).round() as usize).max(16);
|
||||
let quiet_limit =
|
||||
((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 {
|
||||
let power = sample.norm_sqr();
|
||||
@@ -99,16 +96,6 @@ impl VdesDecoder {
|
||||
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.burst_samples.len() >= min_burst_samples {
|
||||
if let Some(msg) = self.finalize_burst(channel) {
|
||||
|
||||
@@ -3139,10 +3139,11 @@ let aprsRadioPath = null;
|
||||
const stationMarkers = new Map();
|
||||
const locatorMarkers = new Map();
|
||||
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 AIS_TRACK_MAX_POINTS = 64;
|
||||
const aisMarkers = new Map();
|
||||
const vdesMarkers = new Map();
|
||||
let selectedAisTrackMmsi = null;
|
||||
|
||||
function syncAprsReceiverMarker() {
|
||||
@@ -3205,6 +3206,17 @@ window.clearMapMarkersByType = function(type) {
|
||||
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") {
|
||||
const prefix = `${type}:`;
|
||||
for (const [key, entry] of locatorMarkers.entries()) {
|
||||
@@ -3311,6 +3323,19 @@ function initAprsMap() {
|
||||
{ className: "aprs-radio-path", weight: 2, interactive: false }
|
||||
).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();
|
||||
|
||||
const aisFilter = document.getElementById("map-filter-ais");
|
||||
const vdesFilter = document.getElementById("map-filter-vdes");
|
||||
const aprsFilter = document.getElementById("map-filter-aprs");
|
||||
const ft8Filter = document.getElementById("map-filter-ft8");
|
||||
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) {
|
||||
aprsFilter.addEventListener("change", () => {
|
||||
mapFilter.aprs = aprsFilter.checked;
|
||||
@@ -3528,6 +3560,43 @@ function buildAisPopupHtml(msg) {
|
||||
`</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(" · ");
|
||||
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) {
|
||||
if (!a || !b) return false;
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!aprsMap || !entry || !Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) return;
|
||||
if (entry.track) {
|
||||
@@ -3719,6 +3797,40 @@ window.aisMapAddVessel = function(msg) {
|
||||
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) {
|
||||
if (!grid || grid.length < 4) return null;
|
||||
const g = grid.toUpperCase();
|
||||
@@ -3754,6 +3866,7 @@ function applyMapFilter() {
|
||||
const type = marker.__trxType;
|
||||
const visible =
|
||||
(type === "ais" && mapFilter.ais) ||
|
||||
(type === "vdes" && mapFilter.vdes) ||
|
||||
(type === "aprs" && mapFilter.aprs) ||
|
||||
(type === "ft8" && mapFilter.ft8) ||
|
||||
(type === "wspr" && mapFilter.wspr);
|
||||
|
||||
@@ -560,6 +560,7 @@
|
||||
<div id="tab-map" class="tab-panel" style="display:none;">
|
||||
<div class="map-controls">
|
||||
<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-ft8" checked /> FT8</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,
|
||||
bit_len: msg.bit_len,
|
||||
raw_bytes: msg.raw_bytes,
|
||||
lat: msg.lat,
|
||||
lon: msg.lon,
|
||||
vessel_name: msg.vessel_name,
|
||||
callsign: msg.callsign,
|
||||
destination: msg.destination,
|
||||
@@ -263,6 +265,9 @@ window.onServerVdes = function(msg) {
|
||||
fec_state: msg.fec_state,
|
||||
ts_ms: msg.ts_ms,
|
||||
});
|
||||
if (msg.lat != null && msg.lon != null && window.vdesMapAddPoint) {
|
||||
window.vdesMapAddPoint(msg);
|
||||
}
|
||||
};
|
||||
|
||||
updateVdesSummary();
|
||||
|
||||
Reference in New Issue
Block a user