diff --git a/OVERVIEW.md b/OVERVIEW.md index d34bb20..74c3d4e 100644 --- a/OVERVIEW.md +++ b/OVERVIEW.md @@ -233,22 +233,36 @@ Physics-derived P gain optimization using a Torque-Inertia Profiler that measure - **P Decrease:** Td faster than target with high noise → P is too high (rare) - **Investigate:** Measurements suggest mechanical issues or abnormal dynamics -- **Output:** Console report and PNG legend overlay showing: Td mean with `windows=` count (valid step-response windows contributing to the Td mean), target, deviation %, noise level, consistency % (orange warning when CV exceeds `TD_COEFFICIENT_OF_VARIATION_MAX`), `[LOW AUTHORITY]` warning when max setpoint is below `LOW_AUTHORITY_SETPOINT_THRESHOLD_DEG_S`, and P recommendation with calculated D adjustment. When profiling is skipped (insufficient punch events), a skip reason appears in both outputs. See **Consistency and Reliability Interpretation** below for signal details. +- **Output:** Console and PNG legend overlay (identical content) per axis: + - `Td:` — measured Td mean with target `±tolerance` and `windows=` count + - `Td source:` — `File Group` (multi-file run) or `Single File`, with flight count and throttle-punch count that produced the physics target + - `Noise:` — HF D-term energy level (`LOW` / `MODERATE` / `HIGH` / `UNKNOWN`) for the current flight + - `Deviation:` — % difference between measured Td and physics target, with zone label + - `Current P=` — P gain value from the flight's metadata + - `Recommendation` — one of `(Conservative)`, `(Decrease)`, `Current P is optimal`, or `Investigate —` with reason; includes calculated D adjustment + - `Reliable:` / `Unreliable:` — always shows both `Consistency=N% (⊢≥70%)` and `CV=N% (⊢≤40%)`; `Unreliable` is highlighted in orange when either threshold is not met + - `Setpoint Authority:` — always shown; classifies flight inputs as `LOW`, `MODERATE`, or `HIGH` based on the **mean** of per-window max setpoints (see below). Orange for `LOW`. + - When profiling is skipped (insufficient punch events), a skip reason replaces the above. See **Consistency and Reliability Interpretation** below. - **Consistency and Reliability Interpretation (CV):** - **CV (Coefficient of Variation)** = standard deviation / mean of individual Td measurements across all valid step-response windows. It quantifies how scattered the measurements are relative to their average. - **Low CV:** Td measurements are tightly clustered — the log contains clean, repeatable dynamics and recommendations are trustworthy. - - **High CV (exceeds `TD_COEFFICIENT_OF_VARIATION_MAX`):** Td measurements vary widely across windows — a consistency warning is shown in both console and PNG. Recommendations should be treated with caution. - - **CV = None:** Fewer than `TD_SAMPLES_MIN_FOR_STDDEV` valid Td samples were available; standard deviation cannot be computed. The mean is still reported but no consistency rating is given. - - **Low-authority flight warning:** When the maximum setpoint across all valid windows is below `LOW_AUTHORITY_SETPOINT_THRESHOLD_DEG_S`, a `[LOW AUTHORITY]` warning is shown in both console and PNG. Hover tests and slow-cruise logs never produce sharp inputs — step-response analysis and Td measurements from such logs are noise-dominated rather than dynamics-dominated, making all recommendations unreliable regardless of CV. + - **High CV (exceeds `TD_COEFFICIENT_OF_VARIATION_MAX`):** Td measurements vary widely across windows — `Unreliable:` is shown (orange) in both console and PNG. Recommendations should be treated with caution. + - **CV = N/A:** Fewer than `TD_SAMPLES_MIN_FOR_STDDEV` valid Td samples were available; standard deviation cannot be computed. The mean is still reported and `CV=N/A` appears in the reliability line. + - **Setpoint Authority classification:** Always-visible line in both console and PNG. Uses the **mean** of per-window max setpoints (not the maximum) to classify the flight: + - `LOW` (`mean < 100 dps`, orange) — hover/slow-cruise inputs; all P:D recommendations are still shown, but the pilot should treat them with caution. + - `MODERATE` (`100–250 dps`) — normal sport/freestyle inputs. + - `HIGH` (`> 250 dps`) — aggressive or race-pace inputs. + Format: `Setpoint Authority: LOW (mean=68dps ⊢≥100dps)`. Using the mean rather than the max prevents a single high-input window from masking an otherwise gentle hover log. - **Why hover logs produce high CV:** Small setpoint inputs → deconvolution is noise-sensitive → each window captures a different noise realisation. The averaged response may appear plausible (noise averages out) while individual window variance remains high. CV exposes this where the mean alone cannot. - **Over-P limitation:** **When P is already too high and the aircraft oscillates, the profiler may report "Optimal" rather than "Decrease P."** An oscillatory step response produces a short, aggressive measured Td — which, fed into the physics formula, yields a P_optimal close to the current (excessive) P. The profiler cannot reliably distinguish a well-tuned fast response from an over-tuned oscillating one using Td alone. The indirect signal is CV: severe oscillation typically scatters Td samples widely and triggers the consistency warning. **If your gains feel high or the craft exhibits oscillation, start from a lower P before relying on these recommendations.** Optimal P estimation is most accurate when the craft is in a reasonable tuning range — it is a validator and refinement tool, not a recovery tool for badly mis-tuned aircraft. Only experienced pilots are likely to recognise this situation by feel. - - **High CV without `[LOW AUTHORITY]`:** If the consistency warning fires but `[LOW AUTHORITY]` is not shown, the scatter is not caused by low-energy hover inputs. Remaining causes include propwash, inconsistent maneuvers, and oscillation from over-P. **If gains feel high, treat this combination as a prompt to verify the craft is not oscillating before acting on any recommendation.** + - **High CV without LOW authority:** If the consistency warning fires but `Setpoint Authority` is `MODERATE` or `HIGH`, the scatter is not caused by low-energy hover inputs. Remaining causes include propwash, inconsistent maneuvers, and oscillation from over-P. **If gains feel high, treat this combination as a prompt to verify the craft is not oscillating before acting on any recommendation.** - **Summary of dependability signals in output:** - `windows=` on the Td line — number of valid step-response windows contributing to the Td mean; more windows = more statistical weight - - Consistency % and CV — how repeatable individual measurements are - - `[LOW AUTHORITY]` — max setpoint too small for reliable step-response characterisation - - Noise level (`LOW` / `MODERATE` / `HIGH`) — HF D-term energy; high noise limits safe P increase + - `Td source:` — flight and throttle-punch counts that calibrated the physics target; `File Group` means data was pooled across multiple logs + - `Reliable:` / `Unreliable:` with `Consistency %` and `CV` — how repeatable the per-flight step-response measurements are (independent of how many punches fed the physics target) + - `Setpoint Authority:` — mean setpoint level across valid windows; `LOW` indicates hover/gentle inputs that may reduce step-response quality + - Noise level (`LOW` / `MODERATE` / `HIGH`) — HF D-term energy for the current flight; high noise limits safe P increase - **Relationship to P:D Recommendations:** - P:D ratio recommendations: analyze peak overshoot → adjust D relative to P diff --git a/src/constants.rs b/src/constants.rs index e0ad3bd..4f2cdba 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -49,10 +49,12 @@ pub const DEFAULT_SETPOINT_THRESHOLD: f64 = 500.0; // Default setpoint threshold // Constants for filtering data based on movement and flight phase. pub const MOVEMENT_THRESHOLD_DEG_S: f64 = 20.0; // Minimum setpoint/gyro magnitude (from PTB/PlasmaTree) -/// Max setpoint (deg/s) below which a flight is considered low-authority. -/// Hover tests and slow-cruise logs never produce sharp inputs, so step-response -/// quality and P:D/optimal-P recommendations from such logs are unreliable. +/// Mean window-max setpoint (deg/s) thresholds for Setpoint Authority classification. +/// LOW < 100 dps : hover/slow-cruise — Moderate/Aggressive recommendations suppressed. +/// MODERATE 100–250 dps : normal flight inputs. +/// HIGH > 250 dps : aggressive/race inputs. pub const LOW_AUTHORITY_SETPOINT_THRESHOLD_DEG_S: f32 = 100.0; +pub const HIGH_AUTHORITY_SETPOINT_THRESHOLD_DEG_S: f32 = 250.0; pub const EXCLUDE_START_S: f64 = 3.0; // Exclude seconds from the start of the log pub const EXCLUDE_END_S: f64 = 3.0; // Exclude seconds from the end of the log diff --git a/src/data_analysis/calc_step_response.rs b/src/data_analysis/calc_step_response.rs index 49f31e6..9c9ff40 100644 --- a/src/data_analysis/calc_step_response.rs +++ b/src/data_analysis/calc_step_response.rs @@ -8,13 +8,57 @@ use crate::types::StepResponseResult; use crate::constants::{ APPLY_INDIVIDUAL_RESPONSE_Y_CORRECTION, ENABLE_NORMALIZED_STEADY_STATE_MEAN_CHECK, - FRAME_LENGTH_S, INITIAL_GYRO_SMOOTHING_WINDOW, MOVEMENT_THRESHOLD_DEG_S, + FRAME_LENGTH_S, HIGH_AUTHORITY_SETPOINT_THRESHOLD_DEG_S, INITIAL_GYRO_SMOOTHING_WINDOW, + LOW_AUTHORITY_SETPOINT_THRESHOLD_DEG_S, MOVEMENT_THRESHOLD_DEG_S, NORMALIZED_STEADY_STATE_MAX_VAL, NORMALIZED_STEADY_STATE_MEAN_MAX, NORMALIZED_STEADY_STATE_MEAN_MIN, NORMALIZED_STEADY_STATE_MIN_VAL, RESPONSE_LENGTH_S, STEADY_STATE_END_S, STEADY_STATE_START_S, SUPERPOSITION_FACTOR, TUKEY_ALPHA, Y_CORRECTION_MIN_UNNORMALIZED_MEAN_ABS, }; +/// Setpoint authority classification derived from the mean of per-window max setpoints. +/// Describes how aggressively the pilot flew during the step-response windows. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SetpointAuthority { + Low, // mean < LOW_AUTHORITY_SETPOINT_THRESHOLD_DEG_S + Moderate, // LOW..HIGH + High, // >= HIGH_AUTHORITY_SETPOINT_THRESHOLD_DEG_S +} + +impl SetpointAuthority { + pub fn name(&self) -> &'static str { + match self { + Self::Low => "LOW", + Self::Moderate => "MODERATE", + Self::High => "HIGH", + } + } + + pub fn is_low(&self) -> bool { + matches!(self, Self::Low) + } +} + +/// Compute SetpointAuthority and mean window-max setpoint from QC-passed window data. +/// Returns None if the slice is empty. +pub fn compute_setpoint_authority( + valid_window_max_setpoints: &[f32], +) -> Option<(SetpointAuthority, f32)> { + if valid_window_max_setpoints.is_empty() { + return None; + } + let mean = + valid_window_max_setpoints.iter().sum::() / valid_window_max_setpoints.len() as f32; + let level = if mean < LOW_AUTHORITY_SETPOINT_THRESHOLD_DEG_S { + SetpointAuthority::Low + } else if mean < HIGH_AUTHORITY_SETPOINT_THRESHOLD_DEG_S { + SetpointAuthority::Moderate + } else { + SetpointAuthority::High + }; + Some((level, mean)) +} + use crate::data_analysis::fft_utils; // tukeywin, winstacker_contiguous, wiener_deconvolution_window, cumulative_sum, diff --git a/src/data_analysis/optimal_p_estimation.rs b/src/data_analysis/optimal_p_estimation.rs index aa28c41..b496559 100644 --- a/src/data_analysis/optimal_p_estimation.rs +++ b/src/data_analysis/optimal_p_estimation.rs @@ -221,6 +221,10 @@ pub struct OptimalPAnalysis { pub td_target_ms: f64, /// Actual Td tolerance (in ms) used during analysis (from physics) pub td_tolerance_ms: f64, + /// Number of throttle-punch events used to derive the physics Td target. + pub source_events: usize, + /// Number of flight files that contributed to the physics Td target. + pub source_files: usize, } impl OptimalPAnalysis { @@ -318,6 +322,8 @@ impl OptimalPAnalysis { recommendation, td_target_ms, td_tolerance_ms, + source_events: 0, + source_files: 0, }) } @@ -498,118 +504,114 @@ impl OptimalPAnalysis { /// Format analysis as human-readable console output pub fn format_console_output(&self, axis_name: &str) -> String { - let target_display = format!("{:.1}±{:.1}ms", self.td_target_ms, self.td_tolerance_ms); let mut output = String::new(); - // Compact header - axis name and basic info + // Header: axis name and Td measurement output.push_str(&format!( - "{}: Td={:.1}ms (target {}, {:+.0}% dev, windows={}), Noise={}\n", + "{}: Td={:.1}ms (target: {:.1}±{:.1}ms, windows={})\n", axis_name, self.td_stats.mean_ms, - target_display, - self.td_deviation_percent, + self.td_target_ms, + self.td_tolerance_ms, self.td_stats.num_samples, - self.noise_level.name(), )); + // Td source: group/single, flights, punches + let source_label = if self.source_files > 1 { + "File Group" + } else { + "Single File" + }; + output.push_str(&format!( + " Td source: {} — {} flights, {} throttle-punches\n", + source_label, self.source_files, self.source_events, + )); + output.push_str(&format!(" Noise: {}\n", self.noise_level.name())); - // Reliability line — always shown with both metrics - { - let cv_str = self.td_stats.coefficient_of_variation.map_or_else( - || "CV=N/A".to_string(), - |cv| { - format!( - "CV={:.1}% (≤{:.0}%)", - cv * 100.0, - TD_COEFFICIENT_OF_VARIATION_MAX * 100.0, - ) - }, - ); - let cons_str = format!( - "Consistency={:.0}% (≥{:.0}%)", - self.td_stats.consistency * 100.0, - TD_CONSISTENCY_MIN_THRESHOLD * 100.0, - ); - if self.td_stats.is_consistent() { - output.push_str(&format!(" Reliable: {cons_str}, {cv_str}\n")); - } else { - output.push_str(&format!(" Unreliable: {cons_str}, {cv_str}\n")); - } - } + // Deviation + let deviation_sign = if self.td_deviation_percent > 0.0 { + "+" + } else { + "" + }; + output.push_str(&format!( + " Deviation: {}{:.1}% ({})\n", + deviation_sign, + self.td_deviation_percent, + self.td_deviation.name(), + )); - // Compact recommendation + // Current P output.push_str(&format!(" Current P={}\n", self.current_p)); - match &self.recommendation { - PRecommendation::Optimal { reasoning } => { - output.push_str(" → Optimal (no change recommended)\n"); - output.push_str(&format!(" {}\n", reasoning)); + // Recommendation — shared D-suffix helper + let effective_pd = self.recommended_pd_conservative.or_else(|| { + self.current_d + .filter(|&d| d > 0) + .map(|d| self.current_p as f64 / d as f64) + }); + let d_suffix = |recommended_p: u32| -> String { + if let (Some(current_d), Some(rec_pd)) = (self.current_d, effective_pd) { + if rec_pd > 0.0 && current_d > 0 { + let rec_d = (recommended_p as f64 / rec_pd).round() as u32; + let d_delta = rec_d as i32 - current_d as i32; + return format!(", D≈{} ({:+})", rec_d, d_delta); + } } - PRecommendation::Increase { - conservative_p, - reasoning, - } => { - output.push_str(" → Increase recommended:\n"); - - // Calculate P delta - let conservative_delta = *conservative_p as i32 - self.current_p as i32; + String::new() + }; - // Show P recommendation (conservative only for simplicity) + match &self.recommendation { + PRecommendation::Optimal { .. } => { output.push_str(&format!( - " Conservative: P≈{} ({:+})", - conservative_p, conservative_delta + " Recommendation: Current P is optimal (P={})\n", + self.current_p )); - - // Add D recommendation: prefer step-response P:D, fall back to current P:D. - let effective_pd = self.recommended_pd_conservative.or_else(|| { - self.current_d - .filter(|&d| d > 0) - .map(|d| self.current_p as f64 / d as f64) - }); - if let (Some(current_d), Some(rec_pd)) = (self.current_d, effective_pd) { - if rec_pd > 0.0 && current_d > 0 { - let conservative_d = ((*conservative_p as f64) / rec_pd).round() as u32; - let conservative_d_delta = conservative_d as i32 - current_d as i32; - output.push_str(&format!( - ", D≈{} ({:+})", - conservative_d, conservative_d_delta - )); - } - } - output.push('\n'); - - output.push_str(&format!(" {}\n", reasoning)); } - PRecommendation::Decrease { - recommended_p, - reasoning, - } => { - output.push_str(" → Decrease recommended:\n"); - let decrease_delta = *recommended_p as i32 - self.current_p as i32; - output.push_str(&format!(" P≈{} ({:+})", recommended_p, decrease_delta)); - - // Add D recommendation: prefer step-response P:D, fall back to current P:D. - let effective_pd = self.recommended_pd_conservative.or_else(|| { - self.current_d - .filter(|&d| d > 0) - .map(|d| self.current_p as f64 / d as f64) - }); - if let (Some(current_d), Some(rec_pd)) = (self.current_d, effective_pd) { - if rec_pd > 0.0 && current_d > 0 { - let recommended_d = ((*recommended_p as f64) / rec_pd).round() as u32; - let d_delta = recommended_d as i32 - current_d as i32; - output.push_str(&format!(", D≈{} ({:+})", recommended_d, d_delta)); - } - } - output.push('\n'); - - output.push_str(&format!(" {}\n", reasoning)); + PRecommendation::Increase { conservative_p, .. } => { + let p_delta = *conservative_p as i32 - self.current_p as i32; + output.push_str(&format!( + " Recommendation (Conservative): P≈{} ({:+}){}\n", + conservative_p, + p_delta, + d_suffix(*conservative_p), + )); + } + PRecommendation::Decrease { recommended_p, .. } => { + let p_delta = *recommended_p as i32 - self.current_p as i32; + output.push_str(&format!( + " Recommendation (Decrease): P≈{} ({:+}){}\n", + recommended_p, + p_delta, + d_suffix(*recommended_p), + )); } PRecommendation::Investigate { issue } => { - output.push_str(" → ⚠ INVESTIGATION RECOMMENDED\n"); - output.push_str(&format!(" {}\n", issue)); + output.push_str(&format!(" Recommendation: Investigate — {}\n", issue)); } } + // Reliability — always shown, after recommendation + let cv_str = self.td_stats.coefficient_of_variation.map_or_else( + || "CV=N/A".to_string(), + |cv| { + format!( + "CV={:.1}% (⊢≤{:.0}%)", + cv * 100.0, + TD_COEFFICIENT_OF_VARIATION_MAX * 100.0, + ) + }, + ); + let cons_str = format!( + "Consistency={:.0}% (⊢≥{:.0}%)", + self.td_stats.consistency * 100.0, + TD_CONSISTENCY_MIN_THRESHOLD * 100.0, + ); + if self.td_stats.is_consistent() { + output.push_str(&format!(" Reliable: {cons_str}, {cv_str}\n")); + } else { + output.push_str(&format!(" Unreliable: {cons_str}, {cv_str}\n")); + } + output } } diff --git a/src/data_analysis/torque_inertia_profiler.rs b/src/data_analysis/torque_inertia_profiler.rs index 175a159..8a1f502 100644 --- a/src/data_analysis/torque_inertia_profiler.rs +++ b/src/data_analysis/torque_inertia_profiler.rs @@ -113,6 +113,8 @@ impl AxisProfile { pub struct AircraftProfile { /// Per-axis profiles (Roll=0, Pitch=1, Yaw=2). pub axes: [AxisProfile; AXIS_COUNT], + /// Number of flight files that contributed to this profile. + pub file_count: usize, } impl AircraftProfile { @@ -125,6 +127,7 @@ impl AircraftProfile { AxisProfile::from_ratios(r1), AxisProfile::from_ratios(r2), ], + file_count: 0, } } diff --git a/src/main.rs b/src/main.rs index f09fb92..e86c20f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -159,6 +159,7 @@ use crate::pid_context::PidContext; // Data analysis imports use crate::data_analysis::calc_step_response; +use crate::data_analysis::calc_step_response::{compute_setpoint_authority, SetpointAuthority}; /// Expand input paths to a list of CSV files. /// If a path is a file, validate CSV extension before adding. @@ -487,7 +488,9 @@ fn profile_aircraft_group(files: &[String], debug_mode: bool) -> AircraftProfile total_events ); } - files_profiled += 1; + if total_events > 0 { + files_profiled += 1; + } } Ok((_, None, ..)) => { if debug_mode { @@ -512,7 +515,9 @@ fn profile_aircraft_group(files: &[String], debug_mode: bool) -> AircraftProfile ); } - AircraftProfile::from_axis_ratios(all_axis_ratios) + let mut profile = AircraftProfile::from_axis_ratios(all_axis_ratios); + profile.file_count = files_profiled; + profile } fn process_file( @@ -789,7 +794,7 @@ INFO ({input_file_str}): Skipping Step Response input data filtering: {reason}." // Only Roll (0) and Pitch (1) let axis_name = crate::axis_names::AXIS_NAMES[axis_index]; - if let Some((response_time, valid_stacked_responses, _valid_window_max_setpoints)) = + if let Some((response_time, valid_stacked_responses, valid_window_max_setpoints)) = &step_response_calculation_results[axis_index] { if valid_stacked_responses.shape()[0] > 0 && !response_time.is_empty() { @@ -931,7 +936,7 @@ INFO ({input_file_str}): Skipping Step Response input data filtering: {reason}." } } - println!("{axis_name}: Peak={peak_value:.3} → {assessment}"); + println!("{axis_name}: Actual Peak={peak_value:.3} → {assessment}"); // Always show current P:D ratio with quality assessment let axis_pid = if axis_index == 0 { @@ -940,11 +945,23 @@ INFO ({input_file_str}): Skipping Step Response input data filtering: {reason}." &pid_metadata.pitch }; - if let Some(p_val) = axis_pid.p { + if axis_pid.p.is_some() { println!(" Current P:D={current_pd_ratio:.2}"); // Needed in both branches below let dmax_enabled = pid_metadata.is_dmax_enabled(); + // Setpoint Authority from mean of per-window max setpoints + let (authority, authority_mean) = compute_setpoint_authority( + valid_window_max_setpoints.as_slice().unwrap_or(&[]), + ) + .unwrap_or((SetpointAuthority::Low, 0.0)); + println!( + " Setpoint Authority: {} (mean={:.0}dps \u{22a2}\u{2265}{}dps)", + authority.name(), + authority_mean, + crate::constants::LOW_AUTHORITY_SETPOINT_THRESHOLD_DEG_S as u32 + ); + // Show recommendations if they were computed (threshold exceeded) if recommended_pd_conservative[axis_index].is_some() { // Check for extreme overshoot (may indicate deeper issues) @@ -988,18 +1005,17 @@ INFO ({input_file_str}): Skipping Step Response input data filtering: {reason}." let d_max_str = recommended_d_max_conservative [axis_index] .map_or("N/A".to_string(), |v| v.to_string()); - println!(" Recommendation (conservative): P:D={:.2} → D-Min≈{}, D-Max≈{} (P={})", + println!(" Recommendation (conservative): P:D={:.2} (D-Min≈{}, D-Max≈{})", recommended_pd_conservative[axis_index].unwrap(), - d_min_str, d_max_str, p_val); + d_min_str, d_max_str); } else if let Some(recommended_d) = recommended_d_conservative[axis_index] { // D-Min/D-Max disabled: show only base D println!( - " Recommendation (conservative): P:D={:.2} → D≈{} (P={})", + " Recommendation (conservative): P:D={:.2} (D≈{})", recommended_pd_conservative[axis_index].unwrap(), - recommended_d, - p_val + recommended_d ); } @@ -1016,18 +1032,17 @@ INFO ({input_file_str}): Skipping Step Response input data filtering: {reason}." let d_max_str = recommended_d_max_aggressive [axis_index] .map_or("N/A".to_string(), |v| v.to_string()); - println!(" Recommendation (moderate): P:D={:.2} → D-Min≈{}, D-Max≈{} (P={})", + println!(" Recommendation (moderate): P:D={:.2} (D-Min≈{}, D-Max≈{})", recommended_pd_aggressive[axis_index].unwrap(), - d_min_str, d_max_str, p_val); + d_min_str, d_max_str); } else if let Some(recommended_d_mod) = recommended_d_aggressive[axis_index] { // D-Min/D-Max disabled: show only base D println!( - " Recommendation (moderate): P:D={:.2} → D≈{} (P={})", + " Recommendation (moderate): P:D={:.2} (D≈{})", recommended_pd_aggressive[axis_index].unwrap(), - recommended_d_mod, - p_val + recommended_d_mod ); } @@ -1055,11 +1070,11 @@ INFO ({input_file_str}): Skipping Step Response input data filtering: {reason}." .map_or("N/A".to_string(), |v| v.to_string()); let d_max_str = rec_d_max_agg .map_or("N/A".to_string(), |v| v.to_string()); - println!(" Recommendation (aggressive): P:D={:.2} → D-Min≈{}, D-Max≈{} (P={})", - aggressive_pd, d_min_str, d_max_str, p_val); + println!(" Recommendation (aggressive): P:D={:.2} (D-Min≈{}, D-Max≈{})", + aggressive_pd, d_min_str, d_max_str); } else if let Some(rec_d3) = rec_d_agg { - println!(" Recommendation (aggressive): P:D={:.2} → D≈{} (P={})", - aggressive_pd, rec_d3, p_val); + println!(" Recommendation (aggressive): P:D={:.2} (D≈{})", + aggressive_pd, rec_d3); } } } else if assessment == "Near optimal" { @@ -1084,16 +1099,15 @@ INFO ({input_file_str}): Skipping Step Response input data filtering: {reason}." }) .map_or("N/A".to_string(), |v| v.to_string()); println!( - " Recommendation (conservative): D-Min≈{}, D-Max≈{} (P={}) [optional D−1]", - d_min_str, d_max_str, p_val + " Recommendation (conservative): D-Min≈{}, D-Max≈{} [optional D−1]", + d_min_str, d_max_str ); } else if let Some(current_d) = axis_pid.d { println!( - " Recommendation (conservative): D≈{} (P={}) [optional D−1]", + " Recommendation (conservative): D≈{} [optional D−1]", current_d.saturating_sub( crate::constants::D_STEP_OPTIONAL - ), - p_val + ) ); } } else { @@ -1126,30 +1140,25 @@ INFO ({input_file_str}): Skipping Step Response input data filtering: {reason}." if analysis_opts.estimate_optimal_p { if let Some(sr) = sample_rate { - println!("\n--- Optimal P Estimation ---"); - println!("Td target: physics-derived from throttle-punch events in log group."); + let group_or_file = if aircraft_profile.file_count > 1 { + "group" + } else { + "file" + }; + println!("\n--- Optimal P (Experimental, log-derived) ---"); + println!( + "Td target: physics-derived from throttle-punch events in log {group_or_file}." + ); println!(); for axis_index in 0..crate::axis_names::ROLL_PITCH_AXIS_COUNT { // Only Roll (0) and Pitch (1) - Yaw excluded by ROLL_PITCH_AXIS_COUNT let axis_name = crate::axis_names::AXIS_NAMES[axis_index]; - if let Some((response_time, valid_stacked_responses, valid_window_max_setpoints)) = + if let Some((response_time, valid_stacked_responses, _valid_window_max_setpoints)) = &step_response_calculation_results[axis_index] { if valid_stacked_responses.shape()[0] > 0 && !response_time.is_empty() { - // Warn when inputs are too gentle to produce reliable step-response data - let max_sp = valid_window_max_setpoints - .iter() - .cloned() - .fold(0.0_f32, f32::max); - if max_sp < crate::constants::LOW_AUTHORITY_SETPOINT_THRESHOLD_DEG_S { - println!( - " ⚠ {axis_name}: [LOW AUTHORITY] max={:.0}dps — recommendations unreliable", - max_sp - ); - } - // Collect individual Td samples from each valid response window let mut td_samples_ms: Vec = Vec::new(); @@ -1247,7 +1256,10 @@ INFO ({input_file_str}): Skipping Step Response input data filtering: {reason}." recommended_pd_conservative[axis_index], physics_td, ) { - Ok(analysis) => { + Ok(mut analysis) => { + analysis.source_events = + aircraft_profile.axes[axis_index].event_count; + analysis.source_files = aircraft_profile.file_count; // Print console output println!("{}", analysis.format_console_output(axis_name)); // Store for PNG overlay (move instead of clone) diff --git a/src/plot_functions/plot_step_response.rs b/src/plot_functions/plot_step_response.rs index b71183d..e811574 100644 --- a/src/plot_functions/plot_step_response.rs +++ b/src/plot_functions/plot_step_response.rs @@ -15,6 +15,7 @@ use crate::constants::{ TD_CONSISTENCY_MIN_THRESHOLD, }; use crate::data_analysis::calc_step_response; // For average_responses and moving_average_smooth_f64 +use crate::data_analysis::calc_step_response::{compute_setpoint_authority, SetpointAuthority}; use crate::data_analysis::optimal_p_estimation::{OptimalPAnalysis, PRecommendation}; use crate::data_input::pid_metadata::PidMetadata; use crate::plot_framework::{draw_stacked_plot, PlotSeries}; @@ -305,7 +306,7 @@ pub fn plot_step_response( .map(|(&t, &v)| (t, v)) .collect(), label: format!( - "< {} deg/s (Peak: {peak_str}, Td: {latency_str})", + "< {} deg/s (Smoothed Peak: {peak_str}, Td: {latency_str})", display.setpoint_threshold ), color: color_low_sp, @@ -328,7 +329,7 @@ pub fn plot_step_response( .map(|(&t, &v)| (t, v)) .collect(), label: format!( - "\u{2265} {} deg/s (Peak: {peak_str}, Td: {latency_str})", + "\u{2265} {} deg/s (Smoothed Peak: {peak_str}, Td: {latency_str})", display.setpoint_threshold ), color: color_high_sp, @@ -350,7 +351,7 @@ pub fn plot_step_response( .zip(resp.iter()) .map(|(&t, &v)| (t, v)) .collect(), - label: format!("Combined (Peak: {peak_str}, Td: {latency_str})"), // This is the average of all Y-corrected & QC'd responses + label: format!("Combined (Smoothed Peak: {peak_str}, Td: {latency_str})"), // average of all Y-corrected & QC'd responses, post-smoothed color: color_combined, stroke_width: line_stroke_plot, }); @@ -372,7 +373,9 @@ pub fn plot_step_response( .zip(resp.iter()) .map(|(&t, &v)| (t, v)) .collect(), - label: format!("step-response (Peak: {peak_str}, Td: {latency_str})"), // This is the average of all Y-corrected & QC'd responses + label: format!( + "Step-Response Avg (Smoothed Peak: {peak_str}, Td: {latency_str})" + ), // average of all Y-corrected & QC'd responses, post-smoothed color: color_combined, stroke_width: line_stroke_plot, }); @@ -396,19 +399,27 @@ pub fn plot_step_response( // Add current P:D ratio with quality assessment as legend entries for Roll/Pitch if axis_index < 2 { - // Low-authority flight check: max setpoint across all valid windows. - let max_sp = valid_window_max_setpoints - .iter() - .cloned() - .fold(0.0_f32, f32::max); - let is_low_authority = max_sp < LOW_AUTHORITY_SETPOINT_THRESHOLD_DEG_S; + // Setpoint Authority from mean of per-window max setpoints. + let (authority, authority_mean) = compute_setpoint_authority( + valid_window_max_setpoints.as_slice().unwrap_or(&[]), + ) + .unwrap_or((SetpointAuthority::Low, 0.0)); + let is_low_authority = authority.is_low(); + + // Separator between curve legend entries and P:D section + series.push(PlotSeries { + data: vec![], + label: "─────────────────────".to_string(), + color: COLOR_OPTIMAL_P_DIVIDER, + stroke_width: 0, + }); // Current P:D ratio and assessment if let Some(current_pd) = current.pd_ratios[axis_index] { let current_label = if let Some(assessment) = current.assessments[axis_index] { if let Some(peak) = current.peak_values[axis_index] { format!( - "Current P:D={:.2} (Peak={:.2}, {})", + "Current P:D={:.2} (Actual Peak={:.2}, {})", current_pd, peak, assessment ) } else { @@ -425,14 +436,22 @@ pub fn plot_step_response( }); } - if is_low_authority { + // Setpoint Authority line — always shown, orange for LOW + { + let auth_color = if is_low_authority { + COLOR_OPTIMAL_P_WARNING + } else { + COLOR_OPTIMAL_P_TEXT + }; series.push(PlotSeries { data: vec![], label: format!( - "[LOW AUTHORITY] max={:.0}dps — recommendations unreliable", - max_sp + " Setpoint Authority: {} (mean={:.0}dps \u{22a2}\u{2265}{}dps)", + authority.name(), + authority_mean, + LOW_AUTHORITY_SETPOINT_THRESHOLD_DEG_S as u32 ), - color: COLOR_OPTIMAL_P_WARNING, // Orange warning + color: auth_color, stroke_width: 0, }); } @@ -446,17 +465,17 @@ pub fn plot_step_response( let d_max_str = conservative.0.d_max_values[axis_index] .map_or("N/A".to_string(), |v| v.to_string()); format!( - "Recommendation (conservative): P:D={:.2} (D-Min≈{}, D-Max≈{})", + " Recommendation (conservative): P:D={:.2} (D-Min≈{}, D-Max≈{})", rec_pd, d_min_str, d_max_str ) } else if let Some(rec_d) = conservative.0.d_values[axis_index] { // D-Min/D-Max disabled: show only base D format!( - "Recommendation (conservative): P:D={:.2} (D≈{})", + " Recommendation (conservative): P:D={:.2} (D≈{})", rec_pd, rec_d ) } else { - format!("Recommendation (conservative): P:D={:.2}", rec_pd) + format!(" Recommendation (conservative): P:D={:.2}", rec_pd) }; series.push(PlotSeries { data: vec![], @@ -479,16 +498,16 @@ pub fn plot_step_response( .map(|v| v.saturating_sub(crate::constants::D_STEP_OPTIONAL)) .map_or("N/A".to_string(), |v| v.to_string()); format!( - "Recommendation (conservative): D-Min≈{}, D-Max≈{} [optional D−1]", + " Recommendation (conservative): D-Min≈{}, D-Max≈{} [optional D−1]", d_min_str, d_max_str ) } else if let Some(current_d) = axis_pid_data.d { format!( - "Recommendation (conservative): D≈{} [optional D−1]", + " Recommendation (conservative): D≈{} [optional D−1]", current_d.saturating_sub(crate::constants::D_STEP_OPTIONAL) ) } else { - "Recommendation (none): No obvious tuning adjustments needed" + " Recommendation (none): No obvious tuning adjustments needed" .to_string() }; series.push(PlotSeries { @@ -502,7 +521,7 @@ pub fn plot_step_response( // Optimal zone (1.02–1.08): no adjustment needed series.push(PlotSeries { data: vec![], - label: "Recommendation (none): No obvious tuning adjustments needed" + label: " Recommendation (none): No obvious tuning adjustments needed" .to_string(), color: RGBColor(100, 100, 100), stroke_width: 0, @@ -518,14 +537,17 @@ pub fn plot_step_response( let d_max_str = moderate.0.d_max_values[axis_index] .map_or("N/A".to_string(), |v| v.to_string()); format!( - "Recommendation (moderate): P:D={:.2} (D-Min≈{}, D-Max≈{})", + " Recommendation (moderate): P:D={:.2} (D-Min≈{}, D-Max≈{})", rec_pd, d_min_str, d_max_str ) } else if let Some(rec_d) = moderate.0.d_values[axis_index] { // D-Min/D-Max disabled: show only base D - format!("Recommendation (moderate): P:D={:.2} (D≈{})", rec_pd, rec_d) + format!( + " Recommendation (moderate): P:D={:.2} (D≈{})", + rec_pd, rec_d + ) } else { - format!("Recommendation (moderate): P:D={:.2}", rec_pd) + format!(" Recommendation (moderate): P:D={:.2}", rec_pd) }; series.push(PlotSeries { data: vec![], @@ -550,16 +572,19 @@ pub fn plot_step_response( let d_max_str = rec_d_max_agg.map_or("N/A".to_string(), |v| v.to_string()); format!( - "Recommendation (aggressive): P:D={:.2} (D-Min≈{}, D-Max≈{})", + " Recommendation (aggressive): P:D={:.2} (D-Min≈{}, D-Max≈{})", aggressive_pd, d_min_str, d_max_str ) } else if let Some(rec_d3) = rec_d_agg { format!( - "Recommendation (aggressive): P:D={:.2} (D≈{})", + " Recommendation (aggressive): P:D={:.2} (D≈{})", aggressive_pd, rec_d3 ) } else { - format!("Recommendation (aggressive): P:D={:.2}", aggressive_pd) + format!( + " Recommendation (aggressive): P:D={:.2}", + aggressive_pd + ) }; series.push(PlotSeries { data: vec![], @@ -587,7 +612,7 @@ pub fn plot_step_response( series.push(PlotSeries { data: vec![], label: "Optimal P (Experimental, log-derived)".to_string(), - color: COLOR_OPTIMAL_P_HEADER, // Blue for section header + color: COLOR_OPTIMAL_P_HEADER, stroke_width: 0, }); @@ -595,16 +620,41 @@ pub fn plot_step_response( series.push(PlotSeries { data: vec![], label: format!( - " Td: {:.1}ms (target: {:.1}ms, windows={})", + " Td: {:.1}ms (target: {:.1}±{:.1}ms, windows={})", analysis.td_stats.mean_ms, analysis.td_target_ms, - analysis.td_stats.num_samples + analysis.td_tolerance_ms, + analysis.td_stats.num_samples, ), color: COLOR_OPTIMAL_P_TEXT, stroke_width: 0, }); - // Deviation: only prefix '+' for strictly positive deviations + // Td source: group/single, flights, punches + let source_label = if analysis.source_files > 1 { + "File Group" + } else { + "Single File" + }; + series.push(PlotSeries { + data: vec![], + label: format!( + " Td source: {} — {} flights, {} throttle-punches", + source_label, analysis.source_files, analysis.source_events, + ), + color: COLOR_OPTIMAL_P_TEXT, + stroke_width: 0, + }); + + // Noise + series.push(PlotSeries { + data: vec![], + label: format!(" Noise: {}", analysis.noise_level.name()), + color: COLOR_OPTIMAL_P_TEXT, + stroke_width: 0, + }); + + // Deviation let deviation_sign = if analysis.td_deviation_percent > 0.0 { "+" } else { @@ -616,116 +666,110 @@ pub fn plot_step_response( " Deviation: {}{:.1}% ({})", deviation_sign, analysis.td_deviation_percent, - analysis.td_deviation.name() + analysis.td_deviation.name(), ), color: COLOR_OPTIMAL_P_TEXT, stroke_width: 0, }); - // Noise level + // Current P series.push(PlotSeries { data: vec![], - label: format!(" Noise: {}", analysis.noise_level.name()), + label: format!(" Current P={}", analysis.current_p), color: COLOR_OPTIMAL_P_TEXT, stroke_width: 0, }); - // Reliability — always shown with both metrics; orange when poor - { - let cv_str = analysis.td_stats.coefficient_of_variation.map_or_else( - || "CV=N/A".to_string(), - |cv| { - format!( - "CV={:.1}% (≤{:.0}%)", - cv * 100.0, - TD_COEFFICIENT_OF_VARIATION_MAX * 100.0, - ) - }, - ); - let cons_str = format!( - "Consistency={:.0}% (≥{:.0}%)", - analysis.td_stats.consistency * 100.0, - TD_CONSISTENCY_MIN_THRESHOLD * 100.0, - ); - let (cons_label, cons_color) = if analysis.td_stats.is_consistent() { - ( - format!(" Reliable: {cons_str}, {cv_str}"), - COLOR_OPTIMAL_P_TEXT, - ) - } else { - ( - format!(" Unreliable: {cons_str}, {cv_str}"), - COLOR_OPTIMAL_P_WARNING, - ) - }; - series.push(PlotSeries { - data: vec![], - label: cons_label, - color: cons_color, - stroke_width: 0, - }); - } - // Recommendation summary - // Helper closure to compute D recommendation suffix. - // Prefers the step-response conservative P:D; falls back to the - // current P:D ratio so D is always shown when D gain is known. + // Recommendation — D-suffix helper let effective_pd = analysis.recommended_pd_conservative.or_else(|| { analysis .current_d .filter(|&d| d > 0) .map(|d| analysis.current_p as f64 / d as f64) }); - let append_d_recommendation = |recommended_p: u32| -> String { + let d_suffix = |recommended_p: u32| -> String { if let (Some(current_d), Some(rec_pd)) = (analysis.current_d, effective_pd) { if rec_pd > 0.0 && current_d > 0 { - let recommended_d = - ((recommended_p as f64) / rec_pd).round() as u32; - let d_delta = (recommended_d as i64) - (current_d as i64); - return format!(", D≈{} ({:+})", recommended_d, d_delta); + let rec_d = ((recommended_p as f64) / rec_pd).round() as u32; + let d_delta = (rec_d as i64) - (current_d as i64); + return format!(", D≈{} ({:+})", rec_d, d_delta); } } String::new() }; - let rec_summary = match &analysis.recommendation { + let rec_label = match &analysis.recommendation { + PRecommendation::Optimal { .. } => format!( + " Recommendation: Current P is optimal (P={})", + analysis.current_p + ), PRecommendation::Increase { conservative_p, .. } => { let p_delta = (*conservative_p as i64) - (analysis.current_p as i64); - let mut rec = format!( - " Recommendation (Conservative): P≈{} ({:+})", - conservative_p, p_delta - ); - rec.push_str(&append_d_recommendation(*conservative_p)); - rec - } - PRecommendation::Optimal { .. } => { format!( - " Recommendation: Current P is optimal (P = {})", - analysis.current_p + " Recommendation (Conservative): P≈{} ({:+}){}", + conservative_p, + p_delta, + d_suffix(*conservative_p), ) } PRecommendation::Decrease { recommended_p, .. } => { let p_delta = (*recommended_p as i64) - (analysis.current_p as i64); - let mut rec = format!( - " Recommendation: P≈{} ({:+})", - recommended_p, p_delta - ); - rec.push_str(&append_d_recommendation(*recommended_p)); - - rec + format!( + " Recommendation (Decrease): P≈{} ({:+}){}", + recommended_p, + p_delta, + d_suffix(*recommended_p), + ) } - PRecommendation::Investigate { .. } => { - " Recommendation: See console output for details".to_string() + PRecommendation::Investigate { issue } => { + format!(" Recommendation: Investigate — {}", issue) } }; series.push(PlotSeries { data: vec![], - label: rec_summary, - color: COLOR_OPTIMAL_P_RECOMMENDATION, // Green for recommendation + label: rec_label, + color: COLOR_OPTIMAL_P_RECOMMENDATION, stroke_width: 0, }); + + // Reliability — always shown, after recommendation; orange when poor + { + let cv_str = analysis.td_stats.coefficient_of_variation.map_or_else( + || "CV=N/A".to_string(), + |cv| { + format!( + "CV={:.1}% (⊢≤{:.0}%)", + cv * 100.0, + TD_COEFFICIENT_OF_VARIATION_MAX * 100.0, + ) + }, + ); + let cons_str = format!( + "Consistency={:.0}% (⊢≥{:.0}%)", + analysis.td_stats.consistency * 100.0, + TD_CONSISTENCY_MIN_THRESHOLD * 100.0, + ); + let (rel_label, rel_color) = if analysis.td_stats.is_consistent() { + ( + format!(" Reliable: {cons_str}, {cv_str}"), + COLOR_OPTIMAL_P_TEXT, + ) + } else { + ( + format!(" Unreliable: {cons_str}, {cv_str}"), + COLOR_OPTIMAL_P_WARNING, + ) + }; + series.push(PlotSeries { + data: vec![], + label: rel_label, + color: rel_color, + stroke_width: 0, + }); + } } else if let Some(skip_reason) = &optimal_p.skip_reasons[axis_index] { // Show skip reason if analysis failed but we have a reason series.push(PlotSeries {