Files
protoloon/js/CandidateFilterByFingerprinting.js
2026-04-02 17:39:02 -06:00

554 lines
17 KiB
JavaScript

/*
Copyright (c) 2023-forever Douglas Malnati. All rights reserved.
See the /faq/tos page for details.
(If this generated header is stamped on a file which is a 3rd party file or under a different license or copyright, then ignore this copyright statement and use that file's terms.)
*/
import { CandidateFilterBase } from './CandidateFilterBase.js';
import { NonRejectedOnlyFilter } from './WsprMessageCandidate.js';
///////////////////////////////////////////////////////////////////////////
// Candidate Filter - Fingerprinting
//
// Identify messages that appear to be yours by matching frequencies
// to data you believe in. Reject everything else.
///////////////////////////////////////////////////////////////////////////
export class CandidateFilterByFingerprinting
extends CandidateFilterBase
{
constructor(t)
{
super("ByFingerprinting", t);
}
OnFilterStart()
{
this.t.Event(`CandidateFilterByFingerprinting Start`);
this.windowDataSet = new Set();
}
OnFilterEnd()
{
for (const windowData of this.windowDataSet)
{
if (windowData?.fingerprintingData)
{
windowData.fingerprintingData.winFreqDrift = this.EstimateWindowFreqDrift(windowData);
}
}
this.t.Event(`CandidateFilterByFingerprinting End`);
}
FilterWindowAlgorithm(msgListList)
{
this.FingerprintAlgorithm_ByReference(msgListList);
}
// private
///////////////////////////////////////////////////////////////////////////
// ByReference Algorithm
//
// If you can find a reference message you believe to be yours, match up
// messages in the other slots by frequency to that reference frequency,
// then reject all others.
///////////////////////////////////////////////////////////////////////////
FingerprintAlgorithm_ByReference(msgListList)
{
let windowData = this.GetWindowDataFromMsgListList(msgListList);
if (windowData)
{
this.windowDataSet.add(windowData);
if (windowData.fingerprintingData)
{
windowData.fingerprintingData.winFreqDrift = null;
}
}
let reference = this.FindNearestReference(msgListList);
if (!reference.ok)
{
for (let msgList of msgListList)
{
let msgListCandidate = NonRejectedOnlyFilter(msgList);
this.RejectAllInList(msgListCandidate, reference.reason);
}
if (windowData?.fingerprintingData)
{
windowData.fingerprintingData.referenceAudit = this.CreateReferenceAuditData(null, null, reference.source, reference.reason);
}
return;
}
let referenceMsg = reference.msg;
let referenceSlot = reference.slot;
windowData = referenceMsg.windowShortcut;
let referenceAudit = this.CreateReferenceAuditData(referenceMsg, referenceSlot, reference.source, reference.reason);
if (windowData?.fingerprintingData)
{
windowData.fingerprintingData.referenceAudit = referenceAudit;
}
for (let slot = 0; slot < 5; ++slot)
{
if (slot == referenceSlot)
{
referenceAudit.slotAuditList[slot] = this.CreateSlotAuditData(slot, [referenceMsg], referenceMsg, referenceAudit);
referenceAudit.slotAuditList[slot].outcome = "reference";
referenceAudit.slotAuditList[slot].msgMatched = referenceMsg;
referenceAudit.slotAuditList[slot].msgMatchList = [referenceMsg];
continue;
}
let msgCandidateList = NonRejectedOnlyFilter(msgListList[slot]);
let msgMatchList = [];
let freqHzDiffMatch = 0;
const FREQ_HZ_PLUS_MINUS_THRESHOLD = 5; // it's +/- this number, so 10Hz
let slotAudit = this.CreateSlotAuditData(slot, msgCandidateList, referenceMsg, referenceAudit);
referenceAudit.slotAuditList[slot] = slotAudit;
if (msgCandidateList.length)
{
slotAudit.msgAuditList = this.GetMsgFingerprintAuditList(referenceMsg, msgCandidateList, referenceAudit.referenceRxCall__rxRecordListMap);
let matchResult = this.GetWithinThresholdMatchResult(slotAudit.msgAuditList, FREQ_HZ_PLUS_MINUS_THRESHOLD);
msgMatchList = matchResult.msgMatchList;
freqHzDiffMatch = matchResult.freqHzDiffMatch;
slotAudit.matchThresholdHz = matchResult.matchThresholdHz;
}
if (msgMatchList.length == 0)
{
this.RejectAllInList(msgCandidateList, `Fingerprint match fail, exceeded ${FREQ_HZ_PLUS_MINUS_THRESHOLD} threshold.`);
slotAudit.outcome = "no_match";
}
else if (msgMatchList.length == 1)
{
this.RejectAllInListExcept(msgCandidateList, msgMatchList[0], `Fingerprint matched other message`);
slotAudit.outcome = "single_match";
slotAudit.msgMatched = msgMatchList[0];
}
else
{
slotAudit.outcome = "multi_match";
}
slotAudit.msgMatchList = msgMatchList;
slotAudit.thresholdHzTriedMax = msgCandidateList.length ? freqHzDiffMatch : null;
}
}
FindNearestReference(msgListList)
{
let slot0List = NonRejectedOnlyFilter(msgListList[0]);
if (slot0List.length == 1)
{
return {
ok: true,
msg: slot0List[0],
slot: 0,
source: "slot0",
reason: "Using unique slot 0 message as fingerprint reference.",
};
}
for (let slot = 0; slot < 5; ++slot)
{
let msgConfirmedList = NonRejectedOnlyFilter(msgListList[slot]).filter(msg => msg.IsConfirmed());
if (msgConfirmedList.length == 1)
{
return {
ok: true,
msg: msgConfirmedList[0],
slot: slot,
source: "borrowed_confirmed_within_window",
reason: `Borrowed fingerprint reference from earliest confirmed message in slot ${slot}.`,
};
}
}
if (slot0List.length == 0)
{
return {
ok: false,
msg: null,
slot: null,
source: "none",
reason: `No anchor frequency message in slot 0, and no confirmed message available to borrow within the window.`,
};
}
return {
ok: false,
msg: null,
slot: null,
source: "none",
reason: `Too many candidates (${slot0List.length}) in slot 0, and no unique confirmed message available to borrow within the window.`,
};
}
GetWindowDataFromMsgListList(msgListList)
{
for (const msgList of msgListList)
{
for (const msg of msgList)
{
if (msg?.windowShortcut)
{
return msg.windowShortcut;
}
}
}
return null;
}
CreateReferenceAuditData(referenceMsg, referenceSlot, referenceSource, referenceReason)
{
return {
referenceMsg: referenceMsg,
referenceSlot: referenceSlot,
referenceSource: referenceSource,
referenceReason: referenceReason,
referenceRxCall__rxRecordListMap: referenceMsg ? this.MakeRxCallToRxRecordListMap(referenceMsg) : new Map(),
slotAuditList: [null, null, null, null, null],
};
}
CreateSlotAuditData(slot, msgCandidateList, referenceMsg, referenceAudit)
{
return {
slot: slot,
referenceMsg: referenceMsg,
referenceSlot: referenceAudit.referenceSlot,
referenceRxCall__rxRecordListMap: referenceAudit.referenceRxCall__rxRecordListMap,
msgCandidateList: msgCandidateList,
msgAuditList: [],
msgMatchList: [],
msgMatched: null,
matchThresholdHz: null,
thresholdHzTriedMax: null,
outcome: msgCandidateList.length ? "pending" : "no_candidates",
};
}
GetMsgFingerprintAuditList(msgA, msgBList, rxCall__rxRecordListAMap)
{
let msgAuditList = [];
for (let msgB of msgBList)
{
let diffData = this.GetMinFreqDiffData(msgA, msgB, rxCall__rxRecordListAMap);
msgAuditList.push({
msg: msgB,
minFreqDiff: diffData.minDiff,
rxCallMatchList: diffData.rxCallMatchList,
candidateRxCall__rxRecordListMap: diffData.rxCall__rxRecordListB,
});
}
return msgAuditList;
}
GetWithinThresholdMatchResult(msgAuditList, thresholdMax)
{
let msgMatchList = [];
let freqHzDiffMatch = thresholdMax;
let matchThresholdHz = null;
for (freqHzDiffMatch = 0; freqHzDiffMatch <= thresholdMax; ++freqHzDiffMatch)
{
msgMatchList = msgAuditList
.filter(msgAudit => msgAudit.minFreqDiff != null && msgAudit.minFreqDiff <= freqHzDiffMatch)
.map(msgAudit => msgAudit.msg);
if (msgMatchList.length != 0)
{
matchThresholdHz = freqHzDiffMatch;
break;
}
}
return {
msgMatchList,
freqHzDiffMatch,
matchThresholdHz,
};
}
// Return the set of msgBList elements which fall within the threshold difference
// of frequency when compared to msgA.
GetWithinThresholdList(msgA, msgBList, threshold)
{
let msgListWithinThreshold = [];
// calculate minimum frequency diff between msgA and this
// message of this slot
let msg__minFreqDiff = new Map();
for (let msgB of msgBList)
{
msg__minFreqDiff.set(msgB, this.GetMinFreqDiff(msgA, msgB));
}
// find out which messages fall within tolerance
for (let [msgB, freqDiff] of msg__minFreqDiff)
{
if (freqDiff != null && freqDiff <= threshold)
{
msgListWithinThreshold.push(msgB);
}
}
return msgListWithinThreshold;
}
MakeRxCallToRxRecordListMap(msg, limitBySet)
{
limitBySet = limitBySet ?? null;
let rxCall__recordMap = new Map();
for (let rxRecord of msg.rxRecordList)
{
let rxCall = rxRecord.rxCallsign;
if (limitBySet == null || limitBySet.has(rxCall))
{
if (rxCall__recordMap.has(rxCall) == false)
{
rxCall__recordMap.set(rxCall, []);
}
rxCall__recordMap.get(rxCall).push(rxRecord);
}
}
return rxCall__recordMap;
}
// Find min diff of entries in B compared to looked up in A.
// Only compare equal rxCallsigns.
// So, we're looking at:
// - for any common rxCallsign
// - across all frequencies reported by that rxCallsign
// - what is the minimum difference in frequency seen?
GetMinFreqDiff(msgA, msgB)
{
return this.GetMinFreqDiffData(msgA, msgB).minDiff;
}
GetMinFreqDiffData(msgA, msgB, rxCall__rxRecordListAMap)
{
let rxCall__rxRecordListA = rxCall__rxRecordListAMap ?? this.MakeRxCallToRxRecordListMap(msgA);
let rxCall__rxRecordListB = this.MakeRxCallToRxRecordListMap(msgB, rxCall__rxRecordListA);
let minDiff = null;
let rxCallMatchList = [];
for (let [rxCall, rxRecordListB] of rxCall__rxRecordListB)
{
let rxRecordListA = rxCall__rxRecordListA.get(rxCall);
// unavoidable(?) M*N operation here, hopefully M and N are small
let diff = this.GetMinFreqDiffRxRecordList(rxRecordListA, rxRecordListB);
rxCallMatchList.push({
rxCall,
rxRecordListA,
rxRecordListB,
minFreqDiff: diff,
});
if (minDiff == null || diff < minDiff)
{
minDiff = diff;
}
}
return {
minDiff,
rxCall__rxRecordListA,
rxCall__rxRecordListB,
rxCallMatchList,
};
}
// Returns the smallest absolute difference between frequencies found in the two
// supplied record lists. This is an M*N operation.
//
// This function has no knowledge or assumptions about the contents of the
// two lists (ie whether the callsigns are the same).
//
// This is simply a function broken out to keep calling code simpler.
GetMinFreqDiffRxRecordList(rxRecordListA, rxRecordListB)
{
let minDiff = null;
for (let rxRecordA of rxRecordListA)
{
for (let rxRecordB of rxRecordListB)
{
let diff = Math.abs(rxRecordA.frequency - rxRecordB.frequency);
if (minDiff == null || diff < minDiff)
{
minDiff = diff;
}
}
}
return minDiff;
}
EstimateWindowFreqDrift(windowData)
{
let referenceAudit = windowData?.fingerprintingData?.referenceAudit;
if (!referenceAudit?.referenceMsg)
{
return null;
}
let slotEstimateList = [];
for (let slot = (referenceAudit.referenceSlot + 1); slot < 5; ++slot)
{
let slotAudit = referenceAudit.slotAuditList?.[slot];
let slotDelta = this.EstimateWindowFreqDriftForSlot(slotAudit);
if (slotDelta == null)
{
continue;
}
slotEstimateList.push({
deltaHz: slotDelta,
weight: slot - referenceAudit.referenceSlot,
});
}
if (slotEstimateList.length == 0)
{
return null;
}
let weightedSum = 0;
let weightSum = 0;
for (const estimate of slotEstimateList)
{
weightedSum += estimate.deltaHz * estimate.weight;
weightSum += estimate.weight;
}
if (weightSum == 0)
{
return null;
}
return Math.round(weightedSum / weightSum);
}
EstimateWindowFreqDriftForSlot(slotAudit)
{
if (!slotAudit?.msgAuditList?.length || !slotAudit?.msgMatchList?.length)
{
return null;
}
let msgMatchSet = new Set(slotAudit.msgMatchList);
let rxCall__bestDelta = new Map();
for (const msgAudit of slotAudit.msgAuditList)
{
if (!msgMatchSet.has(msgAudit.msg))
{
continue;
}
for (const rxCallMatch of msgAudit.rxCallMatchList || [])
{
let bestSignedDelta = this.GetBestSignedFreqDelta(rxCallMatch.rxRecordListA, rxCallMatch.rxRecordListB);
if (bestSignedDelta == null)
{
continue;
}
let rxCall = rxCallMatch.rxCall;
if (!rxCall__bestDelta.has(rxCall))
{
rxCall__bestDelta.set(rxCall, bestSignedDelta);
continue;
}
let cur = rxCall__bestDelta.get(rxCall);
if (Math.abs(bestSignedDelta) < Math.abs(cur))
{
rxCall__bestDelta.set(rxCall, bestSignedDelta);
}
}
}
let deltaList = Array.from(rxCall__bestDelta.values());
if (deltaList.length == 0)
{
return null;
}
return this.GetMedian(deltaList);
}
GetBestSignedFreqDelta(rxRecordListA, rxRecordListB)
{
if (!Array.isArray(rxRecordListA) || !Array.isArray(rxRecordListB))
{
return null;
}
let bestSignedDelta = null;
for (const rxRecordA of rxRecordListA)
{
for (const rxRecordB of rxRecordListB)
{
let freqA = Number(rxRecordA?.frequency);
let freqB = Number(rxRecordB?.frequency);
if (!Number.isFinite(freqA) || !Number.isFinite(freqB))
{
continue;
}
let signedDelta = freqB - freqA;
if (bestSignedDelta == null || Math.abs(signedDelta) < Math.abs(bestSignedDelta))
{
bestSignedDelta = signedDelta;
}
}
}
return bestSignedDelta;
}
GetMedian(numList)
{
if (!numList || numList.length == 0)
{
return null;
}
let list = [...numList].sort((a, b) => a - b);
let idxMid = Math.floor(list.length / 2);
if (list.length % 2 == 1)
{
return list[idxMid];
}
return (list[idxMid - 1] + list[idxMid]) / 2;
}
}