/* 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; } }