Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 13 additions & 9 deletions Libraries/LogHelpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,18 +63,22 @@ function get_version_and_board(log) {
// Check we have bracketed the messages we need
continue
}
let types = []
for (const type of Object.keys(build_types)) {
types.push("(?:" + type + ")")
}
const regex = new RegExp("(" + types.join("|") + ").+\\((.+)\\)", 'g')
const found = regex.exec(MSG.Message[i])
if (found == null) {
continue
}
if (fw_string == null) {
let types = []
for (const type of Object.keys(build_types)) {
types.push("(?:" + type + ")")
}
const regex = new RegExp("(" + types.join("|") + ").+\\((.+)\\)", 'g')
const found = regex.exec(MSG.Message[i])
if (found == null) {
continue
}
fw_string = found[0]
}
if (build_type == null) {
build_type = build_types[found[1]]
}
if (fw_hash == null) {
fw_hash = found[2]
}
os_string = MSG.Message[i+1]
Expand Down

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The D low pass filter is done after the differentiation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

done

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
147 changes: 107 additions & 40 deletions PIDReview/PIDReview.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ var DataflashParser
const import_done = import('../modules/JsDataflashParser/parser.js').then((mod) => { DataflashParser = mod.default });

// Keys in data object to run FFT of
const fft_keys = ["Tar", "Act", "Err", "P", "I", "D", "FF", "Out"]
const fft_keys = ["Tar", "Act", "Err", "P", "I", "D", "FF", "DFF", "Out"]

