diff --git a/CONTRIBUTOR-LICENSE-AGREEMENT.md b/CONTRIBUTOR-LICENSE-AGREEMENT.md index 7654273..a0487af 100644 --- a/CONTRIBUTOR-LICENSE-AGREEMENT.md +++ b/CONTRIBUTOR-LICENSE-AGREEMENT.md @@ -171,10 +171,10 @@ text does not change an Agreement already accepted. You accept this Agreement by posting the signature statement requested by the CLA assistant in a pull request in the official PlotX repository. Acceptance is -recorded in `signatures/version1/cla.json` together with your GitHub account, -the pull request, and the date. You accept once; the acceptance then covers -Your Contributions under this version, including any Contribution Submitted -before the date of acceptance. +recorded in the `cla-signatures` branch at `signatures/version1/cla.json`, +together with your GitHub account, the pull request, and the date. You accept +once; the acceptance then covers Your Contributions under this version, +including any Contribution Submitted before the date of acceptance. ## Attribution diff --git a/Cargo.lock b/Cargo.lock index b36b69b..f81c5d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -899,6 +899,17 @@ dependencies = [ "shlex", ] +[[package]] +name = "cfb" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a347dcabdae9c31b0825fd6a8bed285ec9c2acb89c47827126d52fa4f59cece3" +dependencies = [ + "fnv", + "uuid", + "web-time", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -2283,6 +2294,12 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -4226,7 +4243,9 @@ name = "plotx-io" version = "0.1.0" dependencies = [ "base64", + "byteorder", "calamine", + "cfb", "flate2", "image", "memmap2", diff --git a/README.md b/README.md index 72d28ec..e81cbe7 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,10 @@ preparation. ## Highlights - **Bring scientific data together.** Current import support includes Axon - ABF2 patch-clamp recordings, Rigaku powder XRD patterns, mzML and Waters - MassLynx LC–MS runs, JEOL Delta, Bruker TopSpin, and Varian/Agilent VnmrJ - experiments, JCAMP-DX spectra, archives, and delimited tables. + ABF2 patch-clamp recordings, Rigaku powder XRD patterns, mzML, Waters + MassLynx, and legacy SCIEX WIFF LC–MS runs, JEOL Delta, Bruker TopSpin, + and Varian/Agilent VnmrJ experiments, JCAMP-DX spectra, archives, and + delimited tables. - **Process and analyze interactively.** Build ordered processing pipelines, then pick peaks, integrate regions, and fit data. NMR workflows also include DOSY and relaxation analysis, plus sweep statistics and IV analysis for diff --git a/crates/analysis/src/craft.rs b/crates/analysis/src/craft.rs index ef170e3..772620b 100644 --- a/crates/analysis/src/craft.rs +++ b/crates/analysis/src/craft.rs @@ -94,6 +94,90 @@ pub enum CraftFitError { Singular, } +/// Replace the leading samples of a complex record by backward linear +/// prediction from the immediately following observed samples. +/// +/// CRAFT uses this after digital filtering because a finite FIR must invent a +/// prehistory at the acquisition boundary. The prediction is fitted in reverse +/// time, so the supplied autoregressive order has the same meaning as a +/// conventional forward linear predictor. +pub fn backward_linear_predict( + samples: &mut [Complex64], + predicted_count: usize, + training_count: usize, + order: usize, +) -> Result<(), CraftFitError> { + if predicted_count == 0 { + return Ok(()); + } + if order == 0 + || training_count <= order + || predicted_count + .checked_add(training_count) + .is_none_or(|required| required > samples.len()) + || samples + .iter() + .take(predicted_count + training_count) + .any(|value| !value.re.is_finite() || !value.im.is_finite()) + { + return Err(CraftFitError::InvalidInput); + } + + let training = &samples[predicted_count..predicted_count + training_count]; + let reversed = training.iter().rev().copied().collect::>(); + let scale = reversed + .iter() + .map(|value| value.norm()) + .fold(0.0_f64, f64::max); + if scale <= f64::MIN_POSITIVE { + return Err(CraftFitError::Singular); + } + let equation_count = reversed.len() - order; + let mut design = DMatrix::::zeros(equation_count * 2, order * 2); + let mut observed = DVector::::zeros(equation_count * 2); + for row in 0..equation_count { + let target = reversed[row + order]; + observed[row * 2] = target.re / scale; + observed[row * 2 + 1] = target.im / scale; + for lag in 0..order { + let basis = reversed[row + order - lag - 1] / scale; + design[(row * 2, lag * 2)] = basis.re; + design[(row * 2, lag * 2 + 1)] = -basis.im; + design[(row * 2 + 1, lag * 2)] = basis.im; + design[(row * 2 + 1, lag * 2 + 1)] = basis.re; + } + } + // Scale the singular-value cutoff by the design energy so rank detection + // remains stable across differently normalized input records. + let rank_tolerance = (5e-14 * design.norm_squared()).sqrt().max(1e-12); + let solution = design + .svd(true, true) + .solve(&observed, rank_tolerance) + .map_err(|_| CraftFitError::Singular)?; + let coefficients = solution + .as_slice() + .as_chunks::<2>() + .0 + .iter() + .map(|pair| Complex64::new(pair[0], pair[1])) + .collect::>(); + let mut history = reversed; + for index in 0..predicted_count { + let predicted = coefficients + .iter() + .enumerate() + .fold(Complex64::new(0.0, 0.0), |sum, (lag, coefficient)| { + sum + coefficient * history[history.len() - lag - 1] + }); + if !predicted.re.is_finite() || !predicted.im.is_finite() { + return Err(CraftFitError::Singular); + } + samples[predicted_count - index - 1] = predicted; + history.push(predicted); + } + Ok(()) +} + /// Fit a fixed set of initial component frequencies. Model-order selection and /// residual candidate discovery live in `plotx-processing`, beside its FFT. pub fn fit_damped_sinusoids_cancellable( diff --git a/crates/analysis/src/craft_tests.rs b/crates/analysis/src/craft_tests.rs index 4d24cfc..ffcfa52 100644 --- a/crates/analysis/src/craft_tests.rs +++ b/crates/analysis/src/craft_tests.rs @@ -23,6 +23,56 @@ fn synthetic( (times, samples) } +#[test] +fn backward_prediction_restores_filtered_record_leading_points() { + let components = [ + (13.0, 4.0, 0.3, 0.8), + (-21.0, 2.5, -0.4, 1.7), + (37.0, 1.2, 1.1, 2.4), + ]; + let (_, expected) = synthetic(&components, 300, 500.0); + let mut samples = expected.clone(); + samples[..5].fill(Complex64::new(100.0, -50.0)); + + backward_linear_predict(&mut samples, 5, 256, 32).unwrap(); + + for index in 0..5 { + assert!( + (samples[index] - expected[index]).norm() < 1e-7, + "index={index} predicted={:?} expected={:?}", + samples[index], + expected[index] + ); + } +} + +#[test] +fn backward_prediction_restores_a_short_single_exponential() { + let (_, expected) = synthetic(&[(0.0, 3.0, 0.4, 5.0)], 192, 4_000.0); + let mut samples = expected.clone(); + samples[..5].fill(Complex64::new(100.0, -50.0)); + + backward_linear_predict(&mut samples, 5, 187, 16).unwrap(); + + for index in 0..5 { + assert!( + (samples[index] - expected[index]).norm() < 1e-7, + "index={index} predicted={:?} expected={:?}", + samples[index], + expected[index] + ); + } +} + +#[test] +fn backward_prediction_rejects_an_underspecified_fit() { + let mut samples = vec![Complex64::new(1.0, 0.0); 12]; + assert_eq!( + backward_linear_predict(&mut samples, 5, 7, 7), + Err(CraftFitError::InvalidInput) + ); +} + #[test] fn recovers_single_damped_sinusoid() { let (times, samples) = synthetic(&[(123.4, 7.5, 0.37, 2.2)], 2048, 2000.0); diff --git a/crates/app/src/shot/craft_shot.rs b/crates/app/src/shot/craft_shot.rs index e1ce8dc..d152913 100644 --- a/crates/app/src/shot/craft_shot.rs +++ b/crates/app/src/shot/craft_shot.rs @@ -12,8 +12,7 @@ pub(super) fn setup(app: &mut PlotxApp, ctx: &egui::Context) -> Result<(), Strin .data .clone(); let mut params = CraftParams::conventional(); - params.max_fit_window_width_hz = data.spectral_width_hz; - params.max_components_per_fit_window = 8; + params.maximum_model_order = 8; let invocation = CraftInvocation::acquisition(&data, params); let result = process_craft_cancellable(&data, &invocation, &|| false) .map_err(|error| format!("CRAFT screenshot analysis failed: {error}"))?; diff --git a/crates/app/src/ui/affordance.rs b/crates/app/src/ui/affordance.rs new file mode 100644 index 0000000..53e20e7 --- /dev/null +++ b/crates/app/src/ui/affordance.rs @@ -0,0 +1,68 @@ +//! Shared visual language for click-to-enter surfaces. +//! +//! Ribbon buttons signal clickability by tinting their leading glyph with the +//! theme accent (`Visuals::hyperlink_color`, see `ribbon_button`). Task-card +//! rows that open an editor or reveal content on click reuse the same colour +//! through these helpers, so "this text is clickable" reads identically on +//! every surface instead of each card inventing its own (or, worse, plain +//! text that gives no signal until hovered). + +use egui::{Color32, Response, TextFormat, TextStyle, Ui, Visuals, text::LayoutJob}; + +/// The accent that marks a clickable surface — the exact colour the Ribbon +/// paints its enabled, unchecked button glyphs with. +pub(crate) fn clickable_tint(visuals: &Visuals) -> Color32 { + visuals.hyperlink_color +} + +/// A selectable row that reads as clickable while idle: the leading glyph +/// carries the clickable accent while the label keeps the theme text colour, +/// mirroring Ribbon buttons. A selected row falls back to the selection +/// styling wholesale so the accent never fights the checked state. +pub(crate) fn selectable_row( + ui: &mut Ui, + selected: bool, + glyph: &str, + label: impl Into, +) -> Response { + let font_id = TextStyle::Body.resolve(ui.style()); + let glyph_color = if selected { + Color32::PLACEHOLDER + } else { + clickable_tint(ui.visuals()) + }; + let mut job = LayoutJob::default(); + job.append( + glyph, + 0.0, + TextFormat { + font_id: font_id.clone(), + color: glyph_color, + ..Default::default() + }, + ); + job.append( + &format!(" {}", label.into()), + 0.0, + TextFormat { + font_id, + color: Color32::PLACEHOLDER, + ..Default::default() + }, + ); + ui.selectable_label(selected, job) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The clickable accent must stay the colour Ribbon glyphs use, in both + /// themes, so every "this is clickable" mark reads as one language. + #[test] + fn clickable_tint_matches_the_ribbon_glyph_colour() { + for visuals in [Visuals::light(), Visuals::dark()] { + assert_eq!(clickable_tint(&visuals), visuals.hyperlink_color); + } + } +} diff --git a/crates/app/src/ui/canvas/craft_results.rs b/crates/app/src/ui/canvas/craft_results.rs index 5e54a47..8cf2b3c 100644 --- a/crates/app/src/ui/canvas/craft_results.rs +++ b/crates/app/src/ui/canvas/craft_results.rs @@ -45,6 +45,17 @@ pub(crate) fn handle_and_paint_craft_result( else { return; }; + paint_craft_ranges(CraftRangePaintContext { + app, + dataset, + run, + stored, + nmr, + plot, + figure, + painter, + ui, + }); if let Some(selected) = app.session.ui.craft_selected_component && let Some(component) = stored .components @@ -102,3 +113,134 @@ pub(crate) fn handle_and_paint_craft_result( .open_task_tab(plotx_core::state::TaskDockTab::Craft); } } + +struct CraftRangePaintContext<'a> { + app: &'a PlotxApp, + dataset: plotx_core::state::DatasetId, + run: plotx_core::state::CraftRunId, + stored: &'a plotx_core::state::StoredCraftRun, + nmr: &'a plotx_core::state::NmrDataset, + plot: PlotRect, + figure: &'a plotx_figure::Figure, + painter: &'a egui::Painter, + ui: &'a Ui, +} + +fn paint_craft_ranges(context: CraftRangePaintContext<'_>) { + let CraftRangePaintContext { + app, + dataset, + run, + stored, + nmr, + plot, + figure, + painter, + ui, + } = context; + let carrier = stored + .provenance + .invocation + .reference + .effective_carrier_ppm(); + let observe = nmr.data.observe_freq_mhz; + let modeling = stored + .diagnostics + .modeling_windows + .iter() + .map(|window| { + ( + carrier + window.modeling_band_hz.0 / observe, + carrier + window.modeling_band_hz.1 / observe, + ) + }) + .collect::>(); + let regions = stored + .region_summaries + .iter() + .map(|region| (region.start_ppm, region.end_ppm)) + .collect::>(); + let report_segments = app + .session + .ui + .craft_selected_report + .and_then(|id| app.doc.report(id)) + .filter(|record| { + record.source + == plotx_core::state::ReportSource { + dataset, + craft_run: run, + } + }) + .and_then(|record| { + serde_json::from_value::( + record.snapshot.clone(), + ) + .ok() + }) + .map(|report| { + report + .segments + .into_iter() + .map(|segment| { + ( + carrier + segment.start_hz / observe, + carrier + segment.end_hz / observe, + ) + }) + .collect::>() + }) + .unwrap_or_default(); + + paint_range_track( + &modeling, + plot.top + 2.0, + plot, + figure, + painter, + ui.visuals().weak_text_color().linear_multiply(0.45), + ); + paint_range_track( + ®ions, + plot.top + 7.0, + plot, + figure, + painter, + ui.visuals().selection.stroke.color.linear_multiply(0.75), + ); + paint_range_track( + &report_segments, + plot.top + 12.0, + plot, + figure, + painter, + ui.visuals().warn_fg_color.linear_multiply(0.75), + ); +} + +fn paint_range_track( + ranges: &[(f64, f64)], + y: f32, + plot: PlotRect, + figure: &plotx_figure::Figure, + painter: &egui::Painter, + color: egui::Color32, +) { + for &(left, right) in ranges { + let first = x_to_screen(left, plot, figure.x.min, figure.x.span(), figure.x.reversed); + let second = x_to_screen( + right, + plot, + figure.x.min, + figure.x.span(), + figure.x.reversed, + ); + let rect = egui::Rect::from_min_max( + Pos2::new(first.min(second).max(plot.left), y), + Pos2::new(first.max(second).min(plot.right()), y + 3.0), + ); + if rect.is_positive() { + painter.rect_filled(rect, 0.0, color); + } + } +} diff --git a/crates/app/src/ui/canvas/mod.rs b/crates/app/src/ui/canvas/mod.rs index 62802f0..2c348b3 100644 --- a/crates/app/src/ui/canvas/mod.rs +++ b/crates/app/src/ui/canvas/mod.rs @@ -57,11 +57,13 @@ mod panel_selection; mod peaks; mod phase; mod readout; +mod reference_pick; mod regions; mod slices; mod snap; mod symmetry; mod tiling; +mod view_fit; pub(crate) use authoring::*; pub(crate) use board::*; @@ -85,12 +87,14 @@ pub(crate) use panel_notes::*; pub(crate) use peaks::*; pub(crate) use phase::*; pub(crate) use readout::*; +pub(crate) use reference_pick::*; pub(crate) use regions::*; pub(crate) use selection_painting::*; pub(crate) use slices::*; pub(crate) use snap::*; pub(crate) use symmetry::*; pub(crate) use tiling::*; +pub(crate) use view_fit::*; fn finite_rect_intersects(a: egui::Rect, b: egui::Rect) -> bool { let finite = |r: egui::Rect| { @@ -107,26 +111,8 @@ fn finite_rect_intersects(a: egui::Rect, b: egui::Rect) -> bool { } #[cfg(test)] -mod culling_tests { - use super::finite_rect_intersects; - - #[test] - fn edge_contact_is_visible_and_non_finite_is_not() { - let clip = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(10.0, 10.0)); - assert!(finite_rect_intersects( - egui::Rect::from_min_max(egui::pos2(10.0, 2.0), egui::pos2(20.0, 8.0)), - clip, - )); - assert!(!finite_rect_intersects( - egui::Rect::from_min_max(egui::pos2(11.0, 2.0), egui::pos2(20.0, 8.0)), - clip, - )); - assert!(!finite_rect_intersects( - egui::Rect::from_min_max(egui::pos2(f32::NAN, 0.0), egui::pos2(1.0, 1.0)), - clip, - )); - } -} +#[path = "mod_tests.rs"] +mod tests; #[derive(Clone, Copy)] pub(crate) enum CanvasInteractionClearScope { @@ -146,9 +132,7 @@ pub fn render_central(app: &mut PlotxApp, ui: &mut Ui) { let geometry = super::workspace_geometry(app, resp.rect, ui.ctx()); let rect = geometry.board_rect; let painter = painter.with_clip_rect(rect); - ui.ctx().data_mut(|data| { - data.insert_temp(egui::Id::new("plotx.canvas.navigation_rect"), rect); - }); + store_navigation_rect(ui.ctx(), rect); let chrome = ChromeStyle::from_visuals(ui.visuals(), app.settings.appearance.canvas_accent); consume_board_reveal(app, ui.ctx()); drive_board_fit(app, ui, &geometry); @@ -163,6 +147,13 @@ pub fn render_central(app: &mut PlotxApp, ui: &mut Ui) { sync_macos_trackpad_gesture(ui.ctx(), &ui.input(|i| i.events.clone()), pointer_owned); let view_consumed = pointer_owned && handle_navigation(app, ci, rect, ui); + // An armed Reference pick owns the pointer over its plot: a click there + // sets the step's source position instead of starting any other gesture. + // Navigation stays ahead of it so wheel and trackpad zoom keep working + // while aiming at a peak. + let view_consumed = + view_consumed || handle_reference_pick(app, ci, rect, ui, pointer_owned && !view_consumed); + let frame_consumed = if pointer_owned && !view_consumed && app.session.ui.interaction.allows_frame_dispatch() { dispatch_frame_gesture(app, rect, ui) @@ -388,6 +379,9 @@ pub fn render_central(app: &mut PlotxApp, ui: &mut Ui) { paint_integrals(app, ci, object_id, di, plot, &painter, chrome); paint_integrals_2d(app, ci, object_id, di, plot, &painter, chrome); paint_peaks(app, ci, object_id, di, plot, &painter, chrome); + if pointer_owned { + paint_reference_pick(app, ci, object_id, di, plot, ui, &painter, chrome); + } handle_and_paint_craft_result(app, ci, object_id, plot, &painter, ui); paint_cursor_tool(app, ci, object_id, di, plot, ui, &painter, chrome); paint_symmetry( @@ -599,198 +593,3 @@ fn resize_cursor(handle: ResizeHandle) -> egui::CursorIcon { ResizeHandle::TopRight | ResizeHandle::BottomLeft => egui::CursorIcon::ResizeNeSw, } } - -#[cfg(test)] -mod tests { - use super::*; - - use plotx_core::state::{CanvasObject, CanvasObjectKind, CanvasViewport, PlotObject, TextBox}; - use plotx_figure::{Axis, Figure}; - - #[test] - fn hit_object_selects_text_box() { - let mut canvas = CanvasDocument::new("page".to_owned(), [200.0, 200.0]); - canvas.objects.push(CanvasObject { - id: ObjectId::new(7), - name: "Text".to_owned(), - frame: ObjectFrame::new(20.0, 20.0, 100.0, 30.0), - locked: false, - visible: true, - kind: CanvasObjectKind::Text(TextBox::label("hi".to_owned())), - }); - - let hit = hit_object(&canvas, Pos2::new(50.0, 30.0), 1.0); - - assert_eq!(hit.map(|hit| hit.object), Some(ObjectId::new(7))); - } - - #[test] - fn hit_object_finds_object_outside_page_bounds() { - let mut canvas = CanvasDocument::new("page".to_owned(), [100.0, 100.0]); - canvas.objects.push(CanvasObject { - id: ObjectId::new(1), - name: "plot".to_owned(), - frame: ObjectFrame::new(-30.0, 20.0, 50.0, 40.0), - locked: false, - visible: true, - kind: CanvasObjectKind::Plot(Box::new({ - let figure = - Figure::new("plot", Axis::new("x", 0.0, 1.0), Axis::new("y", 0.0, 1.0)); - let viewport = CanvasViewport::from_figure(&figure); - PlotObject::new( - None, - plotx_core::state::SeriesId::new(1), - plotx_core::state::DataBinding { series: Vec::new() }, - plotx_core::state::ChartSpec::default(), - plotx_core::state::StackSpec::default(), - plotx_core::state::AxisProjections::default(), - plotx_core::state::AxisOverrides::default(), - figure, - viewport, - ) - })), - }); - - let hit = hit_object(&canvas, Pos2::new(-10.0, 30.0), 1.0); - - assert_eq!(hit.map(|hit| hit.object), Some(ObjectId::new(1))); - } - - #[test] - fn data_edit_target_requires_data_tool_and_selected_plot() { - let mut app = PlotxApp::new(); - let mut canvas = CanvasDocument::new("page".to_owned(), [200.0, 200.0]); - canvas.objects.push(CanvasObject { - id: ObjectId::new(3), - name: "plot".to_owned(), - frame: ObjectFrame::new(10.0, 10.0, 80.0, 60.0), - locked: false, - visible: true, - kind: CanvasObjectKind::Plot(Box::new({ - let figure = - Figure::new("plot", Axis::new("x", 0.0, 1.0), Axis::new("y", 0.0, 1.0)); - let viewport = CanvasViewport::from_figure(&figure); - PlotObject::new( - None, - plotx_core::state::SeriesId::new(1), - plotx_core::state::DataBinding { series: Vec::new() }, - plotx_core::state::ChartSpec::default(), - plotx_core::state::StackSpec::default(), - plotx_core::state::AxisProjections::default(), - plotx_core::state::AxisOverrides::default(), - figure, - viewport, - ) - })), - }); - app.doc.canvases.push(canvas); - app.session.active_canvas = Some(0); - app.doc.canvases[0].selected_object = Some(ObjectId::new(3)); - - app.session.tool = Tool::Select; - assert_eq!(data_edit_target(&app, 0), None); - - app.session.tool = Tool::BrowseZoom; - assert_eq!(data_edit_target(&app, 0), Some(ObjectId::new(3))); - } - - #[test] - fn phase_editor_open_drives_on_plot_pivot() { - use num_complex::Complex64; - use plotx_core::state::{Dataset, NmrDataset, PhaseAxis}; - use plotx_io::{Domain, NmrData}; - use std::f64::consts::TAU; - - let npoints = 256; - let (sw, obs, carrier) = (4000.0, 400.0, 5.0); - let dt = 1.0 / sw; - let points = (0..npoints) - .map(|k| { - let t = k as f64 * dt; - let decay = (-t / 0.25f64).exp(); - let freq_hz = (2.0 - carrier) * obs; - Complex64::from_polar(decay, TAU * freq_hz * t) - }) - .collect(); - let data = NmrData { - points, - domain: Domain::Time, - spectral_width_hz: sw, - observe_freq_mhz: obs, - carrier_ppm: carrier, - nucleus: "1H".to_owned(), - source: "synthetic".to_owned(), - group_delay: 0.0, - }; - - let mut app = PlotxApp::new(); - app.doc - .datasets - .push(Dataset::Nmr(Box::new(NmrDataset::load(data)))); - let mut canvas = CanvasDocument::new("page".to_owned(), [200.0, 200.0]); - let id = canvas.allocate_object_id(); - let obj = app.build_plot_object( - 0, - ObjectFrame::new(10.0, 10.0, 80.0, 60.0), - id, - "plot".into(), - ); - canvas.objects.push(obj); - app.doc.canvases.push(canvas); - app.session.active_canvas = Some(0); - app.focus_single(0); - - let pivot = Color32::from_rgb(0xE0, 0x6C, 0x22); - let count = |app: &mut PlotxApp| { - let input = egui::RawInput { - screen_rect: Some(egui::Rect::from_min_size( - egui::Pos2::ZERO, - egui::vec2(1000.0, 800.0), - )), - ..Default::default() - }; - let ctx = egui::Context::default(); - // Two passes: the first lays out the board, the second paints with a - // stable geometry. - let _ = ctx.run_ui(input.clone(), |ui| { - egui::CentralPanel::default().show_inside(ui, |ui| render_central(app, ui)); - }); - let out = ctx.run_ui(input, |ui| { - egui::CentralPanel::default().show_inside(ui, |ui| render_central(app, ui)); - }); - out.shapes - .iter() - .filter(|cs| match &cs.shape { - egui::epaint::Shape::LineSegment { stroke, .. } => stroke.color == pivot, - egui::epaint::Shape::Circle(c) => c.fill == pivot, - _ => false, - }) - .count() - }; - - let phase_id = app.doc.datasets[0] - .axis_pipeline(PhaseAxis::Direct) - .unwrap() - .steps - .iter() - .find(|s| matches!(s.kind, plotx_processing::StepKind::Phase(_))) - .unwrap() - .id; - - app.sync_phase_interaction(); - assert_eq!(count(&mut app), 0, "no pivot before the Phase editor opens"); - - app.session.ui.proc_expanded_step = Some((app.doc.datasets[0].resource_id(), phase_id)); - app.sync_phase_interaction(); - assert_eq!(app.session.tool, Tool::ManualPhase); - assert!( - count(&mut app) > 0, - "pivot appears while the Phase editor is open" - ); - - app.session.ui.proc_expanded_step = None; - app.sync_phase_interaction(); - assert_ne!(app.session.tool, Tool::ManualPhase); - assert_eq!(count(&mut app), 0, "pivot gone after the editor collapses"); - } -} diff --git a/crates/app/src/ui/canvas/mod_tests.rs b/crates/app/src/ui/canvas/mod_tests.rs new file mode 100644 index 0000000..282a048 --- /dev/null +++ b/crates/app/src/ui/canvas/mod_tests.rs @@ -0,0 +1,206 @@ +use super::*; + +use plotx_core::state::{CanvasObject, CanvasObjectKind, CanvasViewport, PlotObject, TextBox}; +use plotx_figure::{Axis, Figure}; + +#[test] +fn edge_contact_is_visible_and_non_finite_is_not() { + let clip = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(10.0, 10.0)); + assert!(finite_rect_intersects( + egui::Rect::from_min_max(egui::pos2(10.0, 2.0), egui::pos2(20.0, 8.0)), + clip, + )); + assert!(!finite_rect_intersects( + egui::Rect::from_min_max(egui::pos2(11.0, 2.0), egui::pos2(20.0, 8.0)), + clip, + )); + assert!(!finite_rect_intersects( + egui::Rect::from_min_max(egui::pos2(f32::NAN, 0.0), egui::pos2(1.0, 1.0)), + clip, + )); +} + +#[test] +fn hit_object_selects_text_box() { + let mut canvas = CanvasDocument::new("page".to_owned(), [200.0, 200.0]); + canvas.objects.push(CanvasObject { + id: ObjectId::new(7), + name: "Text".to_owned(), + frame: ObjectFrame::new(20.0, 20.0, 100.0, 30.0), + locked: false, + visible: true, + kind: CanvasObjectKind::Text(TextBox::label("hi".to_owned())), + }); + + let hit = hit_object(&canvas, Pos2::new(50.0, 30.0), 1.0); + + assert_eq!(hit.map(|hit| hit.object), Some(ObjectId::new(7))); +} + +#[test] +fn hit_object_finds_object_outside_page_bounds() { + let mut canvas = CanvasDocument::new("page".to_owned(), [100.0, 100.0]); + canvas.objects.push(CanvasObject { + id: ObjectId::new(1), + name: "plot".to_owned(), + frame: ObjectFrame::new(-30.0, 20.0, 50.0, 40.0), + locked: false, + visible: true, + kind: CanvasObjectKind::Plot(Box::new({ + let figure = Figure::new("plot", Axis::new("x", 0.0, 1.0), Axis::new("y", 0.0, 1.0)); + let viewport = CanvasViewport::from_figure(&figure); + PlotObject::new( + None, + plotx_core::state::SeriesId::new(1), + plotx_core::state::DataBinding { series: Vec::new() }, + plotx_core::state::ChartSpec::default(), + plotx_core::state::StackSpec::default(), + plotx_core::state::AxisProjections::default(), + plotx_core::state::AxisOverrides::default(), + figure, + viewport, + ) + })), + }); + + let hit = hit_object(&canvas, Pos2::new(-10.0, 30.0), 1.0); + + assert_eq!(hit.map(|hit| hit.object), Some(ObjectId::new(1))); +} + +#[test] +fn data_edit_target_requires_data_tool_and_selected_plot() { + let mut app = PlotxApp::new(); + let mut canvas = CanvasDocument::new("page".to_owned(), [200.0, 200.0]); + canvas.objects.push(CanvasObject { + id: ObjectId::new(3), + name: "plot".to_owned(), + frame: ObjectFrame::new(10.0, 10.0, 80.0, 60.0), + locked: false, + visible: true, + kind: CanvasObjectKind::Plot(Box::new({ + let figure = Figure::new("plot", Axis::new("x", 0.0, 1.0), Axis::new("y", 0.0, 1.0)); + let viewport = CanvasViewport::from_figure(&figure); + PlotObject::new( + None, + plotx_core::state::SeriesId::new(1), + plotx_core::state::DataBinding { series: Vec::new() }, + plotx_core::state::ChartSpec::default(), + plotx_core::state::StackSpec::default(), + plotx_core::state::AxisProjections::default(), + plotx_core::state::AxisOverrides::default(), + figure, + viewport, + ) + })), + }); + app.doc.canvases.push(canvas); + app.session.active_canvas = Some(0); + app.doc.canvases[0].selected_object = Some(ObjectId::new(3)); + + app.session.tool = Tool::Select; + assert_eq!(data_edit_target(&app, 0), None); + + app.session.tool = Tool::BrowseZoom; + assert_eq!(data_edit_target(&app, 0), Some(ObjectId::new(3))); +} + +#[test] +fn phase_editor_open_drives_on_plot_pivot() { + use num_complex::Complex64; + use plotx_core::state::{Dataset, NmrDataset, PhaseAxis}; + use plotx_io::{Domain, NmrData}; + use std::f64::consts::TAU; + + let npoints = 256; + let (sw, obs, carrier) = (4000.0, 400.0, 5.0); + let dt = 1.0 / sw; + let points = (0..npoints) + .map(|k| { + let t = k as f64 * dt; + let decay = (-t / 0.25f64).exp(); + let freq_hz = (2.0 - carrier) * obs; + Complex64::from_polar(decay, TAU * freq_hz * t) + }) + .collect(); + let data = NmrData { + points, + domain: Domain::Time, + spectral_width_hz: sw, + observe_freq_mhz: obs, + carrier_ppm: carrier, + nucleus: "1H".to_owned(), + source: "synthetic".to_owned(), + group_delay: 0.0, + }; + + let mut app = PlotxApp::new(); + app.doc + .datasets + .push(Dataset::Nmr(Box::new(NmrDataset::load(data)))); + let mut canvas = CanvasDocument::new("page".to_owned(), [200.0, 200.0]); + let id = canvas.allocate_object_id(); + let obj = app.build_plot_object( + 0, + ObjectFrame::new(10.0, 10.0, 80.0, 60.0), + id, + "plot".into(), + ); + canvas.objects.push(obj); + app.doc.canvases.push(canvas); + app.session.active_canvas = Some(0); + app.focus_single(0); + + let pivot = Color32::from_rgb(0xE0, 0x6C, 0x22); + let count = |app: &mut PlotxApp| { + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(1000.0, 800.0), + )), + ..Default::default() + }; + let ctx = egui::Context::default(); + // Two passes: the first lays out the board, the second paints with a + // stable geometry. + let _ = ctx.run_ui(input.clone(), |ui| { + egui::CentralPanel::default().show_inside(ui, |ui| render_central(app, ui)); + }); + let out = ctx.run_ui(input, |ui| { + egui::CentralPanel::default().show_inside(ui, |ui| render_central(app, ui)); + }); + out.shapes + .iter() + .filter(|cs| match &cs.shape { + egui::epaint::Shape::LineSegment { stroke, .. } => stroke.color == pivot, + egui::epaint::Shape::Circle(c) => c.fill == pivot, + _ => false, + }) + .count() + }; + + let phase_id = app.doc.datasets[0] + .axis_pipeline(PhaseAxis::Direct) + .unwrap() + .steps + .iter() + .find(|s| matches!(s.kind, plotx_processing::StepKind::Phase(_))) + .unwrap() + .id; + + app.sync_phase_interaction(); + assert_eq!(count(&mut app), 0, "no pivot before the Phase editor opens"); + + app.session.ui.proc_expanded_step = Some((app.doc.datasets[0].resource_id(), phase_id)); + app.sync_phase_interaction(); + assert_eq!(app.session.tool, Tool::ManualPhase); + assert!( + count(&mut app) > 0, + "pivot appears while the Phase editor is open" + ); + + app.session.ui.proc_expanded_step = None; + app.sync_phase_interaction(); + assert_ne!(app.session.tool, Tool::ManualPhase); + assert_eq!(count(&mut app), 0, "pivot gone after the editor collapses"); +} diff --git a/crates/app/src/ui/canvas/painting.rs b/crates/app/src/ui/canvas/painting.rs index 5a0c95d..b562faa 100644 --- a/crates/app/src/ui/canvas/painting.rs +++ b/crates/app/src/ui/canvas/painting.rs @@ -592,7 +592,7 @@ pub(crate) fn paint_peaks( } } - let resolved = peaks.resolve(); + let resolved = peaks.resolve(app.doc.datasets[dataset].peak_reference_offset_ppm()); let selected = app.session.ui.selected_peak; for peak in &resolved { let p = Pos2::new(sx(peak.x), sy(peak.y)); @@ -649,7 +649,10 @@ pub(crate) fn paint_peaks( return; } let hover_x = screen_to_x(hp.x, plot, fig.x.min, fig.x.span(), fig.x.reversed); - let (px, py) = trace.snap(hover_x); + // The preview must resolve exactly as the click would, modifier included. + let shift = painter.ctx().input(|i| i.modifiers.shift); + let snap = super::peaks::manual_peak_snap(shift, fig.x.span(), plot.width); + let (px, py) = trace.pick(hover_x, snap); let at = Pos2::new(sx(px), sy(py)); if plot_contains(plot, at) { painter.circle_stroke(at, 4.0, Stroke::new(1.5_f32, chrome.selection_active)); diff --git a/crates/app/src/ui/canvas/peaks.rs b/crates/app/src/ui/canvas/peaks.rs index 768659d..4298e9c 100644 --- a/crates/app/src/ui/canvas/peaks.rs +++ b/crates/app/src/ui/canvas/peaks.rs @@ -1,9 +1,27 @@ use super::*; -use plotx_core::state::{PeakBandDrag, PeakSet, PeakThresholdDrag, ResolvedPeak, Trace1d}; +use plotx_core::state::{ + ManualPeakSnap, PeakBandDrag, PeakSet, PeakThresholdDrag, ResolvedPeak, Trace1d, +}; const PEAK_GRAB_PX: f32 = 10.0; const LINE_GRAB_PX: f32 = 6.0; const DRAG_DEADZONE_PX: f32 = 4.0; +/// Pixel radius of the manual-pick apex search. Fixed in screen space so +/// zooming in narrows the data window along with the click precision it +/// affords, letting weak lines be picked next to strong ones. +const PEAK_SNAP_PX: f32 = 12.0; + +/// The snap for a manual pick: an apex search over ± `PEAK_SNAP_PX` at the +/// current zoom, or the nearest sample while `Shift` is held (free placement +/// for shoulders the apex search refuses to land on). +pub(crate) fn manual_peak_snap(shift: bool, x_span: f64, plot_width: f32) -> ManualPeakSnap { + if shift { + return ManualPeakSnap::NearestSample; + } + ManualPeakSnap::Apex { + half_width: x_span.abs() / f64::from(plot_width.max(1.0)) * f64::from(PEAK_SNAP_PX), + } +} fn peak_hit(resolved: &[ResolvedPeak], sc: &Screen, p: Pos2) -> Option { let mut best: Option<(u64, f32)> = None; @@ -93,7 +111,7 @@ pub(crate) fn handle_peaks( yrev: fig.y.reversed, }; - let (hover, pressed, down, released, del, esc) = ui.input(|i| { + let (hover, pressed, down, released, del, esc, shift) = ui.input(|i| { ( i.pointer.hover_pos(), i.pointer.primary_pressed(), @@ -101,10 +119,11 @@ pub(crate) fn handle_peaks( i.pointer.primary_released(), i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace), i.key_pressed(egui::Key::Escape), + i.modifiers.shift, ) }); - let resolved = peaks.resolve(); + let resolved = peaks.resolve(app.doc.datasets[dataset].peak_reference_offset_ppm()); peak_context_menu(app, dataset, &resolved, &sc, hover, resp); if esc { @@ -146,7 +165,7 @@ pub(crate) fn handle_peaks( drag.current_x = sc.to_x(p.x.clamp(plot.left, plot.right())); } if released || !down { - finish_band_drag(app, dataset, &sc, column); + finish_band_drag(app, dataset, &sc, column, shift); } return; } @@ -212,18 +231,20 @@ fn finish_threshold_drag( } /// A band wider than the click dead-zone picks every peak inside it; a narrower one -/// is a plain click that places a single snapped peak. +/// is a plain click that places a single snapped peak (`Shift` skips the snap). fn finish_band_drag( app: &mut PlotxApp, dataset: usize, sc: &Screen, column: Option, + shift: bool, ) { let Interaction::PeakBand(drag) = app.take_interaction() else { return; }; if (sc.x(drag.anchor_x) - sc.x(drag.current_x)).abs() < DRAG_DEADZONE_PX { - app.add_manual_peak(dataset, drag.anchor_x, column); + let snap = manual_peak_snap(shift, sc.xspan, sc.plot.width); + app.add_manual_peak(dataset, drag.anchor_x, column, snap); } else { app.add_peaks_in_range(dataset, drag.anchor_x, drag.current_x, column); } diff --git a/crates/app/src/ui/canvas/reference_pick.rs b/crates/app/src/ui/canvas/reference_pick.rs new file mode 100644 index 0000000..51e8d6a --- /dev/null +++ b/crates/app/src/ui/canvas/reference_pick.rs @@ -0,0 +1,265 @@ +//! The one-shot on-plot pick for a Reference step's source position. +//! +//! While the step editor arms a pick, hovering the target plot previews the +//! snapped position and a click writes the step's `at_ppm` through the +//! property catalog (one undo step, same recompute as typing the number). +//! The mode's lifetime is owned by `PlotxApp::resolve_reference_pick`. + +use super::*; +use plotx_core::automation::{ComponentRef, ResourceRef, TargetRef}; +use plotx_core::properties::PropertyValue; +use plotx_core::state::{PhaseOrient, ResolvedReferencePick}; + +#[cfg(test)] +#[path = "reference_pick_tests.rs"] +mod tests; + +/// Screen-space geometry of the pick target plot: the axis mappings copied out +/// of the figure so previews and commits share one conversion. +#[derive(Clone, Copy)] +struct PickGeometry { + xmin: f64, + xspan: f64, + xrev: bool, + ymin: f64, + yspan: f64, + yrev: bool, +} + +impl PickGeometry { + fn of(figure: &plotx_figure::Figure) -> Self { + Self { + xmin: figure.x.min, + xspan: figure.x.span(), + xrev: figure.x.reversed, + ymin: figure.y.min, + yspan: figure.y.span(), + yrev: figure.y.reversed, + } + } +} + +/// The previewed pick under the pointer: the position in finished-axis ppm and +/// where its guide line and optional apex dot sit on screen. +struct PickedPosition { + ppm: f64, + line_px: f32, + apex: Option, +} + +/// Input half of the pick. Returns `true` while the pointer hovers the armed +/// plot, so the caller keeps layout and data gestures from starting under a +/// click that is meant for the pick. `pointer_allowed` carries the caller's +/// pointer-ownership verdict; Escape disarms regardless of it. +pub(crate) fn handle_reference_pick( + app: &mut PlotxApp, + ci: usize, + board_rect: egui::Rect, + ui: &Ui, + pointer_allowed: bool, +) -> bool { + let Some(resolved) = app.resolve_reference_pick() else { + return false; + }; + let (esc, hover, pressed, shift) = ui.input(|input| { + ( + input.key_pressed(egui::Key::Escape), + input.pointer.hover_pos(), + input.pointer.primary_pressed(), + input.modifiers.shift, + ) + }); + if esc { + app.session.ui.reference_pick = None; + app.session.status = "Reference pick cancelled.".to_owned(); + return false; + } + if !pointer_allowed || app.interaction().is_active() { + return false; + } + let Some((plot, geometry)) = pick_plot(app, ci, board_rect, &resolved) else { + return false; + }; + let Some(p) = hover.filter(|p| plot_contains(plot, *p)) else { + return false; + }; + if pressed && let Some(picked) = picked_position(app, &resolved, geometry, plot, p, shift) { + commit_reference_pick(app, &resolved, picked.ppm); + } + true +} + +/// Preview half of the pick, painted with the other plot chrome: a guide line +/// at the snapped position, its apex, and a ppm readout by the cursor. +#[allow(clippy::too_many_arguments)] +pub(crate) fn paint_reference_pick( + app: &PlotxApp, + ci: usize, + object_id: ObjectId, + di: usize, + plot: PlotRect, + ui: &Ui, + painter: &egui::Painter, + chrome: ChromeStyle, +) { + let Some(resolved) = app.resolve_reference_pick() else { + return; + }; + if resolved.dataset_index != di || app.interaction().is_active() { + return; + } + let Some(figure) = app.doc.canvases[ci] + .object(object_id) + .and_then(|object| object.plot()) + .map(|plot| plot.figure()) + else { + return; + }; + let geometry = PickGeometry::of(figure); + let (hover, shift) = ui.input(|input| (input.pointer.hover_pos(), input.modifiers.shift)); + let Some(p) = hover.filter(|p| plot_contains(plot, *p)) else { + return; + }; + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + let Some(picked) = picked_position(app, &resolved, geometry, plot, p, shift) else { + return; + }; + let stroke = Stroke::new(1.5_f32, chrome.pivot); + let (a, b) = match resolved.axis.orient() { + PhaseOrient::Vertical => ( + Pos2::new(picked.line_px, plot.top), + Pos2::new(picked.line_px, plot.bottom()), + ), + PhaseOrient::Horizontal => ( + Pos2::new(plot.left, picked.line_px), + Pos2::new(plot.right(), picked.line_px), + ), + }; + // Dashed, so the transient pick guide never reads as the phase pivot line. + painter.add(egui::Shape::dashed_line(&[a, b], stroke, 5.0, 4.0)); + if let Some(apex) = picked.apex { + painter.circle_filled(apex, 3.5, chrome.pivot); + } + let galley = painter.layout_no_wrap( + format!("{:.3} ppm", picked.ppm), + egui::FontId::proportional(11.0), + chrome.pivot, + ); + let anchor = p + egui::vec2(12.0, -18.0); + painter.rect_filled( + egui::Rect::from_min_size(anchor, galley.size()).expand(3.0), + 3.0, + Color32::from_black_alpha(if ui.visuals().dark_mode { 150 } else { 20 }), + ); + painter.galley(anchor, galley, chrome.pivot); +} + +/// The plot the armed pick may act on: the canvas's data-edit or active plot, +/// but only while it shows the picked dataset. +fn pick_plot( + app: &PlotxApp, + ci: usize, + board_rect: egui::Rect, + resolved: &ResolvedReferencePick, +) -> Option<(PlotRect, PickGeometry)> { + let object_id = + data_edit_target(app, ci).or_else(|| app.doc.canvases[ci].active_plot_object_id())?; + let object = app.doc.canvases[ci].object(object_id)?; + let di = object.dataset().and_then(|id| app.doc.dataset_index(id))?; + if di != resolved.dataset_index { + return None; + } + let outer = object_screen_rect( + app.session.board, + &app.doc.canvases[ci], + object_id, + board_rect, + )?; + let figure = object.plot()?.figure(); + let zoom = app.session.board.zoom; + let layout = plotx_render::axis_layout(figure, outer.width / zoom, outer.height / zoom); + let plot = plotx_render::Projector::new(figure, outer, &layout.margins.scaled(zoom)).plot; + Some((plot, PickGeometry::of(figure))) +} + +/// Resolve the pointer into a picked position. On a 1D trace the pick snaps to +/// the same zoom-scaled apex search manual peak picking uses (`Shift` = nearest +/// sample); an axis without a 1D trace (a 2D dimension) picks the raw +/// coordinate. +fn picked_position( + app: &PlotxApp, + resolved: &ResolvedReferencePick, + geometry: PickGeometry, + plot: PlotRect, + p: Pos2, + shift: bool, +) -> Option { + match resolved.axis.orient() { + PhaseOrient::Vertical => { + let x = screen_to_x(p.x, plot, geometry.xmin, geometry.xspan, geometry.xrev); + let trace = app + .doc + .datasets + .get(resolved.dataset_index) + .and_then(|dataset| dataset.displayed_trace(None)); + match trace { + Some(trace) => { + let snap = super::peaks::manual_peak_snap(shift, geometry.xspan, plot.width); + let (px, py) = trace.pick(x, snap); + let sx = x_to_screen(px, plot, geometry.xmin, geometry.xspan, geometry.xrev); + let sy = y_to_screen(py, plot, geometry.ymin, geometry.yspan, geometry.yrev); + Some(PickedPosition { + ppm: px, + line_px: sx, + apex: plot_contains(plot, Pos2::new(sx, sy)).then_some(Pos2::new(sx, sy)), + }) + } + None => Some(PickedPosition { + ppm: x, + line_px: p.x, + apex: None, + }), + } + } + PhaseOrient::Horizontal => Some(PickedPosition { + ppm: screen_to_y(p.y, plot, geometry.ymin, geometry.yspan, geometry.yrev), + line_px: p.y, + apex: None, + }), + } +} + +/// Write the picked position into the step's `at_ppm` through the property +/// catalog. The displayed ppm converts into the step's own axis coordinates by +/// removing the calibration that reference steps from this one onward apply +/// (`AxisPipeline::chemical_shift_offset_from_step_ppm`), so after the edit the +/// picked feature reads exactly `target_ppm`. +fn commit_reference_pick(app: &mut PlotxApp, resolved: &ResolvedReferencePick, picked_ppm: f64) { + let offset = app + .doc + .datasets + .get(resolved.dataset_index) + .and_then(|dataset| dataset.axis_pipeline(resolved.axis)) + .map(|pipeline| pipeline.chemical_shift_offset_from_step_ppm(resolved.pick.step)) + .unwrap_or(0.0); + let at_ppm = picked_ppm - offset; + app.session.ui.reference_pick = None; + let target = TargetRef { + resource: ResourceRef::from(resolved.pick.dataset), + component: Some(ComponentRef::ProcessingStep(resolved.pick.step)), + }; + match app.plan_property_write( + plotx_core::properties::reference::AT_PPM, + std::slice::from_ref(&target), + &PropertyValue::Float(at_ppm), + ) { + Ok(commit) => { + app.commit_property(commit); + app.session.status = + format!("Reference source set to {at_ppm:.3} ppm — now set the target position."); + } + Err(error) => { + app.session.status = format!("Could not set the reference source: {error}"); + } + } +} diff --git a/crates/app/src/ui/canvas/reference_pick_tests.rs b/crates/app/src/ui/canvas/reference_pick_tests.rs new file mode 100644 index 0000000..b199883 --- /dev/null +++ b/crates/app/src/ui/canvas/reference_pick_tests.rs @@ -0,0 +1,116 @@ +use super::*; +use plotx_core::state::{Dataset, NmrDataset, PhaseAxis}; +use plotx_io::{Domain, NmrData}; +use plotx_processing::{ProcessingStep, ReferenceParams, StepId, StepKind, StepSource}; + +fn synthetic_app() -> PlotxApp { + use num_complex::Complex64; + use std::f64::consts::TAU; + let npoints = 256; + let (sw, obs, carrier) = (4000.0, 400.0, 5.0); + let dt = 1.0 / sw; + let points = (0..npoints) + .map(|k| { + let t = k as f64 * dt; + let decay = (-t / 0.25f64).exp(); + let freq_hz = (2.0 - carrier) * obs; + Complex64::from_polar(decay, TAU * freq_hz * t) + }) + .collect(); + let data = NmrData { + points, + domain: Domain::Time, + spectral_width_hz: sw, + observe_freq_mhz: obs, + carrier_ppm: carrier, + nucleus: "1H".to_owned(), + source: "synthetic".to_owned(), + group_delay: 0.0, + }; + let mut app = PlotxApp::new(); + app.doc + .datasets + .push(Dataset::Nmr(Box::new(NmrDataset::load(data)))); + app.focus_single(0); + app +} + +fn add_reference_step(app: &mut PlotxApp, params: ReferenceParams) -> StepId { + let pipe = app.doc.datasets[0] + .axis_pipeline_mut(PhaseAxis::Direct) + .unwrap(); + let id = StepId::new(pipe.steps.iter().map(|s| s.id.get()).max().unwrap_or(0) + 1); + pipe.steps.push(ProcessingStep::new( + id, + StepKind::Reference(params), + StepSource::User, + )); + id +} + +fn reference_params(app: &PlotxApp, step: StepId) -> ReferenceParams { + let StepKind::Reference(params) = app.doc.datasets[0] + .axis_pipeline(PhaseAxis::Direct) + .unwrap() + .steps + .iter() + .find(|s| s.id == step) + .unwrap() + .kind + else { + panic!("the step must stay a Reference step"); + }; + params +} + +/// A pick on the displayed axis must land the picked feature exactly on +/// `target_ppm` after the recompute: the step's own current offset is removed +/// from the picked coordinate before it becomes the new `at_ppm`. +#[test] +fn commit_converts_the_displayed_pick_into_step_coordinates() { + let mut app = synthetic_app(); + let step = add_reference_step( + &mut app, + ReferenceParams { + at_ppm: 1.0, + target_ppm: 2.0, + }, + ); + let dataset = app.doc.datasets[0].resource_id(); + app.session.ui.proc_expanded_step = Some((dataset, step)); + app.toggle_reference_pick(dataset, step); + let resolved = app.resolve_reference_pick().expect("armed and expanded"); + + // The step currently applies +1.0 ppm, so a feature displayed at 5.0 sits + // at 4.0 on the axis entering the step. + commit_reference_pick(&mut app, &resolved, 5.0); + + let params = reference_params(&app, step); + assert!((params.at_ppm - 4.0).abs() < 1e-12); + assert_eq!(params.target_ppm, 2.0); + // One-shot: the pick disarms on commit, and the edit is one undo step. + assert!(app.session.ui.reference_pick.is_none()); + app.undo(); + assert_eq!(reference_params(&app, step).at_ppm, 1.0); +} + +/// A fresh step (zero offset) stores the picked coordinate verbatim. +#[test] +fn commit_on_a_fresh_step_stores_the_picked_position() { + let mut app = synthetic_app(); + let step = add_reference_step( + &mut app, + ReferenceParams { + at_ppm: 0.0, + target_ppm: 0.0, + }, + ); + let dataset = app.doc.datasets[0].resource_id(); + app.session.ui.proc_expanded_step = Some((dataset, step)); + app.toggle_reference_pick(dataset, step); + let resolved = app.resolve_reference_pick().expect("armed and expanded"); + + commit_reference_pick(&mut app, &resolved, 3.25); + + assert!((reference_params(&app, step).at_ppm - 3.25).abs() < 1e-12); +} diff --git a/crates/app/src/ui/canvas/view_fit.rs b/crates/app/src/ui/canvas/view_fit.rs new file mode 100644 index 0000000..3939f73 --- /dev/null +++ b/crates/app/src/ui/canvas/view_fit.rs @@ -0,0 +1,221 @@ +//! Keyboard data-viewport fits. `H` fits the intensity axis to the data +//! visible inside the current x window (the NMR convention for a vertical +//! fit); `F` over a plot's data area fits both axes. Both are the keyboard +//! form of the double-click viewport resets in `navigation.rs` and commit +//! through the same undoable viewport action. + +use super::*; + +const NAVIGATION_RECT_ID: &str = "plotx.canvas.navigation_rect"; + +/// Which data-viewport axes a keyboard fit resets. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum PlotFitAxes { + /// Fit the y range to the data visible in the current x window and + /// re-enable automatic y scaling; the x window stays put. + Y, + /// Fit both axes to the full data range. + Both, +} + +/// Publish the board rectangle canvas navigation ran against this frame, so +/// keyboard commands can hit-test the pointer without re-deriving layout. +pub(crate) fn store_navigation_rect(ctx: &egui::Context, rect: EguiRect) { + ctx.data_mut(|data| { + data.insert_temp(egui::Id::new(NAVIGATION_RECT_ID), rect); + }); +} + +fn navigation_rect(ctx: &egui::Context) -> Option { + ctx.data(|data| data.get_temp::(egui::Id::new(NAVIGATION_RECT_ID))) +} + +/// The pointer position, unless a floating task card sits under it — over a +/// card the pointer does not address the plot below (mirrors +/// `task_card::pointer_allows_canvas` for gesture dispatch). +fn uncovered_pointer(app: &PlotxApp, ctx: &egui::Context) -> Option { + let p = ctx.input(|input| input.pointer.hover_pos())?; + let covered = crate::ui::tools::task_card::visible_area_id(app) + .and_then(|id| ctx.memory(|memory| memory.area_rect(id))) + .is_some_and(|rect| rect.expand(6.0).contains(p)); + (!covered).then_some(p) +} + +/// Whether the plain `F` chord currently addresses a plot's data viewport: +/// the pointer rests on the data area of a plot on the active canvas. Outside +/// that context the chord keeps its board meaning (Zoom to Selection). +pub(crate) fn pointer_in_plot_data(app: &PlotxApp, ctx: &egui::Context) -> bool { + let Some(ci) = app.session.active_canvas else { + return false; + }; + let Some(rect) = navigation_rect(ctx) else { + return false; + }; + let Some(p) = uncovered_pointer(app, ctx).filter(|p| rect.contains(*p)) else { + return false; + }; + plot_under_cursor(app, ci, rect, p) + .is_some_and(|(_, outer, plot)| hit_zone(p, outer, plot) == HitZone::Plot) +} + +/// The plot a keyboard fit addresses: the plot under the pointer when there is +/// one, otherwise the active plot object — so the palette (no meaningful +/// pointer) still acts on the plot the user is working with. +fn fit_target(app: &PlotxApp, ctx: &egui::Context) -> Option<(usize, ObjectId)> { + let ci = app.session.active_canvas?; + let pointed = navigation_rect(ctx) + .zip(uncovered_pointer(app, ctx)) + .filter(|(rect, p)| rect.contains(*p)) + .and_then(|(rect, p)| plot_under_cursor(app, ci, rect, p)) + .map(|(id, _, _)| id); + pointed + .or_else(|| app.doc.canvases.get(ci)?.active_plot_object_id()) + .map(|id| (ci, id)) +} + +/// Reset the target plot's data viewport on the requested axes as one +/// undoable step. Returns whether a plot was fitted. +pub(crate) fn fit_plot_viewport( + app: &mut PlotxApp, + ctx: &egui::Context, + axes: PlotFitAxes, +) -> bool { + let Some((ci, object_id)) = fit_target(app, ctx) else { + return false; + }; + let Some(plot_object) = app.doc.canvases[ci] + .object(object_id) + .and_then(|object| object.plot()) + else { + return false; + }; + let before = plot_object.viewport.clone(); + let mut after = before.clone(); + match axes { + PlotFitAxes::Y => after.reset_y(plot_object.figure()), + PlotFitAxes::Both => after.reset_all(), + } + app.commit_object_viewport(ci, object_id, before, after); + true +} + +#[cfg(test)] +mod tests { + use super::*; + use plotx_core::state::{CanvasViewport, PlotObject}; + use plotx_figure::{Axis, Figure, Series}; + + const PLOT_ID: ObjectId = ObjectId::new(1); + + /// One line plot whose trace is small inside x = 2..8 and spikes outside, + /// so a window-scoped intensity fit is distinguishable from a full fit. + fn line_plot_app() -> PlotxApp { + let mut app = PlotxApp::new(); + let mut canvas = CanvasDocument::new("page".to_owned(), [200.0, 120.0]); + let mut figure = Figure::new( + "plot", + Axis::new("x", 0.0, 10.0), + Axis::new("y", -1.0, 100.0), + ); + figure.series.push(Series::line( + "trace", + vec![ + [0.0, 100.0], + [1.0, 90.0], + [3.0, 1.0], + [5.0, 2.0], + [7.0, 3.0], + [9.0, 80.0], + [10.0, 100.0], + ], + )); + let viewport = CanvasViewport { + full_x: AxisRange::new(0.0, 10.0), + full_y: AxisRange::new(-1.0, 100.0), + view_x: AxisRange::new(2.0, 8.0), + view_y: AxisRange::new(-50.0, 50.0), + auto_y: false, + }; + viewport.apply_to(&mut figure); + canvas.objects.push(CanvasObject { + id: PLOT_ID, + name: "Plot".to_owned(), + frame: ObjectFrame::new(10.0, 10.0, 180.0, 100.0), + locked: false, + visible: true, + kind: CanvasObjectKind::Plot(Box::new(PlotObject::new( + None, + plotx_core::state::SeriesId::new(1), + plotx_core::state::DataBinding { series: Vec::new() }, + plotx_core::state::ChartSpec::default(), + plotx_core::state::StackSpec::default(), + plotx_core::state::AxisProjections::default(), + plotx_core::state::AxisOverrides::default(), + figure, + viewport, + ))), + }); + app.doc.canvases.push(canvas); + app.session.active_canvas = Some(0); + app + } + + fn viewport(app: &PlotxApp) -> CanvasViewport { + app.doc.canvases[0] + .object(PLOT_ID) + .and_then(|object| object.plot()) + .expect("fixture plot") + .viewport + .clone() + } + + #[test] + fn y_fit_scales_to_the_data_visible_in_the_current_x_window() { + let mut app = line_plot_app(); + let ctx = egui::Context::default(); + + assert!(fit_plot_viewport(&mut app, &ctx, PlotFitAxes::Y)); + + let fitted = viewport(&app); + assert_eq!(fitted.view_x, AxisRange::new(2.0, 8.0)); + assert!(fitted.auto_y); + // Only the points at x = 3, 5, 7 (y = 1..3) are inside the window; the + // fitted y range is that span plus the auto-fit padding, far below the + // out-of-window spikes. + assert!((fitted.view_y.min - 0.9).abs() < 1e-9); + assert!((fitted.view_y.max - 3.16).abs() < 1e-9); + + app.undo(); + let restored = viewport(&app); + assert_eq!(restored.view_y, AxisRange::new(-50.0, 50.0)); + assert!(!restored.auto_y); + } + + #[test] + fn both_axes_fit_resets_the_full_data_range_undoably() { + let mut app = line_plot_app(); + let ctx = egui::Context::default(); + + assert!(fit_plot_viewport(&mut app, &ctx, PlotFitAxes::Both)); + + let fitted = viewport(&app); + assert_eq!(fitted.view_x, AxisRange::new(0.0, 10.0)); + assert_eq!(fitted.view_y, AxisRange::new(-1.0, 100.0)); + assert!(fitted.auto_y); + + app.undo(); + assert_eq!(viewport(&app).view_x, AxisRange::new(2.0, 8.0)); + } + + #[test] + fn fit_without_a_plot_reports_no_target() { + let mut app = PlotxApp::new(); + app.doc + .canvases + .push(CanvasDocument::new("empty".to_owned(), [100.0, 80.0])); + app.session.active_canvas = Some(0); + let ctx = egui::Context::default(); + + assert!(!fit_plot_viewport(&mut app, &ctx, PlotFitAxes::Both)); + } +} diff --git a/crates/app/src/ui/command_exec.rs b/crates/app/src/ui/command_exec.rs index b53ca70..b20fe3e 100644 --- a/crates/app/src/ui/command_exec.rs +++ b/crates/app/src/ui/command_exec.rs @@ -133,6 +133,16 @@ fn execute_inner( _ => format!("Fit {count} selected frames to view."), }; } + CommandId::FitPlotY => { + if super::canvas::fit_plot_viewport(app, ctx, super::canvas::PlotFitAxes::Y) { + app.session.status = "Fit intensity to the visible window.".into(); + } + } + CommandId::FitPlotXY => { + if super::canvas::fit_plot_viewport(app, ctx, super::canvas::PlotFitAxes::Both) { + app.session.status = "Fit plot to the full data range.".into(); + } + } CommandId::UiScaleUp => crate::scale::nudge_ui_zoom(app, ctx, 1), CommandId::UiScaleDown => crate::scale::nudge_ui_zoom(app, ctx, -1), CommandId::UiScaleReset => crate::scale::reset_ui_zoom(app, ctx), diff --git a/crates/app/src/ui/commands.rs b/crates/app/src/ui/commands.rs index 6b68f0d..2bb1266 100644 --- a/crates/app/src/ui/commands.rs +++ b/crates/app/src/ui/commands.rs @@ -13,7 +13,7 @@ use identity::command_identity; pub(crate) use identity::recent_entry_label; mod helpers; pub(super) use helpers::chart_plot_target; -use helpers::{requires, selected_paths_unlocked, tool_commands}; +use helpers::{has_active_plot, requires, selected_paths_unlocked, tool_commands}; mod ribbon; use ribbon::ribbon_placement; pub use ribbon::{Applicability, RibbonPlacement}; @@ -68,6 +68,8 @@ pub enum CommandId { ToggleSecondarySidebar, ZoomToFit, ZoomToSelection, + FitPlotY, + FitPlotXY, UiScaleUp, UiScaleDown, UiScaleReset, @@ -199,6 +201,8 @@ pub fn catalog(app: &PlotxApp) -> Vec { CommandId::ToggleSecondarySidebar, CommandId::ZoomToFit, CommandId::ZoomToSelection, + CommandId::FitPlotY, + CommandId::FitPlotXY, CommandId::UiScaleUp, CommandId::UiScaleDown, CommandId::UiScaleReset, @@ -497,6 +501,10 @@ pub fn describe(app: &PlotxApp, id: CommandId) -> CommandDescriptor { "Create a panel before renumbering panel labels.", ), CommandId::ZoomToFit => requires(has_canvas, "Open a canvas before zooming to fit."), + CommandId::FitPlotY | CommandId::FitPlotXY => requires( + has_active_plot(app), + "Plot a dataset on the canvas before fitting its data view.", + ), CommandId::ZoomToSelection => { requires(has_canvas, "Open a canvas before zooming to the selection.") } diff --git a/crates/app/src/ui/commands/helpers.rs b/crates/app/src/ui/commands/helpers.rs index e5a826d..cec4e3d 100644 --- a/crates/app/src/ui/commands/helpers.rs +++ b/crates/app/src/ui/commands/helpers.rs @@ -32,6 +32,13 @@ impl CommandId { } } +pub(super) fn has_active_plot(app: &PlotxApp) -> bool { + app.session + .active_canvas + .and_then(|ci| app.doc.canvases.get(ci)) + .is_some_and(|canvas| canvas.active_plot_object_id().is_some()) +} + pub(super) fn requires(ok: bool, reason: &'static str) -> Result<(), &'static str> { if ok { Ok(()) } else { Err(reason) } } diff --git a/crates/app/src/ui/commands/identity.rs b/crates/app/src/ui/commands/identity.rs index 23400b0..ebc9e58 100644 --- a/crates/app/src/ui/commands/identity.rs +++ b/crates/app/src/ui/commands/identity.rs @@ -132,6 +132,16 @@ pub(super) fn command_identity( ), CommandId::ZoomToFit => ("Zoom to Fit".into(), Some(icon::ARROWS_OUT), None), CommandId::ZoomToSelection => ("Zoom to Selection".into(), None, None), + CommandId::FitPlotY => ( + "Fit Plot Vertically".into(), + Some(icon::ARROWS_VERTICAL), + None, + ), + CommandId::FitPlotXY => ( + "Fit Plot to Data".into(), + Some(icon::ARROWS_OUT_SIMPLE), + None, + ), CommandId::UiScaleUp => ( ui_scale_label(app, "Increase UI Scale"), Some(icon::MAGNIFYING_GLASS_PLUS), @@ -421,6 +431,8 @@ fn simple_stable_id(id: CommandId) -> &'static str { CommandId::ToggleSecondarySidebar => "view.secondary_sidebar", CommandId::ZoomToFit => "view.zoom_fit", CommandId::ZoomToSelection => "view.zoom_selection", + CommandId::FitPlotY => "view.fit_plot_y", + CommandId::FitPlotXY => "view.fit_plot_xy", CommandId::UiScaleUp => "view.ui_scale_up", CommandId::UiScaleDown => "view.ui_scale_down", CommandId::UiScaleReset => "view.ui_scale_reset", diff --git a/crates/app/src/ui/commands_craft_tests.rs b/crates/app/src/ui/commands_craft_tests.rs index fdd5de1..d54442a 100644 --- a/crates/app/src/ui/commands_craft_tests.rs +++ b/crates/app/src/ui/commands_craft_tests.rs @@ -2,7 +2,7 @@ use super::tests::app_with_nmr; use super::*; fn use_short_fixture_filter(app: &mut PlotxApp) { - app.session.ui.craft_overrides.filter_taps = Some(31); + app.session.ui.craft_overrides.fir_filter_taps = Some(31); } #[test] diff --git a/crates/app/src/ui/commands_tests.rs b/crates/app/src/ui/commands_tests.rs index 07c71ff..2713a6f 100644 --- a/crates/app/src/ui/commands_tests.rs +++ b/crates/app/src/ui/commands_tests.rs @@ -431,7 +431,12 @@ fn transient_state_never_changes_ribbon_group_visibility() { .active_plot_object_id() .expect("NMR plot object"); let range = app.analysis_range_for(0).expect("visible NMR range"); - app.add_manual_peak(0, (range.min + range.max) / 2.0, None); + app.add_manual_peak( + 0, + (range.min + range.max) / 2.0, + None, + plotx_core::state::ManualPeakSnap::NearestSample, + ); app.session.ui.selection = Selection::single(object); let expected = ribbon_groups(&app); diff --git a/crates/app/src/ui/file_dialogs.rs b/crates/app/src/ui/file_dialogs.rs index b26509f..9173989 100644 --- a/crates/app/src/ui/file_dialogs.rs +++ b/crates/app/src/ui/file_dialogs.rs @@ -379,7 +379,7 @@ pub(crate) fn open_file(app: &mut PlotxApp) { &["png", "jpg", "jpeg", "tif", "tiff", "webp", "bmp"], ) .add_filter( - "All supported data (*.mzML, *.rasx, *.raw, *.vms, *.txt, *.spm, *.pfc, *.abf, *.jdf, fid, ser, *.zip, *.opj)", + "All supported data (*.mzML, *.wiff, *.rasx, *.raw, *.vms, *.txt, *.spm, *.pfc, *.abf, *.jdf, fid, ser, *.zip, *.opj)", origin::OPEN_FILE_FILTER_EXTENSIONS, ) .add_filter("Rigaku XRD (*.rasx, *.raw, *.txt)", &["rasx", "raw", "txt"]) @@ -391,6 +391,7 @@ pub(crate) fn open_file(app: &mut PlotxApp) { .add_filter("Axon Binary Format 2 (*.abf)", &["abf"]) .add_filter("JEOL Delta (*.jdf)", &["jdf"]) .add_filter("mzML mass spectrometry (*.mzML)", &["mzML"]) + .add_filter("SCIEX legacy WIFF (*.wiff)", &["wiff"]) .add_filter("XPS (*.vms, CasaXPS *.txt)", &["vms", "txt"]) .add_filter("Bruker TopSpin (fid, ser)", &["fid", "ser"]) .add_filter("Varian/Agilent VnmrJ (fid)", &["fid"]) @@ -427,7 +428,7 @@ pub(crate) fn choose_project_save_path() -> Option { pub(crate) fn open_folder(app: &mut PlotxApp) { if let Some(path) = rfd::FileDialog::new() - .set_title("Open a data folder (Waters MassLynx RAW, Bruker, Varian/Agilent VnmrJ, or recursive AFM/ABF2 import)") + .set_title("Open a data folder (vendor acquisitions or recursive scientific-data import)") .pick_folder() { open_folder_path(app, &path); diff --git a/crates/app/src/ui/file_dialogs/discovery.rs b/crates/app/src/ui/file_dialogs/discovery.rs index 9ef3ddf..3e998d5 100644 --- a/crates/app/src/ui/file_dialogs/discovery.rs +++ b/crates/app/src/ui/file_dialogs/discovery.rs @@ -26,7 +26,7 @@ pub(super) fn collect_data_files(folder: &Path, output: &mut Vec) { .extension() .and_then(|value| value.to_str()) .unwrap_or(""); - let supported_extension = ["abf", "spm", "pfc", "rasx", "vms"] + let supported_extension = ["abf", "spm", "pfc", "rasx", "vms", "wiff"] .iter() .any(|supported| extension.eq_ignore_ascii_case(supported)); let recognized_raw = @@ -92,4 +92,22 @@ mod tests { assert_eq!(found, vec![dataset]); std::fs::remove_dir_all(root).unwrap(); } + + #[test] + fn folder_scan_discovers_only_the_primary_wiff_file() { + let root = + std::env::temp_dir().join(format!("plotx-wiff-discovery-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&root).unwrap(); + let wiff = root.join("sample.WIFF"); + std::fs::write(&wiff, b"container").unwrap(); + std::fs::write(root.join("sample.WIFF.scan"), b"scans").unwrap(); + std::fs::write(root.join("sample.wiff2"), b"wiff2").unwrap(); + std::fs::write(root.join("sample.timeseries.data"), b"data").unwrap(); + + let mut found = Vec::new(); + collect_data_files(&root, &mut found); + + assert_eq!(found, vec![wiff]); + std::fs::remove_dir_all(root).unwrap(); + } } diff --git a/crates/app/src/ui/file_dialogs/origin.rs b/crates/app/src/ui/file_dialogs/origin.rs index b0182f2..c561cdd 100644 --- a/crates/app/src/ui/file_dialogs/origin.rs +++ b/crates/app/src/ui/file_dialogs/origin.rs @@ -21,7 +21,8 @@ pub(super) const ORIGIN_PROJECT_FILTER_LABEL: &str = "Origin projects (experimental: OPJ import; OPJU recognition only)"; pub(super) const ORIGIN_PROJECT_FILTER_EXTENSIONS: &[&str] = &["opj", "opju"]; pub(super) const OPEN_FILE_FILTER_EXTENSIONS: &[&str] = &[ - "mzML", "rasx", "raw", "vms", "txt", "spm", "pfc", "abf", "jdf", "fid", "ser", "zip", "opj", + "mzML", "wiff", "rasx", "raw", "vms", "txt", "spm", "pfc", "abf", "jdf", "fid", "ser", "zip", + "opj", ]; const ORIGIN_MEDIA_TYPE: &str = "application/x-origin-project"; diff --git a/crates/app/src/ui/file_dialogs/origin_tests.rs b/crates/app/src/ui/file_dialogs/origin_tests.rs index b7950ed..c581b38 100644 --- a/crates/app/src/ui/file_dialogs/origin_tests.rs +++ b/crates/app/src/ui/file_dialogs/origin_tests.rs @@ -118,6 +118,7 @@ fn origin_import_filter_retains_tables_and_adds_experimental_projects() { fn origin_supported_file_filter_excludes_recognition_only_opju() { assert!(OPEN_FILE_FILTER_EXTENSIONS.contains(&"opj")); assert!(OPEN_FILE_FILTER_EXTENSIONS.contains(&"mzML")); + assert!(OPEN_FILE_FILTER_EXTENSIONS.contains(&"wiff")); assert!(!OPEN_FILE_FILTER_EXTENSIONS.contains(&"opju")); } diff --git a/crates/app/src/ui/menus.rs b/crates/app/src/ui/menus.rs index 10f78a9..bac22ff 100644 --- a/crates/app/src/ui/menus.rs +++ b/crates/app/src/ui/menus.rs @@ -129,6 +129,8 @@ pub(crate) fn menu_bar_spec() -> Vec<(&'static str, Vec)> { Separator, Command(CommandId::ZoomToFit), Command(CommandId::ZoomToSelection), + Command(CommandId::FitPlotY), + Command(CommandId::FitPlotXY), Command(CommandId::Present), Separator, Command(CommandId::UiScaleUp), diff --git a/crates/app/src/ui/mod.rs b/crates/app/src/ui/mod.rs index 551420b..dceac92 100644 --- a/crates/app/src/ui/mod.rs +++ b/crates/app/src/ui/mod.rs @@ -1,3 +1,4 @@ +pub(crate) mod affordance; pub(crate) mod align; pub(crate) mod arithmetic; pub(crate) mod batch_workflow; @@ -115,6 +116,7 @@ pub fn render( || batch_workflow.is_open(); if !modal_open { primary_sidebar::selection::handle_keyboard_selection(app, &ctx); + handle_plot_fit_shortcut(app, clipboard_table_paste, &ctx); handle_command_shortcuts(app, clipboard_table_paste, &ctx); handle_escape_shortcut(app, &ctx); handle_rename_shortcut(app, &ctx); @@ -152,6 +154,9 @@ pub fn render( // A sidebar may have changed an expanded Phase step before the canvas paints. app.sync_phase_interaction(); + // Same for an armed Reference pick: collapsing its editor or switching + // datasets must not leave a live click trap on the plot. + app.sync_reference_pick(); egui::CentralPanel::default() .frame(egui::Frame::new().fill(workspace_fill(dark)).inner_margin( central_workspace_margin( diff --git a/crates/app/src/ui/properties/sections.rs b/crates/app/src/ui/properties/sections.rs index 8d9a32a..267fb09 100644 --- a/crates/app/src/ui/properties/sections.rs +++ b/crates/app/src/ui/properties/sections.rs @@ -352,7 +352,35 @@ pub(crate) fn baseline_section(app: &mut PlotxApp, target: &TargetRef, ui: &mut } pub(crate) fn reference_section(app: &mut PlotxApp, target: &TargetRef, ui: &mut Ui) -> bool { - processing_parameter_section(app, REFERENCE_SECTION, "Reference", target, ui) + let rendered = processing_parameter_section(app, REFERENCE_SECTION, "Reference", target, ui); + if rendered { + reference_pick_row(app, target, ui); + } + rendered +} + +/// The click-to-enter row that arms the one-shot on-plot pick of the source +/// position: click the peak on the spectrum instead of typing its ppm. +fn reference_pick_row(app: &mut PlotxApp, target: &TargetRef, ui: &mut Ui) { + use plotx_core::automation::ComponentRef; + let Some(ComponentRef::ProcessingStep(step)) = target.component else { + return; + }; + let Ok(dataset) = plotx_core::state::DatasetId::try_from(&target.resource) else { + return; + }; + let armed = + app.session.ui.reference_pick == Some(plotx_core::state::ReferencePick { dataset, step }); + let label = if armed { + "Picking… click the spectrum (Esc cancels)" + } else { + "Pick position on spectrum" + }; + if crate::ui::affordance::selectable_row(ui, armed, egui_phosphor::regular::CROSSHAIR, label) + .clicked() + { + app.toggle_reference_pick(dataset, step); + } } pub(crate) fn smooth_section(app: &mut PlotxApp, target: &TargetRef, ui: &mut Ui) -> bool { diff --git a/crates/app/src/ui/shortcuts.rs b/crates/app/src/ui/shortcuts.rs index 0cc2218..18dafd7 100644 --- a/crates/app/src/ui/shortcuts.rs +++ b/crates/app/src/ui/shortcuts.rs @@ -141,10 +141,28 @@ static BINDINGS: &[CommandBinding] = &[ dispatch: false, menu_accelerator: true, }, + // Plain `F` is context-split by `handle_plot_fit_shortcut`: over a plot's + // data area it runs FitPlotXY, elsewhere ZoomToSelection. Both bindings + // stay in the table so labels derive normally, but neither dispatches + // through the chord table. CommandBinding { id: commands::CommandId::ZoomToSelection, primary: plain(egui::Key::F), aliases: &[], + dispatch: false, + menu_accelerator: false, + }, + CommandBinding { + id: commands::CommandId::FitPlotXY, + primary: plain(egui::Key::F), + aliases: &[], + dispatch: false, + menu_accelerator: false, + }, + CommandBinding { + id: commands::CommandId::FitPlotY, + primary: plain(egui::Key::H), + aliases: &[], dispatch: true, menu_accelerator: false, }, @@ -251,6 +269,30 @@ pub(super) fn handle_palette_shortcut( } } +/// Sole owner of the plain `F` chord, which is context-split: with the pointer +/// on a plot's data area it fits that plot's data viewport on both axes, and +/// everywhere else it keeps its original board meaning, Zoom to Selection. The +/// split lives in this focused handler instead of the dispatch table so both +/// commands keep their own identity, gating and palette entries. +pub(super) fn handle_plot_fit_shortcut( + app: &mut PlotxApp, + clipboard: &mut clipboard_table::ClipboardTablePaste, + ctx: &egui::Context, +) { + if ctx.egui_wants_keyboard_input() { + return; + } + if !ctx.input(|i| chord_pressed(i, plain(egui::Key::F))) { + return; + } + let id = if canvas::pointer_in_plot_data(app, ctx) { + commands::CommandId::FitPlotXY + } else { + commands::CommandId::ZoomToSelection + }; + commands::execute(id, app, clipboard, ctx); +} + /// Route global bindings through the same command dispatcher used by menus, /// the Ribbon and the command palette. Direct-manipulation-only keys remain in /// their focused handlers below. @@ -602,121 +644,5 @@ pub(super) fn handle_delete_shortcut(app: &mut PlotxApp, ctx: &egui::Context) { } #[cfg(test)] -mod tests { - use super::*; - - /// Two dispatchable bindings must never share an effective chord. The - /// matcher ignores Shift for plain keys, so those normalize shift away. - #[test] - fn dispatchable_chords_are_unambiguous() { - let mut seen = std::collections::HashSet::new(); - for binding in BINDINGS.iter().filter(|binding| binding.dispatch) { - for chord in std::iter::once(binding.primary).chain(binding.aliases.iter().copied()) { - assert!( - seen.insert((chord.command, chord.command && chord.shift, chord.key)), - "chord {chord:?} bound twice" - ); - } - } - } - - #[test] - fn labels_derive_from_the_binding_table() { - let label = shortcut_label(commands::CommandId::SaveProject).unwrap(); - assert!(label.ends_with("+S")); - assert!( - shortcut_label(commands::CommandId::PasteImage) - .is_some_and(|label| label.ends_with("+V")) - ); - assert_eq!( - shortcut_label(commands::CommandId::Tool(Tool::Select)).as_deref(), - Some("V") - ); - assert_eq!( - shortcut_label(commands::CommandId::CycleCursor).as_deref(), - Some("C") - ); - assert!(shortcut_label(commands::CommandId::Tool(Tool::Symmetry)).is_none()); - assert!(shortcut_label(commands::CommandId::About).is_none()); - } - - fn paste_key_event() -> egui::Event { - egui::Event::Key { - key: egui::Key::V, - physical_key: Some(egui::Key::V), - pressed: true, - repeat: false, - modifiers: egui::Modifiers::CTRL, - } - } - - #[test] - fn restored_ctrl_v_and_platform_paste_events_route_to_paste_image() { - for event in [ - paste_key_event(), - egui::Event::Paste("clipboard".to_owned()), - ] { - let ctx = egui::Context::default(); - let input = egui::RawInput { - events: vec![event], - modifiers: egui::Modifiers::CTRL, - ..Default::default() - }; - let mut command = None; - let _ = ctx.run_ui(input, |ui| command = shortcut_command(ui.ctx())); - assert_eq!(command, Some(commands::CommandId::PasteImage)); - } - } - - #[test] - fn focused_text_edit_keeps_ctrl_v_for_text_paste() { - let ctx = egui::Context::default(); - let mut text = String::new(); - let _ = ctx.run_ui(egui::RawInput::default(), |ui| { - ui.add(egui::TextEdit::singleline(&mut text)) - .request_focus(); - }); - let input = egui::RawInput { - events: vec![egui::Event::Paste("text".to_owned())], - modifiers: egui::Modifiers::CTRL, - ..Default::default() - }; - let mut command = None; - let _ = ctx.run_ui(input, |ui| { - command = shortcut_command(ui.ctx()); - ui.add(egui::TextEdit::singleline(&mut text)); - }); - assert_eq!(command, None); - assert_eq!(text, "text"); - } - - #[test] - fn escape_exits_an_active_tool_after_other_fallbacks() { - let mut app = PlotxApp::new_with_settings(plotx_core::settings::Settings::default()); - app.set_tool(Tool::Integrate); - - handle_escape(&mut app, 0.0); - - assert_eq!(app.session.tool, Tool::BrowseZoom); - assert_eq!(app.session.status, "Exited tool mode."); - } - - #[test] - fn escape_finishes_a_pending_wheel_property_gesture() { - let mut app = PlotxApp::new_with_settings(plotx_core::settings::Settings::default()); - app.session.ui.wheel_property = Some(plotx_core::actions::PendingWheelPropertyEdit { - canvas: 0, - object: plotx_core::state::ObjectId::new(1), - property: plotx_core::properties::contour::BASE_MAGNITUDE, - targets: Vec::new(), - accumulator: 0.0, - last_input_time: 0.0, - gesture_started: false, - }); - - handle_escape(&mut app, 1.0); - - assert!(app.session.ui.wheel_property.is_none()); - assert_eq!(app.session.status, "Cancelled interaction."); - } -} +#[path = "shortcuts_tests.rs"] +mod tests; diff --git a/crates/app/src/ui/shortcuts_tests.rs b/crates/app/src/ui/shortcuts_tests.rs new file mode 100644 index 0000000..07e0e7a --- /dev/null +++ b/crates/app/src/ui/shortcuts_tests.rs @@ -0,0 +1,189 @@ +use super::*; + +/// Two dispatchable bindings must never share an effective chord. The +/// matcher ignores Shift for plain keys, so those normalize shift away. +#[test] +fn dispatchable_chords_are_unambiguous() { + let mut seen = std::collections::HashSet::new(); + for binding in BINDINGS.iter().filter(|binding| binding.dispatch) { + for chord in std::iter::once(binding.primary).chain(binding.aliases.iter().copied()) { + assert!( + seen.insert((chord.command, chord.command && chord.shift, chord.key)), + "chord {chord:?} bound twice" + ); + } + } +} + +#[test] +fn labels_derive_from_the_binding_table() { + let label = shortcut_label(commands::CommandId::SaveProject).unwrap(); + assert!(label.ends_with("+S")); + assert!( + shortcut_label(commands::CommandId::PasteImage).is_some_and(|label| label.ends_with("+V")) + ); + assert_eq!( + shortcut_label(commands::CommandId::Tool(Tool::Select)).as_deref(), + Some("V") + ); + assert_eq!( + shortcut_label(commands::CommandId::CycleCursor).as_deref(), + Some("C") + ); + assert!(shortcut_label(commands::CommandId::Tool(Tool::Symmetry)).is_none()); + assert!(shortcut_label(commands::CommandId::About).is_none()); +} + +fn paste_key_event() -> egui::Event { + egui::Event::Key { + key: egui::Key::V, + physical_key: Some(egui::Key::V), + pressed: true, + repeat: false, + modifiers: egui::Modifiers::CTRL, + } +} + +#[test] +fn restored_ctrl_v_and_platform_paste_events_route_to_paste_image() { + for event in [ + paste_key_event(), + egui::Event::Paste("clipboard".to_owned()), + ] { + let ctx = egui::Context::default(); + let input = egui::RawInput { + events: vec![event], + modifiers: egui::Modifiers::CTRL, + ..Default::default() + }; + let mut command = None; + let _ = ctx.run_ui(input, |ui| command = shortcut_command(ui.ctx())); + assert_eq!(command, Some(commands::CommandId::PasteImage)); + } +} + +#[test] +fn focused_text_edit_keeps_ctrl_v_for_text_paste() { + let ctx = egui::Context::default(); + let mut text = String::new(); + let _ = ctx.run_ui(egui::RawInput::default(), |ui| { + ui.add(egui::TextEdit::singleline(&mut text)) + .request_focus(); + }); + let input = egui::RawInput { + events: vec![egui::Event::Paste("text".to_owned())], + modifiers: egui::Modifiers::CTRL, + ..Default::default() + }; + let mut command = None; + let _ = ctx.run_ui(input, |ui| { + command = shortcut_command(ui.ctx()); + ui.add(egui::TextEdit::singleline(&mut text)); + }); + assert_eq!(command, None); + assert_eq!(text, "text"); +} + +fn f_key_event() -> egui::Event { + egui::Event::Key { + key: egui::Key::F, + physical_key: Some(egui::Key::F), + pressed: true, + repeat: false, + modifiers: egui::Modifiers::default(), + } +} + +/// Plain `F` is context-split: over a plot's data area it fits that plot's +/// data viewport, elsewhere it keeps the board Zoom-to-Selection meaning. +#[test] +fn plain_f_fits_the_plot_under_the_pointer_and_the_board_otherwise() { + let (mut app, ids) = crate::ui::properties::fixture::contour_page(1); + app.session.board = plotx_core::state::BoardViewport { + zoom: 1.0, + world_center: [500.0, 400.0], + }; + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 800.0)); + let plot = canvas::plot_inner_rect(&app, 0, ids[0], screen).expect("plot on the board"); + let inside = egui::Pos2::new( + (plot.left + plot.right()) * 0.5, + (plot.top + plot.bottom()) * 0.5, + ); + + // Zoom the data viewport away from the full range first. + let plot_object = app.doc.canvases[0] + .object_mut(ids[0]) + .and_then(|object| object.plot_mut()) + .expect("fixture plot"); + let full_x = plot_object.viewport.full_x; + let full_y = plot_object.viewport.full_y; + plot_object.viewport.view_x = plotx_core::state::AxisRange::new( + full_x.min + full_x.span() * 0.25, + full_x.max - full_x.span() * 0.25, + ); + plot_object.apply_viewport(); + + let ctx = egui::Context::default(); + let mut clipboard = clipboard_table::ClipboardTablePaste::default(); + let mut frame = |app: &mut PlotxApp, pointer: egui::Pos2| { + let _ = ctx.run_ui( + egui::RawInput { + screen_rect: Some(screen), + events: vec![egui::Event::PointerMoved(pointer), f_key_event()], + ..Default::default() + }, + |ui| { + canvas::store_navigation_rect(ui.ctx(), screen); + handle_plot_fit_shortcut(app, &mut clipboard, ui.ctx()); + }, + ); + }; + + frame(&mut app, inside); + let viewport = app.doc.canvases[0] + .object(ids[0]) + .and_then(|object| object.plot()) + .expect("fixture plot") + .viewport + .clone(); + assert_eq!(viewport.view_x, full_x); + assert_eq!(viewport.view_y, full_y); + assert_eq!(app.session.status, "Fit plot to the full data range."); + + // Outside any plot the chord still fits the board to the selection. + frame(&mut app, egui::Pos2::new(5.0, 5.0)); + assert!(matches!( + app.session.viewport_mode, + plotx_core::state::ViewportMode::Fit(_) + )); +} + +#[test] +fn escape_exits_an_active_tool_after_other_fallbacks() { + let mut app = PlotxApp::new_with_settings(plotx_core::settings::Settings::default()); + app.set_tool(Tool::Integrate); + + handle_escape(&mut app, 0.0); + + assert_eq!(app.session.tool, Tool::BrowseZoom); + assert_eq!(app.session.status, "Exited tool mode."); +} + +#[test] +fn escape_finishes_a_pending_wheel_property_gesture() { + let mut app = PlotxApp::new_with_settings(plotx_core::settings::Settings::default()); + app.session.ui.wheel_property = Some(plotx_core::actions::PendingWheelPropertyEdit { + canvas: 0, + object: plotx_core::state::ObjectId::new(1), + property: plotx_core::properties::contour::BASE_MAGNITUDE, + targets: Vec::new(), + accumulator: 0.0, + last_input_time: 0.0, + gesture_started: false, + }); + + handle_escape(&mut app, 1.0); + + assert!(app.session.ui.wheel_property.is_none()); + assert_eq!(app.session.status, "Cancelled interaction."); +} diff --git a/crates/app/src/ui/tools/craft.rs b/crates/app/src/ui/tools/craft.rs index 1cdc797..0f61ac9 100644 --- a/crates/app/src/ui/tools/craft.rs +++ b/crates/app/src/ui/tools/craft.rs @@ -8,6 +8,7 @@ use super::task_card::{self, TaskCardGeometry}; use crate::ui::commands::{self, CommandId}; mod results; +mod results_diagnostics; mod setup; mod spectrum; @@ -304,6 +305,18 @@ fn command_button(app: &mut PlotxApp, command: CommandId, label: &str, primary: #[cfg(test)] mod tests { use super::*; + + fn preview_sample_indices(point_count: usize, sample_count: usize) -> Vec { + let count = point_count.min(sample_count.max(2)); + if count == 0 { + return Vec::new(); + } + if count == 1 { + return vec![0]; + } + let last = point_count - 1; + (0..count).map(|index| index * last / (count - 1)).collect() + } use num_complex::Complex64; use plotx_core::state::{CraftRunId, NmrDataset, StoredCraftRun}; use plotx_io::{Domain, NmrData}; @@ -340,8 +353,9 @@ mod tests { residual_rss: 1.0, normalized_residual: 1.0, maximum_condition_number: Some(1.0), - fit_windows: Vec::new(), + modeling_windows: Vec::new(), warnings: Vec::new(), + stability: Default::default(), }, synthetic_fid: Vec::new(), residual_fid: Vec::new(), @@ -354,7 +368,7 @@ mod tests { let first = NmrDataset::load(time_domain_data("first")); let mut second = NmrDataset::load(time_domain_data("second")); let mut provenance_params = CraftParams::ssfp(); - provenance_params.min_amplitude_to_noise = 8.5; + provenance_params.minimum_amplitude_to_noise = 8.5; second .craft_runs .push(stored_run(&second.data, provenance_params.clone())); @@ -364,10 +378,10 @@ mod tests { app.set_active_dataset(Some(0)); open_for_active(&mut app); - app.session.ui.craft_overrides.min_amplitude_to_noise = Some(6.0); + app.session.ui.craft_overrides.minimum_amplitude_to_noise = Some(6.0); open_for_active(&mut app); assert_eq!( - app.session.ui.craft_overrides.min_amplitude_to_noise, + app.session.ui.craft_overrides.minimum_amplitude_to_noise, Some(6.0) ); @@ -382,7 +396,7 @@ mod tests { #[test] fn preview_indices_cover_endpoints_with_a_bounded_sample_count() { - let indices = results::preview_sample_indices(65_536, 310); + let indices = preview_sample_indices(65_536, 310); assert_eq!(indices.len(), 310); assert_eq!(indices.first(), Some(&0)); @@ -408,7 +422,7 @@ mod tests { &dataset.data, dataset.craft_reference(), &plotx_processing::craft::CraftParamOverrides { - filter_taps: Some(31), + fir_filter_taps: Some(31), ..Default::default() }, None, @@ -427,7 +441,7 @@ mod tests { } #[test] - fn detected_signal_width_is_independent_of_internal_fit_window_width() { + fn detected_signal_width_is_independent_of_modeling_bandwidth() { assert!((45.0 / 600.0_f64 - 0.075).abs() < f64::EPSILON); } } diff --git a/crates/app/src/ui/tools/craft/results.rs b/crates/app/src/ui/tools/craft/results.rs index f8247c2..9cd51fc 100644 --- a/crates/app/src/ui/tools/craft/results.rs +++ b/crates/app/src/ui/tools/craft/results.rs @@ -3,6 +3,7 @@ use plotx_core::state::{ CraftAnalysisIntent, CraftComponentSort, CraftResultTab, CraftTaskPage, PlotxApp, StoredCraftRun, }; +use plotx_processing::craft::{CraftAmplitudeReport, CraftReportDefinition}; use plotx_processing::craft::{CraftComponent, CraftProfile, CraftRegionId, CraftRunStatus}; use crate::ui::commands::CommandId; @@ -123,6 +124,11 @@ pub(super) fn show(app: &mut PlotxApp, index: usize, ui: &mut Ui) { CraftResultTab::Diagnostics, "Diagnostics", ); + ui.selectable_value( + &mut app.session.ui.craft_result_tab, + CraftResultTab::Reports, + "Reports", + ); }); ui.separator(); @@ -130,7 +136,221 @@ pub(super) fn show(app: &mut PlotxApp, index: usize, ui: &mut Ui) { CraftResultTab::Overview => overview(app, &nmr, &run, ui), CraftResultTab::Components => components(app, &nmr, &run, ui), CraftResultTab::Diagnostics => diagnostics(app, &nmr, &run, ui), + CraftResultTab::Reports => reports(app, index, &nmr, &run, ui), + } +} + +fn reports( + app: &mut PlotxApp, + index: usize, + nmr: &plotx_core::state::NmrDataset, + run: &StoredCraftRun, + ui: &mut Ui, +) { + let source = plotx_core::state::ReportSource { + dataset: nmr.resource_id, + craft_run: run.id, + }; + let report_ids = app + .doc + .reports_for_source(source) + .map(|r| r.id) + .collect::>(); + let quantitative_ready = run.diagnostics.status == CraftRunStatus::Complete + && run.diagnostics.stability.passed + && !run.is_stale_for(&nmr.data, nmr.craft_reference()); + ui.horizontal_wrapped(|ui| { + if ui + .add_enabled(quantitative_ready, egui::Button::new("New report")) + .on_disabled_hover_text( + "A stable, current CRAFT run is required for a quantitative amplitude report.", + ) + .clicked() + { + let definition = CraftReportDefinition { + threshold_an: run.provenance.invocation.params.minimum_amplitude_to_noise, + segment_width_hz: 1.0, + regions: Vec::new(), + }; + if let Ok(snapshot) = run.amplitude_report(definition.clone()) + && let (Ok(definition), Ok(snapshot)) = ( + serde_json::to_value(definition), + serde_json::to_value(snapshot), + ) + { + let id = app.doc.create_report(plotx_core::state::NewAnalysisReport { + name: format!("CRAFT report {}", report_ids.len() + 1), + kind: plotx_core::state::ReportKindId::new("craft_amplitude"), + source, + definition, + snapshot, + source_fingerprint: run.provenance.input_sha256.clone(), + schema_version: 1, + }); + app.session.ui.craft_selected_report = Some(id); + } + } + if !report_ids.is_empty() { + let mut selected = app + .session + .ui + .craft_selected_report + .filter(|id| report_ids.contains(id)) + .or_else(|| report_ids.first().copied()); + egui::ComboBox::from_id_salt(("craft_report", run.id.0)) + .selected_text( + selected + .map(|id| format!("Report {}", id.0 + 1)) + .unwrap_or_default(), + ) + .show_ui(ui, |ui| { + for id in &report_ids { + ui.selectable_value( + &mut selected, + Some(*id), + format!("Report {}", id.0 + 1), + ); + } + }); + app.session.ui.craft_selected_report = selected; + if let Some(id) = selected { + if ui.button("Delete").clicked() { + app.doc.delete_report(id); + app.session.ui.craft_selected_report = None; + return; + } + if ui.button("Copy").clicked() + && let Ok(copy) = app.doc.copy_report(id, None) + { + app.session.ui.craft_selected_report = Some(copy); + } + if ui.button("Rename").clicked() { + let _ = app + .doc + .rename_report(id, format!("CRAFT report {}", id.0 + 1)); + } + } + } + }); + let Some(id) = app.session.ui.craft_selected_report else { + ui.weak("Create a report to summarize trusted CRAFT components."); + return; + }; + let Some(record) = app.doc.report(id).cloned() else { + return; + }; + match record.status(&app.doc) { + plotx_core::state::ReportStatus::Unavailable => { + ui.colored_label( + ui.visuals().error_fg_color, + "Source CRAFT run is unavailable.", + ); + return; + } + plotx_core::state::ReportStatus::NeedsReview => { + ui.colored_label( + ui.visuals().warn_fg_color, + "Source CRAFT run changed. Review or recreate this report.", + ); + } + plotx_core::state::ReportStatus::Available => {} + } + let mut definition: CraftReportDefinition = + serde_json::from_value(record.definition.clone()).unwrap_or_default(); + let mut changed = false; + ui.horizontal(|ui| { + ui.label("Report threshold A/N"); + changed |= ui + .add( + egui::DragValue::new(&mut definition.threshold_an) + .speed(0.1) + .range(0.001..=1_000.0), + ) + .changed(); + ui.label("Segment width"); + changed |= ui + .add( + egui::DragValue::new(&mut definition.segment_width_hz) + .speed(0.1) + .range(0.001..=1_000_000.0), + ) + .changed(); + ui.label(format!( + "Hz ({:.5} ppm)", + definition.segment_width_hz / nmr.data.observe_freq_mhz + )); + }); + let mut snapshot: CraftAmplitudeReport = serde_json::from_value(record.snapshot.clone()) + .unwrap_or(CraftAmplitudeReport { + schema_version: 1, + definition: definition.clone(), + segments: Vec::new(), + }); + if changed && let Ok(generated_snapshot) = run.amplitude_report(definition.clone()) { + snapshot = generated_snapshot.clone(); + let mut updated = record.clone(); + updated.definition = + serde_json::to_value(&definition).unwrap_or_else(|_| record.definition.clone()); + updated.snapshot = + serde_json::to_value(generated_snapshot).unwrap_or_else(|_| record.snapshot.clone()); + let _ = app.doc.update_report(updated); + } + let component_count: usize = snapshot.segments.iter().map(|s| s.component_count).sum(); + let scalar: f64 = snapshot + .segments + .iter() + .map(|s| s.scalar_amplitude_sum_t0) + .sum(); + let coherent: f64 = snapshot + .segments + .iter() + .map(|s| s.coherent_amplitude_t0) + .sum(); + ui.small(format!( + "{} segment(s) · {} component(s) · scalar {:.5} · coherent {:.5}", + snapshot.segments.len(), + component_count, + scalar, + coherent + )); + if ui + .add_enabled(quantitative_ready, egui::Button::new("Export report…")) + .on_disabled_hover_text( + "Quantitative export is unavailable until CRAFT stability checks pass.", + ) + .clicked() + { + match app.materialize_craft_report_table(index, id) { + Ok(table) => app.open_data_export(table), + Err(message) => app.session.status = message, + } } + egui::Grid::new(("craft_report_table", id.0)) + .striped(true) + .show(ui, |ui| { + ui.strong("Segment"); + ui.strong("Bounds (Hz)"); + ui.strong("Components"); + ui.strong("Scalar"); + ui.strong("Coherent"); + ui.end_row(); + for (i, segment) in snapshot.segments.iter().enumerate() { + ui.label((i + 1).to_string()); + ui.label(format!("{:.4} .. {:.4}", segment.start_hz, segment.end_hz)); + ui.label( + segment + .component_ids + .iter() + .map(|id| id.0.to_string()) + .collect::>() + .join(", "), + ); + ui.label(format!("{:.5}", segment.scalar_amplitude_sum_t0)); + ui.label(format!("{:.5}", segment.coherent_amplitude_t0)); + ui.end_row(); + } + }); + let _ = index; } fn prepare_rerun(app: &mut PlotxApp, run: &StoredCraftRun) { @@ -201,6 +421,21 @@ fn overview( run.components.len(), run.diagnostics.normalized_residual )); + ui.small(format!( + "Fixed protocol: {:.0} Hz modeling bandwidth · boundary dispersion: {:.2}%", + run.provenance + .invocation + .params + .profile + .modeling_bandwidth_hz(), + run.diagnostics + .stability + .regions + .iter() + .map(|region| region.metric.relative_dispersion) + .fold(0.0, f64::max) + * 100.0, + )); ui.small(format!( "Chemical-shift reference {:+.5} ppm · effective carrier {:.5} ppm", run.provenance.invocation.reference.offset_ppm, @@ -234,23 +469,24 @@ fn overview( }); for (position, summary) in run.region_summaries.iter().enumerate() { let selected = app.session.ui.craft_component_region == Some(summary.region); - if ui - .selectable_label( - selected, - format!( - "{} · {:.4}–{:.4} ppm · coherent amplitude {:.4} · {} component(s)", - if exploratory { - "Full bandwidth".into() - } else { - format!("Signal {}", position + 1) - }, - summary.start_ppm, - summary.end_ppm, - summary.coherent_amplitude_t0, - summary.component_count, - ), - ) - .clicked() + if crate::ui::affordance::selectable_row( + ui, + selected, + egui_phosphor::regular::WAVEFORM, + format!( + "{} · {:.4}–{:.4} ppm · coherent amplitude {:.4} · {} component(s)", + if exploratory { + "Full bandwidth".into() + } else { + format!("Signal {}", position + 1) + }, + summary.start_ppm, + summary.end_ppm, + summary.coherent_amplitude_t0, + summary.component_count, + ), + ) + .clicked() { app.session.ui.craft_component_region = Some(summary.region); app.session.ui.craft_result_tab = CraftResultTab::Components; @@ -514,22 +750,22 @@ fn diagnostics( )); ui.small(format!( "A/N {:.2} ({:?}) · model limit {} ({:?}) · linewidth {:.3}–{:.3} Hz ({:?})", - invocation.params.min_amplitude_to_noise, - invocation.sources.min_amplitude_to_noise, - invocation.params.max_components_per_fit_window, - invocation.sources.max_components_per_fit_window, - invocation.params.linewidth_hz.0, - invocation.params.linewidth_hz.1, - invocation.sources.linewidth_hz, + invocation.params.minimum_amplitude_to_noise, + invocation.sources.minimum_amplitude_to_noise, + invocation.params.maximum_model_order, + invocation.sources.maximum_model_order, + invocation.params.component_linewidth_bounds_hz.0, + invocation.params.component_linewidth_bounds_hz.1, + invocation.sources.component_linewidth_bounds_hz, )); ui.small(format!( - "Skip {} points ({:?}) · FIR {} taps · {} available · {} reconstructed · {} fit window(s)", + "Skip {} points ({:?}) · FIR {} taps · {} available · {} reconstructed · {} modeling window(s)", invocation.derived_plan.effective_skip_points, invocation.derived_plan.effective_skip_source, - invocation.derived_plan.actual_filter_taps, + invocation.derived_plan.effective_fir_filter_taps, invocation.derived_plan.available_points, invocation.derived_plan.reconstruction_points, - invocation.derived_plan.fit_windows.len(), + invocation.derived_plan.modeling_windows.len(), )); for issue in &invocation.assessment.issues { ui.colored_label( @@ -541,27 +777,7 @@ fn diagnostics( ); } }); - if !run.diagnostics.fit_windows.is_empty() { - ui.collapsing("Fit-window BIC", |ui| { - for (window, diagnostic) in run.diagnostics.fit_windows.iter().enumerate() { - ui.small(format!( - "Window {} · Region {} · order {}/{} · decimation {} · {} samples · BIC {} · condition {}", - window + 1, - region_number(run, diagnostic.region), - diagnostic.selected_model_order, - diagnostic.evaluated_model_orders, - diagnostic.actual_decimation, - diagnostic.retained_samples, - diagnostic - .bic - .map_or_else(|| "unavailable".into(), |value| format!("{value:.4}")), - diagnostic - .condition_number - .map_or_else(|| "unavailable".into(), |value| format!("{value:.3e}")), - )); - } - }); - } + super::results_diagnostics::show_modeling_windows(run, ui); } fn region_number(run: &StoredCraftRun, id: CraftRegionId) -> usize { @@ -570,16 +786,3 @@ fn region_number(run: &StoredCraftRun, id: CraftRegionId) -> usize { .position(|summary| summary.region == id) .map_or(0, |position| position + 1) } - -#[cfg(test)] -pub(super) fn preview_sample_indices(point_count: usize, sample_count: usize) -> Vec { - let count = point_count.min(sample_count.max(2)); - if count == 0 { - return Vec::new(); - } - if count == 1 { - return vec![0]; - } - let last = point_count - 1; - (0..count).map(|index| index * last / (count - 1)).collect() -} diff --git a/crates/app/src/ui/tools/craft/results_diagnostics.rs b/crates/app/src/ui/tools/craft/results_diagnostics.rs new file mode 100644 index 0000000..dccba2d --- /dev/null +++ b/crates/app/src/ui/tools/craft/results_diagnostics.rs @@ -0,0 +1,32 @@ +use egui::Ui; +use plotx_core::state::StoredCraftRun; + +pub(super) fn show_modeling_windows(run: &StoredCraftRun, ui: &mut Ui) { + if run.diagnostics.modeling_windows.is_empty() { + return; + } + ui.collapsing("Modeling-window validation", |ui| { + for (window, diagnostic) in run.diagnostics.modeling_windows.iter().enumerate() { + let training_bic = diagnostic + .training_bic + .map_or_else(|| "unavailable".into(), |value| format!("{value:.4}")); + let condition = diagnostic + .condition_number + .map_or_else(|| "unavailable".into(), |value| format!("{value:.3e}")); + ui.small(format!( + "Window {} · retain {:.1}..{:.1} Hz · model {:.1}..{:.1} Hz · order {}/{} · decimation {} · {} samples · training residual {:.3} · validation residual {:.3} · training BIC {training_bic} · condition {condition}", + window + 1, + diagnostic.retention_band_hz.0, + diagnostic.retention_band_hz.1, + diagnostic.modeling_band_hz.0, + diagnostic.modeling_band_hz.1, + diagnostic.selected_model_order, + diagnostic.evaluated_model_orders, + diagnostic.decimation_factor, + diagnostic.modeled_sample_count, + diagnostic.training_normalized_residual, + diagnostic.validation_normalized_residual, + )); + } + }); +} diff --git a/crates/app/src/ui/tools/craft/setup.rs b/crates/app/src/ui/tools/craft/setup.rs index 07ea9ca..fc08d60 100644 --- a/crates/app/src/ui/tools/craft/setup.rs +++ b/crates/app/src/ui/tools/craft/setup.rs @@ -69,10 +69,10 @@ fn readiness(intent: CraftAnalysisIntent, invocation: &CraftInvocation, ui: &mut }; ui.colored_label(color, crate::typography::headline(label)); ui.small(format!( - "{} points ({} usable) · {duration} · {} fit window(s) · {} clear signal(s)", + "{} points ({} usable) · {duration} · {} modeling window(s) · {} clear signal(s)", assessment.point_count, assessment.effective_point_count, - assessment.fit_window_count, + assessment.modeling_window_count, assessment.clear_signals.len(), )); for issue in &assessment.issues { @@ -179,43 +179,43 @@ fn settings(app: &mut PlotxApp, index: usize, invocation: &CraftInvocation, ui: ui.add_space(8.0); ui.label(crate::typography::headline( - "3. Confirm acquisition and fit settings", + "3. Confirm acquisition and component settings", )); - ui.collapsing("Advanced fit settings", |ui| { - let mut value = invocation.params.min_amplitude_to_noise; + ui.collapsing("Advanced component settings", |ui| { + let mut value = invocation.params.minimum_amplitude_to_noise; if setting_row( ui, "Minimum A/N", - invocation.sources.min_amplitude_to_noise, + invocation.sources.minimum_amplitude_to_noise, &nmr, - &mut overrides.min_amplitude_to_noise, + &mut overrides.minimum_amplitude_to_noise, |ui| { ui.add(DragValue::new(&mut value).range(0.1..=100.0).speed(0.1)) .changed() }, ) { - overrides.min_amplitude_to_noise = Some(value); + overrides.minimum_amplitude_to_noise = Some(value); } - let mut value = invocation.params.max_components_per_fit_window; + let mut value = invocation.params.maximum_model_order; if setting_row( ui, - "Max components / fit window", - invocation.sources.max_components_per_fit_window, + "Maximum model order", + invocation.sources.maximum_model_order, &nmr, - &mut overrides.max_components_per_fit_window, + &mut overrides.maximum_model_order, |ui| ui.add(DragValue::new(&mut value).range(1..=64)).changed(), ) { - overrides.max_components_per_fit_window = Some(value); + overrides.maximum_model_order = Some(value); } - let mut value = invocation.params.linewidth_hz; + let mut value = invocation.params.component_linewidth_bounds_hz; if setting_row( ui, - "Linewidth range (Hz)", - invocation.sources.linewidth_hz, + "Component linewidth range (Hz)", + invocation.sources.component_linewidth_bounds_hz, &nmr, - &mut overrides.linewidth_hz, + &mut overrides.component_linewidth_bounds_hz, |ui| { let first = ui .add(DragValue::new(&mut value.0).range(0.001..=1_000.0)) @@ -226,52 +226,7 @@ fn settings(app: &mut PlotxApp, index: usize, invocation: &CraftInvocation, ui: .changed() }, ) { - overrides.linewidth_hz = Some(value); - } - - let mut value = invocation.params.max_fit_window_width_hz; - if setting_row( - ui, - "Fit window width (Hz)", - invocation.sources.max_fit_window_width_hz, - &nmr, - &mut overrides.max_fit_window_width_hz, - |ui| { - ui.add(DragValue::new(&mut value).range(10.0..=10_000.0)) - .changed() - }, - ) { - overrides.max_fit_window_width_hz = Some(value); - } - - let mut value = invocation.params.filter_taps; - if setting_row( - ui, - "FIR taps", - invocation.sources.filter_taps, - &nmr, - &mut overrides.filter_taps, - |ui| { - ui.add(DragValue::new(&mut value).range(3..=4_095)) - .changed() - }, - ) { - overrides.filter_taps = Some(value | 1); - } - - let mut value = invocation.params.max_downsampled_points; - if setting_row( - ui, - "Max downsampled points", - invocation.sources.max_downsampled_points, - &nmr, - &mut overrides.max_downsampled_points, - |ui| { - ui.add(DragValue::new(&mut value).range(64..=65_536)) - .changed() - }, - ) { - overrides.max_downsampled_points = Some(value); + overrides.component_linewidth_bounds_hz = Some(value); } if invocation.params.profile == CraftProfile::Ssfp { @@ -311,9 +266,11 @@ fn settings(app: &mut PlotxApp, index: usize, invocation: &CraftInvocation, ui: } } ui.weak(format!( - "Derived plan: skip {} points · {} actual taps · {} reconstructed points", + "Fixed protocol: {:.0} Hz modeling bandwidth · {:.2} s modeling duration · skip {} points · {} FIR taps · {} reconstructed points", + invocation.params.profile.modeling_bandwidth_hz(), + invocation.params.profile.modeling_duration_s(), invocation.derived_plan.effective_skip_points, - invocation.derived_plan.actual_filter_taps, + invocation.derived_plan.effective_fir_filter_taps, invocation.derived_plan.reconstruction_points, )); }); diff --git a/crates/app/src/ui/tools/mod.rs b/crates/app/src/ui/tools/mod.rs index ec7f3c1..17b1a35 100644 --- a/crates/app/src/ui/tools/mod.rs +++ b/crates/app/src/ui/tools/mod.rs @@ -284,7 +284,7 @@ fn peaks_group(app: &mut PlotxApp, di: usize, ui: &mut Ui) -> bool { } }); - let resolved = peaks.resolve(); + let resolved = peaks.resolve(app.doc.datasets[di].peak_reference_offset_ppm()); ui.horizontal(|ui| { ui.label(format!("Peaks: {}", resolved.len())); if ui diff --git a/crates/app/src/ui/tools/processing/surface.rs b/crates/app/src/ui/tools/processing/surface.rs index 8346c22..bf43cd5 100644 --- a/crates/app/src/ui/tools/processing/surface.rs +++ b/crates/app/src/ui/tools/processing/surface.rs @@ -482,16 +482,13 @@ fn step_row( egui::Frame::group(ui.style()).show(ui, |ui| { ui.horizontal_wrapped(|ui| { crate::ui::properties::panel::processing_step_section(app, &target, ui); - let response = ui - .selectable_label( - expanded, - format!( - "{} {}", - editors::kind_icon(&step.kind), - editors::kind_label(&step.kind) - ), - ) - .on_hover_text(editors::kind_summary(&step.kind)); + let response = crate::ui::affordance::selectable_row( + ui, + expanded, + editors::kind_icon(&step.kind), + editors::kind_label(&step.kind), + ) + .on_hover_text(editors::kind_summary(&step.kind)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.menu_button(icon::DOTS_THREE, |ui| { row_menu( diff --git a/crates/app/src/ui/tools/region_analysis.rs b/crates/app/src/ui/tools/region_analysis.rs index 185e030..2d6fe2f 100644 --- a/crates/app/src/ui/tools/region_analysis.rs +++ b/crates/app/src/ui/tools/region_analysis.rs @@ -247,9 +247,13 @@ fn region_task_body(app: &mut PlotxApp, di: usize, ui: &mut Ui) { } else { format!("{:.3}–{:.3} {axis_unit}", region.lo_min(), region.hi_max()) }; - if ui - .add(Button::selectable(selected == Some(region.id), interval)) - .clicked() + if crate::ui::affordance::selectable_row( + ui, + selected == Some(region.id), + icon::SELECTION, + interval, + ) + .clicked() { select_id = Some(region.id); } diff --git a/crates/app/src/ui/tools/task_card.rs b/crates/app/src/ui/tools/task_card.rs index 88d4d3a..a646450 100644 --- a/crates/app/src/ui/tools/task_card.rs +++ b/crates/app/src/ui/tools/task_card.rs @@ -70,11 +70,13 @@ pub(super) fn geometry( chrome + preferred_body }, ); + let initial_rect = egui::Rect::from_min_size( + host_rect.right_top() + egui::vec2(-desired_size.x, TOP_OFFSET), + desired_size, + ); let initial = CardLayout { - rect: egui::Rect::from_min_size( - host_rect.right_top() + egui::vec2(-desired_size.x, TOP_OFFSET), - desired_size, - ), + rect: initial_rect, + preferred: initial_rect, bounds: host_rect, horizontal: HorizontalAnchor::Right, vertical: VerticalAnchor::Top, @@ -313,6 +315,7 @@ fn update_drag_position(ui: &Ui, area_id: Id, drag: &egui::Response) { .get_temp::(area_id.with("layout")) .unwrap_or(CardLayout { rect: origin.rect, + preferred: origin.rect, bounds, horizontal: HorizontalAnchor::Right, vertical: VerticalAnchor::Top, @@ -321,6 +324,7 @@ fn update_drag_position(ui: &Ui, area_id: Id, drag: &egui::Response) { collapsed: false, }); layout.rect = rect; + layout.preferred = rect; layout.bounds = bounds; layout.horizontal = if rect.left() - bounds.left() <= bounds.right() - rect.right() { HorizontalAnchor::Left @@ -573,6 +577,7 @@ pub(super) fn resize_handles( ui.ctx().data_mut(|data| { if let Some(mut layout) = data.get_temp::(area_id.with("layout")) { layout.rect = resized; + layout.preferred = resized; layout.bounds = bounds; if edges.left { layout.horizontal = HorizontalAnchor::Right; diff --git a/crates/app/src/ui/tools/task_card_layout.rs b/crates/app/src/ui/tools/task_card_layout.rs index 74d13ac..d76ecde 100644 --- a/crates/app/src/ui/tools/task_card_layout.rs +++ b/crates/app/src/ui/tools/task_card_layout.rs @@ -14,7 +14,14 @@ pub(super) enum VerticalAnchor { #[derive(Clone, Copy, Debug)] pub(super) struct CardLayout { + /// The rectangle actually rendered this frame: `preferred` fitted into the + /// current workspace bounds. pub rect: Rect, + /// The user-intended rectangle, written only by gestures (drag, resize) and + /// by boundary-following of a flush edge. It may lie outside the current + /// bounds; keeping it lets a card that a sidebar pushed aside return to its + /// place when the sidebar hides again. + pub preferred: Rect, pub bounds: Rect, pub horizontal: HorizontalAnchor, pub vertical: VerticalAnchor, @@ -23,43 +30,78 @@ pub(super) struct CardLayout { pub collapsed: bool, } -/// Rebuild a card from its preferred size and the edges fixed by the user's -/// last gesture. Viewport fitting may temporarily reduce the rendered size, -/// but never mutates that preferred size. +/// Distance within which a card edge counts as resting on a workspace boundary. +/// A resting edge keeps following that boundary (the default top-right docking +/// across sidebar toggles); a card parked anywhere else keeps its absolute +/// position and is only clamped back inside the new bounds. +const FLUSH_EPS: f32 = 1.0; + +/// Rebuild a card from its preferred rectangle, its preferred size and the +/// edges fixed by the user's last gesture. Viewport fitting may temporarily +/// move or shrink the rendered rectangle, but never mutates the preference: +/// bounds changes must not teleport a parked card (only a flush edge follows +/// its boundary), and a card displaced by a shrinking boundary returns once +/// the boundary recedes. pub(super) fn fit_layout( mut layout: CardLayout, bounds: Rect, desired_size: Vec2, collapsed: bool, ) -> CardLayout { - let rect = match layout.horizontal { + let mut preferred = layout.preferred; + match layout.horizontal { + HorizontalAnchor::Left => { + if (preferred.left() - layout.bounds.left()).abs() <= FLUSH_EPS { + preferred = + preferred.translate(Vec2::new(bounds.left() - layout.bounds.left(), 0.0)); + } + } + HorizontalAnchor::Right => { + if (layout.bounds.right() - preferred.right()).abs() <= FLUSH_EPS { + preferred = + preferred.translate(Vec2::new(bounds.right() - layout.bounds.right(), 0.0)); + } + } + } + match layout.vertical { + VerticalAnchor::Top => { + if (preferred.top() - layout.bounds.top()).abs() <= FLUSH_EPS { + preferred = preferred.translate(Vec2::new(0.0, bounds.top() - layout.bounds.top())); + } + } + VerticalAnchor::Bottom => { + if (layout.bounds.bottom() - preferred.bottom()).abs() <= FLUSH_EPS { + preferred = + preferred.translate(Vec2::new(0.0, bounds.bottom() - layout.bounds.bottom())); + } + } + } + let x_range = match layout.horizontal { HorizontalAnchor::Left => { - let left = (layout.rect.left() + bounds.left() - layout.bounds.left()) - .clamp(bounds.left(), bounds.right()); + let left = preferred.left().clamp(bounds.left(), bounds.right()); let width = desired_size.x.min((bounds.right() - left).max(1.0)); - Rect::from_x_y_ranges(left..=left + width, layout.rect.y_range()) + left..=left + width } HorizontalAnchor::Right => { - let right = (layout.rect.right() + bounds.right() - layout.bounds.right()) - .clamp(bounds.left(), bounds.right()); + let right = preferred.right().clamp(bounds.left(), bounds.right()); let width = desired_size.x.min((right - bounds.left()).max(1.0)); - Rect::from_x_y_ranges(right - width..=right, layout.rect.y_range()) + right - width..=right } }; - layout.rect = match layout.vertical { + let y_range = match layout.vertical { VerticalAnchor::Top => { - let top = (rect.top() + bounds.top() - layout.bounds.top()) - .clamp(bounds.top(), bounds.bottom()); + let top = preferred.top().clamp(bounds.top(), bounds.bottom()); let height = desired_size.y.min((bounds.bottom() - top).max(1.0)); - Rect::from_x_y_ranges(rect.x_range(), top..=top + height) + top..=top + height } VerticalAnchor::Bottom => { - let bottom = (rect.bottom() + bounds.bottom() - layout.bounds.bottom()) - .clamp(bounds.top(), bounds.bottom()); + let bottom = preferred.bottom().clamp(bounds.top(), bounds.bottom()); let height = desired_size.y.min((bottom - bounds.top()).max(1.0)); - Rect::from_x_y_ranges(rect.x_range(), bottom - height..=bottom) + bottom - height..=bottom } }; + layout.rect = Rect::from_x_y_ranges(x_range, y_range); + layout.preferred = preferred; layout.bounds = bounds; layout.collapsed = collapsed; layout diff --git a/crates/app/src/ui/tools/task_card_tests.rs b/crates/app/src/ui/tools/task_card_tests.rs index c6b6e88..aa1a6f8 100644 --- a/crates/app/src/ui/tools/task_card_tests.rs +++ b/crates/app/src/ui/tools/task_card_tests.rs @@ -178,6 +178,126 @@ fn title_drag_moves_the_shared_task_card() { assert_eq!(moved.min, before_drag.min + (pointer_end - pointer_start)); } +#[test] +fn sidebar_toggle_keeps_a_parked_card_and_its_drag_gesture() { + let ctx = egui::Context::default(); + let mut fonts = egui::FontDefinitions::default(); + let emphasized = fonts.families[&egui::FontFamily::Proportional].clone(); + fonts.families.insert( + egui::FontFamily::Name(crate::typography::EMPHASIZED_FAMILY_NAME.into()), + emphasized, + ); + ctx.set_fonts(fonts); + crate::typography::apply(&ctx); + let screen = egui::Rect::from_min_size(Pos2::ZERO, egui::vec2(1400.0, 800.0)); + let mut app = app_with_task(TaskDockTab::Processing, false); + app.session.active_canvas = Some(0); + app.session.secondary_sidebar_visible = true; + let mut clipboard = crate::ui::clipboard_table::ClipboardTablePaste::default(); + let mut workflow = crate::ui::batch_workflow::AutomationUi::default(); + let mut title = None; + let id = area_id(TaskDockTab::Processing); + let mut frame = |app: &mut PlotxApp, events: Vec| { + let _ = ctx.run_ui( + egui::RawInput { + screen_rect: Some(screen), + events, + ..Default::default() + }, + |ui| { + crate::ui::render( + app, + &mut clipboard, + &mut workflow, + &mut title, + ui, + false, + crate::ui::RibbonChrome::default(), + ); + }, + ); + }; + let press = |pos: Pos2| egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::default(), + }; + let release = |pos: Pos2| egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::default(), + }; + for _ in 0..4 { + frame(&mut app, Vec::new()); + } + let card = ctx.memory(|m| m.area_rect(id)).expect("card rendered"); + + // Park the card away from every boundary by dragging its title. + let grab = card.min + egui::vec2(30.0, 12.0); + frame(&mut app, vec![egui::Event::PointerMoved(grab)]); + frame(&mut app, vec![egui::Event::PointerMoved(grab), press(grab)]); + let target = egui::pos2(700.0, 250.0); + frame(&mut app, vec![egui::Event::PointerMoved(target)]); + frame(&mut app, vec![release(target)]); + frame(&mut app, Vec::new()); + let parked = ctx.memory(|m| m.area_rect(id)).expect("card rendered"); + assert_eq!(parked.min, card.min + (target - grab)); + + // Hiding the secondary sidebar must not teleport the parked card. + app.session.secondary_sidebar_visible = false; + frame(&mut app, Vec::new()); + frame(&mut app, Vec::new()); + let after_hide = ctx.memory(|m| m.area_rect(id)).expect("card rendered"); + assert_eq!(after_hide, parked); + + // The next title drag moves the card by exactly the pointer travel. + let grab = after_hide.min + egui::vec2(30.0, 12.0); + frame(&mut app, vec![egui::Event::PointerMoved(grab)]); + frame(&mut app, vec![egui::Event::PointerMoved(grab), press(grab)]); + let target = grab + egui::vec2(-120.0, -40.0); + frame(&mut app, vec![egui::Event::PointerMoved(target)]); + frame(&mut app, vec![release(target)]); + frame(&mut app, Vec::new()); + let dragged = ctx.memory(|m| m.area_rect(id)).expect("card rendered"); + assert_eq!(dragged.min, after_hide.min + (target - grab)); +} + +#[test] +fn bounds_change_keeps_a_parked_card_in_place() { + let bounds = egui::Rect::from_min_max(egui::pos2(200.0, 0.0), egui::pos2(1112.0, 700.0)); + // Parked with a clear gap to the right boundary, but still right-anchored. + let original = layout( + egui::Rect::from_min_max(egui::pos2(660.0, 120.0), egui::pos2(1000.0, 560.0)), + bounds, + ); + let without_sidebar = + egui::Rect::from_min_max(egui::pos2(200.0, 0.0), egui::pos2(1396.0, 700.0)); + + let fitted = fit_layout(original, without_sidebar, Vec2::new(340.0, 440.0), false); + + assert_eq!(fitted.rect, original.rect); +} + +#[test] +fn a_card_displaced_by_a_sidebar_returns_when_it_hides() { + let wide = egui::Rect::from_min_max(egui::pos2(200.0, 0.0), egui::pos2(1396.0, 700.0)); + let original = layout( + egui::Rect::from_min_max(egui::pos2(816.0, 40.0), egui::pos2(1156.0, 480.0)), + wide, + ); + let with_sidebar = egui::Rect::from_min_max(egui::pos2(200.0, 0.0), egui::pos2(1112.0, 700.0)); + let size = Vec2::new(340.0, 440.0); + + let displaced = fit_layout(original, with_sidebar, size, false); + assert_eq!(displaced.rect.right(), with_sidebar.right()); + assert_eq!(displaced.preferred, original.preferred); + + let restored = fit_layout(displaced, wide, size, false); + assert_eq!(restored.rect, original.rect); +} + #[test] fn collapsed_cards_use_the_compact_width_without_overwriting_the_preference() { let app = PlotxApp::new_with_settings(plotx_core::settings::Settings::default()); @@ -296,6 +416,7 @@ fn shrinking_from_the_left_keeps_the_right_edge_fixed() { fn layout(rect: egui::Rect, bounds: egui::Rect) -> CardLayout { CardLayout { rect, + preferred: rect, bounds, horizontal: HorizontalAnchor::Right, vertical: VerticalAnchor::Top, diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 79218fc..f23d078 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -605,6 +605,17 @@ fn text_report(report: &InspectionReport) -> String { ephys.protocol.as_deref().unwrap_or("unknown") )); } + if let Some(mass_spec) = &report.mass_spectrometry { + if let Some(instrument) = &mass_spec.instrument { + lines.push(format!("mass_spec.instrument: {instrument}")); + } + lines.push(format!("mass_spec.streams: {}", mass_spec.stream_count)); + lines.push(format!("mass_spec.scans: {}", mass_spec.ms_scan_count)); + lines.push(format!( + "mass_spec.chromatograms: {}", + mass_spec.chromatograms.join(", ") + )); + } if let Some(xrd) = &report.xrd { lines.push(format!("xrd.points: {}", xrd.point_count)); lines.push(format!( @@ -728,6 +739,43 @@ mod tests { assert!(parse(&["plotx-cli", "batch", "workflow.json"]).is_err()); } + #[test] + fn text_inspection_includes_mass_spectrometry_statistics() { + let report = InspectionReport { + schema: plotx_core::workflow::INSPECTION_SCHEMA, + format: "sciex-wiff".to_owned(), + provenance: plotx_core::workflow::ProvenanceReport { + selected_path: "sample.wiff".into(), + data_path: "sample.wiff".into(), + parameter_paths: Vec::new(), + companion_paths: vec!["sample.wiff.scan".into()], + }, + dimension: plotx_core::workflow::DimensionReport { + count: 3, + shape: vec![2, 42, 1], + }, + domain: "mass_spectrometry".to_owned(), + warnings: Vec::new(), + electrophysiology: None, + afm: None, + mass_spectrometry: Some(plotx_core::workflow::MassSpecReport { + instrument: Some("SCIEX TripleTOF 6600".to_owned()), + stream_count: 2, + ms_scan_count: 42, + chromatograms: vec!["total ion current chromatogram".to_owned()], + }), + xrd: None, + xps: None, + }; + + let output = text_report(&report); + + assert!(output.contains("format: sciex-wiff")); + assert!(output.contains("mass_spec.streams: 2")); + assert!(output.contains("mass_spec.scans: 42")); + assert!(output.contains("mass_spec.chromatograms: total ion current chromatogram")); + } + #[test] fn workflow_errors_map_to_stable_exit_categories() { let status = fail(WorkflowError::FigureUnavailable("NMR 1D")); diff --git a/crates/core/src/actions/app_impl/processing.rs b/crates/core/src/actions/app_impl/processing.rs index 5ee8c6a..753ba5a 100644 --- a/crates/core/src/actions/app_impl/processing.rs +++ b/crates/core/src/actions/app_impl/processing.rs @@ -301,6 +301,58 @@ impl PlotxApp { } } + /// Arm or disarm the one-shot pick of a Reference step's source position + /// on the plot. Arming replaces any previously armed pick. + pub fn toggle_reference_pick(&mut self, dataset: DatasetId, step: plotx_processing::StepId) { + let pick = crate::state::ReferencePick { dataset, step }; + if self.session.ui.reference_pick.take() == Some(pick) { + self.session.status = "Reference pick cancelled.".to_owned(); + return; + } + self.session.ui.reference_pick = Some(pick); + self.session.status = + "Click the spectrum to pick the reference source position — Shift skips the peak snap, Esc cancels." + .to_owned(); + } + + /// The armed Reference pick resolved against the document, or `None` when + /// nothing valid is armed. A pick is valid only while the step editor that + /// armed it is still expanded on the active dataset and the step is still + /// a Reference step — the same lifetime the on-plot phase mode uses — so a + /// collapsed card cannot leave a live click trap on the plot. + pub fn resolve_reference_pick(&self) -> Option { + let pick = self.session.ui.reference_pick?; + if self.session.ui.proc_expanded_step != Some((pick.dataset, pick.step)) { + return None; + } + let dataset_index = self.active_dataset()?; + let dataset = self.doc.datasets.get(dataset_index)?; + if dataset.resource_id() != pick.dataset { + return None; + } + let axis = dataset.phase_axes().iter().copied().find(|&axis| { + dataset.axis_pipeline(axis).is_some_and(|pipe| { + pipe.steps.iter().any(|step| { + step.id == pick.step + && matches!(step.kind, plotx_processing::StepKind::Reference(_)) + }) + }) + })?; + Some(crate::state::ResolvedReferencePick { + pick, + dataset_index, + axis, + }) + } + + /// Drop an armed Reference pick that went stale — its editor collapsed, + /// the active dataset changed, or the step is gone. + pub fn sync_reference_pick(&mut self) { + if self.session.ui.reference_pick.is_some() && self.resolve_reference_pick().is_none() { + self.session.ui.reference_pick = None; + } + } + /// Switch the first enabled Phase step on `axis` to manual, seeding it from the /// phase the auto method currently yields so the display does not jump. Used by /// the on-plot phase grab and the panel's Manual/Auto switch. diff --git a/crates/core/src/actions/tests/mod.rs b/crates/core/src/actions/tests/mod.rs index fb3f3e8..f534d66 100644 --- a/crates/core/src/actions/tests/mod.rs +++ b/crates/core/src/actions/tests/mod.rs @@ -14,6 +14,7 @@ mod linefit; mod more; mod multiplet; mod pr2; +mod reference_pick; mod scheme_apply; mod stable_identity; mod stack; diff --git a/crates/core/src/actions/tests/reference_pick.rs b/crates/core/src/actions/tests/reference_pick.rs new file mode 100644 index 0000000..dabf737 --- /dev/null +++ b/crates/core/src/actions/tests/reference_pick.rs @@ -0,0 +1,83 @@ +//! Lifetime of the one-shot Reference on-plot pick: it is valid exactly while +//! the step editor that armed it stays expanded on the active dataset. + +use super::*; +use crate::state::{PhaseAxis, ReferencePick}; +use plotx_processing::{ProcessingStep, ReferenceParams, StepId, StepKind, StepSource}; + +fn add_reference_step(app: &mut PlotxApp) -> StepId { + let pipe = app.doc.datasets[0] + .axis_pipeline_mut(PhaseAxis::Direct) + .unwrap(); + let id = StepId::new(pipe.steps.iter().map(|s| s.id.get()).max().unwrap_or(0) + 1); + pipe.steps.push(ProcessingStep::new( + id, + StepKind::Reference(ReferenceParams { + at_ppm: 0.0, + target_ppm: 0.0, + }), + StepSource::User, + )); + id +} + +#[test] +fn reference_pick_resolves_only_while_its_editor_is_expanded() { + let mut app = sample_app(); + let step = add_reference_step(&mut app); + let dataset = dataset_id(&app, 0); + + app.toggle_reference_pick(dataset, step); + assert_eq!( + app.session.ui.reference_pick, + Some(ReferencePick { dataset, step }) + ); + // Armed but the editor is not expanded: not resolvable, and sync drops it. + assert!(app.resolve_reference_pick().is_none()); + app.sync_reference_pick(); + assert!(app.session.ui.reference_pick.is_none()); + + app.session.ui.proc_expanded_step = Some((dataset, step)); + app.toggle_reference_pick(dataset, step); + let resolved = app + .resolve_reference_pick() + .expect("expanded editor arms a valid pick"); + assert_eq!(resolved.dataset_index, 0); + assert_eq!(resolved.axis, PhaseAxis::Direct); + assert_eq!(resolved.pick, ReferencePick { dataset, step }); + + // Collapsing the editor invalidates the pick; sync clears the arm state. + app.session.ui.proc_expanded_step = None; + assert!(app.resolve_reference_pick().is_none()); + app.sync_reference_pick(); + assert!(app.session.ui.reference_pick.is_none()); +} + +#[test] +fn reference_pick_toggle_disarms_and_a_non_reference_step_never_resolves() { + let mut app = sample_app(); + let step = add_reference_step(&mut app); + let dataset = dataset_id(&app, 0); + app.session.ui.proc_expanded_step = Some((dataset, step)); + + app.toggle_reference_pick(dataset, step); + assert!(app.resolve_reference_pick().is_some()); + app.toggle_reference_pick(dataset, step); + assert!(app.session.ui.reference_pick.is_none()); + + // A pick armed for a step that is not a Reference step must not resolve — + // the pick would otherwise write at_ppm into a foreign step. + let phase_step = app.doc.datasets[0] + .axis_pipeline(PhaseAxis::Direct) + .unwrap() + .steps + .iter() + .find(|s| matches!(s.kind, StepKind::Phase(_))) + .unwrap() + .id; + app.session.ui.proc_expanded_step = Some((dataset, phase_step)); + app.toggle_reference_pick(dataset, phase_step); + assert!(app.resolve_reference_pick().is_none()); + app.sync_reference_pick(); + assert!(app.session.ui.reference_pick.is_none()); +} diff --git a/crates/core/src/data_export.rs b/crates/core/src/data_export.rs index 364e05f..221458d 100644 --- a/crates/core/src/data_export.rs +++ b/crates/core/src/data_export.rs @@ -372,7 +372,7 @@ impl DataExportSnapshot { dataset .peaks() .ok_or(DataExportError::ContentUnavailable)? - .resolve(), + .resolve(dataset.peak_reference_offset_ppm()), ), DataExportContent::Integrals => { if let Some(nmr) = dataset.as_nmr() { diff --git a/crates/core/src/project/craft_tests.rs b/crates/core/src/project/craft_tests.rs index df8a39a..57bdbad 100644 --- a/crates/core/src/project/craft_tests.rs +++ b/crates/core/src/project/craft_tests.rs @@ -1,10 +1,15 @@ use super::tests::synthetic_1d; use super::*; -use crate::state::{CraftRunId, FieldPayload, StoredCraftRun}; +use crate::state::{ + CraftRunId, FieldPayload, NewAnalysisReport, ReportKindId, ReportSource, ReportStatus, + StoredCraftRun, +}; use plotx_processing::craft::{ - CraftComponent, CraftComponentId, CraftDiagnostics, CraftFitWindowDiagnostic, + CraftComponent, CraftComponentId, CraftDiagnostics, CraftModelingWindowDiagnostic, CraftParamOverrides, CraftParams, CraftReference, CraftRegionId, CraftRegionRatio, - CraftRegionSummary, CraftResult, CraftRunStatus, resolve_craft_invocation, + CraftRegionSummary, CraftReportDefinition, CraftResult, CraftRunStatus, + CraftStabilityDiagnostics, CraftStabilityMetric, CraftStabilityRegion, + resolve_craft_invocation, }; fn sample_run(data: &NmrData) -> StoredCraftRun { @@ -80,31 +85,54 @@ fn sample_run(data: &NmrData) -> StoredCraftRun { residual_rss: 1.0, normalized_residual: 0.02, maximum_condition_number: Some(4.0), - fit_windows: vec![ - CraftFitWindowDiagnostic { - region: CraftRegionId(0), - core_hz: (-1300.0, -1100.0), - padded_hz: (-1320.0, -1080.0), - actual_decimation: 4, - retained_samples: 256, + modeling_windows: vec![ + CraftModelingWindowDiagnostic { + retention_band_hz: (-1300.0, -1100.0), + modeling_band_hz: (-1320.0, -1080.0), + decimation_factor: 4, + modeled_sample_count: 256, evaluated_model_orders: 7, selected_model_order: 1, - bic: Some(-25.0), + training_bic: Some(-25.0), condition_number: Some(3.0), + modeled_duration_s: 1.0, + training_normalized_residual: 0.01, + validation_normalized_residual: 0.02, }, - CraftFitWindowDiagnostic { - region: CraftRegionId(1), - core_hz: (1100.0, 1300.0), - padded_hz: (1080.0, 1320.0), - actual_decimation: 4, - retained_samples: 256, + CraftModelingWindowDiagnostic { + retention_band_hz: (1100.0, 1300.0), + modeling_band_hz: (1080.0, 1320.0), + decimation_factor: 4, + modeled_sample_count: 256, evaluated_model_orders: 7, selected_model_order: 1, - bic: Some(-20.0), + training_bic: Some(-20.0), condition_number: Some(4.0), + modeled_duration_s: 1.0, + training_normalized_residual: 0.01, + validation_normalized_residual: 0.02, }, ], warnings: Vec::new(), + stability: CraftStabilityDiagnostics { + delta_ppm: 0.016, + regions: vec![CraftStabilityRegion { + region: CraftRegionId(0), + metric: CraftStabilityMetric { + median: 1.0, + minimum: 0.999, + maximum: 1.001, + relative_dispersion: 0.002, + }, + component_count_min: 1, + component_count_max: 1, + model_order_min: 1, + model_order_max: 1, + }], + ratio: None, + passed: true, + skipped: Vec::new(), + }, }, synthetic_fid: Vec::new(), residual_fid: Vec::new(), @@ -181,7 +209,7 @@ fn unavailable_craft_diagnostics_survive_project_roundtrip() { run.components[0].linewidth_std_hz = None; run.components[0].phase_std_rad = None; run.diagnostics.maximum_condition_number = None; - run.diagnostics.fit_windows[0].bic = None; + run.diagnostics.modeling_windows[0].training_bic = None; let mut dataset = NmrDataset::load(data); dataset.craft_runs.push(run.clone()); dataset.reconcile_craft_fields(); @@ -200,6 +228,101 @@ fn unavailable_craft_diagnostics_survive_project_roundtrip() { assert_eq!(dataset.craft_runs, vec![run]); } +#[test] +fn only_stable_complete_runs_create_quantitative_reports() { + let data = synthetic_1d(); + let definition = CraftReportDefinition::default(); + let stable = sample_run(&data); + assert!(stable.amplitude_report(definition.clone()).is_ok()); + + let mut needs_review = stable.clone(); + needs_review.diagnostics.status = CraftRunStatus::Partial; + needs_review.diagnostics.stability.passed = false; + assert!( + needs_review + .amplitude_report(definition) + .unwrap_err() + .contains("NeedsReview") + ); + assert!(crate::state::craft_component_table(&needs_review).is_ok()); +} + +#[test] +fn report_status_tracks_stability_and_source_availability() { + let data = synthetic_1d(); + let mut dataset = NmrDataset::load(data.clone()); + let mut run = sample_run(&data); + run.provenance.invocation.reference = dataset.craft_reference(); + let definition = CraftReportDefinition::default(); + let snapshot = run.amplitude_report(definition.clone()).unwrap(); + let source = ReportSource { + dataset: dataset.resource_id, + craft_run: run.id, + }; + dataset.craft_runs.push(run); + dataset.reconcile_craft_fields(); + let mut app = crate::state::PlotxApp::new(); + app.doc.datasets.push(Dataset::Nmr(Box::new(dataset))); + let report = app.doc.create_report(NewAnalysisReport { + name: "CRAFT stability status".to_owned(), + kind: ReportKindId::new("craft_amplitude"), + source, + definition: serde_json::to_value(definition).unwrap(), + snapshot: serde_json::to_value(snapshot).unwrap(), + source_fingerprint: crate::state::craft_input_sha256(&data), + schema_version: 1, + }); + + assert_eq!( + app.doc.report(report).unwrap().status(&app.doc), + ReportStatus::Available + ); + app.doc.datasets[0].as_nmr_mut().unwrap().craft_runs[0] + .diagnostics + .stability + .passed = false; + assert_eq!( + app.doc.report(report).unwrap().status(&app.doc), + ReportStatus::NeedsReview + ); + app.doc.datasets[0].as_nmr_mut().unwrap().craft_runs.clear(); + assert_eq!( + app.doc.report(report).unwrap().status(&app.doc), + ReportStatus::Unavailable + ); +} + +#[test] +fn stability_snapshot_survives_project_roundtrip() { + let data = synthetic_1d(); + let mut run = sample_run(&data); + run.diagnostics.stability.skipped = vec!["contract: overlapping regions".to_owned()]; + run.diagnostics.stability.ratio = Some(CraftStabilityMetric { + median: 0.5, + minimum: 0.498, + maximum: 0.502, + relative_dispersion: 0.008, + }); + let mut dataset = NmrDataset::load(data); + dataset.craft_runs.push(run.clone()); + dataset.reconcile_craft_fields(); + let mut app = crate::state::PlotxApp::new(); + app.doc.datasets.push(Dataset::Nmr(Box::new(dataset))); + let path = super::tests::temp_project("craft-stability"); + let _ = std::fs::remove_file(&path); + + save_project(&app, &path, false).unwrap(); + let loaded = load_project(&path).unwrap(); + let _ = std::fs::remove_file(&path); + + assert_eq!( + loaded.doc.datasets[0].as_nmr().unwrap().craft_runs[0] + .diagnostics + .stability, + run.diagnostics.stability + ); +} + #[test] fn craft_component_table_link_and_board_visibility_survive_roundtrip() { let data = synthetic_1d(); diff --git a/crates/core/src/project/mod.rs b/crates/core/src/project/mod.rs index cbd8e80..ffe0b3e 100644 --- a/crates/core/src/project/mod.rs +++ b/crates/core/src/project/mod.rs @@ -403,6 +403,16 @@ fn save_project_impl( }); } + for report in &doc.reports { + let path = format!("reports/{}.json", report.id.0); + write_json(&mut zip, options, &path, report)?; + manifest.runs.push(Entry { + id: report.id.0.to_string(), + role: "report".to_owned(), + path, + }); + } + let workspace = Workspace { dataset_order: bindings, view_order, @@ -469,6 +479,7 @@ pub fn load_project(path: &Path) -> Result { app.doc.datasets.clear(); app.doc.canvases.clear(); app.doc.assets.clear(); + app.doc.reports.clear(); app.session.project_load_warnings = asset_codec::load_assets(&mut zip, &manifest, &mut app)?; app.doc.project_path = Some(path.to_owned()); // Restore before the canvases below are built: figures stamp the document @@ -532,8 +543,16 @@ pub fn load_project(path: &Path) -> Result { app.doc.automation_runs = manifest .runs .iter() + .filter(|entry| entry.role == "run") .map(|entry| read_json(&mut zip, &entry.path)) .collect::>>()?; + app.doc.reports = manifest + .runs + .iter() + .filter(|entry| entry.role == "report") + .map(|entry| read_json(&mut zip, &entry.path)) + .collect::>>()?; + app.doc.repair_report_allocator(); app.doc.automation_revision = workspace.automation_revision; asset_codec::append_undeclared_image_warnings( &app.doc, diff --git a/crates/core/src/state/app_impl.rs b/crates/core/src/state/app_impl.rs index 070f6a9..2b828f8 100644 --- a/crates/core/src/state/app_impl.rs +++ b/crates/core/src/state/app_impl.rs @@ -45,6 +45,8 @@ impl PlotxApp { project_revision: None, automation_revision: 0, automation_runs: Vec::new(), + reports: Vec::new(), + next_report_id: 0, edit_generation: 0, dirty: false, save_include_view_snapshots: settings.export.include_view_snapshots, diff --git a/crates/core/src/state/app_impl_compute.rs b/crates/core/src/state/app_impl_compute.rs index 01ea236..f986d20 100644 --- a/crates/core/src/state/app_impl_compute.rs +++ b/crates/core/src/state/app_impl_compute.rs @@ -279,6 +279,49 @@ impl PlotxApp { Ok(table_index) } + pub fn materialize_craft_report_table( + &mut self, + dataset: usize, + report_id: crate::state::ReportId, + ) -> Result { + let record = self + .doc + .report(report_id) + .cloned() + .ok_or_else(|| "The CRAFT report is no longer available.".to_owned())?; + match record.status(&self.doc) { + crate::state::ReportStatus::Available => {} + crate::state::ReportStatus::NeedsReview => { + return Err("The CRAFT report needs review and cannot be exported as reliable quantitative data.".to_owned()); + } + crate::state::ReportStatus::Unavailable => { + return Err("The CRAFT report source is unavailable.".to_owned()); + } + } + let report: plotx_processing::craft::CraftAmplitudeReport = + serde_json::from_value(record.snapshot) + .map_err(|error| format!("Could not decode CRAFT report snapshot: {error}"))?; + let mut table = craft_amplitude_report_table(&report)?; + let source_id = self + .doc + .datasets + .get(dataset) + .map(Dataset::resource_id) + .ok_or_else(|| "The CRAFT source dataset is no longer available.".to_owned())?; + table.lineage = Some(DatasetLineage::new( + DerivationKind::CraftComponentTable, + [source_id], + )); + table.name = Some(format!("CRAFT report {}", report_id.0 + 1)); + let sheet = table.board_rect_pt(); + table.board_pos = crate::state::next_board_frame_pos(self, [sheet.width, sheet.height]); + table.board_sheet_visible = false; + let table_index = self.doc.datasets.len(); + self.doc.datasets.push(Dataset::Table(Box::new(table))); + self.mark_document_dirty(); + Ok(table_index) + } + pub fn show_craft_component_table_on_board(&mut self, table: usize) -> Result<(), String> { let table = self .doc diff --git a/crates/core/src/state/app_impl_compute_tests.rs b/crates/core/src/state/app_impl_compute_tests.rs index be1798b..2bf74bd 100644 --- a/crates/core/src/state/app_impl_compute_tests.rs +++ b/crates/core/src/state/app_impl_compute_tests.rs @@ -74,10 +74,9 @@ fn craft_result_is_installed_with_provenance_by_dataset_identity() { let target = app.doc.datasets[0].resource_id(); app.session.ui.craft_task_dataset = Some(target); let mut params = plotx_processing::craft::CraftParams::conventional(); - params.filter_taps = 31; - params.max_fit_window_width_hz = 2_000.0; - params.max_downsampled_points = 512; - params.max_components_per_fit_window = 2; + params.fir_filter_taps = 31; + params.maximum_modeled_sample_count = 512; + params.maximum_model_order = 2; assert!(app.request_craft_analysis( 0, @@ -173,10 +172,9 @@ fn craft_rerun_keeps_requested_parent_without_hijacking_another_task() { let target = app.doc.datasets[0].resource_id(); app.session.ui.craft_task_dataset = Some(target); let mut params = plotx_processing::craft::CraftParams::conventional(); - params.filter_taps = 31; - params.max_fit_window_width_hz = 2_000.0; - params.max_downsampled_points = 512; - params.max_components_per_fit_window = 2; + params.fir_filter_taps = 31; + params.maximum_modeled_sample_count = 512; + params.maximum_model_order = 2; assert!(app.request_craft_analysis( 0, plotx_processing::craft::CraftParamOverrides::from_params(params), diff --git a/crates/core/src/state/app_impl_linefit.rs b/crates/core/src/state/app_impl_linefit.rs index 1f24b4e..7bf238a 100644 --- a/crates/core/src/state/app_impl_linefit.rs +++ b/crates/core/src/state/app_impl_linefit.rs @@ -75,7 +75,7 @@ impl PlotxApp { let mut positions: Vec = self.doc.datasets[dataset] .peaks() - .map(|p| p.resolve()) + .map(|p| p.resolve(self.doc.datasets[dataset].peak_reference_offset_ppm())) .unwrap_or_default() .iter() .map(|p| p.x) diff --git a/crates/core/src/state/app_impl_multiplet.rs b/crates/core/src/state/app_impl_multiplet.rs index 0b33692..fe53e33 100644 --- a/crates/core/src/state/app_impl_multiplet.rs +++ b/crates/core/src/state/app_impl_multiplet.rs @@ -55,7 +55,7 @@ impl PlotxApp { if peaks.is_empty() { let marks = self.doc.datasets[dataset] .peaks() - .map(|p| p.resolve()) + .map(|p| p.resolve(self.doc.datasets[dataset].peak_reference_offset_ppm())) .unwrap_or_default(); for m in marks.iter().filter(|m| m.x >= lo && m.x <= hi) { peaks.push(MultipletPeak { diff --git a/crates/core/src/state/app_impl_peaks.rs b/crates/core/src/state/app_impl_peaks.rs index 67921ac..80fa452 100644 --- a/crates/core/src/state/app_impl_peaks.rs +++ b/crates/core/src/state/app_impl_peaks.rs @@ -210,13 +210,15 @@ impl PlotxApp { self.execute_action(Action::set_peaks(dataset_id, before, after)); } - /// Place a hand-picked peak, snapping the clicked `x` to the nearest local - /// maximum of the displayed trace. + /// Place a hand-picked peak, resolving the clicked `x` per `snap` — an + /// apex search within a zoom-derived window, or the nearest sample for the + /// modifier-click free placement. pub fn add_manual_peak( &mut self, dataset: usize, x: f64, column: Option, + snap: ManualPeakSnap, ) { let column_id = table_peak_column(&self.doc.datasets, dataset, column); let Some(trace) = self @@ -228,13 +230,14 @@ impl PlotxApp { self.session.status = "Peaks are available for 1D traces only.".into(); return; }; - let (px, py) = trace.snap(x); + let (px, py) = trace.pick(x, snap); + let offset = self.doc.datasets[dataset].peak_reference_offset_ppm(); self.edit_peaks(dataset, |peaks| { peaks.column = column_id; let id = peaks.next_id(); peaks.marks.push(PeakMark { id, - x: px, + x: px - offset, y: py, origin: PeakOrigin::Manual, label: None, @@ -267,10 +270,12 @@ impl PlotxApp { return; } let tol = trace.tol(); + let offset = self.doc.datasets[dataset].peak_reference_offset_ppm(); let mut added = 0; self.edit_peaks(dataset, |peaks| { peaks.column = column_id; for (x, y) in found { + let x = x - offset; if peaks.marks.iter().any(|m| (m.x - x).abs() <= tol) { continue; } @@ -313,10 +318,11 @@ impl PlotxApp { else { return; }; + let offset = self.doc.datasets[dataset].peak_reference_offset_ppm(); self.edit_peaks(dataset, |peaks| { peaks.column = column_id; peaks.detector.threshold = threshold; - peaks.redetect(&trace); + peaks.redetect(&trace, offset); }); let count = self .doc @@ -343,10 +349,11 @@ impl PlotxApp { else { return; }; + let offset = self.doc.datasets[dataset].peak_reference_offset_ppm(); self.edit_peaks(dataset, |peaks| { peaks.column = column_id; peaks.detector.max_count = max_count; - peaks.redetect(&trace); + peaks.redetect(&trace, offset); }); } diff --git a/crates/core/src/state/charts.rs b/crates/core/src/state/charts.rs index 0088f5c..965ee28 100644 --- a/crates/core/src/state/charts.rs +++ b/crates/core/src/state/charts.rs @@ -414,7 +414,8 @@ fn build_nmr_spectrum(dataset: &Dataset, _ctx: &ChartContext) -> Option
Some(build_processed_1d_figure( &n.data, &n.processed, - &n.peaks.resolve(), + &n.peaks + .resolve(n.pipeline.chemical_shift_reference_offset_ppm()), )) } @@ -473,7 +474,7 @@ fn build_nmr_2d(dataset: &Dataset, _ctx: &ChartContext) -> Option
{ fn build_table_line(dataset: &Dataset, _ctx: &ChartContext) -> Option
{ let t = dataset.as_table()?; - Some(apply_peak_labels(t.figure(), &t.peaks.resolve())) + Some(apply_peak_labels(t.figure(), &t.peaks.resolve(0.0))) } fn build_table_bar(dataset: &Dataset, ctx: &ChartContext) -> Option
{ diff --git a/crates/core/src/state/craft.rs b/crates/core/src/state/craft.rs index b9915a0..a2da678 100644 --- a/crates/core/src/state/craft.rs +++ b/crates/core/src/state/craft.rs @@ -1,8 +1,9 @@ use super::{FloatSeries, NmrDataset, TableDataset, materialized_float_series_table}; use plotx_io::NmrData; use plotx_processing::craft::{ - CRAFT_ALGORITHM, CRAFT_ALGORITHM_VERSION, CraftComponent, CraftDiagnostics, CraftInvocation, - CraftReference, CraftRegionRatio, CraftRegionSummary, CraftResult, + CRAFT_ALGORITHM, CRAFT_ALGORITHM_VERSION, CraftAmplitudeReport, CraftComponent, + CraftDiagnostics, CraftInvocation, CraftReference, CraftRegionRatio, CraftRegionSummary, + CraftReportDefinition, CraftResult, calculate_craft_report, }; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -32,6 +33,17 @@ pub struct StoredCraftRun { } impl StoredCraftRun { + pub fn amplitude_report( + &self, + definition: CraftReportDefinition, + ) -> Result { + if self.diagnostics.status != plotx_processing::craft::CraftRunStatus::Complete + || !self.diagnostics.stability.passed + { + return Err("CRAFT run is marked NeedsReview; quantitative amplitude reports are unavailable until stability checks pass.".to_owned()); + } + calculate_craft_report(&self.components, definition).map_err(|e| e.to_string()) + } pub fn from_result( id: CraftRunId, data: &NmrData, @@ -241,3 +253,81 @@ pub fn craft_component_table(run: &StoredCraftRun) -> Result Result { + let rows = report.segments.len(); + let values = |read: fn(&plotx_processing::craft::CraftReportSegment) -> f64| { + report + .segments + .iter() + .map(|segment| Some(read(segment))) + .collect() + }; + materialized_float_series_table( + ( + "segment".into(), + "".into(), + (1..=rows).map(|i| Some(i as f64)).collect(), + ), + vec![ + FloatSeries { + name: "report threshold A/N".into(), + unit: "".into(), + values: vec![Some(report.definition.threshold_an); rows], + uncertainty: None, + fit: None, + }, + FloatSeries { + name: "segment width".into(), + unit: "Hz".into(), + values: vec![Some(report.definition.segment_width_hz); rows], + uncertainty: None, + fit: None, + }, + FloatSeries { + name: "center".into(), + unit: "Hz".into(), + values: values(|s| s.center_hz), + uncertainty: None, + fit: None, + }, + FloatSeries { + name: "start".into(), + unit: "Hz".into(), + values: values(|s| s.start_hz), + uncertainty: None, + fit: None, + }, + FloatSeries { + name: "end".into(), + unit: "Hz".into(), + values: values(|s| s.end_hz), + uncertainty: None, + fit: None, + }, + FloatSeries { + name: "component count".into(), + unit: "".into(), + values: values(|s| s.component_count as f64), + uncertainty: None, + fit: None, + }, + FloatSeries { + name: "scalar amplitude sum t0".into(), + unit: "".into(), + values: values(|s| s.scalar_amplitude_sum_t0), + uncertainty: None, + fit: None, + }, + FloatSeries { + name: "coherent amplitude t0".into(), + unit: "".into(), + values: values(|s| s.coherent_amplitude_t0), + uncertainty: None, + fit: None, + }, + ], + "plotx.analysis.craft-amplitude-report.v1", + ) + .map_err(|error| error.to_string()) +} diff --git a/crates/core/src/state/dataset_trace.rs b/crates/core/src/state/dataset_trace.rs index 6f46cd8..0df3457 100644 --- a/crates/core/src/state/dataset_trace.rs +++ b/crates/core/src/state/dataset_trace.rs @@ -196,6 +196,19 @@ impl Dataset { Self::Afm(_) => None, Self::MassSpec(data) => { let stream = data.run.stream(data.active_stream)?; + let chromatogram = + super::mass_spec_tic::points_for_stream_tic(&data.run, data.active_stream); + if let Some(points) = chromatogram { + let (xs, ys): (Vec<_>, Vec<_>) = points + .into_iter() + .map(|[time, value]| (time, value)) + .unzip(); + return Some(Trace1d { + xs, + ys, + x_reversed: false, + }); + } Some(Trace1d { xs: stream .spectra diff --git a/crates/core/src/state/datasets_dispatch.rs b/crates/core/src/state/datasets_dispatch.rs index 3ed0aea..98011aa 100644 --- a/crates/core/src/state/datasets_dispatch.rs +++ b/crates/core/src/state/datasets_dispatch.rs @@ -245,6 +245,17 @@ impl Dataset { } /// The dataset's peak set, for domains that carry one (1D spectra and tables). + /// The net chemical-shift reference translation currently applied to the + /// dataset's finished 1D trace. Peak marks store uncalibrated positions + /// (finished x minus this value), so every reader resolves through it; + /// domains without reference steps calibrate by zero. + pub fn peak_reference_offset_ppm(&self) -> f64 { + match self { + Dataset::Nmr(d) => d.pipeline.chemical_shift_reference_offset_ppm(), + _ => 0.0, + } + } + pub fn peaks(&self) -> Option<&PeakSet> { match self { Dataset::Nmr(d) => Some(&d.peaks), diff --git a/crates/core/src/state/field.rs b/crates/core/src/state/field.rs index 5f6d661..2b19d82 100644 --- a/crates/core/src/state/field.rs +++ b/crates/core/src/state/field.rs @@ -514,7 +514,8 @@ impl super::Dataset { Some(crate::figures::build_processed_1d_figure( &nmr.data, &nmr.processed, - &nmr.peaks.resolve(), + &nmr.peaks + .resolve(nmr.pipeline.chemical_shift_reference_offset_ppm()), )) }, |spec| nmr.craft_field_figure(spec), @@ -527,7 +528,7 @@ impl super::Dataset { Self::Table(table) => match encoding { SeriesEncoding::Line(_) => Some(crate::figures::apply_peak_labels( table.figure(), - &table.peaks.resolve(), + &table.peaks.resolve(0.0), )), SeriesEncoding::Contour(_) | SeriesEncoding::Heatmap(_) diff --git a/crates/core/src/state/mass_spec.rs b/crates/core/src/state/mass_spec.rs index ea9bf39..38f9f7f 100644 --- a/crates/core/src/state/mass_spec.rs +++ b/crates/core/src/state/mass_spec.rs @@ -1,3 +1,4 @@ +use super::mass_spec_tic::points_for_stream_tic; use super::{ DatasetId, DatasetLineage, FieldCatalog, FieldId, mass_spec_xic::{ExtractedIonChromatogram, IonChromatogramId, xic_key, xic_title}, @@ -460,15 +461,18 @@ impl MassSpecDataset { let stream_id = stream.id; let stream_label = stream_display_label(stream); if self.field_catalog.id_for_key(&stream_tic_key(stream_id)) == Some(id) { + let chromatogram_points = points_for_stream_tic(&self.run, stream_id); return Some(( format!("{stream_label} TIC"), "Retention time (min)", "Total ion current".to_owned(), - stream - .spectra - .iter() - .map(|scan| [scan.retention_time_min, scan.tic]) - .collect(), + chromatogram_points.unwrap_or_else(|| { + stream + .spectra + .iter() + .map(|scan| [scan.retention_time_min, scan.tic]) + .collect() + }), false, )); } diff --git a/crates/core/src/state/mass_spec_tests.rs b/crates/core/src/state/mass_spec_tests.rs index 1f4decb..a2e2bf7 100644 --- a/crates/core/src/state/mass_spec_tests.rs +++ b/crates/core/src/state/mass_spec_tests.rs @@ -3,6 +3,7 @@ use crate::actions::Action; use crate::state::{ AxisRange, Dataset, ObjectFrame, PlotxApp, SeriesBinding, SeriesSource, ToolGroup, }; +use plotx_io::{ChromatogramChannel, ChromatogramChannelId}; #[test] fn dynamic_catalog_and_stable_selection_follow_stream_identity() { @@ -86,6 +87,48 @@ fn mean_extraction_averages_missing_profile_coordinates_as_zero() { assert_eq!(points, [[10.0, 1.0], [20.0, 4.5], [30.0, 0.5]]); } +#[test] +fn stream_tic_prefers_bound_chromatogram_points() { + let mut run = sample_mass_spec_run(); + run.chromatograms.push(ChromatogramChannel { + id: ChromatogramChannelId("tic:bound".to_owned()), + kind: ChromatogramKind::Unknown, + source_stream: Some(AcquisitionStreamId::new(3)), + coordinate: None, + description: "Total ion current".to_owned(), + unit: "cps".to_owned(), + time_min: vec![0.0, 2.0], + values: vec![11.0, 22.0], + }); + let dataset = MassSpecDataset::load(run); + let field = dataset + .field_catalog + .id_for_key(&stream_tic_key(AcquisitionStreamId::new(3))) + .expect("stream TIC field"); + let (_, _, _, points, stick) = dataset.field_values(field).expect("TIC values"); + assert!(!stick); + assert_eq!(points, [[0.0, 11.0], [2.0, 22.0]]); +} + +#[test] +fn displayed_mass_spec_trace_uses_bound_tic_channel() { + let mut run = sample_mass_spec_run(); + run.chromatograms.push(ChromatogramChannel { + id: ChromatogramChannelId("tic:rendered".to_owned()), + kind: ChromatogramKind::Unknown, + source_stream: Some(AcquisitionStreamId::new(3)), + coordinate: None, + description: "Total ion current".to_owned(), + unit: "cps".to_owned(), + time_min: vec![0.0, 1.0], + values: vec![100.0, 200.0], + }); + let dataset = Dataset::MassSpec(Box::new(MassSpecDataset::load(run))); + let trace = dataset.displayed_trace(None).expect("mass-spec trace"); + assert_eq!(trace.xs, [0.0, 1.0]); + assert_eq!(trace.ys, [100.0, 200.0]); +} + #[test] fn stream_and_retention_time_selection_retarget_all_linked_plots() { let dataset = Dataset::MassSpec(Box::new(MassSpecDataset::load(sample_mass_spec_run()))); diff --git a/crates/core/src/state/mass_spec_tic.rs b/crates/core/src/state/mass_spec_tic.rs new file mode 100644 index 0000000..418104e --- /dev/null +++ b/crates/core/src/state/mass_spec_tic.rs @@ -0,0 +1,23 @@ +use plotx_io::{AcquisitionStreamId, MassSpecRun}; + +pub(crate) fn points_for_stream_tic( + run: &MassSpecRun, + stream_id: AcquisitionStreamId, +) -> Option> { + let channel = run + .chromatograms + .iter() + .find(|channel| channel.source_stream == Some(stream_id))?; + if channel.time_min.len() != channel.values.len() { + return None; + } + Some( + channel + .time_min + .iter() + .copied() + .zip(channel.values.iter().copied()) + .map(|(time, value)| [time, value]) + .collect(), + ) +} diff --git a/crates/core/src/state/mod.rs b/crates/core/src/state/mod.rs index 3639194..ef1570f 100644 --- a/crates/core/src/state/mod.rs +++ b/crates/core/src/state/mod.rs @@ -82,6 +82,7 @@ mod linefit; mod mass_spec; mod mass_spec_app; mod mass_spec_ranges; +mod mass_spec_tic; mod mass_spec_xic; mod multiplet; mod nmr_integrals; @@ -95,7 +96,9 @@ mod peaks2d; mod plot_interaction; mod plot_object; mod pseudo_map_field; +mod reference_pick; mod region; +mod reports; mod scientific_summary; mod selection; mod series_binding; @@ -187,7 +190,9 @@ pub use peaks2d::*; pub use plot_interaction::*; pub use plot_object::*; pub(crate) use pseudo_map_field::{DOSY_GRID_COLS, DOSY_GRID_ROWS, dosy_scalar_grid}; +pub use reference_pick::*; pub use region::*; +pub use reports::*; pub use scientific_summary::*; pub use selection::*; pub use series_binding::*; diff --git a/crates/core/src/state/peaks.rs b/crates/core/src/state/peaks.rs index 7289779..0af1630 100644 --- a/crates/core/src/state/peaks.rs +++ b/crates/core/src/state/peaks.rs @@ -20,15 +20,29 @@ impl Trace1d { x_tolerance(self) } - /// Snap `x` to the tallest local maximum within a small window, so a click near - /// a peak lands on its apex. Falls back to the nearest sample. - pub fn snap(&self, x: f64) -> (f64, f64) { + /// Resolve a manual pick at `x` per `snap`. See [`ManualPeakSnap`]. + pub fn pick(&self, x: f64, snap: ManualPeakSnap) -> (f64, f64) { + match snap { + ManualPeakSnap::Apex { half_width } => self.snap_within(x, half_width), + ManualPeakSnap::NearestSample => self.nearest_sample(x), + } + } + + /// Snap `x` to the tallest local maximum within ± `half_width` (data + /// units), so a click near a peak lands on its apex. Falls back to the + /// nearest sample when the window holds no local maximum. + /// + /// Callers derive `half_width` from screen pixels: zooming in narrows the + /// data window in step with the click precision the zoom affords, which is + /// what lets a weak line be picked next to a strong one. Tallest-in-window + /// (rather than nearest local maximum) keeps zoomed-out clicks landing on + /// real peaks instead of the noise wiggle closest to the pointer. + pub fn snap_within(&self, x: f64, half_width: f64) -> (f64, f64) { let n = self.xs.len(); - let window = x_tolerance(self) * 15.0; let mut best: Option<(f64, f64)> = None; for i in 1..n.saturating_sub(1) { let px = self.xs[i]; - if (px - x).abs() > window { + if (px - x).abs() > half_width { continue; } let v = self.ys[i]; @@ -36,15 +50,32 @@ impl Trace1d { best = Some((px, v)); } } - best.unwrap_or_else(|| { - self.xs - .iter() - .zip(&self.ys) - .min_by(|a, b| (a.0 - x).abs().partial_cmp(&(b.0 - x).abs()).unwrap()) - .map(|(&px, &py)| (px, py)) - .unwrap_or((x, 0.0)) - }) + best.unwrap_or_else(|| self.nearest_sample(x)) } + + /// The sample closest to `x`, with no apex search. + pub fn nearest_sample(&self, x: f64) -> (f64, f64) { + self.xs + .iter() + .zip(&self.ys) + .filter(|(px, py)| px.is_finite() && py.is_finite()) + .min_by(|a, b| (a.0 - x).abs().partial_cmp(&(b.0 - x).abs()).unwrap()) + .map(|(&px, &py)| (px, py)) + .unwrap_or((x, 0.0)) + } +} + +/// How a manual pick chooses its apex from the clicked x position. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum ManualPeakSnap { + /// Snap to the tallest local maximum within ± `half_width` data units. + /// UI callers convert a fixed pixel radius at the current zoom, so the + /// search window narrows as the user zooms in. + Apex { half_width: f64 }, + /// No apex search: place on the sample nearest the click. The escape + /// hatch (modifier-click) for shoulders and signals snapping refuses to + /// resolve. + NearestSample, } /// Provenance only — both kinds are ordinary, individually editable marks. @@ -57,6 +88,12 @@ pub enum PeakOrigin { /// One peak: a labelled `(x, y)` apex. `label` overrides the shift-formatted /// default when set. +/// +/// `x` is stored *uncalibrated*: the finished-spectrum position minus the +/// pipeline's chemical-shift reference offset at pick time. Readers add the +/// current offset back through [`PeakSet::resolve`], so marks follow the +/// spectrum when a Reference step is edited instead of freezing at the old +/// axis. `y` remains a pick-time intensity snapshot. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct PeakMark { pub id: u64, @@ -155,11 +192,13 @@ impl PeakSet { /// Re-run detection at the stored threshold: replace every detected mark with a /// fresh set, leaving hand-placed marks (and any detection coincident with one) - /// untouched. - pub fn redetect(&mut self, trace: &Trace1d) { + /// untouched. `reference_offset_ppm` is the calibration currently applied + /// to the finished `trace`; detections are stored uncalibrated. + pub fn redetect(&mut self, trace: &Trace1d, reference_offset_ppm: f64) { self.marks.retain(|m| m.origin == PeakOrigin::Manual); let tol = x_tolerance(trace); for (x, y) in Self::detect_at(trace, self.detector.threshold, self.detector.max_count) { + let x = x - reference_offset_ppm; if self.marks.iter().any(|m| (m.x - x).abs() <= tol) { continue; } @@ -203,15 +242,21 @@ impl PeakSet { .collect() } - pub fn resolve(&self) -> Vec { + /// Marks in finished-spectrum coordinates: the stored uncalibrated x plus + /// the pipeline's *current* chemical-shift reference offset, so labels + /// follow a Reference edit instead of pinning the pick-time axis. + pub fn resolve(&self, reference_offset_ppm: f64) -> Vec { self.marks .iter() - .map(|m| ResolvedPeak { - x: m.x, - y: m.y, - label: m.label.clone().unwrap_or_else(|| default_label(m.x)), - origin: m.origin, - mark_id: Some(m.id), + .map(|m| { + let x = m.x + reference_offset_ppm; + ResolvedPeak { + x, + y: m.y, + label: m.label.clone().unwrap_or_else(|| default_label(x)), + origin: m.origin, + mark_id: Some(m.id), + } }) .collect() } @@ -230,6 +275,10 @@ fn baseline(ys: &[f64]) -> f64 { finite[finite.len() / 2] } +#[cfg(test)] +#[path = "peaks_tests.rs"] +mod tests; + fn x_tolerance(trace: &Trace1d) -> f64 { let (lo, hi) = trace .xs diff --git a/crates/core/src/state/peaks_tests.rs b/crates/core/src/state/peaks_tests.rs new file mode 100644 index 0000000..ca32ed4 --- /dev/null +++ b/crates/core/src/state/peaks_tests.rs @@ -0,0 +1,156 @@ +use super::*; + +fn trace(ys: Vec) -> Trace1d { + Trace1d { + xs: (0..ys.len()).map(|i| i as f64).collect(), + ys, + x_reversed: false, + } +} + +/// A strong apex at x = 5 and a weak one at x = 10. +fn two_peaks() -> Trace1d { + let mut ys = vec![0.0; 16]; + ys[4] = 40.0; + ys[5] = 100.0; + ys[6] = 40.0; + ys[9] = 2.0; + ys[10] = 5.0; + ys[11] = 2.0; + trace(ys) +} + +#[test] +fn a_narrow_window_picks_the_weak_apex_beside_a_strong_one() { + let trace = two_peaks(); + // Clicking at the weak line while zoomed in: the pixel-derived window no + // longer reaches the strong apex. + assert_eq!(trace.snap_within(10.4, 2.0), (10.0, 5.0)); +} + +#[test] +fn a_wide_window_still_lands_on_the_tallest_apex() { + let trace = two_peaks(); + // Zoomed out, the same click may sit several samples off; the tallest + // apex in reach is the intended target, not the nearest noise wiggle. + assert_eq!(trace.snap_within(7.0, 6.0), (5.0, 100.0)); +} + +#[test] +fn free_placement_takes_the_nearest_sample_without_an_apex_search() { + let trace = two_peaks(); + assert_eq!(trace.pick(9.4, ManualPeakSnap::NearestSample), (9.0, 2.0)); +} + +#[test] +fn an_empty_window_falls_back_to_the_nearest_sample() { + let trace = two_peaks(); + // No local maximum within reach of x = 14. + assert_eq!(trace.snap_within(14.2, 1.0), (14.0, 0.0)); +} + +#[test] +fn apex_snap_routes_through_pick() { + let trace = two_peaks(); + assert_eq!( + trace.pick(10.4, ManualPeakSnap::Apex { half_width: 2.0 }), + (10.0, 5.0) + ); +} + +/// A small frequency-domain spectrum with one clear line, loaded as a dataset. +fn frequency_app() -> crate::state::PlotxApp { + let mut points = vec![num_complex::Complex64::new(0.0, 0.0); 64]; + points[32] = num_complex::Complex64::new(100.0, 0.0); + points[31] = num_complex::Complex64::new(40.0, 0.0); + points[33] = num_complex::Complex64::new(40.0, 0.0); + let data = plotx_io::NmrData { + points, + domain: plotx_io::Domain::Frequency, + spectral_width_hz: 640.0, + observe_freq_mhz: 100.0, + carrier_ppm: 5.0, + nucleus: "1H".into(), + source: "test".into(), + group_delay: 0.0, + }; + let mut app = crate::state::PlotxApp::new(); + app.doc.datasets.push(crate::state::Dataset::Nmr(Box::new( + crate::state::NmrDataset::load(data), + ))); + app +} + +fn apply_reference(app: &mut crate::state::PlotxApp, at_ppm: f64, target_ppm: f64) { + let nmr = app.doc.datasets[0].as_nmr_mut().expect("NMR dataset"); + let id = plotx_processing::StepId::new(nmr.next_step_id); + nmr.next_step_id += 1; + nmr.pipeline + .steps + .push(plotx_processing::ProcessingStep::new( + id, + plotx_processing::StepKind::Reference(plotx_processing::ReferenceParams { + at_ppm, + target_ppm, + }), + plotx_processing::StepSource::User, + )); + let nmr = app.doc.datasets[0].as_nmr_mut().expect("NMR dataset"); + nmr.processed = plotx_processing::reapply_output(&nmr.base, &nmr.pipeline); +} + +fn resolved_marks(app: &crate::state::PlotxApp) -> Vec { + let dataset = &app.doc.datasets[0]; + dataset + .peaks() + .expect("peak set") + .resolve(dataset.peak_reference_offset_ppm()) +} + +/// The reported defect: mark a peak, then edit the Reference step — the mark +/// must follow the recalibrated axis instead of pinning the old coordinates. +#[test] +fn marks_follow_a_later_reference_edit() { + let mut app = frequency_app(); + let apex_x = app.doc.datasets[0] + .displayed_trace(None) + .expect("1D trace") + .xs[32]; + app.add_manual_peak(0, apex_x, None, ManualPeakSnap::NearestSample); + let before = resolved_marks(&app); + assert_eq!(before.len(), 1); + assert!((before[0].x - apex_x).abs() < 1e-12); + + apply_reference(&mut app, apex_x, apex_x + 0.5); + + let after = resolved_marks(&app); + assert!((after[0].x - (apex_x + 0.5)).abs() < 1e-12); + // The mark tracks the shifted trace: the same array position now reads + // the mark's resolved x. + let shifted = app.doc.datasets[0] + .displayed_trace(None) + .expect("1D trace") + .xs[32]; + assert!((after[0].x - shifted).abs() < 1e-12); + // The default label reads the calibrated position. + assert_eq!(after[0].label, format!("{:.2}", after[0].x)); +} + +/// Picks made on an already-referenced spectrum resolve back to the clicked +/// finished coordinate (the stored value is uncalibrated). +#[test] +fn picks_on_a_referenced_spectrum_round_trip() { + let mut app = frequency_app(); + apply_reference(&mut app, 0.0, 0.75); + let apex_x = app.doc.datasets[0] + .displayed_trace(None) + .expect("1D trace") + .xs[32]; + + app.add_manual_peak(0, apex_x, None, ManualPeakSnap::NearestSample); + + let resolved = resolved_marks(&app); + assert!((resolved[0].x - apex_x).abs() < 1e-12); + let stored = &app.doc.datasets[0].peaks().expect("peak set").marks[0]; + assert!((stored.x - (apex_x - 0.75)).abs() < 1e-12); +} diff --git a/crates/core/src/state/reference_pick.rs b/crates/core/src/state/reference_pick.rs new file mode 100644 index 0000000..cbcbefb --- /dev/null +++ b/crates/core/src/state/reference_pick.rs @@ -0,0 +1,20 @@ +//! The one-shot "pick a Reference position on the plot" arm state. + +use super::{DatasetId, PhaseAxis, StepId}; + +/// A one-shot request to pick a Reference step's source position (`at_ppm`) on +/// the plot. Typed IDs, because the arm state outlives the frame that set it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ReferencePick { + pub dataset: DatasetId, + pub step: StepId, +} + +/// An armed [`ReferencePick`] resolved against the current document: the pick +/// plus the one-shot dataset index and the axis whose pipeline owns the step. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ResolvedReferencePick { + pub pick: ReferencePick, + pub dataset_index: usize, + pub axis: PhaseAxis, +} diff --git a/crates/core/src/state/reports.rs b/crates/core/src/state/reports.rs new file mode 100644 index 0000000..449ad45 --- /dev/null +++ b/crates/core/src/state/reports.rs @@ -0,0 +1,191 @@ +use super::{CraftRunId, DatasetId, Document}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct ReportId(pub u64); + +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct ReportKindId(pub String); + +impl ReportKindId { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct ReportSource { + pub dataset: DatasetId, + pub craft_run: CraftRunId, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ReportStatus { + Available, + NeedsReview, + Unavailable, +} + +impl AnalysisReportRecord { + pub fn status(&self, document: &Document) -> ReportStatus { + let Some(dataset) = document + .datasets + .iter() + .find(|d| d.resource_id() == self.source.dataset) + else { + return ReportStatus::Unavailable; + }; + let Some(nmr) = dataset.as_nmr() else { + return ReportStatus::Unavailable; + }; + let Some(run) = nmr.craft_run(self.source.craft_run) else { + return ReportStatus::Unavailable; + }; + if self.kind.0 == "craft_amplitude" + && (run.diagnostics.status != plotx_processing::craft::CraftRunStatus::Complete + || !run.diagnostics.stability.passed) + { + return ReportStatus::NeedsReview; + } + if self.kind.0 == "craft_amplitude" { + let valid_definition = serde_json::from_value::< + plotx_processing::craft::CraftReportDefinition, + >(self.definition.clone()) + .ok() + .is_some_and(|definition| definition.validate().is_ok()); + if !valid_definition { + return ReportStatus::NeedsReview; + } + } + if self.kind.0 == "craft_amplitude" + && serde_json::from_value::( + self.snapshot.clone(), + ) + .ok() + .is_none_or(|snapshot| snapshot.validate_against(&run.components).is_err()) + { + return ReportStatus::NeedsReview; + } + if run.provenance.input_sha256 != self.source_fingerprint + || run.is_stale_for(&nmr.data, nmr.craft_reference()) + { + ReportStatus::NeedsReview + } else { + ReportStatus::Available + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct AnalysisReportRecord { + pub id: ReportId, + pub name: String, + pub kind: ReportKindId, + pub source: ReportSource, + /// Tagged, domain-owned definition and resolved result snapshot. + pub definition: serde_json::Value, + pub snapshot: serde_json::Value, + pub source_fingerprint: String, + pub schema_version: u32, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct NewAnalysisReport { + pub name: String, + pub kind: ReportKindId, + pub source: ReportSource, + pub definition: serde_json::Value, + pub snapshot: serde_json::Value, + pub source_fingerprint: String, + pub schema_version: u32, +} + +impl Document { + pub fn allocate_report_id(&mut self) -> ReportId { + let id = ReportId(self.next_report_id); + self.next_report_id = self + .next_report_id + .checked_add(1) + .expect("report id overflow"); + id + } + + pub fn repair_report_allocator(&mut self) { + let required = self + .reports + .iter() + .map(|r| r.id.0.saturating_add(1)) + .max() + .unwrap_or(0); + self.next_report_id = self.next_report_id.max(required); + } + + pub fn report(&self, id: ReportId) -> Option<&AnalysisReportRecord> { + self.reports.iter().find(|r| r.id == id) + } + pub fn reports_for_source( + &self, + source: ReportSource, + ) -> impl Iterator { + self.reports.iter().filter(move |r| r.source == source) + } + pub fn create_report(&mut self, report: NewAnalysisReport) -> ReportId { + let id = self.allocate_report_id(); + self.reports.push(AnalysisReportRecord { + id, + name: report.name, + kind: report.kind, + source: report.source, + definition: report.definition, + snapshot: report.snapshot, + source_fingerprint: report.source_fingerprint, + schema_version: report.schema_version, + }); + self.mark_dirty(); + id + } + pub fn rename_report(&mut self, id: ReportId, name: String) -> Result<(), String> { + let report = self + .report_mut(id) + .ok_or_else(|| "Report not found".to_owned())?; + report.name = name; + self.mark_dirty(); + Ok(()) + } + pub fn copy_report(&mut self, id: ReportId, name: Option) -> Result { + let source = self + .report(id) + .cloned() + .ok_or_else(|| "Report not found".to_owned())?; + let new_id = self.allocate_report_id(); + self.reports.push(AnalysisReportRecord { + id: new_id, + name: name.unwrap_or_else(|| format!("{} copy", source.name)), + ..source + }); + self.mark_dirty(); + Ok(new_id) + } + pub fn update_report(&mut self, record: AnalysisReportRecord) -> Result<(), String> { + let slot = self + .reports + .iter_mut() + .find(|r| r.id == record.id) + .ok_or_else(|| "Report not found".to_owned())?; + *slot = record; + self.mark_dirty(); + Ok(()) + } + pub fn delete_report(&mut self, id: ReportId) -> bool { + let before = self.reports.len(); + self.reports.retain(|r| r.id != id); + let changed = before != self.reports.len(); + if changed { + self.mark_dirty(); + } + changed + } + fn report_mut(&mut self, id: ReportId) -> Option<&mut AnalysisReportRecord> { + self.reports.iter_mut().find(|r| r.id == id) + } +} diff --git a/crates/core/src/state/ui_state.rs b/crates/core/src/state/ui_state.rs index 845f8f8..8dc0973 100644 --- a/crates/core/src/state/ui_state.rs +++ b/crates/core/src/state/ui_state.rs @@ -22,6 +22,13 @@ mod trace_composer; pub use trace_composer::{TraceComposerItem, TraceComposerState}; mod trace_alignment; pub use trace_alignment::TraceAlignmentDialogState; +#[path = "ui_state_dialogs.rs"] +mod dialogs; +pub use dialogs::{ + AlignSpectraDialogState, CommandPaletteState, ProcessingSchemeDialogState, + ProcessingTemplateDialogState, SpectrumArithmeticDialogState, SpectrumArithmeticOp, + TemplateBrowserEntry, +}; #[path = "ui_state_rename.rs"] mod rename; pub use rename::{RenameState, RenameTarget}; @@ -101,92 +108,6 @@ impl SettingsDialog { } } -#[derive(Default)] -pub struct CommandPaletteState { - pub query: String, - pub selected: usize, -} - -pub enum ProcessingSchemeDialogState { - ResolvePending { - fallback_dataset: usize, - }, - Review { - path: std::path::PathBuf, - plan: crate::project::SchemeApplicationPlan, - policy: crate::project::SchemeApplicationPolicy, - }, -} - -pub struct TemplateBrowserEntry { - pub name: String, - pub path: std::path::PathBuf, - pub scheme: Result, -} - -#[derive(Clone, Copy, PartialEq, Eq)] -pub enum SpectrumArithmeticOp { - AddDataset, - SubtractDataset, - MultiplyConstant, - AddConstant, -} - -impl SpectrumArithmeticOp { - pub const ALL: [Self; 4] = [ - Self::AddDataset, - Self::SubtractDataset, - Self::MultiplyConstant, - Self::AddConstant, - ]; - - pub fn label(self) -> &'static str { - match self { - Self::AddDataset => "A + k·B", - Self::SubtractDataset => "A − k·B", - Self::MultiplyConstant => "A × k", - Self::AddConstant => "A + c", - } - } - - pub fn is_binary(self) -> bool { - matches!(self, Self::AddDataset | Self::SubtractDataset) - } -} - -#[derive(Clone, Copy)] -pub struct SpectrumArithmeticDialogState { - pub a: usize, - pub b: usize, - pub op: SpectrumArithmeticOp, - pub k: f64, - pub constant: f64, -} - -#[derive(Clone)] -pub struct AlignSpectraDialogState { - pub lo: f64, - pub hi: f64, - pub custom_target: bool, - pub target_ppm: f64, - /// Preview cache: peak detection over every candidate is too heavy to rerun - /// on each repaint, so the plan persists until inputs or the doc change. - pub plan: Option, - pub history_mark: (usize, usize), -} - -pub enum ProcessingTemplateDialogState { - SaveAs { - dataset: usize, - name: String, - }, - Browse { - dataset: usize, - entries: Vec, - confirm_delete: Option, - }, -} - /// Cached model-editor validation: the parsed definition plus its /// unclassified symbols on success, or the parse/compile error text. #[derive(Clone)] @@ -362,6 +283,7 @@ pub struct UiState { pub craft_selected_run: Option, pub craft_task_page: CraftTaskPage, pub craft_result_tab: CraftResultTab, + pub craft_selected_report: Option, pub craft_component_sort: CraftComponentSort, pub craft_component_region: Option, pub craft_selected_component: Option, @@ -437,6 +359,10 @@ pub struct UiState { /// dataset is stored alongside it; without it, expanding a row on one /// dataset would light up the same-numbered row on every other one. pub proc_expanded_step: Option<(DatasetId, StepId)>, + /// The armed one-shot request to pick a Reference step's source position on + /// the plot. Armed by the step editor's pick button; disarmed by the pick + /// click, Escape, or going stale (see `PlotxApp::sync_reference_pick`). + pub reference_pick: Option, /// Latched result of the last phase-editing sync: `true` while the canvas is /// held in on-plot phase mode because a Phase step's editor is open. Edge- /// detected so a manual tool switch mid-phasing isn't fought each frame. @@ -559,6 +485,7 @@ impl Default for UiState { craft_selected_run: None, craft_task_page: CraftTaskPage::Setup, craft_result_tab: CraftResultTab::Overview, + craft_selected_report: None, craft_component_sort: CraftComponentSort::ChemicalShift, craft_component_region: None, craft_selected_component: None, @@ -598,6 +525,7 @@ impl Default for UiState { slice: None, slice_kind: plotx_processing::SliceKind::Row, proc_expanded_step: None, + reference_pick: None, phase_edit_active: false, proc_paused: false, proc_pending: None, @@ -619,6 +547,8 @@ pub struct Document { pub project_revision: Option, pub automation_revision: u64, pub automation_runs: Vec, + pub reports: Vec, + pub next_report_id: u64, /// Incremented for every persisted edit. Background save completion uses /// this token instead of clearing `dirty` unconditionally. pub edit_generation: u64, diff --git a/crates/core/src/state/ui_state_craft.rs b/crates/core/src/state/ui_state_craft.rs index 6ed5a56..7676934 100644 --- a/crates/core/src/state/ui_state_craft.rs +++ b/crates/core/src/state/ui_state_craft.rs @@ -11,6 +11,7 @@ pub enum CraftResultTab { Overview, Components, Diagnostics, + Reports, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] diff --git a/crates/core/src/state/ui_state_dialogs.rs b/crates/core/src/state/ui_state_dialogs.rs new file mode 100644 index 0000000..3833f87 --- /dev/null +++ b/crates/core/src/state/ui_state_dialogs.rs @@ -0,0 +1,89 @@ +//! Working state for the modal dialogs the UI can open: command palette, +//! processing schemes and templates, spectrum arithmetic, and alignment. +//! Split from `ui_state.rs` to keep that file under the source-size limit. + +#[derive(Default)] +pub struct CommandPaletteState { + pub query: String, + pub selected: usize, +} + +pub enum ProcessingSchemeDialogState { + ResolvePending { + fallback_dataset: usize, + }, + Review { + path: std::path::PathBuf, + plan: crate::project::SchemeApplicationPlan, + policy: crate::project::SchemeApplicationPolicy, + }, +} + +pub struct TemplateBrowserEntry { + pub name: String, + pub path: std::path::PathBuf, + pub scheme: Result, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum SpectrumArithmeticOp { + AddDataset, + SubtractDataset, + MultiplyConstant, + AddConstant, +} + +impl SpectrumArithmeticOp { + pub const ALL: [Self; 4] = [ + Self::AddDataset, + Self::SubtractDataset, + Self::MultiplyConstant, + Self::AddConstant, + ]; + + pub fn label(self) -> &'static str { + match self { + Self::AddDataset => "A + k·B", + Self::SubtractDataset => "A − k·B", + Self::MultiplyConstant => "A × k", + Self::AddConstant => "A + c", + } + } + + pub fn is_binary(self) -> bool { + matches!(self, Self::AddDataset | Self::SubtractDataset) + } +} + +#[derive(Clone, Copy)] +pub struct SpectrumArithmeticDialogState { + pub a: usize, + pub b: usize, + pub op: SpectrumArithmeticOp, + pub k: f64, + pub constant: f64, +} + +#[derive(Clone)] +pub struct AlignSpectraDialogState { + pub lo: f64, + pub hi: f64, + pub custom_target: bool, + pub target_ppm: f64, + /// Preview cache: peak detection over every candidate is too heavy to rerun + /// on each repaint, so the plan persists until inputs or the doc change. + pub plan: Option, + pub history_mark: (usize, usize), +} + +pub enum ProcessingTemplateDialogState { + SaveAs { + dataset: usize, + name: String, + }, + Browse { + dataset: usize, + entries: Vec, + confirm_delete: Option, + }, +} diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index 413cc45..bcdb785 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -26,3 +26,5 @@ tiff.workspace = true sha2.workspace = true memmap2.workspace = true tempfile.workspace = true +cfb = "0.14" +byteorder = "1.5" diff --git a/crates/io/src/format.rs b/crates/io/src/format.rs index f9ad52f..3679553 100644 --- a/crates/io/src/format.rs +++ b/crates/io/src/format.rs @@ -35,6 +35,7 @@ pub enum AfmFormat { pub enum MassSpectrometryFormat { WatersMassLynxRaw, MzMl, + SciexWiff, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -87,6 +88,7 @@ impl DataFormat { "waters-masslynx-raw" } Self::MassSpectrometry(MassSpectrometryFormat::MzMl) => "mzml", + Self::MassSpectrometry(MassSpectrometryFormat::SciexWiff) => "sciex-wiff", Self::Xrd(XrdFormat::RigakuRasx) => "rigaku-rasx", Self::Xrd(XrdFormat::RigakuRaw) => "rigaku-raw-fi", Self::Xrd(XrdFormat::RigakuProfile) => "rigaku-profile", diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index c96735e..a49191d 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -12,6 +12,7 @@ pub mod mzml; pub mod nanoscope; mod nmr_origin; pub mod origin; +pub mod sciex_wiff; pub mod varian; pub mod waters; pub mod xlsx; @@ -659,6 +660,12 @@ pub enum IoError { #[error("invalid or unsupported mzML: {0}")] InvalidMzMl(String), + #[error("invalid or unsupported SCIEX WIFF: {0}")] + InvalidSciexWiff(String), + + #[error("unsupported SCIEX WIFF: {0}")] + UnsupportedSciexWiff(String), + #[error("invalid XPS data: {0}")] InvalidXps(String), @@ -702,6 +709,11 @@ pub fn detect_format(path: impl AsRef) -> Result { .and_then(|e| e.to_str()) .unwrap_or("") .to_ascii_lowercase(); + let lower_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); match ext.as_str() { "rasx" => Ok(DataFormat::Xrd(XrdFormat::RigakuRasx)), "raw" if xrd::is_rigaku_raw(path) => Ok(DataFormat::Xrd(XrdFormat::RigakuRaw)), @@ -722,6 +734,15 @@ pub fn detect_format(path: impl AsRef) -> Result { "jdf" => Ok(DataFormat::Nmr(NmrFormat::JeolDelta)), "dx" | "jdx" | "jcamp" => Ok(DataFormat::Nmr(NmrFormat::JcampDx1D)), "mzml" => Ok(DataFormat::MassSpectrometry(MassSpectrometryFormat::MzMl)), + "wiff" => Ok(DataFormat::MassSpectrometry( + MassSpectrometryFormat::SciexWiff, + )), + "wiff2" | "data" if ext == "wiff2" || lower_name.ends_with(".timeseries.data") => { + Err(IoError::UnsupportedSciexWiff( + "SCIEX WIFF2 and timeseries.data are not currently supported; convert the acquisition to mzML before opening it in PlotX" + .to_owned(), + )) + } // Fall back to a content sniff so extensionless or mislabelled files // are still recognised by their magic bytes. _ if abf2::is_abf2(path) => { @@ -729,7 +750,7 @@ pub fn detect_format(path: impl AsRef) -> Result { } _ if jeol::is_jdf(path) => Ok(DataFormat::Nmr(NmrFormat::JeolDelta)), _ => Err(IoError::Unsupported(format!( - "unrecognised path {}: expected mzML, Rigaku FI .raw/.rasx/profile .txt, a Waters .raw directory, NanoScope .spm/.pfc, ABF2 .abf, JEOL .jdf, JCAMP-DX .dx/.jdx/.jcamp, Bruker fid/ser or pdata, or a Varian/Agilent VnmrJ .fid directory", + "unrecognised path {}: expected mzML, legacy SCIEX .wiff, Rigaku FI .raw/.rasx/profile .txt, a Waters .raw directory, NanoScope .spm/.pfc, ABF2 .abf, JEOL .jdf, JCAMP-DX .dx/.jdx/.jcamp, Bruker fid/ser or pdata, or a Varian/Agilent VnmrJ .fid directory", path.display() ))), } @@ -753,6 +774,7 @@ pub fn load_path(path: impl AsRef) -> Result { waters::load(path) } DataFormat::MassSpectrometry(MassSpectrometryFormat::MzMl) => mzml::load(path), + DataFormat::MassSpectrometry(MassSpectrometryFormat::SciexWiff) => sciex_wiff::load(path), DataFormat::Xrd(XrdFormat::RigakuRasx) => xrd::load_rasx(path), DataFormat::Xrd(XrdFormat::RigakuRaw) => xrd::load_raw(path), DataFormat::Xrd(XrdFormat::RigakuProfile) => xrd::load_profile(path), diff --git a/crates/io/src/sciex_wiff.rs b/crates/io/src/sciex_wiff.rs new file mode 100644 index 0000000..3e4c940 --- /dev/null +++ b/crates/io/src/sciex_wiff.rs @@ -0,0 +1,789 @@ +#![allow(dead_code)] +use crate::{ + Acquisition, AcquisitionStream, AcquisitionStreamId, DataFormat, IoError, LoadResult, + LoadWarning, LoadWarningCode, MassSpecRun, MassSpectrometryFormat, MassSpectrum, Polarity, + Precursor, Provenance, SpectrumId, SpectrumRepresentation, StreamRole, +}; +use byteorder::{ByteOrder, LittleEndian}; +use std::collections::{BTreeMap, BTreeSet, btree_map::Entry}; +use std::fs::File; +use std::io::Read; +use std::path::Path; + +#[path = "sciex_wiff_scan.rs"] +mod scan; +use scan::{companion_path, decode_scan_block}; +#[path = "sciex_wiff_tic.rs"] +mod tic; +struct StreamBuilder { + id: AcquisitionStreamId, + experiment_index: u32, + ms_level: u8, + polarity: Polarity, + low_mz: f64, + high_mz: f64, + spectra: Vec, +} +#[rustfmt::skip] +#[derive(Clone, Copy, Debug, PartialEq)] enum SourcePolarity { Positive, Negative } +#[rustfmt::skip] +#[derive(Clone, Copy, Debug, PartialEq)] enum ScanMode { Centroid, Profile } +#[rustfmt::skip] +#[allow(clippy::upper_case_acronyms)] +#[derive(Clone, Copy, Debug, PartialEq)] enum Activation { HCD, MPID, ETD, CID, ECD, IRMPD, PD, PQD, UVPD, SID, EThcD } +#[rustfmt::skip] +#[allow(clippy::upper_case_acronyms)] +#[derive(Clone, Copy, Debug, PartialEq)] enum Analyzer { TOFMS, TQMS } +#[derive(Clone, Debug, Default)] +struct PrecursorInfo { + selected_mz: Option, + target_mz: Option, + isolation_width: Option, + charge: Option, + collision_energy: Option, + activation: Option, +} +#[derive(Clone, Debug)] +struct SpectrumRecord { + index: usize, + scan_number: u32, + native_id: String, + ms_level: u32, + polarity: Option, + scan_mode: Option, + retention_time_sec: f64, + total_ion_current: Option, + precursor: Option, + mz: Vec, + intensity: Vec, + analyzer: Option, + acquisition_event_id: Option, + filter: Option, + base_peak_mz: Option, + base_peak_intensity: Option, + low_mz: Option, + high_mz: Option, + ion_injection_time_ms: Option, + inv_mobility: Option, + faims_cv: Option, + inv_mobility_per_peak: Option>, + extra: BTreeMap, +} +#[derive(Clone, Debug)] +struct IdxRecord { + scan_offset: u32, + scan_size: u32, + acquisition_time_ms: f64, + legacy_time_min: f32, + tic: f64, + declared_ms_level: u32, + experiment_index: usize, + cycle_index: usize, +} +#[derive(Clone, Copy)] +struct Calibration { + slope: f64, + intercept: f64, +} +impl Calibration { + fn apply(self, value: u32) -> f64 { + self.intercept + self.slope * value as f64 + } +} +fn list_samples(path: &Path) -> Result, IoError> { + let file = File::open(path).map_err(|e| invalid(e.to_string()))?; + let compound = cfb::CompoundFile::open(file).map_err(|e| invalid(e.to_string()))?; + let mut names = compound + .read_storage("SampleSubtree") + .map_err(|e| invalid(e.to_string()))? + .filter(|entry| entry.is_storage()) + .map(|entry| entry.name().to_owned()) + .collect::>(); + names.sort_by_key(|name| { + name.strip_prefix("Sample") + .and_then(|n| n.parse::().ok()) + .unwrap_or(u64::MAX) + }); + Ok(names) +} +#[allow(clippy::chunks_exact_to_as_chunks)] +fn read_idx(path: &Path, sample: &str) -> Result, IoError> { + let mut file = File::open(path).map_err(|e| invalid(e.to_string()))?; + let mut compound = cfb::CompoundFile::open(&mut file).map_err(|e| invalid(e.to_string()))?; + let mut data = Vec::new(); + compound + .open_stream(format!("SampleSubtree/{sample}/Idx")) + .map_err(|e| invalid(e.to_string()))? + .read_to_end(&mut data) + .map_err(|e| invalid(e.to_string()))?; + const HEADER: usize = 32; + const SIZE: usize = 54; + let body = data + .get(HEADER..) + .ok_or_else(|| invalid(format!("WIFF sample {sample} has a truncated Idx header")))?; + if body.is_empty() || body.len() % SIZE != 0 { + return Err(invalid(format!( + "WIFF sample {sample} has an unsupported Idx record layout" + ))); + } + let record_count = body.len() / SIZE; + const EXPERIMENTS: usize = 11; + let experiments = if record_count == 1 { 1 } else { EXPERIMENTS }; + if record_count < experiments || !record_count.is_multiple_of(experiments) { + return Err(invalid(format!( + "WIFF sample {sample} does not contain complete 11-slot acquisition cycles" + ))); + } + let mut out = Vec::with_capacity(record_count); + for (index, chunk) in body.chunks_exact(SIZE).enumerate() { + let acquisition_time_ms = LittleEndian::read_f64(&chunk[8..16]); + let tic = LittleEndian::read_f64(&chunk[18..26]); + if !acquisition_time_ms.is_finite() + || acquisition_time_ms < 0.0 + || !tic.is_finite() + || tic < 0.0 + { + return Err(invalid(format!( + "WIFF sample {sample} contains invalid Idx time or TIC at record {index}" + ))); + } + out.push(IdxRecord { + scan_offset: LittleEndian::read_u32(&chunk[..4]), + scan_size: LittleEndian::read_u32(&chunk[4..8]), + acquisition_time_ms, + legacy_time_min: LittleEndian::read_f32(&chunk[12..16]), + tic, + declared_ms_level: u32::from(LittleEndian::read_u16(&chunk[16..18])), + experiment_index: index % experiments, + cycle_index: index / experiments, + }); + } + for slot in 0..EXPERIMENTS { + let records = out.iter().skip(slot).step_by(EXPERIMENTS); + let mut previous = f64::NEG_INFINITY; + for record in records { + if record.acquisition_time_ms < previous { + return Err(invalid(format!( + "WIFF sample {sample} has non-monotonic acquisition time in experiment {}", + slot + 1 + ))); + } + previous = record.acquisition_time_ms; + } + } + if out.is_empty() { + return Err(invalid(format!( + "WIFF sample {sample} contains no index records" + ))); + } + Ok(out) +} +fn read_stream(path: &Path, stream_path: &str) -> Result>, IoError> { + let mut file = File::open(path).map_err(|e| invalid(e.to_string()))?; + let mut compound = cfb::CompoundFile::open(&mut file).map_err(|e| invalid(e.to_string()))?; + let Ok(mut stream) = compound.open_stream(stream_path) else { + return Ok(None); + }; + let mut bytes = Vec::new(); + stream + .read_to_end(&mut bytes) + .map_err(|e| invalid(e.to_string()))?; + Ok(Some(bytes)) +} +fn validate_auxiliary_layout( + path: &Path, + sample: &str, + records: &[IdxRecord], +) -> Result<(), IoError> { + let cycles = records + .iter() + .map(|record| record.cycle_index) + .max() + .map_or(0, |value| value + 1); + for (name, stride) in [("Itc", 88_usize), ("DDERealTimeData", 320_usize)] { + let stream_path = format!("SampleSubtree/{sample}/{name}"); + if let Some(bytes) = read_stream(path, &stream_path)? { + let expected = 32_usize + .checked_add(cycles.checked_mul(stride).ok_or_else(|| { + invalid(format!( + "WIFF sample {sample} has too many acquisition cycles" + )) + })?) + .ok_or_else(|| invalid("WIFF auxiliary stream length overflow"))?; + if bytes.len() != expected { + return Err(invalid(format!( + "WIFF sample {sample} has unsupported {name} length {} (expected {expected})", + bytes.len() + ))); + } + } + } + Ok(()) +} +fn read_calibration(path: &Path, sample: &str) -> Result, IoError> { + let mut file = File::open(path).map_err(|e| invalid(e.to_string()))?; + let mut compound = cfb::CompoundFile::open(&mut file).map_err(|e| invalid(e.to_string()))?; + let Ok(mut stream) = compound.open_stream(format!("SampleSubtree/{sample}/TOFCalibrationData")) + else { + return Ok(None); + }; + let mut bytes = Vec::new(); + stream + .read_to_end(&mut bytes) + .map_err(|e| invalid(e.to_string()))?; + if bytes.len() < 48 { + return Ok(None); + } + let calibration = Calibration { + slope: LittleEndian::read_f64(&bytes[32..40]), + intercept: LittleEndian::read_f64(&bytes[40..48]), + }; + Ok((calibration.slope.is_finite() && calibration.intercept.is_finite()).then_some(calibration)) +} + +fn decode_sample( + path: &Path, + sample: &str, + idx: &[IdxRecord], + calibration: Option, +) -> Result, IoError> { + let scan = std::fs::read(companion_path(path)?).map_err(|e| invalid(e.to_string()))?; + let mut out = Vec::new(); + for (i, rec) in idx.iter().enumerate() { + let base = usize::try_from(rec.scan_offset) + .map_err(|_| invalid("WIFF scan offset does not fit in memory"))?; + if rec.scan_size > 0 + && (base >= scan.len() + || base + .checked_add( + usize::try_from(rec.scan_size) + .map_err(|_| invalid("WIFF scan size overflow"))?, + ) + .is_none_or(|end| end > scan.len())) + { + return Err(invalid(format!( + "WIFF sample {sample} scan {i} exceeds the .scan payload" + ))); + } + let end_by_size = base + .checked_add( + usize::try_from(rec.scan_size).map_err(|_| invalid("WIFF scan size overflow"))?, + ) + .and_then(|value| value.checked_add(64)) + .ok_or_else(|| invalid("WIFF scan boundary overflow"))?; + let next_same_experiment = idx + .get(i + 11) + .filter(|next| next.experiment_index == rec.experiment_index) + .map(|next| usize::try_from(next.scan_offset).unwrap_or(scan.len())) + .unwrap_or(scan.len()); + if rec.scan_size > 0 && next_same_experiment < base { + return Err(invalid(format!( + "WIFF sample {sample} experiment {} has decreasing scan offsets", + rec.experiment_index + 1 + ))); + } + let end = end_by_size.min(next_same_experiment).min(scan.len()); + let (pts, _payload_start) = if rec.scan_size == 0 { + (Vec::new(), base) + } else if base >= end { + return Err(invalid(format!( + "WIFF sample {sample} scan {i} points outside the .scan payload" + ))); + } else { + decode_scan_block(&scan[base..end], base) + }; + let mut mz = Vec::new(); + let mut intensity = Vec::new(); + for p in pts { + if p.raw_intensity > 0 { + mz.push( + calibration + .as_ref() + .map_or(p.raw_mz_bin as f64, |c| c.apply(p.raw_mz_bin)), + ); + intensity.push(p.raw_intensity as f32); + } + } + out.push(SpectrumRecord { + index: i, + scan_number: (i + 1) as u32, + native_id: if idx.len() == 1 { + format!( + "file={} scan={}", + path.file_stem().and_then(|s| s.to_str()).unwrap_or(sample), + i + 1 + ) + } else { + format!( + "file={} experiment={} cycle={} scan={}", + path.file_stem().and_then(|s| s.to_str()).unwrap_or(sample), + rec.experiment_index + 1, + rec.cycle_index + 1, + i + 1 + ) + }, + ms_level: if idx.len() == 1 { + rec.declared_ms_level + } else if rec.experiment_index == 0 { + 1 + } else { + 2 + }, + polarity: calibration.map(|_| SourcePolarity::Positive), + scan_mode: None, + retention_time_sec: if idx.len() == 1 { + f64::from(rec.legacy_time_min) * 60.0 + } else if rec.acquisition_time_ms > 0.0 { + rec.acquisition_time_ms / 1000.0 + } else { + f64::from(rec.legacy_time_min) * 60.0 + }, + total_ion_current: Some(rec.tic), + precursor: None, + mz, + intensity, + analyzer: Some(Analyzer::TOFMS), + acquisition_event_id: Some( + u32::try_from(rec.experiment_index) + .map_err(|_| invalid("WIFF experiment index overflow"))?, + ), + filter: None, + base_peak_mz: None, + base_peak_intensity: None, + low_mz: None, + high_mz: None, + ion_injection_time_ms: None, + inv_mobility: None, + faims_cv: None, + inv_mobility_per_peak: None, + extra: BTreeMap::new(), + }); + } + Ok(out) +} + +struct SampleGroup { + label: String, + sample: String, +} +#[allow(clippy::chunks_exact_to_as_chunks)] +fn sample_name(path: &Path, sample: &str) -> Result { + let stream_path = format!("SampleSubtree/{sample}/SampleDABE/DATA"); + let Some(bytes) = read_stream(path, &stream_path)? else { + return Ok(sample.to_owned()); + }; + if bytes.len() >= 38 { + let byte_len = LittleEndian::read_u16(&bytes[36..38]) as usize; + let end = 38_usize.saturating_add(byte_len).min(bytes.len()); + if end > 38 { + let candidate = String::from_utf16_lossy( + &bytes[38..end] + .chunks_exact(2) + .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) + .take(byte_len / 2) + .collect::>(), + ); + let candidate = candidate.trim_matches('\0').trim(); + if candidate.len() >= 2 && candidate.chars().all(|c| c.is_ascii_graphic() || c == ' ') { + return Ok(candidate.to_owned()); + } + } + } + let mut best = String::new(); + let mut current = String::new(); + for pair in bytes.chunks_exact(2) { + let value = u16::from_le_bytes([pair[0], pair[1]]); + if value == 0 { + if current.len() > best.len() { + best.clone_from(¤t); + } + current.clear(); + } else if (0x20..=0x7e).contains(&value) { + current.push(value as u8 as char); + } else if !current.is_empty() { + if current.len() > best.len() { + best.clone_from(¤t); + } + current.clear(); + } + } + if current.len() > best.len() { + best = current; + } + let label = best.trim().to_owned(); + Ok(if label.len() >= 2 { + label + } else { + sample.to_owned() + }) +} + +fn sample_groups(path: &Path, samples: &[String]) -> Result, IoError> { + let mut counts = BTreeMap::::new(); + samples + .iter() + .map(|sample| { + let base = sample_name(path, sample)?; + let count = counts.entry(base.clone()).or_default(); + *count += 1; + let label = if *count == 1 { + base + } else { + format!("{base} #{}", *count) + }; + Ok(SampleGroup { + label, + sample: sample.clone(), + }) + }) + .collect::, IoError>>() + .map(|mut groups| { + let mut bases = BTreeMap::::new(); + for group in &groups { + *bases + .entry( + group + .label + .split(" #") + .next() + .unwrap_or(&group.label) + .to_owned(), + ) + .or_default() += 1; + } + for group in &mut groups { + if !group.label.contains(" #") && bases.get(&group.label).copied().unwrap_or(0) > 1 + { + group.label.push_str(" #1"); + } + } + groups + }) +} + +pub fn load(path: &Path) -> Result { + let scan_path = companion_path(path)?; + if !scan_path.is_file() { + return Err(invalid(format!( + "paired .wiff.scan file is missing: {}", + scan_path.display() + ))); + } + + let samples = list_samples(path)?; + if samples.is_empty() { + return Err(invalid("the WIFF container contains no samples")); + } + + let mut metadata = BTreeMap::new(); + metadata.insert("source format".to_owned(), "SCIEX WIFF".to_owned()); + let groups = sample_groups(path, &samples)?; + metadata.insert("sample count".to_owned(), groups.len().to_string()); + metadata.insert( + "samples".to_owned(), + groups + .iter() + .map(|group| group.label.as_str()) + .collect::>() + .join(", "), + ); + let multiple_samples = groups.len() > 1; + let mut streams = Vec::new(); + let mut chromatograms = Vec::new(); + let mut import_warnings = Vec::new(); + let mut instruments = BTreeSet::new(); + let mut next_stream_id = 1_u64; + for (sample_index, group) in groups.iter().enumerate() { + let idx = read_idx(path, &group.sample)?; + validate_auxiliary_layout(path, &group.sample, &idx)?; + let calibration = read_calibration(path, &group.sample)?; + if sample_index == 0 { + metadata.insert("source file format".to_owned(), "SCIEX WIFF".to_owned()); + metadata.insert( + "native ID format".to_owned(), + "file=... scan=...".to_owned(), + ); + metadata.insert( + "reader".to_owned(), + format!("{} {}", "PlotX", "native WIFF parser"), + ); + } + instruments.insert("SCIEX instrument model".to_owned()); + let decoded = decode_sample(path, &group.sample, &idx, calibration)?; + let built_streams = build_streams( + decoded, + &group.label, + &mut next_stream_id, + &mut import_warnings, + )?; + if built_streams.is_empty() { + return Err(invalid(format!( + "WIFF sample {} contains no spectra", + group.label + ))); + } + let source_stream = built_streams + .iter() + .find(|stream| { + stream + .source_native_id + .as_deref() + .is_some_and(|id| id.contains("experiment=1")) + }) + .map(|stream| stream.id); + streams.extend(built_streams.iter().cloned()); + let tic_records = idx + .iter() + .map(|record| { + ( + record.experiment_index, + record.acquisition_time_ms, + record.legacy_time_min, + record.tic, + ) + }) + .collect::>(); + chromatograms.extend(tic::channels( + &tic_records, + &built_streams, + &group.label, + multiple_samples, + )?); + let _ = source_stream; + } + let instrument = + (!instruments.is_empty()).then(|| instruments.into_iter().collect::>().join(", ")); + let run = MassSpecRun { + source: path.to_string_lossy().into_owned(), + metadata, + instrument, + streams, + chromatograms, + import_warnings: import_warnings.clone(), + }; + run.validate().map_err(invalid)?; + + let mut identity = crate::AcquisitionIdentity::from_path(path); + if let [group] = groups.as_slice() { + identity.subject = Some(group.label.clone()); + } else { + identity.acquisition = Some(format!("{} samples", groups.len())); + } + + Ok(LoadResult::new( + Acquisition::MassSpec(Box::new(run)), + identity, + DataFormat::MassSpectrometry(MassSpectrometryFormat::SciexWiff), + Provenance { + selected_path: path.to_owned(), + data_path: path.to_owned(), + parameter_paths: Vec::new(), + companion_paths: vec![scan_path], + }, + import_warnings + .into_iter() + .map(|message| LoadWarning { + code: LoadWarningCode::InvalidMetadata, + message, + path: Some(path.to_owned()), + }) + .collect(), + )) +} + +fn build_streams( + records: Vec, + sample: &str, + next_stream_id: &mut u64, + warnings: &mut Vec, +) -> Result, IoError> { + let mut builders = BTreeMap::<(u32, u8), StreamBuilder>::new(); + for record in records { + if record.mz.is_empty() + && record.intensity.is_empty() + && record.acquisition_event_id.is_none() + { + warnings.push(format!( + "WIFF sample {sample} scan {} contained no decoded points and was skipped", + record.native_id + )); + continue; + } + let ms_level = u8::try_from(record.ms_level).map_err(|_| { + invalid(format!( + "scan {} has an unsupported MS level", + record.native_id + )) + })?; + if ms_level == 0 { + return Err(invalid(format!( + "scan {} has an invalid MS level of zero", + record.native_id + ))); + } + let polarity = map_polarity(record.polarity); + let polarity_key = match polarity { + Polarity::Unknown => 0, + Polarity::Positive => 1, + Polarity::Negative => 2, + }; + let experiment_index = record + .acquisition_event_id + .unwrap_or(record.index as u32 % 11); + let builder = match builders.entry((experiment_index, polarity_key)) { + Entry::Occupied(entry) => entry.into_mut(), + Entry::Vacant(entry) => { + let id = AcquisitionStreamId::new(*next_stream_id); + *next_stream_id = next_stream_id.checked_add(1).ok_or_else(|| { + invalid("the WIFF container has too many acquisition streams") + })?; + entry.insert(StreamBuilder { + id, + experiment_index, + ms_level, + polarity, + low_mz: f64::INFINITY, + high_mz: f64::NEG_INFINITY, + spectra: Vec::new(), + }) + } + }; + let spectrum = convert_spectrum(record, ms_level, polarity)?; + for &mz in &spectrum.mz { + builder.low_mz = builder.low_mz.min(mz); + builder.high_mz = builder.high_mz.max(mz); + } + builder.spectra.push(spectrum); + } + + if builders.is_empty() { + return Err(invalid(format!( + "WIFF sample {sample} contains no decoded spectra" + ))); + } + + let single_builder = builders.len() == 1; + Ok(builders + .into_values() + .map(|builder| { + let polarity = polarity_label(builder.polarity); + AcquisitionStream { + id: builder.id, + source_native_id: Some(format!( + "sample={sample} experiment={} ms_level={} polarity={polarity}", + builder.experiment_index + 1, + builder.ms_level + )), + source_label: Some(if single_builder { + format!("{sample} - MS{} {polarity}", builder.ms_level) + } else { + format!( + "{sample} - Experiment {} MS{} {polarity}", + builder.experiment_index + 1, + builder.ms_level + ) + }), + role: StreamRole::Primary, + acquisition_range: (builder.low_mz <= builder.high_mz) + .then_some([builder.low_mz, builder.high_mz]), + spectra: builder.spectra, + } + }) + .collect()) +} + +fn convert_spectrum( + record: SpectrumRecord, + ms_level: u8, + polarity: Polarity, +) -> Result { + if record.mz.len() != record.intensity.len() { + return Err(invalid(format!( + "scan {} has {} m/z values but {} intensity values", + record.native_id, + record.mz.len(), + record.intensity.len() + ))); + } + if record.mz.is_empty() && record.acquisition_event_id.is_none() { + return Err(invalid(format!( + "scan {} contains no decoded points", + record.native_id + ))); + } + let intensity: Vec = record + .intensity + .iter() + .map(|&value| f64::from(value)) + .collect(); + let tic = record + .total_ion_current + .filter(|value| value.is_finite() && *value >= 0.0) + .unwrap_or_else(|| intensity.iter().copied().sum::()); + let base_peak = intensity + .iter() + .enumerate() + .filter(|(_, value)| value.is_finite() && **value >= 0.0) + .max_by(|(_, left), (_, right)| left.total_cmp(right)); + let (base_peak_mz, base_peak_intensity) = base_peak.map_or((None, None), |(index, value)| { + (record.mz.get(index).copied(), Some(*value)) + }); + let precursor = record.precursor.and_then(|source| { + let selected_mz = source.selected_mz.or(source.target_mz)?; + let half_width = source.isolation_width.map(|width| width / 2.0); + Some(Precursor { + selected_mz, + charge: source.charge, + isolation_window_lower_offset: half_width, + isolation_window_upper_offset: half_width, + collision_energy: source.collision_energy, + activation_method: source.activation.map(activation_label), + }) + }); + + Ok(MassSpectrum { + id: SpectrumId::new(record.scan_number.into()), + source_native_id: Some(record.native_id), + retention_time_min: record.retention_time_sec / 60.0, + ms_level, + polarity, + representation: match record.scan_mode { + Some(ScanMode::Centroid) => SpectrumRepresentation::Centroid, + Some(ScanMode::Profile) => SpectrumRepresentation::Profile, + None => SpectrumRepresentation::Unknown, + }, + mz: record.mz, + intensity, + tic, + base_peak_mz, + base_peak_intensity, + precursor, + }) +} +fn map_polarity(polarity: Option) -> Polarity { + match polarity { + Some(SourcePolarity::Positive) => Polarity::Positive, + Some(SourcePolarity::Negative) => Polarity::Negative, + None => Polarity::Unknown, + } +} + +fn polarity_label(polarity: Polarity) -> &'static str { + match polarity { + Polarity::Positive => "positive", + Polarity::Negative => "negative", + Polarity::Unknown => "unknown", + } +} + +fn activation_label(activation: Activation) -> String { + format!("{activation:?}") +} + +fn invalid(message: impl Into) -> IoError { + IoError::InvalidSciexWiff(message.into()) +} + +#[cfg(test)] +#[path = "sciex_wiff_tests.rs"] +mod tests; diff --git a/crates/io/src/sciex_wiff_scan.rs b/crates/io/src/sciex_wiff_scan.rs new file mode 100644 index 0000000..f870d4d --- /dev/null +++ b/crates/io/src/sciex_wiff_scan.rs @@ -0,0 +1,116 @@ +use crate::IoError; +use byteorder::{ByteOrder, LittleEndian}; +use std::path::{Path, PathBuf}; + +pub(super) fn companion_path(path: &Path) -> Result { + let mut name = path + .file_name() + .ok_or_else(|| IoError::InvalidSciexWiff("the WIFF path has no filename".to_owned()))? + .to_os_string(); + name.push(".scan"); + let mut companion = path.to_owned(); + companion.set_file_name(name); + Ok(companion) +} + +#[derive(Clone, Copy)] +pub(super) struct ScanPoint { + pub raw_mz_bin: u32, + pub raw_intensity: u32, +} + +pub(super) fn decode_payload(payload: &[u8]) -> Vec { + let mut points = Vec::new(); + let mut mz = 0_u32; + let mut i = 0; + while i < payload.len() { + let b = payload[i]; + if b == 0xff && payload.get(i..i + 4) == Some(&[0xff; 4]) { + break; + } + match b { + 0..=0x7f => { + mz = mz.wrapping_add(b as u32); + i += 1; + } + 0x80..=0xfb => { + points.push(ScanPoint { + raw_mz_bin: mz, + raw_intensity: (b & 0x7f) as u32, + }); + i += 1; + } + 0xfc => { + if i + 1 >= payload.len() { + break; + } + points.push(ScanPoint { + raw_mz_bin: mz, + raw_intensity: payload[i + 1] as u32, + }); + i += 2; + } + 0xfd => { + if i + 2 >= payload.len() { + break; + } + points.push(ScanPoint { + raw_mz_bin: mz, + raw_intensity: LittleEndian::read_u16(&payload[i + 1..i + 3]) as u32, + }); + i += 3; + } + 0xfe => { + if i + 3 >= payload.len() { + break; + } + let value = payload[i + 1] as u32 + | (payload[i + 2] as u32) << 8 + | (payload[i + 3] as u32) << 16; + points.push(ScanPoint { + raw_mz_bin: mz, + raw_intensity: value, + }); + i += 4; + } + 0xff => { + if i + 4 >= payload.len() { + break; + } + points.push(ScanPoint { + raw_mz_bin: mz, + raw_intensity: LittleEndian::read_u32(&payload[i + 1..i + 5]), + }); + i += 5; + } + } + } + points +} + +pub(super) fn decode_scan_block(block: &[u8], absolute_base: usize) -> (Vec, usize) { + let terminator = block.windows(4).position(|window| window == [0xff; 4]); + let mut starts = vec![56.min(block.len())]; + if let Some(position) = terminator { + starts.push(position.saturating_add(8).min(block.len())); + starts.push(position.saturating_add(4).min(block.len())); + } + starts.push(0); + let mut best = Vec::new(); + let mut best_start = 0; + for start in starts { + if start >= block.len() { + continue; + } + let stop = block[start..] + .windows(4) + .position(|window| window == [0xff; 4]) + .map_or(block.len(), |position| start + position); + let points = decode_payload(&block[start..stop]); + if points.len() > best.len() { + best = points; + best_start = absolute_base + start; + } + } + (best, best_start) +} diff --git a/crates/io/src/sciex_wiff_tests.rs b/crates/io/src/sciex_wiff_tests.rs new file mode 100644 index 0000000..584dad9 --- /dev/null +++ b/crates/io/src/sciex_wiff_tests.rs @@ -0,0 +1,372 @@ +use super::*; +use std::collections::BTreeMap; +use std::io::Write; +use std::path::PathBuf; + +fn source_spectrum() -> SpectrumRecord { + SpectrumRecord { + index: 4, + scan_number: 5, + native_id: "file=fixture scan=5".to_owned(), + ms_level: 2, + polarity: Some(SourcePolarity::Positive), + scan_mode: Some(ScanMode::Centroid), + analyzer: Some(Analyzer::TOFMS), + acquisition_event_id: None, + filter: None, + retention_time_sec: 90.0, + total_ion_current: None, + base_peak_mz: None, + base_peak_intensity: None, + low_mz: None, + high_mz: None, + ion_injection_time_ms: None, + inv_mobility: None, + faims_cv: None, + precursor: Some(PrecursorInfo { + selected_mz: Some(445.34), + target_mz: Some(445.35), + isolation_width: Some(2.0), + charge: Some(2), + collision_energy: Some(30.0), + activation: Some(Activation::CID), + }), + mz: vec![100.0, 250.0, 600.0], + intensity: vec![3.0, 11.0, 7.0], + inv_mobility_per_peak: None, + extra: BTreeMap::new(), + } +} + +fn write_synthetic_pair(path: &Path, samples: &[(&str, u32, f32, f64)]) -> PathBuf { + let file = std::fs::File::create(path).unwrap(); + let mut compound = cfb::CompoundFile::create(file).unwrap(); + compound.create_storage("SampleSubtree").unwrap(); + let mut scan = vec![0_u8; samples.len() * 100]; + for (index, (sample, ms_level, time_min, tic)) in samples.iter().enumerate() { + compound + .create_storage(format!("SampleSubtree/{sample}")) + .unwrap(); + let offset = index * 100; + let mut idx = vec![0_u8; 32 + 54]; + idx[32..36].copy_from_slice(&u32::try_from(offset).unwrap().to_le_bytes()); + idx[36..40].copy_from_slice(&100_u32.to_le_bytes()); + idx[44..48].copy_from_slice(&time_min.to_le_bytes()); + idx[48..50].copy_from_slice(&u16::try_from(*ms_level).unwrap().to_le_bytes()); + idx[50..58].copy_from_slice(&tic.to_le_bytes()); + compound + .create_stream(format!("SampleSubtree/{sample}/Idx")) + .unwrap() + .write_all(&idx) + .unwrap(); + scan[offset + 56..offset + 64] + .copy_from_slice(&[100, 0x85, 10, 0x89, 0xff, 0xff, 0xff, 0xff]); + } + compound.flush().unwrap(); + drop(compound); + + let mut scan_path = path.to_owned(); + let mut name = path.file_name().unwrap().to_os_string(); + name.push(".scan"); + scan_path.set_file_name(name); + std::fs::write(&scan_path, scan).unwrap(); + scan_path +} + +#[test] +fn detects_wiff_extension_case_insensitively() { + for path in ["run.wiff", "run.WIFF", "run.WiFf"] { + assert_eq!( + crate::detect_format(path).unwrap(), + DataFormat::MassSpectrometry(MassSpectrometryFormat::SciexWiff) + ); + } + assert_eq!( + DataFormat::MassSpectrometry(MassSpectrometryFormat::SciexWiff).as_str(), + "sciex-wiff" + ); +} + +#[test] +fn rejects_wiff2_and_timeseries_with_conversion_guidance() { + for path in ["run.wiff2", "run.WIFF2", "run.timeseries.data"] { + let error = crate::detect_format(path).unwrap_err().to_string(); + assert!(error.contains("not currently supported"), "{error}"); + assert!(error.contains("mzML"), "{error}"); + } +} + +#[test] +fn requires_the_paired_scan_file() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("missing.wiff"); + std::fs::write(&path, b"not inspected before companion check").unwrap(); + + let error = load(&path).unwrap_err().to_string(); + + assert!( + error.contains("paired .wiff.scan file is missing"), + "{error}" + ); + assert!(error.contains("missing.wiff.scan"), "{error}"); +} + +#[test] +fn reports_a_corrupt_wiff_container() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("corrupt.wiff"); + std::fs::write(&path, b"not an OLE container").unwrap(); + std::fs::write(directory.path().join("corrupt.wiff.scan"), b"scan").unwrap(); + + let error = load(&path).unwrap_err().to_string(); + + assert!( + error.starts_with("invalid or unsupported SCIEX WIFF:"), + "{error}" + ); +} + +#[test] +fn rejects_an_empty_sample_container() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("samples.wiff"); + let file = std::fs::File::create(&path).unwrap(); + let mut compound = cfb::CompoundFile::create(file).unwrap(); + compound.create_storage("SampleSubtree").unwrap(); + compound.flush().unwrap(); + drop(compound); + std::fs::write(directory.path().join("samples.wiff.scan"), b"scan").unwrap(); + + let error = load(&path).unwrap_err().to_string(); + + assert!(error.contains("contains no samples"), "{error}"); +} + +#[test] +fn loads_a_synthetic_single_sample_wiff_pair_end_to_end() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("single.wiff"); + let scan_path = write_synthetic_pair(&path, &[("Sample1", 1, 1.25, 14.0)]); + + let loaded = crate::load_path(&path).unwrap(); + + assert_eq!( + loaded.format, + DataFormat::MassSpectrometry(MassSpectrometryFormat::SciexWiff) + ); + assert_eq!(loaded.provenance.companion_paths, vec![scan_path]); + let Acquisition::MassSpec(run) = loaded.acquisition else { + panic!("WIFF should produce a mass-spectrometry run"); + }; + assert_eq!(run.streams.len(), 1); + assert_eq!( + run.streams[0].source_label.as_deref(), + Some("Sample1 - MS1 unknown") + ); + assert_eq!( + run.metadata.get("sample count").map(String::as_str), + Some("1") + ); + let spectrum = &run.streams[0].spectra[0]; + assert_eq!( + spectrum.source_native_id.as_deref(), + Some("file=single scan=1") + ); + assert_eq!(spectrum.retention_time_min, 1.25); + assert_eq!(spectrum.mz, vec![100.0, 110.0]); + assert_eq!(spectrum.intensity, vec![5.0, 9.0]); + assert_eq!(spectrum.tic, 14.0); + assert_eq!(run.chromatograms.len(), 1); + assert_eq!(run.chromatograms[0].time_min, vec![1.25]); + assert_eq!(run.chromatograms[0].values, vec![14.0]); +} + +#[test] +fn loads_all_samples_as_distinct_streams_and_chromatograms() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("multi.wiff"); + let scan_path = write_synthetic_pair( + &path, + &[("Sample1", 1, 1.25, 14.0), ("Sample2", 2, 2.5, 28.0)], + ); + + let loaded = crate::load_path(&path).unwrap(); + + assert_eq!(loaded.provenance.companion_paths, vec![scan_path]); + assert_eq!(loaded.acquisition_identity.subject, None); + assert_eq!( + loaded.acquisition_identity.acquisition.as_deref(), + Some("2 samples") + ); + let Acquisition::MassSpec(run) = loaded.acquisition else { + panic!("WIFF should produce a mass-spectrometry run"); + }; + assert_eq!( + run.metadata.get("sample count").map(String::as_str), + Some("2") + ); + assert_eq!( + run.metadata.get("samples").map(String::as_str), + Some("Sample1, Sample2") + ); + assert_eq!(run.streams.len(), 2); + assert_eq!(run.streams[0].id, AcquisitionStreamId::new(1)); + assert_eq!(run.streams[1].id, AcquisitionStreamId::new(2)); + assert_eq!( + run.streams[0].source_label.as_deref(), + Some("Sample1 - MS1 unknown") + ); + assert_eq!( + run.streams[1].source_label.as_deref(), + Some("Sample2 - MS2 unknown") + ); + assert_eq!(run.streams[0].spectra[0].retention_time_min, 1.25); + assert_eq!(run.streams[1].spectra[0].retention_time_min, 2.5); + assert_eq!( + run.chromatograms + .iter() + .map(|channel| channel.id.0.as_str()) + .collect::>(), + vec!["Sample1:TIC", "Sample2:TIC"] + ); + assert_eq!(run.chromatograms[0].values, vec![14.0]); + assert_eq!(run.chromatograms[1].values, vec![28.0]); +} + +#[test] +fn maps_spectrum_identity_time_polarity_precursor_and_summaries() { + let streams = + build_streams(vec![source_spectrum()], "Sample1", &mut 1, &mut Vec::new()).unwrap(); + let stream = &streams[0]; + let spectrum = &stream.spectra[0]; + + assert_eq!(stream.acquisition_range, Some([100.0, 600.0])); + assert_eq!( + stream.source_label.as_deref(), + Some("Sample1 - MS2 positive") + ); + assert_eq!(spectrum.id, SpectrumId::new(5)); + assert_eq!( + spectrum.source_native_id.as_deref(), + Some("file=fixture scan=5") + ); + assert_eq!(spectrum.retention_time_min, 1.5); + assert_eq!(spectrum.ms_level, 2); + assert_eq!(spectrum.polarity, Polarity::Positive); + assert_eq!(spectrum.representation, SpectrumRepresentation::Centroid); + assert_eq!(spectrum.mz.len(), spectrum.intensity.len()); + assert_eq!(spectrum.tic, 21.0); + assert_eq!(spectrum.base_peak_mz, Some(250.0)); + assert_eq!(spectrum.base_peak_intensity, Some(11.0)); + let precursor = spectrum.precursor.as_ref().unwrap(); + assert_eq!(precursor.selected_mz, 445.34); + assert_eq!(precursor.charge, Some(2)); + assert_eq!(precursor.isolation_window_lower_offset, Some(1.0)); + assert_eq!(precursor.isolation_window_upper_offset, Some(1.0)); + assert_eq!(precursor.collision_energy, Some(30.0)); + assert_eq!(precursor.activation_method.as_deref(), Some("CID")); +} + +#[test] +fn rejects_a_sample_with_no_decoded_spectra() { + let mut record = source_spectrum(); + record.mz.clear(); + record.intensity.clear(); + + let mut warnings = Vec::new(); + let error = build_streams(vec![record], "Sample1", &mut 1, &mut warnings) + .unwrap_err() + .to_string(); + + assert!(error.contains("Sample1"), "{error}"); + assert!(error.contains("contains no decoded spectra"), "{error}"); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("file=fixture scan=5")); + assert!(warnings[0].contains("was skipped")); +} + +#[test] +fn skips_an_empty_scan_when_the_sample_has_readable_spectra() { + let mut empty = source_spectrum(); + empty.native_id = "file=fixture scan=4".to_owned(); + empty.scan_number = 4; + empty.mz.clear(); + empty.intensity.clear(); + let mut warnings = Vec::new(); + + let streams = build_streams( + vec![empty, source_spectrum()], + "Sample1", + &mut 1, + &mut warnings, + ) + .unwrap(); + + assert_eq!(streams.len(), 1); + assert_eq!(streams[0].spectra.len(), 1); + assert_eq!(streams[0].spectra[0].id, SpectrumId::new(5)); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("file=fixture scan=4")); +} + +#[test] +fn local_wiff_fixture_imports_validated_multi_sample_layout_when_present() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join(".tmp/WIFF/20250305.wiff"); + if !path.is_file() { + return; + } + + let loaded = load(&path).expect("local legacy WIFF fixture should import every sample"); + assert!( + loaded.warnings.is_empty(), + "valid empty scan headers are not import warnings" + ); + assert_eq!( + loaded.format, + DataFormat::MassSpectrometry(MassSpectrometryFormat::SciexWiff) + ); + let Acquisition::MassSpec(run) = loaded.acquisition else { + panic!("WIFF should produce a mass-spectrometry run"); + }; + assert_eq!(run.metadata["sample count"].parse::().unwrap(), 2); + assert_eq!(run.metadata["samples"], "yjs_10ppm #1, yjs_10ppm #2"); + assert_eq!(run.streams.len(), 22); + assert_eq!(run.chromatograms.len(), 22); + let sample0: usize = run.streams[..11] + .iter() + .flat_map(|stream| &stream.spectra) + .filter(|spectrum| spectrum.tic > 0.0) + .count(); + let sample1: usize = run.streams[11..] + .iter() + .flat_map(|stream| &stream.spectra) + .filter(|spectrum| spectrum.tic > 0.0) + .count(); + assert_eq!((sample0, sample1), (3141, 3072)); + assert!( + run.streams + .iter() + .flat_map(|stream| &stream.spectra) + .all(|spectrum| { + spectrum.mz.len() == spectrum.intensity.len() + && spectrum.retention_time_min.is_finite() + && spectrum.mz.iter().all(|value| value.is_finite()) + }) + ); + let tic = &run.chromatograms[0]; + assert_eq!(tic.time_min.len(), 3905); + assert!(tic.time_min.windows(2).all(|pair| pair[1] > pair[0])); + assert!((tic.time_min[0] - 0.002533333333333333).abs() < 1e-9); + assert!((tic.time_min[3904] - 13.49435).abs() < 1e-9); + let ms1 = &run.streams[0]; + let peak = ms1 + .spectra + .iter() + .max_by(|left, right| left.tic.total_cmp(&right.tic)) + .unwrap(); + assert!((peak.retention_time_min - 0.9720333333333334).abs() < 1e-9); + assert_eq!(peak.tic, 5374726.0); + assert_eq!(loaded.provenance.companion_paths.len(), 1); +} diff --git a/crates/io/src/sciex_wiff_tic.rs b/crates/io/src/sciex_wiff_tic.rs new file mode 100644 index 0000000..1a62793 --- /dev/null +++ b/crates/io/src/sciex_wiff_tic.rs @@ -0,0 +1,62 @@ +use crate::{ + AcquisitionStream, ChromatogramChannel, ChromatogramChannelId, ChromatogramKind, IoError, +}; + +pub(super) fn channels( + idx: &[(usize, f64, f32, f64)], + streams: &[AcquisitionStream], + sample: &str, + multiple: bool, +) -> Result, IoError> { + let count = idx.iter().map(|r| r.0).max().map_or(0, |v| v + 1); + (0..count) + .map(|experiment| { + let source = streams.iter().find(|stream| { + stream + .source_native_id + .as_deref() + .is_some_and(|id| id.contains(&format!("experiment={}", experiment + 1))) + }); + let (time_min, values): (Vec<_>, Vec<_>) = idx + .iter() + .filter(|r| r.0 == experiment) + .map(|r| { + ( + if idx.len() == 1 { + f64::from(r.2) + } else { + r.1 / 60_000.0 + }, + r.3, + ) + }) + .unzip(); + if time_min.is_empty() { + return Err(IoError::InvalidSciexWiff(format!( + "WIFF sample {sample} has no TIC records for experiment {}", + experiment + 1 + ))); + } + let prefix = if multiple { + format!("{sample}:") + } else { + String::new() + }; + let local = if count == 1 { + "TIC".to_owned() + } else { + format!("Experiment{}:TIC", experiment + 1) + }; + Ok(ChromatogramChannel { + id: ChromatogramChannelId(format!("{prefix}{local}")), + kind: ChromatogramKind::Unknown, + source_stream: source.map(|s| s.id), + coordinate: Some((experiment + 1) as f64), + description: format!("{sample} experiment {} total ion current", experiment + 1), + unit: "cps".to_owned(), + time_min, + values, + }) + }) + .collect() +} diff --git a/crates/processing/src/craft.rs b/crates/processing/src/craft.rs index fb0f326..ff48866 100644 --- a/crates/processing/src/craft.rs +++ b/crates/processing/src/craft.rs @@ -1,36 +1,42 @@ //! Complete Reduction to Amplitude Frequency Table (CRAFT) for one-dimensional FIDs. use num_complex::Complex64; -use plotx_analysis::craft::{ - CraftFitBounds, CraftFitError, DampedSinusoid, evaluate_damped_sinusoids_cancellable, - matrix_pencil_estimates, -}; +use plotx_analysis::craft::CraftFitError; use plotx_io::{Domain, NmrData}; use serde::{Deserialize, Serialize}; -use std::f64::consts::{PI, TAU}; mod diagnostics; +mod fitting; mod preflight; mod reconstruction; mod regions; +mod report; mod resolution; +mod stability; pub use diagnostics::{ - CraftDiagnostics, CraftFitWindowDiagnostic, CraftRegionRatio, CraftRegionSummary, - CraftRunStatus, CraftWarning, CraftWarningKind, + CraftDiagnostics, CraftModelingWindowDiagnostic, CraftRegionRatio, CraftRegionSummary, + CraftRunStatus, CraftStabilityDiagnostics, CraftStabilityMetric, CraftStabilityRegion, + CraftWarning, CraftWarningKind, }; +use fitting::{CraftModelingContext, fit_modeling_window}; pub use preflight::{ CraftAssessmentIssue, CraftInputAssessment, CraftIssueAction, CraftIssueCode, CraftIssueSeverity, CraftSignalSuggestion, }; use reconstruction::model_at; pub use reconstruction::{synthesize_craft_fid, synthesize_craft_samples}; -use regions::{HzRegion, build_regions, region_ratio, selections_are_valid, summarize_regions}; +use regions::{build_modeling_windows, region_ratio, selections_are_valid, summarize_regions}; +pub use report::{ + CraftAmplitudeReport, CraftReportDefinition, CraftReportError, CraftReportSegment, + calculate_craft_report, +}; pub use resolution::{ - CraftDerivedPlan, CraftDerivedWindow, CraftParamOverrides, CraftParamSource, - CraftParameterSources, resolve_craft_invocation, + CraftDerivedModelingWindow, CraftDerivedPlan, CraftModelingPolicy, CraftParamOverrides, + CraftParamSource, CraftParameterSources, resolve_craft_invocation, }; +use stability::{components_for_regions, stability_diagnostics}; -pub const CRAFT_ALGORITHM: &str = "plotx-craft-matrix-pencil-bic"; +pub const CRAFT_ALGORITHM: &str = "plotx-craft-matrix-pencil-validation"; pub const CRAFT_ALGORITHM_VERSION: u32 = 1; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -41,6 +47,22 @@ pub enum CraftProfile { Ssfp, } +impl CraftProfile { + pub const fn modeling_bandwidth_hz(self) -> f64 { + match self { + Self::Conventional => 250.0, + Self::Ssfp => 2_000.0, + } + } + + pub const fn modeling_duration_s(self) -> f64 { + match self { + Self::Conventional => 1.0, + Self::Ssfp => 1.2, + } + } +} + #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] pub struct CraftRegion { pub id: CraftRegionId, @@ -114,13 +136,11 @@ pub struct CraftParams { pub profile: CraftProfile, /// Empty means the complete acquired spectral width. pub regions: Vec, - pub max_components_per_fit_window: usize, - pub min_amplitude_to_noise: f64, - pub linewidth_hz: (f64, f64), - pub filter_taps: usize, - pub padding_fraction: f64, - pub max_fit_window_width_hz: f64, - pub max_downsampled_points: usize, + pub maximum_model_order: usize, + pub minimum_amplitude_to_noise: f64, + pub component_linewidth_bounds_hz: (f64, f64), + pub fir_filter_taps: usize, + pub maximum_modeled_sample_count: usize, pub skip_duration_s: f64, pub reconstruction_duration_s: Option, } @@ -130,13 +150,11 @@ impl CraftParams { Self { profile: CraftProfile::Conventional, regions: Vec::new(), - max_components_per_fit_window: 15, - min_amplitude_to_noise: 3.3, - linewidth_hz: (0.05, 10.0), - filter_taps: 499, - padding_fraction: 0.2, - max_fit_window_width_hz: 500.0, - max_downsampled_points: 8192, + maximum_model_order: 15, + minimum_amplitude_to_noise: 3.3, + component_linewidth_bounds_hz: (0.05, 20.0), + fir_filter_taps: 499, + maximum_modeled_sample_count: 8192, skip_duration_s: 0.0, reconstruction_duration_s: None, } @@ -147,34 +165,29 @@ impl CraftParams { profile: CraftProfile::Ssfp, skip_duration_s: 0.0005, reconstruction_duration_s: Some(1.2), - max_fit_window_width_hz: 2_000.0, ..Self::conventional() } } pub fn discovery() -> Self { Self { - min_amplitude_to_noise: 2.5, + minimum_amplitude_to_noise: 2.5, ..Self::conventional() } } pub fn validate(&self) -> Result<(), CraftError> { - if self.max_components_per_fit_window == 0 - || self.max_components_per_fit_window > 64 - || !self.min_amplitude_to_noise.is_finite() - || self.min_amplitude_to_noise <= 0.0 - || !self.linewidth_hz.0.is_finite() - || !self.linewidth_hz.1.is_finite() - || self.linewidth_hz.0 <= 0.0 - || self.linewidth_hz.0 >= self.linewidth_hz.1 - || self.filter_taps < 3 - || self.filter_taps.is_multiple_of(2) - || !self.padding_fraction.is_finite() - || !(0.0..=1.0).contains(&self.padding_fraction) - || !self.max_fit_window_width_hz.is_finite() - || self.max_fit_window_width_hz <= 0.0 - || self.max_downsampled_points < 64 + if self.maximum_model_order == 0 + || self.maximum_model_order > 64 + || !self.minimum_amplitude_to_noise.is_finite() + || self.minimum_amplitude_to_noise <= 0.0 + || !self.component_linewidth_bounds_hz.0.is_finite() + || !self.component_linewidth_bounds_hz.1.is_finite() + || self.component_linewidth_bounds_hz.0 <= 0.0 + || self.component_linewidth_bounds_hz.0 >= self.component_linewidth_bounds_hz.1 + || self.fir_filter_taps < 3 + || self.fir_filter_taps.is_multiple_of(2) + || self.maximum_modeled_sample_count < 64 || !self.skip_duration_s.is_finite() || self.skip_duration_s < 0.0 || self @@ -206,6 +219,7 @@ pub struct CraftInvocation { pub reference: CraftReference, pub derived_plan: CraftDerivedPlan, pub assessment: CraftInputAssessment, + pub modeling_policy: CraftModelingPolicy, } impl CraftInvocation { @@ -217,6 +231,9 @@ impl CraftInvocation { pub fn validate(&self, data: &NmrData) -> Result<(), CraftError> { self.params.validate()?; self.reference.validate(data)?; + if self.modeling_policy != CraftModelingPolicy::for_params(&self.params) { + return Err(CraftError::InvalidParameters); + } if self.assessment.can_run() { Ok(()) } else { @@ -272,21 +289,10 @@ pub enum CraftError { InvalidReference, #[error("CRAFT analysis was cancelled")] Cancelled, - #[error("CRAFT could not fit a requested region: {0}")] + #[error("CRAFT could not fit a modeling window: {0}")] Fit(#[from] CraftFitError), } -struct RegionResult { - components: Vec, - center_hz: f64, - bic: Option, - condition_number: f64, - decimation: usize, - retained_samples: usize, - evaluated_model_orders: usize, - warning: Option<(CraftWarningKind, String)>, -} - pub fn process_craft_cancellable( data: &NmrData, invocation: &CraftInvocation, @@ -318,59 +324,94 @@ pub fn process_craft_cancellable( return Err(CraftError::InvalidInput); } let input = &data.points[skip..]; + let modeling_context = CraftModelingContext { + input, + skipped_points: skip, + group_delay_points: data.group_delay, + spectral_width_hz: sw, + params, + policy: invocation.modeling_policy, + }; let noise_sigma = estimate_complex_noise(input).max(f64::MIN_POSITIVE); - let regions = build_regions(data, params, reference)?; + let modeling_windows = build_modeling_windows( + data, + params, + reference, + &invocation.assessment.clear_signals, + )?; let mut fitted = Vec::new(); let mut warnings = Vec::new(); - let mut fit_windows = Vec::with_capacity(regions.len()); + let mut window_diagnostics = Vec::with_capacity(modeling_windows.len()); let mut max_condition = 1.0_f64; + let selected_frequency_bands = params + .regions + .iter() + .map(|region| { + let region = region.normalized(); + ( + (region.start_ppm - reference.effective_carrier_ppm()) * data.observe_freq_mhz, + (region.end_ppm - reference.effective_carrier_ppm()) * data.observe_freq_mhz, + ) + }) + .collect::>(); - for (index, region) in regions.iter().copied().enumerate() { + for (index, window) in modeling_windows.iter().copied().enumerate() { if cancelled() { return Err(CraftError::Cancelled); } - let result = fit_region(input, skip, data.group_delay, sw, region, params, cancelled)?; - if let Some((kind, message)) = result.warning { + let result = fit_modeling_window(&modeling_context, window, cancelled)?; + let contributes_to_selection = selected_frequency_bands.is_empty() + || selected_frequency_bands.iter().any(|&(start, end)| { + window.retention_band_hz.0 <= end && window.retention_band_hz.1 >= start + }); + if let Some((kind, message)) = result.warning + && contributes_to_selection + { warnings.push(CraftWarning { kind, - region: Some(region.selection.id), - fit_window: Some(index), - message: format!("Fit window {}: {message}", index + 1), + region: None, + modeling_window: Some(index), + message: format!("Modeling window {}: {message}", index + 1), }); } - fit_windows.push(CraftFitWindowDiagnostic { - region: region.selection.id, - core_hz: region.core, - padded_hz: region.padded, - actual_decimation: result.decimation, - retained_samples: result.retained_samples, + window_diagnostics.push(CraftModelingWindowDiagnostic { + retention_band_hz: window.retention_band_hz, + modeling_band_hz: window.modeling_band_hz, + decimation_factor: result.decimation, + modeled_sample_count: result.modeled_sample_count, evaluated_model_orders: result.evaluated_model_orders, selected_model_order: result.components.len(), - bic: result.bic, + training_bic: result.training_bic, condition_number: result .condition_number .is_finite() .then_some(result.condition_number), + modeled_duration_s: result.modeled_duration_s, + training_normalized_residual: result.training_normalized_residual, + validation_normalized_residual: result.validation_normalized_residual, }); max_condition = max_condition.max(result.condition_number); for component in result.components { let frequency_hz = component.frequency_hz + result.center_hz; - if frequency_hz >= region.core.0 - && frequency_hz < region.core.1 - && component.amplitude / noise_sigma >= params.min_amplitude_to_noise + let is_last_window = index + 1 == modeling_windows.len(); + if frequency_hz >= window.retention_band_hz.0 + && (frequency_hz < window.retention_band_hz.1 + || (is_last_window && frequency_hz <= window.retention_band_hz.1)) { - fitted.push((region.selection.id, frequency_hz, component)); + // Padded modeling bands may overlap. Retention bands assign a + // model to exactly one window before the sub-tables are joined. + fitted.push((frequency_hz, component)); } } } - fitted.sort_by(|left, right| left.1.total_cmp(&right.1)); - let components: Vec = fitted + fitted.sort_by(|left, right| left.0.total_cmp(&right.0)); + let all_components: Vec = fitted .into_iter() .enumerate() - .map(|(id, (region, frequency_hz, component))| CraftComponent { + .map(|(id, (frequency_hz, component))| CraftComponent { id: CraftComponentId(id as u64), - region, + region: CraftRegionId(0), frequency_hz, chemical_shift_ppm: reference.effective_carrier_ppm() + frequency_hz / data.observe_freq_mhz, @@ -378,18 +419,32 @@ pub fn process_craft_cancellable( phase_rad: component.phase_rad, decay_rate_s_inv: component.decay_rate_s_inv, linewidth_hz: component.linewidth_hz, - amplitude_to_noise: component.amplitude / noise_sigma, + amplitude_to_noise: component + .amplitude_std + .filter(|value| *value > 0.0) + .map_or(0.0, |value| component.amplitude / value), amplitude_std: component.amplitude_std, frequency_std_hz: component.frequency_std_hz, linewidth_std_hz: component.linewidth_std_hz, phase_std_rad: component.phase_std_rad, }) .collect(); - if params.min_amplitude_to_noise < 3.3 { + let selections = if params.regions.is_empty() { + let half_width_ppm = sw / (2.0 * data.observe_freq_mhz); + vec![CraftRegion::new( + CraftRegionId(0), + reference.effective_carrier_ppm() - half_width_ppm, + reference.effective_carrier_ppm() + half_width_ppm, + )] + } else { + params.regions.clone() + }; + let components = components_for_regions(&all_components, &selections); + if params.minimum_amplitude_to_noise < 3.3 { warnings.push(CraftWarning { kind: CraftWarningKind::LowAmplitudeThreshold, region: None, - fit_window: None, + modeling_window: None, message: "Discovery threshold is below the strict 3.3 amplitude/noise threshold; confirm weak components independently." .to_owned(), }); @@ -398,7 +453,7 @@ pub fn process_craft_cancellable( warnings.push(CraftWarning { kind: CraftWarningKind::SsfpQuantitation, region: None, - fit_window: None, + modeling_window: None, message: "SSFP response is relaxation-dependent; use this result for screening or relative comparison, not absolute qNMR." .to_owned(), }); @@ -412,7 +467,7 @@ pub fn process_craft_cancellable( .map(|issue| CraftWarning { kind: CraftWarningKind::InputAssessment, region: issue.region, - fit_window: None, + modeling_window: None, message: issue.message.clone(), }), ); @@ -420,18 +475,18 @@ pub fn process_craft_cancellable( warnings.push(CraftWarning { kind: CraftWarningKind::IllConditionedFit, region: None, - fit_window: None, + modeling_window: None, message: "One or more fits are ill-conditioned; inspect overlapping components and uncertainties.".to_owned(), }); } if components.iter().any(|component| { - (component.linewidth_hz - params.linewidth_hz.0).abs() < 1e-6 - || (component.linewidth_hz - params.linewidth_hz.1).abs() < 1e-6 + (component.linewidth_hz - params.component_linewidth_bounds_hz.0).abs() < 1e-6 + || (component.linewidth_hz - params.component_linewidth_bounds_hz.1).abs() < 1e-6 }) { warnings.push(CraftWarning { kind: CraftWarningKind::LinewidthAtBound, region: None, - fit_window: None, + modeling_window: None, message: "One or more linewidths reached a configured fit bound.".to_owned(), }); } @@ -444,7 +499,7 @@ pub fn process_craft_cancellable( warnings.push(CraftWarning { kind: CraftWarningKind::UnboundedUncertainty, region: None, - fit_window: None, + modeling_window: None, message: "One or more components have unbounded uncertainties.".to_owned(), }); } @@ -463,28 +518,13 @@ pub fn process_craft_cancellable( let residual_rss: f64 = residual_fid[skip..].iter().map(Complex64::norm_sqr).sum(); let input_rss: f64 = input.iter().map(Complex64::norm_sqr).sum(); let normalized_residual = (residual_rss / input_rss.max(f64::MIN_POSITIVE)).sqrt(); - let selections = if params.regions.is_empty() { - vec![regions[0].selection] - } else { - params - .regions - .iter() - .map(|requested| { - regions - .iter() - .find(|window| window.selection.id == requested.id) - .map(|window| window.selection) - .expect("validated CRAFT region has at least one fit window") - }) - .collect() - }; let region_summaries = summarize_regions(&components, &selections); for (position, summary) in region_summaries.iter().enumerate() { if summary.component_count == 0 { warnings.push(CraftWarning { kind: CraftWarningKind::EmptyRegion, region: Some(summary.region), - fit_window: None, + modeling_window: None, message: format!( "Region {} contains no retained signal components.", position + 1 @@ -492,6 +532,22 @@ pub fn process_craft_cancellable( }); } } + let stability = stability_diagnostics( + &all_components, + &selections, + &window_diagnostics, + invocation.modeling_policy, + reference, + data, + ); + if !stability.passed { + warnings.push(CraftWarning { + kind: CraftWarningKind::StabilityFailure, + region: None, + modeling_window: None, + message: "Boundary perturbation exceeded the 1% stability tolerance; retain the full fit for review, but do not use it for quantitative reporting.".to_owned(), + }); + } let status = if invocation.assessment.has_warnings() || warnings.iter().any(CraftWarning::blocks_quantitation) { @@ -510,244 +566,15 @@ pub fn process_craft_cancellable( residual_rss, normalized_residual, maximum_condition_number: max_condition.is_finite().then_some(max_condition), - fit_windows, + modeling_windows: window_diagnostics, warnings, + stability, }, synthetic_fid, residual_fid, }) } -fn fit_region( - input: &[Complex64], - skipped_points: usize, - group_delay_points: f64, - sw: f64, - region: HzRegion, - params: &CraftParams, - cancelled: &impl Fn() -> bool, -) -> Result { - let center_hz = (region.padded.0 + region.padded.1) * 0.5; - let padded_width = region.padded.1 - region.padded.0; - // Only the early signal-bearing record is modeled. Include one filter - // length of guard samples so the centered FIR has a fully observed window - // for every retained point. - let filter_input_len = input - .len() - .min(6_000_usize.saturating_add(params.filter_taps)); - let mixed: Vec = input[..filter_input_len] - .iter() - .enumerate() - .map(|(index, &value)| { - // Bruker points after the digital-filter transient are already - // samples of the FID starting at `ceil(delay) - delay`. Keeping the - // raw point number here would reintroduce the removed delay as an - // amplitude extrapolation and a first-order phase ramp. - let time = (skipped_points as f64 + index as f64 - group_delay_points) / sw; - value * Complex64::from_polar(1.0, -TAU * center_hz * time) - }) - .collect(); - let filtered = low_pass_fir( - &mixed, - sw, - padded_width * 0.5, - params.filter_taps, - cancelled, - )?; - // Retain two samples per padded-bandwidth interval so the FIR transition - // band remains below the downsampled Nyquist limit. - let mut decimation = (sw / (2.0 * padded_width).max(f64::MIN_POSITIVE)) - .floor() - .max(1.0) as usize; - decimation = decimation.max(filtered.len().div_ceil(params.max_downsampled_points)); - let filter_half = effective_filter_taps(params.filter_taps, mixed.len()) / 2; - let valid_end = filtered.len().saturating_sub(filter_half); - let phase_search_end = valid_end.min(filter_half.saturating_add(params.filter_taps)); - let phase_start = filtered[filter_half..phase_search_end] - .iter() - .enumerate() - .max_by(|left, right| left.1.norm_sqr().total_cmp(&right.1.norm_sqr())) - .map(|(index, _)| filter_half + index) - .unwrap_or(filter_half); - let useful_end = phase_start.saturating_add(6_000).min(valid_end); - let samples: Vec = filtered[phase_start..useful_end] - .iter() - .step_by(decimation) - .copied() - .collect(); - let times: Vec = (0..samples.len()) - .map(|index| { - (skipped_points as f64 + phase_start as f64 + (index * decimation) as f64 - - group_delay_points) - / sw - }) - .collect(); - if samples.len() < 16 { - return Ok(RegionResult { - components: Vec::new(), - center_hz, - bic: None, - condition_number: 1.0, - decimation, - retained_samples: samples.len(), - evaluated_model_orders: 0, - warning: Some(( - CraftWarningKind::FitWindowFailure, - "too few samples remained after filtering".to_owned(), - )), - }); - } - let relative_bounds = (region.padded.0 - center_hz, region.padded.1 - center_hz); - let n_observations = (samples.len() * 2) as f64; - let initial_rss = samples.iter().map(Complex64::norm_sqr).sum(); - let mut best_bic = bic(initial_rss, n_observations, 1.0); - let mut best_components = Vec::new(); - let mut best_condition = 1.0; - let mut warning = None; - - let fit_bounds = CraftFitBounds { - frequency_hz: relative_bounds, - linewidth_hz: params.linewidth_hz, - }; - let dwell_s = decimation as f64 / sw; - let merge_hz = sw / input.len() as f64; - let max_order = params - .max_components_per_fit_window - .min(samples.len() / 2 - 1); - let mut evaluated_model_orders = 0; - // Matrix-pencil cost grows cubically with its Hankel dimension. The early - // 256 uniformly sampled points contain the same frequency/decay poles and - // keep full-width, long acquisitions bounded; the final LM still uses all - // retained samples. - let pencil_samples = &samples[..samples.len().min(256)]; - for order in 1..=max_order { - evaluated_model_orders += 1; - let Ok(candidate) = matrix_pencil_estimates(pencil_samples, dwell_s, order, fit_bounds) - else { - continue; - }; - if candidate.components.len() != order { - continue; - } - let fit = match evaluate_damped_sinusoids_cancellable( - &samples, - ×, - &candidate.components, - fit_bounds, - cancelled, - ) { - Ok(fit) => Some(fit), - Err(CraftFitError::Cancelled) => return Err(CraftError::Cancelled), - Err(error) => { - warning = Some((CraftWarningKind::FitWindowFailure, error.to_string())); - None - } - }; - if let Some(fit) = fit { - let candidate_bic = bic( - fit.rss, - n_observations, - (fit.components.len() * 4 + 1) as f64, - ); - let separated = fit - .components - .windows(2) - .all(|pair| (pair[1].frequency_hz - pair[0].frequency_hz).abs() >= merge_hz); - if candidate_bic < best_bic && separated && fit.condition_number <= 1e8 { - best_bic = candidate_bic; - best_condition = fit.condition_number; - best_components = fit.components; - } - } - } - if !best_components.is_empty() - && warning - .as_ref() - .is_some_and(|(kind, _)| *kind == CraftWarningKind::FitWindowFailure) - { - warning = None; - } - if best_components.len() == max_order { - warning = Some(( - CraftWarningKind::ModelOrderLimit, - "model order reached the fit-window limit; inspect the residual before quantitation" - .to_owned(), - )); - } - Ok(RegionResult { - components: best_components, - center_hz, - bic: Some(best_bic), - condition_number: best_condition, - decimation, - retained_samples: samples.len(), - evaluated_model_orders, - warning, - }) -} - -fn low_pass_fir( - input: &[Complex64], - sample_rate_hz: f64, - cutoff_hz: f64, - requested_taps: usize, - cancelled: &impl Fn() -> bool, -) -> Result, CraftError> { - if cutoff_hz * 2.0 >= sample_rate_hz * 0.999 { - return Ok(input.to_vec()); - } - let taps = effective_filter_taps(requested_taps, input.len()); - if taps < 3 { - return Ok(input.to_vec()); - } - let half = taps / 2; - let normalized = cutoff_hz / sample_rate_hz; - let mut kernel = Vec::with_capacity(taps); - for index in 0..taps { - let x = index as isize - half as isize; - let sinc = if x == 0 { - 2.0 * normalized - } else { - (TAU * normalized * x as f64).sin() / (PI * x as f64) - }; - let window = 0.42 - 0.5 * (TAU * index as f64 / (taps - 1) as f64).cos() - + 0.08 * (2.0 * TAU * index as f64 / (taps - 1) as f64).cos(); - kernel.push(sinc * window); - } - let sum: f64 = kernel.iter().sum(); - for coefficient in &mut kernel { - *coefficient /= sum; - } - let mut output = vec![Complex64::new(0.0, 0.0); input.len()]; - for (center, filtered) in output - .iter_mut() - .enumerate() - .take(input.len().saturating_sub(half)) - .skip(half) - { - if center % 64 == 0 && cancelled() { - return Err(CraftError::Cancelled); - } - let start = center - half; - *filtered = input[start..start + taps] - .iter() - .zip(&kernel) - .fold(Complex64::new(0.0, 0.0), |sum, (&sample, &coefficient)| { - sum + sample * coefficient - }); - } - Ok(output) -} - -fn effective_filter_taps(requested_taps: usize, input_len: usize) -> usize { - let taps = requested_taps.min(input_len.saturating_sub(1)); - if taps.is_multiple_of(2) { - taps.saturating_sub(1) - } else { - taps - } -} - fn estimate_complex_noise(values: &[Complex64]) -> f64 { let start = values.len().saturating_sub((values.len() / 4).max(64)); let tail = &values[start..]; @@ -777,10 +604,6 @@ fn median(values: &mut [f64]) -> f64 { } } -fn bic(rss: f64, observations: f64, parameters: f64) -> f64 { - observations * (rss.max(f64::MIN_POSITIVE) / observations).ln() + parameters * observations.ln() -} - #[cfg(test)] #[path = "craft_tests.rs"] mod tests; diff --git a/crates/processing/src/craft/diagnostics.rs b/crates/processing/src/craft/diagnostics.rs index ab652e0..4ccc5b5 100644 --- a/crates/processing/src/craft/diagnostics.rs +++ b/crates/processing/src/craft/diagnostics.rs @@ -17,21 +17,62 @@ pub struct CraftDiagnostics { pub normalized_residual: f64, /// `None` means at least one fitted design was rank deficient or unbounded. pub maximum_condition_number: Option, - /// One entry per internal fit window. A user region can require several - /// windows, but those windows never become user-visible region identities. - pub fit_windows: Vec, + /// One entry per protocol-owned modeling window. These are independent of + /// user-visible signal-region identities. + pub modeling_windows: Vec, pub warnings: Vec, + pub stability: CraftStabilityDiagnostics, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct CraftStabilityDiagnostics { + pub delta_ppm: f64, + pub regions: Vec, + pub ratio: Option, + pub passed: bool, + pub skipped: Vec, +} + +impl Default for CraftStabilityDiagnostics { + fn default() -> Self { + Self { + delta_ppm: 0.0, + regions: Vec::new(), + ratio: None, + passed: false, + skipped: Vec::new(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct CraftStabilityRegion { + pub region: CraftRegionId, + pub metric: CraftStabilityMetric, + pub component_count_min: usize, + pub component_count_max: usize, + pub model_order_min: usize, + pub model_order_max: usize, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct CraftStabilityMetric { + pub median: f64, + pub minimum: f64, + pub maximum: f64, + pub relative_dispersion: f64, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum CraftWarningKind { - FitWindowFailure, + ModelingWindowFailure, ModelOrderLimit, EmptyRegion, LinewidthAtBound, UnboundedUncertainty, IllConditionedFit, + StabilityFailure, LowAmplitudeThreshold, SsfpQuantitation, InputAssessment, @@ -41,7 +82,7 @@ pub enum CraftWarningKind { pub struct CraftWarning { pub kind: CraftWarningKind, pub region: Option, - pub fit_window: Option, + pub modeling_window: Option, pub message: String, } @@ -49,27 +90,30 @@ impl CraftWarning { pub fn blocks_quantitation(&self) -> bool { matches!( self.kind, - CraftWarningKind::FitWindowFailure + CraftWarningKind::ModelingWindowFailure | CraftWarningKind::ModelOrderLimit | CraftWarningKind::EmptyRegion | CraftWarningKind::LinewidthAtBound | CraftWarningKind::UnboundedUncertainty | CraftWarningKind::IllConditionedFit + | CraftWarningKind::StabilityFailure ) } } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct CraftFitWindowDiagnostic { - pub region: CraftRegionId, - pub core_hz: (f64, f64), - pub padded_hz: (f64, f64), - pub actual_decimation: usize, - pub retained_samples: usize, +pub struct CraftModelingWindowDiagnostic { + pub retention_band_hz: (f64, f64), + pub modeling_band_hz: (f64, f64), + pub decimation_factor: usize, + pub modeled_sample_count: usize, pub evaluated_model_orders: usize, pub selected_model_order: usize, - pub bic: Option, + pub training_bic: Option, pub condition_number: Option, + pub modeled_duration_s: f64, + pub training_normalized_residual: f64, + pub validation_normalized_residual: f64, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/processing/src/craft/fitting.rs b/crates/processing/src/craft/fitting.rs new file mode 100644 index 0000000..ae2669a --- /dev/null +++ b/crates/processing/src/craft/fitting.rs @@ -0,0 +1,496 @@ +use num_complex::Complex64; +use plotx_analysis::craft::{ + CraftFitBounds, CraftFitError, CraftFitOptions, DampedSinusoid, backward_linear_predict, + fit_damped_sinusoids_initialized_cancellable, matrix_pencil_estimates, +}; +use std::f64::consts::{PI, TAU}; + +use super::regions::ModelingWindow; +use super::{CraftError, CraftModelingPolicy, CraftParams, CraftProfile, CraftWarningKind}; + +pub(super) struct ModelingWindowResult { + pub(super) components: Vec, + pub(super) center_hz: f64, + pub(super) training_bic: Option, + pub(super) condition_number: f64, + pub(super) decimation: usize, + pub(super) modeled_sample_count: usize, + pub(super) evaluated_model_orders: usize, + pub(super) modeled_duration_s: f64, + pub(super) training_normalized_residual: f64, + pub(super) validation_normalized_residual: f64, + pub(super) warning: Option<(CraftWarningKind, String)>, +} + +pub(super) struct CraftModelingContext<'a> { + pub(super) input: &'a [Complex64], + pub(super) skipped_points: usize, + pub(super) group_delay_points: f64, + pub(super) spectral_width_hz: f64, + pub(super) params: &'a CraftParams, + pub(super) policy: CraftModelingPolicy, +} + +struct ValidatedCandidate { + order: usize, + components: Vec, + training_bic: f64, + condition_number: f64, + training_normalized_residual: f64, + validation_normalized_residual: f64, +} + +pub(super) fn fit_modeling_window( + context: &CraftModelingContext<'_>, + window: ModelingWindow, + cancelled: &impl Fn() -> bool, +) -> Result { + let CraftModelingContext { + input, + skipped_points, + group_delay_points, + spectral_width_hz: sw, + params, + policy, + } = context; + let center_hz = (window.modeling_band_hz.0 + window.modeling_band_hz.1) * 0.5; + let modeled_bandwidth_hz = window.modeling_band_hz.1 - window.modeling_band_hz.0; + // Include one filter length of guard samples so the modeled interval has + // complete centered-FIR support at both ends. + let modeled_points = (policy.modeling_duration_s * *sw).ceil().max(1.0) as usize; + let filter_input_len = input + .len() + .min(modeled_points.saturating_add(params.fir_filter_taps)); + let mixed: Vec = input[..filter_input_len] + .iter() + .enumerate() + .map(|(index, &value)| { + // Raw point numbers preserve the fractional group-delay time origin + // after the digital-filter transient has been skipped. + let time = (*skipped_points as f64 + index as f64 - *group_delay_points) / *sw; + value * Complex64::from_polar(1.0, -TAU * center_hz * time) + }) + .collect(); + let filtered = low_pass_fir( + &mixed, + *sw, + modeled_bandwidth_hz * 0.5, + params.fir_filter_taps, + cancelled, + )?; + let mut decimation = (*sw / (2.0 * modeled_bandwidth_hz).max(f64::MIN_POSITIVE)) + .floor() + .max(1.0) as usize; + decimation = decimation.max(filtered.len().div_ceil(params.maximum_modeled_sample_count)); + let filter_half = effective_filter_taps(params.fir_filter_taps, mixed.len()) / 2; + let valid_end = filtered.len().saturating_sub(filter_half); + // Match the established CRAFT digital-filter workflow: retain the early + // record through a phase-preserving FIR precharge, then replace the five + // boundary-dependent downsampled points by backward linear prediction. + let modeling_start = 0_usize; + let useful_end = modeling_start.saturating_add(modeled_points).min(valid_end); + let mut samples: Vec = filtered[modeling_start..useful_end] + .iter() + .step_by(decimation) + .copied() + .collect(); + let predicted_count = samples.len().min(5); + let training_count = samples.len().saturating_sub(predicted_count).min(256); + let configured_order = if samples.len() > 261 { 32 } else { 16 }; + let prediction_order = configured_order.min(training_count.saturating_sub(1) / 2); + if params.profile == CraftProfile::Conventional && predicted_count > 0 && prediction_order > 0 { + match backward_linear_predict( + &mut samples, + predicted_count, + training_count, + prediction_order, + ) { + Ok(()) => {} + // A rankless no-signal record has nothing to predict. Keep the + // phase-preserving FIR precharge so exploratory runs can report + // the empty window instead of failing the complete invocation. + Err(CraftFitError::Singular) => {} + Err(CraftFitError::Cancelled) => return Err(CraftError::Cancelled), + Err(error) => return Err(CraftError::Fit(error)), + } + } + let times: Vec = (0..samples.len()) + .map(|index| { + (*skipped_points as f64 + modeling_start as f64 + (index * decimation) as f64 + - *group_delay_points) + / *sw + }) + .collect(); + if samples.len() < 16 { + return Ok(ModelingWindowResult { + components: Vec::new(), + center_hz, + training_bic: None, + condition_number: 1.0, + decimation, + modeled_sample_count: samples.len(), + evaluated_model_orders: 0, + modeled_duration_s: samples.len() as f64 * decimation as f64 / *sw, + training_normalized_residual: 1.0, + validation_normalized_residual: 1.0, + warning: Some(( + CraftWarningKind::ModelingWindowFailure, + "too few samples remained after filtering".to_owned(), + )), + }); + } + + let relative_frequency_bounds = ( + window.modeling_band_hz.0 - center_hz, + window.modeling_band_hz.1 - center_hz, + ); + let validation_count = if samples.len() >= 32 { + ((samples.len() as f64 * policy.validation_tail_fraction).round() as usize).max(8) + } else { + 0 + }; + let validation_start = samples.len() - validation_count; + let training_samples = samples.as_slice(); + let training_times = times.as_slice(); + let validation_samples = &samples[validation_start..]; + let validation_times = ×[validation_start..]; + let training_energy = training_samples + .iter() + .map(Complex64::norm_sqr) + .sum::() + .max(f64::MIN_POSITIVE); + let validation_energy = validation_samples + .iter() + .map(Complex64::norm_sqr) + .sum::() + .max(f64::MIN_POSITIVE); + let observation_count = (training_samples.len() * 2) as f64; + let mut candidates = Vec::new(); + let mut warning = None; + + let fit_bounds = CraftFitBounds { + frequency_hz: relative_frequency_bounds, + linewidth_hz: policy.component_linewidth_bounds_hz, + }; + let dwell_s = decimation as f64 / *sw; + let minimum_separation_hz = *sw / input.len() as f64; + let maximum_order = params + .maximum_model_order + .min(training_samples.len() / 2 - 1); + let mut evaluated_model_orders = 0; + // Candidate generation is deliberately bounded because the Hankel SVD is + // cubic. Final amplitudes, evidence, covariance, and residuals use the + // complete sub-FID, matching the established CRAFT workflow. + let pencil_samples = &training_samples[..training_samples.len().min(256)]; + for order in 1..=maximum_order { + evaluated_model_orders += 1; + let Ok(candidate) = matrix_pencil_estimates(pencil_samples, dwell_s, order, fit_bounds) + else { + continue; + }; + if candidate.components.len() != order { + continue; + } + let fit = match fit_damped_sinusoids_initialized_cancellable( + training_samples, + training_times, + &candidate.components, + fit_bounds, + CraftFitOptions::default(), + cancelled, + ) { + Ok(fit) => Some(fit), + Err(CraftFitError::Cancelled) => return Err(CraftError::Cancelled), + Err(error) => { + warning = Some((CraftWarningKind::ModelingWindowFailure, error.to_string())); + None + } + }; + if let Some(mut fit) = fit { + fit.components + .sort_by(|left, right| left.frequency_hz.total_cmp(&right.frequency_hz)); + let candidate_bic = bic( + fit.rss, + observation_count, + (fit.components.len() * 4 + 1) as f64, + ); + let sufficiently_separated = fit.components.windows(2).all(|pair| { + (pair[1].frequency_hz - pair[0].frequency_hz).abs() >= minimum_separation_hz + }); + let linewidths_are_interior = fit.components.iter().all(|component| { + component.linewidth_hz > fit_bounds.linewidth_hz.0 + 1e-6 + && component.linewidth_hz < fit_bounds.linewidth_hz.1 - 1e-6 + }); + let uncertainties_are_bounded = fit.components.iter().all(|component| { + component.amplitude_std.is_some() + && component.frequency_std_hz.is_some() + && component.linewidth_std_hz.is_some() + && component.phase_std_rad.is_some() + }); + let model_amplitude_to_noise = coherent_amplitude(&fit.components) + / fit + .components + .iter() + .filter_map(|component| component.amplitude_std) + .map(|value| value * value) + .sum::() + .sqrt() + .max(f64::MIN_POSITIVE); + if sufficiently_separated + && linewidths_are_interior + && uncertainties_are_bounded + && model_amplitude_to_noise >= params.minimum_amplitude_to_noise + && fit.condition_number <= 1e8 + { + let validation_rss = if validation_samples.is_empty() { + fit.rss + } else { + validation_samples + .iter() + .zip(validation_times) + .map(|(&sample, &time)| { + (sample - damped_model_at(&fit.components, time)).norm_sqr() + }) + .sum() + }; + candidates.push(ValidatedCandidate { + order, + components: fit.components, + training_bic: candidate_bic, + condition_number: fit.condition_number, + training_normalized_residual: (fit.rss / training_energy).sqrt(), + validation_normalized_residual: if validation_samples.is_empty() { + (fit.rss / training_energy).sqrt() + } else { + (validation_rss / validation_energy).sqrt() + }, + }); + } + } + } + + let minimum_training_bic = candidates + .iter() + .map(|candidate| candidate.training_bic) + .min_by(f64::total_cmp) + .unwrap_or_else(|| bic(training_energy, observation_count, 1.0)); + // Bretthorst CRAFT selects model order from the evidence in the modeled + // record. BIC is the deterministic evidence approximation used here; a + // two-unit band retains the simplest statistically comparable model. + let comparable_bic_limit = minimum_training_bic + 2.0; + candidates.sort_by_key(|candidate| candidate.order); + let selected = candidates + .into_iter() + .find(|candidate| candidate.training_bic <= comparable_bic_limit); + let (mut components, training_bic, condition_number, training_residual, validation_residual) = + selected.map_or_else( + || { + ( + Vec::new(), + bic(training_energy, observation_count, 1.0), + 1.0, + 1.0, + 1.0, + ) + }, + |candidate| { + ( + candidate.components, + candidate.training_bic, + candidate.condition_number, + candidate.training_normalized_residual, + candidate.validation_normalized_residual, + ) + }, + ); + // Very small poles at a window edge are commonly transition-band leakage + // or a split of the dominant line, not an independently quantifiable + // resonance. Apply the threshold to the selected multiplet so weak lines + // are retained relative to their local partner rather than compared with + // a global raw-FID noise estimate. + if let Some(maximum_amplitude) = components + .iter() + .map(|component| component.amplitude) + .max_by(f64::total_cmp) + { + let minimum_amplitude = maximum_amplitude * 0.05; + components.retain(|component| component.amplitude >= minimum_amplitude); + } + if !components.is_empty() + && warning + .as_ref() + .is_some_and(|(kind, _)| *kind == CraftWarningKind::ModelingWindowFailure) + { + warning = None; + } + let actual_taps = effective_filter_taps(params.fir_filter_taps, mixed.len()); + for component in &mut components { + let gain = fir_response( + *sw, + modeled_bandwidth_hz * 0.5, + actual_taps, + component.frequency_hz, + component.decay_rate_s_inv, + ); + let gain_norm = gain.norm(); + if gain_norm > 0.1 { + component.amplitude /= gain_norm; + component.amplitude_std = component.amplitude_std.map(|value| value / gain_norm); + component.phase_rad -= gain.arg(); + } + } + if components.len() == maximum_order { + warning = Some(( + CraftWarningKind::ModelOrderLimit, + "model order reached the modeling-window limit; inspect the residual before quantitation" + .to_owned(), + )); + } + if !components.is_empty() && training_residual > 0.25 { + warning = Some(( + CraftWarningKind::ModelingWindowFailure, + format!( + "modeled-record residual {training_residual:.3} exceeded the quantitative limit" + ), + )); + } + Ok(ModelingWindowResult { + components, + center_hz, + training_bic: Some(training_bic), + condition_number, + decimation, + modeled_sample_count: samples.len(), + evaluated_model_orders, + modeled_duration_s: samples.len() as f64 * decimation as f64 / *sw, + training_normalized_residual: training_residual, + validation_normalized_residual: validation_residual, + warning, + }) +} + +fn damped_model_at(components: &[DampedSinusoid], time_s: f64) -> Complex64 { + components + .iter() + .fold(Complex64::new(0.0, 0.0), |sum, component| { + sum + Complex64::from_polar( + component.amplitude * (-component.decay_rate_s_inv * time_s).exp(), + component.phase_rad + TAU * component.frequency_hz * time_s, + ) + }) +} + +fn coherent_amplitude(components: &[DampedSinusoid]) -> f64 { + components + .iter() + .fold(Complex64::new(0.0, 0.0), |sum, component| { + sum + Complex64::from_polar(component.amplitude, component.phase_rad) + }) + .norm() +} + +fn low_pass_fir( + input: &[Complex64], + sample_rate_hz: f64, + cutoff_hz: f64, + requested_taps: usize, + cancelled: &impl Fn() -> bool, +) -> Result, CraftError> { + if cutoff_hz * 2.0 >= sample_rate_hz * 0.999 { + return Ok(input.to_vec()); + } + let taps = effective_filter_taps(requested_taps, input.len()); + if taps < 3 { + return Ok(input.to_vec()); + } + let half = taps / 2; + let kernel = fir_kernel(sample_rate_hz, cutoff_hz, taps); + let mut output = vec![Complex64::new(0.0, 0.0); input.len()]; + let first = input[0]; + let reflection = if first.norm_sqr() > f64::MIN_POSITIVE { + first / first.conj() + } else { + Complex64::new(1.0, 0.0) + }; + for (center, filtered) in output + .iter_mut() + .enumerate() + .take(input.len().saturating_sub(half)) + { + if center % 64 == 0 && cancelled() { + return Err(CraftError::Cancelled); + } + *filtered = kernel.iter().enumerate().fold( + Complex64::new(0.0, 0.0), + |sum, (index, &coefficient)| { + let source = center as isize + index as isize - half as isize; + let sample = if source >= 0 { + input[source as usize] + } else { + reflection * input[source.unsigned_abs()].conj() + }; + sum + sample * coefficient + }, + ); + } + Ok(output) +} + +fn fir_response( + sample_rate_hz: f64, + cutoff_hz: f64, + taps: usize, + frequency_hz: f64, + decay_rate_s_inv: f64, +) -> Complex64 { + if cutoff_hz * 2.0 >= sample_rate_hz * 0.999 || taps < 3 { + return Complex64::new(1.0, 0.0); + } + let half = taps / 2; + fir_kernel(sample_rate_hz, cutoff_hz, taps) + .iter() + .enumerate() + .fold(Complex64::new(0.0, 0.0), |sum, (index, coefficient)| { + let offset_s = (index as f64 - half as f64) / sample_rate_hz; + sum + Complex64::from_polar( + coefficient * (-decay_rate_s_inv * offset_s).exp(), + TAU * frequency_hz * offset_s, + ) + }) +} + +fn fir_kernel(sample_rate_hz: f64, cutoff_hz: f64, taps: usize) -> Vec { + let half = taps / 2; + let normalized = cutoff_hz / sample_rate_hz; + let mut kernel = (0..taps) + .map(|index| { + let x = index as isize - half as isize; + let sinc = if x == 0 { + 2.0 * normalized + } else { + (TAU * normalized * x as f64).sin() / (PI * x as f64) + }; + let window = 0.42 - 0.5 * (TAU * index as f64 / (taps - 1) as f64).cos() + + 0.08 * (2.0 * TAU * index as f64 / (taps - 1) as f64).cos(); + sinc * window + }) + .collect::>(); + let sum = kernel.iter().sum::(); + for coefficient in &mut kernel { + *coefficient /= sum; + } + kernel +} + +fn effective_filter_taps(requested_taps: usize, input_len: usize) -> usize { + let taps = requested_taps.min(input_len.saturating_sub(1)); + if taps.is_multiple_of(2) { + taps.saturating_sub(1) + } else { + taps + } +} + +fn bic(rss: f64, observations: f64, parameters: f64) -> f64 { + observations * (rss.max(f64::MIN_POSITIVE) / observations).ln() + parameters * observations.ln() +} diff --git a/crates/processing/src/craft/preflight.rs b/crates/processing/src/craft/preflight.rs index f2e034f..a2909ef 100644 --- a/crates/processing/src/craft/preflight.rs +++ b/crates/processing/src/craft/preflight.rs @@ -2,6 +2,7 @@ use plotx_analysis::peaks::{DetectParams, detect_peaks, estimate_noise}; use plotx_io::{Domain, NmrData}; use rustfft::FftPlanner; use serde::{Deserialize, Serialize}; +use std::f64::consts::PI; use super::{CraftDerivedPlan, CraftParams, CraftReference, CraftRegionId}; @@ -36,7 +37,7 @@ pub enum CraftIssueAction { CheckImport, CheckAcquisitionMetadata, CorrectReference, - ResetFitSettings, + ResetModelingSettings, AdjustRegions, ReduceSkippedPoints, ReviewAcquisition, @@ -65,7 +66,7 @@ pub struct CraftInputAssessment { pub point_count: usize, pub effective_point_count: usize, pub acquisition_duration_s: Option, - pub fit_window_count: usize, + pub modeling_window_count: usize, pub clear_signals: Vec, pub issues: Vec, } @@ -136,8 +137,8 @@ impl CraftInputAssessment { if params.validate().is_err() { error( CraftIssueCode::InvalidParameters, - "One or more explicit fit settings are invalid.", - CraftIssueAction::ResetFitSettings, + "One or more explicit component or acquisition settings are invalid.", + CraftIssueAction::ResetModelingSettings, ); } if regions_outside_bandwidth_or_overlap(data, reference, params) { @@ -155,14 +156,14 @@ impl CraftInputAssessment { ); } if plan - .fit_windows + .modeling_windows .iter() - .any(|window| window.planned_retained_samples < 16) + .any(|window| window.planned_modeled_sample_count < 16) { error( CraftIssueCode::TooFewEffectivePoints, - "Fewer than 16 samples remain in one or more fit windows after FIR filtering.", - CraftIssueAction::ResetFitSettings, + "Fewer than 16 samples remain in one or more modeling windows after FIR filtering.", + CraftIssueAction::ResetModelingSettings, ); } } @@ -193,9 +194,10 @@ impl CraftInputAssessment { if count == 0 && !clear_signals.is_empty() { issues.push(warning(CraftIssueCode::RegionWithoutClearSignal, Some(region.id), "A selected region contains no clear signal; adjust the region or confirm it with independent evidence.", CraftIssueAction::AdjustRegions)); } - if count >= params.max_components_per_fit_window { - issues.push(warning(CraftIssueCode::DenseSignalWindow, Some(region.id), "Detected peak density reaches the model-order limit; consider narrowing the region or increasing the limit.", CraftIssueAction::IncreaseModelLimit)); - } + // Peak-picking is only a preflight hint. The FFT can contain many + // transition-band extrema for one physical multiplet, so raw peak + // count must not be used as a model-capacity warning. Capacity is + // assessed after the bounded time-domain fit has selected a model. } Self { point_count: data.points.len(), @@ -203,7 +205,7 @@ impl CraftInputAssessment { acquisition_duration_s: (data.spectral_width_hz.is_finite() && data.spectral_width_hz > 0.0) .then(|| data.points.len() as f64 / data.spectral_width_hz), - fit_window_count: plan.fit_windows.len(), + modeling_window_count: plan.modeling_windows.len(), clear_signals, issues, } @@ -278,7 +280,7 @@ fn regions_outside_bandwidth_or_overlap( .any(|pair| pair[0].end_ppm > pair[1].start_ppm) } -fn detect_clear_signals( +pub(super) fn detect_clear_signals( data: &NmrData, reference: CraftReference, skip: usize, @@ -289,7 +291,12 @@ fn detect_clear_signals( } let fft_len = input.len().next_power_of_two(); let mut spectrum = vec![num_complex::Complex64::new(0.0, 0.0); fft_len]; - spectrum[..input.len()].copy_from_slice(input); + let duration_s = input.len() as f64 / data.spectral_width_hz; + let matched_line_broadening_hz = 1.0 / duration_s.max(f64::MIN_POSITIVE); + for (index, (&sample, output)) in input.iter().zip(&mut spectrum).enumerate() { + let time_s = index as f64 / data.spectral_width_hz; + *output = sample * (-PI * matched_line_broadening_hz * time_s).exp(); + } FftPlanner::::new() .plan_fft_forward(fft_len) .process(&mut spectrum); @@ -308,7 +315,14 @@ fn detect_clear_signals( &DetectParams { min_height: Some(6.0 * sigma), min_prominence: 5.0 * sigma, - min_spacing: None, + // Merge FFT extrema closer than one acquired spectral + // resolution element (1/acquisition time). Matched exponential + // apodization broadens a line and otherwise creates several + // equally significant extrema for a single resonance. + // `xs` is expressed in zero-padded FFT-bin indices. One acquired + // spectral-resolution element spans `fft_len / input.len()` of + // those bins when the FFT is zero-padded. + min_spacing: Some(fft_len as f64 / input.len() as f64), max_count: Some(64), }, ); diff --git a/crates/processing/src/craft/regions.rs b/crates/processing/src/craft/regions.rs index ed8affd..c5d3001 100644 --- a/crates/processing/src/craft/regions.rs +++ b/crates/processing/src/craft/regions.rs @@ -2,14 +2,13 @@ use plotx_io::NmrData; use super::{ CraftComponent, CraftError, CraftParams, CraftReference, CraftRegion, CraftRegionId, - CraftRegionRatio, CraftRegionSummary, + CraftRegionRatio, CraftRegionSummary, CraftSignalSuggestion, }; -#[derive(Clone, Copy)] -pub(super) struct HzRegion { - pub(super) selection: CraftRegion, - pub(super) core: (f64, f64), - pub(super) padded: (f64, f64), +#[derive(Clone, Copy, Debug)] +pub(super) struct ModelingWindow { + pub(super) retention_band_hz: (f64, f64), + pub(super) modeling_band_hz: (f64, f64), } pub(super) fn selections_are_valid(regions: &[CraftRegion]) -> bool { @@ -37,11 +36,12 @@ pub(super) fn selections_are_valid(regions: &[CraftRegion]) -> bool { .all(|pair| pair[0].end_ppm <= pair[1].start_ppm) } -pub(super) fn build_regions( +pub(super) fn build_modeling_windows( data: &NmrData, params: &CraftParams, reference: CraftReference, -) -> Result, CraftError> { + clear_signals: &[CraftSignalSuggestion], +) -> Result, CraftError> { let half_sw = data.spectral_width_hz * 0.5; let effective_carrier_ppm = reference.effective_carrier_ppm(); let requested: Vec<(CraftRegion, f64, f64)> = if params.regions.is_empty() { @@ -88,31 +88,101 @@ pub(super) fn build_regions( return Err(CraftError::InvalidParameters); } - let mut regions = Vec::new(); - for (selection, start, end) in requested_cores { - let pieces = ((end - start) / params.max_fit_window_width_hz) - .ceil() - .max(1.0) as usize; - let width = (end - start) / pieces as f64; - for index in 0..pieces { - let core = ( - start + index as f64 * width, - start + (index + 1) as f64 * width, - ); - let padding = (core.1 - core.0) * params.padding_fraction * 0.5; - regions.push(HzRegion { - selection, - core, - padded: ( - (core.0 - padding).max(-half_sw), - (core.1 + padding).min(half_sw), - ), + // Modeling windows are a profile-owned protocol. They tile the acquired + // bandwidth with a fixed physical width and therefore do not change when a + // user nudges a reporting region boundary. + let width = params + .profile + .modeling_bandwidth_hz() + .min(data.spectral_width_hz); + let signal_hz = clear_signals + .iter() + .filter_map(|signal| { + let frequency = + (signal.chemical_shift_ppm - effective_carrier_ppm) * data.observe_freq_mhz; + let weight = signal.prominence_sigma.max(f64::MIN_POSITIVE); + (frequency.is_finite() + && weight.is_finite() + && frequency >= -half_sw + && frequency <= half_sw) + .then_some((frequency, weight)) + }) + .collect::>(); + let mut centers = signal_cluster_centers(&signal_hz, width); + if centers.is_empty() { + let pieces = (data.spectral_width_hz / width).ceil().max(1.0) as usize; + centers.extend((0..pieces).map(|index| { + let start = -half_sw + index as f64 * width; + (start + (start + width).min(half_sw)) * 0.5 + })); + } + let mut regions = Vec::with_capacity(centers.len()); + for (index, center) in centers.iter().copied().enumerate() { + let nominal_start = (center - width * 0.5).max(-half_sw); + let nominal_end = (center + width * 0.5).min(half_sw); + let retention_start = centers + .get(index.wrapping_sub(1)) + .map_or(nominal_start, |previous| { + nominal_start.max((previous + center) * 0.5) }); - } + let retention_end = centers + .get(index + 1) + .map_or(nominal_end, |next| nominal_end.min((center + next) * 0.5)); + regions.push(ModelingWindow { + retention_band_hz: (retention_start, retention_end), + modeling_band_hz: (nominal_start, nominal_end), + }); } Ok(regions) } +fn signal_cluster_centers(signals: &[(f64, f64)], window_width_hz: f64) -> Vec { + let mut ranked = signals.to_vec(); + ranked.sort_by(|left, right| { + right + .1 + .total_cmp(&left.1) + .then_with(|| left.0.total_cmp(&right.0)) + }); + let minimum_center_spacing = window_width_hz * 0.5; + let mut seeds = Vec::new(); + for &(frequency, _) in &ranked { + if seeds + .iter() + .all(|seed: &f64| (frequency - *seed).abs() > minimum_center_spacing) + { + seeds.push(frequency); + } + } + + let mut clusters = vec![Vec::new(); seeds.len()]; + for signal in ranked { + if let Some((index, _)) = seeds.iter().enumerate().min_by(|(_, left), (_, right)| { + (signal.0 - **left) + .abs() + .total_cmp(&(signal.0 - **right).abs()) + }) { + clusters[index].push(signal); + } + } + let mut centers = clusters + .into_iter() + .filter_map(weighted_median_frequency) + .collect::>(); + centers.sort_by(f64::total_cmp); + centers +} + +fn weighted_median_frequency(mut signals: Vec<(f64, f64)>) -> Option { + signals.sort_by(|left, right| left.0.total_cmp(&right.0)); + let half_weight = signals.iter().map(|signal| signal.1).sum::() * 0.5; + let mut accumulated = 0.0; + signals.into_iter().find_map(|(frequency, weight)| { + accumulated += weight; + (accumulated >= half_weight).then_some(frequency) + }) +} + pub(super) fn summarize_regions( components: &[CraftComponent], selections: &[CraftRegion], diff --git a/crates/processing/src/craft/report.rs b/crates/processing/src/craft/report.rs new file mode 100644 index 0000000..33ad7e9 --- /dev/null +++ b/crates/processing/src/craft/report.rs @@ -0,0 +1,222 @@ +use super::{CraftComponent, CraftComponentId, CraftRegionId}; +use num_complex::Complex64; +use serde::{Deserialize, Serialize}; + +/// User-controlled definition of a derived CRAFT amplitude report. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct CraftReportDefinition { + pub threshold_an: f64, + pub segment_width_hz: f64, + /// Empty selects every region in the source run. + #[serde(default)] + pub regions: Vec, +} + +impl Default for CraftReportDefinition { + fn default() -> Self { + Self { + threshold_an: 3.3, + segment_width_hz: 1.0, + regions: Vec::new(), + } + } +} + +impl CraftReportDefinition { + pub fn validate(&self) -> Result<(), CraftReportError> { + if !self.threshold_an.is_finite() || self.threshold_an <= 0.0 { + return Err(CraftReportError::InvalidThreshold); + } + if !self.segment_width_hz.is_finite() || self.segment_width_hz <= 0.0 { + return Err(CraftReportError::InvalidWidth); + } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct CraftReportSegment { + pub center_hz: f64, + pub start_hz: f64, + pub end_hz: f64, + pub component_ids: Vec, + pub component_count: usize, + pub scalar_amplitude_sum_t0: f64, + pub coherent_amplitude_t0: f64, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct CraftAmplitudeReport { + pub schema_version: u32, + pub definition: CraftReportDefinition, + pub segments: Vec, +} + +impl CraftAmplitudeReport { + pub fn validate_against(&self, components: &[CraftComponent]) -> Result<(), CraftReportError> { + self.definition.validate()?; + let mut seen = std::collections::HashSet::new(); + for segment in &self.segments { + for id in &segment.component_ids { + if !seen.insert(*id) || !components.iter().any(|component| component.id == *id) { + return Err(CraftReportError::UnknownComponent); + } + } + } + Ok(()) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] +pub enum CraftReportError { + #[error("CRAFT report threshold must be finite and positive")] + InvalidThreshold, + #[error("CRAFT report segment width must be finite and positive")] + InvalidWidth, + #[error("CRAFT report references an unknown component")] + UnknownComponent, +} + +/// Build a report from the complete retained component list. Components are +/// sorted by frequency, and overlapping windows are merged without duplicate +/// membership, making the result independent of input ordering. +pub fn calculate_craft_report( + components: &[CraftComponent], + definition: CraftReportDefinition, +) -> Result { + definition.validate()?; + let mut selected: Vec<&CraftComponent> = components + .iter() + .filter(|component| { + (definition.regions.is_empty() || definition.regions.contains(&component.region)) + && component.amplitude_to_noise >= definition.threshold_an + }) + .collect(); + selected.sort_by(|a, b| { + a.frequency_hz + .total_cmp(&b.frequency_hz) + .then(a.id.0.cmp(&b.id.0)) + }); + let half = definition.segment_width_hz * 0.5; + let mut segments = Vec::new(); + for component in selected { + let start = component.frequency_hz - half; + let end = component.frequency_hz + half; + let append = segments + .last() + .is_none_or(|segment: &CraftReportSegment| start > segment.end_hz); + if append { + segments.push(CraftReportSegment { + center_hz: component.frequency_hz, + start_hz: start, + end_hz: end, + component_ids: vec![component.id], + component_count: 1, + scalar_amplitude_sum_t0: component.amplitude_t0, + coherent_amplitude_t0: Complex64::from_polar( + component.amplitude_t0, + component.phase_rad, + ) + .norm(), + }); + } else { + let segment = segments.last_mut().expect("segment exists"); + segment.end_hz = segment.end_hz.max(end); + segment.center_hz = (segment.start_hz + segment.end_hz) * 0.5; + if !segment.component_ids.contains(&component.id) { + segment.component_ids.push(component.id); + segment.component_count += 1; + segment.scalar_amplitude_sum_t0 += component.amplitude_t0; + let phase_sum = segment + .component_ids + .iter() + .filter_map(|id| components.iter().find(|candidate| candidate.id == *id)) + .fold(Complex64::new(0.0, 0.0), |sum, item| { + sum + Complex64::from_polar(item.amplitude_t0, item.phase_rad) + }); + segment.coherent_amplitude_t0 = phase_sum.norm(); + } + } + } + Ok(CraftAmplitudeReport { + schema_version: 1, + definition, + segments, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + fn component( + id: u64, + frequency_hz: f64, + amplitude_to_noise: f64, + phase_rad: f64, + ) -> CraftComponent { + CraftComponent { + id: CraftComponentId(id), + region: CraftRegionId(1), + frequency_hz, + chemical_shift_ppm: 0.0, + amplitude_t0: 1.0, + phase_rad, + decay_rate_s_inv: 1.0, + linewidth_hz: 1.0, + amplitude_to_noise, + amplitude_std: None, + frequency_std_hz: None, + linewidth_std_hz: None, + phase_std_rad: None, + } + } + #[test] + fn filters_at_threshold_and_merges_without_duplicates() { + let components = vec![ + component(2, 1.4, 3.3, std::f64::consts::PI), + component(1, 1.0, 3.2, 0.0), + component(3, 1.8, 4.0, 0.0), + ]; + let report = calculate_craft_report( + &components, + CraftReportDefinition { + threshold_an: 3.3, + segment_width_hz: 1.0, + regions: vec![], + }, + ) + .unwrap(); + assert_eq!(report.segments.len(), 1); + assert_eq!( + report.segments[0].component_ids, + vec![CraftComponentId(2), CraftComponentId(3)] + ); + assert_eq!(report.segments[0].scalar_amplitude_sum_t0, 2.0); + assert!((report.segments[0].coherent_amplitude_t0 - 0.0).abs() < 1e-12); + } + #[test] + fn rejects_invalid_definition() { + assert_eq!( + calculate_craft_report( + &[], + CraftReportDefinition { + threshold_an: f64::NAN, + ..Default::default() + } + ) + .unwrap_err(), + CraftReportError::InvalidThreshold + ); + assert_eq!( + calculate_craft_report( + &[], + CraftReportDefinition { + segment_width_hz: 0.0, + ..Default::default() + } + ) + .unwrap_err(), + CraftReportError::InvalidWidth + ); + } +} diff --git a/crates/processing/src/craft/resolution.rs b/crates/processing/src/craft/resolution.rs index 5c81a66..b6e1d4d 100644 --- a/crates/processing/src/craft/resolution.rs +++ b/crates/processing/src/craft/resolution.rs @@ -1,6 +1,8 @@ use plotx_io::NmrData; use serde::{Deserialize, Serialize}; +use super::preflight::detect_clear_signals; +use super::regions::build_modeling_windows; use super::{ CraftInputAssessment, CraftInvocation, CraftParams, CraftProfile, CraftReference, CraftRegion, CraftRegionId, @@ -19,13 +21,11 @@ pub enum CraftParamSource { pub struct CraftParamOverrides { pub profile: Option, pub regions: Option>, - pub max_components_per_fit_window: Option, - pub min_amplitude_to_noise: Option, - pub linewidth_hz: Option<(f64, f64)>, - pub filter_taps: Option, - pub padding_fraction: Option, - pub max_fit_window_width_hz: Option, - pub max_downsampled_points: Option, + pub maximum_model_order: Option, + pub minimum_amplitude_to_noise: Option, + pub component_linewidth_bounds_hz: Option<(f64, f64)>, + pub fir_filter_taps: Option, + pub maximum_modeled_sample_count: Option, pub skip_duration_s: Option, pub reconstruction_duration_s: Option>, } @@ -36,13 +36,11 @@ impl CraftParamOverrides { Self { profile: Some(params.profile), regions: Some(params.regions), - max_components_per_fit_window: Some(params.max_components_per_fit_window), - min_amplitude_to_noise: Some(params.min_amplitude_to_noise), - linewidth_hz: Some(params.linewidth_hz), - filter_taps: Some(params.filter_taps), - padding_fraction: Some(params.padding_fraction), - max_fit_window_width_hz: Some(params.max_fit_window_width_hz), - max_downsampled_points: Some(params.max_downsampled_points), + maximum_model_order: Some(params.maximum_model_order), + minimum_amplitude_to_noise: Some(params.minimum_amplitude_to_noise), + component_linewidth_bounds_hz: Some(params.component_linewidth_bounds_hz), + fir_filter_taps: Some(params.fir_filter_taps), + maximum_modeled_sample_count: Some(params.maximum_modeled_sample_count), skip_duration_s: Some(params.skip_duration_s), reconstruction_duration_s: Some(params.reconstruction_duration_s), } @@ -64,13 +62,11 @@ impl CraftParamOverrides { pub struct CraftParameterSources { pub profile: CraftParamSource, pub regions: CraftParamSource, - pub max_components_per_fit_window: CraftParamSource, - pub min_amplitude_to_noise: CraftParamSource, - pub linewidth_hz: CraftParamSource, - pub filter_taps: CraftParamSource, - pub padding_fraction: CraftParamSource, - pub max_fit_window_width_hz: CraftParamSource, - pub max_downsampled_points: CraftParamSource, + pub maximum_model_order: CraftParamSource, + pub minimum_amplitude_to_noise: CraftParamSource, + pub component_linewidth_bounds_hz: CraftParamSource, + pub fir_filter_taps: CraftParamSource, + pub maximum_modeled_sample_count: CraftParamSource, pub skip_duration_s: CraftParamSource, pub reconstruction_duration_s: CraftParamSource, } @@ -80,13 +76,11 @@ impl CraftParameterSources { [ self.profile, self.regions, - self.max_components_per_fit_window, - self.min_amplitude_to_noise, - self.linewidth_hz, - self.filter_taps, - self.padding_fraction, - self.max_fit_window_width_hz, - self.max_downsampled_points, + self.maximum_model_order, + self.minimum_amplitude_to_noise, + self.component_linewidth_bounds_hz, + self.fir_filter_taps, + self.maximum_modeled_sample_count, self.skip_duration_s, self.reconstruction_duration_s, ] @@ -95,12 +89,44 @@ impl CraftParameterSources { } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct CraftDerivedWindow { - pub region: CraftRegionId, - pub core_hz: (f64, f64), - pub padded_hz: (f64, f64), - pub planned_decimation: usize, - pub planned_retained_samples: usize, +pub struct CraftDerivedModelingWindow { + pub retention_band_hz: (f64, f64), + pub modeling_band_hz: (f64, f64), + pub planned_decimation_factor: usize, + pub planned_modeled_sample_count: usize, + pub planned_modeled_duration_s: f64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct CraftModelingPolicy { + pub modeling_bandwidth_hz: f64, + pub modeling_duration_s: f64, + pub validation_tail_fraction: f64, + pub boundary_stability_relative_tolerance: f64, + pub component_linewidth_bounds_hz: (f64, f64), +} + +impl Default for CraftModelingPolicy { + fn default() -> Self { + Self { + modeling_bandwidth_hz: 250.0, + modeling_duration_s: 1.0, + validation_tail_fraction: 0.2, + boundary_stability_relative_tolerance: 0.01, + component_linewidth_bounds_hz: (0.05, 20.0), + } + } +} + +impl CraftModelingPolicy { + pub(super) fn for_params(params: &CraftParams) -> Self { + Self { + modeling_bandwidth_hz: params.profile.modeling_bandwidth_hz(), + modeling_duration_s: params.profile.modeling_duration_s(), + component_linewidth_bounds_hz: params.component_linewidth_bounds_hz, + ..Self::default() + } + } } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -108,10 +134,10 @@ pub struct CraftDerivedPlan { pub effective_skip_points: usize, pub effective_skip_source: CraftParamSource, pub available_points: usize, - pub actual_filter_taps: usize, + pub effective_fir_filter_taps: usize, pub reconstruction_points: usize, pub resolved_regions: Vec, - pub fit_windows: Vec, + pub modeling_windows: Vec, } pub fn resolve_craft_invocation( @@ -160,43 +186,40 @@ pub fn resolve_craft_invocation( regions = full_bandwidth_region(data, reference).into_iter().collect(); regions_source = CraftParamSource::InputDerived; } - let (max_components_per_fit_window, max_components_source) = - resolved!(max_components_per_fit_window); - let (min_amplitude_to_noise, min_amplitude_source) = resolved!(min_amplitude_to_noise); - let (linewidth_hz, linewidth_source) = resolved!(linewidth_hz); - let (filter_taps, filter_taps_source) = resolved!(filter_taps); - let (padding_fraction, padding_source) = resolved!(padding_fraction); - let (max_fit_window_width_hz, fit_window_source) = resolved!(max_fit_window_width_hz); - let (max_downsampled_points, downsample_source) = resolved!(max_downsampled_points); + let (maximum_model_order, maximum_model_order_source) = resolved!(maximum_model_order); + let (minimum_amplitude_to_noise, minimum_amplitude_source) = + resolved!(minimum_amplitude_to_noise); + let (component_linewidth_bounds_hz, component_linewidth_bounds_source) = + resolved!(component_linewidth_bounds_hz); + let (fir_filter_taps, fir_filter_taps_source) = resolved!(fir_filter_taps); + let (maximum_modeled_sample_count, maximum_modeled_sample_count_source) = + resolved!(maximum_modeled_sample_count); let (skip_duration_s, skip_source) = resolved!(skip_duration_s); let (reconstruction_duration_s, reconstruction_source) = resolved!(reconstruction_duration_s); let params = CraftParams { profile, regions, - max_components_per_fit_window, - min_amplitude_to_noise, - linewidth_hz, - filter_taps, - padding_fraction, - max_fit_window_width_hz, - max_downsampled_points, + maximum_model_order, + minimum_amplitude_to_noise, + component_linewidth_bounds_hz, + fir_filter_taps, + maximum_modeled_sample_count, skip_duration_s, reconstruction_duration_s, }; let sources = CraftParameterSources { profile: profile_source, regions: regions_source, - max_components_per_fit_window: max_components_source, - min_amplitude_to_noise: min_amplitude_source, - linewidth_hz: linewidth_source, - filter_taps: filter_taps_source, - padding_fraction: padding_source, - max_fit_window_width_hz: fit_window_source, - max_downsampled_points: downsample_source, + maximum_model_order: maximum_model_order_source, + minimum_amplitude_to_noise: minimum_amplitude_source, + component_linewidth_bounds_hz: component_linewidth_bounds_source, + fir_filter_taps: fir_filter_taps_source, + maximum_modeled_sample_count: maximum_modeled_sample_count_source, skip_duration_s: skip_source, reconstruction_duration_s: reconstruction_source, }; - let derived_plan = derive_plan(data, reference, ¶ms, &sources); + let modeling_policy = CraftModelingPolicy::for_params(¶ms); + let derived_plan = derive_plan(data, reference, ¶ms, &sources, modeling_policy); let assessment = CraftInputAssessment::assess(data, reference, ¶ms, &derived_plan); CraftInvocation { params, @@ -204,6 +227,7 @@ pub fn resolve_craft_invocation( reference, derived_plan, assessment, + modeling_policy, } } @@ -224,6 +248,7 @@ fn derive_plan( reference: CraftReference, params: &CraftParams, sources: &CraftParameterSources, + modeling_policy: CraftModelingPolicy, ) -> CraftDerivedPlan { let requested_skip = if data.spectral_width_hz.is_finite() && params.skip_duration_s.is_finite() { @@ -245,9 +270,16 @@ fn derive_plan( sources.skip_duration_s }; let available_points = data.points.len().saturating_sub(effective_skip_points); - let actual_filter_taps = effective_taps( - params.filter_taps, - available_points.min(6_000_usize.saturating_add(params.filter_taps)), + let fit_points = if data.spectral_width_hz.is_finite() && data.spectral_width_hz > 0.0 { + (modeling_policy.modeling_duration_s * data.spectral_width_hz) + .ceil() + .max(1.0) as usize + } else { + 0 + }; + let effective_fir_filter_taps = effective_taps( + params.fir_filter_taps, + available_points.min(fit_points.saturating_add(params.fir_filter_taps)), ); let acquired_duration = if data.spectral_width_hz.is_finite() && data.spectral_width_hz > 0.0 { data.points.len() as f64 / data.spectral_width_hz @@ -269,73 +301,50 @@ fn derive_plan( 0 }; let resolved_regions = params.regions.clone(); - let mut fit_windows = Vec::new(); + let mut modeling_windows = Vec::new(); if data.spectral_width_hz.is_finite() && data.spectral_width_hz > 0.0 && data.observe_freq_mhz.is_finite() && data.observe_freq_mhz > 0.0 && reference.effective_carrier_ppm().is_finite() - && params.max_fit_window_width_hz.is_finite() - && params.max_fit_window_width_hz > 0.0 + && params.profile.modeling_bandwidth_hz().is_finite() { - let half_sw = data.spectral_width_hz * 0.5; - let carrier = reference.effective_carrier_ppm(); - for selection in &resolved_regions { - let normalized = selection.normalized(); - let start = - ((normalized.start_ppm - carrier) * data.observe_freq_mhz).clamp(-half_sw, half_sw); - let end = - ((normalized.end_ppm - carrier) * data.observe_freq_mhz).clamp(-half_sw, half_sw); - if !start.is_finite() || !end.is_finite() || end <= start { - continue; - } - let pieces = ((end - start) / params.max_fit_window_width_hz) - .ceil() - .max(1.0) as usize; - let width = (end - start) / pieces as f64; - for index in 0..pieces { - let core = ( - start + index as f64 * width, - start + (index + 1) as f64 * width, - ); - let padding = width * params.padding_fraction.max(0.0) * 0.5; - let padded = ( - (core.0 - padding).max(-half_sw), - (core.1 + padding).min(half_sw), - ); - let padded_width = padded.1 - padded.0; - let filter_input = - available_points.min(6_000_usize.saturating_add(params.filter_taps)); - let mut decimation = (data.spectral_width_hz - / (2.0 * padded_width).max(f64::MIN_POSITIVE)) - .floor() - .max(1.0) as usize; - if params.max_downsampled_points > 0 { - decimation = - decimation.max(filter_input.div_ceil(params.max_downsampled_points)); - } - let retained = filter_input - .saturating_sub(actual_filter_taps) - .min(6_000) - .div_ceil(decimation); - fit_windows.push(CraftDerivedWindow { - region: selection.id, - core_hz: core, - padded_hz: padded, - planned_decimation: decimation, - planned_retained_samples: retained, - }); + let filter_input = available_points.min(fit_points.saturating_add(params.fir_filter_taps)); + let clear_signals = detect_clear_signals(data, reference, effective_skip_points); + for window in + build_modeling_windows(data, params, reference, &clear_signals).unwrap_or_default() + { + let modeled_bandwidth_hz = window.modeling_band_hz.1 - window.modeling_band_hz.0; + let mut decimation = (data.spectral_width_hz + / (2.0 * modeled_bandwidth_hz).max(f64::MIN_POSITIVE)) + .floor() + .max(1.0) as usize; + if params.maximum_modeled_sample_count > 0 { + decimation = + decimation.max(filter_input.div_ceil(params.maximum_modeled_sample_count)); } + let retained = filter_input + .saturating_sub(effective_fir_filter_taps) + .min(fit_points) + .div_ceil(decimation); + modeling_windows.push(CraftDerivedModelingWindow { + retention_band_hz: window.retention_band_hz, + modeling_band_hz: window.modeling_band_hz, + planned_decimation_factor: decimation, + planned_modeled_sample_count: retained, + planned_modeled_duration_s: retained as f64 * decimation as f64 + / data.spectral_width_hz, + }); } } CraftDerivedPlan { effective_skip_points, effective_skip_source, available_points, - actual_filter_taps, + effective_fir_filter_taps, reconstruction_points, resolved_regions, - fit_windows, + modeling_windows, } } diff --git a/crates/processing/src/craft/stability.rs b/crates/processing/src/craft/stability.rs new file mode 100644 index 0000000..f006e36 --- /dev/null +++ b/crates/processing/src/craft/stability.rs @@ -0,0 +1,193 @@ +use plotx_io::NmrData; + +use super::diagnostics::{ + CraftModelingWindowDiagnostic, CraftStabilityDiagnostics, CraftStabilityMetric, + CraftStabilityRegion, +}; +use super::regions::{region_ratio, selections_are_valid, summarize_regions}; +use super::{CraftComponent, CraftComponentId, CraftModelingPolicy, CraftReference, CraftRegion}; + +pub(super) fn stability_diagnostics( + all_components: &[CraftComponent], + selections: &[CraftRegion], + windows: &[CraftModelingWindowDiagnostic], + policy: CraftModelingPolicy, + reference: CraftReference, + data: &NmrData, +) -> CraftStabilityDiagnostics { + let delta_ppm = (0.01_f64).max(8.0 / data.observe_freq_mhz.max(f64::MIN_POSITIVE)); + let mut perturbations = vec![("original".to_owned(), selections.to_vec())]; + for (name, start_delta, end_delta) in [ + ("shift left", -delta_ppm, -delta_ppm), + ("shift right", delta_ppm, delta_ppm), + ("expand", -delta_ppm, delta_ppm), + ("contract", delta_ppm, -delta_ppm), + ] { + perturbations.push(( + name.to_owned(), + selections + .iter() + .map(|region| { + CraftRegion::new( + region.id, + region.start_ppm + start_delta, + region.end_ppm + end_delta, + ) + }) + .collect(), + )); + } + for (index, region) in selections.iter().enumerate() { + for (side, start_delta, end_delta) in [ + ("left edge left", -delta_ppm, 0.0), + ("left edge right", delta_ppm, 0.0), + ("right edge left", 0.0, -delta_ppm), + ("right edge right", 0.0, delta_ppm), + ] { + let mut moved = selections.to_vec(); + moved[index] = CraftRegion::new( + region.id, + region.start_ppm + start_delta, + region.end_ppm + end_delta, + ); + perturbations.push((format!("region {} {side}", region.id.0), moved)); + } + } + + let carrier = reference.effective_carrier_ppm(); + let half_ppm = data.spectral_width_hz / (2.0 * data.observe_freq_mhz); + let lower = carrier - half_ppm; + let upper = carrier + half_ppm; + let mut skipped = Vec::new(); + let mut observations = Vec::new(); + for (name, regions) in perturbations { + if !selections_are_valid(®ions) + || regions.iter().any(|region| { + let region = region.normalized(); + region.start_ppm < lower || region.end_ppm > upper + }) + { + skipped.push(format!("{name}: invalid or overlapping regions")); + continue; + } + let assigned = components_for_regions(all_components, ®ions); + let summaries = summarize_regions(&assigned, ®ions); + let ratio = region_ratio(&summaries).map(|ratio| ratio.value); + observations.push((summaries, ratio)); + } + + let total_model_order = windows + .iter() + .map(|window| window.selected_model_order) + .sum(); + let regions = selections + .iter() + .map(|selection| { + let summaries = observations + .iter() + .filter_map(|(summaries, _)| { + summaries + .iter() + .find(|summary| summary.region == selection.id) + }) + .collect::>(); + let values = summaries + .iter() + .map(|summary| summary.coherent_amplitude_t0) + .collect::>(); + CraftStabilityRegion { + region: selection.id, + metric: stability_metric(&values), + component_count_min: summaries + .iter() + .map(|summary| summary.component_count) + .min() + .unwrap_or(0), + component_count_max: summaries + .iter() + .map(|summary| summary.component_count) + .max() + .unwrap_or(0), + model_order_min: total_model_order, + model_order_max: total_model_order, + } + }) + .collect::>(); + let ratio_values = observations + .iter() + .filter_map(|(_, ratio)| *ratio) + .collect::>(); + let ratio = (selections.len() == 2).then(|| stability_metric(&ratio_values)); + let passed = !all_components.is_empty() + && !observations.is_empty() + && regions.iter().all(|region| { + region.metric.relative_dispersion <= policy.boundary_stability_relative_tolerance + && region.component_count_min == region.component_count_max + }) + && ratio.as_ref().is_none_or(|metric| { + ratio_values.len() == observations.len() + && metric.relative_dispersion <= policy.boundary_stability_relative_tolerance + }); + CraftStabilityDiagnostics { + delta_ppm, + regions, + ratio, + passed, + skipped, + } +} + +pub(super) fn components_for_regions( + components: &[CraftComponent], + regions: &[CraftRegion], +) -> Vec { + components + .iter() + .filter_map(|component| { + regions + .iter() + .find(|region| { + let region = region.normalized(); + component.chemical_shift_ppm >= region.start_ppm + && component.chemical_shift_ppm <= region.end_ppm + }) + .map(|region| { + let mut selected = component.clone(); + selected.region = region.id; + selected + }) + }) + .enumerate() + .map(|(id, mut component)| { + component.id = CraftComponentId(id as u64); + component + }) + .collect() +} + +fn stability_metric(values: &[f64]) -> CraftStabilityMetric { + if values.is_empty() { + return CraftStabilityMetric { + median: 0.0, + minimum: 0.0, + maximum: 0.0, + relative_dispersion: f64::MAX, + }; + } + let mut sorted = values.to_vec(); + sorted.sort_by(f64::total_cmp); + let middle = sorted.len() / 2; + let median = if sorted.len().is_multiple_of(2) { + (sorted[middle - 1] + sorted[middle]) * 0.5 + } else { + sorted[middle] + }; + let minimum = sorted[0]; + let maximum = sorted[sorted.len() - 1]; + CraftStabilityMetric { + median, + minimum, + maximum, + relative_dispersion: (maximum - minimum) / median.abs().max(f64::MIN_POSITIVE), + } +} diff --git a/crates/processing/src/craft_tests.rs b/crates/processing/src/craft_tests.rs index 03eff81..c735e03 100644 --- a/crates/processing/src/craft_tests.rs +++ b/crates/processing/src/craft_tests.rs @@ -1,4 +1,5 @@ use super::*; +use std::f64::consts::{PI, TAU}; fn data(components: &[(f64, f64, f64, f64)], count: usize, sw: f64) -> NmrData { let points = (0..count) @@ -35,8 +36,7 @@ fn complete_reduction_recovers_table_and_residual() { 2000.0, ); let params = CraftParams { - max_fit_window_width_hz: 2_000.0, - filter_taps: 127, + fir_filter_taps: 127, ..CraftParams::default() }; let invocation = CraftInvocation::acquisition(&input, params); @@ -44,18 +44,25 @@ fn complete_reduction_recovers_table_and_residual() { assert_eq!(result.components.len(), 2, "{:?}", result.components); assert!((result.components[0].frequency_hz + 75.0).abs() < 0.05); assert!((result.components[1].frequency_hz - 120.0).abs() < 0.05); - assert!(result.diagnostics.normalized_residual < 1e-4); + assert!((result.components[0].amplitude_t0 - 8.0).abs() / 8.0 < 0.01); + assert!((result.components[1].amplitude_t0 - 4.0).abs() / 4.0 < 0.01); + assert!( + result.diagnostics.normalized_residual < 0.01, + "components={:?} diagnostics={:?}", + result.components, + result.diagnostics + ); } #[test] -fn full_band_fit_treats_empty_internal_windows_as_valid_no_signal_results() { +fn full_band_modeling_treats_empty_windows_as_valid_no_signal_results() { let input = data( &[(-300.0, 4.0, 0.1, 2.0), (300.0, 2.0, -0.2, 2.0)], 4096, 2_000.0, ); let params = CraftParams { - filter_taps: 63, + fir_filter_taps: 63, ..CraftParams::default() }; @@ -69,7 +76,7 @@ fn full_band_fit_treats_empty_internal_windows_as_valid_no_signal_results() { assert_eq!(result.region_summaries.len(), 1); assert_eq!(result.region_summaries[0].region, CraftRegionId(0)); assert!(result.region_summaries[0].component_count >= 2); - assert_eq!(result.diagnostics.fit_windows.len(), 4); + assert_eq!(result.diagnostics.modeling_windows.len(), 2); } #[test] @@ -80,14 +87,18 @@ fn ssfp_skip_extrapolates_amplitude_to_time_zero() { profile: CraftProfile::Ssfp, skip_duration_s: 10.0 / input.spectral_width_hz, reconstruction_duration_s: Some(0.1), - max_fit_window_width_hz: 20_000.0, - filter_taps: 63, + fir_filter_taps: 63, ..CraftParams::default() }; let invocation = CraftInvocation::acquisition(&input, params); let result = process_craft_cancellable(&input, &invocation, &|| false).unwrap(); - assert_eq!(result.components.len(), 1); - assert!((result.components[0].amplitude_t0 - 5.0).abs() < 0.05); + assert_eq!(result.components.len(), 1, "{:?}", result.components); + assert!( + (result.components[0].amplitude_t0 - 5.0).abs() < 0.05, + "components={:?} diagnostics={:?}", + result.components, + result.diagnostics + ); assert_eq!(result.synthetic_fid.len(), 2000); assert!( result @@ -117,7 +128,7 @@ fn group_delay_uses_the_physical_fid_time_origin() { .collect(); let params = CraftParams { regions: vec![CraftRegion::new(CraftRegionId(1), 0.18, 0.22)], - filter_taps: 127, + fir_filter_taps: 127, ..CraftParams::default() }; @@ -149,10 +160,10 @@ fn default_model_limit_resolves_non_lorentzian_multiplet_quantitation() { let input = data(&components, 4096, 2_000.0); let params = CraftParams { regions: vec![ - CraftRegion::new(CraftRegionId(0), -0.23, -0.17), - CraftRegion::new(CraftRegionId(1), 0.14, 0.23), + CraftRegion::new(CraftRegionId(0), -0.25, -0.15), + CraftRegion::new(CraftRegionId(1), 0.12, 0.25), ], - filter_taps: 127, + fir_filter_taps: 127, ..CraftParams::default() }; @@ -163,9 +174,29 @@ fn default_model_limit_resolves_non_lorentzian_multiplet_quantitation() { ) .unwrap(); - assert!(result.diagnostics.fit_windows[1].selected_model_order > 7); + assert_eq!(result.region_summaries[1].component_count, 8); + assert!( + !result + .diagnostics + .warnings + .iter() + .any(|warning| warning.kind == CraftWarningKind::InputAssessment + && warning.message.contains("peak density")) + ); let ratio = result.region_ratio.unwrap().value; assert!((ratio - 1.5).abs() < 0.03, "ratio was {ratio}"); + assert!( + result.diagnostics.stability.passed, + "{:?}", + result.diagnostics.stability + ); + assert!( + result + .diagnostics + .stability + .ratio + .is_some_and(|metric| metric.relative_dispersion < 0.01) + ); } #[test] @@ -190,12 +221,11 @@ fn overlapping_requested_regions_are_rejected_as_ambiguous() { CraftRegion::new(CraftRegionId(10), -1.0, 1.0), CraftRegion::new(CraftRegionId(20), 0.5, 2.0), ], - max_fit_window_width_hz: 500.0, ..CraftParams::default() }; assert!(matches!( - build_regions(&input, ¶ms, CraftReference::acquisition(&input)), + build_modeling_windows(&input, ¶ms, CraftReference::acquisition(&input), &[],), Err(CraftError::InvalidParameters) )); } @@ -206,14 +236,24 @@ fn reference_maps_displayed_regions_and_reported_shifts_without_changing_frequen let reference = CraftReference::new(input.carrier_ppm, 0.15); let params = CraftParams { regions: vec![CraftRegion::new(CraftRegionId(7), 0.38, 0.40)], - max_fit_window_width_hz: 500.0, - filter_taps: 127, + fir_filter_taps: 127, ..CraftParams::default() }; - let regions = build_regions(&input, ¶ms, reference).unwrap(); - assert!((regions[0].core.0 - 115.0).abs() < 1e-9); - assert!((regions[0].core.1 - 125.0).abs() < 1e-9); + let clear_signals = preflight::detect_clear_signals(&input, reference, 0); + let regions = build_modeling_windows(&input, ¶ms, reference, &clear_signals).unwrap(); + assert_eq!(regions.len(), 1); + assert!( + ((regions[0].retention_band_hz.0 + regions[0].retention_band_hz.1) * 0.5 - 120.0).abs() + < 0.25, + "signals={clear_signals:?} window={:?}", + regions[0] + ); + assert!( + regions + .iter() + .all(|window| window.retention_band_hz.1 - window.retention_band_hz.0 <= 500.0) + ); let invocation = resolve_craft_invocation( &input, @@ -230,11 +270,10 @@ fn reference_maps_displayed_regions_and_reported_shifts_without_changing_frequen } #[test] -fn narrow_window_fit_is_independent_of_signal_phase() { +fn modeling_window_is_independent_of_signal_phase() { let params = CraftParams { regions: vec![CraftRegion::new(CraftRegionId(1), 0.18, 0.22)], - max_fit_window_width_hz: 500.0, - filter_taps: 127, + fir_filter_taps: 127, ..CraftParams::default() }; let fitted_frequency = |phase| { @@ -252,7 +291,7 @@ fn narrow_window_fit_is_independent_of_signal_phase() { } #[test] -fn fit_windows_preserve_user_region_identity_and_one_region_ratio() { +fn modeling_windows_are_independent_while_components_preserve_region_identity() { let input = data( &[(-300.0, 4.0, 0.1, 2.0), (300.0, 2.0, 0.1, 2.0)], 4096, @@ -263,24 +302,19 @@ fn fit_windows_preserve_user_region_identity_and_one_region_ratio() { CraftRegion::new(CraftRegionId(22), 0.1, 1.0), CraftRegion::new(CraftRegionId(11), -1.0, -0.1), ], - max_fit_window_width_hz: 150.0, - max_components_per_fit_window: 3, - filter_taps: 63, + maximum_model_order: 3, + fir_filter_taps: 63, ..CraftParams::default() }; let reference = CraftReference::acquisition(&input); - let windows = build_regions(&input, ¶ms, reference).unwrap(); - assert_eq!(windows.len(), 6); + let clear_signals = preflight::detect_clear_signals(&input, reference, 0); + let windows = build_modeling_windows(&input, ¶ms, reference, &clear_signals).unwrap(); + assert_eq!(windows.len(), 2); assert!( - windows[..3] + windows .iter() - .all(|window| window.selection.id == CraftRegionId(11)) - ); - assert!( - windows[3..] - .iter() - .all(|window| window.selection.id == CraftRegionId(22)) + .all(|window| window.retention_band_hz.1 - window.retention_band_hz.0 <= 500.0) ); let invocation = resolve_craft_invocation( @@ -302,6 +336,55 @@ fn fit_windows_preserve_user_region_identity_and_one_region_ratio() { assert!((result.region_ratio.unwrap().value - 0.5).abs() < 0.05); } +#[test] +fn overlapping_modeling_bands_retain_each_component_once() { + let input = data( + &[ + (-100.0, 10.0, 0.1, 2.0), + (0.0, 2.0, 0.1, 2.0), + (110.0, 8.0, 0.1, 2.0), + ], + 4096, + 2_000.0, + ); + let params = CraftParams { + maximum_model_order: 4, + fir_filter_taps: 127, + ..CraftParams::default() + }; + + let result = process_craft_cancellable( + &input, + &CraftInvocation::acquisition(&input, params), + &|| false, + ) + .unwrap(); + + assert_eq!(result.diagnostics.modeling_windows.len(), 2); + assert_eq!( + result + .diagnostics + .modeling_windows + .iter() + .map(|window| window.selected_model_order) + .sum::(), + 4, + "{:?}", + result.diagnostics.modeling_windows + ); + assert_eq!(result.components.len(), 3, "{:?}", result.components); + assert_eq!( + result + .components + .iter() + .filter(|component| component.frequency_hz.abs() < 1.0) + .count(), + 1, + "{:?}", + result.components + ); +} + #[test] fn rejects_non_finite_reference() { let input = data(&[], 128, 1_000.0); @@ -386,11 +469,11 @@ fn zero_overrides_resolve_complete_bandwidth_and_stable_sources() { fn resolver_applies_per_field_explicit_provenance_default_priority() { let input = data(&[(80.0, 10.0, 0.0, 2.0)], 1024, 1_000.0); let mut prior_params = CraftParams::ssfp(); - prior_params.min_amplitude_to_noise = 8.0; - prior_params.filter_taps = 127; + prior_params.minimum_amplitude_to_noise = 8.0; + prior_params.fir_filter_taps = 127; let prior = CraftInvocation::acquisition(&input, prior_params); let overrides = CraftParamOverrides { - min_amplitude_to_noise: Some(5.0), + minimum_amplitude_to_noise: Some(5.0), ..CraftParamOverrides::default() }; @@ -401,14 +484,14 @@ fn resolver_applies_per_field_explicit_provenance_default_priority() { Some(&prior), ); - assert_eq!(resolved.params.min_amplitude_to_noise, 5.0); + assert_eq!(resolved.params.minimum_amplitude_to_noise, 5.0); assert_eq!( - resolved.sources.min_amplitude_to_noise, + resolved.sources.minimum_amplitude_to_noise, CraftParamSource::ExplicitInput ); - assert_eq!(resolved.params.filter_taps, 127); + assert_eq!(resolved.params.fir_filter_taps, 127); assert_eq!( - resolved.sources.filter_taps, + resolved.sources.fir_filter_taps, CraftParamSource::ResultProvenance ); assert_eq!(resolved.params.profile, CraftProfile::Ssfp); @@ -419,7 +502,7 @@ fn selecting_profile_clears_profile_owned_overrides_but_keeps_regions() { let region = CraftRegion::new(CraftRegionId(9), -0.2, 0.2); let mut overrides = CraftParamOverrides { regions: Some(vec![region]), - min_amplitude_to_noise: Some(9.0), + minimum_amplitude_to_noise: Some(9.0), ..CraftParamOverrides::default() }; @@ -427,23 +510,18 @@ fn selecting_profile_clears_profile_owned_overrides_but_keeps_regions() { assert_eq!(overrides.profile, Some(CraftProfile::Ssfp)); assert_eq!(overrides.regions, Some(vec![region])); - assert_eq!(overrides.min_amplitude_to_noise, None); + assert_eq!(overrides.minimum_amplitude_to_noise, None); let input = data(&[(80.0, 10.0, 0.0, 2.0)], 1024, 1_000.0); - let mut conventional = CraftParams::conventional(); - conventional.max_fit_window_width_hz = 125.0; - let previous = CraftInvocation::acquisition(&input, conventional); + let previous = CraftInvocation::acquisition(&input, CraftParams::conventional()); let resolved = resolve_craft_invocation( &input, CraftReference::acquisition(&input), &overrides, Some(&previous), ); - assert_eq!(resolved.params.max_fit_window_width_hz, 2_000.0); - assert_eq!( - resolved.sources.max_fit_window_width_hz, - CraftParamSource::StableDefault - ); + assert_eq!(resolved.params.profile, CraftProfile::Ssfp); + assert_eq!(resolved.modeling_policy.modeling_bandwidth_hz, 2_000.0); } #[test] @@ -483,7 +561,7 @@ fn short_and_invalid_inputs_are_classified_before_execution() { } #[test] -fn derived_plan_matches_actual_fit_window_diagnostics() { +fn derived_plan_matches_actual_modeling_window_diagnostics() { let input = data(&[(100.0, 10.0, 0.1, 2.0)], 2048, 1_000.0); let invocation = resolve_craft_invocation( &input, @@ -494,18 +572,174 @@ fn derived_plan_matches_actual_fit_window_diagnostics() { let result = process_craft_cancellable(&input, &invocation, &|| false).unwrap(); assert_eq!( - result.diagnostics.fit_windows.len(), - invocation.derived_plan.fit_windows.len() + result.diagnostics.modeling_windows.len(), + invocation.derived_plan.modeling_windows.len() ); for (actual, planned) in result .diagnostics - .fit_windows + .modeling_windows + .iter() + .zip(&invocation.derived_plan.modeling_windows) + { + assert_eq!(actual.retention_band_hz, planned.retention_band_hz); + assert_eq!(actual.modeling_band_hz, planned.modeling_band_hz); + assert_eq!(actual.decimation_factor, planned.planned_decimation_factor); + } +} + +#[test] +fn user_boundaries_do_not_change_fixed_modeling_protocol() { + let input = data( + &[(-100.0, 6.0, 0.2, 2.0), (100.0, 4.0, 0.2, 2.0)], + 4096, + 2_000.0, + ); + let invocation = |regions| { + let params = CraftParams { + regions, + fir_filter_taps: 127, + ..CraftParams::default() + }; + CraftInvocation::acquisition(&input, params) + }; + let narrow = invocation(vec![CraftRegion::new(CraftRegionId(1), -0.24, -0.16)]); + let wide = invocation(vec![CraftRegion::new(CraftRegionId(1), -0.30, -0.10)]); + + assert_eq!(narrow.modeling_policy, wide.modeling_policy); + assert_eq!(narrow.modeling_policy.modeling_bandwidth_hz, 250.0); + assert_eq!(narrow.modeling_policy.modeling_duration_s, 1.0); + assert_eq!( + narrow.derived_plan.modeling_windows.len(), + wide.derived_plan.modeling_windows.len() + ); + for (left, right) in narrow + .derived_plan + .modeling_windows .iter() - .zip(&invocation.derived_plan.fit_windows) + .zip(&wide.derived_plan.modeling_windows) { - assert_eq!(actual.region, planned.region); - assert_eq!(actual.core_hz, planned.core_hz); - assert_eq!(actual.padded_hz, planned.padded_hz); - assert_eq!(actual.actual_decimation, planned.planned_decimation); + assert_eq!(left.retention_band_hz, right.retention_band_hz); + assert_eq!(left.modeling_band_hz, right.modeling_band_hz); + assert_eq!( + left.planned_decimation_factor, + right.planned_decimation_factor + ); + assert_eq!( + left.planned_modeled_sample_count, + right.planned_modeled_sample_count + ); + assert_eq!( + left.planned_modeled_duration_s, + right.planned_modeled_duration_s + ); + } +} + +#[test] +fn boundary_instability_marks_run_partial_but_keeps_components() { + let input = data(&[(100.0, 5.0, 0.2, 2.0)], 4096, 2_000.0); + let params = CraftParams { + regions: vec![CraftRegion::new(CraftRegionId(7), 0.195, 0.30)], + fir_filter_taps: 127, + ..CraftParams::default() + }; + + let result = process_craft_cancellable( + &input, + &CraftInvocation::acquisition(&input, params), + &|| false, + ) + .unwrap(); + + assert_eq!(result.components.len(), 1); + assert_eq!(result.diagnostics.status, CraftRunStatus::Partial); + assert!(!result.diagnostics.stability.passed); + assert!( + result + .diagnostics + .warnings + .iter() + .any(|warning| { warning.kind == CraftWarningKind::StabilityFailure }) + ); +} + +#[test] +fn global_zero_order_phase_does_not_change_coherent_amplitude() { + let input = data( + &[(-20.0, 3.0, 0.1, 1.5), (20.0, 2.0, 0.4, 1.8)], + 4096, + 2_000.0, + ); + let params = CraftParams { + regions: vec![CraftRegion::new(CraftRegionId(3), -0.10, 0.10)], + fir_filter_taps: 127, + ..CraftParams::default() + }; + let fit = |input: &NmrData| { + process_craft_cancellable( + input, + &CraftInvocation::acquisition(input, params.clone()), + &|| false, + ) + .unwrap() + .region_summaries[0] + .coherent_amplitude_t0 + }; + let expected = fit(&input); + let rotation = Complex64::from_polar(1.0, 1.1); + let mut rotated = input.clone(); + for point in &mut rotated.points { + *point *= rotation; } + + let actual = fit(&rotated); + assert!( + (actual - expected).abs() / expected < 1e-6, + "expected={expected} actual={actual}" + ); +} + +#[test] +fn no_clear_signal_allows_exploration_but_requires_review() { + let input = data(&[], 4096, 2_000.0); + let invocation = CraftInvocation::acquisition( + &input, + CraftParams { + fir_filter_taps: 63, + ..CraftParams::default() + }, + ); + + assert!(invocation.assessment.can_run()); + assert!(invocation.assessment.issues.iter().any(|issue| { + issue.code == CraftIssueCode::NoClearSignal && issue.severity == CraftIssueSeverity::Warning + })); + let result = process_craft_cancellable(&input, &invocation, &|| false).unwrap(); + + assert_eq!(result.diagnostics.status, CraftRunStatus::Partial); + assert!(result.components.is_empty()); + assert!(!result.diagnostics.stability.passed); +} + +#[test] +fn validation_selection_keeps_model_order_when_only_the_unused_tail_changes() { + let components = [(-30.0, 5.0, 0.2, 1.5), (42.0, 3.0, -0.3, 2.0)]; + let selected_orders = [4096, 4112].map(|count| { + let input = data(&components, count, 2_000.0); + let invocation = CraftInvocation::acquisition( + &input, + CraftParams { + fir_filter_taps: 127, + ..CraftParams::default() + }, + ); + process_craft_cancellable(&input, &invocation, &|| false) + .unwrap() + .diagnostics + .modeling_windows[0] + .selected_model_order + }); + + assert_eq!(selected_orders[0], 2); + assert_eq!(selected_orders[1], selected_orders[0]); } diff --git a/crates/processing/src/lib.rs b/crates/processing/src/lib.rs index a17d280..2f6f656 100644 --- a/crates/processing/src/lib.rs +++ b/crates/processing/src/lib.rs @@ -629,6 +629,26 @@ impl AxisPipeline { .sum() } + /// Net chemical-shift translation applied at or after `step` by enabled + /// reference steps — the calibration separating the coordinates entering + /// `step` from the finished axis. A position picked on the displayed + /// spectrum converts into `step`'s own `at_ppm` by subtracting this value. + /// `step` counts only while enabled, matching how its current offset does + /// or does not shape the display. Kept beside + /// [`Self::chemical_shift_reference_offset_ppm`] so reference-step + /// semantics stay in one place. + pub fn chemical_shift_offset_from_step_ppm(&self, step: StepId) -> f64 { + self.steps + .iter() + .skip_while(|s| s.id != step) + .filter(|s| s.enabled) + .filter_map(|s| match s.kind { + StepKind::Reference(reference) => Some(reference.target_ppm - reference.at_ppm), + _ => None, + }) + .sum() + } + /// The zero-fill target for this axis: the last enabled `ZeroFill` step, or /// `None` when the axis carries none. pub fn zero_fill(&self) -> ZeroFill { diff --git a/crates/processing/src/tests.rs b/crates/processing/src/tests.rs index 59dc6ee..80c959f 100644 --- a/crates/processing/src/tests.rs +++ b/crates/processing/src/tests.rs @@ -490,6 +490,40 @@ fn pipeline_reference_offset_matches_all_enabled_reference_steps() { assert!((pipeline.chemical_shift_reference_offset_ppm() - 0.15).abs() < 1e-12); } +/// The from-step reduction is the calibration between a step's input axis and +/// the finished axis: it must count the step itself only while enabled, count +/// later enabled reference steps, and ignore earlier ones. +#[test] +fn pipeline_offset_from_step_counts_that_step_and_later_enabled_ones() { + let first = step(StepKind::Reference(ReferenceParams { + at_ppm: 1.0, + target_ppm: 1.2, + })); + let mut disabled = step(StepKind::Reference(ReferenceParams { + at_ppm: 4.0, + target_ppm: 20.0, + })); + disabled.enabled = false; + let last = step(StepKind::Reference(ReferenceParams { + at_ppm: 3.0, + target_ppm: 2.95, + })); + let (first_id, disabled_id, last_id) = (first.id, disabled.id, last.id); + let pipeline = AxisPipeline { + steps: vec![step(StepKind::Fft), first, disabled, last], + }; + + assert!((pipeline.chemical_shift_offset_from_step_ppm(first_id) - 0.15).abs() < 1e-12); + // A disabled step contributes nothing of its own, only what follows it. + assert!((pipeline.chemical_shift_offset_from_step_ppm(disabled_id) - -0.05).abs() < 1e-12); + assert!((pipeline.chemical_shift_offset_from_step_ppm(last_id) - -0.05).abs() < 1e-12); + // An id the pipeline does not hold calibrates by zero. + assert_eq!( + pipeline.chemical_shift_offset_from_step_ppm(StepId::new(u64::MAX)), + 0.0 + ); +} + #[test] fn public_process_returns_a_time_output_instead_of_panicking() { let data = fid(2.0, 0.0); diff --git a/docs/src/content/docs/guides/craft.md b/docs/src/content/docs/guides/craft.md index 795369b..865b44b 100644 --- a/docs/src/content/docs/guides/craft.md +++ b/docs/src/content/docs/guides/craft.md @@ -50,27 +50,38 @@ fields under **Signal groups** are available when you need exact bounds. A signal group can contain several fitted components. A component is a fitted resonance contribution, not a compound identification or a guaranteed visible -multiplet. A wide group may be split into several calculation windows, but its -components are still reported under the group you selected. +multiplet. Fixed modeling windows determine how the FID is solved; the signal +group boundary only determines which completed components belong to the group. -## Advanced fit settings +## Advanced component settings -Leave **Advanced fit settings** closed for routine work. The conventional +Leave **Advanced component settings** closed for routine work. The conventional profile uses these defaults: - **Minimum A/N**: 3.3. Lower values retain weaker candidates but increase the chance of fitting noise; values below 3.3 are flagged for review. -- **Max components / fit window**: 15 (allowed range 1–64). Reaching the limit - is reported as a diagnostic warning. -- **Linewidth range (Hz)**: 0.05–10 Hz. -- **Fit window width (Hz)**: 500 Hz. This controls how wide groups are divided - for calculation; it does not create extra signal groups. +- **Maximum model order**: 15 (allowed range 1–64) for each modeling window. + Reaching the limit is reported as a diagnostic warning. +- **Component linewidth range (Hz)**: 0.05–20 Hz. The bound applies to each + component, not the frequency range modeled at once. The 20 Hz default is a + typical starting point rather than a universal constant. Change it only when + the acquisition and expected line shape justify a different bound. + +The fixed modeling bandwidth is 250 Hz for Conventional and 2000 Hz for SSFP. +This is the actual frequency width of a modeling window, not a linewidth and +not a quantitative tuning control. A modeling window can contain many component +lines, each still constrained by the separate component-linewidth range. Use **Reset** beside an edited value to restore the value inherited from the selected run or the profile default. Changing profiles keeps the selected groups and loads the new profile's settings. Conventional FID is always the default; PlotX does not infer SSFP from the waveform. +CRAFT keeps Bruker acquisition `GRPDLY` separate from its own FIR filtering. +The importer/FFT path uses `GRPDLY` to define the acquisition time origin; the +499-tap CRAFT FIR has an independent edge transient handled by phase-conjugate +precharge. Neither delay is silently folded into the other. + The **SSFP / interrupted FID** profile starts with **Skip initial** at 0.5 ms and **Extend reconstructed FID** enabled for 1.2 s. Skipping early points can remove fast-decaying background; reconstruction extends the modeled FID for @@ -106,11 +117,29 @@ or enabled **Reference** step changes; rerun it before interpreting the result. Runs with warnings or a partial fit are marked **Needs review** even when the calculation completes. +CRAFT performs deterministic boundary-perturbation checks around each selected +group. Small shifts, expansions, contractions, and one-sided moves must keep +amplitudes and ratios within 1%. A run that fails this stability gate keeps its +complete component table and residual for inspection, but cannot create or +export a quantitative amplitude report. + Choose **Export components…** to open the standard CSV, TSV, XLSX, or clipboard export dialog. Under **Signals**, **Create data table** creates a sortable PlotX table without leaving CRAFT. Choose **View data table** to inspect, chart, or export it, and **Add to board** only when you want the table on a board sheet. +## CRAFT amplitude reports + +The model's **Minimum A/N** is a trust criterion for retaining fitted components. +The **Reports** tab is a separate reporting layer: its **Report threshold** +selects which retained components are included, without refitting or changing +the complete component table. **Segment width** is the total fixed frequency +window (Hz) around each selected peak; overlapping windows are merged. Reports +show both the scalar sum of component amplitudes and the phase-aware coherent +amplitude. These segment amplitudes are summaries of fitted components, not +integrals of frequency-domain bins. A report whose source run changes is marked +for review rather than silently recalculated. + ## Interpretation CRAFT components describe the selected FID; they do not identify compounds or diff --git a/docs/src/content/docs/guides/importing-data.md b/docs/src/content/docs/guides/importing-data.md index 9e2c5b8..b607371 100644 --- a/docs/src/content/docs/guides/importing-data.md +++ b/docs/src/content/docs/guides/importing-data.md @@ -14,6 +14,7 @@ no conversion step is needed. | Bruker TopSpin | `fid` / `ser` directories | 1D and 2D | | Varian/Agilent VnmrJ | `.fid` directory | Raw time-domain 1D and conventional 2D | | Waters MassLynx RAW | `.raw` directory | Validated low-resolution runs, including SQD2 data | +| SCIEX legacy WIFF | `.wiff` + `.wiff.scan` | Single- and multi-sample legacy runs; both files must remain together | | Rigaku powder XRD | `.rasx`, FI `.raw`, RAS_RAW `.txt` | Diffraction pattern, acquisition metadata, and attenuation when available | | mzML | `.mzML` | Centroided or profile LC–MS spectra with 32-bit or 64-bit arrays, uncompressed or zlib-compressed | | Bruker NanoScope AFM | `.spm` / `.pfc` | Images, force curves, force-volume and PeakForce Capture cubes | @@ -35,7 +36,9 @@ TopSpin, Varian/Agilent VnmrJ, and Waters MassLynx RAW), *Open Project…*, or Each imported dataset appears in the Primary Side Bar and is placed on the board automatically. The file picker accepts several ABF files at once. Opening a folder recursively -imports every `.abf`, `.spm`, `.pfc`, `.vms`, structured CasaXPS `.txt`, and recognized `.raw` bundle below it. +imports every `.abf`, `.spm`, `.pfc`, `.vms`, `.wiff`, structured CasaXPS +`.txt`, and recognized `.raw` bundle below it. A `.wiff.scan` companion is +never imported as a separate dataset. A `.raw` directory is imported once as a complete run; its internal files are not treated as separate datasets. For ABF files, each immediate parent folder becomes the initial, editable cell ID. @@ -65,6 +68,22 @@ The importer accepts little-endian 32-bit and 64-bit floating-point m/z and intensity arrays with no compression or zlib compression. Numpress, big-endian arrays, and spectra without both required arrays stop the import with an error. +## SCIEX legacy WIFF + +Open or drop the `.wiff` file. Keep the paired file with `.scan` appended to +the full filename beside it, for example `sample.wiff` and +`sample.wiff.scan`. PlotX imports native scan IDs, retention times, m/z and +intensity arrays, precursor details when available, polarity, instrument and +acquisition-start metadata, and a separate TIC for each verified experiment. +Spectra are grouped into independent sample/experiment acquisition streams and +retain cycle order, including zero-TIC and empty DDA slots. Duplicate sample +names remain separate and are displayed with stable suffixes such as +`yjs_10ppm #1` and `yjs_10ppm #2`. + +PlotX rejects a missing companion, an empty container, or an unrecognized WIFF +layout rather than creating a partial dataset. SCIEX `.wiff2` and `.timeseries.data` are not supported; +convert those acquisitions to mzML before opening them in PlotX. + ## Rigaku powder XRD Open the `.rasx` file when it is available. PlotX reads the measured 2theta, diff --git a/docs/src/content/docs/guides/peaks-and-regions.md b/docs/src/content/docs/guides/peaks-and-regions.md index 872b7bd..9e873cf 100644 --- a/docs/src/content/docs/guides/peaks-and-regions.md +++ b/docs/src/content/docs/guides/peaks-and-regions.md @@ -11,6 +11,13 @@ with [Choosing an analysis tool](/guides/choosing-a-tool/). The **Peaks** tool detects peaks by prominence. Drag the threshold line on the plot to adjust detection — peaks are recomputed when you release it. Detected peaks can be edited, added, and removed by hand. + +A click places one peak, snapped to the tallest apex within a small radius +around the pointer. The radius is fixed on screen, so zooming in narrows it — +zoom in to pick a weak line sitting next to a strong one. Hold `Shift` while +clicking to skip the snap entirely and place the mark on the nearest data +point (useful for shoulders). The hover preview shows exactly where the mark +will land. Dragging across a range picks every prominent peak inside it. Choose **Export Data…** and **Peak table** to save or copy the current peak list. ## 1D NMR integrals diff --git a/docs/src/content/docs/guides/processing.md b/docs/src/content/docs/guides/processing.md index 715ba8a..a6a8c19 100644 --- a/docs/src/content/docs/guides/processing.md +++ b/docs/src/content/docs/guides/processing.md @@ -29,7 +29,10 @@ screen is immediately usable, and a session touches at most three things: 2. **Baseline** — baseline correction is off by default; enable the step when the baseline rolls or offsets. 3. **Reference** — add a Reference step to pin a known peak to its - chemical-shift position. + chemical-shift position, typing the source position or picking it directly + on the spectrum. Peak marks are calibrated with the spectrum: editing the + Reference step moves existing marks along with the axis. See + [Reference](#reference). 2D datasets get a cosine-bell apodization enabled by default. A true 2D acquisition shows two pipelines, **F2 (direct)** then **F1 (indirect)**, in the @@ -192,6 +195,24 @@ phase angles in radians, the pivot as a fraction. Baseline correction is off by default. Enable the step when your spectrum needs it. +## Reference + +A Reference step shifts the whole chemical-shift axis so the point currently at +**Source** reads **Target**: pin your standard (TMS, DSS, a solvent line) to +its known position. Add it from **Add step → Frequency domain → Reference**. + +You can type the source position, or pick it on the spectrum: open the step and +click **Pick position on spectrum**. Hovering the plot previews the position — +the pick snaps to the nearest peak apex within a small window around the +cursor, so zooming in first lets you land on a weak line precisely. Hold +Shift to skip the snap and read the exact cursor position instead. +Click to fill **Source**, then type the shift that peak should have into +**Target**. Esc, collapsing the step, or switching datasets cancels +an unused pick. + +Peak marks are calibrated with the spectrum, so editing a Reference step moves +existing marks along with the axis. + ## Reusing a pipeline A pipeline (including the group-delay setting) can be saved as a portable diff --git a/docs/src/content/docs/reference/file-formats.md b/docs/src/content/docs/reference/file-formats.md index 340c7f3..81142a2 100644 --- a/docs/src/content/docs/reference/file-formats.md +++ b/docs/src/content/docs/reference/file-formats.md @@ -73,6 +73,23 @@ non-uniform sampling, and arrayed parameters other than phase are not supported. The import also stops if the recorded dimensions do not match the data. +## SCIEX legacy WIFF + +PlotX reads a legacy `.wiff` OLE metadata container together with the +`.wiff.scan` payload whose name is formed by appending `.scan` to the complete +`.wiff` filename. The importer is pure Rust and does not require SCIEX Analyst, +SCIEX OS, or a ProteoWizard SDK installation. + +Only the validated legacy layout is supported. The native importer reads the +sample subtree/index streams, preserves duplicate sample names as independent +samples, and exposes each of the 11 verified experiment slots as its own +acquisition stream and TIC channel. Records remain in cycle order, including +zero-TIC and empty DDA slots; no cross-experiment time merging or interpolation +is performed. Unknown or structurally different WIFF variants are rejected +before a dataset is created. Encrypted or newer `.wiff2` and +`.timeseries.data` input is outside this boundary and must first be converted +to mzML. + ## Workflow and run-record files An [automation](/guides/automation/) workflow file is a JSON description of a diff --git a/docs/src/content/docs/reference/shortcuts.md b/docs/src/content/docs/reference/shortcuts.md index 6a1b652..470caed 100644 --- a/docs/src/content/docs/reference/shortcuts.md +++ b/docs/src/content/docs/reference/shortcuts.md @@ -55,9 +55,16 @@ the cursor, or on the board when the cursor is over empty space. | Drag on an axis strip | Select a range on that axis to zoom into | | Double-click a plot | Reset both axes to full range | | Double-click an axis strip | Reset that axis only | -| `F` | Zoom the board to fit the selected frames (everything when nothing is selected) | +| `H` | Fit the y axis to the data visible in the current x window (the x window stays put) | +| `F` over a plot body | Fit both axes of that plot to the full data range | +| `F` elsewhere | Zoom the board to fit the selected frames (everything when nothing is selected) | | `Enter` | Zoom the board to the selected page or sheet | +`H` acts on the plot under the pointer, or on the active plot when the pointer +is elsewhere — the vertical-fit convention NMR software users expect. Both fits +are single undoable steps, and both are available from the command palette as +**Fit Plot Vertically** and **Fit Plot to Data**. + Hovering a plot body or an axis strip outlines the area the wheel will act on and names the action in its top-left corner, including which setting `Alt` + scroll wheel would change and on how many series. Where one plot draws two diff --git a/docs/src/content/docs/zh-cn/guides/craft.md b/docs/src/content/docs/zh-cn/guides/craft.md index 3e4be97..a0f6f85 100644 --- a/docs/src/content/docs/zh-cn/guides/craft.md +++ b/docs/src/content/docs/zh-cn/guides/craft.md @@ -37,25 +37,33 @@ CRAFT 直接拟合一维 NMR 采集中的原始复数 FID,并报告共振分 时每次移动十个)。需要精确边界时,可直接编辑 **Signal groups** 下的数值字段。 一个信号组可以包含多个拟合分量。分量是拟合得到的共振贡献,不是化合物鉴定,也不 -保证对应一个肉眼可见的多重峰。较宽的信号组可能被分成多个计算窗口,但分量仍会 -归在你选择的信号组下。 +保证对应一个肉眼可见的多重峰。固定建模窗口决定如何求解 FID;信号组边界只决定哪些 +已完成拟合的分量归入该信号组。 -## 高级拟合设置 +## 高级分量设置 -常规分析无需展开 **Advanced fit settings**。Conventional FID 的默认值为: +常规分析无需展开 **Advanced component settings**。Conventional FID 的默认值为: - **Minimum A/N**:3.3。较低值会保留更弱的候选分量,但也更容易拟合噪声;低于 3.3 时会标记为需要复核。 -- **Max components / fit window**:15(允许范围 1–64)。达到上限会在诊断中给出 - 警告。 -- **Linewidth range (Hz)**:0.05–10 Hz。 -- **Fit window width (Hz)**:500 Hz。它只决定宽信号组如何分段计算,不会增加 - 信号组。 +- **Maximum model order**:每个建模窗口默认为 15(允许范围 1–64)。达到上限会在 + 诊断中给出警告。 +- **Component linewidth range (Hz)**:0.05–20 Hz。该范围限制每个分量,而不是 + 一次建模所覆盖的频率范围。20 Hz 默认值是典型起点,并非对所有样品都固定不变; + 只有在采集条件和预期线形有独立依据时才应修改该范围。 + +固定建模带宽在 Conventional 中为 250 Hz,在 SSFP 中为 2000 Hz。这是一个建模窗口 +实际覆盖的频率宽度,不是线宽,也不是用于追逐定量结果的调参旋钮。一个建模窗口可以 +包含多个分量谱线,每个分量仍受独立的分量线宽范围约束。 编辑后的值可用旁边的 **Reset** 恢复为所选运行或配置的默认值。切换配置会保留已选 信号组,并载入新配置的设置。Conventional FID 始终是默认配置;PlotX 不会根据波形 猜测 SSFP。 +CRAFT 将 Bruker 采集的 `GRPDLY` 与自身 FIR 滤波严格分开。导入器/FFT 流程用 +`GRPDLY` 定义采集时间原点;499-tap CRAFT FIR 具有独立的边界瞬态,并通过相位共轭 +预充电处理。两种延迟不会互相重复补偿。 + **SSFP / interrupted FID** 配置的 **Skip initial** 默认值为 0.5 ms,并默认启用 时长 1.2 s 的 **Extend reconstructed FID**。跳过开头的数据点可以排除快速衰减的 背景;重建功能会为该配置延长模型 FID。除非实验已经完成定量验证,否则 SSFP 结果 @@ -83,11 +91,24 @@ CRAFT 直接拟合一维 NMR 采集中的原始复数 FID,并报告共振分 运行会标记为 **Stale**;请重新运行后再解读。即使计算完成,只要有警告或拟合不完整, 运行也会标记为 **Needs review**。 +CRAFT 会对每个所选信号组执行确定性的边界扰动检查,包括整体平移、两侧扩展或收缩以及 +单侧移动。振幅和比值的相对离散度必须保持在 1% 以内。未通过稳定性门槛的运行仍会保存 +完整分量表和残差供检查,但不能创建或导出可靠的定量振幅报告。 + 选择 **Export components…** 可打开标准 CSV、TSV、XLSX 或剪贴板导出对话框。在 **Signals** 中选择 **Create data table**,可在 CRAFT 卡片内创建可排序的 PlotX 表格。 创建后选择 **View data table** 查看、绘图或导出;只有需要把表格放到 board 的 sheet 上时,才选择 **Add to board**。 +## CRAFT 振幅报告 + +建模设置中的 **Minimum A/N** 是判断分量是否可信的标准。**Reports** 标签是独立的 +报告层;其中的 **Report threshold** 只决定哪些已保留分量进入报告,不会重新拟合,也 +不会改变完整分量表。**Segment width** 是围绕每个入选峰的固定总频宽(Hz);相互重叠 +的窗口会合并。报告同时显示分量振幅的直接相加值和考虑相位的相干振幅。这些值是拟合 +分量的汇总,不是频域 bin 积分。来源运行发生变化时,报告会标记为需要复核,而不会 +静默地重新计算。 + ## 解释结果 CRAFT 分量描述的是所选 FID,不会鉴定化合物,也不能替代浓度校准。进行定量指纹分析 diff --git a/docs/src/content/docs/zh-cn/guides/importing-data.md b/docs/src/content/docs/zh-cn/guides/importing-data.md index 9e95101..2315761 100644 --- a/docs/src/content/docs/zh-cn/guides/importing-data.md +++ b/docs/src/content/docs/zh-cn/guides/importing-data.md @@ -13,6 +13,7 @@ PlotX 直接读取厂商 LC–MS、NMR、XPS、AFM 与电生理格式,无需 | Bruker TopSpin | `fid` / `ser` 目录 | 1D 与 2D | | Varian/Agilent VnmrJ | `.fid` 目录 | 原始时域 1D 与常规 2D | | Waters MassLynx RAW | `.raw` 目录 | 已验证的低分辨率数据,包括 SQD2 数据 | +| SCIEX legacy WIFF | `.wiff` + `.wiff.scan` | 支持单样本与多样本 legacy 数据;两个文件必须放在一起 | | Rigaku 粉末 XRD | `.rasx`、FI `.raw`、RAS_RAW `.txt` | 衍射图样、采集元数据,以及文件提供的衰减系数 | | mzML | `.mzML` | 使用 32 位或 64 位、未压缩或 zlib 压缩数组的质心或轮廓 LC–MS 谱图 | | Bruker NanoScope AFM | `.spm` / `.pfc` | 图像、力曲线、Force Volume 与 PeakForce Capture 数据立方体 | @@ -32,7 +33,8 @@ PlotX 直接读取厂商 LC–MS、NMR、XPS、AFM 与电生理格式,无需 *Open Project…* 或 *Import Table…*。每个导入的数据集会出现在主侧栏中, 并自动放置到画板上。 文件选择器可以一次选择多个 ABF。打开文件夹时会递归导入其中所有 `.abf`、 -`.spm`、`.pfc`、`.vms`、结构化 CasaXPS `.txt` 和已识别的 `.raw` 数据包。每个 `.raw` 目录会作为一次完整采集 +`.spm`、`.pfc`、`.vms`、`.wiff`、结构化 CasaXPS `.txt` 和已识别的 `.raw` +数据包;配套的 `.wiff.scan` 不会作为独立数据集导入。每个 `.raw` 目录会作为一次完整采集 导入一次,其中的内部文件不会被当作独立数据集。对 ABF 文件,每个文件的直接 父目录名会成为可编辑的初始 cell ID。 CasaXPS `.txt` 按结构头内容识别,而不是只看扩展名;其他 `.txt` 仍进入表格导入。 @@ -56,6 +58,19 @@ CasaXPS `.txt` 按结构头内容识别,而不是只看扩展名;其他 `.tx 导入器支持小端 32 位和 64 位浮点 m/z 与强度数组,可不压缩或使用 zlib 压缩。 Numpress、大端数组以及缺少任一必需数组的谱图会使导入停止并显示错误。 +## SCIEX legacy WIFF + +请打开或拖入 `.wiff` 文件,并将文件名末尾追加 `.scan` 的配套文件放在同一 +目录,例如 `sample.wiff` 与 `sample.wiff.scan`。PlotX 会导入原始 scan ID、 +保留时间、m/z 与强度数组、可用的 precursor 信息、极性、仪器与采集开始时间 +元数据,以及每个已验证 experiment 的独立 TIC。谱图按样本与 experiment 分成独立 +的 acquisition stream,并保留 cycle 顺序,包括零 TIC 和空 DDA 槽位。重复样本名 +不会合并,会稳定显示为 `yjs_10ppm #1`、`yjs_10ppm #2` 等后缀。 + +缺少配套文件、容器无样本或 WIFF 布局未识别时,PlotX 会明确拒绝,不会创建不完整的数据集。 +暂不支持 SCIEX `.wiff2` 与 `.timeseries.data`;请先将这些采集转换为 mzML, +再在 PlotX 中打开。 + ## Rigaku 粉末 XRD 有 `.rasx` 时请优先打开该文件。PlotX 会同时读取实测 2theta、强度、衰减系数, diff --git a/docs/src/content/docs/zh-cn/guides/peaks-and-regions.md b/docs/src/content/docs/zh-cn/guides/peaks-and-regions.md index 1fcfc89..93c3485 100644 --- a/docs/src/content/docs/zh-cn/guides/peaks-and-regions.md +++ b/docs/src/content/docs/zh-cn/guides/peaks-and-regions.md @@ -10,6 +10,11 @@ description: 峰拾取与交互式区域分析。 **峰**工具按显著度(prominence)检测峰。在图上拖动阈值线即可调整检测—— 松开时重新计算峰。检测到的峰也可以手动编辑、添加和删除。 + +单击放置一个峰,标记会吸附到指针附近小半径内最高的峰顶。该半径以屏幕像素 +为准,放大视图时会随之收窄——想选中紧邻强峰的弱峰,放大后再点击即可。按住 +`Shift` 单击可完全跳过吸附,把标记放在最近的数据点上(适合肩峰)。悬停预览 +会显示标记将要落到的位置。横向拖选一段范围则拾取其中所有显著的峰。 选择**导出数据…**和**峰表**可保存或复制当前峰列表。 ## 1D NMR 积分 diff --git a/docs/src/content/docs/zh-cn/guides/processing.md b/docs/src/content/docs/zh-cn/guides/processing.md index 678cb09..f8c7921 100644 --- a/docs/src/content/docs/zh-cn/guides/processing.md +++ b/docs/src/content/docs/zh-cn/guides/processing.md @@ -21,7 +21,9 @@ XPS 为每个谱区使用独立的有序 recipe,而不是 NMR 管线。recipe 1. **相位**——自动结果不理想时,打开相位校正步骤手动调节 φ0 / φ1 并 实时预览,或切换自动算法。 2. **基线**——基线校正默认关闭;基线起伏或偏移时启用该步骤。 -3. **参考**——添加参考步骤,把已知峰定标到其化学位移位置。 +3. **参考**——添加参考步骤,把已知峰定标到其化学位移位置;源位置可以直接 + 输入,也可以在谱图上拾取。峰标记与谱图共用同一定标:修改参考步骤时, + 已有的峰标记会随坐标轴一起平移。参见[参考(定标)](#参考定标)。 2D 数据集默认启用余弦钟形切趾。真 2D 谱会按处理顺序显示两条管线:先 **F2 (direct)**,后 **F1 (indirect)**。已经变换过的数据标记为 @@ -163,6 +165,21 @@ FFT 是一个普通的 *Time to Frequency* 类型步骤,而不是列表中固 基线校正默认关闭。谱图需要时启用该步骤即可。 +## 参考(定标) + +参考步骤把整条化学位移轴平移,使当前位于 **Source** 的点读作 **Target**: +用它把标准物(TMS、DSS 或溶剂峰)定标到已知位置。从 **Add step → +Frequency domain → Reference** 添加。 + +Source 既可以直接输入,也可以在谱图上拾取:展开该步骤并点击 +**Pick position on spectrum**。悬停谱图即可预览位置——拾取会吸附到光标附近 +小窗口内最近的峰顶,因此先放大再点击就能精确落在弱峰上。按住 +Shift 可跳过吸附,直接读取光标位置。单击填入 **Source**,再在 +**Target** 中输入该峰应有的化学位移。按 Esc、折叠该步骤或切换 +数据集都会取消未使用的拾取。 + +峰标记与谱图共用同一定标,因此编辑参考步骤时已有标记会随坐标轴一起平移。 + ## 复用管线 管线(含群延迟设置)可保存为可移植的 `.plotxproc` 配方文件或命名模板, diff --git a/docs/src/content/docs/zh-cn/reference/file-formats.md b/docs/src/content/docs/zh-cn/reference/file-formats.md index 4da449a..0d6c39a 100644 --- a/docs/src/content/docs/zh-cn/reference/file-formats.md +++ b/docs/src/content/docs/zh-cn/reference/file-formats.md @@ -59,6 +59,19 @@ States 2D 数据。暂不支持处理后的谱图、3D 或 4D 实验、成像、 非均匀采样,以及除 phase 以外的参数数组。如果文件记录的维度与数据不一致, 导入也会停止。 +## SCIEX legacy WIFF + +PlotX 会同时读取 legacy `.wiff` OLE 元数据容器,以及在完整 `.wiff` 文件名 +末尾追加 `.scan` 所得到的 `.wiff.scan` payload。导入器使用纯 Rust,不要求 +安装 SCIEX Analyst、SCIEX OS 或 ProteoWizard SDK。 + +目前仅支持已验证的 legacy 布局。原生导入器会读取 sample subtree/index 流,保留 +重复样本为独立样本,并将已验证的 11 个 experiment 槽位分别提供为独立的 +acquisition stream 与 TIC channel。记录按 cycle 顺序保留,包括零 TIC 和空 DDA +槽位;不会跨 experiment 合并时间或插值。未知或结构不同的 WIFF 变体会在创建 +数据集之前被拒绝。加密或较新的 `.wiff2` 与 +`.timeseries.data` 不在此边界内,必须先转换为 mzML。 + ## 工作流与运行记录文件 [自动化](/zh-cn/guides/automation/)工作流文件是一次批处理运行的 JSON diff --git a/docs/src/content/docs/zh-cn/reference/shortcuts.md b/docs/src/content/docs/zh-cn/reference/shortcuts.md index 2f224b9..d32ef9a 100644 --- a/docs/src/content/docs/zh-cn/reference/shortcuts.md +++ b/docs/src/content/docs/zh-cn/reference/shortcuts.md @@ -52,9 +52,15 @@ description: 键盘与鼠标快捷操作。 | 在坐标轴带上拖动 | 框选该轴的范围并缩放至所选区间 | | 双击图内 | 双轴恢复完整范围 | | 双击坐标轴带 | 仅恢复该轴 | -| `F` | 缩放画板以适配所选图框(未选中时适配全部) | +| `H` | 按当前 X 窗口内可见数据的强度范围适配 Y 轴(X 窗口保持不变) | +| `F`(指针在图内) | 该图双轴适配到完整数据范围 | +| `F`(指针在其它位置) | 缩放画板以适配所选图框(未选中时适配全部) | | `Enter` | 缩放画板至所选页面或工作表 | +`H` 作用于指针所在的图;指针不在任何图内时作用于当前活动的图——这正是 NMR 软件 +用户熟悉的纵向适配习惯。两种适配都是单个可撤销步骤,也都能在命令面板中找到 +(**Fit Plot Vertically** 与 **Fit Plot to Data**)。 + 光标悬停在图内或坐标轴带上时,PlotX 会勾出滚轮将要作用的区域,并在其左上角写明 操作,包括 `Alt` + 鼠标滚轮会改哪一项设置、涉及多少条谱线。若同一幅图画了两个各自带 显示参数的图层(例如等高线覆盖在热图上),`Alt` + 鼠标滚轮不会去猜你指的是哪一层, diff --git a/signatures/version1/cla.json b/signatures/version1/cla.json deleted file mode 100644 index 18d5487..0000000 --- a/signatures/version1/cla.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "signedContributors": [] -} \ No newline at end of file