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

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