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