function run_batch_fft(data_set) {

Expand Down Expand Up @@ -64,7 +64,8 @@ function run_batch_fft(data_set) {
// Log section is too short, skip
continue
}
var ret = run_fft(data_set[j][i], fft_keys, window_size, window_spacing, windowing_function, fft)
const valid_keys = fft_keys.filter(key => data_set[j][i][key] != null)
var ret = run_fft(data_set[j][i], valid_keys, window_size, window_spacing, windowing_function, fft)

// Initialize arrays
if (!have_data) {
Expand All @@ -76,7 +77,7 @@ function run_batch_fft(data_set) {
}

data_set[j].FFT.time.push(...array_offset(array_scale(ret.center, sample_time), data_set[j][i].time[0]))
for (const key of fft_keys) {
for (const key of valid_keys) {
data_set[j].FFT[key].push(...ret[key])
}
}
Expand Down Expand Up @@ -272,7 +273,7 @@ function setup_plots() {
Plotly.newPlot(plot, TimeInputs.data, TimeInputs.layout, {displaylogo: false})


const pid_outputs = ["P","I","D","FF","Output"]
const pid_outputs = ["P","I","D","FF","D FF","Output"]
TimeOutputs.data = []
for (const item of pid_outputs) {
TimeOutputs.data.push({ mode: "lines",
Expand Down Expand Up @@ -388,7 +389,7 @@ function link_plots() {
}

// Add data sets to FFT plot
const plot_types = ["Target", "Actual", "Error", "P", "I", "D", "FF", "Output"]
const plot_types = ["Target", "Actual", "Error", "P", "I", "D", "FF", "D FF", "Output"]
function get_FFT_data_index(set_num, plot_type) {
return set_num*plot_types.length + plot_type
}
Expand Down Expand Up @@ -618,13 +619,13 @@ function add_param_sets() {
set_cell_style(item)

const names = get_PID_param_names(PID.params.prefix)
for (const [name, param_string] of Object.entries(names)) {
for (const [name, param] of Object.entries(names)) {
let item = document.createElement("th")
header.appendChild(item)
set_cell_style(item)

item.appendChild(document.createTextNode(name.replace("_", " ")))
item.setAttribute('title', param_string)
item.appendChild(document.createTextNode(param.title))
item.setAttribute('title', param.name)
}

// Add line for each param set
Expand Down Expand Up @@ -667,21 +668,21 @@ function add_param_sets() {
checkbox.disabled = (valid_sets == 1) || !valid
item.appendChild(checkbox)

for (const name of Object.keys(names)) {
for (const [key, param] of Object.entries(names)) {
let item = document.createElement("td")
row.appendChild(item)
set_cell_style(item, color)

const value = set[name]
const value = set[key]
if (value == null) {
continue
}

const text = document.createTextNode(value.toFixed(4))
const text = document.createTextNode(value.toFixed(param.decimalPlaces))

let changed = false
if (i > 0) {
const last_value = PID.params.sets[i-1][name]
const last_value = PID.params.sets[i-1][key]
if (value != last_value) {
changed = true
}
Expand Down Expand Up @@ -724,24 +725,32 @@ function add_param_sets() {
document.getElementById("Spec_D").disabled = !have_all
document.getElementById("Spec_FF").disabled = !have_all

// DFF is only available in newer firmware logs
const have_DFF = have_all && PID.sets.some(set => set != null && set.some(batch => batch.DFF != null))
document.getElementById("PIDX_DFF").disabled = !have_DFF
document.getElementById("Spec_DFF").disabled = !have_DFF

// Uncheck any that are disabled
if (!have_all) {
document.getElementById("PIDX_Err").checked = false
document.getElementById("PIDX_P").checked = false
document.getElementById("PIDX_I").checked = false
document.getElementById("PIDX_D").checked = false
document.getElementById("PIDX_FF").checked = false
}
if (!have_DFF) {
document.getElementById("PIDX_DFF").checked = false
}

// Change to Out on spectrogram if disabled option is set
const disabled_checked = document.getElementById("Spec_Err").checked ||
document.getElementById("Spec_P").checked ||
document.getElementById("Spec_I").checked ||
document.getElementById("Spec_D").checked ||
document.getElementById("Spec_FF").checked
if (disabled_checked) {
document.getElementById("Spec_Out").checked = true
}

// Change to Out on spectrogram if disabled option is set
const disabled_checked = document.getElementById("Spec_Err").checked ||
document.getElementById("Spec_P").checked ||
document.getElementById("Spec_I").checked ||
document.getElementById("Spec_D").checked ||
document.getElementById("Spec_FF").checked ||
document.getElementById("Spec_DFF").checked
if ((!have_all || !have_DFF) && disabled_checked) {
document.getElementById("Spec_Out").checked = true
}


Expand Down Expand Up @@ -806,7 +815,10 @@ function redraw() {
if ("FF" in set[i]) {
TimeOutputs.data[3].y = TimeOutputs.data[3].y.concat(set[i].FF)
}
TimeOutputs.data[4].y = TimeOutputs.data[4].y.concat(set[i].Out)
if (set[i].DFF != null) {
TimeOutputs.data[4].y = TimeOutputs.data[4].y.concat(set[i].DFF)
}
TimeOutputs.data[5].y = TimeOutputs.data[5].y.concat(set[i].Out)
}
}

Expand Down Expand Up @@ -1204,7 +1216,7 @@ function update_hidden(source) {
var index
for (let j = 0; j < fft_keys.length; j++) {
const key = fft_keys[j]
if (id.endsWith(key)) {
if (id.endsWith("_" + key)) {
index = j
break
}
Expand Down Expand Up @@ -1296,15 +1308,68 @@ function time_range_changed() {
}

function get_PID_param_names(prefix) {
return { KP: prefix + "P",
KI: prefix + "I",
KD: prefix + "D",
FF: prefix + "FF",
I_max: prefix + "IMAX",
Target_filter: prefix + "FLTT",
Error_filter: prefix + "FLTE",
D_filter: prefix + "FLTD",
Slew_max: prefix + "SMAX"}
return {
KP: {
title: "KP",
name: prefix + "P",
decimalPlaces: 4,
},
KI: {
title: "KI",
name: prefix + "I",
decimalPlaces: 4,
},
KD: {
title: "KD",
name: prefix + "D",
decimalPlaces: 4,
},
FF: {
title: "KFF",
name: prefix + "FF",
decimalPlaces: 4,
},
D_FF: {
title: "KDFF",
name: prefix + "D_FF",
decimalPlaces: 4,
},
I_max: {
title: "I Max",
name: prefix + "IMAX",
decimalPlaces: 4,
},
Target_filter: {
title: "Target Filter (Hz)",
name: prefix + "FLTT",
decimalPlaces: 4,
},
Notch_target: {
title: "Target Notch Index",
name: prefix + "NTF",
decimalPlaces: 0,
},
Error_filter: {
title: "Error Filter (Hz)",
name: prefix + "FLTE",
decimalPlaces: 4,
},
Notch_error: {
title: "Error Notch Index",
name: prefix + "NEF",
decimalPlaces: 0,
},
D_filter: {
title: "D Filter (Hz)",
name: prefix + "FLTD",
decimalPlaces: 4,
},
Slew_max: {
title: "Slew Max",
name: prefix + "SMAX",
decimalPlaces: 4,
}
}
}

// Split use the given time array to return split points in log data
Expand Down Expand Up @@ -1459,14 +1524,14 @@ async function load(log_file) {
let last_set_end
for (let j = 0; j < PARM.Name.length; j++) {
const param_name = PARM.Name[j]
for (const [name, param_string] of Object.entries(names)) {
if (param_name !== param_string) {
for (const [key, param] of Object.entries(names)) {
if (param_name !== param.name) {
continue
}
const time = PARM.TimeUS[j] * US2S
const value = PARM.Value[j]
found_param = true
if (param_values[name] != null && (param_values[name] != value)) {
if (param_values[key] != null && (param_values[key] != value)) {
if ((last_set_end == null) || (time - last_set_end > 1.0)) {
// First param change for a second
last_set_end = time
Expand All @@ -1479,12 +1544,12 @@ async function load(log_file) {

} else {
// Very recent param change, combine with latest set, this leaves gap between sets
param_values[name] = value
param_values[key] = value
param_values.start_time = time

}
}
param_values[name] = value
param_values[key] = value
break
}
}
Expand Down Expand Up @@ -1545,7 +1610,9 @@ async function load(log_file) {
P: Array.from(log_msg.P.slice(batch.batch_start, batch.batch_end)),
I: Array.from(log_msg.I.slice(batch.batch_start, batch.batch_end)),
D: Array.from(log_msg.D.slice(batch.batch_start, batch.batch_end)),
FF: Array.from(log_msg.FF.slice(batch.batch_start, batch.batch_end))})
FF: Array.from(log_msg.FF.slice(batch.batch_start, batch.batch_end)),
DFF: ("DFF" in log_msg) ? Array.from(log_msg.DFF.slice(batch.batch_start, batch.batch_end)) : null,
})
}
}

Expand Down Expand Up @@ -1610,7 +1677,7 @@ async function load(log_file) {
const len = batch.P.length
batch.Out = new Array(len)
for (let i = 0; i<len; i++) {
batch.Out[i] = batch.P[i] + batch.I[i] + batch.D[i] + batch.FF[i]
batch.Out[i] = batch.P[i] + batch.I[i] + batch.D[i] + batch.FF[i] + (batch.DFF != null ? batch.DFF[i] : 0)
}
}
}
Expand Down
66 changes: 64 additions & 2 deletions PIDReview/Readme.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,65 @@
## PID review
# PID Review

A FFT tool for reviewing flight logs and PID settings. A windowed FFT is applied to the logged PID data, the step response is extracted by estimating the transfer function between the target and actual. Log is split in to sections based on parameter changes.
A browser-based tool for analysing ArduPilot flight logs and evaluating PID tuning quality.

## Requirements

The **PID** bit of the `LOG_BITMASK` parameter should be set before flying so that `PIDx` log messages (`PIDR`, `PIDP`, `PIDY`, `PIQR`, `PIQP`, `PIQY`, `PIDS`, `PIDA`) are recorded.
Without this the tool will fallback to using the less detailed `RATE` messages and will disable some features.

## Supported vehicles

| Vehicle | Axes / controllers |
|---------|--------------------|
| Copter | Roll, Pitch, Yaw rate PIDs (`PIDR/P/Y`) and raw RATE (`RATE R/P/Y`) |
| Plane | Roll, Pitch, Yaw rate PIDs (`PIDR/P/Y`), VTOL rate PIDs (`PIQR/P/Y`) and VTOL RATE |
| Rover | Steering rate (`PIDS`) and speed (`PIDA`) controllers |

### ArduCopter angle rate control loop

For this vehicle type, here is the annotated signal diagram:

![ArduCopter angle rate control loop](ArduCopter_angle_rate_control_loop.drawio.png)

## How to use

1. Open the tool in a browser and load a `.bin` log file using the file picker.
2. Select the axis / controller to analyse using the radio buttons in the **Axis** panel.
3. Optionally narrow the analysis window using the **Start / End** time inputs or by zooming into the **Flight Data** plot and dragging the range slider.
4. Click **Calculate** to run the FFT. The button is re-enabled automatically whenever the time range or window size is changed.

## Plots

### Flight Data

Overview of roll, pitch, throttle and altitude for the whole flight.
Use the range slider to set the analysis time window.

### Time Domain

- **Inputs** – target, actual and error signals in the time domain, in deg/s.
- **Outputs** – individual PID components (P, I, D, FF) and total output in the time domain.

Multiple test sections (caused by in-flight parameter changes) are shown with coloured background rectangles and can be toggled on/off individually in the **Tests** table.

### Frequency Domain (FFT)

Averaged windowed FFT of the selected PID signals over the chosen time range.
Each signal (Target, Actual, Error, P, I, D, FF, DFF, Output) can be shown or hidden independently.

- **Amplitude scale** – Linear, dB, or Power Spectral Density (PSD).
- **Frequency scale** – Linear or logarithmic, in Hz or RPM.
- **Window size** – Controls FFT frequency resolution; must be a power of two. Changing it re-enables the Calculate button.

The **Logging rate** and **Frequency resolution** fields below the checkboxes reflect the actual sample rate and bin width of the computed FFT.

### Step Response *(Roll / Pitch / Yaw axes only)*

Estimates the closed-loop step response from the flight data using a Wiener-filter / transfer-function approach between the target and actual signals.
Individual window estimates are shown in grey; the mean response is shown as a coloured line.
Useful for evaluating rise time, overshoot, and overall damping without requiring a dedicated step-input manoeuvre.

### PID Spectrogram

Time–frequency heatmap of the selected PID component.
Shows how the spectral content of the control signal evolves throughout the flight, making it easy to spot resonances that appear only at certain throttle levels or flight phases.
Loading