diff --git a/index.html b/index.html index 5218a8d4..fb31e032 100644 --- a/index.html +++ b/index.html @@ -457,8 +457,11 @@

Workspace

@@ -490,6 +493,22 @@

Workspace

/> + + + + + + + diff --git a/src/css/main.css b/src/css/main.css index 37a37bfd..e3be6f5a 100644 --- a/src/css/main.css +++ b/src/css/main.css @@ -543,6 +543,12 @@ html.has-analyser-fullscreen.has-analyser display: block; } +html.has-analyser-fullscreen.has-analyser + .analyser + label:not(.onlyFullScreenException) { + display: block; +} + #analyser, #log-seek-bar { z-index: 10; @@ -616,7 +622,7 @@ html.has-analyser-fullscreen.has-analyser height: 0; overflow: hidden; opacity: 0; - left: 120px; + left: 130px; float: left; z-index: 9; position: absolute; @@ -670,6 +676,7 @@ html.has-analyser-fullscreen.has-analyser top: 10px; float: right; } + .analyser input#analyserZoomY { width: 10px; height: 100px; @@ -678,6 +685,65 @@ html.has-analyser-fullscreen.has-analyser top: 30px; } +.analyser input#analyserMinPSD::-webkit-inner-spin-button, +.analyser input#analyserMinPSD::-webkit-outer-spin-button, +.analyser input#analyserMaxPSD::-webkit-inner-spin-button, +.analyser input#analyserMaxPSD::-webkit-outer-spin-button, +.analyser input#analyserLowLevelPSD::-webkit-inner-spin-button, +.analyser input#analyserLowLevelPSD::-webkit-outer-spin-button { + -webkit-appearance: auto !important; + -moz-appearance: auto !important; + appearance: auto !important; + opacity: 1 !important; + height: auto !important; + width: auto !important; +} + +.analyser input#analyserMaxPSD { + width: 50px; + height: 20px; + left: 0px; + top: 30px; +} + +.analyser input#analyserMinPSD { + width: 50px; + height: 20px; + left: 0px; + top: 55px; +} + +.analyser input#analyserLowLevelPSD { + width: 50px; + height: 20px; + left: 0px; + top: 80px; +} + +.analyser label#analyserMaxPSDLabel { + position:absolute; + color:gray; + left: 0px; + top: 30px; + font-size: 12px; +} + +.analyser label#analyserMinPSDLabel { + position:absolute; + color:gray; + left: 0px; + top: 55px; + font-size: 12px; +} + +.analyser label#analyserLowLevelPSDLabel { + position:absolute; + color:gray; + left: 0px; + top: 80px; + font-size: 12px; +} + .analyser input.onlyFullScreen { display: none; padding: 3px; @@ -686,6 +752,14 @@ html.has-analyser-fullscreen.has-analyser position: absolute; } +.analyser label.onlyFullScreen { + display: none; + padding: 3px; + margin-right: 3px; + z-index: 9; + position: absolute; +} + .analyser, .map-container, .log-seek-bar { diff --git a/src/graph_spectrum.js b/src/graph_spectrum.js index 843fa284..ecd26c9b 100644 --- a/src/graph_spectrum.js +++ b/src/graph_spectrum.js @@ -4,6 +4,8 @@ import { GraphSpectrumPlot, SPECTRUM_TYPE, SPECTRUM_OVERDRAW_TYPE, + DEFAULT_MIN_DBM_VALUE, + DEFAULT_MAX_DBM_VALUE, } from "./graph_spectrum_plot"; import { PrefStorage } from "./pref_storage"; import { SpectrumExporter } from "./spectrum-exporter"; @@ -14,26 +16,30 @@ export function FlightLogAnalyser(flightLog, canvas, analyserCanvas) { ANALYSER_LARGE_HEIGHT_MARGIN = 20, ANALYSER_LARGE_WIDTH_MARGIN = 20; - let that = this, - analyserZoomX = 1.0 /* 100% */, + const that = this, + prefs = new PrefStorage(); + let analyserZoomX = 1.0 /* 100% */, analyserZoomY = 1.0 /* 100% */, dataReload = false, - fftData = null, - prefs = new PrefStorage(); + fftData = null; try { let isFullscreen = false; - let sysConfig = flightLog.getSysConfig(); + const sysConfig = flightLog.getSysConfig(); const logRateInfo = GraphSpectrumCalc.initialize(flightLog, sysConfig); GraphSpectrumPlot.initialize(analyserCanvas, sysConfig); GraphSpectrumPlot.setLogRateWarningInfo(logRateInfo); - let analyserZoomXElem = $("#analyserZoomX"); - let analyserZoomYElem = $("#analyserZoomY"); + const analyserZoomXElem = $("#analyserZoomX"); + const analyserZoomYElem = $("#analyserZoomY"); + const analyserMinPSD = $("#analyserMinPSD"); + const analyserMaxPSD = $("#analyserMaxPSD"); + const analyserLowLevelPSD = $("#analyserLowLevelPSD"); - let spectrumToolbarElem = $("#spectrumToolbar"); - let spectrumTypeElem = $("#spectrumTypeSelect"); - let overdrawSpectrumTypeElem = $("#overdrawSpectrumTypeSelect"); + + const spectrumToolbarElem = $("#spectrumToolbar"); + const spectrumTypeElem = $("#spectrumTypeSelect"); + const overdrawSpectrumTypeElem = $("#overdrawSpectrumTypeSelect"); this.setFullscreen = function (size) { isFullscreen = size == true; @@ -51,7 +57,7 @@ export function FlightLogAnalyser(flightLog, canvas, analyserCanvas) { return GraphSpectrumCalc.setOutTime(time); }; - let getSize = function () { + const getSize = function () { if (isFullscreen) { return { height: canvas.clientHeight - ANALYSER_LARGE_HEIGHT_MARGIN, @@ -71,28 +77,46 @@ export function FlightLogAnalyser(flightLog, canvas, analyserCanvas) { }; this.resize = function () { - let newSize = getSize(); + const newSize = getSize(); // Determine the analyserCanvas location GraphSpectrumPlot.setSize(newSize.width, newSize.height); // Recenter the analyser canvas in the bottom left corner - let parentElem = $(analyserCanvas).parent(); + const parentElem = $(analyserCanvas).parent(); $(parentElem).css({ left: newSize.left, // (canvas.width * getSize().left) + "px", top: newSize.top, // (canvas.height * getSize().top ) + "px" }); // place the sliders. - $("input:first-of-type", parentElem).css({ + $("#analyserZoomX", parentElem).css({ left: `${newSize.width - 130}px`, }); - $("input:last-of-type", parentElem).css({ + $("#analyserZoomY", parentElem).css({ left: `${newSize.width - 20}px`, }); $("#analyserResize", parentElem).css({ left: `${newSize.width - 20}px`, }); + $("#analyserMaxPSD", parentElem).css({ + left: `${newSize.width - 90}px`, + }); + $("#analyserMinPSD", parentElem).css({ + left: `${newSize.width - 90}px`, + }); + $("#analyserLowLevelPSD", parentElem).css({ + left: `${newSize.width - 90}px`, + }); + $("#analyserMaxPSDLabel", parentElem).css({ + left: `${newSize.width - 150}px`, + }); + $("#analyserMinPSDLabel", parentElem).css({ + left: `${newSize.width - 150}px`, + }); + $("#analyserLowLevelPSDLabel", parentElem).css({ + left: `${newSize.width - 155}px`, + }); }; const dataLoad = function (fieldIndex, curve, fieldName) { @@ -109,10 +133,22 @@ export function FlightLogAnalyser(flightLog, canvas, analyserCanvas) { fftData = GraphSpectrumCalc.dataLoadFrequencyVsRpm(); break; + case SPECTRUM_TYPE.PSD_VS_THROTTLE: + fftData = GraphSpectrumCalc.dataLoadPowerSpectralDensityVsThrottle(); + break; + + case SPECTRUM_TYPE.PSD_VS_RPM: + fftData = GraphSpectrumCalc.dataLoadPowerSpectralDensityVsRpm(); + break; + case SPECTRUM_TYPE.PIDERROR_VS_SETPOINT: fftData = GraphSpectrumCalc.dataLoadPidErrorVsSetpoint(); break; + case SPECTRUM_TYPE.POWER_SPECTRAL_DENSITY: + fftData = GraphSpectrumCalc.dataLoadPSD(analyserZoomY); + break; + case SPECTRUM_TYPE.FREQUENCY: default: fftData = GraphSpectrumCalc.dataLoadFrequency(); @@ -164,7 +200,7 @@ export function FlightLogAnalyser(flightLog, canvas, analyserCanvas) { analyserZoomX = analyserZoomXElem.val() / 100; GraphSpectrumPlot.setZoom(analyserZoomX, analyserZoomY); that.refresh(); - }) + }), ) .dblclick(function () { $(this).val(DEFAULT_ZOOM).trigger("input"); @@ -177,14 +213,77 @@ export function FlightLogAnalyser(flightLog, canvas, analyserCanvas) { debounce(100, function () { analyserZoomY = 1 / (analyserZoomYElem.val() / 100); GraphSpectrumPlot.setZoom(analyserZoomX, analyserZoomY); + // Recalculate PSD with updated samples per segment count + if (userSettings.spectrumType == SPECTRUM_TYPE.POWER_SPECTRAL_DENSITY) { + dataLoad(); + GraphSpectrumPlot.setData(fftData, userSettings.spectrumType); + } that.refresh(); - }) + }), ) .dblclick(function () { $(this).val(DEFAULT_ZOOM).trigger("input"); }) .val(DEFAULT_ZOOM); + analyserMinPSD + .on( + "input", + debounce(100, function () { + const min = parseInt(analyserMinPSD.val()); + GraphSpectrumPlot.setMinPSD(min); + analyserLowLevelPSD.prop("min", min); + analyserMaxPSD.prop("min", min + 5); + if (analyserLowLevelPSD.val() < min) { + analyserLowLevelPSD.val(min).trigger("input"); + } + that.refresh(); + }), + ) + .dblclick(function (e) { + if (e.ctrlKey) { + $(this).val(DEFAULT_MIN_DBM_VALUE).trigger("input"); + } + }) + .val(DEFAULT_MIN_DBM_VALUE); + + analyserMaxPSD + .on( + "input", + debounce(100, function () { + const max = parseInt(analyserMaxPSD.val()); + GraphSpectrumPlot.setMaxPSD(max); + analyserMinPSD.prop("max", max - 5); + analyserLowLevelPSD.prop("max", max); + if (analyserLowLevelPSD.val() > max) { + analyserLowLevelPSD.val(max).trigger("input"); + } + that.refresh(); + }), + ) + .dblclick(function (e) { + if (e.ctrlKey) { + $(this).val(DEFAULT_MAX_DBM_VALUE).trigger("input"); + } + }) + .val(DEFAULT_MAX_DBM_VALUE); + + analyserLowLevelPSD + .on( + "input", + debounce(100, function () { + const lowLevel = analyserLowLevelPSD.val(); + GraphSpectrumPlot.setLowLevelPSD(lowLevel); + that.refresh(); + }), + ) + .dblclick(function (e) { + if (e.ctrlKey) { + $(this).val(analyserMinPSD.val()).trigger("input"); + } + }) + .val(analyserMinPSD.val()); + // Spectrum type to show userSettings.spectrumType = userSettings.spectrumType || SPECTRUM_TYPE.FREQUENCY; @@ -206,10 +305,37 @@ export function FlightLogAnalyser(flightLog, canvas, analyserCanvas) { // Hide overdraw and zoomY if needed const pidErrorVsSetpointSelected = optionSelected === SPECTRUM_TYPE.PIDERROR_VS_SETPOINT; + const psdHeatMapSelected = + optionSelected === SPECTRUM_TYPE.PSD_VS_THROTTLE || + optionSelected === SPECTRUM_TYPE.PSD_VS_RPM; overdrawSpectrumTypeElem.toggle(!pidErrorVsSetpointSelected); analyserZoomYElem.toggleClass( "onlyFullScreenException", - pidErrorVsSetpointSelected + pidErrorVsSetpointSelected || psdHeatMapSelected, + ); + analyserLowLevelPSD.toggleClass( + "onlyFullScreenException", + !psdHeatMapSelected, + ); + analyserMinPSD.toggleClass( + "onlyFullScreenException", + !psdHeatMapSelected, + ); + analyserMaxPSD.toggleClass( + "onlyFullScreenException", + !psdHeatMapSelected, + ); + $("#analyserMaxPSDLabel").toggleClass( + "onlyFullScreenException", + !psdHeatMapSelected, + ); + $("#analyserMinPSDLabel").toggleClass( + "onlyFullScreenException", + !psdHeatMapSelected, + ); + $("#analyserLowLevelPSDLabel").toggleClass( + "onlyFullScreenException", + !psdHeatMapSelected, ); $("#spectrumComparison").css("visibility", (optionSelected == 0 ? "visible" : "hidden")); @@ -223,7 +349,7 @@ export function FlightLogAnalyser(flightLog, canvas, analyserCanvas) { GraphSpectrumPlot.setOverdraw(userSettings.overdrawSpectrumType); overdrawSpectrumTypeElem.change(function () { - let optionSelected = parseInt(overdrawSpectrumTypeElem.val(), 10); + const optionSelected = parseInt(overdrawSpectrumTypeElem.val(), 10); if (optionSelected != userSettings.overdrawSpectrumType) { userSettings.overdrawSpectrumType = optionSelected; @@ -247,9 +373,9 @@ export function FlightLogAnalyser(flightLog, canvas, analyserCanvas) { // Hide the combo and maximize buttons spectrumToolbarElem.removeClass("non-shift"); - let rect = analyserCanvas.getBoundingClientRect(); - let mouseX = e.clientX - rect.left; - let mouseY = e.clientY - rect.top; + const rect = analyserCanvas.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; if (mouseX != lastMouseX || mouseY != lastMouseY) { lastMouseX = mouseX; lastMouseY = mouseY; @@ -315,7 +441,7 @@ export function FlightLogAnalyser(flightLog, canvas, analyserCanvas) { }; } catch (e) { - console.log(`Failed to create analyser... error: ${e}`); + console.error(`Failed to create analyser... error: ${e}`); } this.clearImportedSpectrums = function() { diff --git a/src/graph_spectrum_calc.js b/src/graph_spectrum_calc.js index 15e085ef..e5d07190 100644 --- a/src/graph_spectrum_calc.js +++ b/src/graph_spectrum_calc.js @@ -15,11 +15,11 @@ const FREQ_VS_THR_CHUNK_TIME_MS = 300, FREQ_VS_THR_WINDOW_DIVISOR = 6, MAX_ANALYSER_LENGTH = 300 * 1000 * 1000, // 5min - NUM_VS_BINS = 100, WARNING_RATE_DIFFERENCE = 0.05, MAX_RPM_HZ_VALUE = 800, - MAX_RPM_AXIS_GAP = 1.05; - + RPM_AXIS_TOP_MARGIN_PERCENT = 2, + MIN_SPECTRUM_SAMPLES_COUNT = 2048; +export const NUM_VS_BINS = 100; export const GraphSpectrumCalc = { _analyserTimeRange : { @@ -79,7 +79,7 @@ GraphSpectrumCalc.setOutTime = function(time) { if ((time - this._analyserTimeRange.in) <= MAX_ANALYSER_LENGTH) { this._analyserTimeRange.out = time; } else { - this._analyserTimeRange.out = analyserTimeRange.in + MAX_ANALYSER_LENGTH; + this._analyserTimeRange.out = this._analyserTimeRange.in + MAX_ANALYSER_LENGTH; } return this._analyserTimeRange.out; }; @@ -91,58 +91,92 @@ GraphSpectrumCalc.setDataBuffer = function(fieldIndex, curve, fieldName) { return undefined; }; +GraphSpectrumCalc.getNearPower2Value = function(size) { + return Math.pow(2, Math.ceil(Math.log2(size))); +}; + GraphSpectrumCalc.dataLoadFrequency = function() { const flightSamples = this._getFlightSamplesFreq(); if (userSettings.analyserHanning) { - this._hanningWindow(flightSamples.samples, flightSamples.count); + this._hanningWindow(flightSamples.samples, flightSamples.count); // Apply Hann function to actual flightSamples.count values only } - //calculate fft + //calculate fft for the all samples const fftOutput = this._fft(flightSamples.samples); // Normalize the result - const fftData = this._normalizeFft(fftOutput, flightSamples.samples.length); + const fftData = this._normalizeFft(fftOutput); return fftData; }; +GraphSpectrumCalc.dataLoadPSD = function(analyserZoomY) { + const flightSamples = this._getFlightSamplesFreq(false); + const multiplier = Math.floor(1 / analyserZoomY); // 0. ... 10 + let pointsPerSegment = 2 ** (8 + multiplier); //256, 512, 1024 ... + + let overlapCount; + if (pointsPerSegment > flightSamples.samples.length) { + pointsPerSegment = flightSamples.samples.length; // Use actual sample length. It will transform to power at 2 value inside the _psd() - fft_segmented + overlapCount = 0; + } else { + overlapCount = pointsPerSegment / 2; + } + + const psd = this._psd(flightSamples.samples, pointsPerSegment, overlapCount); + + const psdData = { + fieldIndex : this._dataBuffer.fieldIndex, + fieldName : this._dataBuffer.fieldName, + psdLength : psd.psdOutput.length, + psdOutput : psd.psdOutput, + blackBoxRate : this._blackBoxRate, + minimum: psd.min, + maximum: psd.max, + maxNoiseFrequency: psd.maxNoiseFrequency, + }; + return psdData; +}; GraphSpectrumCalc._dataLoadFrequencyVsX = function(vsFieldNames, minValue = Infinity, maxValue = -Infinity) { const flightSamples = this._getFlightSamplesFreqVsX(vsFieldNames, minValue, maxValue); - // We divide it into FREQ_VS_THR_CHUNK_TIME_MS FFT chunks, we calculate the average throttle // for each chunk. We use a moving window to get more chunks available. - const fftChunkLength = this._blackBoxRate * FREQ_VS_THR_CHUNK_TIME_MS / 1000; + const fftChunkLength = Math.round(this._blackBoxRate * FREQ_VS_THR_CHUNK_TIME_MS / 1000); const fftChunkWindow = Math.round(fftChunkLength / FREQ_VS_THR_WINDOW_DIVISOR); - + const fftBufferSize = this.getNearPower2Value(fftChunkLength); + const magnitudeLength = Math.floor(fftBufferSize / 2); let maxNoise = 0; // Stores the maximum amplitude of the fft over all chunks // Matrix where each row represents a bin of vs values, and the columns are amplitudes at frequencies - const matrixFftOutput = new Array(NUM_VS_BINS).fill(null).map(() => new Float64Array(fftChunkLength * 2)); - + const matrixFftOutput = new Array(NUM_VS_BINS).fill(null).map(() => new Float64Array(fftBufferSize * 2)); const numberSamples = new Uint32Array(NUM_VS_BINS); // Number of samples in each vs value, used to average them later. + const fft = new FFT.complex(fftBufferSize, false); + const fftInput = new Float64Array(fftBufferSize); + const fftOutput = new Float64Array(fftBufferSize * 2); - const fft = new FFT.complex(fftChunkLength, false); for (let fftChunkIndex = 0; fftChunkIndex + fftChunkLength < flightSamples.samples.length; fftChunkIndex += fftChunkWindow) { - const fftInput = flightSamples.samples.slice(fftChunkIndex, fftChunkIndex + fftChunkLength); - let fftOutput = new Float64Array(fftChunkLength * 2); + const samples = flightSamples.samples.slice(fftChunkIndex, fftChunkIndex + fftChunkLength); + fftInput.set(samples); - // Hanning window applied to input data + // Hanning window applied to input data, without padding zeros if (userSettings.analyserHanning) { this._hanningWindow(fftInput, fftChunkLength); } fft.simple(fftOutput, fftInput, 'real'); - fftOutput = fftOutput.slice(0, fftChunkLength); - - // Use only abs values - for (let i = 0; i < fftChunkLength; i++) { - fftOutput[i] = Math.abs(fftOutput[i]); - maxNoise = Math.max(fftOutput[i], maxNoise); +// Compute magnitude +// The fftOutput contains two side spectrum, we use the first part only to get one side + const magnitudes = new Float64Array(magnitudeLength); + for (let i = 0; i < magnitudeLength; i++) { + const re = fftOutput[2 * i], + im = fftOutput[2 * i + 1]; + magnitudes[i] = Math.hypot(re, im); + maxNoise = Math.max(magnitudes[i], maxNoise); } // calculate a bin index and put the fft value in that bin for each field (e.g. eRPM[0], eRPM[1]..) sepparately @@ -154,14 +188,12 @@ GraphSpectrumCalc._dataLoadFrequencyVsX = function(vsFieldNames, minValue = Infi } // Translate the average vs value to a bin index const avgVsValue = sumVsValues / fftChunkLength; - let vsBinIndex = Math.floor(NUM_VS_BINS * (avgVsValue - flightSamples.minValue) / (flightSamples.maxValue - flightSamples.minValue)); - // ensure that avgVsValue == flightSamples.maxValue does not result in an out of bounds access - if (vsBinIndex === NUM_VS_BINS) { vsBinIndex = NUM_VS_BINS - 1; } + const vsBinIndex = Math.round((NUM_VS_BINS - 1) * (avgVsValue - flightSamples.minValue) / (flightSamples.maxValue - flightSamples.minValue)); numberSamples[vsBinIndex]++; // add the output from the fft to the row given by the vs value bin index - for (let i = 0; i < fftOutput.length; i++) { - matrixFftOutput[vsBinIndex][i] += fftOutput[i]; + for (let i = 0; i < magnitudeLength; i++) { + matrixFftOutput[vsBinIndex][i] += magnitudes[i]; } } } @@ -180,23 +212,100 @@ GraphSpectrumCalc._dataLoadFrequencyVsX = function(vsFieldNames, minValue = Infi // blur algorithm to the heat map image const fftData = { - fieldIndex : this._dataBuffer.fieldIndex, - fieldName : this._dataBuffer.fieldName, - fftLength : fftChunkLength, - fftOutput : matrixFftOutput, - maxNoise : maxNoise, - blackBoxRate : this._blackBoxRate, - vsRange : { min: flightSamples.minValue, max: flightSamples.maxValue}, + fieldIndex : this._dataBuffer.fieldIndex, + fieldName : this._dataBuffer.fieldName, + fftLength : magnitudeLength, + fftOutput : matrixFftOutput, + maxNoise : maxNoise, + blackBoxRate : this._blackBoxRate, + vsRange : { min: flightSamples.minValue, max: flightSamples.maxValue}, }; return fftData; }; +GraphSpectrumCalc._dataLoadPowerSpectralDensityVsX = function(vsFieldNames, minValue = Infinity, maxValue = -Infinity) { + const flightSamples = this._getFlightSamplesFreqVsX(vsFieldNames, minValue, maxValue, false); + + // We divide it into FREQ_VS_THR_CHUNK_TIME_MS FFT chunks, we calculate the average throttle + // for each chunk. We use a moving window to get more chunks available. + const fftChunkLength = Math.round(this._blackBoxRate * FREQ_VS_THR_CHUNK_TIME_MS / 1000); + const fftChunkWindow = Math.round(fftChunkLength / FREQ_VS_THR_WINDOW_DIVISOR); + + let maxNoise = 0; // Stores the maximum amplitude of the fft over all chunks + let psdLength = 0; + // Matrix where each row represents a bin of vs values, and the columns are amplitudes at frequencies + const BACKGROUND_PSD_VALUE = -200; + let matrixPsdOutput; + + const numberSamples = new Uint32Array(NUM_VS_BINS); // Number of samples in each vs value, used to average them later. + + for (let fftChunkIndex = 0; fftChunkIndex + fftChunkLength < flightSamples.samples.length; fftChunkIndex += fftChunkWindow) { + + const fftInput = flightSamples.samples.slice(fftChunkIndex, fftChunkIndex + fftChunkLength); + const psd = this._psd(fftInput, fftChunkLength, 0, 'density'); // Using the one segment with all chunks fftChunkLength size, it will extended at power at 2 size inside _psd() - _fft_segmented() + maxNoise = Math.max(psd.max, maxNoise); + // The _psd() can extend fft data size. Set psdLength and create matrix by first using + if (matrixPsdOutput == undefined) { + psdLength = psd.psdOutput.length; + matrixPsdOutput = new Array(NUM_VS_BINS) + .fill(null) + .map(() => (new Float64Array(psdLength)).fill(BACKGROUND_PSD_VALUE)); + } + // calculate a bin index and put the fft value in that bin for each field (e.g. eRPM[0], eRPM[1]..) sepparately + for (const vsValueArray of flightSamples.vsValues) { + // Calculate average of the VS values in the chunk + let sumVsValues = 0; + for (let indexVs = fftChunkIndex; indexVs < fftChunkIndex + fftChunkLength; indexVs++) { + sumVsValues += vsValueArray[indexVs]; + } + // Translate the average vs value to a bin index + const avgVsValue = sumVsValues / fftChunkLength; + const vsBinIndex = Math.round((NUM_VS_BINS - 1) * (avgVsValue - flightSamples.minValue) / (flightSamples.maxValue - flightSamples.minValue)); + numberSamples[vsBinIndex]++; + + // add the output from the fft to the row given by the vs value bin index + for (let i = 0; i < psdLength; i++) { + matrixPsdOutput[vsBinIndex][i] += psd.psdOutput[i]; + } + } + } + + // Divide the values from the fft in each row (vs value bin) by the number of samples in the bin + for (let i = 0; i < NUM_VS_BINS; i++) { + if (numberSamples[i] > 1) { + for (let j = 0; j < psdLength; j++) { + matrixPsdOutput[i][j] /= numberSamples[i]; + } + } + } + + // The output data needs to be smoothed, the sampling is not perfect + // but after some tests we let the data as is, an we prefer to apply a + // blur algorithm to the heat map image + + const psdData = { + fieldIndex : this._dataBuffer.fieldIndex, + fieldName : this._dataBuffer.fieldName, + fftLength : psdLength, + fftOutput : matrixPsdOutput, + maxNoise : maxNoise, + blackBoxRate : this._blackBoxRate, + vsRange : { min: flightSamples.minValue, max: flightSamples.maxValue}, + }; + + return psdData; +}; + GraphSpectrumCalc.dataLoadFrequencyVsThrottle = function() { return this._dataLoadFrequencyVsX(FIELD_THROTTLE_NAME, 0, 100); }; +GraphSpectrumCalc.dataLoadPowerSpectralDensityVsThrottle = function() { + return this._dataLoadPowerSpectralDensityVsX(FIELD_THROTTLE_NAME, 0, 100); +}; + GraphSpectrumCalc.dataLoadFrequencyVsRpm = function() { const fftData = this._dataLoadFrequencyVsX(FIELD_RPM_NAMES, 0); fftData.vsRange.max *= 3.333 / this._motorPoles; @@ -204,6 +313,13 @@ GraphSpectrumCalc.dataLoadFrequencyVsRpm = function() { return fftData; }; +GraphSpectrumCalc.dataLoadPowerSpectralDensityVsRpm = function() { + const fftData = this._dataLoadPowerSpectralDensityVsX(FIELD_RPM_NAMES, 0); + fftData.vsRange.max *= 3.333 / this._motorPoles; + fftData.vsRange.min *= 3.333 / this._motorPoles; + return fftData; +}; + GraphSpectrumCalc.dataLoadPidErrorVsSetpoint = function() { // Detect the axis @@ -285,7 +401,7 @@ GraphSpectrumCalc._getFlightChunks = function() { return allChunks; }; -GraphSpectrumCalc._getFlightSamplesFreq = function() { +GraphSpectrumCalc._getFlightSamplesFreq = function(scaled = true) { const allChunks = this._getFlightChunks(); @@ -295,13 +411,26 @@ GraphSpectrumCalc._getFlightSamplesFreq = function() { let samplesCount = 0; for (const chunk of allChunks) { for (const frame of chunk.frames) { - samples[samplesCount] = (this._dataBuffer.curve.lookupRaw(frame[this._dataBuffer.fieldIndex])); + if (scaled) { + samples[samplesCount] = this._dataBuffer.curve.lookupRaw(frame[this._dataBuffer.fieldIndex]); + } else { + samples[samplesCount] = frame[this._dataBuffer.fieldIndex]; + } samplesCount++; } } + // The FFT input size is power 2 to get maximal performance + // Limit fft input count for simple spectrum chart to get normal charts plot quality + let fftBufferSize; + if (scaled && samplesCount < MIN_SPECTRUM_SAMPLES_COUNT) { + fftBufferSize = MIN_SPECTRUM_SAMPLES_COUNT; + } else { + fftBufferSize = this.getNearPower2Value(samplesCount); + } + return { - samples : samples, + samples : samples.slice(0, fftBufferSize), count : samplesCount, }; }; @@ -316,7 +445,7 @@ GraphSpectrumCalc._getVsIndexes = function(vsFieldNames) { return fieldIndexes; }; -GraphSpectrumCalc._getFlightSamplesFreqVsX = function(vsFieldNames, minValue = Infinity, maxValue = -Infinity) { +GraphSpectrumCalc._getFlightSamplesFreqVsX = function(vsFieldNames, minValue = Infinity, maxValue = -Infinity, scaled = true) { const allChunks = this._getFlightChunks(); const vsIndexes = this._getVsIndexes(vsFieldNames); @@ -327,7 +456,11 @@ GraphSpectrumCalc._getFlightSamplesFreqVsX = function(vsFieldNames, minValue = I let samplesCount = 0; for (const chunk of allChunks) { for (let frameIndex = 0; frameIndex < chunk.frames.length; frameIndex++) { - samples[samplesCount] = (this._dataBuffer.curve.lookupRaw(chunk.frames[frameIndex][this._dataBuffer.fieldIndex])); + if (scaled) { + samples[samplesCount] = (this._dataBuffer.curve.lookupRaw(chunk.frames[frameIndex][this._dataBuffer.fieldIndex])); + } else { + samples[samplesCount] = chunk.frames[frameIndex][this._dataBuffer.fieldIndex]; + } for (let i = 0; i < vsIndexes.length; i++) { let vsFieldIx = vsIndexes[i]; let value = chunk.frames[frameIndex][vsFieldIx]; @@ -347,7 +480,7 @@ GraphSpectrumCalc._getFlightSamplesFreqVsX = function(vsFieldNames, minValue = I } // Calculate min max average of the VS values in the chunk what will used by spectrum data definition - const fftChunkLength = this._blackBoxRate * FREQ_VS_THR_CHUNK_TIME_MS / 1000; + const fftChunkLength = Math.round(this._blackBoxRate * FREQ_VS_THR_CHUNK_TIME_MS / 1000); const fftChunkWindow = Math.round(fftChunkLength / FREQ_VS_THR_WINDOW_DIVISOR); for (let fftChunkIndex = 0; fftChunkIndex + fftChunkLength < samplesCount; fftChunkIndex += fftChunkWindow) { for (const vsValueArray of vsValues) { @@ -363,31 +496,35 @@ GraphSpectrumCalc._getFlightSamplesFreqVsX = function(vsFieldNames, minValue = I } } - maxValue *= MAX_RPM_AXIS_GAP; +// Use small top margin for RPM axis only. Because it has bad axis view for throttle + if (vsFieldNames == FIELD_RPM_NAMES) { + maxValue += (maxValue - minValue) * RPM_AXIS_TOP_MARGIN_PERCENT / 100; + } if (minValue > maxValue) { if (minValue == Infinity) { // this should never happen minValue = 0; maxValue = 100; - console.log("Invalid minimum value"); + console.warn("Invalid minimum value"); } else { - console.log("Maximum value %f smaller than minimum value %d", maxValue, minValue); + console.warn("Maximum value %f smaller than minimum value %d", maxValue, minValue); minValue = 0; maxValue = 100; } } - let slicedVsValues = []; + const slicedVsValues = []; for (const vsValueArray of vsValues) { slicedVsValues.push(vsValueArray.slice(0, samplesCount)); } + return { - samples : samples.slice(0, samplesCount), - vsValues : slicedVsValues, - count : samplesCount, - minValue : minValue, - maxValue : maxValue, - }; + samples : samples.slice(0, samplesCount), + vsValues : slicedVsValues, + count : samplesCount, + minValue : minValue, + maxValue : maxValue, + }; }; GraphSpectrumCalc._getFlightSamplesPidErrorVsSetpoint = function(axisIndex) { @@ -416,8 +553,8 @@ GraphSpectrumCalc._getFlightSamplesPidErrorVsSetpoint = function(axisIndex) { } return { - piderror, - setpoint, + piderror: piderror.slice(0, samplesCount), + setpoint: setpoint.slice(0, samplesCount), maxSetpoint, count: samplesCount, }; @@ -453,36 +590,170 @@ GraphSpectrumCalc._fft = function(samples, type) { /** * Makes all the values absolute and returns the index of maxFrequency found */ -GraphSpectrumCalc._normalizeFft = function(fftOutput, fftLength) { - - if (!fftLength) { - fftLength = fftOutput.length; - } +GraphSpectrumCalc._normalizeFft = function(fftOutput) { + // The fft output contains two side spectrum, we use the first part only to get one side + const fftLength = fftOutput.length / 2; - // Make all the values absolute, and calculate some useful values (max noise, etc.) + // The fft output contains complex values (re, im pairs) of two-side spectrum + // Compute magnitudes for one spectrum side + const magnitudeLength = Math.floor(fftLength / 2); const maxFrequency = (this._blackBoxRate / 2.0); - const noiseLowEndIdx = 100 / maxFrequency * fftLength; + const noiseLowEndIdx = 100 / maxFrequency * magnitudeLength; + const magnitudes = new Float64Array(magnitudeLength); let maxNoiseIdx = 0; let maxNoise = 0; - for (let i = 0; i < fftLength; i++) { - fftOutput[i] = Math.abs(fftOutput[i]); - if (i > noiseLowEndIdx && fftOutput[i] > maxNoise) { - maxNoise = fftOutput[i]; + for (let i = 0; i < magnitudeLength; i++) { + const re = fftOutput[2 * i], + im = fftOutput[2 * i + 1]; + magnitudes[i] = Math.hypot(re, im); + if (i > noiseLowEndIdx && magnitudes[i] > maxNoise) { + maxNoise = magnitudes[i]; maxNoiseIdx = i; } } - maxNoiseIdx = maxNoiseIdx / fftLength * maxFrequency; + const maxNoiseFrequency = maxNoiseIdx / magnitudeLength * maxFrequency; const fftData = { fieldIndex : this._dataBuffer.fieldIndex, fieldName : this._dataBuffer.fieldName, - fftLength : fftLength, - fftOutput : fftOutput, - maxNoiseIdx : maxNoiseIdx, + fftLength : magnitudeLength, + fftOutput : magnitudes, + maxNoiseFrequency : maxNoiseFrequency, blackBoxRate : this._blackBoxRate, }; return fftData; }; + +/** + * Compute PSD for data samples by Welch method follow Python code + * It is good to use power at 2 values for pointsPerSegment. + * For short data length, set pointsPerSegment same samples.length to extend samples count for power at 2 value inside _fft_segmented + */ +GraphSpectrumCalc._psd = function(samples, pointsPerSegment, overlapCount, scaling = 'density') { +// Compute FFT for samples segments + const fftOutput = this._fft_segmented(samples, pointsPerSegment, overlapCount); + + const dataCount = fftOutput[0].length; + const segmentsCount = fftOutput.length; + const psdOutput = new Float64Array(dataCount); + +// Compute power scale coef + let scale = 1; + if (userSettings.analyserHanning) { + const window = Array(pointsPerSegment).fill(1); + this._hanningWindow(window, pointsPerSegment); + if (scaling == 'density') { + let skSum = 0; + for (const value of window) { + skSum += value ** 2; + } + scale = 1 / (this._blackBoxRate * skSum); + } else if (scaling == 'spectrum') { + let sum = 0; + for (const value of window) { + sum += value; + } + scale = 1 / sum ** 2; + } + } else if (scaling == 'density') { + scale = 1 / pointsPerSegment; + } else if (scaling == 'spectrum') { + scale = 1 / pointsPerSegment ** 2; + } + +// Compute average for scaled power + let min = 1e6, + max = -1e6; + // Early exit if no segments were processed + if (segmentsCount === 0) { + return { + psdOutput: new Float64Array(0), + min: 0, + max: 0, + maxNoiseFrequency: 0, + }; + } + const maxFrequency = (this._blackBoxRate / 2.0); + const noise50HzIdx = 50 / maxFrequency * dataCount; + const noise3HzIdx = 3 / maxFrequency * dataCount; + let maxNoiseIdx = 0; + let maxNoise = -100; + for (let i = 0; i < dataCount; i++) { + psdOutput[i] = 0.0; + for (let j = 0; j < segmentsCount; j++) { + let p = scale * fftOutput[j][i] ** 2; + if (i != 0) { + const even = dataCount % 2 == 0; + if (!even || even && i != dataCount - 1) { + p *= 2; + } + } + psdOutput[i] += p; + } + + const min_avg = 1e-7; // limit min value for -70db + let avg = psdOutput[i] / segmentsCount; + avg = Math.max(avg, min_avg); + psdOutput[i] = 10 * Math.log10(avg); + if (i > noise3HzIdx) { // Miss big zero freq magnitude + min = Math.min(psdOutput[i], min); + max = Math.max(psdOutput[i], max); + } + if (i > noise50HzIdx && psdOutput[i] > maxNoise) { + maxNoise = psdOutput[i]; + maxNoiseIdx = i; + } + } + + const maxNoiseFrequency = maxNoiseIdx / dataCount * maxFrequency; + + return { + psdOutput: psdOutput, + min: min, + max: max, + maxNoiseFrequency: maxNoiseFrequency, + }; +}; + +/** + * Compute FFT for samples segments by lenghts as pointsPerSegment with overlapCount overlap points count + * It is good to use power at 2 values for pointsPerSegment. + * For short data length, set pointsPerSegment same samples.length to extend samples count for power at 2 value inside _fft_segmented + */ +GraphSpectrumCalc._fft_segmented = function(samples, pointsPerSegment, overlapCount) { + const samplesCount = samples.length; + const output = []; + + for (let i = 0; i <= samplesCount - pointsPerSegment; i += pointsPerSegment - overlapCount) { + let fftInput = samples.slice(i, i + pointsPerSegment); + + if (userSettings.analyserHanning) { + this._hanningWindow(fftInput, pointsPerSegment); + } + + let fftLength; + if (pointsPerSegment != samplesCount) { + fftLength = Math.floor(pointsPerSegment / 2); + } else { // Extend the one segment input on power at 2 size + const fftSize = this.getNearPower2Value(pointsPerSegment); + const power2Input = new Float64Array(fftSize); + power2Input.set(fftInput); + fftInput = power2Input; + fftLength = fftSize / 2; + } + + const fftComplex = this._fft(fftInput); + const magnitudes = new Float64Array(fftLength); + for (let i = 0; i < fftLength; i++) { + const re = fftComplex[2 * i]; + const im = fftComplex[2 * i + 1]; + magnitudes[i] = Math.hypot(re, im); + } + output.push(magnitudes); + } + + return output; +}; diff --git a/src/graph_spectrum_plot.js b/src/graph_spectrum_plot.js index b43d4aea..c8cad625 100644 --- a/src/graph_spectrum_plot.js +++ b/src/graph_spectrum_plot.js @@ -1,5 +1,6 @@ import { FILTER_TYPE } from "./flightlog_fielddefs"; import { constrain } from "./tools"; +import { NUM_VS_BINS } from "./graph_spectrum_calc"; const BLUR_FILTER_PIXEL = 1, DEFAULT_FONT_FACE = "Verdana, Arial, sans-serif", @@ -10,13 +11,20 @@ const BLUR_FILTER_PIXEL = 1, MARGIN_LEFT_FULLSCREEN = 35, MAX_SETPOINT_DEFAULT = 100, PID_ERROR_VERTICAL_CHUNK = 5, - ZOOM_X_MAX = 5; + ZOOM_X_MAX = 5, + MAX_SPECTRUM_LINE_COUNT = 30000; + +export const DEFAULT_MIN_DBM_VALUE = -40, + DEFAULT_MAX_DBM_VALUE = 10; export const SPECTRUM_TYPE = { FREQUENCY: 0, FREQ_VS_THROTTLE: 1, - PIDERROR_VS_SETPOINT: 2, - FREQ_VS_RPM: 3, + FREQ_VS_RPM: 2, + POWER_SPECTRAL_DENSITY: 3, + PSD_VS_THROTTLE: 4, + PSD_VS_RPM: 5, + PIDERROR_VS_SETPOINT: 6, }; export const SPECTRUM_OVERDRAW_TYPE = { @@ -43,6 +51,9 @@ export const GraphSpectrumPlot = window.GraphSpectrumPlot || { _sysConfig: null, _zoomX: 1.0, _zoomY: 1.0, + _minPSD: DEFAULT_MIN_DBM_VALUE, + _maxPSD: DEFAULT_MAX_DBM_VALUE, + _lowLevelPSD: DEFAULT_MIN_DBM_VALUE, _drawingParams: { fontSizeFrameLabel: "6", fontSizeFrameLabelFullscreen: "9", @@ -72,6 +83,33 @@ GraphSpectrumPlot.setZoom = function (zoomX, zoomY) { } }; +GraphSpectrumPlot.setMinPSD = function (min) { + const modified = this._minPSD !== min; + if (modified) { + this._minPSD = min; + this._invalidateCache(); + this._invalidateDataCache(); + } +}; + +GraphSpectrumPlot.setMaxPSD = function (max) { + const modified = this._maxPSD !== max; + if (modified) { + this._maxPSD = max; + this._invalidateCache(); + this._invalidateDataCache(); + } +}; + +GraphSpectrumPlot.setLowLevelPSD = function (lowLevel) { + const modifiedLevel = this._lowLevelPSD !== lowLevel; + if (modifiedLevel) { + this._lowLevelPSD = lowLevel; + this._invalidateCache(); + this._invalidateDataCache(); + } +}; + GraphSpectrumPlot.setSize = function (width, height) { this._canvasCtx.canvas.width = width; this._canvasCtx.canvas.height = height; @@ -168,9 +206,21 @@ GraphSpectrumPlot._drawGraph = function (canvasCtx) { this._drawFrequencyVsXGraph(canvasCtx); break; + case SPECTRUM_TYPE.PSD_VS_THROTTLE: + this._drawFrequencyVsXGraph(canvasCtx, true); + break; + + case SPECTRUM_TYPE.PSD_VS_RPM: + this._drawFrequencyVsXGraph(canvasCtx, true); + break; + case SPECTRUM_TYPE.PIDERROR_VS_SETPOINT: this._drawPidErrorVsSetpointGraph(canvasCtx); break; + + case SPECTRUM_TYPE.POWER_SPECTRAL_DENSITY: + this._drawPowerSpectralDensityGraph(canvasCtx); + break; } }; @@ -188,9 +238,6 @@ GraphSpectrumPlot._drawFrequencyGraph = function (canvasCtx) { this._drawGradientBackground(canvasCtx, WIDTH, HEIGHT); - const barWidth = WIDTH / (PLOTTED_BUFFER_LENGTH / 10) - 1; - let x = 0; - const barGradient = canvasCtx.createLinearGradient(0, HEIGHT, 0, 0); barGradient.addColorStop( constrain(0 / this._zoomY, 0, 1), @@ -212,13 +259,17 @@ GraphSpectrumPlot._drawFrequencyGraph = function (canvasCtx) { canvasCtx.fillStyle = barGradient; const fftScale = HEIGHT / (this._zoomY * 100); - for (let i = 0; i < PLOTTED_BUFFER_LENGTH; i += 10) { + // Limit maximal count of drawing line to get good performance + const stepData = Math.floor(PLOTTED_BUFFER_LENGTH / MAX_SPECTRUM_LINE_COUNT) + 1; + const stepX = WIDTH / (PLOTTED_BUFFER_LENGTH / stepData); + const barWidth = Math.max(stepX, 1); + let x = 0; + for (let i = 0; i < PLOTTED_BUFFER_LENGTH; i += stepData) { const barHeight = this._fftData.fftOutput[i] * fftScale; canvasCtx.fillRect(x, HEIGHT - barHeight, barWidth, barHeight); - x += barWidth + 1; + x += stepX; } - //Draw imported spectrums const curvesColors = [ "Blue", @@ -265,7 +316,7 @@ GraphSpectrumPlot._drawFrequencyGraph = function (canvasCtx) { } //Legend draw - if (this._isFullScreen) { + if (this._isFullScreen && spectrumCount > 0) { const legendPosX = 0.84 * WIDTH, legendPosY = 0.6 * HEIGHT, rowHeight = 16, @@ -294,7 +345,7 @@ GraphSpectrumPlot._drawFrequencyGraph = function (canvasCtx) { this._fftData.fieldName, WIDTH - 4, HEIGHT - 6, - "right" + "right", ); this._drawHorizontalGridLines( canvasCtx, @@ -304,11 +355,103 @@ GraphSpectrumPlot._drawFrequencyGraph = function (canvasCtx) { WIDTH, HEIGHT, MARGIN, - "Hz" + "Hz", ); }; -GraphSpectrumPlot._drawFrequencyVsXGraph = function (canvasCtx) { +GraphSpectrumPlot._drawPowerSpectralDensityGraph = function (canvasCtx) { + const HEIGHT = canvasCtx.canvas.height - MARGIN; + const ACTUAL_MARGIN_LEFT = this._getActualMarginLeft(); + const WIDTH = canvasCtx.canvas.width - ACTUAL_MARGIN_LEFT; + const LEFT = canvasCtx.canvas.offsetLeft + ACTUAL_MARGIN_LEFT; + const TOP = canvasCtx.canvas.offsetTop; + + const PLOTTED_BLACKBOX_RATE = this._fftData.blackBoxRate / this._zoomX; + + canvasCtx.save(); + canvasCtx.translate(LEFT, TOP); + this._drawGradientBackground(canvasCtx, WIDTH, HEIGHT); + + const pointsCount = this._fftData.psdLength; + const scaleX = 2 * WIDTH / PLOTTED_BLACKBOX_RATE * this._zoomX; + canvasCtx.beginPath(); + canvasCtx.lineWidth = 1; + canvasCtx.strokeStyle = "white"; + + // Allign y axis range by 10db + const dbStep = 10; + const minY = Math.floor(this._fftData.minimum / dbStep) * dbStep; + let maxY = (Math.floor(this._fftData.maximum / dbStep) + 1) * dbStep; + if (minY == maxY) { + maxY = minY + 1; // prevent divide by zero + } + const ticksCount = (maxY - minY) / dbStep; + const scaleY = HEIGHT / (maxY - minY); + //Store vsRange for _drawMousePosition + this._fftData.vsRange = { + min: minY, + max: maxY, + }; + canvasCtx.moveTo(0, 0); + for (let pointNum = 0; pointNum < pointsCount; pointNum++) { + const freq = PLOTTED_BLACKBOX_RATE / 2 * pointNum / pointsCount; + const y = HEIGHT - (this._fftData.psdOutput[pointNum] - minY) * scaleY; + canvasCtx.lineTo(freq * scaleX, y); + } + canvasCtx.stroke(); + + this._drawAxisLabel( + canvasCtx, + this._fftData.fieldName, + WIDTH - 4, + HEIGHT - 6, + "right", + ); + this._drawHorizontalGridLines( + canvasCtx, + PLOTTED_BLACKBOX_RATE / 2, + LEFT, + TOP, + WIDTH, + HEIGHT, + MARGIN, + "Hz", + ); + this._drawVerticalGridLines( + canvasCtx, + LEFT, + TOP, + WIDTH, + HEIGHT, + minY, + maxY, + "dBm/Hz", + ticksCount, + ); + const offset = 1; + this._drawInterestFrequency( + canvasCtx, + this._fftData.maxNoiseFrequency, + PLOTTED_BLACKBOX_RATE, + "Max noise", + WIDTH, + HEIGHT, + 15 * offset + MARGIN, + "rgba(255,0,0,0.50)", + 3, + ); + + canvasCtx.restore(); +}; + +GraphSpectrumPlot.getPSDbyFreq = function(frequency) { + let freqIndex = Math.round(2 * frequency / this._fftData.blackBoxRate * (this._fftData.psdOutput.length - 1) ); + freqIndex = Math.min(freqIndex, this._fftData.psdOutput.length - 1); + freqIndex = Math.max(freqIndex, 0); + return this._fftData.psdOutput.length ? this._fftData.psdOutput[freqIndex] : 0; +}; + +GraphSpectrumPlot._drawFrequencyVsXGraph = function (canvasCtx, drawPSD = false) { const PLOTTED_BLACKBOX_RATE = this._fftData.blackBoxRate / this._zoomX; const ACTUAL_MARGIN_LEFT = this._getActualMarginLeft(); @@ -320,7 +463,7 @@ GraphSpectrumPlot._drawFrequencyVsXGraph = function (canvasCtx) { canvasCtx.translate(LEFT, TOP); if (this._cachedDataCanvas == null) { - this._cachedDataCanvas = this._drawHeatMap(); + this._cachedDataCanvas = this._drawHeatMap(drawPSD); } canvasCtx.drawImage(this._cachedDataCanvas, 0, 0, WIDTH, HEIGHT); @@ -355,7 +498,8 @@ GraphSpectrumPlot._drawFrequencyVsXGraph = function (canvasCtx) { "Hz" ); - if (this._spectrumType === SPECTRUM_TYPE.FREQ_VS_THROTTLE) { + if (this._spectrumType === SPECTRUM_TYPE.FREQ_VS_THROTTLE || + this._spectrumType === SPECTRUM_TYPE.PSD_VS_THROTTLE) { this._drawVerticalGridLines( canvasCtx, LEFT, @@ -366,7 +510,8 @@ GraphSpectrumPlot._drawFrequencyVsXGraph = function (canvasCtx) { this._fftData.vsRange.max, "%" ); - } else if (this._spectrumType === SPECTRUM_TYPE.FREQ_VS_RPM) { + } else if (this._spectrumType === SPECTRUM_TYPE.FREQ_VS_RPM || + this._spectrumType === SPECTRUM_TYPE.PSD_VS_RPM) { this._drawVerticalGridLines( canvasCtx, LEFT, @@ -380,9 +525,10 @@ GraphSpectrumPlot._drawFrequencyVsXGraph = function (canvasCtx) { } }; -GraphSpectrumPlot._drawHeatMap = function () { +GraphSpectrumPlot._drawHeatMap = function (drawPSD = false) { const THROTTLE_VALUES_SIZE = 100; - const SCALE_HEATMAP = 1.3; // Value decided after some tests to be similar to the scale of frequency graph + //The magnitude is greate then seperete Re or Im value up to 1.4=sqrt(2). Therefore the SCALE_HEATMAP is decreased from 1.3 to 1.1 + const SCALE_HEATMAP = 1.1; // Value decided after some tests to be similar to the s // This value will be maximum color const heatMapCanvas = document.createElement("canvas"); @@ -392,15 +538,23 @@ GraphSpectrumPlot._drawHeatMap = function () { canvasCtx.canvas.width = this._fftData.fftLength; canvasCtx.canvas.height = THROTTLE_VALUES_SIZE; - const fftColorScale = 100 / (this._zoomY * SCALE_HEATMAP); - // Loop for throttle - for (let j = 0; j < 100; j++) { + for (let j = 0; j < THROTTLE_VALUES_SIZE; j++) { // Loop for frequency for (let i = 0; i < this._fftData.fftLength; i++) { - const valuePlot = Math.round( - Math.min(this._fftData.fftOutput[j][i] * fftColorScale, 100) - ); + let valuePlot = this._fftData.fftOutput[j][i]; + if (drawPSD) { + if (valuePlot < this._lowLevelPSD) { + valuePlot = this._minPSD; // Filter low values + } else { + valuePlot = Math.max(valuePlot, this._minPSD); + } + valuePlot = Math.min(valuePlot, this._maxPSD); + valuePlot = Math.round((valuePlot - this._minPSD) * 100 / (this._maxPSD - this._minPSD)); + } else { + const fftColorScale = 100 / (this._zoomY * SCALE_HEATMAP); + valuePlot = Math.round(Math.min(valuePlot * fftColorScale, 100)); + } // The fillStyle is slow, but I haven't found a way to do this faster... canvasCtx.fillStyle = `hsl(360, 100%, ${valuePlot}%)`; @@ -416,6 +570,18 @@ GraphSpectrumPlot._drawHeatMap = function () { return heatMapCanvas; }; +GraphSpectrumPlot.getValueFromMatrixFFT = function(frequency, vsArgument) { + const matrixFFT = this._fftData; + let vsArgumentIndex = Math.round(NUM_VS_BINS * (vsArgument - matrixFFT.vsRange.min) / (matrixFFT.vsRange.max - matrixFFT.vsRange.min)); + if (vsArgumentIndex >= NUM_VS_BINS) { + vsArgumentIndex = NUM_VS_BINS - 1; + } + let freqIndex = Math.round(2 * frequency / matrixFFT.blackBoxRate * (matrixFFT.fftLength - 1)); + freqIndex = Math.max(freqIndex, 0); + freqIndex = Math.min(freqIndex, matrixFFT.fftLength - 1); + return matrixFFT.fftOutput[vsArgumentIndex][freqIndex]; +}; + GraphSpectrumPlot._drawPidErrorVsSetpointGraph = function (canvasCtx) { const ACTUAL_MARGIN_LEFT = this._getActualMarginLeft(); @@ -854,15 +1020,15 @@ GraphSpectrumPlot._drawFiltersAndMarkers = function (canvasCtx) { } offset++; // make some space! } catch (e) { - console.log("Notch filter fieldName missing"); + console.warn("Notch filter fieldName missing"); } if (this._spectrumType === SPECTRUM_TYPE.FREQUENCY) { this._drawInterestFrequency( canvasCtx, - this._fftData.maxNoiseIdx, + this._fftData.maxNoiseFrequency, PLOTTED_BLACKBOX_RATE, - "Max motor noise", + "Max noise", WIDTH, HEIGHT, 15 * offset + MARGIN, @@ -993,22 +1159,22 @@ GraphSpectrumPlot._drawVerticalGridLines = function ( HEIGHT, minValue, maxValue, - label + label, + ticks = 5, ) { - const TICKS = 5; - for (let i = 0; i <= TICKS; i++) { + for (let i = 0; i <= ticks; i++) { canvasCtx.beginPath(); canvasCtx.lineWidth = 1; canvasCtx.strokeStyle = "rgba(255,255,255,0.25)"; - const verticalPosition = i * (HEIGHT / TICKS); + const verticalPosition = i * (HEIGHT / ticks); canvasCtx.moveTo(0, verticalPosition); canvasCtx.lineTo(WIDTH, verticalPosition); canvasCtx.stroke(); const verticalAxisValue = ( - (maxValue - minValue) * ((TICKS - i) / TICKS) + + (maxValue - minValue) * ((ticks - i) / ticks) + minValue ).toFixed(0); let textBaseline; @@ -1016,7 +1182,7 @@ GraphSpectrumPlot._drawVerticalGridLines = function ( case 0: textBaseline = "top"; break; - case TICKS: + case ticks: textBaseline = "bottom"; break; default: @@ -1129,8 +1295,8 @@ GraphSpectrumPlot._drawGradientBackground = function ( ); if (this._isFullScreen) { - backgroundGradient.addColorStop(1, "rgba(0,0,0,0.9)"); - backgroundGradient.addColorStop(0, "rgba(0,0,0,0.7)"); + backgroundGradient.addColorStop(1, "rgba(0,0,0,1)"); + backgroundGradient.addColorStop(0, "rgba(0,0,0,0.9)"); } else { backgroundGradient.addColorStop(1, "rgba(255,255,255,0.25)"); backgroundGradient.addColorStop(0, "rgba(255,255,255,0)"); @@ -1151,7 +1317,14 @@ GraphSpectrumPlot._drawInterestFrequency = function ( stroke, lineWidth ) { - const interestLabel = `${label} ${frequency.toFixed(0)}Hz`; + + let interestLabel = ""; + if (this._spectrumType === SPECTRUM_TYPE.POWER_SPECTRAL_DENSITY && label != "" ) { + const psdValue = this.getPSDbyFreq(frequency); + interestLabel = `${label}: (${frequency.toFixed(0)}Hz, ${psdValue.toFixed(0)}dBm/Hz)`; + } else { + interestLabel = `${label} ${frequency.toFixed(0)}Hz`; + } return this._drawVerticalMarkerLine( canvasCtx, frequency, @@ -1221,7 +1394,9 @@ GraphSpectrumPlot._drawLowpassDynFilter = function ( // frequency2 line const offsetByType = this._spectrumType === SPECTRUM_TYPE.FREQ_VS_THROTTLE || - this._spectrumType === SPECTRUM_TYPE.FREQ_VS_RPM + this._spectrumType === SPECTRUM_TYPE.FREQ_VS_RPM || + this._spectrumType === SPECTRUM_TYPE.PSD_VS_THROTTLE || + this._spectrumType === SPECTRUM_TYPE.PSD_VS_RPM ? 0 : OFFSET; const x2 = this._drawVerticalMarkerLine( @@ -1243,7 +1418,9 @@ GraphSpectrumPlot._drawLowpassDynFilter = function ( if ( this._spectrumType === SPECTRUM_TYPE.FREQ_VS_THROTTLE || - this._spectrumType === SPECTRUM_TYPE.FREQ_VS_RPM + this._spectrumType === SPECTRUM_TYPE.FREQ_VS_RPM || + this._spectrumType === SPECTRUM_TYPE.PSD_VS_THROTTLE || + this._spectrumType === SPECTRUM_TYPE.PSD_VS_RPM ) { /* * It draws a curve: @@ -1309,7 +1486,9 @@ GraphSpectrumPlot._drawNotchFilter = function ( if ( this._spectrumType === SPECTRUM_TYPE.FREQ_VS_THROTTLE || - this._spectrumType === SPECTRUM_TYPE.FREQ_VS_RPM + this._spectrumType === SPECTRUM_TYPE.FREQ_VS_RPM || + this._spectrumType === SPECTRUM_TYPE.PSD_VS_THROTTLE || + this._spectrumType === SPECTRUM_TYPE.PSD_VS_RPM ) { canvasCtx.moveTo(cutoffX, 0); canvasCtx.lineTo(centerX * 2 - cutoffX, HEIGHT); @@ -1356,16 +1535,20 @@ GraphSpectrumPlot._drawMousePosition = function ( lineWidth ) { // X axis + let mouseFrequency = 0; if ( this._spectrumType === SPECTRUM_TYPE.FREQUENCY || this._spectrumType === SPECTRUM_TYPE.FREQ_VS_THROTTLE || - this._spectrumType === SPECTRUM_TYPE.FREQ_VS_RPM + this._spectrumType === SPECTRUM_TYPE.FREQ_VS_RPM || + this._spectrumType === SPECTRUM_TYPE.POWER_SPECTRAL_DENSITY || + this._spectrumType === SPECTRUM_TYPE.PSD_VS_THROTTLE || + this._spectrumType === SPECTRUM_TYPE.PSD_VS_RPM ) { // Calculate frequency at mouse const sampleRate = this._fftData.blackBoxRate / this._zoomX; const marginLeft = this._getActualMarginLeft(); - const mouseFrequency = + mouseFrequency = ((mouseX - marginLeft) / WIDTH) * (this._fftData.blackBoxRate / this._zoomX / 2); if (mouseFrequency >= 0 && mouseFrequency <= sampleRate) { @@ -1382,15 +1565,31 @@ GraphSpectrumPlot._drawMousePosition = function ( ); } + if (this._spectrumType === SPECTRUM_TYPE.POWER_SPECTRAL_DENSITY) { + const psdLabel = Math.round(this.getPSDbyFreq(mouseFrequency)).toString() + "dBm/Hz"; + this._drawAxisLabel( + canvasCtx, + psdLabel, + mouseX - 30, + mouseY - 4, + "left", + ); + } + // Y axis let unitLabel; switch (this._spectrumType) { case SPECTRUM_TYPE.FREQ_VS_THROTTLE: + case SPECTRUM_TYPE.PSD_VS_THROTTLE: unitLabel = "%"; break; case SPECTRUM_TYPE.FREQ_VS_RPM: + case SPECTRUM_TYPE.PSD_VS_RPM: unitLabel = "Hz"; break; + case SPECTRUM_TYPE.POWER_SPECTRAL_DENSITY: + unitLabel = "dBm/Hz"; + break; default: unitLabel = null; break; @@ -1398,12 +1597,12 @@ GraphSpectrumPlot._drawMousePosition = function ( if (unitLabel !== null) { const val_min = this._fftData.vsRange.min; const val_max = this._fftData.vsRange.max; - const mouseValue = (1 - mouseY / HEIGHT) * (val_max - val_min) + val_min; - if (mouseValue >= val_min && mouseValue <= val_max) { - const valueLabel = `${mouseValue.toFixed(0)}${unitLabel}`; + const vsArgValue = (1 - mouseY / HEIGHT) * (val_max - val_min) + val_min; + if (vsArgValue >= val_min && vsArgValue <= val_max) { + const valueLabel = `${vsArgValue.toFixed(0)}${unitLabel}`; this._drawHorizontalMarkerLine( canvasCtx, - mouseValue, + vsArgValue, val_min, val_max, valueLabel, @@ -1413,6 +1612,18 @@ GraphSpectrumPlot._drawMousePosition = function ( stroke, lineWidth ); + + if (this._spectrumType === SPECTRUM_TYPE.PSD_VS_THROTTLE || + this._spectrumType === SPECTRUM_TYPE.PSD_VS_RPM) { + const label = Math.round(this.getValueFromMatrixFFT(mouseFrequency, vsArgValue)).toString() + "dBm/Hz"; + this._drawAxisLabel( + canvasCtx, + label, + mouseX - 30, + mouseY - 4, + "left", + ); + } } } } else if (this._spectrumType === SPECTRUM_TYPE.PIDERROR_VS_SETPOINT) { @@ -1466,6 +1677,9 @@ GraphSpectrumPlot._getActualMarginLeft = function () { switch (this._spectrumType) { case SPECTRUM_TYPE.FREQ_VS_THROTTLE: case SPECTRUM_TYPE.FREQ_VS_RPM: + case SPECTRUM_TYPE.PSD_VS_THROTTLE: + case SPECTRUM_TYPE.PSD_VS_RPM: + case SPECTRUM_TYPE.POWER_SPECTRAL_DENSITY: actualMarginLeft = this._isFullScreen ? MARGIN_LEFT_FULLSCREEN : MARGIN_LEFT;