Skip to content

Commit fddd046

Browse files
nerdCopterclaude
andauthored
feat: always-on markdown report from computed analysis outputs (#156)
* feat: always-on markdown statistical report per processed file Automatically writes <stem>_report.md alongside PNGs on every run. No flag required. Includes firmware/config metadata, per-axis signal statistics (mean/std/min/max/rms for gyro, setpoint, P/I/D/F), and linked (not inline) references to all generated PNG files. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add BodeAxisResult return type to plot_bode_analysis Returns per-axis StabilityMargins from Bode analysis for downstream report collection. Early-exit paths return empty vec. Suppress dead_code until report wiring in a follow-up commit. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add MotorOscillationResult return type to plot_motor_spectrums Returns per-motor oscillation analysis (detected flag, peak/avg in 50-200 Hz range, overall max amplitude) for downstream report collection. Restructures the oscillation-check loop to build typed results instead of printing only. Early-exit paths return empty vec. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: structured markdown report from computed analysis outputs Replaces raw CSV-stats report with typed structs capturing the same analysis results already printed to console — no CSV re-reading, no println duplication. - report.rs: rewritten with FlightReport, StepAxisReport, DTermRec; generate_markdown_report(&FlightReport, &Path) formats P:D ratios, step response assessments and D-term recommendations, Optimal P analysis, Bode stability margins, motor oscillation, and PNG links - plot_bode.rs: remove unused axis field from BodeAxisResult; drop #[allow(dead_code)] now that fields are consumed by the report - plot_motor_spectrums.rs: drop #[allow(dead_code)] from MotorOscillationResult - main.rs: capture pd_ratios_for_report, build step_reports from existing analysis arrays (re-computing aggressive tier inline), clone optimal_p_analyses before move into OptimalPConfig, capture motor_results and bode_results from plot function returns, assemble FlightReport and call new generate_markdown_report Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(report): add setpoint authority to step response report section StepAxisReport gains setpoint_authority_name/mean fields populated from the already-computed compute_setpoint_authority() result in main.rs. Report displays authority level and mean dps under each axis's step response section. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(report): add gyro filtering delay and spectrum peaks section plot_gyro_spectrums now returns GyroAnalysisResult containing the average filtering delay (already computed via cross-correlation) and per-axis primary unfiltered spectrum peaks. FlightReport stores this as gyro_analysis; the report emits a Gyro Analysis section with delay and a peaks table. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(report): add D-term filtering delay and spectrum peaks section plot_d_term_spectrums now returns Vec<DTermAxisResult> with per-axis primary peak (freq/amplitude) and filtering delay (ms + confidence) already computed by d_term_delay::calculate_d_term_filtering_delay_comparison. FlightReport stores dterm_results; the report emits a D-Term Analysis table with delay and primary peak per axis. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(report): all spectrum peaks per axis; per-axis gyro delay with confidence - Store full peaks Vec (primary + subordinates up to MAX_PEAKS_TO_LABEL=3) in both GyroSpectrumAxisResult and DTermAxisResult instead of first-only - DelayAnalysisResult gains axis_delays Vec so per-axis (delay_ms, confidence) is surfaced from calculate_average_filtering_delay_comparison - GyroSpectrumAxisResult carries per-axis delay_ms/delay_confidence sourced from axis_delays; GyroAnalysisResult.average_delay_ms removed (superseded) - Report tables for Gyro Analysis and D-Term Analysis now share identical column format: Axis | Delay (ms) | Confidence | Peak | Freq (Hz) | Amplitude Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: filter config section, D-term N/A disambiguation, step warnings in report (#153) Add three new report sections from console output that were missing: 1. Filter Configuration: per-axis LPF1/LPF2/IMUF table + dynamic notch and RPM filter lines. Parses AllFilterConfigs, DynamicNotchConfig, RpmFilterConfig from header metadata via filter_response. Works for BF (unified), EmuFlight (per-axis), HELIOSPRING (IMUF PTn), and EmuFlight pseudo-Kalman. 2. D-term N/A disambiguation: DTermAxisDelay struct replaces Vec<Option<DelayResult>>, carrying na_reason alongside the result. Possible reasons: "D gain disabled", "Low signal correlation", "Insufficient samples", "No D-term data". Report now shows e.g. "N/A (Low signal correlation)" instead of bare "N/A". 3. Step response warnings: warnings: Vec<String> on StepAxisReport captures severe overshoot and unreasonable P:D ratio alerts that previously only appeared on console. Rendered as bold "⚠ Warning:" lines after recommendations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: document gyroUnfilt debug fallback in report metadata When gyroUnfilt data comes from debug[0-2] channels instead of dedicated gyroUnfilt columns, add a ⚠ note in Metadata with the debug mode name (e.g. GYRO_SCALED, RC_SMOOTHING). Pilots need to know that unfiltered gyro spectrums and filtering delay calculations in that report are derived from debug channels, not actual gyroUnfilt — affecting their interpretation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: clone root_name_string before move into FlightReport root_name_string was moved into FlightReport.root_name then borrowed again by plot_eso_output; clone before the move. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: correct na_reason and fmt_imuf fallback Two issues flagged by CodeRabbit review: 1. d_term_delay.rs: split the combined gyroUnfilt/d_term unavailability guard into separate checks so na_reason correctly reflects the actual cause — 'No gyroUnfilt data' vs 'No D-term data'. Previously both conditions silently fell through with 'No D-term data', misleading pilots whose log lacked gyroUnfilt but had D-term data. 2. report.rs fmt_imuf: change the catch-all ptn_order arm from '2×PT1' to 'PT?' so an unknown or zero order renders visibly wrong rather than silently plausible. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: document always-on markdown report in README and OVERVIEW Fill in the empty 'Generated Reports' section in OVERVIEW.md and add a 'Markdown Report' subsection to README.md Output section. Both now describe the always-on *_report.md output, its sections, and when optional sections (Optimal P, Bode) appear. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address CodeRabbit inline findings from PR#156 review Four issues resolved: 1. ESO PNG missing from report: moved report generation to after the ESO block so png_links is complete. Also adds the ESO stacked PNG to png_links on success. 2. Error propagation: replaced log-and-continue match on generate_markdown_report with ? operator so a write failure propagates out of process_file(). 3. Empty axis_delays in plot_setpoint_vs_gyro.rs: changed Vec::new() fallback to vec![None; AXIS_NAMES.len()] to satisfy the per-axis indexing contract expected by downstream consumers. 4. Hardcoded axis counts: replaced [Option<f64>; 3], [None, None, None], [Vec::new()...] and 0..2 introduced by the report wiring with AXIS_COUNT, std::array::from_fn, and ROLL_PITCH_AXIS_COUNT. Deferred: png_links as second source of truth (heavy refactor — tracked as follow-up). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 0d47da9 commit fddd046

14 files changed

Lines changed: 983 additions & 56 deletions

OVERVIEW.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,11 @@ When `--step` flag is not used, all plots below are generated:
182182
- **`*_Throttle_Freq_Heatmap_comparative.png`** — System noise characteristics across throttle levels and frequencies
183183
- **`*_PID_Activity_stacked.png`** — P, I, D term activity over time for each axis (Roll, Pitch, Yaw). Displays all three PID components on the same time-domain plot with unified Y-axis scaling for visual comparison. Each term shows min/avg/max statistics in the legend. Useful for visualizing PID contribution balance during flight and identifying control issues (persistent P-term offset, I-term wind direction, D-term phase lag).
184184

185+
#### Generated Reports
186+
187+
- **`*_report.md`** — Structured markdown flight report written alongside PNGs on every run. Content is assembled from typed result structs returned by each analysis pass — no CSV re-reading. Sections: Metadata (firmware revision, craft name, PIDs, sample rate, gyroUnfilt source warning), Filter Configuration (per-axis LPF1/LPF2/IMUF/Pseudo-Kalman table, Dynamic Notch, RPM filter), PID Tuning P:D ratios, Step Response Analysis (Roll/Pitch: peak value, assessment, setpoint authority, P:D recommendations), Gyro Analysis (per-axis filtering delay with confidence, spectrum peaks), D-Term Analysis (per-axis filtering delay with N/A disambiguation, spectrum peaks), Motor Oscillation table, and relative links to all generated PNGs. Optimal P Estimation and Bode Analysis sections are included when those features produce results.
188+
189+
185190
#### P:D Ratio Recommendations
186191

187192
The system provides intelligent P:D tuning recommendations based on step-response peak analysis:

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,10 @@ Arguments can be in any order. Wildcards (e.g., *.csv) are shell-expanded and wo
104104
- `*_Gyro_PSD_Spectrogram_comparative.png` — Gyro spectrogram (PSD vs. time)
105105
- `*_Throttle_Freq_Heatmap_comparative.png` — Throttle/frequency heatmap analysis
106106
107+
#### Markdown Report (always generated)
108+
109+
- `*_report.md` — Structured flight report written alongside PNGs on every run. Sections: Metadata (firmware, PIDs, sample rate, gyroUnfilt source), Filter Configuration (LPF1/LPF2/IMUF/Pseudo-Kalman table, Dynamic Notch, RPM filter), PID Tuning, Step Response Analysis (Roll/Pitch with P:D assessment and setpoint authority), Gyro Analysis (filtering delay, confidence, spectrum peaks per axis), D-Term Analysis (filtering delay with N/A reason, spectrum peaks), Motor Oscillation, and links to all generated PNGs. Optimal P Estimation and Bode Analysis sections appear when those features are active.
110+
107111
#### Console Output:
108112
- Current P:D ratio and peak analysis with response assessment
109113
- Conservative and Moderate tuning recommendations (with D/D-Min/D-Max values)

src/data_analysis/d_term_delay.rs

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@ use crate::data_analysis::derivative::calculate_derivative;
1212
use crate::data_analysis::filter_delay;
1313
use crate::data_input::log_data::LogRowData;
1414

15+
/// Per-axis outcome from D-term delay calculation, including why delay is unavailable.
16+
pub struct DTermAxisDelay {
17+
pub result: Option<filter_delay::DelayResult>,
18+
/// Human-readable reason when result is None (None when result is Some).
19+
pub na_reason: Option<&'static str>,
20+
}
21+
1522
/// Calculate filtering delay comparison between unfiltered and filtered D-terms.
1623
///
1724
/// This function computes the delay between the unfiltered D-term (calculated as derivative of gyroUnfilt)
@@ -44,23 +51,36 @@ use crate::data_input::log_data::LogRowData;
4451
pub fn calculate_d_term_filtering_delay_comparison(
4552
log_data: &[LogRowData],
4653
sample_rate: f64,
47-
) -> Vec<Option<filter_delay::DelayResult>> {
48-
// Validate input parameters
54+
) -> Vec<DTermAxisDelay> {
55+
let make_empty = |reason: &'static str| -> Vec<DTermAxisDelay> {
56+
(0..AXIS_NAMES.len())
57+
.map(|_| DTermAxisDelay {
58+
result: None,
59+
na_reason: Some(reason),
60+
})
61+
.collect()
62+
};
63+
4964
if !sample_rate.is_finite() || sample_rate <= 0.0 {
5065
eprintln!(
5166
"Error: Invalid sample rate ({}) for D-term delay analysis",
5267
sample_rate
5368
);
54-
return vec![None; AXIS_NAMES.len()];
69+
return make_empty("Invalid sample rate");
5570
}
5671

5772
if log_data.is_empty() {
5873
eprintln!("Error: Empty log data provided for D-term delay analysis");
59-
return vec![None; AXIS_NAMES.len()];
74+
return make_empty("No log data");
6075
}
6176

62-
// Initialize with None for all axes to preserve axis alignment
63-
let mut results: Vec<Option<filter_delay::DelayResult>> = vec![None; AXIS_NAMES.len()];
77+
// Initialize with no-data reason; overwritten when data is found
78+
let mut results: Vec<DTermAxisDelay> = (0..AXIS_NAMES.len())
79+
.map(|_| DTermAxisDelay {
80+
result: None,
81+
na_reason: Some("No D-term data"),
82+
})
83+
.collect();
6484

6585
// First, check data availability for diagnosis
6686
let mut gyro_unfilt_available = [false; 3];
@@ -112,8 +132,12 @@ pub fn calculate_d_term_filtering_delay_comparison(
112132

113133
// Use AXIS_NAMES.iter().enumerate() for consistency with other parts of the codebase
114134
for (axis_idx, axis_name) in AXIS_NAMES.iter().enumerate() {
115-
// Skip if either data type is unavailable
116-
if !gyro_unfilt_available[axis_idx] || !d_term_available[axis_idx] {
135+
if !gyro_unfilt_available[axis_idx] {
136+
results[axis_idx].na_reason = Some("No gyroUnfilt data");
137+
continue;
138+
}
139+
if !d_term_available[axis_idx] {
140+
// na_reason already "No D-term data" from init
117141
continue;
118142
}
119143

@@ -141,6 +165,7 @@ pub fn calculate_d_term_filtering_delay_comparison(
141165
gyro_unfilt_data.len(),
142166
d_term_filtered_data.len()
143167
);
168+
results[axis_idx].na_reason = Some("Insufficient samples");
144169
continue;
145170
}
146171

@@ -157,6 +182,7 @@ pub fn calculate_d_term_filtering_delay_comparison(
157182
" {}: D-term appears disabled (max abs value: {:.2e}, likely D gain = 0)",
158183
axis_name, d_term_max_abs
159184
);
185+
results[axis_idx].na_reason = Some("D gain disabled");
160186
continue;
161187
}
162188

@@ -175,6 +201,7 @@ pub fn calculate_d_term_filtering_delay_comparison(
175201
" {}: D-term has no variation (std dev: {:.2e}, likely D gain = 0)",
176202
axis_name, d_term_std_dev
177203
);
204+
results[axis_idx].na_reason = Some("D gain disabled");
178205
continue;
179206
}
180207

@@ -203,12 +230,16 @@ pub fn calculate_d_term_filtering_delay_comparison(
203230
result.delay_ms,
204231
result.confidence * 100.0
205232
);
206-
results[axis_idx] = Some(result);
233+
results[axis_idx] = DTermAxisDelay {
234+
result: Some(result),
235+
na_reason: None,
236+
};
207237
} else {
208238
println!(
209239
" {}: D-term delay calculation failed - correlation below D-term threshold",
210240
axis_name
211241
);
242+
results[axis_idx].na_reason = Some("Low signal correlation");
212243
}
213244
} else if let Some(result) = calculate_d_term_filtering_delay_enhanced_xcorr(
214245
&Array1::from_vec(d_term_filtered_data),
@@ -221,12 +252,16 @@ pub fn calculate_d_term_filtering_delay_comparison(
221252
result.delay_ms,
222253
result.confidence * 100.0
223254
);
224-
results[axis_idx] = Some(result);
255+
results[axis_idx] = DTermAxisDelay {
256+
result: Some(result),
257+
na_reason: None,
258+
};
225259
} else {
226260
println!(
227261
" {}: D-term delay calculation failed - correlation below D-term threshold",
228262
axis_name
229263
);
264+
results[axis_idx].na_reason = Some("Low signal correlation");
230265
}
231266
}
232267

