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
89 changes: 88 additions & 1 deletion crates/analysis/src/alignment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,20 @@ use crate::peaks::{DetectParams, detect_peaks, estimate_noise};

const MIN_PROMINENCE_SIGMA: f64 = 5.0;

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PeakPolarity {
Positive,
Negative,
Magnitude,
}

/// The tallest significant peak of `ys` with `lo <= x <= hi`.
pub fn reference_peak(x: &[f64], ys: &[f64], lo: f64, hi: f64) -> Option<f64> {
let (lo, hi) = (lo.min(hi), lo.max(hi));
let mut xs_window = Vec::new();
let mut ys_window = Vec::new();
for (&x, &y) in x.iter().zip(ys) {
if x >= lo && x <= hi {
if x.is_finite() && y.is_finite() && x >= lo && x <= hi {
xs_window.push(x);
ys_window.push(y);
}
Expand All @@ -28,6 +35,68 @@ pub fn reference_peak(x: &[f64], ys: &[f64], lo: f64, hi: f64) -> Option<f64> {
.map(|peak| peak.x)
}

/// Most prominent significant feature of a generic trace in a displayed x window.
/// Magnitude compares upward and downward prominence rather than `abs(y)`, so a
/// non-zero baseline does not become a false feature.
pub fn trace_peak_anchor(
x: &[f64],
ys: &[f64],
lo: f64,
hi: f64,
polarity: PeakPolarity,
) -> Option<f64> {
let (lo, hi) = (lo.min(hi), lo.max(hi));
let mut xs = Vec::new();
let mut values = Vec::new();
for (&x, &y) in x.iter().zip(ys) {
if x.is_finite() && y.is_finite() && x >= lo && x <= hi {
xs.push(x);
values.push(y);
}
}
let scale = values
.iter()
.fold(0.0_f64, |max, value| max.max(value.abs()));
let floor = (MIN_PROMINENCE_SIGMA * estimate_noise(&values)).max(f64::EPSILON * scale.max(1.0));
let params = DetectParams {
min_height: None,
min_prominence: floor,
min_spacing: None,
max_count: Some(1),
};
let strongest = |values: &[f64]| detect_peaks(&xs, values, &params).into_iter().next();
match polarity {
PeakPolarity::Positive => strongest(&values).map(|peak| peak.x),
PeakPolarity::Negative => {
let inverted: Vec<_> = values.iter().map(|value| -*value).collect();
strongest(&inverted).map(|peak| peak.x)
}
PeakPolarity::Magnitude => {
let positive = strongest(&values);
let inverted: Vec<_> = values.iter().map(|value| -*value).collect();
let negative = strongest(&inverted);
match (positive, negative) {
(Some(up), Some(down)) => {
let mut sorted = values.clone();
sorted.sort_by(f64::total_cmp);
let baseline = sorted[sorted.len() / 2];
let up_excursion = (values[up.index] - baseline).abs();
let down_excursion = (values[down.index] - baseline).abs();
if down.prominence > up.prominence
|| (down.prominence == up.prominence && down_excursion > up_excursion)
{
Some(down.x)
} else {
Some(up.x)
}
}
(Some(peak), None) | (None, Some(peak)) => Some(peak.x),
(None, None) => None,
}
}
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -72,4 +141,22 @@ mod tests {
assert_eq!(reference_peak(&xs, &noisy, 6.0, 9.0), None);
assert!((reference_peak(&xs, &noisy, 0.0, 10.0).unwrap() - 2.0).abs() < 0.05);
}

#[test]
fn magnitude_compares_prominence_on_nonzero_baselines() {
let x: Vec<_> = (0..9).map(|i| i as f64).collect();
let y = vec![10.0, 10.0, 12.0, 10.0, 10.0, 4.0, 10.0, 10.0, 10.0];
assert_eq!(
trace_peak_anchor(&x, &y, 0.0, 8.0, PeakPolarity::Positive),
Some(2.0)
);
assert_eq!(
trace_peak_anchor(&x, &y, 0.0, 8.0, PeakPolarity::Negative),
Some(5.0)
);
assert_eq!(
trace_peak_anchor(&x, &y, 0.0, 8.0, PeakPolarity::Magnitude),
Some(5.0)
);
}
}
1 change: 1 addition & 0 deletions crates/app/src/ui/command_exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ fn execute_inner(
}
CommandId::SpectrumArithmetic => super::arithmetic::open_spectrum_arithmetic_dialog(app),
CommandId::AlignSpectra => super::align::open_align_spectra_dialog(app),
CommandId::AlignTraces => super::trace_alignment::open_active_trace_alignment_dialog(app),
CommandId::StackData => app.stack_selected_data(),
CommandId::ExtractMassSpectrum => {
app.set_tool(Tool::SelectRegion);
Expand Down
13 changes: 13 additions & 0 deletions crates/app/src/ui/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ pub struct RibbonPlacement {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Applicability {
Always,
LineAlignmentOnly,
TableOnly,
SeriesOnly,
Homonuclear2dOnly,
Expand Down Expand Up @@ -87,6 +88,7 @@ pub enum CommandId {
ApplyProcessingTemplate,
SpectrumArithmetic,
AlignSpectra,
AlignTraces,
StackData,
ExtractMassSpectrum,
SelectRange,
Expand Down Expand Up @@ -228,6 +230,7 @@ pub fn catalog(app: &PlotxApp) -> Vec<CommandDescriptor> {
CommandId::ApplyProcessingTemplate,
CommandId::SpectrumArithmetic,
CommandId::AlignSpectra,
CommandId::AlignTraces,
CommandId::StackData,
CommandId::ExtractMassSpectrum,
CommandId::SelectRange,
Expand Down Expand Up @@ -483,6 +486,10 @@ pub fn describe(app: &PlotxApp, id: CommandId) -> CommandDescriptor {
app.can_align_spectra(),
"Select at least two non-empty 1D NMR spectra, or clear the selection to use all spectra.",
),
CommandId::AlignTraces => requires(
app.trace_alignment_target().is_some(),
"Select a plot with at least two visible line series that use the same x-axis unit.",
),
CommandId::StackData => requires(
app.stackable_selection().is_some(),
"Select at least two compatible datasets. Trace collections such as electrophysiology require compatible axes and units.",
Expand Down Expand Up @@ -668,6 +675,7 @@ pub fn describe(app: &PlotxApp, id: CommandId) -> CommandDescriptor {
&& app.session.ui.processing_template_dialog.is_none()
&& app.session.ui.spectrum_arithmetic_dialog.is_none()
&& app.session.ui.align_spectra_dialog.is_none()
&& app.session.ui.trace_alignment_dialog.is_none()
&& app.session.ui.trace_composer.is_none()
&& !app.session.ui.interaction.is_active());
// Activation requirements must not trap an already-active tool after the
Expand All @@ -682,6 +690,7 @@ pub fn describe(app: &PlotxApp, id: CommandId) -> CommandDescriptor {
// `groups_for_tab`, pushing usable groups into the overflow menu.
let ribbon = ribbon_placement(id).filter(|placement| match placement.applicability {
Applicability::Always => true,
Applicability::LineAlignmentOnly => app.trace_alignment_target().is_some(),
Applicability::TableOnly => is_table(),
Applicability::SeriesOnly => is_series(),
Applicability::Homonuclear2dOnly => dataset()
Expand Down Expand Up @@ -737,3 +746,7 @@ mod mass_spec_tests;
#[cfg(test)]
#[path = "commands_xps_tests.rs"]
mod xps_tests;

#[cfg(test)]
#[path = "commands_alignment_tests.rs"]
mod alignment_tests;
2 changes: 2 additions & 0 deletions crates/app/src/ui/commands/identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ pub(super) fn command_identity(
}
CommandId::SpectrumArithmetic => plain("Spectrum Arithmetic…", Some(icon::MATH_OPERATIONS)),
CommandId::AlignSpectra => plain("Align Spectra…", Some(icon::ARROWS_LEFT_RIGHT)),
CommandId::AlignTraces => plain("Align Traces…", Some(icon::ARROWS_LEFT_RIGHT)),
CommandId::StackData => plain("Stack Selected Data", Some(icon::STACK)),
CommandId::ExtractMassSpectrum => plain("Extract Mass Spectrum", Some(icon::WAVE_SINE)),
CommandId::SelectRange => (
Expand Down Expand Up @@ -378,6 +379,7 @@ fn simple_stable_id(id: CommandId) -> &'static str {
CommandId::ApplyProcessingTemplate => "process.apply_template",
CommandId::SpectrumArithmetic => "process.arithmetic",
CommandId::AlignSpectra => "process.align_spectra",
CommandId::AlignTraces => "analysis.align_traces",
CommandId::StackData => "data.stack",
CommandId::ExtractMassSpectrum => "analysis.extract_mass_spectrum",
CommandId::SelectRange => "analysis.select_range",
Expand Down
3 changes: 2 additions & 1 deletion crates/app/src/ui/commands/ribbon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use plotx_core::state::{Tool, ToolGroup, WorkflowTab};
use super::{Applicability, CommandId, RibbonPlacement};

pub(super) fn ribbon_placement(id: CommandId) -> Option<RibbonPlacement> {
use Applicability::{Always, Homonuclear2dOnly, SeriesOnly, TableOnly};
use Applicability::{Always, Homonuclear2dOnly, LineAlignmentOnly, SeriesOnly, TableOnly};
use WorkflowTab::{Analyze, Arrange, Data, Figure, Process, View};
let (tab, group, priority, applicability) = match id {
CommandId::Tool(Tool::BrowseZoom) | CommandId::ZoomToFit | CommandId::ZoomToSelection => {
Expand All @@ -28,6 +28,7 @@ pub(super) fn ribbon_placement(id: CommandId) -> Option<RibbonPlacement> {
Applicability::ToolGroup(ToolGroup::Peaks),
),
CommandId::Tool(Tool::Symmetry) => (Analyze, "Review", 1, Homonuclear2dOnly),
CommandId::AlignTraces => (Analyze, "Align", 1, LineAlignmentOnly),
CommandId::Tool(Tool::ManualPhase) => (Process, "Correct", 0, Always),
CommandId::SpectrumArithmetic | CommandId::AlignSpectra => {
(Process, "Transform", 1, Always)
Expand Down
114 changes: 114 additions & 0 deletions crates/app/src/ui/commands_alignment_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
use super::*;
use plotx_core::actions::Action;
use plotx_core::state::{
CanvasObject, CanvasObjectKind, DEFAULT_CANVAS_SIZE_MM, Dataset, ElectrophysiologyDataset,
ObjectFrame, TextBox,
};

fn alignable_plot_app() -> PlotxApp {
let mut app = PlotxApp::new_with_settings(plotx_core::settings::Settings::default());
let recording = plotx_io::ElectrophysiologyData {
abf_version: "2.9.0.0".to_owned(),
sample_rate_hz: 10_000.0,
channels: vec![plotx_io::RecordedChannel {
name: "Current".to_owned(),
unit: plotx_io::ElectricalUnit::from_symbol("pA"),
}],
sweeps: vec![
plotx_io::Sweep {
start_time_s: 0.0,
channels: vec![vec![0.0, -1.0, 0.0]],
commands: Vec::new(),
},
plotx_io::Sweep {
start_time_s: 0.001,
channels: vec![vec![0.0, -2.0, 0.0]],
commands: Vec::new(),
},
],
protocol: None,
source: "alignment-command.abf".to_owned(),
import_warnings: Vec::new(),
};
let action = Action::insert_dataset_with_default_canvas(
&app,
Dataset::Electrophysiology(Box::new(ElectrophysiologyDataset::load(recording))),
"Alignment command".to_owned(),
DEFAULT_CANVAS_SIZE_MM,
);
app.execute_action(action);
app
}

#[test]
fn trace_alignment_has_one_contextual_ribbon_command() {
let empty = PlotxApp::new_with_settings(plotx_core::settings::Settings::default());
let unavailable = describe(&empty, CommandId::AlignTraces);
assert!(!unavailable.enabled);
assert_eq!(unavailable.ribbon, None);

let app = alignable_plot_app();
let command = describe(&app, CommandId::AlignTraces);
assert!(command.enabled);
assert_eq!(command.label, "Align Traces…");
assert_eq!(command.id.stable_id(), "analysis.align_traces");
assert_eq!(
command.ribbon,
Some(RibbonPlacement {
tab: WorkflowTab::Analyze,
group: "Align",
priority: 1,
applicability: Applicability::LineAlignmentOnly,
})
);
}

#[test]
fn ribbon_command_opens_the_shared_alignment_dialog() {
let mut app = alignable_plot_app();
let target = app.trace_alignment_target().unwrap();
execute_without_clipboard(CommandId::AlignTraces, &mut app, &egui::Context::default());
let dialog = app.session.ui.trace_alignment_dialog.as_ref().unwrap();
assert_eq!((dialog.canvas, dialog.object), target);
}

#[test]
fn ribbon_target_never_guesses_on_a_multi_plot_canvas() {
let mut app = alignable_plot_app();
let second_id = app.doc.canvases[0].allocate_object_id();
let second = app.build_plot_object(
0,
ObjectFrame::new(20.0, 20.0, 300.0, 200.0),
second_id,
"Second plot".to_owned(),
);
app.doc.canvases[0].objects.push(second);
app.doc.canvases[0].selected_object = None;
assert_eq!(app.trace_alignment_target(), None);
assert_eq!(describe(&app, CommandId::AlignTraces).ribbon, None);

app.doc.canvases[0].selected_object = Some(second_id);
assert_eq!(
app.trace_alignment_target(),
Some((app.doc.canvases[0].resource_id, second_id))
);
}

#[test]
fn selected_non_plot_never_falls_back_to_an_unrelated_plot() {
let mut app = alignable_plot_app();
let text_id = app.doc.canvases[0].allocate_object_id();
app.doc.canvases[0].objects.push(CanvasObject {
id: text_id,
name: "Note".to_owned(),
frame: ObjectFrame::new(0.0, 0.0, 120.0, 40.0),
locked: false,
visible: true,
group: None,
kind: CanvasObjectKind::Text(TextBox::label("Note".to_owned())),
});
app.doc.canvases[0].selected_object = Some(text_id);

assert_eq!(app.trace_alignment_target(), None);
assert_eq!(describe(&app, CommandId::AlignTraces).ribbon, None);
}
7 changes: 6 additions & 1 deletion crates/app/src/ui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ mod switcher;
#[cfg(not(target_os = "macos"))]
mod title_bar;
pub(crate) mod tools;
mod trace_alignment;
mod trace_composer;
mod windows;

Expand Down Expand Up @@ -91,6 +92,7 @@ pub fn render(
|| app.session.ui.processing_template_dialog.is_some()
|| app.session.ui.spectrum_arithmetic_dialog.is_some()
|| app.session.ui.align_spectra_dialog.is_some()
|| app.session.ui.trace_alignment_dialog.is_some()
|| app.session.ui.trace_composer.is_some()
|| app.session.ui.command_palette.is_some()
|| app.session.ui.save_project_options
Expand Down Expand Up @@ -172,6 +174,7 @@ pub fn render(
processing_templates::processing_template_window(app, &ctx);
arithmetic::spectrum_arithmetic_window(app, &ctx);
align::align_spectra_window(app, &ctx);
trace_alignment::trace_alignment_window(app, &ctx);
trace_composer::trace_composer_window(app, &ctx);
batch_workflow.show(app, &ctx);

Expand Down Expand Up @@ -349,9 +352,11 @@ fn feedback_banner(app: &mut PlotxApp, ui: &mut Ui, dark: bool) {

fn render_sidebars(app: &mut PlotxApp, ui: &mut Ui, dark: bool, workspace_width: f32) {
let compact = workspace_width < 1200.0;
if !app.session.secondary_sidebar_visible {
let inspector_visible = app.session.secondary_sidebar_visible;
if !inspector_visible {
app.finish_axis_overrides_edit();
}
object_inspector::finish_series_edit_if_inactive(app, inspector_visible);
if app.session.primary_sidebar_visible {
let panel = egui::Panel::left("primary_sidebar")
.frame(egui::Frame::NONE.inner_margin(egui::Margin {
Expand Down
20 changes: 20 additions & 0 deletions crates/app/src/ui/object_inspector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,26 @@ pub(crate) fn render(app: &mut PlotxApp, ui: &mut Ui) {
ui.add_space(2.0);
}

pub(crate) fn finish_series_edit_if_inactive(app: &mut PlotxApp, inspector_visible: bool) {
let target_changed = app
.session
.ui
.series_presentation_edit
.as_ref()
.is_some_and(|edit| {
app.session.active_canvas != Some(edit.canvas)
|| app
.doc
.canvases
.get(edit.canvas)
.and_then(|canvas| canvas.selected_object)
!= Some(edit.object)
});
if !inspector_visible || target_changed {
app.finish_series_presentation_edit();
}
}

fn inspector_header(app: &PlotxApp, canvas: Option<usize>, ids: &[ObjectId], ui: &mut Ui) {
let context = canvas
.map(|canvas| selection_context_label(app, canvas, ids))
Expand Down
Loading
Loading