Skip to content

Conversation

@demvlad
Copy link
Contributor

@demvlad demvlad commented Apr 30, 2025

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 follow python SciPy library and Python Spectral Analysis tutorial.
The Blackbox explorer PSD chart for this PR (in db/Hz unit):
PSD_BBExplorer
The PID tool box PSD chart:
PSD_PTB
The Python PSD chart:
PSD_Python

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

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.

@demvlad demvlad marked this pull request as draft April 30, 2025 14:33
@demvlad demvlad force-pushed the psd_spectrum branch 2 times, most recently from 0cce938 to ee88e41 Compare April 30, 2025 15:32
@nerdCopter
Copy link
Member

the smoothing seems to be rather different in the BBE vs the PTB graphs.

@demvlad
Copy link
Contributor Author

demvlad commented Apr 30, 2025

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.

@demvlad
Copy link
Contributor Author

demvlad commented Apr 30, 2025

@mituritsyn
I'm interested in your opinion about this improvement.

@demvlad demvlad marked this pull request as ready for review April 30, 2025 17:11
Comment on lines 560 to 564
if (dataCount % 2) {
p *= 2;
} else if (i != dataCount - 1) {
p *= 2;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we perform same action for both if cases?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The first condition is removed. The fft output has even size only for our fft transform procedure

@coderabbitai
Copy link

coderabbitai bot commented Apr 30, 2025

Caution

Review failed

The pull request is closed.

"""

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)

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
Loading

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.
🐇✨
"""


📜 Recent review details

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

📥 Commits

Reviewing files that changed from the base of the PR and between 2eef77e and 011b977.

📒 Files selected for processing (2)
  • src/graph_spectrum_calc.js (4 hunks)
  • src/graph_spectrum_plot.js (10 hunks)
✨ Finishing Touches
  • 📝 Generate Docstrings

🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • 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 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 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 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 for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

♻️ Duplicate comments (1)
index.html (1)

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.

🧹 Nitpick comments (1)
src/graph_spectrum_calc.js (1)

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:

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

📜 Review details

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

📥 Commits

Reviewing files that changed from the base of the PR and between 3639e69 and 4bfe62f.

📒 Files selected for processing (4)
  • index.html (1 hunks)
  • src/graph_spectrum.js (1 hunks)
  • src/graph_spectrum_calc.js (4 hunks)
  • src/graph_spectrum_plot.js (4 hunks)
🧰 Additional context used
🧬 Code Graph Analysis (1)
src/graph_spectrum.js (2)
src/graph_spectrum_plot.js (2)
  • SPECTRUM_TYPE (15-21)
  • SPECTRUM_TYPE (15-21)
src/graph_spectrum_calc.js (6)
  • fftData (104-104)
  • fftData (203-211)
  • fftData (222-222)
  • fftData (504-511)
  • GraphSpectrumCalc (24-38)
  • GraphSpectrumCalc (24-38)
🔇 Additional comments (5)
index.html (1)

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.

src/graph_spectrum.js (1)

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.

src/graph_spectrum_plot.js (3)

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

Comment on lines 319 to 323
if (scaled) {
samples[samplesCount] = (this._dataBuffer.curve.lookupRaw(frame[this._dataBuffer.fieldIndex]));
} else {
samples[samplesCount] = frame[this._dataBuffer.fieldIndex];
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ 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.

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

📝 Committable suggestion

‼️ 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.

Suggested change
if (scaled) {
samples[samplesCount] = (this._dataBuffer.curve.lookupRaw(frame[this._dataBuffer.fieldIndex]));
} else {
samples[samplesCount] = 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];
}

Comment on lines 109 to 132
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;
};

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ 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:

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

Comment on lines 556 to 572
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);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ 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.

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

📝 Committable suggestion

‼️ 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.

Suggested change
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);
}
// 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);
}

Comment on lines 316 to 364
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",
);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ 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:

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):

  // 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
  ) {
📝 Committable suggestion

‼️ 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.

Suggested change
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",
);
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",
);
};

@demvlad
Copy link
Contributor Author

demvlad commented Apr 30, 2025

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.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🔭 Outside diff range comments (1)
src/graph_spectrum_calc.js (1)

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:

 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];
 }
♻️ Duplicate comments (3)
src/graph_spectrum_calc.js (3)

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:

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

// 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:

-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);
}
🧹 Nitpick comments (1)
src/graph_spectrum_calc.js (1)

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.

/**
- * 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
 */
📜 Review details

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

📥 Commits

Reviewing files that changed from the base of the PR and between 4bfe62f and 9f229c2.

📒 Files selected for processing (1)
  • src/graph_spectrum_calc.js (4 hunks)

@demvlad
Copy link
Contributor Author

demvlad commented May 1, 2025

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

@demvlad
Copy link
Contributor Author

demvlad commented May 1, 2025

I've tryed follow AI suggestion and get two side symetrical spectrum!
two_side

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