@@ -235,7 +270,7 @@ pub fn calculate_d_term_filtering_delay_comparison(
235270
.iter()
236271
.enumerate()
237272
.filter_map(|(idx, result)| {
238-
if result.is_some() {
273+
if result.result.is_some() {
239274
Some(AXIS_NAMES[idx])
240275
} else {
241276
None

src/data_analysis/filter_delay.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ pub fn calculate_average_filtering_delay_comparison(
188188
sample_rate: f64,
189189
) -> DelayAnalysisResult {
190190
let mut all_results: Vec<DelayResult> = Vec::new();
191+
let mut axis_delays: Vec<Option<(f32, f32)>> = vec![None; AXIS_NAMES.len()];
191192

192193
// First, diagnose data availability
193194
println!("=== Gyro Data Availability Diagnostic ===");
@@ -274,6 +275,13 @@ pub fn calculate_average_filtering_delay_comparison(
274275
}
275276
}
276277

278+
// Capture per-axis delay for callers that need it (e.g. report)
279+
if let Some(r) = axis_results
280+
.iter()
281+
.find(|r| r.method == "Enhanced Cross-Correlation")
282+
{
283+
axis_delays[axis] = Some((r.delay_ms, r.confidence));
284+
}
277285
all_results.extend(axis_results);
278286
}
279287
}
@@ -298,17 +306,20 @@ pub fn calculate_average_filtering_delay_comparison(
298306
DelayAnalysisResult {
299307
average_delay: Some(avg_delay),
300308
results: method_summaries,
309+
axis_delays,
301310
}
302311
} else {
303312
DelayAnalysisResult {
304313
average_delay: None,
305314
results: method_summaries,
315+
axis_delays,
306316
}
307317
}
308318
} else {
309319
DelayAnalysisResult {
310320
average_delay: None,
311321
results: Vec::new(),
322+
axis_delays,
312323
}
313324
}
314325
}
@@ -325,6 +336,8 @@ pub struct DelayResult {
325336
pub struct DelayAnalysisResult {
326337
pub average_delay: Option<f32>,
327338
pub results: Vec<DelayResult>,
339+
/// Per-axis (delay_ms, confidence 0–1); index matches AXIS_NAMES order
340+
pub axis_delays: Vec<Option<(f32, f32)>>,
328341
}
329342

330343
/// Calculate filtering delay using enhanced cross-correlation method only

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,5 @@ pub mod font_config;
1010
pub mod pid_context;
1111
pub mod plot_framework;
1212
pub mod plot_functions;
13+
pub mod report;
1314
pub mod types;

0 commit comments

Comments
 (0)