1171 lines
41 KiB
JavaScript
1171 lines
41 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 * as utl from '/js/Utl.js';
|
|
|
|
import { Base } from './Base.js';
|
|
import { TabularData } from '../../../../js/TabularData.js';
|
|
import { ColumnBuilderRegularType1 } from './WsprSearchResultDataTableColumnBuilderRegularType1.js';
|
|
import { ColumnBuilderHeartbeat } from './WsprSearchResultDataTableColumnBuilderHeartbeat.js';
|
|
import { ColumnBuilderTelemetryBasic } from './WsprSearchResultDataTableColumnBuilderTelemetryBasic.js';
|
|
import { ColumnBuilderExpandedBasicTelemetry } from './WsprSearchResultDataTableColumnBuilderExpandedBasicTelemetry.js';
|
|
import { ColumnBuilderTelemetryExtendedUserOrVendorDefined } from './WsprSearchResultDataTableColumnBuilderTelemetryExtendedUserOrVendorDefined.js';
|
|
import { ColumnBuilderHighResLocation } from './WsprSearchResultDataTableColumnBuilderHighResLocation.js';
|
|
import { CodecHeartbeat } from './CodecHeartbeat.js';
|
|
import { CodecExpandedBasicTelemetry } from './CodecExpandedBasicTelemetry.js';
|
|
import { CodecHighResLocation } from './CodecHighResLocation.js';
|
|
|
|
import { WSPREncoded } from '/js/WSPREncoded.js';
|
|
import { GreatCircle } from '/js/GreatCircle.js';
|
|
|
|
import './suncalc.js';
|
|
|
|
|
|
// Adapter to the WsprSearch results.
|
|
// Extracts data from the results where unambiguous.
|
|
// Enriches with maximum value add
|
|
// Including decoding, unit converting, etc
|
|
//
|
|
export class WsprSearchResultDataTableBuilder
|
|
extends Base
|
|
{
|
|
constructor()
|
|
{
|
|
super();
|
|
|
|
this.codecHeartbeat = new CodecHeartbeat();
|
|
this.codecExpandedBasicTelemetry = new CodecExpandedBasicTelemetry();
|
|
this.codecHighResLocation = new CodecHighResLocation();
|
|
}
|
|
|
|
SetDebug(tf)
|
|
{
|
|
super.SetDebug(tf);
|
|
|
|
this.t.SetCcGlobal(tf);
|
|
}
|
|
|
|
BuildDataTable(wsprSearch)
|
|
{
|
|
this.t.Reset();
|
|
this.t.Event(`WsprSearchResultDataTableBuilder::BuildTable Start`);
|
|
|
|
let codecMakerUserDefinedList = wsprSearch.GetCodecMakerUserDefinedList();
|
|
let codecMakerVendorDefinedList = wsprSearch.GetCodecMakerVendorDefinedList();
|
|
|
|
// find the set of column builders that apply to this dataset
|
|
let cbSetOrdered = new Set([
|
|
new ColumnBuilderRegularType1(),
|
|
new ColumnBuilderTelemetryBasic(),
|
|
new ColumnBuilderHeartbeat(),
|
|
new ColumnBuilderExpandedBasicTelemetry(),
|
|
new ColumnBuilderHighResLocation(),
|
|
]);
|
|
|
|
for (let slot = 0; slot < 5; ++slot)
|
|
{
|
|
cbSetOrdered.add(new ColumnBuilderTelemetryExtendedUserOrVendorDefined(slot, codecMakerUserDefinedList[slot], "ud"));
|
|
}
|
|
|
|
for (let slot = 0; slot < 5; ++slot)
|
|
{
|
|
cbSetOrdered.add(new ColumnBuilderTelemetryExtendedUserOrVendorDefined(slot, codecMakerVendorDefinedList[slot], "vd"));
|
|
}
|
|
|
|
// take note of which column builders have matched the data
|
|
let cbSetNotSeen = new Set(cbSetOrdered);
|
|
let cbSetSeen = new Set()
|
|
wsprSearch.ForEachWindow((time, slotMsgList) => {
|
|
let retVal = true;
|
|
|
|
for (const cb of cbSetNotSeen)
|
|
{
|
|
if (cb.MatchWindow?.(slotMsgList))
|
|
{
|
|
cbSetSeen.add(cb);
|
|
cbSetNotSeen.delete(cb);
|
|
}
|
|
}
|
|
|
|
// search across every slot
|
|
for (const msg of slotMsgList)
|
|
{
|
|
if (msg)
|
|
{
|
|
for (const cb of cbSetNotSeen)
|
|
{
|
|
if (cb.Match?.(msg))
|
|
{
|
|
cbSetSeen.add(cb);
|
|
cbSetNotSeen.delete(cb);
|
|
}
|
|
}
|
|
}
|
|
|
|
// no need to keep looking if every supported builder is known already
|
|
if (cbSetNotSeen.size == 0)
|
|
{
|
|
retVal = false;
|
|
|
|
break;
|
|
}
|
|
}
|
|
|
|
return retVal;
|
|
});
|
|
|
|
// columns may wind up ordered oddly, since (say) in the first row, slot 4 might be seen,
|
|
// but then in the second row, slot 3 then slot 4. This would leave an ordering of
|
|
// slot 4, slot 3.
|
|
// we sort by the original order here to restore.
|
|
let cbSetSeenNew = new Set();
|
|
|
|
for (const cb of cbSetOrdered)
|
|
{
|
|
if (cbSetSeen.has(cb))
|
|
{
|
|
cbSetSeenNew.add(cb);
|
|
}
|
|
}
|
|
|
|
cbSetSeen = cbSetSeenNew;
|
|
|
|
// build data table
|
|
let colNameList = [];
|
|
colNameList.push(... [
|
|
"DateTimeUtc",
|
|
"DateTimeLocal",
|
|
]);
|
|
|
|
for (const cb of cbSetSeen)
|
|
{
|
|
colNameList.push(... cb.GetColNameList());
|
|
}
|
|
|
|
let td = new TabularData([colNameList]);
|
|
|
|
// populate data table
|
|
wsprSearch.ForEachWindow((time, slotMsgList) => {
|
|
let row = td.AddRow();
|
|
|
|
// fill out time columns
|
|
td.Set(row, "DateTimeUtc", time);
|
|
td.Set(row, "DateTimeLocal", utl.ConvertUtcToLocal(time));
|
|
|
|
// only let a column builder run once per window
|
|
let cbSetUse = new Set(cbSetSeen);
|
|
|
|
for (const cb of cbSetUse)
|
|
{
|
|
if (cb.MatchWindow?.(slotMsgList))
|
|
{
|
|
let colNameList = cb.GetColNameList();
|
|
let valList = cb.GetValListForWindow(slotMsgList);
|
|
|
|
for (let i = 0; i < colNameList.length; ++i)
|
|
{
|
|
td.Set(row, colNameList[i], valList[i]);
|
|
}
|
|
|
|
cbSetUse.delete(cb);
|
|
}
|
|
}
|
|
|
|
for (const msg of slotMsgList)
|
|
{
|
|
if (msg)
|
|
{
|
|
for (const cb of cbSetUse)
|
|
{
|
|
if (cb.Match?.(msg))
|
|
{
|
|
let colNameList = cb.GetColNameList();
|
|
let valList = cb.GetValList(msg)
|
|
|
|
for (let i = 0; i < colNameList.length; ++i)
|
|
{
|
|
td.Set(row, colNameList[i], valList[i]);
|
|
}
|
|
|
|
// only let a column builder run once per window
|
|
cbSetUse.delete(cb);
|
|
|
|
break;
|
|
}
|
|
}
|
|
|
|
// if all column builders have run, nothing left to do for this window
|
|
if (cbSetUse.size == 0)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// add column metadata
|
|
for (const cb of cbSetSeen)
|
|
{
|
|
// these must be the same length
|
|
let colNameList = cb.GetColNameList();
|
|
let metaDataList = cb.GetColMetaDataList();
|
|
|
|
for (let i = 0; i < colNameList.length; ++i)
|
|
{
|
|
let colName = colNameList[i];
|
|
let colMetaData = metaDataList[i];
|
|
|
|
td.SetColMetaData(colName, colMetaData);
|
|
}
|
|
}
|
|
|
|
// add row metadata
|
|
let idx = 0;
|
|
wsprSearch.ForEachWindow((time, slotMsgList) => {
|
|
td.SetRowMetaData(idx, {
|
|
time,
|
|
slotMsgList,
|
|
});
|
|
++idx;
|
|
});
|
|
|
|
this.SynthesizeData(td, wsprSearch);
|
|
|
|
this.t.Event(`WsprSearchResultDataTableBuilder::BuildTable End`);
|
|
|
|
return td;
|
|
}
|
|
|
|
GetRxStationCount(slotMsgList)
|
|
{
|
|
let rxStationSet = new Set();
|
|
|
|
for (const msg of slotMsgList)
|
|
{
|
|
if (!msg || !Array.isArray(msg.rxRecordList))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
for (const rxRecord of msg.rxRecordList)
|
|
{
|
|
// rxCallsign identifies receiving station in the raw query row.
|
|
let rxStation = rxRecord?.rxCallsign;
|
|
if (rxStation == undefined || rxStation == null || rxStation === "")
|
|
{
|
|
continue;
|
|
}
|
|
|
|
rxStationSet.add(rxStation);
|
|
}
|
|
}
|
|
|
|
return rxStationSet.size;
|
|
}
|
|
|
|
GetRowTxLatLng(td, row)
|
|
{
|
|
let lat = td.Idx("Lat") != undefined ? td.Get(row, "Lat") : null;
|
|
let lng = td.Idx("Lng") != undefined ? td.Get(row, "Lng") : null;
|
|
|
|
if (lat != null && lng != null)
|
|
{
|
|
return [lat, lng];
|
|
}
|
|
|
|
let overlap = td.GetRowMetaData(row)?.overlap;
|
|
let hasOtherLocationBearing = !!(overlap?.selected?.bt || overlap?.selected?.ebt || overlap?.selected?.hrl);
|
|
|
|
if (!hasOtherLocationBearing)
|
|
{
|
|
let regLat = td.Idx("RegLat") != undefined ? td.Get(row, "RegLat") : null;
|
|
let regLng = td.Idx("RegLng") != undefined ? td.Get(row, "RegLng") : null;
|
|
|
|
if (regLat != null && regLng != null)
|
|
{
|
|
return [regLat, regLng];
|
|
}
|
|
}
|
|
|
|
return [null, null];
|
|
}
|
|
|
|
GetRowTxLatLngForSolarAngle(td, row)
|
|
{
|
|
let [lat, lng] = this.GetRowTxLatLng(td, row);
|
|
|
|
if (lat != null && lng != null)
|
|
{
|
|
return [lat, lng];
|
|
}
|
|
|
|
let overlap = td.GetRowMetaData(row)?.overlap;
|
|
let hasOtherLocationBearing = !!(overlap?.selected?.bt || overlap?.selected?.ebt || overlap?.selected?.hrl);
|
|
let regGrid = td.Idx("RegGrid") != undefined ? td.Get(row, "RegGrid") : null;
|
|
|
|
if (regGrid != null && !hasOtherLocationBearing)
|
|
{
|
|
return WSPREncoded.DecodeMaidenheadToDeg(regGrid);
|
|
}
|
|
|
|
return [null, null];
|
|
}
|
|
|
|
SynthesizeData(td, wsprSearch)
|
|
{
|
|
// Build the cross-message overlap view first so later synthesized columns
|
|
// can consume the resolved/raw-derived families as ordinary table data.
|
|
this.SynthesizeOverlapFamilies(td);
|
|
this.ShortenTime(td);
|
|
this.SynthesizeTxFreqMhz(td, wsprSearch);
|
|
this.SynthesizeSolarAngle(td);
|
|
this.SynthesizeRxStationCount(td);
|
|
this.SynthesizeWinFreqDrift(td, wsprSearch);
|
|
this.SynthesizeAltChg(td);
|
|
this.SynthesizeDistance(td);
|
|
this.SynthesizeSpeedGPS(td);
|
|
}
|
|
|
|
SynthesizeTxFreqMhz(td, wsprSearch)
|
|
{
|
|
if (td.Idx("TxFreqHzIdx") == undefined)
|
|
{
|
|
return;
|
|
}
|
|
|
|
let band = wsprSearch?.band ?? "";
|
|
td.AppendGeneratedColumns([
|
|
"TxFreqMhz"
|
|
], row => {
|
|
let txFreqHzIdx = td.Get(row, "TxFreqHzIdx");
|
|
let txFreqHz = this.codecHeartbeat.DecodeTxFreqHzFromBand(band, txFreqHzIdx);
|
|
let txFreqMhz = txFreqHz != null ? txFreqHz / 1000000 : null;
|
|
|
|
return [txFreqMhz];
|
|
}, true);
|
|
|
|
td.SetColMetaData("TxFreqMhz", {});
|
|
}
|
|
|
|
ShortenTime(td)
|
|
{
|
|
td.GenerateModifiedColumn([
|
|
"DateTimeUtc"
|
|
], row => {
|
|
let retVal = [
|
|
td.Get(row, "DateTimeUtc").substr(0, 16),
|
|
];
|
|
|
|
return retVal;
|
|
});
|
|
|
|
td.GenerateModifiedColumn([
|
|
"DateTimeLocal"
|
|
], row => {
|
|
let retVal = [
|
|
td.Get(row, "DateTimeLocal").substr(0, 16),
|
|
];
|
|
|
|
return retVal;
|
|
});
|
|
}
|
|
|
|
GetSelectedOverlapMessages(slotMsgList)
|
|
{
|
|
let selected = {
|
|
rt1: null,
|
|
bt: null,
|
|
ebt: null,
|
|
hrl: null,
|
|
};
|
|
|
|
if (!Array.isArray(slotMsgList))
|
|
{
|
|
return selected;
|
|
}
|
|
|
|
let msg0 = slotMsgList[0];
|
|
if (msg0?.IsRegular?.())
|
|
{
|
|
selected.rt1 = { slot: 0, msg: msg0 };
|
|
}
|
|
|
|
let msg1 = slotMsgList[1];
|
|
if (msg1?.IsTelemetryBasic?.())
|
|
{
|
|
selected.bt = { slot: 1, msg: msg1 };
|
|
}
|
|
|
|
for (let slot = Math.min(4, slotMsgList.length - 1); slot >= 1; --slot)
|
|
{
|
|
let msg = slotMsgList[slot];
|
|
if (!msg?.IsTelemetryExtended?.())
|
|
{
|
|
continue;
|
|
}
|
|
|
|
let codec = msg.GetCodec?.();
|
|
if (selected.ebt == null && this.codecExpandedBasicTelemetry.IsCodecExpandedBasicTelemetry(codec))
|
|
{
|
|
selected.ebt = { slot, msg };
|
|
}
|
|
if (selected.hrl == null && this.codecHighResLocation.IsCodecHighResLocation(codec))
|
|
{
|
|
selected.hrl = { slot, msg };
|
|
}
|
|
|
|
if (selected.ebt && selected.hrl)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
|
|
return selected;
|
|
}
|
|
|
|
SynthesizeOverlapFamilies(td)
|
|
{
|
|
// Decide which overlap families exist at all from raw builder output.
|
|
// This keeps raw extraction independent while letting synthesis add only
|
|
// the resolved/derived columns that make sense for this dataset.
|
|
let hasRegLocation = td.Idx("RegGrid") != undefined || td.Idx("RegLat") != undefined || td.Idx("RegLng") != undefined;
|
|
let hasBtLocation = td.Idx("BtGrid56") != undefined;
|
|
let hasEbtLocation = td.Idx("EbtLatitudeIdx") != undefined;
|
|
let hasHiResLocation = td.Idx("HiResLatitudeIdx") != undefined;
|
|
|
|
let needsLocationFamily = hasRegLocation || hasBtLocation || hasEbtLocation || hasHiResLocation;
|
|
let needsTemperatureFamily = td.Idx("BtTempC") != undefined || td.Idx("EbtTempF") != undefined;
|
|
let needsVoltageFamily = td.Idx("BtVoltage") != undefined || td.Idx("EbtVoltage") != undefined;
|
|
let needsAltitudeFamily = td.Idx("BtAltM") != undefined || td.Idx("EbtAltFt") != undefined;
|
|
let needsSpeedFamily = td.Idx("BtKPH") != undefined || td.Idx("BtMPH") != undefined;
|
|
|
|
// Selection happens before synthesis so later family logic can stay
|
|
// readable: each row already knows which message instance is "the"
|
|
// RT1/BT/EBT/HRL selected for that window.
|
|
for (let row = 0; row < td.Length(); ++row)
|
|
{
|
|
let metaData = td.GetRowMetaData(row) ?? {};
|
|
metaData.overlapSelected = this.GetSelectedOverlapMessages(metaData.slotMsgList ?? []);
|
|
td.SetRowMetaData(row, metaData);
|
|
}
|
|
|
|
let colList = [];
|
|
if (hasBtLocation) { colList.push("BtGrid6", "BtLat", "BtLng"); }
|
|
if (hasEbtLocation) { colList.push("EbtLat", "EbtLng"); }
|
|
if (hasHiResLocation) { colList.push("HiResLat", "HiResLng"); }
|
|
if (needsLocationFamily) { colList.push("Lat", "Lng"); }
|
|
if (needsTemperatureFamily) { colList.push("TempF", "TempC"); }
|
|
if (needsVoltageFamily) { colList.push("Voltage"); }
|
|
if (needsAltitudeFamily) { colList.push("AltFt", "AltM"); }
|
|
if (needsSpeedFamily) { colList.push("KPH", "MPH"); }
|
|
|
|
let SetOverlapMetaData = (row, selected, sourceByFamily = {}, style = null) => {
|
|
let metaData = td.GetRowMetaData(row) ?? {};
|
|
delete metaData.overlapSelected;
|
|
metaData.overlap = {
|
|
selected,
|
|
resolved: { sourceByFamily },
|
|
style: style ?? {
|
|
dimmedCols: new Set(),
|
|
italicCols: new Set(),
|
|
precisionByCol: {},
|
|
},
|
|
};
|
|
td.SetRowMetaData(row, metaData);
|
|
};
|
|
|
|
if (colList.length == 0)
|
|
{
|
|
for (let row = 0; row < td.Length(); ++row)
|
|
{
|
|
let selected = td.GetRowMetaData(row)?.overlapSelected ?? this.GetSelectedOverlapMessages([]);
|
|
SetOverlapMetaData(row, selected);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
td.AppendGeneratedColumns(colList, row => {
|
|
let metaData = td.GetRowMetaData(row) ?? {};
|
|
let selected = metaData.overlapSelected ?? this.GetSelectedOverlapMessages([]);
|
|
let sourceByFamily = {};
|
|
let style = {
|
|
dimmedCols: new Set(),
|
|
italicCols: new Set(),
|
|
precisionByCol: {},
|
|
};
|
|
let out = new Map();
|
|
|
|
let regGrid = td.Idx("RegGrid") != undefined ? td.Get(row, "RegGrid") : null;
|
|
let regLat = td.Idx("RegLat") != undefined ? td.Get(row, "RegLat") : null;
|
|
let regLng = td.Idx("RegLng") != undefined ? td.Get(row, "RegLng") : null;
|
|
let hasSelectedOtherLocationBearing = !!(selected.bt || selected.ebt || selected.hrl);
|
|
|
|
if (regLat != null && regLng != null)
|
|
{
|
|
style.precisionByCol.RegLat = 2;
|
|
style.precisionByCol.RegLng = 2;
|
|
}
|
|
|
|
// Location family:
|
|
// 1. derive raw per-message location columns that require cross-message
|
|
// context (RT1 reference grid)
|
|
// 2. choose the winning location source by latest usable selected message
|
|
// 3. populate resolved location plus styling for dimmed/superseded raw data
|
|
let btGpsValid = td.Idx("BtGpsValid") != undefined ? !!td.Get(row, "BtGpsValid") : false;
|
|
let btGrid56 = td.Idx("BtGrid56") != undefined ? td.Get(row, "BtGrid56") : null;
|
|
let btGrid6 = regGrid != null && btGrid56 != null ? `${regGrid}${btGrid56}` : null;
|
|
let btLat = null;
|
|
let btLng = null;
|
|
if (btGpsValid && btGrid6 != null)
|
|
{
|
|
[btLat, btLng] = WSPREncoded.DecodeMaidenheadToDeg(btGrid6);
|
|
style.precisionByCol.BtLat = 4;
|
|
style.precisionByCol.BtLng = 4;
|
|
}
|
|
if (hasBtLocation)
|
|
{
|
|
out.set("BtGrid6", btGrid6);
|
|
out.set("BtLat", btLat);
|
|
out.set("BtLng", btLng);
|
|
if (!btGpsValid)
|
|
{
|
|
["BtGrid56", "BtGrid6", "BtLat", "BtLng"].forEach(col => style.dimmedCols.add(col));
|
|
}
|
|
}
|
|
|
|
let ebtGpsValid = td.Idx("EbtGpsValid") != undefined ? !!td.Get(row, "EbtGpsValid") : false;
|
|
let ebtLat = null;
|
|
let ebtLng = null;
|
|
if (hasEbtLocation && regGrid != null && ebtGpsValid)
|
|
{
|
|
let location = this.codecExpandedBasicTelemetry.DecodeFieldValuesToLocation(
|
|
regGrid,
|
|
td.Get(row, "EbtLatitudeIdx"),
|
|
td.Get(row, "EbtLongitudeIdx"),
|
|
);
|
|
|
|
ebtLat = location?.lat ?? null;
|
|
ebtLng = location?.lng ?? null;
|
|
style.precisionByCol.EbtLat = 6;
|
|
style.precisionByCol.EbtLng = 6;
|
|
}
|
|
if (hasEbtLocation)
|
|
{
|
|
out.set("EbtLat", ebtLat);
|
|
out.set("EbtLng", ebtLng);
|
|
if (!ebtGpsValid)
|
|
{
|
|
["EbtLatitudeIdx", "EbtLongitudeIdx", "EbtLat", "EbtLng"].forEach(col => style.dimmedCols.add(col));
|
|
}
|
|
}
|
|
|
|
let hiResReference = td.Idx("HiResReference") != undefined ? td.Get(row, "HiResReference") : null;
|
|
let hiResValid = Number(hiResReference) == 1;
|
|
let hiResLat = null;
|
|
let hiResLng = null;
|
|
if (hasHiResLocation && regGrid != null && hiResValid)
|
|
{
|
|
let location = this.codecHighResLocation.DecodeFieldValuesToLocation(
|
|
regGrid,
|
|
hiResReference,
|
|
td.Get(row, "HiResLatitudeIdx"),
|
|
td.Get(row, "HiResLongitudeIdx"),
|
|
);
|
|
|
|
hiResLat = location?.lat ?? null;
|
|
hiResLng = location?.lng ?? null;
|
|
style.precisionByCol.HiResLat = 6;
|
|
style.precisionByCol.HiResLng = 6;
|
|
}
|
|
if (hasHiResLocation)
|
|
{
|
|
out.set("HiResLat", hiResLat);
|
|
out.set("HiResLng", hiResLng);
|
|
if (!hiResValid)
|
|
{
|
|
["HiResReference", "HiResLatitudeIdx", "HiResLongitudeIdx", "HiResLat", "HiResLng"].forEach(col => style.dimmedCols.add(col));
|
|
}
|
|
}
|
|
|
|
let locationCandidateList = [];
|
|
if (selected.bt && btGpsValid && btLat != null && btLng != null)
|
|
{
|
|
locationCandidateList.push({
|
|
source: "BT",
|
|
slot: selected.bt.slot,
|
|
lat: btLat,
|
|
lng: btLng,
|
|
italicCols: ["BtGrid56", "BtGrid6", "BtLat", "BtLng"],
|
|
precision: 4,
|
|
});
|
|
}
|
|
if (selected.ebt && ebtGpsValid && ebtLat != null && ebtLng != null)
|
|
{
|
|
locationCandidateList.push({
|
|
source: "EBT",
|
|
slot: selected.ebt.slot,
|
|
lat: ebtLat,
|
|
lng: ebtLng,
|
|
grid: null,
|
|
italicCols: ["EbtLatitudeIdx", "EbtLongitudeIdx", "EbtLat", "EbtLng"],
|
|
precision: 6,
|
|
});
|
|
}
|
|
if (selected.hrl && hiResValid && hiResLat != null && hiResLng != null)
|
|
{
|
|
locationCandidateList.push({
|
|
source: "HRL",
|
|
slot: selected.hrl.slot,
|
|
lat: hiResLat,
|
|
lng: hiResLng,
|
|
grid: null,
|
|
italicCols: ["HiResReference", "HiResLatitudeIdx", "HiResLongitudeIdx", "HiResLat", "HiResLng"],
|
|
precision: 6,
|
|
});
|
|
}
|
|
if (selected.rt1 && !hasSelectedOtherLocationBearing && regLat != null && regLng != null)
|
|
{
|
|
locationCandidateList.push({
|
|
source: "RT1",
|
|
slot: selected.rt1.slot,
|
|
lat: regLat,
|
|
lng: regLng,
|
|
italicCols: ["RegGrid", "RegLat", "RegLng"],
|
|
precision: 2,
|
|
});
|
|
}
|
|
|
|
locationCandidateList.sort((a, b) => b.slot - a.slot);
|
|
let selectedLocation = locationCandidateList[0] ?? null;
|
|
sourceByFamily.location = selectedLocation?.source ?? null;
|
|
|
|
if (needsLocationFamily)
|
|
{
|
|
out.set("Lat", selectedLocation?.lat ?? null);
|
|
out.set("Lng", selectedLocation?.lng ?? null);
|
|
if (selectedLocation)
|
|
{
|
|
style.precisionByCol.Lat = selectedLocation.precision;
|
|
style.precisionByCol.Lng = selectedLocation.precision;
|
|
}
|
|
}
|
|
|
|
if (selectedLocation?.source != "RT1" && regGrid != null)
|
|
{
|
|
["RegGrid", "RegLat", "RegLng"].forEach(col => style.italicCols.add(col));
|
|
}
|
|
if (selectedLocation?.source != "BT" && selected.bt && btGpsValid && btLat != null && btLng != null)
|
|
{
|
|
["BtGrid56", "BtGrid6", "BtLat", "BtLng"].forEach(col => style.italicCols.add(col));
|
|
}
|
|
if (selectedLocation?.source != "EBT" && selected.ebt && ebtGpsValid && ebtLat != null && ebtLng != null)
|
|
{
|
|
["EbtLatitudeIdx", "EbtLongitudeIdx", "EbtLat", "EbtLng"].forEach(col => style.italicCols.add(col));
|
|
}
|
|
if (selectedLocation?.source != "HRL" && selected.hrl && hiResValid && hiResLat != null && hiResLng != null)
|
|
{
|
|
["HiResReference", "HiResLatitudeIdx", "HiResLongitudeIdx", "HiResLat", "HiResLng"].forEach(col => style.italicCols.add(col));
|
|
}
|
|
|
|
// Temperature family:
|
|
// choose the latest selected source carrying temperature, independent
|
|
// of GPS validity since temperature is not GPS-derived.
|
|
let temperatureCandidateList = [];
|
|
if (selected.bt && td.Idx("BtTempF") != undefined)
|
|
{
|
|
temperatureCandidateList.push({
|
|
source: "BT",
|
|
slot: selected.bt.slot,
|
|
tempF: td.Get(row, "BtTempF"),
|
|
tempC: td.Get(row, "BtTempC"),
|
|
italicCols: ["BtTempC", "BtTempF"],
|
|
});
|
|
}
|
|
if (selected.ebt && td.Idx("EbtTempF") != undefined)
|
|
{
|
|
temperatureCandidateList.push({
|
|
source: "EBT",
|
|
slot: selected.ebt.slot,
|
|
tempF: td.Get(row, "EbtTempF"),
|
|
tempC: td.Get(row, "EbtTempC"),
|
|
italicCols: ["EbtTempC", "EbtTempF"],
|
|
});
|
|
}
|
|
|
|
temperatureCandidateList.sort((a, b) => b.slot - a.slot);
|
|
let selectedTemperature = temperatureCandidateList[0] ?? null;
|
|
if (needsTemperatureFamily)
|
|
{
|
|
out.set("TempF", selectedTemperature?.tempF ?? null);
|
|
out.set("TempC", selectedTemperature?.tempC ?? null);
|
|
}
|
|
sourceByFamily.temperature = selectedTemperature?.source ?? null;
|
|
for (let candidate of temperatureCandidateList)
|
|
{
|
|
if (candidate.source != selectedTemperature?.source)
|
|
{
|
|
candidate.italicCols.forEach(col => style.italicCols.add(col));
|
|
}
|
|
}
|
|
|
|
// Voltage family:
|
|
// same selection model as temperature: latest selected source wins.
|
|
let voltageCandidateList = [];
|
|
if (selected.bt && td.Idx("BtVoltage") != undefined)
|
|
{
|
|
voltageCandidateList.push({
|
|
source: "BT",
|
|
slot: selected.bt.slot,
|
|
voltage: td.Get(row, "BtVoltage"),
|
|
italicCols: ["BtVoltage"],
|
|
});
|
|
}
|
|
if (selected.ebt && td.Idx("EbtVoltage") != undefined)
|
|
{
|
|
voltageCandidateList.push({
|
|
source: "EBT",
|
|
slot: selected.ebt.slot,
|
|
voltage: td.Get(row, "EbtVoltage"),
|
|
italicCols: ["EbtVoltage"],
|
|
});
|
|
}
|
|
|
|
voltageCandidateList.sort((a, b) => b.slot - a.slot);
|
|
let selectedVoltage = voltageCandidateList[0] ?? null;
|
|
if (needsVoltageFamily)
|
|
{
|
|
out.set("Voltage", selectedVoltage?.voltage ?? null);
|
|
}
|
|
sourceByFamily.voltage = selectedVoltage?.source ?? null;
|
|
for (let candidate of voltageCandidateList)
|
|
{
|
|
if (candidate.source != selectedVoltage?.source)
|
|
{
|
|
candidate.italicCols.forEach(col => style.italicCols.add(col));
|
|
}
|
|
}
|
|
|
|
// Altitude family:
|
|
// altitude is GPS-derived, so unusable sources are dimmed and excluded
|
|
// from selection before latest-slot precedence is applied.
|
|
if (td.Idx("BtAltM") != undefined && !btGpsValid)
|
|
{
|
|
["BtAltM", "BtAltFt"].forEach(col => style.dimmedCols.add(col));
|
|
}
|
|
if (td.Idx("EbtAltFt") != undefined && !ebtGpsValid)
|
|
{
|
|
["EbtAltFt", "EbtAltM"].forEach(col => style.dimmedCols.add(col));
|
|
}
|
|
|
|
let altitudeCandidateList = [];
|
|
if (selected.bt && btGpsValid && td.Idx("BtAltM") != undefined)
|
|
{
|
|
altitudeCandidateList.push({
|
|
source: "BT",
|
|
slot: selected.bt.slot,
|
|
altFt: td.Get(row, "BtAltFt"),
|
|
altM: td.Get(row, "BtAltM"),
|
|
italicCols: ["BtAltM", "BtAltFt"],
|
|
});
|
|
}
|
|
if (selected.ebt && ebtGpsValid && td.Idx("EbtAltFt") != undefined)
|
|
{
|
|
altitudeCandidateList.push({
|
|
source: "EBT",
|
|
slot: selected.ebt.slot,
|
|
altFt: td.Get(row, "EbtAltFt"),
|
|
altM: td.Get(row, "EbtAltM"),
|
|
italicCols: ["EbtAltFt", "EbtAltM"],
|
|
});
|
|
}
|
|
|
|
altitudeCandidateList.sort((a, b) => b.slot - a.slot);
|
|
let selectedAltitude = altitudeCandidateList[0] ?? null;
|
|
if (needsAltitudeFamily)
|
|
{
|
|
out.set("AltFt", selectedAltitude?.altFt ?? null);
|
|
out.set("AltM", selectedAltitude?.altM ?? null);
|
|
}
|
|
sourceByFamily.altitude = selectedAltitude?.source ?? null;
|
|
for (let candidate of altitudeCandidateList)
|
|
{
|
|
if (candidate.source != selectedAltitude?.source)
|
|
{
|
|
candidate.italicCols.forEach(col => style.italicCols.add(col));
|
|
}
|
|
}
|
|
|
|
// Speed family:
|
|
// BT is the only carrier, so synthesis just exposes the resolved form.
|
|
if (needsSpeedFamily)
|
|
{
|
|
let btMph = td.Idx("BtMPH") != undefined ? td.Get(row, "BtMPH") : null;
|
|
|
|
out.set("KPH", td.Idx("BtKPH") != undefined ? td.Get(row, "BtKPH") : null);
|
|
out.set("MPH", btMph);
|
|
}
|
|
sourceByFamily.speed = selected.bt && (td.Idx("BtKPH") != undefined || td.Idx("BtMPH") != undefined) ? "BT" : null;
|
|
|
|
// Persist the selection/result/style decisions so UI formatting and
|
|
// downstream synthesized columns can use the overlap model directly.
|
|
SetOverlapMetaData(row, selected, sourceByFamily, style);
|
|
|
|
return colList.map(col => out.has(col) ? out.get(col) : null);
|
|
});
|
|
|
|
// Treat synthesized overlap columns like first-class table columns so
|
|
// downstream formatting, filtering, and chart ranges can reason about
|
|
// them the same way as raw builder output.
|
|
let tempCMinResolved = -50;
|
|
let tempCMaxResolved = 39;
|
|
if (td.Idx("TempC") != undefined)
|
|
{
|
|
td.ForEach(row => {
|
|
let rawVal = td.Get(row, "TempC");
|
|
if (rawVal == null || rawVal === "")
|
|
{
|
|
return;
|
|
}
|
|
|
|
let val = Number(rawVal);
|
|
if (Number.isFinite(val))
|
|
{
|
|
if (val < -50)
|
|
{
|
|
tempCMinResolved = -51;
|
|
}
|
|
if (val > 39)
|
|
{
|
|
tempCMaxResolved = 39;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
let voltageMinResolved = 3;
|
|
let voltageMaxResolved = 4.95;
|
|
if (td.Idx("Voltage") != undefined)
|
|
{
|
|
td.ForEach(row => {
|
|
let rawVal = td.Get(row, "Voltage");
|
|
if (rawVal == null || rawVal === "")
|
|
{
|
|
return;
|
|
}
|
|
|
|
let val = Number(rawVal);
|
|
if (Number.isFinite(val))
|
|
{
|
|
if (val < 3 || val > 4.95)
|
|
{
|
|
voltageMinResolved = 1.8;
|
|
voltageMaxResolved = 7.0;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
let colMetaDataList = new Map([
|
|
["BtGrid6", {}],
|
|
["BtLat", { rangeMin: -90, rangeMax: 90 }],
|
|
["BtLng", { rangeMin: -180, rangeMax: 180 }],
|
|
["EbtLat", { rangeMin: -90, rangeMax: 90 }],
|
|
["EbtLng", { rangeMin: -180, rangeMax: 180 }],
|
|
["HiResLat", { rangeMin: -90, rangeMax: 90 }],
|
|
["HiResLng", { rangeMin: -180, rangeMax: 180 }],
|
|
["Lat", { rangeMin: -90, rangeMax: 90 }],
|
|
["Lng", { rangeMin: -180, rangeMax: 180 }],
|
|
["TempF", { rangeMin: utl.CtoF_Round(tempCMinResolved), rangeMax: utl.CtoF_Round(tempCMaxResolved) }],
|
|
["TempC", { rangeMin: tempCMinResolved, rangeMax: tempCMaxResolved }],
|
|
["Voltage", { rangeMin: voltageMinResolved, rangeMax: voltageMaxResolved }],
|
|
["AltFt", { rangeMin: 0, rangeMax: 120000 }],
|
|
["AltM", { rangeMin: 0, rangeMax: 36576 }],
|
|
["KPH", { rangeMin: 0, rangeMax: utl.KnotsToKph_Round(82) }],
|
|
["MPH", { rangeMin: 0, rangeMax: utl.KnotsToMph_Round(82) }],
|
|
]);
|
|
|
|
for (let col of colList)
|
|
{
|
|
td.SetColMetaData(col, colMetaDataList.get(col) ?? {});
|
|
}
|
|
}
|
|
|
|
SynthesizeSolarAngle(td)
|
|
{
|
|
if (td.Idx("Lat") == undefined && td.Idx("RegLat") == undefined) { return; }
|
|
|
|
// synthesize solar angle
|
|
td.AppendGeneratedColumns([
|
|
"SolAngle"
|
|
], row => {
|
|
let retVal = [null];
|
|
|
|
let [lat, lng] = this.GetRowTxLatLngForSolarAngle(td, row);
|
|
|
|
if (lat != null && lng != null)
|
|
{
|
|
let msSinceEpoch = utl.ParseTimeToMs(td.Get(row, "DateTimeLocal"));
|
|
|
|
let sunPos = SunCalc.getPosition(msSinceEpoch, lat, lng);
|
|
|
|
let elevation = sunPos.altitude * 180 / Math.PI;
|
|
|
|
retVal = [Math.round(elevation)];
|
|
}
|
|
|
|
return retVal;
|
|
}, true);
|
|
}
|
|
|
|
SynthesizeRxStationCount(td)
|
|
{
|
|
td.AppendGeneratedColumns([
|
|
"RxStationCount"
|
|
], row => {
|
|
let slotMsgList = td.GetRowMetaData(row)?.slotMsgList;
|
|
let retVal = [this.GetRxStationCount(slotMsgList || [])];
|
|
|
|
return retVal;
|
|
}, true);
|
|
}
|
|
|
|
SynthesizeWinFreqDrift(td, wsprSearch)
|
|
{
|
|
td.AppendGeneratedColumns([
|
|
"WinFreqDrift"
|
|
], row => {
|
|
let time = td.GetRowMetaData(row)?.time;
|
|
let windowData = wsprSearch?.time__windowData?.get?.(time);
|
|
let retVal = [windowData?.fingerprintingData?.winFreqDrift ?? null];
|
|
|
|
return retVal;
|
|
}, true);
|
|
|
|
td.SetColMetaData("WinFreqDrift", {});
|
|
}
|
|
|
|
SynthesizeAltChg(td)
|
|
{
|
|
if (td.Idx("AltM") == undefined) { return; }
|
|
|
|
let altMlast = null;
|
|
let altMTimeLast = null;
|
|
|
|
// synthesize altitude change
|
|
td.AppendGeneratedColumns([
|
|
"AltChgMpm", "AltChgFpm"
|
|
], row => {
|
|
let retVal = [null, null];
|
|
|
|
let altM = td.Get(row, "AltM");
|
|
let altMTime = td.Get(row, "DateTimeLocal");
|
|
|
|
if (altM != null && altMTime != null && altMlast != null && altMTimeLast != null)
|
|
{
|
|
let altMTimeMs = utl.ParseTimeToMs(altMTime);
|
|
let altMTimeLastMs = utl.ParseTimeToMs(altMTimeLast);
|
|
|
|
let altMTimeDiffMs = altMTimeMs - altMTimeLastMs;
|
|
let altMTimeDiffMin = altMTimeDiffMs / (60 * 1000);
|
|
|
|
let altMDiff = altM - altMlast;
|
|
|
|
let altMpm = Math.round(altMDiff / altMTimeDiffMin);
|
|
let altFpm = Math.round(utl.MtoFt(altMpm));
|
|
|
|
retVal = [altMpm, altFpm];
|
|
}
|
|
|
|
altMlast = altM;
|
|
altMTimeLast = altMTime;
|
|
|
|
return retVal;
|
|
}, true);
|
|
|
|
let rangeMinM = -150;
|
|
let rangeMaxM = 150;
|
|
|
|
let rangeMinFt = utl.MtoFt_Round(rangeMinM);
|
|
let rangeMaxFt = utl.MtoFt_Round(rangeMaxM);
|
|
|
|
td.SetColMetaData("AltChgMpm", {
|
|
rangeMin: rangeMinM,
|
|
rangeMax: rangeMaxM,
|
|
});
|
|
td.SetColMetaData("AltChgFpm", {
|
|
rangeMin: rangeMinFt,
|
|
rangeMax: rangeMaxFt,
|
|
});
|
|
}
|
|
|
|
SynthesizeDistance(td)
|
|
{
|
|
if (td.Idx("Lat") == undefined && td.Idx("RegLat") == undefined) { return; }
|
|
|
|
// synthesize distance traveled
|
|
let lastLat = null;
|
|
let lastLng = null;
|
|
td.AppendGeneratedColumns([
|
|
"DistKm", "DistMi"
|
|
], row => {
|
|
let retVal = [null, null];
|
|
|
|
let [lat, lng] = this.GetRowTxLatLng(td, row);
|
|
|
|
if (lat != null && lng != null)
|
|
{
|
|
if (lastLat != null && lastLng != null)
|
|
{
|
|
let km = GreatCircle.distance(lastLat, lastLng, lat, lng, "KM");
|
|
let mi = GreatCircle.distance(lastLat, lastLng, lat, lng, "MI");
|
|
|
|
retVal = [Math.round(km), Math.round(mi)];
|
|
}
|
|
|
|
lastLat = lat;
|
|
lastLng = lng;
|
|
}
|
|
|
|
return retVal;
|
|
}, true);
|
|
}
|
|
|
|
SynthesizeSpeedGPS(td)
|
|
{
|
|
if (td.Idx("Lat") == undefined || td.Idx("Lng") == undefined) { return; }
|
|
|
|
let kphRawList = new Array(td.Length()).fill(null);
|
|
|
|
// First build a raw segment-speed estimate that discounts movement inside
|
|
// the combined uncertainty envelope of the two location sources.
|
|
let rowLast = null;
|
|
td.ForEach((row, idx) => {
|
|
let [lat, lng] = this.GetRowTxLatLng(td, row);
|
|
if (lat == null || lng == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (rowLast != null)
|
|
{
|
|
let [latLast, lngLast] = this.GetRowTxLatLng(td, rowLast);
|
|
if (latLast != null && lngLast != null)
|
|
{
|
|
let msNow = utl.ParseTimeToMs(td.Get(row, "DateTimeLocal"));
|
|
let msLast = utl.ParseTimeToMs(td.Get(rowLast, "DateTimeLocal"));
|
|
let msDiff = msNow - msLast;
|
|
|
|
let MS_PER_10_MIN = 60 * 10 * 1000;
|
|
if (msDiff == MS_PER_10_MIN)
|
|
{
|
|
let sourceNow = td.GetRowMetaData(row)?.overlap?.resolved?.sourceByFamily?.location ?? null;
|
|
let sourceLast = td.GetRowMetaData(rowLast)?.overlap?.resolved?.sourceByFamily?.location ?? null;
|
|
|
|
// Ignore coarse RT1-only positions here. GPS speed should be
|
|
// driven only by refined GPS-derived location sources.
|
|
if (sourceNow == null || sourceLast == null || sourceNow == "RT1" || sourceLast == "RT1")
|
|
{
|
|
rowLast = row;
|
|
return;
|
|
}
|
|
|
|
let km = GreatCircle.distance(latLast, lngLast, lat, lng, "KM");
|
|
|
|
let uncertaintyNowKm = this.GetLocationSourceUncertaintyKm(sourceNow) ?? 0;
|
|
let uncertaintyLastKm = this.GetLocationSourceUncertaintyKm(sourceLast) ?? 0;
|
|
let combinedUncertaintyKm = uncertaintyNowKm + uncertaintyLastKm;
|
|
|
|
// Ignore apparent movement that fits within the precision envelope
|
|
// of the two endpoint locations.
|
|
let effectiveKm = Math.max(0, km - combinedUncertaintyKm);
|
|
let kph = effectiveKm * 6;
|
|
|
|
kphRawList[idx] = kph;
|
|
}
|
|
}
|
|
}
|
|
|
|
rowLast = row;
|
|
}, true);
|
|
|
|
let mphList = new Array(td.Length()).fill(null);
|
|
let kphList = new Array(td.Length()).fill(null);
|
|
let row__idx = new Map();
|
|
td.ForEach((row, idx) => {
|
|
row__idx.set(row, idx);
|
|
});
|
|
|
|
// Then smooth with a rolling median, which is more resistant to one-off
|
|
// spikes than the original mean-based smoothing.
|
|
for (let row = 0; row < td.Length(); ++row)
|
|
{
|
|
let kphCandidateList = [];
|
|
for (let idx = Math.max(0, row - 2); idx <= Math.min(td.Length() - 1, row + 2); ++idx)
|
|
{
|
|
let kph = kphRawList[idx];
|
|
if (kph != null && Number.isFinite(kph))
|
|
{
|
|
kphCandidateList.push(kph);
|
|
}
|
|
}
|
|
|
|
if (kphCandidateList.length >= 2)
|
|
{
|
|
let kph = this.GetMedian(kphCandidateList);
|
|
kphList[row] = Math.round(kph);
|
|
mphList[row] = Math.round(kph * 0.621371);
|
|
}
|
|
}
|
|
|
|
td.AppendGeneratedColumns([
|
|
"GpsKPH", "GpsMPH"
|
|
], (row) => {
|
|
let rowIdx = row__idx.get(row);
|
|
return [kphList[rowIdx], mphList[rowIdx]];
|
|
}, true);
|
|
}
|
|
|
|
GetLocationSourceUncertaintyKm(source)
|
|
{
|
|
switch (source)
|
|
{
|
|
// RT1 is only a coarse grid4 location.
|
|
case "RT1": return 80;
|
|
|
|
// BT is based on grid6 refinement and is materially better than RT1,
|
|
// but still much coarser than explicit indexed lat/lng messages.
|
|
case "BT": return 4;
|
|
|
|
// EBT and HRL are explicit indexed latitude/longitude refinements.
|
|
case "EBT": return 1.5;
|
|
case "HRL": return 0.5;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
GetMedian(numList)
|
|
{
|
|
if (!numList?.length)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
let sorted = [...numList].sort((a, b) => a - b);
|
|
let mid = Math.floor(sorted.length / 2);
|
|
|
|
if (sorted.length % 2)
|
|
{
|
|
return sorted[mid];
|
|
}
|
|
|
|
return (sorted[mid - 1] + sorted[mid]) / 2;
|
|
}
|
|
}
|