@demvlad
Copy link
Contributor Author

demvlad commented May 1, 2025

IMO it should work like last commit:
PSD_Final

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🔭 Outside diff range comments (1)
src/graph_spectrum_calc.js (1)

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.

-      if (scaled) {
+      if (scaled && this._dataBuffer.curve) {
         samples[samplesCount] = (this._dataBuffer.curve.lookupRaw(frame[this._dataBuffer.fieldIndex]));
       } else {
         samples[samplesCount] = frame[this._dataBuffer.fieldIndex];
       }
♻️ Duplicate comments (3)
src/graph_spectrum_calc.js (3)

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.

-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.
 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.
 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;
     }
🧹 Nitpick comments (1)
src/graph_spectrum_calc.js (1)

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.

 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);
📜 Review details

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

📥 Commits

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

📒 Files selected for processing (1)
  • src/graph_spectrum_calc.js (4 hunks)
🔇 Additional comments (1)
src/graph_spectrum_calc.js (1)

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.

haslinghuis
haslinghuis previously approved these changes May 5, 2025
Co-authored-by: Mark Haslinghuis <[email protected]>
@sonarqubecloud
Copy link

sonarqubecloud bot commented May 5, 2025

Copy link
Member

@haslinghuis haslinghuis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you

@haslinghuis haslinghuis merged commit a263152 into betaflight:master May 5, 2025
2 of 4 checks passed
@demvlad demvlad deleted the psd_spectrum branch May 5, 2025 19:22
@demvlad demvlad restored the psd_spectrum branch May 5, 2025 19:41
@demvlad
Copy link
Contributor Author

demvlad commented May 5, 2025

@haslinghuis
Sorry, It was some error in the last code.
I've pushed fix commits in this branch.

demvlad added a commit to demvlad/blackbox-log-viewer that referenced this pull request May 5, 2025
@haslinghuis
Copy link
Member

@demvlad you don't have access to revert button ?

image

@haslinghuis
Copy link
Member

@nerdCopter please revert - so @demvlad can start over ?

@demvlad
Copy link
Contributor Author

demvlad commented May 5, 2025

@demvlad you don't have access to revert button ?

I have access. It creates new PR. Can i do this?

@haslinghuis
Copy link
Member

Yes - simple revert. And start over.

Or better fix current code in new PR.

@demvlad
Copy link
Contributor Author

demvlad commented May 5, 2025

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

haslinghuis pushed a commit that referenced this pull request May 5, 2025
)

Revert "Added power spectral density curves at the spectrum chart (#820)"

This reverts commit a263152.
@haslinghuis
Copy link
Member

@demvlad reverted.

haslinghuis added a commit that referenced this pull request May 10, 2025
… of #820 PR) (#826)

* Added computing of PSD - Power Spectral Density for spectrum chart

* Added PSD curves at spectrum chart

* Removed redundant check TODO code

* Code issues are resolved

* PSD caption is edited in index.html

Co-authored-by: MikeNomatter <[email protected]>

* Removed non execute condition. The FFT output is allways even value

* Powers scale computing code refactoring

* Draw the all points of PSD data

* Resolved issue of magnitude computing

* Added vertical grid lines

* Added grid with db value captions

* The PSD Y axises range is alligned by value 10db/Hz

* Added setup points per segment by using vertical slider (Y-scale slider)

* maximal num per segment count value is limited by data samples count

* The names variable are changed to JS code style

* Added the maximal noise frequency label

* The 'Max motor noise' label is renamed to 'Max noise'

* Added mouse marker at the PSD chart

* Zeroes frequency magnitudes removed from curves min-max range

* Decreased spectrums charts plot transparent

* Code style improvement

* Max noise definition low frequency limit is changed from 100Hz to 50Hz for PSD chart

* Added horizont mouse position marker line at PSD chart

* The max noise label is shifted down at PSD chart

* The PSD chart updates immediately after Y scale sliders change

* PSD unit label is changed at dBm/Hz

* The minimum PSD value is set as -70dBm

* Code style improvement

Co-authored-by: Mark Haslinghuis <[email protected]>

* Code style improvement

Co-authored-by: Mark Haslinghuis <[email protected]>

* Code style improvement

* Added checking for missing samples segment data

Co-authored-by: Mark Haslinghuis <[email protected]>

* Code style improvement

Co-authored-by: Mark Haslinghuis <[email protected]>

* Resolved wrong variable name

* Resolved missing coma

* Resolved "for" loop condition issue, when num per segment value is equal samples count

* Code style improvement

---------

Co-authored-by: MikeNomatter <[email protected]>
Co-authored-by: Mark Haslinghuis <[email protected]>
haslinghuis pushed a commit that referenced this pull request May 13, 2025
…mpletion of #820 PR)" (#830)

Revert "Added Power Spectral Density curves at the spectrum chart (Completion…"

This reverts commit cd62b52.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants