Repository: betaflight/blackbox-log-viewer
Pull request #820: Added power spectral density curves at the spectrum chart
Author: login: demvlad
Labels: name: RN: IMPROVEMENT
Created: 2025-04-30T14:32:45Z | Merged: 2025-05-05T19:20:39Z

The power spectral density (PSD) is usefull tools for noise analysis. 
The PID tool box shows PSD at its chart.
This PR starts this work. 
Added power spectral density curves at the spectrum chart.
I try to implement [Welch PSD computing method](https://en.wikipedia.org/wiki/Welch%27s_method) follow [python SciPy library ](https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.welch.html) and [Python Spectral Analysis tutorial](https://docs.scipy.org/doc/scipy/tutorial/signal.html#tutorial-spectralanalysis).
The Blackbox explorer PSD chart for this PR (in db/Hz unit):
![PSD_BBExplorer](https://github.com/user-attachments/assets/d8ccc647-954b-49c3-acf6-3e40565bf47d)
The PID tool box PSD chart:
![PSD_PTB](https://github.com/user-attachments/assets/b141813e-be27-48c1-bbfc-61f5c01ea954)
The Python PSD chart:
![PSD_Python](https://github.com/user-attachments/assets/d00591ed-517d-4fd1-a25d-c55ad93839bc)

The PSD curves are showed in db/Hz units. 
The Welch method can compute signal power mean average values for many data samples segments with overlapping.
The mean average gives smooth spectrum curves.
My future plans:
- to check the all algorithms math
- to add grid and db values captions at the chart
- to add PSD computing settings (data points per segment, overlap points count) in setup
- do possible to show several spectrums at the chart by using mouse click at the data curves legend - curves multiple switch to compare spectrums.

Its exists two scale mode also: "density" and "spectrum" in Welch method. I use "density" on the example above. Interested to look at "spectrum" mode too.


The current BBExplorer amplitude (not PSD) spectrum shows some internal 0...100 units, it depends from curves scale. It has much pulsations, therefore it unpossible to show several spectrums at the chart without some filtering. The PSD has standard db/Hz units. It can do smooth by using standard settings for good view.

The current BBExplorers Frequency spectrum:
![spectrum_bbe](https://github.com/user-attachments/assets/1ceadbde-5c00-4a9d-9e95-a3d596ff9f1a)



<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
	- Added a "Power spectral density" option to the spectrum type dropdown in the Workspace section.
	- Introduced visualization and analysis of power spectral density (PSD) using Welch's method, allowing users to view PSD graphs versus frequency.
- **Enhancements**
	- Improved spectrum graphing capabilities with new plotting and scaling for PSD data.
	- Updated graph labels and markers for clearer noise frequency identification.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->


Diff:
diff --git a/index.html b/index.html
index 5218a8d4..3f41083f 100644
--- a/index.html
+++ b/index.html
@@ -459,6 +459,7 @@ <h4>Workspace</h4>
                                 <option value="1">Freq. vs Throttle</option>
                                 <option value="3">Freq. vs RPM</option>
                                 <option value="2">Error vs Setpoint</option>
+                                <option value="4">Power spectral density</option>
                             </select>
                         </div>
 
diff --git a/src/graph_spectrum.js b/src/graph_spectrum.js
index c89251a5..5d07c588 100644
--- a/src/graph_spectrum.js
+++ b/src/graph_spectrum.js
@@ -116,6 +116,10 @@ export function FlightLogAnalyser(flightLog, canvas, analyserCanvas) {
           fftData = GraphSpectrumCalc.dataLoadPidErrorVsSetpoint();
           break;
 
+        case SPECTRUM_TYPE.POWER_SPECTRAL_DENSITY:
+          fftData = GraphSpectrumCalc.dataLoadPSD(analyserZoomY);
+          break;
+
         case SPECTRUM_TYPE.FREQUENCY:
         default:
           fftData = GraphSpectrumCalc.dataLoadFrequency();
@@ -187,6 +191,11 @@ 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();
         })
       )
diff --git a/src/graph_spectrum_calc.js b/src/graph_spectrum_calc.js
index b358dc22..da9b24e3 100644
--- a/src/graph_spectrum_calc.js
+++ b/src/graph_spectrum_calc.js
@@ -106,6 +106,33 @@ GraphSpectrumCalc.dataLoadFrequency = function() {
   return fftData;
 };
 
+GraphSpectrumCalc.dataLoadPSD = function(analyserZoomY) {
+  const flightSamples = this._getFlightSamplesFreq(false);
+
+  let pointsPerSegment = 512;
+  const multiplier = Math.floor(1 / analyserZoomY); // 0. ... 10
+  if (multipiler == 0) {
+    pointsPerSegment = 256;
+  } else if (multiplier > 1) {
+    pointsPerSegment *= 2 ** Math.floor(multiplier / 2);
+  }
+  pointsPerSegment = Math.min(pointsPerSegment, flightSamples.samples.length);
+  const overlapCount = Math.floor(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,
+    maxNoiseIdx: psd.maxNoiseIdx,
+  };
+  return psdData;
+};
 
 GraphSpectrumCalc._dataLoadFrequencyVsX = function(vsFieldNames, minValue = Infinity, maxValue = -Infinity) {
 
@@ -283,7 +310,7 @@ GraphSpectrumCalc._getFlightChunks = function() {
   return allChunks;
 };
 
-GraphSpectrumCalc._getFlightSamplesFreq = function() {
+GraphSpectrumCalc._getFlightSamplesFreq = function(scaled = true) {
 
   const allChunks = this._getFlightChunks();
 
@@ -293,7 +320,11 @@ 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++;
     }
   }
@@ -485,3 +516,116 @@ GraphSpectrumCalc._normalizeFft = function(fftOutput, fftLength) {
 
   return fftData;
 };
+
+/**
+ * Compute PSD for data samples by Welch method follow Python code
+ */
+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,
+      maxNoiseIdx: 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 != 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,
+      maxNoiseIdx: maxNoiseFrequency,
+    };
+};
+
+
+/**
+ * Compute FFT for samples segments by lenghts as n_per_seg with n_overlap overlap points count
+ */
+GraphSpectrumCalc._fft_segmented  = function(samples, n_per_seg, n_overlap) {
+  const samplesCount = samples.length;
+  let output = [];
+  for (let i = 0; i < samplesCount - n_per_seg; i += n_per_seg - n_overlap) {
+    const fftInput = samples.slice(i, i + n_per_seg);
+
+    if (userSettings.analyserHanning) {
+      this._hanningWindow(fftInput, n_per_seg);
+    }
+
+    const fftComplex = this._fft(fftInput);
+    const magnitudes = new Float64Array(n_per_seg / 2);
+    for (let i = 0; i < n_per_seg / 2; 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..2ff35470 100644
--- a/src/graph_spectrum_plot.js
+++ b/src/graph_spectrum_plot.js
@@ -17,6 +17,7 @@ export const SPECTRUM_TYPE = {
   FREQ_VS_THROTTLE: 1,
   PIDERROR_VS_SETPOINT: 2,
   FREQ_VS_RPM: 3,
+  POWER_SPECTRAL_DENSITY: 4,
 };
 
 export const SPECTRUM_OVERDRAW_TYPE = {
@@ -171,6 +172,10 @@ GraphSpectrumPlot._drawGraph = function (canvasCtx) {
     case SPECTRUM_TYPE.PIDERROR_VS_SETPOINT:
       this._drawPidErrorVsSetpointGraph(canvasCtx);
       break;
+
+    case SPECTRUM_TYPE.POWER_SPECTRAL_DENSITY:
+      this._drawPowerSpectralDensityGraph(canvasCtx);
+      break;
   }
 };
 
@@ -294,7 +299,7 @@ GraphSpectrumPlot._drawFrequencyGraph = function (canvasCtx) {
     this._fftData.fieldName,
     WIDTH - 4,
     HEIGHT - 6,
-    "right"
+    "right",
   );
   this._drawHorizontalGridLines(
     canvasCtx,
@@ -304,10 +309,92 @@ GraphSpectrumPlot._drawFrequencyGraph = function (canvasCtx) {
     WIDTH,
     HEIGHT,
     MARGIN,
-    "Hz"
+    "Hz",
   );
 };
 
+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;
+  const maxY = (Math.floor(this._fftData.maximum / dbStep) + 1) * dbStep;
+  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.maxNoiseIdx,
+    PLOTTED_BLACKBOX_RATE,
+    "Max noise",
+    WIDTH,
+    HEIGHT,
+    15 * offset + MARGIN,
+    "rgba(255,0,0,0.50)",
+    3,
+  );
+
+  canvasCtx.restore();
+};
+
 GraphSpectrumPlot._drawFrequencyVsXGraph = function (canvasCtx) {
   const PLOTTED_BLACKBOX_RATE = this._fftData.blackBoxRate / this._zoomX;
 
@@ -862,7 +949,7 @@ GraphSpectrumPlot._drawFiltersAndMarkers = function (canvasCtx) {
       canvasCtx,
       this._fftData.maxNoiseIdx,
       PLOTTED_BLACKBOX_RATE,
-      "Max motor noise",
+      "Max noise",
       WIDTH,
       HEIGHT,
       15 * offset + MARGIN,
@@ -993,22 +1080,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 +1103,7 @@ GraphSpectrumPlot._drawVerticalGridLines = function (
       case 0:
         textBaseline = "top";
         break;
-      case TICKS:
+      case ticks:
         textBaseline = "bottom";
         break;
       default:
@@ -1129,8 +1216,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)");
@@ -1359,7 +1446,8 @@ GraphSpectrumPlot._drawMousePosition = function (
   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
   ) {
     // Calculate frequency at mouse
     const sampleRate = this._fftData.blackBoxRate / this._zoomX;
@@ -1391,6 +1479,9 @@ GraphSpectrumPlot._drawMousePosition = function (
       case SPECTRUM_TYPE.FREQ_VS_RPM:
         unitLabel = "Hz";
         break;
+      case SPECTRUM_TYPE.POWER_SPECTRAL_DENSITY:
+        unitLabel = "dBm/Hz";
+        break;
       default:
         unitLabel = null;
         break;
@@ -1466,6 +1557,7 @@ GraphSpectrumPlot._getActualMarginLeft = function () {
   switch (this._spectrumType) {
     case SPECTRUM_TYPE.FREQ_VS_THROTTLE:
     case SPECTRUM_TYPE.FREQ_VS_RPM:
+    case SPECTRUM_TYPE.POWER_SPECTRAL_DENSITY:
       actualMarginLeft = this._isFullScreen
         ? MARGIN_LEFT_FULLSCREEN
         : MARGIN_LEFT;


Reviews:
user: login: mituritsyn
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T19:55:25Z
user: login: mituritsyn
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T20:37:00Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 5**

<details>
<summary>♻️ Duplicate comments (1)</summary><blockquote>

<details>
<summary>index.html (1)</summary><blockquote>

`462-463`: **Consider renaming for UX consistency.**  
Other dropdown entries use an “X vs Y” pattern (e.g. “Freq. vs Throttle”); you might prefer a label like `"Frequency vs noise level"` or `"Noise level vs Frequency"` to align with the existing naming convention.

</blockquote></details>

</blockquote></details>

<details>
<summary>🧹 Nitpick comments (1)</summary><blockquote>

<details>
<summary>src/graph_spectrum_calc.js (1)</summary><blockquote>

`528-553`: **Window-scaling formula deviates from reference implementation**

For `scaling === 'density'`, SciPy uses  
`scale = 1 / (fs * Σw²)` (OK), **not** `1 / n_per_seg` when no window is applied – it should still be divided by `fs`.  

Likewise, in `'spectrum'` mode `scale = 1 / (Σw)²`, independent of `fs`. Your fallback `1 / n_per_seg ** 2` is close, but would benefit from mirroring the reference formulas for consistency:

```diff
-  } else if (!userSettings.analyserHanning) {
-    scale = 1 / n_per_seg;
+  } else if (!userSettings.analyserHanning) {
+    scale = 1 / (fs * n_per_seg);
   }
...
-  } else {
-    scale = 1 / n_per_seg ** 2;
+  } else {
+    scale = 1 / (n_per_seg ** 2);
   }
```

Not critical for visualisation, but necessary for unit correctness.

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 3639e69d10f3f0ef50ac649ba6a8ba80e5c95482 and 4bfe62f786b0bf48062f809f51e4aa0599469783.

</details>

<details>
<summary>📒 Files selected for processing (4)</summary>

* `index.html` (1 hunks)
* `src/graph_spectrum.js` (1 hunks)
* `src/graph_spectrum_calc.js` (4 hunks)
* `src/graph_spectrum_plot.js` (4 hunks)

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Graph Analysis (1)</summary>

<details>
<summary>src/graph_spectrum.js (2)</summary><blockquote>

<details>
<summary>src/graph_spectrum_plot.js (2)</summary>

* `SPECTRUM_TYPE` (15-21)
* `SPECTRUM_TYPE` (15-21)

</details>
<details>
<summary>src/graph_spectrum_calc.js (6)</summary>

* `fftData` (104-104)
* `fftData` (203-211)
* `fftData` (222-222)
* `fftData` (504-511)
* `GraphSpectrumCalc` (24-38)
* `GraphSpectrumCalc` (24-38)

</details>

</blockquote></details>

</details>

</details>

<details>
<summary>🔇 Additional comments (5)</summary><blockquote>

<details>
<summary>index.html (1)</summary>

`462-463`: **Approve adding PSD to spectrum type dropdown.**  
The new `<option value="4">Power spectral density</option>` correctly surfaces the PSD feature in the UI, matching the corresponding `SPECTRUM_TYPE.PSD_VS_FREQUENCY` handling in the code.

</details>
<details>
<summary>src/graph_spectrum.js (1)</summary>

`119-122`: **Implementation looks good! Adds proper PSD support to switch case.**

This change properly adds the new `PSD_VS_FREQUENCY` case to load Power Spectral Density data, following the same pattern as other spectrum types.

</details>
<details>
<summary>src/graph_spectrum_plot.js (3)</summary>

`20-20`: **LGTM! Added new spectrum type enum value.**

Adding the new enum value for Power Spectral Density is correct and follows the existing pattern.

---

`176-178`: **LGTM! Added case for new spectrum type.**

This change correctly adds the new case to draw the PSD graph, matching the pattern used for other spectrum types.

---

`302-303`: **Minor style improvements.**

Small formatting improvements for consistency with trailing commas.



Also applies to: 312-314

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-30T21:18:03Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 0**

<details>
<summary>🔭 Outside diff range comments (1)</summary><blockquote>

<details>
<summary>src/graph_spectrum_calc.js (1)</summary><blockquote>

`309-323`: _⚠️ Potential issue_

**Add null check to prevent potential crash.**

The current implementation can throw an error if `this._dataBuffer.curve` is null when `scaled` is true. This was previously flagged in a code review but hasn't been fixed in this implementation.

Apply this fix:

```diff
 if (scaled) {
+  if (!this._dataBuffer.curve) {
+    samples[samplesCount] = frame[this._dataBuffer.fieldIndex];
+  } else {
     samples[samplesCount] = (this._dataBuffer.curve.lookupRaw(frame[this._dataBuffer.fieldIndex]));
+  }
 } else {
   samples[samplesCount] = frame[this._dataBuffer.fieldIndex];
 }
```

</blockquote></details>

</blockquote></details>

<details>
<summary>♻️ Duplicate comments (3)</summary><blockquote>

<details>
<summary>src/graph_spectrum_calc.js (3)</summary><blockquote>

`109-131`: _🛠️ Refactor suggestion_

**Add safeguards and make Welch parameters configurable.**

The method uses fixed values for `points_per_segment` and `overlap_count` which may not work well for all data sizes. If `flightSamples.samples.length < 512`, this will cause issues when calculating PSD.



Apply this improvement:

```diff
-const points_per_segment = 512,
-      overlap_count = 256;
+// Pick reasonable defaults, but clamp them to the available data
+const MIN_SEGMENT = 64;          // avoid pathological tiny segments
+let points_per_segment = 512;
+let overlap_count      = 256;
+
+// Shrink segment size when we have fewer samples than the default
+if (flightSamples.samples.length < points_per_segment) {
+  points_per_segment = Math.max(
+    MIN_SEGMENT,
+    1 << Math.floor(Math.log2(flightSamples.samples.length))
+  );
+  overlap_count = Math.floor(points_per_segment / 2);
+}
+
+// Bail out early when we still do not have enough data
+if (flightSamples.samples.length < points_per_segment) {
+  return {
+    error: 'Not enough data for PSD calculation',
+    fieldName: this._dataBuffer.fieldName
+  };
+}
```

---

`516-573`: _⚠️ Potential issue_

**Fix power doubling logic and add division-by-zero protection.**

The current implementation has two issues:
1. Potential division-by-zero error when `segmentsCount` is 0
2. Incorrect power doubling logic for real-input FFT




Apply this fix:

```diff
// Compute average for scaled power
+ if (segmentsCount === 0) return psdOutput.fill(NaN);  // early-exit guard

+ const nyquistIdx = (dataCount % 2 === 0) ? dataCount / 2 : null;
  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 != dataCount - 1) {
-       p *= 2;
-     }
+     // Double all bins except DC and Nyquist (if present)
+     if (i !== 0 && i !== nyquistIdx) {
+       p *= 2;
+     }
      psdOutput[i] += p;
    }
```

---

`579-594`: _⚠️ Potential issue_

**Fix FFT segment processing to use complex magnitude and include the last segment.**

There are two issues with the current implementation:
1. It ignores the imaginary part of the FFT output, using only the real part
2. The loop condition misses the last valid segment due to a strict inequality




Apply this fix:

```diff
-for (let i = 0; i < samplesCount - n_per_seg; i += n_per_seg - n_overlap) {
+for (let i = 0; i <= samplesCount - n_per_seg; i += n_per_seg - n_overlap) {
    const fftInput = samples.slice(i, i + n_per_seg);

    if (userSettings.analyserHanning) {
      this._hanningWindow(fftInput, n_per_seg);
    }

-   const fftOutput = this._fft(fftInput);
-   output.push(fftOutput.slice(0, n_per_seg));
+   const fftComplex = this._fft(fftInput);  // [re0, im0, re1, im1, ...]
+   const mag = new Float64Array(n_per_seg);
+   for (let k = 0; k < n_per_seg; k++) {
+     const re = fftComplex[2 * k];
+     const im = fftComplex[2 * k + 1];
+     mag[k] = Math.hypot(re, im);           // √(re² + im²)
+   }
+   output.push(mag);
}
```

</blockquote></details>

</blockquote></details>

<details>
<summary>🧹 Nitpick comments (1)</summary><blockquote>

<details>
<summary>src/graph_spectrum_calc.js (1)</summary><blockquote>

`516-519`: **Document PSD function parameters more clearly.**

The function documentation could be improved to explain what each parameter does, especially the scaling parameter which has two possible values.


```diff
/**
- * Compute PSD for data samples by Welch method follow Python code
+ * Compute Power Spectral Density (PSD) for data samples using Welch's method
+ * @param {Float64Array} samples - Input time-domain samples
+ * @param {number} fs - Sampling frequency in Hz
+ * @param {number} n_per_seg - Number of points per segment
+ * @param {number} n_overlap - Number of points to overlap between segments
+ * @param {string} scaling - Scaling mode: 'density' (dB/Hz) or 'spectrum' (dB)
+ * @returns {Float64Array} - PSD values in dB
 */
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 4bfe62f786b0bf48062f809f51e4aa0599469783 and 9f229c2212b9ded263050e2e351dcac626e624a5.

</details>

<details>
<summary>📒 Files selected for processing (1)</summary>

* `src/graph_spectrum_calc.js` (4 hunks)

</details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-01T08:02:24Z
user: login: demvlad
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T08:02:49Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 0**

<details>
<summary>🔭 Outside diff range comments (1)</summary><blockquote>

<details>
<summary>src/graph_spectrum_calc.js (1)</summary><blockquote>

`309-324`: _⚠️ Potential issue_

**Add null check for curve before using lookupRaw**

When `scaled === true`, the code directly calls `this._dataBuffer.curve.lookupRaw(...)` without checking if `curve` exists first. This could cause a null/undefined reference error.


```diff
-      if (scaled) {
+      if (scaled && this._dataBuffer.curve) {
         samples[samplesCount] = (this._dataBuffer.curve.lookupRaw(frame[this._dataBuffer.fieldIndex]));
       } else {
         samples[samplesCount] = frame[this._dataBuffer.fieldIndex];
       }
```

</blockquote></details>

</blockquote></details>

<details>
<summary>♻️ Duplicate comments (3)</summary><blockquote>

<details>
<summary>src/graph_spectrum_calc.js (3)</summary><blockquote>

`109-131`: _🛠️ Refactor suggestion_

**Add configurable PSD parameters**

The current implementation uses fixed values for segment size (512) and overlap (256), which may not be optimal for all datasets or provide sufficient smoothing. As mentioned in the PR comments, these parameters significantly affect smoothing quality.


```diff
-const points_per_segment = 512,
-      overlap_count = 256;
+// Use configurable parameters with reasonable defaults
+const points_per_segment = userSettings.psdPointsPerSegment || 512,
+      overlap_count = userSettings.psdOverlapCount || 256;
+
+// Ensure we have enough data for PSD calculation
+if (flightSamples.samples.length < points_per_segment) {
+  return {
+    error: 'Not enough data for PSD calculation',
+    fieldName: this._dataBuffer.fieldName
+  };
+}
```

---

`575-593`: _🛠️ Refactor suggestion_

**Fix segment processing logic**

There are two issues in the FFT segmentation function:

1. The loop condition `i < samplesCount - n_per_seg` skips the last valid segment. It should be `i <= samplesCount - n_per_seg`.
2. The function only uses half of the FFT output (magnitudes up to n_per_seg/2) which is correct for real data, but ensure n_per_seg is always even to avoid issues.


```diff
 GraphSpectrumCalc._fft_segmented  = function(samples, n_per_seg, n_overlap) {
   const samplesCount = samples.length;
   let output = [];
-  for (let i = 0; i < samplesCount - n_per_seg; i += n_per_seg - n_overlap) {
+  // Ensure we capture all valid segments including the last one
+  for (let i = 0; i <= samplesCount - n_per_seg; i += n_per_seg - n_overlap) {
     const fftInput = samples.slice(i, i + n_per_seg);

     if (userSettings.analyserHanning) {
       this._hanningWindow(fftInput, n_per_seg);
     }

     const fftComplex = this._fft(fftInput);
+    // Ensure n_per_seg is even to avoid truncation issues
     const magnitudes = new Float64Array(n_per_seg / 2);
     for (let i = 0; i < n_per_seg / 2; i++) {
       const re = fftComplex[2 * i];
```

---

`519-569`: _⚠️ Potential issue_

**Fix power doubling logic and add division-by-zero check**

There are two issues with the PSD calculation:

1. The code incorrectly applies power doubling to all frequencies except the last one. For real signals, doubling should exclude both the DC component (i=0) and Nyquist frequency (if present).
2. There's no check for empty segments, which could lead to division by zero.


```diff
 GraphSpectrumCalc._psd  = function(samples, fs, n_per_seg, n_overlap, scaling = 'density') {
   // Compute FFT for samples segments
   const fftOutput = this._fft_segmented(samples, n_per_seg, n_overlap);

   const dataCount = fftOutput[0]?.length || 0;
   const segmentsCount = fftOutput.length;
+  
+  // Check if we have any data to process
+  if (segmentsCount === 0 || dataCount === 0) {
+    return new Float64Array(0);
+  }
+  
   const psdOutput = new Float64Array(dataCount);

   // Compute power scale coef
   let scale = 1;
   if (userSettings.analyserHanning) {
     // ... [scale calculation code remains unchanged]
   }

   // Compute average for scaled power
   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 != dataCount - 1) {
+      // Double all bins except DC (i=0) and Nyquist (if present at dataCount/2)
+      const isNyquist = (dataCount % 2 === 0) && (i === dataCount / 2);
+      if (i !== 0 && !isNyquist) {
         p *= 2;
       }
       psdOutput[i] += p;
     }
```

</blockquote></details>

</blockquote></details>

<details>
<summary>🧹 Nitpick comments (1)</summary><blockquote>

<details>
<summary>src/graph_spectrum_calc.js (1)</summary><blockquote>

`516-569`: **Consider making scaling mode configurable**

The `_psd` function supports two scaling modes ('density' and 'spectrum'), but only 'density' is used in the calling function. Consider making this configurable via user settings to offer more flexibility.


```diff
 GraphSpectrumCalc.dataLoadFrequencyPSD = function() {
   const points_per_segment = 512,
         overlap_count = 256;
   const flightSamples = this._getFlightSamplesFreq(false);
-  const psd =  this._psd(flightSamples.samples, this._blackBoxRate, points_per_segment, overlap_count);
+  const scaling = userSettings.psdScalingMode || 'density'; // Add to userSettings
+  const psd =  this._psd(flightSamples.samples, this._blackBoxRate, points_per_segment, overlap_count, scaling);
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 082512daaeec9e93ca67c909a7f036ed6cc95d0a and 721965d3581c9c93b1a01efb4183a294c691969c.

</details>

<details>
<summary>📒 Files selected for processing (1)</summary>

* `src/graph_spectrum_calc.js` (4 hunks)

</details>

<details>
<summary>🔇 Additional comments (1)</summary><blockquote>

<details>
<summary>src/graph_spectrum_calc.js (1)</summary>

`587-591`: **Good implementation of complex magnitude calculation**

The code correctly computes the magnitude of complex FFT results using the Euclidean norm (hypot) of real and imaginary parts. This is the right approach for spectral analysis.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-01T10:13:21Z
user: login: mituritsyn
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-04T09:57:59Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 2**

<details>
<summary>🔭 Outside diff range comments (1)</summary><blockquote>

<details>
<summary>src/graph_spectrum_calc.js (1)</summary><blockquote>

`318-332`: _🛠️ Refactor suggestion_

**Updated to support both raw and scaled samples.**

The modification to support both scaled and unscaled sample retrieval is useful, but there's a potential issue with null curve.


Add a null check to prevent errors when curve is null:

```diff
if (scaled) {
-  samples[samplesCount] = (this._dataBuffer.curve.lookupRaw(frame[this._dataBuffer.fieldIndex]));
+  if (this._dataBuffer.curve) {
+    samples[samplesCount] = this._dataBuffer.curve.lookupRaw(frame[this._dataBuffer.fieldIndex]);
+  } else {
+    samples[samplesCount] = frame[this._dataBuffer.fieldIndex];
+  }
} else {
  samples[samplesCount] = frame[this._dataBuffer.fieldIndex];
}
```

This safeguard aligns with the previous review comment on lines 328-332.

</blockquote></details>

</blockquote></details>

<details>
<summary>♻️ Duplicate comments (1)</summary><blockquote>

<details>
<summary>src/graph_spectrum_calc.js (1)</summary><blockquote>

`528-578`: _⚠️ Potential issue_

**PSD calculation implementation needs safeguards.**

The PSD calculation implements the Welch method but has several issues that could lead to errors.


1. Add a guard against division by zero when segment count is zero:

```diff
// Compute average for scaled power
+ if (segmentsCount === 0) return psdOutput.fill(NaN);  // Early exit if no segments
+ 
  for (let i = 0; i < dataCount; i++) {
```

2. Fix the power doubling logic for FFT results:

```diff
  for (let j = 0; j < segmentsCount; j++) {
    let p = scale * fftOutput[j][i] ** 2;
-   if (i != dataCount - 1) {
+   // Double all bins except DC (i === 0) and Nyquist (for even n_per_seg)
+   if (i !== 0 && (pointsPerSegment % 2 !== 0 || i !== pointsPerSegment/4)) {
      p *= 2;
    }
    psdOutput[i] += p;
  }
```

3. Use consistent naming conventions:

```diff
-   const min_avg = 1e-5; // limit min value for -50db
+   const minAvg = 1e-5; // limit min value for -50db
    let avg = psdOutput[i] / segmentsCount;
-   avg = Math.max(avg, min_avg);
+   avg = Math.max(avg, minAvg);
```

</blockquote></details>

</blockquote></details>

<details>
<summary>🧹 Nitpick comments (4)</summary><blockquote>

<details>
<summary>src/graph_spectrum_calc.js (4)</summary><blockquote>

`525-527`: **Improve function documentation to clarify its purpose and parameters.**

The documentation comment for the _psd function could be more informative.


Enhance the JSDoc comment:

```diff
/**
- * Compute PSD for data samples by Welch method follow Python code
+ * Compute Power Spectral Density (PSD) for data samples using Welch's method.
+ * 
+ * @param {Float64Array} samples - The input time-domain samples
+ * @param {number} samplesRate - The sampling rate in Hz
+ * @param {number} pointsPerSegment - Number of points per segment
+ * @param {number} overlapCount - Number of overlapping points between segments
+ * @param {string} scaling - Scaling mode ('density' or 'spectrum')
+ * @returns {Float64Array} The computed PSD values in dB/Hz
 */
```

---

`581-583`: **Improve function documentation for clarity.**


Enhance the JSDoc comment for _fft_segmented:

```diff
/**
- * Compute FFT for samples segments by lenghts as n_per_seg with n_overlap overlap points count
+ * Computes FFT for overlapping segments of the input samples.
+ * 
+ * @param {Float64Array} samples - The input time-domain samples
+ * @param {number} n_per_seg - Number of points per segment
+ * @param {number} n_overlap - Number of overlapping points between segments
+ * @returns {Array<Float64Array>} Array of FFT magnitude arrays for each segment
 */
```

---

`112-121`: **Add configurable PSD parameters in the user interface.**

The PR objectives mention allowing users to adjust PSD computation settings. This implementation hardcodes the parameters with dynamic adjustment based on zoom level.


Consider exposing these parameters to the UI as mentioned in the PR objectives. This would allow users to fine-tune the PSD visualization according to their preferences:

```javascript
// Example slider implementation in the UI
// <input type="range" id="psdSegmentSlider" min="64" max="2048" step="64" value="512">

// Then in this code:
let pointsPerSegment = userSettings.psdPointsPerSegment || 512;
// Allow override from UI slider if present
if (document.getElementById('psdSegmentSlider')) {
  pointsPerSegment = parseInt(document.getElementById('psdSegmentSlider').value, 10);
}
```

This aligns with demvlad's comment about needing to find optimal settings and adding a slider interface.

---

`559-577`: **Consider logging errors for important calculation steps.**


Add some basic error logging to help troubleshoot any issues with the PSD calculation:

```diff
// Compute average for scaled power
+ try {
  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 != dataCount - 1) {
        p *= 2;
      }
      psdOutput[i] += p;
    }

    const min_avg = 1e-5; // limit min value for -50db
    let avg = psdOutput[i] / segmentsCount;
    avg = Math.max(avg, min_avg);
    psdOutput[i] = 10 * Math.log10(avg);
  }
+ } catch (e) {
+   console.error('Error computing PSD values:', e);
+   return new Float64Array(dataCount).fill(NaN);
+ }
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 78258021a17bccc137db1d707f4219d76b163faa and b3bcdc367602b961dc6db1c10d492c7a231cbbab.

</details>

<details>
<summary>📒 Files selected for processing (1)</summary>

* `src/graph_spectrum_calc.js` (4 hunks)

</details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-04T11:44:08Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 0**

<details>
<summary>🔭 Outside diff range comments (1)</summary><blockquote>

<details>
<summary>src/graph_spectrum_calc.js (1)</summary><blockquote>

`313-327`: _🛠️ Refactor suggestion_

**Check for null curve before calling lookupRaw.**

You've added a useful `scaled` parameter, but there's a potential null reference issue when accessing `this._dataBuffer.curve` when `scaled` is true.


```diff
 if (scaled) {
+  if (this._dataBuffer.curve) {
     samples[samplesCount] = (this._dataBuffer.curve.lookupRaw(frame[this._dataBuffer.fieldIndex]));
+  } else {
+    samples[samplesCount] = frame[this._dataBuffer.fieldIndex];
+  }
 } else {
   samples[samplesCount] = frame[this._dataBuffer.fieldIndex];
 }
```

</blockquote></details>

</blockquote></details>

<details>
<summary>♻️ Duplicate comments (3)</summary><blockquote>

<details>
<summary>src/graph_spectrum_calc.js (3)</summary><blockquote>

`109-135`: _🛠️ Refactor suggestion_

**Implementation of PSD calculation with dynamic segment sizing.**

The method provides a good implementation of PSD calculation with dynamic segment sizing based on the zoom level. However, there are a few improvements needed:

1. Fix the variable name typo.
2. Add input validation for small datasets.


```diff
 GraphSpectrumCalc.dataLoadPSD = function(analyserZoomY) {
   const flightSamples = this._getFlightSamplesFreq(false);
 
+  // Ensure we have enough samples for PSD calculation
+  if (flightSamples.samples.length < 256) {
+    return {
+      error: 'Not enough data for PSD calculation',
+      fieldName: this._dataBuffer.fieldName
+    };
+  }
+
   let pointsPerSegment = 512;
-  const multipler = Math.floor(1 / analyserZoomY); // 0. ... 10
-  if (multipler == 0) {
+  const multiplier = Math.floor(1 / analyserZoomY); // 0. ... 10
+  if (multiplier == 0) {
     pointsPerSegment = 256;
-  } else if(multipler > 1) {
-    pointsPerSegment *= 2 ** Math.floor(multipler / 2);
+  } else if(multiplier > 1) {
+    pointsPerSegment *= 2 ** Math.floor(multiplier / 2);
   }
```

---

`598-619`: _🛠️ Refactor suggestion_

**Fix segmented FFT calculation issues.**

The segmented FFT calculation has several issues that need to be addressed:

1. The loop condition misses the last valid segment
2. There's a variable naming conflict with nested loops using the same iterator variable `i`
3. The method name doesn't follow JavaScript camelCase conventions


```diff
-GraphSpectrumCalc._fft_segmented  = function(samples, n_per_seg, n_overlap) {
+GraphSpectrumCalc._fftSegmented = function(samples, pointsPerSegment, overlapCount) {
   const samplesCount = samples.length;
   let output = [];
-  for (let i = 0; i < samplesCount - n_per_seg; i += n_per_seg - n_overlap) {
-    const fftInput = samples.slice(i, i + n_per_seg);
+  for (let i = 0; i <= samplesCount - pointsPerSegment; i += pointsPerSegment - overlapCount) {
+    const fftInput = samples.slice(i, i + pointsPerSegment);

     if (userSettings.analyserHanning) {
-      this._hanningWindow(fftInput, n_per_seg);
+      this._hanningWindow(fftInput, pointsPerSegment);
     }

     const fftComplex = this._fft(fftInput);
-  const magnitudes = new Float64Array(n_per_seg / 2);
-  for (let i = 0; i < n_per_seg / 2; i++) {
-    const re = fftComplex[2 * i];
-    const im = fftComplex[2 * i + 1];
-    magnitudes[i] = Math.hypot(re, im);
+  const magnitudes = new Float64Array(pointsPerSegment / 2);
+  for (let j = 0; j < pointsPerSegment / 2; j++) {
+    const re = fftComplex[2 * j];
+    const im = fftComplex[2 * j + 1];
+    magnitudes[j] = Math.hypot(re, im);
   }
     output.push(magnitudes);
   }
```

Also, update the reference in the _psd function:
```diff
-  const fftOutput = this._fft_segmented(samples, pointsPerSegment, overlapCount);
+  const fftOutput = this._fftSegmented(samples, pointsPerSegment, overlapCount);
```

---

`520-592`: _⚠️ Potential issue_

**Fix power doubling logic in PSD calculation.**

The current implementation doubles the power for all bins except the last one, but standard practice for real-input FFT is to double all bins except DC (index 0) and Nyquist (if present, at dataCount/2 for even-length FFTs).


```diff
 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 != dataCount - 1) {
+    // Double all bins except DC and Nyquist (if present)
+    if (i !== 0 && (dataCount % 2 !== 0 || i !== dataCount / 2)) {
       p *= 2;
     }
     psdOutput[i] += p;
   }

+  // Protect against division by zero
+  if (segmentsCount === 0) {
+    psdOutput[i] = -50; // -50dB as a default minimum
+    continue;
+  }
   
   const min_avg = 1e-5; // limit min value for -50db
   let avg = psdOutput[i] / segmentsCount;
```

</blockquote></details>

</blockquote></details>

<details>
<summary>🧹 Nitpick comments (1)</summary><blockquote>

<details>
<summary>src/graph_spectrum_calc.js (1)</summary><blockquote>

`523-562`: **Allow PSD computation settings to be configurable.**

Per the PR objectives, you mention plans to "enable PSD computation settings such as segment length and overlap in the setup". To prepare for this, consider refactoring the code to make the parameters more configurable.


Consider exposing these parameters through `userSettings` rather than hardcoding them:

```diff
 GraphSpectrumCalc._psd = function(samples, pointsPerSegment, overlapCount, scaling = 'density') {
+  // Use user preferences if available
+  scaling = userSettings.psdScaling || scaling;
+  const useHanning = userSettings.analyserHanning;
+  
   // 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) {
+  if (useHanning) {
```

You could also add these settings to the UI in a future PR.

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between b3bcdc367602b961dc6db1c10d492c7a231cbbab and 709cb4c3c8cf19b3b0e7cea19b62e1ddeb2f1f56.

</details>

<details>
<summary>📒 Files selected for processing (2)</summary>

* `src/graph_spectrum_calc.js` (4 hunks)
* `src/graph_spectrum_plot.js` (6 hunks)

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (1)</summary>

* src/graph_spectrum_plot.js

</details>

<details>
<summary>🔇 Additional comments (1)</summary><blockquote>

<details>
<summary>src/graph_spectrum_calc.js (1)</summary>

`556-582`: **Mark maximum motor noise in the PSD output.**

In the PR objectives, you mentioned showing "the maximum amplitude frequency point". Your implementation correctly identifies this point (maxNoiseIdx), but ensure it's properly displayed in the UI.


The logic to identify the frequency with maximum noise above 100Hz is well implemented. This will be useful for motor noise analysis as mentioned in the PR objectives.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-04T12:32:26Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 1**

<details>
<summary>🔭 Outside diff range comments (1)</summary><blockquote>

<details>
<summary>src/graph_spectrum_plot.js (1)</summary><blockquote>

`1440-1444`: _🛠️ Refactor suggestion_

**Update mouse position tracking for PSD graphs.**

The `_drawMousePosition` function needs to be updated to handle the new PSD spectrum type for consistent user experience.


```diff
  // X axis
  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
  ) {
```

</blockquote></details>

</blockquote></details>

<details>
<summary>♻️ Duplicate comments (1)</summary><blockquote>

<details>
<summary>src/graph_spectrum_plot.js (1)</summary><blockquote>

`377-387`: _💡 Verification agent_

<details>
<summary>🧩 Analysis chain</summary>

**Consider adding imported spectrum support for PSD graphs.**

The `_drawFrequencyGraph` method supports displaying imported spectrums for comparison, but the PSD graph does not have this functionality.



Can you verify if there's a need to support imported spectrums in the PSD graph as well? This would allow users to compare PSD curves from different logs.

---

🏁 Script executed:

```shell
#!/bin/bash
# Check if imported spectrum methods are used elsewhere
rg -A 1 "addImportedSpectrumData|getImportedSpectrumCount|clearImportedSpectrums" --type js
```

Length of output: 1124

---



</details>

**Support imported spectrums in PSD graph**

I confirmed that although `GraphSpectrumPlot` exposes  
• getImportedSpectrumCount()  
• addImportedSpectrumData()  
• clearImportedSpectrums()  
and those are exercised in `src/graph_spectrum.js` / `src/main.js`, the PSD‐plotting routine in `src/graph_spectrum_plot.js` never iterates over `this._importedSpectrumsData`. Users cannot overlay imported PSD curves for comparison.

Action items:
- In `src/graph_spectrum_plot.js`, locate the PSD‐render function (around lines 350–400, the block that calls `this._drawInterestFrequency`).
- After computing your PSD data but before finalizing the canvas, loop through `this._importedSpectrumsData` and draw each curve (e.g. via a new helper or by reusing the existing `_drawSpectrumCurve`/`_drawInterestFrequency` pattern).
- Mirror the pattern used in `_drawFrequencyGraph` for imported spectrum support:
  
  ```diff
     // existing PSD draw logic
     this._drawFrequencyData(...);
+    // draw imported PSD curves
+    this._importedSpectrumsData.forEach((imp, idx) => {
+      this._drawSpectrumCurve(
+        canvasCtx,
+        imp.curve,           // imported PSD values
+        imp.name,            // imported trace label
+        WIDTH,
+        HEIGHT,
+        /* offset? styling? e.g. */ MARGIN + idx * 10,
+        /* color/style e.g. */ "rgba(0,0,255,0.5)",
+        2
+      );
+    });
  ```
- Update any unit/UI tests or legends to include imported‐curve labels/colors.

This will enable direct PSD‐curve comparisons across logs.

</blockquote></details>

</blockquote></details>

<details>
<summary>🧹 Nitpick comments (3)</summary><blockquote>

<details>
<summary>src/graph_spectrum_plot.js (3)</summary><blockquote>

`336-336`: **Fix typo in comment.**

Minor typo in the comment.


```diff
- // Allign y axis range by 10db
+ // Align y axis range by 10db
```

---

`342-346`: **Consider performance optimization for point plotting.**

The current implementation plots every point in the PSD data, which could be inefficient for large datasets. Other graph functions in this file (like `_drawFrequencyGraph`) skip points for performance.


```diff
  canvasCtx.moveTo(0, 0);
- for (let pointNum = 0; pointNum < pointsCount; pointNum++) {
+ // Skip points for performance, similar to the frequency graph
+ const skipFactor = Math.max(1, Math.floor(pointsCount / 100)); // Adjust based on your needs
+ for (let pointNum = 0; pointNum < pointsCount; pointNum += skipFactor) {
    const freq = PLOTTED_BLACKBOX_RATE / 2 * pointNum / pointsCount;
    const y = HEIGHT - (this._fftData.psdOutput[pointNum] - minY) * scaleY;
    canvasCtx.lineTo(freq * scaleX, y);
  }
```

---

`1078-1078`: **Use consistent variable naming conventions.**

Parameter name `Ticks` uses PascalCase, which is inconsistent with the JavaScript camelCase convention used elsewhere in the codebase.


```diff
- Ticks = 5,
+ ticks = 5,
```

Remember to update all references to this parameter in the method body as well.

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between dc19b37962a82260f408349a48f9d46b12dfbe92 and 69dd67e4715fbdff871b019b5fd8919e12557794.

</details>

<details>
<summary>📒 Files selected for processing (1)</summary>

* `src/graph_spectrum_plot.js` (7 hunks)

</details>

<details>
<summary>🔇 Additional comments (4)</summary><blockquote>

<details>
<summary>src/graph_spectrum_plot.js (4)</summary>

`20-20`: **LGTM! Added new spectrum type for power spectral density.**

Clean addition of a new spectrum type constant that follows the existing pattern and numbering convention.

---

`176-178`: **LGTM! Added case handler for the new spectrum type.**

The case statement correctly maps the new spectrum type to its respective drawing function with proper formatting and spacing.

---

`946-946`: **LGTM! Updated label for max noise marker.**

Good change to make the label more generic ("Max noise" instead of "Max motor noise"), which better represents that noise could come from various sources, not just motors.

---

`1550-1550`: **LGTM! Added support for PSD margins.**

Correctly added the new spectrum type to the margin calculation to ensure consistent layout with other spectrum types.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-04T12:48:44Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 0**

<details>
<summary>♻️ Duplicate comments (1)</summary><blockquote>

<details>
<summary>src/graph_spectrum_plot.js (1)</summary><blockquote>

`316-345`: _🛠️ Refactor suggestion_

**Add validation for required PSD data properties**

The implementation looks good overall, but you should validate that required properties exist before accessing them to prevent errors.


```diff
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);

+ // Validate required properties
+ if (!this._fftData.psdLength || !this._fftData.psdOutput || 
+     this._fftData.minimum === undefined || this._fftData.maximum === undefined) {
+   console.error('PSD data is missing required properties');
+   canvasCtx.restore();
+   return;
+ }
+
  const pointsCount = this._fftData.psdLength;
  const scaleX = 2 * WIDTH / PLOTTED_BLACKBOX_RATE * this._zoomX;
  canvasCtx.beginPath();
  canvasCtx.lineWidth = 1;
  canvasCtx.strokeStyle = "white";
```

</blockquote></details>

</blockquote></details>

<details>
<summary>🧹 Nitpick comments (2)</summary><blockquote>

<details>
<summary>src/graph_spectrum_plot.js (2)</summary><blockquote>

`1443-1444`: **PSD support for mouse position tracking**

Good update to include the PSD spectrum type in mouse position tracking, enabling frequency display with mouse movement. However, the implementation doesn't show the PSD value (in dB/Hz) at the mouse position.


Consider adding PSD value display at mouse position similar to how other spectrum types display their Y-axis values:

```diff
  // Y axis
  let unitLabel;
  switch (this._spectrumType) {
    case SPECTRUM_TYPE.FREQ_VS_THROTTLE:
      unitLabel = "%";
      break;
    case SPECTRUM_TYPE.FREQ_VS_RPM:
      unitLabel = "Hz";
      break;
+   case SPECTRUM_TYPE.POWER_SPECTRAL_DENSITY:
+     unitLabel = "db/Hz";
+     break;
    default:
      unitLabel = null;
      break;
  }
  if (unitLabel !== null) {
    const val_min = this._spectrumType === SPECTRUM_TYPE.POWER_SPECTRAL_DENSITY 
                  ? this._fftData.minimum 
                  : this._fftData.vsRange.min;
    const val_max = this._spectrumType === SPECTRUM_TYPE.POWER_SPECTRAL_DENSITY 
                  ? this._fftData.maximum 
                  : this._fftData.vsRange.max;
```

---

`316-389`: **Add support for imported spectrums in PSD view**

Unlike the frequency graph, the PSD implementation doesn't support displaying imported spectrums. This would be a valuable addition for comparing multiple PSD datasets.


Consider adding similar code as in `_drawFrequencyGraph` to support imported spectrums with appropriate scaling:

```diff
  canvasCtx.stroke();

+ // Draw imported spectrums if available
+ const curvesColors = [
+   "Blue",
+   "Purple",
+   "DeepPink",
+   "DarkCyan",
+   "Chocolate",
+ ];
+
+ const spectrumCount = this._importedSpectrumsData.length;
+ for (let spectrumNum = 0; spectrumNum < spectrumCount; spectrumNum++) {
+   const curvesPonts = this._importedSpectrumsData[spectrumNum].points;
+   const pointsCount = curvesPonts.length;
+   const scaleX = 2 * WIDTH / PLOTTED_BLACKBOX_RATE * this._zoomX;
+
+   canvasCtx.beginPath();
+   canvasCtx.lineWidth = 1;
+   canvasCtx.strokeStyle = curvesColors[spectrumNum];
+   
+   // Draw PSD curve for imported spectrum
+   // (Implementation would depend on how imported spectrums are formatted)
+   // ...
+   
+   canvasCtx.stroke();
+ }
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 8cb92b2359aab90322f9f4ee720dd72dc15c674e and 81a4abd736d2663af021ddf9b63526eaa149a998.

</details>

<details>
<summary>📒 Files selected for processing (1)</summary>

* `src/graph_spectrum_plot.js` (9 hunks)

</details>

<details>
<summary>🔇 Additional comments (8)</summary><blockquote>

<details>
<summary>src/graph_spectrum_plot.js (8)</summary>

`20-20`: **New spectrum type added: POWER_SPECTRAL_DENSITY**

Great addition. This new spectrum type enables the PSD visualization as described in the PR objectives.

---

`176-178`: **Good switch case implementation for the new spectrum type**

The new case handles the power spectral density visualization by calling the appropriate drawing function.

---

`336-340`: **Smart approach for aligning dB scale**

Good technique for aligning the y-axis to 10dB increments, making the scale more readable and professional.

---

`366-388`: **Good use of grid lines and interest point marker**

The implementation properly draws grid lines with appropriate units and adds a marker for the maximum noise frequency. The change from "Max motor noise" to "Max noise" aligns with the PR comments to avoid misleading users.

---

`1077-1078`: **Good enhancement to vertical grid lines function**

Making the tick count customizable via a parameter with default value is a great improvement for reusability.

---

`1551-1551`: **Updated margin handling for PSD**

Good update to the margin handling to include the PSD spectrum type, maintaining consistency with other spectrum types.

---

`1213-1214`: **Improved background gradient for fullscreen mode**

Making the background colors fully opaque in fullscreen mode improves visibility and contrast.

---

`946-946`: **Label change from "Max motor noise" to "Max noise"**

Good change to make the label more generic and avoid misleading users, as mentioned in the PR comments.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-04T17:26:16Z
user: login: mituritsyn
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-04T19:44:16Z
user: login: demvlad
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-04T20:11:54Z
user: login: mituritsyn
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-04T20:14:54Z
user: login: haslinghuis
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-05T18:32:36Z
user: login: haslinghuis
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-05T18:33:08Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 1**

<details>
<summary>🔭 Outside diff range comments (1)</summary><blockquote>

<details>
<summary>src/graph_spectrum_calc.js (1)</summary><blockquote>

`313-327`: _⚠️ Potential issue_

**Add null check to prevent potential runtime error.**

The code assumes `this._dataBuffer.curve` is always present when `scaled` is true, but this may not be the case. If `curve` is null, calling `lookupRaw` will throw an error.


```diff
  if (scaled) {
+   if (this._dataBuffer.curve) {
      samples[samplesCount] = this._dataBuffer.curve.lookupRaw(frame[this._dataBuffer.fieldIndex]);
+   } else {
+     samples[samplesCount] = frame[this._dataBuffer.fieldIndex];
+   }
  } else {
    samples[samplesCount] = frame[this._dataBuffer.fieldIndex];
  }
```

</blockquote></details>

</blockquote></details>

<details>
<summary>♻️ Duplicate comments (5)</summary><blockquote>

<details>
<summary>src/graph_spectrum_calc.js (5)</summary><blockquote>

`524-526`: _🛠️ Refactor suggestion_

**Use camelCase naming convention for consistency.**

The method name `_fft_segmented` uses snake_case, which is inconsistent with JavaScript conventions and the rest of the codebase that uses camelCase.


```diff
-  const fftOutput = this._fft_segmented(samples, pointsPerSegment, overlapCount);
+  const fftOutput = this._fftSegmented(samples, pointsPerSegment, overlapCount);
```

Additionally, you should rename the method definition at line 601 to follow camelCase convention.

---

`601-622`: _⚠️ Potential issue_

**Fix loop condition and variable naming conflicts in segmented FFT calculation.**

There are several issues in this implementation:

1. The loop condition `i < samplesCount - n_per_seg` misses processing the last valid segment. It should be `i <= samplesCount - n_per_seg`.
2. The iterator variable `i` is reused in the nested loop, which could lead to bugs.
3. The method and parameter names use snake_case instead of camelCase.


```diff
-GraphSpectrumCalc._fft_segmented  = function(samples, n_per_seg, n_overlap) {
+GraphSpectrumCalc._fftSegmented = function(samples, pointsPerSegment, overlapCount) {
  const samplesCount = samples.length;
  let output = [];
-  for (let i = 0; i < samplesCount - n_per_seg; i += n_per_seg - n_overlap) {
-    const fftInput = samples.slice(i, i + n_per_seg);
+  for (let i = 0; i <= samplesCount - pointsPerSegment; i += pointsPerSegment - overlapCount) {
+    const fftInput = samples.slice(i, i + pointsPerSegment);

    if (userSettings.analyserHanning) {
-      this._hanningWindow(fftInput, n_per_seg);
+      this._hanningWindow(fftInput, pointsPerSegment);
    }

    const fftComplex = this._fft(fftInput);
-    const magnitudes = new Float64Array(n_per_seg / 2);
-    for (let i = 0; i < n_per_seg / 2; i++) {
-      const re = fftComplex[2 * i];
-      const im = fftComplex[2 * i + 1];
-      magnitudes[i] = Math.hypot(re, im);
+    const magnitudes = new Float64Array(pointsPerSegment / 2);
+    for (let j = 0; j < pointsPerSegment / 2; j++) {
+      const re = fftComplex[2 * j];
+      const im = fftComplex[2 * j + 1];
+      magnitudes[j] = Math.hypot(re, im);
    }
    output.push(magnitudes);
  }

  return output;
};
```

---

`109-135`: _🛠️ Refactor suggestion_

**Add input validation for insufficient sample data.**

The `dataLoadPSD` method doesn't check if there are enough samples to compute a meaningful PSD. With very small sample sets, this could lead to incorrect results or errors.


```diff
GraphSpectrumCalc.dataLoadPSD = function(analyserZoomY) {
  const flightSamples = this._getFlightSamplesFreq(false);

+ // Ensure we have enough samples for PSD calculation
+ const MIN_SAMPLES_FOR_PSD = 256;
+ if (flightSamples.samples.length < MIN_SAMPLES_FOR_PSD) {
+   return {
+     error: 'Not enough data for PSD calculation',
+     fieldName: this._dataBuffer.fieldName
+   };
+ }

  let pointsPerSegment = 512;
  // ... rest of the method
```

---

`113-117`: _⚠️ Potential issue_

**Fix variable naming and style consistency issues.**

There are several issues with this code segment:

1. Typo in variable name: `multipler` should be `multiplier`
2. Inconsistent spacing in conditional: `else if(multipler > 1)` should have a space after `if`
3. Using the misspelled variable in conditionals


```diff
-  const multipler = Math.floor(1 / analyserZoomY); // 0. ... 10
-  if (multipler == 0) {
+  const multiplier = Math.floor(1 / analyserZoomY); // 0. ... 10
+  if (multiplier == 0) {
     pointsPerSegment = 256;
-  } else if(multipler > 1) {
-    pointsPerSegment *= 2 ** Math.floor(multipler / 2);
+  } else if (multiplier > 1) {
+    pointsPerSegment *= 2 ** Math.floor(multiplier / 2);
   }
```

---

`563-572`: _⚠️ Potential issue_

**Fix power doubling logic for one-sided PSD calculation.**

The current implementation doubles all power values except the last bin, but this is incorrect for a real-valued FFT. In standard PSD calculations using a real FFT, all frequency bins except DC (i=0) and Nyquist (for even-length FFTs) should be doubled.


```diff
  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 != dataCount - 1) {
+     // Double all bins except DC and Nyquist (if present)
+     if (i !== 0 && (dataCount % 2 !== 0 || i !== dataCount / 2)) {
        p *= 2;
      }
      psdOutput[i] += p;
    }
```

</blockquote></details>

</blockquote></details>

<details>
<summary>🧹 Nitpick comments (1)</summary><blockquote>

<details>
<summary>src/graph_spectrum_calc.js (1)</summary><blockquote>

`581-585`: **Adjust minimum frequency threshold for noise detection.**

The current implementation uses a 50Hz threshold for determining maximum noise frequency, which may not be appropriate for all drone sizes. Larger drones can have peak noise at sub-50Hz frequencies.


```diff
-  const noise50HzIdx = 50 / maxFrequency * dataCount;
+  const noiseMinFreqIdx = 20 / maxFrequency * dataCount; // Lower threshold to capture larger drone noise
  const noise3HzIdx = 3 / maxFrequency * dataCount;
  let maxNoiseIdx = 0;
  let maxNoise = -100;
  
  // ...later in the code...
  
-    if (i > noise50HzIdx && psdOutput[i] > maxNoise) {
+    if (i > noiseMinFreqIdx && psdOutput[i] > maxNoise) {
      maxNoise = psdOutput[i];
      maxNoiseIdx = i;
    }
```

This addresses the concern mentioned by mituritsyn in the PR comments about missing low-frequency noise from larger drones.

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 2cde4dd33b2d34d65f302a829fedd0502e7ee06d and 30f775851d2ea042ee0343d1796076cdf2587bb4.

</details>

<details>
<summary>📒 Files selected for processing (1)</summary>

* `src/graph_spectrum_calc.js` (4 hunks)

</details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-05T18:46:11Z
user: login: haslinghuis
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-05T19:04:19Z
user: login: haslinghuis
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-05T19:04:46Z
user: login: demvlad
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T19:11:20Z
user: login: haslinghuis
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-05T19:16:16Z
user: login: haslinghuis
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-05-05T19:17:41Z
user: login: haslinghuis
body: Thank you
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-05T19:20:12Z
user: login: haslinghuis
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-05T19:23:04Z
user: login: coderabbitai[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-05T19:23:31Z

Review comments:
diff_hunk: @@ -293,7 +316,11 @@ 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]));
path: src/graph_spectrum_calc.js
user: login: haslinghuis
body: ```suggestion
        samples[samplesCount] = this._dataBuffer.curve.lookupRaw(frame[this._dataBuffer.fieldIndex]);
```
created_at: 2025-04-30T17:47:10Z
author_association: MEMBER
diff_hunk: @@ -459,6 +459,7 @@ <h4>Workspace</h4>
                                 <option value="1">Freq. vs Throttle</option>
                                 <option value="3">Freq. vs RPM</option>
                                 <option value="2">Error vs Setpoint</option>
+                                <option value="4">PSD vs frequency</option>
path: index.html
user: login: mituritsyn
body: PSD is already in frequency domain. also in other plots it is x vs y, while here frequency is on the x axis. so maybe this way would be better, or "frequency vs noise level"
```suggestion
                                <option value="4">Power spectral density</option>
```
created_at: 2025-04-30T19:55:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -485,3 +512,85 @@ GraphSpectrumCalc._normalizeFft = function(fftOutput, fftLength) {
 
   return fftData;
 };
+
+/**
+ * Compute PSD for data samples by Welch method follow Python code
+ */
+GraphSpectrumCalc._psd  = function(samples, fs, n_per_seg, n_overlap, scaling = 'density') {
+// Compute FFT for samples segments
+  const fftOutput = this._fft_segmented(samples, n_per_seg, n_overlap);
+
+  const dataCount = fftOutput[0].length;
+  const segmentsCount = fftOutput.length;
+  const psdOutput = new Float64Array(dataCount);
+
+// Compute power scale coef
+  let scale = 1;
+  if (scaling == 'density') {
+    if (userSettings.analyserHanning) {
+      const window = Array(n_per_seg).fill(1);
+      this._hanningWindow(window, n_per_seg);
+      let skSum = 0;
+      for (const value of window) {
+        skSum += value ** 2;
+      }
+        scale = 1 / (fs * skSum);
+    } else {
+      scale = 1 / n_per_seg;
+    }
+  } else if (scaling == 'spectrum') {
+    if (userSettings.analyserHanning) {
+      const window = Array(n_per_seg).fill(1);
+      this._hanningWindow(window, n_per_seg);
+      let sum = 0;
+      for (const value of window) {
+        sum += value;
+      }
+      scale = 1 / sum ** 2;
+    } else {
+      scale = 1 / n_per_seg ** 2;
+    }
+  }
+
+// Compute average for scaled power
+  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 (dataCount % 2) {
+        p *= 2;
+      } else if (i != dataCount - 1) {
+        p *= 2;
+      }
path: src/graph_spectrum_calc.js
user: login: mituritsyn
body: should we perform same action for both if cases?
created_at: 2025-04-30T20:36:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -293,7 +316,11 @@ 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];
+      }
path: src/graph_spectrum_calc.js
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Avoid `lookupRaw` when no curve is present**

When `scaled === false`, the code falls back to raw frame values (good).  
However, when `scaled === true` it unconditionally calls `this._dataBuffer.curve.lookupRaw(...)`. If `curve` is `null` (e.g., for already-scaled fields), this will throw.

```diff
-if (scaled) {
-  samples[samplesCount] = (this._dataBuffer.curve.lookupRaw(frame[this._dataBuffer.fieldIndex]));
+if (scaled && this._dataBuffer.curve) {
+  samples[samplesCount] = this._dataBuffer.curve.lookupRaw(
+    frame[this._dataBuffer.fieldIndex]
+  );
 } else {
   samples[samplesCount] = frame[this._dataBuffer.fieldIndex];
 }
```

Preventing the null-dereference keeps existing FFT paths stable.  



<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
      if (scaled && this._dataBuffer.curve) {
        samples[samplesCount] = this._dataBuffer.curve.lookupRaw(
          frame[this._dataBuffer.fieldIndex]
        );
      } else {
        samples[samplesCount] = frame[this._dataBuffer.fieldIndex];
      }
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-30T21:18:02Z
author_association: NONE
diff_hunk: @@ -106,6 +106,29 @@ GraphSpectrumCalc.dataLoadFrequency = function() {
   return fftData;
 };
 
+GraphSpectrumCalc.dataLoadFrequencyPSD = function() {
+  const points_per_segment = 512,
+        overlap_count = 256;
+  const flightSamples = this._getFlightSamplesFreq(false);
+  const psd =  this._psd(flightSamples.samples, this._blackBoxRate, points_per_segment, overlap_count);
+  let min = 1e6,
+      max = -1e6;
+  for (const value of psd) {
+      min = Math.min(value, min);
+      max = Math.max(value, max);
+  }
+
+  const psdData = {
+    fieldIndex   : this._dataBuffer.fieldIndex,
+    fieldName  : this._dataBuffer.fieldName,
+    psdLength  : psd.length,
+    psdOutput  : psd,
+    blackBoxRate : this._blackBoxRate,
+    minimum: min,
+    maximum: max,
+  };
+  return psdData;
+};
 
path: src/graph_spectrum_calc.js
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Add safeguards & make Welch parameters configurable**

`dataLoadFrequencyPSD` uses fixed values (`points_per_segment = 512`, `overlap_count = 256`) and proceeds even when the captured sample-count is smaller than `points_per_segment`.  
If `samples.length < 512`, `_fft_segmented` returns an empty array, which later causes `segmentsCount === 0` and a *division-by-zero* in `_psd` (lines 556-572).  

Consider:

```diff
-const points_per_segment = 512,
-      overlap_count = 256;
+// Pick reasonable defaults, but clamp them to the available data
+const MIN_SEGMENT = 64;          // avoid pathological tiny segments
+let points_per_segment = 512;
+let overlap_count      = 256;
+
+// Shrink segment size when we have fewer samples than the default
+if (flightSamples.samples.length < points_per_segment) {
+  points_per_segment = Math.max(
+    MIN_SEGMENT,
+    1 << Math.floor(Math.log2(flightSamples.samples.length))
+  );
+  overlap_count = Math.floor(points_per_segment / 2);
+}
+
+// Bail out early when we still do not have enough data
+if (flightSamples.samples.length < points_per_segment) {
+  return {
+    error: 'Not enough data for PSD calculation',
+    fieldName: this._dataBuffer.fieldName
+  };
+}
```

Exposing `points_per_segment` & `overlap_count` through the UI (or at least constants in `userSettings`) will also address the feedback about “too small number of points per segment / too large overlap”.  



> Committable suggestion skipped: line range outside the PR's diff.

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-30T21:18:02Z
author_association: NONE
diff_hunk: @@ -485,3 +512,85 @@ GraphSpectrumCalc._normalizeFft = function(fftOutput, fftLength) {
 
   return fftData;
 };
+
+/**
+ * Compute PSD for data samples by Welch method follow Python code
+ */
+GraphSpectrumCalc._psd  = function(samples, fs, n_per_seg, n_overlap, scaling = 'density') {
+// Compute FFT for samples segments
+  const fftOutput = this._fft_segmented(samples, n_per_seg, n_overlap);
+
+  const dataCount = fftOutput[0].length;
+  const segmentsCount = fftOutput.length;
+  const psdOutput = new Float64Array(dataCount);
+
+// Compute power scale coef
+  let scale = 1;
+  if (scaling == 'density') {
+    if (userSettings.analyserHanning) {
+      const window = Array(n_per_seg).fill(1);
+      this._hanningWindow(window, n_per_seg);
+      let skSum = 0;
+      for (const value of window) {
+        skSum += value ** 2;
+      }
+        scale = 1 / (fs * skSum);
+    } else {
+      scale = 1 / n_per_seg;
+    }
+  } else if (scaling == 'spectrum') {
+    if (userSettings.analyserHanning) {
+      const window = Array(n_per_seg).fill(1);
+      this._hanningWindow(window, n_per_seg);
+      let sum = 0;
+      for (const value of window) {
+        sum += value;
+      }
+      scale = 1 / sum ** 2;
+    } else {
+      scale = 1 / n_per_seg ** 2;
+    }
+  }
+
+// Compute average for scaled power
+  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 (dataCount % 2) {
+        p *= 2;
+      } else if (i != dataCount - 1) {
+        p *= 2;
+      }
+      psdOutput[i] += p;
+    }
+
+    const min_avg = 1e-5; // limit min value for -50db
+    let avg = psdOutput[i] / segmentsCount;
+    avg = Math.max(avg, min_avg);
+    psdOutput[i] = 10 * Math.log10(avg);
+  }
path: src/graph_spectrum_calc.js
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**Incorrect power doubling logic & potential divide-by-zero**

1. `segmentsCount` may be `0` (see previous comment) – dividing by it (line 569) will yield `Infinity / NaN`.  
   Guard against empty `fftOutput`.

2. Doubling of the one-sided spectrum should **exclude** the DC component (`i === 0`) and, for even `n_per_seg`, the Nyquist bin (`i === dataCount/2`).  
   Current checks (`dataCount % 2 …` / `i != dataCount - 1`) are wrong for a real-input FFT.

```diff
-  for (let i = 0; i < dataCount; i++) {
+  if (segmentsCount === 0) return psdOutput.fill(NaN);     // early-exit guard
+
+  const nyquistIdx = (dataCount % 2 === 0) ? dataCount / 2 : null;
+  for (let i = 0; i < dataCount; i++) {
     ...
-      if (dataCount % 2) {
-        p *= 2;
-      } else if (i != dataCount - 1) {
-        p *= 2;
-      }
+      // Double all bins except DC and Nyquist (if present)
+      if (i !== 0 && i !== nyquistIdx) {
+        p *= 2;
+      }
     ...
   }
```

This follows the same rule used by SciPy’s `signal.welch`.  



<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
  // early-exit guard against divide-by-zero
  if (segmentsCount === 0) return psdOutput.fill(NaN);

  const nyquistIdx = (dataCount % 2 === 0) ? dataCount / 2 : null;
  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;
      // Double all bins except DC and Nyquist (if present)
      if (i !== 0 && i !== nyquistIdx) {
        p *= 2;
      }
      psdOutput[i] += p;
    }

    const min_avg = 1e-5; // limit min value for -50 dB
    let avg = psdOutput[i] / segmentsCount;
    avg = Math.max(avg, min_avg);
    psdOutput[i] = 10 * Math.log10(avg);
  }
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-30T21:18:02Z
author_association: NONE
diff_hunk: @@ -485,3 +512,85 @@ GraphSpectrumCalc._normalizeFft = function(fftOutput, fftLength) {
 
   return fftData;
 };
+
+/**
+ * Compute PSD for data samples by Welch method follow Python code
+ */
+GraphSpectrumCalc._psd  = function(samples, fs, n_per_seg, n_overlap, scaling = 'density') {
+// Compute FFT for samples segments
+  const fftOutput = this._fft_segmented(samples, n_per_seg, n_overlap);
+
+  const dataCount = fftOutput[0].length;
+  const segmentsCount = fftOutput.length;
+  const psdOutput = new Float64Array(dataCount);
+
+// Compute power scale coef
+  let scale = 1;
+  if (scaling == 'density') {
+    if (userSettings.analyserHanning) {
+      const window = Array(n_per_seg).fill(1);
+      this._hanningWindow(window, n_per_seg);
+      let skSum = 0;
+      for (const value of window) {
+        skSum += value ** 2;
+      }
+        scale = 1 / (fs * skSum);
+    } else {
+      scale = 1 / n_per_seg;
+    }
+  } else if (scaling == 'spectrum') {
+    if (userSettings.analyserHanning) {
+      const window = Array(n_per_seg).fill(1);
+      this._hanningWindow(window, n_per_seg);
+      let sum = 0;
+      for (const value of window) {
+        sum += value;
+      }
+      scale = 1 / sum ** 2;
+    } else {
+      scale = 1 / n_per_seg ** 2;
+    }
+  }
+
+// Compute average for scaled power
+  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 (dataCount % 2) {
+        p *= 2;
+      } else if (i != dataCount - 1) {
+        p *= 2;
+      }
+      psdOutput[i] += p;
+    }
+
+    const min_avg = 1e-5; // limit min value for -50db
+    let avg = psdOutput[i] / segmentsCount;
+    avg = Math.max(avg, min_avg);
+    psdOutput[i] = 10 * Math.log10(avg);
+  }
+
+  return psdOutput;
+};
+
+
+/**
+ * Compute FFT for samples segments by lenghts as n_per_seg with n_overlap overlap points count
+ */
+GraphSpectrumCalc._fft_segmented  = function(samples, n_per_seg, n_overlap) {
+  const samplesCount = samples.length;
+  let output = [];
+  for (let i = 0; i < samplesCount - n_per_seg; i += n_per_seg - n_overlap) {
+    const fftInput = samples.slice(i, i + n_per_seg);
+
+    if (userSettings.analyserHanning) {
+      this._hanningWindow(fftInput, n_per_seg);
+    }
+
+    const fftOutput = this._fft(fftInput);
+    output.push(fftOutput.slice(0, n_per_seg));
+  }
+
+  return output;
+};
path: src/graph_spectrum_calc.js
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**FFT segment processing ignores the imaginary part & misses the last valid segment**

Two issues degrade PSD accuracy:

1. **Magnitude computation** – `slice(0, n_per_seg)` keeps only the *real* part of the interleaved `[re, im, re, im,…]` array. Use the complex magnitude instead.

2. **Off-by-one** – the loop condition `i < samplesCount - n_per_seg` skips the segment that starts exactly at `samplesCount - n_per_seg`.

```diff
-for (let i = 0; i < samplesCount - n_per_seg; i += n_per_seg - n_overlap) {
+for (let i = 0; i <= samplesCount - n_per_seg; i += n_per_seg - n_overlap) {
     ...
-    const fftOutput = this._fft(fftInput);
-    output.push(fftOutput.slice(0, n_per_seg));
+    const fftComplex = this._fft(fftInput);  // [re0, im0, re1, im1, ...]
+    const mag = new Float64Array(n_per_seg);
+    for (let k = 0; k < n_per_seg; k++) {
+      const re = fftComplex[2 * k];
+      const im = fftComplex[2 * k + 1];
+      mag[k] = Math.hypot(re, im);           // √(re² + im²)
+    }
+    output.push(mag);
 }
```

Accurate magnitudes are essential; otherwise PSD levels can be off by ~3 dB on average.  



<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
GraphSpectrumCalc._fft_segmented  = function(samples, n_per_seg, n_overlap) {
  const samplesCount = samples.length;
  let output = [];
  for (let i = 0; i <= samplesCount - n_per_seg; i += n_per_seg - n_overlap) {
    const fftInput = samples.slice(i, i + n_per_seg);

    if (userSettings.analyserHanning) {
      this._hanningWindow(fftInput, n_per_seg);
    }

    const fftComplex = this._fft(fftInput);  // [re0, im0, re1, im1, ...]
    const mag = new Float64Array(n_per_seg);
    for (let k = 0; k < n_per_seg; k++) {
      const re = fftComplex[2 * k];
      const im = fftComplex[2 * k + 1];
      mag[k] = Math.hypot(re, im);           // √(re² + im²)
    }
    output.push(mag);
  }

  return output;
};
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commit 721965d
created_at: 2025-04-30T21:18:02Z
author_association: NONE
diff_hunk: @@ -304,7 +309,58 @@ GraphSpectrumPlot._drawFrequencyGraph = function (canvasCtx) {
     WIDTH,
     HEIGHT,
     MARGIN,
-    "Hz"
+    "Hz",
+  );
+};
+
+GraphSpectrumPlot._drawFrequencyPSDGraph = function (canvasCtx) {
+  const HEIGHT = canvasCtx.canvas.height - MARGIN;
+  const WIDTH = canvasCtx.canvas.width;
+  const LEFT = canvasCtx.canvas.left;
+  const TOP = canvasCtx.canvas.top;
+
+  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 = "orange";
+
+  canvasCtx.moveTo(0, 0);
+  const a1 = Math.abs(this._fftData.minimum),
+        a2 = Math.abs(this._fftData.maximum),
+        limit = Math.max(a1, a2);
+  const scaleY = HEIGHT / 2 / limit;
+  for (let pointNum = 0; pointNum < pointsCount; pointNum += 2) {
+    const freq = PLOTTED_BLACKBOX_RATE / 2 * pointNum / pointsCount;
+    const y = HEIGHT / 2 - this._fftData.psdOutput[pointNum] * scaleY;
+    canvasCtx.lineTo(freq * scaleX, y);
+  }
+  canvasCtx.stroke();
+
+  canvasCtx.restore();
+
+  this._drawAxisLabel(
+    canvasCtx,
+    this._fftData.fieldName,
+    WIDTH - 4,
+    HEIGHT - 6,
+    "right",
+  );
+  this._drawHorizontalGridLines(
+    canvasCtx,
+    PLOTTED_BLACKBOX_RATE / 2,
+    LEFT,
+    TOP,
+    WIDTH,
+    HEIGHT,
+    MARGIN,
+    "Hz",
   );
path: src/graph_spectrum_plot.js
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**PSD graph drawing implementation needs validation and improved mouse interaction.**

The basic implementation looks good, but there are a few improvements that could be made:

1. Missing validation for `this._fftData.psdLength`, `this._fftData.psdOutput`, `this._fftData.minimum`, and `this._fftData.maximum`
2. Using `pointNum += 2` without a comment explaining why only every other point is plotted
3. No support for showing imported spectrums (unlike the frequency graph)
4. No support for mouse position tracking in `_drawMousePosition` function for this spectrum type


I recommend adding data validation to prevent errors when properties are missing:

```diff
GraphSpectrumPlot._drawFrequencyPSDGraph = function (canvasCtx) {
  const HEIGHT = canvasCtx.canvas.height - MARGIN;
  const WIDTH = canvasCtx.canvas.width;
  const LEFT = canvasCtx.canvas.left;
  const TOP = canvasCtx.canvas.top;

  const PLOTTED_BLACKBOX_RATE = this._fftData.blackBoxRate / this._zoomX;

  canvasCtx.save();
  canvasCtx.translate(LEFT, TOP);
  this._drawGradientBackground(canvasCtx, WIDTH, HEIGHT);

+ // Validate required properties
+ if (!this._fftData.psdLength || !this._fftData.psdOutput || 
+     this._fftData.minimum === undefined || this._fftData.maximum === undefined) {
+   console.error('PSD data is missing required properties');
+   canvasCtx.restore();
+   return;
+ }
+
  const pointsCount = this._fftData.psdLength;
  const scaleX = 2 * WIDTH / PLOTTED_BLACKBOX_RATE * this._zoomX;
  canvasCtx.beginPath();
  canvasCtx.lineWidth = 1;
  canvasCtx.strokeStyle = "orange";

  canvasCtx.moveTo(0, 0);
  const a1 = Math.abs(this._fftData.minimum),
        a2 = Math.abs(this._fftData.maximum),
        limit = Math.max(a1, a2);
  const scaleY = HEIGHT / 2 / limit;
+ // Draw PSD curve - only plot every other point for performance optimization
  for (let pointNum = 0; pointNum < pointsCount; pointNum += 2) {
    const freq = PLOTTED_BLACKBOX_RATE / 2 * pointNum / pointsCount;
    const y = HEIGHT / 2 - this._fftData.psdOutput[pointNum] * scaleY;
    canvasCtx.lineTo(freq * scaleX, y);
  }
  canvasCtx.stroke();
```

Also, mouse position tracking should be updated in the `_drawMousePosition` function (around line 1404):

```diff
  // X axis
  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.PSD_VS_FREQUENCY
  ) {
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
GraphSpectrumPlot._drawFrequencyPSDGraph = function (canvasCtx) {
  const HEIGHT = canvasCtx.canvas.height - MARGIN;
  const WIDTH = canvasCtx.canvas.width;
  const LEFT = canvasCtx.canvas.left;
  const TOP = canvasCtx.canvas.top;

  const PLOTTED_BLACKBOX_RATE = this._fftData.blackBoxRate / this._zoomX;

  canvasCtx.save();
  canvasCtx.translate(LEFT, TOP);
  this._drawGradientBackground(canvasCtx, WIDTH, HEIGHT);

  // Validate required properties
  if (!this._fftData.psdLength ||
      !this._fftData.psdOutput ||
      this._fftData.minimum === undefined ||
      this._fftData.maximum === undefined) {
    console.error('PSD data is missing required properties');
    canvasCtx.restore();
    return;
  }

  const pointsCount = this._fftData.psdLength;
  const scaleX = 2 * WIDTH / PLOTTED_BLACKBOX_RATE * this._zoomX;
  canvasCtx.beginPath();
  canvasCtx.lineWidth = 1;
  canvasCtx.strokeStyle = "orange";

  canvasCtx.moveTo(0, 0);
  const a1 = Math.abs(this._fftData.minimum),
        a2 = Math.abs(this._fftData.maximum),
        limit = Math.max(a1, a2);
  const scaleY = HEIGHT / 2 / limit;
  // Draw PSD curve - only plot every other point for performance optimization
  for (let pointNum = 0; pointNum < pointsCount; pointNum += 2) {
    const freq = PLOTTED_BLACKBOX_RATE / 2 * pointNum / pointsCount;
    const y = HEIGHT / 2 - this._fftData.psdOutput[pointNum] * scaleY;
    canvasCtx.lineTo(freq * scaleX, y);
  }
  canvasCtx.stroke();

  canvasCtx.restore();

  this._drawAxisLabel(
    canvasCtx,
    this._fftData.fieldName,
    WIDTH - 4,
    HEIGHT - 6,
    "right",
  );
  this._drawHorizontalGridLines(
    canvasCtx,
    PLOTTED_BLACKBOX_RATE / 2,
    LEFT,
    TOP,
    WIDTH,
    HEIGHT,
    MARGIN,
    "Hz",
  );
};
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-30T21:18:03Z
author_association: NONE
diff_hunk: @@ -485,3 +512,85 @@ GraphSpectrumCalc._normalizeFft = function(fftOutput, fftLength) {
 
   return fftData;
 };
+
+/**
+ * Compute PSD for data samples by Welch method follow Python code
+ */
+GraphSpectrumCalc._psd  = function(samples, fs, n_per_seg, n_overlap, scaling = 'density') {
+// Compute FFT for samples segments
+  const fftOutput = this._fft_segmented(samples, n_per_seg, n_overlap);
+
+  const dataCount = fftOutput[0].length;
+  const segmentsCount = fftOutput.length;
+  const psdOutput = new Float64Array(dataCount);
+
+// Compute power scale coef
+  let scale = 1;
+  if (scaling == 'density') {
+    if (userSettings.analyserHanning) {
+      const window = Array(n_per_seg).fill(1);
+      this._hanningWindow(window, n_per_seg);
+      let skSum = 0;
+      for (const value of window) {
+        skSum += value ** 2;
+      }
+        scale = 1 / (fs * skSum);
+    } else {
+      scale = 1 / n_per_seg;
+    }
+  } else if (scaling == 'spectrum') {
+    if (userSettings.analyserHanning) {
+      const window = Array(n_per_seg).fill(1);
+      this._hanningWindow(window, n_per_seg);
+      let sum = 0;
+      for (const value of window) {
+        sum += value;
+      }
+      scale = 1 / sum ** 2;
+    } else {
+      scale = 1 / n_per_seg ** 2;
+    }
+  }
+
+// Compute average for scaled power
+  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 (dataCount % 2) {
+        p *= 2;
+      } else if (i != dataCount - 1) {
+        p *= 2;
+      }
path: src/graph_spectrum_calc.js
user: login: demvlad
body: The first condition is removed. The fft output has even size only for our fft transform procedure
created_at: 2025-05-01T08:02:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -106,6 +106,29 @@ GraphSpectrumCalc.dataLoadFrequency = function() {
   return fftData;
 };
 
+GraphSpectrumCalc.dataLoadPSD = function() {
+  const points_per_segment = 512,
+        overlap_count = 256;
path: src/graph_spectrum_calc.js
user: login: mituritsyn
body: ```suggestion
  const pointsPerSegment = 512,
        overlapCount = 256;
```
use camelCase
created_at: 2025-05-03T19:33:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -106,6 +106,38 @@ GraphSpectrumCalc.dataLoadFrequency = function() {
   return fftData;
 };
 
+GraphSpectrumCalc.dataLoadPSD = function(analyserZoomY) {
+  const flightSamples = this._getFlightSamplesFreq(false);
+
+  let pointsPerSegment = 512;
+  const multipler = Math.floor(1 / analyserZoomY); // 0. ... 10
+  if (multipler == 0) {
+    pointsPerSegment = 256;
+  } else if(multipler > 1) {
+    pointsPerSegment *= 2 ** Math.floor(multipler / 2);
+  }
+  pointsPerSegment = Math.min(pointsPerSegment, flightSamples.samples.length);
+  const overlapCount = pointsPerSegment / 2;
+
+  const psd =  this._psd(flightSamples.samples, this._blackBoxRate, pointsPerSegment, overlapCount);
+  let min = 1e6,
+      max = -1e6;
+  for (const value of psd) {
+      min = Math.min(value, min);
+      max = Math.max(value, max);
+  }
+
+  const psdData = {
+    fieldIndex   : this._dataBuffer.fieldIndex,
+    fieldName  : this._dataBuffer.fieldName,
+    psdLength  : psd.length,
+    psdOutput  : psd,
+    blackBoxRate : this._blackBoxRate,
+    minimum: min,
+    maximum: max,
+  };
+  return psdData;
+};
path: src/graph_spectrum_calc.js
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Implementation of PSD calculation using the Welch method.**

The PSD calculation is a valuable addition for spectral analysis. However, I have some concerns about its robustness:


1. Add input validation for sample size to prevent errors with small datasets:

```diff
GraphSpectrumCalc.dataLoadPSD = function(analyserZoomY) {
  const flightSamples = this._getFlightSamplesFreq(false);

+ // Ensure we have enough samples for PSD calculation
+ if (flightSamples.samples.length < 256) {
+   return {
+     error: 'Not enough data for PSD calculation',
+     fieldName: this._dataBuffer.fieldName
+   };
+ }

  let pointsPerSegment = 512;
```

2. Fix the typo in variable name and use consistent camelCase for JavaScript:

```diff
- const multipler = Math.floor(1 / analyserZoomY); // 0. ... 10
+ const multiplier = Math.floor(1 / analyserZoomY); // 0. ... 10
- if (multipler == 0) {
+ if (multiplier == 0) {
    pointsPerSegment = 256;
- } else if(multipler > 1) {
+ } else if(multiplier > 1) {
-   pointsPerSegment *= 2 ** Math.floor(multipler / 2);
+   pointsPerSegment *= 2 ** Math.floor(multiplier / 2);
  }
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
GraphSpectrumCalc.dataLoadPSD = function(analyserZoomY) {
  const flightSamples = this._getFlightSamplesFreq(false);

  // Ensure we have enough samples for PSD calculation
  if (flightSamples.samples.length < 256) {
    return {
      error: 'Not enough data for PSD calculation',
      fieldName: this._dataBuffer.fieldName
    };
  }

  let pointsPerSegment = 512;
  const multiplier = Math.floor(1 / analyserZoomY); // 0. ... 10
  if (multiplier == 0) {
    pointsPerSegment = 256;
  } else if (multiplier > 1) {
    pointsPerSegment *= 2 ** Math.floor(multiplier / 2);
  }
  pointsPerSegment = Math.min(pointsPerSegment, flightSamples.samples.length);
  const overlapCount = pointsPerSegment / 2;

  const psd = this._psd(
    flightSamples.samples,
    this._blackBoxRate,
    pointsPerSegment,
    overlapCount
  );

  let min = 1e6,
      max = -1e6;
  for (const value of psd) {
    min = Math.min(value, min);
    max = Math.max(value, max);
  }

  const psdData = {
    fieldIndex:    this._dataBuffer.fieldIndex,
    fieldName:     this._dataBuffer.fieldName,
    psdLength:     psd.length,
    psdOutput:     psd,
    blackBoxRate:  this._blackBoxRate,
    minimum:       min,
    maximum:       max,
  };
  return psdData;
};
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-04T11:44:07Z
author_association: NONE
diff_hunk: @@ -485,3 +521,85 @@
 
   return fftData;
 };
+
+/**
+ * Compute PSD for data samples by Welch method follow Python code
+ */
+GraphSpectrumCalc._psd  = function(samples, samplesRate, 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 / (samplesRate * 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
+  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 != dataCount - 1) {
+        p *= 2;
+      }
+      psdOutput[i] += p;
+    }
+
+    const min_avg = 1e-5; // limit min value for -50db
+    let avg = psdOutput[i] / segmentsCount;
+    avg = Math.max(avg, min_avg);
+    psdOutput[i] = 10 * Math.log10(avg);
+  }
+
+  return psdOutput;
+};
+
+
+/**
+ * Compute FFT for samples segments by lenghts as n_per_seg with n_overlap overlap points count
+ */
+GraphSpectrumCalc._fft_segmented  = function(samples, n_per_seg, n_overlap) {
+  const samplesCount = samples.length;
+  let output = [];
+  for (let i = 0; i < samplesCount - n_per_seg; i += n_per_seg - n_overlap) {
+    const fftInput = samples.slice(i, i + n_per_seg);
+
+    if (userSettings.analyserHanning) {
+      this._hanningWindow(fftInput, n_per_seg);
+    }
+
+    const fftComplex = this._fft(fftInput);
+  const magnitudes = new Float64Array(n_per_seg / 2);
+  for (let i = 0; i < n_per_seg / 2; i++) {
+    const re = fftComplex[2 * i];
+    const im = fftComplex[2 * i + 1];
+    magnitudes[i] = Math.hypot(re, im);
+  }
+    output.push(magnitudes);
+  }
+
+  return output;
+};
path: src/graph_spectrum_calc.js
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**Segmented FFT calculation has logical and style issues.**


1. The loop condition skips the last valid segment:

```diff
- for (let i = 0; i < samplesCount - n_per_seg; i += n_per_seg - n_overlap) {
+ for (let i = 0; i <= samplesCount - n_per_seg; i += n_per_seg - n_overlap) {
```

2. There's a variable naming conflict with two nested loops using the same iterator variable:

```diff
  const fftComplex = this._fft(fftInput);
  const magnitudes = new Float64Array(n_per_seg / 2);
- for (let i = 0; i < n_per_seg / 2; i++) {
+ for (let j = 0; j < n_per_seg / 2; j++) {
-   const re = fftComplex[2 * i];
-   const im = fftComplex[2 * i + 1];
+   const re = fftComplex[2 * j];
+   const im = fftComplex[2 * j + 1];
-   magnitudes[i] = Math.hypot(re, im);
+   magnitudes[j] = Math.hypot(re, im);
  }
```

3. Use consistent camelCase naming for JavaScript:

```diff
- GraphSpectrumCalc._fft_segmented  = function(samples, n_per_seg, n_overlap) {
+ GraphSpectrumCalc._fftSegmented  = function(samples, pointsPerSegment, overlapCount) {
```

And update references elsewhere:
```diff
- const fftOutput = this._fft_segmented(samples, pointsPerSegment, overlapCount);
+ const fftOutput = this._fftSegmented(samples, pointsPerSegment, overlapCount);
```


> Committable suggestion skipped: line range outside the PR's diff.

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-04T11:44:07Z
author_association: NONE
diff_hunk: @@ -304,10 +309,86 @@ GraphSpectrumPlot._drawFrequencyGraph = function (canvasCtx) {
     WIDTH,
     HEIGHT,
     MARGIN,
-    "Hz"
+    "Hz",
   );
 };
 
+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;
+  const maxY = (Math.floor(this._fftData.maximum / dbStep) + 1) * dbStep;
+  const ticksCount = (maxY - minY) / dbStep;
+  const scaleY = HEIGHT / (maxY - minY);
+  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,
+    "db/Hz",
+    ticksCount,
+  );
+  this._drawInterestFrequency(
+      canvasCtx,
+      this._fftData.maxNoiseIdx,
+      PLOTTED_BLACKBOX_RATE,
+      "Max noise",
+      WIDTH,
+      HEIGHT,
+      15 * 5 + MARGIN,
+      "rgba(255,0,0,0.50)",
+      3,
+    );
+
+  canvasCtx.restore();
+};
path: src/graph_spectrum_plot.js
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Add validation for PSD data properties.**

The implementation for drawing the power spectral density graph looks good, but it's missing validation for required properties.


Similar to other graph drawing methods, you should validate that the required PSD data properties exist before accessing them:

```diff
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);

+ // Validate required properties
+ if (!this._fftData.psdLength || !this._fftData.psdOutput || 
+     this._fftData.minimum === undefined || this._fftData.maximum === undefined) {
+   console.error('PSD data is missing required properties');
+   canvasCtx.restore();
+   return;
+ }
+
  const pointsCount = this._fftData.psdLength;
  const scaleX = 2 * WIDTH / PLOTTED_BLACKBOX_RATE * this._zoomX;
  canvasCtx.beginPath();
  canvasCtx.lineWidth = 1;
  canvasCtx.strokeStyle = "white";
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
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);

  // Validate required properties
  if (!this._fftData.psdLength || !this._fftData.psdOutput ||
      this._fftData.minimum === undefined || this._fftData.maximum === undefined) {
    console.error('PSD data is missing required properties');
    canvasCtx.restore();
    return;
  }

  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;
  const maxY = (Math.floor(this._fftData.maximum / dbStep) + 1) * dbStep;
  const ticksCount = (maxY - minY) / dbStep;
  const scaleY = HEIGHT / (maxY - minY);
  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,
    "db/Hz",
    ticksCount,
  );
  this._drawInterestFrequency(
    canvasCtx,
    this._fftData.maxNoiseIdx,
    PLOTTED_BLACKBOX_RATE,
    "Max noise",
    WIDTH,
    HEIGHT,
    15 * 5 + MARGIN,
    "rgba(255,0,0,0.50)",
    3,
  );

  canvasCtx.restore();
};
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-04T12:48:43Z
author_association: NONE
diff_hunk: @@ -556,9 +556,10 @@ GraphSpectrumCalc._psd  = function(samples, pointsPerSegment, overlapCount, scal
   let min = 1e6,
       max = -1e6;
   const maxFrequency = (this._blackBoxRate / 2.0);
-  const noiseLowEndIdx = 100 / maxFrequency * dataCount;
+  const noise100HzIdx = 100 / maxFrequency * dataCount;
path: src/graph_spectrum_calc.js
user: login: mituritsyn
body: ~What if, instead of hardcoding a limit, we search for the first local minimum and assign it as the starting point?~
This will not guarantee a correct extremum search either
created_at: 2025-05-04T19:44:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -556,9 +556,10 @@ GraphSpectrumCalc._psd  = function(samples, pointsPerSegment, overlapCount, scal
   let min = 1e6,
       max = -1e6;
   const maxFrequency = (this._blackBoxRate / 2.0);
-  const noiseLowEndIdx = 100 / maxFrequency * dataCount;
+  const noise100HzIdx = 100 / maxFrequency * dataCount;
path: src/graph_spectrum_calc.js
user: login: demvlad
body: it should be a reliable solution for the all data with any points per segment settings.
By other hand, the 100Hz is good limit for auto find max noise. We can scale freq axis by slider and reseach  <100Hz range manualy when it needs.
created_at: 2025-05-04T20:11:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -556,9 +556,10 @@ GraphSpectrumCalc._psd  = function(samples, pointsPerSegment, overlapCount, scal
   let min = 1e6,
       max = -1e6;
   const maxFrequency = (this._blackBoxRate / 2.0);
-  const noiseLowEndIdx = 100 / maxFrequency * dataCount;
+  const noise100HzIdx = 100 / maxFrequency * dataCount;
path: src/graph_spectrum_calc.js
user: login: mituritsyn
body: 7" and larger drones can easily have peak at sub 100Hz frequencies. Also, filtered data would have this issue too. Can you use at least 50Hz limit?
created_at: 2025-05-04T20:14:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -993,30 +1080,30 @@ GraphSpectrumPlot._drawVerticalGridLines = function (
   HEIGHT,
   minValue,
   maxValue,
-  label
+  label,
+  Ticks = 5,
path: src/graph_spectrum_plot.js
user: login: haslinghuis
body: ```suggestion
  ticks = 5,
```
created_at: 2025-05-05T18:17:46Z
author_association: MEMBER
diff_hunk: @@ -106,6 +106,33 @@ GraphSpectrumCalc.dataLoadFrequency = function() {
   return fftData;
 };
 
+GraphSpectrumCalc.dataLoadPSD = function(analyserZoomY) {
+  const flightSamples = this._getFlightSamplesFreq(false);
+
+  let pointsPerSegment = 512;
+  const multipler = Math.floor(1 / analyserZoomY); // 0. ... 10
+  if (multipler == 0) {
+    pointsPerSegment = 256;
+  } else if(multipler > 1) {
+    pointsPerSegment *= 2 ** Math.floor(multipler / 2);
path: src/graph_spectrum_calc.js
user: login: haslinghuis
body: ```suggestion
  const multiplier = Math.floor(1 / analyserZoomY); // 0. ... 10
  if (multipiler == 0) {
    pointsPerSegment = 256;
  } else if (multiplier > 1) {
    pointsPerSegment *= 2 ** Math.floor(multiplier / 2);
```
created_at: 2025-05-05T18:31:27Z
author_association: MEMBER
diff_hunk: @@ -993,30 +1080,30 @@ GraphSpectrumPlot._drawVerticalGridLines = function (
   HEIGHT,
   minValue,
   maxValue,
-  label
+  label,
+  Ticks = 5,
path: src/graph_spectrum_plot.js
user: login: haslinghuis
body: Did not touch other places where ticks is used.
created_at: 2025-05-05T18:33:08Z
author_association: MEMBER
diff_hunk: @@ -485,3 +516,107 @@ GraphSpectrumCalc._normalizeFft = function(fftOutput, fftLength) {
 
   return fftData;
 };
+
+/**
+ * Compute PSD for data samples by Welch method follow Python code
+ */
+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;
+  const maxFrequency = (this._blackBoxRate / 2.0);
+  const noise50HzIdx = 50 / maxFrequency * dataCount;
+  const noise3HzIdx = 3 / maxFrequency * dataCount;
+  let maxNoiseIdx = 0;
+  let maxNoise = -100;
path: src/graph_spectrum_calc.js
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Add validation to prevent potential issues with empty FFT output.**

There's no verification that `fftOutput` is non-empty before accessing `fftOutput[0].length` on line 527. If `_fft_segmented` returns an empty array, this could cause runtime errors.


```diff
  let min = 1e6,
      max = -1e6;
+ 
+ // Early exit if no segments were processed
+ if (segmentsCount === 0) {
+   return {
+     psdOutput: new Float64Array(0),
+     min: 0,
+     max: 0,
+     maxNoiseIdx: 0
+   };
+ }
+
  const maxFrequency = (this._blackBoxRate / 2.0);
  const noise50HzIdx = 50 / maxFrequency * dataCount;
  const noise3HzIdx = 3 / maxFrequency * dataCount;
  let maxNoiseIdx = 0;
  let maxNoise = -100;
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
  let min = 1e6,
      max = -1e6;

  // Early exit if no segments were processed
  if (segmentsCount === 0) {
    return {
      psdOutput: new Float64Array(0),
      min: 0,
      max: 0,
      maxNoiseIdx: 0
    };
  }

  const maxFrequency = (this._blackBoxRate / 2.0);
  const noise50HzIdx = 50 / maxFrequency * dataCount;
  const noise3HzIdx = 3 / maxFrequency * dataCount;
  let maxNoiseIdx = 0;
  let maxNoise = -100;
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commit cab6757
created_at: 2025-05-05T18:46:09Z
author_association: NONE
diff_hunk: @@ -862,7 +949,7 @@ GraphSpectrumPlot._drawFiltersAndMarkers = function (canvasCtx) {
       canvasCtx,
       this._fftData.maxNoiseIdx,
       PLOTTED_BLACKBOX_RATE,
-      "Max motor noise",
+      "Max noise",
path: src/graph_spectrum_plot.js
user: login: haslinghuis
body: This is not motor noise ?
created_at: 2025-05-05T19:02:35Z
author_association: MEMBER
diff_hunk: @@ -485,3 +516,107 @@ GraphSpectrumCalc._normalizeFft = function(fftOutput, fftLength) {
 
   return fftData;
 };
+
+/**
+ * Compute PSD for data samples by Welch method follow Python code
+ */
+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;
path: src/graph_spectrum_calc.js
user: login: haslinghuis
body: ```suggestion
      max = -1e6;
      // Early exit if no segments were processed
  if (segmentsCount === 0) {
    return {
      psdOutput: new Float64Array(0),
      min: 0,
      max: 0,
      maxNoiseIdx: 0
    };
  }
```
created_at: 2025-05-05T19:04:11Z
author_association: MEMBER
diff_hunk: @@ -485,3 +516,107 @@ GraphSpectrumCalc._normalizeFft = function(fftOutput, fftLength) {
 
   return fftData;
 };
+
+/**
+ * Compute PSD for data samples by Welch method follow Python code
+ */
+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;
path: src/graph_spectrum_calc.js
user: login: haslinghuis
body: This was suggested by the bot :)
created_at: 2025-05-05T19:04:46Z
author_association: MEMBER
diff_hunk: @@ -862,7 +949,7 @@ GraphSpectrumPlot._drawFiltersAndMarkers = function (canvasCtx) {
       canvasCtx,
       this._fftData.maxNoiseIdx,
       PLOTTED_BLACKBOX_RATE,
-      "Max motor noise",
+      "Max noise",
path: src/graph_spectrum_plot.js
user: login: demvlad
body: The motor in the most cases, but maybe something other...
created_at: 2025-05-05T19:11:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -304,10 +309,92 @@ GraphSpectrumPlot._drawFrequencyGraph = function (canvasCtx) {
     WIDTH,
     HEIGHT,
     MARGIN,
-    "Hz"
+    "Hz",
   );
 };
 
+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;
+  const maxY = (Math.floor(this._fftData.maximum / dbStep) + 1) * dbStep;
+  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.maxNoiseIdx,
+      PLOTTED_BLACKBOX_RATE,
+      "Max noise",
+      WIDTH,
+      HEIGHT,
+      15 * offset + MARGIN,
+      "rgba(255,0,0,0.50)",
+      3,
+    );
path: src/graph_spectrum_plot.js
user: login: haslinghuis
body: Updated suggestion
```suggestion
  this._drawInterestFrequency(
    canvasCtx,
    this._fftData.maxNoiseIdx,
    PLOTTED_BLACKBOX_RATE,
    "Max noise",
    WIDTH,
    HEIGHT,
    15 * offset + MARGIN,
    "rgba(255,0,0,0.50)",
    3,
  );
```
created_at: 2025-05-05T19:16:15Z
author_association: MEMBER
diff_hunk: @@ -485,3 +516,107 @@ GraphSpectrumCalc._normalizeFft = function(fftOutput, fftLength) {
 
   return fftData;
 };
+
+/**
+ * Compute PSD for data samples by Welch method follow Python code
+ */
+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;
path: src/graph_spectrum_calc.js
user: login: haslinghuis
body: @coderabbitai you forgot the trailing comma in your suggestion
created_at: 2025-05-05T19:23:04Z
author_association: MEMBER
diff_hunk: @@ -485,3 +516,107 @@ GraphSpectrumCalc._normalizeFft = function(fftOutput, fftLength) {
 
   return fftData;
 };
+
+/**
+ * Compute PSD for data samples by Welch method follow Python code
+ */
+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;
path: src/graph_spectrum_calc.js
user: login: coderabbitai[bot]
body: @haslinghuis You're absolutely right! I did miss the trailing comma after `maxNoiseIdx: 0`. Thank you for catching that. Here's the corrected suggestion:

```javascript
      max = -1e6;
      // Early exit if no segments were processed
  if (segmentsCount === 0) {
    return {
      psdOutput: new Float64Array(0),
      min: 0,
      max: 0,
      maxNoiseIdx: 0,
    };
  }
```



(\_/)
(•ᴥ•)

---

<details>
<summary>✏️ Learnings added</summary>

```
Learnt from: haslinghuis
PR: betaflight/blackbox-log-viewer#820
File: src/graph_spectrum_calc.js:557-557
Timestamp: 2025-05-05T19:23:29.945Z
Learning: In JavaScript object literals, include trailing commas even on the last property for better maintainability and cleaner diffs when new properties are added later.
```

> Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure [review instructions](https://docs.coderabbit.ai/guides/review-instructions). You can manage existing learnings in the [app](https://app.coderabbit.ai/learnings).

</details>




<!-- <agentComponentType>chat-answer</agentComponentType> -->

<!-- <rawResChunk><answer>@haslinghuis You're absolutely right! I did miss the trailing comma after `maxNoiseIdx: 0`. Thank you for catching that. Here's the corrected suggestion:

```javascript
      max = -1e6;
      // Early exit if no segments were processed
  if (segmentsCount === 0) {
    return {
      psdOutput: new Float64Array(0),
      min: 0,
      max: 0,
      maxNoiseIdx: 0,
    };
  }
```



(\_/)
(•ᴥ•)

---

<details>
<summary>✏️ Learnings added</summary>

```
Learnt from: haslinghuis
PR: betaflight/blackbox-log-viewer#820
File: src/graph_spectrum_calc.js:557-557
Timestamp: 2025-05-05T19:23:29.945Z
Learning: In JavaScript object literals, include trailing commas even on the last property for better maintainability and cleaner diffs when new properties are added later.
```

> Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure [review instructions](https://docs.coderabbit.ai/guides/review-instructions). You can manage existing learnings in the [app](https://app.coderabbit.ai/learnings).

</details>

</answer></rawResChunk> -->

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-05T19:23:31Z
author_association: NONE

PR comments:
user: login: nerdCopter
created_at: 2025-04-30T15:35:58Z
body: the smoothing seems to be rather different in the BBE vs the PTB graphs.
author_association: MEMBER
user: login: demvlad
created_at: 2025-04-30T15:52:40Z
body: > the smoothing seems to be rather different in the BBE vs the PTB graphs.

Yes. I am using too small point per segment and big overlap values probably. It needs to find some optimal settings.
But it depends from samples count, it needs some variable settings or something like slider interface.
author_association: CONTRIBUTOR
user: login: demvlad
created_at: 2025-04-30T16:21:12Z
body: @mituritsyn 
I'm interested in your opinion about this improvement.
author_association: CONTRIBUTOR
user: login: coderabbitai[bot]
created_at: 2025-04-30T21:15:06Z
body: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- This is an auto-generated comment: failure by coderabbit.ai -->

> [!CAUTION]
> ## Review failed
> 
> The pull request is closed.

<!-- end of auto-generated comment: failure by coderabbit.ai -->
<!-- walkthrough_start -->

"""
## Walkthrough

A new option labeled "Power spectral density" was added to the spectrum type dropdown in the user interface. The data loading logic was extended to support this new spectrum type by invoking a new method that computes the Power Spectral Density (PSD) using Welch's method. Helper functions were added for segmenting FFT calculations and computing PSD with appropriate scaling. The plotting module was updated to recognize the PSD spectrum type and render the corresponding graph with frequency and power density axes, grid lines, labels, and a marker for maximum noise frequency. Minor formatting improvements were made in the plotting code. No existing features were removed.

## Changes

| File(s)                    | Change Summary                                                                                                                                                  |
|----------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------|
| index.html                 | Added "Power spectral density" (value 4) option to the spectrum type dropdown in the Workspace section.                                                        |
| src/graph_spectrum.js      | Added a new case for `SPECTRUM_TYPE.POWER_SPECTRAL_DENSITY` in `dataLoad` to call `GraphSpectrumCalc.dataLoadPSD()` with zoom parameter; updated zoom event handler to reload PSD data.                       |
| src/graph_spectrum_calc.js | Added `dataLoadPSD()` method implementing PSD calculation using Welch's method; added helper methods `_psd()` and `_fft_segmented()`; modified `_getFlightSamplesFreq()` to accept a scaling flag. |
| src/graph_spectrum_plot.js | Added `POWER_SPECTRAL_DENSITY = 4` to spectrum type enum; extended `_drawGraph()` to handle PSD type; added `_drawPowerSpectralDensityGraph()` for rendering PSD graphs; updated margin calculation and vertical grid lines method; changed interest frequency label from "Max motor noise" to "Max noise"; minor formatting fixes. |

## Sequence Diagram(s)

```mermaid
sequenceDiagram
    participant User
    participant UI
    participant GraphSpectrum
    participant GraphSpectrumCalc
    participant GraphSpectrumPlot

    User->>UI: Selects "Power spectral density" in dropdown
    UI->>GraphSpectrum: Calls dataLoad with POWER_SPECTRAL_DENSITY
    GraphSpectrum->>GraphSpectrumCalc: Calls dataLoadPSD(zoom)
    GraphSpectrumCalc->>GraphSpectrumCalc: Computes PSD using Welch's method
    GraphSpectrumCalc-->>GraphSpectrum: Returns PSD data
    GraphSpectrum->>GraphSpectrumPlot: Calls _drawPowerSpectralDensityGraph()
    GraphSpectrumPlot->>GraphSpectrumPlot: Renders PSD graph on canvas
```

## Poem

> In the meadow of code where the data flows free,  
> A new spectrum blooms—PSD, you see!  
> With Welch’s wise method and FFT’s might,  
> We plot power and frequency, orange and bright.  
> Now rabbits and users can clearly discern,  
> The song of the signal at every turn.  
> 🐇✨
"""

<!-- walkthrough_end -->
<!-- internal state start -->


<!-- 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 -->

<!-- internal state end -->

---

<details>
<summary>📜 Recent review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 2eef77ef2b0194005c1da84b59e72ed109e67169 and 011b977e9e4e2e73cabd930be098e38482ea669a.

</details>

<details>
<summary>📒 Files selected for processing (2)</summary>

* `src/graph_spectrum_calc.js` (4 hunks)
* `src/graph_spectrum_plot.js` (10 hunks)

</details>

</details>
<!-- finishing_touch_checkbox_start -->

<details open="true">
<summary>✨ Finishing Touches</summary>

- [ ] <!-- {"checkboxId": "7962f53c-55bc-4827-bfbf-6a18da830691"} --> 📝 Generate Docstrings

</details>

<!-- finishing_touch_checkbox_end -->
<!-- tips_start -->

---



<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=betaflight/blackbox-log-viewer&utm_content=820):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Generate unit testing code for this file.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai generate unit testing code for this file.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and generate unit testing code.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

### Support

Need help? Create a ticket on our [support page](https://www.coderabbit.ai/contact-us/support) for assistance with any issues or questions.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai generate sequence diagram` to generate a sequence diagram of the changes in this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
author_association: NONE
user: login: demvlad
created_at: 2025-04-30T21:51:33Z
body: A small question about FFT algorithm.
Could someone says, what structure has FFT function output array.?
IIRC, the FFT can return complex values array. But i see, that first half output arrays part is used as magnitude value in spectrum. What is the second arrays part in this case? Is it the phase value? Or the first half array is real values and second part is Im value? Or the output is the sequence of re,im pair valurs. Are we use the fft output correctly? But probably, the spectrum could not work properly with wrong values the all time.
Also i see that original python code uses real fft - rfft method call, what returns half data size output. I get full size output from our fft method. My PSD chart looks similar python, but i want to study how it works. I am not big expert in FFT.
author_association: CONTRIBUTOR
user: login: demvlad
created_at: 2025-05-01T08:47:05Z
body: > Could someone says, what structure has FFT function output array.? 

The clever coderabbitai doubt too, it suggests:
const re = fftComplex[2 * k];
const im = fftComplex[2 * k + 1];
mag[k] = Math.hypot(re, im);           // √(re² + im²)

But i confuse a small bit, how spectrum worked before?...
This method uses the first half of output only as magnitude:
GraphSpectrumCalc._normalizeFft = function(fftOutput, fftLength) {
  for (let i = 0; i < fftLength; i++) {
    fftOutput[i] = Math.abs(fftOutput[i]);
    if (i > noiseLowEndIdx && fftOutput[i] > maxNoise) {
      maxNoise = fftOutput[i];
      maxNoiseIdx = i;
    }
  }
The spectrum plot uses this values directly as magnitude too.
Will check next...

author_association: CONTRIBUTOR
user: login: demvlad
created_at: 2025-05-01T09:40:37Z
body: I've tryed follow AI suggestion and get two side symetrical spectrum! 
![two_side](https://github.com/user-attachments/assets/7e1328d1-e6c0-4b24-9507-e81ce319563e)

Seems i understand what is our FFT result now!
If i will use half FFT output as (re, im) values, i will get like Python code result for real oneside rfft call.
But seems we have some issue in our standard spectrum anything else...
author_association: CONTRIBUTOR
user: login: demvlad
created_at: 2025-05-01T10:12:32Z
body: IMO it should work like last commit:
![PSD_Final](https://github.com/user-attachments/assets/88f196ea-c6ad-4959-a706-ea71114839d2)


author_association: CONTRIBUTOR
user: login: demvlad
created_at: 2025-05-01T10:50:51Z
body: The current BBE spectrum shows the mix of Re and Im values by freq axis at the spectrum chart. But this is not the spectrum magnitude. It find max noise level inside these Re, Im values. It is not just properly. 
I am going to create other PR to improve this issue.
author_association: CONTRIBUTOR
user: login: demvlad
created_at: 2025-05-03T10:19:58Z
body: ![psd_grid](https://github.com/user-attachments/assets/63273a61-e246-4eec-a5bb-142f6f623880)

author_association: CONTRIBUTOR
user: login: demvlad
created_at: 2025-05-03T19:02:24Z
body: The Y-axis range is alligned by value 10db:
![PSD_ROLLunf](https://github.com/user-attachments/assets/b0111d96-e7a2-4744-8114-37d51b0ace04)
![PSD_ROLLfilt](https://github.com/user-attachments/assets/9cab3fd0-c784-424b-89d9-bb9a366c7a5c)

author_association: CONTRIBUTOR
user: login: demvlad
created_at: 2025-05-03T20:27:46Z
body: The vertical slider (specrum scale Y) changes parameter Number samples per segment in PSD computing.
Notice: The slider has action after curves spectrum redraw only. It can not work immediately after slider move.
The examples with different slider position:
![PSD_nperseg0](https://github.com/user-attachments/assets/7f339a95-7512-42b4-9057-7122cda90743)
![PSD_nperseg2](https://github.com/user-attachments/assets/4b4f245d-3d91-4857-aa5d-66c0e917ccd1)
![PSD_nperseg3](https://github.com/user-attachments/assets/bc43a6a8-57a6-40c3-8162-2ac97d7588c2)

author_association: CONTRIBUTOR
user: login: demvlad
created_at: 2025-05-03T20:32:08Z
body: Perhaps that is all for this PR.
Almost:).  
- to show maximum amplitude frequency point
- to restore Mouse freq cursor by Shift key
author_association: CONTRIBUTOR
user: login: mituritsyn
created_at: 2025-05-04T09:56:24Z
body: @blckmn preview deploy failed is it an issue?
author_association: CONTRIBUTOR
user: login: demvlad
created_at: 2025-05-04T12:33:58Z
body: Added max noise frequency label:
![PSD_noise_freq](https://github.com/user-attachments/assets/e9d42460-c8e1-4303-a4ea-db034211d86f)

author_association: CONTRIBUTOR
user: login: mituritsyn
created_at: 2025-05-04T12:36:07Z
body: > Added max noise frequency label

What if the maximum noise source is not from the motor? This label could mislead users. 
author_association: CONTRIBUTOR
user: login: demvlad
created_at: 2025-05-04T12:41:59Z
body: > What if the maximum noise source is not from the motor? This label could mislead users

Yes, the simple "Max noise ___ Hz" label will be better.
Will change at this and "Frequency" chart.
![PSD_noise_freq](https://github.com/user-attachments/assets/f31b9d9f-046a-42ff-a654-11c3932ffa47)
![PSD_noise_freq](https://github.com/user-attachments/assets/018a459a-deef-419d-b921-5c0164ff5867)


author_association: CONTRIBUTOR
user: login: demvlad
created_at: 2025-05-04T13:00:43Z
body: Added mouse position marker:
![PSD_Mouse](https://github.com/user-attachments/assets/7de0501f-0780-4e9a-9c13-816ce056d529)

author_association: CONTRIBUTOR
user: login: demvlad
created_at: 2025-05-04T13:04:41Z
body: That is all.
author_association: CONTRIBUTOR
user: login: mituritsyn
created_at: 2025-05-04T14:24:54Z
body: > Added mouse position marker

would be great if it could show 2 pos coordinates like as for other plots but with mouse you choose the frequency and it will automatically stick to the corresponding value in PSD
author_association: CONTRIBUTOR
user: login: mituritsyn
created_at: 2025-05-04T15:00:04Z
body: top left corner looks buggy. also it shows max noise at 0Hz
![image](https://github.com/user-attachments/assets/803e9e72-0898-41c1-a96f-e41d28cec298)

author_association: CONTRIBUTOR
user: login: mituritsyn
created_at: 2025-05-04T15:02:37Z
body: Could you make the PSD plot less transparent?
![image](https://github.com/user-attachments/assets/6171f8ad-1657-4d13-91ef-8960ba0bd53f)

author_association: CONTRIBUTOR
user: login: demvlad
created_at: 2025-05-04T15:43:16Z
body: > would be great if it could show 2 pos coordinates like as for other plots but with mouse you choose the frequency and it will automatically stick to the corresponding value in PSD

Ok. Will try to do it. I've used default "Frequency" charts behavior.


author_association: CONTRIBUTOR
user: login: demvlad
created_at: 2025-05-04T15:46:46Z
body: > top left corner looks buggy. also it shows max noise at 0Hz.

Yes, i do not like this too. The zero freq magnitude is max in the spectrum. The easy solution is to remove first point from chart.


author_association: CONTRIBUTOR
user: login: demvlad
created_at: 2025-05-04T15:48:28Z
body: > Could you make the PSD plot less transparent?

Ok. Will try.


author_association: CONTRIBUTOR
user: login: demvlad
created_at: 2025-05-04T17:26:58Z
body: ![PSD](https://github.com/user-attachments/assets/33137410-759c-45e8-ae98-098f5cc4e427)

The current min freq limit for max noise definition is 100Hz. Threfore chart shows 113Hz
![PSD2](https://github.com/user-attachments/assets/e0266d87-2fa8-48d0-a156-6ecc231c4ccc)


author_association: CONTRIBUTOR
user: login: demvlad
created_at: 2025-05-05T14:28:12Z
body: - The PSD chart updates immediately after Y-scale sliders action.
- Added horizontal and vertical mouse positions marker line at PSD chart.
- Maximal noise definition low frequency limit is changed from 100Hz to 50Hz
- Resolved issue of maximal noise marker position

Will good to have mouse position marker like one vertical line with both current frequency and PSD values label. Will try to do this feature in the next PR.

Default vertical slider position:
![PSD_new](https://github.com/user-attachments/assets/ca9f7d72-fcd6-48be-9071-409784a578c6)
The maximal vertical slider position:
![PSD_new2](https://github.com/user-attachments/assets/206bb3d5-182b-469c-85f0-290f7dccf225)

author_association: CONTRIBUTOR
user: login: mituritsyn
created_at: 2025-05-05T18:11:58Z
body: Looks great for me, but a core team review is needed.
author_association: CONTRIBUTOR
user: login: sonarqubecloud[bot]
created_at: 2025-05-05T19:19:40Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=betaflight_blackbox-log-viewer&pullRequest=820) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [2 New issues](https://sonarcloud.io/project/issues?id=betaflight_blackbox-log-viewer&pullRequest=820&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=betaflight_blackbox-log-viewer&pullRequest=820&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=betaflight_blackbox-log-viewer&pullRequest=820&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=betaflight_blackbox-log-viewer&pullRequest=820&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=betaflight_blackbox-log-viewer&pullRequest=820&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=betaflight_blackbox-log-viewer&pullRequest=820)


author_association: NONE
user: login: demvlad
created_at: 2025-05-05T19:51:37Z
body: @haslinghuis 
Sorry, It was some error in the last code.
I've pushed fix commits in this branch.
author_association: CONTRIBUTOR
user: login: haslinghuis
created_at: 2025-05-05T19:57:24Z
body: @demvlad you don't have access to revert button ?

![image](https://github.com/user-attachments/assets/e9083de2-6593-4de5-9e17-e0c269752378)

author_association: MEMBER
user: login: haslinghuis
created_at: 2025-05-05T20:00:55Z
body: @nerdCopter please revert - so @demvlad can start over ?
author_association: MEMBER
user: login: demvlad
created_at: 2025-05-05T20:02:12Z
body: > @demvlad you don't have access to revert button ?
> 
I have access. It creates new PR. Can i do this?

author_association: CONTRIBUTOR
user: login: haslinghuis
created_at: 2025-05-05T20:04:36Z
body: Yes - simple revert. And start over.

Or better fix current code in new PR.
author_association: MEMBER
user: login: demvlad
created_at: 2025-05-05T22:09:50Z
body: > Yes - simple revert. And start over.
> 
> Or better fix current code in new PR.


@haslinghuis @nerdCopter 
The rebase to master is too hardly for new PR, it needs to apply much commits.
I think will better to revert this PR
I have last version with all commits.
I hope, it will not need rebase after revert prev commits
author_association: CONTRIBUTOR
user: login: haslinghuis
created_at: 2025-05-05T22:14:54Z
body: @demvlad reverted.
author_association: MEMBER