diff --git a/Cargo.lock b/Cargo.lock index f81c5d07..6550dedf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3421,6 +3421,19 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "nmr" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5749f9cb01773d2df50a3824c4591af5367da58daeaf9dd199d051a1358fda9c" +dependencies = [ + "num-complex", + "rustfft", + "sha2", + "tempfile", + "thiserror 2.0.18", +] + [[package]] name = "no-std-compat" version = "0.4.1" @@ -4136,6 +4149,7 @@ dependencies = [ "libc", "log", "muda", + "nmr", "num-complex", "plotx-analysis", "plotx-core", @@ -4170,10 +4184,12 @@ dependencies = [ name = "plotx-cli" version = "0.1.0" dependencies = [ + "nmr", "plotx-core", "plotx-io", "plotx-processing", "serde_json", + "tempfile", ] [[package]] @@ -4182,6 +4198,7 @@ version = "0.1.0" dependencies = [ "directories", "image", + "nmr", "num-complex", "pdf-writer", "plotx-analysis", @@ -4249,6 +4266,7 @@ dependencies = [ "flate2", "image", "memmap2", + "nmr", "num-complex", "quick-xml", "rust_xlsxwriter", @@ -4265,10 +4283,10 @@ dependencies = [ name = "plotx-processing" version = "0.1.0" dependencies = [ + "nmr", "num-complex", "plotx-analysis", "plotx-io", - "rustfft", "serde", "thiserror 2.0.18", ] @@ -4894,9 +4912,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.42" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "log", "once_cell", @@ -4918,9 +4936,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "ring", "rustls-pki-types", diff --git a/Cargo.toml b/Cargo.toml index 546408cd..f5b22ac0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,10 +38,10 @@ publish = false repository = "https://github.com/nmrtist/plotx" [workspace.dependencies] +nmr = "=0.1.0" colorous = "1" num-complex = "0.4" nalgebra = { version = "0.35", default-features = false, features = ["std"] } -rustfft = "6.4" serde = { version = "1.0", features = ["derive", "rc"] } serde_json = { version = "1.0", features = ["float_roundtrip"] } semver = "1" @@ -123,7 +123,10 @@ opt-level = 3 [profile.dev.package.plotx-processing] opt-level = 3 -# rustfft is the only third-party dependency that needs optimized debug code. +[profile.dev.package.nmr] +opt-level = 3 + +# Optimize the shared FFT backend as well as its callers. [profile.dev.package.rustfft] opt-level = 2 diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 8e2ab206..63dd75c9 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -17,6 +17,7 @@ default = [] datafusion = ["plotx-core/datafusion"] [dependencies] +nmr.workspace = true plotx-core.workspace = true plotx-analysis.workspace = true plotx-io.workspace = true diff --git a/crates/app/src/shot.rs b/crates/app/src/shot.rs index df996dc3..e1839851 100644 --- a/crates/app/src/shot.rs +++ b/crates/app/src/shot.rs @@ -516,7 +516,9 @@ fn setup(app: &mut PlotxApp) { let data = synthetic_fid(); let action = Action::insert_dataset_with_default_canvas( app, - Dataset::Nmr(Box::new(NmrDataset::load(data))), + Dataset::Nmr(Box::new( + NmrDataset::load(data).expect("the synthetic FID is valid"), + )), "Canvas 1 — synthetic".to_owned(), DEFAULT_CANVAS_SIZE_MM, ); @@ -553,7 +555,7 @@ fn line_fit(app: &mut PlotxApp, ctx: &egui::Context) -> Result<(), String> { fn symmetry_setup(app: &mut PlotxApp) -> Result<(), String> { *app = PlotxApp::new_with_settings(Settings::default()); - let mut dataset = Nmr2DDataset::load(synthetic_cosy()); + let mut dataset = Nmr2DDataset::load(synthetic_cosy()).expect("valid synthetic 2D acquisition"); let ids = dataset .peaks .add_pair( @@ -587,7 +589,8 @@ fn symmetry_setup(app: &mut PlotxApp) -> Result<(), String> { fn region_result(app: &mut PlotxApp) { *app = PlotxApp::new_with_settings(Settings::default()); - let mut dataset = Nmr2DDataset::load(synthetic_series()); + let mut dataset = + Nmr2DDataset::load(synthetic_series()).expect("valid synthetic 2D acquisition"); dataset.region_analysis.regions.push(Region { id: RegionId::new(0), lo: 4.65, diff --git a/crates/app/src/shot/craft_shot.rs b/crates/app/src/shot/craft_shot.rs index d1529130..2e9358c8 100644 --- a/crates/app/src/shot/craft_shot.rs +++ b/crates/app/src/shot/craft_shot.rs @@ -13,8 +13,9 @@ pub(super) fn setup(app: &mut PlotxApp, ctx: &egui::Context) -> Result<(), Strin .clone(); let mut params = CraftParams::conventional(); params.maximum_model_order = 8; - let invocation = CraftInvocation::acquisition(&data, params); - let result = process_craft_cancellable(&data, &invocation, &|| false) + let fid = data.craft_fid().map_err(|error| error.to_string())?; + let invocation = CraftInvocation::acquisition(&fid, params); + let result = process_craft_cancellable(&fid, &invocation, &|| false) .map_err(|error| format!("CRAFT screenshot analysis failed: {error}"))?; let nmr = app.doc.datasets[0] .as_nmr_mut() diff --git a/crates/app/src/ui/canvas/craft_regions.rs b/crates/app/src/ui/canvas/craft_regions.rs index 7cb25707..2c157a75 100644 --- a/crates/app/src/ui/canvas/craft_regions.rs +++ b/crates/app/src/ui/canvas/craft_regions.rs @@ -19,9 +19,14 @@ pub(crate) fn handle_craft_region_drag( } let acquired_bounds = nmr.spectrum().unwrap().ppm_bounds(); let dataset_id = nmr.resource_id; - let observe_freq = nmr.data.observe_freq_mhz.max(f64::MIN_POSITIVE); - let point_step = - nmr.data.spectral_width_hz.abs() / observe_freq / nmr.data.points.len().max(1) as f64; + let Some(reference) = nmr.craft_reference() else { + return; + }; + let Some(width) = nmr.data.axes()[0].spectral_width_hz else { + return; + }; + let observe_freq = reference.reference_frequency_mhz; + let point_step = width / observe_freq / nmr.data.len() as f64; let suggestions = app .session .ui diff --git a/crates/app/src/ui/canvas/craft_results.rs b/crates/app/src/ui/canvas/craft_results.rs index 8cf2b3c5..36ecefa8 100644 --- a/crates/app/src/ui/canvas/craft_results.rs +++ b/crates/app/src/ui/canvas/craft_results.rs @@ -50,7 +50,6 @@ pub(crate) fn handle_and_paint_craft_result( dataset, run, stored, - nmr, plot, figure, painter, @@ -119,7 +118,6 @@ struct CraftRangePaintContext<'a> { 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, @@ -132,7 +130,6 @@ fn paint_craft_ranges(context: CraftRangePaintContext<'_>) { dataset, run, stored, - nmr, plot, figure, painter, @@ -143,7 +140,11 @@ fn paint_craft_ranges(context: CraftRangePaintContext<'_>) { .invocation .reference .effective_carrier_ppm(); - let observe = nmr.data.observe_freq_mhz; + let observe = stored + .provenance + .invocation + .reference + .reference_frequency_mhz; let modeling = stored .diagnostics .modeling_windows diff --git a/crates/app/src/ui/canvas/cursors.rs b/crates/app/src/ui/canvas/cursors.rs index 2ff4007d..0b979fa7 100644 --- a/crates/app/src/ui/canvas/cursors.rs +++ b/crates/app/src/ui/canvas/cursors.rs @@ -362,13 +362,29 @@ fn inspect_text(app: &PlotxApp, dataset: usize, point: CursorPoint) -> String { return String::new(); }; match data { - Dataset::Nmr(_) => format!("x {:.4} ppm · I {}", point.x, fmt_number(point.intensity)), - Dataset::Nmr2D(_) => format!( - "F2 {:.4} ppm · F1 {:.4} ppm · I {}", - point.x, - point.y.unwrap_or_default(), - fmt_number(point.intensity), - ), + Dataset::Nmr(n) => { + let unit = n + .spectrum() + .map_or("s", |s| plotx_processing::axis_unit_label(Some(s.unit))); + format!( + "x {:.4} {unit} · I {}", + point.x, + fmt_number(point.intensity) + ) + } + Dataset::Nmr2D(n) => { + let Processed2D::Ft(s) = &n.processed else { + return String::new(); + }; + format!( + "F2 {:.4} {} · F1 {:.4} {} · I {}", + point.x, + s.direct.unit_label(), + point.y.unwrap_or_default(), + s.indirect.unit_label(), + fmt_number(point.intensity) + ) + } _ => String::new(), } } @@ -380,24 +396,41 @@ fn delta_text(app: &PlotxApp, dataset: usize, delta: CursorDelta) -> String { let dx = delta.second.x - delta.first.x; let di = delta.second.intensity - delta.first.intensity; match data { - Dataset::Nmr(nmr) => format!( - "Δx {} ppm ({} Hz) · ΔI {}", - fmt_delta(dx), - fmt_delta(dx * nmr.data.observe_freq_mhz), - fmt_number(di), - ), + Dataset::Nmr(nmr) => { + if let Some(spectrum) = nmr.spectrum() { + if spectrum.unit == nmr::axis::AxisUnit::Hertz { + format!("Δx {} Hz · ΔI {}", fmt_delta(dx), fmt_number(di)) + } else { + let hz = nmr + .native_processed + .reference_frequency_mhz(0) + .map(|frequency| format!(" ({} Hz)", fmt_delta(dx * frequency))) + .unwrap_or_default(); + format!("Δx {} ppm{hz} · ΔI {}", fmt_delta(dx), fmt_number(di)) + } + } else { + format!("Δt {} s · ΔI {}", fmt_delta(dx), fmt_number(di)) + } + } Dataset::Nmr2D(nmr) => { let Processed2D::Ft(spectrum) = &nmr.processed else { return String::new(); }; let dy = delta.second.y.unwrap_or_default() - delta.first.y.unwrap_or_default(); + let axis_delta = |value: f64, meta: &plotx_processing::AxisMeta| { + let unit = match meta.unit { + Some(nmr::axis::AxisUnit::Second) => "s", + Some(nmr::axis::AxisUnit::Hertz) => "Hz", + Some(nmr::axis::AxisUnit::Ppm) => "ppm", + _ => "", + }; + format!("{} {unit}", fmt_delta(value)) + }; format!( - "ΔF2 {} ppm ({} Hz) · ΔF1 {} ppm ({} Hz) · ΔI {}", - fmt_delta(dx), - fmt_delta(dx * spectrum.direct.observe_freq_mhz), - fmt_delta(dy), - fmt_delta(dy * spectrum.indirect.observe_freq_mhz), - fmt_number(di), + "ΔF2 {} · ΔF1 {} · ΔI {}", + axis_delta(dx, &spectrum.direct), + axis_delta(dy, &spectrum.indirect), + fmt_number(di) ) } _ => String::new(), diff --git a/crates/app/src/ui/canvas/mod_tests.rs b/crates/app/src/ui/canvas/mod_tests.rs index 282a0482..1192ae75 100644 --- a/crates/app/src/ui/canvas/mod_tests.rs +++ b/crates/app/src/ui/canvas/mod_tests.rs @@ -137,7 +137,7 @@ fn phase_editor_open_drives_on_plot_pivot() { let mut app = PlotxApp::new(); app.doc .datasets - .push(Dataset::Nmr(Box::new(NmrDataset::load(data)))); + .push(Dataset::Nmr(Box::new(NmrDataset::load(data).unwrap()))); let mut canvas = CanvasDocument::new("page".to_owned(), [200.0, 200.0]); let id = canvas.allocate_object_id(); let obj = app.build_plot_object( diff --git a/crates/app/src/ui/canvas/reference_pick_tests.rs b/crates/app/src/ui/canvas/reference_pick_tests.rs index b199883f..d729f4f6 100644 --- a/crates/app/src/ui/canvas/reference_pick_tests.rs +++ b/crates/app/src/ui/canvas/reference_pick_tests.rs @@ -30,7 +30,7 @@ fn synthetic_app() -> PlotxApp { let mut app = PlotxApp::new(); app.doc .datasets - .push(Dataset::Nmr(Box::new(NmrDataset::load(data)))); + .push(Dataset::Nmr(Box::new(NmrDataset::load(data).unwrap()))); app.focus_single(0); app } diff --git a/crates/app/src/ui/canvas/slices.rs b/crates/app/src/ui/canvas/slices.rs index 48b4855b..5b421f75 100644 --- a/crates/app/src/ui/canvas/slices.rs +++ b/crates/app/src/ui/canvas/slices.rs @@ -110,14 +110,30 @@ pub(crate) fn paint_slice( }; // A Row cut runs along F2 (the plot's x-axis); a Column cut along F1 (y). - let (slice, along_x, mode) = match &n.processed { - Processed2D::Ft(s) => ( - s.slice(cursor.kind, cursor.index), - cursor.kind == SliceKind::Row, - DisplayMode::Real, - ), - Processed2D::Stack(s) => (s.slice(cursor.index), true, DisplayMode::Real), + let kind = if matches!(n.processed, Processed2D::Stack(_)) { + SliceKind::Row + } else { + cursor.kind + }; + let (_, slice) = match plotx_processing::slice::extract( + &n.native_processed, + kind, + plotx_processing::slice::Reduction::Slice(cursor.index), + ) { + Ok(output) => output, + Err(error) => { + painter.text( + Pos2::new(plot.left + 8.0, plot.top + 8.0), + egui::Align2::LEFT_TOP, + format!("Slice unavailable: {error}"), + egui::FontId::proportional(12.0), + SLICE_COLOR, + ); + return; + } }; + let along_x = kind == SliceKind::Row; + let mode = DisplayMode::Real; if let Some(position) = slice.position { if along_x { diff --git a/crates/app/src/ui/clipboard_figure.rs b/crates/app/src/ui/clipboard_figure.rs index 47afd819..1c7447c1 100644 --- a/crates/app/src/ui/clipboard_figure.rs +++ b/crates/app/src/ui/clipboard_figure.rs @@ -401,7 +401,7 @@ mod tests { let mut app = plotx_core::state::PlotxApp::new(); let action = Action::insert_dataset_with_default_canvas( &app, - Dataset::Nmr(Box::new(NmrDataset::load(data))), + Dataset::Nmr(Box::new(NmrDataset::load(data).unwrap())), "probe".to_owned(), DEFAULT_CANVAS_SIZE_MM, ); diff --git a/crates/app/src/ui/command_exec.rs b/crates/app/src/ui/command_exec.rs index 7931ba5c..1daf55ee 100644 --- a/crates/app/src/ui/command_exec.rs +++ b/crates/app/src/ui/command_exec.rs @@ -51,6 +51,7 @@ fn execute_inner( } CommandId::OpenFile => super::file_dialogs::open_file(app), CommandId::OpenFolder => super::file_dialogs::open_folder(app), + CommandId::ImportNmrSampling => super::file_dialogs::nmr_sampling::open(app), CommandId::RunBatchWorkflow => super::batch_workflow::AutomationUi::request_open(ctx), CommandId::RunScientificScript => { super::batch_workflow::AutomationUi::request_run_script(ctx) diff --git a/crates/app/src/ui/commands.rs b/crates/app/src/ui/commands.rs index 917cd73d..58231338 100644 --- a/crates/app/src/ui/commands.rs +++ b/crates/app/src/ui/commands.rs @@ -30,6 +30,7 @@ pub enum CommandId { CloseProject, OpenFile, OpenFolder, + ImportNmrSampling, RunBatchWorkflow, RunScientificScript, OpenRecent(usize), @@ -258,6 +259,10 @@ pub fn describe(app: &PlotxApp, id: CommandId) -> CommandDescriptor { app.session.ui.table_import_preview.is_none(), "Finish or cancel the current table import preview before importing another table.", ), + CommandId::ImportNmrSampling => requires( + app.session.ui.nmr_import.is_none(), + "Finish or cancel the current NMR import before importing another acquisition.", + ), CommandId::ImportImage | CommandId::ImportImageFirstFrame | CommandId::ImportImageWithoutMetadata diff --git a/crates/app/src/ui/commands/craft.rs b/crates/app/src/ui/commands/craft.rs index f4599f99..4f54ddb3 100644 --- a/crates/app/src/ui/commands/craft.rs +++ b/crates/app/src/ui/commands/craft.rs @@ -14,7 +14,7 @@ pub(super) fn gate(app: &PlotxApp, command: CommandId) -> Result<(), &'static st app.active_dataset().is_some_and(|index| { app.doc.datasets[index] .as_nmr() - .is_some_and(|nmr| nmr.data.domain == plotx_io::Domain::Time) + .is_some_and(|nmr| nmr.input_domain() == plotx_io::Domain::Time) }), "Select a one-dimensional time-domain NMR FID before opening CRAFT.", ), @@ -22,7 +22,7 @@ pub(super) fn gate(app: &PlotxApp, command: CommandId) -> Result<(), &'static st target.is_some_and(|index| { app.doc.datasets[index] .as_nmr() - .is_some_and(|nmr| nmr.data.domain == plotx_io::Domain::Time) + .is_some_and(|nmr| nmr.input_domain() == plotx_io::Domain::Time) }), "Open CRAFT for a one-dimensional time-domain NMR FID before running it.", ) @@ -32,15 +32,19 @@ pub(super) fn gate(app: &PlotxApp, command: CommandId) -> Result<(), &'static st if let Some(cache) = &app.session.ui.craft_resolution_cache && cache.dataset == nmr.resource_id && cache.dataset_epoch == app.session.dataset_epoch - && cache.reference == nmr.craft_reference() + && Some(cache.reference) == nmr.craft_reference() && cache.overrides == app.session.ui.craft_overrides && cache.parent_run == app.session.ui.craft_base_run { return cache.invocation.assessment.can_run(); } + let (Ok(data), Some(reference)) = (nmr.data.craft_fid(), nmr.craft_reference()) + else { + return false; + }; plotx_processing::craft::resolve_craft_invocation( - &nmr.data, - nmr.craft_reference(), + &data, + reference, &app.session.ui.craft_overrides, app.session .ui @@ -58,9 +62,13 @@ pub(super) fn gate(app: &PlotxApp, command: CommandId) -> Result<(), &'static st .and_then(|()| { let selected_count = target.map_or(0, |index| { let nmr = app.doc.datasets[index].as_nmr().unwrap(); + let (Ok(data), Some(reference)) = (nmr.data.craft_fid(), nmr.craft_reference()) + else { + return 0; + }; let invocation = plotx_processing::craft::resolve_craft_invocation( - &nmr.data, - nmr.craft_reference(), + &data, + reference, &app.session.ui.craft_overrides, app.session .ui diff --git a/crates/app/src/ui/commands/identity.rs b/crates/app/src/ui/commands/identity.rs index 732c95b5..a7727acb 100644 --- a/crates/app/src/ui/commands/identity.rs +++ b/crates/app/src/ui/commands/identity.rs @@ -51,6 +51,7 @@ pub(super) fn command_identity( CommandId::CloseProject => plain("Close Project", Some(icon::X)), CommandId::OpenFile => plain("Open File…", Some(icon::FILE)), CommandId::OpenFolder => plain("Open Folder…", Some(icon::FOLDER)), + CommandId::ImportNmrSampling => plain("Import NMR with Sampling Table…", Some(icon::FILE)), CommandId::RunBatchWorkflow => plain("Automation…", Some(icon::PLAY)), CommandId::RunScientificScript => plain("Run Scientific Script", Some(icon::PLAY)), CommandId::OpenRecent(i) => ( @@ -416,6 +417,7 @@ fn simple_stable_id(id: CommandId) -> &'static str { CommandId::CloseProject => "file.close_project", CommandId::OpenFile => "file.open_file", CommandId::OpenFolder => "file.open_folder", + CommandId::ImportNmrSampling => "file.import_nmr_sampling", CommandId::RunBatchWorkflow => "tools.automation", CommandId::RunScientificScript => "tools.run_scientific_script", CommandId::ImportTable => "file.import_table", diff --git a/crates/app/src/ui/commands/roster.rs b/crates/app/src/ui/commands/roster.rs index 24812cea..e4469162 100644 --- a/crates/app/src/ui/commands/roster.rs +++ b/crates/app/src/ui/commands/roster.rs @@ -17,6 +17,7 @@ pub(super) fn command_ids(recent_files: usize) -> Vec { CommandId::CloseProject, CommandId::OpenFile, CommandId::OpenFolder, + CommandId::ImportNmrSampling, CommandId::RunBatchWorkflow, CommandId::RunScientificScript, CommandId::ClearRecentFiles, diff --git a/crates/app/src/ui/commands_craft_tests.rs b/crates/app/src/ui/commands_craft_tests.rs index d54442ac..ede3acd0 100644 --- a/crates/app/src/ui/commands_craft_tests.rs +++ b/crates/app/src/ui/commands_craft_tests.rs @@ -28,7 +28,9 @@ fn craft_command_opens_a_task_for_the_original_time_domain_fid() { fn craft_warning_does_not_disable_run() { let mut app = app_with_nmr(); let nmr = app.doc.datasets[0].as_nmr_mut().unwrap(); - nmr.data.points.fill(num_complex::Complex64::new(0.0, 0.0)); + let mut input = nmr.data.craft_fid().unwrap(); + input.points.fill(num_complex::Complex64::new(0.0, 0.0)); + nmr.data = input.try_into().unwrap(); execute_without_clipboard(CommandId::Craft, &mut app, &egui::Context::default()); use_short_fixture_filter(&mut app); @@ -40,7 +42,9 @@ fn craft_warning_does_not_disable_run() { fn craft_hard_preflight_error_disables_run() { let mut app = app_with_nmr(); let nmr = app.doc.datasets[0].as_nmr_mut().unwrap(); - nmr.data.group_delay = nmr.data.points.len().saturating_sub(8) as f64; + let mut input = nmr.data.craft_fid().unwrap(); + input.group_delay = input.points.len().saturating_sub(8) as f64; + nmr.data = input.try_into().unwrap(); execute_without_clipboard(CommandId::Craft, &mut app, &egui::Context::default()); use_short_fixture_filter(&mut app); diff --git a/crates/app/src/ui/commands_tests.rs b/crates/app/src/ui/commands_tests.rs index 7a6a3bef..8fc69a93 100644 --- a/crates/app/src/ui/commands_tests.rs +++ b/crates/app/src/ui/commands_tests.rs @@ -70,7 +70,7 @@ pub(super) fn app_with_nmr() -> PlotxApp { }; let action = Action::insert_dataset_with_default_canvas( &app, - Dataset::Nmr(Box::new(NmrDataset::load(data))), + Dataset::Nmr(Box::new(NmrDataset::load(data).unwrap())), "Canvas — 1D NMR".to_owned(), DEFAULT_CANVAS_SIZE_MM, ); @@ -127,7 +127,7 @@ fn time_domain_nmr_hides_frequency_analysis_and_disables_spectral_commands() { | plotx_processing::StepKind::Invert ) }); - dataset.retransform(); + dataset.retransform().unwrap(); assert!( !app.doc.datasets[0] diff --git a/crates/app/src/ui/data_sheet.rs b/crates/app/src/ui/data_sheet.rs index 5c74352a..aed3ee4f 100644 --- a/crates/app/src/ui/data_sheet.rs +++ b/crates/app/src/ui/data_sheet.rs @@ -140,24 +140,16 @@ pub(super) fn data_sheet_window(app: &mut PlotxApp, ctx: &egui::Context) { pub(super) fn nmr2d_sheet(ui: &mut Ui, n: &plotx_core::state::Nmr2DDataset) { let d = &n.data; - ui.label(format!( - "{} × {} points · indirect quadrature {:?}", - d.cols, d.rows, d.quad - )); - ui.label(format!( - "Direct (F2): {} · {:.3} MHz · SW {:.0} Hz · carrier {:.2} ppm", - d.direct.nucleus, - d.direct.observe_freq_mhz, - d.direct.spectral_width_hz, - d.direct.carrier_ppm - )); - ui.label(format!( - "Indirect (F1): {} · {:.3} MHz · SW {:.0} Hz · carrier {:.2} ppm", - d.indirect.nucleus, - d.indirect.observe_freq_mhz, - d.indirect.spectral_width_hz, - d.indirect.carrier_ppm - )); + ui.label(format!("{} × {} points (F2 × F1)", d.cols, d.rows)); + for (name, axis) in [("Direct (F2)", &d.direct), ("Indirect (F1)", &d.indirect)] { + ui.label(format!("{name}: {} · {:?}", axis.nucleus, axis.domain)); + if let Some(frequency) = axis.observe_freq_mhz { + ui.label(format!("Observe frequency: {frequency:.3} MHz")); + } + if let Some(width) = axis.spectral_width_hz { + ui.label(format!("Spectral width: {width:.0} Hz")); + } + } if let Some(exp) = &d.experiment { ui.label(format!("Experiment hint: {exp}")); } @@ -167,8 +159,8 @@ pub(super) fn nmr2d_sheet(ui: &mut Ui, n: &plotx_core::state::Nmr2DDataset) { let (f2lo, f2hi) = s.f2_bounds(); let (f1lo, f1hi) = s.f1_bounds(); ui.label(format!( - "Contour spectrum {}×{} (F1×F2) — F2 {f2lo:.2}..{f2hi:.2} ppm, F1 {f1lo:.2}..{f1hi:.2} ppm", - s.f1_size, s.f2_size + "Contour spectrum {}×{} (F1×F2) — F2 {f2lo:.2}..{f2hi:.2} {}, F1 {f1lo:.2}..{f1hi:.2} {}", + s.f1_size, s.f2_size, s.direct.unit_label(), s.indirect.unit_label() )); } plotx_processing::Processed2D::Stack(s) => { @@ -182,7 +174,7 @@ pub(super) fn nmr2d_sheet(ui: &mut Ui, n: &plotx_core::state::Nmr2DDataset) { pub(super) fn nmr_sheet(ui: &mut Ui, n: &plotx_core::state::NmrDataset) { let len = n.processed.values().len(); - ui.label(format!("{} · {} pts", n.data.nucleus, len)); + ui.label(format!("{} · {} pts", n.data.nucleus(), len)); ui.separator(); let columns: Vec<(String, Vec)> = match &n.processed { @@ -195,7 +187,10 @@ pub(super) fn nmr_sheet(ui: &mut Ui, n: &plotx_core::state::NmrDataset) { ), ], plotx_processing::Processed1D::Frequency(spec) => vec![ - ("ppm".to_owned(), spec.ppm.clone()), + ( + plotx_processing::axis_unit_label(Some(spec.unit)).to_owned(), + spec.ppm.clone(), + ), ("Real".to_owned(), spec.real()), ( "Imag".to_owned(), diff --git a/crates/app/src/ui/file_dialogs.rs b/crates/app/src/ui/file_dialogs.rs index 91739898..e023679e 100644 --- a/crates/app/src/ui/file_dialogs.rs +++ b/crates/app/src/ui/file_dialogs.rs @@ -9,6 +9,7 @@ use plotx_core::state::ProcessingSchemeDialogState; mod delimited; mod discovery; pub(crate) mod image_import; +pub(crate) mod nmr_sampling; mod origin; mod path; mod preview; @@ -365,11 +366,16 @@ where } pub(crate) fn load_and_note(app: &mut PlotxApp, path: &std::path::Path) { - let before = app.doc.datasets.len(); - app.load_from(path); - if app.doc.datasets.len() > before { - app.note_recent_file(path); + if plotx_io::archive::is_zip(path) { + let before = app.doc.datasets.len(); + app.load_from(path); + if app.doc.datasets.len() > before { + app.note_recent_file(path); + } + return; } + let selected = path.to_owned(); + app.queue_data_import(selected.clone(), move || Ok(vec![selected])); } pub(crate) fn open_file(app: &mut PlotxApp) { @@ -440,30 +446,8 @@ pub(crate) fn open_folder(app: &mut PlotxApp) { /// flush every other entry out of the capped list. The folder is noted when /// any file of the batch loaded, not just the last one. fn open_folder_path(app: &mut PlotxApp, path: &std::path::Path) { - let before = app.doc.datasets.len(); - let mut data_files = Vec::new(); - discovery::collect_data_files(path, &mut data_files); - if data_files.is_empty() { - app.load_from(path); - } else { - data_files.sort(); - let companion_paths: std::collections::HashSet = data_files - .iter() - .filter(|file| { - file.extension() - .is_some_and(|ext| ext.eq_ignore_ascii_case("pfc")) - }) - .filter_map(|file| plotx_io::load_path(file).ok()) - .flat_map(|loaded| loaded.provenance.companion_paths) - .collect(); - data_files.retain(|file| !companion_paths.contains(file)); - for file in data_files { - app.load_from(&file); - } - } - if app.doc.datasets.len() > before { - app.note_recent_file(path); - } + let folder = path.to_owned(); + app.queue_data_import(folder.clone(), move || discovery::discover_folder(&folder)); } pub(crate) fn choose_export_path(settings: &ExportSettings) -> Option { diff --git a/crates/app/src/ui/file_dialogs/discovery.rs b/crates/app/src/ui/file_dialogs/discovery.rs index 3e998d5f..d3f9d69d 100644 --- a/crates/app/src/ui/file_dialogs/discovery.rs +++ b/crates/app/src/ui/file_dialogs/discovery.rs @@ -1,26 +1,18 @@ use std::path::{Path, PathBuf}; -pub(super) fn collect_data_files(folder: &Path, output: &mut Vec) { +pub(super) fn collect_data_files(folder: &Path, output: &mut Vec) -> std::io::Result<()> { // Vendor acquisition directories are atomic. Their payload files must // never be rediscovered as independent datasets. - if plotx_io::waters::is_masslynx_raw(folder) - || plotx_io::bruker::detect_processed(folder).is_some() - || plotx_io::bruker::is_bruker_dir(folder) - || plotx_io::varian::is_varian(folder) - { + if plotx_io::waters::is_masslynx_raw(folder) || plotx_io::nmr_bridge::is_candidate(folder) { output.push(folder.to_owned()); - return; + return Ok(()); } - let Ok(entries) = std::fs::read_dir(folder) else { - return; - }; - for entry in entries.flatten() { + for entry in std::fs::read_dir(folder)? { + let entry = entry?; let path = entry.path(); - let Ok(kind) = entry.file_type() else { - continue; - }; + let kind = entry.file_type()?; if kind.is_dir() && !kind.is_symlink() { - collect_data_files(&path, output); + collect_data_files(&path, output)?; } else if kind.is_file() { let extension = path .extension() @@ -33,11 +25,43 @@ pub(super) fn collect_data_files(folder: &Path, output: &mut Vec) { extension.eq_ignore_ascii_case("raw") && plotx_io::xrd::is_rigaku_raw(&path); let recognized_casaxps = extension.eq_ignore_ascii_case("txt") && plotx_io::xps::is_casaxps_text(&path); - if supported_extension || recognized_raw || recognized_casaxps { + if supported_extension + || recognized_raw + || recognized_casaxps + || plotx_io::nmr_bridge::is_candidate(&path) + { output.push(path); } } } + Ok(()) +} + +/// Keep companion files out of the batch while doing all discovery I/O off-thread. +pub(super) fn discover_folder(folder: &Path) -> Result, String> { + let mut files = Vec::new(); + collect_data_files(folder, &mut files).map_err(|error| error.to_string())?; + if files.is_empty() { + return Ok(vec![folder.to_owned()]); + } + files.sort(); + let mut companions = std::collections::HashSet::new(); + for file in &files { + if file + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("pfc")) + { + // The actual import will surface a failed probe as a per-file error. + match plotx_io::load_path(file) { + Ok(loaded) => companions.extend(loaded.provenance.companion_paths), + Err(error) => { + log::warn!("Companion discovery failed for {}: {error}", file.display()) + } + } + } + } + files.retain(|file| !companions.contains(file)); + Ok(files) } #[cfg(test)] @@ -54,7 +78,7 @@ mod tests { std::fs::write(root.join("_FUNC001.IDX"), vec![0; 22]).unwrap(); std::fs::write(root.join("_FUNC001.DAT"), []).unwrap(); let mut found = Vec::new(); - collect_data_files(&root, &mut found); + collect_data_files(&root, &mut found).unwrap(); assert_eq!(found.as_slice(), std::slice::from_ref(&root)); std::fs::remove_dir_all(root).unwrap(); } @@ -71,7 +95,7 @@ mod tests { std::fs::write(&unrelated, b"not an XRD file").unwrap(); let mut found = Vec::new(); - collect_data_files(&root, &mut found); + collect_data_files(&root, &mut found).unwrap(); assert_eq!(found, vec![xrd]); std::fs::remove_dir_all(root).unwrap(); @@ -87,7 +111,7 @@ mod tests { std::fs::write(dataset.join("fid"), [0; 32]).unwrap(); let mut found = Vec::new(); - collect_data_files(&root, &mut found); + collect_data_files(&root, &mut found).unwrap(); assert_eq!(found, vec![dataset]); std::fs::remove_dir_all(root).unwrap(); @@ -105,7 +129,7 @@ mod tests { std::fs::write(root.join("sample.timeseries.data"), b"data").unwrap(); let mut found = Vec::new(); - collect_data_files(&root, &mut found); + collect_data_files(&root, &mut found).unwrap(); assert_eq!(found, vec![wiff]); std::fs::remove_dir_all(root).unwrap(); diff --git a/crates/app/src/ui/file_dialogs/nmr_sampling.rs b/crates/app/src/ui/file_dialogs/nmr_sampling.rs new file mode 100644 index 00000000..7b4a7bdb --- /dev/null +++ b/crates/app/src/ui/file_dialogs/nmr_sampling.rs @@ -0,0 +1,72 @@ +use plotx_core::state::{NmrImportDraft, PlotxApp}; + +pub(crate) fn open(app: &mut PlotxApp) { + if let Some(path) = rfd::FileDialog::new() + .set_title("Select a Bruker ser or JEOL JDF acquisition") + .add_filter("NMR acquisition", &["ser", "jdf"]) + .add_filter("All files", &["*"]) + .pick_file() + { + app.session.ui.nmr_import = Some(NmrImportDraft::new(path)); + } +} + +pub(crate) fn window(app: &mut PlotxApp, ctx: &egui::Context) { + let Some(mut draft) = app.session.ui.nmr_import.take() else { + return; + }; + let mut import = false; + let mut cancel = false; + let modal = super::super::modal(ctx, "nmr_sampling_import", super::super::ModalKind::Dialog) + .show(ctx, |ui| { + ui.set_width(520.0); + ui.heading("Import NMR with sampling table"); + ui.label(draft.path.display().to_string()); + ui.label("For 2D Bruker NUS or JEOL reduced-grid acquisitions. Supply the original acquisition grid and observation order."); + ui.separator(); + egui::Grid::new("nmr_sampling_fields").num_columns(2).show(ui, |ui| { + ui.label("Table source / explanation"); + ui.text_edit_singleline(&mut draft.source); + ui.end_row(); + ui.label("Original indirect grid points"); + ui.text_edit_singleline(&mut draft.grid); + ui.end_row(); + ui.label("Lanes per observation"); + ui.text_edit_singleline(&mut draft.lanes); + ui.end_row(); + ui.label("Index base"); + ui.horizontal(|ui| { + ui.radio_value(&mut draft.one_based, Some(false), "Zero-based"); + ui.radio_value(&mut draft.one_based, Some(true), "One-based"); + }); + ui.end_row(); + }); + ui.label("Indirect index: one observation per line, including repeats"); + egui::ScrollArea::vertical().max_height(180.0).show(ui, |ui| { + ui.add(egui::TextEdit::multiline(&mut draft.rows).desired_rows(6).desired_width(f32::INFINITY)); + }); + ui.label("Each row includes all lanes. The table must agree with the acquisition and any embedded list. Repeated observations are preserved; IST rejects repeats."); + if let Some(error) = &draft.error { + ui.colored_label(ui.visuals().error_fg_color, error); + } + ui.horizontal(|ui| { + import = ui.button("Validate and import").clicked(); + cancel = ui.button("Cancel").clicked(); + }); + }); + if import { + match draft.declaration() { + Ok(declaration) => { + if app.load_nmr_with_sampling(&draft.path, declaration) { + app.note_recent_file(&draft.path); + return; + } + draft.error = Some(app.session.status.clone()); + } + Err(error) => draft.error = Some(error), + } + } + if !cancel && !modal.should_close() { + app.session.ui.nmr_import = Some(draft); + } +} diff --git a/crates/app/src/ui/menus.rs b/crates/app/src/ui/menus.rs index 19532d68..a88d8df7 100644 --- a/crates/app/src/ui/menus.rs +++ b/crates/app/src/ui/menus.rs @@ -54,6 +54,7 @@ pub(crate) fn menu_bar_spec() -> Vec<(&'static str, Vec)> { Separator, Command(CommandId::OpenFile), Command(CommandId::OpenFolder), + Command(CommandId::ImportNmrSampling), Command(CommandId::RunBatchWorkflow), Submenu( "Open Recent", diff --git a/crates/app/src/ui/mod.rs b/crates/app/src/ui/mod.rs index 4ddefd4b..4c04eca9 100644 --- a/crates/app/src/ui/mod.rs +++ b/crates/app/src/ui/mod.rs @@ -81,6 +81,9 @@ pub fn render( sync_chrome_theme(&ctx, app.settings.appearance.theme); clipboard_table_paste.begin_frame(app, &ctx); file_dialogs::image_import::poll(app, &ctx); + if app.poll_data_import() { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } file_dialogs::image_import::large_image_consent_window(app, &ctx); if let Some(payload) = app.poll_data_export() { copy_table_export(&ctx, payload); @@ -116,6 +119,7 @@ pub fn render( || app.session.ui.export_options.is_some() || app.session.ui.data_export.is_some() || app.session.ui.table_import_preview.is_some() + || app.session.ui.nmr_import.is_some() || app.session.ui.settings_dialog.is_some() || batch_workflow.is_open(); if !modal_open { @@ -201,6 +205,7 @@ pub fn render( quit_confirm_window(app, &ctx); diagnostic_history_window(app, &ctx); file_dialogs::processing_scheme_window(app, &ctx); + file_dialogs::nmr_sampling::window(app, &ctx); processing_templates::processing_template_window(app, &ctx); arithmetic::spectrum_arithmetic_window(app, &ctx); align::align_spectra_window(app, &ctx); @@ -217,6 +222,11 @@ pub fn render( app.finish_pending_wheel_zoom(now, false); app.finish_pending_wheel_property(now, false); activity::observe(app); + // Menus and drops can enqueue after this frame's poll. Schedule the first + // worker poll even if the user stops moving the mouse immediately afterward. + if app.session.data_imports.is_pending() { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } } fn project_window_title(app: &PlotxApp) -> String { diff --git a/crates/app/src/ui/object_inspector/chart_gallery.rs b/crates/app/src/ui/object_inspector/chart_gallery.rs index d6398b77..076aa904 100644 --- a/crates/app/src/ui/object_inspector/chart_gallery.rs +++ b/crates/app/src/ui/object_inspector/chart_gallery.rs @@ -193,21 +193,34 @@ mod tests { nucleus: nucleus.to_owned(), group_delay: 0.0, }; - let nmr = Dataset::Nmr2D(Box::new(Nmr2DDataset::load(plotx_io::NmrData2D { - data: vec![num_complex::Complex64::new(1.0, 0.0); 4], - rows: 2, - cols: 2, - domain: plotx_io::Domain::Frequency, - direct: dimension("1H"), - indirect: dimension("13C"), - quad: plotx_io::QuadMode::Complex, - indirect_conjugate: false, - experiment: None, - pseudo_axis: None, - diffusion: None, - nus: None, - source: "gallery NMR".to_owned(), - }))); + let nmr = Dataset::Nmr2D(Box::new( + Nmr2DDataset::load_with_pipeline( + plotx_io::NmrData2D { + data: vec![num_complex::Complex64::new(1.0, 0.0); 4], + rows: 2, + cols: 2, + domain: plotx_io::Domain::Frequency, + direct: dimension("1H"), + indirect: dimension("13C"), + quad: plotx_io::QuadMode::Complex, + indirect_conjugate: false, + experiment: None, + pseudo_axis: None, + diffusion: None, + nus: None, + source: "gallery NMR".to_owned(), + }, + Some(plotx_processing::Params2D { + layout: plotx_processing::Layout2D::Ft, + f2: plotx_processing::AxisPipeline { steps: Vec::new() }, + f1: plotx_processing::AxisPipeline { steps: Vec::new() }, + }), + Some(false), + None, + true, + ) + .unwrap(), + )); let nmr_plane = nmr .field_descriptors() .into_iter() diff --git a/crates/app/src/ui/primary_sidebar/data_browser.rs b/crates/app/src/ui/primary_sidebar/data_browser.rs index a654c954..7196acfe 100644 --- a/crates/app/src/ui/primary_sidebar/data_browser.rs +++ b/crates/app/src/ui/primary_sidebar/data_browser.rs @@ -420,16 +420,21 @@ mod tests { use plotx_io::{Domain, NmrData}; fn root(name: &str) -> Dataset { - let mut dataset = NmrDataset::load(NmrData { - points: vec![1.0.into(), 0.0.into()], - domain: Domain::Frequency, - spectral_width_hz: 1.0, - observe_freq_mhz: 1.0, - carrier_ppm: 0.0, - nucleus: "1H".into(), - source: name.into(), - group_delay: 0.0, - }); + let mut dataset = NmrDataset::load_with_pipeline( + NmrData { + points: vec![1.0.into(), 0.0.into()], + domain: Domain::Frequency, + spectral_width_hz: 1.0, + observe_freq_mhz: 1.0, + carrier_ppm: 0.0, + nucleus: "1H".into(), + source: name.into(), + group_delay: 0.0, + }, + Some(plotx_processing::AxisPipeline { steps: Vec::new() }), + Some(false), + ) + .unwrap(); dataset.name = Some(name.into()); Dataset::Nmr(Box::new(dataset)) } diff --git a/crates/app/src/ui/properties/fixture.rs b/crates/app/src/ui/properties/fixture.rs index 0d632529..57e51c67 100644 --- a/crates/app/src/ui/properties/fixture.rs +++ b/crates/app/src/ui/properties/fixture.rs @@ -23,7 +23,7 @@ fn nmr1d(domain: plotx_io::Domain) -> Dataset { source: "fixture".to_owned(), group_delay: 0.0, }; - Dataset::Nmr(Box::new(NmrDataset::load(data))) + Dataset::Nmr(Box::new(NmrDataset::load(data).unwrap())) } pub(crate) fn time_domain_1d() -> Dataset { @@ -64,9 +64,9 @@ fn nmr2d(source: &str) -> plotx_io::NmrData2D { /// One page holding `plots` contour plots of one 2D spectrum, all selected. pub(crate) fn contour_page(plots: usize) -> (PlotxApp, Vec) { let mut app = PlotxApp::new(); - app.doc - .datasets - .push(Dataset::Nmr2D(Box::new(Nmr2DDataset::load(nmr2d("panel"))))); + app.doc.datasets.push(Dataset::Nmr2D(Box::new( + Nmr2DDataset::load(nmr2d("panel")).unwrap(), + ))); let mut canvas = CanvasDocument::new("page".to_owned(), [200.0, 200.0]); let mut ids = Vec::new(); for index in 0..plots { @@ -89,11 +89,9 @@ pub(crate) fn contour_page(plots: usize) -> (PlotxApp, Vec) { /// A second dataset, so a navigation test can tell whether the data focus /// followed the object it landed on. pub(crate) fn add_dataset(app: &mut PlotxApp) -> usize { - app.doc - .datasets - .push(Dataset::Nmr2D(Box::new(Nmr2DDataset::load(nmr2d( - "second", - ))))); + app.doc.datasets.push(Dataset::Nmr2D(Box::new( + Nmr2DDataset::load(nmr2d("second")).unwrap(), + ))); app.doc.datasets.len() - 1 } @@ -159,12 +157,12 @@ pub(crate) fn set_lowest_level(app: &mut PlotxApp, object: ObjectId, multiplier: pub(crate) fn time_domain_2d() -> Dataset { let mut data = nmr2d("time domain"); data.domain = plotx_io::Domain::Time; - Dataset::Nmr2D(Box::new(Nmr2DDataset::load(data))) + Dataset::Nmr2D(Box::new(Nmr2DDataset::load(data).unwrap())) } pub(crate) fn homonuclear_frequency_2d() -> Dataset { let mut data = nmr2d("homonuclear frequency domain"); data.indirect = data.direct.clone(); data.experiment = Some("cosy".to_owned()); - Dataset::Nmr2D(Box::new(Nmr2DDataset::load(data))) + Dataset::Nmr2D(Box::new(Nmr2DDataset::load(data).unwrap())) } diff --git a/crates/app/src/ui/tools/craft.rs b/crates/app/src/ui/tools/craft.rs index 90a6a958..e9a34291 100644 --- a/crates/app/src/ui/tools/craft.rs +++ b/crates/app/src/ui/tools/craft.rs @@ -18,7 +18,7 @@ pub(crate) fn open_for_active(app: &mut PlotxApp) { let Some(nmr) = app.doc.datasets.get(index).and_then(Dataset::as_nmr) else { return; }; - if nmr.data.domain != plotx_io::Domain::Time { + if nmr.input_domain() != plotx_io::Domain::Time { return; } let dataset = nmr.resource_id; @@ -105,7 +105,13 @@ pub(crate) fn select_regions_on_canvas(app: &mut PlotxApp, index: usize) { .to_owned(); return; }; - let invocation = setup::resolved(app, index); + let invocation = match setup::resolved(app, index) { + Ok(invocation) => invocation, + Err(error) => { + app.session.status = error; + return; + } + }; if app.session.ui.craft_overrides.regions.is_none() { app.session.ui.craft_overrides.regions = Some( if invocation.sources.regions @@ -180,7 +186,7 @@ pub(crate) fn render_task(app: &mut PlotxApp, host: &mut Ui) { || !app.doc.datasets.get(index).is_some_and(|dataset| { dataset .as_nmr() - .is_some_and(|nmr| nmr.data.domain == plotx_io::Domain::Time) + .is_some_and(|nmr| nmr.input_domain() == plotx_io::Domain::Time) }) { return; @@ -322,7 +328,7 @@ mod tests { fn stored_run(data: &NmrData, params: CraftParams) -> StoredCraftRun { StoredCraftRun::from_result( CraftRunId(0), - data, + &data.clone().try_into().unwrap(), CraftInvocation::acquisition(data, params), None, CraftResult { @@ -347,13 +353,14 @@ mod tests { #[test] fn changing_target_rebuilds_draft_from_target_provenance() { - let first = NmrDataset::load(time_domain_data("first")); - let mut second = NmrDataset::load(time_domain_data("second")); + let first = NmrDataset::load(time_domain_data("first")).unwrap(); + let mut second = NmrDataset::load(time_domain_data("second")).unwrap(); let mut provenance_params = CraftParams::ssfp(); provenance_params.minimum_amplitude_to_noise = 8.5; - second - .craft_runs - .push(stored_run(&second.data, provenance_params.clone())); + second.craft_runs.push(stored_run( + &second.data.craft_fid().unwrap(), + provenance_params.clone(), + )); let mut app = PlotxApp::new_with_settings(plotx_core::settings::Settings::default()); app.doc.datasets.push(Dataset::Nmr(Box::new(first))); app.doc.datasets.push(Dataset::Nmr(Box::new(second))); @@ -371,7 +378,10 @@ mod tests { open_for_active(&mut app); assert_eq!(app.session.ui.craft_overrides, Default::default()); - assert_eq!(setup::resolved(&mut app, 1).params, provenance_params); + assert_eq!( + setup::resolved(&mut app, 1).unwrap().params, + provenance_params + ); assert_eq!(app.session.ui.craft_selected_run, Some(CraftRunId(0))); assert_eq!(app.session.ui.craft_task_page, CraftTaskPage::Results); } @@ -398,11 +408,11 @@ mod tests { + Complex64::from_polar(3.0, -std::f64::consts::TAU * 250.0 * time) }) .collect(); - let dataset = NmrDataset::load(data); + let dataset = NmrDataset::load(data).unwrap(); let invocation = plotx_processing::craft::resolve_craft_invocation( - &dataset.data, - dataset.craft_reference(), + &dataset.data.craft_fid().unwrap(), + dataset.craft_reference().unwrap(), &plotx_processing::craft::CraftParamOverrides { fir_filter_taps: Some(31), ..Default::default() diff --git a/crates/app/src/ui/tools/craft/results.rs b/crates/app/src/ui/tools/craft/results.rs index 9cd51fc7..b39d941f 100644 --- a/crates/app/src/ui/tools/craft/results.rs +++ b/crates/app/src/ui/tools/craft/results.rs @@ -277,7 +277,8 @@ fn reports( .changed(); ui.label(format!( "Hz ({:.5} ppm)", - definition.segment_width_hz / nmr.data.observe_freq_mhz + definition.segment_width_hz + / run.provenance.invocation.reference.reference_frequency_mhz )); }); let mut snapshot: CraftAmplitudeReport = serde_json::from_value(record.snapshot.clone()) diff --git a/crates/app/src/ui/tools/craft/setup.rs b/crates/app/src/ui/tools/craft/setup.rs index fc08d608..2949ba59 100644 --- a/crates/app/src/ui/tools/craft/setup.rs +++ b/crates/app/src/ui/tools/craft/setup.rs @@ -9,7 +9,13 @@ use plotx_processing::craft::{ use crate::ui::commands::CommandId; pub(super) fn show(app: &mut PlotxApp, index: usize, ui: &mut Ui) { - let invocation = resolved(app, index); + let invocation = match resolved(app, index) { + Ok(invocation) => invocation, + Err(error) => { + ui.colored_label(ui.visuals().error_fg_color, error); + return; + } + }; settings(app, index, &invocation, ui); ui.separator(); readiness(app.session.ui.craft_analysis_intent, &invocation, ui); @@ -17,10 +23,12 @@ pub(super) fn show(app: &mut PlotxApp, index: usize, ui: &mut Ui) { run_controls(app, index, ui); } -pub(super) fn resolved(app: &mut PlotxApp, index: usize) -> CraftInvocation { +pub(super) fn resolved(app: &mut PlotxApp, index: usize) -> Result { let nmr = app.doc.datasets[index].as_nmr().unwrap(); let dataset = nmr.resource_id; - let reference = nmr.craft_reference(); + let reference = nmr + .craft_reference() + .ok_or("CRAFT requires chemical-shift reference evidence")?; let parent_run = app.session.ui.craft_base_run; if let Some(cache) = &app.session.ui.craft_resolution_cache && cache.dataset == dataset @@ -29,10 +37,11 @@ pub(super) fn resolved(app: &mut PlotxApp, index: usize) -> CraftInvocation { && cache.overrides == app.session.ui.craft_overrides && cache.parent_run == parent_run { - return cache.invocation.clone(); + return Ok(cache.invocation.clone()); } + let data = nmr.data.craft_fid().map_err(|error| error.to_string())?; let invocation = resolve_craft_invocation( - &nmr.data, + &data, reference, &app.session.ui.craft_overrides, parent_run.and_then(|id| nmr.craft_run(id).map(|run| &run.provenance.invocation)), @@ -45,7 +54,7 @@ pub(super) fn resolved(app: &mut PlotxApp, index: usize) -> CraftInvocation { parent_run, invocation: invocation.clone(), }); - invocation + Ok(invocation) } fn readiness(intent: CraftAnalysisIntent, invocation: &CraftInvocation, ui: &mut Ui) { @@ -102,7 +111,7 @@ fn readiness(intent: CraftAnalysisIntent, invocation: &CraftInvocation, ui: &mut fn settings(app: &mut PlotxApp, index: usize, invocation: &CraftInvocation, ui: &mut Ui) { let nmr = app.doc.datasets[index].as_nmr().unwrap().clone(); - let reference = nmr.craft_reference(); + let reference = invocation.reference; ui.label(crate::typography::headline("1. Choose the analysis goal")); ui.horizontal_wrapped(|ui| { ui.selectable_value( @@ -155,7 +164,7 @@ fn settings(app: &mut PlotxApp, index: usize, invocation: &CraftInvocation, ui: ui.weak(format!( "Chemical-shift axis: acquisition {:.5} ppm · reference {:+.5} ppm · effective {:.5} ppm", - nmr.data.carrier_ppm, + reference.acquisition_carrier_ppm, reference.offset_ppm, reference.effective_carrier_ppm(), )); @@ -339,7 +348,7 @@ fn regions( regions.remove(position); changed = true; } - let half_width = 45.0 / nmr.data.observe_freq_mhz.max(f64::MIN_POSITIVE); + let half_width = 45.0 / invocation.reference.reference_frequency_mhz; let suggestions = invocation .assessment .clear_signals @@ -375,7 +384,7 @@ fn regions( }); } if ui.small_button("Add custom region").clicked() { - let center = nmr.craft_reference().effective_carrier_ppm(); + let center = invocation.reference.effective_carrier_ppm(); regions.push(CraftRegion::new( next_region_id(®ions), center - half_width, @@ -428,7 +437,13 @@ fn run_controls(app: &mut PlotxApp, index: usize, ui: &mut Ui) { _ => ui.label("Run"), }; super::command_button(app, CommandId::RunCraft, "Run CRAFT", true, ui); - let invocation = resolved(app, index); + let invocation = match resolved(app, index) { + Ok(invocation) => invocation, + Err(error) => { + ui.colored_label(ui.visuals().error_fg_color, error); + return; + } + }; if let Some(message) = invocation.assessment.first_blocking_message() { ui.colored_label(ui.visuals().error_fg_color, message); } diff --git a/crates/app/src/ui/tools/processing/mod.rs b/crates/app/src/ui/tools/processing/mod.rs index f47d16ae..6e7de553 100644 --- a/crates/app/src/ui/tools/processing/mod.rs +++ b/crates/app/src/ui/tools/processing/mod.rs @@ -172,8 +172,11 @@ fn move_entry( fn add_step_menu(app: &mut PlotxApp, di: usize, axis: PhaseAxis, ui: &mut Ui) { let dataset = &app.doc.datasets[di]; let input_domain = match dataset { - Dataset::Nmr(dataset) => dataset.data.domain, - Dataset::Nmr2D(dataset) => dataset.data.domain, + Dataset::Nmr(dataset) => dataset.input_domain(), + Dataset::Nmr2D(dataset) => match dataset.input_domain(axis) { + Ok(domain) => domain, + Err(_) => return, + }, Dataset::Table(_) | Dataset::Electrophysiology(_) | Dataset::Afm(_) @@ -273,7 +276,7 @@ fn default_bin_params(app: &PlotxApp, dataset: usize) -> BinParams { let Some(spectrum) = dataset.spectrum() else { return BinParams::DEFAULT; }; - let effective_minimum = 1.5 * plotx_processing::cleanup::axis_step(&spectrum.ppm); + let effective_minimum = 1.5 * spectrum.coordinate_spacing().unwrap_or(0.0); BinParams { width: BinParams::DEFAULT.width.max(effective_minimum.next_up()), ..BinParams::DEFAULT @@ -323,8 +326,11 @@ fn apply_row_op(app: &mut PlotxApp, di: usize, axis: PhaseAxis, id: StepId, op: }; let owner = dataset.resource_id(); let input_domain = match dataset { - Dataset::Nmr(dataset) => dataset.data.domain, - Dataset::Nmr2D(dataset) => dataset.data.domain, + Dataset::Nmr(dataset) => dataset.input_domain(), + Dataset::Nmr2D(dataset) => match dataset.input_domain(axis) { + Ok(domain) => domain, + Err(_) => return, + }, Dataset::Table(_) | Dataset::Electrophysiology(_) | Dataset::Afm(_) @@ -522,7 +528,7 @@ mod tests { nus: None, source: "default badge".to_owned(), }; - let mut dataset = Dataset::Nmr2D(Box::new(Nmr2DDataset::load(data))); + let mut dataset = Dataset::Nmr2D(Box::new(Nmr2DDataset::load(data).unwrap())); assert!(is_default_processing(&dataset)); dataset.as_nmr2d_mut().unwrap().group_delay_correct = false; assert!(!is_default_processing(&dataset)); diff --git a/crates/app/src/ui/tools/processing/surface.rs b/crates/app/src/ui/tools/processing/surface.rs index ac5b1d04..e16f6cc1 100644 --- a/crates/app/src/ui/tools/processing/surface.rs +++ b/crates/app/src/ui/tools/processing/surface.rs @@ -45,15 +45,15 @@ struct SurfaceShape { fn surface_shape(dataset: &Dataset) -> Option { let (input_domain, source) = match dataset { Dataset::Nmr(dataset) => ( - dataset.data.domain, - match dataset.data.domain { + dataset.input_domain(), + match dataset.input_domain() { Domain::Time => SourceShape::RawFid, Domain::Frequency => SourceShape::ImportedSpectrum, }, ), Dataset::Nmr2D(dataset) => ( - dataset.data.domain, - match dataset.data.domain { + dataset.input_domain(PhaseAxis::F2).ok()?, + match dataset.input_domain(PhaseAxis::F2).ok()? { Domain::Time => SourceShape::RawAcquisition2D, Domain::Frequency => SourceShape::ImportedSpectrum, }, diff --git a/crates/app/src/ui/tools/pseudo.rs b/crates/app/src/ui/tools/pseudo.rs index d3d8ee41..62faeb91 100644 --- a/crates/app/src/ui/tools/pseudo.rs +++ b/crates/app/src/ui/tools/pseudo.rs @@ -32,6 +32,12 @@ pub(super) fn experiment_group(app: &mut PlotxApp, di: usize, ui: &mut Ui) -> bo let n = app.doc.datasets[di].as_nmr2d().unwrap(); let layout = match n.params.layout { Layout2D::Ft => "Contour (true 2D FT)", + Layout2D::Stack + if n.native_processed.dataset().as_raw().is_some() && n.data.nus.is_some() => + { + "Acquired NUS observations (not reconstructed)" + } + Layout2D::Stack if n.data.nus.is_some() => "Stack (reconstructed NUS slices)", Layout2D::Stack => "Stack (pseudo-2D 1D slices)", }; ui.label(format!("Layout: {layout}")); @@ -48,7 +54,7 @@ pub(super) fn experiment_group(app: &mut PlotxApp, di: usize, ui: &mut Ui) -> bo if is_pseudo { ui.separator(); pseudo_group(app, di, ui); - } else if is_stack { + } else if is_stack && app.doc.datasets[di].as_nmr2d().unwrap().data.nus.is_none() { ui.separator(); ui.small( "This looks like a pseudo-2D array but no indirect-axis ruler \ @@ -65,99 +71,59 @@ pub(super) fn experiment_group(app: &mut PlotxApp, di: usize, ui: &mut Ui) -> bo false } -/// Non-uniform-sampling controls. The reader normally recovers JEOL schedules; -/// manual entry remains available for older or malformed files. +/// Reconstruction inputs apply to the imported sampling coordinates. fn nus_group(app: &mut PlotxApp, di: usize, ui: &mut Ui) { - let Some(nus) = app.doc.datasets[di] - .as_nmr2d() - .and_then(|n| n.data.nus.clone()) - else { + let Some(dataset) = app.doc.datasets[di].as_nmr2d() else { + return; + }; + let Some(nus) = &dataset.data.nus else { return; }; ui.separator(); ui.label(crate::typography::headline("Non-uniform sampling")); - let scheme = if nus.echo_antiecho { - "echo/anti-echo (P/N)" - } else { - "phase-modulated" - }; + if let Some(warning) = &dataset.reconstruction_warning { + ui.colored_label(ui.visuals().warn_fg_color, warning); + } ui.small(format!( - "{} scheduling — {} of {} F1 increments acquired ({}).", - nus.mode, nus.acquired, nus.grid, scheme, + "{} observations on a {}-point indirect grid.", + nus.acquired, nus.grid )); - - if nus.schedule.is_some() { - ui.small("Spectrum reconstructed from the available sampling list."); - } else { - ui.colored_label( - ui.visuals().warn_fg_color, - "No valid NUS schedule was found in the data file. Paste the sampling \ - list (space/comma separated) to reconstruct the spectrum.", - ); - } - - let text_id = ui.make_persistent_id(("nus_list", di)); - let base_id = ui.make_persistent_id(("nus_base", di)); - let err_id = ui.make_persistent_id(("nus_err", di)); - let mut text = ui.data_mut(|d| d.get_temp::(text_id).unwrap_or_default()); - let mut base = ui.data_mut(|d| d.get_temp::(base_id).unwrap_or(nus.idx_base)); - - ui.horizontal(|ui| { - ui.label("Index base"); - if ui.selectable_label(base == 1, "1-based").clicked() { - base = 1; - } - if ui.selectable_label(base == 0, "0-based").clicked() { - base = 0; - } - }); - ui.data_mut(|d| d.insert_temp(base_id, base)); - - let resp = ui.add( - egui::TextEdit::multiline(&mut text) - .hint_text("1 2 3 5 7 9 …") - .desired_rows(2) - .desired_width(f32::INFINITY), - ); - if resp.changed() { - ui.data_mut(|d| d.insert_temp(text_id, text.clone())); - } - - if ui - .add(Button::new(format!( - "Reconstruct ({} indices)", - nus.acquired - ))) - .clicked() - { - let result = match parse_indices(&text) { - Ok(values) => app.apply_nus_schedule(di, &values, base), - Err(e) => Err(e), - }; - let err = result.err().unwrap_or_default(); - ui.data_mut(|d| d.insert_temp::(err_id, err)); - } - let err = ui.data_mut(|d| d.get_temp::(err_id).unwrap_or_default()); - if !err.is_empty() { - ui.colored_label(ui.visuals().error_fg_color, err); - } -} - -fn parse_indices(text: &str) -> Result, String> { - let mut out = Vec::new(); - for tok in text - .split(|c: char| c.is_whitespace() || c == ',' || c == ';') - .filter(|s| !s.is_empty()) - { - let v: usize = tok - .parse() - .map_err(|_| format!("'{tok}' is not a whole number."))?; - out.push(v); + let mut request = dataset.nus_request.unwrap_or_default(); + let mut noise_known = request.noise_standard_deviation.is_some(); + let mut changed = ui + .checkbox(&mut noise_known, "Override automatic noise estimate") + .changed(); + let mut noise = request.noise_standard_deviation.unwrap_or(0.0); + changed |= ui + .add_enabled( + noise_known, + DragValue::new(&mut noise) + .range(0.0..=f64::MAX) + .prefix("Noise σ "), + ) + .changed(); + ui.small("Noise is estimated automatically from acquired data. An override uses the standard deviation after the current F2 recipe; zero explicitly asserts noiseless input."); + request.noise_standard_deviation = noise_known.then_some(noise); + changed |= ui + .add( + DragValue::new(&mut request.max_iterations) + .range(1..=2048) + .prefix("Maximum iterations "), + ) + .changed(); + let before = DatasetProcessingState::from_dataset(&app.doc.datasets[di]); + let mut after = before.clone(); + if let DatasetProcessingState::Nmr2D { nus_request, .. } = &mut after { + *nus_request = Some(request); } - if out.is_empty() { - return Err("Enter the sampling indices.".into()); + if changed && before != after { + app.execute_action(Action::update_dataset_processing( + app.doc.datasets[di].resource_id(), + before, + after, + )); } - Ok(out) + ui.small("NUS reconstruction runs automatically with the F2 FFT. The F1 FFT produces the second frequency axis."); } fn pseudo_group(app: &mut PlotxApp, di: usize, ui: &mut Ui) { @@ -302,6 +268,12 @@ fn pseudo_group(app: &mut PlotxApp, di: usize, ui: &mut Ui) { } } + let input_error = app.doc.datasets[di] + .as_nmr2d() + .and_then(|n| n.dosy_input_error()); + if let Some(error) = input_error { + ui.small(error); + } let progress = app .doc .datasets @@ -311,7 +283,10 @@ fn pseudo_group(app: &mut PlotxApp, di: usize, ui: &mut Ui) { if is_dosy && !is_ilt && ui - .add_enabled(progress.is_none(), Button::new("Build DOSY map")) + .add_enabled( + input_error.is_none() && progress.is_none(), + Button::new("Build DOSY map"), + ) .clicked() { app.request_dosy_map(di); @@ -320,7 +295,7 @@ fn pseudo_group(app: &mut PlotxApp, di: usize, ui: &mut Ui) { && is_ilt && ui .add_enabled( - is_gradient && progress.is_none(), + is_gradient && input_error.is_none() && progress.is_none(), Button::new("Build ILT DOSY map"), ) .clicked() diff --git a/crates/app/src/ui/tools/task_card_tests.rs b/crates/app/src/ui/tools/task_card_tests.rs index aa1a6f8e..0e60fed7 100644 --- a/crates/app/src/ui/tools/task_card_tests.rs +++ b/crates/app/src/ui/tools/task_card_tests.rs @@ -15,9 +15,14 @@ fn app_with_task(tab: TaskDockTab, collapsed: bool) -> PlotxApp { source: "test".into(), group_delay: 0.0, }; - app.doc - .datasets - .push(Dataset::Nmr(Box::new(NmrDataset::load(data)))); + app.doc.datasets.push(Dataset::Nmr(Box::new( + NmrDataset::load_with_pipeline( + data, + Some(plotx_processing::AxisPipeline { steps: Vec::new() }), + Some(false), + ) + .unwrap(), + ))); app.doc .canvases .push(CanvasDocument::new("p".into(), [100.0, 80.0])); diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 6ecd7feb..e617d028 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -17,7 +17,11 @@ default = [] datafusion = ["plotx-core/datafusion"] [dependencies] +nmr.workspace = true plotx-core.workspace = true plotx-io.workspace = true plotx-processing.workspace = true serde_json.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/cli/src/craft.rs b/crates/cli/src/craft.rs index 9bfb6298..88923424 100644 --- a/crates/cli/src/craft.rs +++ b/crates/cli/src/craft.rs @@ -78,17 +78,23 @@ pub(super) fn run( } fn analyze(path: &Path, overrides: &CraftParamOverrides) -> Result { - let loaded = workflow::load_dataset(path).map_err(|error| error.to_string())?; - let dataset = loaded - .dataset - .as_nmr() - .ok_or_else(|| "CRAFT requires a one-dimensional NMR dataset".to_owned())?; - let invocation = resolve_craft_invocation( - &dataset.data, - plotx_processing::craft::CraftReference::acquisition(&dataset.data), - overrides, - None, + let input = plotx_io::nmr_bridge::read(path, &mut nmr::ExecutionContext::default()) + .map_err(|error| error.to_string())?; + let inspection = workflow::inspect_nmr_dataset(&input).map_err(|error| error.to_string())?; + let source = plotx_io::nmr_view::NmrSource::new(input).map_err(|error| error.to_string())?; + let data = source.craft_fid().map_err(|error| error.to_string())?; + let reference = source + .dataset() + .as_raw() + .and_then(|raw| raw.descriptor().axes().first()) + .and_then(|axis| axis.chemical_shift_reference()) + .ok_or("CRAFT requires chemical-shift reference evidence")?; + let reference = plotx_processing::craft::CraftReference::new( + reference.carrier_ppm(), + reference.reference_frequency_mhz(), + 0.0, ); + let invocation = resolve_craft_invocation(&data, reference, overrides, None); if !invocation.assessment.can_run() { return Err(invocation .assessment @@ -96,9 +102,15 @@ fn analyze(path: &Path, overrides: &CraftParamOverrides) -> Result Result values[0].norm() && magnitude >= values[2].norm()).then(|| { - json!({ - "chemical_shift_ppm": spectrum.ppm[index + 1], - "magnitude": magnitude, - }) + let spectrum = plotx_processing::craft::preview_spectrum( + &data, + invocation.reference, + invocation.derived_plan.effective_skip_points, + ) + .map_err(|error| error.to_string())?; + let mut fft_peaks = { + spectrum + .values + .windows(3) + .enumerate() + .filter_map(|(index, values)| { + let magnitude = values[1].norm(); + (magnitude > values[0].norm() && magnitude >= values[2].norm()).then(|| { + json!({ + "chemical_shift_ppm": spectrum.ppm[index + 1], + "magnitude": magnitude, }) }) - .collect::>() - }) - .unwrap_or_default(); + }) + .collect::>() + }; fft_peaks.sort_by(|left, right| { right["magnitude"] .as_f64() @@ -140,16 +155,14 @@ fn analyze(path: &Path, overrides: &CraftParamOverrides) -> Result Result>(); - peaks.sort_by(|left, right| { - right["magnitude"] - .as_f64() - .unwrap_or_default() - .total_cmp(&left["magnitude"].as_f64().unwrap_or_default()) - }); - peaks.truncate(5); - json!({ "region": region, "strongest_bins": peaks }) - }) - .collect::>() - }) - .unwrap_or_default(); + peaks.sort_by(|left, right| { + right["magnitude"] + .as_f64() + .unwrap_or_default() + .total_cmp(&left["magnitude"].as_f64().unwrap_or_default()) + }); + peaks.truncate(5); + json!({ "region": region, "strongest_bins": peaks }) + }) + .collect::>() + }; let region_amplitude_ratio = result.region_ratio.map(|ratio| ratio.value); Ok(json!({ "input": path, @@ -180,15 +192,16 @@ fn analyze(path: &Path, overrides: &CraftParamOverrides) -> Result Result Result, String> { - if !input.is_dir() || input.extension().is_some() || is_raw_acquisition(input) { + if !input.is_dir() || input.extension().is_some() || is_raw_acquisition(input)? { return Ok(vec![input.to_owned()]); } let mut children = std::fs::read_dir(input) .map_err(|error| format!("could not read {}: {error}", input.display()))? - .filter_map(Result::ok) - .map(|entry| entry.path()) - .filter(|path| path.is_dir() && is_raw_acquisition(path)) - .collect::>(); + .map(|entry| entry.map(|entry| entry.path())) + .collect::, _>>() + .map_err(|error| format!("could not enumerate {}: {error}", input.display()))? + .into_iter() + .try_fold(Vec::new(), |mut paths, path| { + if path.is_dir() && is_raw_acquisition(&path)? { + paths.push(path); + } + Ok::<_, String>(paths) + })?; children.sort(); if children.is_empty() { Err(format!( @@ -224,13 +243,12 @@ fn acquisition_inputs(input: &Path) -> Result, String> { } } -fn is_raw_acquisition(path: &Path) -> bool { - matches!( - plotx_io::detect_format(path), - Ok(plotx_io::DataFormat::Nmr( - plotx_io::NmrFormat::BrukerRaw | plotx_io::NmrFormat::VarianAgilentRaw, - )) - ) +fn is_raw_acquisition(path: &Path) -> Result { + match plotx_io::nmr_bridge::read_options().detect(path) { + Ok(format) => Ok(matches!(format, nmr::Format::Raw(_))), + Err(error) if error.kind() == nmr::ReadErrorKind::Unrecognized => Ok(false), + Err(error) => Err(format!("could not detect {}: {error}", path.display())), + } } #[cfg(test)] diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index f23d0788..bda30039 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -15,9 +15,9 @@ mod craft; const HELP: &str = r#"plotx-cli - headless PlotX workflows USAGE: - plotx-cli inspect [--json] + plotx-cli inspect [--json] [--sampling-declaration ] plotx-cli craft --output [--region ]... [--expected-ratio ]... - plotx-cli process --scheme --output [--format svg|pdf|png|tiff|jpeg] + plotx-cli process --scheme --output [--format svg|pdf|png|tiff|jpeg] [--sampling-declaration ] plotx-cli batch --workflow --manifest COMMANDS: @@ -63,12 +63,14 @@ enum Command { Inspect { input: PathBuf, json: bool, + sampling_declaration: Option, }, Process { input: PathBuf, scheme: PathBuf, output: PathBuf, format: OutputFormat, + sampling_declaration: Option, }, Craft { input: PathBuf, @@ -138,6 +140,7 @@ enum Flag { Manifest, Region, ExpectedRatio, + SamplingDeclaration, } impl Flag { @@ -152,6 +155,7 @@ impl Flag { Some("--manifest") => Ok(Some(Self::Manifest)), Some("--region") => Ok(Some(Self::Region)), Some("--expected-ratio") => Ok(Some(Self::ExpectedRatio)), + Some("--sampling-declaration") => Ok(Some(Self::SamplingDeclaration)), Some(value) if value.starts_with('-') => { Err(ParseError::new(format!("unknown option: {value}"))) } @@ -170,6 +174,7 @@ impl Flag { Self::Manifest => "--manifest", Self::Region => "--region", Self::ExpectedRatio => "--expected-ratio", + Self::SamplingDeclaration => "--sampling-declaration", } } } @@ -311,11 +316,18 @@ fn parse_batch(mut args: VecDeque) -> Result fn parse_inspect(mut args: VecDeque) -> Result { let mut input = None; let mut json = false; + let mut sampling_declaration = None; while let Some(token) = args.pop_front() { match Flag::parse(&token)? { Some(Flag::Help) => return Ok(ParseOutcome::Help), Some(Flag::Json) if !json => json = true, Some(Flag::Json) => return Err(ParseError::new("--json was provided more than once")), + Some(Flag::SamplingDeclaration) if sampling_declaration.is_none() => { + sampling_declaration = Some(PathBuf::from(take_value( + &mut args, + Flag::SamplingDeclaration, + )?)); + } Some(flag) => { return Err(ParseError::new(format!( "{} is not valid for inspect", @@ -329,6 +341,7 @@ fn parse_inspect(mut args: VecDeque) -> Result"))?, json, + sampling_declaration, })) } @@ -337,6 +350,7 @@ fn parse_process(mut args: VecDeque) -> Result return Ok(ParseOutcome::Help), @@ -353,6 +367,12 @@ fn parse_process(mut args: VecDeque) -> Result return Err(ParseError::new("--json is not valid for process")), + Some(Flag::SamplingDeclaration) if sampling_declaration.is_none() => { + sampling_declaration = Some(PathBuf::from(take_value( + &mut args, + Flag::SamplingDeclaration, + )?)); + } Some(flag) => { return Err(ParseError::new(format!( "{} was provided more than once", @@ -374,6 +394,7 @@ fn parse_process(mut args: VecDeque) -> Result std::process::ExitCode { fn run(command: Command) -> Status { match command { - Command::Inspect { input, json } => { + Command::Inspect { + input, + json, + sampling_declaration, + } => { eprintln!("plotx-cli: loading {}", input.display()); - match workflow::load_dataset(&input) { - Ok(loaded) => { - emit_warnings(&loaded.inspection); + let inspection = if let Some(path) = sampling_declaration { + plotx_io::nmr_sampling::read_declaration(&path) + .and_then(|declaration| { + plotx_io::nmr_sampling::read( + &input, + declaration, + &mut nmr::ExecutionContext::default(), + ) + }) + .map_err(WorkflowError::from) + .and_then(|dataset| workflow::inspect_nmr_dataset(&dataset)) + } else { + workflow::inspect_file(&input) + }; + match inspection { + Ok(inspection) => { + emit_warnings(&inspection); let result = if json { - serde_json::to_string_pretty(&loaded.inspection) + serde_json::to_string_pretty(&inspection) } else { - Ok(text_report(&loaded.inspection)) + Ok(text_report(&inspection)) }; match result { Ok(output) => { @@ -452,13 +491,26 @@ fn run(command: Command) -> Status { scheme, output, format, + sampling_declaration, } => { eprintln!( "plotx-cli: processing {} with {}", input.display(), scheme.display() ); - match workflow::process_file(&input, &scheme, &output, format.0) { + let result = if let Some(path) = sampling_declaration { + plotx_io::nmr_sampling::read_declaration(&path) + .map_err(WorkflowError::from) + .and_then(|declaration| { + workflow::load_dataset_with_sampling(&input, declaration) + }) + .and_then(|loaded| { + workflow::process_loaded_dataset(loaded, &scheme, &output, format.0) + }) + } else { + workflow::process_file(&input, &scheme, &output, format.0) + }; + match result { Ok(result) => { emit_warnings(&result.inspection); let value = json!({ @@ -548,7 +600,7 @@ fn fail_automation(error: AutomationError) -> Status { fn fail(error: WorkflowError) -> Status { let status = match &error { - WorkflowError::Load(_) => Status::Input, + WorkflowError::Load(_) | WorkflowError::Nmr(_) => Status::Input, WorkflowError::Scheme(_) | WorkflowError::Processing(_) | WorkflowError::Integration(_) @@ -639,148 +691,5 @@ fn text_report(report: &InspectionReport) -> String { } #[cfg(test)] -mod tests { - use super::*; - - fn parse(values: &[&str]) -> Result { - parse_args(values.iter().map(OsString::from)) - } - - #[test] - fn inspect_parser_accepts_json_on_either_side_of_input() { - let expected = ParseOutcome::Command(Command::Inspect { - input: "sample.jdf".into(), - json: true, - }); - assert_eq!( - parse(&["plotx-cli", "inspect", "--json", "sample.jdf"]), - Ok(expected.clone()) - ); - assert_eq!( - parse(&["plotx-cli", "inspect", "sample.jdf", "--json"]), - Ok(expected) - ); - } - - #[test] - fn process_parser_infers_format_and_requires_named_paths() { - assert_eq!( - parse(&[ - "plotx-cli", - "process", - "sample.jdf", - "--scheme", - "routine.plotxproc", - "--output", - "figure.svg", - ]), - Ok(ParseOutcome::Command(Command::Process { - input: "sample.jdf".into(), - scheme: "routine.plotxproc".into(), - output: "figure.svg".into(), - format: OutputFormat(ExportFormat::Svg), - })) - ); - assert!(parse(&["plotx-cli", "process", "sample.jdf"]).is_err()); - } - - #[test] - fn craft_parser_accepts_multiple_and_negative_ppm_regions() { - assert_eq!( - parse(&[ - "plotx-cli", - "craft", - "acquisitions", - "--region", - "-0.5:0.2", - "--region", - "6.3:6.5", - "--expected-ratio", - "0.75", - "--output", - "result.json", - ]), - Ok(ParseOutcome::Command(Command::Craft { - input: "acquisitions".into(), - output: "result.json".into(), - regions: vec![ - plotx_processing::craft::CraftRegion::new( - plotx_processing::craft::CraftRegionId(0), - -0.5, - 0.2, - ), - plotx_processing::craft::CraftRegion::new( - plotx_processing::craft::CraftRegionId(1), - 6.3, - 6.5, - ), - ], - expected_ratios: vec![0.75], - })) - ); - } - - #[test] - fn batch_parser_requires_workflow_and_manifest_paths() { - assert_eq!( - parse(&[ - "plotx-cli", - "batch", - "--workflow", - "workflow.json", - "--manifest", - "run.json", - ]), - Ok(ParseOutcome::Command(Command::Batch { - workflow: "workflow.json".into(), - manifest: "run.json".into(), - })) - ); - 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")); - assert_eq!(status, Status::Canvas); - assert_eq!(Status::Usage as u8, 2); - assert_eq!(Status::Export as u8, 6); - } -} +#[path = "main_tests.rs"] +mod tests; diff --git a/crates/cli/src/main_tests.rs b/crates/cli/src/main_tests.rs new file mode 100644 index 00000000..fee820a5 --- /dev/null +++ b/crates/cli/src/main_tests.rs @@ -0,0 +1,145 @@ +use super::*; + +fn parse(values: &[&str]) -> Result { + parse_args(values.iter().map(OsString::from)) +} + +#[test] +fn inspect_parser_accepts_json_on_either_side_of_input() { + let expected = ParseOutcome::Command(Command::Inspect { + input: "sample.jdf".into(), + json: true, + sampling_declaration: None, + }); + assert_eq!( + parse(&["plotx-cli", "inspect", "--json", "sample.jdf"]), + Ok(expected.clone()) + ); + assert_eq!( + parse(&["plotx-cli", "inspect", "sample.jdf", "--json"]), + Ok(expected) + ); +} + +#[test] +fn process_parser_infers_format_and_requires_named_paths() { + assert_eq!( + parse(&[ + "plotx-cli", + "process", + "sample.jdf", + "--scheme", + "routine.plotxproc", + "--output", + "figure.svg", + ]), + Ok(ParseOutcome::Command(Command::Process { + input: "sample.jdf".into(), + scheme: "routine.plotxproc".into(), + output: "figure.svg".into(), + format: OutputFormat(ExportFormat::Svg), + sampling_declaration: None, + })) + ); + assert!(parse(&["plotx-cli", "process", "sample.jdf"]).is_err()); +} + +#[test] +fn craft_parser_accepts_multiple_and_negative_ppm_regions() { + assert_eq!( + parse(&[ + "plotx-cli", + "craft", + "acquisitions", + "--region", + "-0.5:0.2", + "--region", + "6.3:6.5", + "--expected-ratio", + "0.75", + "--output", + "result.json", + ]), + Ok(ParseOutcome::Command(Command::Craft { + input: "acquisitions".into(), + output: "result.json".into(), + regions: vec![ + plotx_processing::craft::CraftRegion::new( + plotx_processing::craft::CraftRegionId(0), + -0.5, + 0.2, + ), + plotx_processing::craft::CraftRegion::new( + plotx_processing::craft::CraftRegionId(1), + 6.3, + 6.5, + ), + ], + expected_ratios: vec![0.75], + })) + ); +} + +#[test] +fn batch_parser_requires_workflow_and_manifest_paths() { + assert_eq!( + parse(&[ + "plotx-cli", + "batch", + "--workflow", + "workflow.json", + "--manifest", + "run.json", + ]), + Ok(ParseOutcome::Command(Command::Batch { + workflow: "workflow.json".into(), + manifest: "run.json".into(), + })) + ); + 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")); + assert_eq!(status, Status::Canvas); + assert_eq!(Status::Usage as u8, 2); + assert_eq!(Status::Export as u8, 6); +} diff --git a/crates/cli/tests/batch_cli.rs b/crates/cli/tests/batch_cli.rs index 6e3c7d6a..a7cf7041 100644 --- a/crates/cli/tests/batch_cli.rs +++ b/crates/cli/tests/batch_cli.rs @@ -47,7 +47,7 @@ fn scheme(path: &Path) { "schema_version": 1, "dimension_count": 1, "pipelines": [{"steps": [ - {"kind": {"Phase": {"phase0": 0.0, "phase1": 0.0, "pivot_frac": 0.5, "auto": null}}, "enabled": true, "source": "User"} + {"kind": "Invert", "enabled": true, "source": "User"} ]}], "group_delay_correct": false }"#, @@ -114,7 +114,7 @@ fn batch_cli_exit_stdout_and_saved_manifest_form_one_contract() { assert_eq!(stdout["schema"], "plotx.run-manifest.v1"); assert_eq!(stdout["caller"], "workflow"); assert_eq!(stdout["nodes"].as_array().unwrap().len(), 3); - assert_eq!(stdout["errors"].as_array().unwrap().len(), 1); + assert_eq!(stdout["errors"].as_array().unwrap().len(), 1, "{stdout:#}"); assert_eq!( stdout["nodes"][0]["result"]["targets"][0]["outcome"], "succeeded" diff --git a/crates/cli/tests/nmr_craft.rs b/crates/cli/tests/nmr_craft.rs new file mode 100644 index 00000000..546a6cb8 --- /dev/null +++ b/crates/cli/tests/nmr_craft.rs @@ -0,0 +1,91 @@ +use std::{ + f64::consts::{PI, TAU}, + process::Command, +}; + +#[test] +fn craft_reads_with_nmr_and_preserves_the_independent_tone_frequencies() { + let dir = tempfile::tempdir().unwrap(); + let input = dir.path().join("acquisition"); + std::fs::create_dir(&input).unwrap(); + let parameters = "##TITLE=PlotX synthetic CRAFT acceptance\n##$TD=8192\n##$PARMODE=0\n##$AQ_mod=3\n##$BYTORDA=0\n##$DTYPA=0\n##$SW_h=2000\n##$SFO1=500.005\n##$BF1=500\n##$O1=5000\n##$NUC1=<1H>\n##$GRPDLY=0\n##END=\n"; + std::fs::write(input.join("acqus"), parameters).unwrap(); + let bytes = (0..4096) + .flat_map(|index| { + let time = index as f64 / 2000.0; + let value = [(-75.0, 800_000.0, 0.3, 1.5), (120.0, 400_000.0, -0.2, 2.0)] + .into_iter() + .fold( + nmr::Complex64::new(0., 0.), + |sum, (frequency, amplitude, phase, width)| { + sum + nmr::Complex64::from_polar( + amplitude * (-PI * width * time).exp(), + phase + TAU * frequency * time, + ) + }, + ); + [value.re, value.im] + .into_iter() + .flat_map(|v| (v.round() as i32).to_le_bytes()) + }) + .collect::>(); + std::fs::write(input.join("fid"), bytes).unwrap(); + let output = dir.path().join("result.json"); + let result = Command::new(env!("CARGO_BIN_EXE_plotx-cli")) + .arg("craft") + .arg(&input) + .arg("--output") + .arg(&output) + .output() + .unwrap(); + let report: serde_json::Value = + serde_json::from_slice(&std::fs::read(&output).unwrap()).unwrap(); + assert!( + result.status.success(), + "{report}\n{}", + String::from_utf8_lossy(&result.stderr) + ); + let item = &report["datasets"][0]; + assert_eq!(item["inspection"]["format"], "bruker-raw"); + assert_eq!(item["acquisition"]["observe_frequency_mhz"], 500.005); + assert_eq!( + item["chemical_shift_reference"]["reference_frequency_mhz"], + 500.0 + ); + let components = item["components"].as_array().unwrap(); + for component in components { + let frequency = component["frequency_hz"].as_f64().unwrap(); + let ppm = component["chemical_shift_ppm"].as_f64().unwrap(); + assert!((ppm - (10.0 + frequency / 500.0)).abs() < 1e-12); + } + for expected in [-75.0, 120.0] { + assert!( + components + .iter() + .any( + |component| (component["frequency_hz"].as_f64().unwrap() - expected).abs() + < 0.05 + ), + "{components:?}" + ); + } + + std::fs::write(input.join("acqus"), parameters.replace("##$GRPDLY=0\n", "")).unwrap(); + let result = Command::new(env!("CARGO_BIN_EXE_plotx-cli")) + .arg("craft") + .arg(&input) + .arg("--output") + .arg(&output) + .output() + .unwrap(); + assert!(!result.status.success()); + let report: serde_json::Value = + serde_json::from_slice(&std::fs::read(output).unwrap()).unwrap(); + assert_eq!(report["datasets"][0]["status"], "failed"); + assert!( + report["datasets"][0]["error"] + .as_str() + .unwrap() + .contains("delay evidence") + ); +} diff --git a/crates/cli/tests/nmr_inspect.rs b/crates/cli/tests/nmr_inspect.rs new file mode 100644 index 00000000..8ecf0c23 --- /dev/null +++ b/crates/cli/tests/nmr_inspect.rs @@ -0,0 +1,88 @@ +use std::{path::PathBuf, process::Command}; + +fn fixture(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../io/tests/fixtures/nmr") + .join(name) +} + +fn inspect(name: &str) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_plotx-cli")) + .arg("inspect") + .arg(fixture(name)) + .arg("--json") + .output() + .unwrap() +} + +#[test] +fn inspection_uses_raw_preference_and_does_not_run_the_default_recipe() { + let output = inspect("bruker-1d"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(report["domain"], "time"); + assert_eq!(report["format"], "bruker-raw"); + assert_eq!(report["dimension"]["shape"], serde_json::json!([2])); +} + +#[test] +fn selected_processed_input_and_sparse_logical_shape_are_preserved() { + for (name, format, shape) in [ + ("bruker-1d/pdata/1/1r", "bruker-processed-1d", vec![4]), + ("bruker-nus", "bruker-raw", vec![4, 2]), + ] { + let output = inspect(name); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(report["format"], format); + assert_eq!(report["dimension"]["shape"], serde_json::json!(shape)); + } +} + +#[test] +fn jeol_without_delay_evidence_is_inspectable_without_blanket_alerts() { + let output = inspect("jeol-complex.jdf"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(report["domain"], "time"); + assert!( + !report["warnings"] + .as_array() + .unwrap() + .iter() + .any(|warning| warning["code"] == "experimental-nmr-semantics") + ); +} + +#[test] +fn ppm_spectrum_is_inspectable_with_its_declared_shape() { + let output = inspect("jcamp-ppm.dx"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(report["domain"], "frequency"); + assert_eq!(report["dimension"]["shape"], serde_json::json!([4])); +} + +#[test] +fn invalid_vendor_input_does_not_fall_back_to_the_previous_reader() { + let output = inspect("varian-short-header.fid"); + assert_eq!(output.status.code(), Some(3)); + assert!(output.stdout.is_empty()); + assert!(String::from_utf8_lossy(&output.stderr).contains("header must contain 11 fields")); +} diff --git a/crates/cli/tests/nmr_sampling.rs b/crates/cli/tests/nmr_sampling.rs new file mode 100644 index 00000000..feb08273 --- /dev/null +++ b/crates/cli/tests/nmr_sampling.rs @@ -0,0 +1,115 @@ +use serde_json::{Value, json}; +use std::{ + path::{Path, PathBuf}, + process::Command, +}; + +fn fixture(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../io/tests/fixtures/nmr") + .join(name) +} +fn declaration() -> Value { + json!({"assertion_id": "cli-table", "source": "synthetic CLI input", "grid_shape": [4], "coordinates": [[4], [2]], "index_base": "one", "component_counts": [2]}) +} +fn raw(dir: &Path) { + for name in ["ser", "acqus", "acqu2s"] { + std::fs::copy(fixture(&format!("bruker-nus/{name}")), dir.join(name)).unwrap(); + } +} + +#[test] +fn inspect_and_process_use_explicit_declaration_and_reject_conflicts() { + let dir = tempfile::tempdir().unwrap(); + raw(dir.path()); + let table = dir.path().join("sampling.json"); + std::fs::write(&table, serde_json::to_vec(&declaration()).unwrap()).unwrap(); + let inspect = Command::new(env!("CARGO_BIN_EXE_plotx-cli")) + .arg("inspect") + .arg(dir.path()) + .arg("--sampling-declaration") + .arg(&table) + .arg("--json") + .output() + .unwrap(); + assert!( + inspect.status.success(), + "{}", + String::from_utf8_lossy(&inspect.stderr) + ); + let value: Value = serde_json::from_slice(&inspect.stdout).unwrap(); + assert_eq!(value["dimension"]["shape"], json!([4, 2])); + let scheme = dir.path().join("empty.plotxproc"); + std::fs::write(&scheme, r#"{"schema_version":1,"dimension_count":2,"pipelines":[{"steps":[]},{"steps":[]}],"group_delay_correct":false}"#).unwrap(); + let svg = dir.path().join("spectrum.svg"); + let process = Command::new(env!("CARGO_BIN_EXE_plotx-cli")) + .arg("process") + .arg(dir.path()) + .arg("--sampling-declaration") + .arg(&table) + .arg("--scheme") + .arg(&scheme) + .arg("--output") + .arg(&svg) + .output() + .unwrap(); + assert!( + process.status.success(), + "{}", + String::from_utf8_lossy(&process.stderr) + ); + assert!(svg.exists()); + let mut bad = declaration(); + bad["component_counts"] = json!([1]); + std::fs::write(&table, serde_json::to_vec(&bad).unwrap()).unwrap(); + let rejected = Command::new(env!("CARGO_BIN_EXE_plotx-cli")) + .arg("inspect") + .arg(dir.path()) + .arg("--sampling-declaration") + .arg(&table) + .arg("--json") + .output() + .unwrap(); + assert_eq!(rejected.status.code(), Some(3)); + assert!(rejected.stdout.is_empty()); + assert!(!dir.path().join("nuslist").exists()); +} + +#[test] +fn batch_import_checks_the_same_declaration() { + let dir = tempfile::tempdir().unwrap(); + raw(dir.path()); + let workflow = dir.path().join("workflow.json"); + let manifest = dir.path().join("manifest.json"); + let definition = json!({ + "schema": "plotx.workflow.v1", "inputs": {}, + "nodes": [{"id":"import", "tool_id":"data.import", + "parameters":{"paths":[dir.path()], "sampling_declaration":declaration()}, + "targets":{"kind":"explicit", "ids":[]}}], + "failure_policy":"continue_compatible" + }); + std::fs::write(&workflow, serde_json::to_vec(&definition).unwrap()).unwrap(); + let result = Command::new(env!("CARGO_BIN_EXE_plotx-cli")) + .arg("batch") + .arg("--workflow") + .arg(&workflow) + .arg("--manifest") + .arg(&manifest) + .output() + .unwrap(); + assert!( + result.status.success(), + "{}\n{}", + String::from_utf8_lossy(&result.stderr), + String::from_utf8_lossy(&result.stdout) + ); + let value: Value = serde_json::from_slice(&result.stdout).unwrap(); + assert_eq!( + value["nodes"][0]["result"]["targets"][0]["outcome"], + "succeeded" + ); + assert_eq!( + value, + serde_json::from_slice::(&std::fs::read(manifest).unwrap()).unwrap() + ); +} diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index f732e8c3..e047dd4d 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -15,6 +15,7 @@ default = [] datafusion = ["dep:plotx-datafusion"] [dependencies] +nmr.workspace = true plotx-data.workspace = true plotx-datafusion = { workspace = true, optional = true } plotx-io.workspace = true diff --git a/crates/core/src/actions/app_impl/processing.rs b/crates/core/src/actions/app_impl/processing.rs index 753ba5af..d6b5eaf7 100644 --- a/crates/core/src/actions/app_impl/processing.rs +++ b/crates/core/src/actions/app_impl/processing.rs @@ -44,11 +44,14 @@ impl PlotxApp { DatasetProcessingState::Nmr2D { params, preset, + nus_request, group_delay_correct, }, ) => { n.params = params.clone(); n.preset = *preset; + n.base_stale |= n.nus_request != *nus_request; + n.nus_request = *nus_request; n.group_delay_correct = *group_delay_correct; } (Dataset::Xrd(data), DatasetProcessingState::Xrd(processing)) => { @@ -94,13 +97,17 @@ impl PlotxApp { DatasetProcessingState::Nmr2D { params, preset, + nus_request, group_delay_correct, }, ) = (self.doc.datasets.get_mut(dataset), state) { - let force_full = current.group_delay_correct != *group_delay_correct; + let force_full = current.group_delay_correct != *group_delay_correct + || current.nus_request != *nus_request; + current.base_stale |= force_full; current.params = params.clone(); current.preset = *preset; + current.nus_request = *nus_request; current.group_delay_correct = *group_delay_correct; self.schedule_2d_processing(dataset, force_full); return Ok(()); @@ -393,18 +400,11 @@ pub(super) fn validate_processing_state( ) -> Result<(), String> { match (dataset, state) { (Dataset::Nmr(dataset), DatasetProcessingState::Nmr { pipeline, .. }) => pipeline - .output_domain(dataset.data.domain) + .output_domain(dataset.input_domain()) .map(|_| ()) .map_err(|error| format!("Cannot apply invalid direct processing pipeline: {error}")), (Dataset::Nmr2D(dataset), DatasetProcessingState::Nmr2D { params, .. }) => { - params - .f2 - .output_domain(dataset.data.domain) - .map_err(|error| format!("Cannot apply invalid F2 processing pipeline: {error}"))?; - params - .f1 - .output_domain(dataset.data.domain) - .map_err(|error| format!("Cannot apply invalid F1 processing pipeline: {error}"))?; + plotx_processing::nmr_execution::validate_2d_domains(&dataset.data, params)?; Ok(()) } (Dataset::Xrd(_), DatasetProcessingState::Xrd(processing)) => { diff --git a/crates/core/src/actions/mod.rs b/crates/core/src/actions/mod.rs index 14c04c84..4bfe77a2 100644 --- a/crates/core/src/actions/mod.rs +++ b/crates/core/src/actions/mod.rs @@ -41,6 +41,7 @@ pub enum DatasetProcessingState { params: Params2D, preset: Preset2D, group_delay_correct: bool, + nus_request: Option, }, /// A table has no reversible processing recipe; its curve fits are edited /// through their own actions. diff --git a/crates/core/src/actions/processing_state.rs b/crates/core/src/actions/processing_state.rs index 9e86eeed..1712c3f1 100644 --- a/crates/core/src/actions/processing_state.rs +++ b/crates/core/src/actions/processing_state.rs @@ -47,6 +47,7 @@ impl DatasetProcessingState { Dataset::Nmr2D(n) => Self::Nmr2D { params: n.params.clone(), preset: n.preset, + nus_request: n.nus_request, group_delay_correct: n.group_delay_correct, }, Dataset::Table(_) => Self::Table, @@ -102,7 +103,7 @@ impl DatasetProcessingState { group_delay_correct, }, ) => { - pipeline.output_domain(n.data.domain).map_err(|error| { + pipeline.output_domain(n.input_domain()).map_err(|error| { ProcessingStateError::InvalidPipeline { axis: "direct", details: error.to_string(), @@ -114,17 +115,26 @@ impl DatasetProcessingState { *group_delay_correct, n.group_delay_correct, ); - n.pipeline = pipeline.clone(); - n.repair_step_allocator(); - n.group_delay_correct = *group_delay_correct; + let mut next = (**n).clone(); + next.pipeline = pipeline.clone(); + next.repair_step_allocator(); + next.group_delay_correct = *group_delay_correct; + let result = if full { + next.retransform() + } else { + next.rebuild() + }; + result.map_err(|details| ProcessingStateError::InvalidPipeline { + axis: "direct", + details, + })?; + next.recompute_integrals(); + **n = next; let rebuild = if full { - n.retransform(); ProcessingRebuild::Retransformed } else { - n.rebuild(); ProcessingRebuild::Rebuilt }; - n.recompute_integrals(); Ok(rebuild) } ( @@ -132,34 +142,40 @@ impl DatasetProcessingState { Self::Nmr2D { params, preset, + nus_request, group_delay_correct, }, ) => { - params.f2.output_domain(n.data.domain).map_err(|error| { - ProcessingStateError::InvalidPipeline { - axis: "F2", - details: error.to_string(), - } - })?; - params.f1.output_domain(n.data.domain).map_err(|error| { - ProcessingStateError::InvalidPipeline { - axis: "F1", - details: error.to_string(), - } + plotx_processing::nmr_execution::validate_2d_domains(&n.data, params).map_err( + |details| ProcessingStateError::InvalidPipeline { + axis: "2D", + details, + }, + )?; + let full = plotx_processing::needs_retransform_2d(params, &n.params) + || *group_delay_correct != n.group_delay_correct + || *nus_request != n.nus_request; + let mut next = (**n).clone(); + next.params = params.clone(); + next.repair_step_allocator(); + next.preset = *preset; + next.nus_request = *nus_request; + next.group_delay_correct = *group_delay_correct; + let result = if full { + next.retransform() + } else { + next.rebuild() + }; + result.map_err(|details| ProcessingStateError::InvalidPipeline { + axis: "2D", + details, })?; - let full = plotx_processing::needs_retransform_2d(params, &n.params); - let full = full || *group_delay_correct != n.group_delay_correct; - n.params = params.clone(); - n.repair_step_allocator(); - n.preset = *preset; - n.group_delay_correct = *group_delay_correct; - if full { - n.retransform(); - Ok(ProcessingRebuild::Retransformed) + **n = next; + Ok(if full { + ProcessingRebuild::Retransformed } else { - n.rebuild(); - Ok(ProcessingRebuild::Rebuilt) - } + ProcessingRebuild::Rebuilt + }) } (Dataset::Table(_), Self::Table) => Ok(ProcessingRebuild::Unchanged), (Dataset::Electrophysiology(data), Self::Electrophysiology(processing)) => { diff --git a/crates/core/src/actions/tests/align.rs b/crates/core/src/actions/tests/align.rs index f5031abf..5b4ce836 100644 --- a/crates/core/src/actions/tests/align.rs +++ b/crates/core/src/actions/tests/align.rs @@ -32,11 +32,9 @@ fn app_with(peaks: &[f64]) -> PlotxApp { let mut app = PlotxApp::new(); app.doc.save_include_view_snapshots = false; for &p in peaks { - app.doc - .datasets - .push(Dataset::Nmr(Box::new(NmrDataset::load(synthetic_at( - p, "1H", - ))))); + app.doc.datasets.push(Dataset::Nmr(Box::new( + NmrDataset::load(synthetic_at(p, "1H")).unwrap(), + ))); } let all: Vec = (0..peaks.len()).collect(); app.focus_datasets(&all, Some(0)); @@ -110,16 +108,12 @@ fn window_without_peak_skips_every_spectrum() { #[test] fn other_nuclei_and_non_1d_datasets_are_skipped_with_reasons() { let mut app = app_with(&[2.0, 2.5]); - app.doc - .datasets - .push(Dataset::Nmr(Box::new(NmrDataset::load(synthetic_at( - 2.2, "13C", - ))))); - app.doc - .datasets - .push(Dataset::Nmr2D(Box::new(crate::state::Nmr2DDataset::load( - synthetic_2d(), - )))); + app.doc.datasets.push(Dataset::Nmr(Box::new( + NmrDataset::load(synthetic_at(2.2, "13C")).unwrap(), + ))); + app.doc.datasets.push(Dataset::Nmr2D(Box::new( + crate::nmr_test_support::load_2d(synthetic_2d()).unwrap(), + ))); app.focus_datasets(&[0, 1, 2, 3], Some(0)); let carbon_before = peak_ppm(&app, 2); diff --git a/crates/core/src/actions/tests/arithmetic.rs b/crates/core/src/actions/tests/arithmetic.rs index 23935839..bee4b6a2 100644 --- a/crates/core/src/actions/tests/arithmetic.rs +++ b/crates/core/src/actions/tests/arithmetic.rs @@ -4,9 +4,9 @@ use plotx_processing::arithmetic::SpectrumBinaryOp; fn two_spectrum_app() -> PlotxApp { let mut app = sample_app(); - app.doc - .datasets - .push(Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d())))); + app.doc.datasets.push(Dataset::Nmr(Box::new( + NmrDataset::load(synthetic_1d()).unwrap(), + ))); app } @@ -86,7 +86,7 @@ fn result_dataset_replays_exactly_after_retransform() { app.combine_spectra_datasets(0, 1, SpectrumBinaryOp::Subtract, 0.5); let mut ds = app.doc.datasets[2].as_nmr().unwrap().clone(); let shown = ds.spectrum().unwrap().clone(); - ds.retransform(); + ds.retransform().unwrap(); assert_eq!(ds.spectrum().unwrap().values.len(), shown.values.len()); for (a, b) in ds.spectrum().unwrap().values.iter().zip(&shown.values) { assert!((a - b).norm() < 1e-9); @@ -103,14 +103,18 @@ fn nucleus_mismatch_is_rejected_without_side_effects() { other.nucleus = "13C".to_owned(); app.doc .datasets - .push(Dataset::Nmr(Box::new(NmrDataset::load(other)))); + .push(Dataset::Nmr(Box::new(NmrDataset::load(other).unwrap()))); let canvases_before = app.doc.canvases.len(); app.combine_spectra_datasets(0, 1, SpectrumBinaryOp::Subtract, 1.0); assert_eq!(app.doc.datasets.len(), 2); assert_eq!(app.doc.canvases.len(), canvases_before); - assert!(app.session.status.contains("Nuclei differ")); + assert!( + app.session.status.contains("incompatible"), + "{}", + app.session.status + ); assert!(app.spectrum_arithmetic_compat(0, 1).is_err()); } @@ -135,17 +139,16 @@ fn single_point_operands_combine_without_panicking() { domain: plotx_io::Domain::Frequency, values: vec![num_complex::Complex64::new(re, 0.0)], nucleus: "1H".to_owned(), - observe_freq_mhz: 400.0, + observe_freq_mhz: Some(400.0), + reference_freq_mhz: Some(400.0), + unit: nmr::axis::AxisUnit::Ppm, position: None, position_domain: plotx_io::Domain::Frequency, }; for (i, s) in [point(1.0, 2.0), point(3.0, 5.0)].into_iter().enumerate() { - app.doc - .datasets - .push(Dataset::Nmr(Box::new(NmrDataset::from_slice( - s, - format!("p{i}"), - )))); + app.doc.datasets.push(Dataset::Nmr(Box::new( + NmrDataset::from_slice(s, format!("p{i}")).unwrap(), + ))); } app.combine_spectra_datasets(0, 1, SpectrumBinaryOp::Add, 1.0); diff --git a/crates/core/src/actions/tests/linefit.rs b/crates/core/src/actions/tests/linefit.rs index 6720f723..10eb1f43 100644 --- a/crates/core/src/actions/tests/linefit.rs +++ b/crates/core/src/actions/tests/linefit.rs @@ -26,11 +26,15 @@ fn two_lorentzian_dataset(name: &str) -> Dataset { domain: plotx_io::Domain::Frequency, values, nucleus: "1H".to_owned(), - observe_freq_mhz: 400.0, + observe_freq_mhz: Some(400.0), + reference_freq_mhz: Some(400.0), + unit: nmr::axis::AxisUnit::Ppm, position: None, position_domain: plotx_io::Domain::Frequency, }; - Dataset::Nmr(Box::new(NmrDataset::from_slice(slice, name.to_owned()))) + Dataset::Nmr(Box::new( + NmrDataset::from_slice(slice, name.to_owned()).unwrap(), + )) } fn two_lorentzian_app() -> PlotxApp { diff --git a/crates/core/src/actions/tests/mod.rs b/crates/core/src/actions/tests/mod.rs index f534d66c..83204eb3 100644 --- a/crates/core/src/actions/tests/mod.rs +++ b/crates/core/src/actions/tests/mod.rs @@ -54,9 +54,9 @@ fn synthetic_1d() -> NmrData { pub(super) fn sample_app() -> PlotxApp { let mut app = PlotxApp::new(); app.doc.save_include_view_snapshots = false; - app.doc - .datasets - .push(Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d())))); + app.doc.datasets.push(Dataset::Nmr(Box::new( + NmrDataset::load(synthetic_1d()).unwrap(), + ))); push_canvas(&mut app, 0, "sample canvas", [120.0, 80.0]); app.focus_single(0); app.session.active_canvas = Some(0); @@ -115,7 +115,7 @@ fn data_tool_target_requires_data_verb_and_selected_plot() { fn insert_dataset_new_canvas_does_not_select_object() { let mut app = PlotxApp::new(); app.doc.save_include_view_snapshots = false; - let dataset = Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d()))); + let dataset = Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d()).unwrap())); app.execute_action(Action::insert_dataset_with_default_canvas( &app, @@ -141,7 +141,7 @@ fn insert_dataset_existing_canvas_does_not_select_inserted_object() { app.doc.canvases[0].selected_object = None; let inserted_id = app.doc.canvases[0].next_object_id; let dataset_index = app.doc.datasets.len(); - let dataset = Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d()))); + let dataset = Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d()).unwrap())); app.execute_action(Action::InsertDatasetWithCanvas { dataset_index, @@ -761,7 +761,12 @@ pub(super) fn synthetic_2d() -> plotx_io::NmrData2D { group_delay: 0.0, }; NmrData2D { - data: vec![Complex64::new(0.0, 0.0); rows * cols], + data: (0..rows * cols) + .map(|i| { + Complex64::from_polar((-0.05 * (i % cols) as f64).exp(), 0.8 * (i % cols) as f64) + * (0.3 * (i / cols) as f64).cos() + }) + .collect(), rows, cols, domain: Domain::Time, diff --git a/crates/core/src/actions/tests/more.rs b/crates/core/src/actions/tests/more.rs index 21fc1463..5cfb2de7 100644 --- a/crates/core/src/actions/tests/more.rs +++ b/crates/core/src/actions/tests/more.rs @@ -5,9 +5,9 @@ use super::*; #[test] fn stacked_binding_builds_distinctly_coloured_series_with_legend() { let mut app = sample_app(); - app.doc - .datasets - .push(Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d())))); + app.doc.datasets.push(Dataset::Nmr(Box::new( + NmrDataset::load(synthetic_1d()).unwrap(), + ))); let object = app.doc.canvases[0].objects[0].id; let mut second = crate::state::SeriesBinding::from_dataset(&app.doc.datasets[1]).unwrap(); second.set_primary_color(plotx_figure::Color::rgb(0x8a, 0x1c, 0x1c)); @@ -133,14 +133,12 @@ fn set_chart_type_switches_table_to_categorical_bars_and_undoes() { #[test] fn stack_candidates_reject_incompatible_datasets() { let mut app = sample_app(); - app.doc - .datasets - .push(Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d())))); - app.doc - .datasets - .push(Dataset::Nmr2D(Box::new(crate::state::Nmr2DDataset::load( - synthetic_2d(), - )))); + app.doc.datasets.push(Dataset::Nmr(Box::new( + NmrDataset::load(synthetic_1d()).unwrap(), + ))); + app.doc.datasets.push(Dataset::Nmr2D(Box::new( + crate::nmr_test_support::load_2d(synthetic_2d()).unwrap(), + ))); let binding = crate::state::DataBinding::single(&app.doc.datasets[0]); let candidates = app.stack_candidates(&binding); @@ -162,11 +160,9 @@ fn axis_projections_attach_and_project_survive_undo() { // dataset 0 = 1D (from sample_app), dataset 1 = a true-2D contour on canvas 1. let mut app = sample_app(); - app.doc - .datasets - .push(Dataset::Nmr2D(Box::new(crate::state::Nmr2DDataset::load( - synthetic_2d(), - )))); + app.doc.datasets.push(Dataset::Nmr2D(Box::new( + crate::nmr_test_support::load_2d(synthetic_2d()).unwrap(), + ))); assert!(app.doc.datasets[1].as_nmr2d().unwrap().is_true_2d()); push_canvas(&mut app, 1, "2d", [120.0, 80.0]); let ci = 1; @@ -224,7 +220,7 @@ fn axis_projections_attach_and_project_survive_undo() { fn auto_phase_pivot_reports_the_peak_ppm() { use crate::state::PhaseAxis; - let dataset = Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d()))); + let dataset = Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d()).unwrap())); let pivot = dataset.pivot_ppm(PhaseAxis::Direct).unwrap(); assert!( (pivot - 2.0).abs() < 0.2, @@ -238,7 +234,7 @@ fn auto_phase_pivot_reports_the_peak_ppm() { fn auto_phase_pivot_reports_the_peak_ppm_2d() { use crate::state::PhaseAxis; - let dataset = crate::state::Nmr2DDataset::load(synthetic_2d()); + let dataset = crate::state::Nmr2DDataset::load(synthetic_2d()).unwrap(); let s = match &dataset.base { plotx_processing::Processed2D::Ft(s) => s, plotx_processing::Processed2D::Stack(_) => unreachable!("synthetic_2d is true-2D"), @@ -273,11 +269,9 @@ fn manual_2d_phase_inherits_the_automatic_solution() { use crate::state::PhaseAxis; let mut app = PlotxApp::new(); - app.doc - .datasets - .push(Dataset::Nmr2D(Box::new(crate::state::Nmr2DDataset::load( - synthetic_2d(), - )))); + app.doc.datasets.push(Dataset::Nmr2D(Box::new( + crate::state::Nmr2DDataset::load(synthetic_2d()).unwrap(), + ))); let expected = app.doc.datasets[0] .automatic_phase_params(PhaseAxis::F2) .unwrap(); diff --git a/crates/core/src/actions/tests/multiplet.rs b/crates/core/src/actions/tests/multiplet.rs index a3e3fdb7..4aeb1a88 100644 --- a/crates/core/src/actions/tests/multiplet.rs +++ b/crates/core/src/actions/tests/multiplet.rs @@ -11,11 +11,13 @@ fn doublet_marked_app() -> PlotxApp { domain: plotx_io::Domain::Frequency, values, nucleus: "1H".to_owned(), - observe_freq_mhz: 400.0, + observe_freq_mhz: Some(400.0), + reference_freq_mhz: Some(400.0), + unit: nmr::axis::AxisUnit::Ppm, position: None, position_domain: plotx_io::Domain::Frequency, }; - let mut nmr = NmrDataset::from_slice(slice, "doublet".to_owned()); + let mut nmr = NmrDataset::from_slice(slice, "doublet".to_owned()).unwrap(); for (id, x) in [(0u64, 2.0), (1u64, 2.0 + 7.0 / 400.0)] { nmr.peaks.marks.push(PeakMark { id, diff --git a/crates/core/src/actions/tests/scheme_apply.rs b/crates/core/src/actions/tests/scheme_apply.rs index 33a31ec2..d86b6a21 100644 --- a/crates/core/src/actions/tests/scheme_apply.rs +++ b/crates/core/src/actions/tests/scheme_apply.rs @@ -25,9 +25,9 @@ fn group_delay(app: &PlotxApp, di: usize) -> bool { fn batch_template_apply_filters_incompatible_targets_and_undoes_as_one_step() { let mut app = PlotxApp::new(); for _ in 0..2 { - app.doc - .datasets - .push(Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d())))); + app.doc.datasets.push(Dataset::Nmr(Box::new( + NmrDataset::load(synthetic_1d()).unwrap(), + ))); } if let Dataset::Nmr(n) = &mut app.doc.datasets[0] { n.group_delay_correct = false; @@ -93,9 +93,9 @@ fn a_hand_written_scheme_without_step_ids_loads_and_applies() { serde_json::from_str(json).expect("a recipe may omit step identities"); let mut app = PlotxApp::new(); - app.doc - .datasets - .push(Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d())))); + app.doc.datasets.push(Dataset::Nmr(Box::new( + NmrDataset::load(synthetic_1d()).unwrap(), + ))); let plan = plan_scheme_application(&scheme, &app.doc.datasets, &[0]); assert_eq!(plan.compatible_count(), 1); @@ -119,9 +119,9 @@ fn a_hand_written_scheme_without_step_ids_loads_and_applies() { #[test] fn a_saved_scheme_omits_step_identities() { let mut app = PlotxApp::new(); - app.doc - .datasets - .push(Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d())))); + app.doc.datasets.push(Dataset::Nmr(Box::new( + NmrDataset::load(synthetic_1d()).unwrap(), + ))); let path = temp_scheme("no-step-ids"); save_scheme(&path, &app.doc.datasets[0]).unwrap(); let written = std::fs::read_to_string(&path).unwrap(); diff --git a/crates/core/src/actions/tests/stable_identity.rs b/crates/core/src/actions/tests/stable_identity.rs index be96a4b9..e79885f9 100644 --- a/crates/core/src/actions/tests/stable_identity.rs +++ b/crates/core/src/actions/tests/stable_identity.rs @@ -9,7 +9,7 @@ use plotx_processing::{ProcessingStep, StepKind, StepSource}; #[test] fn dataset_delete_undo_restores_identity_and_persistent_references() { let mut app = sample_app(); - let mut inserted = Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d()))); + let mut inserted = Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d()).unwrap())); let inserted_id = DatasetId::new(); inserted.set_resource_id(inserted_id); let action = Action::insert_dataset_with_default_canvas( @@ -71,7 +71,7 @@ fn canvas_dataset_ids_follow_first_appearance_and_page_indices_follow_document_o ]; app.doc.datasets[0].set_resource_id(ids[0]); for id in &ids[1..] { - let mut dataset = Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d()))); + let mut dataset = Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d()).unwrap())); dataset.set_resource_id(*id); app.doc.datasets.push(dataset); } @@ -121,7 +121,7 @@ fn syncing_integral_curves_ignores_a_stale_dataset_index() { #[test] fn series_reorder_preserves_ids_and_only_changes_order() { let mut app = sample_app(); - let second = Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d()))); + let second = Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d()).unwrap())); let second_id = second.resource_id(); app.doc.datasets.push(second); let plot = app.doc.canvases[0].objects[0].plot_mut().unwrap(); @@ -144,7 +144,7 @@ fn series_reorder_preserves_ids_and_only_changes_order() { #[test] fn step_and_series_allocators_do_not_rollback_with_undo() { let mut app = sample_app(); - let second = Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d()))); + let second = Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d()).unwrap())); let second_id = second.resource_id(); app.doc.datasets.push(second); @@ -254,9 +254,9 @@ fn step_and_series_allocators_do_not_rollback_with_undo() { #[test] fn an_expanded_step_does_not_leak_onto_another_dataset_with_the_same_id() { let mut app = sample_app(); - app.doc - .datasets - .push(Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d())))); + app.doc.datasets.push(Dataset::Nmr(Box::new( + NmrDataset::load(synthetic_1d()).unwrap(), + ))); let phase_id = |app: &crate::state::PlotxApp, index: usize| { app.doc.datasets[index] diff --git a/crates/core/src/actions/tests/stack.rs b/crates/core/src/actions/tests/stack.rs index e15f8736..409cd806 100644 --- a/crates/core/src/actions/tests/stack.rs +++ b/crates/core/src/actions/tests/stack.rs @@ -7,9 +7,9 @@ fn stacked_figure_is_domain_generic_with_offset_scale_and_hide() { // NMR 1D and Table domains exercise the same generic stacking path. let mut nmr = sample_app(); - nmr.doc - .datasets - .push(Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d())))); + nmr.doc.datasets.push(Dataset::Nmr(Box::new( + NmrDataset::load(synthetic_1d()).unwrap(), + ))); let (mut table, _) = table_app_with_sigma(vec![0.1, 0.1, 0.1]); let second = second_table_with_sigma(vec![0.2, 0.2, 0.2]); table.doc.datasets.push(Dataset::Table(Box::new(second))); @@ -83,16 +83,12 @@ fn field_overlay_stacks_two_2d_contours_in_distinct_colors() { 0.0, ); } - app.doc - .datasets - .push(Dataset::Nmr2D(Box::new(crate::state::Nmr2DDataset::load( - signed_grid.clone(), - )))); - app.doc - .datasets - .push(Dataset::Nmr2D(Box::new(crate::state::Nmr2DDataset::load( - signed_grid, - )))); + app.doc.datasets.push(Dataset::Nmr2D(Box::new( + crate::nmr_test_support::load_2d(signed_grid.clone()).unwrap(), + ))); + app.doc.datasets.push(Dataset::Nmr2D(Box::new( + crate::nmr_test_support::load_2d(signed_grid).unwrap(), + ))); let (a, b) = (app.doc.datasets.len() - 2, app.doc.datasets.len() - 1); let mut binding = DataBinding { series: vec![ @@ -145,9 +141,9 @@ fn field_overlay_stacks_two_2d_contours_in_distinct_colors() { #[test] fn plain_then_ctrl_click_selects_two_datasets_for_stacking() { let mut app = sample_app(); - app.doc - .datasets - .push(Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d())))); + app.doc.datasets.push(Dataset::Nmr(Box::new( + NmrDataset::load(synthetic_1d()).unwrap(), + ))); // The first click must count toward the stack: plain-click A then Ctrl-click // B yields a two-item selection (no "Ctrl the first item" trap). @@ -183,9 +179,9 @@ fn plain_then_ctrl_click_selects_two_datasets_for_stacking() { #[test] fn ctrl_clicking_two_identical_1d_datasets_enables_stack() { let mut app = sample_app(); - app.doc - .datasets - .push(Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d())))); + app.doc.datasets.push(Dataset::Nmr(Box::new( + NmrDataset::load(synthetic_1d()).unwrap(), + ))); app.clear_selection(); app.toggle_selection(0, true); @@ -203,9 +199,9 @@ fn ctrl_clicking_two_identical_1d_datasets_enables_stack() { #[test] fn selecting_canvas_populates_data_selection_with_its_datasets() { let mut app = sample_app(); - app.doc - .datasets - .push(Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d())))); + app.doc.datasets.push(Dataset::Nmr(Box::new( + NmrDataset::load(synthetic_1d()).unwrap(), + ))); let object = app.doc.canvases[0].objects[0].id; let binding = crate::state::DataBinding { series: vec![ @@ -238,9 +234,9 @@ fn selecting_canvas_populates_data_selection_with_its_datasets() { fn plot_object_reports_every_bound_dataset_for_selection_mirroring() { use crate::state::{DataBinding, SeriesBinding}; let mut app = sample_app(); - app.doc - .datasets - .push(Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d())))); + app.doc.datasets.push(Dataset::Nmr(Box::new( + NmrDataset::load(synthetic_1d()).unwrap(), + ))); let object = app.doc.canvases[0].objects[0].id; let binding = DataBinding { series: vec![ @@ -309,9 +305,9 @@ fn shear_sign_flips_the_pseudo_3d_lean_direction() { fn multi_selecting_pages_in_the_workspace_populates_data_for_stacking() { use crate::state::FrameRef; let mut app = sample_app(); - app.doc - .datasets - .push(Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d())))); + app.doc.datasets.push(Dataset::Nmr(Box::new( + NmrDataset::load(synthetic_1d()).unwrap(), + ))); push_canvas(&mut app, 1, "second canvas", [120.0, 80.0]); app.session.ui.frame_selection = @@ -331,9 +327,9 @@ fn multi_selecting_pages_in_the_workspace_populates_data_for_stacking() { fn selecting_one_page_pulls_active_into_the_set_so_no_phantom_highlight() { use crate::state::FrameRef; let mut app = sample_app(); - app.doc - .datasets - .push(Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d())))); + app.doc.datasets.push(Dataset::Nmr(Box::new( + NmrDataset::load(synthetic_1d()).unwrap(), + ))); push_canvas(&mut app, 1, "second canvas", [120.0, 80.0]); // A stale active dataset (0) points outside the frame about to be selected. @@ -352,9 +348,9 @@ fn selecting_one_page_pulls_active_into_the_set_so_no_phantom_highlight() { #[test] fn every_selection_mutator_keeps_active_inside_the_set() { let mut app = sample_app(); - app.doc - .datasets - .push(Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d())))); + app.doc.datasets.push(Dataset::Nmr(Box::new( + NmrDataset::load(synthetic_1d()).unwrap(), + ))); let holds = |a: &PlotxApp| { a.active_dataset() diff --git a/crates/core/src/actions/tests/symmetry.rs b/crates/core/src/actions/tests/symmetry.rs index 6d81751f..b05519ad 100644 --- a/crates/core/src/actions/tests/symmetry.rs +++ b/crates/core/src/actions/tests/symmetry.rs @@ -1,5 +1,5 @@ use super::*; -use crate::state::{Nmr2DDataset, Peak2DOrigin, Peak2DPoint, Peak2DReview, Peak2DSet}; +use crate::state::{Peak2DOrigin, Peak2DPoint, Peak2DReview, Peak2DSet}; #[test] fn cross_peak_pair_is_one_undoable_edit() { @@ -8,9 +8,9 @@ fn cross_peak_pair_is_one_undoable_edit() { data.experiment = Some("cosy".to_owned()); let mut app = PlotxApp::new(); - app.doc - .datasets - .push(Dataset::Nmr2D(Box::new(Nmr2DDataset::load(data)))); + app.doc.datasets.push(Dataset::Nmr2D(Box::new( + crate::nmr_test_support::load_2d(data).unwrap(), + ))); let dataset_id = app.doc.datasets[0].resource_id(); let before = Peak2DSet::default(); let mut after = before.clone(); diff --git a/crates/core/src/automation/properties_tests_inbound_value.rs b/crates/core/src/automation/properties_tests_inbound_value.rs index 3d891019..6821ecd8 100644 --- a/crates/core/src/automation/properties_tests_inbound_value.rs +++ b/crates/core/src/automation/properties_tests_inbound_value.rs @@ -214,7 +214,7 @@ fn smoothing_app() -> (PlotxApp, String) { source: "automation smoothing".to_owned(), group_delay: 0.0, }; - let mut dataset = NmrDataset::load(data); + let mut dataset = NmrDataset::load(data).unwrap(); let id = dataset.allocate_step_id(); dataset .pipeline diff --git a/crates/core/src/automation/properties_tests_outbound.rs b/crates/core/src/automation/properties_tests_outbound.rs index 0bc46afa..e23eb9b6 100644 --- a/crates/core/src/automation/properties_tests_outbound.rs +++ b/crates/core/src/automation/properties_tests_outbound.rs @@ -202,9 +202,8 @@ fn document_property_tools_address_the_document_root() { #[test] fn dataset_property_tools_expand_processing_steps_and_report_non_apodization_skips() { let mut app = PlotxApp::new(); - app.doc - .datasets - .push(Dataset::Nmr(Box::new(NmrDataset::load(NmrData { + app.doc.datasets.push(Dataset::Nmr(Box::new( + NmrDataset::load(NmrData { points: (0..32) .map(|value| num_complex::Complex64::new(f64::from(value), 0.0)) .collect(), @@ -215,7 +214,9 @@ fn dataset_property_tools_expand_processing_steps_and_report_non_apodization_ski nucleus: "1H".to_owned(), source: "automation apodization".to_owned(), group_delay: 0.0, - })))); + }) + .unwrap(), + ))); let dataset = app.doc.datasets[0].resource_id().to_string(); let request = request( &app, @@ -268,9 +269,8 @@ fn dataset_property_tools_expand_processing_steps_and_report_non_apodization_ski #[test] fn inspect_reports_the_actionable_reason_for_a_disabled_phase_parameter() { let mut app = PlotxApp::new(); - app.doc - .datasets - .push(Dataset::Nmr(Box::new(NmrDataset::load(NmrData { + app.doc.datasets.push(Dataset::Nmr(Box::new( + NmrDataset::load(NmrData { points: (0..32) .map(|value| num_complex::Complex64::new(f64::from(value), 0.25)) .collect(), @@ -281,7 +281,9 @@ fn inspect_reports_the_actionable_reason_for_a_disabled_phase_parameter() { nucleus: "1H".to_owned(), source: "automation phase availability".to_owned(), group_delay: 0.0, - })))); + }) + .unwrap(), + ))); let dataset = app.doc.datasets[0].resource_id().to_string(); let inspect = request( &app, @@ -301,9 +303,8 @@ fn inspect_reports_the_actionable_reason_for_a_disabled_phase_parameter() { #[test] fn degree_schema_dto_keeps_display_log_and_unit_consistent() { let mut app = PlotxApp::new(); - app.doc - .datasets - .push(Dataset::Nmr(Box::new(NmrDataset::load(NmrData { + app.doc.datasets.push(Dataset::Nmr(Box::new( + NmrDataset::load(NmrData { points: (0..32) .map(|value| num_complex::Complex64::new(f64::from(value), 0.25)) .collect(), @@ -314,7 +315,9 @@ fn degree_schema_dto_keeps_display_log_and_unit_consistent() { nucleus: "1H".to_owned(), source: "automation phase display".to_owned(), group_delay: 0.0, - })))); + }) + .unwrap(), + ))); let dataset = app.doc.datasets[0].resource_id().to_string(); let inspect = request( &app, diff --git a/crates/core/src/automation/properties_tests_rejections.rs b/crates/core/src/automation/properties_tests_rejections.rs index 87aae9a3..864c503d 100644 --- a/crates/core/src/automation/properties_tests_rejections.rs +++ b/crates/core/src/automation/properties_tests_rejections.rs @@ -158,9 +158,8 @@ fn whole_encoding_reset_is_not_a_tool() { #[test] fn the_catalog_capability_follows_addressable_components_not_the_dataset_kind() { let (mut app, _) = contour_app(); - app.doc - .datasets - .push(Dataset::Nmr(Box::new(NmrDataset::load(NmrData { + app.doc.datasets.push(Dataset::Nmr(Box::new( + NmrDataset::load(NmrData { points: (0..8) .map(|value| num_complex::Complex64::new(f64::from(value), 0.0)) .collect(), @@ -171,7 +170,9 @@ fn the_catalog_capability_follows_addressable_components_not_the_dataset_kind() nucleus: "1H".to_owned(), source: "capability gate".to_owned(), group_delay: 0.0, - })))); + }) + .unwrap(), + ))); let catalog = CapabilityId::new(CAP_PROPERTY_CATALOG); let provider = ProjectResourceProvider::new(&app); let descriptors = provider.descriptors(); diff --git a/crates/core/src/automation/registry.rs b/crates/core/src/automation/registry.rs index ef7ddea6..c337d9a5 100644 --- a/crates/core/src/automation/registry.rs +++ b/crates/core/src/automation/registry.rs @@ -122,6 +122,8 @@ pub(super) struct SchemeParams { #[serde(deny_unknown_fields)] pub(super) struct ImportParams { pub paths: Vec, + #[serde(default)] + pub sampling_declaration: Option, } #[derive(Deserialize)] #[serde(deny_unknown_fields)] @@ -165,7 +167,7 @@ schema!(CompareParams, [], ["before" => "array"]); schema!(RenameParams, ["name" => "string"], []); schema!(ThemeParams, ["theme_id" => "string"], []); schema!(SchemeParams, ["path" => "string"], ["compatible_only" => "boolean"]); -schema!(ImportParams, ["paths" => "array"], []); +schema!(ImportParams, ["paths" => "array"], ["sampling_declaration" => "object"]); schema!(TransformParams, ["plan" => "object", "name" => "string"], ["memory_limit_bytes" => "integer"]); schema!(ExportParams, ["directory" => "string", "format" => "string"], ["dpi" => "integer", "overwrite" => "boolean"]); schema!(super::properties::PropertyKeyParams, ["key" => "string"], []); diff --git a/crates/core/src/automation/tool_executors.rs b/crates/core/src/automation/tool_executors.rs index 56bf660e..ad7e9219 100644 --- a/crates/core/src/automation/tool_executors.rs +++ b/crates/core/src/automation/tool_executors.rs @@ -249,7 +249,10 @@ pub(super) fn execute_import( parent_id: None, local_id: None, }); - let loaded = match crate::workflow::load_dataset(path) { + let loaded = match params.sampling_declaration.clone().map_or_else( + || crate::workflow::load_dataset(path), + |declaration| crate::workflow::load_dataset_with_sampling(path, declaration), + ) { Ok(loaded) => loaded, Err(error) => { item_results.push(TargetResult { diff --git a/crates/core/src/data_export.rs b/crates/core/src/data_export.rs index 221458d1..fe5dcc5f 100644 --- a/crates/core/src/data_export.rs +++ b/crates/core/src/data_export.rs @@ -8,6 +8,8 @@ use plotx_processing::{Processed2D, Spectrum2D, StackSpectrum, StepKind}; use std::io::{self, Write}; use std::sync::Arc; +#[path = "data_export/nmr.rs"] +mod nmr_export; mod service; pub use service::*; mod write; @@ -482,7 +484,10 @@ fn capture_processed(dataset: &Dataset) -> Result Dataset::Nmr(nmr) => { let (axis, axis_label) = match &nmr.processed { plotx_processing::Processed1D::Time(trace) => (trace.time_s.clone(), "time_s"), - plotx_processing::Processed1D::Frequency(spectrum) => (spectrum.ppm.clone(), "ppm"), + plotx_processing::Processed1D::Frequency(spectrum) => ( + spectrum.ppm.clone(), + plotx_processing::axis_unit_label(Some(spectrum.unit)), + ), }; Ok(SnapshotData::Nmr1D { axis, @@ -490,23 +495,7 @@ fn capture_processed(dataset: &Dataset) -> Result values: nmr.processed.values().to_vec(), }) } - Dataset::Nmr2D(nmr) => match &nmr.processed { - Processed2D::Ft(spectrum) => Ok(SnapshotData::True2D(Arc::clone(spectrum))), - Processed2D::Stack(spectrum) => { - let axis = nmr.data.pseudo_axis.as_ref(); - Ok(SnapshotData::Pseudo2D { - spectrum: Arc::clone(spectrum), - ruler_name: axis - .map(|axis| axis.name.clone()) - .filter(|name| !name.is_empty()) - .unwrap_or_else(|| "Ruler".into()), - ruler_unit: axis.map(|axis| axis.unit.clone()).unwrap_or_default(), - ruler: axis - .map(|axis| axis.values.clone()) - .unwrap_or_else(|| (0..spectrum.increments()).map(|i| i as f64).collect()), - }) - } - }, + Dataset::Nmr2D(nmr) => nmr_export::snapshot_series(nmr), Dataset::Electrophysiology(recording) => { let channel = recording .data diff --git a/crates/core/src/data_export/nmr.rs b/crates/core/src/data_export/nmr.rs new file mode 100644 index 00000000..dd3572ef --- /dev/null +++ b/crates/core/src/data_export/nmr.rs @@ -0,0 +1,37 @@ +use super::*; + +pub(super) fn snapshot_series( + nmr: &crate::state::Nmr2DDataset, +) -> Result { + match &nmr.processed { + Processed2D::Ft(spectrum) => Ok(SnapshotData::True2D(Arc::clone(spectrum))), + Processed2D::Stack(spectrum) => { + let axis = nmr.data.pseudo_axis.as_ref(); + if nmr.stack_field_key() == "nmr.observations" { + let nus = nmr + .data + .nus + .as_ref() + .ok_or(DataExportError::ContentUnavailable)?; + return Ok(SnapshotData::Pseudo2D { + spectrum: Arc::clone(spectrum), + ruler_name: "Acquired grid index (zero based)".into(), + ruler_unit: String::new(), + ruler: nus.schedule.iter().map(|index| *index as f64).collect(), + }); + } + + Ok(SnapshotData::Pseudo2D { + spectrum: Arc::clone(spectrum), + ruler_name: axis + .map(|axis| axis.name.clone()) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| "Ruler".into()), + ruler_unit: axis.map(|axis| axis.unit.clone()).unwrap_or_default(), + ruler: axis + .map(|axis| axis.values.clone()) + .unwrap_or_else(|| (0..spectrum.increments()).map(|i| i as f64).collect()), + }) + } + } +} diff --git a/crates/core/src/data_export/tests.rs b/crates/core/src/data_export/tests.rs index cb76a2cd..1c0e13bf 100644 --- a/crates/core/src/data_export/tests.rs +++ b/crates/core/src/data_export/tests.rs @@ -225,6 +225,7 @@ fn complete_table_interleaves_sigma_and_leaves_missing_values_empty() { #[test] fn true_2d_matrix_and_long_keep_row_major_axis_order() { let spectrum = Arc::new(Spectrum2D { + magnitude_plane: None, f2_domain: plotx_io::Domain::Frequency, f1_domain: plotx_io::Domain::Frequency, f2_ppm: vec![10.0, 20.0], @@ -239,11 +240,13 @@ fn true_2d_matrix_and_long_keep_row_major_axis_order() { f1_size: 2, direct: plotx_processing::AxisMeta { nucleus: "1H".into(), - observe_freq_mhz: 400.0, + observe_freq_mhz: Some(400.0), + unit: Some(nmr::axis::AxisUnit::Ppm), }, indirect: plotx_processing::AxisMeta { nucleus: "1H".into(), - observe_freq_mhz: 400.0, + observe_freq_mhz: Some(400.0), + unit: Some(nmr::axis::AxisUnit::Ppm), }, source: String::new(), }); @@ -320,7 +323,8 @@ fn pseudo_2d_long_uses_the_actual_ruler_name_and_unit() { traces: vec![vec![Complex64::new(1.0, 2.0), Complex64::new(3.0, 4.0)]], direct: plotx_processing::AxisMeta { nucleus: "1H".into(), - observe_freq_mhz: 400.0, + observe_freq_mhz: Some(400.0), + unit: Some(nmr::axis::AxisUnit::Ppm), }, source: String::new(), }); @@ -462,7 +466,7 @@ fn default_channel_tracks_the_enabled_magnitude_display_step() { source: "spectrum".into(), group_delay: 0.0, }; - let mut nmr = crate::state::NmrDataset::load(data); + let mut nmr = crate::nmr_test_support::load_1d(data).unwrap(); let dataset = Dataset::Nmr(Box::new(nmr.clone())); assert_eq!( DataExportAvailability::for_dataset(&dataset).default_channel, diff --git a/crates/core/src/data_export/write.rs b/crates/core/src/data_export/write.rs index c83b8f5c..07dac634 100644 --- a/crates/core/src/data_export/write.rs +++ b/crates/core/src/data_export/write.rs @@ -259,8 +259,8 @@ pub(super) fn write_true_2d( spectrum: &Spectrum2D, request: DataExportRequest, ) -> io::Result<()> { - let f1_label = domain_column("f1", spectrum.f1_domain); - let f2_label = domain_column("f2", spectrum.f2_domain); + let f1_label = axis_column("f1", spectrum.indirect.unit); + let f2_label = axis_column("f2", spectrum.direct.unit); if request.shape == TableShape::Long { writer.write_record(&[ Field::Text(&f1_label), @@ -273,7 +273,12 @@ pub(super) fn write_true_2d( .data .get(row * spectrum.f2_size + column) .copied() - .map(|value| request.channel.reduce(value)); + .and_then(|value| match request.channel { + IntensityChannel::Magnitude => { + spectrum.magnitude_at(row * spectrum.f2_size + column) + } + _ => Some(request.channel.reduce(value)), + }); writer.write_record(&[ Field::Number(*f1), Field::Number(*f2), @@ -284,8 +289,8 @@ pub(super) fn write_true_2d( return Ok(()); } let mut header = Vec::with_capacity(spectrum.f2_ppm.len() + 1); - let corner = if spectrum.f1_domain == plotx_io::Domain::Frequency - && spectrum.f2_domain == plotx_io::Domain::Frequency + let corner = if spectrum.indirect.unit == Some(nmr::axis::AxisUnit::Ppm) + && spectrum.direct.unit == Some(nmr::axis::AxisUnit::Ppm) { "F1/F2 (ppm)".to_owned() } else { @@ -304,7 +309,12 @@ pub(super) fn write_true_2d( .get(row * spectrum.f2_size + column) .copied() .map_or(Field::Empty, |value| { - Field::Number(request.channel.reduce(value)) + Field::Number(match request.channel { + IntensityChannel::Magnitude => spectrum + .magnitude_at(row * spectrum.f2_size + column) + .expect("view shape"), + _ => request.channel.reduce(value), + }) }), ); } @@ -324,7 +334,7 @@ pub(super) fn write_pseudo_2d( let ruler_header = with_unit(ruler_name, ruler_unit); let direct_label = match spectrum.direct_domain { plotx_io::Domain::Time => "direct_time_s".to_owned(), - plotx_io::Domain::Frequency => "ppm".to_owned(), + plotx_io::Domain::Frequency => spectrum.direct.unit_label().to_owned(), }; if request.shape == TableShape::Long { writer.write_record(&[ @@ -369,10 +379,10 @@ pub(super) fn write_pseudo_2d( Ok(()) } -fn domain_column(axis: &str, domain: plotx_io::Domain) -> String { - match domain { - plotx_io::Domain::Time => format!("{axis}_time_s"), - plotx_io::Domain::Frequency => format!("{axis}_ppm"), +fn axis_column(axis: &str, unit: Option) -> String { + match unit { + Some(nmr::axis::AxisUnit::Second) => format!("{axis}_time_s"), + _ => format!("{axis}_{}", plotx_processing::axis_unit_label(unit)), } } diff --git a/crates/core/src/figures.rs b/crates/core/src/figures.rs index 715bc418..6ba3619d 100644 --- a/crates/core/src/figures.rs +++ b/crates/core/src/figures.rs @@ -7,7 +7,7 @@ use plotx_figure::{ Annotation, Axis, AxisFrame, Color, Contour, ContourBasePolicy, ContourLevelSpec, ContourSpec, Figure, Series, }; -use plotx_io::NmrData; +use plotx_io::nmr_view::NmrSource; use plotx_processing::{Preset2D, Processed1D, Spectrum, Spectrum2D, StackSpectrum, TimeTrace}; use crate::state::{ @@ -15,22 +15,35 @@ use crate::state::{ scalar_grid_capabilities, }; -pub fn build_figure(data: &NmrData, spec: &Spectrum, peaks: &[ResolvedPeak]) -> Figure { +pub fn build_figure(data: &NmrSource, spec: &Spectrum, peaks: &[ResolvedPeak]) -> Figure { let (ppm_lo, ppm_hi) = spec.ppm_bounds(); let (i_lo, i_hi) = spec.intensity_bounds(); let range = (i_hi - i_lo).max(f64::MIN_POSITIVE); // Pad the intensity range, with extra headroom on top for peak labels. let y = Axis::new("Intensity (a.u.)", i_lo - 0.05 * range, i_hi + 0.08 * range); // NMR convention: chemical shift increases to the left. - let x = Axis::new(axis_label(&data.nucleus), ppm_lo, ppm_hi).reversed(true); + let x = Axis::new( + if spec.unit == nmr::axis::AxisUnit::Ppm { + axis_label(data.nucleus()) + } else { + "Frequency (Hz)".into() + }, + ppm_lo, + ppm_hi, + ) + .reversed(spec.unit == nmr::axis::AxisUnit::Ppm); - let fig = Figure::new(format!("{} spectrum — {}", data.nucleus, data.source), x, y) - .with_series(Series::line("real", spec.real_points()).colored(Color::TRACE)); + let fig = Figure::new( + format!("{} spectrum — {}", data.nucleus(), data.source()), + x, + y, + ) + .with_series(Series::line("real", spec.real_points()).colored(Color::TRACE)); apply_peak_labels(fig, peaks) } -pub fn build_time_figure(data: &NmrData, trace: &TimeTrace) -> Figure { +pub fn build_time_figure(data: &NmrSource, trace: &TimeTrace) -> Figure { let (time_lo, time_hi) = trace.time_bounds(); let mut intensity = trace.values.iter().map(|value| value.re); let first = intensity.next().unwrap_or(0.0); @@ -44,12 +57,12 @@ pub fn build_time_figure(data: &NmrData, trace: &TimeTrace) -> Figure { minimum - 0.05 * range, maximum + 0.05 * range, ); - Figure::new(format!("{} FID — {}", data.nucleus, data.source), x, y) + Figure::new(format!("{} FID — {}", data.nucleus(), data.source()), x, y) .with_series(Series::line("real", trace.real_points()).colored(Color::TRACE)) } pub fn build_processed_1d_figure( - data: &NmrData, + data: &NmrSource, processed: &Processed1D, peaks: &[ResolvedPeak], ) -> Figure { @@ -77,8 +90,8 @@ pub fn apply_peak_labels(mut fig: Figure, peaks: &[ResolvedPeak]) -> Figure { pub fn build_figure_2d(spec: &Spectrum2D, preset: Preset2D) -> Figure { let (f2_lo, f2_hi) = spec.f2_bounds(); let (f1_lo, f1_hi) = spec.f1_bounds(); - let x = Axis::new(axis_label(&spec.direct.nucleus), f2_lo, f2_hi).reversed(true); - let y = Axis::new(axis_label(&spec.indirect.nucleus), f1_lo, f1_hi).reversed(true); + let x = nmr_axis(&spec.direct, f2_lo, f2_hi); + let y = nmr_axis(&spec.indirect, f1_lo, f1_hi); let mut fig = Figure::new(format!("{} — {}", preset.label(), spec.source), x, y) .with_axis_frame(AxisFrame::Box); @@ -91,7 +104,9 @@ pub fn build_figure_2d(spec: &Spectrum2D, preset: Preset2D) -> Figure { pub(crate) fn equal_scale_for_nmr_2d(spec: &Spectrum2D) -> bool { if spec.f2_domain != plotx_io::Domain::Frequency || spec.f1_domain != plotx_io::Domain::Frequency + || spec.direct.nucleus.is_empty() || spec.direct.nucleus != spec.indirect.nucleus + || spec.direct.unit != spec.indirect.unit { return false; } @@ -192,9 +207,7 @@ pub fn build_stack_figure(stack: &StackSpectrum) -> Figure { let x = match stack.direct_domain { plotx_io::Domain::Time => Axis::new("Direct acquisition time (s)", lo, hi), - plotx_io::Domain::Frequency => { - Axis::new(axis_label(&stack.direct.nucleus), lo, hi).reversed(true) - } + plotx_io::Domain::Frequency => nmr_axis(&stack.direct, lo, hi), }; // The stack is phased to absorptive, so traces carry the signed real part: // short-τ relaxation increments dip below their baseline (inverted peaks). @@ -215,6 +228,17 @@ pub fn build_stack_figure(stack: &StackSpectrum) -> Figure { fig } +pub(crate) fn nmr_axis(meta: &plotx_processing::AxisMeta, lo: f64, hi: f64) -> Axis { + use nmr::axis::AxisUnit; + let label = match meta.unit { + Some(AxisUnit::Ppm) => axis_label(&meta.nucleus), + Some(AxisUnit::Hertz) => format!("{} frequency (Hz)", format_nucleus(&meta.nucleus)), + Some(AxisUnit::Second) => "Acquisition time (s)".into(), + _ => "Coordinate".into(), + }; + Axis::new(label, lo, hi).reversed(meta.unit == Some(AxisUnit::Ppm)) +} + pub(crate) fn axis_label(nucleus: &str) -> String { format!("{} chemical shift (ppm)", format_nucleus(nucleus)) } @@ -559,6 +583,7 @@ mod tests { let f1_ppm = vec![0.0, 1.0, 2.0, 3.0]; let (f2_size, f1_size) = (f2_ppm.len(), f1_ppm.len()); Spectrum2D { + magnitude_plane: None, f2_domain: plotx_io::Domain::Frequency, f1_domain: plotx_io::Domain::Frequency, data: vec![Complex64::new(1.0, 0.0); f1_size * f2_size], @@ -568,11 +593,13 @@ mod tests { f1_size, direct: AxisMeta { nucleus: "1H".to_owned(), - observe_freq_mhz: 400.0, + observe_freq_mhz: Some(400.0), + unit: Some(nmr::axis::AxisUnit::Ppm), }, indirect: AxisMeta { nucleus: "13C".to_owned(), - observe_freq_mhz: 100.0, + observe_freq_mhz: Some(100.0), + unit: Some(nmr::axis::AxisUnit::Ppm), }, source: "test".to_owned(), } diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 12730087..363b8bba 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -260,8 +260,9 @@ mod tests { .into_iter() .map(|v| Complex64::new(v, 0.0)) .collect(), - hz_per_point: 1.0, - observe_freq_mhz: 400.0, + unit: nmr::axis::AxisUnit::Ppm, + hz_per_point: Some(1.0), + observe_freq_mhz: Some(400.0), nucleus: "1H".to_owned(), } } @@ -274,3 +275,6 @@ mod tests { assert!((integral.area - 9.0).abs() < 1e-9); } } + +#[cfg(test)] +mod nmr_test_support; diff --git a/crates/core/src/nmr_test_support.rs b/crates/core/src/nmr_test_support.rs new file mode 100644 index 00000000..3ba8986b --- /dev/null +++ b/crates/core/src/nmr_test_support.rs @@ -0,0 +1,46 @@ +//! Explicit manual recipes for presentation and application-state fixtures. +use crate::state::{Nmr2DDataset, NmrDataset}; +use plotx_processing::{AxisPipeline, Params2D, PhaseParams, StepKind}; +fn manual(pipeline: &mut AxisPipeline) { + for step in &mut pipeline.steps { + if let StepKind::Phase(ref mut phase) = step.kind { + *phase = PhaseParams::MANUAL_ZERO; + } + } +} +pub(crate) fn load_1d(input: plotx_io::NmrData) -> Result { + let mut pipeline = match input.domain { + plotx_io::Domain::Time => AxisPipeline::default_1d(), + plotx_io::Domain::Frequency => AxisPipeline::frequency_1d(), + }; + manual(&mut pipeline); + NmrDataset::load_with_pipeline(input, Some(pipeline), None) +} +pub(crate) fn load_2d(input: plotx_io::NmrData2D) -> Result { + let source = plotx_io::nmr_series::NmrSeriesSource::try_from(input) + .map_err(|error| error.to_string())?; + let preset = plotx_processing::recommend_preset(&source); + let mut params = if source.direct.domain == nmr::axis::AxisDomain::Time { + Params2D::default_for(preset) + } else { + Params2D::frequency_domain(preset) + }; + if source.indirect.domain == nmr::axis::AxisDomain::Parameter { + params.layout = plotx_processing::Layout2D::Stack; + params.f1.steps.clear(); + } + manual(&mut params.f2); + manual(&mut params.f1); + for (axis, pipeline) in [(1, &mut params.f2), (0, &mut params.f1)] { + if source.source_dataset().axes()[axis].domain == nmr::axis::AxisDomain::Frequency + && !source.source_dataset().has_imaginary(axis) + { + for step in &mut pipeline.steps { + if matches!(step.kind, StepKind::Phase(_)) { + step.enabled = false; + } + } + } + } + Nmr2DDataset::load_with_pipeline(source, Some(params), None, None, true) +} diff --git a/crates/core/src/project/acquisition_identity_tests.rs b/crates/core/src/project/acquisition_identity_tests.rs index a1a80b2b..05df7b6f 100644 --- a/crates/core/src/project/acquisition_identity_tests.rs +++ b/crates/core/src/project/acquisition_identity_tests.rs @@ -20,40 +20,20 @@ fn v1_rejects_dataset_objects_without_acquisition_identity() { } #[test] -fn v1_requires_an_explicit_nmr_origin_and_preserves_it_exactly() { - let mut app = tests::sample_app(); - let origin = plotx_io::NmrOrigin::Instrument { - instrument: plotx_io::NmrInstrumentOrigin { - format: plotx_io::NmrSourceFormat::BrukerRaw, - source_sha256: [42; 32], - portable: plotx_io::NmrPortableMetadata::default(), - parameters: plotx_io::NmrSourceParameters::Bruker { - acqus: "##$TD= 2048".to_owned(), - title: Some("Sample".to_owned()), - pulse_program: Some("zg30".to_owned()), - }, - }, - }; - app.doc.datasets[0].as_nmr_mut().unwrap().origin = origin.clone(); - let mut objects = dataset_to_objects(&app.doc.datasets[0], "data-1", "recipe-1").unwrap(); - assert_eq!(read_nmr_origin(&objects.data).unwrap(), origin); - - objects.data.extensions["plotx.nmr"] - .as_object_mut() - .unwrap() - .remove("origin"); - let error = read_nmr_origin(&objects.data).unwrap_err(); - assert!( - error - .to_string() - .contains("missing required plotx.nmr.origin") - ); - - app.doc.datasets[0].as_nmr_mut().unwrap().origin = origin.clone(); - let path = tests::temp_project("nmr-instrument-origin"); +fn v1_embeds_one_nmr_snapshot_without_duplicate_vendor_metadata() { + let app = tests::sample_app(); + let source = app.doc.datasets[0].as_nmr().unwrap().data.dataset(); + let objects = dataset_to_objects(&app.doc.datasets[0], "data-1", "recipe-1").unwrap(); + assert_eq!(objects.data.payload.storage, super::nmr_snapshot::STORAGE); + assert!(objects.data.dimensions.is_empty()); + assert!(objects.data.extensions.get("plotx.nmr").is_none()); + let path = tests::temp_project("nmr-snapshot"); save_project(&app, &path, false).unwrap(); - let loaded = load_project(&path).unwrap(); - let restored = loaded.doc.datasets[0].as_nmr().unwrap(); - assert_eq!(restored.origin, origin); + let restored = load_project(&path).unwrap(); + let restored = restored.doc.datasets[0].as_nmr().unwrap(); + assert_eq!( + restored.data.dataset().canonical_digests(), + source.canonical_digests() + ); std::fs::remove_file(path).unwrap(); } diff --git a/crates/core/src/project/cleanup_tests.rs b/crates/core/src/project/cleanup_tests.rs index f38ed724..ec2f4a16 100644 --- a/crates/core/src/project/cleanup_tests.rs +++ b/crates/core/src/project/cleanup_tests.rs @@ -5,7 +5,7 @@ use crate::state::Dataset; #[test] fn project_and_scheme_roundtrips_preserve_cleanup_steps() { let mut app = PlotxApp::new(); - let mut dataset = NmrDataset::load(synthetic_1d()); + let mut dataset = NmrDataset::load(synthetic_1d()).unwrap(); let cleanup = [ StepKind::Smooth(SmoothMethod::SavitzkyGolay { window: 11, @@ -26,7 +26,7 @@ fn project_and_scheme_roundtrips_preserve_cleanup_steps() { .steps .push(ProcessingStep::new(id, kind.clone(), StepSource::User)); } - dataset.retransform(); + dataset.retransform().unwrap(); let expected: Vec = cleanup.to_vec(); app.doc.datasets.push(Dataset::Nmr(Box::new(dataset))); @@ -49,7 +49,7 @@ fn project_and_scheme_roundtrips_preserve_cleanup_steps() { save_scheme(&scheme_path, &loaded.doc.datasets[0]).unwrap(); let scheme = load_scheme(&scheme_path).unwrap(); let _ = std::fs::remove_file(&scheme_path); - let target = Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d()))); + let target = Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d()).unwrap())); let crate::actions::DatasetProcessingState::Nmr { pipeline, .. } = apply_scheme(&scheme, &target).unwrap() else { @@ -64,7 +64,7 @@ fn project_and_scheme_roundtrips_preserve_cleanup_steps() { #[test] fn applying_a_scheme_reports_an_invalid_stored_smoothing_window() { - let target = Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d()))); + let target = Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d()).unwrap())); let mut pipeline = AxisPipeline::default_1d(); pipeline.steps.push(ProcessingStep::new( StepId::new(99), diff --git a/crates/core/src/project/codec.rs b/crates/core/src/project/codec.rs index 8742c11f..474266ba 100644 --- a/crates/core/src/project/codec.rs +++ b/crates/core/src/project/codec.rs @@ -240,19 +240,7 @@ pub fn write_dataset_blob( ) -> Result<()> { zip.start_file(path, options)?; match blob { - DatasetBlob::Complex(values) => { - const VALUES_PER_CHUNK: usize = 4096; - let mut buffer = Vec::with_capacity(VALUES_PER_CHUNK * 16); - for chunk in values.chunks(VALUES_PER_CHUNK) { - buffer.clear(); - for value in chunk { - buffer.extend_from_slice(&value.re.to_le_bytes()); - buffer.extend_from_slice(&value.im.to_le_bytes()); - } - zip.write_all(&buffer)?; - } - Ok(()) - } + DatasetBlob::Nmr(source) => super::nmr_snapshot::write(zip, source), DatasetBlob::Electrophysiology(recording) => { super::electrophysiology_convert::write_electrophysiology_blob(zip, recording) } @@ -318,105 +306,6 @@ pub fn validate_manifest(manifest: &Manifest) -> Result<()> { Ok(()) } -pub fn complex_to_bytes(values: &[Complex64]) -> Vec { - let mut out = Vec::with_capacity(values.len() * 16); - for c in values { - out.extend_from_slice(&c.re.to_le_bytes()); - out.extend_from_slice(&c.im.to_le_bytes()); - } - out -} - -pub fn complex_from_bytes(raw: &[u8]) -> Result> { - if !raw.len().is_multiple_of(16) { - return Err(ProjectError::Invalid(format!( - "complex blob length {} is not divisible by 16", - raw.len() - ))); - } - Ok(raw - .as_chunks::<16>() - .0 - .iter() - .map(|chunk| { - let mut re = [0u8; 8]; - let mut im = [0u8; 8]; - re.copy_from_slice(&chunk[..8]); - im.copy_from_slice(&chunk[8..]); - Complex64::new(f64::from_le_bytes(re), f64::from_le_bytes(im)) - }) - .collect()) -} - -pub fn complex_from_reader(reader: &mut EntryReader<'_, R>) -> Result> { - if !reader.remaining().is_multiple_of(16) { - return Err(reader.invalid(format!( - "complex blob length {} is not divisible by 16", - reader.remaining() - ))); - } - let count = usize::try_from(reader.remaining() / 16) - .map_err(|_| reader.invalid("complex element count exceeds usize"))?; - let mut values = Vec::new(); - values - .try_reserve_exact(count) - .map_err(|_| reader.invalid("could not reserve complex data"))?; - let mut bytes = [0_u8; 16]; - for _ in 0..count { - reader - .read_exact(&mut bytes) - .map_err(|error| reader.invalid(format!("complex blob is truncated: {error}")))?; - let re = f64::from_le_bytes(bytes[..8].try_into().expect("fixed eight-byte half")); - let im = f64::from_le_bytes(bytes[8..].try_into().expect("fixed eight-byte half")); - values.push(Complex64::new(re, im)); - } - Ok(values) -} - -pub fn required(value: Option, name: &str) -> Result { - value.ok_or_else(|| ProjectError::Invalid(format!("missing dimension field {name}"))) -} - -pub fn nmr_source(data: &DataObject) -> String { - nmr_ext_str(data, "source") - .map(str::to_owned) - .unwrap_or_else(|| data.id.clone()) -} - -pub fn read_nmr_origin(data: &DataObject) -> Result { - let value = data - .extensions - .get("plotx.nmr") - .and_then(|value| value.get("origin")) - .cloned() - .ok_or_else(|| { - ProjectError::Invalid(format!( - "NMR dataset {} is missing required plotx.nmr.origin", - data.id - )) - })?; - serde_json::from_value(value).map_err(|error| { - ProjectError::Invalid(format!( - "NMR dataset {} has invalid plotx.nmr.origin: {error}", - data.id - )) - }) -} - -pub fn nmr_ext_str<'a>(data: &'a DataObject, key: &str) -> Option<&'a str> { - data.extensions - .get("plotx.nmr") - .and_then(|v| v.get(key)) - .and_then(|v| v.as_str()) -} - -pub fn nmr_ext_bool(data: &DataObject, key: &str) -> Option { - data.extensions - .get("plotx.nmr") - .and_then(|v| v.get(key)) - .and_then(|v| v.as_bool()) -} - pub fn temporary_path(path: &Path) -> PathBuf { let mut tmp = path.to_owned(); let name = path @@ -428,20 +317,6 @@ pub fn temporary_path(path: &Path) -> PathBuf { tmp } -pub fn domain_to_str(v: Domain) -> &'static str { - match v { - Domain::Time => "time", - Domain::Frequency => "frequency", - } -} - -pub fn domain_from_str(v: &str) -> Domain { - match v { - "frequency" => Domain::Frequency, - _ => Domain::Time, - } -} - pub fn layout_to_str(v: Layout2D) -> &'static str { match v { Layout2D::Ft => "ft", @@ -482,112 +357,6 @@ pub fn preset_from_str(v: &str) -> Preset2D { } } -pub fn quad_to_str(v: QuadMode) -> &'static str { - match v { - QuadMode::Complex => "complex", - QuadMode::States => "states", - QuadMode::StatesTppi => "states_tppi", - QuadMode::EchoAntiecho => "echo_antiecho", - } -} - -pub fn quad_from_str(v: &str) -> QuadMode { - match v { - "states" => QuadMode::States, - "states_tppi" => QuadMode::StatesTppi, - "echo_antiecho" => QuadMode::EchoAntiecho, - _ => QuadMode::Complex, - } -} - -pub fn pseudo_kind_to_str(v: PseudoKind) -> &'static str { - match v { - PseudoKind::Gradient => "gradient", - PseudoKind::Delay => "delay", - PseudoKind::Generic => "generic", - } -} - -pub fn pseudo_kind_from_str(v: &str) -> PseudoKind { - match v { - "gradient" => PseudoKind::Gradient, - "delay" => PseudoKind::Delay, - _ => PseudoKind::Generic, - } -} - -pub fn axis_source_to_str(v: AxisSource) -> &'static str { - match v { - AxisSource::EmbeddedList => "embedded_list", - AxisSource::EmbeddedRamp => "embedded_ramp", - AxisSource::LinearHeader => "linear_header", - AxisSource::Manual => "manual", - } -} - -pub fn axis_source_from_str(v: &str) -> AxisSource { - match v { - "embedded_list" => AxisSource::EmbeddedList, - "embedded_ramp" => AxisSource::EmbeddedRamp, - "manual" => AxisSource::Manual, - _ => AxisSource::LinearHeader, - } -} - -pub fn pseudo_axis_to_dto(axis: &PseudoAxis) -> PseudoAxisDto { - PseudoAxisDto { - name: axis.name.clone(), - kind: pseudo_kind_to_str(axis.kind).to_owned(), - values: axis.values.clone(), - unit: axis.unit.clone(), - source: axis_source_to_str(axis.source).to_owned(), - } -} - -pub fn pseudo_axis_from_dto(dto: PseudoAxisDto) -> PseudoAxis { - PseudoAxis { - name: dto.name, - kind: pseudo_kind_from_str(&dto.kind), - values: dto.values, - unit: dto.unit, - source: axis_source_from_str(&dto.source), - } -} - -pub fn diffusion_to_dto(meta: &DiffusionMeta) -> DiffusionMetaDto { - DiffusionMetaDto { - gamma: meta.gamma, - delta: meta.delta, - big_delta: meta.big_delta, - tau: meta.tau, - shape_factor: meta.shape_factor, - } -} - -pub fn diffusion_from_dto(dto: DiffusionMetaDto) -> DiffusionMeta { - DiffusionMeta { - gamma: dto.gamma, - delta: dto.delta, - big_delta: dto.big_delta, - tau: dto.tau, - shape_factor: dto.shape_factor, - } -} - -pub fn read_pseudo_axis(data: &DataObject) -> Option { - let value = data.extensions.get("plotx.nmr")?.get("pseudo_axis")?; - serde_json::from_value::(value.clone()) - .ok() - .map(pseudo_axis_from_dto) -} - -pub fn read_diffusion(data: &DataObject) -> Option { - let value = data.extensions.get("plotx.nmr")?.get("diffusion")?; - serde_json::from_value::(value.clone()) - .ok() - .map(diffusion_from_dto) -} - pub fn primary_view_to_str(v: PrimaryView) -> &'static str { match v { PrimaryView::Canvas => "canvas", diff --git a/crates/core/src/project/codec_tests.rs b/crates/core/src/project/codec_tests.rs index 90c9420e..5f03caaf 100644 --- a/crates/core/src/project/codec_tests.rs +++ b/crates/core/src/project/codec_tests.rs @@ -141,7 +141,7 @@ fn crc_failure_surfaces_when_success_path_consumes_eof() { } #[test] -fn complex_decoder_uses_constant_sized_read_requests() { +fn native_snapshot_decoder_keeps_streamed_reads_bounded() { struct Probe { inner: Cursor>, largest_request: usize, @@ -152,13 +152,33 @@ fn complex_decoder_uses_constant_sized_read_requests() { self.inner.read(buffer) } } + let input = plotx_io::nmr_view::NmrSource::try_from(NmrData { + points: vec![Complex64::new(0.0, 0.0); 100_000], + domain: Domain::Frequency, + spectral_width_hz: 1000.0, + observe_freq_mhz: 100.0, + carrier_ppm: 0.0, + nucleus: "1H".into(), + source: "streamed snapshot".into(), + group_delay: 0.0, + }) + .unwrap(); + let limits = nmr::snapshot::SnapshotLimits::default(); + let mut bytes = Vec::new(); + let mut context = nmr::ExecutionContext::default(); + plotx_io::nmr_bridge::snapshot::write(input.dataset(), &mut bytes, limits, &mut context) + .unwrap(); + let length = bytes.len() as u64; let probe = Probe { - inner: Cursor::new(vec![0_u8; 16 * 100_000]), + inner: Cursor::new(bytes), largest_request: 0, }; - let mut reader = EntryReader::new(probe, "nmr.bin", "NMR", 1_600_000, 1_600_000).unwrap(); - let values = complex_from_reader(&mut reader).unwrap(); - assert_eq!(values.len(), 100_000); - assert!(reader.inner.largest_request <= 16); + let mut reader = EntryReader::new(probe, "nmr.bin", "NMR snapshot", length, length).unwrap(); + let output = plotx_io::nmr_bridge::snapshot::read(&mut reader, limits, &mut context).unwrap(); + assert_eq!( + output.canonical_digests(), + input.dataset().canonical_digests() + ); + assert!(reader.inner.largest_request <= 64 * 1024); reader.finish().unwrap(); } diff --git a/crates/core/src/project/convert.rs b/crates/core/src/project/convert.rs index 1a1787d2..6cb12124 100644 --- a/crates/core/src/project/convert.rs +++ b/crates/core/src/project/convert.rs @@ -8,7 +8,7 @@ use crate::{DosyMethod, PseudoDisplay}; use plotx_processing::Processed2D; pub enum DatasetBlob<'a> { - Complex(&'a [Complex64]), + Nmr(&'a nmr::Dataset), Electrophysiology(&'a crate::state::ElectrophysiologyDataset), Afm(&'a plotx_io::AfmData), MassSpec(&'a crate::state::MassSpecDataset), @@ -47,18 +47,14 @@ pub fn dataset_to_objects<'a>( role: "data".to_owned(), classification: nmr_acquisition_classification(), label: n.name.clone(), - dimensions: vec![dimension_from_1d(&n.data)], + dimensions: Vec::new(), payload: Payload { - storage: STORAGE_COMPLEX_F64_LE.to_owned(), + storage: super::nmr_snapshot::STORAGE.to_owned(), blob: format!("objects/{data_id}/data.bin"), - shape: vec![n.data.points.len()], - domain: domain_to_str(n.data.domain).to_owned(), + shape: vec![n.data.len()], + domain: "nmr".to_owned(), }, extensions: serde_json::json!({ - "plotx.nmr": { - "source": &n.data.source, - "origin": &n.origin - }, "plotx.fields": &n.field_catalog }), }; @@ -86,7 +82,7 @@ pub fn dataset_to_objects<'a>( } }), }; - DatasetObjects::primary(data, DatasetBlob::Complex(&n.data.points), recipe) + DatasetObjects::primary(data, DatasetBlob::Nmr(n.data.dataset()), recipe) } Dataset::Nmr2D(n) => { let data = DataObject { @@ -94,26 +90,20 @@ pub fn dataset_to_objects<'a>( role: "data".to_owned(), classification: nmr_acquisition_classification(), label: n.name.clone(), - dimensions: vec![ - dimension_from_dim("f1", "indirect", 0, n.data.rows, &n.data.indirect), - dimension_from_dim("f2", "direct", 1, n.data.cols, &n.data.direct), - ], + dimensions: Vec::new(), payload: Payload { - storage: STORAGE_COMPLEX_F64_LE.to_owned(), + storage: super::nmr_snapshot::STORAGE.to_owned(), blob: format!("objects/{data_id}/data.bin"), - shape: vec![n.data.rows, n.data.cols], - domain: domain_to_str(n.data.domain).to_owned(), + shape: n + .data + .source_dataset() + .axes() + .iter() + .map(|axis| axis.points) + .collect(), + domain: "nmr".to_owned(), }, extensions: serde_json::json!({ - "plotx.nmr": { - "source": &n.data.source, - "origin": &n.origin, - "quad": quad_to_str(n.data.quad), - "indirect_conjugate": n.data.indirect_conjugate, - "experiment_hint": &n.data.experiment, - "pseudo_axis": n.data.pseudo_axis.as_ref().map(pseudo_axis_to_dto), - "diffusion": n.data.diffusion.as_ref().map(diffusion_to_dto), - }, "plotx.fields": &n.field_catalog }), }; @@ -177,7 +167,7 @@ pub fn dataset_to_objects<'a>( }; DatasetObjects { data, - blob: DatasetBlob::Complex(&n.data.data), + blob: DatasetBlob::Nmr(n.data.source_dataset().dataset()), recipe, extra_blobs, } @@ -320,6 +310,20 @@ pub fn dataset_to_objects<'a>( Dataset::Xps(xps) => super::xps_convert::to_objects(xps, data_id, recipe_id), }; write_acquisition_identity(&mut objects.data, dataset.acquisition_identity())?; + let execution = match dataset { + Dataset::Nmr(n) => Some(super::nmr_snapshot::execution_evidence( + &n.native_processed, + &n.phase_reports, + )?), + Dataset::Nmr2D(n) => Some(super::nmr_snapshot::execution_evidence( + &n.native_processed, + &n.phase_reports, + )?), + _ => None, + }; + if let Some(execution) = execution { + objects.data.extensions["plotx.nmr_execution"] = execution; + } Ok(objects) } @@ -489,144 +493,29 @@ pub fn object_to_dataset( data.classification.domain, data.classification.technique, data.classification.object ))); } - if data.payload.storage != STORAGE_COMPLEX_F64_LE { - return Err(ProjectError::Unsupported(format!( - "payload storage {}", - data.payload.storage - ))); + if recipe.parameters.dimension_count == 1 { + return super::nmr_snapshot::read_1d(zip, data, recipe); } - let expected_values = match data.dimensions.len() { - 1 => data - .payload - .shape - .first() - .copied() - .unwrap_or(data.dimensions[0].size), - 2 => data - .payload - .shape - .first() - .copied() - .zip(data.payload.shape.get(1).copied()) - .ok_or_else(|| ProjectError::Invalid("2D payload shape is incomplete".to_owned()))? - .0 - .checked_mul(data.payload.shape[1]) - .ok_or_else(|| ProjectError::Invalid("2D NMR shape overflows usize".to_owned()))?, - n => { - return Err(ProjectError::Unsupported(format!( - "NMR acquisitions with {n} dimensions" - ))); - } - }; - let expected_bytes = expected_values.checked_mul(16).ok_or_else(|| { - ProjectError::Invalid("NMR payload byte length overflows usize".to_owned()) - })?; - let values = read_entry( - zip, - &data.payload.blob, - "NMR complex-f64 payload", - ProjectLoadLimits::default().max_entry_bytes, - |reader| { - if reader.remaining() != expected_bytes as u64 { - return Err(reader.invalid(format!( - "complex payload has {} bytes but shape requires {expected_bytes}", - reader.remaining() - ))); - } - complex_from_reader(reader) - }, - )?; - match data.dimensions.len() { - 1 => { - let dim = data.dimensions.first().unwrap(); - let expected = data.payload.shape.first().copied().unwrap_or(dim.size); - if values.len() != expected { - return Err(ProjectError::Invalid(format!( - "1D data length {} does not match shape {expected}", - values.len() - ))); - } - let mut dataset = NmrDataset::load_with_origin( - NmrData { - points: values, - domain: domain_from_str(&data.payload.domain), - spectral_width_hz: required(dim.spectral_width_hz, "spectral_width_hz")?, - observe_freq_mhz: required(dim.observe_freq_mhz, "observe_freq_mhz")?, - carrier_ppm: required(dim.carrier_ppm, "carrier_ppm")?, - nucleus: dim.nucleus.clone().unwrap_or_else(|| "X".to_owned()), - source: nmr_source(data), - group_delay: dim.group_delay.unwrap_or(0.0), - }, - read_nmr_origin(data)?, - ); - dataset.acquisition_identity = read_acquisition_identity(data)?; - dataset.field_catalog = read_field_catalog(data)?; - apply_1d_recipe(&mut dataset, recipe)?; - dataset.name = data.label.clone(); - dataset.retransform(); - let dataset = Dataset::Nmr(Box::new(dataset)); - dataset - .validate_field_catalog() - .map_err(ProjectError::Invalid)?; - Ok(dataset) - } + let source = super::nmr_snapshot::read(zip, data)?; + match source.axes().len() { 2 => { - let rows = *data - .payload - .shape - .first() - .ok_or_else(|| ProjectError::Invalid("2D payload missing rows".to_owned()))?; - let cols = *data - .payload - .shape - .get(1) - .ok_or_else(|| ProjectError::Invalid("2D payload missing cols".to_owned()))?; - let expected_len = rows - .checked_mul(cols) - .ok_or_else(|| ProjectError::Invalid("2D NMR shape overflows usize".to_owned()))?; - if values.len() != expected_len { - return Err(ProjectError::Invalid(format!( - "2D data length {} does not match shape {}x{}", - values.len(), - rows, - cols - ))); - } - let direct = data - .dimensions - .iter() - .find(|d| d.role == "direct") - .or_else(|| data.dimensions.iter().find(|d| d.storage_axis == 1)) - .ok_or_else(|| { - ProjectError::Invalid("2D data missing direct dimension".to_owned()) - })?; - let indirect = data - .dimensions - .iter() - .find(|d| d.role == "indirect") - .or_else(|| data.dimensions.iter().find(|d| d.storage_axis == 0)) - .ok_or_else(|| { - ProjectError::Invalid("2D data missing indirect dimension".to_owned()) - })?; - let mut dataset = Nmr2DDataset::load_with_origin_and_equal_scale_preference( - NmrData2D { - data: values, - rows, - cols, - domain: domain_from_str(&data.payload.domain), - direct: dim_from_dimension(direct)?, - indirect: dim_from_dimension(indirect)?, - quad: quad_from_str(nmr_ext_str(data, "quad").unwrap_or("complex")), - indirect_conjugate: nmr_ext_bool(data, "indirect_conjugate").unwrap_or(false), - experiment: nmr_ext_str(data, "experiment_hint").map(str::to_owned), - pseudo_axis: read_pseudo_axis(data), - diffusion: read_diffusion(data), - nus: None, - source: nmr_source(data), + let initial = Params2D { + layout: if source.axes()[0].domain == nmr::axis::AxisDomain::Parameter + || source + .dataset() + .as_raw() + .is_some_and(|raw| raw.data().is_sparse()) + { + Layout2D::Stack + } else { + Layout2D::Ft }, - read_nmr_origin(data)?, - true, - ); + f2: AxisPipeline { steps: Vec::new() }, + f1: AxisPipeline { steps: Vec::new() }, + }; + let mut dataset = + Nmr2DDataset::load_with_pipeline(source, Some(initial), Some(false), None, true) + .map_err(ProjectError::Invalid)?; dataset.acquisition_identity = read_acquisition_identity(data)?; dataset.field_catalog = read_field_catalog(data)?; apply_2d_recipe(&mut dataset, recipe)?; @@ -634,7 +523,7 @@ pub fn object_to_dataset( read_integrals_2d(&mut dataset, recipe)?; read_peaks_2d(&mut dataset, recipe)?; dataset.name = data.label.clone(); - dataset.retransform(); + dataset.retransform().map_err(ProjectError::Invalid)?; // `retransform` deliberately invalidates every analysis map. Restore // auxiliary DOSY state only after it, or a load will silently erase // the stored result and serve the stack fallback instead. diff --git a/crates/core/src/project/convert_dimensions.rs b/crates/core/src/project/convert_dimensions.rs deleted file mode 100644 index 25a1bfed..00000000 --- a/crates/core/src/project/convert_dimensions.rs +++ /dev/null @@ -1,51 +0,0 @@ -use super::*; - -pub fn dimension_from_1d(data: &NmrData) -> Dimension { - Dimension { - id: "f2".to_owned(), - role: "direct".to_owned(), - size: data.points.len(), - storage_axis: 0, - quantity: "time_or_frequency".to_owned(), - display_quantity: Some("chemical_shift".to_owned()), - unit: Some("ppm".to_owned()), - nucleus: Some(data.nucleus.clone()), - spectral_width_hz: Some(data.spectral_width_hz), - observe_freq_mhz: Some(data.observe_freq_mhz), - carrier_ppm: Some(data.carrier_ppm), - group_delay: Some(data.group_delay), - } -} - -pub fn dimension_from_dim( - id: &str, - role: &str, - storage_axis: usize, - size: usize, - dim: &Dim, -) -> Dimension { - Dimension { - id: id.to_owned(), - role: role.to_owned(), - size, - storage_axis, - quantity: "time_or_frequency".to_owned(), - display_quantity: Some("chemical_shift".to_owned()), - unit: Some("ppm".to_owned()), - nucleus: Some(dim.nucleus.clone()), - spectral_width_hz: Some(dim.spectral_width_hz), - observe_freq_mhz: Some(dim.observe_freq_mhz), - carrier_ppm: Some(dim.carrier_ppm), - group_delay: Some(dim.group_delay), - } -} - -pub fn dim_from_dimension(dim: &Dimension) -> Result { - Ok(Dim { - spectral_width_hz: required(dim.spectral_width_hz, "spectral_width_hz")?, - observe_freq_mhz: required(dim.observe_freq_mhz, "observe_freq_mhz")?, - carrier_ppm: required(dim.carrier_ppm, "carrier_ppm")?, - nucleus: dim.nucleus.clone().unwrap_or_else(|| "X".to_owned()), - group_delay: dim.group_delay.unwrap_or(0.0), - }) -} diff --git a/crates/core/src/project/convert_recipes.rs b/crates/core/src/project/convert_recipes.rs index 4c1030a5..bc0ba532 100644 --- a/crates/core/src/project/convert_recipes.rs +++ b/crates/core/src/project/convert_recipes.rs @@ -19,7 +19,7 @@ pub fn apply_1d_recipe(dataset: &mut NmrDataset, recipe: &RecipeObject) -> Resul .unwrap_or(0); dataset.repair_step_allocator(); dataset.group_delay_correct = p.group_delay_correct; - dataset.has_imaginary = true; + dataset.has_imaginary = dataset.data.has_imaginary(0); let analysis = recipe.extensions.get("plotx.analysis").ok_or_else(|| { ProjectError::Invalid("1D NMR recipe is missing plotx.analysis".to_owned()) })?; @@ -88,6 +88,10 @@ pub(super) fn nmr2d_recipe_extensions( dosy: Option, ) -> serde_json::Value { let mut extensions = serde_json::Map::new(); + extensions.insert( + "plotx.nus_request".into(), + serde_json::json!(dataset.nus_request), + ); extensions.insert( "plotx.step_allocator".to_owned(), serde_json::json!({ "next_id": dataset.next_step_id }), @@ -123,6 +127,13 @@ pub(super) fn nmr2d_recipe_extensions( } pub fn apply_2d_recipe(dataset: &mut Nmr2DDataset, recipe: &RecipeObject) -> Result<()> { + dataset.nus_request = recipe + .extensions + .get("plotx.nus_request") + .map(|value| serde_json::from_value(value.clone())) + .transpose() + .map_err(|error| ProjectError::Invalid(format!("Invalid NUS request: {error}")))? + .flatten(); let p = &recipe.parameters; let preset = p .preset @@ -141,14 +152,8 @@ pub fn apply_2d_recipe(dataset: &mut Nmr2DDataset, recipe: &RecipeObject) -> Res .as_deref() .map(layout_from_str) .unwrap_or_else(|| preset.layout()); - params - .f2 - .output_domain(dataset.data.domain) - .map_err(|error| ProjectError::Invalid(format!("invalid F2 pipeline: {error}")))?; - params - .f1 - .output_domain(dataset.data.domain) - .map_err(|error| ProjectError::Invalid(format!("invalid F1 pipeline: {error}")))?; + plotx_processing::nmr_execution::validate_2d_domains(&dataset.data, ¶ms) + .map_err(ProjectError::Invalid)?; dataset.preset = preset; dataset.params = params; @@ -160,6 +165,6 @@ pub fn apply_2d_recipe(dataset: &mut Nmr2DDataset, recipe: &RecipeObject) -> Res .unwrap_or(0); dataset.repair_step_allocator(); dataset.group_delay_correct = p.group_delay_correct; - dataset.has_imaginary = true; + dataset.has_imaginary = dataset.data.source_dataset().has_imaginary(1); Ok(()) } diff --git a/crates/core/src/project/craft_tests.rs b/crates/core/src/project/craft_tests.rs index 57bdbadd..71a3e9df 100644 --- a/crates/core/src/project/craft_tests.rs +++ b/crates/core/src/project/craft_tests.rs @@ -15,10 +15,10 @@ use plotx_processing::craft::{ fn sample_run(data: &NmrData) -> StoredCraftRun { StoredCraftRun::from_result( CraftRunId(4), - data, + &data.clone().try_into().unwrap(), resolve_craft_invocation( data, - CraftReference::new(data.carrier_ppm, 0.25), + CraftReference::new(data.carrier_ppm, data.observe_freq_mhz, 0.25), &CraftParamOverrides::from_params(CraftParams::conventional()), None, ), @@ -143,7 +143,7 @@ fn sample_run(data: &NmrData) -> StoredCraftRun { #[test] fn craft_runs_survive_project_roundtrip_and_reseed_ids() { let data = synthetic_1d(); - let mut dataset = NmrDataset::load(data.clone()); + let mut dataset = NmrDataset::load(data.clone()).unwrap(); dataset.craft_runs.push(sample_run(&data)); dataset.reconcile_craft_fields(); dataset.next_craft_run_id = 5; @@ -166,7 +166,7 @@ fn craft_runs_survive_project_roundtrip_and_reseed_ids() { #[test] fn recipe_without_craft_runs_is_rejected() { let data = synthetic_1d(); - let mut dataset = NmrDataset::load(data.clone()); + let mut dataset = NmrDataset::load(data.clone()).unwrap(); dataset.craft_runs.push(sample_run(&data)); dataset.next_craft_run_id = 5; let recipe = RecipeObject { @@ -210,7 +210,7 @@ fn unavailable_craft_diagnostics_survive_project_roundtrip() { run.components[0].phase_std_rad = None; run.diagnostics.maximum_condition_number = None; run.diagnostics.modeling_windows[0].training_bic = None; - let mut dataset = NmrDataset::load(data); + let mut dataset = NmrDataset::load(data).unwrap(); dataset.craft_runs.push(run.clone()); dataset.reconcile_craft_fields(); let mut app = crate::state::PlotxApp::new(); @@ -250,9 +250,9 @@ fn only_stable_complete_runs_create_quantitative_reports() { #[test] fn report_status_tracks_stability_and_source_availability() { let data = synthetic_1d(); - let mut dataset = NmrDataset::load(data.clone()); + let mut dataset = NmrDataset::load(data.clone()).unwrap(); let mut run = sample_run(&data); - run.provenance.invocation.reference = dataset.craft_reference(); + run.provenance.invocation.reference = dataset.craft_reference().unwrap(); let definition = CraftReportDefinition::default(); let snapshot = run.amplitude_report(definition.clone()).unwrap(); let source = ReportSource { @@ -269,7 +269,7 @@ fn report_status_tracks_stability_and_source_availability() { source, definition: serde_json::to_value(definition).unwrap(), snapshot: serde_json::to_value(snapshot).unwrap(), - source_fingerprint: crate::state::craft_input_sha256(&data), + source_fingerprint: crate::state::craft_input_sha256(&data.clone().try_into().unwrap()), schema_version: 1, }); @@ -303,7 +303,7 @@ fn stability_snapshot_survives_project_roundtrip() { maximum: 0.502, relative_dispersion: 0.008, }); - let mut dataset = NmrDataset::load(data); + let mut dataset = NmrDataset::load(data).unwrap(); dataset.craft_runs.push(run.clone()); dataset.reconcile_craft_fields(); let mut app = crate::state::PlotxApp::new(); @@ -326,7 +326,7 @@ fn stability_snapshot_survives_project_roundtrip() { #[test] fn craft_component_table_link_and_board_visibility_survive_roundtrip() { let data = synthetic_1d(); - let mut dataset = NmrDataset::load(data.clone()); + let mut dataset = NmrDataset::load(data.clone()).unwrap(); dataset.craft_runs.push(sample_run(&data)); dataset.reconcile_craft_fields(); let mut app = crate::state::PlotxApp::new(); @@ -357,7 +357,7 @@ fn craft_component_table_link_and_board_visibility_survive_roundtrip() { #[test] fn craft_result_canvas_round_trips_binding_fields_and_linked_x_axis() { let data = synthetic_1d(); - let mut dataset = NmrDataset::load(data.clone()); + let mut dataset = NmrDataset::load(data.clone()).unwrap(); let dataset_id = dataset.resource_id; dataset.store_craft_run(sample_run(&data)); let mut app = crate::state::PlotxApp::new(); @@ -471,7 +471,7 @@ fn craft_group_field_uses_requested_reconstruction_duration() { run.provenance.invocation.derived_plan.reconstruction_points, requested_points ); - let mut dataset = NmrDataset::load(data); + let mut dataset = NmrDataset::load(data).unwrap(); dataset.store_craft_run(run); let dataset = Dataset::Nmr(Box::new(dataset)); let group_field = dataset diff --git a/crates/core/src/project/dto.rs b/crates/core/src/project/dto.rs index 11db4d88..42610435 100644 --- a/crates/core/src/project/dto.rs +++ b/crates/core/src/project/dto.rs @@ -247,28 +247,6 @@ pub enum StepSourceDto { Imported, } -/// Serialized in the 2D data extension so a save/load round-trip keeps a dataset -/// a `PseudoNmr` rather than degrading it to a plain 2D. -#[derive(Serialize, Deserialize, Clone)] -pub struct PseudoAxisDto { - pub name: String, - pub kind: String, - pub values: Vec, - pub unit: String, - pub source: String, -} - -/// Serialized alongside the pseudo axis so the Stejskal–Tanner b-factor survives -/// a round-trip. -#[derive(Serialize, Deserialize, Clone, Copy)] -pub struct DiffusionMetaDto { - pub gamma: f64, - pub delta: f64, - pub big_delta: f64, - pub tau: f64, - pub shape_factor: f64, -} - #[derive(Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ViewObject { diff --git a/crates/core/src/project/field_catalog.rs b/crates/core/src/project/field_catalog.rs index 307d7cf1..d74abac8 100644 --- a/crates/core/src/project/field_catalog.rs +++ b/crates/core/src/project/field_catalog.rs @@ -81,7 +81,7 @@ mod tests { source: "field validation".to_owned(), group_delay: 0.0, }; - let dataset = Dataset::Nmr(Box::new(crate::state::NmrDataset::load(source))); + let dataset = Dataset::Nmr(Box::new(crate::nmr_test_support::load_1d(source).unwrap())); let field = dataset.default_field_id().unwrap(); let error = validate_series( &dataset, diff --git a/crates/core/src/project/field_encoding_tests.rs b/crates/core/src/project/field_encoding_tests.rs index c62127f8..264116a1 100644 --- a/crates/core/src/project/field_encoding_tests.rs +++ b/crates/core/src/project/field_encoding_tests.rs @@ -1,6 +1,6 @@ use super::tests::{first_plot, synthetic_true_2d, temp_project}; use super::*; -use crate::state::{AfmDataset, CanvasDocument, Dataset, Nmr2DDataset, ObjectFrame, PlotxApp}; +use crate::state::{AfmDataset, CanvasDocument, Dataset, ObjectFrame, PlotxApp}; use plotx_figure::{HeatmapSpec, SeriesEncoding}; use std::collections::BTreeSet; use std::sync::Arc; @@ -8,11 +8,9 @@ use std::sync::Arc; #[test] fn project_roundtrip_preserves_concrete_contour_series_encoding() { let mut app = PlotxApp::new(); - app.doc - .datasets - .push(Dataset::Nmr2D(Box::new(Nmr2DDataset::load( - synthetic_true_2d(), - )))); + app.doc.datasets.push(Dataset::Nmr2D(Box::new( + crate::nmr_test_support::load_2d(synthetic_true_2d()).unwrap(), + ))); let mut canvas = CanvasDocument::new("contour".to_owned(), [120.0, 80.0]); let [width, height] = canvas.size_pt(); let object = app.build_plot_object( @@ -40,7 +38,9 @@ fn project_roundtrip_preserves_concrete_contour_series_encoding() { #[test] fn nmr_two_dimensional_fields_expose_real_and_magnitude_capabilities() { - let dataset = Dataset::Nmr2D(Box::new(Nmr2DDataset::load(synthetic_true_2d()))); + let dataset = Dataset::Nmr2D(Box::new( + crate::nmr_test_support::load_2d(synthetic_true_2d()).unwrap(), + )); let fields = dataset.field_descriptors(); assert_eq!(fields.len(), 2); assert_eq!(fields[0].local_id, "nmr.real"); diff --git a/crates/core/src/project/lineage_tests.rs b/crates/core/src/project/lineage_tests.rs index ba064ab7..e916b07e 100644 --- a/crates/core/src/project/lineage_tests.rs +++ b/crates/core/src/project/lineage_tests.rs @@ -9,18 +9,18 @@ use crate::state::{ #[test] fn project_roundtrip_maps_multi_source_lineage_by_data_id() { let mut app = PlotxApp::new(); - app.doc - .datasets - .push(Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d())))); - app.doc - .datasets - .push(Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d())))); + app.doc.datasets.push(Dataset::Nmr(Box::new( + NmrDataset::load(synthetic_1d()).unwrap(), + ))); + app.doc.datasets.push(Dataset::Nmr(Box::new( + NmrDataset::load(synthetic_1d()).unwrap(), + ))); let sources = [ app.doc.datasets[1].resource_id(), app.doc.datasets[0].resource_id(), app.doc.datasets[1].resource_id(), ]; - let mut derived = Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d()))); + let mut derived = Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d()).unwrap())); derived.set_lineage(Some(DatasetLineage::new( DerivationKind::SpectrumArithmetic, sources, @@ -48,9 +48,9 @@ fn project_roundtrip_maps_multi_source_lineage_by_data_id() { #[test] fn region_provenance_without_lineage_stays_unlinked() { let mut app = PlotxApp::new(); - app.doc - .datasets - .push(Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d())))); + app.doc.datasets.push(Dataset::Nmr(Box::new( + NmrDataset::load(synthetic_1d()).unwrap(), + ))); let source_resource = app.doc.datasets[0].resource_id().to_string(); let source_field = app.doc.datasets[0].default_field_id().unwrap(); let mut table = materialized_float_series_table( @@ -94,8 +94,8 @@ fn region_provenance_without_lineage_stays_unlinked() { fn lineage_resolution_rejects_missing_self_and_cycles() { let datasets = || { vec![ - Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d()))), - Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d()))), + Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d()).unwrap())), + Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d()).unwrap())), ] }; let binding = |data: &str, sources: &[&str]| DatasetBinding { @@ -134,9 +134,9 @@ fn v1_dataset_binding_without_derivation_deserializes() { #[test] fn v1_table_roundtrip_preserves_units_missing_uncertainty_and_lineage() { let mut app = PlotxApp::new(); - app.doc - .datasets - .push(Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d())))); + app.doc.datasets.push(Dataset::Nmr(Box::new( + NmrDataset::load(synthetic_1d()).unwrap(), + ))); let mut table = materialized_float_series_table( ("Time".into(), "s".into(), vec![Some(0.0), Some(1.0)]), vec![FloatSeries { diff --git a/crates/core/src/project/linefit_tests.rs b/crates/core/src/project/linefit_tests.rs index b9ea3333..954129ac 100644 --- a/crates/core/src/project/linefit_tests.rs +++ b/crates/core/src/project/linefit_tests.rs @@ -114,7 +114,7 @@ fn table_statistics_survive_project_roundtrip() { #[test] fn recipe_without_line_fits_key_is_rejected() { - let mut dataset = NmrDataset::load(synthetic_1d()); + let mut dataset = NmrDataset::load(synthetic_1d()).unwrap(); dataset.line_fits.push(sample_line_fit()); dataset.next_line_fit_id = 8; let recipe = RecipeObject { diff --git a/crates/core/src/project/mod.rs b/crates/core/src/project/mod.rs index ffe0b3e1..de402b5b 100644 --- a/crates/core/src/project/mod.rs +++ b/crates/core/src/project/mod.rs @@ -5,8 +5,10 @@ use crate::state::{ ObjectFrame, ObjectId, PlotObject, PlotxApp, PrimaryView, SeriesBinding, ShapeKind, ShapeObject, StackMode, StackSpec, TextAlign, TextBox, Tool, }; +#[cfg(test)] use num_complex::Complex64; use plotx_figure::Color; +#[cfg(test)] use plotx_io::{ AxisSource, DiffusionMeta, Dim, Domain, NmrData, NmrData2D, PseudoAxis, PseudoKind, QuadMode, }; @@ -29,7 +31,6 @@ mod asset_codec; mod axis_overrides; mod codec; mod convert; -mod convert_dimensions; mod convert_recipes; mod convert_views; mod dosy_convert; @@ -39,6 +40,7 @@ mod field_catalog; mod integrals2d; mod lineage_convert; mod mass_spec_convert; +mod nmr_snapshot; mod peaks2d; mod persistence; mod pipeline_conv; @@ -50,7 +52,6 @@ mod xrd_convert; pub use codec::*; pub use convert::*; -pub use convert_dimensions::*; pub use convert_recipes::*; pub use convert_views::*; pub use dto::*; @@ -66,7 +67,6 @@ pub use typed_table::*; const FORMAT: &str = "plotx-project"; const SCHEMA_VERSION: u32 = 1; -const STORAGE_COMPLEX_F64_LE: &str = "complex_f64_le"; const STORAGE_TABLE_V1: &str = "plotx_table_envelope_v1"; const STORAGE_AFM_V1: &str = "plotx_afm_v1"; const STORAGE_DOSY_V1: &str = "plotx_dosy_v1"; @@ -679,6 +679,8 @@ mod linefit_tests; #[cfg(test)] mod multiplet_tests; #[cfg(test)] +mod nmr_snapshot_tests; +#[cfg(test)] mod panel_schema_tests; #[cfg(test)] mod pipeline_domain_tests; diff --git a/crates/core/src/project/multiplet_tests.rs b/crates/core/src/project/multiplet_tests.rs index b8026a87..33b921a8 100644 --- a/crates/core/src/project/multiplet_tests.rs +++ b/crates/core/src/project/multiplet_tests.rs @@ -26,7 +26,7 @@ pub(super) fn sample_multiplet() -> StoredMultiplet { #[test] fn multiplets_survive_project_roundtrip() { - let mut dataset = NmrDataset::load(synthetic_1d()); + let mut dataset = NmrDataset::load(synthetic_1d()).unwrap(); dataset.multiplets.push(sample_multiplet()); dataset.next_multiplet_id = 4; let mut app = crate::state::PlotxApp::new(); @@ -45,7 +45,7 @@ fn multiplets_survive_project_roundtrip() { #[test] fn recipe_without_multiplets_key_is_rejected() { - let mut dataset = NmrDataset::load(synthetic_1d()); + let mut dataset = NmrDataset::load(synthetic_1d()).unwrap(); dataset.multiplets.push(sample_multiplet()); dataset.next_multiplet_id = 4; let recipe = RecipeObject { diff --git a/crates/core/src/project/nmr_snapshot.rs b/crates/core/src/project/nmr_snapshot.rs new file mode 100644 index 00000000..9b88a923 --- /dev/null +++ b/crates/core/src/project/nmr_snapshot.rs @@ -0,0 +1,131 @@ +//! The NMR snapshot is the sole persisted scientific payload. + +use super::*; +use plotx_io::nmr_view::NmrSource; + +pub(super) const STORAGE: &str = "nmr_snapshot_v1"; + +/// Evidence of the last completed output, which may precede a queued recipe +/// edit. It is never used as a sample cache or replayed in place of the recipe. +pub(super) fn execution_evidence( + source: &NmrSource, + phases: &[plotx_processing::nmr_bridge::PhaseReport], +) -> Result { + let Some(processed) = source.dataset().as_processed() else { + return Ok(serde_json::Value::Null); + }; + let mut report = Vec::new(); + nmr::execution_report::write_json( + processed, + &[], + &mut report, + ProjectLoadLimits::default().max_metadata_bytes as usize, + ) + .map_err(|error| ProjectError::Invalid(format!("NMR execution evidence: {error}")))?; + let report: serde_json::Value = serde_json::from_slice(&report)?; + let digest = |value: nmr::provenance::CanonicalDatasetDigests| { + value + .dataset() + .as_bytes() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + }; + let phases: Vec<_> = phases.iter().map(|phase| serde_json::json!({ + "step_id": phase.step.get(), "axis": phase.axis, "points": phase.points, + "display_pivot": phase.display_pivot, "method": format!("{:?}", phase.method), + "algorithm": phase.method.algorithm_version(), "input": digest(phase.input), + "correction": { "p0_degrees": phase.correction.p0_degrees(), "p1_degrees": phase.correction.p1_degrees(), + "pivot_fraction": phase.correction.pivot_fraction(), "convention": "exp(+i phase), i/N" }, + "objective": phase.objective, "evaluations": phase.evaluations, + "representative": phase.representative.as_ref().map(|trace| serde_json::json!({ + "policy": "strongest-cartesian-component.v1", "removed_axis": trace.removed_axis, + "logical_index": trace.index, "component": trace.component, "input": digest(trace.input) + })) + })).collect(); + Ok(serde_json::json!({ "library": report, "automatic_phase": phases })) +} + +fn limits() -> nmr::snapshot::SnapshotLimits { + let project = ProjectLoadLimits::default(); + nmr::snapshot::SnapshotLimits { + max_bytes: project.max_entry_bytes, + max_sample_bytes: project.max_materialized_bytes as usize, + max_metadata_bytes: project.max_metadata_bytes as usize, + max_working_bytes: (project.max_materialized_bytes + project.max_metadata_bytes) as usize, + ..Default::default() + } +} + +pub(super) fn write(writer: &mut impl Write, source: &nmr::Dataset) -> Result<()> { + plotx_io::nmr_bridge::snapshot::write( + source, + writer, + limits(), + &mut nmr::ExecutionContext::default(), + ) + .map_err(|error| ProjectError::Invalid(format!("NMR snapshot: {error}"))) +} + +pub(super) fn read(zip: &mut ZipArchive, data: &DataObject) -> Result { + if data.payload.storage != STORAGE { + return Err(ProjectError::Unsupported(format!( + "NMR payload storage {}", + data.payload.storage + ))); + } + if !data.dimensions.is_empty() || data.payload.domain != "nmr" { + return Err(ProjectError::Invalid( + "NMR dimensions and calibration belong to the snapshot".into(), + )); + } + let source = read_entry( + zip, + &data.payload.blob, + "NMR snapshot", + limits().max_bytes, + |reader| { + plotx_io::nmr_bridge::snapshot::read( + reader, + limits(), + &mut nmr::ExecutionContext::default(), + ) + .map_err(|error| ProjectError::Invalid(format!("NMR snapshot: {error}"))) + }, + )?; + let shape = plotx_io::nmr_bridge::shape(&source) + .map_err(|error| ProjectError::Invalid(error.to_string()))?; + if shape != data.payload.shape { + return Err(ProjectError::Invalid( + "NMR snapshot shape differs from the object index".into(), + )); + } + let identity = read_acquisition_identity(data)?; + NmrSource::new(source) + .map(|source| source.with_display_label(identity.source_label)) + .map_err(|error| ProjectError::Invalid(error.to_string())) +} + +pub(super) fn read_1d( + zip: &mut ZipArchive, + data: &DataObject, + recipe: &RecipeObject, +) -> Result { + let source = read(zip, data)?; + let mut dataset = NmrDataset::load_with_pipeline( + source, + Some(AxisPipeline { steps: Vec::new() }), + Some(false), + ) + .map_err(ProjectError::Invalid)?; + dataset.acquisition_identity = read_acquisition_identity(data)?; + dataset.field_catalog = super::field_catalog::read(data)?; + apply_1d_recipe(&mut dataset, recipe)?; + dataset.name = data.label.clone(); + dataset.retransform().map_err(ProjectError::Invalid)?; + let dataset = Dataset::Nmr(Box::new(dataset)); + dataset + .validate_field_catalog() + .map_err(ProjectError::Invalid)?; + Ok(dataset) +} diff --git a/crates/core/src/project/nmr_snapshot_tests.rs b/crates/core/src/project/nmr_snapshot_tests.rs new file mode 100644 index 00000000..08bbd022 --- /dev/null +++ b/crates/core/src/project/nmr_snapshot_tests.rs @@ -0,0 +1,415 @@ +use super::*; +use crate::state::NmrImportDraft; +use plotx_io::nmr_view::NmrSource; +use std::sync::Arc; + +fn path(suffix: &str) -> PathBuf { + std::env::temp_dir().join(format!("plotx-nmr-{}-{suffix}", uuid::Uuid::new_v4())) +} + +#[test] +fn user_sampling_import_errors_are_visible_and_declarations_reopen_without_vendor_files() { + let dir = path("sampling-input"); + std::fs::create_dir(&dir).unwrap(); + let fixture = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../io/tests/fixtures/nmr/bruker-nus"); + for name in ["ser", "acqus", "acqu2s"] { + std::fs::copy(fixture.join(name), dir.join(name)).unwrap(); + } + let mut draft = NmrImportDraft::new(dir.clone()); + draft.grid = "4".into(); + draft.lanes = "2".into(); + draft.source = "user supplied synthetic table".into(); + draft.rows = "2\n2".into(); + assert!(draft.declaration().is_err()); + draft.one_based = Some(true); + let declaration = draft.declaration().unwrap(); + let mut invalid = declaration.clone(); + invalid.grid_shape = vec![5]; + let mut app = PlotxApp::new(); + assert!(!app.load_nmr_with_sampling(&dir, invalid)); + assert!(app.doc.datasets.is_empty()); + assert!(app.session.status.contains("Failed to load")); + assert!( + app.load_nmr_with_sampling(&dir, declaration.clone()), + "{}", + app.session.status + ); + let nmr = app.doc.datasets[0].as_nmr2d().unwrap(); + assert_eq!(nmr.data.nus.as_ref().unwrap().schedule, [1, 1]); + let field = nmr.field_catalog.id_for_key("nmr.observations").unwrap(); + let items = nmr + .field_catalog + .trace_collection(field) + .unwrap() + .items + .clone(); + assert_ne!(items[0].id, items[1].id); + let project = path("declared-nus.plotx"); + save_project(&app, &project, false).unwrap(); + for name in ["ser", "acqus", "acqu2s"] { + std::fs::remove_file(dir.join(name)).unwrap(); + } + std::fs::remove_dir(dir).unwrap(); + let restored = load_project(&project).unwrap(); + std::fs::remove_file(project).unwrap(); + let nmr = restored.doc.datasets[0].as_nmr2d().unwrap(); + let source = nmr.data.source_dataset().dataset().as_raw().unwrap(); + assert_eq!( + source.sampling_schedule().unwrap().declaration(), + Some(&declaration.into_native().unwrap()) + ); + assert_eq!( + nmr.field_catalog.trace_collection(field).unwrap().items, + items + ); +} + +fn rewrite(path: &Path, mut change: impl FnMut(&str, &mut Vec)) { + let mut archive = ZipArchive::new(File::open(path).unwrap()).unwrap(); + let entries: Vec<_> = (0..archive.len()) + .map(|index| { + let mut entry = archive.by_index(index).unwrap(); + let name = entry.name().to_owned(); + let mut bytes = Vec::new(); + entry.read_to_end(&mut bytes).unwrap(); + change(&name, &mut bytes); + (name, bytes) + }) + .collect(); + drop(archive); + let mut archive = zip::ZipWriter::new(File::create(path).unwrap()); + for (name, bytes) in entries { + write_bytes(&mut archive, SimpleFileOptions::default(), &name, &bytes).unwrap(); + } + archive.finish().unwrap(); +} + +#[test] +fn imported_hertz_spectrum_reopens_without_vendor_files_or_invented_calibration() { + let vendor = path("spectrum.dx"); + std::fs::write( + &vendor, + include_str!("../../../io/tests/fixtures/nmr/jcamp-hz.dx") + .lines() + .filter(|line| !line.starts_with("##.OBSERVE FREQUENCY")) + .collect::>() + .join("\n"), + ) + .unwrap(); + let loaded = plotx_io::load_path(&vendor).unwrap(); + let plotx_io::Acquisition::Nmr(source) = loaded.acquisition else { + panic!("NMR import"); + }; + let original = source.dataset().canonical_digests(); + let mut app = PlotxApp::new(); + app.doc + .datasets + .push(Dataset::Nmr(Box::new(NmrDataset::load(source).unwrap()))); + let project = path("offline.plotx"); + save_project(&app, &project, false).unwrap(); + std::fs::remove_file(vendor).unwrap(); + let restored = load_project(&project).unwrap(); + std::fs::remove_file(project).unwrap(); + let nmr = restored.doc.datasets[0].as_nmr().unwrap(); + assert_eq!(original, nmr.data.dataset().canonical_digests()); + assert_eq!(nmr.spectrum().unwrap().ppm, [4.0, 3.0, 2.0, 1.0]); + assert_eq!(nmr.spectrum().unwrap().unit, nmr::axis::AxisUnit::Hertz); + assert_eq!(nmr.data.axes()[0].observe_frequency_mhz(), None); + assert!(!nmr.data.has_imaginary(0)); + assert_eq!( + restored.doc.datasets[0].field_descriptors()[0].units, + ["Hz"] + ); + let figure = crate::figures::build_figure(&nmr.data, nmr.spectrum().unwrap(), &[]); + assert!(figure.x.label.contains("Hz")); + assert!( + restored + .analyze_multiplets(0, 1.0, 4.0) + .unwrap_err() + .contains("calibrated in ppm") + ); +} + +#[test] +fn project_rejects_corrupt_trailing_old_storage_and_conflicting_shape() { + let mut app = PlotxApp::new(); + app.doc.datasets.push(Dataset::Nmr(Box::new( + NmrDataset::load(super::tests::synthetic_1d()).unwrap(), + ))); + for damage in ["sample", "trailing", "storage", "shape"] { + let project = path("corrupt.plotx"); + save_project(&app, &project, false).unwrap(); + rewrite(&project, |name, bytes| { + if name.ends_with("/data.bin") { + if damage == "sample" { + let last = bytes.len() - 1; + bytes[last] ^= 1; + } + if damage == "trailing" { + bytes.push(0); + } + } else if name.ends_with("/object.json") { + let mut value: serde_json::Value = serde_json::from_slice(bytes).unwrap(); + if value.get("payload").is_some() { + assert_eq!(value["payload"]["storage"], "nmr_snapshot_v1"); + assert_eq!(value["dimensions"], serde_json::json!([])); + if damage == "storage" { + value["payload"]["storage"] = "complex_f64_le".into(); + } + if damage == "shape" { + value["payload"]["shape"] = serde_json::json!([3]); + } + *bytes = serde_json::to_vec(&value).unwrap(); + } + } + }); + let error = load_project(&project) + .err() + .expect("untrusted payload must fail") + .to_string(); + std::fs::remove_file(project).unwrap(); + assert!( + error.contains("NMR") || error.contains("trailing"), + "{damage}: {error}" + ); + } +} + +#[test] +fn column_derivation_and_snapshot_preserve_indirect_cartesian_components() { + use nmr::axis::{AxisCoordinates, AxisDomain, AxisRole, AxisUnit}; + use nmr::processed::{ + ComponentBasis, ProcessedAxis, ProcessedData, ProcessedDataset, ProcessedDescriptor, + ProcessedOrigin, ProcessedProvenance, + }; + let axes = [2, 3] + .into_iter() + .map(|points| { + ProcessedAxis::new( + AxisRole::Signal, + AxisDomain::Frequency, + Some(AxisUnit::Hertz), + points, + AxisCoordinates::Uniform { + start: 0.0, + step: 1.0, + }, + ComponentBasis::Cartesian, + ) + .unwrap() + }) + .collect(); + let descriptor = ProcessedDescriptor::new(axes).unwrap(); + let data = + ProcessedData::from_descriptor(&descriptor, (1..=24).map(f64::from).collect()).unwrap(); + let native = ProcessedDataset::new( + descriptor, + data, + ProcessedProvenance::new(ProcessedOrigin::Unknown, vec![]).unwrap(), + ) + .unwrap(); + let expected: Vec<_> = (0..2) + .map(|row| { + Complex64::new( + native.data().get(&[row, 1], &[0, 0]).unwrap(), + native.data().get(&[row, 1], &[1, 0]).unwrap(), + ) + }) + .collect(); + let source = NmrSource::new(Arc::new(native.into())).unwrap(); + let (column, view) = plotx_processing::slice::extract( + &source, + plotx_processing::SliceKind::Column, + plotx_processing::slice::Reduction::Slice(1), + ) + .unwrap(); + assert_eq!(view.values, expected); + let mut app = PlotxApp::new(); + app.doc.datasets.push(Dataset::Nmr(Box::new( + NmrDataset::load_with_pipeline(column, Some(AxisPipeline { steps: vec![] }), Some(false)) + .unwrap(), + ))); + let project = path("column.plotx"); + save_project(&app, &project, false).unwrap(); + let restored = load_project(&project).unwrap(); + std::fs::remove_file(project).unwrap(); + let nmr = restored.doc.datasets[0].as_nmr().unwrap(); + assert_eq!(nmr.data.trace().unwrap(), expected); + assert!(nmr.data.has_imaginary(0)); + assert!( + nmr.data + .dataset() + .as_processed() + .unwrap() + .provenance() + .history() + .is_some() + ); + assert_eq!(nmr.native_processed.reference_frequency_mhz(0), None); +} + +#[test] +fn nus_observation_identities_keep_duplicate_schedule_order_across_project_roundtrip() { + let mut data = super::tests::synthetic_dosy_2d(); + data.pseudo_axis = None; + data.diffusion = None; + data.experiment = None; + data.rows = 3; + data.data.truncate(3 * data.cols); + data.nus = Some(plotx_io::NusMeta { + grid: 8, + acquired: 3, + schedule: Some(vec![5, 1, 5]), + }); + let nmr = Nmr2DDataset::load(data).unwrap(); + let field = nmr.field_catalog.id_for_key("nmr.observations").unwrap(); + let items = nmr + .field_catalog + .trace_collection(field) + .unwrap() + .items + .clone(); + assert_eq!(items.len(), 3); + assert_ne!(items[0].id, items[2].id); + let mut app = PlotxApp::new(); + app.doc.datasets.push(Dataset::Nmr2D(Box::new(nmr))); + assert_eq!(app.doc.datasets[0].default_field_id(), Some(field)); + let project = path("nus.plotx"); + save_project(&app, &project, false).unwrap(); + let restored = load_project(&project).unwrap(); + std::fs::remove_file(project).unwrap(); + let nmr = restored.doc.datasets[0].as_nmr2d().unwrap(); + assert_eq!(nmr.data.nus.as_ref().unwrap().schedule, [5, 1, 5]); + assert_eq!( + nmr.field_catalog.trace_collection(field).unwrap().items, + items + ); + assert!(nmr.nus_request.is_none()); +} + +#[test] +fn completed_execution_evidence_records_actual_method_and_input() { + let nmr = NmrDataset::load(super::tests::synthetic_1d()).unwrap(); + let dataset = Dataset::Nmr(Box::new(nmr)); + let objects = dataset_to_objects(&dataset, "d0", "r0").unwrap(); + let evidence = &objects.data.extensions["plotx.nmr_execution"]; + assert!(evidence["library"].is_object()); + let phases = evidence["automatic_phase"].as_array().unwrap(); + assert_eq!(phases.len(), 1); + assert!(phases[0]["algorithm"].as_str().unwrap().contains("entropy")); + assert_eq!(phases[0]["input"].as_str().unwrap().len(), 64); + assert!(phases[0]["evaluations"].as_u64().unwrap() > 0); +} + +#[test] +fn nus_reconstruction_changes_live_bindings_and_reopens_with_grid_identities() { + use plotx_processing::{Layout2D, ProcessingStep, StepKind, StepSource}; + let mut data = super::tests::synthetic_dosy_2d(); + data.pseudo_axis = None; + data.diffusion = None; + data.experiment = None; + data.rows = 6; + data.cols = 8; + data.quad = plotx_io::QuadMode::States; + data.data = [5, 1, 6] + .into_iter() + .flat_map(|row| { + (0..2).flat_map(move |lane| { + (0..8).map(move |col| { + let phase = std::f64::consts::TAU * row as f64 / 8.0; + let amplitude = if lane == 0 { phase.cos() } else { phase.sin() }; + Complex64::from_polar(amplitude, std::f64::consts::TAU * col as f64 / 8.0) + }) + }) + }) + .collect(); + data.nus = Some(plotx_io::NusMeta { + grid: 8, + acquired: 3, + schedule: Some(vec![5, 1, 6]), + }); + let mut app = PlotxApp::new(); + app.doc + .datasets + .push(Dataset::Nmr2D(Box::new(Nmr2DDataset::load(data).unwrap()))); + let canvas = crate::workflow::build_default_canvas(&app.doc.datasets[0], "NUS"); + app.doc.canvases.push(canvas); + let observations = app.doc.canvases[0].objects[0] + .plot() + .unwrap() + .binding + .series[0] + .source; + for layout in [Layout2D::Stack, Layout2D::Ft, Layout2D::Stack] { + let nmr = app.doc.datasets[0].as_nmr2d_mut().unwrap(); + nmr.params.layout = layout; + nmr.params.f2.steps = vec![ProcessingStep::new( + nmr.allocate_step_id(), + StepKind::Fft, + StepSource::User, + )]; + nmr.params.f1.steps = if layout == Layout2D::Ft { + vec![ProcessingStep::new( + nmr.allocate_step_id(), + StepKind::Fft, + StepSource::User, + )] + } else { + vec![] + }; + nmr.nus_request = Some(plotx_processing::nmr_execution::NusRequest { + max_iterations: 1000, + noise_standard_deviation: Some(0.0), + }); + assert!(app.schedule_2d_processing(0, true)); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while app.compute_busy() && std::time::Instant::now() < deadline { + app.poll_compute(); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + app.poll_compute(); + assert!(!app.compute_busy()); + assert!( + !app.session.status.contains("failed"), + "{}", + app.session.status + ); + let dataset = &app.doc.datasets[0]; + let field = dataset.default_field_id().unwrap(); + assert_ne!(field, observations.field); + let plot = app.doc.canvases[0].objects[0].plot().unwrap(); + let binding = app.display_binding(plot.display_owner, &plot.binding); + assert!(!binding.series.is_empty()); + assert!( + binding + .series + .iter() + .all(|series| series.source.field == field) + ); + if layout == Layout2D::Stack { + assert_eq!(binding.series.len(), 8); + assert!( + binding + .series + .iter() + .all(|series| series.source.item != observations.item) + ); + } + let project = path("reconstructed.plotx"); + save_project(&app, &project, false).unwrap(); + let restored = load_project(&project).unwrap(); + std::fs::remove_file(project).unwrap(); + assert_eq!( + restored.doc.canvases[0].objects[0].plot().unwrap().binding, + plot.binding + ); + let merged = app.merge_display_binding(plot.display_owner, &plot.binding, binding); + assert!( + merged + .series + .iter() + .any(|series| series.source == observations) + ); + } +} diff --git a/crates/core/src/project/pipeline_conv.rs b/crates/core/src/project/pipeline_conv.rs index 2b4cdb7f..86e74359 100644 --- a/crates/core/src/project/pipeline_conv.rs +++ b/crates/core/src/project/pipeline_conv.rs @@ -18,22 +18,37 @@ pub fn pipeline_from_dto(dto: &AxisPipelineDto) -> AxisPipeline { /// will receive. The error names the stored value and the data-derived bound so /// a malformed project or scheme never opens into a silently rewritten state. pub fn validate_1d_pipeline( - data: &plotx_io::NmrData, + data: &plotx_io::nmr_view::NmrSource, pipeline: &AxisPipeline, group_delay_correct: bool, ) -> std::result::Result<(), String> { - let output = pipeline - .output_domain(data.domain) - .map_err(|error| error.to_string())?; - if output == plotx_io::Domain::Time { - return Ok(()); - } - let mut spectrum = plotx_processing::transform_base(data, pipeline, group_delay_correct); - for step in pipeline - .steps - .iter() - .skip_while(|step| step.kind.at_or_before_fft()) - { + use plotx_processing::nmr_bridge::{DelayPolicy, RecipeRange}; + for (index, step) in pipeline.steps.iter().enumerate() { + if !matches!( + step.kind, + StepKind::Smooth(_) | StepKind::Normalize(_) | StepKind::Bin(_) + ) { + continue; + } + let prefix = AxisPipeline { + steps: pipeline.steps[..index].to_vec(), + }; + let output = plotx_processing::nmr_execution::execute_1d( + data, + &prefix, + if group_delay_correct { + DelayPolicy::AxisEvidence + } else { + DelayPolicy::Disabled + }, + RecipeRange::All, + &mut nmr::ExecutionContext::default(), + ) + .map_err(|e| e.to_string())?; + let spectrum = output + .view + .as_frequency() + .ok_or_else(|| "Cleanup requires frequency-domain data".to_owned())?; match step.kind { StepKind::Smooth(method) => { let capped = spectrum.values.len().min(201); @@ -72,7 +87,7 @@ pub fn validate_1d_pipeline( )); } StepKind::Bin(params) => { - let minimum = 1.5 * plotx_processing::cleanup::axis_step(&spectrum.ppm); + let minimum = 1.5 * spectrum.coordinate_spacing().unwrap_or(0.0); if !params.width.is_finite() || params.width <= minimum { return Err(format!( "stored bin width {} is out of range: it must be greater than {minimum} for this axis", @@ -82,11 +97,20 @@ pub fn validate_1d_pipeline( } _ => {} } - if step.enabled { - plotx_processing::apply_freq_step(&mut spectrum, &step.kind); - } } - Ok(()) + plotx_processing::nmr_execution::execute_1d( + data, + pipeline, + if group_delay_correct { + DelayPolicy::AxisEvidence + } else { + DelayPolicy::Disabled + }, + RecipeRange::All, + &mut nmr::ExecutionContext::default(), + ) + .map(|_| ()) + .map_err(|error| error.to_string()) } /// Drop step identities from a pipeline destined for a detached recipe diff --git a/crates/core/src/project/pipeline_domain_tests.rs b/crates/core/src/project/pipeline_domain_tests.rs index 30d8b017..994f4acd 100644 --- a/crates/core/src/project/pipeline_domain_tests.rs +++ b/crates/core/src/project/pipeline_domain_tests.rs @@ -15,9 +15,9 @@ fn invalid_stack_pipelines() -> Vec { #[test] fn stack_scheme_rejects_an_invalid_dormant_f1_pipeline() { - let target = Dataset::Nmr2D(Box::new(Nmr2DDataset::load( - super::tests::synthetic_dosy_2d(), - ))); + let target = Dataset::Nmr2D(Box::new( + Nmr2DDataset::load(super::tests::synthetic_dosy_2d()).unwrap(), + )); let scheme = ProcessingScheme { schema_version: 1, dimension_count: 2, @@ -31,7 +31,7 @@ fn stack_scheme_rejects_an_invalid_dormant_f1_pipeline() { #[test] fn project_recipe_rejects_an_invalid_2d_pipeline_before_retransform() { - let mut dataset = Nmr2DDataset::load(super::tests::synthetic_dosy_2d()); + let mut dataset = Nmr2DDataset::load(super::tests::synthetic_dosy_2d()).unwrap(); let recipe = RecipeObject { id: "recipe_000000".to_owned(), role: "recipe".to_owned(), diff --git a/crates/core/src/project/pseudo_tests.rs b/crates/core/src/project/pseudo_tests.rs index 86b64068..3bc61851 100644 --- a/crates/core/src/project/pseudo_tests.rs +++ b/crates/core/src/project/pseudo_tests.rs @@ -150,7 +150,7 @@ fn rewrite_project(path: &Path, mut edit: impl FnMut(&str, &mut Vec) -> bool fn pseudo_project_with_view(name: &str) -> PathBuf { let mut app = PlotxApp::new(); - let dataset = Dataset::Nmr2D(Box::new(Nmr2DDataset::load(synthetic_dosy_2d()))); + let dataset = Dataset::Nmr2D(Box::new(Nmr2DDataset::load(synthetic_dosy_2d()).unwrap())); let canvas = crate::workflow::build_default_canvas(&dataset, "strict-pseudo"); app.doc.datasets.push(dataset); app.doc.canvases.push(canvas); @@ -217,7 +217,7 @@ fn assert_f64_bits_equal(actual: &[f64], expected: &[f64]) { #[test] fn project_load_ignores_stored_pseudo_fit_curve() { let mut app = PlotxApp::new(); - let ds = Nmr2DDataset::load(synthetic_dosy_2d()); + let ds = Nmr2DDataset::load(synthetic_dosy_2d()).unwrap(); app.doc.datasets.push(Dataset::Nmr2D(Box::new(ds))); let path = temp_project("pseudo-fit-curve"); @@ -240,7 +240,7 @@ fn project_load_ignores_stored_pseudo_fit_curve() { #[test] fn project_round_trip_restores_both_real_dosy_maps_after_retransform() { let mut app = PlotxApp::new(); - let mut ds = Nmr2DDataset::load(synthetic_dosy_2d()); + let mut ds = Nmr2DDataset::load(synthetic_dosy_2d()).unwrap(); assert!(ds.build_dosy_map(), "the real per-column fit must populate"); let original_dosy = ds.dosy_map.clone().unwrap(); let params = crate::IltParams { @@ -365,7 +365,7 @@ fn project_round_trip_restores_both_real_dosy_maps_after_retransform() { #[test] fn mismatched_fingerprint_keeps_the_stored_map_and_reports_both_fingerprints() { let mut app = PlotxApp::new(); - let mut ds = Nmr2DDataset::load(synthetic_dosy_2d()); + let mut ds = Nmr2DDataset::load(synthetic_dosy_2d()).unwrap(); assert!(ds.build_dosy_map()); let original = ds.dosy_map.clone().unwrap(); app.doc.datasets.push(Dataset::Nmr2D(Box::new(ds))); @@ -374,8 +374,14 @@ fn mismatched_fingerprint_keeps_the_stored_map_and_reports_both_fingerprints() { let _ = std::fs::remove_file(&path); save_project(&app, &path, false).unwrap(); rewrite_project(&path, |name, bytes| { - if name.ends_with("/data.bin") { - bytes[0] ^= 1; + if name.ends_with(".json") { + let mut value: serde_json::Value = serde_json::from_slice(bytes).unwrap(); + if let Some(fingerprint) = + value.pointer_mut("/extensions/plotx.dosy/provenance/diffusion/data_fingerprint") + { + *fingerprint = serde_json::Value::String("0".repeat(64)); + *bytes = serde_json::to_vec(&value).unwrap(); + } } true }); @@ -424,7 +430,7 @@ fn mismatched_fingerprint_keeps_the_stored_map_and_reports_both_fingerprints() { #[test] fn missing_selected_blob_explains_the_stack_fallback() { let mut app = PlotxApp::new(); - let mut ds = Nmr2DDataset::load(synthetic_dosy_2d()); + let mut ds = Nmr2DDataset::load(synthetic_dosy_2d()).unwrap(); assert!(ds.build_dosy_map()); app.doc.datasets.push(Dataset::Nmr2D(Box::new(ds))); @@ -491,7 +497,7 @@ fn project_json_numbers_survive_a_round_trip_bit_for_bit() { #[test] fn a_snapshot_is_not_replayed_when_the_stored_map_could_not_be_restored() { let mut app = PlotxApp::new(); - let mut ds = Nmr2DDataset::load(synthetic_dosy_2d()); + let mut ds = Nmr2DDataset::load(synthetic_dosy_2d()).unwrap(); assert!(ds.build_dosy_map()); let action = crate::actions::Action::insert_dataset_with_default_canvas( &app, @@ -552,7 +558,7 @@ fn a_snapshot_is_not_replayed_when_the_stored_map_could_not_be_restored() { #[test] fn the_missing_map_complaint_does_not_survive_selecting_a_method_that_has_one() { let mut app = PlotxApp::new(); - let mut ds = Nmr2DDataset::load(synthetic_dosy_2d()); + let mut ds = Nmr2DDataset::load(synthetic_dosy_2d()).unwrap(); let params = IltParams { lambda: 0.02, d_min: 1e-11, diff --git a/crates/core/src/project/scheme.rs b/crates/core/src/project/scheme.rs index e44cf84c..50533ac2 100644 --- a/crates/core/src/project/scheme.rs +++ b/crates/core/src/project/scheme.rs @@ -201,9 +201,9 @@ pub fn apply_scheme( .first() .ok_or_else(|| incompatible("scheme carries no pipeline"))?; let mut pipeline = pipeline_from_dto(dto); + remint_pipeline(&mut pipeline, &mut dataset_next_step_id(dataset)); validate_1d_pipeline(&n.data, &pipeline, scheme.group_delay_correct) .map_err(ProjectError::Invalid)?; - remint_pipeline(&mut pipeline, &mut dataset_next_step_id(dataset)); Ok(DatasetProcessingState::Nmr { pipeline, group_delay_correct: scheme.group_delay_correct, @@ -231,18 +231,13 @@ pub fn apply_scheme( f2: pipeline_from_dto(f2), f1: pipeline_from_dto(f1), }; - params - .f2 - .output_domain(n.data.domain) - .map_err(|error| incompatible(&error.to_string()))?; - params - .f1 - .output_domain(n.data.domain) - .map_err(|error| incompatible(&error.to_string()))?; + plotx_processing::nmr_execution::validate_2d_domains(&n.data, ¶ms) + .map_err(|error| incompatible(&error))?; let mut next = dataset_next_step_id(dataset); remint_pipeline(&mut params.f2, &mut next); remint_pipeline(&mut params.f1, &mut next); Ok(DatasetProcessingState::Nmr2D { + nus_request: n.nus_request, params, preset: n.preset, group_delay_correct: scheme.group_delay_correct, @@ -287,13 +282,14 @@ fn remint_pipeline(pipeline: &mut AxisPipeline, next: &mut u64) { pub fn reset_processing(dataset: &Dataset) -> Option { let mut state = match dataset { Dataset::Nmr(n) => Some(DatasetProcessingState::Nmr { - pipeline: AxisPipeline::default_1d(), - group_delay_correct: crate::state::default_group_delay_correct(n.data.domain), + pipeline: crate::state::default_nmr_pipeline(&n.data), + group_delay_correct: crate::state::default_group_delay_correct(&n.data), }), Dataset::Nmr2D(n) => Some(DatasetProcessingState::Nmr2D { - params: Params2D::default_for(n.preset), + nus_request: n.nus_request, + params: crate::state::default_nmr_params(&n.data, n.preset), preset: n.preset, - group_delay_correct: crate::state::default_group_delay_correct(n.data.domain), + group_delay_correct: crate::state::default_group_delay_correct(n.data.source_dataset()), }), Dataset::Table(_) => None, Dataset::Electrophysiology(_) => None, diff --git a/crates/core/src/project/step_identity_tests.rs b/crates/core/src/project/step_identity_tests.rs index 0623561b..68fa078f 100644 --- a/crates/core/src/project/step_identity_tests.rs +++ b/crates/core/src/project/step_identity_tests.rs @@ -38,7 +38,7 @@ fn step_ids_and_allocator_survive_project_roundtrip() { #[test] fn project_roundtrip_preserves_custom_pipeline_steps() { let mut app = PlotxApp::new(); - let mut dataset = NmrDataset::load(synthetic_1d()); + let mut dataset = NmrDataset::load(synthetic_1d()).unwrap(); // One time-side step (an exponential window before the FFT) and one // frequency-side step (a referencing shift) that must both survive. let fft_pos = dataset @@ -65,7 +65,7 @@ fn project_roundtrip_preserves_custom_pipeline_steps() { }), StepSource::User, )); - dataset.retransform(); + dataset.retransform().unwrap(); app.doc.datasets.push(Dataset::Nmr(Box::new(dataset))); let path = temp_project("pipeline"); diff --git a/crates/core/src/project/symmetry_tests.rs b/crates/core/src/project/symmetry_tests.rs index bb0a71a8..084ac5b1 100644 --- a/crates/core/src/project/symmetry_tests.rs +++ b/crates/core/src/project/symmetry_tests.rs @@ -4,7 +4,7 @@ use crate::state::{Peak2DOrigin, Peak2DPoint, Peak2DReview}; #[test] fn project_roundtrip_preserves_cross_peak_pairs_and_review_state() { let mut app = PlotxApp::new(); - let mut dataset = Nmr2DDataset::load(super::tests::synthetic_true_2d()); + let mut dataset = crate::nmr_test_support::load_2d(super::tests::synthetic_true_2d()).unwrap(); let ids = dataset .peaks .add_pair( diff --git a/crates/core/src/project/templates.rs b/crates/core/src/project/templates.rs index 9f1f3616..635f5b1e 100644 --- a/crates/core/src/project/templates.rs +++ b/crates/core/src/project/templates.rs @@ -114,16 +114,19 @@ mod tests { let points = (0..64) .map(|k| Complex64::from_polar((-(k as f64) / 16.0).exp(), 0.4 * k as f64)) .collect(); - Dataset::Nmr(Box::new(NmrDataset::load(NmrData { - points, - domain: Domain::Time, - spectral_width_hz: 4000.0, - observe_freq_mhz: 400.0, - carrier_ppm: 5.0, - nucleus: "1H".to_owned(), - source: "synthetic".to_owned(), - group_delay: 0.0, - }))) + Dataset::Nmr(Box::new( + NmrDataset::load(NmrData { + points, + domain: Domain::Time, + spectral_width_hz: 4000.0, + observe_freq_mhz: 400.0, + carrier_ppm: 5.0, + nucleus: "1H".to_owned(), + source: "synthetic".to_owned(), + group_delay: 0.0, + }) + .unwrap(), + )) } #[test] diff --git a/crates/core/src/project/tests.rs b/crates/core/src/project/tests.rs index 6fdddd4f..17f94c72 100644 --- a/crates/core/src/project/tests.rs +++ b/crates/core/src/project/tests.rs @@ -91,10 +91,10 @@ pub(super) fn synthetic_dosy_2d() -> NmrData2D { pub(super) fn sample_app() -> PlotxApp { let mut app = PlotxApp::new(); - let mut dataset = NmrDataset::load(synthetic_1d()); + let mut dataset = NmrDataset::load(synthetic_1d()).unwrap(); dataset.name = Some("sample data".to_owned()); set_manual_phase(&mut dataset.pipeline, 0.25, -0.5, 0.4); - dataset.rebuild(); + dataset.rebuild().unwrap(); app.doc.datasets.push(Dataset::Nmr(Box::new(dataset))); let chart = crate::state::ChartSpec::default_for(app.doc.datasets[0].domain()); @@ -369,7 +369,7 @@ fn project_roundtrip_preserves_data_recipe_and_view() { panic!("expected 1D NMR dataset"); }; assert_eq!(n.name.as_deref(), Some("sample data")); - assert_eq!(n.data.points.len(), 1024); + assert_eq!(n.data.len(), 1024); assert_eq!(n.peaks.marks.len(), 1); assert_eq!(n.peaks.marks[0].label.as_deref(), Some("2.00")); assert_eq!(n.integrals.len(), 1); @@ -416,7 +416,7 @@ fn project_roundtrip_preserves_axis_projections() { // dataset 0 = the 1D spectrum a projection attaches to; dataset 1 = the contour. let mut app = sample_app(); - let ds = Nmr2DDataset::load(synthetic_true_2d()); + let ds = crate::nmr_test_support::load_2d(synthetic_true_2d()).unwrap(); assert!(ds.is_true_2d()); app.doc.datasets.push(Dataset::Nmr2D(Box::new(ds))); let mut canvas = CanvasDocument::new("2d".to_owned(), [120.0, 80.0]); @@ -459,7 +459,7 @@ fn project_roundtrip_preserves_axis_projections() { #[test] fn project_roundtrip_preserves_pseudo2d_metadata() { let mut app = PlotxApp::new(); - let ds = Nmr2DDataset::load(synthetic_dosy_2d()); + let ds = Nmr2DDataset::load(synthetic_dosy_2d()).unwrap(); assert!(ds.is_pseudo(), "fixture should be a pseudo-2D dataset"); app.doc.datasets.push(Dataset::Nmr2D(Box::new(ds))); @@ -477,7 +477,7 @@ fn project_roundtrip_preserves_pseudo2d_metadata() { assert_eq!(axis.name, "g"); assert_eq!(axis.kind, PseudoKind::Gradient); assert_eq!(axis.unit, "mT/m"); - assert_eq!(axis.source, AxisSource::EmbeddedRamp); + assert_eq!(axis.source, AxisSource::LibraryEvidence); assert_eq!(axis.values.len(), 8); let meta = n.data.diffusion.as_ref().expect("diffusion meta preserved"); assert!((meta.delta - 2e-3).abs() < 1e-12); @@ -487,7 +487,7 @@ fn project_roundtrip_preserves_pseudo2d_metadata() { #[test] fn project_roundtrip_preserves_trace_item_sources_and_visibility() { - let dataset = Dataset::Nmr2D(Box::new(Nmr2DDataset::load(synthetic_dosy_2d()))); + let dataset = Dataset::Nmr2D(Box::new(Nmr2DDataset::load(synthetic_dosy_2d()).unwrap())); let mut app = PlotxApp::new(); app.doc.canvases.push(crate::workflow::build_default_canvas( &dataset, @@ -609,9 +609,9 @@ fn project_roundtrip_preserves_zorder() { fn project_roundtrip_preserves_overlay_binding() { let mut app = PlotxApp::new(); for _ in 0..2 { - app.doc - .datasets - .push(Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d())))); + app.doc.datasets.push(Dataset::Nmr(Box::new( + NmrDataset::load(synthetic_1d()).unwrap(), + ))); } app.doc.datasets[1].set_name(Some("treatment".to_owned())); let mut canvas = CanvasDocument::new("overlay".to_owned(), [120.0, 80.0]); @@ -665,9 +665,9 @@ fn project_roundtrip_preserves_overlay_binding() { fn project_roundtrip_preserves_stack_spec_and_series_fields() { let mut app = PlotxApp::new(); for _ in 0..2 { - app.doc - .datasets - .push(Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d())))); + app.doc.datasets.push(Dataset::Nmr(Box::new( + NmrDataset::load(synthetic_1d()).unwrap(), + ))); } let mut canvas = CanvasDocument::new("stack".to_owned(), [120.0, 80.0]); let [w, h] = canvas.size_pt(); @@ -731,7 +731,7 @@ fn plot_without_explicit_series_is_rejected_by_the_project_schema() { #[test] fn scheme_save_load_apply_roundtrips() { use crate::actions::DatasetProcessingState; - let mut source = NmrDataset::load(synthetic_1d()); + let mut source = NmrDataset::load(synthetic_1d()).unwrap(); set_manual_phase(&mut source.pipeline, 0.3, 0.1, 0.6); let source_ds = Dataset::Nmr(Box::new(source)); @@ -742,7 +742,7 @@ fn scheme_save_load_apply_roundtrips() { let _ = std::fs::remove_file(&path); assert_eq!(scheme.dimension_count, 1); - let target = Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d()))); + let target = Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d()).unwrap())); let DatasetProcessingState::Nmr { pipeline, .. } = apply_scheme(&scheme, &target).unwrap() else { panic!("expected a 1D processing state"); @@ -752,7 +752,9 @@ fn scheme_save_load_apply_roundtrips() { assert!((phase.phase0 - 0.3).abs() < 1e-9); assert!((phase.pivot_frac - 0.6).abs() < 1e-9); - let two_d = Dataset::Nmr2D(Box::new(Nmr2DDataset::load(synthetic_true_2d()))); + let two_d = Dataset::Nmr2D(Box::new( + crate::nmr_test_support::load_2d(synthetic_true_2d()).unwrap(), + )); assert!(apply_scheme(&scheme, &two_d).is_err()); let DatasetProcessingState::Nmr { pipeline, .. } = reset_processing(&source_ds).unwrap() else { diff --git a/crates/core/src/properties/apodization_tests.rs b/crates/core/src/properties/apodization_tests.rs index 7147befc..a8a5a3f8 100644 --- a/crates/core/src/properties/apodization_tests.rs +++ b/crates/core/src/properties/apodization_tests.rs @@ -64,7 +64,7 @@ fn time_domain_app_of(experiment: Option<&str>) -> PlotxApp { let mut app = PlotxApp::new(); app.doc .datasets - .push(Dataset::Nmr2D(Box::new(Nmr2DDataset::load(data)))); + .push(Dataset::Nmr2D(Box::new(Nmr2DDataset::load(data).unwrap()))); app } diff --git a/crates/core/src/properties/bin.rs b/crates/core/src/properties/bin.rs index bedd85e6..50856e18 100644 --- a/crates/core/src/properties/bin.rs +++ b/crates/core/src/properties/bin.rs @@ -75,14 +75,14 @@ impl PropertyProvider for BinProvider { let StepKind::Bin(current) = context.step.kind else { unreachable!("the shared context checked the step kind"); }; - let bounds = resolved_width_bounds(&context)?; + let (bounds, unit) = resolved_width_bounds(&context)?; Ok(ResolvedProperty { address: address.clone(), modified: None, value: AggregateValue::Uniform(value_of(definition, current)?), default_value: None, availability: Availability::Editable, - schema: schema_for(definition, bounds), + schema: schema_for(definition, bounds, unit), }) } @@ -97,7 +97,7 @@ impl PropertyProvider for BinProvider { let context = step_context(app, address, definition, |kind| { matches!(kind, StepKind::Bin(_)) })?; - let bounds = resolved_width_bounds(&context)?; + let (bounds, _) = resolved_width_bounds(&context)?; let value = match operation { EditOp::Set(value) => checked_value(definition, bounds, value)?, EditOp::Reset => { @@ -140,11 +140,15 @@ fn value_of( } } -fn schema_for(definition: &'static PropertyDefinition, bounds: FloatBounds) -> ResolvedSchema { +fn schema_for( + definition: &'static PropertyDefinition, + bounds: FloatBounds, + unit: &'static str, +) -> ResolvedSchema { if definition.id == WIDTH { ResolvedSchema::Float { bounds, - display: FloatDisplay::Linear("ppm"), + display: FloatDisplay::Linear(unit), } } else { ResolvedSchema::Enum { @@ -179,12 +183,15 @@ fn checked_value( fn resolved_width_bounds( context: &super::processing_common::StepContext<'_>, -) -> Result { +) -> Result<(FloatBounds, &'static str), PropertyError> { let spectrum = spectrum_before_step(context).ok_or_else(|| { PropertyError::NotApplicable( "Binning needs a one-dimensional input spectrum with an axis.".to_owned(), ) })?; - let axis_step = plotx_processing::cleanup::axis_step(&spectrum.ppm); - Ok(FloatBounds::above(1.5 * axis_step, f64::MAX)) + let axis_step = spectrum.coordinate_spacing().unwrap_or(0.0); + Ok(( + FloatBounds::above(1.5 * axis_step, f64::MAX), + plotx_processing::axis_unit_label(Some(spectrum.unit)), + )) } diff --git a/crates/core/src/properties/group_delay.rs b/crates/core/src/properties/group_delay.rs index 9e7dd7bd..28ce6634 100644 --- a/crates/core/src/properties/group_delay.rs +++ b/crates/core/src/properties/group_delay.rs @@ -130,8 +130,10 @@ impl NmrDatasetContext<'_> { fn factory_value(self) -> bool { match self { - Self::One(dataset) => crate::state::default_group_delay_correct(dataset.data.domain), - Self::Two(dataset) => crate::state::default_group_delay_correct(dataset.data.domain), + Self::One(dataset) => crate::state::default_group_delay_correct(&dataset.data), + Self::Two(dataset) => { + crate::state::default_group_delay_correct(dataset.data.source_dataset()) + } } } } diff --git a/crates/core/src/properties/group_delay_tests.rs b/crates/core/src/properties/group_delay_tests.rs index 11dca4ed..44313a71 100644 --- a/crates/core/src/properties/group_delay_tests.rs +++ b/crates/core/src/properties/group_delay_tests.rs @@ -33,7 +33,7 @@ fn time_domain_2d_app() -> PlotxApp { let mut app = PlotxApp::new(); app.doc .datasets - .push(Dataset::Nmr2D(Box::new(Nmr2DDataset::load(data)))); + .push(Dataset::Nmr2D(Box::new(Nmr2DDataset::load(data).unwrap()))); app } @@ -78,10 +78,19 @@ fn two_dimensional_group_delay_is_in_the_typed_action_and_is_undoable() { ); let disabled_input = app.doc.datasets[0] .as_nmr2d() - .expect("the dataset remains 2D NMR") - .processing_data(); - assert_eq!(disabled_input.direct.group_delay, 0.0); - assert_eq!(disabled_input.indirect.group_delay, 4.0); + .unwrap() + .data + .source_dataset(); + let delay = disabled_input + .dataset() + .as_raw() + .unwrap() + .descriptor() + .axes()[1] + .group_delay(); + assert!( + matches!(delay, nmr::acquisition::GroupDelayState::Pending(value) if value.delay_points() == 4.0) + ); app.undo(); assert!( @@ -101,7 +110,20 @@ fn two_dimensional_group_delay_settings_produce_different_real_spectra() { }; let corrected = { let dataset = app.doc.datasets[0].as_nmr2d().unwrap(); - plotx_processing::process_2d(&dataset.processing_data(), &dataset.params) + plotx_processing::nmr_execution::execute_2d( + dataset.data.source_dataset(), + &dataset.params, + if dataset.group_delay_correct { + plotx_processing::nmr_bridge::DelayPolicy::AxisEvidence + } else { + plotx_processing::nmr_bridge::DelayPolicy::Disabled + }, + plotx_processing::nmr_bridge::RecipeRange::Base, + None, + &mut nmr::ExecutionContext::default(), + ) + .unwrap() + .view }; let changed = app .plan_property_write( @@ -113,7 +135,20 @@ fn two_dimensional_group_delay_settings_produce_different_real_spectra() { app.commit_property(changed); let uncorrected = { let dataset = app.doc.datasets[0].as_nmr2d().unwrap(); - plotx_processing::process_2d(&dataset.processing_data(), &dataset.params) + plotx_processing::nmr_execution::execute_2d( + dataset.data.source_dataset(), + &dataset.params, + if dataset.group_delay_correct { + plotx_processing::nmr_bridge::DelayPolicy::AxisEvidence + } else { + plotx_processing::nmr_bridge::DelayPolicy::Disabled + }, + plotx_processing::nmr_bridge::RecipeRange::Base, + None, + &mut nmr::ExecutionContext::default(), + ) + .unwrap() + .view }; let ( plotx_processing::Processed2D::Ft(corrected), @@ -147,3 +182,51 @@ fn group_delay_reset_uses_the_same_factory_rule_as_dataset_construction() { app.commit_property(reset); assert!(app.doc.datasets[0].as_nmr2d().unwrap().group_delay_correct); } + +#[test] +fn reset_preserves_unknown_delay_and_processed_input_defaults() { + let fixtures = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../io/tests/fixtures/nmr"); + for name in ["jeol-complex.jdf", "bruker-1d/pdata/1/1r"] { + let loaded = plotx_io::load_path(fixtures.join(name)).unwrap(); + let plotx_io::Acquisition::Nmr(source) = loaded.acquisition else { + panic!("expected NMR fixture"); + }; + let mut app = PlotxApp::new(); + app.doc.datasets.push(Dataset::Nmr(Box::new( + crate::state::NmrDataset::load(source).unwrap(), + ))); + let target = TargetRef { + resource: ResourceRef::from(app.doc.datasets[0].resource_id()), + component: None, + }; + let reset = app + .plan_property_reset(group_delay::CORRECT, &[target]) + .unwrap(); + app.commit_property(reset); + assert!(!app.doc.datasets[0].as_nmr().unwrap().group_delay_correct); + let reset = crate::project::reset_processing(&app.doc.datasets[0]).unwrap(); + reset.apply_to(&mut app.doc.datasets[0]).unwrap(); + let dataset = app.doc.datasets[0].as_nmr().unwrap(); + assert!(!dataset.group_delay_correct); + assert!(!dataset.pipeline.has_enabled_fft()); + assert_eq!(dataset.output_domain(), dataset.input_domain()); + } +} + +#[test] +fn reset_of_an_imported_2d_spectrum_does_not_add_time_domain_steps() { + let app = time_domain_2d_app(); + let source = app.doc.datasets[0] + .as_nmr2d() + .unwrap() + .native_processed + .clone(); + let mut dataset = Dataset::Nmr2D(Box::new(Nmr2DDataset::load(source).unwrap())); + let reset = crate::project::reset_processing(&dataset).unwrap(); + reset.apply_to(&mut dataset).unwrap(); + let dataset = dataset.as_nmr2d().unwrap(); + assert!(!dataset.params.f1.has_enabled_fft()); + assert!(!dataset.params.f2.has_enabled_fft()); + assert!(!dataset.group_delay_correct); +} diff --git a/crates/core/src/properties/ilt_tests.rs b/crates/core/src/properties/ilt_tests.rs index a9249057..f5e12f23 100644 --- a/crates/core/src/properties/ilt_tests.rs +++ b/crates/core/src/properties/ilt_tests.rs @@ -2,7 +2,7 @@ use super::*; use crate::automation::{ResourceRef, TargetRef}; use crate::properties::ilt; use crate::settings::{MAX_ILT_LAMBDA, MIN_ILT_LAMBDA, Settings}; -use crate::state::{Dataset, Nmr2DDataset, PlotxApp}; +use crate::state::{Dataset, PlotxApp}; use crate::{DosyInvocation, DosyResultProvenance, IltParams}; use num_complex::Complex64; use plotx_io::{ @@ -49,7 +49,7 @@ fn data() -> NmrData2D { pub(crate) fn ilt_app(lambda: f64) -> (PlotxApp, TargetRef) { let mut app = PlotxApp::new_with_settings(Settings::default()); - let mut dataset = Nmr2DDataset::load(data()); + let mut dataset = crate::nmr_test_support::load_2d(data()).unwrap(); dataset.ilt_provenance = Some(DosyResultProvenance { algorithm: "ilt_map".to_owned(), version: 1, diff --git a/crates/core/src/properties/object_tests.rs b/crates/core/src/properties/object_tests.rs index f79561d1..b102a193 100644 --- a/crates/core/src/properties/object_tests.rs +++ b/crates/core/src/properties/object_tests.rs @@ -78,9 +78,9 @@ fn stack_app() -> (PlotxApp, ObjectId) { source: source.to_owned(), group_delay: 0.0, }; - app.doc - .datasets - .push(Dataset::Nmr(Box::new(crate::state::NmrDataset::load(data)))); + app.doc.datasets.push(Dataset::Nmr(Box::new( + crate::state::NmrDataset::load(data).unwrap(), + ))); } let mut canvas = CanvasDocument::new("stack".to_owned(), [120.0, 80.0]); let id = canvas.allocate_object_id(); diff --git a/crates/core/src/properties/processing_common.rs b/crates/core/src/properties/processing_common.rs index 1fd90e11..fedb0062 100644 --- a/crates/core/src/properties/processing_common.rs +++ b/crates/core/src/properties/processing_common.rs @@ -154,10 +154,7 @@ pub(super) fn raw_point_count(dataset: &Dataset, axis: PhaseAxis) -> usize { match dataset { Dataset::Nmr(n) => n.data.len(), Dataset::Nmr2D(n) => match axis { - PhaseAxis::F1 => n.data.nus.as_ref().map_or_else( - || plotx_processing::fft2::f1_increments(n.data.rows, n.data.quad), - |nus| nus.grid, - ), + PhaseAxis::F1 => n.data.source_dataset().axes()[0].points, PhaseAxis::F2 | PhaseAxis::Direct => n.data.cols, }, Dataset::Table(_) @@ -176,19 +173,26 @@ pub(super) fn spectrum_before_step(context: &StepContext<'_>) -> Option result.view.as_frequency().cloned(), + Err(error) => { + eprintln!("Cannot resolve NMR property bounds: {error}"); + None } } - None } diff --git a/crates/core/src/properties/processing_test_support.rs b/crates/core/src/properties/processing_test_support.rs index 250fc397..b2a11687 100644 --- a/crates/core/src/properties/processing_test_support.rs +++ b/crates/core/src/properties/processing_test_support.rs @@ -27,11 +27,18 @@ pub(super) fn time_domain_app() -> PlotxApp { let mut app = PlotxApp::new(); app.doc .datasets - .push(Dataset::Nmr(Box::new(NmrDataset::load(data)))); + .push(Dataset::Nmr(Box::new(NmrDataset::load(data).unwrap()))); app } pub(super) fn states_2d_app(rows: usize, cols: usize) -> PlotxApp { + states_2d_app_with_sampling(rows, cols, None) +} +pub(super) fn states_2d_app_with_sampling( + rows: usize, + cols: usize, + nus: Option, +) -> PlotxApp { let dim = |nucleus: &str, width| Dim { spectral_width_hz: width, observe_freq_mhz: 400.0, @@ -53,13 +60,23 @@ pub(super) fn states_2d_app(rows: usize, cols: usize) -> PlotxApp { experiment: Some("hsqc".to_owned()), pseudo_axis: None, diffusion: None, - nus: None, + nus, source: "States property test".to_owned(), }; let mut app = PlotxApp::new(); - app.doc - .datasets - .push(Dataset::Nmr2D(Box::new(Nmr2DDataset::load(data)))); + app.doc.datasets.push(Dataset::Nmr2D(Box::new( + Nmr2DDataset::load_with_pipeline( + data, + Some(plotx_processing::Params2D::default()), + Some(true), + Some(plotx_processing::nmr_execution::NusRequest { + noise_standard_deviation: Some(0.0), + ..Default::default() + }), + true, + ) + .unwrap(), + ))); app } diff --git a/crates/core/src/properties/provider_tests.rs b/crates/core/src/properties/provider_tests.rs index 86c58b82..0726da3b 100644 --- a/crates/core/src/properties/provider_tests.rs +++ b/crates/core/src/properties/provider_tests.rs @@ -167,11 +167,9 @@ fn a_same_value_write_is_reported_without_an_empty_commit() { #[test] fn line_stroke_width_reports_mixed_values_and_skips_other_encodings() { let (mut app, contour) = contour_app(); - app.doc - .datasets - .push(Dataset::Nmr(Box::new(NmrDataset::load(nmr1d_with( - "lines", - ))))); + app.doc.datasets.push(Dataset::Nmr(Box::new( + NmrDataset::load(nmr1d_with("lines")).unwrap(), + ))); let mut line_targets = Vec::new(); for name in ["Line A", "Line B"] { let id = app.doc.canvases[0].allocate_object_id(); @@ -274,11 +272,9 @@ fn line_stroke_width_reports_mixed_values_and_skips_other_encodings() { #[test] fn a_line_readout_dispatches_by_property_address() { let (mut app, _) = contour_app(); - app.doc - .datasets - .push(Dataset::Nmr(Box::new(NmrDataset::load(nmr1d_with( - "line readout", - ))))); + app.doc.datasets.push(Dataset::Nmr(Box::new( + NmrDataset::load(nmr1d_with("line readout")).unwrap(), + ))); let id = app.doc.canvases[0].allocate_object_id(); let object = app.build_plot_object( 1, diff --git a/crates/core/src/properties/step_enabled.rs b/crates/core/src/properties/step_enabled.rs index bedb24ea..6ecc112e 100644 --- a/crates/core/src/properties/step_enabled.rs +++ b/crates/core/src/properties/step_enabled.rs @@ -78,8 +78,10 @@ impl PropertyProvider for StepEnabledProvider { EditOp::Step(_) => return Err(no_step_gesture(definition)), }; let input_domain = match context.dataset { - crate::state::Dataset::Nmr(dataset) => dataset.data.domain, - crate::state::Dataset::Nmr2D(dataset) => dataset.data.domain, + crate::state::Dataset::Nmr(dataset) => dataset.input_domain(), + crate::state::Dataset::Nmr2D(dataset) => dataset + .input_domain(context.axis) + .map_err(PropertyError::NotApplicable)?, crate::state::Dataset::Table(_) | crate::state::Dataset::Electrophysiology(_) | crate::state::Dataset::Afm(_) diff --git a/crates/core/src/properties/tests.rs b/crates/core/src/properties/tests.rs index 1fe45aee..34910044 100644 --- a/crates/core/src/properties/tests.rs +++ b/crates/core/src/properties/tests.rs @@ -6,8 +6,7 @@ use crate::automation::{ }; use crate::state::{ CONTOUR_BASE_ABSOLUTE, CONTOUR_BASE_FRACTION_OF_RANGE, CONTOUR_BASE_NOISE_FLOOR, - CanvasDocument, Dataset, Nmr2DDataset, NmrDataset, ObjectFrame, PlotxApp, SeriesBinding, - SeriesId, + CanvasDocument, Dataset, NmrDataset, ObjectFrame, PlotxApp, SeriesBinding, SeriesId, }; #[path = "tests_fixture.rs"] diff --git a/crates/core/src/properties/tests_fixture.rs b/crates/core/src/properties/tests_fixture.rs index 5dfc8fa0..fadfcbff 100644 --- a/crates/core/src/properties/tests_fixture.rs +++ b/crates/core/src/properties/tests_fixture.rs @@ -61,11 +61,9 @@ pub(crate) fn contour_app() -> (PlotxApp, TargetRef) { /// a given dynamic range in front of the catalog. pub(crate) fn contour_app_with_plane(values: &[f64]) -> (PlotxApp, TargetRef) { let mut app = PlotxApp::new(); - app.doc - .datasets - .push(Dataset::Nmr2D(Box::new(Nmr2DDataset::load(nmr2d_with( - "contour", values, - ))))); + app.doc.datasets.push(Dataset::Nmr2D(Box::new( + crate::nmr_test_support::load_2d(nmr2d_with("contour", values)).unwrap(), + ))); let mut canvas = CanvasDocument::new("page".to_owned(), [120.0, 80.0]); let id = canvas.allocate_object_id(); let object = app.build_plot_object( diff --git a/crates/core/src/properties/zero_fill_tests.rs b/crates/core/src/properties/zero_fill_tests.rs index 1bd07dc0..eec49659 100644 --- a/crates/core/src/properties/zero_fill_tests.rs +++ b/crates/core/src/properties/zero_fill_tests.rs @@ -2,7 +2,6 @@ use super::processing_test_support::{ spectrum, states_2d_app, step, step_mut, target_for, target_for_axis, time_domain_app, }; use super::*; -use crate::state::Dataset; use plotx_processing::{StepKind, ZeroFill}; #[test] @@ -133,18 +132,15 @@ fn states_f1_uses_complex_increments_as_its_raw_point_count() { #[test] fn nus_f1_uses_the_nominal_reconstruction_grid_as_its_raw_count() { - let mut app = states_2d_app(10, 6); - let Dataset::Nmr2D(dataset) = &mut app.doc.datasets[0] else { - panic!("the fixture is 2D NMR"); - }; - std::sync::Arc::make_mut(&mut dataset.data).nus = Some(plotx_io::NusMeta { - grid: 17, - acquired: 5, - idx_base: 0, - mode: "test".to_owned(), - echo_antiecho: false, - schedule: Some(vec![0, 2, 5, 9, 16]), - }); + let mut app = super::processing_test_support::states_2d_app_with_sampling( + 10, + 6, + Some(plotx_io::NusMeta { + grid: 17, + acquired: 5, + schedule: Some(vec![0, 2, 5, 9, 16]), + }), + ); let target = target_for_axis(&app, crate::state::PhaseAxis::F1, |kind| { matches!(kind, StepKind::ZeroFill(_)) }); diff --git a/crates/core/src/state/app_impl.rs b/crates/core/src/state/app_impl.rs index 7857f688..f9a6b448 100644 --- a/crates/core/src/state/app_impl.rs +++ b/crates/core/src/state/app_impl.rs @@ -79,6 +79,7 @@ impl PlotxApp { ..Default::default() }, compute: ComputeService::new(), + data_imports: DataImports::default(), updates: crate::update::UpdateService::new(&settings.updates), line_fit_job: None, xps_fit_job: None, @@ -145,23 +146,25 @@ impl PlotxApp { let Some(d2) = self.doc.datasets.get(dataset).and_then(Dataset::as_nmr2d) else { return; }; - let Processed2D::Ft(spec) = &d2.processed else { + let Processed2D::Ft(_) = &d2.processed else { return; }; - fig.top_projection = self.build_axis_trace(spec, SliceKind::Row, &projections.top); - fig.left_projection = self.build_axis_trace(spec, SliceKind::Column, &projections.left); + fig.top_projection = + self.build_axis_trace(&d2.native_processed, SliceKind::Row, &projections.top); + fig.left_projection = + self.build_axis_trace(&d2.native_processed, SliceKind::Column, &projections.left); } fn build_axis_trace( &self, - spec: &plotx_processing::Spectrum2D, + source: &plotx_io::nmr_view::NmrSource, kind: SliceKind, cfg: &AxisProjection, ) -> Option { if !cfg.is_shown() { return None; } - let slice = match &cfg.source { + let reduction = match &cfg.source { ProjectionSource::None => return None, ProjectionSource::Attached(other) => { return self @@ -169,9 +172,20 @@ impl PlotxApp { .dataset_index(*other) .and_then(|index| self.attached_axis_trace(index)); } - ProjectionSource::Sum => spec.project(kind, ProjectionMode::Sum), - ProjectionSource::Skyline => spec.project(kind, ProjectionMode::Skyline), - ProjectionSource::Slice(index) => spec.slice(kind, *index), + ProjectionSource::Sum => { + plotx_processing::slice::Reduction::Projection(ProjectionMode::Sum) + } + ProjectionSource::Skyline => { + plotx_processing::slice::Reduction::Projection(ProjectionMode::Skyline) + } + ProjectionSource::Slice(index) => plotx_processing::slice::Reduction::Slice(*index), + }; + let (_, slice) = match plotx_processing::slice::extract(source, kind, reduction) { + Ok(output) => output, + Err(error) => { + eprintln!("NMR axis projection unavailable: {error}"); + return None; + } }; let points = slice .coordinates @@ -592,7 +606,10 @@ impl PlotxApp { /// Secondary Side Bar tool widgets. pub fn apply_dataset_edit(&mut self, dataset: usize) { if let Some(n) = self.doc.datasets[dataset].as_nmr_mut() { - n.rebuild(); + if let Err(error) = n.rebuild() { + self.session.status = format!("NMR processing failed: {error}"); + return; + } n.recompute_integrals(); } else if self.doc.datasets[dataset].as_nmr2d().is_some() { self.schedule_2d_processing(dataset, false); @@ -618,7 +635,10 @@ impl PlotxApp { /// for dragging a time-domain step parameter, where the cached base changes. pub fn apply_dataset_retransform(&mut self, dataset: usize) { if let Some(n) = self.doc.datasets[dataset].as_nmr_mut() { - n.retransform(); + if let Err(error) = n.retransform() { + self.session.status = format!("NMR processing failed: {error}"); + return; + } n.recompute_integrals(); } else if self.doc.datasets[dataset].as_nmr2d().is_some() { self.schedule_2d_processing(dataset, true); diff --git a/crates/core/src/state/app_impl_analysis.rs b/crates/core/src/state/app_impl_analysis.rs index 73a32e54..4c782c52 100644 --- a/crates/core/src/state/app_impl_analysis.rs +++ b/crates/core/src/state/app_impl_analysis.rs @@ -1,31 +1,6 @@ use super::*; impl PlotxApp { - /// Apply a user-entered non-uniform-sampling schedule to a 2D dataset and - /// re-run the reconstruction. Returns the validation error (if any) so the - /// caller can surface it next to the input field. - pub fn apply_nus_schedule( - &mut self, - dataset: usize, - values: &[usize], - base: usize, - ) -> Result<(), String> { - let Some(d2) = self - .doc - .datasets - .get_mut(dataset) - .and_then(Dataset::as_nmr2d_mut) - else { - return Err("NUS reconstruction needs a 2D dataset.".into()); - }; - d2.set_nus_schedule(values, base)?; - self.schedule_2d_processing(dataset, true); - self.mark_document_dirty(); - self.session.status = - "Reconstructing the NUS spectrum from the entered sampling list…".into(); - Ok(()) - } - /// Fit every column to build the DOSY contour map (diffusion datasets only). pub fn build_dosy_map_for(&mut self, dataset: usize) { let Some(d2) = self @@ -37,6 +12,10 @@ impl PlotxApp { self.session.status = "DOSY maps need a diffusion dataset.".into(); return; }; + if let Some(error) = d2.dosy_input_error() { + self.session.status = error.into(); + return; + } if d2.data.diffusion.is_none() { self.session.status = "This dataset has no diffusion parameters (not a DOSY array).".into(); @@ -77,6 +56,10 @@ impl PlotxApp { self.session.status = "ILT DOSY maps need a diffusion dataset.".into(); return; }; + if let Some(error) = d2.dosy_input_error() { + self.session.status = error.into(); + return; + } if d2.data.diffusion.is_none() { self.session.status = "This dataset has no diffusion parameters (not a DOSY array).".into(); diff --git a/crates/core/src/state/app_impl_analysis_tests.rs b/crates/core/src/state/app_impl_analysis_tests.rs index f67b8e4c..c564e9e9 100644 --- a/crates/core/src/state/app_impl_analysis_tests.rs +++ b/crates/core/src/state/app_impl_analysis_tests.rs @@ -35,7 +35,7 @@ fn live_and_frozen_region_tables_record_lineage() { nus: None, source: "series".to_owned(), }; - let mut source = Nmr2DDataset::load(data); + let mut source = crate::nmr_test_support::load_2d(data).unwrap(); source.region_analysis.regions.push(Region { id: RegionId::new(0), lo: 4.0, diff --git a/crates/core/src/state/app_impl_arithmetic.rs b/crates/core/src/state/app_impl_arithmetic.rs index 17bd5357..31c9c655 100644 --- a/crates/core/src/state/app_impl_arithmetic.rs +++ b/crates/core/src/state/app_impl_arithmetic.rs @@ -1,5 +1,5 @@ use super::*; -use plotx_processing::Slice1D; +use plotx_io::nmr_view::NmrSource; use plotx_processing::arithmetic::{ SpectrumBinaryOp, combine_spectra, same_grid, scale_offset_spectrum, }; @@ -34,6 +34,17 @@ impl PlotxApp { sa.nucleus, sb.nucleus )); } + let left = self.doc.datasets[a] + .as_nmr() + .ok_or("Select an NMR spectrum")?; + let right = self.doc.datasets[b] + .as_nmr() + .ok_or("Select an NMR spectrum")?; + plotx_processing::arithmetic::validate_combination( + &left.native_processed, + &right.native_processed, + ) + .map_err(|error| error.to_string())?; if same_grid(sa, sb) { Ok(None) } else { @@ -46,12 +57,14 @@ impl PlotxApp { } pub fn combine_spectra_datasets(&mut self, a: usize, b: usize, op: SpectrumBinaryOp, k: f64) { - let (Some(sa), Some(sb)) = (self.arithmetic_spectrum(a), self.arithmetic_spectrum(b)) - else { + let (Some(sa), Some(sb)) = ( + self.doc.datasets.get(a).and_then(Dataset::as_nmr), + self.doc.datasets.get(b).and_then(Dataset::as_nmr), + ) else { self.session.status = "Spectrum arithmetic needs two 1D NMR spectra.".into(); return; }; - let result = match combine_spectra(sa, sb, op, k) { + let result = match combine_spectra(&sa.native_processed, &sb.native_processed, op, k) { Ok(result) => result, Err(error) => { self.session.status = error.to_string(); @@ -70,7 +83,7 @@ impl PlotxApp { } pub fn scale_spectrum_dataset(&mut self, a: usize, scale: f64, offset: f64) { - let Some(sa) = self.arithmetic_spectrum(a) else { + let Some(sa) = self.doc.datasets.get(a).and_then(Dataset::as_nmr) else { self.session.status = "Spectrum arithmetic needs a 1D NMR spectrum.".into(); return; }; @@ -78,7 +91,13 @@ impl PlotxApp { self.session.status = "Nothing to compute: scale is 1 and offset is 0.".into(); return; } - let result = scale_offset_spectrum(sa, scale, offset); + let result = match scale_offset_spectrum(&sa.native_processed, scale, offset) { + Ok(result) => result, + Err(error) => { + self.session.status = error.to_string(); + return; + } + }; let name_a = self.doc.datasets[a].display_name(); let scaled = if scale == 1.0 { name_a @@ -108,7 +127,7 @@ impl PlotxApp { /// dataset on its own page, as one undoable step (same path as slices). fn insert_arithmetic_dataset( &mut self, - result: Spectrum, + result: NmrSource, name: String, sources: impl IntoIterator, ) { @@ -116,16 +135,20 @@ impl PlotxApp { .into_iter() .filter_map(|index| self.doc.datasets.get(index).map(Dataset::resource_id)) .collect::>(); - let slice = Slice1D { - coordinates: result.ppm, - domain: plotx_io::Domain::Frequency, - values: result.values, - nucleus: result.nucleus, - observe_freq_mhz: result.observe_freq_mhz, - position: None, - position_domain: plotx_io::Domain::Frequency, + let dataset = match NmrDataset::load_with_pipeline( + result, + Some(AxisPipeline { steps: Vec::new() }), + Some(false), + ) { + Ok(dataset) => dataset, + Err(error) => { + self.session.status = format!("Spectrum arithmetic failed: {error}"); + return; + } }; - let mut ds = Dataset::Nmr(Box::new(NmrDataset::from_slice(slice, name.clone()))); + let mut dataset = dataset; + dataset.name = Some(name.clone()); + let mut ds = Dataset::Nmr(Box::new(dataset)); ds.set_lineage(Some(DatasetLineage::new( DerivationKind::SpectrumArithmetic, sources, diff --git a/crates/core/src/state/app_impl_compute.rs b/crates/core/src/state/app_impl_compute.rs index f986d200..9c85cecf 100644 --- a/crates/core/src/state/app_impl_compute.rs +++ b/crates/core/src/state/app_impl_compute.rs @@ -57,6 +57,10 @@ impl PlotxApp { self.session.status = "DOSY maps need a diffusion dataset.".into(); return; }; + if let Some(error) = d2.dosy_input_error() { + self.session.status = error.into(); + return; + } if d2.data.diffusion.is_none() { self.session.status = "This dataset has no diffusion parameters (not a DOSY array).".into(); @@ -104,6 +108,10 @@ impl PlotxApp { self.session.status = "ILT DOSY maps need a diffusion dataset.".into(); return; }; + if let Some(error) = d2.dosy_input_error() { + self.session.status = error.into(); + return; + } if d2.data.diffusion.is_none() { self.session.status = "This dataset has no diffusion parameters (not a DOSY array).".into(); @@ -184,18 +192,33 @@ impl PlotxApp { self.session.status = "CRAFT requires a one-dimensional NMR dataset.".into(); return false; }; - if nmr.data.domain != Domain::Time { + if nmr.input_domain() != Domain::Time { self.session.status = "CRAFT requires the original time-domain FID.".into(); return false; } let dataset_id = nmr.resource_id; - let reference = nmr.craft_reference(); + let data = match nmr.data.craft_fid() { + Ok(data) => data, + Err(error) => { + let message = error.to_string(); + self.session.status = message.clone(); + self.session + .ui + .craft_feedback + .insert(dataset_id, CraftRunFeedback::Failed { message }); + return false; + } + }; + let Some(reference) = nmr.craft_reference() else { + self.session.status = "CRAFT requires chemical-shift reference evidence".into(); + return false; + }; let provenance = base_run.and_then(|id| nmr.craft_run(id).map(|run| &run.provenance.invocation)); let invocation = plotx_processing::craft::resolve_craft_invocation( - &nmr.data, reference, &overrides, provenance, + &data, reference, &overrides, provenance, ); - if let Err(error) = invocation.validate(&nmr.data) { + if let Err(error) = invocation.validate(&data) { self.session.status = error.to_string(); self.session.ui.craft_feedback.insert( dataset_id, @@ -205,7 +228,7 @@ impl PlotxApp { ); return false; } - let data = std::sync::Arc::new(nmr.data.clone()); + let data = std::sync::Arc::new(data); let parent_run = invocation .sources .uses_result_provenance() @@ -536,6 +559,7 @@ impl PlotxApp { let Some(dataset) = self.doc.dataset_index(dataset) else { continue; }; + let previous_field = self.doc.datasets[dataset].default_field_id(); let Some(d2) = self .doc .datasets @@ -551,11 +575,15 @@ impl PlotxApp { // `params` may also lag `d2.params` for a paused edit, which is // the intended display-trails-recipe contract. if let Some(base) = base { - d2.base = base; + d2.native_base = base.source; + d2.base = base.view; d2.base_params = params; d2.base_stale = false; } - d2.processed = processed; + d2.native_processed = processed.source; + d2.reconstruction_warning = None; + d2.phase_reports = processed.phases; + d2.processed = processed.view; d2.processed_figure = std::sync::Arc::new(build_processed_figure(&d2.processed, d2.preset)); d2.invalidate_dosy_results( @@ -566,11 +594,19 @@ impl PlotxApp { .compute .promote_field_version(field.source, field.summary); } + self.initialize_nmr_result_bindings(dataset, previous_field); self.recompute_integrals_2d_after_processing(dataset); self.rebuild_canvases_for(dataset); self.mark_document_dirty(); self.session.status = "Updated 2D processing.".into(); } + Done::Processing2DFailed { + dataset, message, .. + } => { + if self.doc.dataset_index(dataset).is_some() { + self.session.status = format!("2D processing failed: {message}"); + } + } Done::EstimateField { key, result } => { let dataset = self.doc @@ -672,8 +708,8 @@ impl PlotxApp { let Some(d2) = self.doc.datasets.get(dataset).and_then(Dataset::as_nmr2d) else { return false; }; - // `base_stale` covers a mutation of `data` itself, which the recipe - // comparison cannot see. It stays set until a fresh base lands, so an + // `base_stale` covers NUS and delay inputs outside the axis recipes. + // It stays set until a fresh base lands, so an // intervening frequency-only edit cannot downgrade the pending retransform // to a re-apply and strand the reconstruction. let full = force_full @@ -699,13 +735,27 @@ impl PlotxApp { .flatten() .collect::>(); let outcome = if full { - self.session - .compute - .request_2d_full(dataset_id, &fields, d2.processing_data(), params) + self.session.compute.request_2d_full( + dataset_id, + &fields, + super::compute::Full2DInput { + source: d2.data.source_dataset().clone(), + delay: if d2.group_delay_correct { + plotx_processing::nmr_bridge::DelayPolicy::AxisEvidence + } else { + plotx_processing::nmr_bridge::DelayPolicy::Disabled + }, + nus: d2.nus_request, + }, + params, + ) } else { - self.session - .compute - .request_2d_reapply(dataset_id, &fields, d2.base.clone(), params) + self.session.compute.request_2d_reapply( + dataset_id, + &fields, + d2.native_base.clone(), + params, + ) }; let aborted = match outcome { Ok(aborted) => aborted, diff --git a/crates/core/src/state/app_impl_compute_tests.rs b/crates/core/src/state/app_impl_compute_tests.rs index 2bf74bde..90a1b0ff 100644 --- a/crates/core/src/state/app_impl_compute_tests.rs +++ b/crates/core/src/state/app_impl_compute_tests.rs @@ -54,12 +54,51 @@ fn craft_data() -> plotx_io::NmrData { } } +#[test] +fn native_processing_failure_reaches_status_without_replacing_the_display() { + let mut app = PlotxApp::new(); + let mut dataset = Nmr2DDataset::load(data_2d("failure target")).unwrap(); + let original = dataset.native_processed.dataset().canonical_digests(); + let step = dataset.allocate_step_id(); + dataset.params.f2.steps.push(ProcessingStep::new( + step, + StepKind::Phase(plotx_processing::PhaseParams { + phase0: f64::NAN, + ..plotx_processing::PhaseParams::MANUAL_ZERO + }), + StepSource::User, + )); + app.doc.datasets.push(Dataset::Nmr2D(Box::new(dataset))); + assert!(app.schedule_2d_processing(0, false)); + let deadline = Instant::now() + Duration::from_secs(3); + while app.compute_busy() && Instant::now() < deadline { + app.poll_compute(); + std::thread::sleep(Duration::from_millis(5)); + } + app.poll_compute(); + assert!(!app.compute_busy()); + assert!( + app.session.status.contains("2D processing failed"), + "{}", + app.session.status + ); + assert_eq!( + app.doc.datasets[0] + .as_nmr2d() + .unwrap() + .native_processed + .dataset() + .canonical_digests(), + original + ); +} + #[test] fn craft_result_is_installed_with_provenance_by_dataset_identity() { let mut app = PlotxApp::new(); - app.doc - .datasets - .push(Dataset::Nmr(Box::new(NmrDataset::load(craft_data())))); + app.doc.datasets.push(Dataset::Nmr(Box::new( + NmrDataset::load(craft_data()).unwrap(), + ))); let nmr = app.doc.datasets[0].as_nmr_mut().unwrap(); let reference_id = nmr.allocate_step_id(); nmr.pipeline.steps.push(ProcessingStep::new( @@ -70,7 +109,7 @@ fn craft_result_is_installed_with_provenance_by_dataset_identity() { }), StepSource::User, )); - nmr.rebuild(); + nmr.rebuild().unwrap(); let target = app.doc.datasets[0].resource_id(); app.session.ui.craft_task_dataset = Some(target); let mut params = plotx_processing::craft::CraftParams::conventional(); @@ -159,16 +198,16 @@ fn craft_result_is_installed_with_provenance_by_dataset_identity() { }) .unwrap(); reference.target_ppm += 0.1; - nmr.rebuild(); + nmr.rebuild().unwrap(); assert!(nmr.craft_runs[0].is_stale_for(&nmr.data, nmr.craft_reference())); } #[test] fn craft_rerun_keeps_requested_parent_without_hijacking_another_task() { let mut app = PlotxApp::new(); - app.doc - .datasets - .push(Dataset::Nmr(Box::new(NmrDataset::load(craft_data())))); + app.doc.datasets.push(Dataset::Nmr(Box::new( + NmrDataset::load(craft_data()).unwrap(), + ))); let target = app.doc.datasets[0].resource_id(); app.session.ui.craft_task_dataset = Some(target); let mut params = plotx_processing::craft::CraftParams::conventional(); @@ -193,9 +232,9 @@ fn craft_rerun_keeps_requested_parent_without_hijacking_another_task() { plotx_processing::craft::CraftParamOverrides::default(), Some(CraftRunId(0)), )); - app.doc - .datasets - .push(Dataset::Nmr(Box::new(NmrDataset::load(craft_data())))); + app.doc.datasets.push(Dataset::Nmr(Box::new( + NmrDataset::load(craft_data()).unwrap(), + ))); let other = app.doc.datasets[1].resource_id(); app.session.ui.craft_task_dataset = Some(other); app.session.ui.craft_base_run = None; @@ -225,16 +264,12 @@ fn craft_rerun_keeps_requested_parent_without_hijacking_another_task() { #[test] fn process_2d_result_follows_dataset_identity_after_earlier_deletion() { let mut app = PlotxApp::new(); - app.doc - .datasets - .push(Dataset::Nmr2D(Box::new(Nmr2DDataset::load(data_2d( - "unrelated", - ))))); - app.doc - .datasets - .push(Dataset::Nmr2D(Box::new(Nmr2DDataset::load(data_2d( - "target", - ))))); + app.doc.datasets.push(Dataset::Nmr2D(Box::new( + Nmr2DDataset::load(data_2d("unrelated")).unwrap(), + ))); + app.doc.datasets.push(Dataset::Nmr2D(Box::new( + Nmr2DDataset::load(data_2d("target")).unwrap(), + ))); let target_id = app.doc.datasets[1].resource_id(); let before = app.doc.datasets[1].as_nmr2d().unwrap().processed.clone(); let target = app.doc.datasets[1].as_nmr2d_mut().unwrap(); @@ -273,11 +308,9 @@ fn process_2d_result_follows_dataset_identity_after_earlier_deletion() { #[test] fn successful_processing_promotes_fresh_runtime_versions_for_each_scalar_field() { let mut app = PlotxApp::new(); - app.doc - .datasets - .push(Dataset::Nmr2D(Box::new(Nmr2DDataset::load(data_2d( - "versioned target", - ))))); + app.doc.datasets.push(Dataset::Nmr2D(Box::new( + Nmr2DDataset::load(data_2d("versioned target")).unwrap(), + ))); let resource = app.doc.datasets[0].resource_id(); let fields = app.doc.datasets[0] .field_descriptors() diff --git a/crates/core/src/state/app_impl_figures.rs b/crates/core/src/state/app_impl_figures.rs index 6928234d..9235539c 100644 --- a/crates/core/src/state/app_impl_figures.rs +++ b/crates/core/src/state/app_impl_figures.rs @@ -10,7 +10,17 @@ impl PlotxApp { /// datasets are plot-owned and remain visible in encoding-compatible modes. pub fn display_binding(&self, owner: Option, binding: &DataBinding) -> DataBinding { let Some((resource, field)) = self.live_display_source(owner) else { - return binding.clone(); + let mut displayed = binding.clone(); + if let Some(dataset @ Dataset::Nmr2D(_)) = + owner.and_then(|id| self.doc.dataset_by_id(id)) + { + let active = dataset.field_descriptors(); + displayed.series.retain(|series| { + series.source.resource != dataset.resource_id() + || active.iter().any(|field| field.id == series.source.field) + }); + } + return displayed; }; let mut owner_series = binding .series @@ -54,9 +64,14 @@ impl PlotxApp { persisted: &DataBinding, displayed: DataBinding, ) -> DataBinding { - let Some((_resource, _field)) = self.live_display_source(owner) else { + let projected_owner = self.live_display_source(owner).is_some() + || matches!( + owner.and_then(|id| self.doc.dataset_by_id(id)), + Some(Dataset::Nmr2D(_)) + ); + if !projected_owner { return displayed; - }; + } let previous_display = self.display_binding(owner, persisted); let previous_ids = previous_display .series @@ -87,6 +102,45 @@ impl PlotxApp { Some((dataset.resource_id(), field)) } + /// A reconstructed grid is a different field from its acquired observations. + /// Retain authored bindings for switching back and allocate fresh series only + /// when a completed result first exposes another field on a live owner plot. + pub(crate) fn initialize_nmr_result_bindings( + &mut self, + dataset: usize, + previous: Option, + ) { + let dataset = &self.doc.datasets[dataset]; + let Some(field) = dataset + .default_field_id() + .filter(|field| Some(*field) != previous) + else { + return; + }; + let resource = dataset.resource_id(); + let additions = SeriesBinding::from_field_all(dataset, field); + for canvas in &mut self.doc.canvases { + for object in &mut canvas.objects { + let Some(plot) = object.plot_mut().filter(|plot| { + plot.display_owner == Some(resource) + && plot.binding.series.iter().any(|series| { + series.source.resource == resource + && Some(series.source.field) == previous + }) + && !plot.binding.series.iter().any(|series| { + series.source.resource == resource && series.source.field == field + }) + }) else { + continue; + }; + for mut series in additions.clone() { + series.id = plot.allocate_series_id(); + plot.binding.series.push(series); + } + } + } + } + /// Build a dataset's figure through the chart registry: resolve `chart`'s /// type for the dataset's domain (falling back to the domain default when the /// recorded id doesn't apply), then dispatch to its builder. The default chart diff --git a/crates/core/src/state/app_impl_io.rs b/crates/core/src/state/app_impl_io.rs index 001056b3..f7aa469c 100644 --- a/crates/core/src/state/app_impl_io.rs +++ b/crates/core/src/state/app_impl_io.rs @@ -218,14 +218,73 @@ impl PlotxApp { self.load_archive_from(path); return; } + self.install_import_result(path, plotx_io::load_path(path)); + } + + pub fn load_nmr_with_sampling( + &mut self, + path: &std::path::Path, + declaration: plotx_io::nmr_sampling::SamplingDeclaration, + ) -> bool { + self.install_import_result(path, plotx_io::nmr_sampling::load(path, declaration)) + } + + fn install_import_result( + &mut self, + path: &std::path::Path, + result: Result, + ) -> bool { + let prepared = result + .map_err(|error| error.to_string()) + .and_then(|loaded| { + super::data_import::PreparedImport::new( + loaded, + self.settings.general.equal_scale_homonuclear_2d_imports, + ) + }); + self.install_prepared_import(path, prepared) + } + + pub(super) fn install_prepared_import( + &mut self, + path: &std::path::Path, + result: Result, + ) -> bool { let operation_id = self.session.begin_operation(); - match plotx_io::load_path(path) { - Ok(result) => { - let (acquisition, acquisition_identity, format, _, nmr_origin, warnings) = - result.into_parts(); - let format = format.as_str(); - let source = self.insert_acquisition(acquisition, acquisition_identity, nmr_origin); - let mut report = if warnings.is_empty() { + match result { + Ok(prepared) => { + let super::data_import::PreparedImport { + dataset, + source, + format, + warnings, + } = prepared; + let reconstruction_warning = dataset + .as_nmr2d() + .and_then(|data| data.reconstruction_warning.clone()); + let before = self.doc.datasets.len(); + if let Err(error) = self.insert_prepared_dataset(dataset, &source) { + return self.install_prepared_import(path, Err(error)); + } + if self.doc.datasets.len() == before { + return self.install_prepared_import(path, Err(self.session.status.clone())); + } + let mut report = if let Some(warning) = reconstruction_warning { + OperationReport::warning( + operation_id, + OperationKind::DatasetLoad, + format!("Loaded {source}. {warning}"), + (), + ) + .with_diagnostic( + Diagnostic::new( + Severity::Warning, + DiagnosticCode::DatasetLoadWarning, + warning, + ) + .with_source("core.nmr_reconstruction"), + ) + } else if warnings.is_empty() { OperationReport::success( operation_id, OperationKind::DatasetLoad, @@ -246,7 +305,7 @@ impl PlotxApp { DiagnosticCode::DatasetLoadSucceeded, "Dataset loaded", ) - .with_context("format", format) + .with_context("format", format.as_str()) .with_context("path", path.display().to_string()) .with_source("core.loading"), ); @@ -255,6 +314,7 @@ impl PlotxApp { } self.session.status = report.summary.clone(); self.session.record_operation(report); + true } Err(e) => { self.session.status = format!("Failed to load {}: {e}", path.display()); @@ -271,6 +331,7 @@ impl PlotxApp { .with_context("path", path.display().to_string()) .with_source("core.loading"), )); + false } } } @@ -297,13 +358,29 @@ impl PlotxApp { self.session.record_operation(report); return; } - let count = result.items.len(); + let mut count = 0; let mut warnings = result.warnings; for item in result.items { - let (acquisition, acquisition_identity, _, _, nmr_origin, item_warnings) = + let (acquisition, acquisition_identity, _, _, item_warnings) = item.into_parts(); warnings.extend(item_warnings); - self.insert_acquisition(acquisition, acquisition_identity, nmr_origin); + match self.insert_acquisition(acquisition, acquisition_identity) { + Ok((_, warning)) => { + count += 1; + if let Some(message) = warning { + warnings.push(plotx_io::LoadWarning { + code: plotx_io::LoadWarningCode::UnsupportedFunction, + message, + path: None, + }); + } + } + Err(error) => warnings.push(plotx_io::LoadWarning { + code: plotx_io::LoadWarningCode::InvalidMetadata, + message: format!("Archive dataset could not be opened: {error}"), + path: Some(path.to_owned()), + }), + } } let summary = if warnings.is_empty() { format!("Loaded {count} spectra from {archive}") @@ -369,22 +446,29 @@ impl PlotxApp { &mut self, acq: plotx_io::Acquisition, acquisition_identity: plotx_io::AcquisitionIdentity, - nmr_origin: Option, - ) -> String { + ) -> Result<(String, Option), crate::workflow::WorkflowError> { let (dataset, source) = crate::workflow::dataset_from_loaded_acquisition( acq, acquisition_identity, - nmr_origin, self.settings.general.equal_scale_homonuclear_2d_imports, - ); - let name = Self::short_name(&source); - self.execute_action(Action::insert_dataset_with_default_canvas( + )?; + let warning = dataset + .as_nmr2d() + .and_then(|data| data.reconstruction_warning.clone()); + self.insert_prepared_dataset(dataset, &source) + .map_err(crate::workflow::WorkflowError::FieldRuntime)?; + Ok((source, warning)) + } + + fn insert_prepared_dataset(&mut self, dataset: Dataset, source: &str) -> Result<(), String> { + let name = Self::short_name(source); + self.try_execute_action(Action::insert_dataset_with_default_canvas( self, dataset, format!("Canvas {} — {}", self.doc.canvases.len() + 1, name), DEFAULT_CANVAS_SIZE_MM, - )); - source + )) + .map_err(|error| error.to_string()) } pub fn request_export(&mut self, format: ExportFormat) { @@ -565,159 +649,5 @@ fn export_status(format: ExportFormat, paths: &[std::path::PathBuf]) -> String { } #[cfg(test)] -mod export_operation_tests { - use super::*; - use crate::operation::{DiagnosticCode, OperationOutcome}; - - #[test] - fn unavailable_export_is_recorded_and_projects_its_summary() { - let mut app = PlotxApp::new_with_settings(crate::settings::Settings::default()); - - app.request_export(ExportFormat::Svg); - - let operation = app - .session - .operation_history - .operations() - .next_back() - .unwrap(); - assert_eq!(operation.kind, OperationKind::Export); - assert_eq!(operation.outcome, OperationOutcome::Failure); - assert_eq!(operation.summary, app.session.status); - assert_eq!(operation.diagnostics.len(), 1); - assert_eq!( - operation.diagnostics[0].code, - DiagnosticCode::ExportUnavailable - ); - } - - #[test] - fn typed_export_error_is_mapped_at_the_workflow_boundary() { - let mut app = PlotxApp::new_with_settings(crate::settings::Settings::default()); - app.doc.canvases.push(CanvasDocument::new( - "page".to_owned(), - DEFAULT_CANVAS_SIZE_MM, - )); - app.session.active_canvas = Some(0); - - app.export_to( - ExportSettings { - format: ExportFormat::Svg, - scope: crate::export::ExportPageScope::Range { start: 2, end: 1 }, - dpi: crate::export::DEFAULT_BITMAP_DPI, - target_width_mm: None, - trim_to_visible_content: false, - allow_missing_images: false, - }, - std::path::Path::new("unused.svg"), - ); - - let operation = app - .session - .operation_history - .operations() - .next_back() - .unwrap(); - assert_eq!(operation.outcome, OperationOutcome::Failure); - assert_eq!(operation.summary, app.session.status); - assert_eq!(operation.diagnostics[0].code, DiagnosticCode::ExportFailed); - assert_eq!( - operation.diagnostics[0] - .context - .get("error_kind") - .map(String::as_str), - Some("invalid_page_range") - ); - } - - #[test] - fn image_pages_open_export_options_for_precheck_and_placeholder_choice() { - let mut app = PlotxApp::new_with_settings(crate::settings::Settings::default()); - app.doc.canvases.push(CanvasDocument::new( - "clean".to_owned(), - DEFAULT_CANVAS_SIZE_MM, - )); - let mut raster_page = CanvasDocument::new("raster".to_owned(), DEFAULT_CANVAS_SIZE_MM); - let id = raster_page.allocate_object_id(); - raster_page.objects.push(crate::state::CanvasObject { - id, - name: "image".to_owned(), - frame: crate::state::ObjectFrame::new(0.0, 0.0, 10.0, 10.0), - locked: false, - visible: true, - kind: crate::state::CanvasObjectKind::RasterImage( - crate::state::RasterImageContent::new(crate::state::AssetId::new()), - ), - }); - app.doc.canvases.push(raster_page); - app.session.active_canvas = Some(0); - - app.request_export(ExportFormat::Svg); - assert!(app.session.ui.export_options.is_some()); - app.session.ui.export_options = None; - app.session.active_canvas = Some(1); - app.request_export(ExportFormat::Svg); - assert!(app.session.ui.export_options.is_some()); - } -} - -#[cfg(test)] -mod install_loaded_project_tests { - use super::*; - - fn record_failure(app: &mut PlotxApp) -> OperationId { - let id = app.session.begin_operation(); - app.session.record_operation(OperationReport::<()>::failure( - id, - OperationKind::DatasetLoad, - "boom", - Diagnostic::new(Severity::Error, DiagnosticCode::DatasetLoadFailed, "boom"), - )); - id - } - - /// The invariant the feedback watermark hinges on: a project swap carries - /// the operation history *including its counters*, so reports recorded - /// after the load always come after a pre-load acknowledgement. - #[test] - fn project_swap_carries_history_counter_and_watermark() { - let mut app = PlotxApp::new_with_settings(crate::settings::Settings::default()); - let before = record_failure(&mut app); - let before_order = app - .session - .operation_history - .operations() - .next_back() - .expect("failure recorded") - .completion_order; - app.session.ui.dismissed_feedback_order = Some(before_order); - - let loaded = PlotxApp::new_with_settings(crate::settings::Settings::default()); - app.install_loaded_project(loaded); - - assert_eq!(app.session.ui.dismissed_feedback_order, Some(before_order)); - let after = record_failure(&mut app); - let after_order = app - .session - .operation_history - .operations() - .next_back() - .expect("failure recorded") - .completion_order; - assert!( - after > before, - "post-load ids must stay above the watermark" - ); - assert!( - after_order > before_order, - "post-load reports must stay after the acknowledgement" - ); - assert!( - app.session - .operation_history - .operations() - .any(|operation| operation.id == before), - "pre-load history is carried across the swap" - ); - } -} +#[path = "app_impl_io_tests.rs"] +mod tests; diff --git a/crates/core/src/state/app_impl_io_tests.rs b/crates/core/src/state/app_impl_io_tests.rs new file mode 100644 index 00000000..ae9f07cc --- /dev/null +++ b/crates/core/src/state/app_impl_io_tests.rs @@ -0,0 +1,159 @@ +use super::*; + +#[cfg(test)] +mod export_operation_tests { + use super::*; + use crate::operation::{DiagnosticCode, OperationOutcome}; + + #[test] + fn unavailable_export_is_recorded_and_projects_its_summary() { + let mut app = PlotxApp::new_with_settings(crate::settings::Settings::default()); + + app.request_export(ExportFormat::Svg); + + let operation = app + .session + .operation_history + .operations() + .next_back() + .unwrap(); + assert_eq!(operation.kind, OperationKind::Export); + assert_eq!(operation.outcome, OperationOutcome::Failure); + assert_eq!(operation.summary, app.session.status); + assert_eq!(operation.diagnostics.len(), 1); + assert_eq!( + operation.diagnostics[0].code, + DiagnosticCode::ExportUnavailable + ); + } + + #[test] + fn typed_export_error_is_mapped_at_the_workflow_boundary() { + let mut app = PlotxApp::new_with_settings(crate::settings::Settings::default()); + app.doc.canvases.push(CanvasDocument::new( + "page".to_owned(), + DEFAULT_CANVAS_SIZE_MM, + )); + app.session.active_canvas = Some(0); + + app.export_to( + ExportSettings { + format: ExportFormat::Svg, + scope: crate::export::ExportPageScope::Range { start: 2, end: 1 }, + dpi: crate::export::DEFAULT_BITMAP_DPI, + target_width_mm: None, + trim_to_visible_content: false, + allow_missing_images: false, + }, + std::path::Path::new("unused.svg"), + ); + + let operation = app + .session + .operation_history + .operations() + .next_back() + .unwrap(); + assert_eq!(operation.outcome, OperationOutcome::Failure); + assert_eq!(operation.summary, app.session.status); + assert_eq!(operation.diagnostics[0].code, DiagnosticCode::ExportFailed); + assert_eq!( + operation.diagnostics[0] + .context + .get("error_kind") + .map(String::as_str), + Some("invalid_page_range") + ); + } + + #[test] + fn image_pages_open_export_options_for_precheck_and_placeholder_choice() { + let mut app = PlotxApp::new_with_settings(crate::settings::Settings::default()); + app.doc.canvases.push(CanvasDocument::new( + "clean".to_owned(), + DEFAULT_CANVAS_SIZE_MM, + )); + let mut raster_page = CanvasDocument::new("raster".to_owned(), DEFAULT_CANVAS_SIZE_MM); + let id = raster_page.allocate_object_id(); + raster_page.objects.push(crate::state::CanvasObject { + id, + name: "image".to_owned(), + frame: crate::state::ObjectFrame::new(0.0, 0.0, 10.0, 10.0), + locked: false, + visible: true, + kind: crate::state::CanvasObjectKind::RasterImage( + crate::state::RasterImageContent::new(crate::state::AssetId::new()), + ), + }); + app.doc.canvases.push(raster_page); + app.session.active_canvas = Some(0); + + app.request_export(ExportFormat::Svg); + assert!(app.session.ui.export_options.is_some()); + app.session.ui.export_options = None; + app.session.active_canvas = Some(1); + app.request_export(ExportFormat::Svg); + assert!(app.session.ui.export_options.is_some()); + } +} + +#[cfg(test)] +mod install_loaded_project_tests { + use super::*; + + fn record_failure(app: &mut PlotxApp) -> OperationId { + let id = app.session.begin_operation(); + app.session.record_operation(OperationReport::<()>::failure( + id, + OperationKind::DatasetLoad, + "boom", + Diagnostic::new(Severity::Error, DiagnosticCode::DatasetLoadFailed, "boom"), + )); + id + } + + /// The invariant the feedback watermark hinges on: a project swap carries + /// the operation history *including its counters*, so reports recorded + /// after the load always come after a pre-load acknowledgement. + #[test] + fn project_swap_carries_history_counter_and_watermark() { + let mut app = PlotxApp::new_with_settings(crate::settings::Settings::default()); + let before = record_failure(&mut app); + let before_order = app + .session + .operation_history + .operations() + .next_back() + .expect("failure recorded") + .completion_order; + app.session.ui.dismissed_feedback_order = Some(before_order); + + let loaded = PlotxApp::new_with_settings(crate::settings::Settings::default()); + app.install_loaded_project(loaded); + + assert_eq!(app.session.ui.dismissed_feedback_order, Some(before_order)); + let after = record_failure(&mut app); + let after_order = app + .session + .operation_history + .operations() + .next_back() + .expect("failure recorded") + .completion_order; + assert!( + after > before, + "post-load ids must stay above the watermark" + ); + assert!( + after_order > before_order, + "post-load reports must stay after the acknowledgement" + ); + assert!( + app.session + .operation_history + .operations() + .any(|operation| operation.id == before), + "pre-load history is carried across the swap" + ); + } +} diff --git a/crates/core/src/state/app_impl_multiplet.rs b/crates/core/src/state/app_impl_multiplet.rs index fe53e336..6663ea42 100644 --- a/crates/core/src/state/app_impl_multiplet.rs +++ b/crates/core/src/state/app_impl_multiplet.rs @@ -35,7 +35,16 @@ impl PlotxApp { let Some(n) = self.doc.datasets.get(dataset).and_then(Dataset::as_nmr) else { return Err("Multiplet analysis needs a 1D NMR dataset.".to_owned()); }; - let obs = n.data.observe_freq_mhz; + let spectrum = n + .spectrum() + .ok_or("Multiplet analysis requires a spectrum")?; + if spectrum.unit != nmr::axis::AxisUnit::Ppm { + return Err("Use a spectrum calibrated in ppm before analyzing multiplets.".into()); + } + let obs = n + .native_processed + .reference_frequency_mhz(0) + .ok_or("Multiplet analysis in ppm requires chemical-shift reference evidence")?; let mut peaks: Vec = Vec::new(); let mut areas: Vec = Vec::new(); diff --git a/crates/core/src/state/app_impl_slice.rs b/crates/core/src/state/app_impl_slice.rs index 71644f41..8ecc319f 100644 --- a/crates/core/src/state/app_impl_slice.rs +++ b/crates/core/src/state/app_impl_slice.rs @@ -1,114 +1,86 @@ use super::*; -use plotx_processing::{Processed1D, ProjectionMode, Slice1D, SliceKind}; +use plotx_processing::{ProjectionMode, Slice1D, SliceKind}; +use std::sync::Arc; impl NmrDataset { - /// Build a standalone 1D trace from a slice/projection lifted out of a 2D - /// dataset without changing its scientific domain. - pub fn from_slice(slice: Slice1D, source: String) -> Self { - let Slice1D { - coordinates, - domain, - values, - nucleus, - observe_freq_mhz, - .. - } = slice; - let (spectral_width_hz, carrier_ppm) = match domain { - plotx_io::Domain::Frequency => linear_axis_params(&coordinates, observe_freq_mhz), - plotx_io::Domain::Time => (time_axis_spectral_width(&coordinates), 0.0), + /// Explicit coordinates are retained for a standalone programmatic trace. + pub fn from_slice(slice: Slice1D, source: String) -> Result { + use nmr::axis::{AxisCoordinates, AxisDomain, AxisRole, AxisUnit, FrequencyEvidence}; + use nmr::processed::{ + ComponentBasis, ProcessedAxis, ProcessedDataset, ProcessedOrigin, ProcessedProvenance, }; - let data = NmrData { - points: values.clone(), - domain, - spectral_width_hz, - observe_freq_mhz, - carrier_ppm, - nucleus: nucleus.clone(), - source: source.clone(), - group_delay: 0.0, + let fail = |error: &dyn std::fmt::Display| error.to_string(); + let (domain, unit) = match slice.domain { + Domain::Time => (AxisDomain::Time, AxisUnit::Second), + Domain::Frequency => (AxisDomain::Frequency, slice.unit), }; - let group_delay_correct = super::default_group_delay_correct(data.domain); - let pipeline = AxisPipeline { steps: Vec::new() }; - let processed = match domain { - plotx_io::Domain::Frequency => { - let n = coordinates.len().max(1); - Processed1D::Frequency(Spectrum { - ppm: coordinates, - values, - hz_per_point: (spectral_width_hz / n as f64).abs(), - observe_freq_mhz, - nucleus, - }) - } - plotx_io::Domain::Time => Processed1D::Time(plotx_processing::TimeTrace { - time_s: coordinates, - values, - nucleus, - source: source.clone(), + let reference = (unit == AxisUnit::Ppm) + .then_some(slice.reference_freq_mhz) + .flatten(); + let coordinates = if let Some(frequency) = reference { + slice + .coordinates + .into_iter() + .map(|value| value * frequency) + .collect() + } else { + slice.coordinates + }; + let axis = ProcessedAxis::new( + AxisRole::Signal, + domain, + Some(if reference.is_some() { + AxisUnit::Hertz + } else { + unit }), + slice.values.len(), + AxisCoordinates::Explicit(coordinates), + ComponentBasis::Cartesian, + ) + .map_err(|error| fail(&error))? + .with_nucleus((!slice.nucleus.is_empty()).then_some(slice.nucleus)) + .map_err(|error| fail(&error))? + .with_frequency_evidence(Some( + FrequencyEvidence::new(slice.observe_freq_mhz, None).map_err(|error| fail(&error))?, + )) + .map_err(|error| fail(&error))?; + let data = ProcessedDataset::from_complex_trace( + axis, + slice.values, + ProcessedProvenance::new(ProcessedOrigin::Unknown, Vec::new()) + .map_err(|error| fail(&error))?, + ) + .map_err(|error| fail(&error))?; + let data = if let Some(frequency) = reference { + use nmr::processing::{ + FrequencyFrame, ProcessingOperation, ProcessingPlan, ReferenceSource, + }; + ProcessingPlan::new(vec![ProcessingOperation::ResolveFrequencyFrame { + axis: 0, + frame: FrequencyFrame::Ppm(ReferenceSource::Explicit( + nmr::raw::ChemicalShiftReference::user_constructed(0.0, frequency) + .map_err(|error| fail(&error))?, + )), + }]) + .map_err(|error| fail(&error))? + .apply(&data.into()) + .map_err(|error| fail(&error))? + } else { + data.into() }; - let mut field_catalog = nmr_field_catalog(); - field_catalog.attach_provenance(&data.source, None); - Self { - resource_id: DatasetId::new(), - field_catalog, - acquisition_identity: plotx_io::AcquisitionIdentity { - subject: None, - acquisition: None, - source_label: source.clone(), - }, - data, - origin: plotx_io::NmrOrigin::Derived, - base: processed.clone(), - pipeline, - next_step_id: 0, - group_delay_correct, - has_imaginary: true, - processed, - name: Some(source), - lineage: None, - peaks: PeakSet::default(), - integrals: Vec::new(), - next_integral_id: 0, - line_fits: Vec::new(), - next_line_fit_id: 0, - multiplets: Vec::new(), - next_multiplet_id: 0, - craft_runs: Vec::new(), - next_craft_run_id: 0, - craft_spectrum_cache: Default::default(), - } - } -} - -/// Spectral width and carrier (ppm) that make [`fft::transform_base`] reproduce a -/// linear ppm axis `p`: `ppm[i] = carrier + (i − n/2)·sw/(n·obs)`. -fn linear_axis_params(ppm: &[f64], obs: f64) -> (f64, f64) { - let n = ppm.len(); - if n < 2 { - return ( - obs.max(f64::MIN_POSITIVE), - ppm.first().copied().unwrap_or(0.0), - ); - } - let dp = (ppm[n - 1] - ppm[0]) / (n - 1) as f64; - let sw = dp * n as f64 * obs; - let carrier = ppm[0] + (n as f64 / 2.0) * dp; - (sw, carrier) -} - -fn time_axis_spectral_width(time_s: &[f64]) -> f64 { - let Some((&first, &last)) = time_s.first().zip(time_s.last()) else { - return 1.0; - }; - if time_s.len() < 2 { - return 1.0; - } - let dwell = (last - first).abs() / (time_s.len() - 1) as f64; - if dwell.is_finite() && dwell > f64::MIN_POSITIVE { - 1.0 / dwell - } else { - 1.0 + let input = plotx_io::nmr_view::NmrSource::new(Arc::new(data)) + .map_err(|error| fail(&error))? + .with_display_label(source.clone()); + let mut dataset = + Self::load_with_pipeline(input, Some(AxisPipeline { steps: Vec::new() }), Some(false))?; + dataset.acquisition_identity = plotx_io::AcquisitionIdentity { + source_label: source.clone(), + subject: None, + acquisition: None, + }; + dataset.name = Some(source); + Ok(dataset) } } @@ -125,12 +97,25 @@ impl PlotxApp { return; }; let parent = self.doc.datasets[dataset].display_name(); - let (slice, is_stack) = match &d2.processed { - Processed2D::Ft(s) => (s.slice(cursor.kind, cursor.index), false), - Processed2D::Stack(s) => (s.slice(cursor.index), true), + let is_stack = matches!(d2.processed, Processed2D::Stack(_)); + let kind = if is_stack { + SliceKind::Row + } else { + cursor.kind + }; + let (source, slice) = match plotx_processing::slice::extract( + &d2.native_processed, + kind, + plotx_processing::slice::Reduction::Slice(cursor.index), + ) { + Ok(output) => output, + Err(error) => { + self.session.status = format!("Slice extraction failed: {error}"); + return; + } }; - let name = slice_name(&parent, &slice, cursor.kind, is_stack, cursor.index); - self.insert_slice_dataset(slice, name, dataset, DerivationKind::Slice); + let name = slice_name(&parent, &slice, kind, is_stack, cursor.index); + self.insert_slice_dataset(source, name, dataset, DerivationKind::Slice); } /// Materialize a whole-axis projection of a true-2D spectrum as a new 1D @@ -144,28 +129,51 @@ impl PlotxApp { let Some(d2) = self.doc.datasets.get(dataset).and_then(Dataset::as_nmr2d) else { return; }; - let Processed2D::Ft(s) = &d2.processed else { + let Processed2D::Ft(_) = &d2.processed else { self.session.status = "Projections are available for true-2D spectra.".into(); return; }; let parent = self.doc.datasets[dataset].display_name(); - let slice = s.project(kind, mode); + let source = match plotx_processing::slice::extract( + &d2.native_processed, + kind, + plotx_processing::slice::Reduction::Projection(mode), + ) { + Ok((source, _)) => source, + Err(error) => { + self.session.status = format!("Projection failed: {error}"); + return; + } + }; let word = match mode { ProjectionMode::Sum => "sum", ProjectionMode::Skyline => "skyline", }; let name = format!("{parent} — {} {word} projection", slice_axis_label(kind)); - self.insert_slice_dataset(slice, name, dataset, DerivationKind::Projection); + self.insert_slice_dataset(source, name, dataset, DerivationKind::Projection); } fn insert_slice_dataset( &mut self, - slice: Slice1D, + source_data: plotx_io::nmr_view::NmrSource, name: String, source: usize, kind: DerivationKind, ) { - let mut ds = Dataset::Nmr(Box::new(NmrDataset::from_slice(slice, name.clone()))); + let dataset = match NmrDataset::load_with_pipeline( + source_data, + Some(AxisPipeline { steps: Vec::new() }), + Some(false), + ) { + Ok(dataset) => dataset, + Err(error) => { + self.session.status = format!("Slice extraction failed: {error}"); + return; + } + }; + let mut dataset = dataset; + dataset.name = Some(name.clone()); + let mut ds = Dataset::Nmr(Box::new(dataset)); ds.set_lineage(Some(DatasetLineage::new( kind, [self.doc.datasets[source].resource_id()], @@ -227,7 +235,9 @@ mod tests { domain: plotx_io::Domain::Frequency, values: vec![Complex64::new(1.0, 0.0), Complex64::new(0.5, 0.0)], nucleus: "1H".to_owned(), - observe_freq_mhz: 400.0, + observe_freq_mhz: Some(400.0), + reference_freq_mhz: Some(400.0), + unit: nmr::axis::AxisUnit::Ppm, position: Some(3.0), position_domain: plotx_io::Domain::Frequency, } @@ -236,16 +246,26 @@ mod tests { #[test] fn slice_and_projection_insertions_record_the_source() { let mut app = PlotxApp::new(); - app.doc - .datasets - .push(Dataset::Nmr(Box::new(NmrDataset::from_slice( - slice(), - "source".to_owned(), - )))); + app.doc.datasets.push(Dataset::Nmr(Box::new( + NmrDataset::from_slice(slice(), "source".to_owned()).unwrap(), + ))); - app.insert_slice_dataset(slice(), "slice".to_owned(), 0, DerivationKind::Slice); app.insert_slice_dataset( - slice(), + app.doc.datasets[0] + .as_nmr() + .unwrap() + .native_processed + .clone(), + "slice".to_owned(), + 0, + DerivationKind::Slice, + ); + app.insert_slice_dataset( + app.doc.datasets[0] + .as_nmr() + .unwrap() + .native_processed + .clone(), "projection".to_owned(), 0, DerivationKind::Projection, @@ -269,11 +289,11 @@ mod tests { #[test] fn frequency_domain_slices_share_the_factory_group_delay_default() { - let dataset = NmrDataset::from_slice(slice(), "slice".to_owned()); + let dataset = NmrDataset::from_slice(slice(), "slice".to_owned()).unwrap(); assert!(!dataset.group_delay_correct); assert_eq!( dataset.group_delay_correct, - default_group_delay_correct(dataset.data.domain) + default_group_delay_correct(&dataset.data) ); } @@ -282,8 +302,8 @@ mod tests { let mut time = slice(); time.coordinates = vec![0.0, 0.002]; time.domain = plotx_io::Domain::Time; - let dataset = NmrDataset::from_slice(time, "FID slice".to_owned()); - assert_eq!(dataset.data.domain, plotx_io::Domain::Time); + let dataset = NmrDataset::from_slice(time, "FID slice".to_owned()).unwrap(); + assert_eq!(dataset.input_domain(), plotx_io::Domain::Time); assert_eq!(dataset.output_domain(), plotx_io::Domain::Time); assert_eq!(dataset.time_trace().unwrap().time_s, vec![0.0, 0.002]); } diff --git a/crates/core/src/state/compute.rs b/crates/core/src/state/compute.rs index ca051496..3765ede5 100644 --- a/crates/core/src/state/compute.rs +++ b/crates/core/src/state/compute.rs @@ -1,5 +1,5 @@ +use nmr::CancellationToken; use std::collections::HashMap; -use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{Receiver, Sender}; use std::sync::{Arc, Mutex}; use std::thread; @@ -8,11 +8,12 @@ use std::time::{Duration, Instant}; use plotx_analysis::diffusion::{DiffusionMap, diffusion_map_cancellable}; use plotx_analysis::ilt::{IltResult, ilt_map_cancellable}; use plotx_figure::Figure; -use plotx_io::{DiffusionMeta, NmrData, NmrData2D}; +use plotx_io::{DiffusionMeta, NmrData, nmr_view::NmrSource}; use plotx_processing::{ Params2D, Processed2D, StackSpectrum, craft::{CraftInvocation, CraftResult, process_craft_cancellable}, - process_2d_cancellable, reapply_2d_cancellable, + nmr_bridge::{DelayPolicy, RecipeRange}, + nmr_execution::{NusRequest, Output2D, execute_2d}, }; use super::{ @@ -93,7 +94,7 @@ enum Job { generation: u64, dataset: DatasetId, epoch: u64, - token: Arc, + token: CancellationToken, stack: Arc, b_factors: Vec, d_grid: Vec, @@ -110,7 +111,7 @@ enum Job { generation: u64, dataset: DatasetId, epoch: u64, - token: Arc, + token: CancellationToken, stack: Arc, values: Vec, meta: DiffusionMeta, @@ -121,7 +122,7 @@ enum Job { generation: u64, dataset: DatasetId, epoch: u64, - token: Arc, + token: CancellationToken, data: Arc, invocation: Box, parent_run: Option, @@ -129,7 +130,7 @@ enum Job { Process2D { version: FieldVersion, dataset: DatasetId, - token: Arc, + token: CancellationToken, input: ProcessingInput, params: Params2D, fields: Vec, @@ -150,9 +151,15 @@ enum ProcessingInputKind { Reapply, } +pub(crate) struct Full2DInput { + pub source: NmrSource, + pub delay: DelayPolicy, + pub nus: Option, +} + enum ProcessingInput { - Full(Arc), - Reapply(Processed2D), + Full(Full2DInput), + Reapply(NmrSource), } impl ProcessingInput { @@ -175,7 +182,7 @@ struct DeferredProcessing { struct ActiveJob { generation: u64, started_at: Instant, - token: Arc, + token: CancellationToken, processing_input: Option, } @@ -217,11 +224,16 @@ pub enum Done { Processing2D { version: FieldVersion, dataset: DatasetId, - base: Option, - processed: Processed2D, + base: Option, + processed: Output2D, fields: Vec, params: Params2D, }, + Processing2DFailed { + version: FieldVersion, + dataset: DatasetId, + message: String, + }, EstimateField { key: EstimateKey, result: EstimateResult, @@ -316,13 +328,13 @@ impl ComputeService { return Err(EnqueueError::Busy(kind)); } let generation = self.next_generation(dataset, ComputeKind::Ilt); - let token = Arc::new(AtomicBool::new(false)); + let token = CancellationToken::new(); self.active.insert( (dataset, ComputeKind::Ilt), ActiveJob { generation, started_at: Instant::now(), - token: Arc::clone(&token), + token: token.clone(), processing_input: None, }, ); @@ -366,13 +378,13 @@ impl ComputeService { return Err(EnqueueError::Busy(kind)); } let generation = self.next_generation(dataset, ComputeKind::Dosy); - let token = Arc::new(AtomicBool::new(false)); + let token = CancellationToken::new(); self.active.insert( (dataset, ComputeKind::Dosy), ActiveJob { generation, started_at: Instant::now(), - token: Arc::clone(&token), + token: token.clone(), processing_input: None, }, ); @@ -409,13 +421,13 @@ impl ComputeService { return Err(EnqueueError::Busy(kind)); } let generation = self.next_generation(dataset, ComputeKind::Craft); - let token = Arc::new(AtomicBool::new(false)); + let token = CancellationToken::new(); self.active.insert( (dataset, ComputeKind::Craft), ActiveJob { generation, started_at: Instant::now(), - token: Arc::clone(&token), + token: token.clone(), processing_input: None, }, ); @@ -444,7 +456,7 @@ impl ComputeService { &mut self, dataset: DatasetId, fields: &[ProcessingField], - data: Arc, + data: Full2DInput, params: Params2D, ) -> Result, FieldEnqueueError> { self.request_2d(dataset, fields, ProcessingInput::Full(data), params) @@ -456,7 +468,7 @@ impl ComputeService { &mut self, dataset: DatasetId, fields: &[ProcessingField], - base: Processed2D, + base: NmrSource, params: Params2D, ) -> Result, FieldEnqueueError> { self.request_2d(dataset, fields, ProcessingInput::Reapply(base), params) @@ -533,14 +545,14 @@ impl ComputeService { let Some(request) = self.deferred_processing.remove(&dataset) else { continue; }; - let token = Arc::new(AtomicBool::new(false)); + let token = CancellationToken::new(); let input_kind = request.input.kind(); self.active.insert( (dataset, ComputeKind::Processing2D), ActiveJob { generation: request.version.0, started_at: Instant::now(), - token: Arc::clone(&token), + token: token.clone(), processing_input: Some(input_kind), }, ); @@ -586,6 +598,7 @@ impl ComputeService { | Done::Craft { .. } | Done::CraftFailed { .. } | Done::Processing2D { .. } + | Done::Processing2DFailed { .. } | Done::Cancelled { .. } | Done::Failed { .. } => {} } @@ -596,12 +609,15 @@ impl ComputeService { .active .get(&(dataset, kind)) .filter(|active| active.generation == generation); + if kind == ComputeKind::Processing2D && matching_active.is_none() { + continue; + } // A worker can send success immediately before cancellation. Check // the shared token again on the receiving side so explicit cancel, // Full/Reapply replacement, and dataset invalidation cannot install // that already-queued success. let cancelled_after_send = - matching_active.is_some_and(|active| active.token.load(Ordering::Relaxed)); + matching_active.is_some_and(|active| active.token.is_cancelled()); if matching_active.is_some() { self.active.remove(&(dataset, kind)); } @@ -621,9 +637,9 @@ impl ComputeService { } pub fn progress(&self, dataset: DatasetId, kind: ComputeKind) -> Option { - self.active.get(&(dataset, kind)).and_then(|active| { - (!active.token.load(Ordering::Relaxed)).then(|| active.started_at.elapsed()) - }) + self.active + .get(&(dataset, kind)) + .and_then(|active| (!active.token.is_cancelled()).then(|| active.started_at.elapsed())) } /// Return the active DOSY computation regardless of which method the UI is @@ -645,7 +661,7 @@ impl ComputeService { self.active .iter() .find(|((active_dataset, _), active)| { - *active_dataset == dataset && !active.token.load(Ordering::Relaxed) + *active_dataset == dataset && !active.token.is_cancelled() }) .map(|((_, kind), _)| *kind) } @@ -671,7 +687,8 @@ impl ComputeService { if compatible_reapply { continue; } - let running = !active.token.swap(true, Ordering::Relaxed); + let running = !active.token.is_cancelled(); + active.token.cancel(); if running && *kind != ComputeKind::Processing2D { aborted.push(*kind); } @@ -685,7 +702,7 @@ impl ComputeService { pub fn cancel(&mut self, dataset: DatasetId, kind: ComputeKind) -> bool { let mut cancelled = false; if let Some(active) = self.active.get(&(dataset, kind)) { - active.token.store(true, Ordering::Relaxed); + active.token.cancel(); cancelled = true; } if kind == ComputeKind::Processing2D && self.deferred_processing.remove(&dataset).is_some() @@ -745,6 +762,9 @@ fn done_identity(done: &Done) -> Option<(DatasetId, ComputeKind, u64)> { } => Some((*dataset, ComputeKind::Craft, *generation)), Done::Processing2D { dataset, version, .. + } + | Done::Processing2DFailed { + dataset, version, .. } => Some((*dataset, ComputeKind::Processing2D, version.0)), Done::Cancelled { dataset, diff --git a/crates/core/src/state/compute/tests.rs b/crates/core/src/state/compute/tests.rs index 14de6ccb..24065107 100644 --- a/crates/core/src/state/compute/tests.rs +++ b/crates/core/src/state/compute/tests.rs @@ -1,12 +1,12 @@ use super::*; use num_complex::Complex64; -use plotx_io::{Dim, Domain, QuadMode}; -use plotx_processing::{PhaseParams, Preset2D, ProcessingStep, StepKind, process_2d}; +use plotx_io::{Dim, Domain, NmrData2D, QuadMode}; +use plotx_processing::{PhaseParams, Preset2D, ProcessingStep, StepKind}; fn dataset(value: u128) -> DatasetId { DatasetId::from_uuid(uuid::Uuid::from_u128(value)) } -fn data_2d() -> Arc { +fn data_2d() -> Full2DInput { let dim = Dim { spectral_width_hz: 1000.0, observe_freq_mhz: 100.0, @@ -14,7 +14,7 @@ fn data_2d() -> Arc { nucleus: "X".into(), group_delay: 0.0, }; - Arc::new(NmrData2D { + let data = NmrData2D { data: (0..16) .map(|i| Complex64::new((i + 1) as f64, 0.0)) .collect(), @@ -30,7 +30,13 @@ fn data_2d() -> Arc { diffusion: None, nus: None, source: "test".into(), - }) + }; + let source = plotx_io::nmr_series::NmrSeriesSource::try_from(data).unwrap(); + Full2DInput { + source: source.source_dataset().clone(), + delay: DelayPolicy::AxisEvidence, + nus: None, + } } fn stack_spectrum() -> Arc { @@ -40,7 +46,8 @@ fn stack_spectrum() -> Arc { traces: vec![vec![Complex64::new(1.0, 0.0); 1]; 3], direct: plotx_processing::AxisMeta { nucleus: "X".into(), - observe_freq_mhz: 100.0, + observe_freq_mhz: Some(100.0), + unit: Some(nmr::axis::AxisUnit::Ppm), }, source: "test".into(), }) @@ -140,15 +147,24 @@ fn reapply_to_reapply_keeps_the_active_job_and_replaces_the_deferred_recipe() { let mut service = ComputeService::new(); let preset = Preset2D::Cosy; let mut first = Params2D::default_for(preset); - let base = process_2d(&data_2d(), &first); + let base = execute_2d( + &data_2d().source, + &first, + DelayPolicy::AxisEvidence, + RecipeRange::Base, + None, + &mut nmr::ExecutionContext::default(), + ) + .unwrap() + .source; - let token = Arc::new(AtomicBool::new(false)); + let token = CancellationToken::new(); service.active.insert( (dataset(0), ComputeKind::Processing2D), ActiveJob { generation: 10, started_at: Instant::now(), - token: Arc::clone(&token), + token: token.clone(), processing_input: Some(ProcessingInputKind::Reapply), }, ); @@ -162,26 +178,26 @@ fn reapply_to_reapply_keeps_the_active_job_and_replaces_the_deferred_recipe() { service .request_2d_reapply(dataset(0), &fields, base.clone(), first) .unwrap(); - assert!(!token.load(Ordering::Relaxed)); + assert!(!token.is_cancelled()); let first_version = service.deferred_processing[&dataset(0)].version; service .request_2d_reapply(dataset(0), &fields, base, Params2D::default_for(preset)) .unwrap(); - assert!(!token.load(Ordering::Relaxed)); + assert!(!token.is_cancelled()); assert!(service.deferred_processing[&dataset(0)].version > first_version); } #[test] fn any_full_retransform_cancels_an_active_reapply() { let mut service = ComputeService::new(); - let token = Arc::new(AtomicBool::new(false)); + let token = CancellationToken::new(); service.active.insert( (dataset(0), ComputeKind::Processing2D), ActiveJob { generation: 10, started_at: Instant::now(), - token: Arc::clone(&token), + token: token.clone(), processing_input: Some(ProcessingInputKind::Reapply), }, ); @@ -196,7 +212,7 @@ fn any_full_retransform_cancels_an_active_reapply() { Params2D::default_for(preset), ) .unwrap(); - assert!(token.load(Ordering::Relaxed)); + assert!(token.is_cancelled()); assert!(matches!( service.deferred_processing[&dataset(0)].input, ProcessingInput::Full(_) @@ -320,7 +336,8 @@ fn cancelling_processing_discards_its_result_and_releases_the_service() { #[test] fn cancelled_ilt_job_reports_acknowledgement_without_a_result() { - let token = Arc::new(AtomicBool::new(true)); + let token = CancellationToken::new(); + token.cancel(); let stack = stack_spectrum(); let done = run_job(Job::Ilt { generation: 7, diff --git a/crates/core/src/state/compute_worker.rs b/crates/core/src/state/compute_worker.rs index cee37552..6a89da45 100644 --- a/crates/core/src/state/compute_worker.rs +++ b/crates/core/src/state/compute_worker.rs @@ -20,7 +20,7 @@ pub(super) fn run_job(job: Job) -> Done { nucleus, source, } => { - let cancelled = || token.load(Ordering::Relaxed); + let cancelled = || token.is_cancelled(); let provenance = ilt_provenance(&stack, &values, &meta, params); match ilt_map_cancellable(&*stack, &b_factors, &d_grid, lambda, &cancelled) { Some(result) if !cancelled() => { @@ -62,7 +62,7 @@ pub(super) fn run_job(job: Job) -> Done { nucleus, source, } => { - let cancelled = || token.load(Ordering::Relaxed); + let cancelled = || token.is_cancelled(); let provenance = mono_exp_provenance(&stack, &values, &meta); match diffusion_map_cancellable(&*stack, &values, &meta, MONO_EXP_SNR_FRAC, &cancelled) { @@ -102,7 +102,7 @@ pub(super) fn run_job(job: Job) -> Done { invocation, parent_run, } => { - let cancelled = || token.load(Ordering::Relaxed); + let cancelled = || token.is_cancelled(); match process_craft_cancellable(&data, &invocation, &cancelled) { Ok(result) if !cancelled() => Done::Craft { generation, @@ -133,28 +133,61 @@ pub(super) fn run_job(job: Job) -> Done { params, fields, } => { - let cancelled = || token.load(Ordering::Relaxed); - let (base, processed) = match input { - ProcessingInput::Full(data) => { - let Some(base) = process_2d_cancellable(&data, ¶ms, &cancelled) else { - return cancelled_done(version.0, dataset); - }; - let Some(processed) = reapply_2d_cancellable(&base, ¶ms, &cancelled) else { - return cancelled_done(version.0, dataset); - }; - (Some(base), processed) + let cancelled = || token.is_cancelled(); + let mut work = plotx_processing::nmr_execution::processing_2d_work_ledger(); + let mut context = + nmr::ExecutionContext::new(&mut work).with_cancellation(token.clone()); + let result = (|| match input { + ProcessingInput::Full(input) => { + let base = execute_2d( + &input.source, + ¶ms, + input.delay, + RecipeRange::Base, + input.nus, + &mut context, + )?; + let processed = execute_2d( + &base.source, + ¶ms, + DelayPolicy::Disabled, + RecipeRange::Frequency, + None, + &mut context, + )?; + Ok((Some(base), processed)) } ProcessingInput::Reapply(base) => { - let Some(processed) = reapply_2d_cancellable(&base, ¶ms, &cancelled) else { - return cancelled_done(version.0, dataset); + let processed = execute_2d( + &base, + ¶ms, + DelayPolicy::Disabled, + RecipeRange::Frequency, + None, + &mut context, + )?; + Ok((None, processed)) + } + })(); + let (base, processed) = match result { + Ok(output) => output, + Err(error) + if plotx_processing::nmr_execution::ExecutionError::is_cancelled(&error) => + { + return cancelled_done(version.0, dataset); + } + Err(error) => { + return Done::Processing2DFailed { + version, + dataset, + message: error.to_string(), }; - (None, processed) } }; if cancelled() { return cancelled_done(version.0, dataset); } - let fields = processed_field_artifacts(&processed, &fields); + let fields = processed_field_artifacts(&processed.view, &fields); Done::Processing2D { version, dataset, diff --git a/crates/core/src/state/contour_budget_tests.rs b/crates/core/src/state/contour_budget_tests.rs index fc43dc34..1cb8c1fa 100644 --- a/crates/core/src/state/contour_budget_tests.rs +++ b/crates/core/src/state/contour_budget_tests.rs @@ -12,8 +12,8 @@ use super::compute_field::run_build_contour; use crate::state::{ AxisSampling, ChartSpec, ContourGeometryCacheKey, DataBinding, DataDomain, Dataset, DatasetId, - FieldId, FieldRef, FieldVersion, FiniteF64, Nmr2DDataset, PlotxApp, ResolvedContourLevels, - ScalarGrid2D, StackSpec, VersionedFieldRef, + FieldId, FieldRef, FieldVersion, FiniteF64, PlotxApp, ResolvedContourLevels, ScalarGrid2D, + StackSpec, VersionedFieldRef, }; use num_complex::Complex64; use plotx_figure::{ @@ -180,24 +180,27 @@ fn dense_dataset(label: &str, values: &[f32]) -> Dataset { nucleus: nucleus.to_owned(), group_delay: 0.0, }; - Dataset::Nmr2D(Box::new(Nmr2DDataset::load(NmrData2D { - data: values - .iter() - .map(|value| Complex64::new(f64::from(*value), 0.0)) - .collect(), - rows: SIDE, - cols: SIDE, - domain: Domain::Frequency, - direct: dimension("1H"), - indirect: dimension("13C"), - quad: QuadMode::Complex, - indirect_conjugate: false, - experiment: None, - pseudo_axis: None, - diffusion: None, - nus: None, - source: label.to_owned(), - }))) + Dataset::Nmr2D(Box::new( + crate::nmr_test_support::load_2d(NmrData2D { + data: values + .iter() + .map(|value| Complex64::new(f64::from(*value), 0.0)) + .collect(), + rows: SIDE, + cols: SIDE, + domain: Domain::Frequency, + direct: dimension("1H"), + indirect: dimension("13C"), + quad: QuadMode::Complex, + indirect_conjugate: false, + experiment: None, + pseudo_axis: None, + diffusion: None, + nus: None, + source: label.to_owned(), + }) + .unwrap(), + )) } #[test] diff --git a/crates/core/src/state/craft.rs b/crates/core/src/state/craft.rs index a2da6781..7b54d5b4 100644 --- a/crates/core/src/state/craft.rs +++ b/crates/core/src/state/craft.rs @@ -1,12 +1,11 @@ use super::{FloatSeries, NmrDataset, TableDataset, materialized_float_series_table}; -use plotx_io::NmrData; +use plotx_io::nmr_view::NmrSource; use plotx_processing::craft::{ 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}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct CraftRunId(pub u64); @@ -46,7 +45,7 @@ impl StoredCraftRun { } pub fn from_result( id: CraftRunId, - data: &NmrData, + data: &NmrSource, invocation: CraftInvocation, parent_run: Option, result: CraftResult, @@ -68,20 +67,27 @@ impl StoredCraftRun { } } - pub fn is_stale_for(&self, data: &NmrData, reference: CraftReference) -> bool { + pub fn is_stale_for(&self, data: &NmrSource, reference: Option) -> bool { self.provenance.input_sha256 != craft_input_sha256(data) - || self.provenance.invocation.reference != reference + || Some(self.provenance.invocation.reference) != reference } } impl NmrDataset { /// Reference context used by analyses that fit the original FID but report /// chemical shifts on the processed spectrum's visible axis. - pub fn craft_reference(&self) -> CraftReference { - CraftReference::new( - self.data.carrier_ppm, + pub fn craft_reference(&self) -> Option { + let raw = self.data.dataset().as_raw()?; + let reference = raw + .descriptor() + .axes() + .first()? + .chemical_shift_reference()?; + Some(CraftReference::new( + reference.carrier_ppm(), + reference.reference_frequency_mhz(), self.pipeline.chemical_shift_reference_offset_ppm(), - ) + )) } pub fn allocate_craft_run_id(&mut self) -> CraftRunId { @@ -114,27 +120,14 @@ impl NmrDataset { } } -pub fn craft_input_sha256(data: &NmrData) -> String { - let mut digest = Sha256::new(); - digest.update(b"plotx.craft.input.v1\0"); - digest.update([match data.domain { - plotx_io::Domain::Time => 0, - plotx_io::Domain::Frequency => 1, - }]); - for value in [ - data.spectral_width_hz, - data.observe_freq_mhz, - data.carrier_ppm, - data.group_delay, - ] { - digest.update(value.to_le_bytes()); - } - digest.update((data.points.len() as u64).to_le_bytes()); - for point in &data.points { - digest.update(point.re.to_le_bytes()); - digest.update(point.im.to_le_bytes()); - } - format!("{:x}", digest.finalize()) +pub fn craft_input_sha256(data: &NmrSource) -> String { + data.dataset() + .canonical_digests() + .dataset() + .as_bytes() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() } pub fn craft_component_table(run: &StoredCraftRun) -> Result { diff --git a/crates/core/src/state/craft_fields.rs b/crates/core/src/state/craft_fields.rs index 23b0e05c..87140cdc 100644 --- a/crates/core/src/state/craft_fields.rs +++ b/crates/core/src/state/craft_fields.rs @@ -98,7 +98,7 @@ impl NmrDataset { .chain(self.craft_field_specs().map(CraftFieldSpec::key)) .collect::>(); self.field_catalog - .reconcile_keys(keys, &self.data.source, None); + .reconcile_keys(keys, self.data.source(), None); self.attach_craft_trace_collections(); } @@ -116,7 +116,7 @@ impl NmrDataset { continue; }; let collection = - TraceCollectionId::derived(self.data.source.as_bytes(), key.as_bytes()); + TraceCollectionId::derived(self.data.source().as_bytes(), key.as_bytes()); let items = run .region_summaries .iter() @@ -192,7 +192,7 @@ impl NmrDataset { let mut figure = Figure::new( "", Axis::new( - crate::figures::axis_label(&self.data.nucleus), + crate::figures::axis_label(self.data.nucleus()), observed.ppm_bounds().0, observed.ppm_bounds().1, ) @@ -241,7 +241,7 @@ impl NmrDataset { CraftFieldKind::Residual | CraftFieldKind::Groups => { let (x, y) = self.craft_curve(spec)?; Some(single_curve_figure( - &self.data.nucleus, + self.data.nucleus(), if spec.kind == CraftFieldKind::Residual { "Complex residual" } else { @@ -264,7 +264,7 @@ impl NmrDataset { self.craft_run(spec.run)?; let spectrum = self.cached_model_spectrum(spec.run, Some(region))?; single_curve_figure( - &self.data.nucleus, + self.data.nucleus(), &label, spectrum.ppm, channel_values(&spectrum.values, spec.channel), @@ -299,7 +299,9 @@ impl NmrDataset { .derived_plan .reconstruction_points .max(1), - ); + ) + .map_err(|error| eprintln!("CRAFT model display: {error}")) + .ok()?; if let Ok(mut cache) = self.craft_spectrum_cache.lock() { cache.models.insert((run, region), spectrum.clone()); } @@ -313,19 +315,25 @@ impl NmrDataset { return Some(spectrum.clone()); } let stored = self.craft_run(run)?; + let data = self + .data + .craft_fid() + .map_err(|error| eprintln!("CRAFT residual display: {error}")) + .ok()?; let model = synthesize_craft_fid( &stored.components, - self.data.points.len(), - self.data.spectral_width_hz, + data.points.len(), + data.spectral_width_hz, ); - let residual = self - .data + let residual = data .points .iter() .zip(model) .map(|(observed, model)| observed - model) .collect(); - let spectrum = transformed_points(self, residual); + let spectrum = transformed_points(self, residual) + .map_err(|error| eprintln!("CRAFT residual display: {error}")) + .ok()?; if let Ok(mut cache) = self.craft_spectrum_cache.lock() { cache.residuals.insert(run, spectrum.clone()); } @@ -337,22 +345,46 @@ fn transformed_fid( dataset: &NmrDataset, components: &[plotx_processing::craft::CraftComponent], point_count: usize, -) -> plotx_processing::Spectrum { +) -> Result { + let data = dataset + .data + .craft_fid() + .map_err(|error| error.to_string())?; transformed_points( dataset, - synthesize_craft_fid(components, point_count, dataset.data.spectral_width_hz), + synthesize_craft_fid(components, point_count, data.spectral_width_hz), ) } fn transformed_points( dataset: &NmrDataset, points: Vec, -) -> plotx_processing::Spectrum { - let mut data = dataset.data.clone(); - data.points = points; - let base = - plotx_processing::transform_base(&data, dataset.pipeline(), dataset.group_delay_correct); - plotx_processing::reapply(&base, dataset.pipeline()) +) -> Result { + use plotx_processing::nmr_bridge::{DelayPolicy, RecipeRange}; + let mut view = dataset + .data + .craft_fid() + .map_err(|error| error.to_string())?; + view.points = points; + let source = + plotx_io::nmr_view::NmrSource::try_from(view).map_err(|error| error.to_string())?; + let output = plotx_processing::nmr_execution::execute_1d( + &source, + dataset.pipeline(), + if dataset.group_delay_correct { + DelayPolicy::AxisEvidence + } else { + DelayPolicy::Disabled + }, + RecipeRange::All, + &mut nmr::ExecutionContext::default(), + ) + .map_err(|error| error.to_string())?; + output + .view + .as_frequency() + .cloned() + .ok_or_else(|| "CRAFT spectrum display requires an FFT".into()) } fn channel_values(values: &[num_complex::Complex64], channel: CraftSpectrumChannel) -> Vec { diff --git a/crates/core/src/state/data_import.rs b/crates/core/src/state/data_import.rs new file mode 100644 index 00000000..8dd3c935 --- /dev/null +++ b/crates/core/src/state/data_import.rs @@ -0,0 +1,200 @@ +//! Bounded, document-scoped background data preparation. +use super::{Dataset, PlotxApp}; +use std::{collections::VecDeque, path::PathBuf, sync::mpsc}; + +pub(super) struct PreparedImport { + pub dataset: Dataset, + pub source: String, + pub format: plotx_io::DataFormat, + pub warnings: Vec, +} + +#[cfg(test)] +#[path = "data_import_tests.rs"] +mod tests; + +impl PreparedImport { + pub(super) fn new(loaded: plotx_io::LoadResult, equal_scale: bool) -> Result { + let (dataset, source) = crate::workflow::dataset_from_loaded_acquisition( + loaded.acquisition, + loaded.acquisition_identity, + equal_scale, + ) + .map_err(|error| error.to_string())?; + Ok(Self { + dataset, + source, + format: loaded.format, + warnings: loaded.warnings, + }) + } +} + +type Discover = Box Result, String> + Send>; +struct Request { + recent: PathBuf, + discover: Discover, + equal_scale: bool, +} + +enum Event { + Started(PathBuf, usize), + Item(PathBuf, Result), + Finished, +} + +struct Job { + receiver: mpsc::Receiver, + recent: PathBuf, + loaded: usize, + failed: usize, +} + +/// Dropping a session disconnects the bounded channel. The worker then exits +/// without joining the UI thread or publishing results into the next document. +#[derive(Default)] +pub struct DataImports { + pending: VecDeque, + active: Option, +} + +impl DataImports { + pub fn is_pending(&self) -> bool { + self.active.is_some() || !self.pending.is_empty() + } +} + +impl PlotxApp { + /// Discovery, parsing, default processing and figure preparation run on one + /// worker. Additional gestures queue behind it instead of multiplying RAM use. + pub fn queue_data_import( + &mut self, + recent: PathBuf, + discover: impl FnOnce() -> Result, String> + Send + 'static, + ) { + self.session.data_imports.pending.push_back(Request { + recent, + discover: Box::new(discover), + equal_scale: self.settings.general.equal_scale_homonuclear_2d_imports, + }); + self.session.status = "Data import queued; you can continue working.".into(); + } + + /// Call once per frame. Never drain the channel: each insertion gets its own + /// frame even when the worker produces many small acquisitions immediately. + pub fn poll_data_import(&mut self) -> bool { + let mut imports = std::mem::take(&mut self.session.data_imports); + if imports.active.is_none() + && let Some(request) = imports.pending.pop_front() + { + let (sender, receiver) = mpsc::sync_channel(1); + let recent = request.recent.clone(); + match std::thread::Builder::new() + .name("data-import".into()) + .spawn(move || { + let paths = match (request.discover)() { + Ok(paths) => paths, + Err(error) => { + if sender + .send(Event::Item(request.recent, Err(error))) + .is_err() + { + return; + } + if sender.send(Event::Finished).is_err() { + return; // The owning document was closed. + } + return; + } + }; + let total = paths.len(); + for path in paths { + if sender.send(Event::Started(path.clone(), total)).is_err() { + return; + } + let result = plotx_io::load_path(&path) + .map_err(|error| error.to_string()) + .and_then(|loaded| PreparedImport::new(loaded, request.equal_scale)); + if sender.send(Event::Item(path, result)).is_err() { + return; + } + } + // A disconnected receiver means the document was closed. + if sender.send(Event::Finished).is_err() { + // Document closure is normal cancellation, not an import failure. + } + }) { + Ok(_) => { + imports.active = Some(Job { + receiver, + recent, + loaded: 0, + failed: 0, + }) + } + Err(error) => { + self.install_prepared_import( + &recent, + Err(format!("Could not start import worker: {error}")), + ); + } + } + } + if let Some(job) = imports.active.as_mut() { + match job.receiver.try_recv() { + Ok(Event::Started(path, total)) => { + self.session.status = format!( + "Importing {}/{}: {} ({} loaded, {} failed)", + job.loaded + job.failed + 1, + total, + path.display(), + job.loaded, + job.failed + ); + } + Ok(Event::Item(path, result)) => { + // Completion must not steal the user's current page or Data + // selection while they edit something else during the batch. + let active_canvas = self.session.active_canvas; + let selection = self.session.ui.data_selection.clone(); + let view = self.session.view; + if self.install_prepared_import(&path, result) { + job.loaded += 1; + } else { + job.failed += 1; + } + self.session.active_canvas = active_canvas; + self.session.ui.data_selection = selection; + self.session.view = view; + self.session.status = format!( + "Importing: {} loaded, {} failed. {}", + job.loaded, + job.failed, + path.display() + ); + } + Ok(Event::Finished) => { + self.session.status = format!( + "Import complete: {} loaded, {} failed.", + job.loaded, job.failed + ); + if job.loaded > 0 { + self.note_recent_file(&job.recent); + } + imports.active = None; + } + Err(mpsc::TryRecvError::Empty) => {} + Err(mpsc::TryRecvError::Disconnected) => { + self.install_prepared_import( + &job.recent, + Err("The import worker stopped unexpectedly; retry the import.".into()), + ); + imports.active = None; + } + } + } + let busy = imports.is_pending(); + self.session.data_imports = imports; + busy + } +} diff --git a/crates/core/src/state/data_import_tests.rs b/crates/core/src/state/data_import_tests.rs new file mode 100644 index 00000000..503e8a44 --- /dev/null +++ b/crates/core/src/state/data_import_tests.rs @@ -0,0 +1,131 @@ +use super::*; +use crate::state::XrdDataset; + +fn prepared() -> PreparedImport { + PreparedImport { + dataset: Dataset::Xrd(Box::new(XrdDataset::load(plotx_io::XrdData { + two_theta_deg: vec![1.0, 2.0, 3.0], + intensity: vec![2.0, 4.0, 2.0], + attenuation: None, + source: "sample.raw".into(), + instrument: None, + target: None, + wavelength_angstrom: None, + voltage_kv: None, + current_ma: None, + scan_step_deg: None, + scan_speed_deg_min: None, + }))), + source: "sample.raw".into(), + format: plotx_io::DataFormat::Xrd(plotx_io::XrdFormat::RigakuRaw), + warnings: Vec::new(), + } +} + +fn app_with_channel() -> (PlotxApp, mpsc::Sender) { + let mut app = PlotxApp::new_with_settings(Default::default()); + let (sender, receiver) = mpsc::channel(); + app.session.data_imports.active = Some(Job { + receiver, + recent: "batch".into(), + loaded: 0, + failed: 0, + }); + (app, sender) +} + +#[test] +fn commits_only_one_item_per_poll_and_preserves_selection_and_undo() { + let (mut app, sender) = app_with_channel(); + assert!(app.install_prepared_import(std::path::Path::new("existing"), Ok(prepared()))); + let selected = app.session.ui.data_selection.clone(); + let active = app.session.active_canvas; + for _ in 0..2 { + sender + .send(Event::Item("next".into(), Ok(prepared()))) + .unwrap(); + } + assert!(app.poll_data_import()); + assert_eq!(app.doc.datasets.len(), 2); + assert_eq!(app.session.active_canvas, active); + assert_eq!(app.session.ui.data_selection, selected); + assert!(app.poll_data_import()); + assert_eq!(app.doc.datasets.len(), 3); + app.undo(); + assert_eq!(app.doc.datasets.len(), 2); + app.redo(); + assert_eq!(app.doc.datasets.len(), 3); +} + +#[test] +fn failed_item_is_reported_and_does_not_prevent_later_success() { + let (mut app, sender) = app_with_channel(); + sender + .send(Event::Item("bad".into(), Err("broken source".into()))) + .unwrap(); + sender + .send(Event::Item("good".into(), Ok(prepared()))) + .unwrap(); + app.poll_data_import(); + assert!(app.doc.datasets.is_empty()); + assert_eq!( + app.session + .operation_history + .operations() + .next_back() + .unwrap() + .outcome, + crate::operation::OperationOutcome::Failure + ); + app.poll_data_import(); + assert_eq!(app.doc.datasets.len(), 1); + assert!(app.session.status.contains("1 loaded, 1 failed")); +} + +#[test] +fn disconnected_worker_reaches_user_feedback() { + let (mut app, sender) = app_with_channel(); + drop(sender); + assert!(!app.poll_data_import()); + assert!(app.session.status.contains("stopped unexpectedly")); + assert_eq!(app.session.operation_history.operations().count(), 1); +} + +#[test] +fn document_swap_discards_ready_results_and_queued_requests() { + let (mut app, sender) = app_with_channel(); + sender + .send(Event::Item("old".into(), Ok(prepared()))) + .unwrap(); + app.queue_data_import("old queued".into(), || { + panic!("old request must be dropped") + }); + app.start_new_project(); + assert!(sender.send(Event::Finished).is_err()); + assert!(!app.poll_data_import()); + assert!(app.doc.datasets.is_empty()); +} + +#[test] +fn discovery_runs_off_thread_and_poll_does_not_wait_for_it() { + let mut app = PlotxApp::new_with_settings(Default::default()); + let main_thread = std::thread::current().id(); + let (entered, started) = mpsc::channel(); + let (release, wait) = mpsc::channel(); + app.queue_data_import("batch".into(), move || { + entered.send(std::thread::current().id()).unwrap(); + wait.recv().unwrap(); + Ok(vec![]) + }); + assert!(app.poll_data_import()); + assert_ne!( + started + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(), + main_thread + ); + assert!(app.poll_data_import()); + app.start_new_project(); + release.send(()).unwrap(); + assert!(!app.poll_data_import()); +} diff --git a/crates/core/src/state/dataset_trace.rs b/crates/core/src/state/dataset_trace.rs index 9704686b..64768854 100644 --- a/crates/core/src/state/dataset_trace.rs +++ b/crates/core/src/state/dataset_trace.rs @@ -23,6 +23,9 @@ impl Dataset { data.craft_group_figure(spec, region, label) } Self::Nmr2D(data) => { + if data.field_catalog.id_for_key(data.stack_field_key()) != Some(field) { + return None; + } let plotx_processing::Processed2D::Stack(stack) = &data.processed else { return None; }; @@ -44,13 +47,7 @@ impl Dataset { y0 = -0.5; y1 = 0.5; } - let x_name = if stack.direct_domain == plotx_io::Domain::Frequency { - crate::figures::axis_label(&stack.direct.nucleus) - } else { - "Time (s)".to_owned() - }; - let x_axis = plotx_figure::Axis::new(x_name, x0, x1) - .reversed(stack.direct_domain == plotx_io::Domain::Frequency); + let x_axis = crate::figures::nmr_axis(&stack.direct, x0, x1); Some( plotx_figure::Figure::new( "", diff --git a/crates/core/src/state/datasets.rs b/crates/core/src/state/datasets.rs index ed4cfe5b..69fb9feb 100644 --- a/crates/core/src/state/datasets.rs +++ b/crates/core/src/state/datasets.rs @@ -1,10 +1,8 @@ use super::*; use std::sync::Arc; -/// Factory rule shared by dataset construction, reset, and property defaults. -pub(crate) fn default_group_delay_correct(domain: Domain) -> bool { - matches!(domain, Domain::Time) -} +mod nmr_defaults; +pub(crate) use nmr_defaults::*; #[derive(Clone, Copy, PartialEq, Eq)] pub enum PhaseDragKind { @@ -34,10 +32,10 @@ pub struct NmrDataset { pub resource_id: DatasetId, /// Persisted child-field identity allocator and key mapping. pub field_catalog: FieldCatalog, - pub data: NmrData, - /// Required v1 origin contract. `Derived` is a real scientific state, not - /// a fallback for projects that omitted the field. - pub origin: plotx_io::NmrOrigin, + pub data: plotx_io::nmr_view::NmrSource, + pub native_base: plotx_io::nmr_view::NmrSource, + pub native_processed: plotx_io::nmr_view::NmrSource, + pub phase_reports: Vec, pub acquisition_identity: plotx_io::AcquisitionIdentity, pub base: Processed1D, pub pipeline: AxisPipeline, @@ -70,36 +68,74 @@ pub struct NmrDataset { } impl NmrDataset { - pub fn load(data: NmrData) -> Self { - Self::load_with_origin(data, plotx_io::NmrOrigin::Derived) + pub fn load(input: T) -> Result + where + T: TryInto, + T::Error: std::fmt::Display, + { + Self::load_with_pipeline(input, None, None) } - pub fn load_with_origin(data: NmrData, origin: plotx_io::NmrOrigin) -> Self { - let acquisition_identity = - plotx_io::AcquisitionIdentity::from_path(std::path::Path::new(&data.source)); - let pipeline = match data.domain { - Domain::Time => AxisPipeline::default_1d(), - Domain::Frequency => AxisPipeline::frequency_1d(), - }; - let group_delay_correct = default_group_delay_correct(data.domain); - let has_imaginary = data.domain == Domain::Time || data.points.iter().any(|v| v.im != 0.0); - let base = transform_output_base(&data, &pipeline, group_delay_correct) - .expect("factory processing pipeline is domain-valid"); - let processed = reapply_output(&base, &pipeline); + pub fn load_with_pipeline( + input: T, + pipeline: Option, + correct_delay: Option, + ) -> Result + where + T: TryInto, + T::Error: std::fmt::Display, + { + use plotx_processing::nmr_bridge::{DelayPolicy, RecipeRange}; + let data = input.try_into().map_err(|error| error.to_string())?; + if data.axes().len() != 1 { + return Err("Select a one-dimensional NMR dataset".into()); + } + let acquisition_identity = data.identity(); + let domain = data.domain().map_err(|error| error.to_string())?; + let known_delay = default_group_delay_correct(&data); + let has_imaginary = data.has_imaginary(0); + let mut pipeline = pipeline.unwrap_or_else(|| default_nmr_pipeline(&data)); + for (index, step) in pipeline.steps.iter_mut().enumerate() { + step.id = StepId::new(index as u64); + } + let group_delay_correct = correct_delay.unwrap_or(domain == Domain::Time && known_delay); + let mut context = nmr::ExecutionContext::default(); + let base = plotx_processing::nmr_execution::execute_1d( + &data, + &pipeline, + if group_delay_correct { + DelayPolicy::AxisEvidence + } else { + DelayPolicy::Disabled + }, + RecipeRange::Base, + &mut context, + ) + .map_err(|error| error.to_string())?; + let processed = plotx_processing::nmr_execution::execute_1d( + &base.source, + &pipeline, + DelayPolicy::Disabled, + RecipeRange::Frequency, + &mut context, + ) + .map_err(|error| error.to_string())?; let mut field_catalog = nmr_field_catalog(); - field_catalog.attach_provenance(&data.source, None); + field_catalog.attach_provenance(data.source(), None); let mut result = Self { resource_id: DatasetId::new(), field_catalog, data, - origin, acquisition_identity, - base, + native_base: base.source, + native_processed: processed.source, + phase_reports: processed.phases, + base: base.view, pipeline, next_step_id: 0, group_delay_correct, has_imaginary, - processed, + processed: processed.view, name: None, lineage: None, peaks: PeakSet::default(), @@ -117,23 +153,65 @@ impl NmrDataset { // allocator starts at 0. Kept so `load` establishes the "ids are unique // and below next_step_id" invariant itself, rather than inheriting it // from whichever template `pipeline` happened to come from. - result.remint_all_steps(); - result + result.repair_step_allocator(); + Ok(result) } - /// Cheap re-apply of the frequency-domain steps from the cached `base`. - pub fn rebuild(&mut self) { + pub fn input_domain(&self) -> Domain { + // Construction validates rank and the direct signal domain. + match self.data.axes()[0].domain { + nmr::axis::AxisDomain::Time => Domain::Time, + _ => Domain::Frequency, + } + } + + pub fn rebuild(&mut self) -> Result<(), String> { + use plotx_processing::nmr_bridge::{DelayPolicy, RecipeRange}; + let output = plotx_processing::nmr_execution::execute_1d( + &self.native_base, + &self.pipeline, + DelayPolicy::Disabled, + RecipeRange::Frequency, + &mut nmr::ExecutionContext::default(), + ) + .map_err(|error| error.to_string())?; + self.native_processed = output.source; + self.processed = output.view; + self.phase_reports = output.phases; self.clear_craft_spectrum_cache(); - self.processed = reapply_output(&self.base, &self.pipeline); + Ok(()) } - /// Rebuild `base` from the acquisition, including a real output-domain - /// transition when FFT was added or removed. - pub fn retransform(&mut self) { + pub fn retransform(&mut self) -> Result<(), String> { + use plotx_processing::nmr_bridge::{DelayPolicy, RecipeRange}; + let mut context = nmr::ExecutionContext::default(); + let base = plotx_processing::nmr_execution::execute_1d( + &self.data, + &self.pipeline, + if self.group_delay_correct { + DelayPolicy::AxisEvidence + } else { + DelayPolicy::Disabled + }, + RecipeRange::Base, + &mut context, + ) + .map_err(|error| error.to_string())?; + let output = plotx_processing::nmr_execution::execute_1d( + &base.source, + &self.pipeline, + DelayPolicy::Disabled, + RecipeRange::Frequency, + &mut context, + ) + .map_err(|error| error.to_string())?; + self.native_base = base.source; + self.base = base.view; + self.native_processed = output.source; + self.processed = output.view; + self.phase_reports = output.phases; self.clear_craft_spectrum_cache(); - self.base = transform_output_base(&self.data, &self.pipeline, self.group_delay_correct) - .expect("live processing pipelines are reconciled before application"); - self.rebuild(); + Ok(()) } pub fn spectrum(&self) -> Option<&Spectrum> { @@ -172,13 +250,6 @@ impl NmrDataset { .unwrap_or(0); self.next_step_id = self.next_step_id.max(required); } - - fn remint_all_steps(&mut self) { - for step in &mut self.pipeline.steps { - step.id = StepId::new(self.next_step_id); - self.next_step_id = self.next_step_id.checked_add(1).expect("step id overflow"); - } - } } /// A loaded 2D acquisition and its processing recipe. `base` is the post-FFT, @@ -189,8 +260,13 @@ pub struct Nmr2DDataset { pub resource_id: DatasetId, /// Persisted child-field identity allocator and key mapping. pub field_catalog: FieldCatalog, - pub data: Arc, - pub origin: plotx_io::NmrOrigin, + pub data: Arc, + pub native_base: plotx_io::nmr_view::NmrSource, + pub native_processed: plotx_io::nmr_view::NmrSource, + pub phase_reports: Vec, + pub nus_request: Option, + /// Import diagnostic when automatic reconstruction could not produce a spectrum. + pub reconstruction_warning: Option, pub acquisition_identity: plotx_io::AcquisitionIdentity, pub params: Params2D, /// Persistent owner-local allocator shared by both axes. @@ -249,37 +325,96 @@ pub struct Nmr2DDataset { pub dosy_provenance_warning: Option, } impl Nmr2DDataset { - pub fn load(data: NmrData2D) -> Self { - Self::load_with_origin_and_equal_scale_preference(data, plotx_io::NmrOrigin::Derived, true) + pub fn load(input: T) -> Result + where + T: TryInto, + T::Error: std::fmt::Display, + { + Self::load_with_equal_scale_preference(input, true) } - pub fn load_with_equal_scale_preference( - data: NmrData2D, - equal_scale_homonuclear_2d_imports: bool, - ) -> Self { - Self::load_with_origin_and_equal_scale_preference( - data, - plotx_io::NmrOrigin::Derived, - equal_scale_homonuclear_2d_imports, - ) + pub fn load_with_equal_scale_preference(input: T, equal_scale: bool) -> Result + where + T: TryInto, + T::Error: std::fmt::Display, + { + let source = input.try_into().map_err(|error| error.to_string())?; + match Self::load_with_pipeline(source.clone(), None, None, None, equal_scale) { + Ok(dataset) => Ok(dataset), + Err(error) if source.nus.is_some() => { + // Preserve the acquisition when estimation/reconstruction is unsupported. + // Import surfaces must report this as a warning, never a completed spectrum. + let params = Params2D { + layout: plotx_processing::Layout2D::Stack, + f2: AxisPipeline { steps: vec![] }, + f1: AxisPipeline { steps: vec![] }, + }; + let mut dataset = + Self::load_with_pipeline(source, Some(params), Some(false), None, equal_scale)?; + dataset.reconstruction_warning = Some(format!( + "Automatic NUS reconstruction failed; showing acquired observations: {error}" + )); + Ok(dataset) + } + Err(error) => Err(error), + } } - pub fn load_with_origin_and_equal_scale_preference( - data: NmrData2D, - origin: plotx_io::NmrOrigin, + pub fn load_with_pipeline( + input: T, + params: Option, + correct_delay: Option, + nus_request: Option, equal_scale_homonuclear_2d_imports: bool, - ) -> Self { - let acquisition_identity = - plotx_io::AcquisitionIdentity::from_path(std::path::Path::new(&data.source)); + ) -> Result + where + T: TryInto, + T::Error: std::fmt::Display, + { + use plotx_processing::nmr_bridge::{DelayPolicy, RecipeRange}; + let data = input.try_into().map_err(|error| error.to_string())?; + let source = data.source_dataset(); + let acquisition_identity = source.identity(); let preset = recommend_preset(&data); - let params = match data.domain { - Domain::Time => Params2D::default_for(preset), - Domain::Frequency => Params2D::frequency_domain(preset), - }; - let group_delay_correct = default_group_delay_correct(data.domain); - let has_imaginary = data.domain == Domain::Time || data.data.iter().any(|v| v.im != 0.0); - let base = process_2d(&data, ¶ms); - let processed = reapply_2d(&base, ¶ms); + let known_delay = default_group_delay_correct(source); + let mut params = params.unwrap_or_else(|| default_nmr_params(&data, preset)); + for (index, step) in params + .f2 + .steps + .iter_mut() + .chain(&mut params.f1.steps) + .enumerate() + { + step.id = StepId::new(index as u64); + } + let group_delay_correct = correct_delay + .unwrap_or(known_delay && data.direct.domain == nmr::axis::AxisDomain::Time); + let has_imaginary = source.has_imaginary(1); + let mut work = plotx_processing::nmr_execution::processing_2d_work_ledger(); + let mut context = nmr::ExecutionContext::new(&mut work); + let base = plotx_processing::nmr_execution::execute_2d( + source, + ¶ms, + if group_delay_correct { + DelayPolicy::AxisEvidence + } else { + DelayPolicy::Disabled + }, + RecipeRange::Base, + nus_request, + &mut context, + ) + .map_err(|error| error.to_string())?; + let output = plotx_processing::nmr_execution::execute_2d( + &base.source, + ¶ms, + DelayPolicy::Disabled, + RecipeRange::Frequency, + None, + &mut context, + ) + .map_err(|error| error.to_string())?; + let processed = output.view; let mut processed_figure = build_processed_figure(&processed, preset); if !equal_scale_homonuclear_2d_imports { processed_figure.lock_aspect = false; @@ -298,16 +433,20 @@ impl Nmr2DDataset { resource_id: DatasetId::new(), field_catalog, data: Arc::new(data), - origin, acquisition_identity, + native_base: base.source, + native_processed: output.source, + phase_reports: output.phases, + nus_request, base_params: params.clone(), + reconstruction_warning: None, base_stale: false, params, next_step_id: 0, preset, group_delay_correct, has_imaginary, - base, + base: base.view, processed, processed_figure, name: None, @@ -327,40 +466,87 @@ impl Nmr2DDataset { integral_error: None, dosy_provenance_warning: None, }; - result.remint_all_steps(); - result + result.repair_step_allocator(); + Ok(result) } - /// Cheap re-apply of per-axis phase from the cached `base` (no FFT). - pub fn rebuild(&mut self) { - self.processed = reapply_2d(&self.base, &self.params); + + pub fn input_domain(&self, axis: PhaseAxis) -> Result { + self.data + .input_domain(if axis == PhaseAxis::F1 { 0 } else { 1 }) + .map_err(|error| error.to_string()) + } + + pub fn rebuild(&mut self) -> Result<(), String> { + use plotx_processing::nmr_bridge::{DelayPolicy, RecipeRange}; + let output = plotx_processing::nmr_execution::execute_2d( + &self.native_base, + &self.params, + DelayPolicy::Disabled, + RecipeRange::Frequency, + None, + &mut nmr::ExecutionContext::default(), + ) + .map_err(|error| error.to_string())?; + self.native_processed = output.source; + self.processed = output.view; + self.phase_reports = output.phases; self.processed_figure = Arc::new(build_processed_figure(&self.processed, self.preset)); self.invalidate_dosy_results("Processing changed and invalidated the selected DOSY map"); + Ok(()) } - /// Rebuild `base` from the FID (a time-domain step or the layout changed) then - /// re-derive the display result. - pub fn retransform(&mut self) { - let data = self.processing_data(); - self.base = process_2d(&data, &self.params); + + pub fn retransform(&mut self) -> Result<(), String> { + use plotx_processing::nmr_bridge::{DelayPolicy, RecipeRange}; + let mut work = plotx_processing::nmr_execution::processing_2d_work_ledger(); + let mut context = nmr::ExecutionContext::new(&mut work); + let base = plotx_processing::nmr_execution::execute_2d( + self.data.source_dataset(), + &self.params, + if self.group_delay_correct { + DelayPolicy::AxisEvidence + } else { + DelayPolicy::Disabled + }, + RecipeRange::Base, + self.nus_request, + &mut context, + ) + .map_err(|error| error.to_string())?; + let output = plotx_processing::nmr_execution::execute_2d( + &base.source, + &self.params, + DelayPolicy::Disabled, + RecipeRange::Frequency, + None, + &mut context, + ) + .map_err(|error| error.to_string())?; + self.native_base = base.source; + self.reconstruction_warning = None; + self.base = base.view; + self.native_processed = output.source; + self.processed = output.view; + self.phase_reports = output.phases; self.base_params = self.params.clone(); self.base_stale = false; - self.rebuild(); + self.processed_figure = Arc::new(build_processed_figure(&self.processed, self.preset)); + self.invalidate_dosy_results("Processing changed and invalidated the selected DOSY map"); + Ok(()) } - /// Input view for the 2D transform's existing unconditional direct-axis - /// delay removal. - /// - /// Keeping the switch here avoids a second FFT implementation: disabling - /// correction presents zero delay metadata to the same scientific kernel. - /// The uncommon disabled path owns one copy so the persisted acquisition - /// metadata remains untouched. - pub(crate) fn processing_data(&self) -> Arc { - if self.group_delay_correct { - return Arc::clone(&self.data); + pub(crate) fn stack_field_key(&self) -> &'static str { + if self + .native_processed + .dataset() + .as_raw() + .is_some_and(|raw| raw.data().is_sparse()) + { + "nmr.observations" + } else { + "nmr.stack" } - let mut data = (*self.data).clone(); - data.direct.group_delay = 0.0; - Arc::new(data) } + /// A true-2D (contour) result, as opposed to a pseudo-2D stack of slices. pub fn is_true_2d(&self) -> bool { matches!(self.processed, Processed2D::Ft(_)) @@ -408,7 +594,11 @@ impl Nmr2DDataset { // Auto steps have a placeholder pivot; show the peak the pass really // rotates about so the on-plot handle isn't pinned to an edge. StepKind::Phase(p) => Some(match p.auto { - Some(_) => self.auto_pivot_frac(axis), + Some(_) => self + .phase_reports + .iter() + .find(|report| report.step == s.id) + .map_or(p.pivot_frac, |report| report.recipe_parameters().2), None => p.pivot_frac, }), _ => None, @@ -416,17 +606,6 @@ impl Nmr2DDataset { .unwrap_or(0.0); Some(lo + (hi - lo) * frac) } - /// The peak the auto-phase pass rotates about, per axis, read from the cached - /// pre-phase `base`. - fn auto_pivot_frac(&self, axis: PhaseAxis) -> f64 { - match &self.base { - Processed2D::Ft(s) => { - let (f2, f1) = s.peak_pivot_fracs(); - if axis == PhaseAxis::F1 { f1 } else { f2 } - } - Processed2D::Stack(s) => s.peak_pivot_frac(), - } - } pub fn set_pivot_ppm(&mut self, axis: PhaseAxis, ppm: f64) { let Some((lo, hi)) = self.axis_ppm_ends(axis) else { return; @@ -466,19 +645,6 @@ impl Nmr2DDataset { .unwrap_or(0); self.next_step_id = self.next_step_id.max(required); } - - fn remint_all_steps(&mut self) { - for step in self - .params - .f2 - .steps - .iter_mut() - .chain(&mut self.params.f1.steps) - { - step.id = StepId::new(self.next_step_id); - self.next_step_id = self.next_step_id.checked_add(1).expect("step id overflow"); - } - } } #[derive(Clone)] diff --git a/crates/core/src/state/datasets/nmr_defaults.rs b/crates/core/src/state/datasets/nmr_defaults.rs new file mode 100644 index 00000000..190b8d90 --- /dev/null +++ b/crates/core/src/state/datasets/nmr_defaults.rs @@ -0,0 +1,66 @@ +//! Import, reset and property defaults use the same checked acquisition evidence. + +use super::*; +use nmr::{acquisition::GroupDelayState, axis::AxisDomain}; +use plotx_io::{nmr_series::NmrSeriesSource, nmr_view::NmrSource}; + +pub(crate) fn default_group_delay_correct(source: &NmrSource) -> bool { + source.dataset().as_raw().is_some_and(|raw| { + raw.descriptor().axes().last().is_some_and(|axis| { + axis.domain() == AxisDomain::Time + && matches!( + axis.group_delay(), + GroupDelayState::Pending(_) | GroupDelayState::NotApplicable + ) + }) + }) +} + +pub(crate) fn default_nmr_pipeline(source: &NmrSource) -> AxisPipeline { + if source.axes()[0].domain == AxisDomain::Time { + return if default_group_delay_correct(source) { + AxisPipeline::default_1d() + } else { + AxisPipeline { steps: Vec::new() } + }; + } + let mut pipeline = AxisPipeline::frequency_1d(); + disable_scalar_phase(&mut pipeline, source, 0); + pipeline +} + +pub(crate) fn default_nmr_params(data: &NmrSeriesSource, preset: Preset2D) -> Params2D { + let source = data.source_dataset(); + let mut params = Params2D::default_for(preset); + for (axis, pipeline) in [(1, &mut params.f2), (0, &mut params.f1)] { + match source.axes()[axis].domain { + AxisDomain::Frequency => { + *pipeline = AxisPipeline::frequency_2d(axis == 1); + disable_scalar_phase(pipeline, source, axis); + } + AxisDomain::Parameter => pipeline.steps.clear(), + _ => {} + } + } + if source.axes()[0].domain == AxisDomain::Parameter { + params.layout = plotx_processing::Layout2D::Stack; + } + if !default_group_delay_correct(source) + && data.direct.domain == AxisDomain::Time + && data.nus.is_none() + { + params.f2.steps.clear(); + params.f1.steps.clear(); + } + params +} + +fn disable_scalar_phase(pipeline: &mut AxisPipeline, source: &NmrSource, axis: usize) { + if !source.has_imaginary(axis) { + for step in &mut pipeline.steps { + if matches!(step.kind, StepKind::Phase(_)) { + step.enabled = false; + } + } + } +} diff --git a/crates/core/src/state/datasets/pseudo_display_binding_tests.rs b/crates/core/src/state/datasets/pseudo_display_binding_tests.rs index 3196e592..08a655ab 100644 --- a/crates/core/src/state/datasets/pseudo_display_binding_tests.rs +++ b/crates/core/src/state/datasets/pseudo_display_binding_tests.rs @@ -3,7 +3,7 @@ use super::*; #[test] fn trace_alignment_merges_stack_projection_without_changing_map_bindings() { - let mut owner = Nmr2DDataset::load(synthetic_dosy(1.2e-9)); + let mut owner = Nmr2DDataset::load(synthetic_dosy(1.2e-9)).unwrap(); assert!(owner.build_dosy_map()); owner.display = PseudoDisplay::Stack; let mut app = PlotxApp::new_with_settings(crate::settings::Settings::default()); @@ -61,10 +61,10 @@ fn trace_alignment_merges_stack_projection_without_changing_map_bindings() { #[test] fn live_binding_projects_the_current_field_and_keeps_external_series() { - let mut owner = Nmr2DDataset::load(synthetic_dosy(1.2e-9)); + let mut owner = Nmr2DDataset::load(synthetic_dosy(1.2e-9)).unwrap(); assert!(owner.build_dosy_map()); owner.display = PseudoDisplay::Stack; - let mut external = Nmr2DDataset::load(synthetic_dosy(1.5e-9)); + let mut external = Nmr2DDataset::load(synthetic_dosy(1.5e-9)).unwrap(); assert!(external.build_dosy_map()); let mut app = PlotxApp::new_with_settings(crate::settings::Settings::default()); app.doc.datasets.push(Dataset::Nmr2D(Box::new(owner))); @@ -249,7 +249,7 @@ fn dosy_map_honors_non_default_contour_levels_and_style() { PositiveFiniteF32, PositiveFiniteF64, SeriesEncoding, }; - let mut dataset = Nmr2DDataset::load(synthetic_dosy(1.2e-9)); + let mut dataset = Nmr2DDataset::load(synthetic_dosy(1.2e-9)).unwrap(); assert!(dataset.build_dosy_map()); let peak = dosy_scalar_grid(dataset.dosy_map.as_ref().unwrap()) .values @@ -307,7 +307,7 @@ fn ilt_map_honors_non_default_contour_style() { PositiveFiniteF32, PositiveFiniteF64, SeriesEncoding, }; - let mut dataset = Nmr2DDataset::load(synthetic_dosy(1.2e-9)); + let mut dataset = Nmr2DDataset::load(synthetic_dosy(1.2e-9)).unwrap(); assert!(dataset.build_ilt_map(IltParams { lambda: 1e-2, d_min: 1e-10, diff --git a/crates/core/src/state/datasets/pseudo_tests.rs b/crates/core/src/state/datasets/pseudo_tests.rs index 059f9555..08c5a074 100644 --- a/crates/core/src/state/datasets/pseudo_tests.rs +++ b/crates/core/src/state/datasets/pseudo_tests.rs @@ -84,7 +84,7 @@ pub(super) fn synthetic_dosy(d_true: f64) -> NmrData2D { #[test] fn dataset_builds_dosy_map() { let d_true = 1.2e-9; - let mut ds = Nmr2DDataset::load(synthetic_dosy(d_true)); + let mut ds = Nmr2DDataset::load(synthetic_dosy(d_true)).unwrap(); assert!(ds.is_pseudo()); assert_eq!(ds.preset, Preset2D::Dosy); @@ -95,13 +95,16 @@ fn dataset_builds_dosy_map() { #[test] fn ordered_series_supports_region_analysis() { - let series = Dataset::Nmr2D(Box::new(Nmr2DDataset::load(synthetic_dosy(1.2e-9)))); + let series = Dataset::Nmr2D(Box::new( + Nmr2DDataset::load(synthetic_dosy(1.2e-9)).unwrap(), + )); assert!(series.supports_region_analysis()); assert!(series.tool_groups().contains(&ToolGroup::RegionAnalysis)); let mut without_ruler = synthetic_dosy(1.2e-9); without_ruler.pseudo_axis = None; - let not_a_series = Dataset::Nmr2D(Box::new(Nmr2DDataset::load(without_ruler))); + without_ruler.diffusion = None; + let not_a_series = Dataset::Nmr2D(Box::new(Nmr2DDataset::load(without_ruler).unwrap())); assert!(!not_a_series.supports_region_analysis()); assert!( !not_a_series @@ -117,7 +120,7 @@ fn ordered_series_supports_region_analysis() { #[test] fn region_support_matches_what_the_table_builder_accepts() { let mut app = crate::state::PlotxApp::new_with_settings(crate::settings::Settings::default()); - let mut series = Nmr2DDataset::load(synthetic_dosy(1.2e-9)); + let mut series = Nmr2DDataset::load(synthetic_dosy(1.2e-9)).unwrap(); series.region_analysis.regions = vec![Region { id: RegionId::new(0), lo: 0.9, @@ -141,7 +144,8 @@ fn region_support_matches_what_the_table_builder_accepts() { // the Series Table command from offering a table that cannot be built. let mut ruler_less = synthetic_dosy(1.2e-9); ruler_less.pseudo_axis = None; - let mut stale = Nmr2DDataset::load(ruler_less); + ruler_less.diffusion = None; + let mut stale = Nmr2DDataset::load(ruler_less).unwrap(); stale.region_analysis.regions = vec![Region { id: RegionId::new(0), lo: 0.9, @@ -163,7 +167,7 @@ fn region_support_matches_what_the_table_builder_accepts() { #[test] fn dataset_builds_ilt_dosy_map() { - let mut ds = Nmr2DDataset::load(synthetic_dosy(1.2e-9)); + let mut ds = Nmr2DDataset::load(synthetic_dosy(1.2e-9)).unwrap(); let params = IltParams { lambda: 1e-2, d_min: 1e-10, @@ -182,7 +186,7 @@ fn dataset_builds_ilt_dosy_map() { #[test] fn pseudo_map_fields_are_truthful_scalar_grids_with_map_encodings() { - let mut dataset = Nmr2DDataset::load(synthetic_dosy(1.2e-9)); + let mut dataset = Nmr2DDataset::load(synthetic_dosy(1.2e-9)).unwrap(); assert!(dataset.build_dosy_map()); assert!(dataset.build_ilt_map(IltParams { lambda: 1e-2, @@ -228,7 +232,7 @@ fn switching_dosy_method_serves_that_methods_figure() { d_max: 1e-8, n_grid: 64, }; - let mut ds = Nmr2DDataset::load(synthetic_dosy(1.2e-9)); + let mut ds = Nmr2DDataset::load(synthetic_dosy(1.2e-9)).unwrap(); assert!(ds.build_dosy_map(), "per-column map should populate"); assert!(ds.build_ilt_map(params), "ILT map should populate"); assert!(ds.figure().title.starts_with("DOSY (ILT)")); @@ -247,41 +251,56 @@ fn switching_dosy_method_serves_that_methods_figure() { assert!(ds.figure().title.starts_with("DOSY (ILT)")); } -/// A NUS schedule mutates `data` while leaving the recipe untouched, so nothing in -/// `params` records that the cached base is void. Without the explicit flag, a -/// frequency-only edit arriving before the reconstruction lands would schedule a -/// re-apply from the pre-NUS base and strand the reconstruction forever. +/// Acquisition coordinates stay fixed; reconstruction invocation changes invalidate the base. #[test] -fn entering_a_nus_schedule_forces_a_retransform_until_a_base_lands() { +fn changing_nus_reconstruction_inputs_keeps_the_base_stale_until_a_result_lands() { let mut data = synthetic_dosy(1.2e-9); + data.pseudo_axis = None; + data.diffusion = None; data.nus = Some(plotx_io::NusMeta { grid: data.rows * 2, acquired: data.rows, - idx_base: 0, - mode: String::new(), - echo_antiecho: false, - schedule: None, + schedule: Some((0..data.rows).map(|index| 2 * index).collect()), }); - let mut ds = Nmr2DDataset::load(data); - assert!(!ds.base_stale); - - let rows = ds.data.rows; - ds.set_nus_schedule(&(0..rows).collect::>(), 0) - .expect("a full in-grid schedule is valid"); - assert!(ds.base_stale, "the cached base no longer derives from data"); - - ds.retransform(); - assert!(!ds.base_stale, "a fresh base clears the flag"); + let mut app = PlotxApp::new(); + app.doc + .datasets + .push(Dataset::Nmr2D(Box::new(Nmr2DDataset::load(data).unwrap()))); + let before = app.doc.datasets[0] + .as_nmr2d() + .unwrap() + .data + .source_dataset() + .dataset() + .canonical_digests(); + let mut state = DatasetProcessingState::from_dataset(&app.doc.datasets[0]); + if let DatasetProcessingState::Nmr2D { nus_request, .. } = &mut state { + *nus_request = Some(plotx_processing::nmr_execution::NusRequest { + noise_standard_deviation: Some(0.01), + ..Default::default() + }); + } + app.set_dataset_processing_state(0, &state).unwrap(); + let ds = app.doc.datasets[0].as_nmr2d().unwrap(); + assert!(ds.base_stale); + assert_eq!( + before, + ds.data.source_dataset().dataset().canonical_digests() + ); + app.doc.datasets[0] + .as_nmr2d_mut() + .unwrap() + .retransform() + .unwrap(); + assert!(!app.doc.datasets[0].as_nmr2d().unwrap().base_stale); } #[test] fn persisted_display_and_method_changes_mark_the_document_dirty() { let mut app = PlotxApp::new_with_settings(crate::settings::Settings::default()); - app.doc - .datasets - .push(Dataset::Nmr2D(Box::new(Nmr2DDataset::load( - synthetic_dosy(1.2e-9), - )))); + app.doc.datasets.push(Dataset::Nmr2D(Box::new( + Nmr2DDataset::load(synthetic_dosy(1.2e-9)).unwrap(), + ))); app.doc.dirty = false; app.set_pseudo_display(0, PseudoDisplay::DosyMap); @@ -302,7 +321,7 @@ fn persisted_display_and_method_changes_mark_the_document_dirty() { #[test] fn switching_an_existing_stack_canvas_to_dosy_rebuilds_it_as_a_map() { let mut app = PlotxApp::new_with_settings(crate::settings::Settings::default()); - let mut dataset = Nmr2DDataset::load(synthetic_dosy(1.2e-9)); + let mut dataset = Nmr2DDataset::load(synthetic_dosy(1.2e-9)).unwrap(); assert!(dataset.build_dosy_map()); dataset.display = PseudoDisplay::Stack; app.doc.datasets.push(Dataset::Nmr2D(Box::new(dataset))); @@ -444,11 +463,11 @@ fn switching_an_existing_stack_canvas_to_dosy_rebuilds_it_as_a_map() { #[test] fn processing_invalidation_explains_the_stack_fallback() { - let mut dataset = Nmr2DDataset::load(synthetic_dosy(1.2e-9)); + let mut dataset = Nmr2DDataset::load(synthetic_dosy(1.2e-9)).unwrap(); assert!(dataset.build_dosy_map()); assert_eq!(dataset.display, PseudoDisplay::DosyMap); - dataset.rebuild(); + dataset.rebuild().unwrap(); assert!(dataset.dosy_map.is_none()); assert!(dataset.figure().title.starts_with("Pseudo-2D stack —")); @@ -466,7 +485,7 @@ fn ilt_invocation_resolution_obeys_explicit_provenance_default_and_reports_empty let mut settings = crate::settings::Settings::default(); settings.processing.ilt_lambda = 0.8; let mut app = PlotxApp::new_with_settings(settings); - let mut dataset = Nmr2DDataset::load(synthetic_dosy(1.2e-9)); + let mut dataset = Nmr2DDataset::load(synthetic_dosy(1.2e-9)).unwrap(); let previous = IltParams { lambda: 0.03, d_min: 1e-10, @@ -488,10 +507,9 @@ fn ilt_invocation_resolution_obeys_explicit_provenance_default_and_reports_empty let mut empty = synthetic_dosy(1.2e-9); empty.data.fill(Complex64::new(0.0, 0.0)); let mut empty_app = PlotxApp::new_with_settings(crate::settings::Settings::default()); - empty_app - .doc - .datasets - .push(Dataset::Nmr2D(Box::new(Nmr2DDataset::load(empty)))); + empty_app.doc.datasets.push(Dataset::Nmr2D(Box::new( + crate::nmr_test_support::load_2d(empty).unwrap(), + ))); // Deliberately not a boundary value: at MIN or MAX the assertion below would // be satisfied by the range text the same message prints, and would still // pass with the value itself removed from the message. @@ -545,9 +563,9 @@ fn a_build_that_fits_nothing_still_marks_the_document_dirty() { .data .iter_mut() .for_each(|value| *value = Complex64::ZERO); - app.doc - .datasets - .push(Dataset::Nmr2D(Box::new(Nmr2DDataset::load(empty)))); + app.doc.datasets.push(Dataset::Nmr2D(Box::new( + crate::nmr_test_support::load_2d(empty).unwrap(), + ))); app.doc.dirty = false; app.build_dosy_map_for(0); @@ -580,7 +598,7 @@ fn a_build_that_fits_nothing_still_marks_the_document_dirty() { #[test] fn the_missing_map_note_tracks_the_current_selection_instead_of_persisting() { let mut app = PlotxApp::new_with_settings(crate::settings::Settings::default()); - let mut ds = Nmr2DDataset::load(synthetic_dosy(1.2e-9)); + let mut ds = Nmr2DDataset::load(synthetic_dosy(1.2e-9)).unwrap(); assert!(ds.build_dosy_map()); app.doc.datasets.push(Dataset::Nmr2D(Box::new(ds))); @@ -627,7 +645,7 @@ fn the_missing_map_note_tracks_the_current_selection_instead_of_persisting() { /// produce a different digest. #[test] fn the_data_fingerprint_covers_coordinates_and_diffusion_metadata() { - let mut ds = Nmr2DDataset::load(synthetic_dosy(1.2e-9)); + let mut ds = Nmr2DDataset::load(synthetic_dosy(1.2e-9)).unwrap(); let Processed2D::Stack(stack) = &ds.processed else { panic!("synthetic DOSY must process as a stack"); }; @@ -713,11 +731,9 @@ fn ilt_parameters_from_a_project_are_validated_before_the_inversion() { // And the build path must actually consult it rather than reaching the // inversion with the value. let mut app = PlotxApp::new_with_settings(crate::settings::Settings::default()); - app.doc - .datasets - .push(Dataset::Nmr2D(Box::new(Nmr2DDataset::load( - synthetic_dosy(1.2e-9), - )))); + app.doc.datasets.push(Dataset::Nmr2D(Box::new( + Nmr2DDataset::load(synthetic_dosy(1.2e-9)).unwrap(), + ))); app.build_ilt_map_for_with_params(0, Some(huge_grid)); assert!( app.doc.datasets[0].as_nmr2d().unwrap().ilt_map.is_none(), diff --git a/crates/core/src/state/datasets_2d_figure.rs b/crates/core/src/state/datasets_2d_figure.rs index df2558e2..048d9919 100644 --- a/crates/core/src/state/datasets_2d_figure.rs +++ b/crates/core/src/state/datasets_2d_figure.rs @@ -168,24 +168,8 @@ impl Nmr2DDataset { fn nmr_axes(spectrum: &plotx_processing::Spectrum2D) -> (Axis, Axis) { let (f2_lo, f2_hi) = spectrum.f2_bounds(); let (f1_lo, f1_hi) = spectrum.f1_bounds(); - let f2 = match spectrum.f2_domain { - plotx_io::Domain::Time => Axis::new("F2 acquisition time (s)", f2_lo, f2_hi), - plotx_io::Domain::Frequency => Axis::new( - format!("{} chemical shift (ppm)", spectrum.direct.nucleus), - f2_lo, - f2_hi, - ) - .reversed(true), - }; - let f1 = match spectrum.f1_domain { - plotx_io::Domain::Time => Axis::new("F1 acquisition time (s)", f1_lo, f1_hi), - plotx_io::Domain::Frequency => Axis::new( - format!("{} chemical shift (ppm)", spectrum.indirect.nucleus), - f1_lo, - f1_hi, - ) - .reversed(true), - }; + let f2 = crate::figures::nmr_axis(&spectrum.direct, f2_lo, f2_hi); + let f1 = crate::figures::nmr_axis(&spectrum.indirect, f1_lo, f1_hi); (f2, f1) } diff --git a/crates/core/src/state/datasets_2d_maps.rs b/crates/core/src/state/datasets_2d_maps.rs index 7a761312..d63d533f 100644 --- a/crates/core/src/state/datasets_2d_maps.rs +++ b/crates/core/src/state/datasets_2d_maps.rs @@ -102,9 +102,24 @@ impl Nmr2DDataset { }); } + pub fn dosy_input_error(&self) -> Option<&'static str> { + match &self.processed { + Processed2D::Stack(stack) + if stack.direct.unit == Some(nmr::axis::AxisUnit::Ppm) + && stack.direct_domain == plotx_io::Domain::Frequency => + { + None + } + _ => Some("DOSY maps require a stack of frequency-domain spectra calibrated in ppm."), + } + } + /// Fit every column to build a DOSY map. Only meaningful for diffusion /// datasets. pub fn build_dosy_map(&mut self) -> bool { + if self.dosy_input_error().is_some() { + return false; + } let (Processed2D::Stack(stack), Some(axis), Some(meta)) = ( &self.processed, &self.data.pseudo_axis, @@ -132,6 +147,9 @@ impl Nmr2DDataset { /// diffusion metadata and a gradient-encoded ruler; each gradient value is /// converted to a Stejskal–Tanner b-factor before inversion. pub fn build_ilt_map(&mut self, params: IltParams) -> bool { + if self.dosy_input_error().is_some() { + return false; + } let (Processed2D::Stack(stack), Some(axis), Some(meta)) = ( &self.processed, &self.data.pseudo_axis, diff --git a/crates/core/src/state/datasets_dispatch.rs b/crates/core/src/state/datasets_dispatch.rs index 98011aa3..a103a465 100644 --- a/crates/core/src/state/datasets_dispatch.rs +++ b/crates/core/src/state/datasets_dispatch.rs @@ -157,10 +157,13 @@ impl Dataset { pub fn summary(&self) -> String { match self { Dataset::Nmr(d) => format!( - "{} · {} pts · {:.2} MHz", - d.data.nucleus, + "{} · {} pts · {}", + d.data.nucleus(), d.data.len(), - d.data.observe_freq_mhz + d.data.axes()[0] + .observe_frequency_mhz() + .map(|value| format!("{value:.1} MHz")) + .unwrap_or_else(|| "frequency unknown".into()) ), Dataset::Nmr2D(d) => d.summary(), Dataset::Table(d) => d.summary(), @@ -496,12 +499,12 @@ impl Dataset { /// rather than two derivations that agree only until one of them changes. pub fn factory_pipeline(&self, axis: PhaseAxis) -> Option { match self { - Dataset::Nmr(n) if axis == PhaseAxis::Direct => Some(match n.data.domain { + Dataset::Nmr(n) if axis == PhaseAxis::Direct => Some(match n.input_domain() { Domain::Time => AxisPipeline::default_1d(), Domain::Frequency => AxisPipeline::frequency_1d(), }), Dataset::Nmr2D(n) => { - let params = match n.data.domain { + let params = match n.input_domain(axis).ok()? { Domain::Time => Params2D::default_for(n.preset), Domain::Frequency => Params2D::frequency_domain(n.preset), }; @@ -527,7 +530,7 @@ impl Dataset { } /// Parameters produced by the currently enabled automatic Phase step. - /// This mirrors the processing kernels so switching to manual is lossless. + /// Uses the retained library result so switching to manual is lossless. pub fn automatic_phase_params(&self, axis: PhaseAxis) -> Option<(f64, f64, f64)> { let pipe = self.axis_pipeline(axis)?; let method = @@ -538,27 +541,19 @@ impl Dataset { StepKind::Phase(params) => params.auto, _ => None, })?; - match self { - Dataset::Nmr(n) => Some(plotx_processing::auto_phase(n.base.as_frequency()?, method)), - Dataset::Nmr2D(n) => match &n.base { - Processed2D::Ft(s) => { - let peak_arg = s - .data - .iter() - .max_by(|a, b| a.norm().total_cmp(&b.norm())) - .map_or(0.0, |value| value.arg()); - let (f2, f1) = s.peak_pivot_fracs(); - Some((peak_arg, 0.0, if axis == PhaseAxis::F1 { f1 } else { f2 })) - } - Processed2D::Stack(s) if axis == PhaseAxis::F2 => { - let (phase0, phase1) = - plotx_processing::fft2::absorptive_phase(&s.traces).unwrap_or((0.0, 0.0)); - Some((phase0, phase1, s.peak_pivot_frac())) - } - Processed2D::Stack(_) => None, - }, - _ => None, - } + let step = pipe.steps.iter().find(|step| { + step.enabled + && matches!(step.kind, StepKind::Phase(params) if params.auto == Some(method)) + })?; + let reports = match self { + Dataset::Nmr(n) => &n.phase_reports, + Dataset::Nmr2D(n) => &n.phase_reports, + _ => return None, + }; + reports + .iter() + .find(|report| report.step == step.id) + .map(|report| report.recipe_parameters()) } pub fn pivot_ppm(&self, axis: PhaseAxis) -> Option { diff --git a/crates/core/src/state/field.rs b/crates/core/src/state/field.rs index 9b118ac3..4192c8c6 100644 --- a/crates/core/src/state/field.rs +++ b/crates/core/src/state/field.rs @@ -15,14 +15,19 @@ use crate::automation::{ CAP_FIELD_XPS_SPECTRUM, CapabilityId, }; use plotx_figure::{ContourStyle, SeriesEncoding}; -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeMap; #[path = "field_contour.rs"] mod field_contour; pub use field_contour::*; #[path = "field_mass_spec.rs"] mod field_mass_spec; - +#[path = "field_metadata.rs"] +mod field_metadata; +#[path = "field_nmr.rs"] +mod field_nmr; +use field_metadata::LINE_X_UNIT_METADATA_KEY; +pub use field_metadata::{FieldCapabilities, FieldDescriptor, FieldMetadata}; impl super::Dataset { /// Describes stable child fields and their encoding capabilities. pub fn field_descriptors(&self) -> Vec { @@ -75,13 +80,17 @@ impl super::Dataset { }, capabilities(id, &[CAP_FIELD_NMR_SIGNAL]), vec![nmr.processed.values().len()], - vec![match nmr.output_domain() { - plotx_io::Domain::Time => "s".to_owned(), - plotx_io::Domain::Frequency => "ppm".to_owned(), - }], + vec![ + plotx_processing::axis_unit_label( + nmr.native_processed.axes()[0].unit, + ) + .to_owned(), + ], "line", ) - .with_line_x_unit(domain_unit(nmr.output_domain())) + .with_line_x_unit( + plotx_processing::axis_unit_label(nmr.native_processed.axes()[0].unit), + ) }) .collect::>(); fields.extend(nmr.craft_field_specs().filter_map(|spec| { @@ -115,8 +124,8 @@ impl super::Dataset { plotx_processing::Processed2D::Ft(spectrum) => ( vec![spectrum.f1_size, spectrum.f2_size], vec![ - domain_unit(spectrum.f1_domain), - domain_unit(spectrum.f2_domain), + spectrum.indirect.unit_label().to_owned(), + spectrum.direct.unit_label().to_owned(), ], ), plotx_processing::Processed2D::Stack(_) => unreachable!("true 2D is FT"), @@ -161,12 +170,16 @@ impl super::Dataset { unreachable!("pseudo 2D is stack") }; let mut fields = Vec::new(); - if let Some(id) = nmr.field_catalog.id_for_key("nmr.stack") { + if let Some(id) = nmr.field_catalog.id_for_key(nmr.stack_field_key()) { fields.push( descriptor( id, - "nmr.stack", - "Stack", + nmr.stack_field_key(), + if nmr.stack_field_key() == "nmr.observations" { + "Acquired NUS observations" + } else { + "Stack" + }, capabilities( id, &[ @@ -175,11 +188,11 @@ impl super::Dataset { CAP_FIELD_REGION_SERIES, ], ), - vec![nmr.data.rows, nmr.data.cols], - vec![String::new(), domain_unit(stack.direct_domain)], + vec![stack.increments(), stack.ppm.len()], + vec![String::new(), stack.direct.unit_label().to_owned()], "line", ) - .with_line_x_unit(domain_unit(stack.direct_domain)), + .with_line_x_unit(stack.direct.unit_label().to_owned()), ); } if let Some(id) = nmr.field_catalog.id_for_key("nmr.dosy_map") { @@ -193,7 +206,10 @@ impl super::Dataset { "DOSY map", capabilities(id, &[CAP_FIELD_BOUNDED, CAP_FIELD_SCALAR_GRID_2D_REGULAR]), dimensions, - vec!["log10(m2/s)".to_owned(), domain_unit(stack.direct_domain)], + vec![ + "log10(m2/s)".to_owned(), + stack.direct.unit_label().to_owned(), + ], "contour", )); } @@ -208,7 +224,10 @@ impl super::Dataset { "ILT map", capabilities(id, &[CAP_FIELD_BOUNDED, CAP_FIELD_SCALAR_GRID_2D_REGULAR]), dimensions, - vec!["log10(m2/s)".to_owned(), domain_unit(stack.direct_domain)], + vec![ + "log10(m2/s)".to_owned(), + stack.direct.unit_label().to_owned(), + ], "contour", )); } @@ -451,7 +470,6 @@ impl super::Dataset { .collect(), } } - pub fn default_field_id(&self) -> Option { if let Self::MassSpec(dataset) = self { return field_mass_spec::default_field_id(dataset); @@ -460,25 +478,23 @@ impl super::Dataset { && !dataset.is_true_2d() { let key = match dataset.display { - super::PseudoDisplay::Stack => "nmr.stack", + super::PseudoDisplay::Stack => dataset.stack_field_key(), super::PseudoDisplay::DosyMap => match dataset.dosy_method { super::DosyMethod::MonoExp if dataset.dosy_map.is_some() => "nmr.dosy_map", super::DosyMethod::Ilt(_) if dataset.ilt_map.is_some() => "nmr.ilt_map", - _ => "nmr.stack", + _ => dataset.stack_field_key(), }, }; return dataset.field_catalog.id_for_key(key); } self.field_descriptors().first().map(|field| field.id) } - pub fn has_field(&self, id: FieldId) -> bool { if let Self::MassSpec(dataset) = self { return dataset.field_catalog.key_for_id(id).is_some(); } - self.field_descriptors().iter().any(|field| field.id == id) + self.field_descriptor(id).is_some() } - pub fn field_descriptor(&self, id: FieldId) -> Option { if let Self::MassSpec(dataset) = self { return field_mass_spec::descriptor(dataset, id); @@ -486,6 +502,7 @@ impl super::Dataset { self.field_descriptors() .into_iter() .find(|field| field.id == id) + .or_else(|| field_nmr::inactive_descriptor(self, id)) } /// A persisted encoding is valid only when its source field exposes the @@ -628,6 +645,7 @@ impl super::Dataset { "nmr.stack".to_owned(), "nmr.dosy_map".to_owned(), "nmr.ilt_map".to_owned(), + "nmr.observations".to_owned(), ], Self::Table(_) => vec!["table.default_series".to_owned()], Self::Electrophysiology(dataset) => (0..dataset.data.channels.len()) @@ -656,15 +674,6 @@ impl super::Dataset { } } } - -fn domain_unit(domain: plotx_io::Domain) -> String { - match domain { - plotx_io::Domain::Time => "s", - plotx_io::Domain::Frequency => "ppm", - } - .to_owned() -} - /// Central capability gate for scalar fields. A provider must derive /// `regular` from its actual coordinate representation, not from its domain. pub fn scalar_grid_capabilities(regular: bool, extra: &[&str]) -> FieldCapabilities { @@ -680,78 +689,6 @@ pub fn scalar_grid_capabilities(regular: bool, extra: &[&str]) -> FieldCapabilit ) } -/// Stable child-resource metadata, including the capabilities used by encoding -/// and chart applicability checks. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct FieldDescriptor { - pub id: FieldId, - pub local_id: String, - pub name: String, - /// The scientific concept represented by this field. This is required so - /// every new field participates in the v1 summary contract by construction. - pub scientific_observation: SummaryPart, - pub capabilities: FieldCapabilities, - pub dimensions: Vec, - pub units: Vec, - pub metadata: FieldMetadata, -} - -impl FieldDescriptor { - pub(crate) fn with_line_x_unit(mut self, unit: impl Into) -> Self { - self.metadata - .0 - .insert(LINE_X_UNIT_METADATA_KEY.to_owned(), unit.into()); - self - } - - pub fn line_x_unit(&self) -> Option<&str> { - self.metadata.line_x_unit() - } -} - -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct FieldCapabilities(BTreeSet); - -impl FieldCapabilities { - pub fn new(values: impl IntoIterator) -> Self { - Self(values.into_iter().collect()) - } - - pub fn contains(&self, capability: &str) -> bool { - self.0.contains(capability) - } - - /// Reject scalar-grid renderers for a colored raster even when a malformed - /// provider advertises both mutually exclusive capabilities. - pub fn supports(&self, required: &[&str]) -> bool { - required.iter().all(|capability| self.contains(capability)) - && !(required.contains(&CAP_FIELD_SCALAR_GRID_2D_REGULAR) - && self.contains(CAP_FIELD_COLORED_RASTER_2D)) - } - - pub fn iter(&self) -> impl Iterator { - self.0.iter() - } -} - -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct FieldMetadata(pub BTreeMap); - -const LINE_X_UNIT_METADATA_KEY: &str = "line_x_unit"; - -impl FieldMetadata { - pub fn recommended_encoding(&self) -> Option<&str> { - self.0.get("recommended_encoding").map(String::as_str) - } - - pub fn line_x_unit(&self) -> Option<&str> { - self.0 - .get(LINE_X_UNIT_METADATA_KEY) - .map(String::as_str) - .filter(|unit| !unit.is_empty()) - } -} - /// A creation-time request. It is resolved to a concrete `SeriesEncoding` /// before a `SeriesBinding` enters document state. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] diff --git a/crates/core/src/state/field_catalog.rs b/crates/core/src/state/field_catalog.rs index 3e20aa6e..a6871d63 100644 --- a/crates/core/src/state/field_catalog.rs +++ b/crates/core/src/state/field_catalog.rs @@ -220,8 +220,9 @@ pub(crate) fn pseudo_axis_display_scale(unit: &str) -> f64 { pub(crate) fn attach_pseudo_trace_collection( catalog: &mut FieldCatalog, - data: &plotx_io::NmrData2D, + data: &plotx_io::nmr_series::NmrSeriesSource, ) { + attach_nus_observations(catalog, data); let Some(field) = catalog.id_for_key("nmr.stack") else { return; }; @@ -273,6 +274,42 @@ pub(crate) fn attach_pseudo_trace_collection( ); } +fn attach_nus_observations( + catalog: &mut FieldCatalog, + data: &plotx_io::nmr_series::NmrSeriesSource, +) { + let (Some(nus), Some(field)) = (&data.nus, catalog.id_for_key("nmr.observations")) else { + return; + }; + let id = TraceCollectionId::derived(data.source.as_bytes(), b"nmr.observations"); + let items = nus + .schedule + .iter() + .enumerate() + .map(|(ordinal, coordinate)| TraceItemDescriptor { + id: TraceItemId::derived(id, &(ordinal as u64).to_le_bytes()), + parameters: vec![TraceItemParameter { + key: "observation".into(), + name: "Observation".into(), + value: TraceParameterValue::Text { + value: format!("Observation {} (grid index {})", ordinal + 1, coordinate), + }, + }], + primary_label_parameter: "observation".into(), + label_override: None, + }) + .collect(); + catalog.set_trace_collection( + field, + TraceCollectionCatalog { + id, + axis_quantity: "Acquired NUS observation".into(), + axis_unit: "".into(), + items, + }, + ); +} + pub(crate) fn attach_electrophysiology_trace_collections( catalog: &mut FieldCatalog, data: &plotx_io::ElectrophysiologyData, @@ -378,6 +415,7 @@ pub(crate) fn nmr2d_field_catalog() -> FieldCatalog { "nmr.stack".to_owned(), "nmr.dosy_map".to_owned(), "nmr.ilt_map".to_owned(), + "nmr.observations".to_owned(), ]) } diff --git a/crates/core/src/state/field_metadata.rs b/crates/core/src/state/field_metadata.rs new file mode 100644 index 00000000..f2807117 --- /dev/null +++ b/crates/core/src/state/field_metadata.rs @@ -0,0 +1,77 @@ +use super::{ + CAP_FIELD_COLORED_RASTER_2D, CAP_FIELD_SCALAR_GRID_2D_REGULAR, CapabilityId, FieldId, + SummaryPart, +}; +use std::collections::{BTreeMap, BTreeSet}; + +/// Stable child-resource metadata, including the capabilities used by encoding +/// and chart applicability checks. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FieldDescriptor { + pub id: FieldId, + pub local_id: String, + pub name: String, + /// The scientific concept represented by this field. This is required so + /// every new field participates in the v1 summary contract by construction. + pub scientific_observation: SummaryPart, + pub capabilities: FieldCapabilities, + pub dimensions: Vec, + pub units: Vec, + pub metadata: FieldMetadata, +} + +impl FieldDescriptor { + pub(crate) fn with_line_x_unit(mut self, unit: impl Into) -> Self { + self.metadata + .0 + .insert(LINE_X_UNIT_METADATA_KEY.to_owned(), unit.into()); + self + } + + pub fn line_x_unit(&self) -> Option<&str> { + self.metadata.line_x_unit() + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct FieldCapabilities(BTreeSet); + +impl FieldCapabilities { + pub fn new(values: impl IntoIterator) -> Self { + Self(values.into_iter().collect()) + } + + pub fn contains(&self, capability: &str) -> bool { + self.0.contains(capability) + } + + /// Reject scalar-grid renderers for a colored raster even when a malformed + /// provider advertises both mutually exclusive capabilities. + pub fn supports(&self, required: &[&str]) -> bool { + required.iter().all(|capability| self.contains(capability)) + && !(required.contains(&CAP_FIELD_SCALAR_GRID_2D_REGULAR) + && self.contains(CAP_FIELD_COLORED_RASTER_2D)) + } + + pub fn iter(&self) -> impl Iterator { + self.0.iter() + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct FieldMetadata(pub BTreeMap); + +pub(super) const LINE_X_UNIT_METADATA_KEY: &str = "line_x_unit"; + +impl FieldMetadata { + pub fn recommended_encoding(&self) -> Option<&str> { + self.0.get("recommended_encoding").map(String::as_str) + } + + pub fn line_x_unit(&self) -> Option<&str> { + self.0 + .get(LINE_X_UNIT_METADATA_KEY) + .map(String::as_str) + .filter(|unit| !unit.is_empty()) + } +} diff --git a/crates/core/src/state/field_nmr.rs b/crates/core/src/state/field_nmr.rs new file mode 100644 index 00000000..cf5a11ed --- /dev/null +++ b/crates/core/src/state/field_nmr.rs @@ -0,0 +1,61 @@ +//! Stable NMR fields remain valid binding targets while another result is active. + +use super::*; + +pub(super) fn inactive_descriptor( + dataset: &super::super::Dataset, + id: FieldId, +) -> Option { + let super::super::Dataset::Nmr2D(nmr) = dataset else { + return None; + }; + let key = nmr.field_catalog.key_for_id(id)?; + let (name, recommended, capabilities) = match key { + "nmr.stack" => ( + "Stack", + "line", + vec![CAP_FIELD_CURVE_1D, CAP_FIELD_TRACE_COLLECTION], + ), + "nmr.observations" if nmr.data.nus.is_some() => ( + "Acquired NUS observations", + "line", + vec![CAP_FIELD_CURVE_1D, CAP_FIELD_TRACE_COLLECTION], + ), + "nmr.real" => ( + "Real", + "contour", + vec![CAP_FIELD_BOUNDED, CAP_FIELD_SCALAR_GRID_2D_REGULAR], + ), + "nmr.magnitude" => ( + "Magnitude", + "heatmap", + vec![CAP_FIELD_BOUNDED, CAP_FIELD_SCALAR_GRID_2D_REGULAR], + ), + "nmr.dosy_map" => ( + "DOSY map", + "contour", + vec![CAP_FIELD_BOUNDED, CAP_FIELD_SCALAR_GRID_2D_REGULAR], + ), + "nmr.ilt_map" => ( + "ILT map", + "contour", + vec![CAP_FIELD_BOUNDED, CAP_FIELD_SCALAR_GRID_2D_REGULAR], + ), + _ => return None, + }; + // These are the provider's declared rendering types, not a fabricated payload. + // Active field discovery and payload access still require an actual result. + Some(FieldDescriptor { + id, + local_id: key.to_owned(), + name: name.to_owned(), + scientific_observation: SummaryPart::new(format!("field:{key}"), name), + capabilities: FieldCapabilities::new(capabilities.into_iter().map(CapabilityId::new)), + dimensions: vec![], + units: vec![], + metadata: FieldMetadata(BTreeMap::from([ + ("recommended_encoding".into(), recommended.into()), + ("availability".into(), "inactive".into()), + ])), + }) +} diff --git a/crates/core/src/state/field_payload.rs b/crates/core/src/state/field_payload.rs index e0defaed..de1400e0 100644 --- a/crates/core/src/state/field_payload.rs +++ b/crates/core/src/state/field_payload.rs @@ -274,7 +274,7 @@ impl super::Dataset { .cloned() .unwrap_or_else(|| { let (source, algorithm) = match self { - Self::Nmr(dataset) => (dataset.data.source.as_str(), None), + Self::Nmr(dataset) => (dataset.data.source(), None), Self::Nmr2D(dataset) => ( dataset.data.source.as_str(), Some(FieldAlgorithmProvenance { @@ -318,7 +318,7 @@ fn nmr_field_payload(dataset: &super::Nmr2DDataset, id: FieldId) -> Option + if dataset.field_catalog.id_for_key(dataset.stack_field_key()) == Some(id) => { let values = stack .traces @@ -360,7 +360,7 @@ fn nmr_field_representation( }) } plotx_processing::Processed2D::Stack(_) - if dataset.field_catalog.id_for_key("nmr.stack") == Some(id) => + if dataset.field_catalog.id_for_key(dataset.stack_field_key()) == Some(id) => { Some(FieldRepresentation::Curve1D) } diff --git a/crates/core/src/state/field_runtime_tests.rs b/crates/core/src/state/field_runtime_tests.rs index 6ea5dc50..c0b2d2d4 100644 --- a/crates/core/src/state/field_runtime_tests.rs +++ b/crates/core/src/state/field_runtime_tests.rs @@ -1,5 +1,5 @@ use super::*; -use crate::state::{ComputeService, DataBinding, Dataset, Nmr2DDataset, PlotxApp}; +use crate::state::{ComputeService, DataBinding, Dataset, PlotxApp}; use num_complex::Complex64; use plotx_figure::{ Color, ColorSource, ContourBasePolicy, ContourLevelSpec, ContourSpec, ContourStyle, @@ -76,21 +76,24 @@ pub(super) fn grid_dataset(label: &str, values: &[f32]) -> Dataset { .copied() .map(|value| Complex64::new(f64::from(value), 0.0)) .collect(); - Dataset::Nmr2D(Box::new(Nmr2DDataset::load(NmrData2D { - data: values, - rows: 4, - cols: 4, - domain: Domain::Frequency, - direct: dimension("1H"), - indirect: dimension("13C"), - quad: QuadMode::Complex, - indirect_conjugate: false, - experiment: None, - pseudo_axis: None, - diffusion: None, - nus: None, - source: label.to_owned(), - }))) + Dataset::Nmr2D(Box::new( + crate::nmr_test_support::load_2d(NmrData2D { + data: values, + rows: 4, + cols: 4, + domain: Domain::Frequency, + direct: dimension("1H"), + indirect: dimension("13C"), + quad: QuadMode::Complex, + indirect_conjugate: false, + experiment: None, + pseudo_axis: None, + diffusion: None, + nus: None, + source: label.to_owned(), + }) + .unwrap(), + )) } fn absolute_signed_contour() -> ContourSpec { @@ -138,6 +141,7 @@ fn settle_estimates(service: &mut ComputeService) { | crate::state::Done::Craft { .. } | crate::state::Done::CraftFailed { .. } | crate::state::Done::Processing2D { .. } + | crate::state::Done::Processing2DFailed { .. } | crate::state::Done::Cancelled { .. } | crate::state::Done::Failed { .. } => { panic!("unexpected non-estimate job while settling estimates"); diff --git a/crates/core/src/state/field_tests.rs b/crates/core/src/state/field_tests.rs index c1625bdd..f12018a0 100644 --- a/crates/core/src/state/field_tests.rs +++ b/crates/core/src/state/field_tests.rs @@ -1,6 +1,6 @@ use super::*; use crate::state::{ - AfmDataset, Dataset, ElectrophysiologyDataset, Nmr2DDataset, ToolGroup, default_contour_spec, + AfmDataset, Dataset, ElectrophysiologyDataset, ToolGroup, default_contour_spec, default_encoding, }; use plotx_figure::HeatmapSpec; @@ -118,8 +118,8 @@ fn afm_dataset(scan_size_x: f64, raw: Vec, forces: bool) -> Dataset { #[test] fn cheap_representation_matches_the_materialized_payload() { - let nmr_1d = Dataset::Nmr(Box::new(crate::state::NmrDataset::load( - plotx_io::NmrData { + let nmr_1d = Dataset::Nmr(Box::new( + crate::nmr_test_support::load_1d(plotx_io::NmrData { points: vec![num_complex::Complex64::new(1.0, 0.0); 8], domain: plotx_io::Domain::Frequency, spectral_width_hz: 4_000.0, @@ -128,14 +128,19 @@ fn cheap_representation_matches_the_materialized_payload() { nucleus: "1H".to_owned(), source: "representation test".to_owned(), group_delay: 0.0, - }, - ))); + }) + .unwrap(), + )); assert_representation_matches_payload(&nmr_1d, "nmr 1d"); - let nmr_2d = Dataset::Nmr2D(Box::new(Nmr2DDataset::load(nmr2d_data("true 2d", None)))); + let nmr_2d = Dataset::Nmr2D(Box::new( + crate::nmr_test_support::load_2d(nmr2d_data("true 2d", None)).unwrap(), + )); assert_representation_matches_payload(&nmr_2d, "nmr 2d"); - let mut irregular = Dataset::Nmr2D(Box::new(Nmr2DDataset::load(nmr2d_data("explicit", None)))); + let mut irregular = Dataset::Nmr2D(Box::new( + crate::nmr_test_support::load_2d(nmr2d_data("explicit", None)).unwrap(), + )); let Dataset::Nmr2D(nmr) = &mut irregular else { panic!("fixture is NMR 2D"); }; @@ -145,16 +150,19 @@ fn cheap_representation_matches_the_materialized_payload() { Arc::make_mut(spectrum).f1_ppm[2] += 0.25; assert_representation_matches_payload(&irregular, "nmr 2d, explicitly sampled"); - let pseudo = Dataset::Nmr2D(Box::new(Nmr2DDataset::load(nmr2d_data( - "pseudo 2d", - Some(plotx_io::PseudoAxis { - name: "delay".to_owned(), - kind: plotx_io::PseudoKind::Delay, - values: vec![0.1, 0.2, 0.3, 0.4], - unit: "s".to_owned(), - source: plotx_io::AxisSource::EmbeddedList, - }), - )))); + let pseudo = Dataset::Nmr2D(Box::new( + crate::nmr_test_support::load_2d(nmr2d_data( + "pseudo 2d", + Some(plotx_io::PseudoAxis { + name: "delay".to_owned(), + kind: plotx_io::PseudoKind::Delay, + values: vec![0.1, 0.2, 0.3, 0.4], + unit: "s".to_owned(), + source: plotx_io::AxisSource::EmbeddedList, + }), + )) + .unwrap(), + )); assert!( !matches!(&pseudo, Dataset::Nmr2D(nmr) if nmr.is_true_2d()), "the pseudo-2D fixture must exercise the stack branch" @@ -587,26 +595,29 @@ fn magnitude_field_renders_magnitude_instead_of_falling_back_to_real() { nucleus: nucleus.to_owned(), group_delay: 0.0, }; - let dataset = Dataset::Nmr2D(Box::new(Nmr2DDataset::load(plotx_io::NmrData2D { - data: vec![ - num_complex::Complex64::new(-3.0, 4.0), - num_complex::Complex64::new(5.0, 12.0), - num_complex::Complex64::new(8.0, 15.0), - num_complex::Complex64::new(-7.0, 24.0), - ], - rows: 2, - cols: 2, - domain: plotx_io::Domain::Frequency, - direct: dimension("1H"), - indirect: dimension("13C"), - quad: plotx_io::QuadMode::Complex, - indirect_conjugate: false, - experiment: None, - pseudo_axis: None, - diffusion: None, - nus: None, - source: "magnitude test".to_owned(), - }))); + let dataset = Dataset::Nmr2D(Box::new( + crate::nmr_test_support::load_2d(plotx_io::NmrData2D { + data: vec![ + num_complex::Complex64::new(-3.0, 4.0), + num_complex::Complex64::new(5.0, 12.0), + num_complex::Complex64::new(8.0, 15.0), + num_complex::Complex64::new(-7.0, 24.0), + ], + rows: 2, + cols: 2, + domain: plotx_io::Domain::Frequency, + direct: dimension("1H"), + indirect: dimension("13C"), + quad: plotx_io::QuadMode::Complex, + indirect_conjugate: false, + experiment: None, + pseudo_axis: None, + diffusion: None, + nus: None, + source: "magnitude test".to_owned(), + }) + .unwrap(), + )); let magnitude = dataset .field_descriptors() .into_iter() @@ -632,21 +643,24 @@ fn default_nmr_contour_never_builds_geometry_inline() { nucleus: nucleus.to_owned(), group_delay: 0.0, }; - let dataset = Dataset::Nmr2D(Box::new(Nmr2DDataset::load(plotx_io::NmrData2D { - data: vec![num_complex::Complex64::new(1.0, 0.0); 16], - rows: 4, - cols: 4, - domain: plotx_io::Domain::Frequency, - direct: dimension("1H"), - indirect: dimension("13C"), - quad: plotx_io::QuadMode::Complex, - indirect_conjugate: false, - experiment: None, - pseudo_axis: None, - diffusion: None, - nus: None, - source: "cache test".to_owned(), - }))); + let dataset = Dataset::Nmr2D(Box::new( + crate::nmr_test_support::load_2d(plotx_io::NmrData2D { + data: vec![num_complex::Complex64::new(1.0, 0.0); 16], + rows: 4, + cols: 4, + domain: plotx_io::Domain::Frequency, + direct: dimension("1H"), + indirect: dimension("13C"), + quad: plotx_io::QuadMode::Complex, + indirect_conjugate: false, + experiment: None, + pseudo_axis: None, + diffusion: None, + nus: None, + source: "cache test".to_owned(), + }) + .unwrap(), + )); let real = dataset .field_descriptors() .into_iter() diff --git a/crates/core/src/state/mod.rs b/crates/core/src/state/mod.rs index a97258a7..1383d0a5 100644 --- a/crates/core/src/state/mod.rs +++ b/crates/core/src/state/mod.rs @@ -12,13 +12,10 @@ use crate::{ use plotx_analysis::diffusion::{DiffusionMap, diffusion_map}; use plotx_analysis::ilt::{IltResult, ilt_map, log_grid}; use plotx_figure::{Axis, Color, Figure}; -use plotx_io::{ - AfmData, Domain, ElectricalQuantity, ElectricalUnit, ElectrophysiologyData, NmrData, NmrData2D, -}; +use plotx_io::{AfmData, Domain, ElectricalQuantity, ElectricalUnit, ElectrophysiologyData}; use plotx_processing::{ AxisPipeline, DisplayMode, Params2D, PhaseParams, Preset2D, Processed1D, Processed2D, Spectrum, - StepId, StepKind, process_2d, reapply_2d, reapply_output, recommend_preset, - transform_output_base, + StepId, StepKind, recommend_preset, }; mod afm; @@ -58,7 +55,9 @@ mod content; mod craft; mod craft_fields; mod cursors; +mod data_import; mod dataset_identity; +pub use data_import::DataImports; mod dataset_trace; mod datasets; mod datasets_2d_figure; @@ -90,7 +89,6 @@ mod mass_spec_xic; mod multiplet; mod nmr_integrals; mod nmr_integrals_2d; -mod nus; mod page_fit; mod panel; mod panel_label; diff --git a/crates/core/src/state/nmr_integrals.rs b/crates/core/src/state/nmr_integrals.rs index eb225c9a..b071311a 100644 --- a/crates/core/src/state/nmr_integrals.rs +++ b/crates/core/src/state/nmr_integrals.rs @@ -85,9 +85,9 @@ impl NmrDataset { } pub fn pivot_ppm(&self) -> f64 { - let Some(base) = self.base.as_frequency() else { + if self.base.as_frequency().is_none() { return 0.0; - }; + } let (lo, hi) = self.ppm_ends(); let frac = self .pipeline @@ -99,7 +99,11 @@ impl NmrDataset { // show the peak the pass actually rotates about so the on-plot handle // sits where the user expects instead of pinned to an edge. StepKind::Phase(p) => Some(match p.auto { - Some(_) => plotx_processing::phase::peak_pivot_frac(&base.values), + Some(_) => self + .phase_reports + .iter() + .find(|report| report.step == s.id) + .map_or(p.pivot_frac, |report| report.recipe_parameters().2), None => p.pivot_frac, }), _ => None, diff --git a/crates/core/src/state/nmr_integrals_2d.rs b/crates/core/src/state/nmr_integrals_2d.rs index 9bf567d6..4d8def70 100644 --- a/crates/core/src/state/nmr_integrals_2d.rs +++ b/crates/core/src/state/nmr_integrals_2d.rs @@ -37,7 +37,13 @@ impl Nmr2DDataset { let grid: Vec = spectrum .data .iter() - .map(|value| mode.reduce(value)) + .enumerate() + .map(|(index, value)| match mode { + DisplayMode::Real => value.re, + DisplayMode::Magnitude => spectrum + .magnitude_at(index) + .expect("view planes have the same shape"), + }) .collect(); let prepared = plotx_analysis::integrate_2d::IntegrationGrid2D::new( &spectrum.f2_ppm, @@ -190,7 +196,7 @@ mod tests { let mut dataset = test_dataset(); dataset.integrals = vec![integral(0, 123.0, Some(1.0))]; - dataset.rebuild(); + dataset.rebuild().unwrap(); assert_eq!(dataset.integrals[0].volume, 123.0); dataset.recompute_integrals().unwrap(); @@ -241,7 +247,7 @@ mod tests { nucleus: "X".to_owned(), group_delay: 0.0, }; - Nmr2DDataset::load(NmrData2D { + crate::nmr_test_support::load_2d(NmrData2D { data: vec![Complex64::new(1.0, 0.0); 4], rows: 2, cols: 2, @@ -256,5 +262,6 @@ mod tests { nus: None, source: "test".to_owned(), }) + .unwrap() } } diff --git a/crates/core/src/state/nus.rs b/crates/core/src/state/nus.rs deleted file mode 100644 index 47ec8d10..00000000 --- a/crates/core/src/state/nus.rs +++ /dev/null @@ -1,46 +0,0 @@ -//! Non-uniform-sampling schedule entry for a 2D dataset. - -use super::*; - -impl Nmr2DDataset { - /// Sampling indices are `base`-indexed on input and stored 0-based; the list - /// must hold exactly one unique in-grid index per acquired increment. - pub fn set_nus_schedule(&mut self, values: &[usize], base: usize) -> Result<(), String> { - let Some(nus) = self.data.nus.as_ref() else { - return Err("This dataset is not non-uniformly sampled.".into()); - }; - let (grid, acquired) = (nus.grid, nus.acquired); - if values.len() != acquired { - return Err(format!( - "Expected {acquired} sampling indices, got {}.", - values.len() - )); - } - let mut zero_based = Vec::with_capacity(values.len()); - for &v in values { - if v < base || v >= base + grid { - return Err(format!( - "Index {v} is outside the grid [{base}, {}].", - base + grid - 1 - )); - } - zero_based.push(v - base); - } - let mut unique = zero_based.clone(); - unique.sort_unstable(); - unique.dedup(); - if unique.len() != zero_based.len() { - return Err("Sampling indices must be unique.".into()); - } - let meta = std::sync::Arc::make_mut(&mut self.data) - .nus - .as_mut() - .unwrap(); - meta.schedule = Some(zero_based); - meta.idx_base = base; - // The cached base was reconstructed from the previous schedule, so it must - // be rebuilt from the FID even though the recipe is unchanged. - self.base_stale = true; - Ok(()) - } -} diff --git a/crates/core/src/state/peaks_tests.rs b/crates/core/src/state/peaks_tests.rs index ca32ed40..c3223646 100644 --- a/crates/core/src/state/peaks_tests.rs +++ b/crates/core/src/state/peaks_tests.rs @@ -76,7 +76,7 @@ fn frequency_app() -> crate::state::PlotxApp { }; let mut app = crate::state::PlotxApp::new(); app.doc.datasets.push(crate::state::Dataset::Nmr(Box::new( - crate::state::NmrDataset::load(data), + crate::state::NmrDataset::load(data).unwrap(), ))); app } @@ -96,7 +96,7 @@ fn apply_reference(app: &mut crate::state::PlotxApp, at_ppm: f64, target_ppm: f6 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); + nmr.rebuild().unwrap(); } fn resolved_marks(app: &crate::state::PlotxApp) -> Vec { diff --git a/crates/core/src/state/scientific_summary/mod.rs b/crates/core/src/state/scientific_summary/mod.rs index 6f807142..4172947c 100644 --- a/crates/core/src/state/scientific_summary/mod.rs +++ b/crates/core/src/state/scientific_summary/mod.rs @@ -81,12 +81,12 @@ fn format_parts(parts: &[SummaryPart]) -> String { #[cfg(test)] mod tests { use super::*; - use crate::state::{Dataset, NmrDataset, PlotxApp}; + use crate::state::{Dataset, PlotxApp}; use num_complex::Complex64; use plotx_io::{AcquisitionIdentity, Domain, NmrData}; fn nmr_dataset() -> Dataset { - let mut dataset = NmrDataset::load(NmrData { + let mut dataset = crate::nmr_test_support::load_1d(NmrData { points: vec![Complex64::new(1.0, 0.0); 8], domain: Domain::Frequency, spectral_width_hz: 4_000.0, @@ -95,7 +95,8 @@ mod tests { nucleus: "1H".to_owned(), source: "raw/exp1/fid".to_owned(), group_delay: 0.0, - }); + }) + .unwrap(); dataset.acquisition_identity = AcquisitionIdentity { subject: Some("Sample A".to_owned()), acquisition: Some("zg30".to_owned()), diff --git a/crates/core/src/state/scientific_summary/resolver.rs b/crates/core/src/state/scientific_summary/resolver.rs index 957ab206..2a258e29 100644 --- a/crates/core/src/state/scientific_summary/resolver.rs +++ b/crates/core/src/state/scientific_summary/resolver.rs @@ -284,11 +284,11 @@ fn nmr_2d_observation(data: &crate::state::Nmr2DDataset) -> SummaryPart { fn nmr_1d_observation(data: &crate::state::NmrDataset) -> SummaryPart { let domain = data.output_domain(); SummaryPart::new( - format!("nmr:{domain:?}:{}", data.data.nucleus), + format!("nmr:{domain:?}:{}", data.data.nucleus()), if domain == plotx_io::Domain::Time { - format!("{} FID", data.data.nucleus) + format!("{} FID", data.data.nucleus()) } else { - data.data.nucleus.clone() + data.data.nucleus().to_owned() }, ) } @@ -555,7 +555,7 @@ mod tests { use plotx_processing::Slice1D; fn nmr(domain: Domain, subject: &str, acquisition: &str) -> Dataset { - let mut data = NmrDataset::load(NmrData { + let mut data = crate::nmr_test_support::load_1d(NmrData { points: vec![Complex64::new(1.0, 0.0); 8], domain, spectral_width_hz: 4_000.0, @@ -564,7 +564,8 @@ mod tests { nucleus: "1H".to_owned(), source: "fid".to_owned(), group_delay: 0.0, - }); + }) + .unwrap(); data.acquisition_identity = AcquisitionIdentity { subject: Some(subject.to_owned()), acquisition: Some(acquisition.to_owned()), @@ -581,7 +582,7 @@ mod tests { nucleus: nucleus.to_owned(), group_delay: 0.0, }; - Nmr2DDataset::load(NmrData2D { + crate::nmr_test_support::load_2d(NmrData2D { data: vec![Complex64::new(1.0, 0.0); 4], rows: 2, cols: 2, @@ -596,6 +597,7 @@ mod tests { nus: None, source: "2d".to_owned(), }) + .unwrap() } #[test] @@ -608,18 +610,23 @@ mod tests { fn derived_slice_inherits_the_source_subject() { let source = nmr(Domain::Frequency, "Specimen A", "HSQC"); let source_id = source.resource_id(); - let mut derived = Dataset::Nmr(Box::new(NmrDataset::from_slice( - Slice1D { - coordinates: vec![2.0, 1.0], - domain: Domain::Frequency, - values: vec![Complex64::new(1.0, 0.0); 2], - nucleus: "1H".to_owned(), - observe_freq_mhz: 400.0, - position: Some(3.0), - position_domain: Domain::Frequency, - }, - "F2 slice at 3 ppm".to_owned(), - ))); + let mut derived = Dataset::Nmr(Box::new( + NmrDataset::from_slice( + Slice1D { + coordinates: vec![2.0, 1.0], + domain: Domain::Frequency, + values: vec![Complex64::new(1.0, 0.0); 2], + nucleus: "1H".to_owned(), + observe_freq_mhz: Some(400.0), + reference_freq_mhz: Some(400.0), + unit: nmr::axis::AxisUnit::Ppm, + position: Some(3.0), + position_domain: Domain::Frequency, + }, + "F2 slice at 3 ppm".to_owned(), + ) + .unwrap(), + )); derived.set_lineage(Some(DatasetLineage::new( DerivationKind::Slice, [source_id], @@ -669,12 +676,15 @@ mod tests { domain: Domain::Time, values: vec![Complex64::new(1.0, 0.0); 2], nucleus: "1H".to_owned(), - observe_freq_mhz: 400.0, + observe_freq_mhz: Some(400.0), + reference_freq_mhz: Some(400.0), + unit: nmr::axis::AxisUnit::Ppm, position: None, position_domain: Domain::Time, }, "FID".to_owned(), - ); + ) + .unwrap(); let frequency = nmr(Domain::Frequency, "A", "zg30"); let time = nmr_1d_observation(&time); let frequency = nmr_1d_observation(frequency.as_nmr().unwrap()); diff --git a/crates/core/src/state/trace_alignment_tests.rs b/crates/core/src/state/trace_alignment_tests.rs index 588d48cd..745a7bb9 100644 --- a/crates/core/src/state/trace_alignment_tests.rs +++ b/crates/core/src/state/trace_alignment_tests.rs @@ -387,7 +387,9 @@ fn stacked_shift_bounds_union_each_provider_range() { #[test] fn pseudo_increment_uses_the_same_plot_owned_plan() { - let dataset = Dataset::Nmr2D(Box::new(Nmr2DDataset::load(pseudo_data()))); + let dataset = Dataset::Nmr2D(Box::new( + crate::nmr_test_support::load_2d(pseudo_data()).unwrap(), + )); let field = dataset.field_catalog().id_for_key("nmr.stack").unwrap(); let mut app = PlotxApp::new(); app.doc.datasets.push(dataset); @@ -507,7 +509,9 @@ fn selected_channel_projection_preserves_other_channel_bindings() { #[test] fn automatic_alignment_skips_incompatible_x_units() { let (mut app, canvas, object, ids) = alignment_recording_app(); - let pseudo = Dataset::Nmr2D(Box::new(Nmr2DDataset::load(pseudo_data()))); + let pseudo = Dataset::Nmr2D(Box::new( + crate::nmr_test_support::load_2d(pseudo_data()).unwrap(), + )); let field = pseudo.field_catalog().id_for_key("nmr.stack").unwrap(); let mut extra = SeriesBinding::from_field_all(&pseudo, field)[0].clone(); extra.id = SeriesId::new(99); @@ -544,7 +548,9 @@ fn provider_line_x_units_describe_plotted_x_axes() { Some("s") ); } - let pseudo = Dataset::Nmr2D(Box::new(Nmr2DDataset::load(pseudo_data()))); + let pseudo = Dataset::Nmr2D(Box::new( + crate::nmr_test_support::load_2d(pseudo_data()).unwrap(), + )); let field = pseudo.field_catalog().id_for_key("nmr.stack").unwrap(); assert_eq!( pseudo.field_descriptor(field).unwrap().line_x_unit(), @@ -553,16 +559,19 @@ fn provider_line_x_units_describe_plotted_x_axes() { } fn scalar_nmr(source: &str, carrier_ppm: f64) -> Dataset { - Dataset::Nmr(Box::new(NmrDataset::load(plotx_io::NmrData { - points: vec![num_complex::Complex64::new(1.0, 0.0); 8], - domain: plotx_io::Domain::Frequency, - spectral_width_hz: 4_000.0, - observe_freq_mhz: 400.0, - carrier_ppm, - nucleus: "1H".to_owned(), - source: source.to_owned(), - group_delay: 0.0, - }))) + Dataset::Nmr(Box::new( + crate::nmr_test_support::load_1d(plotx_io::NmrData { + points: vec![num_complex::Complex64::new(1.0, 0.0); 8], + domain: plotx_io::Domain::Frequency, + spectral_width_hz: 4_000.0, + observe_freq_mhz: 400.0, + carrier_ppm, + nucleus: "1H".to_owned(), + source: source.to_owned(), + group_delay: 0.0, + }) + .unwrap(), + )) } #[test] @@ -613,7 +622,8 @@ fn ordinary_scalar_line_stack_uses_the_same_alignment_planner() { .as_nmr() .unwrap() .data - .points + .trace() + .unwrap() .iter() .map(|point| (point.re.to_bits(), point.im.to_bits())) .collect::>() @@ -668,7 +678,8 @@ fn ordinary_scalar_line_stack_uses_the_same_alignment_planner() { .as_nmr() .unwrap() .data - .points + .trace() + .unwrap() .iter() .map(|point| (point.re.to_bits(), point.im.to_bits())) .collect::>() diff --git a/crates/core/src/state/trace_provider.rs b/crates/core/src/state/trace_provider.rs index 61da642b..a871fba3 100644 --- a/crates/core/src/state/trace_provider.rs +++ b/crates/core/src/state/trace_provider.rs @@ -70,6 +70,15 @@ impl Dataset { "NMR trace collection item count does not match the acquisition".to_owned(), ); } + if let Some(nus) = &dataset.data.nus { + let observations = catalog + .id_for_key("nmr.observations") + .and_then(|field| catalog.trace_collection(field)) + .ok_or("NUS observations are missing their trace catalog")?; + if observations.items.len() != nus.acquired { + return Err("NUS trace catalog differs from acquired observations".into()); + } + } } Self::Electrophysiology(dataset) => { for field in self diff --git a/crates/core/src/state/trace_provider_tests.rs b/crates/core/src/state/trace_provider_tests.rs index 766ae202..9760e20a 100644 --- a/crates/core/src/state/trace_provider_tests.rs +++ b/crates/core/src/state/trace_provider_tests.rs @@ -33,7 +33,7 @@ fn pseudo_data() -> plotx_io::NmrData2D { #[test] fn pseudo_trace_items_keep_identity_and_format_display_units() { - let mut dataset = Nmr2DDataset::load(pseudo_data()); + let mut dataset = crate::nmr_test_support::load_2d(pseudo_data()).unwrap(); let field = dataset.field_catalog.id_for_key("nmr.stack").unwrap(); let before = dataset .field_catalog @@ -49,7 +49,7 @@ fn pseudo_trace_items_keep_identity_and_format_display_units() { .as_deref(), Some("20 mT/m") ); - dataset.rebuild(); + dataset.rebuild().unwrap(); assert_eq!( before, dataset @@ -433,7 +433,7 @@ fn trace_composer_uses_each_recordings_selected_channel() { fn pseudo_map_display_composes_the_stable_stack_collection() { let mut app = PlotxApp::new(); for _ in 0..2 { - let mut dataset = Nmr2DDataset::load(pseudo_data()); + let mut dataset = crate::nmr_test_support::load_2d(pseudo_data()).unwrap(); dataset.display = PseudoDisplay::DosyMap; app.doc.datasets.push(Dataset::Nmr2D(Box::new(dataset))); } @@ -576,7 +576,9 @@ fn trace_contract_uses_capabilities_concrete_encoding_and_units_not_domain_polic .unwrap(); electrophysiology_descriptor.metadata = FieldMetadata::default(); - let pseudo = Dataset::Nmr2D(Box::new(Nmr2DDataset::load(pseudo_data()))); + let pseudo = Dataset::Nmr2D(Box::new( + crate::nmr_test_support::load_2d(pseudo_data()).unwrap(), + )); let pseudo_field = pseudo.active_trace_collection_field().unwrap(); let pseudo_binding = SeriesBinding::from_field_all(&pseudo, pseudo_field) .into_iter() @@ -618,9 +620,9 @@ fn trace_stack_forces_offset_even_when_the_primary_domain_is_field_stacked() { let mut true_2d = pseudo_data(); true_2d.pseudo_axis = None; let mut app = PlotxApp::new(); - app.doc - .datasets - .push(Dataset::Nmr2D(Box::new(Nmr2DDataset::load(true_2d)))); + app.doc.datasets.push(Dataset::Nmr2D(Box::new( + crate::nmr_test_support::load_2d(true_2d).unwrap(), + ))); app.doc.datasets.push(recording("pA", Some("mV"))); assert_eq!( app.doc.datasets[0].domain().stack_kind(), @@ -691,7 +693,9 @@ fn fixed_prepulse_is_skipped_for_the_varying_abf_test_pulse() { #[test] fn single_and_multi_item_materialization_apply_identical_line_style() { - let dataset = Dataset::Nmr2D(Box::new(Nmr2DDataset::load(pseudo_data()))); + let dataset = Dataset::Nmr2D(Box::new( + crate::nmr_test_support::load_2d(pseudo_data()).unwrap(), + )); let field = dataset.field_catalog().id_for_key("nmr.stack").unwrap(); let mut bindings = SeriesBinding::from_field_all(&dataset, field); for binding in bindings.iter_mut().take(2) { diff --git a/crates/core/src/state/ui_state.rs b/crates/core/src/state/ui_state.rs index 98769dd4..6acbec36 100644 --- a/crates/core/src/state/ui_state.rs +++ b/crates/core/src/state/ui_state.rs @@ -1,6 +1,9 @@ use super::*; +#[path = "ui_state_nmr_import.rs"] +mod nmr_import; use crate::actions::PendingWheelPropertyEdit; use crate::operation::{OperationHistory, OperationId, OperationReport}; +pub use nmr_import::NmrImportDraft; use std::collections::{HashMap, HashSet}; use std::ops::{Deref, DerefMut}; use std::sync::Arc; @@ -205,6 +208,7 @@ pub struct UiState { pub export_options: Option, pub data_export: Option, pub table_import_preview: Option, + pub nmr_import: Option, pub settings_dialog: Option, pub command_palette: Option, pub ribbon_tab: WorkflowTab, @@ -444,6 +448,7 @@ impl Default for UiState { export_options: None, data_export: None, table_import_preview: None, + nmr_import: None, settings_dialog: None, command_palette: None, ribbon_tab: WorkflowTab::default(), @@ -637,6 +642,7 @@ pub struct Session { /// Off-thread runner for the heaviest button-triggered DOSY computations. /// Not serialized; rebuilt fresh whenever a `PlotxApp` is constructed. pub compute: ComputeService, + pub data_imports: super::DataImports, /// Background update checker/downloader. Not serialized. pub updates: crate::update::UpdateService, pub line_fit_job: Option, diff --git a/crates/core/src/state/ui_state_nmr_import.rs b/crates/core/src/state/ui_state_nmr_import.rs new file mode 100644 index 00000000..85d68080 --- /dev/null +++ b/crates/core/src/state/ui_state_nmr_import.rs @@ -0,0 +1,56 @@ +use plotx_io::nmr_sampling::{IndexBase, SamplingDeclaration}; +use std::path::PathBuf; + +#[derive(Clone, Debug)] +pub struct NmrImportDraft { + pub path: PathBuf, + pub assertion_id: String, + pub source: String, + pub grid: String, + pub lanes: String, + pub one_based: Option, + pub rows: String, + pub error: Option, +} + +impl NmrImportDraft { + pub fn new(path: PathBuf) -> Self { + Self { + path, + assertion_id: format!("plotx-user-schedule-{}", uuid::Uuid::new_v4()), + source: String::new(), + grid: String::new(), + lanes: String::new(), + one_based: None, + rows: String::new(), + error: None, + } + } + + pub fn declaration(&self) -> Result { + let number = |text: &str, name: &str| { + text.trim() + .parse::() + .map_err(|_| format!("Enter an integer for {name}.")) + }; + let index_base = match self.one_based { + Some(true) => IndexBase::One, + Some(false) => IndexBase::Zero, + None => return Err("Select the sampling table's index base.".into()), + }; + let coordinates = self + .rows + .lines() + .filter(|row| !row.trim().is_empty()) + .map(|row| number(row, "each observation (one per line)").map(|value| vec![value])) + .collect::, _>>()?; + Ok(SamplingDeclaration { + assertion_id: self.assertion_id.clone(), + source: self.source.clone(), + grid_shape: vec![number(&self.grid, "the original indirect grid")?], + component_counts: vec![number(&self.lanes, "lanes per observation")?], + coordinates, + index_base, + }) + } +} diff --git a/crates/core/src/workflow.rs b/crates/core/src/workflow.rs index 5a4b4d20..528545d3 100644 --- a/crates/core/src/workflow.rs +++ b/crates/core/src/workflow.rs @@ -9,7 +9,7 @@ use crate::state::{ PlotObject, PlotxApp, StackMode, StackSpec, default_chart_type, }; use plotx_figure::{Axis, Figure}; -use plotx_io::{Acquisition, DataFormat, Domain, LoadWarning, LoadWarningCode, Provenance}; +use plotx_io::{Acquisition, DataFormat, LoadWarning, LoadWarningCode, Provenance}; use serde::Serialize; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -21,10 +21,13 @@ pub use dataset::{ }; #[path = "workflow/mass_spec_layout.rs"] mod mass_spec_layout; +#[path = "workflow/nmr.rs"] +mod nmr_inspection; #[path = "workflow/trace_collection.rs"] mod trace_collection; #[path = "workflow/xps.rs"] mod xps; +pub use nmr_inspection::{inspect_file, inspect_nmr_dataset}; pub const INSPECTION_SCHEMA: &str = "plotx.inspect.v1"; #[derive(Clone, Debug, Serialize)] pub struct InspectionReport { @@ -151,6 +154,8 @@ pub struct ProcessResult { #[derive(Debug, thiserror::Error)] pub enum WorkflowError { + #[error("NMR dataset: {0}")] + Nmr(String), #[error("input load failed: {0}")] Load(#[from] plotx_io::IoError), #[error("processing scheme failed: {0}")] @@ -169,15 +174,36 @@ pub enum WorkflowError { pub fn load_dataset(path: &Path) -> Result { let loaded = plotx_io::load_path(path)?; - let inspection = inspection_report( + dataset_from_load_result(loaded) +} + +pub fn load_dataset_with_sampling( + path: &Path, + declaration: plotx_io::nmr_sampling::SamplingDeclaration, +) -> Result { + dataset_from_load_result(plotx_io::nmr_sampling::load(path, declaration)?) +} + +fn dataset_from_load_result(loaded: plotx_io::LoadResult) -> Result { + let mut inspection = inspection_report( loaded.format, &loaded.provenance, &loaded.warnings, &loaded.acquisition, ); - let (acquisition, acquisition_identity, _, _, nmr_origin, _) = loaded.into_parts(); + let (acquisition, acquisition_identity, _, _, _) = loaded.into_parts(); let (dataset, source) = - dataset_from_loaded_acquisition(acquisition, acquisition_identity, nmr_origin, true); + dataset_from_loaded_acquisition(acquisition, acquisition_identity, true)?; + if let Some(warning) = dataset + .as_nmr2d() + .and_then(|data| data.reconstruction_warning.as_ref()) + { + inspection.warnings.push(WarningReport { + code: "nmr-reconstruction-failed", + message: warning.clone(), + path: None, + }); + } Ok(LoadedDataset { dataset, inspection, @@ -191,7 +217,15 @@ pub fn process_file( output: &Path, format: ExportFormat, ) -> Result { - let mut loaded = load_dataset(input)?; + process_loaded_dataset(load_dataset(input)?, scheme, output, format) +} + +pub fn process_loaded_dataset( + mut loaded: LoadedDataset, + scheme: &Path, + output: &Path, + format: ExportFormat, +) -> Result { loaded.apply_scheme_file(scheme)?; let mut app = PlotxApp::new_with_settings(crate::settings::Settings::default()); app.session @@ -254,10 +288,12 @@ pub fn build_dataset_figure(dataset: &Dataset, chart: &ChartSpec, size_mm: [f32; fn default_binding(dataset: &Dataset) -> DataBinding { let fields = match dataset { - Dataset::Nmr2D(data) if !data.is_true_2d() => ["nmr.stack", "nmr.dosy_map", "nmr.ilt_map"] - .into_iter() - .filter_map(|key| data.field_catalog.id_for_key(key)) - .collect::>(), + Dataset::Nmr2D(data) if !data.is_true_2d() => { + [data.stack_field_key(), "nmr.dosy_map", "nmr.ilt_map"] + .into_iter() + .filter_map(|key| data.field_catalog.id_for_key(key)) + .collect::>() + } Dataset::Electrophysiology(_) => dataset .field_descriptors() .into_iter() @@ -488,9 +524,45 @@ fn inspection_report( warnings: &[LoadWarning], acquisition: &Acquisition, ) -> InspectionReport { - let (count, shape, domain) = match acquisition { - Acquisition::D1(data) => (1, vec![data.len()], data.domain), - Acquisition::D2(data) => (2, vec![data.rows, data.cols], data.domain), + if let Acquisition::Nmr(source) = acquisition { + let axes = source.axes(); + let domain = if axes + .iter() + .all(|axis| axis.domain == nmr::axis::AxisDomain::Time) + { + "time" + } else if axes + .iter() + .all(|axis| axis.domain == nmr::axis::AxisDomain::Frequency) + { + "frequency" + } else { + "mixed" + }; + return InspectionReport { + schema: INSPECTION_SCHEMA, + format: format.as_str().to_owned(), + provenance: ProvenanceReport { + selected_path: provenance.selected_path.clone(), + data_path: provenance.data_path.clone(), + parameter_paths: provenance.parameter_paths.clone(), + companion_paths: provenance.companion_paths.clone(), + }, + dimension: DimensionReport { + count: axes.len(), + shape: axes.iter().map(|axis| axis.points).collect(), + }, + domain: domain.into(), + warnings: warnings.iter().map(warning_report).collect(), + electrophysiology: None, + afm: None, + mass_spectrometry: None, + xrd: None, + xps: None, + }; + } + match acquisition { + Acquisition::Nmr(_) => unreachable!("native NMR was handled above"), Acquisition::Electrophysiology(data) => { let max_points = data .sweeps @@ -499,7 +571,7 @@ fn inspection_report( .map(Vec::len) .max() .unwrap_or(0); - return InspectionReport { + InspectionReport { schema: INSPECTION_SCHEMA, format: format.as_str().to_owned(), provenance: ProvenanceReport { @@ -534,7 +606,7 @@ fn inspection_report( mass_spectrometry: None, xrd: None, xps: None, - }; + } } Acquisition::Afm(data) => { let force = data.forces.as_ref(); @@ -546,7 +618,7 @@ fn inspection_report( }, |force| vec![force.grid_height, force.grid_width, force.samples_per_curve], ); - return InspectionReport { + InspectionReport { schema: INSPECTION_SCHEMA, format: format.as_str().to_owned(), provenance: ProvenanceReport { @@ -573,7 +645,7 @@ fn inspection_report( mass_spectrometry: None, xrd: None, xps: None, - }; + } } Acquisition::MassSpec(run) => { let ms_scan_count = run @@ -582,7 +654,7 @@ fn inspection_report( .filter(|stream| stream.role == plotx_io::StreamRole::Primary) .map(|stream| stream.spectra.len()) .sum(); - return InspectionReport { + InspectionReport { schema: INSPECTION_SCHEMA, format: format.as_str().to_owned(), provenance: ProvenanceReport { @@ -611,68 +683,47 @@ fn inspection_report( }), xrd: None, xps: None, - }; - } - Acquisition::Xrd(data) => { - return InspectionReport { - schema: INSPECTION_SCHEMA, - format: format.as_str().to_owned(), - provenance: ProvenanceReport { - selected_path: provenance.selected_path.clone(), - data_path: provenance.data_path.clone(), - parameter_paths: provenance.parameter_paths.clone(), - companion_paths: provenance.companion_paths.clone(), - }, - dimension: DimensionReport { - count: 1, - shape: vec![data.len()], - }, - domain: "xrd".to_owned(), - warnings: warnings.iter().map(warning_report).collect(), - electrophysiology: None, - afm: None, - mass_spectrometry: None, - xrd: Some(XrdReport { - instrument: data.instrument.clone(), - target: data.target.clone(), - wavelength_angstrom: data.wavelength_angstrom, - two_theta_range_deg: [ - data.two_theta_deg.first().copied().unwrap_or(0.0), - data.two_theta_deg.last().copied().unwrap_or(0.0), - ], - point_count: data.len(), - }), - xps: None, - }; + } } + Acquisition::Xrd(data) => InspectionReport { + schema: INSPECTION_SCHEMA, + format: format.as_str().to_owned(), + provenance: ProvenanceReport { + selected_path: provenance.selected_path.clone(), + data_path: provenance.data_path.clone(), + parameter_paths: provenance.parameter_paths.clone(), + companion_paths: provenance.companion_paths.clone(), + }, + dimension: DimensionReport { + count: 1, + shape: vec![data.len()], + }, + domain: "xrd".to_owned(), + warnings: warnings.iter().map(warning_report).collect(), + electrophysiology: None, + afm: None, + mass_spectrometry: None, + xrd: Some(XrdReport { + instrument: data.instrument.clone(), + target: data.target.clone(), + wavelength_angstrom: data.wavelength_angstrom, + two_theta_range_deg: [ + data.two_theta_deg.first().copied().unwrap_or(0.0), + data.two_theta_deg.last().copied().unwrap_or(0.0), + ], + point_count: data.len(), + }), + xps: None, + }, Acquisition::Xps(experiment) => { - return xps::inspection_report(format, provenance, warnings, experiment); + xps::inspection_report(format, provenance, warnings, experiment) } - }; - InspectionReport { - schema: INSPECTION_SCHEMA, - format: format.as_str().to_owned(), - provenance: ProvenanceReport { - selected_path: provenance.selected_path.clone(), - data_path: provenance.data_path.clone(), - parameter_paths: provenance.parameter_paths.clone(), - companion_paths: provenance.companion_paths.clone(), - }, - dimension: DimensionReport { count, shape }, - domain: domain_label(domain).to_owned(), - warnings: warnings.iter().map(warning_report).collect(), - electrophysiology: None, - afm: None, - mass_spectrometry: None, - xrd: None, - xps: None, } } pub(super) fn warning_report(warning: &LoadWarning) -> WarningReport { let code = match warning.code { LoadWarningCode::ArchiveEntryFailed => "archive-entry-failed", - LoadWarningCode::OptionalImaginaryMissing => "optional-imaginary-missing", LoadWarningCode::MissingStimulus => "missing-stimulus", LoadWarningCode::InvalidMetadata => "invalid-metadata", LoadWarningCode::MissingCalibration => "missing-calibration", @@ -688,13 +739,6 @@ pub(super) fn warning_report(warning: &LoadWarning) -> WarningReport { } } -fn domain_label(domain: Domain) -> &'static str { - match domain { - Domain::Time => "time", - Domain::Frequency => "frequency", - } -} - fn short_name(source: &str) -> String { Path::new(source) .file_name() diff --git a/crates/core/src/workflow/dataset.rs b/crates/core/src/workflow/dataset.rs index 745e744c..657ad27f 100644 --- a/crates/core/src/workflow/dataset.rs +++ b/crates/core/src/workflow/dataset.rs @@ -4,57 +4,52 @@ use crate::state::{Nmr2DDataset, NmrDataset}; pub fn dataset_from_loaded_acquisition( acquisition: Acquisition, acquisition_identity: plotx_io::AcquisitionIdentity, - nmr_origin: Option, equal_scale_homonuclear_2d_imports: bool, -) -> (Dataset, String) { - let (mut dataset, source) = dataset_from_acquisition_with_origin( - acquisition, - nmr_origin, - equal_scale_homonuclear_2d_imports, - ); +) -> Result<(Dataset, String), WorkflowError> { + let (mut dataset, source) = + convert_acquisition(acquisition, equal_scale_homonuclear_2d_imports)?; dataset.set_acquisition_identity(acquisition_identity); - (dataset, source) + Ok((dataset, source)) } -pub fn dataset_from_acquisition(acquisition: Acquisition) -> (Dataset, String) { +pub fn dataset_from_acquisition( + acquisition: Acquisition, +) -> Result<(Dataset, String), WorkflowError> { dataset_from_acquisition_with_equal_scale_preference(acquisition, true) } pub fn dataset_from_acquisition_with_equal_scale_preference( acquisition: Acquisition, equal_scale_homonuclear_2d_imports: bool, -) -> (Dataset, String) { - dataset_from_acquisition_with_origin(acquisition, None, equal_scale_homonuclear_2d_imports) +) -> Result<(Dataset, String), WorkflowError> { + convert_acquisition(acquisition, equal_scale_homonuclear_2d_imports) } -fn dataset_from_acquisition_with_origin( +fn convert_acquisition( acquisition: Acquisition, - nmr_origin: Option, equal_scale_homonuclear_2d_imports: bool, -) -> (Dataset, String) { - match acquisition { - Acquisition::D1(data) => { - let source = data.source.clone(); - ( - Dataset::Nmr(Box::new(NmrDataset::load_with_origin( - data, - nmr_origin.unwrap_or(plotx_io::NmrOrigin::Derived), - ))), - source, - ) - } - Acquisition::D2(data) => { - let source = data.source.clone(); - ( - Dataset::Nmr2D(Box::new( - Nmr2DDataset::load_with_origin_and_equal_scale_preference( - *data, - nmr_origin.unwrap_or(plotx_io::NmrOrigin::Derived), +) -> Result<(Dataset, String), WorkflowError> { + Ok(match acquisition { + Acquisition::Nmr(data) => { + let source = data.source().to_owned(); + let dataset = match data.axes().len() { + 1 => Dataset::Nmr(Box::new( + NmrDataset::load(data).map_err(WorkflowError::Nmr)?, + )), + 2 => Dataset::Nmr2D(Box::new( + Nmr2DDataset::load_with_equal_scale_preference( + data, equal_scale_homonuclear_2d_imports, - ), + ) + .map_err(WorkflowError::Nmr)?, )), - source, - ) + rank => { + return Err(WorkflowError::Nmr(format!( + "PlotX does not yet display rank-{rank} NMR data" + ))); + } + }; + (dataset, source) } Acquisition::Electrophysiology(data) => { let source = data.source.clone(); @@ -93,7 +88,7 @@ fn dataset_from_acquisition_with_origin( source, ) } - } + }) } pub fn dataset_title(dataset: &Dataset) -> String { @@ -101,7 +96,7 @@ pub fn dataset_title(dataset: &Dataset) -> String { Dataset::Nmr(nmr) => nmr .name .clone() - .unwrap_or_else(|| short_name(&nmr.data.source)), + .unwrap_or_else(|| short_name(nmr.data.source())), Dataset::Nmr2D(nmr) => nmr .name .clone() diff --git a/crates/core/src/workflow/nmr.rs b/crates/core/src/workflow/nmr.rs new file mode 100644 index 00000000..fdba111c --- /dev/null +++ b/crates/core/src/workflow/nmr.rs @@ -0,0 +1,79 @@ +use super::*; +use nmr::{axis::AxisDomain, dataset::DescriptorRef}; + +/// Inspect source data without constructing a processing recipe or running FFT. +/// The NMR branch uses the checked library model, including its logical NUS shape. +pub fn inspect_file(path: &Path) -> Result { + match plotx_io::nmr_bridge::read_options().detect(path) { + Ok(_) => {} + Err(error) if error.kind() == nmr::ReadErrorKind::Unrecognized => { + // A recognized but unsupported NMR input must never fall back to an + // older vendor reader. Detection of other scientific families stays here. + if matches!(plotx_io::detect_format(path)?, DataFormat::Nmr(_)) { + return Err(plotx_io::IoError::Nmr(Box::new(error)).into()); + } + let loaded = plotx_io::load_path(path)?; + return Ok(inspection_report( + loaded.format, + &loaded.provenance, + &loaded.warnings, + &loaded.acquisition, + )); + } + Err(error) => return Err(plotx_io::IoError::Nmr(Box::new(error)).into()), + } + let dataset = plotx_io::nmr_bridge::read(path, &mut nmr::ExecutionContext::default())?; + inspect_nmr_dataset(&dataset) +} + +pub fn inspect_nmr_dataset(dataset: &nmr::Dataset) -> Result { + let domains: Vec<_> = match dataset.descriptor() { + DescriptorRef::Raw(descriptor) => { + descriptor.axes().iter().map(|axis| axis.domain()).collect() + } + DescriptorRef::Processed(descriptor) => { + descriptor.axes().iter().map(|axis| axis.domain()).collect() + } + _ => { + return Err( + plotx_io::IoError::NmrConversion("unsupported NMR descriptor".into()).into(), + ); + } + }; + let domain = if domains.iter().all(|domain| *domain == AxisDomain::Time) { + "time" + } else if domains + .iter() + .all(|domain| *domain == AxisDomain::Frequency) + { + "frequency" + } else { + "mixed" + }; + let provenance = plotx_io::nmr_bridge::provenance(dataset)?; + let shape = plotx_io::nmr_bridge::shape(dataset)?; + Ok(InspectionReport { + schema: INSPECTION_SCHEMA, + format: plotx_io::nmr_bridge::format(dataset)?.as_str().to_owned(), + provenance: ProvenanceReport { + selected_path: provenance.selected_path, + data_path: provenance.data_path, + parameter_paths: provenance.parameter_paths, + companion_paths: provenance.companion_paths, + }, + dimension: DimensionReport { + count: shape.len(), + shape, + }, + domain: domain.to_owned(), + warnings: plotx_io::nmr_bridge::warnings(dataset) + .iter() + .map(warning_report) + .collect(), + electrophysiology: None, + afm: None, + mass_spectrometry: None, + xrd: None, + xps: None, + }) +} diff --git a/crates/core/src/workflow_tests.rs b/crates/core/src/workflow_tests.rs index c0cf6a07..172d5edf 100644 --- a/crates/core/src/workflow_tests.rs +++ b/crates/core/src/workflow_tests.rs @@ -1,17 +1,24 @@ use super::*; use num_complex::Complex64; +use plotx_io::Domain; fn acquisition() -> Acquisition { - Acquisition::D1(plotx_io::NmrData { - points: vec![Complex64::new(1.0, 0.0); 8], - domain: Domain::Frequency, - spectral_width_hz: 4_000.0, - observe_freq_mhz: 400.0, - carrier_ppm: 4.7, - nucleus: "1H".to_owned(), - source: "sample.dx".to_owned(), - group_delay: 0.0, - }) + Acquisition::Nmr( + plotx_io::NmrData { + points: (0..8) + .map(|i| Complex64::new(1.0 / (1.0 + (i as f64 - 3.0).powi(2)), 0.0)) + .collect(), + domain: Domain::Frequency, + spectral_width_hz: 4_000.0, + observe_freq_mhz: 400.0, + carrier_ppm: 4.7, + nucleus: "1H".to_owned(), + source: "sample.dx".to_owned(), + group_delay: 0.0, + } + .try_into() + .unwrap(), + ) } fn homonuclear_2d_acquisition() -> Acquisition { @@ -22,8 +29,10 @@ fn homonuclear_2d_acquisition() -> Acquisition { nucleus: "1H".to_owned(), group_delay: 0.0, }; - Acquisition::D2(Box::new(plotx_io::NmrData2D { - data: vec![Complex64::new(1.0, 0.0); 16], + let source = plotx_io::nmr_series::NmrSeriesSource::try_from(plotx_io::NmrData2D { + data: (0..16) + .map(|i| Complex64::new(1.0 / (1.0 + (i as f64 - 5.0).powi(2)), 0.0)) + .collect(), rows: 4, cols: 4, domain: Domain::Frequency, @@ -36,12 +45,14 @@ fn homonuclear_2d_acquisition() -> Acquisition { diffusion: None, nus: None, source: "cosy".to_owned(), - })) + }) + .unwrap(); + Acquisition::Nmr(source.source_dataset().clone()) } #[test] fn canonical_conversion_and_default_canvas_share_dataset_identity() { - let (dataset, source) = dataset_from_acquisition(acquisition()); + let (dataset, source) = dataset_from_acquisition(acquisition()).unwrap(); assert_eq!(dataset.kind_label(), "NMR 1D"); let canvas = build_default_canvas(&dataset, &source); assert_eq!(canvas.dataset_ids(), vec![dataset.resource_id()]); @@ -68,7 +79,8 @@ fn import_preference_seeds_one_persistent_plot_override() { let (dataset, source) = dataset_from_acquisition_with_equal_scale_preference( homonuclear_2d_acquisition(), preference, - ); + ) + .unwrap(); let canvas = build_default_canvas(&dataset, &source); let plot = canvas.objects[0].plot().expect("default plot"); assert_eq!(plot.axis_overrides.lock_aspect, Some(expected)); diff --git a/crates/core/tests/nmr_auto_nus.rs b/crates/core/tests/nmr_auto_nus.rs new file mode 100644 index 00000000..9001248b --- /dev/null +++ b/crates/core/tests/nmr_auto_nus.rs @@ -0,0 +1,247 @@ +//! Automatic import uses the library estimator and retains its execution evidence. +use nmr::axis::{AxisCoordinates, AxisDomain, AxisUnit}; +use nmr::raw::*; +use nmr::{Complex64, Dataset as NativeDataset}; +use plotx_core::state::{Dataset, Nmr2DDataset, PlotxApp}; +use plotx_io::nmr_view::NmrSource; +use std::sync::Arc; + +fn synthetic() -> NmrSource { + let (grid, observations, points) = (256, 96, 128); + let axis = |kind, points| { + RawAxis::new( + kind, + AxisDomain::Time, + Some(AxisUnit::Second), + points, + AxisCoordinates::Uniform { + start: 0.0, + step: 0.001, + }, + ) + .unwrap() + .with_group_delay(nmr::acquisition::GroupDelayState::NotApplicable) + .unwrap() + }; + let axes = vec![ + axis( + RawAxisKind::Indirect(IndirectComponents::Cartesian( + ComponentEvidence::user_constructed(), + )), + grid, + ), + axis(RawAxisKind::Direct(DirectSamples::Complex), points), + ]; + let coordinates: Vec<_> = (0..observations) + .map(|i| SamplingCoordinate::new(vec![i * 73 % grid])) + .collect(); + let mut rng = 7193_u64; + let mut uniform = || { + rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1); + ((rng >> 11) as f64 + 0.5) / (1u64 << 53) as f64 + }; + let traces = coordinates + .iter() + .enumerate() + .map(|(ordinal, coordinate)| { + let mut samples = Vec::new(); + let angle = + std::f64::consts::TAU * 17.0 * coordinate.as_slice()[0] as f64 / grid as f64; + for lane in [angle.cos(), angle.sin()] { + for j in 0..points { + let signal = Complex64::from_polar( + lane * (-6.0 * j as f64 / points as f64).exp(), + std::f64::consts::TAU * 21.0 * j as f64 / points as f64, + ); + let radius = 0.1 * (-2.0 * uniform().ln()).sqrt(); + samples.push( + signal + Complex64::from_polar(radius, std::f64::consts::TAU * uniform()), + ); + } + } + SparseTrace::new( + ObservationOrdinal::new(ordinal), + coordinate.clone(), + samples, + ) + }) + .collect(); + let raw = RawDatasetBuilder::new(axes, RawMetadata::default()) + .unwrap() + .sparse( + traces, + SamplingSchedule::new(vec![grid], coordinates).unwrap(), + ) + .unwrap(); + NmrSource::new(Arc::new(raw.into())).unwrap() +} + +fn assert_frequency(dataset: &Nmr2DDataset) { + assert!(dataset.is_true_2d()); + assert!(dataset.reconstruction_warning.is_none()); + assert!( + dataset.nus_request.is_none(), + "automatic sigma is result evidence, not an override" + ); + let processed = dataset.native_processed.dataset().as_processed().unwrap(); + assert!( + processed + .descriptor() + .axes() + .iter() + .all(|axis| axis.domain() == AxisDomain::Frequency) + ); + assert!( + dataset + .native_processed + .dataset() + .as_dense_processed() + .unwrap() + .samples() + .iter() + .all(|x| x.is_finite()) + ); +} + +fn assert_auto_evidence(dataset: &NativeDataset) { + let mut bytes = Vec::new(); + nmr::execution_report::write_json( + dataset.as_processed().unwrap(), + &[], + &mut bytes, + 16 * 1024 * 1024, + ) + .unwrap(); + let json = String::from_utf8(bytes).unwrap(); + assert!( + [ + "split-observation-cartesian-rms.v1", + "split-holdout-component-rms.v1", + "jeol-interior-split-rms.v1", + "jeol-interior-holdout-rms.v1", + ] + .iter() + .any(|method| json.contains(method)), + "{json}" + ); +} + +#[test] +fn automatic_import_and_offline_project_reopen_produce_2d() { + let dataset = Nmr2DDataset::load(synthetic()).unwrap(); + assert_frequency(&dataset); + assert_auto_evidence(dataset.native_processed.dataset()); + let mut app = PlotxApp::new(); + app.doc.datasets.push(Dataset::Nmr2D(Box::new(dataset))); + assert!(app.schedule_2d_processing(0, true)); + let started = std::time::Instant::now(); + while app.session.compute.is_busy() { + assert!(started.elapsed() < std::time::Duration::from_secs(30)); + std::thread::sleep(std::time::Duration::from_millis(5)); + app.poll_compute(); + } + app.poll_compute(); + assert_eq!(app.session.status, "Updated 2D processing."); + assert_frequency(app.doc.datasets[0].as_nmr2d().unwrap()); + let path = std::env::temp_dir().join(format!("plotx-auto-nus-{}.plotx", uuid::Uuid::new_v4())); + plotx_core::project::save_project(&app, &path, false).unwrap(); + let reopened = plotx_core::project::load_project(&path).unwrap(); + std::fs::remove_file(path).unwrap(); + let dataset = reopened.doc.datasets[0].as_nmr2d().unwrap(); + assert_frequency(dataset); + assert_auto_evidence(dataset.native_processed.dataset()); +} + +#[test] +fn failed_auto_estimation_keeps_observations_and_reports_the_reason() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../io/tests/fixtures/nmr/bruker-nus"); + let mut app = PlotxApp::new(); + app.load_from(&path); + assert_eq!(app.doc.datasets.len(), 1); + let data = app.doc.datasets[0].as_nmr2d().unwrap(); + assert!(!data.is_true_2d()); + assert!(data.native_processed.dataset().as_raw().is_some()); + let warning = data.reconstruction_warning.as_ref().unwrap(); + assert!( + warning.contains("automatic NUS noise requires"), + "{warning}" + ); + assert!(app.session.status.contains(warning)); + let loaded = plotx_core::workflow::load_dataset(&path).unwrap(); + assert!( + loaded + .inspection + .warnings + .iter() + .any(|warning| warning.code == "nmr-reconstruction-failed" + && warning.message.contains("automatic NUS noise requires")) + ); +} + +#[test] +fn automatic_noise_analysis_honors_cancellation_and_work_limits() { + use plotx_processing::nmr_bridge::{DelayPolicy, RecipeRange}; + use plotx_processing::nmr_execution::{execute_2d, processing_2d_work_ledger}; + let input = synthetic(); + let params = plotx_processing::Params2D::default_for(plotx_processing::Preset2D::Generic); + let token = nmr::CancellationToken::new(); + let cancel = token.clone(); + let mut saw_noise = false; + let mut progress = |event: nmr::execution::ProgressEvent| { + if event.stage == nmr::execution::ExecutionStage::NoiseEstimation { + saw_noise = true; + cancel.cancel(); + } + }; + let mut work = processing_2d_work_ledger(); + let mut context = nmr::ExecutionContext::new(&mut work) + .with_cancellation(token) + .with_progress(&mut progress); + let error = execute_2d( + &input, + ¶ms, + DelayPolicy::AxisEvidence, + RecipeRange::Base, + None, + &mut context, + ) + .unwrap_err(); + assert!(error.is_cancelled(), "{error}"); + assert!(saw_noise); + + let mut work = nmr::resource::WorkLedger::new(1); + let error = execute_2d( + &input, + ¶ms, + DelayPolicy::AxisEvidence, + RecipeRange::Base, + None, + &mut nmr::ExecutionContext::new(&mut work), + ) + .unwrap_err(); + assert!(!error.is_cancelled()); + assert!(error.to_string().contains("work"), "{error}"); +} + +#[test] +#[ignore = "requires a local NMR acquisition path in PLOTX_NUS_SAMPLE"] +fn local_nus_import_uses_the_default_plotx_recipe() { + let path = std::env::var_os("PLOTX_NUS_SAMPLE").expect("set PLOTX_NUS_SAMPLE"); + let mut app = PlotxApp::new(); + app.load_from(std::path::Path::new(&path)); + assert_eq!(app.doc.datasets.len(), 1, "{}", app.session.status); + let dataset = app.doc.datasets[0].as_nmr2d().unwrap(); + assert_frequency(dataset); + assert_auto_evidence(dataset.native_processed.dataset()); + println!( + "shape={:?}", + dataset + .native_processed + .dataset() + .as_processed() + .unwrap() + .descriptor() + .logical_shape() + ); +} diff --git a/crates/core/tests/slice.rs b/crates/core/tests/slice.rs index e52d61f7..b4ad61f1 100644 --- a/crates/core/tests/slice.rs +++ b/crates/core/tests/slice.rs @@ -4,7 +4,7 @@ use num_complex::Complex64; use plotx_analysis::peaks::{DetectParams, detect_peaks, estimate_noise}; use plotx_core::build_figure; use plotx_io::{Domain, NmrData}; -use plotx_processing::{AxisPipeline, process}; +use plotx_processing::AxisPipeline; use std::f64::consts::TAU; /// An ethanol-like ¹H FID (three singlets at 3:2:1), so the test needs no file. @@ -44,7 +44,16 @@ fn full_slice_load_process_figure_export() { let data = ethanol_fid(); assert_eq!(data.len(), 16_384); - let processed = process(&data, &AxisPipeline::default_1d(), true).unwrap(); + let source = plotx_io::nmr_view::NmrSource::try_from(data.clone()).unwrap(); + let processed = plotx_processing::nmr_execution::execute_1d( + &source, + &AxisPipeline::default_1d(), + plotx_processing::nmr_bridge::DelayPolicy::AxisEvidence, + plotx_processing::nmr_bridge::RecipeRange::All, + &mut nmr::ExecutionContext::default(), + ) + .unwrap() + .view; let spec = processed.as_frequency().unwrap(); assert_eq!(spec.len(), data.len()); @@ -65,7 +74,7 @@ fn full_slice_load_process_figure_export() { assert!(has(2.61), "missing OH peak; got {peaks:?}"); assert!(has(3.70), "missing CH2 peak; got {peaks:?}"); - let fig = build_figure(&data, spec, &[]); + let fig = build_figure(&data.try_into().unwrap(), spec, &[]); let svg = plotx_render::svg::export(&fig); assert!(svg.starts_with(" Dim { } } -/// A phase-modulated 2D FID with a single cross peak at `(f2_ppm, f1_ppm)`. +/// A Cartesian (States) 2D FID with a single cross peak at `(f2_ppm, f1_ppm)`. fn synthetic_hsqc(f2_ppm: f64, f1_ppm: f64, experiment: &str) -> NmrData2D { let (cols, rows) = (256usize, 128usize); let direct = dim(4000.0, 400.0, "1H"); @@ -41,26 +41,31 @@ fn synthetic_hsqc(f2_ppm: f64, f1_ppm: f64, experiment: &str) -> NmrData2D { let dt1 = 1.0 / indirect.spectral_width_hz; let f2_hz = f2_ppm * direct.observe_freq_mhz; let f1_hz = f1_ppm * indirect.observe_freq_mhz; - let mut data = Vec::with_capacity(rows * cols); + let mut data = Vec::with_capacity(2 * rows * cols); for k in 0..rows { let t1 = k as f64 * dt1; - for j in 0..cols { - let t2 = j as f64 * dt2; - let decay = (-t2 / 0.3 - t1 / 0.3).exp(); - data.push(Complex64::from_polar( - decay, - TAU * (f2_hz * t2 + f1_hz * t1), - )); + for component in 0..2 { + let indirect = if component == 0 { + (TAU * f1_hz * t1).cos() + } else { + (TAU * f1_hz * t1).sin() + }; + for j in 0..cols { + let t2 = j as f64 * dt2; + let decay = (-t2 / 0.3 - t1 / 0.3).exp(); + data.push(Complex64::from_polar(decay, TAU * f2_hz * t2) * indirect); + } } } + NmrData2D { data, - rows, + rows: 2 * rows, cols, domain: Domain::Time, direct, indirect, - quad: QuadMode::Complex, + quad: QuadMode::States, indirect_conjugate: false, experiment: Some(experiment.to_owned()), pseudo_axis: None, @@ -74,7 +79,7 @@ fn synthetic_hsqc(f2_ppm: f64, f1_ppm: f64, experiment: &str) -> NmrData2D { fn contour_slice_places_peak_and_exports_svg() { // Shifts stay inside the ±SW/2 Nyquist range (F1: 10 ppm × 100 MHz = 1 kHz). let data = synthetic_hsqc(3.0, 10.0, "hsqcetgpsisp"); - let preset = recommend_preset(&data); + let preset = recommend_preset(&data.clone().try_into().unwrap()); assert_eq!(preset, Preset2D::Hsqc); assert_eq!(preset.layout(), Layout2D::Ft); @@ -108,7 +113,7 @@ fn contour_slice_places_peak_and_exports_svg() { let mut app = PlotxApp::new(); app.doc .datasets - .push(Dataset::Nmr2D(Box::new(Nmr2DDataset::load(data)))); + .push(Dataset::Nmr2D(Box::new(Nmr2DDataset::load(data).unwrap()))); let mut canvas = CanvasDocument::new("contour".to_owned(), [120.0, 80.0]); let [width, height] = canvas.size_pt(); let object = app.build_plot_object( @@ -167,11 +172,9 @@ fn settle(app: &mut PlotxApp) { /// a genuine noise estimate rather than a hand-written grid. fn contour_page() -> (PlotxApp, ObjectId) { let mut app = PlotxApp::new(); - app.doc - .datasets - .push(Dataset::Nmr2D(Box::new(Nmr2DDataset::load( - synthetic_hsqc(3.0, 10.0, "hsqcetgpsisp"), - )))); + app.doc.datasets.push(Dataset::Nmr2D(Box::new( + Nmr2DDataset::load(synthetic_hsqc(3.0, 10.0, "hsqcetgpsisp")).unwrap(), + ))); let mut canvas = CanvasDocument::new("contour".to_owned(), [120.0, 80.0]); let [width, height] = canvas.size_pt(); let id = canvas.allocate_object_id(); @@ -639,7 +642,10 @@ fn stack_slice_exports_waterfall() { let mut data = synthetic_hsqc(3.0, 40.0, "ledbpgp2s"); // A DOSY-style hint should recommend the stacked (pseudo-2D) layout. data.experiment = Some("ledbpgp2s".into()); - assert_eq!(recommend_preset(&data).layout(), Layout2D::Stack); + assert_eq!( + recommend_preset(&data.clone().try_into().unwrap()).layout(), + Layout2D::Stack + ); let stack = match process_2d( &data, @@ -651,10 +657,25 @@ fn stack_slice_exports_waterfall() { Processed2D::Stack(s) => s, Processed2D::Ft(_) => panic!("expected Stack"), }; - assert_eq!(stack.increments(), data.rows); + // States stores two component rows for each logical increment. + assert_eq!(stack.increments(), data.rows / 2); let fig = build_stack_figure(&stack); assert!(!fig.series.is_empty()); let svg = plotx_render::svg::export(&fig); assert!(svg.contains(" Processed2D { + let source = plotx_io::nmr_series::NmrSeriesSource::try_from(data.clone()).unwrap(); + plotx_processing::nmr_execution::execute_2d( + source.source_dataset(), + params, + plotx_processing::nmr_bridge::DelayPolicy::AxisEvidence, + plotx_processing::nmr_bridge::RecipeRange::Base, + None, + &mut nmr::ExecutionContext::default(), + ) + .unwrap() + .view +} diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index bcdb7856..efcad4c9 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -11,6 +11,7 @@ name = "plotx_io" path = "src/lib.rs" [dependencies] +nmr.workspace = true num-complex.workspace = true thiserror.workspace = true zip.workspace = true diff --git a/crates/io/src/archive.rs b/crates/io/src/archive.rs index 1a403bd8..099a7fa1 100644 --- a/crates/io/src/archive.rs +++ b/crates/io/src/archive.rs @@ -76,10 +76,7 @@ fn scratch_dir() -> PathBuf { // is loaded as a unit and not descended into; any other directory is recursed; // loose JEOL and JCAMP-DX files are read individually. fn collect_acquisitions(dir: &Path, out: &mut ArchiveLoadResult) { - if crate::bruker::detect_processed(dir).is_some() - || crate::bruker::is_bruker_dir(dir) - || crate::varian::is_varian(dir) - { + if crate::nmr_bridge::is_candidate(dir) { match crate::load_path(dir) { Ok(result) => out.items.push(result), Err(error) => out.warnings.push(entry_warning(dir, error)), @@ -115,14 +112,6 @@ fn entry_warning(path: &Path, error: IoError) -> LoadWarning { } } -fn is_jdf(path: &Path) -> bool { - path.extension() - .and_then(|e| e.to_str()) - .map(|e| e.eq_ignore_ascii_case("jdf")) - .unwrap_or(false) - || crate::jeol::is_jdf(path) -} - fn is_supported_spectrum(path: &Path) -> bool { - is_jdf(path) || crate::jcamp_dx::has_jcamp_extension(path) + crate::nmr_bridge::is_candidate(path) } diff --git a/crates/io/src/bruker.rs b/crates/io/src/bruker.rs deleted file mode 100644 index 8843844e..00000000 --- a/crates/io/src/bruker.rs +++ /dev/null @@ -1,776 +0,0 @@ -//! Bruker TopSpin acquisition reader — a directory of a binary `fid` plus a -//! text `acqus` parameter file. - -use crate::{ - Acquisition, DataFormat, Dim, Domain, IoError, LoadResult, LoadWarning, LoadWarningCode, - NmrData, NmrData2D, NmrFormat, NmrInstrumentOrigin, NmrOrigin, NmrPortableMetadata, - NmrSourceFormat, NmrSourceParameters, Provenance, QuadMode, -}; -use num_complex::Complex64; -use sha2::{Digest, Sha256}; -use std::collections::HashMap; -use std::path::{Path, PathBuf}; - -// Each 1D FID within a `ser` file is padded so its byte length is a multiple of -// this block size (256 four-byte words). -const SER_BLOCK_BYTES: usize = 1024; - -mod processed; - -pub use processed::{detect_processed, load_processed}; - -/// A directory holding a Bruker acquisition: an `acqus` parameter file next to a -/// binary `fid` (1D) or `ser` (nD) data file. -pub fn is_bruker_dir(path: &Path) -> bool { - path.is_dir() - && path.join("acqus").is_file() - && (path.join("fid").is_file() || path.join("ser").is_file()) -} - -/// A Bruker acquisition selected either as its directory or directly as the -/// `fid`/`ser` data file inside it (whose parent holds the `acqus`). -pub fn is_bruker(path: &Path) -> bool { - if path.is_dir() { - return is_bruker_dir(path); - } - matches!( - path.file_name().and_then(|s| s.to_str()), - Some("fid" | "ser") - ) && path - .parent() - .map(|d| d.join("acqus").is_file()) - .unwrap_or(false) -} - -// Resolve a user-selected path to (acquisition dir, binary data file). A -// directory prefers `fid` over `ser`; a file is taken as-is with its parent as -// the acquisition dir. -fn resolve_bruker(path: &Path) -> (PathBuf, PathBuf) { - if path.is_dir() { - let fid = path.join("fid"); - let data = if fid.is_file() { fid } else { path.join("ser") }; - (path.to_path_buf(), data) - } else { - let dir = path - .parent() - .map(Path::to_path_buf) - .unwrap_or_else(|| PathBuf::from(".")); - (dir, path.to_path_buf()) - } -} - -// A readable dataset label for an acquisition dir `/`: the -// processed-data title (or the sample folder as a fallback), always tagged with -// the numeric expno since one sample folder holds many experiments. -fn source_prefix(dir: &Path) -> String { - let expno = dir.file_name().and_then(|s| s.to_str()); - let name = pdata_title(dir).or_else(|| { - dir.parent() - .and_then(Path::file_name) - .and_then(|s| s.to_str()) - .map(str::to_owned) - }); - match (name, expno) { - (Some(name), Some(expno)) => format!("{name} (expno {expno})"), - (Some(name), None) => name, - (None, Some(expno)) => expno.to_owned(), - (None, None) => "".to_owned(), - } -} - -fn acquisition_identity(dir: &Path, params: Option<&JcampParams>) -> crate::AcquisitionIdentity { - let source_label = dir - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or("Untitled NMR") - .to_owned(); - let subject = dir - .parent() - .and_then(Path::file_name) - .and_then(|value| value.to_str()) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_owned); - let acquisition = params - .and_then(|params| params.string("EXP").or_else(|| params.string("PULPROG"))) - .map(|value| { - value - .trim_matches(|c| c == '<' || c == '>') - .trim() - .to_owned() - }) - .filter(|value| !value.is_empty()); - crate::AcquisitionIdentity { - subject, - acquisition, - source_label, - } -} - -// The first non-empty line of a processed-data `title` file, preferring proc no. -// 1 and otherwise the lowest-numbered proc dir carrying a non-empty title. -fn pdata_title(dir: &Path) -> Option { - let mut procs: Vec = std::fs::read_dir(dir.join("pdata")) - .ok()? - .flatten() - .map(|e| e.path()) - .filter(|p| p.is_dir()) - .collect(); - procs.sort_by_key(|p| { - p.file_name() - .and_then(|s| s.to_str()) - .and_then(|s| s.parse::().ok()) - .unwrap_or(u64::MAX) - }); - procs.iter().find_map(|proc| { - let text = std::fs::read_to_string(proc.join("title")).ok()?; - text.lines() - .map(str::trim) - .find(|l| !l.is_empty()) - .map(str::to_owned) - }) -} - -pub fn read_bruker(path: &Path) -> Result { - let (dir, data_path) = resolve_bruker(path); - let acqus_path = dir.join("acqus"); - let params = JcampParams::parse(&std::fs::read_to_string(&acqus_path)?); - - // A `ser` file alongside an `acqu2s` is a 2D (or nD) acquisition. - let acqu2s_path = dir.join("acqu2s"); - let is_ser = data_path.file_name().and_then(|s| s.to_str()) == Some("ser"); - if is_ser && acqu2s_path.is_file() { - return read_bruker_2d(&dir, &data_path, ¶ms, &acqu2s_path) - .map(|d| Acquisition::D2(Box::new(d))); - } - - read_bruker_1d(&dir, &data_path, ¶ms).map(Acquisition::D1) -} - -pub fn load_raw(path: &Path) -> Result { - let (dir, data_path) = resolve_bruker(path); - let acqus = std::fs::read_to_string(dir.join("acqus"))?; - let params = JcampParams::parse(&acqus); - let mut parameter_paths = vec![dir.join("acqus")]; - if data_path.file_name().and_then(|s| s.to_str()) == Some("ser") && dir.join("acqu2s").is_file() - { - parameter_paths.push(dir.join("acqu2s")); - } - let acquisition = read_bruker(path)?; - let data_bytes = std::fs::read(&data_path)?; - let mut digest = Sha256::new(); - digest.update(b"fid\0"); - digest.update(&data_bytes); - digest.update(b"acqus\0"); - digest.update(acqus.as_bytes()); - let title = pdata_title(&dir); - if let Some(title) = &title { - digest.update(b"title\0"); - digest.update(title.as_bytes()); - } - let pulse_program = params - .string("PULPROG") - .map(|value| value.trim_matches(['<', '>']).to_owned()); - Ok(LoadResult::new( - acquisition, - acquisition_identity(&dir, Some(¶ms)), - DataFormat::Nmr(NmrFormat::BrukerRaw), - Provenance { - selected_path: path.to_path_buf(), - data_path, - parameter_paths, - companion_paths: Vec::new(), - }, - Vec::new(), - ) - .with_nmr_origin(NmrOrigin::Instrument { - instrument: NmrInstrumentOrigin { - format: NmrSourceFormat::BrukerRaw, - source_sha256: digest.finalize().into(), - portable: NmrPortableMetadata { - solvent: params - .string("SOLVENT") - .map(|value| value.trim_matches(['<', '>']).to_owned()), - temperature_k: params.f64("TE").filter(|value| value.is_finite()), - transients: params - .usize("NS") - .and_then(|value| u64::try_from(value).ok()), - pulse_sequence: pulse_program.clone(), - }, - parameters: NmrSourceParameters::Bruker { - acqus, - title, - pulse_program, - }, - }, - })) -} - -fn read_bruker_1d(dir: &Path, fid_path: &Path, params: &JcampParams) -> Result { - // TD counts individual real values, so complex points = TD/2. - let td = params.usize("TD").unwrap_or(0); - if td < 2 { - return Err(IoError::Unsupported(format!( - "acqus reports TD={td} (need at least one complex point)" - ))); - } - let n_complex = td / 2; - - let byte_order = match params.i64("BYTORDA").unwrap_or(0) { - 1 => Endian::Big, - _ => Endian::Little, - }; - let sample = match params.i64("DTYPA").unwrap_or(0) { - 2 => SampleFmt::F64, - _ => SampleFmt::I32, - }; - let stride = sample.size(); - - let bytes = std::fs::read(fid_path)?; - let need = n_complex - .checked_mul(2 * stride) - .ok_or_else(|| IoError::Unsupported("TD overflow".into()))?; - if bytes.len() < need { - return Err(IoError::Truncated { - offset: 0, - needed: need, - have: bytes.len(), - }); - } - - // De-interleave (re, im, re, im, …) into complex points. - let r = Reader { - bytes: &bytes, - endian: byte_order, - }; - let points: Vec = (0..n_complex) - .map(|i| { - let base = i * 2 * stride; - Complex64::new(r.real(base, sample), r.real(base + stride, sample)) - }) - .collect(); - - let spectral_width_hz = params - .f64("SW_h") - .filter(|v| v.is_finite() && *v > 0.0) - .unwrap_or(0.0); - // SFO1 is the observed (Larmor) frequency; BF1 is the 0-ppm reference. - let observe_freq_mhz = params - .f64("SFO1") - .or_else(|| params.f64("BF1")) - .filter(|v| v.is_finite() && *v > 1.0) - .unwrap_or(400.0); - let bf1 = params - .f64("BF1") - .filter(|v| v.is_finite() && *v > 1.0) - .unwrap_or(observe_freq_mhz); - let carrier_ppm = params.f64("O1").map(|o1| o1 / bf1).unwrap_or(0.0); - - let nucleus = params - .string("NUC1") - .map(|s| s.trim_matches(|c| c == '<' || c == '>').to_string()) - .filter(|s| !s.is_empty() && s != "off") - .unwrap_or_else(|| guess_nucleus(observe_freq_mhz)); - - let group_delay = group_delay(params); - - let source = format!( - "{} (Bruker TopSpin, {sample:?}, {n_complex} pts)", - source_prefix(dir) - ); - - Ok(NmrData { - points, - domain: Domain::Time, - spectral_width_hz: if spectral_width_hz > 0.0 { - spectral_width_hz - } else { - observe_freq_mhz * 20.0 - }, - observe_freq_mhz, - carrier_ppm, - nucleus, - source, - group_delay, - }) -} - -fn read_bruker_2d( - dir: &Path, - ser_path: &Path, - f2: &JcampParams, - acqu2s_path: &Path, -) -> Result { - let f1 = JcampParams::parse(&std::fs::read_to_string(acqu2s_path)?); - - let td2 = f2.usize("TD").unwrap_or(0); - let rows = f1.usize("TD").unwrap_or(0); - if td2 < 2 || rows == 0 { - return Err(IoError::Unsupported(format!( - "acqus/acqu2s report TD={td2}, TD1={rows} (need a non-empty 2D)" - ))); - } - let cols = td2 / 2; - - let byte_order = match f2.i64("BYTORDA").unwrap_or(0) { - 1 => Endian::Big, - _ => Endian::Little, - }; - let sample = match f2.i64("DTYPA").unwrap_or(0) { - 2 => SampleFmt::F64, - _ => SampleFmt::I32, - }; - let stride = sample.size(); - - // Each stored row is `td2` reals padded up to a whole number of blocks. - let row_bytes = td2 - .checked_mul(stride) - .map(|b| b.div_ceil(SER_BLOCK_BYTES) * SER_BLOCK_BYTES) - .ok_or_else(|| IoError::Unsupported("TD overflow".into()))?; - let bytes = std::fs::read(ser_path)?; - let need = rows - .checked_mul(row_bytes) - .ok_or_else(|| IoError::Unsupported("ser size overflow".into()))?; - if bytes.len() < need { - return Err(IoError::Truncated { - offset: 0, - needed: need, - have: bytes.len(), - }); - } - - let r = Reader { - bytes: &bytes, - endian: byte_order, - }; - let mut data = Vec::with_capacity(rows * cols); - for row in 0..rows { - let base = row * row_bytes; - for i in 0..cols { - let off = base + i * 2 * stride; - data.push(Complex64::new( - r.real(off, sample), - r.real(off + stride, sample), - )); - } - } - - let quad = match f1.i64("FnMODE").unwrap_or(0) { - 4 => QuadMode::States, - 5 => QuadMode::StatesTppi, - 6 => QuadMode::EchoAntiecho, - _ => QuadMode::Complex, - }; - - let direct = dim_from(f2, group_delay(f2)); - let indirect = dim_from(&f1, 0.0); - - let experiment = acquisition_identity(dir, Some(f2)).acquisition; - - let source = format!( - "{} (Bruker TopSpin 2D, {sample:?}, {cols}×{rows})", - source_prefix(dir) - ); - - Ok(NmrData2D { - data, - rows, - cols, - domain: Domain::Time, - direct, - indirect, - quad, - indirect_conjugate: false, - experiment, - pseudo_axis: None, - diffusion: None, - nus: None, - source, - }) -} - -fn dim_from(p: &JcampParams, group_delay: f64) -> Dim { - let observe_freq_mhz = p - .f64("SFO1") - .or_else(|| p.f64("BF1")) - .filter(|v| v.is_finite() && *v > 1.0) - .unwrap_or(400.0); - let bf1 = p - .f64("BF1") - .filter(|v| v.is_finite() && *v > 1.0) - .unwrap_or(observe_freq_mhz); - let spectral_width_hz = p - .f64("SW_h") - .filter(|v| v.is_finite() && *v > 0.0) - .unwrap_or(observe_freq_mhz * 20.0); - let nucleus = p - .string("NUC1") - .map(|s| s.trim_matches(|c| c == '<' || c == '>').to_string()) - .filter(|s| !s.is_empty() && s != "off") - .unwrap_or_else(|| guess_nucleus(observe_freq_mhz)); - Dim { - spectral_width_hz, - observe_freq_mhz, - carrier_ppm: p.f64("O1").map(|o1| o1 / bf1).unwrap_or(0.0), - nucleus, - group_delay, - } -} - -// Group delay in points: an explicit `GRPDLY` when present, else a lookup from -// the (`DSPFVS`, `DECIM`) table for older data. -fn group_delay(params: &JcampParams) -> f64 { - if let Some(g) = params.f64("GRPDLY") - && g.is_finite() - && g >= 0.0 - { - return g; - } - let dspfvs = params.i64("DSPFVS").unwrap_or(-1); - let decim = params.i64("DECIM").unwrap_or(-1); - grpdly_from_table(dspfvs, decim).unwrap_or(0.0) -} - -fn guess_nucleus(mhz: f64) -> String { - if mhz > 300.0 { - "1H".into() - } else if mhz > 90.0 { - "13C".into() - } else { - "X".into() - } -} - -// Parsed JCAMP-DX `acqus`: scalar `##$KEY= value` entries. Array-valued -// parameters (`##$KEY= (0..N)` then value lines) are skipped. -struct JcampParams { - map: HashMap, -} - -impl JcampParams { - fn parse(text: &str) -> Self { - let mut map = HashMap::new(); - for line in text.lines() { - let Some(rest) = line.strip_prefix("##$") else { - continue; - }; - let Some((key, val)) = rest.split_once('=') else { - continue; - }; - let val = val.trim(); - // Array declarations like "(0..15)" carry their payload on following - // lines, which are not consumed. - if val.starts_with('(') { - continue; - } - map.insert(key.trim().to_string(), val.to_string()); - } - Self { map } - } - - fn string(&self, key: &str) -> Option { - self.map.get(key).cloned() - } - - fn f64(&self, key: &str) -> Option { - self.map.get(key)?.parse().ok() - } - - fn i64(&self, key: &str) -> Option { - self.map.get(key)?.parse().ok() - } - - fn usize(&self, key: &str) -> Option { - self.map.get(key)?.parse().ok() - } -} - -// Standard Bruker group-delay table for older data (`DSPFVS` 10–13), keyed by -// `DECIM`. -#[allow(clippy::excessive_precision)] -fn grpdly_from_table(dspfvs: i64, decim: i64) -> Option { - let row: &[(i64, f64)] = match dspfvs { - 10 => &[ - (2, 44.75), - (3, 33.5), - (4, 66.625), - (6, 59.083333333333333), - (8, 68.5625), - (12, 60.375), - (16, 69.53125), - (24, 61.020833333333333), - (32, 70.015625), - (48, 61.34375), - (64, 70.2578125), - (96, 61.505208333333333), - (128, 70.37890625), - (192, 61.5859375), - (256, 70.439453125), - (384, 61.626302083333333), - (512, 70.4697265625), - (768, 61.646484375), - (1024, 70.48486328125), - (1536, 61.656575520833333), - (2048, 70.4924316406250), - ], - 11 => &[ - (2, 46.0), - (3, 36.5), - (4, 48.0), - (6, 50.166666666666667), - (8, 53.25), - (12, 69.5), - (16, 72.25), - (24, 70.166666666666667), - (32, 72.75), - (48, 70.5), - (64, 73.0), - (96, 70.666666666666667), - (128, 72.5), - (192, 71.333333333333333), - (256, 72.25), - (384, 71.666666666666667), - (512, 72.125), - (768, 71.833333333333333), - (1024, 72.0625), - (1536, 71.916666666666667), - (2048, 72.03125), - ], - 12 => &[ - (2, 46.0), - (3, 36.5), - (4, 48.0), - (6, 50.166666666666667), - (8, 53.25), - (12, 69.5), - (16, 71.625), - (24, 70.166666666666667), - (32, 72.125), - (48, 70.5), - (64, 72.375), - (96, 70.666666666666667), - (128, 72.5), - (192, 71.333333333333333), - (256, 72.25), - (384, 71.666666666666667), - (512, 72.125), - (768, 71.833333333333333), - (1024, 72.0625), - (1536, 71.916666666666667), - (2048, 72.03125), - ], - 13 => &[ - (2, 2.75), - (3, 2.8333333333333333), - (4, 2.875), - (6, 2.9166666666666667), - (8, 2.9375), - (12, 2.9583333333333333), - (16, 2.96875), - (24, 2.9791666666666667), - (32, 2.984375), - (48, 2.9895833333333333), - (64, 2.9921875), - (96, 2.9947916666666667), - ], - _ => return None, - }; - row.iter().find(|(d, _)| *d == decim).map(|(_, g)| *g) -} - -#[derive(Debug, Clone, Copy)] -enum SampleFmt { - I32, - F64, -} - -impl SampleFmt { - #[inline] - fn size(self) -> usize { - match self { - SampleFmt::I32 => 4, - SampleFmt::F64 => 8, - } - } -} - -#[derive(Debug, Clone, Copy)] -enum Endian { - Big, - Little, -} - -struct Reader<'a> { - bytes: &'a [u8], - endian: Endian, -} - -impl Reader<'_> { - fn real(&self, at: usize, fmt: SampleFmt) -> f64 { - match fmt { - SampleFmt::I32 => { - let b: [u8; 4] = self.bytes[at..at + 4].try_into().unwrap(); - let v = match self.endian { - Endian::Big => i32::from_be_bytes(b), - Endian::Little => i32::from_le_bytes(b), - }; - v as f64 - } - SampleFmt::F64 => { - let b: [u8; 8] = self.bytes[at..at + 8].try_into().unwrap(); - match self.endian { - Endian::Big => f64::from_be_bytes(b), - Endian::Little => f64::from_le_bytes(b), - } - } - } - } -} - -#[cfg(test)] -#[path = "bruker/parser_tests.rs"] -mod parser_tests; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn accepts_dir_or_fid_file() { - let dir = std::env::temp_dir().join(format!("plotx_bruker_{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write( - dir.join("acqus"), - "##$TD= 4\n##$DTYPA= 2\n##$BYTORDA= 0\n##$SW_h= 1000\n##$SFO1= 400\n##$BF1= 400\n", - ) - .unwrap(); - let mut fid = Vec::new(); - for v in [1.0f64, 2.0, 3.0, 4.0] { - fid.extend_from_slice(&v.to_le_bytes()); - } - std::fs::write(dir.join("fid"), &fid).unwrap(); - - assert!(is_bruker(&dir)); - assert!(is_bruker(&dir.join("fid"))); - - let unwrap1d = |a: Acquisition| match a { - Acquisition::D1(d) => d, - Acquisition::D2(_) => panic!("expected 1D"), - Acquisition::Electrophysiology(_) => panic!("expected NMR"), - Acquisition::Afm(_) => panic!("expected NMR"), - Acquisition::MassSpec(_) | Acquisition::Xrd(_) => panic!("expected NMR"), - Acquisition::Xps(_) => panic!("expected NMR"), - }; - let from_dir = unwrap1d(read_bruker(&dir).unwrap()); - let from_file = unwrap1d(read_bruker(&dir.join("fid")).unwrap()); - assert_eq!( - from_dir.points, - vec![Complex64::new(1.0, 2.0), Complex64::new(3.0, 4.0)] - ); - assert_eq!(from_dir.points, from_file.points); - - let mut loaded = load_raw(&dir).unwrap(); - let origin = loaded.take_nmr_origin().unwrap(); - let instrument = origin.instrument().expect("raw Bruker origin"); - assert_eq!(instrument.format, NmrSourceFormat::BrukerRaw); - assert!(matches!( - instrument.parameters, - NmrSourceParameters::Bruker { .. } - )); - - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[test] - fn reads_a_hand_built_2d_ser() { - let dir = std::env::temp_dir().join(format!("plotx_bruker2d_{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write( - dir.join("acqus"), - "##$TD= 4\n##$DTYPA= 2\n##$BYTORDA= 0\n##$SW_h= 1000\n##$SFO1= 600\n##$BF1= 600\n\ - ##$O1= 1200\n##$NUC1= <1H>\n##$GRPDLY= 0\n##$PULPROG= \n", - ) - .unwrap(); - std::fs::write( - dir.join("acqu2s"), - "##$TD= 2\n##$SW_h= 1000\n##$SFO1= 600\n##$BF1= 600\n##$O1= 1200\n##$NUC1= <1H>\n\ - ##$FnMODE= 4\n", - ) - .unwrap(); - - // Two rows; each 1D FID (4 reals) padded to a 1024-byte block. - let mut ser = vec![0u8; 2 * SER_BLOCK_BYTES]; - for (row, vals) in [[1.0f64, 2.0, 3.0, 4.0], [5.0, 6.0, 7.0, 8.0]] - .iter() - .enumerate() - { - for (i, v) in vals.iter().enumerate() { - let off = row * SER_BLOCK_BYTES + i * 8; - ser[off..off + 8].copy_from_slice(&v.to_le_bytes()); - } - } - std::fs::write(dir.join("ser"), &ser).unwrap(); - - let two = match read_bruker(&dir).unwrap() { - Acquisition::D2(d) => *d, - Acquisition::D1(_) => panic!("expected 2D"), - Acquisition::Electrophysiology(_) => panic!("expected NMR"), - Acquisition::Afm(_) => panic!("expected NMR"), - Acquisition::MassSpec(_) | Acquisition::Xrd(_) => panic!("expected NMR"), - Acquisition::Xps(_) => panic!("expected NMR"), - }; - assert_eq!((two.cols, two.rows), (2, 2)); - assert_eq!( - two.data, - vec![ - Complex64::new(1.0, 2.0), - Complex64::new(3.0, 4.0), - Complex64::new(5.0, 6.0), - Complex64::new(7.0, 8.0), - ] - ); - assert_eq!(two.quad, QuadMode::States); - assert!(!two.indirect_conjugate); - assert_eq!(two.experiment.as_deref(), Some("cosygpppqf")); - assert!((two.direct.carrier_ppm - 2.0).abs() < 1e-9); - - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[test] - fn source_prefix_prefers_title_then_sample_folder() { - let base = std::env::temp_dir().join(format!("plotx_bruker_name_{}", std::process::id())); - let _ = std::fs::remove_dir_all(&base); - let expno = base.join("Sucrose").join("3"); - std::fs::create_dir_all(expno.join("pdata").join("1")).unwrap(); - std::fs::create_dir_all(expno.join("pdata").join("2")).unwrap(); - - // An empty proc-1 title falls through to the next-lowest proc. - std::fs::write(expno.join("pdata").join("1").join("title"), " \n").unwrap(); - std::fs::write( - expno.join("pdata").join("2").join("title"), - "\nProton in CDCl3\n", - ) - .unwrap(); - assert_eq!(source_prefix(&expno), "Proton in CDCl3 (expno 3)"); - - // A non-empty proc-1 title wins. - std::fs::write(expno.join("pdata").join("1").join("title"), "Sucrose 1H\n").unwrap(); - assert_eq!(source_prefix(&expno), "Sucrose 1H (expno 3)"); - - // With no titles at all, the sample folder names the dataset. - std::fs::remove_file(expno.join("pdata").join("1").join("title")).unwrap(); - std::fs::remove_file(expno.join("pdata").join("2").join("title")).unwrap(); - assert_eq!(source_prefix(&expno), "Sucrose (expno 3)"); - - let params = JcampParams::parse("##$EXP= \n##$PULPROG= \n"); - let identity = acquisition_identity(&expno, Some(¶ms)); - assert_eq!(identity.subject.as_deref(), Some("Sucrose")); - assert_eq!(identity.acquisition.as_deref(), Some("COSY")); - assert_eq!(identity.source_label, "3"); - - std::fs::remove_dir_all(&base).unwrap(); - } -} diff --git a/crates/io/src/bruker/parser_tests.rs b/crates/io/src/bruker/parser_tests.rs deleted file mode 100644 index 99813649..00000000 --- a/crates/io/src/bruker/parser_tests.rs +++ /dev/null @@ -1,56 +0,0 @@ -use super::*; - -#[test] -fn parses_scalar_and_skips_arrays() { - let text = "\ -##TITLE= params -##$TD= 16384 -##$NUC1= <1H> -##$SW_h= 9615.38461538464 -##$GRPDLY= 76 -##$XGF= (0..3) -0 0 0 0 -##$O1= 2820.61 -"; - let params = JcampParams::parse(text); - assert_eq!(params.usize("TD"), Some(16384)); - assert_eq!(params.string("NUC1").as_deref(), Some("<1H>")); - assert_eq!(params.f64("GRPDLY"), Some(76.0)); - assert_eq!(params.f64("O1"), Some(2820.61)); - assert_eq!(params.string("XGF"), None); -} - -#[test] -fn group_delay_prefers_explicit_grpdly() { - let params = JcampParams::parse("##$GRPDLY= 67.98\n##$DSPFVS= 21\n##$DECIM= 2080\n"); - assert!((group_delay(¶ms) - 67.98).abs() < 1e-9); -} - -#[test] -fn group_delay_falls_back_to_table() { - let params = JcampParams::parse("##$GRPDLY= -1\n##$DSPFVS= 12\n##$DECIM= 16\n"); - assert!((group_delay(¶ms) - 71.625).abs() < 1e-9); -} - -#[test] -fn deinterleaves_complex_f64() { - // TD = 4 real values → 2 complex points: (1+2i), (3+4i). - let mut buffer = Vec::new(); - for value in [1.0f64, 2.0, 3.0, 4.0] { - buffer.extend_from_slice(&value.to_le_bytes()); - } - let reader = Reader { - bytes: &buffer, - endian: Endian::Little, - }; - let first = Complex64::new( - reader.real(0, SampleFmt::F64), - reader.real(8, SampleFmt::F64), - ); - let second = Complex64::new( - reader.real(16, SampleFmt::F64), - reader.real(24, SampleFmt::F64), - ); - assert_eq!(first, Complex64::new(1.0, 2.0)); - assert_eq!(second, Complex64::new(3.0, 4.0)); -} diff --git a/crates/io/src/bruker/processed.rs b/crates/io/src/bruker/processed.rs deleted file mode 100644 index 7d3e669a..00000000 --- a/crates/io/src/bruker/processed.rs +++ /dev/null @@ -1,334 +0,0 @@ -//! Bruker processed-data reader (`1r`/`1i`/`2rr`) built from the `procs` -//! parameter files under a `pdata` directory. - -use super::*; -use crate::NmrFormat; - -/// Identify a processed payload from a proc directory, a `1r`/`1i`/`2rr` -/// file, a `pdata` directory, or an experiment directory containing `pdata`. -pub fn detect_processed(path: &Path) -> Option { - let resolved = resolve_processed(path)?; - Some(if resolved.two_d { - DataFormat::Nmr(NmrFormat::BrukerProcessed2D) - } else { - DataFormat::Nmr(NmrFormat::BrukerProcessed1D) - }) -} - -struct ProcessedPaths { - proc_dir: PathBuf, - data_path: PathBuf, - two_d: bool, -} - -fn resolve_processed(path: &Path) -> Option { - if path.is_file() { - let name = path.file_name()?.to_str()?; - if matches!(name, "1r" | "1i" | "2rr") { - let proc_dir = path.parent()?.to_path_buf(); - let two_d = name == "2rr" || proc_dir.join("2rr").is_file(); - let data_path = if two_d { - proc_dir.join("2rr") - } else { - proc_dir.join("1r") - }; - return (proc_dir.join("procs").is_file() && data_path.is_file()).then_some( - ProcessedPaths { - proc_dir, - data_path, - two_d, - }, - ); - } - return None; - } - if !path.is_dir() { - return None; - } - - if path.join("procs").is_file() { - return processed_in_proc_dir(path); - } - let pdata = if path.file_name().and_then(|s| s.to_str()) == Some("pdata") { - path.to_path_buf() - } else { - path.join("pdata") - }; - let mut proc_dirs: Vec = std::fs::read_dir(pdata) - .ok()? - .filter_map(|e| e.ok().map(|e| e.path())) - .filter(|p| p.is_dir()) - .collect(); - proc_dirs.sort_by_key(|p| { - let procno = p - .file_name() - .and_then(|s| s.to_str()) - .and_then(|s| s.parse::().ok()) - .unwrap_or(u64::MAX); - (procno != 1, procno, p.clone()) - }); - proc_dirs.iter().find_map(|p| processed_in_proc_dir(p)) -} - -fn processed_in_proc_dir(proc_dir: &Path) -> Option { - if !proc_dir.join("procs").is_file() { - return None; - } - let (data_path, two_d) = if proc_dir.join("2rr").is_file() && proc_dir.join("proc2s").is_file() - { - (proc_dir.join("2rr"), true) - } else if proc_dir.join("1r").is_file() { - (proc_dir.join("1r"), false) - } else { - return None; - }; - Some(ProcessedPaths { - proc_dir: proc_dir.to_path_buf(), - data_path, - two_d, - }) -} - -pub fn load_processed(path: &Path) -> Result { - let resolved = resolve_processed(path).ok_or_else(|| { - IoError::Unsupported(format!( - "no complete Bruker processed dataset at {}", - path.display() - )) - })?; - let procs_path = resolved.proc_dir.join("procs"); - let procs = JcampParams::parse(&std::fs::read_to_string(&procs_path)?); - let mut warnings = Vec::new(); - let (mut acquisition, format, mut parameter_paths) = if resolved.two_d { - let proc2s_path = resolved.proc_dir.join("proc2s"); - let proc2s = JcampParams::parse(&std::fs::read_to_string(&proc2s_path)?); - ( - Acquisition::D2(Box::new(read_processed_2d( - &resolved.proc_dir, - &resolved.data_path, - &procs, - &proc2s, - )?)), - DataFormat::Nmr(NmrFormat::BrukerProcessed2D), - vec![procs_path, proc2s_path], - ) - } else { - let imag_path = resolved.proc_dir.join("1i"); - if !imag_path.is_file() { - warnings.push(LoadWarning { - code: LoadWarningCode::OptionalImaginaryMissing, - message: "Bruker 1i is absent; phase correction is limited to the real channel" - .into(), - path: Some(imag_path.clone()), - }); - } - ( - Acquisition::D1(read_processed_1d( - &resolved.proc_dir, - &resolved.data_path, - imag_path.is_file().then_some(imag_path.as_path()), - &procs, - )?), - DataFormat::Nmr(NmrFormat::BrukerProcessed1D), - vec![procs_path], - ) - }; - let experiment_dir = resolved.proc_dir.parent().and_then(Path::parent); - let acquisition_identity = experiment_dir - .map(|dir| { - let params = std::fs::read_to_string(dir.join("acqus")) - .ok() - .map(|text| JcampParams::parse(&text)); - super::acquisition_identity(dir, params.as_ref()) - }) - .unwrap_or_else(|| crate::AcquisitionIdentity::from_path(path)); - if let Acquisition::D2(data) = &mut acquisition { - data.experiment - .clone_from(&acquisition_identity.acquisition); - } - if let Some(acqus) = acquisition_params_for(&resolved.proc_dir, "acqus") { - parameter_paths.push(acqus); - } - if resolved.two_d - && let Some(acqu2s) = acquisition_params_for(&resolved.proc_dir, "acqu2s") - { - parameter_paths.push(acqu2s); - } - Ok(LoadResult::new( - acquisition, - acquisition_identity, - format, - Provenance { - selected_path: path.to_path_buf(), - data_path: resolved.data_path, - parameter_paths, - companion_paths: Vec::new(), - }, - warnings, - )) -} - -fn acquisition_params_for(proc_dir: &Path, name: &str) -> Option { - proc_dir - .parent()? - .parent() - .map(|experiment| experiment.join(name)) - .filter(|p| p.is_file()) -} - -fn processed_sample(params: &JcampParams) -> SampleFmt { - match params.i64("DTYPP").unwrap_or(0) { - 2 => SampleFmt::F64, - _ => SampleFmt::I32, - } -} - -fn processed_endian(params: &JcampParams) -> Endian { - match params.i64("BYTORDP").unwrap_or(0) { - 1 => Endian::Big, - _ => Endian::Little, - } -} - -fn read_processed_values( - path: &Path, - count: usize, - params: &JcampParams, -) -> Result, IoError> { - let sample = processed_sample(params); - let bytes = std::fs::read(path)?; - let need = count - .checked_mul(sample.size()) - .ok_or_else(|| IoError::Unsupported("processed SI overflow".into()))?; - if bytes.len() < need { - return Err(IoError::Truncated { - offset: 0, - needed: need, - have: bytes.len(), - }); - } - let reader = Reader { - bytes: &bytes, - endian: processed_endian(params), - }; - let scale = 2.0f64.powi( - params - .i64("NC_proc") - .unwrap_or(0) - .clamp(i32::MIN as i64, i32::MAX as i64) as i32, - ); - Ok((0..count) - .map(|i| reader.real(i * sample.size(), sample) * scale) - .collect()) -} - -fn processed_dim(params: &JcampParams) -> Dim { - let observe_freq_mhz = params - .f64("SF") - .filter(|v| v.is_finite() && *v > 1.0) - .unwrap_or(400.0); - let spectral_width_hz = params - .f64("SW_p") - .filter(|v| v.is_finite() && *v > 0.0) - .unwrap_or(observe_freq_mhz * 20.0); - let offset = params.f64("OFFSET").unwrap_or(0.0); - let nucleus = params - .string("AXNUC") - .map(|s| s.trim_matches(|c| c == '<' || c == '>').to_string()) - .filter(|s| !s.is_empty() && s != "off") - .unwrap_or_else(|| guess_nucleus(observe_freq_mhz)); - Dim { - spectral_width_hz, - observe_freq_mhz, - carrier_ppm: offset - spectral_width_hz / (2.0 * observe_freq_mhz), - nucleus, - group_delay: 0.0, - } -} - -fn read_processed_1d( - proc_dir: &Path, - real_path: &Path, - imag_path: Option<&Path>, - params: &JcampParams, -) -> Result { - let si = params - .usize("SI") - .filter(|n| *n > 0) - .ok_or_else(|| IoError::Unsupported("Bruker procs has no positive SI".into()))?; - let real = read_processed_values(real_path, si, params)?; - let imag = imag_path - .map(|p| read_processed_values(p, si, params)) - .transpose()?; - let mut points: Vec = (0..si) - .map(|i| Complex64::new(real[i], imag.as_ref().map_or(0.0, |v| v[i]))) - .collect(); - points.reverse(); - let dim = processed_dim(params); - Ok(NmrData { - points, - domain: Domain::Frequency, - spectral_width_hz: dim.spectral_width_hz, - observe_freq_mhz: dim.observe_freq_mhz, - carrier_ppm: dim.carrier_ppm, - nucleus: dim.nucleus, - source: format!( - "{} (Bruker TopSpin processed 1D, {si} pts)", - processed_source_prefix(proc_dir) - ), - group_delay: 0.0, - }) -} - -fn read_processed_2d( - proc_dir: &Path, - data_path: &Path, - f2: &JcampParams, - f1: &JcampParams, -) -> Result { - let cols = f2 - .usize("SI") - .filter(|n| *n > 0) - .ok_or_else(|| IoError::Unsupported("Bruker procs has no positive SI".into()))?; - let rows = f1 - .usize("SI") - .filter(|n| *n > 0) - .ok_or_else(|| IoError::Unsupported("Bruker proc2s has no positive SI".into()))?; - let stored = read_processed_values( - data_path, - rows.checked_mul(cols) - .ok_or_else(|| IoError::Unsupported("processed 2D SI overflow".into()))?, - f2, - )?; - let mut data = Vec::with_capacity(stored.len()); - for r in (0..rows).rev() { - for c in (0..cols).rev() { - data.push(Complex64::new(stored[r * cols + c], 0.0)); - } - } - Ok(NmrData2D { - data, - rows, - cols, - domain: Domain::Frequency, - direct: processed_dim(f2), - indirect: processed_dim(f1), - quad: QuadMode::Complex, - indirect_conjugate: false, - experiment: None, - pseudo_axis: None, - diffusion: None, - nus: None, - source: format!( - "{} (Bruker TopSpin processed 2D, {cols}x{rows})", - processed_source_prefix(proc_dir) - ), - }) -} - -fn processed_source_prefix(proc_dir: &Path) -> String { - let experiment = proc_dir.parent().and_then(Path::parent); - experiment - .map(source_prefix) - .unwrap_or_else(|| proc_dir.display().to_string()) -} diff --git a/crates/io/src/jcamp_dx.rs b/crates/io/src/jcamp_dx.rs deleted file mode 100644 index 1005bc08..00000000 --- a/crates/io/src/jcamp_dx.rs +++ /dev/null @@ -1,746 +0,0 @@ -//! Strict JCAMP-DX reader for one-dimensional, frequency-domain NMR spectra. -//! -//! This module deliberately owns the JCAMP label-record and ASDF semantics. It -//! is not related to Bruker's similarly shaped parameter files. - -use crate::{Acquisition, DataFormat, Domain, IoError, LoadResult, NmrData, NmrFormat, Provenance}; -use num_complex::Complex64; -use std::collections::HashMap; -use std::path::Path; - -#[derive(Debug, thiserror::Error)] -pub enum JcampDxError { - #[error("JCAMP-DX input is not valid UTF-8/ASCII text")] - InvalidTextEncoding, - #[error("malformed JCAMP-DX label record on line {line}: {detail}")] - MalformedRecord { line: usize, detail: String }, - #[error("required JCAMP-DX label ##{0}= is missing")] - MissingLabel(&'static str), - #[error("duplicate JCAMP-DX label ##{label}= is not valid for a single spectrum")] - DuplicateLabel { label: String }, - #[error("JCAMP-DX LINK/compound files are not supported")] - LinkDataset, - #[error("JCAMP-DX NTUPLES data are not supported")] - NtuplesDataset, - #[error("JCAMP-DX file contains more than one spectrum")] - MultipleSpectra, - #[error("unsupported JCAMP-DX DATA TYPE: {0}")] - UnsupportedDataType(String), - #[error("unsupported JCAMP-DX table declaration: {0}")] - UnsupportedTable(String), - #[error("unsupported JCAMP-DX {axis} unit: {unit}")] - UnsupportedUnit { axis: &'static str, unit: String }, - #[error("invalid value for JCAMP-DX ##{label}=: {value}")] - InvalidMetadata { label: &'static str, value: String }, - #[error("malformed JCAMP-DX XYDATA on line {line}: {detail}")] - MalformedData { line: usize, detail: String }, - #[error("JCAMP-DX invalid-data ordinate '?' is unsupported (line {line})")] - InvalidOrdinate { line: usize }, - #[error("JCAMP-DX X-sequence check failed on line {line}: expected {expected}, found {found}")] - XSequence { - line: usize, - expected: f64, - found: f64, - }, - #[error( - "JCAMP-DX DIF checkpoint failed on line {line}: expected ordinate {expected}, found {found}" - )] - Checkpoint { - line: usize, - expected: f64, - found: f64, - }, - #[error("JCAMP-DX decoded {actual} points, but ##NPOINTS= declares {declared}")] - PointCount { declared: usize, actual: usize }, -} - -#[derive(Debug)] -struct Document { - fields: HashMap, - xy_declaration: String, - data_lines: Vec<(usize, String)>, -} - -#[derive(Debug, Clone, Copy)] -enum XUnit { - Ppm, - Hz, -} - -#[derive(Debug, Clone, Copy)] -enum EncodedValue { - Actual(f64), - Difference(f64), - Duplicate(usize), - Invalid, -} - -#[derive(Debug, Clone, Copy)] -enum RepeatBasis { - Actual(f64), - Difference(f64), -} - -/// True for the registered JCAMP-DX filename extensions. -pub fn has_jcamp_extension(path: &Path) -> bool { - path.extension() - .and_then(|extension| extension.to_str()) - .map(|extension| { - matches!( - extension.to_ascii_lowercase().as_str(), - "dx" | "jdx" | "jcamp" - ) - }) - .unwrap_or(false) -} - -/// Load one standard JCAMP-DX 1D NMR spectrum with complete provenance. -pub fn load(path: &Path) -> Result { - let bytes = std::fs::read(path)?; - let acquisition = parse_bytes(&bytes, path.to_string_lossy().as_ref())?; - Ok(LoadResult::new( - acquisition, - crate::AcquisitionIdentity::from_path(path), - DataFormat::Nmr(NmrFormat::JcampDx1D), - Provenance { - selected_path: path.to_path_buf(), - data_path: path.to_path_buf(), - parameter_paths: Vec::new(), - companion_paths: Vec::new(), - }, - Vec::new(), - )) -} - -fn parse_bytes(bytes: &[u8], source: &str) -> Result { - let text = std::str::from_utf8(bytes).map_err(|_| JcampDxError::InvalidTextEncoding)?; - if !text.is_ascii() { - return Err(JcampDxError::InvalidTextEncoding); - } - let document = parse_document(text)?; - parse_spectrum(document, source).map(Acquisition::D1) -} - -fn parse_document(text: &str) -> Result { - let mut fields = HashMap::new(); - let mut xy_declaration = None; - let mut data_lines = Vec::new(); - let mut in_xydata = false; - let mut title_count = 0usize; - - for (index, original) in text.lines().enumerate() { - let line_number = index + 1; - let uncommented = original.split("$$").next().unwrap_or("").trim_end(); - let trimmed = uncommented.trim_start(); - if let Some(record) = trimmed.strip_prefix("##") { - in_xydata = false; - let (raw_label, raw_value) = - record - .split_once('=') - .ok_or_else(|| JcampDxError::MalformedRecord { - line: line_number, - detail: "label record has no '=' delimiter".to_owned(), - })?; - let label = normalize_label(raw_label); - let value = raw_value.trim().to_owned(); - if label.is_empty() { - return Err(JcampDxError::MalformedRecord { - line: line_number, - detail: "empty label".to_owned(), - }); - } - - match label.as_str() { - "TITLE" => { - title_count += 1; - if title_count > 1 { - return Err(JcampDxError::MultipleSpectra); - } - insert_unique(&mut fields, label, value)?; - } - "XYDATA" => { - if xy_declaration.replace(value).is_some() { - return Err(JcampDxError::DuplicateLabel { - label: "XYDATA".to_owned(), - }); - } - in_xydata = true; - } - "NTUPLES" | "VARNAME" | "SYMBOL" | "DATATABLE" | "PAGE" => { - return Err(JcampDxError::NtuplesDataset); - } - "XYPOINTS" | "PEAKTABLE" | "PEAKASSIGNMENTS" => { - return Err(JcampDxError::UnsupportedTable(raw_label.trim().to_owned())); - } - "END" => {} - _ if is_core_label(&label) => insert_unique(&mut fields, label, value)?, - _ => {} - } - } else if in_xydata && !trimmed.is_empty() { - data_lines.push((line_number, uncommented.trim().to_owned())); - } - } - - let xy_declaration = xy_declaration.ok_or(JcampDxError::MissingLabel("XYDATA"))?; - Ok(Document { - fields, - xy_declaration, - data_lines, - }) -} - -fn insert_unique( - fields: &mut HashMap, - label: String, - value: String, -) -> Result<(), JcampDxError> { - if fields.insert(label.clone(), value).is_some() { - return Err(JcampDxError::DuplicateLabel { label }); - } - Ok(()) -} - -fn normalize_label(label: &str) -> String { - label - .trim() - .trim_start_matches(['.', '$']) - .chars() - .filter(|character| !character.is_ascii_whitespace() && !matches!(character, '-' | '_')) - .map(|character| character.to_ascii_uppercase()) - .collect() -} - -fn is_core_label(label: &str) -> bool { - matches!( - label, - "DATATYPE" - | "XUNITS" - | "YUNITS" - | "FIRSTX" - | "LASTX" - | "NPOINTS" - | "XFACTOR" - | "YFACTOR" - | "OBSERVEFREQUENCY" - | "OBSERVENUCLEUS" - | "BLOCKS" - ) -} - -fn parse_spectrum(document: Document, source: &str) -> Result { - required(&document.fields, "TITLE", "TITLE")?; - let data_type = required(&document.fields, "DATATYPE", "DATA TYPE")?; - let normalized_data_type = data_type.trim().to_ascii_uppercase(); - if normalized_data_type.contains("LINK") || document.fields.contains_key("BLOCKS") { - return Err(JcampDxError::LinkDataset); - } - if !normalized_data_type.contains("NMR") || !normalized_data_type.contains("SPECTRUM") { - return Err(JcampDxError::UnsupportedDataType(data_type.to_owned())); - } - - let declaration: String = document - .xy_declaration - .chars() - .filter(|character| !character.is_ascii_whitespace()) - .map(|character| character.to_ascii_uppercase()) - .collect(); - if declaration != "(X++(Y..Y))" { - return Err(JcampDxError::UnsupportedTable(document.xy_declaration)); - } - - let x_unit_raw = required(&document.fields, "XUNITS", "XUNITS")?; - let x_unit = match normalized_unit(x_unit_raw).as_str() { - "PPM" => XUnit::Ppm, - "HZ" | "HERTZ" => XUnit::Hz, - _ => { - return Err(JcampDxError::UnsupportedUnit { - axis: "X", - unit: x_unit_raw.to_owned(), - }); - } - }; - let y_unit_raw = required(&document.fields, "YUNITS", "YUNITS")?; - if !matches!( - normalized_unit(y_unit_raw).as_str(), - "ARBITRARYUNITS" | "RELATIVEINTENSITY" | "INTENSITY" | "COUNTS" - ) { - return Err(JcampDxError::UnsupportedUnit { - axis: "Y", - unit: y_unit_raw.to_owned(), - }); - } - - let first_x = field_f64(&document.fields, "FIRSTX", "FIRSTX")?; - let last_x = field_f64(&document.fields, "LASTX", "LASTX")?; - let npoints = field_usize(&document.fields, "NPOINTS", "NPOINTS")?; - if npoints < 2 { - return Err(JcampDxError::InvalidMetadata { - label: "NPOINTS", - value: npoints.to_string(), - }); - } - if first_x == last_x { - return Err(JcampDxError::InvalidMetadata { - label: "FIRSTX/LASTX", - value: first_x.to_string(), - }); - } - - let x_factor = optional_field_f64(&document.fields, "XFACTOR", "XFACTOR")?.unwrap_or(1.0); - let y_factor = optional_field_f64(&document.fields, "YFACTOR", "YFACTOR")?.unwrap_or(1.0); - if x_factor == 0.0 || y_factor == 0.0 { - return Err(JcampDxError::InvalidMetadata { - label: if x_factor == 0.0 { - "XFACTOR" - } else { - "YFACTOR" - }, - value: "0".to_owned(), - }); - } - - let observe_freq_mhz = field_f64(&document.fields, "OBSERVEFREQUENCY", "OBSERVE FREQUENCY")?; - if observe_freq_mhz <= 0.0 { - return Err(JcampDxError::InvalidMetadata { - label: "OBSERVE FREQUENCY", - value: observe_freq_mhz.to_string(), - }); - } - let nucleus_raw = required(&document.fields, "OBSERVENUCLEUS", "OBSERVE NUCLEUS")?; - let nucleus = normalize_nucleus(nucleus_raw); - if nucleus.is_empty() { - return Err(JcampDxError::InvalidMetadata { - label: "OBSERVE NUCLEUS", - value: nucleus_raw.to_owned(), - }); - } - - let mut ordinates = decode_xydata(&document.data_lines, first_x, last_x, npoints, x_factor)?; - for ordinate in &mut ordinates { - *ordinate *= y_factor; - if !ordinate.is_finite() { - return Err(JcampDxError::InvalidMetadata { - label: "YFACTOR", - value: y_factor.to_string(), - }); - } - } - - let to_ppm = |x: f64| match x_unit { - XUnit::Ppm => x, - XUnit::Hz => x / observe_freq_mhz, - }; - let first_ppm = to_ppm(first_x); - let last_ppm = to_ppm(last_x); - let (low_ppm, high_ppm) = if first_ppm <= last_ppm { - (first_ppm, last_ppm) - } else { - ordinates.reverse(); - (last_ppm, first_ppm) - }; - let step_ppm = (high_ppm - low_ppm) / (npoints - 1) as f64; - let spectral_width_hz = step_ppm * observe_freq_mhz * npoints as f64; - let carrier_ppm = low_ppm + npoints as f64 * step_ppm / 2.0; - let points = ordinates - .into_iter() - .map(|ordinate| Complex64::new(ordinate, 0.0)) - .collect(); - - Ok(NmrData { - points, - domain: Domain::Frequency, - spectral_width_hz, - observe_freq_mhz, - carrier_ppm, - nucleus, - source: format!("{source} (JCAMP-DX 1D NMR, {npoints} pts)"), - group_delay: 0.0, - }) -} - -fn normalized_unit(unit: &str) -> String { - unit.chars() - .filter(|character| !character.is_ascii_whitespace() && !matches!(character, '-' | '_')) - .map(|character| character.to_ascii_uppercase()) - .collect() -} - -fn normalize_nucleus(nucleus: &str) -> String { - nucleus - .trim() - .trim_matches(|character| matches!(character, '<' | '>' | '"' | '\'')) - .chars() - .filter(|character| !character.is_ascii_whitespace() && *character != '^') - .collect() -} - -fn required<'a>( - fields: &'a HashMap, - key: &str, - label: &'static str, -) -> Result<&'a str, JcampDxError> { - fields - .get(key) - .map(String::as_str) - .filter(|value| !value.trim().is_empty()) - .ok_or(JcampDxError::MissingLabel(label)) -} - -fn field_f64( - fields: &HashMap, - key: &str, - label: &'static str, -) -> Result { - let raw = required(fields, key, label)?; - parse_finite(raw).ok_or_else(|| JcampDxError::InvalidMetadata { - label, - value: raw.to_owned(), - }) -} - -fn optional_field_f64( - fields: &HashMap, - key: &str, - label: &'static str, -) -> Result, JcampDxError> { - let Some(raw) = fields.get(key) else { - return Ok(None); - }; - parse_finite(raw) - .map(Some) - .ok_or_else(|| JcampDxError::InvalidMetadata { - label, - value: raw.to_owned(), - }) -} - -fn field_usize( - fields: &HashMap, - key: &str, - label: &'static str, -) -> Result { - let raw = required(fields, key, label)?; - raw.trim() - .parse::() - .map_err(|_| JcampDxError::InvalidMetadata { - label, - value: raw.to_owned(), - }) -} - -fn parse_finite(raw: &str) -> Option { - raw.trim() - .parse::() - .ok() - .filter(|value| value.is_finite()) -} - -fn decode_xydata( - lines: &[(usize, String)], - first_x: f64, - last_x: f64, - npoints: usize, - x_factor: f64, -) -> Result, JcampDxError> { - let increment = (last_x - first_x) / (npoints - 1) as f64; - let mut values = Vec::with_capacity(npoints); - let mut previous_y = None; - let mut previous_line_ended_in_difference = false; - - for (line_number, line) in lines { - let (encoded_x, remainder) = split_line_x(line, *line_number)?; - let x = encoded_x * x_factor; - let checkpoint = previous_line_ended_in_difference && !values.is_empty(); - let index = if checkpoint { - values.len() - 1 - } else { - values.len() - }; - if index >= npoints { - return Err(JcampDxError::PointCount { - declared: npoints, - actual: values.len() + 1, - }); - } - let expected_x = first_x + index as f64 * increment; - if !axis_close(x, expected_x, increment) { - return Err(JcampDxError::XSequence { - line: *line_number, - expected: expected_x, - found: x, - }); - } - - let tokens = tokenize_ordinates(remainder, *line_number)?; - if tokens.is_empty() { - return Err(JcampDxError::MalformedData { - line: *line_number, - detail: "data line has no ordinates".to_owned(), - }); - } - let first = tokens[0]; - let first_actual = match first { - EncodedValue::Actual(value) => value, - EncodedValue::Invalid => { - return Err(JcampDxError::InvalidOrdinate { line: *line_number }); - } - _ => { - return Err(JcampDxError::MalformedData { - line: *line_number, - detail: "the first ordinate of a line must be an absolute AFFN/SQZ value" - .to_owned(), - }); - } - }; - - let mut basis = RepeatBasis::Actual(first_actual); - if checkpoint { - let expected_y = previous_y.expect("checkpoint requires a preceding ordinate"); - if !ordinate_close(first_actual, expected_y) { - return Err(JcampDxError::Checkpoint { - line: *line_number, - expected: expected_y, - found: first_actual, - }); - } - } else { - append_value(&mut values, first_actual, npoints)?; - previous_y = Some(first_actual); - } - - for token in tokens.into_iter().skip(1) { - match token { - EncodedValue::Actual(value) => { - append_value(&mut values, value, npoints)?; - previous_y = Some(value); - basis = RepeatBasis::Actual(value); - } - EncodedValue::Difference(difference) => { - let value = previous_y.ok_or_else(|| JcampDxError::MalformedData { - line: *line_number, - detail: "DIF value has no preceding ordinate".to_owned(), - })? + difference; - append_value(&mut values, value, npoints)?; - previous_y = Some(value); - basis = RepeatBasis::Difference(difference); - } - EncodedValue::Duplicate(count) => { - if count < 2 { - return Err(JcampDxError::MalformedData { - line: *line_number, - detail: "DUP count must include at least two values".to_owned(), - }); - } - for _ in 1..count { - let value = match basis { - RepeatBasis::Actual(value) => value, - RepeatBasis::Difference(difference) => { - previous_y.expect("DIF duplicate requires a preceding ordinate") - + difference - } - }; - append_value(&mut values, value, npoints)?; - previous_y = Some(value); - } - } - EncodedValue::Invalid => { - return Err(JcampDxError::InvalidOrdinate { line: *line_number }); - } - } - } - previous_line_ended_in_difference = matches!(basis, RepeatBasis::Difference(_)); - } - - if values.len() != npoints { - return Err(JcampDxError::PointCount { - declared: npoints, - actual: values.len(), - }); - } - Ok(values) -} - -fn append_value(values: &mut Vec, value: f64, declared: usize) -> Result<(), JcampDxError> { - if !value.is_finite() || values.len() >= declared { - return Err(JcampDxError::PointCount { - declared, - actual: values.len() + 1, - }); - } - values.push(value); - Ok(()) -} - -fn axis_close(actual: f64, expected: f64, increment: f64) -> bool { - let tolerance = (expected.abs().max(1.0) * 1.0e-9).max(increment.abs() * 1.0e-5); - (actual - expected).abs() <= tolerance -} - -fn ordinate_close(actual: f64, expected: f64) -> bool { - (actual - expected).abs() <= expected.abs().max(1.0) * 1.0e-10 -} - -fn split_line_x(line: &str, line_number: usize) -> Result<(f64, &str), JcampDxError> { - let text = line.trim_start(); - let length = numeric_prefix_len(text); - if length == 0 { - return Err(JcampDxError::MalformedData { - line: line_number, - detail: "line does not start with an AFFN X value".to_owned(), - }); - } - let x = parse_finite(&text[..length]).ok_or_else(|| JcampDxError::MalformedData { - line: line_number, - detail: "invalid AFFN X value".to_owned(), - })?; - Ok((x, &text[length..])) -} - -fn tokenize_ordinates(text: &str, line_number: usize) -> Result, JcampDxError> { - let bytes = text.as_bytes(); - let mut tokens = Vec::new(); - let mut offset = 0usize; - while offset < bytes.len() { - let byte = bytes[offset]; - if byte.is_ascii_whitespace() || matches!(byte, b',' | b';') { - offset += 1; - continue; - } - if byte == b'?' { - tokens.push(EncodedValue::Invalid); - offset += 1; - continue; - } - if let Some((kind, leading, sign)) = pseudo_digit(byte) { - let mut end = offset + 1; - while end < bytes.len() && bytes[end].is_ascii_digit() { - end += 1; - } - match kind { - PseudoKind::Squeezed => { - tokens.push(EncodedValue::Actual(pseudo_number( - leading, - sign, - &bytes[offset + 1..end], - ))); - } - PseudoKind::Difference => { - tokens.push(EncodedValue::Difference(pseudo_number( - leading, - sign, - &bytes[offset + 1..end], - ))); - } - PseudoKind::Duplicate => { - tokens.push(EncodedValue::Duplicate(pseudo_count( - leading, - &bytes[offset + 1..end], - line_number, - )?)); - } - } - offset = end; - continue; - } - - let length = numeric_prefix_len(&text[offset..]); - if length == 0 { - return Err(JcampDxError::MalformedData { - line: line_number, - detail: format!("unexpected character {:?}", byte as char), - }); - } - let raw = &text[offset..offset + length]; - let value = parse_finite(raw).ok_or_else(|| JcampDxError::MalformedData { - line: line_number, - detail: format!("invalid AFFN/PAC ordinate {raw:?}"), - })?; - tokens.push(EncodedValue::Actual(value)); - offset += length; - } - Ok(tokens) -} - -#[derive(Debug, Clone, Copy)] -enum PseudoKind { - Squeezed, - Difference, - Duplicate, -} - -fn pseudo_digit(byte: u8) -> Option<(PseudoKind, u8, f64)> { - match byte { - b'@' => Some((PseudoKind::Squeezed, 0, 1.0)), - b'A'..=b'I' => Some((PseudoKind::Squeezed, byte - b'A' + 1, 1.0)), - b'a'..=b'i' => Some((PseudoKind::Squeezed, byte - b'a' + 1, -1.0)), - b'%' => Some((PseudoKind::Difference, 0, 1.0)), - b'J'..=b'R' => Some((PseudoKind::Difference, byte - b'J' + 1, 1.0)), - b'j'..=b'r' => Some((PseudoKind::Difference, byte - b'j' + 1, -1.0)), - b'S'..=b'Z' => Some((PseudoKind::Duplicate, byte - b'S' + 1, 1.0)), - b's' => Some((PseudoKind::Duplicate, 9, 1.0)), - _ => None, - } -} - -fn pseudo_number(leading: u8, sign: f64, tail: &[u8]) -> f64 { - let mut value = leading as f64; - for digit in tail { - value = value * 10.0 + (digit - b'0') as f64; - } - sign * value -} - -fn pseudo_count(leading: u8, tail: &[u8], line_number: usize) -> Result { - let mut value = leading as usize; - for digit in tail { - value = value - .checked_mul(10) - .and_then(|value| value.checked_add((digit - b'0') as usize)) - .ok_or_else(|| JcampDxError::MalformedData { - line: line_number, - detail: "DUP count overflows usize".to_owned(), - })?; - } - Ok(value) -} - -fn numeric_prefix_len(text: &str) -> usize { - let bytes = text.as_bytes(); - let mut index = 0usize; - if matches!(bytes.first(), Some(b'+') | Some(b'-')) { - index += 1; - } - let mut digits = 0usize; - while index < bytes.len() && bytes[index].is_ascii_digit() { - index += 1; - digits += 1; - } - if index < bytes.len() && bytes[index] == b'.' { - index += 1; - while index < bytes.len() && bytes[index].is_ascii_digit() { - index += 1; - digits += 1; - } - } - if digits == 0 { - return 0; - } - if index < bytes.len() && matches!(bytes[index], b'E' | b'e') { - let exponent_start = index; - index += 1; - if index < bytes.len() && matches!(bytes[index], b'+' | b'-') { - index += 1; - } - let exponent_digits = index; - while index < bytes.len() && bytes[index].is_ascii_digit() { - index += 1; - } - if index == exponent_digits { - return exponent_start; - } - } - index -} - -#[cfg(test)] -mod tests; diff --git a/crates/io/src/jcamp_dx/tests.rs b/crates/io/src/jcamp_dx/tests.rs deleted file mode 100644 index 419f01d2..00000000 --- a/crates/io/src/jcamp_dx/tests.rs +++ /dev/null @@ -1,123 +0,0 @@ -use super::*; - -fn header(extra: &str, body: &str) -> String { - format!( - "##TITLE=fixture\n\ - ##JCAMP-DX=5.01\n\ - ##DATA TYPE=NMR SPECTRUM\n\ - ##XUNITS=PPM\n\ - ##YUNITS=ARBITRARY UNITS\n\ - ##XFACTOR=1\n\ - ##YFACTOR=1\n\ - ##FIRSTX=0\n\ - ##LASTX=3\n\ - ##NPOINTS=4\n\ - ##.OBSERVE FREQUENCY=400\n\ - ##.OBSERVE NUCLEUS=^1H\n\ - {extra}\ - ##XYDATA=(X++(Y..Y))\n\ - {body}\n\ - ##END=\n" - ) -} - -fn data(text: &str) -> NmrData { - match parse_bytes(text.as_bytes(), "fixture.jdx").unwrap() { - Acquisition::D1(data) => data, - Acquisition::D2(_) => panic!("expected 1D data"), - Acquisition::Electrophysiology(_) => panic!("expected NMR"), - Acquisition::Afm(_) => panic!("expected NMR"), - Acquisition::MassSpec(_) | Acquisition::Xrd(_) => panic!("expected NMR"), - Acquisition::Xps(_) => panic!("expected NMR"), - } -} - -#[test] -fn decodes_affn_and_pac() { - let spectrum = data(&header("", "0 1+2-3+4")); - let values: Vec = spectrum.points.iter().map(|point| point.re).collect(); - assert_eq!(values, vec![1.0, 2.0, -3.0, 4.0]); -} - -#[test] -fn decodes_sqz_dif_dup_and_checkpoint_continuity() { - let fixture = "##TITLE=compressed\n\ - ##DATA TYPE=NMR SPECTRUM\n\ - ##XUNITS=PPM\n\ - ##YUNITS=RELATIVE INTENSITY\n\ - ##XFACTOR=1\n\ - ##YFACTOR=0.5\n\ - ##FIRSTX=0\n\ - ##LASTX=7\n\ - ##NPOINTS=8\n\ - ##.OBSERVE FREQUENCY=400\n\ - ##.OBSERVE NUCLEUS=<1H>\n\ - ##XYDATA=(X++(Y..Y))\n\ - 0A0KU\n\ - 3A6%TjN\n\ - 7B0 $$ final DIF checkpoint\n\ - ##END=\n"; - let spectrum = data(fixture); - let values: Vec = spectrum.points.iter().map(|point| point.re).collect(); - assert_eq!(values, vec![5.0, 6.0, 7.0, 8.0, 8.0, 8.0, 7.5, 10.0]); -} - -#[test] -fn applies_factors_and_canonicalizes_a_descending_axis() { - let fixture = "##TITLE=reverse\n\ - ##DATA TYPE=NMR SPECTRUM\n\ - ##XUNITS=PPM\n\ - ##YUNITS=ARBITRARY UNITS\n\ - ##XFACTOR=0.5\n\ - ##YFACTOR=0.25\n\ - ##FIRSTX=10\n\ - ##LASTX=7\n\ - ##NPOINTS=4\n\ - ##.OBSERVE FREQUENCY=400\n\ - ##.OBSERVE NUCLEUS=1H\n\ - ##XYDATA=(X++(Y..Y))\n\ - 20 2 4\n\ - 16 6 8\n\ - ##END=\n"; - let spectrum = data(fixture); - let values: Vec = spectrum.points.iter().map(|point| point.re).collect(); - assert_eq!(values, vec![2.0, 1.5, 1.0, 0.5]); - assert!((spectrum.spectral_width_hz - 1600.0).abs() < 1.0e-12); - assert!((spectrum.carrier_ppm - 9.0).abs() < 1.0e-12); -} - -#[test] -fn rejects_compound_and_ntuples_documents() { - let link = header("##BLOCKS=2\n##DATA TYPE=LINK\n", "0 1 2 3 4"); - assert!(matches!( - parse_bytes(link.as_bytes(), "link.jdx"), - Err(JcampDxError::DuplicateLabel { .. }) | Err(JcampDxError::LinkDataset) - )); - - let ntuples = header("##NTUPLES=NMR SPECTRUM\n", "0 1 2 3 4"); - assert!(matches!( - parse_bytes(ntuples.as_bytes(), "ntuples.jdx"), - Err(JcampDxError::NtuplesDataset) - )); -} - -#[test] -fn rejects_missing_metadata_unsupported_units_and_bad_checkpoints() { - let missing = header("", "0 1 2 3 4").replace("##.OBSERVE NUCLEUS=^1H\n", ""); - assert!(matches!( - parse_bytes(missing.as_bytes(), "missing.jdx"), - Err(JcampDxError::MissingLabel("OBSERVE NUCLEUS")) - )); - - let unit = header("", "0 1 2 3 4").replace("##XUNITS=PPM", "##XUNITS=SECONDS"); - assert!(matches!( - parse_bytes(unit.as_bytes(), "unit.jdx"), - Err(JcampDxError::UnsupportedUnit { axis: "X", .. }) - )); - - let checkpoint = header("", "0A0K\n1A3KK\n3A6"); - assert!(matches!( - parse_bytes(checkpoint.as_bytes(), "checkpoint.jdx"), - Err(JcampDxError::Checkpoint { .. }) - )); -} diff --git a/crates/io/src/jeol.rs b/crates/io/src/jeol.rs deleted file mode 100644 index e24870de..00000000 --- a/crates/io/src/jeol.rs +++ /dev/null @@ -1,746 +0,0 @@ -//! JEOL Delta `.jdf` reader. - -use crate::{ - Acquisition, AcquisitionIdentity, DataFormat, DiffusionMeta, Dim, Domain, IoError, LoadResult, - NmrData, NmrData2D, NmrFormat, NmrInstrumentOrigin, NmrOrigin, NmrPortableMetadata, - NmrSourceFormat, NmrSourceParameters, Provenance, PseudoAxis, PseudoKind, QuadMode, - gradient_shape_factor, gyromagnetic_ratio, -}; -use base64::{Engine as _, engine::general_purpose::STANDARD}; -use num_complex::Complex64; -use sha2::{Digest, Sha256}; -use std::path::Path; - -mod filter; -mod nus; -mod params; -mod ruler; -use filter::group_delay; -use nus::detect_nus; -#[cfg(test)] -use nus::extract_nuslist; -use params::Params; -use ruler::{kind_for_unit, prefix_exponent, scan_embedded_axis}; - -const MAGIC: &[u8; 8] = b"JEOL.NMR"; -const HEADER_LEN: usize = 1360; - -// Byte offsets into the fixed big-endian header. Array fields hold one slot per -// possible dimension; slot 0 is read for 1D data. -#[allow(dead_code)] -mod off { - pub const ENDIAN: usize = 8; // u8: body endianness, 0 = big, 1 = little - pub const MAJOR_VERSION: usize = 9; // u8 - pub const DATA_DIMENSION_NUMBER: usize = 12; // u8 - pub const DATA_AXIS_TYPE: usize = 24; // 8 × u8 (0 None, 1 Real, 3 Complex, ...) - pub const DATA_AXIS_UNITS: usize = 32; // 8 × (unit prefix/power u8, base unit u8) - pub const DATA_POINTS: usize = 176; // 8 × u32 (per axis, padded to a tile edge) - pub const DATA_OFFSET_STOP: usize = 240; // 8 × u32 (per axis, last real index) - pub const DATA_AXIS_START: usize = 272; // 8 × f64 (axis low end) - pub const DATA_AXIS_STOP: usize = 336; // 8 × f64 (axis high end; for a FID = acq time, s) - pub const BASE_FREQ: usize = 1064; // 8 × f64 (MHz) - pub const PARAM_LIST: usize = 1360; // parameter-list header, right after the fixed header - pub const DATA_START: usize = 1284; // u32: byte offset of the data section - pub const DATA_LENGTH: usize = 1288; // u64: length of the data section in bytes -} - -const AXIS_COMPLEX: u8 = 3; -const AXIS_REAL_COMPLEX: u8 = 4; -const UNIT_HERTZ: u8 = 13; -const UNIT_PPM: u8 = 26; -const UNIT_SECOND: u8 = 28; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum AxisUnit { - Hertz, - Ppm, - Second, -} - -impl AxisUnit { - fn decode(bytes: &[u8], axis: usize) -> Result<(Self, f64), IoError> { - let at = off::DATA_AXIS_UNITS + axis * 2; - let unit = match bytes[at + 1] { - UNIT_HERTZ => Self::Hertz, - UNIT_PPM => Self::Ppm, - UNIT_SECOND => Self::Second, - unit => { - return Err(IoError::Unsupported(format!( - "unknown JEOL unit {unit} for axis {}", - axis + 1 - ))); - } - }; - let scale = 10f64.powi(prefix_exponent(bytes[at])); - Ok((unit, scale)) - } -} - -// Edge of the square submatrix tiles nD data is stored in. Data_Points are -// padded up to a multiple of this along every axis. True-2D data uses the 32 -// edge; pseudo-2D arrays with few increments use the 4 edge. -const TILE: usize = 32; -const SMALL_TILE: usize = 4; - -/// True if the file begins with the JEOL Delta magic, regardless of extension. -pub fn is_jdf(path: &Path) -> bool { - use std::io::Read; - let mut magic = [0u8; MAGIC.len()]; - std::fs::File::open(path) - .and_then(|mut f| f.read_exact(&mut magic)) - .map(|()| &magic == MAGIC) - .unwrap_or(false) -} - -pub fn read_jdf_path(path: &Path) -> Result { - let bytes = std::fs::read(path)?; - let source = path - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or("") - .to_string(); - read_jdf_bytes(&bytes, source) -} - -pub fn load_jdf_path(path: &Path) -> Result { - let bytes = std::fs::read(path)?; - let source = path - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or("") - .to_owned(); - let acquisition = read_jdf_bytes(&bytes, source)?; - let endian = if bytes[off::ENDIAN] == 0 { - Endian::Big - } else { - Endian::Little - }; - let params = Params::parse(&bytes, off::PARAM_LIST, endian); - let mut acquisition_identity = AcquisitionIdentity::from_path(path); - acquisition_identity.acquisition = experiment_name(¶ms); - let header = Reader { - bytes: &bytes, - endian: Endian::Big, - }; - let data_start = (header.u32(off::DATA_START) as usize).clamp(HEADER_LEN, bytes.len()); - Ok(LoadResult::new( - acquisition, - acquisition_identity, - DataFormat::Nmr(NmrFormat::JeolDelta), - Provenance { - selected_path: path.to_path_buf(), - data_path: path.to_path_buf(), - parameter_paths: Vec::new(), - companion_paths: Vec::new(), - }, - Vec::new(), - ) - .with_nmr_origin(NmrOrigin::Instrument { - instrument: NmrInstrumentOrigin { - format: NmrSourceFormat::JeolDelta, - source_sha256: Sha256::digest(&bytes).into(), - portable: NmrPortableMetadata { - solvent: params.string_ci("solvent"), - temperature_k: None, - transients: params - .f64("scans") - .filter(|value| *value > 0.0 && value.fract() == 0.0) - .map(|value| value as u64), - pulse_sequence: experiment_name(¶ms), - }, - parameters: NmrSourceParameters::Jeol { - metadata_base64: STANDARD.encode(&bytes[..data_start]), - }, - }, - })) -} - -pub fn read_jdf_bytes(bytes: &[u8], source: String) -> Result { - if bytes.len() < HEADER_LEN { - return Err(IoError::Truncated { - offset: 0, - needed: HEADER_LEN, - have: bytes.len(), - }); - } - if &bytes[..8] != MAGIC { - return Err(IoError::BadMagic); - } - - let body_endian = match bytes[off::ENDIAN] { - 0 => Endian::Big, - 1 => Endian::Little, - other => { - return Err(IoError::Unsupported(format!( - "unknown endian marker {other} at byte 8" - ))); - } - }; - - match bytes[off::DATA_DIMENSION_NUMBER] { - 1 => read_jdf_1d(bytes, source, body_endian).map(Acquisition::D1), - 2 => read_jdf_2d(bytes, source, body_endian).map(|d| Acquisition::D2(Box::new(d))), - ndim => Err(IoError::Unsupported(format!( - "{ndim}-dimensional data (only 1D and 2D are implemented)" - ))), - } -} - -fn read_jdf_1d(bytes: &[u8], source: String, body_endian: Endian) -> Result { - // The fixed header is always big-endian; the body follows the Endian byte. - let h = Reader { - bytes, - endian: Endian::Big, - }; - - let axis_type = bytes[off::DATA_AXIS_TYPE]; - let components = match axis_type { - AXIS_COMPLEX | AXIS_REAL_COMPLEX => 2, - _ => 1, - }; - - let npoints = h.u32(off::DATA_POINTS) as usize; - if npoints == 0 { - return Err(IoError::Unsupported( - "header reports zero data points".into(), - )); - } - let raw_axis_start = h.f64(off::DATA_AXIS_START); - let raw_axis_stop = h.f64(off::DATA_AXIS_STOP); - let (axis_unit, axis_scale) = AxisUnit::decode(bytes, 0)?; - let axis_start = raw_axis_start * axis_scale; - let axis_stop = raw_axis_stop * axis_scale; - let domain = match axis_unit { - AxisUnit::Hertz | AxisUnit::Ppm => Domain::Frequency, - AxisUnit::Second => Domain::Time, - }; - // DATA_POINTS is padded to a tile edge; DATA_OFFSET_STOP is the last real - // time-domain index, so real count = stop+1. Processed spectra use the full - // DATA_POINTS array; their offset-stop describes the processing window, not - // trailing storage padding, and truncating there shifts every ppm coordinate. - let real_n = if domain == Domain::Time { - let stop = h.u32(off::DATA_OFFSET_STOP) as usize; - if stop > 0 { - (stop + 1).min(npoints) - } else { - npoints - } - } else { - npoints - }; - - let base_freq_mhz = h.f64(off::BASE_FREQ); - let axis_span = (axis_stop - axis_start).abs(); - - let params = Params::parse(bytes, off::PARAM_LIST, body_endian); - - let data_start = { - let ds = h.u32(off::DATA_START) as usize; - if ds >= HEADER_LEN && ds < bytes.len() { - ds - } else { - HEADER_LEN - } - }; - let data_length = h.u64(off::DATA_LENGTH) as usize; - - // Sample width (f32/f64) comes from the data-section byte budget, requiring an - // exact f32 or f64 fit rather than an ambiguous type nibble or a size guess. - let total_samples = npoints - .checked_mul(components) - .ok_or_else(|| IoError::Unsupported("point count overflow".into()))?; - let avail = bytes.len().saturating_sub(data_start); - let budget = if data_length > 0 && data_length <= avail { - data_length - } else { - avail - }; - let sample = sample_format(budget, total_samples)?; - let stride = sample.size(); - - let need = data_start - .checked_add(total_samples * stride) - .ok_or_else(|| IoError::Unsupported("data section size overflow".into()))?; - if bytes.len() < need { - return Err(IoError::Truncated { - offset: data_start, - needed: total_samples * stride, - have: avail, - }); - } - - let d = Reader { - bytes, - endian: body_endian, - }; - // Real then imaginary channel, each padded to `npoints`; read only the real - // extent, but the imaginary channel still starts after the full padded block. - let real = d.read_reals(data_start, real_n, sample); - let imag = if components == 2 { - d.read_reals(data_start + npoints * stride, real_n, sample) - } else { - vec![0.0; real_n] - }; - // JEOL stores the FID with the opposite quadrature sense to a naive forward - // FFT; conjugating it here (negating the imaginary channel) makes a plain - // forward FFT downstream yield the correct ppm ordering. - let mut points: Vec = real - .into_iter() - .zip(imag) - .map(|(re, im)| Complex64::new(re, -im)) - .collect(); - - if !base_freq_mhz.is_finite() || base_freq_mhz <= 1.0 { - return Err(IoError::Unsupported(format!( - "invalid JEOL observe frequency {base_freq_mhz} MHz" - ))); - } - let observe_freq_mhz = base_freq_mhz; - // Sweep width = 1/dwell; the last FID point sits at (N-1)·dwell = acq_time. - let spectral_width_hz = match axis_unit { - AxisUnit::Second if axis_span.is_finite() && axis_span > 0.0 && real_n > 1 => { - (real_n as f64 - 1.0) / axis_span - } - AxisUnit::Hertz if axis_span.is_finite() && axis_span > 0.0 => axis_span, - AxisUnit::Ppm if axis_span.is_finite() && axis_span > 0.0 => axis_span * observe_freq_mhz, - _ => { - return Err(IoError::Unsupported(format!( - "invalid JEOL axis span from {raw_axis_start} to {raw_axis_stop}" - ))); - } - }; - let axis_midpoint = (axis_start + axis_stop) / 2.0; - let carrier_ppm = match axis_unit { - AxisUnit::Hertz => axis_midpoint / observe_freq_mhz, - AxisUnit::Ppm => axis_midpoint, - AxisUnit::Second => params.f64("X_OFFSET").unwrap_or(0.0), - }; - - // Stored processed spectra run from high to low ppm. PlotX's imported - // frequency representation is low to high, matching its generated axis. - if domain == Domain::Frequency && axis_stop < axis_start { - points.reverse(); - } - - let nucleus = params - .string("X_DOMAIN") - .map(|s| normalize_nucleus(&s)) - .unwrap_or_else(|| guess_nucleus(observe_freq_mhz)); - let solvent = params.string("SOLVENT").unwrap_or_default(); - let provenance = if solvent.is_empty() { - format!("{source} (JEOL Delta, {sample:?}, {real_n} pts)") - } else { - format!("{source} (JEOL Delta, {solvent}, {real_n} pts)") - }; - - Ok(NmrData { - points, - domain, - spectral_width_hz, - observe_freq_mhz, - carrier_ppm, - nucleus, - source: provenance, - group_delay: group_delay(¶ms), - }) -} - -// nD data is stored as square submatrix tiles of edge `TILE`. For a 2D dataset -// with a complex direct (F2) axis and a real indirect (F1) axis there are two -// planes — all F2-real tiles, then all F2-imag tiles. Within a plane the F1 -// tile-block is the outer loop and the F2 tile-block the inner, and each tile is -// row-major. The imaginary plane is negated relative to a forward-FFT -// convention, so the complex value is `re - i·im` (the same conjugation the 1D -// reader applies). -fn read_jdf_2d(bytes: &[u8], source: String, body_endian: Endian) -> Result { - let h = Reader { - bytes, - endian: Endian::Big, - }; - let axis_u32 = |base: usize, i: usize| h.u32(base + i * 4) as usize; - - let cols_pad = axis_u32(off::DATA_POINTS, 0); - let rows_pad = axis_u32(off::DATA_POINTS, 1); - if cols_pad == 0 || rows_pad == 0 { - return Err(IoError::Unsupported( - "header reports zero data points".into(), - )); - } - // Real (non-padding) extent; the offset-stop is the last valid index. - let cols_real = (axis_u32(off::DATA_OFFSET_STOP, 0) + 1).min(cols_pad); - let rows_real = (axis_u32(off::DATA_OFFSET_STOP, 1) + 1).min(rows_pad); - - // JEOL stores nD data in square submatrix tiles. True-2D acquisitions use a - // 32-point tile edge; pseudo-2D arrays with few increments (DOSY, T1/T2) use - // a 4-point tile. Both loop the indirect (F1) tile-block outer and the direct - // (F2) tile-block inner, row-major within each tile. - let tile = if cols_pad % TILE == 0 && rows_pad % TILE == 0 { - TILE - } else { - SMALL_TILE - }; - if cols_pad % tile != 0 || rows_pad % tile != 0 { - return Err(IoError::Unsupported(format!( - "2D data points ({cols_pad}×{rows_pad}) are not a multiple of the {tile}-point tile edge" - ))); - } - - let axis_kind = |i: usize| bytes[off::DATA_AXIS_TYPE + i]; - let f2_complex = matches!(axis_kind(0), AXIS_COMPLEX | AXIS_REAL_COMPLEX); - // A `Complex` (type 3) indirect axis is States-style hypercomplex: the F1 - // cosine and sine modulations are acquired separately and stored as their own - // sample planes, so the plane count doubles and the indirect FFT needs States - // recombination. A `Real_Complex` (type 4) indirect axis is already a single - // phase-modulated interferogram (one plane pair) recombined as plain Complex. - let f1_hypercomplex = axis_kind(1) == AXIS_COMPLEX; - let f2_planes = if f2_complex { 2 } else { 1 }; - let f1_planes = if f1_hypercomplex { 2 } else { 1 }; - let planes = f2_planes * f1_planes; - - let data_start = { - let ds = h.u32(off::DATA_START) as usize; - if ds >= HEADER_LEN && ds < bytes.len() { - ds - } else { - HEADER_LEN - } - }; - let data_length = h.u64(off::DATA_LENGTH) as usize; - - let total_samples = cols_pad - .checked_mul(rows_pad) - .and_then(|v| v.checked_mul(planes)) - .ok_or_else(|| IoError::Unsupported("2D point count overflow".into()))?; - let avail = bytes.len().saturating_sub(data_start); - let budget = if data_length > 0 && data_length <= avail { - data_length - } else { - avail - }; - let sample = sample_format(budget, total_samples)?; - let stride = sample.size(); - let need = data_start - .checked_add(total_samples * stride) - .ok_or_else(|| IoError::Unsupported("2D data section size overflow".into()))?; - if bytes.len() < need { - return Err(IoError::Truncated { - offset: data_start, - needed: total_samples * stride, - have: avail, - }); - } - - let d = Reader { - bytes, - endian: body_endian, - }; - let n_f2_blocks = cols_pad / tile; - let plane_len = rows_pad * cols_pad; - let sample_at = |plane: usize, row: usize, col: usize| -> f64 { - let block = (row / tile) * n_f2_blocks + (col / tile); - let idx = plane * plane_len + block * tile * tile + (row % tile) * tile + (col % tile); - d.sample(data_start + idx * stride, sample) - }; - // Section plane index for (F1 imaginary?, F2 imaginary?); F2 toggles fastest, - // matching the 2-plane (F2-only-complex) layout the 1D reader shares. JEOL's - // imaginary plane is negated relative to a forward-FFT convention. - let complex_at = |f1_imag: usize, row: usize, col: usize| -> Complex64 { - let re = sample_at(f1_imag * f2_planes, row, col); - let im = f2_complex.then(|| sample_at(f1_imag * f2_planes + 1, row, col)); - Complex64::new(re, -im.unwrap_or(0.0)) - }; - - // For a hypercomplex indirect axis, interleave each increment's cosine - // (F1-real) and sine (F1-imag) channel as consecutive rows so the indirect - // FFT's States recombination pairs the 2k / 2k+1 rows into one t1 point. - let f1_channels = if f1_hypercomplex { 2 } else { 1 }; - let mut data = Vec::with_capacity(f1_channels * rows_real * cols_real); - for row in 0..rows_real { - for f1_imag in 0..f1_channels { - for col in 0..cols_real { - data.push(complex_at(f1_imag, row, col)); - } - } - } - let stored_rows = f1_channels * rows_real; - let quad = if f1_hypercomplex { - QuadMode::States - } else { - QuadMode::Complex - }; - - let params = Params::parse(bytes, off::PARAM_LIST, body_endian); - let acq_time = - |i: usize| (h.f64(off::DATA_AXIS_STOP + i * 8) - h.f64(off::DATA_AXIS_START + i * 8)).abs(); - let base_freq = |i: usize| { - let f = h.f64(off::BASE_FREQ + i * 8); - if f.is_finite() && f > 1.0 { f } else { 400.0 } - }; - let sweep = |i: usize, real_n: usize| { - let acq = acq_time(i); - if acq.is_finite() && acq > 0.0 && real_n > 1 { - (real_n as f64 - 1.0) / acq - } else { - base_freq(i) * 20.0 - } - }; - // The indirect axis stores no usable t1 acquisition time (its `Data_Axis_Stop` - // is not the increment span), so the acq-time estimate collapses the F1 sweep. - // The `Y_SWEEP` parameter (SI Hz, scaler folded) is authoritative; fall back to - // the acq-time estimate only when it is absent. - let indirect_sweep = params - .si("Y_SWEEP") - .filter(|v| v.is_finite() && *v > 1.0) - .unwrap_or_else(|| sweep(1, rows_real)); - let direct = Dim { - spectral_width_hz: sweep(0, cols_real), - observe_freq_mhz: base_freq(0), - carrier_ppm: params.f64("X_OFFSET").unwrap_or(0.0), - nucleus: params - .string("X_DOMAIN") - .map(|s| normalize_nucleus(&s)) - .unwrap_or_else(|| guess_nucleus(base_freq(0))), - group_delay: group_delay(¶ms), - }; - let indirect = Dim { - spectral_width_hz: indirect_sweep, - observe_freq_mhz: base_freq(1), - carrier_ppm: params.f64("Y_OFFSET").unwrap_or(0.0), - nucleus: params - .string("Y_DOMAIN") - .map(|s| normalize_nucleus(&s)) - .unwrap_or_else(|| guess_nucleus(base_freq(1))), - group_delay: 0.0, - }; - - let experiment = experiment_name(¶ms).map(|name| name.to_ascii_lowercase()); - - let (pseudo_axis, diffusion) = extract_pseudo(bytes, ¶ms, &experiment, &direct, rows_real); - let nus = detect_nus(bytes, ¶ms, rows_real); - - Ok(NmrData2D { - data, - rows: stored_rows, - cols: cols_real, - domain: Domain::Time, - direct, - indirect, - quad, - indirect_conjugate: true, - experiment, - pseudo_axis, - diffusion, - nus, - source: format!("{source} (JEOL Delta 2D, {sample:?}, {cols_real}×{rows_real})"), - }) -} - -fn experiment_value(params: &Params) -> Option { - params - .string_ci("experiment") - .or_else(|| params.string_ci("content")) - .map(|value| value.trim().to_owned()) - .filter(|value| !value.is_empty()) -} - -fn experiment_name(params: &Params) -> Option { - let value = experiment_value(params)?; - let file_name = value.rsplit(['/', '\\']).next().unwrap_or(&value).trim(); - let name = file_name - .rsplit_once('.') - .filter(|(_, extension)| extension.eq_ignore_ascii_case("jxp")) - .map_or(file_name, |(stem, _)| stem) - .trim(); - (!name.is_empty()).then(|| name.to_owned()) -} - -/// Recover the pseudo-2D indirect ruler and (for DOSY) the diffusion-encoding -/// parameters. The ruler comes from the embedded experiment text; diffusion -/// scalars come from the SI-normalized parameter list. -fn extract_pseudo( - bytes: &[u8], - params: &Params, - experiment: &Option, - direct: &Dim, - rows: usize, -) -> (Option, Option) { - let axis = scan_embedded_axis(bytes).map(|(name, mut values, unit, source)| { - // Trust the stored row count over a ramp that rounded to a different length. - if values.len() > rows && rows > 0 { - values.truncate(rows); - } - PseudoAxis { - kind: kind_for_unit(&unit), - name, - values, - unit, - source, - } - }); - - let hint = experiment.as_deref().unwrap_or(""); - let looks_dosy = axis - .as_ref() - .map(|a| a.kind == PseudoKind::Gradient) - .unwrap_or(false) - || ["dosy", "diffusion", "bpp", "ste", "led", "oneshot"] - .iter() - .any(|k| hint.contains(k)); - - let diffusion = if looks_dosy { - let gamma = gyromagnetic_ratio(&direct.nucleus).unwrap_or(2.675_222_005e8); - let delta = params.si("delta").unwrap_or(0.0); - let big_delta = params - .si("diffusion_time") - .or_else(|| params.si("delta_large")) - .unwrap_or(0.0); - let tau = params.si("tau").unwrap_or(0.0); - let shape_factor = gradient_shape_factor( - params - .string_ci("grad_shape") - .as_deref() - .unwrap_or("SQUARE"), - ); - (delta > 0.0 && big_delta > 0.0).then_some(DiffusionMeta { - gamma, - delta, - big_delta, - tau, - shape_factor, - }) - } else { - None - }; - - (axis, diffusion) -} - -/// Recover non-uniform-sampling metadata from the parameter list. Present only -/// when `sampling` reports a NUS scheme; the acquired increment count is the -/// stored real row count and the nominal grid is inferred from the sampling -/// rate. Recent Delta files also serialize `Y_NUSLIST` as a big-endian integer -/// array near the file tail; use it when its size and bounds agree with the -/// acquisition, otherwise leave the schedule for the user to supply. -fn normalize_nucleus(domain: &str) -> String { - match domain.trim().to_ascii_lowercase().as_str() { - "proton" => "1H".into(), - "carbon13" | "carbon" => "13C".into(), - "phosphorus31" | "phosphorus" => "31P".into(), - "fluorine19" | "fluorine" => "19F".into(), - "nitrogen15" | "nitrogen" => "15N".into(), - other if !other.is_empty() => domain.trim().to_string(), - _ => "X".into(), - } -} - -fn guess_nucleus(mhz: f64) -> String { - if mhz > 300.0 { - "1H".into() - } else if mhz > 90.0 { - "13C".into() - } else { - "X".into() - } -} - -/// Stored sample width from the data-section byte budget, requiring an exact f32/f64 -/// fit — a size matching neither is reported, not guessed (else silent garbage). -fn sample_format(budget: usize, total_samples: usize) -> Result { - if total_samples == 0 { - return Err(IoError::Unsupported( - "header reports zero data points".into(), - )); - } - if budget == 8 * total_samples { - Ok(SampleFmt::F64) - } else if budget == 4 * total_samples { - Ok(SampleFmt::F32) - } else { - Err(IoError::Unsupported(format!( - "data section of {budget} bytes fits neither f32 ({}) nor f64 ({}) for {total_samples} samples", - 4 * total_samples, - 8 * total_samples - ))) - } -} - -#[derive(Debug, Clone, Copy)] -enum SampleFmt { - F32, - F64, -} - -impl SampleFmt { - #[inline] - fn size(self) -> usize { - match self { - SampleFmt::F32 => 4, - SampleFmt::F64 => 8, - } - } -} - -#[derive(Debug, Clone, Copy)] -enum Endian { - Big, - Little, -} - -struct Reader<'a> { - bytes: &'a [u8], - endian: Endian, -} - -impl Reader<'_> { - fn u32(&self, at: usize) -> u32 { - let b: [u8; 4] = self.bytes[at..at + 4].try_into().unwrap(); - match self.endian { - Endian::Big => u32::from_be_bytes(b), - Endian::Little => u32::from_le_bytes(b), - } - } - - fn u64(&self, at: usize) -> u64 { - let b: [u8; 8] = self.bytes[at..at + 8].try_into().unwrap(); - match self.endian { - Endian::Big => u64::from_be_bytes(b), - Endian::Little => u64::from_le_bytes(b), - } - } - - fn f64(&self, at: usize) -> f64 { - let b: [u8; 8] = self.bytes[at..at + 8].try_into().unwrap(); - match self.endian { - Endian::Big => f64::from_be_bytes(b), - Endian::Little => f64::from_le_bytes(b), - } - } - - fn f32(&self, at: usize) -> f32 { - let b: [u8; 4] = self.bytes[at..at + 4].try_into().unwrap(); - match self.endian { - Endian::Big => f32::from_be_bytes(b), - Endian::Little => f32::from_le_bytes(b), - } - } - - #[inline] - fn sample(&self, at: usize, fmt: SampleFmt) -> f64 { - match fmt { - SampleFmt::F32 => self.f32(at) as f64, - SampleFmt::F64 => self.f64(at), - } - } - - fn read_reals(&self, at: usize, n: usize, fmt: SampleFmt) -> Vec { - (0..n) - .map(|i| match fmt { - SampleFmt::F32 => self.f32(at + i * 4) as f64, - SampleFmt::F64 => self.f64(at + i * 8), - }) - .collect() - } -} - -#[cfg(test)] -mod tests; diff --git a/crates/io/src/jeol/filter.rs b/crates/io/src/jeol/filter.rs deleted file mode 100644 index 06172c0e..00000000 --- a/crates/io/src/jeol/filter.rs +++ /dev/null @@ -1,51 +0,0 @@ -//! JEOL Delta digital-filter (oversampling FIR decimation) group delay. - -use super::Params; - -// Group delay in final sample points, present on the direct axis whenever -// `DIGITAL_FILTER = TRUE`. The filter is a cascade of symmetric FIR stages: -// `orders` is the stage count followed by each stage's tap count (`"2 41 74"` → -// two stages of 41 and 74 taps) and `factors` the matching per-stage decimation -// (`"6 2"`). A symmetric FIR of `M` taps delays by `(M-1)/2` samples at its own -// input rate; referred to the fully-decimated output rate that is scaled by the -// decimation accumulated before the stage, so the total is -// `Σ (M_k-1)/2 · D_{k-1} / D_total`. Returns 0.0 when the filter is off or the -// parameters are missing/unparsable, leaving the FID untouched. -pub(super) fn group_delay(params: &Params) -> f64 { - let enabled = params - .string_ci("DIGITAL_FILTER") - .map(|s| s.trim().eq_ignore_ascii_case("true")) - .unwrap_or(false); - if !enabled { - return 0.0; - } - let ints = |name: &str| -> Vec { - params - .string_ci(name) - .map(|s| { - s.split_whitespace() - .filter_map(|t| t.parse().ok()) - .collect() - }) - .unwrap_or_default() - }; - let orders = ints("orders"); - let factors = ints("factors"); - let taps = orders.split_first().map(|(_, rest)| rest).unwrap_or(&[]); - let stages = taps.len().min(factors.len()); - if stages == 0 { - return 0.0; - } - let total_decim: f64 = factors[..stages].iter().product(); - if total_decim <= 0.0 { - return 0.0; - } - let mut delay = 0.0; - let mut cumulative = 1.0; // decimation accumulated before the current stage - for k in 0..stages { - delay += (taps[k] - 1.0) / 2.0 * cumulative; - cumulative *= factors[k]; - } - let g = delay / total_decim; - if g.is_finite() && g >= 0.0 { g } else { 0.0 } -} diff --git a/crates/io/src/jeol/nus.rs b/crates/io/src/jeol/nus.rs deleted file mode 100644 index bc9fdf0e..00000000 --- a/crates/io/src/jeol/nus.rs +++ /dev/null @@ -1,120 +0,0 @@ -//! JEOL non-uniform-sampling metadata and serialized schedule extraction. - -use super::Params; -use crate::NusMeta; - -pub(super) fn detect_nus(bytes: &[u8], params: &Params, acquired: usize) -> Option { - let sampling = params.string_ci("sampling")?; - if !sampling.trim().to_ascii_uppercase().starts_with("NUS") { - return None; - } - // `sampling_rate` is a percentage (25 → 0.25). Fall back to a 1:1 grid. - let rate = params - .f64("sampling_rate") - .or_else(|| params.si("sampling_rate")) - .filter(|r| *r > 0.0 && *r <= 100.0) - .map(|r| r / 100.0) - .unwrap_or(1.0); - let grid = params - .f64("Y_ORIG_POINTS") - .or_else(|| params.si("Y_ORIG_POINTS")) - .filter(|v| v.is_finite() && *v >= acquired as f64) - .map(|v| v.round() as usize) - .unwrap_or_else(|| ((acquired as f64 / rate).round() as usize).max(acquired)); - let idx_base = params - .f64("nuslist_idx_base") - .or_else(|| params.si("nuslist_idx_base")) - .map(|v| v as usize) - .unwrap_or(1); - let mode = params - .string_ci("nus_mode") - .or_else(|| params.string_ci("auto_nus_mode")) - .unwrap_or_else(|| "unknown".to_string()); - let echo_antiecho = params - .string_ci("pn_type") - .map(|s| s.trim().eq_ignore_ascii_case("y")) - .unwrap_or(false); - let schedule = extract_nuslist(bytes, b"Y_NUSLIST").and_then(|raw| { - if raw.len() != acquired { - return None; - } - raw.into_iter() - .map(|value| value.checked_sub(idx_base).filter(|value| *value < grid)) - .collect::>>() - .filter(|values| { - let mut sorted = values.clone(); - sorted.sort_unstable(); - sorted.dedup(); - sorted.len() == values.len() - }) - }); - Some(NusMeta { - grid, - acquired, - idx_base, - mode, - echo_antiecho, - schedule, - }) -} - -/// Find a named integer-array parameter in Delta's serialized parameter tail. -/// The fixed parameter table contains the name too, so candidates are accepted -/// only when the preceding string header and following typed array both match. -pub(super) fn extract_nuslist(bytes: &[u8], wanted: &[u8]) -> Option> { - const STRING_TAG: u32 = 0x271d; - const INTEGER_TAG: u32 = 0x271a; - const CONTAINER_TAG: u32 = 0x2b2a; - - let be_u32 = |at: usize| -> Option { - let chunk: [u8; 4] = bytes.get(at..at.checked_add(4)?)?.try_into().ok()?; - Some(u32::from_be_bytes(chunk)) - }; - - for (name_at, name) in bytes.windows(wanted.len()).enumerate() { - if !name.eq_ignore_ascii_case(wanted) || name_at < 8 { - continue; - } - if be_u32(name_at - 8) != Some(STRING_TAG) - || be_u32(name_at - 4) != u32::try_from(wanted.len()).ok() - { - continue; - } - - let after_name = name_at + wanted.len(); - let container_at = (after_name..after_name.saturating_add(4)) - .take_while(|at| { - bytes - .get(after_name..*at) - .is_some_and(|pad| pad.iter().all(|b| *b == 0)) - }) - .find(|at| be_u32(*at) == Some(CONTAINER_TAG)); - let Some(container_at) = container_at else { - continue; - }; - let count = be_u32(container_at + 4).and_then(|v| usize::try_from(v).ok())?; - let array_bytes = count.checked_mul(12)?; - let mut pos = container_at.checked_add(8)?; - if pos - .checked_add(array_bytes) - .is_none_or(|end| end > bytes.len()) - { - continue; - } - - let mut values = Vec::with_capacity(count); - for _ in 0..count { - if be_u32(pos) != Some(INTEGER_TAG) || be_u32(pos + 4) != Some(1) { - values.clear(); - break; - } - let value = be_u32(pos + 8).and_then(|v| usize::try_from(v).ok())?; - values.push(value); - pos += 12; - } - if values.len() == count { - return Some(values); - } - } - None -} diff --git a/crates/io/src/jeol/params.rs b/crates/io/src/jeol/params.rs deleted file mode 100644 index cd5d846d..00000000 --- a/crates/io/src/jeol/params.rs +++ /dev/null @@ -1,106 +0,0 @@ -use super::{Endian, Reader}; -use crate::jeol::ruler::{ascii_trim, prefix_exponent}; -use std::collections::HashMap; - -pub(super) struct Params { - pub(super) f64s: HashMap, - /// SI-normalized numeric values (raw value with its scaler prefix folded in). - si: HashMap, - pub(super) strings: HashMap, -} - -impl Params { - const SCALER: usize = 0x06; - const VALUE: usize = 0x10; - const VALUE_TYPE: usize = 0x20; - const NAME: usize = 0x24; - const NAME_LEN: usize = 28; - - pub(super) fn empty() -> Self { - Self { - f64s: HashMap::new(), - si: HashMap::new(), - strings: HashMap::new(), - } - } - - // List header at `at` (body endianness): record_size u32, low_index u32, - // high_index u32, total_size u32; then fixed-size records. - pub(super) fn parse(bytes: &[u8], at: usize, endian: Endian) -> Self { - let r = Reader { bytes, endian }; - if at + 16 > bytes.len() { - return Self::empty(); - } - let rec_size = r.u32(at) as usize; - let high = r.u32(at + 8) as usize; - if !(Self::NAME + Self::NAME_LEN..=4096).contains(&rec_size) { - return Self::empty(); - } - let count = high.saturating_add(1).min(4096); - let base = at + 16; - let mut out = Self::empty(); - for i in 0..count { - let rec = base + i * rec_size; - if rec + rec_size > bytes.len() { - break; - } - let name = ascii_trim(&bytes[rec + Self::NAME..rec + Self::NAME + Self::NAME_LEN]); - if name.is_empty() { - continue; - } - match r.u32(rec + Self::VALUE_TYPE) { - 2 => { - let raw = r.f64(rec + Self::VALUE); - let si = raw * 10f64.powi(prefix_exponent(bytes[rec + Self::SCALER])); - out.si.insert(name.clone(), si); - out.f64s.insert(name, raw); - } - 0 => { - let value = ascii_trim(&bytes[rec + Self::VALUE..rec + Self::VALUE + 16]); - if !value.is_empty() { - out.strings.insert(name, value); - } - } - _ => {} - } - } - out - } - - pub(super) fn f64(&self, name: &str) -> Option { - self.f64s - .get(name) - .copied() - .filter(|value| value.is_finite()) - } - - pub(super) fn string(&self, name: &str) -> Option { - self.strings.get(name).cloned() - } - - pub(super) fn si(&self, name: &str) -> Option { - self.numeric_ci(name, &self.si) - } - - fn numeric_ci(&self, name: &str, values: &HashMap) -> Option { - values - .get(name) - .copied() - .or_else(|| { - values - .iter() - .find(|(key, _)| key.eq_ignore_ascii_case(name)) - .map(|(_, value)| *value) - }) - .filter(|value| value.is_finite()) - } - - pub(super) fn string_ci(&self, name: &str) -> Option { - self.strings.get(name).cloned().or_else(|| { - self.strings - .iter() - .find(|(key, _)| key.eq_ignore_ascii_case(name)) - .map(|(_, value)| value.clone()) - }) - } -} diff --git a/crates/io/src/jeol/ruler.rs b/crates/io/src/jeol/ruler.rs deleted file mode 100644 index 26e3b29f..00000000 --- a/crates/io/src/jeol/ruler.rs +++ /dev/null @@ -1,127 +0,0 @@ -//! Decoding of JEOL scaler bytes, display units, and the embedded arrayed-axis -//! (pseudo-2D ruler) text. - -use crate::{AxisSource, PseudoKind}; - -/// Decode a JEOL scaler byte to the base-10 exponent it applies to a stored -/// value. The high nibble is a signed SI-prefix index: 0→10⁰, 1→milli, 2→micro, -/// 3→nano, …, and 0xF→kilo, 0xE→mega for the positive prefixes. -pub(super) fn prefix_exponent(scaler: u8) -> i32 { - let n = (scaler >> 4) as i32; - if n < 8 { -3 * n } else { -3 * (n - 16) } -} - -/// Convert a `value[unit]` display unit to an SI multiplier, e.g. `ms → 1e-3`, -/// `mT/m → 1e-3`, `G/cm → 1e-2`. Unrecognised units map to 1.0. -fn unit_to_si(unit: &str) -> f64 { - match unit.trim() { - "s" => 1.0, - "ms" => 1e-3, - "us" | "µs" => 1e-6, - "ns" => 1e-9, - "T/m" => 1.0, - "mT/m" => 1e-3, - "G/cm" => 1e-2, // 1 gauss/cm = 1e-4 T / 1e-2 m = 1e-2 T/m - "G/mm" => 0.1, - _ => 1.0, - } -} - -pub(super) fn kind_for_unit(unit: &str) -> PseudoKind { - match unit.trim() { - "s" | "ms" | "us" | "µs" | "ns" => PseudoKind::Delay, - "T/m" | "mT/m" | "G/cm" | "G/mm" => PseudoKind::Gradient, - _ => PseudoKind::Generic, - } -} - -/// Parse a single `123.4[unit]` token into `(value, unit)`; the value is left in -/// its display unit (the caller applies `unit_to_si`). -fn parse_quantity_token(tok: &str) -> Option<(f64, String)> { - let tok = tok.trim(); - let open = tok.find('[')?; - let close = tok.find(']')?; - if close < open { - return None; - } - let value: f64 = tok[..open].trim().parse().ok()?; - let unit = tok[open + 1..close].trim().to_string(); - Some((value, unit)) -} - -/// Scan the embedded experiment text for the arrayed indirect axis. JEOL writes -/// it as `name => y_acq {v1[u], v2[u], …}` (explicit list) or -/// `name => y_acq start[u]..stop[u] : step[u]` (linear ramp). Returns the SI -/// values, the (display) unit, and which form was found. -pub(super) fn scan_embedded_axis(bytes: &[u8]) -> Option<(String, Vec, String, AxisSource)> { - // Work over a lossy-ASCII view; the experiment text is plain ASCII. - let text = String::from_utf8_lossy(bytes); - let marker = "y_acq"; - let mut search_from = 0; - while let Some(rel) = text[search_from..].find(marker) { - let at = search_from + rel; - search_from = at + marker.len(); - - // Recover the parameter name: the identifier just before "=>"/"=?". - let name = text[..at] - .rfind(['>', '?']) - .map(|arrow| text[..arrow].trim_end_matches(['=', ' ']).to_string()) - .and_then(|s| s.rsplit([' ', '\n', '\t', ';']).next().map(str::to_string)) - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| "increment".to_string()); - - let rest = text[at + marker.len()..].trim_start(); - - // Explicit list form: { … }. - if let Some(stripped) = rest.strip_prefix('{') - && let Some(end) = stripped.find('}') - { - let mut unit = String::new(); - let values: Vec = stripped[..end] - .split(',') - .filter_map(|tok| { - let (v, u) = parse_quantity_token(tok)?; - if unit.is_empty() { - unit = u.clone(); - } - Some(v * unit_to_si(&u)) - }) - .collect(); - if values.len() >= 2 { - return Some((name, values, unit, AxisSource::EmbeddedList)); - } - } - - // Ramp form: start[u]..stop[u] : step[u]. Each token carries its own - // unit (start may be mT/m while stop is T/m), so convert independently. - let ramp = rest.split(['\n', ',']).next().unwrap_or(rest); - if let Some((lo_s, hi_step)) = ramp.split_once("..") { - let (hi_s, step_s) = hi_step.split_once(':').unwrap_or((hi_step, "")); - if let (Some((lo, lu)), Some((hi, hu)), Some((step, su))) = ( - parse_quantity_token(lo_s), - parse_quantity_token(hi_s), - parse_quantity_token(step_s), - ) { - let lo_si = lo * unit_to_si(&lu); - let hi_si = hi * unit_to_si(&hu); - let step_si = (step * unit_to_si(&su)).abs(); - if step_si > 0.0 && hi_si.is_finite() && lo_si.is_finite() { - let mut values = Vec::new(); - let n = ((hi_si - lo_si) / step_si).round() as i64; - for i in 0..=n.max(0) { - values.push(lo_si + step_si * i as f64); - } - if values.len() >= 2 { - return Some((name, values, lu, AxisSource::EmbeddedRamp)); - } - } - } - } - } - None -} - -pub(super) fn ascii_trim(raw: &[u8]) -> String { - let end = raw.iter().position(|&b| b == 0).unwrap_or(raw.len()); - String::from_utf8_lossy(&raw[..end]).trim().to_string() -} diff --git a/crates/io/src/jeol/tests.rs b/crates/io/src/jeol/tests.rs deleted file mode 100644 index 02e6a8a3..00000000 --- a/crates/io/src/jeol/tests.rs +++ /dev/null @@ -1,718 +0,0 @@ -use super::*; -use crate::AxisSource; -use crate::jeol::ruler::prefix_exponent; - -#[test] -fn prefix_exponent_ladder() { - assert_eq!(prefix_exponent(0x01), 0); // none - assert_eq!(prefix_exponent(0x11), -3); // milli - assert_eq!(prefix_exponent(0x21), -6); // micro - assert_eq!(prefix_exponent(0x31), -9); // nano - assert_eq!(prefix_exponent(0xF1), 3); // kilo - assert_eq!(prefix_exponent(0xE1), 6); // mega -} - -#[test] -fn scans_embedded_list_ruler() { - let text = b"comment_7 => \"*** Pulse Delay ***\";\n tau_interval \ - => y_acq {1[ms], 1.7644[ms], 3.11312[ms], 5[s]}, help \"arrayed list\";"; - let (name, values, unit, source) = scan_embedded_axis(text).expect("axis"); - assert_eq!(name, "tau_interval"); - assert_eq!(unit, "ms"); - assert_eq!(source, AxisSource::EmbeddedList); - assert!((values[0] - 0.001).abs() < 1e-12); - assert!((values[1] - 0.0017644).abs() < 1e-12); - assert!((values[3] - 5.0).abs() < 1e-12); // 5[s] converted from seconds - assert_eq!(kind_for_unit(&unit), PseudoKind::Delay); -} - -#[test] -fn scans_embedded_ramp_ruler() { - let text = - b" g => y_acq 20[mT/m]..0.28[T/m] : 17.33333[mT/m], help \"g\";"; - let (name, values, unit, source) = scan_embedded_axis(text).expect("axis"); - assert_eq!(name, "g"); - assert_eq!(source, AxisSource::EmbeddedRamp); - assert_eq!(kind_for_unit(&unit), PseudoKind::Gradient); - assert_eq!(values.len(), 16); - assert!((values[0] - 0.02).abs() < 1e-9); - assert!((values[15] - 0.28).abs() < 1e-6); -} - -fn params_with(strings: &[(&str, &str)]) -> Params { - let mut p = Params::empty(); - for (k, v) in strings { - p.strings.insert((*k).to_string(), (*v).to_string()); - } - p -} - -#[test] -fn experiment_name_is_cleaned_for_acquisition_identity() { - let params = params_with(&[( - "experiment", - r"C:\Program Files\JEOL\experiments\13c_eb_sn.jxp", - )]); - assert_eq!(experiment_name(¶ms).as_deref(), Some("13c_eb_sn")); - - let params = params_with(&[("CONTENT", "cosy.JXP")]); - assert_eq!(experiment_name(¶ms).as_deref(), Some("cosy")); -} - -#[test] -fn group_delay_from_fir_cascade() { - // orders = " ", factors = per-stage decimation. - // Delay (final points) = Σ (taps_k-1)/2 · D_{k-1} / D_total. - // "6 2" / "41 74": (20·1 + 36.5·6)/12 = 19.9166… - let g = group_delay(¶ms_with(&[ - ("DIGITAL_FILTER", "TRUE"), - ("orders", "2 41 74"), - ("factors", "6 2"), - ])); - assert!((g - 239.0 / 12.0).abs() < 1e-9, "got {g}"); - - // "2 2" / "15 73": (7·1 + 36·2)/4 = 19.75. - let g = group_delay(¶ms_with(&[ - ("DIGITAL_FILTER", "TRUE"), - ("orders", "2 15 73"), - ("factors", "2 2"), - ])); - assert!((g - 19.75).abs() < 1e-9, "got {g}"); -} - -#[test] -fn group_delay_gated_and_guarded() { - // Filter off → no correction even with orders/factors present. - let g = group_delay(¶ms_with(&[ - ("DIGITAL_FILTER", "FALSE"), - ("orders", "2 41 74"), - ("factors", "6 2"), - ])); - assert_eq!(g, 0.0); - - // Filter flag absent → no correction. - assert_eq!(group_delay(¶ms_with(&[("orders", "2 41 74")])), 0.0); - - // Filter on but parameters missing → no correction, no panic. - assert_eq!( - group_delay(¶ms_with(&[("DIGITAL_FILTER", "TRUE")])), - 0.0 - ); -} - -#[test] -fn rejects_bad_magic() { - let buf = vec![0u8; HEADER_LEN + 16]; - let err = read_jdf_bytes(&buf, "x".into()).unwrap_err(); - assert!(matches!(err, IoError::BadMagic)); -} - -#[test] -fn rejects_truncated_header() { - let buf = vec![0u8; 100]; - let err = read_jdf_bytes(&buf, "x".into()).unwrap_err(); - assert!(matches!(err, IoError::Truncated { .. })); -} - -#[test] -fn round_trips_a_hand_built_1d_le_file() { - let npoints = 4usize; - let rec_size = 64usize; - let param_hdr = HEADER_LEN; - let param_recs = param_hdr + 16; - let data_start = param_recs + rec_size; - let data_len = npoints * 8 * 2; - let mut buf = vec![0u8; data_start + data_len]; - - buf[..8].copy_from_slice(MAGIC); - buf[off::ENDIAN] = 1; // little-endian body - buf[off::DATA_DIMENSION_NUMBER] = 1; - buf[off::DATA_AXIS_TYPE] = AXIS_COMPLEX; - buf[off::DATA_AXIS_UNITS + 1] = UNIT_SECOND; - buf[off::DATA_POINTS..off::DATA_POINTS + 4].copy_from_slice(&(npoints as u32).to_be_bytes()); - buf[off::BASE_FREQ..off::BASE_FREQ + 8].copy_from_slice(&600.17f64.to_be_bytes()); - // SW = (npoints-1)/acq = 1000 Hz. - let acq = (npoints as f64 - 1.0) / 1000.0; - buf[off::DATA_AXIS_START..off::DATA_AXIS_START + 8].copy_from_slice(&0.0f64.to_be_bytes()); - buf[off::DATA_AXIS_STOP..off::DATA_AXIS_STOP + 8].copy_from_slice(&acq.to_be_bytes()); - buf[off::DATA_START..off::DATA_START + 4].copy_from_slice(&(data_start as u32).to_be_bytes()); - buf[off::DATA_LENGTH..off::DATA_LENGTH + 8].copy_from_slice(&(data_len as u64).to_be_bytes()); - - buf[param_hdr..param_hdr + 4].copy_from_slice(&(rec_size as u32).to_le_bytes()); - buf[param_hdr + 8..param_hdr + 12].copy_from_slice(&0u32.to_le_bytes()); - buf[param_recs + 0x10..param_recs + 0x18].copy_from_slice(&4.7f64.to_le_bytes()); - buf[param_recs + 0x20..param_recs + 0x24].copy_from_slice(&2u32.to_le_bytes()); - let name = b"X_OFFSET"; - buf[param_recs + 0x24..param_recs + 0x24 + name.len()].copy_from_slice(name); - - for i in 0..npoints { - let ro = data_start + i * 8; - let io = data_start + npoints * 8 + i * 8; - buf[ro..ro + 8].copy_from_slice(&((i as f64) + 1.0).to_le_bytes()); - buf[io..io + 8].copy_from_slice(&((i as f64) + 5.0).to_le_bytes()); - } - - let data = match read_jdf_bytes(&buf, "test.jdf".into()).unwrap() { - Acquisition::D1(d) => d, - Acquisition::D2(_) => panic!("expected 1D"), - Acquisition::Electrophysiology(_) => panic!("expected NMR"), - Acquisition::Afm(_) => panic!("expected NMR"), - Acquisition::MassSpec(_) | Acquisition::Xrd(_) => panic!("expected NMR"), - Acquisition::Xps(_) => panic!("expected NMR"), - }; - assert_eq!(data.len(), 4); - // FID conjugated on read (imaginary channel negated). - assert_eq!(data.points[0], Complex64::new(1.0, -5.0)); - assert_eq!(data.points[3], Complex64::new(4.0, -8.0)); - assert!((data.observe_freq_mhz - 600.17).abs() < 1e-6); - assert!((data.spectral_width_hz - 1000.0).abs() < 1e-6); - assert!( - (data.carrier_ppm - 4.7).abs() < 1e-9, - "carrier from X_OFFSET" - ); - assert_eq!(data.nucleus, "1H"); -} - -#[test] -fn recognizes_a_processed_1d_axis_without_applying_fft() { - let data = processed_1d(0x01, UNIT_PPM, -10.0, 10.0, 100.0); - - assert_eq!(data.domain, Domain::Frequency); - assert_eq!(data.spectral_width_hz, 2000.0); - assert_eq!(data.carrier_ppm, 0.0); - assert_eq!( - data.points.iter().map(|point| point.re).collect::>(), - vec![1.0, 2.0, 3.0, 4.0] - ); -} - -#[test] -fn normalizes_a_descending_processed_axis_and_its_samples() { - let data = processed_1d(0x01, UNIT_PPM, 10.0, -10.0, 100.0); - - assert_eq!(data.spectral_width_hz, 2000.0); - assert_eq!(data.carrier_ppm, 0.0); - assert_eq!( - data.points.iter().map(|point| point.re).collect::>(), - vec![4.0, 3.0, 2.0, 1.0] - ); -} - -#[test] -fn processed_axis_uses_all_stored_points_not_the_time_domain_offset_stop() { - let mut buf = processed_1d_bytes(0x01, UNIT_PPM, -10.0, 10.0, 100.0); - buf[off::DATA_OFFSET_STOP..off::DATA_OFFSET_STOP + 4].copy_from_slice(&2u32.to_be_bytes()); - - let Acquisition::D1(data) = read_jdf_bytes(&buf, "processed-window.jdf".into()).unwrap() else { - panic!("expected 1D NMR"); - }; - assert_eq!(data.len(), 4); - assert_eq!(data.spectral_width_hz, 2000.0); -} - -#[test] -fn converts_hertz_axis_to_frequency_metadata() { - let data = processed_1d(0x01, UNIT_HERTZ, 1000.0, 3000.0, 400.0); - - assert_eq!(data.domain, Domain::Frequency); - assert_eq!(data.spectral_width_hz, 2000.0); - assert_eq!(data.carrier_ppm, 5.0); -} - -#[test] -fn applies_axis_prefix_before_converting_frequency_metadata() { - let data = processed_1d(0xF1, UNIT_HERTZ, -1.0, 3.0, 400.0); - - assert_eq!(data.spectral_width_hz, 4000.0); - assert_eq!(data.carrier_ppm, 2.5); -} - -#[test] -fn applies_axis_prefix_to_fid_acquisition_time() { - let data = processed_1d(0x11, UNIT_SECOND, 0.0, 3.0, 400.0); - - assert_eq!(data.domain, Domain::Time); - assert!((data.spectral_width_hz - 1000.0).abs() < 1e-9); -} - -#[test] -fn rejects_an_unknown_axis_unit() { - let mut buf = vec![0u8; HEADER_LEN + 16]; - buf[..8].copy_from_slice(MAGIC); - buf[off::DATA_DIMENSION_NUMBER] = 1; - buf[off::DATA_AXIS_TYPE] = AXIS_COMPLEX; - buf[off::DATA_POINTS..off::DATA_POINTS + 4].copy_from_slice(&1u32.to_be_bytes()); - - let err = read_jdf_bytes(&buf, "unknown-unit.jdf".into()).unwrap_err(); - assert!( - matches!(err, IoError::Unsupported(ref message) if message.contains("unknown JEOL unit")), - "got {err:?}" - ); -} - -#[test] -fn rejects_invalid_frequency_metadata_instead_of_inventing_defaults() { - let mut buf = processed_1d_bytes(0x01, UNIT_HERTZ, 1.0, 1.0, 0.0); - let err = read_jdf_bytes(&buf, "invalid-frequency.jdf".into()).unwrap_err(); - assert!( - matches!(err, IoError::Unsupported(ref message) if message.contains("observe frequency")), - "got {err:?}" - ); - - buf[off::BASE_FREQ..off::BASE_FREQ + 8].copy_from_slice(&400.0f64.to_be_bytes()); - let err = read_jdf_bytes(&buf, "invalid-span.jdf".into()).unwrap_err(); - assert!( - matches!(err, IoError::Unsupported(ref message) if message.contains("axis span")), - "got {err:?}" - ); -} - -fn processed_1d( - unit_scaler: u8, - unit: u8, - axis_start: f64, - axis_stop: f64, - observe_freq_mhz: f64, -) -> NmrData { - let buf = processed_1d_bytes(unit_scaler, unit, axis_start, axis_stop, observe_freq_mhz); - let Acquisition::D1(data) = read_jdf_bytes(&buf, "axis.jdf".into()).unwrap() else { - panic!("expected 1D NMR"); - }; - data -} - -fn processed_1d_bytes( - unit_scaler: u8, - unit: u8, - axis_start: f64, - axis_stop: f64, - observe_freq_mhz: f64, -) -> Vec { - let npoints = 4usize; - let data_start = HEADER_LEN + 16; - let data_len = npoints * 8 * 2; - let mut buf = vec![0u8; data_start + data_len]; - - buf[..8].copy_from_slice(MAGIC); - buf[off::ENDIAN] = 1; - buf[off::DATA_DIMENSION_NUMBER] = 1; - buf[off::DATA_AXIS_TYPE] = AXIS_COMPLEX; - buf[off::DATA_AXIS_UNITS] = unit_scaler; - buf[off::DATA_AXIS_UNITS + 1] = unit; - buf[off::DATA_POINTS..off::DATA_POINTS + 4].copy_from_slice(&(npoints as u32).to_be_bytes()); - buf[off::DATA_OFFSET_STOP..off::DATA_OFFSET_STOP + 4] - .copy_from_slice(&((npoints - 1) as u32).to_be_bytes()); - buf[off::BASE_FREQ..off::BASE_FREQ + 8].copy_from_slice(&observe_freq_mhz.to_be_bytes()); - buf[off::DATA_AXIS_START..off::DATA_AXIS_START + 8].copy_from_slice(&axis_start.to_be_bytes()); - buf[off::DATA_AXIS_STOP..off::DATA_AXIS_STOP + 8].copy_from_slice(&axis_stop.to_be_bytes()); - buf[off::DATA_START..off::DATA_START + 4].copy_from_slice(&(data_start as u32).to_be_bytes()); - buf[off::DATA_LENGTH..off::DATA_LENGTH + 8].copy_from_slice(&(data_len as u64).to_be_bytes()); - - for i in 0..npoints { - let real = data_start + i * 8; - let imag = data_start + npoints * 8 + i * 8; - buf[real..real + 8].copy_from_slice(&(i as f64 + 1.0).to_le_bytes()); - buf[imag..imag + 8].copy_from_slice(&0.0f64.to_le_bytes()); - } - - buf -} - -#[test] -fn uses_real_point_count_over_padded_count_for_1d() { - // 8 padded points, only 4 real (DATA_OFFSET_STOP = 3). The FID must be - // truncated to 4 and the sweep width computed from the real count, not the - // padded one — otherwise every ppm is scaled by (8-1)/(4-1). - let npad = 8usize; - let nreal = 4usize; - let rec_size = 64usize; - let param_hdr = HEADER_LEN; - let param_recs = param_hdr + 16; - let data_start = param_recs + rec_size; - let data_len = npad * 8 * 2; // padded reals then padded imags, f64 - let mut buf = vec![0u8; data_start + data_len]; - - buf[..8].copy_from_slice(MAGIC); - buf[off::ENDIAN] = 1; - buf[off::DATA_DIMENSION_NUMBER] = 1; - buf[off::DATA_AXIS_TYPE] = AXIS_COMPLEX; - buf[off::DATA_AXIS_UNITS + 1] = UNIT_SECOND; - buf[off::DATA_POINTS..off::DATA_POINTS + 4].copy_from_slice(&(npad as u32).to_be_bytes()); - buf[off::DATA_OFFSET_STOP..off::DATA_OFFSET_STOP + 4] - .copy_from_slice(&((nreal - 1) as u32).to_be_bytes()); - buf[off::BASE_FREQ..off::BASE_FREQ + 8].copy_from_slice(&600.0f64.to_be_bytes()); - // acq time over the real count → SW = (nreal-1)/acq = 1000 Hz. - let acq = (nreal as f64 - 1.0) / 1000.0; - buf[off::DATA_AXIS_START..off::DATA_AXIS_START + 8].copy_from_slice(&0.0f64.to_be_bytes()); - buf[off::DATA_AXIS_STOP..off::DATA_AXIS_STOP + 8].copy_from_slice(&acq.to_be_bytes()); - buf[off::DATA_START..off::DATA_START + 4].copy_from_slice(&(data_start as u32).to_be_bytes()); - buf[off::DATA_LENGTH..off::DATA_LENGTH + 8].copy_from_slice(&(data_len as u64).to_be_bytes()); - - buf[param_hdr..param_hdr + 4].copy_from_slice(&(rec_size as u32).to_le_bytes()); - buf[param_hdr + 8..param_hdr + 12].copy_from_slice(&0u32.to_le_bytes()); - - for i in 0..npad { - // Real channel: 1..=4 real, then padding sentinels that must be dropped. - let ro = data_start + i * 8; - let io = data_start + npad * 8 + i * 8; - let re = if i < nreal { i as f64 + 1.0 } else { 999.0 }; - let im = if i < nreal { i as f64 + 5.0 } else { -999.0 }; - buf[ro..ro + 8].copy_from_slice(&re.to_le_bytes()); - buf[io..io + 8].copy_from_slice(&im.to_le_bytes()); - } - - let data = match read_jdf_bytes(&buf, "padded.jdf".into()).unwrap() { - Acquisition::D1(d) => d, - Acquisition::D2(_) => panic!("expected 1D"), - Acquisition::Electrophysiology(_) => panic!("expected NMR"), - Acquisition::Afm(_) => panic!("expected NMR"), - Acquisition::MassSpec(_) | Acquisition::Xrd(_) => panic!("expected NMR"), - Acquisition::Xps(_) => panic!("expected NMR"), - }; - assert_eq!(data.len(), nreal, "FID truncated to the real point count"); - assert_eq!(data.points[0], Complex64::new(1.0, -5.0)); - assert_eq!(data.points[3], Complex64::new(4.0, -8.0)); - assert!( - (data.spectral_width_hz - 1000.0).abs() < 1e-6, - "sweep width uses the real count, got {}", - data.spectral_width_hz - ); -} - -#[test] -fn rejects_ambiguous_sample_width() { - // A data section that is neither 4× nor 8× the sample count must error rather - // than silently pick a width and splice unrelated samples into garbage. - let npoints = 4usize; - let rec_size = 64usize; - let param_hdr = HEADER_LEN; - let param_recs = param_hdr + 16; - let data_start = param_recs + rec_size; - // components = 2 → total 8 samples; f32 wants 32 bytes, f64 wants 64. Give 48. - let data_len = 48usize; - let mut buf = vec![0u8; data_start + data_len]; - - buf[..8].copy_from_slice(MAGIC); - buf[off::ENDIAN] = 1; - buf[off::DATA_DIMENSION_NUMBER] = 1; - buf[off::DATA_AXIS_TYPE] = AXIS_COMPLEX; - buf[off::DATA_AXIS_UNITS + 1] = UNIT_SECOND; - buf[off::DATA_POINTS..off::DATA_POINTS + 4].copy_from_slice(&(npoints as u32).to_be_bytes()); - buf[off::DATA_START..off::DATA_START + 4].copy_from_slice(&(data_start as u32).to_be_bytes()); - buf[off::DATA_LENGTH..off::DATA_LENGTH + 8].copy_from_slice(&(data_len as u64).to_be_bytes()); - buf[param_hdr..param_hdr + 4].copy_from_slice(&(rec_size as u32).to_le_bytes()); - buf[param_hdr + 8..param_hdr + 12].copy_from_slice(&0u32.to_le_bytes()); - - let err = read_jdf_bytes(&buf, "ambiguous.jdf".into()).unwrap_err(); - assert!(matches!(err, IoError::Unsupported(_)), "got {err:?}"); -} - -fn write_param_record(buf: &mut [u8], rec: usize, name: &[u8], is_f64: bool, f: f64, s: &[u8]) { - if is_f64 { - buf[rec + 0x10..rec + 0x18].copy_from_slice(&f.to_le_bytes()); - buf[rec + 0x20..rec + 0x24].copy_from_slice(&2u32.to_le_bytes()); - } else { - buf[rec + 0x10..rec + 0x10 + s.len()].copy_from_slice(s); - buf[rec + 0x20..rec + 0x24].copy_from_slice(&0u32.to_le_bytes()); - } - buf[rec + 0x24..rec + 0x24 + name.len()].copy_from_slice(name); -} - -#[test] -fn de_tiles_a_hand_built_2d_across_tile_blocks() { - // 64×32 padded (two F2 tile-blocks), 34×2 real, complex X / real Y. - let (cols_pad, rows_pad) = (64usize, 32usize); - let (cols_real, rows_real) = (34usize, 2usize); - let planes = 2usize; - let rec_size = 64usize; - let param_hdr = HEADER_LEN; - let param_recs = param_hdr + 16; - let n_records = 4usize; - let data_start = param_recs + n_records * rec_size; - let data_len = cols_pad * rows_pad * planes * 8; - let mut buf = vec![0u8; data_start + data_len]; - - buf[..8].copy_from_slice(MAGIC); - buf[off::ENDIAN] = 1; // little-endian body - buf[off::DATA_DIMENSION_NUMBER] = 2; - buf[off::DATA_AXIS_TYPE] = AXIS_REAL_COMPLEX; - buf[off::DATA_AXIS_TYPE + 1] = AXIS_REAL_COMPLEX; - buf[off::DATA_POINTS..off::DATA_POINTS + 4].copy_from_slice(&(cols_pad as u32).to_be_bytes()); - buf[off::DATA_POINTS + 4..off::DATA_POINTS + 8] - .copy_from_slice(&(rows_pad as u32).to_be_bytes()); - buf[off::DATA_OFFSET_STOP..off::DATA_OFFSET_STOP + 4] - .copy_from_slice(&((cols_real - 1) as u32).to_be_bytes()); - buf[off::DATA_OFFSET_STOP + 4..off::DATA_OFFSET_STOP + 8] - .copy_from_slice(&((rows_real - 1) as u32).to_be_bytes()); - buf[off::BASE_FREQ..off::BASE_FREQ + 8].copy_from_slice(&600.0f64.to_be_bytes()); - buf[off::BASE_FREQ + 8..off::BASE_FREQ + 16].copy_from_slice(&150.0f64.to_be_bytes()); - buf[off::DATA_AXIS_STOP..off::DATA_AXIS_STOP + 8].copy_from_slice(&1e-3f64.to_be_bytes()); - buf[off::DATA_AXIS_STOP + 8..off::DATA_AXIS_STOP + 16].copy_from_slice(&2e-3f64.to_be_bytes()); - buf[off::DATA_START..off::DATA_START + 4].copy_from_slice(&(data_start as u32).to_be_bytes()); - buf[off::DATA_LENGTH..off::DATA_LENGTH + 8].copy_from_slice(&(data_len as u64).to_be_bytes()); - - buf[param_hdr..param_hdr + 4].copy_from_slice(&(rec_size as u32).to_le_bytes()); - buf[param_hdr + 8..param_hdr + 12].copy_from_slice(&((n_records - 1) as u32).to_le_bytes()); - write_param_record(&mut buf, param_recs, b"X_OFFSET", true, 1.5, b""); - write_param_record( - &mut buf, - param_recs + rec_size, - b"Y_OFFSET", - true, - 75.0, - b"", - ); - write_param_record( - &mut buf, - param_recs + 2 * rec_size, - b"X_DOMAIN", - false, - 0.0, - b"Proton", - ); - write_param_record( - &mut buf, - param_recs + 3 * rec_size, - b"Y_DOMAIN", - false, - 0.0, - b"Carbon13", - ); - - // Fill the data section in JEOL tiled order (plane, F1-block, F2-block, - // row-in-tile, col-in-tile) with a distinctive value per cell. - let re_val = |row: usize, col: usize| 100.0 + row as f64 * 10.0 + col as f64; - let im_val = |row: usize, col: usize| 1.0 + row as f64 + col as f64 * 0.5; - let n_f2b = cols_pad / TILE; - let n_f1b = rows_pad / TILE; - let mut w = data_start; - for plane in 0..planes { - for fb in 0..n_f1b { - for cb in 0..n_f2b { - for r in 0..TILE { - for c in 0..TILE { - let (row, col) = (fb * TILE + r, cb * TILE + c); - let v = if row < rows_real && col < cols_real { - if plane == 0 { - re_val(row, col) - } else { - im_val(row, col) - } - } else { - 0.0 - }; - buf[w..w + 8].copy_from_slice(&v.to_le_bytes()); - w += 8; - } - } - } - } - } - - let two = match read_jdf_bytes(&buf, "t2d.jdf".into()).unwrap() { - Acquisition::D2(d) => *d, - Acquisition::D1(_) => panic!("expected 2D"), - Acquisition::Electrophysiology(_) => panic!("expected NMR"), - Acquisition::Afm(_) => panic!("expected NMR"), - Acquisition::MassSpec(_) | Acquisition::Xrd(_) => panic!("expected NMR"), - Acquisition::Xps(_) => panic!("expected NMR"), - }; - assert_eq!((two.cols, two.rows), (cols_real, rows_real)); - assert_eq!(two.data.len(), cols_real * rows_real); - for row in 0..rows_real { - for col in 0..cols_real { - let got = two.data[row * cols_real + col]; - // Imaginary channel negated (conjugated on read). - assert_eq!( - got, - Complex64::new(re_val(row, col), -im_val(row, col)), - "cell ({row},{col}) mismatch (col {col} is in F2 block {})", - col / TILE - ); - } - } - assert_eq!(two.direct.nucleus, "1H"); - assert_eq!(two.indirect.nucleus, "13C"); - assert!((two.direct.carrier_ppm - 1.5).abs() < 1e-9); - assert!((two.indirect.carrier_ppm - 75.0).abs() < 1e-9); - assert_eq!(two.quad, QuadMode::Complex); - assert!(two.indirect_conjugate); -} - -#[test] -fn de_tiles_a_hand_built_hypercomplex_2d() { - // Both axes `Complex` (States hypercomplex): four sample planes ordered - // (F1-imag?, F2-imag?) with F2 toggling fastest — RR, RI, IR, II. Each t1 - // increment's cosine (F1-real) and sine (F1-imag) channel must be interleaved - // as consecutive stored rows and tagged QuadMode::States. - let (cols_pad, rows_pad) = (32usize, 32usize); - let (cols_real, rows_real) = (3usize, 2usize); - let planes = 4usize; - let rec_size = 64usize; - let param_hdr = HEADER_LEN; - let param_recs = param_hdr + 16; - let data_start = param_recs + rec_size; - let data_len = cols_pad * rows_pad * planes * 8; - let mut buf = vec![0u8; data_start + data_len]; - - buf[..8].copy_from_slice(MAGIC); - buf[off::ENDIAN] = 1; // little-endian body - buf[off::DATA_DIMENSION_NUMBER] = 2; - buf[off::DATA_AXIS_TYPE] = AXIS_COMPLEX; - buf[off::DATA_AXIS_TYPE + 1] = AXIS_COMPLEX; - buf[off::DATA_POINTS..off::DATA_POINTS + 4].copy_from_slice(&(cols_pad as u32).to_be_bytes()); - buf[off::DATA_POINTS + 4..off::DATA_POINTS + 8] - .copy_from_slice(&(rows_pad as u32).to_be_bytes()); - buf[off::DATA_OFFSET_STOP..off::DATA_OFFSET_STOP + 4] - .copy_from_slice(&((cols_real - 1) as u32).to_be_bytes()); - buf[off::DATA_OFFSET_STOP + 4..off::DATA_OFFSET_STOP + 8] - .copy_from_slice(&((rows_real - 1) as u32).to_be_bytes()); - buf[off::DATA_START..off::DATA_START + 4].copy_from_slice(&(data_start as u32).to_be_bytes()); - buf[off::DATA_LENGTH..off::DATA_LENGTH + 8].copy_from_slice(&(data_len as u64).to_be_bytes()); - buf[param_hdr..param_hdr + 4].copy_from_slice(&(rec_size as u32).to_le_bytes()); - buf[param_hdr + 8..param_hdr + 12].copy_from_slice(&0u32.to_le_bytes()); - - // Distinctive value per (plane, row, col); a single 32-tile so tiling is a - // plain row-major fill within each plane. - let val = |plane: usize, row: usize, col: usize| { - 1000.0 * plane as f64 + 10.0 * row as f64 + col as f64 - }; - let mut w = data_start; - for plane in 0..planes { - for row in 0..rows_pad { - for col in 0..cols_pad { - let v = if row < rows_real && col < cols_real { - val(plane, row, col) - } else { - 0.0 - }; - buf[w..w + 8].copy_from_slice(&v.to_le_bytes()); - w += 8; - } - } - } - - let two = match read_jdf_bytes(&buf, "hc2d.jdf".into()).unwrap() { - Acquisition::D2(d) => *d, - Acquisition::D1(_) => panic!("expected 2D"), - Acquisition::Electrophysiology(_) => panic!("expected NMR"), - Acquisition::Afm(_) => panic!("expected NMR"), - Acquisition::MassSpec(_) | Acquisition::Xrd(_) => panic!("expected NMR"), - Acquisition::Xps(_) => panic!("expected NMR"), - }; - assert_eq!(two.quad, QuadMode::States); - assert_eq!(two.cols, cols_real); - assert_eq!( - two.rows, - 2 * rows_real, - "cos/sin channels interleaved as rows" - ); - assert_eq!(two.data.len(), 2 * rows_real * cols_real); - for row in 0..rows_real { - for col in 0..cols_real { - // Cosine channel (F1-real): planes RR (0) and RI (1). - let cos = two.data[(2 * row) * cols_real + col]; - assert_eq!(cos, Complex64::new(val(0, row, col), -val(1, row, col))); - // Sine channel (F1-imag): planes IR (2) and II (3). - let sin = two.data[(2 * row + 1) * cols_real + col]; - assert_eq!(sin, Complex64::new(val(2, row, col), -val(3, row, col))); - } - } - assert!(two.indirect_conjugate); -} - -#[test] -fn detects_nus_echo_antiecho_grid_from_rate() { - let mut p = Params::empty(); - p.strings.insert("sampling".into(), "NUS (Auto)".into()); - p.strings.insert("pn_type".into(), "y".into()); - p.strings.insert("nus_mode".into(), "poisson gap".into()); - p.f64s.insert("sampling_rate".into(), 25.0); - let nus = detect_nus(&[], &p, 32).expect("nus detected"); - assert_eq!(nus.acquired, 32); - assert_eq!(nus.grid, 128, "grid = round(M / rate)"); - assert!(nus.echo_antiecho, "pn_type = y is echo/anti-echo"); - assert!(nus.schedule.is_none(), "schedule withheld until entered"); - assert_eq!(nus.mode, "poisson gap"); -} - -#[test] -fn detects_nus_phase_modulated_and_skips_linear() { - // Type-4 NUS (HMBC): NUS but no P/N conversion. - let mut p = Params::empty(); - p.strings.insert("sampling".into(), "NUS (Auto)".into()); - p.f64s.insert("sampling_rate".into(), 25.0); - let nus = detect_nus(&[], &p, 64).expect("nus detected"); - assert_eq!(nus.grid, 256); - assert!(!nus.echo_antiecho); - - // Uniform (Linear) sampling is not NUS. - let mut lin = Params::empty(); - lin.strings.insert("sampling".into(), "Linear".into()); - assert!(detect_nus(&[], &lin, 256).is_none()); - - // No sampling parameter at all is not NUS. - assert!(detect_nus(&[], &Params::empty(), 128).is_none()); -} - -fn serialized_nuslist(name: &[u8], values: &[u32]) -> Vec { - let mut bytes = Vec::new(); - bytes.extend_from_slice(&0x271du32.to_be_bytes()); - bytes.extend_from_slice(&(name.len() as u32).to_be_bytes()); - bytes.extend_from_slice(name); - bytes.extend_from_slice(&[0, 0]); - bytes.extend_from_slice(&0x2b2au32.to_be_bytes()); - bytes.extend_from_slice(&(values.len() as u32).to_be_bytes()); - for value in values { - bytes.extend_from_slice(&0x271au32.to_be_bytes()); - bytes.extend_from_slice(&1u32.to_be_bytes()); - bytes.extend_from_slice(&value.to_be_bytes()); - } - bytes -} - -#[test] -fn extracts_serialized_big_endian_nuslist() { - let bytes = serialized_nuslist(b"Y_NUSLIST", &[1, 2, 5, 9, 16]); - assert_eq!( - extract_nuslist(&bytes, b"Y_NUSLIST"), - Some(vec![1, 2, 5, 9, 16]) - ); -} - -#[test] -fn detect_nus_uses_valid_file_schedule_and_original_grid() { - let bytes = serialized_nuslist(b"Y_NUSLIST", &[1, 2, 5, 9]); - let mut p = Params::empty(); - p.strings.insert("sampling".into(), "NUS (Auto)".into()); - p.f64s.insert("sampling_rate".into(), 50.0); - p.f64s.insert("Y_ORIG_POINTS".into(), 16.0); - p.f64s.insert("nuslist_idx_base".into(), 1.0); - - let nus = detect_nus(&bytes, &p, 4).expect("nus detected"); - assert_eq!(nus.grid, 16); - assert_eq!(nus.schedule, Some(vec![0, 1, 4, 8])); -} - -#[test] -fn detect_nus_rejects_invalid_file_schedule() { - let mut p = Params::empty(); - p.strings.insert("sampling".into(), "NUS (Auto)".into()); - p.f64s.insert("sampling_rate".into(), 25.0); - - let wrong_count = serialized_nuslist(b"Y_NUSLIST", &[1, 2, 3]); - assert!( - detect_nus(&wrong_count, &p, 4) - .expect("nus detected") - .schedule - .is_none() - ); - - let duplicate = serialized_nuslist(b"Y_NUSLIST", &[1, 2, 2, 4]); - assert!( - detect_nus(&duplicate, &p, 4) - .expect("nus detected") - .schedule - .is_none() - ); -} diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index a49191dc..0662c956 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -1,19 +1,20 @@ -//! Data I/O: spectral format parsers producing the neutral [`NmrData`] container. +//! Data I/O. NMR imports retain the checked, evidence-bearing nmr dataset. pub mod abf2; pub mod archive; -pub mod bruker; pub mod delimited; mod format; -pub mod jcamp_dx; -pub mod jeol; mod mass_spec; pub mod mzml; pub mod nanoscope; -mod nmr_origin; +pub mod nmr_bridge; +mod nmr_input; +pub mod nmr_sampling; +pub mod nmr_series; +mod nmr_series_input; +pub mod nmr_view; pub mod origin; pub mod sciex_wiff; -pub mod varian; pub mod waters; pub mod xlsx; pub mod xps; @@ -40,7 +41,6 @@ pub struct Provenance { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LoadWarningCode { ArchiveEntryFailed, - OptionalImaginaryMissing, MissingStimulus, InvalidMetadata, MissingCalibration, @@ -66,7 +66,6 @@ pub struct LoadResult { pub acquisition_identity: AcquisitionIdentity, pub format: DataFormat, pub provenance: Provenance, - nmr_origin: Option, pub warnings: Vec, } @@ -83,20 +82,10 @@ impl LoadResult { acquisition_identity, format, provenance, - nmr_origin: None, warnings, } } - pub fn with_nmr_origin(mut self, origin: NmrOrigin) -> Self { - self.nmr_origin = Some(origin); - self - } - - pub fn take_nmr_origin(&mut self) -> Option { - self.nmr_origin.take() - } - pub fn into_parts( self, ) -> ( @@ -104,7 +93,6 @@ impl LoadResult { AcquisitionIdentity, DataFormat, Provenance, - Option, Vec, ) { ( @@ -112,16 +100,11 @@ impl LoadResult { self.acquisition_identity, self.format, self.provenance, - self.nmr_origin, self.warnings, ) } } -pub use nmr_origin::{ - NmrInstrumentOrigin, NmrOrigin, NmrPortableMetadata, NmrSourceFormat, NmrSourceParameters, -}; - #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct AcquisitionIdentity { /// The specimen, recording, run, or other scientific subject. @@ -156,7 +139,8 @@ pub enum Domain { Frequency, } -/// Neutral, format-independent container for a single 1D acquisition. +/// Explicit calibrated samples for simulations and CRAFT input views. +/// File imports and project payloads retain the native NMR Dataset. #[derive(Debug, Clone)] pub struct NmrData { pub points: Vec, @@ -166,8 +150,8 @@ pub struct NmrData { pub carrier_ppm: f64, pub nucleus: String, pub source: String, - /// Digital-filter group delay in points, removed by the FFT stage as a - /// first-order phase ramp. Nonzero for Bruker; 0.0 when absent. + /// Explicit digital-filter delay in points; zero declares a known zero + /// delay. Inputs with unknown delay must use the native Dataset API. pub group_delay: f64, } @@ -246,6 +230,8 @@ pub enum PseudoKind { /// reconstructed or hand-entered rulers. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AxisSource { + /// Exact portable axis evidence retained by the NMR library. + LibraryEvidence, /// Explicit `{v1, v2, …}` list embedded in the experiment text (exact). EmbeddedList, /// `start..stop : step` ramp descriptor embedded in the experiment text. @@ -319,43 +305,18 @@ pub fn gyromagnetic_ratio(nucleus: &str) -> Option { Some(g) } -/// Gradient-shape δ-coefficient for the effective diffusion time, matching the -/// JEOL `bpp_ste_diffusion` definitions. Defaults to the SQUARE value. -pub fn gradient_shape_factor(shape: &str) -> f64 { - match shape.trim().to_ascii_uppercase().as_str() { - "SINE" => 0.3125, - "SQUARE_SINE" => 0.30167, - "TRAPEZOID" => 0.32545, - "S_RECTANGLE" => 0.32526, - _ => 1.0 / 3.0, - } -} - -/// Non-uniform sampling (NUS) metadata for the indirect axis. Present when the -/// acquisition sampled only a subset of the nominal F1 grid; the missing -/// increments must be reconstructed before the F1 FFT. Readers recover the -/// sampling schedule when the source format stores it; otherwise `schedule` -/// stays `None` until the user supplies the list. +/// Explicit sampling declaration for programmatic two-dimensional inputs. +/// Imported schedules belong to the native Dataset, including order and duplicates. #[derive(Debug, Clone)] pub struct NusMeta { - /// Nominal full grid size N (complex increments) the schedule indexes into. pub grid: usize, - /// Acquired complex increment count M (the stored, sampled rows). pub acquired: usize, - /// Index base of a sampling list (JEOL `nuslist_idx_base`, normally 1). - pub idx_base: usize, - /// Scheduling mode label (`poisson gap`, …), surfaced for the user. - pub mode: String, - /// True for echo/anti-echo (P/N) coherence selection (`pn_type = "y"`): the - /// two stored F1 channels are P and N and need a `pn_to_shr` conversion - /// before the States-style hypercomplex assembly. - pub echo_antiecho: bool, - /// Sampling schedule from the source file or user: one nominal-grid index - /// per acquired increment, stored 0-based (`idx_base` already subtracted). + /// Zero-based logical indices, one per acquired observation. Construction + /// rejects an absent schedule; PlotX never fills missing observations. pub schedule: Option>, } -/// Neutral, format-independent container for a single 2D acquisition. `data` is +/// Explicit programmatic input, not a vendor reader or persisted payload. `data` is /// a row-major matrix of `rows` (indirect / F1) rows, each a complex FID of /// `cols` (direct / F2) points. #[derive(Debug, Clone)] @@ -400,8 +361,7 @@ impl NmrData2D { /// A loaded acquisition: 1D or 2D. Higher layers dispatch on the dimensionality. #[derive(Debug, Clone)] pub enum Acquisition { - D1(NmrData), - D2(Box), + Nmr(nmr_view::NmrSource), Electrophysiology(Box), Afm(Box), MassSpec(Box), @@ -613,15 +573,14 @@ pub struct ElectrophysiologyData { #[derive(Debug, thiserror::Error)] pub enum IoError { + #[error("NMR read failed: {0}")] + Nmr(#[source] Box), #[error("i/o error: {0}")] Io(#[from] std::io::Error), #[error("archive error: {0}")] Archive(String), - #[error("not a JEOL Delta file: bad magic (expected \"JEOL.NMR\")")] - BadMagic, - #[error("file is truncated: needed {needed} bytes at offset {offset}, have {have}")] Truncated { offset: usize, @@ -629,15 +588,12 @@ pub enum IoError { have: usize, }, - #[error("unsupported JEOL feature: {0}")] + #[error("unsupported data: {0}")] Unsupported(String), #[error("invalid ABF2 file: {0}")] InvalidAbf2(String), - #[error(transparent)] - JcampDx(#[from] jcamp_dx::JcampDxError), - #[error("invalid NanoScope file: {0}")] InvalidNanoScope(String), @@ -669,12 +625,6 @@ pub enum IoError { #[error("invalid XPS data: {0}")] InvalidXps(String), - #[error("invalid Varian/Agilent VnmrJ data: {0}")] - InvalidVarian(String), - - #[error("unsupported Varian/Agilent VnmrJ data: {0}")] - UnsupportedVarian(String), - #[error("NMR conversion failed: {0}")] NmrConversion(String), } @@ -695,15 +645,6 @@ pub fn detect_format(path: impl AsRef) -> Result { MassSpectrometryFormat::WatersMassLynxRaw, )); } - if let Some(format) = bruker::detect_processed(path) { - return Ok(format); - } - if bruker::is_bruker(path) { - return Ok(DataFormat::Nmr(NmrFormat::BrukerRaw)); - } - if varian::is_varian(path) { - return Ok(DataFormat::Nmr(NmrFormat::VarianAgilentRaw)); - } let ext = path .extension() .and_then(|e| e.to_str()) @@ -731,8 +672,6 @@ pub fn detect_format(path: impl AsRef) -> Result { "abf" if abf2::is_abf2(path) => { Ok(DataFormat::Electrophysiology(ElectrophysiologyFormat::Abf2)) } - "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, @@ -748,11 +687,13 @@ pub fn detect_format(path: impl AsRef) -> Result { _ if abf2::is_abf2(path) => { Ok(DataFormat::Electrophysiology(ElectrophysiologyFormat::Abf2)) } - _ if jeol::is_jdf(path) => Ok(DataFormat::Nmr(NmrFormat::JeolDelta)), - _ => Err(IoError::Unsupported(format!( - "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() - ))), + _ => match nmr_bridge::read_options().detect(path) { + Ok(format) => nmr_bridge::detected_format(format, path), + Err(error) if error.kind() == nmr::ReadErrorKind::Unrecognized => { + Err(IoError::Unsupported(format!("unrecognised scientific acquisition {}", path.display()))) + } + Err(error) => Err(IoError::Nmr(Box::new(error))), + }, } } @@ -760,13 +701,7 @@ pub fn load_path(path: impl AsRef) -> Result { let path = path.as_ref(); match detect_format(path)? { DataFormat::Electrophysiology(ElectrophysiologyFormat::Abf2) => abf2::load(path), - DataFormat::Nmr(NmrFormat::JeolDelta) => jeol::load_jdf_path(path), - DataFormat::Nmr(NmrFormat::BrukerRaw) => bruker::load_raw(path), - DataFormat::Nmr(NmrFormat::VarianAgilentRaw) => varian::load_raw(path), - DataFormat::Nmr(NmrFormat::BrukerProcessed1D | NmrFormat::BrukerProcessed2D) => { - bruker::load_processed(path) - } - DataFormat::Nmr(NmrFormat::JcampDx1D) => jcamp_dx::load(path), + DataFormat::Nmr(_) => nmr_bridge::load(path), DataFormat::Afm(AfmFormat::BrukerNanoScopeSpm | AfmFormat::BrukerPeakForceCapture) => { nanoscope::load(path) } diff --git a/crates/io/src/nmr_bridge.rs b/crates/io/src/nmr_bridge.rs new file mode 100644 index 00000000..301be3f7 --- /dev/null +++ b/crates/io/src/nmr_bridge.rs @@ -0,0 +1,179 @@ +//! Checked NMR import boundary. The library dataset owns all scientific facts; +//! the values below are presentation summaries, never processing inputs. + +use crate::{ + AcquisitionIdentity, DataFormat, IoError, LoadWarning, LoadWarningCode, NmrFormat, Provenance, +}; +use nmr::dataset::DescriptorRef; +use nmr::provenance::SourceKind; +use nmr::{Dataset, ExecutionContext, Format, ReadOptions, ReadPreference, ReadWarning}; +use std::{path::Path, sync::Arc}; + +#[path = "nmr_bridge_snapshot.rs"] +pub mod snapshot; + +/// PlotX's agreed import policy. Exact file selections are resolved by nmr; +/// ordinary experiment directories prefer raw and same-kind ambiguity is an error. +pub fn read_options() -> ReadOptions { + ReadOptions::new() + .preference(ReadPreference::PreferRaw) + .allow_experimental_vendor_semantics(true) +} + +pub fn read(path: &Path, context: &mut ExecutionContext<'_>) -> Result, IoError> { + read_options() + .read_with_context(path, context) + .map(Arc::new) + .map_err(|error| IoError::Nmr(Box::new(error))) +} + +pub fn load(path: &Path) -> Result { + let dataset = read(path, &mut ExecutionContext::default())?; + loaded(dataset) +} + +pub(super) fn loaded(dataset: Arc) -> Result { + Ok(crate::LoadResult { + acquisition: crate::Acquisition::Nmr(crate::nmr_view::NmrSource::new(dataset.clone())?), + acquisition_identity: identity(&dataset), + format: format(&dataset)?, + provenance: provenance(&dataset)?, + warnings: warnings(&dataset), + }) +} + +pub fn format(dataset: &Dataset) -> Result { + use nmr::{processed::Format as Processed, raw::RawFormat as Raw}; + let format = match dataset.source_format() { + Some(Format::Raw(Raw::BrukerRaw)) => NmrFormat::BrukerRaw, + Some(Format::Raw(Raw::VarianRaw)) => NmrFormat::VarianAgilentRaw, + Some(Format::Raw(Raw::JeolDelta) | Format::Processed(Processed::JeolDelta)) => { + NmrFormat::JeolDelta + } + Some(Format::Processed(Processed::JcampDx)) => NmrFormat::JcampDx1D, + Some(Format::Processed(Processed::BrukerTopSpin)) => match shape(dataset)?.len() { + 1 => NmrFormat::BrukerProcessed1D, + 2 => NmrFormat::BrukerProcessed2D, + _ => { + return Err(IoError::NmrConversion( + "unsupported Bruker spectrum rank".into(), + )); + } + }, + _ => { + return Err(IoError::NmrConversion( + "dataset has no supported import format".into(), + )); + } + }; + Ok(DataFormat::Nmr(format)) +} + +pub fn shape(dataset: &Dataset) -> Result, IoError> { + match dataset.descriptor() { + DescriptorRef::Raw(descriptor) => Ok(descriptor.logical_shape()), + DescriptorRef::Processed(descriptor) => Ok(descriptor.logical_shape()), + _ => Err(IoError::NmrConversion("unsupported NMR descriptor".into())), + } +} + +pub fn identity(dataset: &Dataset) -> AcquisitionIdentity { + let identity = dataset.identity(); + AcquisitionIdentity { + subject: identity.subject().map(str::to_owned), + acquisition: identity.acquisition().map(str::to_owned), + source_label: identity + .source_label() + .map(str::to_owned) + .unwrap_or_else(|| { + AcquisitionIdentity::from_path(dataset.selected_path().unwrap_or(Path::new(""))) + .source_label + }), + } +} + +pub fn provenance(dataset: &Dataset) -> Result { + let selected_path = dataset + .selected_path() + .ok_or_else(|| IoError::NmrConversion("dataset has no original read selection".into()))?; + let mut data = dataset + .sources() + .iter() + .filter(|source| source.kind() == SourceKind::Data); + let primary = data.next().ok_or_else(|| { + IoError::NmrConversion("imported dataset has no data source record".into()) + })?; + Ok(Provenance { + selected_path: selected_path.to_owned(), + data_path: primary.path().to_owned(), + parameter_paths: dataset + .sources() + .iter() + .filter(|source| source.kind() == SourceKind::Parameters) + .map(|source| source.path().to_owned()) + .collect(), + companion_paths: data + .map(|source| source.path().to_owned()) + .chain( + dataset + .sources() + .iter() + .filter(|source| { + !matches!(source.kind(), SourceKind::Data | SourceKind::Parameters) + }) + .map(|source| source.path().to_owned()), + ) + .collect(), + }) +} + +pub fn warnings(dataset: &Dataset) -> Vec { + dataset.warnings().iter().filter_map(|warning| { + let (code, message, path) = match warning { + // The opt-in policy is documented; retain this evidence on the NMR + // dataset without turning every successful import into an alert. + ReadWarning::ExperimentalVendorSemantics { .. } => return None, + ReadWarning::MissingOptionalSource { role, path, impact, .. } => ( + LoadWarningCode::MissingCompanion, + format!("Optional {role} is missing (affects {impact:?})."), + Some(path.clone()), + ), + ReadWarning::MissingMetadata { field, axis, impact, .. } => ( + if *impact == nmr::WarningImpact::AxisCalibration { LoadWarningCode::MissingCalibration } else { LoadWarningCode::InvalidMetadata }, + format!("NMR metadata {field:?} is missing on axis {axis:?} (affects {impact:?}); no value was inferred."), + dataset.selected_path().map(Path::to_owned), + ), + _ => (LoadWarningCode::InvalidMetadata, format!("NMR import: {warning:?}"), dataset.selected_path().map(Path::to_owned)), + }; + Some(LoadWarning { code, message, path }) + }).collect() +} + +/// Recognized NMR selections, including malformed/ambiguous acquisitions that +/// must reach the reader's diagnostic rather than be descended into as folders. +pub fn is_candidate(path: &Path) -> bool { + match read_options().detect(path) { + Ok(_) => true, + Err(error) => { + error.format().is_some() || matches!(error.kind(), nmr::ReadErrorKind::Ambiguous) + } + } +} + +pub(crate) fn detected_format(format_id: Format, path: &Path) -> Result { + use nmr::{processed::Format as Processed, raw::RawFormat as Raw}; + Ok(DataFormat::Nmr(match format_id { + Format::Raw(Raw::BrukerRaw) => NmrFormat::BrukerRaw, + Format::Raw(Raw::VarianRaw) => NmrFormat::VarianAgilentRaw, + Format::Raw(Raw::JeolDelta) | Format::Processed(Processed::JeolDelta) => { + NmrFormat::JeolDelta + } + Format::Processed(Processed::JcampDx) => NmrFormat::JcampDx1D, + // The host's format catalog distinguishes spectrum ranks. Obtain that + // fact from the library descriptor; do not inspect vendor parameters. + Format::Processed(Processed::BrukerTopSpin) => { + return self::format(read(path, &mut ExecutionContext::default())?.as_ref()); + } + _ => return Err(IoError::Unsupported(format!("NMR format {format_id:?}"))), + })) +} diff --git a/crates/io/src/nmr_bridge_snapshot.rs b/crates/io/src/nmr_bridge_snapshot.rs new file mode 100644 index 00000000..63b4c848 --- /dev/null +++ b/crates/io/src/nmr_bridge_snapshot.rs @@ -0,0 +1,36 @@ +//! A single snapshot frame for embedding in a bounded project entry. +//! The project writer owns transaction publication and compression. + +use nmr::{ + Dataset, ExecutionContext, + snapshot::{AcceptRecordedHistory, SnapshotError, SnapshotLimits}, +}; +use std::{ + io::{Read, Write}, + sync::Arc, +}; + +pub fn write( + input: &Dataset, + writer: &mut impl Write, + limits: SnapshotLimits, + context: &mut ExecutionContext<'_>, +) -> Result<(), SnapshotError> { + nmr::snapshot::write_snapshot_with_context(input, writer, limits, context) +} + +/// Accept recorded history only after integrity/model checks. Restore never +/// consults source paths. The caller must supply one bounded archive entry. +pub fn read( + reader: &mut impl Read, + limits: SnapshotLimits, + context: &mut ExecutionContext<'_>, +) -> Result, SnapshotError> { + let checked = nmr::snapshot::read_snapshot_with_context(reader, limits, context)?; + context.check_cancelled()?; + let mut trailing = [0u8; 1]; + if reader.read(&mut trailing)? != 0 { + return Err(SnapshotError::Structure); + } + Ok(Arc::new(checked.restore(AcceptRecordedHistory))) +} diff --git a/crates/io/src/nmr_input.rs b/crates/io/src/nmr_input.rs new file mode 100644 index 00000000..245e78a0 --- /dev/null +++ b/crates/io/src/nmr_input.rs @@ -0,0 +1,108 @@ +//! Explicit programmatic samples for simulations and high-level analysis. +//! File imports use `nmr_bridge::read` and never pass through this constructor. + +use crate::{Domain, IoError, NmrData, nmr_view::NmrSource}; +use nmr::acquisition::{ComponentBasis, GroupDelayState, PendingGroupDelay}; +use nmr::axis::{AxisCoordinates, AxisDomain, AxisRole, AxisUnit, FrequencyEvidence}; +use nmr::processed::{ProcessedAxis, ProcessedDataset, ProcessedOrigin, ProcessedProvenance}; +use nmr::raw::{ + ChemicalShiftReference, DirectSamples, RawAxis, RawAxisKind, RawDatasetBuilder, RawMetadata, +}; +use std::sync::Arc; + +impl TryFrom for NmrSource { + type Error = IoError; + + fn try_from(data: NmrData) -> Result { + let fail = |error: &dyn std::fmt::Display| IoError::NmrConversion(error.to_string()); + let frequency = + FrequencyEvidence::new(Some(data.observe_freq_mhz), None).map_err(|e| fail(&e))?; + let dataset = match data.domain { + Domain::Time => { + let axis = RawAxis::new( + RawAxisKind::Direct(DirectSamples::Complex), + AxisDomain::Time, + Some(AxisUnit::Second), + data.points.len(), + AxisCoordinates::Uniform { + start: 0.0, + step: 1.0 / data.spectral_width_hz, + }, + ) + .map_err(|e| fail(&e))? + .with_spectral_width_hz(Some(data.spectral_width_hz)) + .map_err(|e| fail(&e))? + .with_frequency_evidence(Some(frequency)) + .map_err(|e| fail(&e))? + .with_nucleus(Some(data.nucleus)) + .map_err(|e| fail(&e))? + .with_chemical_shift_reference(Some( + ChemicalShiftReference::user_constructed( + data.carrier_ppm, + data.observe_freq_mhz, + ) + .map_err(|e| fail(&e))?, + )) + .map_err(|e| fail(&e))? + .with_group_delay(GroupDelayState::Pending( + PendingGroupDelay::user_constructed(data.group_delay).map_err(|e| fail(&e))?, + )) + .map_err(|e| fail(&e))?; + RawDatasetBuilder::new(vec![axis], RawMetadata::default()) + .map_err(|e| fail(&e))? + .dense(data.points) + .map_err(|e| fail(&e))? + .into() + } + Domain::Frequency => { + // This input type declares a uniform ppm grid by width, carrier + // and reference frequency. Imported explicit grids bypass it. + let step = data.spectral_width_hz / data.points.len() as f64; + let axis = ProcessedAxis::new( + AxisRole::Signal, + AxisDomain::Frequency, + Some(AxisUnit::Hertz), + data.points.len(), + AxisCoordinates::Uniform { + start: -(data.points.len() as f64) / 2.0 * step, + step, + }, + ComponentBasis::Cartesian, + ) + .map_err(|e| fail(&e))? + .with_frequency_evidence(Some(frequency)) + .map_err(|e| fail(&e))? + .with_spectral_width_hz(Some(data.spectral_width_hz.abs())) + .map_err(|e| fail(&e))? + .with_nucleus(Some(data.nucleus)) + .map_err(|e| fail(&e))?; + let spectrum = ProcessedDataset::from_complex_trace( + axis, + data.points, + ProcessedProvenance::new(ProcessedOrigin::Unknown, Vec::new()) + .map_err(|e| fail(&e))?, + ) + .map_err(|e| fail(&e))?; + use nmr::processing::{ + FrequencyFrame, ProcessingOperation, ProcessingPlan, ReferenceSource, + }; + ProcessingPlan::new(vec![ProcessingOperation::ResolveFrequencyFrame { + axis: 0, + frame: FrequencyFrame::Ppm(ReferenceSource::Explicit( + ChemicalShiftReference::user_constructed( + data.carrier_ppm, + data.observe_freq_mhz, + ) + .map_err(|e| fail(&e))?, + )), + }]) + .map_err(|e| fail(&e))? + .apply(&spectrum.into()) + .map_err(|e| fail(&e))? + } + }; + let mut source = Self::new(Arc::new(dataset))?; + source.set_programmatic_label(data.source); + Ok(source) + } +} diff --git a/crates/io/src/nmr_origin.rs b/crates/io/src/nmr_origin.rs deleted file mode 100644 index f46f6053..00000000 --- a/crates/io/src/nmr_origin.rs +++ /dev/null @@ -1,66 +0,0 @@ -//! Retained provenance for imported NMR acquisitions. - -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum NmrSourceFormat { - BrukerRaw, - JeolDelta, -} - -impl NmrSourceFormat { - pub const fn label(self) -> &'static str { - match self { - Self::BrukerRaw => "Bruker", - Self::JeolDelta => "JEOL", - } - } -} - -/// Lossless acquisition parameters retained without duplicating the signal. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "vendor", rename_all = "snake_case")] -pub enum NmrSourceParameters { - Bruker { - acqus: String, - title: Option, - pulse_program: Option, - }, - Jeol { - /// Fixed header and parameter-list bytes before the signal section. - metadata_base64: String, - }, -} - -#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] -pub struct NmrPortableMetadata { - pub solvent: Option, - pub temperature_k: Option, - pub transients: Option, - pub pulse_sequence: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct NmrInstrumentOrigin { - pub format: NmrSourceFormat, - pub source_sha256: [u8; 32], - pub portable: NmrPortableMetadata, - pub parameters: NmrSourceParameters, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "snake_case")] -pub enum NmrOrigin { - Instrument { instrument: NmrInstrumentOrigin }, - Derived, -} - -impl NmrOrigin { - pub fn instrument(&self) -> Option<&NmrInstrumentOrigin> { - match self { - Self::Instrument { instrument } => Some(instrument), - Self::Derived => None, - } - } -} diff --git a/crates/io/src/nmr_sampling.rs b/crates/io/src/nmr_sampling.rs new file mode 100644 index 00000000..0a2d0116 --- /dev/null +++ b/crates/io/src/nmr_sampling.rs @@ -0,0 +1,82 @@ +//! Explicit import inputs; nmr validates them against the vendor acquisition. + +use crate::{IoError, LoadResult, nmr_bridge}; +use serde::{Deserialize, Serialize}; +use std::{io::Read, path::Path, sync::Arc}; + +/// Invocation data only. The checked declaration is persisted inside snapshot v1. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct SamplingDeclaration { + pub assertion_id: String, + pub source: String, + pub grid_shape: Vec, + pub coordinates: Vec>, + pub index_base: IndexBase, + pub component_counts: Vec, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum IndexBase { + Zero, + One, +} + +impl SamplingDeclaration { + pub fn into_native(self) -> Result { + let id = nmr::raw::AssertionId::try_new(self.assertion_id) + .map_err(|error| IoError::NmrConversion(error.to_string()))?; + Ok(nmr::SamplingDeclaration::new( + id, + self.source, + self.grid_shape, + self.coordinates, + match self.index_base { + IndexBase::Zero => nmr::SamplingIndexBase::Zero, + IndexBase::One => nmr::SamplingIndexBase::One, + }, + self.component_counts, + )) + } +} + +/// Bound external declaration text before decoding its nested coordinate lists. +pub fn read_declaration(path: &Path) -> Result { + const MAX_BYTES: u64 = 8 * 1024 * 1024; + let mut bytes = Vec::new(); + std::fs::File::open(path)? + .take(MAX_BYTES + 1) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_BYTES { + return Err(IoError::NmrConversion( + "sampling declaration exceeds 8 MiB".into(), + )); + } + serde_json::from_slice(&bytes) + .map_err(|error| IoError::NmrConversion(format!("invalid sampling declaration: {error}"))) +} + +pub fn read( + path: &Path, + declaration: SamplingDeclaration, + context: &mut nmr::ExecutionContext<'_>, +) -> Result, IoError> { + nmr_bridge::read_options() + .sampling_declaration(declaration.into_native()?) + .read_with_context(path, context) + .map(Arc::new) + .map_err(|error| IoError::Nmr(Box::new(error))) +} + +pub fn load(path: &Path, declaration: SamplingDeclaration) -> Result { + nmr_bridge::loaded(read( + path, + declaration, + &mut nmr::ExecutionContext::default(), + )?) +} + +pub fn load_with_declaration_file(path: &Path, declaration: &Path) -> Result { + load(path, read_declaration(declaration)?) +} diff --git a/crates/io/src/nmr_series.rs b/crates/io/src/nmr_series.rs new file mode 100644 index 00000000..d2b93681 --- /dev/null +++ b/crates/io/src/nmr_series.rs @@ -0,0 +1,170 @@ +//! Immutable application summaries of a rank-two library Dataset. + +use crate::nmr_view::{NmrAxis, NmrSource}; +use crate::{AxisSource, DiffusionMeta, Domain, IoError, PseudoAxis, PseudoKind}; +use nmr::axis::{AxisCoordinates, AxisDomain, AxisQuantity, AxisUnit}; +use std::ops::Deref; + +#[derive(Clone, Debug)] +pub struct NmrDimension { + pub nucleus: String, + pub observe_freq_mhz: Option, + pub spectral_width_hz: Option, + pub unit: Option, + pub domain: AxisDomain, +} + +impl From<&NmrAxis> for NmrDimension { + fn from(axis: &NmrAxis) -> Self { + Self { + nucleus: axis.nucleus.clone().unwrap_or_default(), + observe_freq_mhz: axis.observe_frequency_mhz(), + spectral_width_hz: axis.spectral_width_hz, + unit: axis.unit, + domain: axis.domain, + } + } +} + +#[derive(Clone, Debug)] +pub struct NusSummary { + pub grid: usize, + pub acquired: usize, + pub schedule: Vec, +} + +/// These fields are read-only through `NmrSeriesSource`. None are serialized as +/// another scientific payload or used to reconstruct library samples. +#[derive(Clone, Debug)] +pub struct SeriesSummary { + pub rows: usize, + pub cols: usize, + pub direct: NmrDimension, + pub indirect: NmrDimension, + pub source: String, + pub experiment: Option, + pub pseudo_axis: Option, + pub diffusion: Option, + pub nus: Option, +} + +#[derive(Clone, Debug)] +pub struct NmrSeriesSource { + source: NmrSource, + summary: SeriesSummary, +} + +impl Deref for NmrSeriesSource { + type Target = SeriesSummary; + fn deref(&self) -> &Self::Target { + &self.summary + } +} + +impl NmrSeriesSource { + pub fn new(source: NmrSource) -> Result { + if source.axes().len() != 2 { + return Err(IoError::NmrConversion( + "select a rank-two NMR dataset".into(), + )); + } + let axes = source.axes(); + let raw = source.dataset().as_raw(); + let quantity = raw + .map(|raw| raw.descriptor().axes()[0].quantity()) + .or_else(|| { + source + .dataset() + .as_processed() + .map(|data| data.descriptor().axes()[0].quantity()) + }) + .flatten(); + let pseudo_axis = if axes[0].domain == AxisDomain::Parameter + && !matches!(axes[0].coordinates, AxisCoordinates::Unknown) + { + Some(PseudoAxis { + name: axes[0].label.clone().unwrap_or_else(|| "Parameter".into()), + kind: match quantity { + Some(AxisQuantity::MagneticFieldGradientStrength) => PseudoKind::Gradient, + Some(AxisQuantity::TimeDelay) => PseudoKind::Delay, + _ => PseudoKind::Generic, + }, + values: axes[0].coordinate_values()?, + unit: match axes[0].unit { + Some(AxisUnit::Second) => "s", + Some(AxisUnit::TeslaPerMeter) => "mT/m", + Some(AxisUnit::Tesla) => "T", + Some(AxisUnit::Hertz) => "Hz", + Some(AxisUnit::Ppm) => "ppm", + _ => "", + } + .into(), + source: AxisSource::LibraryEvidence, + }) + } else { + None + }; + let acquisition = raw.map(|raw| raw.descriptor().acquisition()); + let diffusion = acquisition + .and_then(|metadata| metadata.diffusion()) + .and_then(|metadata| { + let shape_factor = match metadata + .gradient_shape()? + .trim() + .to_ascii_uppercase() + .as_str() + { + "SQUARE" => 1.0 / 3.0, + "SINE" => 0.3125, + "SQUARE_SINE" => 0.30167, + "TRAPEZOID" => 0.32545, + "S_RECTANGLE" => 0.32526, + _ => return None, + }; + Some(DiffusionMeta { + gamma: crate::gyromagnetic_ratio(axes[1].nucleus.as_deref()?)?, + delta: metadata.gradient_pulse_duration_seconds(), + big_delta: metadata.diffusion_time_seconds(), + tau: metadata.recovery_delay_seconds()?, + shape_factor, + }) + }); + let nus = raw + .and_then(|raw| raw.sampling_schedule()) + .map(|schedule| NusSummary { + grid: axes[0].points, + acquired: schedule.coordinates().len(), + schedule: schedule + .coordinates() + .iter() + .map(|coordinate| coordinate.as_slice()[0]) + .collect(), + }); + let summary = SeriesSummary { + rows: axes[0].points, + cols: axes[1].points, + direct: (&axes[1]).into(), + indirect: (&axes[0]).into(), + source: source.source().to_owned(), + experiment: acquisition + .and_then(|metadata| metadata.pulse_program().map(str::to_owned)), + pseudo_axis, + diffusion, + nus, + }; + Ok(Self { source, summary }) + } + + pub fn source_dataset(&self) -> &NmrSource { + &self.source + } + pub fn input_domain(&self, axis: usize) -> Result { + match self.source.axes().get(axis).map(|axis| axis.domain) { + Some(AxisDomain::Time) => Ok(Domain::Time), + Some(AxisDomain::Frequency) => Ok(Domain::Frequency), + _ => Err(IoError::NmrConversion( + "parameter axes do not accept spectral processing".into(), + )), + } + } +} diff --git a/crates/io/src/nmr_series_input.rs b/crates/io/src/nmr_series_input.rs new file mode 100644 index 00000000..68039d72 --- /dev/null +++ b/crates/io/src/nmr_series_input.rs @@ -0,0 +1,278 @@ +//! Programmatic tensor construction; vendor readers supply their own library Dataset. + +use crate::nmr_series::NmrSeriesSource; +use crate::nmr_view::NmrSource; +use crate::{Domain, IoError, NmrData2D, PseudoKind, QuadMode}; +use nmr::axis::{AxisCoordinates, AxisDomain, AxisQuantity, AxisRole, AxisUnit, FrequencyEvidence}; +use nmr::processed::{ + ComponentBasis, ProcessedAxis, ProcessedDataset, ProcessedDescriptor, ProcessedOrigin, + ProcessedProvenance, +}; +use nmr::raw::*; +use std::sync::Arc; + +fn fail(error: impl std::fmt::Display) -> IoError { + IoError::NmrConversion(error.to_string()) +} + +impl TryFrom for NmrSeriesSource { + type Error = IoError; + fn try_from(input: NmrData2D) -> Result { + if input.indirect_conjugate + || matches!(input.quad, QuadMode::StatesTppi | QuadMode::EchoAntiecho) + { + return Err(fail( + "Construct an explicit nmr component encoding for this programmatic input", + )); + } + let parameter = input.pseudo_axis.as_ref(); + let lanes = if input.domain == Domain::Time + && input.quad == QuadMode::States + && parameter.is_none() + { + 2 + } else { + 1 + }; + if !input.rows.is_multiple_of(lanes) + || input.data.len() + != input + .rows + .checked_mul(input.cols) + .ok_or_else(|| fail("tensor size overflow"))? + { + return Err(fail("programmatic tensor shape does not match samples")); + } + let rows = input + .nus + .as_ref() + .map_or(input.rows / lanes, |nus| nus.grid); + let axis_fields = |index: usize| { + let dim = if index == 0 { + &input.indirect + } else { + &input.direct + }; + let points = if index == 0 { rows } else { input.cols }; + if index == 0 + && let Some(parameter) = parameter + { + let (unit, quantity) = match parameter.kind { + PseudoKind::Gradient => ( + Some(AxisUnit::TeslaPerMeter), + Some(AxisQuantity::MagneticFieldGradientStrength), + ), + PseudoKind::Delay => (Some(AxisUnit::Second), Some(AxisQuantity::TimeDelay)), + PseudoKind::Generic => (None, None), + }; + return ( + dim, + points, + AxisDomain::Parameter, + unit, + AxisCoordinates::Explicit(parameter.values.clone()), + quantity, + ); + } + match input.domain { + Domain::Time => ( + dim, + points, + AxisDomain::Time, + Some(AxisUnit::Second), + AxisCoordinates::Uniform { + start: 0.0, + step: 1.0 / dim.spectral_width_hz, + }, + None, + ), + Domain::Frequency => { + let step = dim.spectral_width_hz / points as f64 / dim.observe_freq_mhz; + ( + dim, + points, + AxisDomain::Frequency, + Some(AxisUnit::Ppm), + AxisCoordinates::Uniform { + start: dim.carrier_ppm - points as f64 / 2.0 * step, + step, + }, + None, + ) + } + } + }; + let dataset: nmr::Dataset = if input.domain == Domain::Time { + let mut axes = Vec::new(); + for index in 0..2 { + let (dim, points, domain, unit, coordinates, quantity) = axis_fields(index); + let kind = if index == 1 { + RawAxisKind::Direct(DirectSamples::Complex) + } else if parameter.is_some() { + RawAxisKind::Parameter + } else if lanes == 2 { + RawAxisKind::Indirect(IndirectComponents::Cartesian( + ComponentEvidence::user_constructed(), + )) + } else { + RawAxisKind::Indirect(IndirectComponents::Scalar) + }; + let mut axis = + RawAxis::new(kind, domain, unit, points, coordinates).map_err(fail)?; + if domain == AxisDomain::Parameter { + axis = axis + .with_quantity(quantity) + .map_err(fail)? + .with_label(parameter.map(|parameter| parameter.name.clone())); + } else { + axis = axis + .with_nucleus((!dim.nucleus.is_empty()).then(|| dim.nucleus.clone())) + .map_err(fail)? + .with_spectral_width_hz(Some(dim.spectral_width_hz)) + .map_err(fail)? + .with_frequency_evidence(Some( + FrequencyEvidence::new(Some(dim.observe_freq_mhz), None) + .map_err(fail)?, + )) + .map_err(fail)? + .with_chemical_shift_reference(Some( + ChemicalShiftReference::user_constructed( + dim.carrier_ppm, + dim.observe_freq_mhz, + ) + .map_err(fail)?, + )) + .map_err(fail)? + .with_group_delay(if index == 1 { + GroupDelayState::Pending( + PendingGroupDelay::user_constructed(dim.group_delay) + .map_err(fail)?, + ) + } else { + GroupDelayState::NotApplicable + }) + .map_err(fail)?; + } + axes.push(axis); + } + let mut metadata = + RawMetadata::new(None, None, None, None, input.experiment.clone()).map_err(fail)?; + if let Some(meta) = input.diffusion { + let shape = if (meta.shape_factor - 1.0 / 3.0).abs() < 1e-12 { + "SQUARE" + } else { + return Err(fail( + "Declare diffusion analysis settings separately for a custom gradient shape", + )); + }; + metadata = metadata + .with_diffusion(Some( + DiffusionAcquisition::new( + 0, + "programmatic_gradient".into(), + meta.delta, + "programmatic_delta".into(), + meta.big_delta, + "programmatic_big_delta".into(), + Some((meta.tau, "programmatic_tau".into())), + Some((shape.into(), "programmatic_shape".into())), + ) + .map_err(fail)?, + )) + .map_err(fail)?; + } + let builder = RawDatasetBuilder::new(axes, metadata).map_err(fail)?; + if let Some(nus) = &input.nus { + let indices = nus + .schedule + .as_ref() + .ok_or_else(|| fail("Supply a complete NUS sampling schedule"))?; + if indices.len() != input.rows / lanes || indices.len() != nus.acquired { + return Err(fail("NUS observations do not match the schedule")); + } + let coordinates: Vec<_> = indices + .iter() + .map(|index| SamplingCoordinate::new(vec![*index])) + .collect(); + let traces = input + .data + .chunks_exact(lanes * input.cols) + .enumerate() + .map(|(index, samples)| { + SparseTrace::new( + ObservationOrdinal::new(index), + coordinates[index].clone(), + samples.to_vec(), + ) + }) + .collect(); + builder + .sparse( + traces, + SamplingSchedule::new(vec![rows], coordinates).map_err(fail)?, + ) + .map_err(fail)? + .into() + } else { + builder.dense(input.data).map_err(fail)?.into() + } + } else { + let mut axes = Vec::new(); + for index in 0..2 { + let (dim, points, domain, unit, coordinates, quantity) = axis_fields(index); + let axis = ProcessedAxis::new( + if domain == AxisDomain::Parameter { + AxisRole::ArrayParameter + } else { + AxisRole::Signal + }, + domain, + unit, + points, + coordinates, + if index == 1 { + ComponentBasis::Cartesian + } else { + ComponentBasis::Scalar + }, + ) + .map_err(fail)?; + axes.push(if domain == AxisDomain::Parameter { + axis.with_quantity(quantity).map_err(fail)? + } else { + axis.with_nucleus((!dim.nucleus.is_empty()).then(|| dim.nucleus.clone())) + .map_err(fail)? + .with_frequency_evidence(Some( + FrequencyEvidence::new(Some(dim.observe_freq_mhz), None) + .map_err(fail)?, + )) + .map_err(fail)? + .with_spectral_width_hz(Some(dim.spectral_width_hz.abs())) + .map_err(fail)? + }); + } + let samples = input + .data + .iter() + .flat_map(|value| [value.re, value.im]) + .collect(); + ProcessedDataset::from_dense_samples( + ProcessedDescriptor::new(axes).map_err(fail)?, + samples, + ProcessedProvenance::new(ProcessedOrigin::Unknown, vec![]).map_err(fail)?, + ) + .map_err(fail)? + .into() + }; + let mut source = NmrSource::new(Arc::new(dataset))?; + source.set_programmatic_label(input.source); + Self::new(source) + } +} + +impl TryFrom for NmrSeriesSource { + type Error = IoError; + fn try_from(source: NmrSource) -> Result { + Self::new(source) + } +} diff --git a/crates/io/src/nmr_view.rs b/crates/io/src/nmr_view.rs new file mode 100644 index 00000000..35f5d6a2 --- /dev/null +++ b/crates/io/src/nmr_view.rs @@ -0,0 +1,294 @@ +//! Immutable descriptor views. The library Dataset remains the scientific input. + +use crate::{IoError, NmrData}; +use nmr::acquisition::{ComponentBasis, GroupDelayState}; +use nmr::axis::{AxisCoordinates, AxisDomain, AxisRole, AxisUnit, FrequencyEvidence}; +use nmr::dataset::DescriptorRef; +use nmr::{Complex64, Dataset}; +use std::sync::Arc; + +#[derive(Clone, Debug)] +pub struct NmrAxis { + pub role: AxisRole, + pub domain: AxisDomain, + pub unit: Option, + pub points: usize, + pub coordinates: AxisCoordinates, + pub nucleus: Option, + pub label: Option, + pub frequency: Option, + pub spectral_width_hz: Option, +} + +impl NmrAxis { + pub fn observe_frequency_mhz(&self) -> Option { + self.frequency + .and_then(|value| value.observe_frequency_mhz()) + } + + pub fn coordinate_values(&self) -> Result, IoError> { + match &self.coordinates { + AxisCoordinates::Explicit(values) => Ok(values.clone()), + AxisCoordinates::Uniform { start, step } => Ok((0..self.points) + .map(|index| step.mul_add(index as f64, *start)) + .collect()), + _ => Err(IoError::NmrConversion( + "axis coordinates are unknown".into(), + )), + } + } +} + +/// The descriptor summaries cannot be mutated independently of the owned input. +#[derive(Clone, Debug)] +pub struct NmrSource { + dataset: Arc, + axes: Vec, + source_label: String, +} + +impl NmrSource { + pub fn new(dataset: Arc) -> Result { + let axes = match dataset.descriptor() { + DescriptorRef::Raw(descriptor) => descriptor + .axes() + .iter() + .map(|axis| NmrAxis { + role: axis.role(), + domain: axis.domain(), + unit: axis.unit(), + points: axis.points(), + coordinates: axis.coordinates().clone(), + nucleus: axis.nucleus().map(str::to_owned), + label: axis.label().map(str::to_owned), + frequency: axis.frequency_evidence(), + spectral_width_hz: axis.spectral_width_hz(), + }) + .collect(), + DescriptorRef::Processed(descriptor) => descriptor + .axes() + .iter() + .map(|axis| NmrAxis { + role: axis.role(), + domain: axis.domain(), + unit: axis.unit(), + points: axis.points(), + coordinates: axis.coordinates().clone(), + nucleus: axis.nucleus().map(str::to_owned), + label: axis.label().map(str::to_owned), + frequency: axis.frequency_evidence(), + spectral_width_hz: axis.spectral_width_hz(), + }) + .collect(), + _ => return Err(IoError::NmrConversion("unsupported NMR descriptor".into())), + }; + let source_label = crate::nmr_bridge::identity(&dataset).source_label; + Ok(Self { + dataset, + axes, + source_label, + }) + } + + pub fn dataset(&self) -> &Arc { + &self.dataset + } + + pub fn axes(&self) -> &[NmrAxis] { + &self.axes + } + + pub fn source(&self) -> &str { + &self.source_label + } + + pub fn identity(&self) -> crate::AcquisitionIdentity { + let mut identity = crate::nmr_bridge::identity(&self.dataset); + identity.source_label = self.source_label.clone(); + identity + } + + /// A host display label does not alter acquisition facts or canonical digests. + pub fn with_display_label(mut self, label: String) -> Self { + self.source_label = label; + self + } + + /// MHz used for chemical-shift differences, distinct from observe frequency. + /// Axes without reference evidence return `None`. + pub fn reference_frequency_mhz(&self, axis: usize) -> Option { + if let Some(raw) = self.dataset.as_raw() { + return raw + .descriptor() + .axes() + .get(axis)? + .chemical_shift_reference() + .map(|reference| reference.reference_frequency_mhz()); + } + self.dataset + .as_processed()? + .axis_evidence(axis)? + .reference_frequency_mhz() + } + + pub fn len(&self) -> usize { + self.axes.first().map_or(0, |axis| axis.points) + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + pub fn nucleus(&self) -> &str { + self.axes + .first() + .and_then(|axis| axis.nucleus.as_deref()) + .unwrap_or("") + } + + pub(crate) fn set_programmatic_label(&mut self, label: String) { + self.source_label = label; + } + + pub fn direct_axis(&self) -> Result<&NmrAxis, IoError> { + self.axes + .last() + .ok_or_else(|| IoError::NmrConversion("NMR input has no axis".into())) + } + + pub fn domain(&self) -> Result { + match self.direct_axis()?.domain { + AxisDomain::Time => Ok(crate::Domain::Time), + AxisDomain::Frequency => Ok(crate::Domain::Frequency), + _ => Err(IoError::NmrConversion( + "NMR signal axis has no time or frequency domain".into(), + )), + } + } + + pub fn has_imaginary(&self, axis: usize) -> bool { + if let Some(raw) = self.dataset.as_raw() { + return raw.descriptor().axes().get(axis).is_some_and(|axis| { + matches!( + axis.kind(), + nmr::raw::RawAxisKind::Direct(nmr::raw::DirectSamples::Complex) + ) || matches!( + axis.kind(), + nmr::raw::RawAxisKind::Indirect( + nmr::raw::IndirectComponents::Cartesian(_) + | nmr::raw::IndirectComponents::SharedComplex { .. } + ) + ) + }); + } + self.dataset.as_processed().is_some_and(|processed| { + processed.descriptor().axes().get(axis).is_some_and(|axis| { + matches!( + axis.component_basis(), + ComponentBasis::Cartesian | ComponentBasis::SharedComplex { .. } + ) + }) + }) + } + + /// Full complex samples of a rank-one input. A scalar display has zero + /// imaginary values, while `has_imaginary` continues to report its true basis. + pub fn trace(&self) -> Result, IoError> { + if self.axes.len() != 1 { + return Err(IoError::NmrConversion( + "select a one-dimensional NMR trace".into(), + )); + } + if let Some(raw) = self.dataset.as_raw() { + return raw + .read_trace(&[]) + .map(|trace| trace.samples().to_vec()) + .map_err(|error| IoError::Nmr(Box::new(error))); + } + let processed = self + .dataset + .as_processed() + .ok_or_else(|| IoError::NmrConversion("unsupported NMR trace representation".into()))?; + let axis = &processed.descriptor().axes()[0]; + if !matches!( + axis.component_basis(), + ComponentBasis::Scalar | ComponentBasis::Cartesian + ) { + return Err(IoError::NmrConversion( + "decode NMR components before displaying a spectrum".into(), + )); + } + (0..axis.points()) + .map(|point| { + let sample = |component| { + processed + .data() + .get(&[point], &[component]) + .map_err(|error| IoError::NmrConversion(error.to_string())) + }; + Ok(Complex64::new( + sample(0)?, + if axis.component_count() == 2 { + sample(1)? + } else { + 0.0 + }, + )) + }) + .collect() + } + + /// CRAFT requires a calibrated complex FID. Missing facts prevent analysis; + /// they do not prevent importing, saving or displaying the library Dataset. + pub fn craft_fid(&self) -> Result { + let missing = |message: &str| IoError::NmrConversion(message.to_owned()); + let raw = self + .dataset + .as_raw() + .ok_or_else(|| missing("CRAFT requires a raw FID"))?; + if self.axes.len() != 1 || !self.has_imaginary(0) { + return Err(missing( + "CRAFT requires one complex direct acquisition axis", + )); + } + let axis = &raw.descriptor().axes()[0]; + let reference = axis + .chemical_shift_reference() + .ok_or_else(|| missing("CRAFT requires chemical-shift reference evidence"))?; + let spectral_width_hz = axis + .spectral_width_hz() + .ok_or_else(|| missing("CRAFT requires spectral width"))?; + let observe_freq_mhz = axis + .frequency_evidence() + .and_then(|value| value.observe_frequency_mhz()) + .ok_or_else(|| missing("CRAFT requires observe frequency"))?; + let group_delay = match axis.group_delay() { + GroupDelayState::Pending(delay) => delay.delay_points(), + GroupDelayState::NotApplicable => 0.0, + _ => { + return Err(missing( + "CRAFT requires known digital-filter delay evidence", + )); + } + }; + if axis.domain() != AxisDomain::Time + || axis.unit() != Some(AxisUnit::Second) + || !matches!(axis.coordinates(), AxisCoordinates::Uniform { start, step } + if *start == 0.0 && (*step * spectral_width_hz - 1.0).abs() < 1e-10) + { + return Err(missing( + "CRAFT requires a uniform FID starting at the acquisition time origin", + )); + } + Ok(NmrData { + points: self.trace()?, + domain: crate::Domain::Time, + spectral_width_hz, + observe_freq_mhz, + carrier_ppm: reference.carrier_ppm(), + nucleus: axis.nucleus().unwrap_or("").to_owned(), + source: crate::nmr_bridge::identity(&self.dataset).source_label, + group_delay, + }) + } +} diff --git a/crates/io/src/varian.rs b/crates/io/src/varian.rs deleted file mode 100644 index 08dac627..00000000 --- a/crates/io/src/varian.rs +++ /dev/null @@ -1,302 +0,0 @@ -//! Varian and Agilent VNMR/VnmrJ raw acquisition reader. - -mod fid; -mod procpar; - -use crate::{ - Acquisition, DataFormat, Dim, Domain, IoError, LoadResult, NmrData, NmrData2D, NmrFormat, - Provenance, QuadMode, -}; -use procpar::Procpar; -use std::path::{Path, PathBuf}; - -pub fn is_varian(path: &Path) -> bool { - resolve(path).is_some_and(|(_, fid, procpar)| fid.is_file() && procpar.is_file()) -} - -fn resolve(path: &Path) -> Option<(PathBuf, PathBuf, PathBuf)> { - let dir = if path.is_dir() { - path.to_path_buf() - } else if path.file_name()?.to_str()? == "fid" { - path.parent()?.to_path_buf() - } else { - return None; - }; - Some((dir.clone(), dir.join("fid"), dir.join("procpar"))) -} - -pub fn load_raw(path: &Path) -> Result { - let (dir, data_path, procpar_path) = resolve(path) - .ok_or_else(|| IoError::InvalidVarian("select a .fid directory or its fid file".into()))?; - if !data_path.is_file() || !procpar_path.is_file() { - return Err(IoError::InvalidVarian( - "a VnmrJ dataset requires sibling fid and procpar files".into(), - )); - } - let params = Procpar::parse(&std::fs::read_to_string(&procpar_path)?)?; - let mut raw = fid::parse(&std::fs::read(&data_path)?)?; - // VNMR stores direct-dimension quadrature with the opposite sense to the - // forward-FFT convention used by PlotX. Normalize it at the importer seam - // so every downstream transform and time-domain model shares one axis - // convention. - for trace in &mut raw.traces { - for point in trace { - point.im = -point.im; - } - } - reject_unsupported(¶ms)?; - let acquisition = assemble(&dir, ¶ms, raw)?; - Ok(LoadResult::new( - acquisition, - crate::AcquisitionIdentity { - subject: sample_name(&dir, ¶ms), - acquisition: experiment_name(¶ms), - source_label: dir - .file_stem() - .and_then(|name| name.to_str()) - .unwrap_or("Untitled NMR") - .to_owned(), - }, - DataFormat::Nmr(NmrFormat::VarianAgilentRaw), - Provenance { - selected_path: path.to_path_buf(), - data_path, - parameter_paths: vec![procpar_path], - companion_paths: Vec::new(), - }, - Vec::new(), - )) -} - -fn reject_unsupported(p: &Procpar) -> Result<(), IoError> { - if p.number("ni2").unwrap_or(0.0) > 1.0 || p.number("ni3").unwrap_or(0.0) > 1.0 { - return Err(IoError::UnsupportedVarian( - "3D and 4D acquisitions are not supported".into(), - )); - } - if p.string("apptype") - .is_some_and(|s| s.to_ascii_lowercase().contains("imaging")) - { - return Err(IoError::UnsupportedVarian( - "MRI and imaging acquisitions are not supported".into(), - )); - } - if ["sampling", "nus", "nuslist"].iter().any(|name| { - p.string(name) - .is_some_and(|s| !s.is_empty() && !s.eq_ignore_ascii_case("n")) - }) { - return Err(IoError::UnsupportedVarian( - "non-uniform sampling is not supported".into(), - )); - } - Ok(()) -} - -fn assemble(dir: &Path, p: &Procpar, raw: fid::FidData) -> Result { - let procpar_np = exact_positive_usize(p.number("np")) - .ok_or_else(|| IoError::InvalidVarian("procpar is missing positive integer np".into()))?; - if procpar_np != raw.np { - return Err(IoError::InvalidVarian(format!( - "dimension mismatch: procpar np is {procpar_np}, but the fid header declares {}", - raw.np - ))); - } - let direct = direct_dim(p)?; - let total = raw.traces.len(); - let ni = exact_positive_usize(p.number("ni")); - let (phase_count, quad) = phase_layout(p)?; - let array = p.string("array").unwrap_or("").trim(); - if ni.unwrap_or(1) == 1 && total == 1 && array.is_empty() { - let source = description(dir, p, &direct, None); - return Ok(Acquisition::D1(NmrData { - points: raw.traces.into_iter().next().unwrap(), - domain: Domain::Time, - spectral_width_hz: direct.spectral_width_hz, - observe_freq_mhz: direct.observe_freq_mhz, - carrier_ppm: direct.carrier_ppm, - nucleus: direct.nucleus, - source, - group_delay: 0.0, - })); - } - let ni = ni.ok_or_else(|| { - IoError::UnsupportedVarian("multiple traces require a positive integer ni".into()) - })?; - if !array.is_empty() && array != "phase" { - return Err(IoError::UnsupportedVarian(format!( - "parameter arrays other than phase are not supported (array={array})" - ))); - } - let expected = ni - .checked_mul(phase_count) - .ok_or_else(|| IoError::InvalidVarian("2D trace count overflow".into()))?; - if total != expected { - return Err(IoError::UnsupportedVarian(format!( - "trace layout mismatch: fid contains {total} traces, but ni × phase_count is {expected}" - ))); - } - let seq = p - .string("seqfil") - .map(|s| s.to_ascii_lowercase()) - .filter(|s| !s.is_empty()); - let indirect = indirect_dim(p, seq.as_deref(), &direct)?; - let cols = raw.np / 2; - let source = description(dir, p, &direct, Some(&indirect)); - Ok(Acquisition::D2(Box::new(NmrData2D { - data: raw.traces.into_iter().flatten().collect(), - rows: total, - cols, - domain: Domain::Time, - direct, - indirect, - quad, - indirect_conjugate: false, - experiment: seq, - pseudo_axis: None, - diffusion: None, - nus: None, - source, - }))) -} - -fn phase_layout(p: &Procpar) -> Result<(usize, QuadMode), IoError> { - match p.numbers("phase").as_deref() { - None | Some([1.0]) => Ok((1, QuadMode::Complex)), - Some([1.0, 2.0]) => Ok((2, QuadMode::States)), - Some(values) => Err(IoError::UnsupportedVarian(format!( - "unsupported phase table {values:?}; only phase=1 and States phase=1,2 are supported" - ))), - } -} - -fn direct_dim(p: &Procpar) -> Result { - let mut direct = dim(p, "sw", "sfrq", "tof", "tn")?; - // VNMRJ uses rfl/rfp to recalibrate the displayed direct-axis reference. - // With a full spectral width, rfl is measured from the high-frequency - // edge and rfp is the chemical shift assigned at that location. - if let Some(rfl) = p.number("rfl").filter(|value| value.is_finite()) { - let rfp = p - .number("rfp") - .filter(|value| value.is_finite()) - .unwrap_or(0.0); - direct.carrier_ppm = rfp + (direct.spectral_width_hz * 0.5 - rfl) / direct.observe_freq_mhz; - } - Ok(direct) -} -fn indirect_dim(p: &Procpar, seq: Option<&str>, direct: &Dim) -> Result { - let homo = seq.is_some_and(|s| { - ["cosy", "tocsy", "noesy", "roesy"] - .iter() - .any(|name| s.contains(name)) - }); - let hetero = seq.is_some_and(|s| ["hsqc", "hmqc", "hmbc"].iter().any(|name| s.contains(name))); - if homo { - return dim_with_sw1(p, direct); - } - if hetero { - return dim(p, "sw1", "dfrq", "dof", "dn"); - } - let tn = normalize_nucleus(p.string("tn").unwrap_or("X")); - let dn = normalize_nucleus(p.string("dn").unwrap_or("X")); - if dn != "X" && dn != tn { - dim(p, "sw1", "dfrq", "dof", "dn") - } else if dn == "X" || dn == tn { - dim_with_sw1(p, direct) - } else { - Err(IoError::UnsupportedVarian( - "unknown sequence has ambiguous indirect channel".into(), - )) - } -} -fn dim_with_sw1(p: &Procpar, direct: &Dim) -> Result { - Ok(Dim { - spectral_width_hz: required_positive(p, "sw1")?, - observe_freq_mhz: direct.observe_freq_mhz, - carrier_ppm: direct.carrier_ppm, - nucleus: direct.nucleus.clone(), - group_delay: 0.0, - }) -} -fn dim(p: &Procpar, sw: &str, freq: &str, offset: &str, nucleus: &str) -> Result { - let spectral_width_hz = required_positive(p, sw)?; - let observe_freq_mhz = required_positive(p, freq)?; - let carrier_ppm = p - .number(offset) - .filter(|v| v.is_finite()) - .ok_or_else(|| IoError::InvalidVarian(format!("procpar is missing finite {offset}")))? - / observe_freq_mhz; - Ok(Dim { - spectral_width_hz, - observe_freq_mhz, - carrier_ppm, - nucleus: normalize_nucleus(p.string(nucleus).unwrap_or("X")), - group_delay: 0.0, - }) -} -fn required_positive(p: &Procpar, name: &str) -> Result { - p.number(name) - .filter(|v| v.is_finite() && *v > 0.0) - .ok_or_else(|| IoError::InvalidVarian(format!("procpar is missing positive finite {name}"))) -} -fn exact_positive_usize(v: Option) -> Option { - let v = v?; - if v.is_finite() && v > 0.0 && v.fract() == 0.0 && v <= usize::MAX as f64 { - Some(v as usize) - } else { - None - } -} -fn normalize_nucleus(value: &str) -> String { - let s = value.trim().trim_matches('"').replace(' ', ""); - let upper = s.to_ascii_uppercase(); - match upper.as_str() { - "H1" | "1H" | "PROTON" => "1H".into(), - "C13" | "13C" => "13C".into(), - "N15" | "15N" => "15N".into(), - "F19" | "19F" => "19F".into(), - "P31" | "31P" => "31P".into(), - "" | "OFF" | "NONE" => "X".into(), - _ => s, - } -} -fn description(dir: &Path, p: &Procpar, direct: &Dim, indirect: Option<&Dim>) -> String { - let nuclei = match indirect { - Some(indirect) => format!("{}/{}", direct.nucleus, indirect.nucleus), - None => direct.nucleus.clone(), - }; - sample_name(dir, p) - .into_iter() - .chain(std::iter::once(nuclei)) - .chain(experiment_name(p)) - .collect::>() - .join(" — ") -} - -fn sample_name(dir: &Path, p: &Procpar) -> Option { - ["samplename", "sample", "name", "filename"] - .into_iter() - .find_map(|name| p.string(name).and_then(nonempty)) - .map(str::to_owned) - .or_else(|| { - dir.file_stem() - .and_then(|name| name.to_str()) - .and_then(nonempty) - .map(str::to_owned) - }) -} - -fn experiment_name(p: &Procpar) -> Option { - ["pslabel", "seqfil"] - .into_iter() - .find_map(|name| p.string(name).and_then(nonempty)) - .map(str::to_owned) -} - -fn nonempty(value: &str) -> Option<&str> { - let value = value.trim(); - (!value.is_empty()).then_some(value) -} - -#[cfg(test)] -#[path = "varian/tests.rs"] -mod tests; diff --git a/crates/io/src/varian/fid.rs b/crates/io/src/varian/fid.rs deleted file mode 100644 index b78bbdec..00000000 --- a/crates/io/src/varian/fid.rs +++ /dev/null @@ -1,200 +0,0 @@ -use crate::IoError; -use num_complex::Complex64; - -const FILE_HEADER: usize = 32; -const BLOCK_HEADER: usize = 28; -const S_DATA: i16 = 0x1; -const S_SPEC: i16 = 0x2; -const S_32: i16 = 0x4; -const S_FLOAT: i16 = 0x8; -const S_COMPLEX: i16 = 0x10; -const S_HYPERCOMPLEX: i16 = 0x20; -const S_DDR: i16 = 0x80; -const S_SECND: i16 = 0x100; -const S_TRANSF: i16 = 0x200; -const S_3D: i16 = 0x400; -const SAMPLE_STATUS: i16 = S_32 | S_FLOAT; -const NB_HEADER_MASK: i32 = 0x0000f; -const NB_NI3: i32 = 0x10000; -const VERSION_FILE_ID_MASK: i16 = 0x07c0; -const VERSION_FID_FILE: i16 = 0x0040; - -#[derive(Debug)] -pub(super) struct FidData { - pub(super) traces: Vec>, - pub(super) np: usize, -} - -pub(super) fn parse(bytes: &[u8]) -> Result { - if bytes.len() < FILE_HEADER { - return truncated(0, FILE_HEADER, bytes.len()); - } - let nblocks = positive_i32(bytes, 0, "nblocks")?; - let ntraces = positive_i32(bytes, 4, "ntraces")?; - let np = positive_i32(bytes, 8, "np")?; - let ebytes = positive_i32(bytes, 12, "ebytes")?; - let tbytes = positive_i32(bytes, 16, "tbytes")?; - let bbytes = positive_i32(bytes, 20, "bbytes")?; - let version_id = i16::from_be_bytes(bytes[24..26].try_into().unwrap()); - let status = i16::from_be_bytes(bytes[26..28].try_into().unwrap()); - let raw_nbheaders = i32::from_be_bytes(bytes[28..32].try_into().unwrap()); - if raw_nbheaders & NB_NI3 != 0 { - return Err(unsupported( - "3D and 4D block-header layouts are not supported", - )); - } - if raw_nbheaders & !(NB_NI3 | NB_HEADER_MASK) != 0 { - return Err(invalid("nbheaders contains unknown layout flags")); - } - let nbheaders = usize::try_from(raw_nbheaders & NB_HEADER_MASK) - .ok() - .filter(|count| *count > 0) - .ok_or_else(|| invalid("nbheaders must declare at least one block header"))?; - if np % 2 != 0 { - return Err(invalid("np must be a positive even number")); - } - if status & S_DATA == 0 || !is_complex_fid(status) { - return Err(unsupported("fid is not complex time-domain data")); - } - if status & (S_SPEC | S_HYPERCOMPLEX) != 0 { - return Err(unsupported( - "processed spectra and hypercomplex payloads are not supported", - )); - } - if status & (S_SECND | S_TRANSF | S_3D) != 0 { - return Err(unsupported( - "transformed, transposed, and 3D payloads are not supported", - )); - } - let file_id = version_id & VERSION_FILE_ID_MASK; - if file_id != 0 && file_id != VERSION_FID_FILE { - return Err(unsupported( - "the software-version header identifies a processed data file", - )); - } - let sample = match (status & S_FLOAT != 0, status & S_32 != 0, ebytes) { - (false, false, 2) => Sample::I16, - (false, true, 4) => Sample::I32, - (true, _, 4) => Sample::F32, - _ => { - return Err(unsupported( - "status flags and ebytes do not describe int16, int32, or float32 samples", - )); - } - }; - let expected_tbytes = np - .checked_mul(ebytes) - .ok_or_else(|| invalid("trace size overflow"))?; - if tbytes != expected_tbytes { - return Err(invalid("tbytes does not equal np * ebytes")); - } - let headers_bytes = nbheaders - .checked_mul(BLOCK_HEADER) - .ok_or_else(|| invalid("block header size overflow"))?; - let trace_bytes = ntraces - .checked_mul(tbytes) - .ok_or_else(|| invalid("block trace size overflow"))?; - let minimum_bbytes = headers_bytes - .checked_add(trace_bytes) - .ok_or_else(|| invalid("block size overflow"))?; - if bbytes < minimum_bbytes { - return Err(invalid("bbytes is smaller than its headers and traces")); - } - let declared = nblocks - .checked_mul(bbytes) - .and_then(|n| FILE_HEADER.checked_add(n)) - .ok_or_else(|| invalid("file size overflow"))?; - if bytes.len() < declared { - return truncated(0, declared, bytes.len()); - } - if bytes.len() != declared { - return Err(invalid( - "file length does not match the declared block layout", - )); - } - - let total = nblocks - .checked_mul(ntraces) - .ok_or_else(|| invalid("trace count overflow"))?; - let mut traces = Vec::with_capacity(total); - for block in 0..nblocks { - let block_at = FILE_HEADER + block * bbytes; - let scale = i16::from_be_bytes(bytes[block_at..block_at + 2].try_into().unwrap()); - let block_status = - i16::from_be_bytes(bytes[block_at + 2..block_at + 4].try_into().unwrap()); - if block_status & S_DATA == 0 - || (block_status & S_COMPLEX == 0 && status & S_DDR == 0) - || block_status & S_SPEC != 0 - { - return Err(invalid( - "block header status is inconsistent with complex time-domain data", - )); - } - if block_status & S_HYPERCOMPLEX != 0 { - return Err(unsupported("hypercomplex block payloads are not supported")); - } - if block_status & SAMPLE_STATUS != status & SAMPLE_STATUS { - return Err(invalid( - "block sample type flags disagree with the file header", - )); - } - let factor = 2.0_f64.powi(i32::from(scale)); - if !factor.is_finite() { - return Err(invalid("block scale is out of range")); - } - for trace in 0..ntraces { - let at = block_at + headers_bytes + trace * tbytes; - let mut points = Vec::with_capacity(np / 2); - for pair in 0..np / 2 { - let real_at = at + pair * 2 * ebytes; - points.push(Complex64::new( - sample.read(bytes, real_at) * factor, - sample.read(bytes, real_at + ebytes) * factor, - )); - } - traces.push(points); - } - } - Ok(FidData { traces, np }) -} - -fn is_complex_fid(status: i16) -> bool { - status & (S_COMPLEX | S_DDR) != 0 -} - -#[derive(Clone, Copy)] -enum Sample { - I16, - I32, - F32, -} -impl Sample { - fn read(self, b: &[u8], at: usize) -> f64 { - match self { - Self::I16 => i16::from_be_bytes(b[at..at + 2].try_into().unwrap()) as f64, - Self::I32 => i32::from_be_bytes(b[at..at + 4].try_into().unwrap()) as f64, - Self::F32 => f32::from_be_bytes(b[at..at + 4].try_into().unwrap()) as f64, - } - } -} - -fn positive_i32(bytes: &[u8], at: usize, name: &str) -> Result { - let value = i32::from_be_bytes(bytes[at..at + 4].try_into().unwrap()); - usize::try_from(value) - .ok() - .filter(|v| *v > 0) - .ok_or_else(|| invalid(format!("{name} must be positive"))) -} -fn invalid(message: impl Into) -> IoError { - IoError::InvalidVarian(message.into()) -} -fn unsupported(message: impl Into) -> IoError { - IoError::UnsupportedVarian(message.into()) -} -fn truncated(offset: usize, needed: usize, have: usize) -> Result { - Err(IoError::Truncated { - offset, - needed, - have, - }) -} diff --git a/crates/io/src/varian/procpar.rs b/crates/io/src/varian/procpar.rs deleted file mode 100644 index 4cb47eb2..00000000 --- a/crates/io/src/varian/procpar.rs +++ /dev/null @@ -1,187 +0,0 @@ -use crate::IoError; -use std::collections::HashMap; - -#[derive(Debug, Clone)] -pub(super) enum Value { - Number(f64), - Text(String), -} - -#[derive(Debug, Default)] -pub(super) struct Procpar { - values: HashMap>, -} - -impl Procpar { - pub(super) fn parse(text: &str) -> Result { - let mut lines = text.lines().enumerate().peekable(); - let mut values = HashMap::new(); - while let Some((line_no, header)) = lines.next() { - if header.trim().is_empty() { - continue; - } - let fields = tokens(header).map_err(|e| invalid(line_no, e))?; - if fields.len() < 3 { - return Err(invalid( - line_no, - "parameter header has fewer than three fields", - )); - } - let name = fields[0].clone(); - let basic_type: i32 = fields[2] - .parse() - .map_err(|_| invalid(line_no, "invalid basic type"))?; - if basic_type != 1 && basic_type != 2 { - return Err(invalid(line_no, "unsupported basic type")); - } - let (value_line_no, first) = lines - .next() - .ok_or_else(|| invalid(line_no, "missing value record"))?; - let mut value_tokens = tokens(first).map_err(|e| invalid(value_line_no, e))?; - let count = parse_count(&mut value_tokens, value_line_no, "value")?; - while value_tokens.len() < count { - let (continuation_no, continuation) = lines - .next() - .ok_or_else(|| invalid(value_line_no, "truncated value record"))?; - value_tokens.extend(tokens(continuation).map_err(|e| invalid(continuation_no, e))?); - } - if value_tokens.len() != count { - return Err(invalid(value_line_no, "value count does not match record")); - } - let parsed = value_tokens - .into_iter() - .map(|token| { - if basic_type == 1 { - token.parse::().map(Value::Number).map_err(|_| { - invalid( - value_line_no, - "numeric parameter contains non-numeric value", - ) - }) - } else { - Ok(Value::Text(token)) - } - }) - .collect::, _>>()?; - - let (enum_line_no, enum_first) = lines - .next() - .ok_or_else(|| invalid(value_line_no, "missing enumeration record"))?; - let mut enum_tokens = tokens(enum_first).map_err(|e| invalid(enum_line_no, e))?; - let enum_count = parse_count(&mut enum_tokens, enum_line_no, "enumeration")?; - while enum_tokens.len() < enum_count { - let (continuation_no, continuation) = lines - .next() - .ok_or_else(|| invalid(enum_line_no, "truncated enumeration record"))?; - enum_tokens.extend(tokens(continuation).map_err(|e| invalid(continuation_no, e))?); - } - if enum_tokens.len() != enum_count { - return Err(invalid( - enum_line_no, - "enumeration count does not match record", - )); - } - values.insert(name, parsed); - } - Ok(Self { values }) - } - - pub(super) fn numbers(&self, name: &str) -> Option> { - self.values - .get(name)? - .iter() - .map(|v| match v { - Value::Number(n) => Some(*n), - Value::Text(_) => None, - }) - .collect() - } - - pub(super) fn number(&self, name: &str) -> Option { - self.numbers(name)?.first().copied() - } - - pub(super) fn strings(&self, name: &str) -> Option> { - self.values - .get(name)? - .iter() - .map(|v| match v { - Value::Text(s) => Some(s.as_str()), - Value::Number(_) => None, - }) - .collect() - } - - pub(super) fn string(&self, name: &str) -> Option<&str> { - self.strings(name)?.first().copied() - } -} - -fn parse_count(tokens: &mut Vec, line: usize, kind: &str) -> Result { - if tokens.is_empty() { - return Err(invalid(line, format!("missing {kind} count"))); - } - let count = tokens - .remove(0) - .parse::() - .map_err(|_| invalid(line, format!("invalid {kind} count")))?; - Ok(count) -} - -fn invalid(line: usize, message: impl Into) -> IoError { - IoError::InvalidVarian(format!("procpar line {}: {}", line + 1, message.into())) -} - -fn tokens(line: &str) -> Result, &'static str> { - let mut out = Vec::new(); - let mut chars = line.chars().peekable(); - while let Some(c) = chars.next() { - if c.is_whitespace() { - continue; - } - if c == '"' { - let mut value = String::new(); - let mut closed = false; - while let Some(c) = chars.next() { - if c == '"' { - closed = true; - break; - } - if c == '\\' { - value.push(chars.next().ok_or("unterminated escape in quoted string")?); - } else { - value.push(c); - } - } - if !closed { - return Err("unterminated quoted string"); - } - out.push(value); - } else { - let mut value = String::from(c); - while chars.peek().is_some_and(|c| !c.is_whitespace()) { - value.push(chars.next().unwrap()); - } - out.push(value); - } - } - Ok(out) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_numbers_strings_arrays_and_enums() { - let p = Procpar::parse("sw 1 1 0 0 0 0 0 0 1 0\n2 1000 2000\n1 5000\ncomment 1 2 0 0 0 0 0 0 1 0\n1 \"a value with spaces\"\n2 \"yes\" \"no\"\n").unwrap(); - assert_eq!(p.numbers("sw"), Some(vec![1000.0, 2000.0])); - assert_eq!(p.string("comment"), Some("a value with spaces")); - } - - #[test] - fn rejects_bad_counts_and_quotes() { - assert!(Procpar::parse("x 1 1\n2 1\n0\n").is_err()); - assert!(Procpar::parse("x 1 2\n1 \"oops\n0\n").is_err()); - } -} diff --git a/crates/io/src/varian/tests.rs b/crates/io/src/varian/tests.rs deleted file mode 100644 index dc70db3f..00000000 --- a/crates/io/src/varian/tests.rs +++ /dev/null @@ -1,296 +0,0 @@ -use super::*; -use num_complex::Complex64; -use std::sync::atomic::{AtomicU64, Ordering}; - -fn record(name: &str, basic: i32, values: &str) -> String { - format!("{name} 1 {basic}\n{values}\n0\n") -} - -fn base_procpar() -> String { - [ - record("np", 1, "1 4"), - record("sw", 1, "1 4000"), - record("sfrq", 1, "1 500"), - record("tof", 1, "1 2500"), - record("tn", 2, "1 \"H1\""), - record("array", 2, "1 \"\""), - ] - .concat() -} - -#[derive(Clone, Copy)] -enum Encoding { - I16, - I32, - F32, -} - -fn fid_bytes(blocks: &[Vec>], encoding: Encoding, scales: &[i16]) -> Vec { - let nblocks = blocks.len(); - let ntraces = blocks[0].len(); - let np = blocks[0][0].len(); - let ebytes = match encoding { - Encoding::I16 => 2, - Encoding::I32 | Encoding::F32 => 4, - }; - let tbytes = np * ebytes; - let bbytes = 28 + ntraces * tbytes; - let status = 0x11 - | match encoding { - Encoding::I16 => 0, - Encoding::I32 => 0x4, - Encoding::F32 => 0xc, - }; - let mut out = Vec::new(); - for value in [nblocks, ntraces, np, ebytes, tbytes, bbytes] { - out.extend_from_slice(&(value as i32).to_be_bytes()); - } - out.extend_from_slice(&0_i16.to_be_bytes()); - out.extend_from_slice(&(status as i16).to_be_bytes()); - out.extend_from_slice(&1_i32.to_be_bytes()); - for (block, &scale) in blocks.iter().zip(scales) { - out.extend_from_slice(&scale.to_be_bytes()); - out.extend_from_slice(&(status as i16).to_be_bytes()); - out.extend_from_slice(&[0; 24]); - for trace in block { - for value in trace { - match encoding { - Encoding::I16 => out.extend_from_slice(&(*value as i16).to_be_bytes()), - Encoding::I32 => out.extend_from_slice(&(*value as i32).to_be_bytes()), - Encoding::F32 => out.extend_from_slice(&(*value as f32).to_be_bytes()), - } - } - } - } - out -} - -fn dataset(procpar: &str, fid: &[u8]) -> std::path::PathBuf { - static NEXT: AtomicU64 = AtomicU64::new(0); - let dir = std::env::temp_dir().join(format!( - "plotx_varian_{}_{}.fid", - std::process::id(), - NEXT.fetch_add(1, Ordering::Relaxed) - )); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("procpar"), procpar).unwrap(); - std::fs::write(dir.join("fid"), fid).unwrap(); - dir -} - -#[test] -fn loads_directory_and_fid_with_provenance_and_metadata() { - let mut procpar = base_procpar(); - procpar.push_str(&record("samplename", 2, "1 \"Test sample\"")); - procpar.push_str(&record("pslabel", 2, "1 \"PROTON\"")); - let dir = dataset( - &procpar, - &fid_bytes(&[vec![vec![1., 2., 3., 4.]]], Encoding::I16, &[1]), - ); - assert_eq!( - crate::detect_format(&dir).unwrap(), - DataFormat::Nmr(crate::NmrFormat::VarianAgilentRaw) - ); - for selected in [&dir, &dir.join("fid")] { - let loaded = load_raw(selected).unwrap(); - assert_eq!(loaded.provenance.selected_path, *selected); - assert_eq!(loaded.provenance.data_path, dir.join("fid")); - assert_eq!(loaded.provenance.parameter_paths, vec![dir.join("procpar")]); - let Acquisition::D1(data) = loaded.acquisition else { - panic!("expected 1D") - }; - assert_eq!( - data.points, - vec![Complex64::new(2., -4.), Complex64::new(6., -8.)] - ); - assert_eq!( - ( - data.spectral_width_hz, - data.observe_freq_mhz, - data.carrier_ppm - ), - (4000., 500., 5.) - ); - assert_eq!(data.nucleus, "1H"); - assert_eq!(data.source, "Test sample — 1H — PROTON"); - } - std::fs::remove_dir_all(dir).unwrap(); -} - -#[test] -fn direct_axis_uses_vnmrj_rfl_rfp_reference_when_present() { - let mut procpar = base_procpar(); - procpar.push_str(&record("rfl", 1, "1 1500")); - procpar.push_str(&record("rfp", 1, "1 1.25")); - let dir = dataset( - &procpar, - &fid_bytes(&[vec![vec![1., 2., 3., 4.]]], Encoding::I16, &[0]), - ); - let loaded = load_raw(&dir).unwrap(); - let Acquisition::D1(data) = loaded.acquisition else { - panic!("expected 1D") - }; - assert!((data.carrier_ppm - 2.25).abs() < 1e-12); - std::fs::remove_dir_all(dir).unwrap(); -} - -#[test] -fn reads_all_sample_widths_and_block_major_trace_minor_order() { - for encoding in [Encoding::I16, Encoding::I32, Encoding::F32] { - let bytes = fid_bytes( - &[ - vec![vec![1., 2.], vec![3., 4.]], - vec![vec![5., 6.], vec![7., 8.]], - ], - encoding, - &[0, 1], - ); - let raw = fid::parse(&bytes).unwrap(); - assert_eq!( - raw.traces.iter().flatten().copied().collect::>(), - vec![ - Complex64::new(1., 2.), - Complex64::new(3., 4.), - Complex64::new(10., 12.), - Complex64::new(14., 16.) - ] - ); - } -} - -#[test] -fn loads_homonuclear_and_heteronuclear_states_2d() { - let raw = fid_bytes( - &[ - vec![vec![1., 2., 3., 4.], vec![5., 6., 7., 8.]], - vec![vec![9., 10., 11., 12.], vec![13., 14., 15., 16.]], - ], - Encoding::I32, - &[0, 0], - ); - for (seq, indirect) in [("gcosy", (500., 5., "1H")), ("ghsqc", (125., 80., "13C"))] { - let mut p = base_procpar(); - p.push_str(&record("ni", 1, "1 2")); - p.push_str(&record("phase", 1, "2 1 2")); - p.push_str(&record("array", 2, "1 \"phase\"")); - p.push_str(&record("sw1", 1, "1 20000")); - p.push_str(&record("seqfil", 2, &format!("1 \"{seq}\""))); - if seq == "ghsqc" { - p.push_str(&record("dfrq", 1, "1 125")); - p.push_str(&record("dof", 1, "1 10000")); - p.push_str(&record("dn", 2, "1 \"C13\"")); - } - let dir = dataset(&p, &raw); - let Acquisition::D2(data) = load_raw(&dir).unwrap().acquisition else { - panic!("expected 2D") - }; - assert_eq!((data.rows, data.cols, data.quad), (4, 2, QuadMode::States)); - assert_eq!( - ( - data.indirect.observe_freq_mhz, - data.indirect.carrier_ppm, - data.indirect.nucleus.as_str() - ), - indirect - ); - assert_eq!(data.experiment.as_deref(), Some(seq)); - assert!(!data.indirect_conjugate); - std::fs::remove_dir_all(dir).unwrap(); - } -} - -#[test] -fn rejects_unsupported_two_entry_phase_table() { - let raw = fid_bytes( - &[ - vec![vec![1., 2., 3., 4.], vec![5., 6., 7., 8.]], - vec![vec![9., 10., 11., 12.], vec![13., 14., 15., 16.]], - ], - Encoding::I32, - &[0, 0], - ); - let mut p = base_procpar(); - p.push_str(&record("ni", 1, "1 2")); - p.push_str(&record("phase", 1, "2 1 3")); - p.push_str(&record("array", 2, "1 \"phase\"")); - let dir = dataset(&p, &raw); - - assert!(matches!(load_raw(&dir), Err(IoError::UnsupportedVarian(_)))); - std::fs::remove_dir_all(dir).unwrap(); -} - -#[test] -fn rejects_procpar_np_disagreement() { - let mut p = base_procpar(); - p.push_str(&record("np", 1, "1 6")); - let dir = dataset( - &p, - &fid_bytes(&[vec![vec![1., 2., 3., 4.]]], Encoding::I16, &[0]), - ); - - assert!(matches!(load_raw(&dir), Err(IoError::InvalidVarian(_)))); - std::fs::remove_dir_all(dir).unwrap(); -} - -#[test] -fn rejects_corrupt_and_unsupported_layouts() { - let mut odd = fid_bytes(&[vec![vec![1., 2., 3., 4.]]], Encoding::I16, &[0]); - odd[8..12].copy_from_slice(&3_i32.to_be_bytes()); - assert!(matches!(fid::parse(&odd), Err(IoError::InvalidVarian(_)))); - let mut truncated = fid_bytes(&[vec![vec![1., 2.]]], Encoding::I16, &[0]); - truncated.pop(); - assert!(matches!( - fid::parse(&truncated), - Err(IoError::Truncated { .. }) - )); - let mut spectrum = fid_bytes(&[vec![vec![1., 2.]]], Encoding::I16, &[0]); - spectrum[26..28].copy_from_slice(&0x13_i16.to_be_bytes()); - assert!(matches!( - fid::parse(&spectrum), - Err(IoError::UnsupportedVarian(_)) - )); -} - -#[test] -fn accepts_ddr_fid_without_legacy_complex_bit() { - let mut ddr = fid_bytes(&[vec![vec![1., 2., 3., 4.]]], Encoding::F32, &[0]); - let status = 0x00c9_i16; - ddr[26..28].copy_from_slice(&status.to_be_bytes()); - ddr[34..36].copy_from_slice(&status.to_be_bytes()); - - let raw = fid::parse(&ddr).unwrap(); - assert_eq!( - raw.traces[0], - vec![Complex64::new(1., 2.), Complex64::new(3., 4.)] - ); -} - -#[test] -fn rejects_processed_and_higher_dimensional_header_flags() { - let base = fid_bytes(&[vec![vec![1., 2., 3., 4.]]], Encoding::F32, &[0]); - for status_bit in [0x2_i16, 0x100, 0x200, 0x400] { - let mut bytes = base.clone(); - let status = i16::from_be_bytes(bytes[26..28].try_into().unwrap()) | status_bit; - bytes[26..28].copy_from_slice(&status.to_be_bytes()); - assert!(matches!( - fid::parse(&bytes), - Err(IoError::UnsupportedVarian(_)) - )); - } - - let mut ni3 = base.clone(); - ni3[28..32].copy_from_slice(&0x10001_i32.to_be_bytes()); - assert!(matches!( - fid::parse(&ni3), - Err(IoError::UnsupportedVarian(_)) - )); -} - -#[test] -fn rejects_block_sample_type_disagreement() { - let mut bytes = fid_bytes(&[vec![vec![1., 2., 3., 4.]]], Encoding::F32, &[0]); - let integer = fid_bytes(&[vec![vec![1., 2., 3., 4.]]], Encoding::I32, &[0]); - bytes[34..36].copy_from_slice(&integer[34..36]); - assert!(matches!(fid::parse(&bytes), Err(IoError::InvalidVarian(_)))); -} diff --git a/crates/io/tests/bruker_processed.rs b/crates/io/tests/bruker_processed.rs deleted file mode 100644 index ad40e06a..00000000 --- a/crates/io/tests/bruker_processed.rs +++ /dev/null @@ -1,131 +0,0 @@ -use plotx_io::{Acquisition, DataFormat, Domain, LoadWarningCode}; - -fn fixture(name: &str) -> std::path::PathBuf { - std::env::temp_dir().join(format!( - "plotx_{name}_{}_{}", - std::process::id(), - std::thread::current().name().unwrap_or("test") - )) -} - -#[test] -fn loads_big_endian_scaled_1r_from_experiment_directory() { - let root = fixture("bruker_processed_1d"); - let experiment = root.join("sample").join("3"); - let proc_dir = experiment.join("pdata").join("1"); - std::fs::create_dir_all(&proc_dir).unwrap(); - std::fs::write( - experiment.join("acqus"), - "##$TD= 8\n##$EXP= \n##$PULPROG= \n", - ) - .unwrap(); - std::fs::write( - proc_dir.join("procs"), - "##$SI= 4\n##$DTYPP= 0\n##$BYTORDP= 1\n##$NC_proc= 1\n\ - ##$SW_p= 4000\n##$SF= 400\n##$OFFSET= 10\n##$AXNUC= <1H>\n", - ) - .unwrap(); - let bytes: Vec = [1i32, 2, 3, 4] - .into_iter() - .flat_map(i32::to_be_bytes) - .collect(); - std::fs::write(proc_dir.join("1r"), bytes).unwrap(); - - assert_eq!( - plotx_io::detect_format(&experiment).unwrap(), - DataFormat::Nmr(plotx_io::NmrFormat::BrukerProcessed1D) - ); - let loaded = plotx_io::load_path(&experiment).unwrap(); - assert_eq!( - loaded.format, - DataFormat::Nmr(plotx_io::NmrFormat::BrukerProcessed1D) - ); - assert_eq!( - loaded.acquisition_identity.subject.as_deref(), - Some("sample") - ); - assert_eq!( - loaded.acquisition_identity.acquisition.as_deref(), - Some("PROTON") - ); - assert!( - loaded - .provenance - .parameter_paths - .contains(&experiment.join("acqus")) - ); - assert!( - loaded - .warnings - .iter() - .any(|warning| { warning.code == LoadWarningCode::OptionalImaginaryMissing }) - ); - let data = match loaded.acquisition { - Acquisition::D1(data) => data, - Acquisition::D2(_) => panic!("expected 1D"), - Acquisition::Electrophysiology(_) => panic!("expected NMR"), - Acquisition::Afm(_) => panic!("expected NMR"), - Acquisition::MassSpec(_) | Acquisition::Xrd(_) => panic!("expected NMR"), - Acquisition::Xps(_) => panic!("expected NMR"), - }; - assert_eq!(data.domain, Domain::Frequency); - assert_eq!( - data.points.iter().map(|value| value.re).collect::>(), - vec![8.0, 6.0, 4.0, 2.0] - ); - assert_eq!(data.carrier_ppm, 5.0); - assert_eq!(data.nucleus, "1H"); - - std::fs::remove_dir_all(root).unwrap(); -} - -#[test] -fn loads_2rr_and_reverses_both_frequency_axes() { - let root = fixture("bruker_processed_2d"); - let proc_dir = root.join("sample").join("7").join("pdata").join("2"); - std::fs::create_dir_all(&proc_dir).unwrap(); - std::fs::write( - proc_dir.join("procs"), - "##$SI= 3\n##$DTYPP= 0\n##$BYTORDP= 0\n##$NC_proc= 0\n\ - ##$SW_p= 3000\n##$SF= 600\n##$OFFSET= 9\n##$AXNUC= <1H>\n", - ) - .unwrap(); - std::fs::write( - proc_dir.join("proc2s"), - "##$SI= 2\n##$SW_p= 2000\n##$SF= 100\n##$OFFSET= 120\n##$AXNUC= <13C>\n", - ) - .unwrap(); - let bytes: Vec = [1i32, 2, 3, 4, 5, 6] - .into_iter() - .flat_map(i32::to_le_bytes) - .collect(); - std::fs::write(proc_dir.join("2rr"), bytes).unwrap(); - - let loaded = plotx_io::load_path(&proc_dir).unwrap(); - assert_eq!( - loaded.format, - DataFormat::Nmr(plotx_io::NmrFormat::BrukerProcessed2D) - ); - assert_eq!( - loaded.acquisition_identity.subject.as_deref(), - Some("sample") - ); - let data = match loaded.acquisition { - Acquisition::D2(data) => *data, - Acquisition::D1(_) => panic!("expected 2D"), - Acquisition::Electrophysiology(_) => panic!("expected NMR"), - Acquisition::Afm(_) => panic!("expected NMR"), - Acquisition::MassSpec(_) | Acquisition::Xrd(_) => panic!("expected NMR"), - Acquisition::Xps(_) => panic!("expected NMR"), - }; - assert_eq!(data.domain, Domain::Frequency); - assert_eq!((data.rows, data.cols), (2, 3)); - assert_eq!( - data.data.iter().map(|value| value.re).collect::>(), - vec![6.0, 5.0, 4.0, 3.0, 2.0, 1.0] - ); - assert_eq!(data.direct.carrier_ppm, 6.5); - assert_eq!(data.indirect.carrier_ppm, 110.0); - - std::fs::remove_dir_all(root).unwrap(); -} diff --git a/crates/io/tests/fixtures/nmr/README.md b/crates/io/tests/fixtures/nmr/README.md new file mode 100644 index 00000000..44737127 --- /dev/null +++ b/crates/io/tests/fixtures/nmr/README.md @@ -0,0 +1,30 @@ +# PlotX synthetic NMR integration inputs + +These small, original byte fixtures contain no experimental or personal data. +They are distributed under the repository license. Binary payloads are source +test inputs, not build output. None establishes independent vendor correctness. + +| Input | Construction and expected scientific meaning | +| --- | --- | +| `jcamp-hz.dx` | AFFN, descending coordinates 4,3,2,1 Hz; YFACTOR=2; scalar values 2,4,6,8. | +| `jcamp-ppm.dx` | Identical input with XUNITS=PPM; coordinates remain 4,3,2,1 ppm. The missing-observe-frequency test removes both OBSERVE records from a temporary copy. | +| `bruker-1d` | Little-endian int32 fid `[1,2,3,4]`; two complex samples `(1,2),(3,4)`; explicit GRPDLY=0. | +| `bruker-1d/pdata/1` | int32 1r `[1,2,3,4]`, NC_proc=1; scalar 2,4,6,8 at 10,7.5,5,2.5 ppm. | +| `bruker-states-tppi` | Continuous ser of int32 1..16; four physical rows, two logical increments and two lanes; FnMODE=5. | +| `bruker-states` | Same bytes and dimensions with FnMODE=4; States has no alternate-increment sign modulation. | +| `bruker-nus` | Same payload, FnTYPE=2, FnMODE=6, NusTD=8; full grid 4, two observations in order `[3,1]`; missing coordinates remain absent. | +| `jeol-complex.jdf` | JDF v1, direct complex axis, float64 LE sections R=1,2,3,4 and I=0,0,0,0; 1 ms dwell, no established delay or nucleus. The descriptor remains complex despite all-zero I. | +| `varian.fid` | File version 1, status 0x11, one 28-byte block header, int16 BE samples 1,2,3,4 and block scale 1; public values `(2,-4),(6,-8)`. Eleven-field procpar headers. | +| `varian-short-header.fid` | Reproduces the old PlotX test's three-field procpar records. Invalid input, not a request to relax the library's format validation. | +| `varian-v0-status.fid` | Full procpar with file version 0 and status 0x11. nmr uses the version-0 complex bit 0x40, so this is scalar. The old PlotX reader treated 0x10 as complex for every version; review that separate issue against real version-0 files. | + +The integration tests verify import policy, coordinate and component preservation, +sampling declarations, and offline snapshot v1 round trips. + +## Sampling-declaration fixture + +`jeol-nus-missing.jdf` is generated by `generate_jeol_nus.py`, using the synthetic +layout exercised in nmr's `tests/unified.rs` and `tests/unified/jeol.rs`. Four +Cartesian planes contain `plane*100 + row*10 + column`, with an indirect grid of +8, four observations, three valid direct points, and no embedded sampling list. +It contains no real vendor data and cannot establish vendor interpretation. diff --git a/crates/io/tests/fixtures/nmr/bruker-1d/acqus b/crates/io/tests/fixtures/nmr/bruker-1d/acqus new file mode 100644 index 00000000..6c5a3fd2 --- /dev/null +++ b/crates/io/tests/fixtures/nmr/bruker-1d/acqus @@ -0,0 +1,13 @@ +##TITLE=PlotX synthetic NMR integration fixture +##$TD= 4 +##$PARMODE= 0 +##$AQ_mod= 3 +##$BYTORDA= 0 +##$DTYPA= 0 +##$SW_h= 4000 +##$SFO1= 400 +##$BF1= 400 +##$O1= 0 +##$NUC1= <1H> +##$GRPDLY= 0 +##END= diff --git a/crates/io/tests/fixtures/nmr/bruker-1d/fid b/crates/io/tests/fixtures/nmr/bruker-1d/fid new file mode 100644 index 00000000..7adcc1a0 Binary files /dev/null and b/crates/io/tests/fixtures/nmr/bruker-1d/fid differ diff --git a/crates/io/tests/fixtures/nmr/bruker-1d/pdata/1/1r b/crates/io/tests/fixtures/nmr/bruker-1d/pdata/1/1r new file mode 100644 index 00000000..7adcc1a0 Binary files /dev/null and b/crates/io/tests/fixtures/nmr/bruker-1d/pdata/1/1r differ diff --git a/crates/io/tests/fixtures/nmr/bruker-1d/pdata/1/procs b/crates/io/tests/fixtures/nmr/bruker-1d/pdata/1/procs new file mode 100644 index 00000000..376f33a4 --- /dev/null +++ b/crates/io/tests/fixtures/nmr/bruker-1d/pdata/1/procs @@ -0,0 +1,10 @@ +##TITLE=PlotX synthetic processed fixture +##$SI= 4 +##$DTYPP= 0 +##$BYTORDP= 0 +##$NC_proc= 1 +##$SW_p= 4000 +##$SF= 400 +##$OFFSET= 10 +##$AXNUC= <1H> +##END= diff --git a/crates/io/tests/fixtures/nmr/bruker-nus/acqu2s b/crates/io/tests/fixtures/nmr/bruker-nus/acqu2s new file mode 100644 index 00000000..5073e16b --- /dev/null +++ b/crates/io/tests/fixtures/nmr/bruker-nus/acqu2s @@ -0,0 +1,10 @@ +##TITLE=PlotX synthetic indirect axis +##$TD= 4 +##$FnMODE= 6 +##$NusTD= 8 +##$SW_h= 1000 +##$SFO1= 100 +##$BF1= 100 +##$O1= 0 +##$NUC1= <13C> +##END= diff --git a/crates/io/tests/fixtures/nmr/bruker-nus/acqus b/crates/io/tests/fixtures/nmr/bruker-nus/acqus new file mode 100644 index 00000000..bb4dd799 --- /dev/null +++ b/crates/io/tests/fixtures/nmr/bruker-nus/acqus @@ -0,0 +1,16 @@ +##TITLE=PlotX synthetic NMR integration fixture +##$TD= 4 +##$PARMODE= 1 +##$AQSEQ= 0 +##$FnTYPE= 2 +##$GO_block_size= +##$AQ_mod= 3 +##$BYTORDA= 0 +##$DTYPA= 0 +##$SW_h= 4000 +##$SFO1= 400 +##$BF1= 400 +##$O1= 0 +##$NUC1= <1H> +##$GRPDLY= 0 +##END= diff --git a/crates/io/tests/fixtures/nmr/bruker-nus/nuslist b/crates/io/tests/fixtures/nmr/bruker-nus/nuslist new file mode 100644 index 00000000..f00580c4 --- /dev/null +++ b/crates/io/tests/fixtures/nmr/bruker-nus/nuslist @@ -0,0 +1,2 @@ +3 +1 diff --git a/crates/io/tests/fixtures/nmr/bruker-nus/ser b/crates/io/tests/fixtures/nmr/bruker-nus/ser new file mode 100644 index 00000000..576ebfc5 Binary files /dev/null and b/crates/io/tests/fixtures/nmr/bruker-nus/ser differ diff --git a/crates/io/tests/fixtures/nmr/bruker-states-tppi/acqu2s b/crates/io/tests/fixtures/nmr/bruker-states-tppi/acqu2s new file mode 100644 index 00000000..27654884 --- /dev/null +++ b/crates/io/tests/fixtures/nmr/bruker-states-tppi/acqu2s @@ -0,0 +1,9 @@ +##TITLE=PlotX synthetic indirect axis +##$TD= 4 +##$FnMODE= 5 +##$SW_h= 1000 +##$SFO1= 100 +##$BF1= 100 +##$O1= 0 +##$NUC1= <13C> +##END= diff --git a/crates/io/tests/fixtures/nmr/bruker-states-tppi/acqus b/crates/io/tests/fixtures/nmr/bruker-states-tppi/acqus new file mode 100644 index 00000000..a3defc6b --- /dev/null +++ b/crates/io/tests/fixtures/nmr/bruker-states-tppi/acqus @@ -0,0 +1,16 @@ +##TITLE=PlotX synthetic NMR integration fixture +##$TD= 4 +##$PARMODE= 1 +##$AQSEQ= 0 +##$FnTYPE= 0 +##$GO_block_size= +##$AQ_mod= 3 +##$BYTORDA= 0 +##$DTYPA= 0 +##$SW_h= 4000 +##$SFO1= 400 +##$BF1= 400 +##$O1= 0 +##$NUC1= <1H> +##$GRPDLY= 0 +##END= diff --git a/crates/io/tests/fixtures/nmr/bruker-states-tppi/ser b/crates/io/tests/fixtures/nmr/bruker-states-tppi/ser new file mode 100644 index 00000000..576ebfc5 Binary files /dev/null and b/crates/io/tests/fixtures/nmr/bruker-states-tppi/ser differ diff --git a/crates/io/tests/fixtures/nmr/bruker-states/acqu2s b/crates/io/tests/fixtures/nmr/bruker-states/acqu2s new file mode 100644 index 00000000..f1a5d17c --- /dev/null +++ b/crates/io/tests/fixtures/nmr/bruker-states/acqu2s @@ -0,0 +1,9 @@ +##TITLE=PlotX synthetic indirect axis +##$TD= 4 +##$FnMODE= 4 +##$SW_h= 1000 +##$SFO1= 100 +##$BF1= 100 +##$O1= 0 +##$NUC1= <13C> +##END= diff --git a/crates/io/tests/fixtures/nmr/bruker-states/acqus b/crates/io/tests/fixtures/nmr/bruker-states/acqus new file mode 100644 index 00000000..a3defc6b --- /dev/null +++ b/crates/io/tests/fixtures/nmr/bruker-states/acqus @@ -0,0 +1,16 @@ +##TITLE=PlotX synthetic NMR integration fixture +##$TD= 4 +##$PARMODE= 1 +##$AQSEQ= 0 +##$FnTYPE= 0 +##$GO_block_size= +##$AQ_mod= 3 +##$BYTORDA= 0 +##$DTYPA= 0 +##$SW_h= 4000 +##$SFO1= 400 +##$BF1= 400 +##$O1= 0 +##$NUC1= <1H> +##$GRPDLY= 0 +##END= diff --git a/crates/io/tests/fixtures/nmr/bruker-states/ser b/crates/io/tests/fixtures/nmr/bruker-states/ser new file mode 100644 index 00000000..576ebfc5 Binary files /dev/null and b/crates/io/tests/fixtures/nmr/bruker-states/ser differ diff --git a/crates/io/tests/fixtures/nmr/generate_jeol_nus.py b/crates/io/tests/fixtures/nmr/generate_jeol_nus.py new file mode 100644 index 00000000..882096d3 --- /dev/null +++ b/crates/io/tests/fixtures/nmr/generate_jeol_nus.py @@ -0,0 +1,33 @@ +"""Synthetic reduced-grid JEOL input, following nmr's unified JEOL test layout. + +No vendor data: four planes contain plane*100 + row*10 + column. +The original indirect grid is eight points; four observations have no embedded +coordinate list. This is an interface fixture, not independent vendor evidence. +""" +from pathlib import Path +import struct + +data_start = 1504 +blob = bytearray(data_start + 4 * 4 * 4 * 8) +blob[:8] = b"JEOL.NMR" +blob[8:15] = bytes([1, 1, 0, 2, 2, 3, 12]) +blob[24:26] = bytes([3, 3]) +blob[34:36] = bytes([1, 28]) +for offset, value in [(176, 4), (180, 4), (240, 2), (244, 3), + (1212, 1360), (1216, 144), (1284, data_start)]: + struct.pack_into(">I", blob, offset, value) +struct.pack_into("<4I", blob, 1360, 64, 0, 2, 144) +for index, (name, kind, unit, value) in enumerate([ + ("y_orig_points", 1, 0, 8), ("y_sweep", 2, 13, 1000.0) +]): + offset = 1376 + index * 64 + blob[offset + 6:offset + 8] = bytes([1, unit]) + struct.pack_into(" +##XYDATA=(X++(Y..Y)) +4 1 2 3 4 +##END= diff --git a/crates/io/tests/fixtures/nmr/jcamp-ppm.dx b/crates/io/tests/fixtures/nmr/jcamp-ppm.dx new file mode 100644 index 00000000..0dd1dd08 --- /dev/null +++ b/crates/io/tests/fixtures/nmr/jcamp-ppm.dx @@ -0,0 +1,15 @@ +##TITLE=PlotX synthetic PPM fixture +##JCAMP-DX=5.00 +##DATA TYPE=NMR SPECTRUM +##XUNITS=PPM +##YUNITS=ARBITRARY UNITS +##XFACTOR=1 +##YFACTOR=2 +##FIRSTX=4 +##LASTX=1 +##NPOINTS=4 +##.OBSERVE FREQUENCY=400 +##.OBSERVE NUCLEUS=<1H> +##XYDATA=(X++(Y..Y)) +4 1 2 3 4 +##END= diff --git a/crates/io/tests/fixtures/nmr/jeol-complex.jdf b/crates/io/tests/fixtures/nmr/jeol-complex.jdf new file mode 100644 index 00000000..843a8ed8 Binary files /dev/null and b/crates/io/tests/fixtures/nmr/jeol-complex.jdf differ diff --git a/crates/io/tests/fixtures/nmr/jeol-nus-missing.jdf b/crates/io/tests/fixtures/nmr/jeol-nus-missing.jdf new file mode 100644 index 00000000..0cefaa81 Binary files /dev/null and b/crates/io/tests/fixtures/nmr/jeol-nus-missing.jdf differ diff --git a/crates/io/tests/fixtures/nmr/varian-short-header.fid/fid b/crates/io/tests/fixtures/nmr/varian-short-header.fid/fid new file mode 100644 index 00000000..6ee1c9d1 Binary files /dev/null and b/crates/io/tests/fixtures/nmr/varian-short-header.fid/fid differ diff --git a/crates/io/tests/fixtures/nmr/varian-short-header.fid/procpar b/crates/io/tests/fixtures/nmr/varian-short-header.fid/procpar new file mode 100644 index 00000000..5bfa1564 --- /dev/null +++ b/crates/io/tests/fixtures/nmr/varian-short-header.fid/procpar @@ -0,0 +1,18 @@ +np 1 1 +1 4 +0 +sw 1 1 +1 4000 +0 +sfrq 1 1 +1 500 +0 +tof 1 1 +1 2500 +0 +tn 1 2 +1 "H1" +0 +array 1 2 +1 "" +0 diff --git a/crates/io/tests/fixtures/nmr/varian-v0-status.fid/fid b/crates/io/tests/fixtures/nmr/varian-v0-status.fid/fid new file mode 100644 index 00000000..6ee1c9d1 Binary files /dev/null and b/crates/io/tests/fixtures/nmr/varian-v0-status.fid/fid differ diff --git a/crates/io/tests/fixtures/nmr/varian-v0-status.fid/procpar b/crates/io/tests/fixtures/nmr/varian-v0-status.fid/procpar new file mode 100644 index 00000000..a1333bf0 --- /dev/null +++ b/crates/io/tests/fixtures/nmr/varian-v0-status.fid/procpar @@ -0,0 +1,18 @@ +np 1 1 32768 0 0 2 1 0 1 64 +1 4 +0 +sw 1 1 32768 0 0 2 1 0 1 64 +1 4000 +0 +sfrq 1 1 32768 0 0 2 1 0 1 64 +1 500 +0 +tof 1 1 32768 0 0 2 1 0 1 64 +1 2500 +0 +tn 1 2 32768 0 0 2 1 0 1 64 +1 "H1" +0 +array 1 2 32768 0 0 2 1 0 1 64 +1 "" +0 diff --git a/crates/io/tests/fixtures/nmr/varian.fid/fid b/crates/io/tests/fixtures/nmr/varian.fid/fid new file mode 100644 index 00000000..ee46105e Binary files /dev/null and b/crates/io/tests/fixtures/nmr/varian.fid/fid differ diff --git a/crates/io/tests/fixtures/nmr/varian.fid/procpar b/crates/io/tests/fixtures/nmr/varian.fid/procpar new file mode 100644 index 00000000..a1333bf0 --- /dev/null +++ b/crates/io/tests/fixtures/nmr/varian.fid/procpar @@ -0,0 +1,18 @@ +np 1 1 32768 0 0 2 1 0 1 64 +1 4 +0 +sw 1 1 32768 0 0 2 1 0 1 64 +1 4000 +0 +sfrq 1 1 32768 0 0 2 1 0 1 64 +1 500 +0 +tof 1 1 32768 0 0 2 1 0 1 64 +1 2500 +0 +tn 1 2 32768 0 0 2 1 0 1 64 +1 "H1" +0 +array 1 2 32768 0 0 2 1 0 1 64 +1 "" +0 diff --git a/crates/io/tests/nmr_bridge.rs b/crates/io/tests/nmr_bridge.rs new file mode 100644 index 00000000..2edb24d4 --- /dev/null +++ b/crates/io/tests/nmr_bridge.rs @@ -0,0 +1,397 @@ +use nmr::{ + DatasetKind, ExecutionContext, + axis::{AxisDomain, AxisUnit}, + raw::GroupDelayState, +}; +use plotx_io::nmr_bridge; +use std::{path::PathBuf, sync::Arc}; + +fn fixture(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/nmr") + .join(name) +} + +fn read(name: &str) -> Arc { + nmr_bridge::read(&fixture(name), &mut ExecutionContext::default()).unwrap() +} + +#[test] +fn directory_prefers_raw_but_processed_file_selection_is_respected() { + let raw = read("bruker-1d"); + assert_eq!(raw.kind(), DatasetKind::Raw); + let direct = &raw.as_raw().unwrap().descriptor().axes()[0]; + assert!( + matches!(direct.group_delay(), GroupDelayState::Pending(delay) if delay.delay_points() == 0.0) + ); + assert_eq!( + raw.as_raw().unwrap().read_trace(&[]).unwrap().samples(), + &[nmr::Complex64::new(1.0, 2.0), nmr::Complex64::new(3.0, 4.0)] + ); + let processed = read("bruker-1d/pdata/1/1r"); + assert_eq!(processed.kind(), DatasetKind::Processed); + let processed = processed.as_processed().unwrap(); + assert_eq!(processed.descriptor().component_counts(), [1]); + assert_eq!(processed.data().samples(), [2.0, 4.0, 6.0, 8.0]); + assert_eq!( + processed.descriptor().axes()[0] + .coordinate_iter() + .unwrap() + .collect::>(), + [10.0, 7.5, 5.0, 2.5] + ); +} + +#[test] +fn jcamp_keeps_explicit_coordinates_scale_and_scalar_descriptor() { + let input = read("jcamp-hz.dx"); + let processed = input.as_processed().unwrap(); + let axis = &processed.descriptor().axes()[0]; + assert_eq!(axis.domain(), AxisDomain::Frequency); + assert_eq!(axis.unit(), Some(AxisUnit::Hertz)); + assert_eq!(axis.component_count(), 1); + assert_eq!( + axis.coordinate_iter().unwrap().collect::>(), + [4.0, 3.0, 2.0, 1.0] + ); + assert_eq!(processed.data().samples(), [2.0, 4.0, 6.0, 8.0]); + assert_eq!( + nmr_bridge::provenance(&input).unwrap().selected_path, + fixture("jcamp-hz.dx") + ); + assert_eq!( + nmr_bridge::identity(&input).subject.as_deref(), + input.identity().subject() + ); + assert_eq!(nmr_bridge::identity(&input).source_label, "jcamp-hz"); +} + +#[test] +fn ppm_jcamp_keeps_coordinates_without_inventing_observe_frequency() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("no-observe.dx"); + let text = std::fs::read_to_string(fixture("jcamp-ppm.dx")).unwrap(); + let text = text + .lines() + .filter(|line| !line.starts_with("##.OBSERVE")) + .collect::>() + .join("\n"); + std::fs::write(&path, text).unwrap(); + let input = nmr_bridge::read(&path, &mut ExecutionContext::default()).unwrap(); + let processed = input.as_processed().unwrap(); + let axis = &processed.descriptor().axes()[0]; + assert_eq!(axis.unit(), Some(AxisUnit::Ppm)); + assert_eq!( + axis.frequency_evidence() + .and_then(|e| e.observe_frequency_mhz()), + None + ); + assert_eq!(axis.nucleus(), None); + assert_eq!( + axis.coordinate_iter().unwrap().collect::>(), + [4.0, 3.0, 2.0, 1.0] + ); + assert_eq!(processed.descriptor().component_counts(), [1]); + assert_eq!(processed.data().samples(), [2.0, 4.0, 6.0, 8.0]); +} + +#[test] +fn states_tppi_retains_component_lanes() { + let input = read("bruker-states-tppi"); + let raw = input.as_raw().unwrap(); + assert_eq!(raw.descriptor().logical_shape(), [2, 2]); + assert_eq!(raw.descriptor().component_lanes(), [2, 1]); + let trace = raw.read_trace(&[1]).unwrap(); + assert_eq!( + trace.samples(), + &[ + nmr::Complex64::new(9., 10.), + nmr::Complex64::new(11., 12.), + nmr::Complex64::new(13., 14.), + nmr::Complex64::new(15., 16.) + ] + ); +} + +#[test] +fn states_decoding_keeps_both_lanes_without_tppi_modulation() { + use nmr::processing::{ProcessingOperation, ProcessingPlan}; + let input = read("bruker-states"); + let output = ProcessingPlan::new(vec![ProcessingOperation::ComponentTransform { axis: 0 }]) + .unwrap() + .apply(&input) + .unwrap(); + let data = output.as_dense_processed().unwrap(); + assert_eq!(data.component_counts(), [2, 2]); + for row in 0..2 { + for lane in 0..2 { + for col in 0..2 { + for channel in 0..2 { + let expected = (1 + row * 8 + lane * 4 + col * 2 + channel) as f64; + assert_eq!(data.get(&[row, col], &[lane, channel]).unwrap(), expected); + } + } + } + } +} + +#[test] +fn jeol_policy_does_not_alert_and_zero_imaginary_values_remain_complex() { + let input = read("jeol-complex.jdf"); + assert!(input.warnings().iter().any(|warning| matches!( + warning, + nmr::ReadWarning::ExperimentalVendorSemantics { .. } + ))); + assert!( + !nmr_bridge::warnings(&input) + .iter() + .any(|warning| warning.message.contains("ExperimentalVendorSemantics")) + ); + let raw = input.as_raw().unwrap(); + assert!(matches!( + raw.descriptor().axes()[0].kind(), + nmr::raw::RawAxisKind::Direct(nmr::raw::DirectSamples::Complex) + )); + assert!( + raw.read_trace(&[]) + .unwrap() + .samples() + .iter() + .all(|sample| sample.im == 0.0) + ); +} + +#[test] +fn snapshot_restores_after_source_removal_with_exact_context_and_corruption_checks() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("source.dx"); + std::fs::copy(fixture("jcamp-hz.dx"), &path).unwrap(); + let input = nmr_bridge::read(&path, &mut ExecutionContext::default()).unwrap(); + let limits = nmr::snapshot::SnapshotLimits::default(); + let mut bytes = Vec::new(); + nmr_bridge::snapshot::write(&input, &mut bytes, limits, &mut ExecutionContext::default()) + .unwrap(); + std::fs::remove_file(path).unwrap(); + let restored = nmr_bridge::snapshot::read( + &mut bytes.as_slice(), + limits, + &mut ExecutionContext::default(), + ) + .unwrap(); + assert_eq!(restored.canonical_digests(), input.canonical_digests()); + assert_eq!(restored.warnings(), input.warnings()); + assert_eq!(restored.sources(), input.sources()); + assert_eq!(restored.selected_path(), input.selected_path()); + assert!(restored.metadata().accepted_archive()); + let mut trailing = bytes.clone(); + trailing.push(0); + assert!( + nmr_bridge::snapshot::read( + &mut trailing.as_slice(), + limits, + &mut ExecutionContext::default() + ) + .is_err() + ); + bytes[30] ^= 1; + assert!( + nmr_bridge::snapshot::read( + &mut bytes.as_slice(), + limits, + &mut ExecutionContext::default() + ) + .is_err() + ); +} + +#[test] +fn cancelled_read_and_snapshot_budget_are_reported() { + let token = nmr::CancellationToken::new(); + token.cancel(); + let error = nmr_bridge::read( + &fixture("bruker-1d"), + &mut ExecutionContext::default().with_cancellation(token), + ) + .unwrap_err(); + assert!( + matches!(error, plotx_io::IoError::Nmr(error) if error.kind() == nmr::ReadErrorKind::Cancelled) + ); + let limits = nmr::snapshot::SnapshotLimits { + max_bytes: 32, + ..Default::default() + }; + assert!( + nmr_bridge::snapshot::write( + &read("bruker-1d"), + &mut Vec::new(), + limits, + &mut ExecutionContext::default() + ) + .is_err() + ); +} + +#[test] +fn varian_keeps_scaling_sign_and_rejects_the_old_simplified_test_header() { + let input = read("varian.fid"); + assert_eq!( + input.as_raw().unwrap().read_trace(&[]).unwrap().samples(), + &[nmr::Complex64::new(2., -4.), nmr::Complex64::new(6., -8.)] + ); + assert!( + matches!(nmr_bridge::read(&fixture("varian-short-header.fid"), &mut ExecutionContext::default()), + Err(plotx_io::IoError::Nmr(error)) if error.kind() == nmr::ReadErrorKind::InvalidMetadata) + ); +} + +#[test] +fn sparse_snapshot_keeps_observation_order_and_missing_points() { + let input = read("bruker-nus"); + assert!( + !nmr_bridge::warnings(&input) + .iter() + .any(|warning| warning.message.contains("ExperimentalVendorSemantics")) + ); + let limits = nmr::snapshot::SnapshotLimits::default(); + let mut bytes = Vec::new(); + nmr_bridge::snapshot::write(&input, &mut bytes, limits, &mut ExecutionContext::default()) + .unwrap(); + let restored = nmr_bridge::snapshot::read( + &mut bytes.as_slice(), + limits, + &mut ExecutionContext::default(), + ) + .unwrap(); + assert_eq!(restored.canonical_digests(), input.canonical_digests()); + let raw = restored.as_raw().unwrap(); + assert!(raw.data().is_sparse()); + assert_eq!( + raw.sampling_schedule() + .unwrap() + .coordinates() + .iter() + .map(|coordinate| coordinate.as_slice()[0]) + .collect::>(), + [3, 1] + ); + assert_eq!( + raw.read_trace(&[0]).unwrap_err().kind(), + nmr::ReadErrorKind::UnsampledCoordinate + ); + assert_eq!( + raw.read_trace(&[3]).unwrap().samples()[0], + nmr::Complex64::new(1., 2.) + ); +} + +#[test] +fn ambiguous_processed_directories_and_truncated_payloads_are_errors() { + let temp = tempfile::tempdir().unwrap(); + for number in [1, 2] { + let path = temp.path().join("pdata").join(number.to_string()); + std::fs::create_dir_all(&path).unwrap(); + for name in ["procs", "1r"] { + std::fs::copy( + fixture(&format!("bruker-1d/pdata/1/{name}")), + path.join(name), + ) + .unwrap(); + } + } + assert!( + matches!(nmr_bridge::read(temp.path(), &mut ExecutionContext::default()), Err(plotx_io::IoError::Nmr(error)) + if error.kind() == nmr::ReadErrorKind::Ambiguous) + ); + let path = temp.path().join("pdata/1/1r"); + std::fs::write(&path, [0u8; 3]).unwrap(); + assert!(nmr_bridge::read(&path, &mut ExecutionContext::default()).is_err()); +} + +#[test] +fn raw_snapshot_reprocesses_without_vendor_files_and_preserves_processed_history() { + let temp = tempfile::tempdir().unwrap(); + for name in ["acqus", "fid"] { + std::fs::copy( + fixture(&format!("bruker-1d/{name}")), + temp.path().join(name), + ) + .unwrap(); + } + let input = nmr_bridge::read(temp.path(), &mut ExecutionContext::default()).unwrap(); + let plan = nmr::processing::ProcessingPlan::new(vec![ + nmr::processing::ProcessingOperation::FourierTransform { + axis: 0, + transform: nmr::processing::FourierTransform::default(), + }, + ]) + .unwrap(); + let expected = plan.apply(&input).unwrap(); + let limits = nmr::snapshot::SnapshotLimits::default(); + let mut bytes = Vec::new(); + nmr_bridge::snapshot::write(&input, &mut bytes, limits, &mut ExecutionContext::default()) + .unwrap(); + temp.close().unwrap(); + let restored = nmr_bridge::snapshot::read( + &mut bytes.as_slice(), + limits, + &mut ExecutionContext::default(), + ) + .unwrap(); + let processed = plan.apply(&restored).unwrap(); + assert_eq!(processed.canonical_digests(), expected.canonical_digests()); + assert_eq!( + processed.as_dense_processed(), + expected.as_dense_processed() + ); + bytes.clear(); + nmr_bridge::snapshot::write( + &processed, + &mut bytes, + limits, + &mut ExecutionContext::default(), + ) + .unwrap(); + let archived = nmr_bridge::snapshot::read( + &mut bytes.as_slice(), + limits, + &mut ExecutionContext::default(), + ) + .unwrap(); + let history = archived + .as_processed() + .unwrap() + .provenance() + .history() + .unwrap(); + let replay = history + .replay_raw( + restored.as_raw().unwrap(), + nmr::processing::ProcessingOptions::new(), + ) + .unwrap(); + assert_eq!(replay.data(), processed.as_dense_processed().unwrap()); +} + +#[test] +fn supported_jcamp_versions_preserve_ppm_coordinates_and_scaling() { + let dir = tempfile::tempdir().unwrap(); + let text = std::fs::read_to_string(fixture("jcamp-ppm.dx")).unwrap(); + for version in ["5.00", "5.01"] { + let path = dir.path().join(format!("v{version}.dx")); + std::fs::write( + &path, + text.replace("##JCAMP-DX=5.00", &format!("##JCAMP-DX={version}")), + ) + .unwrap(); + let source = nmr_bridge::read(&path, &mut ExecutionContext::default()).unwrap(); + let processed = source.as_processed().unwrap(); + assert_eq!( + processed.descriptor().axes()[0] + .coordinate_iter() + .unwrap() + .collect::>(), + [4.0, 3.0, 2.0, 1.0] + ); + assert_eq!(processed.data().samples(), [2.0, 4.0, 6.0, 8.0]); + } +} diff --git a/crates/io/tests/nmr_group_delay.rs b/crates/io/tests/nmr_group_delay.rs new file mode 100644 index 00000000..67db1b04 --- /dev/null +++ b/crates/io/tests/nmr_group_delay.rs @@ -0,0 +1,105 @@ +//! Original valid vendor-delay test inputs through the unified public reader. +use nmr::raw::GroupDelayState; +use plotx_io::nmr_bridge; +use std::{path::PathBuf, sync::Arc}; + +fn fixture(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/nmr") + .join(name) +} + +fn delay(input: &nmr::Dataset) -> f64 { + match input.as_raw().unwrap().descriptor().axes()[0].group_delay() { + GroupDelayState::Pending(value) => value.delay_points(), + other => panic!("expected known delay, got {other:?}"), + } +} + +#[test] +fn group_delay_prefers_the_original_explicit_grpdly() { + let dir = tempfile::tempdir().unwrap(); + std::fs::copy(fixture("bruker-1d/fid"), dir.path().join("fid")).unwrap(); + let text = std::fs::read_to_string(fixture("bruker-1d/acqus")) + .unwrap() + .replace( + "##$GRPDLY= 0", + "##$GRPDLY= 67.98\n##$DSPFVS= 21\n##$DECIM= 2080", + ); + std::fs::write(dir.path().join("acqus"), text).unwrap(); + let input = nmr_bridge::read(dir.path(), &mut nmr::ExecutionContext::default()).unwrap(); + assert!((delay(&input) - 67.98).abs() < 1e-9); +} + +#[test] +fn group_delay_falls_back_to_table() { + let dir = tempfile::tempdir().unwrap(); + std::fs::copy(fixture("bruker-1d/fid"), dir.path().join("fid")).unwrap(); + let text = std::fs::read_to_string(fixture("bruker-1d/acqus")) + .unwrap() + .replace("##$GRPDLY= 0", "##$GRPDLY= -1\n##$DSPFVS= 12\n##$DECIM= 16"); + std::fs::write(dir.path().join("acqus"), text).unwrap(); + let input = nmr_bridge::read(dir.path(), &mut nmr::ExecutionContext::default()).unwrap(); + // Retain the original Bruker parser test's input and numerical contract. + // The reader owns hardware lookup; PlotX must not implement a second table. + let actual = input.as_raw().unwrap().descriptor().axes()[0].group_delay(); + assert!( + matches!(actual, GroupDelayState::Pending(value) if (value.delay_points() - 71.625).abs() < 1e-9), + "GRPDLY=-1 / DSPFVS=12 / DECIM=16 must resolve to 71.625 points; got {actual:?}" + ); +} + +fn jeol_filter(parameters: &[(&str, &str)]) -> Arc { + let original = std::fs::read(fixture("jeol-complex.jdf")).unwrap(); + let old_start = u32::from_be_bytes(original[1284..1288].try_into().unwrap()) as usize; + let parameter_bytes = 16 + 64 * parameters.len(); + let data_start = 1360 + parameter_bytes; + let mut bytes = vec![0; data_start]; + bytes[..1360].copy_from_slice(&original[..1360]); + bytes[1212..1216].copy_from_slice(&1360u32.to_be_bytes()); + bytes[1216..1220].copy_from_slice(&(parameter_bytes as u32).to_be_bytes()); + bytes[1284..1288].copy_from_slice(&(data_start as u32).to_be_bytes()); + for (offset, value) in [ + (1360, 64), + (1364, 0), + (1368, parameters.len()), + (1372, parameter_bytes), + ] { + bytes[offset..offset + 4].copy_from_slice(&(value as u32).to_le_bytes()); + } + for (index, (name, value)) in parameters.iter().enumerate() { + let start = 1376 + 64 * index; + bytes[start + 16..start + 16 + value.len()].copy_from_slice(value.as_bytes()); + bytes[start + 36..start + 36 + name.len()].copy_from_slice(name.as_bytes()); + } + bytes.extend_from_slice(&original[old_start..]); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("filter.jdf"); + std::fs::write(&path, bytes).unwrap(); + nmr_bridge::read(&path, &mut nmr::ExecutionContext::default()).unwrap() +} + +#[test] +fn group_delay_from_the_original_jeol_fir_cascades() { + for (orders, factors, expected) in [ + ("2 41 74", "6 2", 239.0 / 12.0), + ("2 15 73", "2 2", 19.75), + ] { + let input = jeol_filter(&[ + ("digital_filter", "TRUE"), + ("orders", orders), + ("factors", factors), + ]); + let g = delay(&input); + assert!((g - expected).abs() < 1e-9, "got {g}"); + } + let disabled = jeol_filter(&[ + ("digital_filter", "FALSE"), + ("orders", "2 41 74"), + ("factors", "6 2"), + ]); + assert_eq!( + disabled.as_raw().unwrap().descriptor().axes()[0].group_delay(), + &GroupDelayState::NotApplicable + ); +} diff --git a/crates/io/tests/nmr_sampling.rs b/crates/io/tests/nmr_sampling.rs new file mode 100644 index 00000000..e11769e4 --- /dev/null +++ b/crates/io/tests/nmr_sampling.rs @@ -0,0 +1,149 @@ +use nmr::ExecutionContext; +use plotx_io::{ + nmr_bridge, + nmr_sampling::{self, IndexBase, SamplingDeclaration}, +}; +use std::path::{Path, PathBuf}; + +fn fixture(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/nmr") + .join(name) +} + +fn declaration(grid: usize, rows: &[usize]) -> SamplingDeclaration { + SamplingDeclaration { + assertion_id: "plotx-test-schedule".into(), + source: "synthetic user table".into(), + grid_shape: vec![grid], + coordinates: rows.iter().map(|&row| vec![row]).collect(), + index_base: IndexBase::One, + component_counts: vec![2], + } +} + +fn read(path: &Path, declaration: SamplingDeclaration) -> std::sync::Arc { + nmr_sampling::read(path, declaration, &mut ExecutionContext::default()).unwrap() +} + +#[test] +fn bruker_declaration_checks_vendor_evidence_and_preserves_duplicate_observations_offline() { + let dir = tempfile::tempdir().unwrap(); + for name in ["ser", "acqus", "acqu2s"] { + std::fs::copy( + fixture(&format!("bruker-nus/{name}")), + dir.path().join(name), + ) + .unwrap(); + } + assert!(nmr_bridge::load(dir.path()).is_err()); + let declared = declaration(4, &[4, 2]); + let input = read(dir.path(), declared.clone()); + let raw = input.as_raw().unwrap(); + let schedule = raw.sampling_schedule().unwrap(); + assert_eq!( + schedule.declaration(), + Some(&declared.clone().into_native().unwrap()) + ); + assert_eq!( + schedule + .coordinates() + .iter() + .map(|c| c.as_slice()) + .collect::>(), + [&[3], &[1]] + ); + assert!(!dir.path().join("nuslist").exists()); + for name in ["ser", "acqus", "acqu2s"] { + assert_eq!( + std::fs::read(dir.path().join(name)).unwrap(), + std::fs::read(fixture(&format!("bruker-nus/{name}"))).unwrap() + ); + } + let mut bad_lanes = declared.clone(); + bad_lanes.component_counts = vec![1]; + for invalid in [ + declaration(5, &[4, 2]), + declaration(4, &[5, 2]), + declaration(4, &[0, 2]), + declaration(4, &[4]), + bad_lanes, + ] { + assert!(nmr_sampling::load(dir.path(), invalid).is_err()); + } + let repeated = read(dir.path(), declaration(4, &[2, 2])); + let traces = repeated.as_raw().unwrap().data().sparse_traces().unwrap(); + assert_eq!(traces[0].coordinate(), traces[1].coordinate()); + assert_ne!(traces[0].samples(), traces[1].samples()); + let mut bytes = Vec::new(); + nmr_bridge::snapshot::write( + &repeated, + &mut bytes, + Default::default(), + &mut ExecutionContext::default(), + ) + .unwrap(); + dir.close().unwrap(); + let restored = nmr_bridge::snapshot::read( + &mut bytes.as_slice(), + Default::default(), + &mut ExecutionContext::default(), + ) + .unwrap(); + assert_eq!(restored.canonical_digests(), repeated.canonical_digests()); + assert_eq!( + restored.as_raw().unwrap().sampling_schedule(), + repeated.as_raw().unwrap().sampling_schedule() + ); + assert!(nmr_sampling::load(&fixture("bruker-nus"), declared).is_ok()); + assert!(nmr_sampling::load(&fixture("bruker-nus"), declaration(4, &[2, 4])).is_err()); + assert!(nmr_sampling::load(&fixture("bruker-1d/pdata/1/1r"), declaration(4, &[4, 2])).is_err()); +} + +#[test] +fn jeol_declaration_reaches_checked_reader_with_four_components_and_no_source_edits() { + let path = fixture("jeol-nus-missing.jdf"); + let before = std::fs::read(&path).unwrap(); + assert!(nmr_bridge::load(&path).is_err()); + let input = read(&path, declaration(8, &[1, 2, 2, 8])); + assert!( + !nmr_bridge::warnings(&input) + .iter() + .any(|warning| warning.message.contains("ExperimentalVendorSemantics")) + ); + let raw = input.as_raw().unwrap(); + assert_eq!(raw.descriptor().logical_shape(), [8, 3]); + let traces = raw.data().sparse_traces().unwrap(); + assert_eq!(traces[1].coordinate(), traces[2].coordinate()); + assert_ne!(traces[1].samples(), traces[2].samples()); + // The F1 imaginary lane uses the opposite quadrature orientation. + assert_eq!( + traces[0].samples(), + &[ + nmr::Complex64::new(0.0, -100.0), + nmr::Complex64::new(1.0, -101.0), + nmr::Complex64::new(2.0, -102.0), + nmr::Complex64::new(-200.0, 300.0), + nmr::Complex64::new(-201.0, 301.0), + nmr::Complex64::new(-202.0, 302.0), + ] + ); + assert_eq!(std::fs::read(path).unwrap(), before); +} + +#[test] +fn declaration_json_rejects_unknown_fields_and_cancelled_read() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("schedule.json"); + let declared = declaration(4, &[4, 2]); + std::fs::write(&path, serde_json::to_vec(&declared).unwrap()).unwrap(); + assert!(nmr_sampling::load_with_declaration_file(&fixture("bruker-nus"), &path).is_ok()); + let mut json = serde_json::to_value(&declared).unwrap(); + json["index_bsae"] = "one".into(); + std::fs::write(&path, serde_json::to_vec(&json).unwrap()).unwrap(); + assert!(nmr_sampling::read_declaration(&path).is_err()); + let token = nmr::CancellationToken::new(); + token.cancel(); + let mut context = ExecutionContext::default().with_cancellation(token); + assert!(nmr_sampling::read(&fixture("bruker-nus"), declared, &mut context).is_err()); +} diff --git a/crates/io/tests/nmr_view.rs b/crates/io/tests/nmr_view.rs new file mode 100644 index 00000000..c52f4828 --- /dev/null +++ b/crates/io/tests/nmr_view.rs @@ -0,0 +1,193 @@ +use nmr::{ExecutionContext, axis::AxisUnit}; +use plotx_io::{nmr_bridge, nmr_view::NmrSource}; +use std::path::PathBuf; + +fn fixture(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/nmr") + .join(name) +} + +#[test] +fn ppm_view_preserves_missing_metadata_and_scalar_components() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("spectrum.dx"); + let text = std::fs::read_to_string(fixture("jcamp-ppm.dx")).unwrap(); + let text = text + .lines() + .filter(|line| !line.starts_with("##.OBSERVE")) + .collect::>() + .join("\n"); + std::fs::write(&path, text).unwrap(); + let source = + NmrSource::new(nmr_bridge::read(&path, &mut ExecutionContext::default()).unwrap()).unwrap(); + assert_eq!(source.axes()[0].observe_frequency_mhz(), None); + assert_eq!(source.axes()[0].unit, Some(AxisUnit::Ppm)); + assert_eq!( + source.axes()[0].coordinate_values().unwrap(), + [4., 3., 2., 1.] + ); + assert!(!source.has_imaginary(0)); + assert_eq!( + source + .trace() + .unwrap() + .iter() + .map(|v| v.re) + .collect::>(), + [2., 4., 6., 8.] + ); + assert!(source.craft_fid().is_err()); +} + +#[test] +fn craft_view_requires_delay_evidence_and_keeps_explicit_zero() { + let dir = tempfile::tempdir().unwrap(); + let text = std::fs::read_to_string(fixture("bruker-1d/acqus")).unwrap(); + std::fs::copy(fixture("bruker-1d/fid"), dir.path().join("fid")).unwrap(); + std::fs::write(dir.path().join("acqus"), &text).unwrap(); + let source = + NmrSource::new(nmr_bridge::read(dir.path(), &mut ExecutionContext::default()).unwrap()) + .unwrap(); + let fid = source.craft_fid().unwrap(); + assert_eq!(fid.group_delay, 0.0); + assert_eq!(fid.observe_freq_mhz, 400.0); + assert_eq!(fid.points, source.trace().unwrap()); + let text = text + .lines() + .filter(|line| !line.starts_with("##$GRPDLY")) + .collect::>() + .join("\n"); + std::fs::write(dir.path().join("acqus"), text).unwrap(); + let source = + NmrSource::new(nmr_bridge::read(dir.path(), &mut ExecutionContext::default()).unwrap()) + .unwrap(); + assert!( + source + .craft_fid() + .unwrap_err() + .to_string() + .contains("delay evidence") + ); +} + +#[test] +fn all_zero_imaginary_channel_is_still_present() { + let source = NmrSource::new( + nmr_bridge::read( + &fixture("jeol-complex.jdf"), + &mut ExecutionContext::default(), + ) + .unwrap(), + ) + .unwrap(); + assert!(source.has_imaginary(0)); + assert!(source.trace().unwrap().iter().all(|value| value.im == 0.0)); +} + +#[test] +fn reference_frequency_survives_binning_without_becoming_observe_frequency() { + use nmr::axis::{AxisCoordinates, AxisDomain, AxisRole, FrequencyEvidence}; + use nmr::processed::{ + ComponentBasis, ProcessedAxis, ProcessedDataset, ProcessedOrigin, ProcessedProvenance, + }; + use nmr::processing::{ + BinAggregation, FrequencyFrame, ProcessingOperation as Op, ProcessingPlan, ReferenceSource, + SpectrumOperation, + }; + let axis = ProcessedAxis::new( + AxisRole::Signal, + AxisDomain::Frequency, + Some(AxisUnit::Hertz), + 8, + AxisCoordinates::Uniform { + start: -4.0, + step: 1.0, + }, + ComponentBasis::Cartesian, + ) + .unwrap() + .with_frequency_evidence(Some(FrequencyEvidence::new(Some(500.005), None).unwrap())) + .unwrap(); + let input = ProcessedDataset::from_complex_trace( + axis, + vec![nmr::Complex64::new(1.0, 2.0); 8], + ProcessedProvenance::new(ProcessedOrigin::Unknown, vec![]).unwrap(), + ) + .unwrap(); + let output = ProcessingPlan::new(vec![ + Op::ResolveFrequencyFrame { + axis: 0, + frame: FrequencyFrame::Ppm(ReferenceSource::Explicit( + nmr::raw::ChemicalShiftReference::user_constructed(10.0, 500.0).unwrap(), + )), + }, + Op::Spectrum { + axis: 0, + operation: SpectrumOperation::Bin { + width: 0.004, + aggregation: BinAggregation::Mean, + }, + }, + ]) + .unwrap() + .apply(&input.into()) + .unwrap(); + let source = NmrSource::new(std::sync::Arc::new(output)).unwrap(); + assert_eq!(source.axes()[0].observe_frequency_mhz(), Some(500.005)); + assert_eq!(source.reference_frequency_mhz(0), Some(500.0)); + assert_eq!(source.len(), 4); + let coordinates = source.axes()[0].coordinate_values().unwrap(); + assert!((coordinates[1] - coordinates[0] - 0.004).abs() < 1e-12); + let mut bytes = Vec::new(); + nmr_bridge::snapshot::write( + source.dataset(), + &mut bytes, + Default::default(), + &mut ExecutionContext::default(), + ) + .unwrap(); + let restored = NmrSource::new( + nmr_bridge::snapshot::read( + &mut bytes.as_slice(), + Default::default(), + &mut ExecutionContext::default(), + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(restored.reference_frequency_mhz(0), Some(500.0)); + assert_eq!(restored.axes()[0].observe_frequency_mhz(), Some(500.005)); + assert_eq!(restored.axes()[0].coordinate_values().unwrap(), coordinates); +} + +#[test] +fn processed_sf_is_reference_evidence_without_observe_carrier_or_filter_claims() { + let source = NmrSource::new( + nmr_bridge::read( + &fixture("bruker-1d/pdata/1/1r"), + &mut ExecutionContext::default(), + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(source.reference_frequency_mhz(0), Some(400.0)); + assert_eq!(source.axes()[0].observe_frequency_mhz(), None); + assert_eq!(source.reference_frequency_mhz(1), None); + let evidence = source + .dataset() + .as_processed() + .unwrap() + .axis_evidence(0) + .unwrap(); + assert!(evidence.reference_evidence().is_some()); + assert!(evidence.chemical_shift_reference().is_none()); + assert_eq!( + evidence.group_delay(), + nmr::processed::ProcessedGroupDelay::Unknown + ); + assert_eq!( + source.axes()[0].coordinate_values().unwrap(), + [10.0, 7.5, 5.0, 2.5] + ); +} diff --git a/crates/processing/Cargo.toml b/crates/processing/Cargo.toml index fb9f0fef..f6f1b9b0 100644 --- a/crates/processing/Cargo.toml +++ b/crates/processing/Cargo.toml @@ -11,9 +11,9 @@ name = "plotx_processing" path = "src/lib.rs" [dependencies] +nmr.workspace = true plotx-io.workspace = true plotx-analysis.workspace = true num-complex.workspace = true -rustfft.workspace = true thiserror.workspace = true serde.workspace = true diff --git a/crates/processing/src/arithmetic.rs b/crates/processing/src/arithmetic.rs index 830cc815..b7f424b7 100644 --- a/crates/processing/src/arithmetic.rs +++ b/crates/processing/src/arithmetic.rs @@ -1,9 +1,10 @@ -//! Spectrum arithmetic: dataset ± dataset (with a scale on the second operand) -//! and constant scale/offset, producing a new standalone frequency-domain trace. - +//! Spectrum arithmetic executes in nmr and retains both parents for replay. use crate::Spectrum; -use num_complex::Complex64; -use std::fmt; +use nmr::processing::{ + LinearCombination, ProcessingOperation, ProcessingOptions, ProcessingPlan, SpectrumOperation, +}; +use plotx_io::nmr_view::NmrSource; +use std::sync::Arc; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SpectrumBinaryOp { @@ -20,124 +21,115 @@ impl SpectrumBinaryOp { } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, thiserror::Error)] pub enum ArithmeticError { - NucleusMismatch { a: String, b: String }, - EmptyOperand, -} - -impl fmt::Display for ArithmeticError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::NucleusMismatch { a, b } => { - write!( - formatter, - "Nuclei differ ({a} vs {b}); pick two spectra of the same nucleus." - ) - } - Self::EmptyOperand => formatter.write_str("Both spectra need at least one point."), - } - } + #[error(transparent)] + Library(#[from] nmr::processing::ProcessingError), + #[error(transparent)] + View(#[from] plotx_io::IoError), } -impl std::error::Error for ArithmeticError {} - pub fn same_grid(a: &Spectrum, b: &Spectrum) -> bool { - a.ppm.len() == b.ppm.len() + a.unit == b.unit + && a.ppm.len() == b.ppm.len() && a.ppm .iter() .zip(&b.ppm) .all(|(x, y)| (x - y).abs() <= 1e-9 * x.abs().max(y.abs()).max(1.0)) } -/// `a op k·b` on `a`'s axis. `b` is linearly interpolated onto `a`'s grid; -/// points of `a` outside `b`'s range use `b = 0`. +pub fn validate_combination(a: &NmrSource, b: &NmrSource) -> Result<(), ArithmeticError> { + LinearCombination::new(1.0)?.prepare(a.dataset(), b.dataset(), ProcessingOptions::default())?; + Ok(()) +} + pub fn combine_spectra( - a: &Spectrum, - b: &Spectrum, + a: &NmrSource, + b: &NmrSource, op: SpectrumBinaryOp, k: f64, -) -> Result { - if a.is_empty() || b.is_empty() { - return Err(ArithmeticError::EmptyOperand); - } - if a.nucleus.trim() != b.nucleus.trim() { - return Err(ArithmeticError::NucleusMismatch { - a: a.nucleus.clone(), - b: b.nucleus.clone(), - }); - } +) -> Result { let scale = match op { SpectrumBinaryOp::Add => k, SpectrumBinaryOp::Subtract => -k, }; - let b_on_a = if same_grid(a, b) { - b.values.clone() - } else { - resample_linear(&b.ppm, &b.values, &a.ppm) - }; - let mut out = a.clone(); - for (v, w) in out.values.iter_mut().zip(&b_on_a) { - *v += scale * w; - } - Ok(out) -} - -/// `scale·a + offset` (the offset raises the real channel only). -pub fn scale_offset_spectrum(a: &Spectrum, scale: f64, offset: f64) -> Spectrum { - let mut out = a.clone(); - for v in &mut out.values { - *v = scale * *v + Complex64::new(offset, 0.0); - } - out + let output = LinearCombination::new(scale)? + .prepare(a.dataset(), b.dataset(), ProcessingOptions::default())? + .execute_with_context(&mut nmr::ExecutionContext::default())?; + Ok(NmrSource::new(Arc::new(output))?) } -fn resample_linear(src_ppm: &[f64], src: &[Complex64], dst_ppm: &[f64]) -> Vec { - let ascending = src_ppm.first() <= src_ppm.last(); - let (axis, values): (Vec, Vec) = if ascending { - (src_ppm.to_vec(), src.to_vec()) - } else { - ( - src_ppm.iter().rev().copied().collect(), - src.iter().rev().copied().collect(), - ) - }; - dst_ppm - .iter() - .map(|&x| { - let (lo, hi) = (axis[0], axis[axis.len() - 1]); - if x < lo || x > hi { - return Complex64::new(0.0, 0.0); - } - let j = axis.partition_point(|&p| p < x); - if j == 0 { - return values[0]; - } - if j >= axis.len() { - return values[values.len() - 1]; - } - let (x0, x1) = (axis[j - 1], axis[j]); - let span = x1 - x0; - if span <= 0.0 { - return values[j]; - } - let t = (x - x0) / span; - values[j - 1] * (1.0 - t) + values[j] * t - }) - .collect() +pub fn scale_offset_spectrum( + a: &NmrSource, + scale: f64, + offset: f64, +) -> Result { + let output = ProcessingPlan::new(vec![ProcessingOperation::Spectrum { + axis: 0, + operation: SpectrumOperation::Affine { + scale, + real_offset: offset, + }, + }])? + .apply(a.dataset())?; + Ok(NmrSource::new(Arc::new(output))?) } #[cfg(test)] mod tests { use super::*; + use num_complex::Complex64; + + fn input(spec: &Spectrum) -> Result { + use nmr::{axis::*, processed::*}; + let fail = + |error: &dyn std::fmt::Display| plotx_io::IoError::NmrConversion(error.to_string()); + let axis = ProcessedAxis::new( + AxisRole::Signal, + AxisDomain::Frequency, + Some(spec.unit), + spec.len(), + AxisCoordinates::Explicit(spec.ppm.clone()), + ComponentBasis::Cartesian, + ) + .map_err(|e| fail(&e))? + .with_nucleus(Some(spec.nucleus.clone())) + .map_err(|e| fail(&e))?; + let data = ProcessedDataset::from_complex_trace( + axis, + spec.values.clone(), + ProcessedProvenance::new(ProcessedOrigin::Unknown, vec![]).map_err(|e| fail(&e))?, + ) + .map_err(|e| fail(&e))?; + Ok(NmrSource::new(Arc::new(data.into()))?) + } + fn view(source: NmrSource) -> Spectrum { + crate::nmr_execution::view_1d(&source) + .unwrap() + .as_frequency() + .unwrap() + .clone() + } + fn combine_spectra( + a: &Spectrum, + b: &Spectrum, + op: SpectrumBinaryOp, + k: f64, + ) -> Result { + super::combine_spectra(&input(a)?, &input(b)?, op, k).map(view) + } + fn scale_offset_spectrum(a: &Spectrum, scale: f64, offset: f64) -> Spectrum { + view(super::scale_offset_spectrum(&input(a).unwrap(), scale, offset).unwrap()) + } fn spec(ppm: Vec, re: Vec, nucleus: &str) -> Spectrum { let values = re.into_iter().map(|r| Complex64::new(r, 0.0)).collect(); Spectrum { ppm, values, - hz_per_point: 1.0, - observe_freq_mhz: 400.0, + unit: nmr::axis::AxisUnit::Ppm, + hz_per_point: Some(1.0), + observe_freq_mhz: Some(400.0), nucleus: nucleus.into(), } } @@ -194,17 +186,14 @@ mod tests { let a = spec(vec![0.0, 1.0], vec![1.0, 1.0], "1H"); let b = spec(vec![0.0, 1.0], vec![1.0, 1.0], "13C"); let err = combine_spectra(&a, &b, SpectrumBinaryOp::Add, 1.0).unwrap_err(); - assert!(matches!(err, ArithmeticError::NucleusMismatch { .. })); + assert!(err.to_string().contains("incompatible")); } #[test] fn empty_operand_is_rejected() { let a = spec(vec![], vec![], "1H"); let b = spec(vec![0.0], vec![1.0], "1H"); - assert!(matches!( - combine_spectra(&a, &b, SpectrumBinaryOp::Add, 1.0), - Err(ArithmeticError::EmptyOperand) - )); + assert!(combine_spectra(&a, &b, SpectrumBinaryOp::Add, 1.0).is_err()); } #[test] diff --git a/crates/processing/src/autophase.rs b/crates/processing/src/autophase.rs deleted file mode 100644 index 9a285c69..00000000 --- a/crates/processing/src/autophase.rs +++ /dev/null @@ -1,478 +0,0 @@ -//! Automatic phase determination beyond the single tallest-peak rule. - -use crate::AutoPhaseMethod; -use crate::phase; -use num_complex::Complex64; -use plotx_analysis::peaks::{DetectParams, detect_peaks, estimate_noise}; -use std::f64::consts::{PI, TAU}; - -/// Cap on the point count fed to the iterative optimizers. -const SEARCH_POINTS: usize = 1024; - -/// Weight on the negative-intensity penalty in the ACME entropy cost, applied to -/// a spectrum normalized to unit peak magnitude. Large enough to break the 180° -/// sign ambiguity the derivative entropy cannot see, following Chen et al. -const ACME_PENALTY: f64 = 1000.0; - -pub fn compute(values: &[Complex64], method: AutoPhaseMethod) -> (f64, f64, f64) { - match method { - AutoPhaseMethod::RobustConsensus => robust_consensus(values), - AutoPhaseMethod::AbsorptivePeak => absorptive_peak(values), - AutoPhaseMethod::Entropy => optimized(values, acme_cost), - AutoPhaseMethod::NegativeMinimization => optimized(values, negative_cost), - AutoPhaseMethod::PeakRegression => peak_regression(values), - } -} - -/// Build candidates with deliberately different failure modes and select among -/// them using a scale-independent objective. The winning candidate is refined -/// once more against that common objective. Testing each candidate's pi-shifted -/// counterpart makes the sign decision explicit. -fn robust_consensus(values: &[Complex64]) -> (f64, f64, f64) { - let (mut dec, frac) = decimate(values, SEARCH_POINTS); - if dec.len() < 4 { - return absorptive_peak(values); - } - let scale = dec.iter().map(|c| c.norm()).fold(0.0_f64, f64::max); - if !scale.is_finite() || scale <= f64::MIN_POSITIVE { - return (0.0, 0.0, phase::peak_pivot_frac(values)); - } - for value in &mut dec { - *value /= scale; - } - let strategies = [ - absorptive_peak(values), - optimized(values, acme_cost), - optimized(values, negative_cost), - peak_regression(values), - ]; - // Every candidate is judged with its zero-order phase pinned so the tallest - // peak is absorptive, leaving `p1` (the ramp) as the only free variable. This - // resolves the isolated-peak case — where the dominant bin stays positive at - // any orientation, so entropy and negative power alone are degenerate — while - // still letting negative power rank the ramp on overlapping spectra. - let objective = |p1: f64| consensus_cost(&dec, &frac, snap_zero_order_to_peak(values, p1), p1); - let mut best_p1 = 0.0; - let mut best_cost = f64::INFINITY; - for (_, phase1, _) in strategies { - let cost = objective(phase1); - if cost < best_cost { - best_p1 = phase1; - best_cost = cost; - } - } - let p1 = pattern_search_1d(&objective, best_p1); - to_pivoted(values, snap_zero_order_to_peak(values, p1), p1) -} - -/// Zero-order phase that lands the tallest bin on the positive real axis given -/// the ramp `p1`. Referencing the consensus ramp to the peak's own argument keeps -/// a clean isolated peak exactly absorptive, which the global objective — flat to -/// a degree or so near its optimum — cannot pin on its own. -fn snap_zero_order_to_peak(values: &[Complex64], p1: f64) -> f64 { - let Some((index, peak)) = values - .iter() - .enumerate() - .max_by(|a, b| a.1.norm().total_cmp(&b.1.norm())) - else { - return 0.0; - }; - if peak.norm() <= f64::MIN_POSITIVE { - return 0.0; - } - let peak_frac = index as f64 / (values.len() - 1).max(1) as f64; - peak.arg() - p1 * peak_frac -} - -/// Normalized derivative entropy rewards sharp absorptive lines while negative -/// power resolves the remaining sign and ramp ambiguity. With the caller pinning -/// the tallest peak absorptive, negative power is what separates a correct ramp -/// (every peak upright) from a wrong one. Both terms are dimensionless, so ranking -/// is invariant under spectrum intensity scaling. -fn consensus_cost(values: &[Complex64], frac: &[f64], p0: f64, p1: f64) -> f64 { - let real = phased_real(values, frac, p0, p1); - let derivatives: Vec = real.windows(2).map(|w| (w[1] - w[0]).abs()).collect(); - let derivative_sum: f64 = derivatives.iter().sum(); - if derivative_sum <= f64::MIN_POSITIVE { - return f64::INFINITY; - } - let entropy = derivatives.iter().fold(0.0, |acc, derivative| { - let probability = derivative / derivative_sum; - if probability > 0.0 { - acc - probability * probability.ln() - } else { - acc - } - }); - let max_entropy = (derivatives.len().max(2) as f64).ln(); - entropy / max_entropy + 4.0 * negative_cost(values, frac, p0, p1) -} - -/// Zeroth-order only: rotate the tallest peak onto the positive real axis. -fn absorptive_peak(values: &[Complex64]) -> (f64, f64, f64) { - let pivot = phase::peak_pivot_frac(values); - let peak = values - .iter() - .max_by(|a, b| a.norm().total_cmp(&b.norm())) - .copied() - .unwrap_or(Complex64::new(0.0, 0.0)); - (peak.arg(), 0.0, pivot) -} - -/// Grid-seeded pattern search over `(phase0, phase1)` on a decimated, unit-peak -/// spectrum, minimizing `cost`. Falls back to the zero-order rule when there are -/// too few points to fit a ramp. -fn optimized( - values: &[Complex64], - cost: fn(&[Complex64], &[f64], f64, f64) -> f64, -) -> (f64, f64, f64) { - let (mut dec, frac) = decimate(values, SEARCH_POINTS); - if dec.len() < 4 { - return absorptive_peak(values); - } - let m = dec.iter().map(|c| c.norm()).fold(0.0_f64, f64::max); - if m <= 0.0 { - return (0.0, 0.0, phase::peak_pivot_frac(values)); - } - for c in &mut dec { - *c /= m; - } - let obj = |p0: f64, p1: f64| cost(&dec, &frac, p0, p1); - let (p0, p1) = coarse_grid(&obj); - let (p0, p1) = pattern_search(&obj, p0, p1); - to_pivoted(values, p0, p1) -} - -/// Detect peaks on the magnitude spectrum, read each one's dispersive angle, and -/// least-squares fit a phase ramp `arg = phase0 + phase1·frac` through them -/// (weighted by peak height). The classic multi-peak linear phasing; needs at -/// least two resolved peaks, else it defers to the zero-order rule. -fn peak_regression(values: &[Complex64]) -> (f64, f64, f64) { - let n = values.len(); - if n < 3 { - return absorptive_peak(values); - } - let mag: Vec = values.iter().map(|c| c.norm()).collect(); - let xs: Vec = (0..n).map(|i| i as f64).collect(); - let sigma = estimate_noise(&mag); - let params = DetectParams { - min_height: Some(6.0 * sigma), - min_prominence: 5.0 * sigma, - min_spacing: None, - max_count: Some(32), - }; - let mut peaks = detect_peaks(&xs, &mag, ¶ms); - if peaks.len() < 2 { - return absorptive_peak(values); - } - peaks.sort_by_key(|a| a.index); - - let denom = (n - 1).max(1) as f64; - // Unwrap successive peak angles so a ramp within ±π per gap fits cleanly. - let mut angles = Vec::with_capacity(peaks.len()); - let mut prev = 0.0; - for (k, p) in peaks.iter().enumerate() { - let raw = values[p.index].arg(); - let a = if k == 0 { - raw - } else { - prev + wrap_to_pi(raw - prev) - }; - angles.push(a); - prev = a; - } - - let (mut sw, mut swx, mut swy, mut swxx, mut swxy) = (0.0, 0.0, 0.0, 0.0, 0.0); - for (p, &y) in peaks.iter().zip(&angles) { - let w = p.y; - let x = p.index as f64 / denom; - sw += w; - swx += w * x; - swy += w * y; - swxx += w * x * x; - swxy += w * x * y; - } - let det = sw * swxx - swx * swx; - if det.abs() <= f64::MIN_POSITIVE { - return absorptive_peak(values); - } - let p0 = (swxx * swy - swx * swxy) / det; - let p1 = (sw * swxy - swx * swy) / det; - to_pivoted(values, p0, p1) -} - -/// ACME (Chen et al. 2002): Shannon entropy of the normalized absolute first -/// derivative of the real spectrum, plus a penalty for negative intensity. -fn acme_cost(values: &[Complex64], frac: &[f64], p0: f64, p1: f64) -> f64 { - let re: Vec = phased_real(values, frac, p0, p1); - let mut deriv: Vec = re.windows(2).map(|w| (w[1] - w[0]).abs()).collect(); - let sum: f64 = deriv.iter().sum(); - if sum <= 0.0 { - return f64::INFINITY; - } - let mut entropy = 0.0; - for d in &mut deriv { - let p = *d / sum; - if p > 0.0 { - entropy -= p * p.ln(); - } - } - let penalty: f64 = re.iter().filter(|&&y| y < 0.0).map(|y| y * y).sum(); - entropy + ACME_PENALTY * penalty -} - -/// Fraction of the real spectrum's power carried by its negative parts; zero when -/// every point is non-negative (a purely absorptive, upright spectrum). -fn negative_cost(values: &[Complex64], frac: &[f64], p0: f64, p1: f64) -> f64 { - let mut neg = 0.0; - let mut total = 0.0; - for (c, &fr) in values.iter().zip(frac) { - let (s, co) = (p0 + p1 * fr).sin_cos(); - let r = c.re * co + c.im * s; - total += r * r; - if r < 0.0 { - neg += r * r; - } - } - if total <= 0.0 { - f64::INFINITY - } else { - neg / total - } -} - -fn phased_real(values: &[Complex64], frac: &[f64], p0: f64, p1: f64) -> Vec { - values - .iter() - .zip(frac) - .map(|(c, &fr)| { - let (s, co) = (p0 + p1 * fr).sin_cos(); - c.re * co + c.im * s - }) - .collect() -} - -/// Coarse scan over `phase0 ∈ [-π, π)` and `phase1 ∈ [-2π, 2π]` for a robust -/// starting point that dodges the local minima the refinement would fall into. -fn coarse_grid(obj: &impl Fn(f64, f64) -> f64) -> (f64, f64) { - const N0: usize = 48; - const N1: usize = 25; - let mut best = (0.0, 0.0); - let mut best_cost = f64::INFINITY; - for i in 0..N0 { - let p0 = -PI + TAU * i as f64 / N0 as f64; - for j in 0..N1 { - let p1 = -2.0 * PI + 4.0 * PI * j as f64 / (N1 - 1) as f64; - let c = obj(p0, p1); - if c < best_cost { - best_cost = c; - best = (p0, p1); - } - } - } - best -} - -/// Hooke–Jeeves pattern search: probe ±step on each axis, step toward any -/// improvement, halve the step when stuck. Refines the grid seed to < 0.01°. -fn pattern_search(obj: &impl Fn(f64, f64) -> f64, mut p0: f64, mut p1: f64) -> (f64, f64) { - let mut step = PI / 18.0; - let mut best = obj(p0, p1); - for _ in 0..80 { - let mut improved = false; - for &(d0, d1) in &[(step, 0.0), (-step, 0.0), (0.0, step), (0.0, -step)] { - let c = obj(p0 + d0, p1 + d1); - if c < best { - best = c; - p0 += d0; - p1 += d1; - improved = true; - } - } - if !improved { - step *= 0.5; - if step < 1e-4 { - break; - } - } - } - (p0, p1) -} - -/// One-dimensional Hooke–Jeeves search over the ramp `p1` alone, used when the -/// zero-order phase is a pinned function of `p1`. Same probe-and-halve schedule. -fn pattern_search_1d(obj: &impl Fn(f64) -> f64, mut p1: f64) -> f64 { - let mut step = PI / 18.0; - let mut best = obj(p1); - for _ in 0..80 { - let mut improved = false; - for &delta in &[step, -step] { - let cost = obj(p1 + delta); - if cost < best { - best = cost; - p1 += delta; - improved = true; - } - } - if !improved { - step *= 0.5; - if step < 1e-4 { - break; - } - } - } - p1 -} - -/// Re-express a pivot-at-origin phase `φ(frac) = p0 + p1·frac` about the tallest -/// peak, so the returned pivot matches the on-plot handle of the other methods. -fn to_pivoted(values: &[Complex64], p0: f64, p1: f64) -> (f64, f64, f64) { - let pivot = phase::peak_pivot_frac(values); - (p0 + p1 * pivot, p1, pivot) -} - -/// Downsample to at most `max` points by max-magnitude pooling: each stride-wide -/// block contributes its tallest point. Plain stride sampling would step over the -/// narrow peaks of a large spectrum (a 160k-point spectrum decimates with stride -/// 160) and feed the optimizer mostly noise, so phasing would minimize the entropy -/// of noise. Keeping each block's peak preserves the lineshape the cost functions -/// need while holding the working length bounded. -fn decimate(values: &[Complex64], max: usize) -> (Vec, Vec) { - let n = values.len(); - if n == 0 { - return (Vec::new(), Vec::new()); - } - let denom = (n - 1).max(1) as f64; - let stride = n.div_ceil(max).max(1); - let mut vals = Vec::new(); - let mut fracs = Vec::new(); - let mut i = 0; - while i < n { - let end = (i + stride).min(n); - let j = (i..end) - .max_by(|&a, &b| values[a].norm().total_cmp(&values[b].norm())) - .unwrap_or(i); - vals.push(values[j]); - fracs.push(j as f64 / denom); - i = end; - } - (vals, fracs) -} - -fn wrap_to_pi(mut a: f64) -> f64 { - while a > PI { - a -= TAU; - } - while a < -PI { - a += TAU; - } - a -} - -#[cfg(test)] -mod tests { - use super::*; - - fn lorentzian(n: usize, center: usize, width: f64) -> Vec { - (0..n) - .map(|i| { - let d = (i as f64 - center as f64) / width; - // Absorption + i·dispersion of a Lorentzian. - Complex64::new(1.0 / (1.0 + d * d), -d / (1.0 + d * d)) - }) - .collect() - } - - fn scramble(values: &[Complex64], p0: f64, p1: f64) -> Vec { - let denom = (values.len() - 1).max(1) as f64; - values - .iter() - .enumerate() - .map(|(i, c)| { - let phi = p0 + p1 * (i as f64 / denom); - c * Complex64::from_polar(1.0, phi) - }) - .collect() - } - - fn apply(values: &[Complex64], p: (f64, f64, f64)) -> Vec { - let denom = (values.len() - 1).max(1) as f64; - values - .iter() - .enumerate() - .map(|(i, c)| { - let phi = p.0 + p.1 * (i as f64 / denom - p.2); - (c * Complex64::from_polar(1.0, -phi)).re - }) - .collect() - } - - fn upright(re: &[f64]) -> bool { - let (imin, &min) = re - .iter() - .enumerate() - .min_by(|a, b| a.1.total_cmp(b.1)) - .unwrap(); - let max = re.iter().cloned().fold(f64::MIN, f64::max); - // Absorptive peak dominates; no deep negative lobe. - max > 0.5 && min > -0.15 * max && imin != re.len() / 2 - } - - #[test] - fn entropy_recovers_scrambled_phase() { - let clean = lorentzian(512, 200, 4.0); - let bad = scramble(&clean, 1.1, 0.7); - let p = compute(&bad, AutoPhaseMethod::Entropy); - assert!(upright(&apply(&bad, p))); - } - - #[test] - fn negative_minimization_recovers_scrambled_phase() { - let clean = lorentzian(512, 300, 5.0); - let bad = scramble(&clean, -0.9, 0.5); - let p = compute(&bad, AutoPhaseMethod::NegativeMinimization); - assert!(upright(&apply(&bad, p))); - } - - #[test] - fn peak_regression_fits_two_peaks() { - let mut clean = lorentzian(1024, 250, 4.0); - for (i, c) in lorentzian(1024, 750, 4.0).into_iter().enumerate() { - clean[i] += c; - } - let bad = scramble(&clean, 0.4, 1.2); - let p = compute(&bad, AutoPhaseMethod::PeakRegression); - assert!(upright(&apply(&bad, p))); - } - - #[test] - fn methods_are_stable_on_degenerate_input() { - for m in [ - AutoPhaseMethod::RobustConsensus, - AutoPhaseMethod::Entropy, - AutoPhaseMethod::NegativeMinimization, - AutoPhaseMethod::PeakRegression, - ] { - let (p0, p1, piv) = compute(&[], m); - assert!(p0.is_finite() && p1.is_finite() && piv.is_finite()); - } - } - - #[test] - fn robust_consensus_handles_scaled_overlapping_peaks() { - let mut clean = lorentzian(768, 330, 6.0); - for (i, value) in lorentzian(768, 342, 8.0).into_iter().enumerate() { - clean[i] += value * 0.65; - } - let bad: Vec<_> = scramble(&clean, -1.2, 1.4) - .into_iter() - .map(|value| value * 2.5e5) - .collect(); - let p = compute(&bad, AutoPhaseMethod::RobustConsensus); - let corrected = apply(&bad, p); - let max = corrected.iter().copied().fold(f64::NEG_INFINITY, f64::max); - let min = corrected.iter().copied().fold(f64::INFINITY, f64::min); - assert!(max > 1.0e5); - assert!(min > -0.2 * max, "negative residual {min} vs peak {max}"); - } -} diff --git a/crates/processing/src/baseline.rs b/crates/processing/src/baseline.rs deleted file mode 100644 index 2082f243..00000000 --- a/crates/processing/src/baseline.rs +++ /dev/null @@ -1,355 +0,0 @@ -use crate::{BaselineMethod, Spectrum}; - -/// Subtract a baseline from the real channel in place, per `method`. -pub fn apply(spec: &mut Spectrum, method: BaselineMethod) { - match method { - BaselineMethod::Offset => correct_offset(spec), - BaselineMethod::Polynomial { order } => subtract_polynomial(spec, order as usize), - BaselineMethod::AsymmetricLeastSquares { - smoothness, - asymmetry, - iterations, - } => subtract_asymmetric_least_squares(spec, smoothness, asymmetry, iterations as usize), - } -} - -/// Estimate a smooth baseline with Eilers' asymmetric least-squares method and -/// subtract it from the real channel. Peaks receive the small asymmetric weight, -/// while points at or below the estimate anchor the baseline. The linear system -/// is symmetric positive definite and pentadiagonal, so each iteration is O(n). -fn subtract_asymmetric_least_squares( - spec: &mut Spectrum, - smoothness: f64, - asymmetry: f64, - iterations: usize, -) { - let n = spec.values.len(); - if n < 3 { - correct_offset(spec); - return; - } - let y = spec.real(); - let lambda = smoothness.clamp(1.0, 1.0e12); - let p = asymmetry.clamp(1.0e-6, 0.5); - let mut weights = vec![1.0; n]; - let mut baseline = vec![0.0; n]; - for _ in 0..iterations.clamp(1, 100) { - let main_penalty = |i: usize| match i { - 0 if n == 3 => 1.0, - 1 if n == 3 => 4.0, - 2 if n == 3 => 1.0, - 0 => 1.0, - 1 => 5.0, - i if i + 2 == n => 5.0, - i if i + 1 == n => 1.0, - _ => 6.0, - }; - let main: Vec = weights - .iter() - .enumerate() - .map(|(i, weight)| weight + lambda * main_penalty(i)) - .collect(); - let first: Vec = (0..n - 1) - .map(|i| { - if i == 0 || i + 2 == n { - -2.0 * lambda - } else { - -4.0 * lambda - } - }) - .collect(); - let second = vec![lambda; n - 2]; - let rhs: Vec = weights.iter().zip(&y).map(|(w, value)| w * value).collect(); - let Some(solution) = solve_symmetric_pentadiagonal(&main, &first, &second, &rhs) else { - correct_offset(spec); - return; - }; - baseline = solution; - for i in 0..n { - weights[i] = if y[i] > baseline[i] { p } else { 1.0 - p }; - } - } - for (value, base) in spec.values.iter_mut().zip(baseline) { - value.re -= base; - } -} - -/// Banded Cholesky solve for a symmetric positive-definite matrix with two -/// populated off-diagonals. `first[i]` is A[i,i+1], `second[i]` is A[i,i+2]. -fn solve_symmetric_pentadiagonal( - main: &[f64], - first: &[f64], - second: &[f64], - rhs: &[f64], -) -> Option> { - let n = main.len(); - if rhs.len() != n || first.len() + 1 != n || second.len() + 2 != n { - return None; - } - let mut diagonal = vec![0.0; n]; - let mut lower1 = vec![0.0; n]; - let mut lower2 = vec![0.0; n]; - for i in 0..n { - if i >= 2 { - lower2[i] = second[i - 2] / diagonal[i - 2]; - } - if i >= 1 { - let cross = if i >= 2 { - lower2[i] * lower1[i - 1] - } else { - 0.0 - }; - lower1[i] = (first[i - 1] - cross) / diagonal[i - 1]; - } - let remainder = main[i] - lower1[i] * lower1[i] - lower2[i] * lower2[i]; - if !remainder.is_finite() || remainder <= f64::MIN_POSITIVE { - return None; - } - diagonal[i] = remainder.sqrt(); - } - let mut solution = vec![0.0; n]; - for i in 0..n { - let mut value = rhs[i]; - if i >= 1 { - value -= lower1[i] * solution[i - 1]; - } - if i >= 2 { - value -= lower2[i] * solution[i - 2]; - } - solution[i] = value / diagonal[i]; - } - for i in (0..n).rev() { - let mut value = solution[i]; - if i + 1 < n { - value -= lower1[i + 1] * solution[i + 1]; - } - if i + 2 < n { - value -= lower2[i + 2] * solution[i + 2]; - } - solution[i] = value / diagonal[i]; - } - Some(solution) -} - -/// Subtract a constant offset, estimated from the quietest region of the -/// spectrum, from the real channel in place. -pub fn correct_offset(spec: &mut Spectrum) { - if spec.values.is_empty() { - return; - } - let offset = estimate_offset(&spec.real()); - for c in &mut spec.values { - c.re -= offset; - } -} - -// Fit a polynomial to the low-lying points of the real channel and subtract it, -// so a rolling or sloped baseline is flattened without peaks (which ride above -// the baseline) dragging the fit up. The fit index is mapped to `[-1, 1]` to -// keep the normal equations well-conditioned for higher orders. -fn subtract_polynomial(spec: &mut Spectrum, order: usize) { - let n = spec.values.len(); - let m = order + 1; - if n <= m { - correct_offset(spec); - return; - } - let real = spec.real(); - let mut sorted = real.clone(); - sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); - let threshold = sorted[n / 2]; - - let t_of = |i: usize| 2.0 * i as f64 / (n - 1) as f64 - 1.0; - let mut ata = vec![vec![0.0; m]; m]; - let mut atb = vec![0.0; m]; - let mut anchors = 0usize; - let mut powers = vec![0.0; m]; - for (i, &value) in real.iter().enumerate() { - if value > threshold { - continue; - } - let t = t_of(i); - powers[0] = 1.0; - for k in 1..m { - powers[k] = powers[k - 1] * t; - } - for a in 0..m { - atb[a] += powers[a] * value; - for b in 0..m { - ata[a][b] += powers[a] * powers[b]; - } - } - anchors += 1; - } - if anchors < m { - correct_offset(spec); - return; - } - let coeffs = match plotx_analysis::fit::solve_linear(&ata, &atb) { - Some(c) => c, - None => { - correct_offset(spec); - return; - } - }; - for i in 0..n { - let t = t_of(i); - let mut tp = 1.0; - let mut base = 0.0; - for &c in &coeffs { - base += c * tp; - tp *= t; - } - spec.values[i].re -= base; - } -} - -fn estimate_offset(real: &[f64]) -> f64 { - if real.is_empty() { - return 0.0; - } - let mut sorted: Vec = real.to_vec(); - sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); - // Robust centre + scale of the whole channel: with sparse (positive) peaks the - // median sits on the baseline noise, and MAD/0.6745 estimates its σ. - let median = median_sorted(&sorted); - let mut dev: Vec = sorted.iter().map(|&v| (v - median).abs()).collect(); - dev.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); - let sigma = median_sorted(&dev) / 0.674_489_75; - if sigma <= f64::MIN_POSITIVE { - return median; - } - // Average the points inside a ±3σ band about the median: peaks are excluded and - // the retained noise is symmetric, so the mean is an unbiased estimate of the - // baseline centre — unlike the lowest decile, whose median sat ~1.6σ too low. - let (lo, hi) = (median - 3.0 * sigma, median + 3.0 * sigma); - let (mut sum, mut count) = (0.0, 0usize); - for &v in &sorted { - if v >= lo && v <= hi { - sum += v; - count += 1; - } - } - if count == 0 { - median - } else { - sum / count as f64 - } -} - -fn median_sorted(sorted: &[f64]) -> f64 { - let n = sorted.len(); - if n == 0 { - 0.0 - } else if n % 2 == 1 { - sorted[n / 2] - } else { - 0.5 * (sorted[n / 2 - 1] + sorted[n / 2]) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use num_complex::Complex64; - - #[test] - fn removes_constant_offset() { - let mut values: Vec = (0..100).map(|_| Complex64::new(5.0, 0.0)).collect(); - values[50] = Complex64::new(105.0, 0.0); - let mut s = Spectrum { - ppm: (0..100).map(|i| i as f64).collect(), - values, - hz_per_point: 1.0, - observe_freq_mhz: 400.0, - nucleus: "1H".into(), - }; - correct_offset(&mut s); - assert!(s.values[0].re.abs() < 1e-9); - assert!((s.values[50].re - 100.0).abs() < 1e-9); - } - - #[test] - fn offset_estimate_is_unbiased_for_noisy_baseline() { - let offset = 10.0; - let real: Vec = (0..400) - .map(|i| { - let noise = (i as f64 * 0.7).sin() * 2.0; - let peak = if i % 137 == 0 { 100.0 } else { 0.0 }; - offset + noise + peak - }) - .collect(); - let est = estimate_offset(&real); - assert!( - (est - offset).abs() < 0.3, - "offset estimate {est} vs {offset}" - ); - } - - #[test] - fn polynomial_flattens_a_sloped_baseline() { - let n = 200; - let values: Vec = (0..n) - .map(|i| { - let ramp = 3.0 + 0.05 * i as f64; - let peak = if i == 150 { 500.0 } else { 0.0 }; - Complex64::new(ramp + peak, 0.0) - }) - .collect(); - let mut s = Spectrum { - ppm: (0..n).map(|i| i as f64).collect(), - values, - hz_per_point: 1.0, - observe_freq_mhz: 400.0, - nucleus: "1H".into(), - }; - apply(&mut s, BaselineMethod::Polynomial { order: 1 }); - for i in 0..n { - if i == 150 { - continue; - } - assert!( - s.values[i].re.abs() < 1e-6, - "baseline at {i} = {}", - s.values[i].re - ); - } - assert!((s.values[150].re - 500.0).abs() < 1e-6); - } - - #[test] - fn asymmetric_least_squares_removes_curved_baseline_without_erasing_peaks() { - let n = 600; - let values: Vec = (0..n) - .map(|i| { - let x = 2.0 * i as f64 / (n - 1) as f64 - 1.0; - let baseline = 8.0 + 4.0 * x + 7.0 * x * x; - let peak = 120.0 * (-((i as f64 - 190.0) / 8.0).powi(2)).exp() - + 75.0 * (-((i as f64 - 430.0) / 13.0).powi(2)).exp(); - Complex64::new(baseline + peak, 0.0) - }) - .collect(); - let mut spectrum = Spectrum { - ppm: (0..n).map(|i| i as f64).collect(), - values, - hz_per_point: 1.0, - observe_freq_mhz: 400.0, - nucleus: "1H".into(), - }; - // This baseline is deliberately steep, so it exercises the solver with a - // smoothness looser than the `AUTO` preset — which is tuned for the gentle - // baselines and broad peaks covered by the crate's quality-contract tests. - apply( - &mut spectrum, - BaselineMethod::AsymmetricLeastSquares { - smoothness: 1.0e4, - asymmetry: 0.001, - iterations: 20, - }, - ); - assert!(spectrum.values[20].re.abs() < 1.0); - assert!(spectrum.values[570].re.abs() < 1.0); - assert!(spectrum.values[190].re > 100.0); - assert!(spectrum.values[430].re > 60.0); - } -} diff --git a/crates/processing/src/cleanup.rs b/crates/processing/src/cleanup.rs deleted file mode 100644 index 22b97163..00000000 --- a/crates/processing/src/cleanup.rs +++ /dev/null @@ -1,360 +0,0 @@ -//! Spectrum cleanup steps: smoothing, normalization, binning, reverse, invert. - -use crate::{BinMethod, BinParams, NormalizeMethod, SmoothMethod, Spectrum}; -use num_complex::Complex64; - -pub fn smooth(spec: &mut Spectrum, method: SmoothMethod) { - match method { - SmoothMethod::MovingAverage { window } => moving_average(&mut spec.values, window as usize), - SmoothMethod::SavitzkyGolay { window, poly_order } => { - savitzky_golay(&mut spec.values, window as usize, poly_order as usize) - } - } -} - -/// Gaussian smoothing for real-valued detection helpers that need a stable, -/// symmetric kernel but are not persisted processing steps. -pub fn gaussian_smooth_real(values: &[f64], sigma: f64) -> Option> { - if values.is_empty() - || !sigma.is_finite() - || sigma <= 0.0 - || values.iter().any(|value| !value.is_finite()) - { - return None; - } - let radius = (3.0 * sigma).ceil() as isize; - Some( - (0..values.len()) - .map(|index| { - let mut weighted = 0.0; - let mut total = 0.0; - for offset in -radius..=radius { - let source = - (index as isize + offset).clamp(0, values.len() as isize - 1) as usize; - let weight = (-0.5 * (offset as f64 / sigma).powi(2)).exp(); - weighted += values[source] * weight; - total += weight; - } - weighted / total - }) - .collect(), - ) -} - -fn moving_average(values: &mut Vec, window: usize) { - let n = values.len(); - let w = (window.max(3) | 1).min(if n % 2 == 1 { n } else { n.saturating_sub(1) }); - if n < 3 || w < 3 { - return; - } - let h = w / 2; - let mut out = Vec::with_capacity(n); - for i in 0..n { - let lo = i.saturating_sub(h); - let hi = (i + h + 1).min(n); - let sum: Complex64 = values[lo..hi].iter().sum(); - out.push(sum / (hi - lo) as f64); - } - *values = out; -} - -/// Least-squares polynomial smoothing: each point is replaced by the value of a -/// degree-`order` polynomial fitted over an odd `window` around it. Edge points -/// reuse the boundary window, evaluated off-center, so a polynomial signal of -/// degree ≤ `order` is reproduced exactly everywhere. -fn savitzky_golay(values: &mut Vec, window: usize, order: usize) { - let n = values.len(); - let w = (window.max(3) | 1).min(if n % 2 == 1 { n } else { n.saturating_sub(1) }); - if n < 3 || w < 3 { - return; - } - let m = order.clamp(1, w - 1) + 1; - let h = w / 2; - let x = |i: usize| i as f64 - h as f64; - - let mut gram = vec![vec![0.0; m]; m]; - for i in 0..w { - let mut powers = vec![1.0; m]; - for k in 1..m { - powers[k] = powers[k - 1] * x(i); - } - for r in 0..m { - for c in 0..m { - gram[r][c] += powers[r] * powers[c]; - } - } - } - let mut gram_inv = vec![vec![0.0; m]; m]; - for k in 0..m { - let mut e = vec![0.0; m]; - e[k] = 1.0; - let Some(col) = plotx_analysis::fit::solve_linear(&gram, &e) else { - return; - }; - for r in 0..m { - gram_inv[r][k] = col[r]; - } - } - let sample_powers: Vec> = (0..w) - .map(|i| { - let mut powers = vec![1.0; m]; - for k in 1..m { - powers[k] = powers[k - 1] * x(i); - } - powers - }) - .collect(); - // projection[k][i]: coefficient k of the fitted polynomial from sample i. - let projection: Vec> = (0..m) - .map(|r| { - sample_powers - .iter() - .map(|powers| (0..m).map(|k| gram_inv[r][k] * powers[k]).sum()) - .collect() - }) - .collect(); - // weights[p][i]: smoothing weights when evaluating at offset p in the window. - let weights: Vec> = (0..w) - .map(|p| { - let mut powers = vec![1.0; m]; - for k in 1..m { - powers[k] = powers[k - 1] * x(p); - } - (0..w) - .map(|i| (0..m).map(|k| powers[k] * projection[k][i]).sum()) - .collect() - }) - .collect(); - - let mut out = Vec::with_capacity(n); - for i in 0..n { - let (start, p) = if i < h { - (0, i) - } else if i + h >= n { - (n - w, i - (n - w)) - } else { - (i - h, h) - }; - let mut acc = Complex64::new(0.0, 0.0); - for (j, &weight) in weights[p].iter().enumerate() { - acc += values[start + j] * weight; - } - out.push(acc); - } - *values = out; -} - -pub fn normalize(spec: &mut Spectrum, method: NormalizeMethod) { - let scale = match method { - NormalizeMethod::MaxPeak => spec.values.iter().map(|c| c.norm()).fold(0.0, f64::max), - NormalizeMethod::TotalArea => { - spec.values.iter().map(|c| c.re.abs()).sum::() * axis_step(&spec.ppm) - } - NormalizeMethod::Constant { divisor } => divisor, - }; - if scale.is_finite() && scale.abs() > f64::MIN_POSITIVE { - for c in &mut spec.values { - *c /= scale; - } - } -} - -/// Aggregate runs of points into bins of `width` axis units. The axis becomes -/// the per-bin mean position and `hz_per_point` grows by the same factor, so -/// axis metadata stays consistent with the reduced point count. -pub fn bin(spec: &mut Spectrum, params: BinParams) { - let n = spec.values.len(); - let step = axis_step(&spec.ppm); - if n == 0 || !params.width.is_finite() || params.width <= 0.0 { - return; - } - let per = ((params.width / step).round() as usize).max(1); - if per <= 1 { - return; - } - let bins = n.div_ceil(per); - let mut values = Vec::with_capacity(bins); - let mut ppm = Vec::with_capacity(bins); - for start in (0..n).step_by(per) { - let end = (start + per).min(n); - let count = (end - start) as f64; - let sum: Complex64 = spec.values[start..end].iter().sum(); - values.push(match params.method { - BinMethod::Sum => sum, - BinMethod::Mean => sum / count, - }); - ppm.push(spec.ppm[start..end].iter().sum::() / count); - } - spec.values = values; - spec.ppm = ppm; - spec.hz_per_point *= per as f64; -} - -/// Mirror the intensities along the axis; the axis itself keeps its ordering. -pub fn reverse(spec: &mut Spectrum) { - spec.values.reverse(); -} - -pub fn invert(spec: &mut Spectrum) { - for c in &mut spec.values { - *c = -*c; - } -} - -/// Effective spacing of the axis consumed by binning. -pub fn axis_step(ppm: &[f64]) -> f64 { - if ppm.len() < 2 { - return 1.0; - } - let span = (ppm[ppm.len() - 1] - ppm[0]).abs(); - if span > 0.0 { - span / (ppm.len() - 1) as f64 - } else { - 1.0 - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn spec_from(real: Vec) -> Spectrum { - let n = real.len(); - Spectrum { - ppm: (0..n).map(|i| i as f64 * 0.01).collect(), - values: real.into_iter().map(|v| Complex64::new(v, 0.0)).collect(), - hz_per_point: 1.0, - observe_freq_mhz: 400.0, - nucleus: "1H".into(), - } - } - - #[test] - fn moving_average_preserves_a_constant_and_averages_neighbors() { - let mut s = spec_from(vec![4.0; 50]); - smooth(&mut s, SmoothMethod::MovingAverage { window: 5 }); - for c in &s.values { - assert!((c.re - 4.0).abs() < 1e-12); - } - let mut s = spec_from(vec![0.0, 0.0, 3.0, 0.0, 0.0]); - smooth(&mut s, SmoothMethod::MovingAverage { window: 3 }); - assert!((s.values[1].re - 1.0).abs() < 1e-12); - assert!((s.values[2].re - 1.0).abs() < 1e-12); - assert!((s.values[3].re - 1.0).abs() < 1e-12); - } - - #[test] - fn savitzky_golay_reproduces_a_cubic_exactly_including_edges() { - let cubic: Vec = (0..80) - .map(|i| { - let t = i as f64 * 0.1; - 2.0 + 3.0 * t - 1.5 * t * t + 0.25 * t * t * t - }) - .collect(); - let mut s = spec_from(cubic.clone()); - smooth( - &mut s, - SmoothMethod::SavitzkyGolay { - window: 9, - poly_order: 3, - }, - ); - for (c, expected) in s.values.iter().zip(&cubic) { - assert!((c.re - expected).abs() < 1e-9, "{} vs {expected}", c.re); - } - } - - #[test] - fn savitzky_golay_attenuates_noise() { - let noisy: Vec = (0..200) - .map(|i| if i % 2 == 0 { 1.0 } else { -1.0 }) - .collect(); - let mut s = spec_from(noisy); - smooth( - &mut s, - SmoothMethod::SavitzkyGolay { - window: 11, - poly_order: 2, - }, - ); - let rms: f64 = - (s.values.iter().map(|c| c.re * c.re).sum::() / s.values.len() as f64).sqrt(); - assert!(rms < 0.5, "rms {rms}"); - } - - #[test] - fn max_peak_normalization_scales_the_tallest_peak_to_one() { - let mut s = spec_from(vec![1.0, -2.0, 8.0, 0.5]); - normalize(&mut s, NormalizeMethod::MaxPeak); - let max = s.values.iter().map(|c| c.norm()).fold(0.0, f64::max); - assert!((max - 1.0).abs() < 1e-12); - assert!((s.values[1].re + 0.25).abs() < 1e-12); - } - - #[test] - fn total_area_normalization_makes_the_integral_one() { - let mut s = spec_from((0..100).map(|i| if i == 50 { 20.0 } else { 2.0 }).collect()); - normalize(&mut s, NormalizeMethod::TotalArea); - let dx = 0.01; - let area: f64 = s.values.iter().map(|c| c.re.abs()).sum::() * dx; - assert!((area - 1.0).abs() < 1e-12, "area {area}"); - } - - #[test] - fn constant_normalization_divides_and_ignores_zero() { - let mut s = spec_from(vec![4.0, 6.0]); - normalize(&mut s, NormalizeMethod::Constant { divisor: 2.0 }); - assert!((s.values[0].re - 2.0).abs() < 1e-12); - normalize(&mut s, NormalizeMethod::Constant { divisor: 0.0 }); - assert!((s.values[0].re - 2.0).abs() < 1e-12); - } - - #[test] - fn binning_reduces_points_and_keeps_axis_and_metadata_consistent() { - let mut s = spec_from((0..100).map(|i| i as f64).collect()); - bin( - &mut s, - BinParams { - width: 0.05, - method: BinMethod::Mean, - }, - ); - assert_eq!(s.values.len(), 20); - assert_eq!(s.ppm.len(), 20); - assert!((s.values[0].re - 2.0).abs() < 1e-12); - assert!((s.values[1].re - 7.0).abs() < 1e-12); - assert!((s.ppm[0] - 0.02).abs() < 1e-12); - assert!((s.hz_per_point - 5.0).abs() < 1e-12); - - let mut s = spec_from(vec![1.0; 10]); - bin( - &mut s, - BinParams { - width: 0.04, - method: BinMethod::Sum, - }, - ); - assert_eq!(s.values.len(), 3); - assert!((s.values[0].re - 4.0).abs() < 1e-12); - assert!((s.values[2].re - 2.0).abs() < 1e-12); - } - - #[test] - fn reverse_mirrors_intensities_and_keeps_the_axis() { - let mut s = spec_from(vec![1.0, 2.0, 3.0]); - let ppm = s.ppm.clone(); - reverse(&mut s); - assert_eq!(s.ppm, ppm); - assert!((s.values[0].re - 3.0).abs() < 1e-12); - reverse(&mut s); - assert!((s.values[0].re - 1.0).abs() < 1e-12); - } - - #[test] - fn invert_negates_intensities() { - let mut s = spec_from(vec![1.0, -2.0]); - invert(&mut s); - assert!((s.values[0].re + 1.0).abs() < 1e-12); - assert!((s.values[1].re - 2.0).abs() < 1e-12); - } -} diff --git a/crates/processing/src/craft.rs b/crates/processing/src/craft.rs index ff488661..edbbc362 100644 --- a/crates/processing/src/craft.rs +++ b/crates/processing/src/craft.rs @@ -7,6 +7,8 @@ use serde::{Deserialize, Serialize}; mod diagnostics; mod fitting; +mod nmr_preview; +pub use nmr_preview::preview_spectrum; mod preflight; mod reconstruction; mod regions; @@ -81,19 +83,26 @@ pub struct CraftRegionId(pub u64); #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] pub struct CraftReference { pub acquisition_carrier_ppm: f64, + /// Frequency defining one ppm, independently of the observed transmitter frequency. + pub reference_frequency_mhz: f64, pub offset_ppm: f64, } impl CraftReference { - pub const fn new(acquisition_carrier_ppm: f64, offset_ppm: f64) -> Self { + pub const fn new( + acquisition_carrier_ppm: f64, + reference_frequency_mhz: f64, + offset_ppm: f64, + ) -> Self { Self { acquisition_carrier_ppm, + reference_frequency_mhz, offset_ppm, } } pub fn acquisition(data: &NmrData) -> Self { - Self::new(data.carrier_ppm, 0.0) + Self::new(data.carrier_ppm, data.observe_freq_mhz, 0.0) } pub fn effective_carrier_ppm(self) -> f64 { @@ -102,6 +111,8 @@ impl CraftReference { pub fn validate(self, data: &NmrData) -> Result<(), CraftError> { if self.acquisition_carrier_ppm.is_finite() + && self.reference_frequency_mhz.is_finite() + && self.reference_frequency_mhz > 0.0 && self.offset_ppm.is_finite() && self.effective_carrier_ppm().is_finite() && self.acquisition_carrier_ppm == data.carrier_ppm @@ -349,8 +360,10 @@ pub fn process_craft_cancellable( .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, + (region.start_ppm - reference.effective_carrier_ppm()) + * reference.reference_frequency_mhz, + (region.end_ppm - reference.effective_carrier_ppm()) + * reference.reference_frequency_mhz, ) }) .collect::>(); @@ -414,7 +427,7 @@ pub fn process_craft_cancellable( region: CraftRegionId(0), frequency_hz, chemical_shift_ppm: reference.effective_carrier_ppm() - + frequency_hz / data.observe_freq_mhz, + + frequency_hz / reference.reference_frequency_mhz, amplitude_t0: component.amplitude, phase_rad: component.phase_rad, decay_rate_s_inv: component.decay_rate_s_inv, @@ -430,7 +443,7 @@ pub fn process_craft_cancellable( }) .collect(); let selections = if params.regions.is_empty() { - let half_width_ppm = sw / (2.0 * data.observe_freq_mhz); + let half_width_ppm = sw / (2.0 * reference.reference_frequency_mhz); vec![CraftRegion::new( CraftRegionId(0), reference.effective_carrier_ppm() - half_width_ppm, diff --git a/crates/processing/src/craft/nmr_preview.rs b/crates/processing/src/craft/nmr_preview.rs new file mode 100644 index 00000000..000321fa --- /dev/null +++ b/crates/processing/src/craft/nmr_preview.rs @@ -0,0 +1,111 @@ +//! Diagnostic transforms over the CRAFT input view use the NMR library kernels. + +use super::{CraftError, CraftReference}; +use crate::Spectrum; +use nmr::axis::{AxisCoordinates, AxisDomain, AxisUnit, FrequencyEvidence}; +use nmr::processing::{ + FourierTransform, FrequencyFrame, ProcessingOperation as Op, ProcessingPlan, ReferenceSource, + SpectrumOperation, Window, ZeroFill, +}; +use nmr::raw::{ + ChemicalShiftReference, DirectSamples, RawAxis, RawAxisKind, RawDatasetBuilder, RawMetadata, +}; +use plotx_io::NmrData; + +/// Transform the selected modeling interval with a matched exponential window. +/// The diagnostic has no digital-filter correction: only magnitudes are used. +pub fn preview_spectrum( + data: &NmrData, + reference: CraftReference, + skip: usize, +) -> Result { + let fail = |error: &dyn std::fmt::Display| CraftError::Preflight(error.to_string()); + if data.domain != plotx_io::Domain::Time { + return Err(CraftError::InvalidInput); + } + reference.validate(data)?; + let retained = data + .points + .len() + .checked_sub(skip) + .filter(|count| *count >= 3) + .ok_or(CraftError::InvalidInput)?; + let target = retained + .checked_next_power_of_two() + .ok_or(CraftError::InvalidInput)?; + let axis = RawAxis::new( + RawAxisKind::Direct(DirectSamples::Complex), + AxisDomain::Time, + Some(AxisUnit::Second), + data.points.len(), + AxisCoordinates::Uniform { + start: 0.0, + step: 1.0 / data.spectral_width_hz, + }, + ) + .map_err(|error| fail(&error))? + .with_spectral_width_hz(Some(data.spectral_width_hz)) + .map_err(|error| fail(&error))? + .with_frequency_evidence(Some( + FrequencyEvidence::new(Some(data.observe_freq_mhz), None).map_err(|error| fail(&error))?, + )) + .map_err(|error| fail(&error))? + .with_chemical_shift_reference(Some( + ChemicalShiftReference::user_constructed( + reference.effective_carrier_ppm(), + reference.reference_frequency_mhz, + ) + .map_err(|error| fail(&error))?, + )) + .map_err(|error| fail(&error))?; + // This is a disposable analysis view, not a substitute for the acquisition + // Dataset and its provenance in application storage. + let input = RawDatasetBuilder::new(vec![axis], RawMetadata::default()) + .map_err(|error| fail(&error))? + .dense(data.points.clone()) + .map_err(|error| fail(&error))?; + let plan = ProcessingPlan::new(vec![ + Op::Spectrum { + axis: 0, + operation: SpectrumOperation::RetainRange { + start: skip, + end: data.points.len(), + }, + }, + Op::Window { + axis: 0, + window: Window::exponential(data.spectral_width_hz / retained as f64) + .map_err(|error| fail(&error))?, + }, + Op::ZeroFill { + axis: 0, + zero_fill: ZeroFill::new(target).map_err(|error| fail(&error))?, + }, + Op::FourierTransform { + axis: 0, + transform: FourierTransform::default(), + }, + Op::ResolveFrequencyFrame { + axis: 0, + frame: FrequencyFrame::Ppm(ReferenceSource::AxisEvidence), + }, + Op::Spectrum { + axis: 0, + operation: SpectrumOperation::Magnitude, + }, + ]) + .map_err(|error| fail(&error))?; + let output = plan.apply(&input.into()).map_err(|error| fail(&error))?; + let source = plotx_io::nmr_view::NmrSource::new(std::sync::Arc::new(output)) + .map_err(|error| fail(&error))?; + Ok(Spectrum { + ppm: source.axes()[0] + .coordinate_values() + .map_err(|error| fail(&error))?, + values: source.trace().map_err(|error| fail(&error))?, + unit: nmr::axis::AxisUnit::Ppm, + hz_per_point: Some(data.spectral_width_hz / target as f64), + observe_freq_mhz: Some(data.observe_freq_mhz), + nucleus: data.nucleus.clone(), + }) +} diff --git a/crates/processing/src/craft/preflight.rs b/crates/processing/src/craft/preflight.rs index a2909efb..31e63aaa 100644 --- a/crates/processing/src/craft/preflight.rs +++ b/crates/processing/src/craft/preflight.rs @@ -1,8 +1,6 @@ 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}; @@ -28,6 +26,7 @@ pub enum CraftIssueCode { NoClearSignal, RegionWithoutClearSignal, DenseSignalWindow, + ProcessingFailure, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -89,6 +88,13 @@ impl CraftInputAssessment { action, }) }; + if let Some(message) = &plan.processing_error { + error( + CraftIssueCode::ProcessingFailure, + message, + CraftIssueAction::CheckAcquisitionMetadata, + ); + } if data.domain != Domain::Time { error( CraftIssueCode::IncompatibleDomain, @@ -177,7 +183,19 @@ impl CraftInputAssessment { { Vec::new() } else { - detect_clear_signals(data, reference, plan.effective_skip_points) + match detect_clear_signals(data, reference, plan.effective_skip_points) { + Ok(signals) => signals, + Err(error) => { + issues.push(CraftAssessmentIssue { + code: CraftIssueCode::ProcessingFailure, + severity: CraftIssueSeverity::Error, + region: None, + message: error.to_string(), + action: CraftIssueAction::CheckAcquisitionMetadata, + }); + Vec::new() + } + } }; if plan.available_points >= 16 && clear_signals.is_empty() { issues.push(warning(CraftIssueCode::NoClearSignal, None, "No clear signal reached the 6σ height and 5σ prominence thresholds; the calculation can run but needs review.", CraftIssueAction::ConfirmWithIndependentEvidence)); @@ -258,7 +276,7 @@ fn regions_outside_bandwidth_or_overlap( { return false; } - let half_ppm = data.spectral_width_hz / (2.0 * data.observe_freq_mhz); + let half_ppm = data.spectral_width_hz / (2.0 * reference.reference_frequency_mhz); let carrier = reference.effective_carrier_ppm(); let lower = carrier - half_ppm; let upper = carrier + half_ppm; @@ -284,28 +302,20 @@ pub(super) fn detect_clear_signals( data: &NmrData, reference: CraftReference, skip: usize, -) -> Vec { - let input = &data.points[skip..]; +) -> Result, super::CraftError> { + let input = data + .points + .get(skip..) + .ok_or(super::CraftError::InvalidInput)?; if input.len() < 3 { - return Vec::new(); - } - let fft_len = input.len().next_power_of_two(); - let mut spectrum = vec![num_complex::Complex64::new(0.0, 0.0); fft_len]; - 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(); + return Ok(Vec::new()); } - FftPlanner::::new() - .plan_fft_forward(fft_len) - .process(&mut spectrum); - let magnitudes = spectrum + let spectrum = super::preview_spectrum(data, reference, skip)?; + let fft_len = spectrum.len(); + let shifted = spectrum + .values .iter() - .map(|value| value.norm()) - .collect::>(); - let shifted = (0..fft_len) - .map(|index| magnitudes[(index + fft_len / 2) % fft_len]) + .map(|value| value.re) .collect::>(); let sigma = estimate_noise(&shifted).max(f64::MIN_POSITIVE); let xs = (0..fft_len).map(|index| index as f64).collect::>(); @@ -326,16 +336,12 @@ pub(super) fn detect_clear_signals( max_count: Some(64), }, ); - peaks + Ok(peaks .into_iter() - .map(|peak| { - let frequency_hz = (peak.index as f64 / fft_len as f64 - 0.5) * data.spectral_width_hz; - CraftSignalSuggestion { - chemical_shift_ppm: reference.effective_carrier_ppm() - + frequency_hz / data.observe_freq_mhz, - height_sigma: shifted[peak.index] / sigma, - prominence_sigma: peak.prominence / sigma, - } + .map(|peak| CraftSignalSuggestion { + chemical_shift_ppm: spectrum.ppm[peak.index], + height_sigma: shifted[peak.index] / sigma, + prominence_sigma: peak.prominence / sigma, }) - .collect() + .collect()) } diff --git a/crates/processing/src/craft/regions.rs b/crates/processing/src/craft/regions.rs index c5d30017..0efe72ec 100644 --- a/crates/processing/src/craft/regions.rs +++ b/crates/processing/src/craft/regions.rs @@ -47,8 +47,8 @@ pub(super) fn build_modeling_windows( let requested: Vec<(CraftRegion, f64, f64)> = if params.regions.is_empty() { let selection = CraftRegion::new( CraftRegionId(0), - effective_carrier_ppm - half_sw / data.observe_freq_mhz, - effective_carrier_ppm + half_sw / data.observe_freq_mhz, + effective_carrier_ppm - half_sw / reference.reference_frequency_mhz, + effective_carrier_ppm + half_sw / reference.reference_frequency_mhz, ); vec![(selection, -half_sw, half_sw)] } else { @@ -60,8 +60,8 @@ pub(super) fn build_modeling_windows( .map(|region| { ( region, - (region.start_ppm - effective_carrier_ppm) * data.observe_freq_mhz, - (region.end_ppm - effective_carrier_ppm) * data.observe_freq_mhz, + (region.start_ppm - effective_carrier_ppm) * reference.reference_frequency_mhz, + (region.end_ppm - effective_carrier_ppm) * reference.reference_frequency_mhz, ) }) .collect() @@ -76,8 +76,8 @@ pub(super) fn build_modeling_windows( requested_cores.push(( CraftRegion::new( selection.id, - effective_carrier_ppm + start / data.observe_freq_mhz, - effective_carrier_ppm + end / data.observe_freq_mhz, + effective_carrier_ppm + start / reference.reference_frequency_mhz, + effective_carrier_ppm + end / reference.reference_frequency_mhz, ), start, end, @@ -98,8 +98,8 @@ pub(super) fn build_modeling_windows( let signal_hz = clear_signals .iter() .filter_map(|signal| { - let frequency = - (signal.chemical_shift_ppm - effective_carrier_ppm) * data.observe_freq_mhz; + let frequency = (signal.chemical_shift_ppm - effective_carrier_ppm) + * reference.reference_frequency_mhz; let weight = signal.prominence_sigma.max(f64::MIN_POSITIVE); (frequency.is_finite() && weight.is_finite() diff --git a/crates/processing/src/craft/resolution.rs b/crates/processing/src/craft/resolution.rs index b6e1d4d0..1a1b1872 100644 --- a/crates/processing/src/craft/resolution.rs +++ b/crates/processing/src/craft/resolution.rs @@ -138,6 +138,8 @@ pub struct CraftDerivedPlan { pub reconstruction_points: usize, pub resolved_regions: Vec, pub modeling_windows: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub processing_error: Option, } pub fn resolve_craft_invocation( @@ -232,7 +234,7 @@ pub fn resolve_craft_invocation( } fn full_bandwidth_region(data: &NmrData, reference: CraftReference) -> Option { - let half_width_ppm = data.spectral_width_hz / (2.0 * data.observe_freq_mhz); + let half_width_ppm = data.spectral_width_hz / (2.0 * reference.reference_frequency_mhz); let carrier = reference.effective_carrier_ppm(); (half_width_ppm.is_finite() && carrier.is_finite()).then(|| { CraftRegion::new( @@ -302,6 +304,7 @@ fn derive_plan( }; let resolved_regions = params.regions.clone(); let mut modeling_windows = Vec::new(); + let mut processing_error = None; if data.spectral_width_hz.is_finite() && data.spectral_width_hz > 0.0 && data.observe_freq_mhz.is_finite() @@ -310,10 +313,16 @@ fn derive_plan( && params.profile.modeling_bandwidth_hz().is_finite() { 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 windows = detect_clear_signals(data, reference, effective_skip_points) + .and_then(|signals| build_modeling_windows(data, params, reference, &signals)); + let windows = match windows { + Ok(windows) => windows, + Err(error) => { + processing_error = Some(error.to_string()); + Vec::new() + } + }; + for window in windows { 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)) @@ -345,6 +354,7 @@ fn derive_plan( reconstruction_points, resolved_regions, modeling_windows, + processing_error, } } diff --git a/crates/processing/src/craft/stability.rs b/crates/processing/src/craft/stability.rs index f006e36d..8692edde 100644 --- a/crates/processing/src/craft/stability.rs +++ b/crates/processing/src/craft/stability.rs @@ -15,7 +15,7 @@ pub(super) fn stability_diagnostics( reference: CraftReference, data: &NmrData, ) -> CraftStabilityDiagnostics { - let delta_ppm = (0.01_f64).max(8.0 / data.observe_freq_mhz.max(f64::MIN_POSITIVE)); + let delta_ppm = (0.01_f64).max(8.0 / reference.reference_frequency_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), @@ -55,7 +55,7 @@ pub(super) fn stability_diagnostics( } let carrier = reference.effective_carrier_ppm(); - let half_ppm = data.spectral_width_hz / (2.0 * data.observe_freq_mhz); + let half_ppm = data.spectral_width_hz / (2.0 * reference.reference_frequency_mhz); let lower = carrier - half_ppm; let upper = carrier + half_ppm; let mut skipped = Vec::new(); diff --git a/crates/processing/src/craft_tests.rs b/crates/processing/src/craft_tests.rs index c735e035..4c057d02 100644 --- a/crates/processing/src/craft_tests.rs +++ b/crates/processing/src/craft_tests.rs @@ -233,14 +233,14 @@ fn overlapping_requested_regions_are_rejected_as_ambiguous() { #[test] fn reference_maps_displayed_regions_and_reported_shifts_without_changing_frequency() { let input = data(&[(120.0, 5.0, 0.2, 2.0)], 4096, 2_000.0); - let reference = CraftReference::new(input.carrier_ppm, 0.15); + let reference = CraftReference::new(input.carrier_ppm, input.observe_freq_mhz, 0.15); let params = CraftParams { regions: vec![CraftRegion::new(CraftRegionId(7), 0.38, 0.40)], fir_filter_taps: 127, ..CraftParams::default() }; - let clear_signals = preflight::detect_clear_signals(&input, reference, 0); + let clear_signals = preflight::detect_clear_signals(&input, reference, 0).unwrap(); let regions = build_modeling_windows(&input, ¶ms, reference, &clear_signals).unwrap(); assert_eq!(regions.len(), 1); assert!( @@ -308,7 +308,7 @@ fn modeling_windows_are_independent_while_components_preserve_region_identity() }; let reference = CraftReference::acquisition(&input); - let clear_signals = preflight::detect_clear_signals(&input, reference, 0); + let clear_signals = preflight::detect_clear_signals(&input, reference, 0).unwrap(); let windows = build_modeling_windows(&input, ¶ms, reference, &clear_signals).unwrap(); assert_eq!(windows.len(), 2); assert!( @@ -393,7 +393,7 @@ fn rejects_non_finite_reference() { &input, &resolve_craft_invocation( &input, - CraftReference::new(input.carrier_ppm, f64::NAN), + CraftReference::new(input.carrier_ppm, input.observe_freq_mhz, f64::NAN), &CraftParamOverrides::default(), None, ), @@ -411,7 +411,7 @@ fn rejects_reference_for_a_different_acquisition_carrier() { &input, &resolve_craft_invocation( &input, - CraftReference::new(input.carrier_ppm + 0.1, 0.0), + CraftReference::new(input.carrier_ppm + 0.1, input.observe_freq_mhz, 0.0), &CraftParamOverrides::default(), None, ), diff --git a/crates/processing/src/fft.rs b/crates/processing/src/fft.rs deleted file mode 100644 index fe2b83dc..00000000 --- a/crates/processing/src/fft.rs +++ /dev/null @@ -1,425 +0,0 @@ -use crate::{Apodization, AxisPipeline, Spectrum, StepKind, TimeTrace}; -use num_complex::Complex64; -use plotx_io::{Domain, NmrData}; -use rustfft::FftPlanner; - -/// Transform an FID into an *unphased* frequency-domain [`Spectrum`]: apply the -/// pipeline's enabled apodization windows and zero-fill, run the forward FFT -/// (removing the digital-filter group delay unless `group_delay_correct` is -/// false), `fftshift`, and build a ppm axis. Phase and other frequency-domain -/// steps are a separate cheap stage ([`crate::reapply`]). -pub fn transform_base(data: &NmrData, pipe: &AxisPipeline, group_delay_correct: bool) -> Spectrum { - let n_raw = data.len(); - if n_raw == 0 { - return Spectrum { - ppm: Vec::new(), - values: Vec::new(), - hz_per_point: 0.0, - observe_freq_mhz: data.observe_freq_mhz, - nucleus: data.nucleus.clone(), - }; - } - - if data.domain == Domain::Frequency { - let n = data.len(); - let sw = data.spectral_width_hz; - let hz_per_point = sw / n as f64; - let obs = data.observe_freq_mhz.max(f64::MIN_POSITIVE); - let half = n as f64 / 2.0; - return Spectrum { - ppm: (0..n) - .map(|i| data.carrier_ppm + (i as f64 - half) * hz_per_point / obs) - .collect(), - values: data.points.clone(), - hz_per_point, - observe_freq_mhz: data.observe_freq_mhz, - nucleus: data.nucleus.clone(), - }; - } - - let dt = data.dwell_s(); - let mut buf = apply_time_steps(data.points.clone(), pipe, dt); - let n = buf.len(); - - if data.domain == Domain::Time { - let mut planner = FftPlanner::::new(); - let fft = planner.plan_fft_forward(n); - fft.process(&mut buf); - if group_delay_correct { - remove_group_delay(&mut buf, data.group_delay); - } - } - - let shifted = fftshift(&buf); - - let sw = data.spectral_width_hz; - let hz_per_point = if n > 0 { sw / n as f64 } else { 0.0 }; - let obs = data.observe_freq_mhz.max(f64::MIN_POSITIVE); - let half = n as f64 / 2.0; - let ppm: Vec = (0..n) - .map(|i| { - let offset_hz = (i as f64 - half) * hz_per_point; - data.carrier_ppm + offset_hz / obs - }) - .collect(); - - Spectrum { - ppm, - values: shifted, - hz_per_point, - observe_freq_mhz: data.observe_freq_mhz, - nucleus: data.nucleus.clone(), - } -} - -/// Apply the enabled time-domain prefix without inventing an FFT. Callers use -/// this when the typed pipeline finishes in the time domain. -pub fn transform_time(data: &NmrData, pipe: &AxisPipeline) -> TimeTrace { - let dt = data.dwell_s(); - let values = apply_time_steps(data.points.clone(), pipe, dt); - TimeTrace { - time_s: (0..values.len()).map(|index| index as f64 * dt).collect(), - values, - nucleus: data.nucleus.clone(), - source: data.source.clone(), - } -} - -/// Apply the enabled time-domain prefix exactly in recipe order. -/// -/// Keeping this one kernel for time output and FFT input means adding/removing -/// FFT changes only the domain transition: it cannot silently reorder windows -/// or collapse multiple zero-fill steps. -pub(crate) fn apply_time_steps( - mut values: Vec, - pipe: &AxisPipeline, - dt: f64, -) -> Vec { - for step in pipe.steps.iter().filter(|step| step.enabled) { - match step.kind { - StepKind::Apodize(window) => apply_apodization(&mut values, window, dt), - StepKind::ZeroFill(fill) => { - let target = fill.target(values.len()); - values.resize(target, Complex64::new(0.0, 0.0)); - } - StepKind::Fft => break, - StepKind::Phase(_) - | StepKind::Baseline(_) - | StepKind::Reference(_) - | StepKind::Magnitude - | StepKind::Smooth(_) - | StepKind::Normalize(_) - | StepKind::Bin(_) - | StepKind::Reverse - | StepKind::Invert => {} - } - } - values -} - -pub(crate) fn time_step_output_len(mut len: usize, pipe: &AxisPipeline) -> usize { - for step in pipe.steps.iter().filter(|step| step.enabled) { - match step.kind { - StepKind::ZeroFill(fill) => len = fill.target(len), - StepKind::Fft => break, - _ => {} - } - } - len -} - -/// Apodize a FID in place over its populated samples. `t = i·dt` seconds, with -/// `dt` the sample interval; `dt` is unused by the point-index windows. -pub(crate) fn apply_apodization(buf: &mut [Complex64], apo: Apodization, dt: f64) { - let n = buf.len(); - match apo { - Apodization::None => {} - Apodization::CosineBell => { - if n <= 1 { - return; - } - let denom = (n - 1) as f64; - for (i, c) in buf.iter_mut().enumerate() { - *c *= (std::f64::consts::FRAC_PI_2 * i as f64 / denom).cos(); - } - } - Apodization::Exponential { lb_hz } => { - let k = std::f64::consts::PI * lb_hz; - for (i, c) in buf.iter_mut().enumerate() { - *c *= (-k * (i as f64 * dt)).exp(); - } - } - Apodization::Gaussian { lb_hz, gb_hz } => { - let a = std::f64::consts::PI * lb_hz; - // 4·ln2 maps the Gaussian's frequency FWHM onto its time-domain width. - let g = (std::f64::consts::PI * gb_hz).powi(2) / (4.0 * std::f64::consts::LN_2); - for (i, c) in buf.iter_mut().enumerate() { - let t = i as f64 * dt; - *c *= (a * t - g * t * t).exp(); - } - } - } -} - -// A group delay is a circular shift of the FID origin by `delay` samples, which -// by the shift theorem appears as a linear phase ramp. Use signed FFT-bin -// frequencies here: for a fractional delay, treating the upper half as positive -// frequencies puts the phase wrap at DC after `fftshift`, creating a visible -// discontinuity in the real spectrum. With signed bins the unavoidable wrap is -// at the Nyquist boundary instead. -fn remove_group_delay(spectrum: &mut [Complex64], delay: f64) { - if delay == 0.0 || !delay.is_finite() { - return; - } - let n = spectrum.len(); - if n == 0 { - return; - } - let phase_per_bin = std::f64::consts::TAU * delay / n as f64; - let negative_start = n.div_ceil(2); - for (m, c) in spectrum.iter_mut().enumerate() { - let signed_bin = if m < negative_start { - m as f64 - } else { - m as f64 - n as f64 - }; - *c *= Complex64::from_polar(1.0, phase_per_bin * signed_bin); - } -} - -fn fftshift(v: &[Complex64]) -> Vec { - let n = v.len(); - let mid = n.div_ceil(2); // pivot for both even and odd N - let mut out = Vec::with_capacity(n); - out.extend_from_slice(&v[mid..]); - out.extend_from_slice(&v[..mid]); - out -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{ - Apodization, AxisPipeline, ProcessingStep, StepId, StepKind, StepSource, ZeroFill, - }; - use plotx_io::Domain; - use std::f64::consts::TAU; - - // A detached test recipe: no dataset owns it, so numbering its own steps - // 0..n is enough to keep them distinguishable. - fn pipe(apo: Option, zf: ZeroFill) -> AxisPipeline { - let kinds = apo - .map(StepKind::Apodize) - .into_iter() - .chain([StepKind::ZeroFill(zf), StepKind::Fft]); - AxisPipeline { - steps: kinds - .enumerate() - .map(|(index, kind)| { - ProcessingStep::new(StepId::new(index as u64), kind, StepSource::User) - }) - .collect(), - } - } - - fn decaying_sinusoid( - npoints: usize, - spectral_width_hz: f64, - observe_freq_mhz: f64, - carrier_ppm: f64, - shift_ppm: f64, - group_delay: f64, - ) -> NmrData { - let dt = 1.0 / spectral_width_hz; - let freq_hz = (shift_ppm - carrier_ppm) * observe_freq_mhz; - let points = (0..npoints) - .map(|k| { - let t = k as f64 * dt; - let decay = (-t / 1.0).exp(); - Complex64::from_polar(decay, TAU * freq_hz * t) - }) - .collect(); - NmrData { - points, - domain: Domain::Time, - spectral_width_hz, - observe_freq_mhz, - carrier_ppm, - nucleus: "1H".into(), - source: "test".into(), - group_delay, - } - } - - #[test] - fn single_peak_lands_at_expected_ppm() { - let data = decaying_sinusoid(4096, 4000.0, 400.0, 0.0, 2.0, 0.0); - let s = transform_base(&data, &pipe(None, ZeroFill::None), true); - - let (idx, _) = s - .real() - .iter() - .enumerate() - .max_by(|a, b| a.1.partial_cmp(b.1).unwrap()) - .unwrap(); - let peak_ppm = s.ppm[idx]; - assert!( - (peak_ppm - 2.0).abs() < 0.05, - "peak found at {peak_ppm} ppm, expected ~2.0" - ); - } - - #[test] - fn group_delay_is_removed() { - let ideal = decaying_sinusoid(1024, 4000.0, 400.0, 0.0, 2.0, 0.0); - let n = ideal.len(); - let d = 7usize; - // Right-shift the FID by `d` points (a leading group delay), tag it. - let mut delayed = ideal.clone(); - delayed.points = (0..n).map(|k| ideal.points[(k + n - d) % n]).collect(); - delayed.group_delay = d as f64; - - let raw = pipe(None, ZeroFill::None); - let a = transform_base(&ideal, &raw, true).real(); - let b = transform_base(&delayed, &raw, true).real(); - let max_err = a - .iter() - .zip(&b) - .map(|(x, y)| (x - y).abs()) - .fold(0.0f64, f64::max); - assert!(max_err < 1e-9, "group delay not removed: max_err={max_err}"); - } - - #[test] - fn fractional_group_delay_uses_signed_fft_frequencies() { - let n = 16usize; - let delay = 3.25; - let negative_start = n.div_ceil(2); - let phase_per_bin = std::f64::consts::TAU * delay / n as f64; - let mut delayed: Vec = (0..n) - .map(|m| { - let signed_bin = if m < negative_start { - m as f64 - } else { - m as f64 - n as f64 - }; - Complex64::from_polar(1.0, -phase_per_bin * signed_bin) - }) - .collect(); - - remove_group_delay(&mut delayed, delay); - - assert!( - delayed - .iter() - .all(|value| (*value - Complex64::new(1.0, 0.0)).norm() < 1e-12), - "fractional delay correction must not introduce a phase jump at DC" - ); - } - - #[test] - fn fftshift_moves_dc_to_center() { - let v: Vec = (0..8).map(|i| Complex64::new(i as f64, 0.0)).collect(); - let s = fftshift(&v); - assert_eq!(s[4], Complex64::new(0.0, 0.0)); - } - - #[test] - fn zero_fill_target_never_shrinks() { - assert_eq!(ZeroFill::None.target(3000), 3000); - assert_eq!(ZeroFill::Factor(1).target(3000), 4096); - assert_eq!(ZeroFill::Factor(2).target(3000), 8192); - assert_eq!(ZeroFill::Size(1000).target(3000), 3000); - assert_eq!(ZeroFill::Size(9000).target(3000), 9000); - } - - #[test] - fn zero_fill_interpolates_without_moving_the_peak() { - let data = decaying_sinusoid(4096, 4000.0, 400.0, 0.0, 2.0, 0.0); - let raw = transform_base(&data, &pipe(None, ZeroFill::None), true); - let filled = transform_base(&data, &pipe(None, ZeroFill::Factor(2)), true); - assert_eq!(filled.len(), 8192); - assert!(filled.len() > raw.len()); - - let peak_ppm = |s: &Spectrum| { - let (i, _) = s - .real() - .iter() - .enumerate() - .max_by(|a, b| a.1.partial_cmp(b.1).unwrap()) - .unwrap(); - s.ppm[i] - }; - assert!((peak_ppm(&raw) - 2.0).abs() < 0.05); - assert!((peak_ppm(&filled) - 2.0).abs() < 0.05); - } - - #[test] - fn time_steps_keep_recipe_order_with_or_without_fft() { - let data = NmrData { - points: vec![Complex64::new(1.0, 0.0); 3], - domain: Domain::Time, - spectral_width_hz: 1000.0, - observe_freq_mhz: 400.0, - carrier_ppm: 0.0, - nucleus: "1H".into(), - source: "ordered time steps".into(), - group_delay: 0.0, - }; - let kinds = [ - StepKind::ZeroFill(ZeroFill::Size(5)), - StepKind::Apodize(Apodization::CosineBell), - StepKind::ZeroFill(ZeroFill::Factor(1)), - StepKind::Fft, - ]; - let spectral = AxisPipeline { - steps: kinds - .into_iter() - .enumerate() - .map(|(index, kind)| { - ProcessingStep::new(StepId::new(index as u64), kind, StepSource::User) - }) - .collect(), - }; - let mut temporal = spectral.clone(); - temporal.steps.last_mut().unwrap().enabled = false; - - let trace = transform_time(&data, &temporal); - let spectrum = transform_base(&data, &spectral, true); - assert_eq!(trace.values.len(), 8); - assert_eq!(spectrum.values.len(), trace.values.len()); - assert!((trace.values[2].re - std::f64::consts::FRAC_1_SQRT_2).abs() < 1e-12); - } - - #[test] - fn exponential_window_broadens_the_line() { - let data = decaying_sinusoid(4096, 4000.0, 400.0, 0.0, 2.0, 0.0); - let sharp = transform_base(&data, &pipe(None, ZeroFill::None), true); - let broad = transform_base( - &data, - &pipe( - Some(Apodization::Exponential { lb_hz: 20.0 }), - ZeroFill::None, - ), - true, - ); - let fwhm = |s: &Spectrum| { - let re = s.real(); - let (peak_i, &peak) = re - .iter() - .enumerate() - .max_by(|a, b| a.1.partial_cmp(b.1).unwrap()) - .unwrap(); - let half = peak / 2.0; - let count = re.iter().filter(|&&v| v >= half).count(); - let _ = peak_i; - count - }; - assert!( - fwhm(&broad) > fwhm(&sharp), - "exponential window should broaden: sharp={} broad={}", - fwhm(&sharp), - fwhm(&broad) - ); - } -} diff --git a/crates/processing/src/fft2.rs b/crates/processing/src/fft2.rs deleted file mode 100644 index 9c6fafaa..00000000 --- a/crates/processing/src/fft2.rs +++ /dev/null @@ -1,553 +0,0 @@ -use crate::fft::{apply_time_steps, time_step_output_len}; -use crate::phase::apply_slice; -use crate::{AxisMeta, Params2D, Spectrum2D, StackSpectrum}; -use num_complex::Complex64; -use plotx_io::{Domain, NmrData2D, QuadMode}; -use rustfft::FftPlanner; - -fn sample_interval(sw_hz: f64) -> f64 { - if sw_hz != 0.0 { 1.0 / sw_hz } else { 0.0 } -} - -/// Transform a 2D FID into an *unphased* frequency-domain [`Spectrum2D`]: FFT -/// along the direct (F2) axis of every row, quadrature recombination + FFT along -/// the indirect (F1) axis, `fftshift` on both, and ppm axes from the per-dimension -/// spectral widths and observe frequencies. Each axis's window and zero-fill are -/// applied before its FFT; phase is a separate cheap stage ([`reapply_phase_2d`]). -pub fn transform(data: &NmrData2D, params: &Params2D) -> Spectrum2D { - transform_cancellable(data, params, &|| false).expect("non-cancelling transform") -} - -/// Cooperative-cancellation variant used by the desktop compute service. The -/// callback is checked between FFT rows/columns, which bounds cancellation -/// latency without adding synchronization inside rustfft itself. -pub fn transform_cancellable( - data: &NmrData2D, - params: &Params2D, - cancelled: &impl Fn() -> bool, -) -> Option { - let cols = data.cols; - let rows = data.rows; - if cols == 0 || rows == 0 { - return Some(empty(data)); - } - if data.domain == Domain::Frequency { - return Some(Spectrum2D { - f2_ppm: ppm_axis( - cols, - data.direct.spectral_width_hz, - data.direct.observe_freq_mhz, - data.direct.carrier_ppm, - ), - f1_ppm: ppm_axis( - rows, - data.indirect.spectral_width_hz, - data.indirect.observe_freq_mhz, - data.indirect.carrier_ppm, - ), - f2_domain: Domain::Frequency, - f1_domain: Domain::Frequency, - data: data.data.clone(), - f2_size: cols, - f1_size: rows, - direct: AxisMeta::from(&data.direct), - indirect: AxisMeta::from(&data.indirect), - source: data.source.clone(), - }); - } - - let f2_domain = params - .f2 - .output_domain(data.domain) - .expect("live F2 pipeline is domain-valid"); - let f1_domain = params - .f1 - .output_domain(data.domain) - .expect("live F1 pipeline is domain-valid"); - let mut planner = FftPlanner::::new(); - - let f2_dt = sample_interval(data.direct.spectral_width_hz); - let f2_n = time_step_output_len(cols, ¶ms.f2); - let f2_fft = (data.domain == Domain::Time && f2_domain == Domain::Frequency) - .then(|| planner.plan_fft_forward(f2_n)); - let mut rows_ft: Vec> = Vec::with_capacity(rows); - for r in 0..rows { - if cancelled() { - return None; - } - let mut buf = apply_time_steps(data.row(r).to_vec(), ¶ms.f2, f2_dt); - if let Some(fft) = &f2_fft { - fft.process(&mut buf); - remove_group_delay(&mut buf, data.direct.group_delay); - buf = fftshift(&buf); - } - rows_ft.push(buf); - } - - // For NUS the acquired increments are reconstructed onto the full grid - // first; without a user-supplied schedule no (mirrored/aliased) spectrum is - // produced — the app surfaces a prompt to enter the sampling list. - let t1_rows = match build_t1_rows(data, &rows_ft, f2_n, &mut planner) { - Some(rows) => rows, - None => return Some(empty(data)), - }; - if cancelled() { - return None; - } - let f1_inc = t1_rows.len(); - let f1_dt = sample_interval(data.indirect.spectral_width_hz); - let f1_n = time_step_output_len(f1_inc, ¶ms.f1); - let f1_fft = (data.domain == Domain::Time && f1_domain == Domain::Frequency) - .then(|| planner.plan_fft_forward(f1_n)); - - let mut out = vec![Complex64::new(0.0, 0.0); f1_n * f2_n]; - let mut col: Vec = Vec::with_capacity(f1_n); - for c in 0..f2_n { - if cancelled() { - return None; - } - col.clear(); - for row in t1_rows.iter() { - col.push(row[c]); - } - col = apply_time_steps(std::mem::take(&mut col), ¶ms.f1, f1_dt); - if let Some(fft) = &f1_fft { - fft.process(&mut col); - col = fftshift(&col); - } - for (k, v) in col.iter().copied().enumerate() { - out[k * f2_n + c] = v; - } - } - - let f2_ppm = coordinate_axis(f2_domain, f2_n, &data.direct); - let f1_ppm = coordinate_axis(f1_domain, f1_n, &data.indirect); - - Some(Spectrum2D { - f2_ppm, - f1_ppm, - f2_domain, - f1_domain, - data: out, - f2_size: f2_n, - f1_size: f1_n, - direct: AxisMeta::from(&data.direct), - indirect: AxisMeta::from(&data.indirect), - source: data.source.clone(), - }) -} - -/// Apply the per-axis phase `(phase0, phase1, pivot_frac)` to an unphased -/// [`Spectrum2D`] from [`transform`], in place on a clone. F2 phase depends only -/// on the column (direct index), F1 phase only on the row (indirect index); both -/// are computed on the display (fftshifted) grid, exactly like the 1D kernel. -pub fn reapply_phase_2d(base: &Spectrum2D, f2: (f64, f64, f64), f1: (f64, f64, f64)) -> Spectrum2D { - reapply_phase_2d_cancellable(base, f2, f1, &|| false).expect("non-cancelling phase pass") -} - -pub fn reapply_phase_2d_cancellable( - base: &Spectrum2D, - f2: (f64, f64, f64), - f1: (f64, f64, f64), - cancelled: &impl Fn() -> bool, -) -> Option { - let mut out = base.clone(); - let nr = out.f1_size; - let nc = out.f2_size; - if nr == 0 || nc == 0 { - return Some(out); - } - let d1 = (nr - 1).max(1) as f64; - let d2 = (nc - 1).max(1) as f64; - let (f2p0, f2p1, f2piv) = f2; - let (f1p0, f1p1, f1piv) = f1; - for r in 0..nr { - if cancelled() { - return None; - } - let phi1 = f1p0 + f1p1 * (r as f64 / d1 - f1piv); - for c in 0..nc { - let phi2 = f2p0 + f2p1 * (c as f64 / d2 - f2piv); - out.data[r * nc + c] *= Complex64::from_polar(1.0, -(phi1 + phi2)); - } - } - Some(out) -} - -/// Apply the direct-axis phase `(phase0, phase1, pivot_frac)` to every trace of -/// an unphased stack. -pub fn reapply_phase_stack(base: &StackSpectrum, f2: (f64, f64, f64)) -> StackSpectrum { - reapply_phase_stack_cancellable(base, f2, &|| false).expect("non-cancelling phase pass") -} - -pub fn reapply_phase_stack_cancellable( - base: &StackSpectrum, - f2: (f64, f64, f64), - cancelled: &impl Fn() -> bool, -) -> Option { - let mut out = base.clone(); - let (p0, p1, piv) = f2; - for t in out.traces.iter_mut() { - if cancelled() { - return None; - } - apply_slice(t, p0, p1, piv); - } - Some(out) -} - -/// Pseudo-2D processing: Fourier transform only the direct dimension, keeping -/// each increment as its own 1D spectrum for a stacked display. No indirect FFT -/// or quadrature recombination is applied — the indirect axis is a parameter -/// array (gradient strength, relaxation delay, …), not a frequency. -pub fn stack(data: &NmrData2D, params: &Params2D) -> StackSpectrum { - stack_cancellable(data, params, &|| false).expect("non-cancelling stack transform") -} - -pub fn stack_cancellable( - data: &NmrData2D, - params: &Params2D, - cancelled: &impl Fn() -> bool, -) -> Option { - let cols = data.cols; - let rows = data.rows; - if cols == 0 || rows == 0 { - let direct_domain = params.f2.output_domain(data.domain).unwrap_or(data.domain); - return Some(StackSpectrum { - ppm: Vec::new(), - direct_domain, - traces: Vec::new(), - direct: AxisMeta::from(&data.direct), - source: data.source.clone(), - }); - } - let direct_domain = params - .f2 - .output_domain(data.domain) - .expect("live direct-axis pipeline is domain-valid"); - let f2_dt = sample_interval(data.direct.spectral_width_hz); - let f2_n = time_step_output_len(cols, ¶ms.f2); - let mut planner = FftPlanner::::new(); - let fft = (data.domain == Domain::Time && direct_domain == Domain::Frequency) - .then(|| planner.plan_fft_forward(f2_n)); - - // Unphased traces; the absorptive phase is derived by `reapply_phase_stack`. - let mut traces = Vec::with_capacity(rows); - for r in 0..rows { - if cancelled() { - return None; - } - let mut buf = apply_time_steps(data.row(r).to_vec(), ¶ms.f2, f2_dt); - if let Some(fft) = &fft { - fft.process(&mut buf); - remove_group_delay(&mut buf, data.direct.group_delay); - buf = fftshift(&buf); - } - traces.push(buf); - } - - let ppm = coordinate_axis(direct_domain, f2_n, &data.direct); - - Some(StackSpectrum { - ppm, - direct_domain, - traces, - direct: AxisMeta::from(&data.direct), - source: data.source.clone(), - }) -} - -// Assemble the complex t1 interferogram rows from the F2-transformed stored -// rows, applying the indirect conjugation that fixes the F1 frequency sense. -// Returns `None` only for a NUS dataset with no user-supplied schedule, so the -// caller withholds the spectrum instead of showing a wrong reconstruction. -fn build_t1_rows( - data: &NmrData2D, - rows_ft: &[Vec], - f2_n: usize, - planner: &mut FftPlanner, -) -> Option>> { - if let Some(nus) = &data.nus { - let schedule = nus.schedule.as_ref()?; - return Some(crate::nus::reconstruct_rows( - rows_ft, - nus.echo_antiecho, - schedule, - nus.grid, - f2_n, - data.indirect_conjugate, - crate::nus::DEFAULT_IST_ITERS, - planner, - )); - } - let f1_inc = f1_increments(data.rows, data.quad); - let rows: Vec> = (0..f1_inc) - .map(|k| { - (0..f2_n) - .map(|c| { - let v = combine(rows_ft, k, c, data.quad); - if data.indirect_conjugate { v.conj() } else { v } - }) - .collect() - }) - .collect(); - Some(rows) -} - -/// Number of complex indirect increments the F1 transform actually receives. -pub fn f1_increments(rows: usize, quad: QuadMode) -> usize { - match quad { - QuadMode::Complex => rows, - QuadMode::States | QuadMode::StatesTppi | QuadMode::EchoAntiecho => rows / 2, - } -} - -// Build the complex t1 sample at increment `k`, F2 point `c`, from the -// F2-transformed rows, per the indirect-dimension quadrature scheme. Each stored -// row is already a complex F2 spectrum, so the cosine/sine channels combine -// directly without needing the F2 phase. -fn combine(rows_ft: &[Vec], k: usize, c: usize, quad: QuadMode) -> Complex64 { - match quad { - QuadMode::Complex => rows_ft[k][c], - QuadMode::States => rows_ft[2 * k][c] + Complex64::i() * rows_ft[2 * k + 1][c], - QuadMode::StatesTppi => { - let sign = if (k & 1) == 0 { 1.0 } else { -1.0 }; - (rows_ft[2 * k][c] + Complex64::i() * rows_ft[2 * k + 1][c]) * sign - } - // Echo/anti-echo each select a single coherence pathway, so one row of - // the pair is already a clean phase-modulated t1 series; magnitude mode - // needs no further recombination. - QuadMode::EchoAntiecho => rows_ft[2 * k + 1][c], - } -} - -fn ppm_axis(n: usize, sw_hz: f64, obs_mhz: f64, carrier_ppm: f64) -> Vec { - let hz_per_point = if n > 0 { sw_hz / n as f64 } else { 0.0 }; - let obs = obs_mhz.max(f64::MIN_POSITIVE); - let half = n as f64 / 2.0; - (0..n) - .map(|i| carrier_ppm + (i as f64 - half) * hz_per_point / obs) - .collect() -} - -fn coordinate_axis(domain: Domain, count: usize, dim: &plotx_io::Dim) -> Vec { - match domain { - Domain::Time => { - let dwell = dim.dwell_s(); - (0..count).map(|index| index as f64 * dwell).collect() - } - Domain::Frequency => ppm_axis( - count, - dim.spectral_width_hz, - dim.observe_freq_mhz, - dim.carrier_ppm, - ), - } -} - -/// Estimate a zero-order `(phase0, phase1)` that makes the highest-energy -/// trace's tallest peak purely absorptive-positive: `phase0 = arg(peak)`, -/// `phase1 = 0`. Applied uniformly, this phases the dominant resonance — the one -/// the user reads for a relaxation/diffusion fit — exactly, with its real part -/// carrying the signal and its imaginary (dispersive) part nulled. First-order -/// correction is deliberately skipped: with truncation ringing or spinning -/// sidebands a single ramp cannot phase every peak and tends to spoil the main -/// one. `None` for an empty stack. Seeded into `f2.phase0` at load. -pub fn absorptive_phase(traces: &[Vec]) -> Option<(f64, f64)> { - let energy = |t: &[Complex64]| t.iter().map(|c| c.norm_sqr()).sum::(); - let reference = traces.iter().filter(|t| !t.is_empty()).max_by(|a, b| { - energy(a) - .partial_cmp(&energy(b)) - .unwrap_or(std::cmp::Ordering::Equal) - })?; - let peak = reference.iter().max_by(|a, b| { - a.norm() - .partial_cmp(&b.norm()) - .unwrap_or(std::cmp::Ordering::Equal) - })?; - if peak.norm() <= f64::MIN_POSITIVE { - return None; - } - // arg(peak) rotates the peak onto the positive real axis (apply_phase rotates - // by e^{-iφ}), so its absorptive lobe points up. - Some((peak.arg(), 0.0)) -} - -fn remove_group_delay(spectrum: &mut [Complex64], delay: f64) { - if delay == 0.0 || !delay.is_finite() { - return; - } - let n = spectrum.len(); - if n == 0 { - return; - } - let k = std::f64::consts::TAU * delay / n as f64; - for (m, c) in spectrum.iter_mut().enumerate() { - *c *= Complex64::from_polar(1.0, k * m as f64); - } -} - -fn fftshift(v: &[Complex64]) -> Vec { - let n = v.len(); - let mid = n.div_ceil(2); - let mut out = Vec::with_capacity(n); - out.extend_from_slice(&v[mid..]); - out.extend_from_slice(&v[..mid]); - out -} - -fn empty(data: &NmrData2D) -> Spectrum2D { - Spectrum2D { - f2_ppm: Vec::new(), - f1_ppm: Vec::new(), - f2_domain: data.domain, - f1_domain: data.domain, - data: Vec::new(), - f2_size: 0, - f1_size: 0, - direct: AxisMeta::from(&data.direct), - indirect: AxisMeta::from(&data.indirect), - source: data.source.clone(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::Params2D; - use plotx_io::Dim; - use std::f64::consts::TAU; - - fn dim(sw: f64, obs: f64, nucleus: &str) -> Dim { - Dim { - spectral_width_hz: sw, - observe_freq_mhz: obs, - carrier_ppm: 0.0, - nucleus: nucleus.into(), - group_delay: 0.0, - } - } - - // A single 2D frequency: phase-modulated e^{iΩ2 t2}·e^{iΩ1 t1} with decay, so - // a forward FFT in each dimension lands one magnitude peak at (f1, f2). - fn single_peak_2d(f2_ppm: f64, f1_ppm: f64) -> NmrData2D { - let (cols, rows) = (256usize, 128usize); - let direct = dim(4000.0, 400.0, "1H"); - let indirect = dim(2000.0, 100.0, "13C"); - let dt2 = 1.0 / direct.spectral_width_hz; - let dt1 = 1.0 / indirect.spectral_width_hz; - let f2_hz = f2_ppm * direct.observe_freq_mhz; - let f1_hz = f1_ppm * indirect.observe_freq_mhz; - let mut data = Vec::with_capacity(rows * cols); - for k in 0..rows { - let t1 = k as f64 * dt1; - for j in 0..cols { - let t2 = j as f64 * dt2; - let decay = (-t2 / 0.3 - t1 / 0.3).exp(); - data.push(Complex64::from_polar( - decay, - TAU * (f2_hz * t2 + f1_hz * t1), - )); - } - } - NmrData2D { - data, - rows, - cols, - domain: Domain::Time, - direct, - indirect, - quad: QuadMode::Complex, - indirect_conjugate: false, - experiment: None, - pseudo_axis: None, - diffusion: None, - nus: None, - source: "synthetic 2D".into(), - } - } - - #[test] - fn ft_places_peak_at_expected_shifts() { - let data = single_peak_2d(2.0, 1.0); - let s = transform(&data, &Params2D::default()); - assert_eq!((s.f1_size, s.f2_size), (128, 256)); - - let mag = s.magnitude(); - let (mut best, mut br, mut bc) = (f32::MIN, 0, 0); - for r in 0..s.f1_size { - for c in 0..s.f2_size { - let v = mag[r * s.f2_size + c]; - if v > best { - best = v; - br = r; - bc = c; - } - } - } - // Tolerances are one frequency bin: ~0.04 ppm (F2), ~0.16 ppm (F1). - assert!((s.f2_ppm[bc] - 2.0).abs() < 0.05, "F2 at {}", s.f2_ppm[bc]); - assert!((s.f1_ppm[br] - 1.0).abs() < 0.2, "F1 at {}", s.f1_ppm[br]); - } - - #[test] - fn reapply_phase_makes_peak_absorptive() { - let data = single_peak_2d(2.0, 1.0); - let base = transform(&data, &Params2D::default()); - - let mag = base.magnitude(); - let (mut best, mut idx) = (f32::MIN, 0usize); - for (i, &v) in mag.iter().enumerate() { - if v > best { - best = v; - idx = i; - } - } - let arg = base.data[idx].arg(); - - // Rotating F2 by the peak's argument lands it on the positive real axis: - // real part carries the magnitude, imaginary part nulls. - let phased = reapply_phase_2d(&base, (arg, 0.0, 0.0), (0.0, 0.0, 0.0)); - let peak = phased.data[idx]; - assert!((peak.re - base.data[idx].norm()).abs() < 1e-6); - assert!(peak.im.abs() < 1e-6); - } - - #[test] - fn stack_keeps_one_spectrum_per_increment() { - let data = single_peak_2d(2.0, 1.0); - let s = stack(&data, &Params2D::default()); - assert_eq!(s.increments(), data.rows); - assert_eq!(s.ppm.len(), data.cols); - let peak_ppm = |trace: &[Complex64]| { - let (mut best, mut bi) = (f64::MIN, 0); - for (i, c) in trace.iter().enumerate() { - if c.norm() > best { - best = c.norm(); - bi = i; - } - } - s.ppm[bi] - }; - assert!((peak_ppm(&s.traces[0]) - 2.0).abs() < 0.05); - assert!((peak_ppm(&s.traces[10]) - 2.0).abs() < 0.05); - } - - #[test] - fn stack_does_not_fft_imported_frequency_rows_again() { - let mut data = single_peak_2d(2.0, 1.0); - data.domain = Domain::Frequency; - data.rows = 2; - data.cols = 3; - data.data = (0..6) - .map(|value| Complex64::new(value as f64, -(value as f64))) - .collect(); - let params = Params2D::frequency_domain(crate::Preset2D::Dosy); - - let result = stack(&data, ¶ms); - - assert_eq!(result.direct_domain, Domain::Frequency); - assert_eq!(result.traces[0], data.row(0)); - assert_eq!(result.traces[1], data.row(1)); - } -} diff --git a/crates/processing/src/lib.rs b/crates/processing/src/lib.rs index 2f6f6563..f3b191b7 100644 --- a/crates/processing/src/lib.rs +++ b/crates/processing/src/lib.rs @@ -1,37 +1,43 @@ -//! Signal processing over [`plotx_io::NmrData`]: FID → FFT → phase → baseline. +//! PlotX processing recipes, native NMR execution, and domain-specific analyses. pub mod align; pub mod arithmetic; -pub mod autophase; -pub mod baseline; -pub mod cleanup; pub mod craft; -pub mod fft; -pub mod fft2; -pub mod nus; +pub mod nmr_bridge; +pub mod nmr_execution; mod output; -pub mod phase; -mod preview; pub mod slice; pub mod timeseries; pub mod xps; pub mod xrd; pub use output::{Processed1D, TimeTrace}; -pub use preview::{Preview, process_up_to}; pub use slice::{ProjectionMode, Slice1D, SliceKind}; use num_complex::Complex64; use plotx_io::Domain; +/// Labels for scientific coordinates supported by NMR display views. +pub fn axis_unit_label(unit: Option) -> &'static str { + use nmr::axis::AxisUnit; + match unit { + Some(AxisUnit::Ppm) => "ppm", + Some(AxisUnit::Hertz) => "Hz", + Some(AxisUnit::Second) => "s", + Some(AxisUnit::TeslaPerMeter) => "T/m", + _ => "", + } +} + #[derive(Debug, Clone)] pub struct Spectrum { - /// Chemical-shift axis in ppm, ordered low → high index. The reversed NMR + /// Spectral coordinates in `unit`. The reversed NMR /// display (high ppm on the left) is a rendering concern, not applied here. pub ppm: Vec, pub values: Vec, - pub hz_per_point: f64, - pub observe_freq_mhz: f64, + pub unit: nmr::axis::AxisUnit, + pub hz_per_point: Option, + pub observe_freq_mhz: Option, pub nucleus: String, } @@ -66,6 +72,15 @@ impl Spectrum { self.points(DisplayMode::Real) } + /// Average spacing for UI bounds; numerical validation belongs to nmr. + pub fn coordinate_spacing(&self) -> Option { + if self.ppm.len() < 2 { + return None; + } + let step = (self.ppm.last()? - self.ppm.first()?).abs() / (self.ppm.len() - 1) as f64; + (step.is_finite() && step > 0.0).then_some(step) + } + pub fn ppm_bounds(&self) -> (f64, f64) { let mut lo = f64::INFINITY; let mut hi = f64::NEG_INFINITY; @@ -195,11 +210,7 @@ impl PhaseParams { pivot_frac: 0.0, auto: None, }; - /// Entropy recovers real first-order phase (tens-to-hundreds of degrees) while - /// staying clean on single peaks and under noise, and — once large spectra are - /// downsampled by peak-preserving pooling rather than plain striding (see - /// `autophase::decimate`) — phases real 13C data without spurious negative - /// peaks. See the ground-truth and large-spectrum tests in `tests.rs`. + /// Library entropy estimation with its versioned scientific quality contract. pub const AUTO: Self = Self { auto: Some(AutoPhaseMethod::Entropy), ..Self::MANUAL_ZERO @@ -678,100 +689,6 @@ impl AxisPipeline { } } -pub use fft::transform_base; - -/// Apply one frequency-domain step to an already transformed spectrum. -pub fn apply_freq_step(spec: &mut Spectrum, kind: &StepKind) { - match kind { - StepKind::Phase(p) => { - let (p0, p1, piv) = match p.auto { - Some(m) => auto_phase(spec, m), - None => (p.phase0, p.phase1, p.pivot_frac), - }; - phase::apply_with_pivot(spec, p0, p1, piv); - } - StepKind::Baseline(m) => baseline::apply(spec, *m), - StepKind::Reference(r) => { - let delta = r.target_ppm - r.at_ppm; - for p in &mut spec.ppm { - *p += delta; - } - } - StepKind::Magnitude => { - for c in &mut spec.values { - *c = Complex64::new(c.norm(), 0.0); - } - } - StepKind::Smooth(m) => cleanup::smooth(spec, *m), - StepKind::Normalize(m) => cleanup::normalize(spec, *m), - StepKind::Bin(p) => cleanup::bin(spec, *p), - StepKind::Reverse => cleanup::reverse(spec), - StepKind::Invert => cleanup::invert(spec), - StepKind::Apodize(_) | StepKind::ZeroFill(_) | StepKind::Fft => {} - } -} - -/// Cheap stage: apply the enabled frequency-domain steps in list order to an -/// unphased `base` from [`transform_base`], producing the display spectrum. -pub fn reapply(base: &Spectrum, pipe: &AxisPipeline) -> Spectrum { - let mut spec = base.clone(); - for step in &pipe.steps { - if step.enabled && !step.kind.at_or_before_fft() { - apply_freq_step(&mut spec, &step.kind); - } - } - spec -} - -pub fn transform_output_base( - data: &plotx_io::NmrData, - pipe: &AxisPipeline, - group_delay_correct: bool, -) -> Result { - match pipe.output_domain(data.domain)? { - Domain::Time => Ok(Processed1D::Time(fft::transform_time(data, pipe))), - Domain::Frequency => Ok(Processed1D::Frequency(transform_base( - data, - pipe, - group_delay_correct, - ))), - } -} - -pub fn reapply_output(base: &Processed1D, pipe: &AxisPipeline) -> Processed1D { - match base { - Processed1D::Time(trace) => Processed1D::Time(trace.clone()), - Processed1D::Frequency(spectrum) => Processed1D::Frequency(reapply(spectrum, pipe)), - } -} - -pub fn process_output( - data: &plotx_io::NmrData, - pipe: &AxisPipeline, - group_delay_correct: bool, -) -> Result { - transform_output_base(data, pipe, group_delay_correct).map(|base| reapply_output(&base, pipe)) -} - -/// Full 1D pipeline, preserving whether the recipe ends in time or frequency. -/// -/// Callers that specifically require a spectrum must inspect the returned -/// [`Processed1D`] instead of turning a valid time-domain output into a panic. -pub fn process( - data: &plotx_io::NmrData, - pipe: &AxisPipeline, - group_delay_correct: bool, -) -> Result { - process_output(data, pipe, group_delay_correct) -} - -/// Compute a phase `(phase0, phase1, pivot_frac)` from the spectrum itself, per -/// the chosen [`AutoPhaseMethod`]. The ramp pivots at the tallest peak so the -/// on-plot handle is consistent across methods. See [`autophase`] for the rules. -pub fn auto_phase(spec: &Spectrum, method: AutoPhaseMethod) -> (f64, f64, f64) { - autophase::compute(&spec.values, method) -} - fn time_side(pipe: &AxisPipeline) -> Vec<(StepKind, bool)> { pipe.steps .iter() @@ -782,7 +699,7 @@ fn time_side(pipe: &AxisPipeline) -> Vec<(StepKind, bool)> { /// Whether moving from `a` to `b` requires re-running the FFT: true iff the /// at-or-before-FFT subsequence (kinds, params, enabled, order) differs, or the -/// group-delay flags differ. Frequency-only edits need only a cheap [`reapply`]. +/// group-delay flags differ. Frequency-only edits need only a cached-base library pass. pub fn needs_retransform(a: &AxisPipeline, b: &AxisPipeline, gd_a: bool, gd_b: bool) -> bool { gd_a != gd_b || time_side(a) != time_side(b) } diff --git a/crates/processing/src/nmr_bridge.rs b/crates/processing/src/nmr_bridge.rs new file mode 100644 index 00000000..c32acba0 --- /dev/null +++ b/crates/processing/src/nmr_bridge.rs @@ -0,0 +1,568 @@ +//! Recipe compilation and staged estimation through the nmr public API. + +use crate::{Apodization, AxisPipeline, PhaseParams, StepId, StepKind, ZeroFill}; +use nmr::acquisition::GroupDelayState; +use nmr::axis::AxisDomain; +use nmr::processing::{ + DelaySource, DigitalFilterCorrection, FourierTransform, PhaseCorrection, ProcessingError, + ProcessingErrorCode, ProcessingOperation as Op, ProcessingOptions, ProcessingPlan, + SpectrumOperation, Window, +}; +use nmr::{Dataset, ExecutionContext, dataset::DescriptorRef}; +use std::{collections::BTreeSet, sync::Arc}; + +#[path = "nmr_bridge_phase.rs"] +mod phase; +pub use phase::{PhaseReport, RepresentativeTrace}; + +pub struct RecipeExecution { + pub dataset: Arc, + /// Estimation provenance for the owning recipe. Series history records the + /// shared explicit correction; this report also identifies its representative. + pub phases: Vec, +} + +#[derive(Debug, thiserror::Error)] +pub enum RecipeError { + #[error("invalid recipe: {0}")] + Invalid(String), + #[error("NMR processing failed at step {step:?}: {source}")] + Library { + step: Option, + #[source] + source: ProcessingError, + }, +} + +impl RecipeError { + pub fn is_cancelled(&self) -> bool { + matches!(self, Self::Library { source, .. } if source.code() == ProcessingErrorCode::Cancelled) + } +} + +#[derive(Clone, Copy, Debug)] +pub enum RecipeRange { + All, + /// Time prefix including the enabled FFT; cache this result before phasing. + Base, + /// Frequency suffix applied to an already cached base. + Frequency, + /// Preview through this stable step; disabled steps remain skipped. + Through(StepId), +} + +/// An invocation override, not a second persisted source of delay state. +#[derive(Clone, Copy, Debug)] +pub enum DelayPolicy { + Disabled, + AxisEvidence, + Explicit(f64), +} + +/// Bound to immutable input so a prepared recipe cannot accidentally execute +/// against a replacement dataset. Local operation positions never escape as IDs. +pub struct CompiledRecipe { + input: Arc, + segments: Vec, + step_ids: Vec, +} + +enum Segment { + Setup(ProcessingPlan), + Plan { + plan: ProcessingPlan, + ids: Vec, + }, + Phase { + axis: usize, + params: PhaseParams, + id: StepId, + }, +} + +impl CompiledRecipe { + /// A deterministic prefix for the library NUS executor. Estimators require + /// their staged input and cannot be flattened into this prefix. + pub fn deterministic_plan(&self) -> Result { + let mut operations = Vec::new(); + for segment in &self.segments { + match segment { + Segment::Setup(plan) | Segment::Plan { plan, .. } => { + operations.extend_from_slice(plan.operations()) + } + Segment::Phase { .. } => { + return Err(RecipeError::Invalid( + "NUS direct prefix cannot contain phase estimation".into(), + )); + } + } + } + ProcessingPlan::new(operations) + .map_err(|source| RecipeError::Library { step: None, source }) + } + pub fn step_id(&self, local_index: usize) -> Option { + self.step_ids.get(local_index).copied() + } + + pub fn execute( + &self, + options: ProcessingOptions, + context: &mut ExecutionContext<'_>, + ) -> Result, RecipeError> { + self.execute_with_report(options, context) + .map(|result| result.dataset) + } + + pub fn execute_with_report( + &self, + options: ProcessingOptions, + context: &mut ExecutionContext<'_>, + ) -> Result { + context + .check_cancelled() + .map_err(|error| self.locate(error.into()))?; + let mut output = Arc::clone(&self.input); + let mut phases = Vec::new(); + for segment in &self.segments { + output = Arc::new(match segment { + Segment::Setup(plan) => plan + .apply_with_context(&output, options, context) + .map_err(|source| RecipeError::Library { step: None, source })?, + Segment::Plan { plan, ids } => plan + .apply_with_context(&output, options, context) + .map_err(|source| RecipeError::Library { + step: source.step_index().and_then(|i| ids.get(i).copied()), + source, + })?, + Segment::Phase { axis, params, id } => { + let result = if let Some(method) = params.auto { + phase::apply_auto( + &output, + *axis, + *id, + phase_method(method), + options, + context, + ) + .map(|(dataset, report)| { + phases.push(report); + dataset + }) + } else { + apply_phase(&output, *axis, *params, options, context) + }; + result.map_err(|source| RecipeError::Library { + step: Some(*id), + source, + })? + } + }); + } + Ok(RecipeExecution { + dataset: output, + phases, + }) + } + + fn locate(&self, source: ProcessingError) -> RecipeError { + RecipeError::Library { + step: source.step_index().and_then(|index| self.step_id(index)), + source, + } + } +} + +pub fn compile( + input: Arc, + pipeline: &AxisPipeline, + axis: usize, + delay: DelayPolicy, + range: RecipeRange, +) -> Result { + let mut seen = BTreeSet::new(); + if pipeline.steps.iter().any(|step| !seen.insert(step.id)) { + return Err(RecipeError::Invalid("duplicate StepId".into())); + } + let end = match range { + RecipeRange::Through(id) => pipeline + .steps + .iter() + .position(|step| step.id == id) + .map(|index| index + 1) + .ok_or_else(|| RecipeError::Invalid(format!("preview step {id:?} does not exist")))?, + _ => pipeline.steps.len(), + }; + let (mut points, mut domain) = match input.descriptor() { + DescriptorRef::Raw(descriptor) => descriptor + .axes() + .get(axis) + .map(|axis| (axis.points(), axis.domain())), + DescriptorRef::Processed(descriptor) => descriptor + .axes() + .get(axis) + .map(|axis| (axis.points(), axis.domain())), + _ => None, + } + .ok_or_else(|| RecipeError::Invalid(format!("axis {axis} does not exist")))?; + let mut ops = Vec::new(); + let mut ids = Vec::new(); + let mut segments = Vec::new(); + if !matches!(range, RecipeRange::Frequency) + && let Some(raw) = input.as_raw() + { + let decoding: Vec<_> = raw + .descriptor() + .axes() + .iter() + .enumerate() + .filter_map(|(axis, value)| { + matches!( + value.kind(), + nmr::raw::RawAxisKind::Indirect(nmr::raw::IndirectComponents::Encoded(_)) + ) + .then_some(Op::ComponentTransform { axis }) + }) + .collect(); + if !decoding.is_empty() { + segments + .push(Segment::Setup(ProcessingPlan::new(decoding).map_err( + |source| RecipeError::Library { step: None, source }, + )?)); + } + } + let mut step_ids = Vec::new(); + for step in &pipeline.steps[..end] { + if !step.enabled { + continue; + } + match range { + RecipeRange::Frequency if step.kind.at_or_before_fft() => continue, + RecipeRange::Base if !step.kind.at_or_before_fft() => break, + _ => {} + } + let required = match step.kind.input_domain() { + plotx_io::Domain::Time => AxisDomain::Time, + plotx_io::Domain::Frequency => AxisDomain::Frequency, + }; + if domain != required { + return Err(RecipeError::Invalid(format!( + "step {:?} ({}) requires {required:?}, found {domain:?}", + step.id, + step.kind.label() + ))); + } + let operation = match step.kind { + StepKind::Apodize(Apodization::None) | StepKind::ZeroFill(ZeroFill::None) => None, + StepKind::Apodize(Apodization::CosineBell) => Some(Op::Window { + axis, + window: Window::SineBell { + offset: 0.5, + end: 1.0, + power: 1.0, + first_point_scale: 1.0, + }, + }), + StepKind::Apodize(Apodization::Exponential { lb_hz }) => Some(Op::Window { + axis, + window: Window::Exponential { lb_hz }, + }), + StepKind::Apodize(Apodization::Gaussian { lb_hz, gb_hz }) => Some(Op::Window { + axis, + window: Window::lorentz_to_gauss(lb_hz, gb_hz).map_err(|source| { + RecipeError::Library { + step: Some(step.id), + source, + } + })?, + }), + StepKind::ZeroFill(fill) => { + points = zero_fill_target(fill, points).map_err(|source| RecipeError::Library { + step: Some(step.id), + source, + })?; + Some(Op::ZeroFill { + axis, + zero_fill: nmr::processing::ZeroFill::new(points).map_err(|source| { + RecipeError::Library { + step: Some(step.id), + source, + } + })?, + }) + } + StepKind::Fft => { + domain = AxisDomain::Frequency; + Some(Op::FourierTransform { + axis, + transform: FourierTransform::default(), + }) + } + StepKind::Phase(params) => { + flush_plan(&mut segments, &mut ops, &mut ids)?; + segments.push(Segment::Phase { + axis, + params, + id: step.id, + }); + step_ids.push(step.id); + None + } + StepKind::Baseline(method) => Some(spectrum_op( + axis, + SpectrumOperation::Baseline(baseline(method)), + )), + StepKind::Magnitude => Some(spectrum_op(axis, SpectrumOperation::Magnitude)), + StepKind::Reference(reference) => Some(spectrum_op( + axis, + SpectrumOperation::Reference { + delta_ppm: reference.target_ppm - reference.at_ppm, + }, + )), + StepKind::Smooth(method) => Some(spectrum_op( + axis, + match method { + crate::SmoothMethod::MovingAverage { window } => { + SpectrumOperation::MovingAverage { + window: usize::from(window), + } + } + crate::SmoothMethod::SavitzkyGolay { window, poly_order } => { + SpectrumOperation::SavitzkyGolay { + window: usize::from(window), + order: usize::from(poly_order), + } + } + }, + )), + StepKind::Normalize(method) => Some(spectrum_op( + axis, + SpectrumOperation::Normalize(match method { + crate::NormalizeMethod::MaxPeak => nmr::processing::Normalization::MaxPeak, + crate::NormalizeMethod::TotalArea => { + nmr::processing::Normalization::TotalArea { + singleton_width: None, + } + } + crate::NormalizeMethod::Constant { divisor } => { + nmr::processing::Normalization::Constant(divisor) + } + }), + )), + StepKind::Bin(bin) => Some(spectrum_op( + axis, + SpectrumOperation::Bin { + width: bin.width, + aggregation: match bin.method { + crate::BinMethod::Sum => nmr::processing::BinAggregation::Sum, + crate::BinMethod::Mean => nmr::processing::BinAggregation::Mean, + }, + }, + )), + StepKind::Reverse => Some(spectrum_op(axis, SpectrumOperation::Reverse)), + StepKind::Invert => Some(spectrum_op(axis, SpectrumOperation::Invert)), + }; + if let Some(operation) = operation { + ops.push(operation); + ids.push(step.id); + step_ids.push(step.id); + } + if matches!(step.kind, StepKind::Fft) { + if let Some(correction) = + delay_correction(&input, axis, delay).map_err(|source| RecipeError::Library { + step: Some(step.id), + source, + })? + { + ops.push(Op::DigitalFilterCorrection { axis, correction }); + ids.push(step.id); + step_ids.push(step.id); + } + let has_reference = input + .as_raw() + .and_then(|raw| raw.descriptor().axes().get(axis)) + .is_some_and(|axis| axis.chemical_shift_reference().is_some()) + || input + .as_processed() + .and_then(|processed| processed.axis_evidence(axis)) + .is_some_and(|evidence| evidence.chemical_shift_reference().is_some()); + if has_reference { + ops.push(Op::ResolveFrequencyFrame { + axis, + frame: nmr::processing::FrequencyFrame::Ppm( + nmr::processing::ReferenceSource::AxisEvidence, + ), + }); + ids.push(step.id); + step_ids.push(step.id); + } + if matches!(range, RecipeRange::Base) { + break; + } + } + } + flush_plan(&mut segments, &mut ops, &mut ids)?; + Ok(CompiledRecipe { + input, + segments, + step_ids, + }) +} + +fn flush_plan( + segments: &mut Vec, + ops: &mut Vec, + ids: &mut Vec, +) -> Result<(), RecipeError> { + if !ops.is_empty() { + let plan = ProcessingPlan::new(std::mem::take(ops)) + .map_err(|source| RecipeError::Library { step: None, source })?; + segments.push(Segment::Plan { + plan, + ids: std::mem::take(ids), + }); + } + Ok(()) +} + +fn spectrum_op(axis: usize, operation: SpectrumOperation) -> Op { + Op::Spectrum { axis, operation } +} + +fn baseline(method: crate::BaselineMethod) -> nmr::processing::RealBaseline { + match method { + crate::BaselineMethod::Offset => nmr::processing::RealBaseline::Offset, + crate::BaselineMethod::Polynomial { order } => nmr::processing::RealBaseline::Polynomial { + order: usize::from(order), + }, + crate::BaselineMethod::AsymmetricLeastSquares { + smoothness, + asymmetry, + iterations, + } => nmr::processing::RealBaseline::Asls { + lambda: smoothness, + asymmetry, + iterations: usize::from(iterations), + }, + } +} + +fn phase_method(method: crate::AutoPhaseMethod) -> nmr::processing::PhaseMethod { + match method { + crate::AutoPhaseMethod::AbsorptivePeak => nmr::processing::PhaseMethod::AbsorptivePeak, + crate::AutoPhaseMethod::Entropy => nmr::processing::PhaseMethod::Entropy, + crate::AutoPhaseMethod::NegativeMinimization => { + nmr::processing::PhaseMethod::NegativeMinimization + } + crate::AutoPhaseMethod::PeakRegression => nmr::processing::PhaseMethod::PeakRegression, + crate::AutoPhaseMethod::RobustConsensus => nmr::processing::PhaseMethod::RobustConsensus, + } +} + +fn apply_phase( + input: &Dataset, + axis: usize, + params: PhaseParams, + options: ProcessingOptions, + context: &mut ExecutionContext<'_>, +) -> Result { + // A previous bin may have changed the length; resolve the endpoint convention + // from the actual intermediate descriptor, never the original input shape. + let points = input + .as_processed() + .and_then(|p| p.descriptor().axes().get(axis)) + .ok_or(ProcessingError::InvalidParameter("phase input axis"))? + .points(); + ProcessingPlan::new(vec![Op::PhaseCorrection { + axis, + correction: manual_phase(params, points)?, + }])? + .apply_with_context(input, options, context) +} + +fn zero_fill_target(fill: ZeroFill, points: usize) -> Result { + match fill { + ZeroFill::None => Ok(points), + ZeroFill::Size(size) => Ok(size.max(points)), + ZeroFill::Factor(factor) => points + .checked_next_power_of_two() + .and_then(|base| { + 1usize + .checked_shl(u32::from(factor.saturating_sub(1))) + .and_then(|multiplier| base.checked_mul(multiplier)) + }) + .ok_or(ProcessingError::SizeOverflow), + } +} + +/// PlotX uses exp(-i phi), radians and i/(N-1). nmr uses exp(+i phi), +/// degrees and i/N. Pivot is an index fraction regardless of coordinate direction. +fn manual_phase(params: PhaseParams, points: usize) -> Result { + if !params.pivot_frac.is_finite() || !(0.0..=1.0).contains(¶ms.pivot_frac) { + return Err(ProcessingError::InvalidParameter("phase pivot")); + } + if points == 1 { + PhaseCorrection::new( + (-params.phase0 + params.phase1 * params.pivot_frac).to_degrees(), + 0.0, + 0.0, + ) + } else { + let scale = points as f64 / (points - 1) as f64; + PhaseCorrection::new( + -params.phase0.to_degrees(), + -params.phase1.to_degrees() * scale, + params.pivot_frac / scale, + ) + } +} + +fn delay_correction( + input: &Dataset, + axis: usize, + policy: DelayPolicy, +) -> Result, ProcessingError> { + use nmr::processed::ProcessedGroupDelay; + let state = input + .as_raw() + .and_then(|raw| raw.descriptor().axes().get(axis)) + .map(|axis| match axis.group_delay() { + GroupDelayState::NotApplicable => ProcessedGroupDelay::NotApplicable, + GroupDelayState::Pending(delay) => ProcessedGroupDelay::Pending(delay), + _ => ProcessedGroupDelay::Unknown, + }) + .or_else(|| { + input + .as_processed()? + .axis_evidence(axis) + .map(|e| e.group_delay()) + }); + let correction = match policy { + DelayPolicy::Disabled => return Ok(None), + DelayPolicy::Explicit(0.0) => { + // Explicitly disabling correction is different from certifying unknown + // hardware delay as zero. nmr requires established zero evidence. + DigitalFilterCorrection::AcknowledgeZeroDelayV1 + } + DelayPolicy::Explicit(value) => { + DigitalFilterCorrection::FrequencyDomainPhaseRampV1(DelaySource::Explicit(value)) + } + DelayPolicy::AxisEvidence => match state { + Some(ProcessedGroupDelay::NotApplicable | ProcessedGroupDelay::Corrected { .. }) => { + return Ok(None); + } + Some(ProcessedGroupDelay::Pending(delay)) if delay.delay_points() == 0.0 => { + DigitalFilterCorrection::AcknowledgeZeroDelayV1 + } + _ => DigitalFilterCorrection::FrequencyDomainPhaseRampV1(DelaySource::AxisEvidence), + }, + }; + Ok(Some(correction)) +} + +#[cfg(test)] +#[path = "nmr_bridge_tests.rs"] +mod tests; + +#[cfg(test)] +#[path = "nmr_bridge_staged_tests.rs"] +mod staged_tests; diff --git a/crates/processing/src/nmr_bridge_phase.rs b/crates/processing/src/nmr_bridge_phase.rs new file mode 100644 index 00000000..4b3fd61b --- /dev/null +++ b/crates/processing/src/nmr_bridge_phase.rs @@ -0,0 +1,195 @@ +//! Representative selection is application policy; estimation and rotation belong to nmr. + +use super::*; +use nmr::processing::PhaseMethod; + +#[derive(Clone, Debug)] +pub struct PhaseReport { + pub step: StepId, + pub axis: usize, + pub points: usize, + pub display_pivot: usize, + pub method: PhaseMethod, + pub correction: PhaseCorrection, + pub objective: f64, + pub evaluations: usize, + pub input: nmr::provenance::CanonicalDatasetDigests, + pub representative: Option, +} + +impl PhaseReport { + /// Convert the library's exp(+i phase), i/N convention to the recipe's + /// exp(-i phase), i/(N-1) convention without estimating another correction. + pub fn recipe_parameters(&self) -> (f64, f64, f64) { + let scale = if self.points > 1 { + (self.points - 1) as f64 / self.points as f64 + } else { + 1.0 + }; + let phase1 = -self.correction.p1_degrees().to_radians() * scale; + let pivot = if self.points > 1 { + self.display_pivot as f64 / (self.points - 1) as f64 + } else { + 0.0 + }; + let phase0 = -self.correction.p0_degrees().to_radians() + + phase1 * (pivot - self.correction.pivot_fraction() / scale); + (phase0, phase1, pivot) + } +} + +/// Logical selection bound to `PhaseReport::input`, not application collection positions. +#[derive(Clone, Debug)] +pub struct RepresentativeTrace { + pub removed_axis: usize, + pub index: usize, + pub component: usize, + pub input: nmr::provenance::CanonicalDatasetDigests, +} + +pub(super) fn apply_auto( + input: &Dataset, + axis: usize, + step: StepId, + method: PhaseMethod, + options: ProcessingOptions, + context: &mut ExecutionContext<'_>, +) -> Result<(Dataset, PhaseReport), ProcessingError> { + let processed = input + .as_processed() + .ok_or(ProcessingError::InvalidParameter( + "automatic phase requires processed input", + ))?; + let axes = processed.descriptor().axes(); + if axes.len() == 1 { + let estimate = method + .prepare(input, axis, options)? + .estimate_with_context(context)?; + let output = estimate.apply_with_context(input, options, context)?; + let mut pivot = 0; + let mut peak = -1.0_f64; + for point in 0..axes[0].points() { + if point % 4096 == 0 { + context.check_cancelled()?; + } + let re = processed + .data() + .get(&[point], &[0]) + .map_err(|_| ProcessingError::InvalidParameter("phase pivot"))?; + let im = processed + .data() + .get(&[point], &[1]) + .map_err(|_| ProcessingError::InvalidParameter("phase pivot"))?; + if re.hypot(im) > peak { + pivot = point; + peak = re.hypot(im); + } + } + return Ok((output, report(input, axis, step, &estimate, None, pivot))); + } + if axes.len() != 2 || axis >= 2 { + return Err(ProcessingError::InvalidParameter("automatic phase axis")); + } + // Choose the trace through the strongest Cartesian component. Selecting by + // peak amplitude avoids overflow from summing squared, unscaled samples and + // also handles an exactly zero real plane in hypercomplex input. + let other = 1 - axis; + let shared_pair = axes.iter().any(|axis| { + matches!( + axis.component_basis(), + nmr::processed::ComponentBasis::SharedComplex { .. } + ) + }); + let data = processed.data(); + let mut peak = -1.0_f64; + let mut selection = (0, 0); + let mut display_pivot = 0; + for index in 0..axes[other].points() { + context.check_cancelled()?; + for component in 0..axes[other].component_count() { + for point in 0..axes[axis].points() { + if point % 4096 == 0 { + context.check_cancelled()?; + } + let mut coordinates = [0; 2]; + coordinates[axis] = point; + coordinates[other] = index; + for channel in 0..axes[axis].component_count() { + let mut components = [0; 2]; + components[axis] = channel; + components[other] = component; + let value = data.get(&coordinates, &components).map_err(|_| { + ProcessingError::InvalidParameter("representative phase component") + })?; + if value.abs() > peak { + peak = value.abs(); + // Shared fields form one complex trace even when its + // imaginary field is strongest; Slice selects that pair at zero. + selection = (index, if shared_pair { 0 } else { component }); + display_pivot = point; + } + } + } + } + } + let representative = ProcessingPlan::new(vec![spectrum_op( + other, + SpectrumOperation::Slice { + index: selection.0, + component: selection.1, + }, + )])? + .apply_with_context(input, options, context)?; + let phase = method + .prepare(&representative, 0, options)? + .estimate_with_context(context)?; + let output = ProcessingPlan::new(vec![Op::PhaseCorrection { + axis, + correction: phase.correction(), + }])? + .apply_with_context(input, options, context)?; + let representative = RepresentativeTrace { + removed_axis: other, + index: selection.0, + component: selection.1, + input: representative.canonical_digests(), + }; + Ok(( + output, + report( + input, + axis, + step, + &phase, + Some(representative), + display_pivot, + ), + )) +} + +fn report( + input: &Dataset, + axis: usize, + step: StepId, + phase: &nmr::processing::PhaseEstimate, + representative: Option, + display_pivot: usize, +) -> PhaseReport { + PhaseReport { + step, + axis, + points: input + .as_processed() + .expect("phase input is processed") + .descriptor() + .axes()[axis] + .points(), + display_pivot, + method: phase.method(), + correction: phase.correction(), + objective: phase.objective(), + evaluations: phase.evaluations(), + input: input.canonical_digests(), + representative, + } +} diff --git a/crates/processing/src/nmr_bridge_staged_tests.rs b/crates/processing/src/nmr_bridge_staged_tests.rs new file mode 100644 index 00000000..914d947b --- /dev/null +++ b/crates/processing/src/nmr_bridge_staged_tests.rs @@ -0,0 +1,302 @@ +use super::*; +use crate::{AutoPhaseMethod, BaselineMethod, BinMethod, BinParams, ProcessingStep, StepSource}; +use nmr::Complex64; +use nmr::axis::{AxisCoordinates, AxisRole, AxisUnit}; +use nmr::processed::{ + ComponentBasis, ProcessedAxis, ProcessedDataset, ProcessedOrigin, ProcessedProvenance, +}; + +fn spectrum(values: Vec) -> Arc { + Arc::new( + ProcessedDataset::from_complex_trace( + ProcessedAxis::new( + AxisRole::Signal, + AxisDomain::Frequency, + Some(AxisUnit::Ppm), + values.len(), + AxisCoordinates::Uniform { + start: 0.0, + step: 1.0, + }, + ComponentBasis::Cartesian, + ) + .unwrap(), + values, + ProcessedProvenance::new(ProcessedOrigin::Unknown, vec![]).unwrap(), + ) + .unwrap() + .into(), + ) +} + +fn pipe(kinds: impl IntoIterator) -> AxisPipeline { + AxisPipeline { + steps: kinds + .into_iter() + .enumerate() + .map(|(i, kind)| { + ProcessingStep::new(StepId::new(71 + i as u64 * 3), kind, StepSource::User) + }) + .collect(), + } +} + +fn run(input: Arc, pipeline: &AxisPipeline) -> Result, RecipeError> { + compile(input, pipeline, 0, DelayPolicy::Disabled, RecipeRange::All)? + .execute(ProcessingOptions::new(), &mut ExecutionContext::default()) +} + +#[test] +fn bin_then_manual_phase_resolves_the_actual_output_length() { + let params = PhaseParams { + phase0: 0.4, + phase1: 1.2, + pivot_frac: 0.3, + auto: None, + }; + let result = run( + spectrum(vec![Complex64::new(1.0, 2.0); 5]), + &pipe([ + StepKind::Bin(BinParams { + width: 2.0, + method: BinMethod::Mean, + }), + StepKind::Phase(params), + ]), + ) + .unwrap(); + let processed = result.as_processed().unwrap(); + assert_eq!( + processed.descriptor().axes()[0] + .coordinate_iter() + .unwrap() + .collect::>(), + [0.5, 2.5, 4.0] + ); + for i in 0..3 { + let expected = Complex64::new(1.0, 2.0) + * Complex64::from_polar(1.0, -(0.4 + 1.2 * (i as f64 / 2.0 - 0.3))); + let actual = Complex64::new( + processed.data().get(&[i], &[0]).unwrap(), + processed.data().get(&[i], &[1]).unwrap(), + ); + assert!((actual - expected).norm() < 1e-13); + } +} + +#[test] +fn estimator_errors_keep_the_recipe_identity_and_leave_the_input_unchanged() { + let input = spectrum(vec![Complex64::new(1.0, 2.0); 8]); + let digest = input.canonical_digests(); + let pipeline = pipe([ + StepKind::Invert, + StepKind::Phase(PhaseParams { + auto: Some(AutoPhaseMethod::PeakRegression), + ..PhaseParams::MANUAL_ZERO + }), + StepKind::Baseline(BaselineMethod::Offset), + ]); + let error = run(Arc::clone(&input), &pipeline).unwrap_err(); + assert!( + matches!(error, RecipeError::Library { step: Some(id), .. } if id == pipeline.steps[1].id) + ); + assert_eq!(input.canonical_digests(), digest); +} + +#[test] +fn estimator_and_later_segments_replay_and_survive_an_offline_snapshot() { + let input = spectrum( + (0..128) + .map(|i| { + let d = (i as f64 - 64.0) / 3.0; + Complex64::new(1.0, d) / (1.0 + d * d) * Complex64::from_polar(1.0, 0.7) + }) + .collect(), + ); + let pipeline = pipe([ + StepKind::Phase(PhaseParams { + auto: Some(AutoPhaseMethod::AbsorptivePeak), + ..PhaseParams::MANUAL_ZERO + }), + StepKind::Phase(PhaseParams { + phase0: 0.2, + ..PhaseParams::MANUAL_ZERO + }), + StepKind::Baseline(BaselineMethod::Offset), + StepKind::Invert, + ]); + let output = run(Arc::clone(&input), &pipeline).unwrap(); + let history = output + .as_processed() + .unwrap() + .provenance() + .history() + .unwrap(); + let replay = history + .replay(&[input.as_ref()], ProcessingOptions::new()) + .unwrap(); + assert_eq!(replay.canonical_digests(), output.canonical_digests()); + let mut bytes = Vec::new(); + plotx_io::nmr_bridge::snapshot::write( + &output, + &mut bytes, + Default::default(), + &mut ExecutionContext::default(), + ) + .unwrap(); + let restored = plotx_io::nmr_bridge::snapshot::read( + &mut bytes.as_slice(), + Default::default(), + &mut ExecutionContext::default(), + ) + .unwrap(); + assert_eq!(restored.canonical_digests(), output.canonical_digests()); + let after = run(restored, &pipe([StepKind::Invert])).unwrap(); + for (a, b) in after + .as_dense_processed() + .unwrap() + .samples() + .iter() + .zip(output.as_dense_processed().unwrap().samples()) + { + assert_eq!(*a, -*b); + } +} + +#[test] +fn axis_magnitude_retains_the_other_cartesian_component() { + let axis = || { + ProcessedAxis::new( + AxisRole::Signal, + AxisDomain::Frequency, + Some(AxisUnit::Ppm), + 1, + AxisCoordinates::Explicit(vec![1.0]), + ComponentBasis::Cartesian, + ) + .unwrap() + }; + let input = Arc::new( + ProcessedDataset::from_dense_samples( + nmr::processed::ProcessedDescriptor::new(vec![axis(), axis()]).unwrap(), + vec![3.0, 4.0, 5.0, 12.0], + ProcessedProvenance::new(ProcessedOrigin::Unknown, vec![]).unwrap(), + ) + .unwrap() + .into(), + ); + let output = compile( + input, + &pipe([StepKind::Magnitude]), + 1, + DelayPolicy::Disabled, + RecipeRange::All, + ) + .unwrap() + .execute(ProcessingOptions::new(), &mut ExecutionContext::default()) + .unwrap(); + assert_eq!( + output + .as_processed() + .unwrap() + .descriptor() + .component_counts(), + [2, 1] + ); + assert_eq!(output.as_dense_processed().unwrap().samples(), [5.0, 13.0]); + let rotated = run( + output, + &pipe([StepKind::Phase(PhaseParams { + phase0: std::f64::consts::FRAC_PI_2, + ..PhaseParams::MANUAL_ZERO + })]), + ) + .unwrap(); + let values = rotated.as_dense_processed().unwrap().samples(); + assert!((values[0] - 13.0).abs() < 1e-12); + assert!((values[1] + 5.0).abs() < 1e-12); +} + +#[test] +fn series_phase_uses_one_representative_and_preserves_parameter_coordinates() { + let parameter = ProcessedAxis::new( + AxisRole::ArrayParameter, + AxisDomain::Parameter, + Some(AxisUnit::Second), + 3, + AxisCoordinates::Explicit(vec![0.003, 0.001, 0.001]), + ComponentBasis::Scalar, + ) + .unwrap(); + let signal = ProcessedAxis::new( + AxisRole::Signal, + AxisDomain::Frequency, + Some(AxisUnit::Ppm), + 8, + AxisCoordinates::Uniform { + start: 4.0, + step: -0.5, + }, + ComponentBasis::Cartesian, + ) + .unwrap(); + let samples: Vec = [1.0, 3.0, -2.0] + .into_iter() + .flat_map(|scale| { + (0..8).flat_map(move |point| { + let value = Complex64::from_polar(scale * if point == 3 { 10.0 } else { 0.1 }, 0.7); + [value.re, value.im] + }) + }) + .collect(); + let input = Arc::new( + ProcessedDataset::from_dense_samples( + nmr::processed::ProcessedDescriptor::new(vec![parameter, signal]).unwrap(), + samples, + ProcessedProvenance::new(ProcessedOrigin::Unknown, vec![]).unwrap(), + ) + .unwrap() + .into(), + ); + let output = compile( + Arc::clone(&input), + &pipe([StepKind::Phase(PhaseParams { + auto: Some(AutoPhaseMethod::AbsorptivePeak), + ..PhaseParams::MANUAL_ZERO + })]), + 1, + DelayPolicy::Disabled, + RecipeRange::All, + ) + .unwrap() + .execute_with_report(ProcessingOptions::new(), &mut ExecutionContext::default()) + .unwrap(); + let report = &output.phases[0]; + assert_eq!(report.step, StepId::new(71)); + assert_eq!(report.method, nmr::processing::PhaseMethod::AbsorptivePeak); + assert_eq!(report.input, input.canonical_digests()); + let selection = report.representative.as_ref().unwrap(); + assert_eq!( + (selection.removed_axis, selection.index, selection.component), + (0, 1, 0) + ); + let processed = output.dataset.as_processed().unwrap(); + let replay = processed + .provenance() + .history() + .unwrap() + .replay(&[input.as_ref()], ProcessingOptions::new()) + .unwrap(); + assert_eq!( + replay.canonical_digests(), + output.dataset.canonical_digests() + ); + assert_eq!( + processed.descriptor().axes()[0].coordinates(), + &AxisCoordinates::Explicit(vec![0.003, 0.001, 0.001]) + ); + for (row, expected) in [10.0, 30.0, -20.0].into_iter().enumerate() { + assert!((processed.data().get(&[row, 3], &[0, 0]).unwrap() - expected).abs() < 1e-12); + assert!(processed.data().get(&[row, 3], &[0, 1]).unwrap().abs() < 1e-12); + } +} diff --git a/crates/processing/src/nmr_bridge_tests.rs b/crates/processing/src/nmr_bridge_tests.rs new file mode 100644 index 00000000..fed71f51 --- /dev/null +++ b/crates/processing/src/nmr_bridge_tests.rs @@ -0,0 +1,448 @@ +use super::*; +use crate::{ProcessingStep, StepSource}; +use nmr::processed::{ComponentBasis, ProcessedAxis, ProcessedDataset}; +use nmr::raw::{DirectSamples, RawAxis, RawAxisKind, RawDatasetBuilder, RawMetadata}; +use nmr::resource::WorkLedger; +use nmr::{ + Complex64, + axis::{AxisCoordinates, AxisRole, AxisUnit}, +}; + +fn raw(points: usize, delay: GroupDelayState) -> Arc { + let axis = RawAxis::new( + RawAxisKind::Direct(DirectSamples::Complex), + AxisDomain::Time, + Some(AxisUnit::Second), + points, + AxisCoordinates::Uniform { + start: 0.0, + step: 0.001, + }, + ) + .unwrap() + .with_group_delay(delay) + .unwrap(); + Arc::new(Dataset::from_raw( + RawDatasetBuilder::new(vec![axis], RawMetadata::default()) + .unwrap() + .dense( + (0..points) + .map(|index| { + Complex64::from_polar( + 1.0, + std::f64::consts::TAU * index as f64 / points as f64, + ) + }) + .collect(), + ) + .unwrap(), + )) +} + +#[test] +fn processed_time_input_retains_reference_and_zero_filter_evidence_across_snapshot_and_fft() { + let input = plotx_io::nmr_bridge::read( + &std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../io/tests/fixtures/nmr/bruker-1d"), + &mut ExecutionContext::default(), + ) + .unwrap(); + // A time-domain operation creates processed state before the FFT recipe. + let time = nmr::processing::ProcessingPlan::new(vec![Op::ZeroFill { + axis: 0, + zero_fill: nmr::processing::ZeroFill::new(8).unwrap(), + }]) + .unwrap() + .apply(&input) + .unwrap(); + assert!(time.as_processed().is_some()); + let mut bytes = Vec::new(); + plotx_io::nmr_bridge::snapshot::write( + &time, + &mut bytes, + Default::default(), + &mut ExecutionContext::default(), + ) + .unwrap(); + let restored = plotx_io::nmr_bridge::snapshot::read( + &mut bytes.as_slice(), + Default::default(), + &mut ExecutionContext::default(), + ) + .unwrap(); + let output = compile( + restored, + &pipeline([StepKind::Fft]), + 0, + DelayPolicy::AxisEvidence, + RecipeRange::All, + ) + .unwrap() + .execute(ProcessingOptions::new(), &mut ExecutionContext::default()) + .unwrap(); + let source = plotx_io::nmr_view::NmrSource::new(output.clone()).unwrap(); + assert_eq!(source.axes()[0].unit, Some(AxisUnit::Ppm)); + assert_eq!(source.reference_frequency_mhz(0), Some(400.0)); + assert!(matches!( + output + .as_processed() + .unwrap() + .axis_evidence(0) + .unwrap() + .group_delay(), + nmr::processed::ProcessedGroupDelay::Corrected { + delay_points: 0.0, + .. + } + )); +} + +fn spectrum(points: usize, descending: bool) -> Arc { + let axis = ProcessedAxis::new( + AxisRole::Signal, + AxisDomain::Frequency, + Some(AxisUnit::Ppm), + points, + AxisCoordinates::Uniform { + start: 4.0, + step: if descending { -0.1 } else { 0.1 }, + }, + ComponentBasis::Cartesian, + ) + .unwrap(); + Arc::new(Dataset::from_processed( + ProcessedDataset::from_complex_trace( + axis, + vec![Complex64::new(1.0, 2.0); points], + nmr::processed::ProcessedProvenance::new( + nmr::processed::ProcessedOrigin::Unknown, + vec![], + ) + .unwrap(), + ) + .unwrap(), + )) +} + +fn pipeline(kinds: impl IntoIterator) -> AxisPipeline { + AxisPipeline { + steps: kinds + .into_iter() + .enumerate() + .map(|(index, kind)| { + ProcessingStep::new(StepId::new(100 + index as u64 * 3), kind, StepSource::User) + }) + .collect(), + } +} + +fn execute( + input: Arc, + pipe: &AxisPipeline, + range: RecipeRange, +) -> Result, RecipeError> { + compile(input, pipe, 0, DelayPolicy::Disabled, range)? + .execute(ProcessingOptions::new(), &mut ExecutionContext::default()) +} + +#[test] +fn phase_preserves_radians_sign_endpoint_pivot_singleton_and_axis_direction() { + let params = PhaseParams { + phase0: 0.7, + phase1: -1.4, + pivot_frac: 0.37, + auto: None, + }; + for points in [1, 2, 5, 6] { + for descending in [false, true] { + let output = execute( + spectrum(points, descending), + &pipeline([StepKind::Phase(params)]), + RecipeRange::All, + ) + .unwrap(); + let data = output.as_dense_processed().unwrap(); + for index in 0..points { + let phi = params.phase0 + + params.phase1 + * (index as f64 / (points - 1).max(1) as f64 - params.pivot_frac); + let expected = Complex64::new(1.0, 2.0) * Complex64::from_polar(1.0, -phi); + let actual = Complex64::new( + data.get(&[index], &[0]).unwrap(), + data.get(&[index], &[1]).unwrap(), + ); + assert!( + (actual - expected).norm() < 2e-14, + "{points}/{index}/{descending}" + ); + } + } + } +} + +#[test] +fn fft_matches_analytic_tone_and_integer_center_for_odd_and_even_lengths() { + for points in [1, 5, 6] { + let output = execute( + raw(points, GroupDelayState::NotApplicable), + &pipeline([StepKind::Fft]), + RecipeRange::All, + ) + .unwrap(); + let processed = output.as_processed().unwrap(); + let axis = &processed.descriptor().axes()[0]; + for index in 0..points { + let q = index as isize - (points / 2) as isize; + assert!( + (axis.coordinate(index).unwrap() - q as f64 * 1000.0 / points as f64).abs() < 1e-12 + ); + let expected = if points == 1 || q == 1 { + points as f64 + } else { + 0.0 + }; + assert!((processed.data().get(&[index], &[0]).unwrap() - expected).abs() < 1e-12); + assert!(processed.data().get(&[index], &[1]).unwrap().abs() < 1e-12); + } + } +} + +#[test] +fn reordered_windows_and_zero_fills_use_current_length_and_preview_identity() { + let pipe = pipeline([ + StepKind::ZeroFill(ZeroFill::Size(5)), + StepKind::Apodize(Apodization::CosineBell), + StepKind::ZeroFill(ZeroFill::Factor(1)), + StepKind::Fft, + ]); + let input = raw(3, GroupDelayState::NotApplicable); + let preview = execute( + Arc::clone(&input), + &pipe, + RecipeRange::Through(pipe.steps[2].id), + ) + .unwrap(); + let data = preview.as_dense_processed().unwrap(); + assert_eq!(data.shape(), &[8]); + let expected = Complex64::from_polar( + std::f64::consts::FRAC_1_SQRT_2, + 4.0 * std::f64::consts::PI / 3.0, + ); + assert!((data.get(&[2], &[0]).unwrap() - expected.re).abs() < 1e-14); + assert!((data.get(&[2], &[1]).unwrap() - expected.im).abs() < 1e-14); + assert_eq!(data.get(&[7], &[0]).unwrap(), 0.0); + let output = execute(input, &pipe, RecipeRange::All).unwrap(); + assert_eq!(output.as_dense_processed().unwrap().shape(), &[8]); +} + +#[test] +fn split_cache_matches_full_recipe_and_shares_work_budget() { + let pipe = pipeline([ + StepKind::Apodize(Apodization::Exponential { lb_hz: 1.0 }), + StepKind::Fft, + StepKind::Phase(PhaseParams { + phase0: 0.3, + ..PhaseParams::MANUAL_ZERO + }), + ]); + let input = raw(8, GroupDelayState::NotApplicable); + let whole = execute(Arc::clone(&input), &pipe, RecipeRange::All).unwrap(); + let mut ledger = WorkLedger::processing_default(); + let mut context = ExecutionContext::new(&mut ledger); + let base = compile(input, &pipe, 0, DelayPolicy::Disabled, RecipeRange::Base) + .unwrap() + .execute(ProcessingOptions::new(), &mut context) + .unwrap(); + let output = compile( + base, + &pipe, + 0, + DelayPolicy::Disabled, + RecipeRange::Frequency, + ) + .unwrap() + .execute(ProcessingOptions::new(), &mut context) + .unwrap(); + assert_eq!(whole.as_dense_processed(), output.as_dense_processed()); + assert!(ledger.used() > 0); +} + +#[test] +fn disabling_fft_preserves_time_output_and_disabled_preview_endpoint() { + let mut pipe = pipeline([ + StepKind::Apodize(Apodization::Exponential { lb_hz: 2.0 }), + StepKind::Fft, + ]); + pipe.steps[1].enabled = false; + let result = execute( + raw(8, GroupDelayState::NotApplicable), + &pipe, + RecipeRange::Through(pipe.steps[1].id), + ) + .unwrap(); + assert_eq!( + result.as_processed().unwrap().descriptor().axes()[0].domain(), + AxisDomain::Time + ); +} + +#[test] +fn unknown_delay_is_not_zero_and_library_error_maps_to_fft_id() { + let pipe = pipeline([StepKind::Apodize(Apodization::None), StepKind::Fft]); + let input = raw(8, GroupDelayState::Unknown); + let recipe = compile(input, &pipe, 0, DelayPolicy::AxisEvidence, RecipeRange::All).unwrap(); + assert_eq!(recipe.step_id(0), Some(pipe.steps[1].id)); + assert_eq!(recipe.step_id(1), Some(pipe.steps[1].id)); + let error = recipe + .execute(ProcessingOptions::new(), &mut ExecutionContext::default()) + .unwrap_err(); + assert!(matches!(error, RecipeError::Library { step: Some(id), .. } if id == pipe.steps[1].id)); + assert!(!error.is_cancelled()); +} + +#[test] +fn new_spectrum_steps_execute_without_changing_the_input() { + let input = spectrum(8, false); + let before = input.canonical_digests(); + let output = execute( + Arc::clone(&input), + &pipeline([ + StepKind::Reference(crate::ReferenceParams { + at_ppm: 4.0, + target_ppm: 1.0, + }), + StepKind::Reverse, + StepKind::Invert, + StepKind::Baseline(crate::BaselineMethod::Offset), + ]), + RecipeRange::All, + ) + .unwrap(); + assert_eq!(input.canonical_digests(), before); + let processed = output.as_processed().unwrap(); + assert_eq!(processed.descriptor().axes()[0].coordinate(0).unwrap(), 1.0); + for i in 0..8 { + assert_eq!(processed.data().get(&[i], &[0]).unwrap(), 0.0); + assert_eq!(processed.data().get(&[i], &[1]).unwrap(), -2.0); + } +} + +#[test] +fn cancellation_is_distinct_including_an_empty_recipe_and_budget_is_enforced() { + let token = nmr::CancellationToken::new(); + token.cancel(); + let mut context = ExecutionContext::default().with_cancellation(token); + let input = raw(8, GroupDelayState::NotApplicable); + let recipe = compile( + Arc::clone(&input), + &pipeline([]), + 0, + DelayPolicy::Disabled, + RecipeRange::All, + ) + .unwrap(); + assert!( + recipe + .execute(ProcessingOptions::new(), &mut context) + .unwrap_err() + .is_cancelled() + ); + let mut ledger = WorkLedger::new(0); + let recipe = compile( + input, + &pipeline([StepKind::Fft]), + 0, + DelayPolicy::Disabled, + RecipeRange::All, + ) + .unwrap(); + let error = recipe + .execute( + ProcessingOptions::new(), + &mut ExecutionContext::new(&mut ledger), + ) + .unwrap_err(); + assert!( + matches!(error, RecipeError::Library { source, .. } if source.code() == ProcessingErrorCode::ResourceLimit) + ); +} + +#[test] +fn overflow_and_stale_preview_ids_are_recoverable() { + let input = raw(8, GroupDelayState::NotApplicable); + assert!( + compile( + Arc::clone(&input), + &pipeline([StepKind::ZeroFill(ZeroFill::Factor(255))]), + 0, + DelayPolicy::Disabled, + RecipeRange::All + ) + .is_err() + ); + assert!( + compile( + input, + &pipeline([StepKind::Fft]), + 0, + DelayPolicy::Disabled, + RecipeRange::Through(StepId::new(17)) + ) + .is_err() + ); +} + +#[test] +fn magnitude_uses_library_scalar_projection_without_inventing_an_imaginary_channel() { + let output = execute( + spectrum(5, false), + &pipeline([StepKind::Magnitude]), + RecipeRange::All, + ) + .unwrap(); + let data = output.as_processed().unwrap(); + assert_eq!(data.descriptor().component_counts(), [1]); + assert!( + data.data() + .samples() + .iter() + .all(|value| (*value - 5.0f64.sqrt()).abs() < 1e-14) + ); +} + +#[test] +fn explicit_fractional_delay_uses_signed_bins_and_conflicting_evidence_fails() { + let pipe = pipeline([StepKind::Fft]); + let input = raw(5, GroupDelayState::Unknown); + let result = compile( + input, + &pipe, + 0, + DelayPolicy::Explicit(0.25), + RecipeRange::All, + ) + .unwrap() + .execute(ProcessingOptions::new(), &mut ExecutionContext::default()) + .unwrap(); + let expected = Complex64::from_polar(5.0, std::f64::consts::TAU * 0.25 / 5.0); + let data = result.as_dense_processed().unwrap(); + assert!((data.get(&[3], &[0]).unwrap() - expected.re).abs() < 1e-12); + assert!((data.get(&[3], &[1]).unwrap() - expected.im).abs() < 1e-12); + let known = raw( + 5, + GroupDelayState::Pending(nmr::raw::PendingGroupDelay::user_constructed(0.5).unwrap()), + ); + let error = compile( + known, + &pipe, + 0, + DelayPolicy::Explicit(0.25), + RecipeRange::All, + ) + .unwrap() + .execute(ProcessingOptions::new(), &mut ExecutionContext::default()) + .unwrap_err(); + assert!( + matches!(error, RecipeError::Library { source, .. } if matches!(source.root_cause(), ProcessingError::DelayEvidenceMismatch)) + ); +} diff --git a/crates/processing/src/nmr_execution.rs b/crates/processing/src/nmr_execution.rs new file mode 100644 index 00000000..48ac967c --- /dev/null +++ b/crates/processing/src/nmr_execution.rs @@ -0,0 +1,113 @@ +//! Application execution retains the full library output beside disposable views. + +use crate::nmr_bridge::{self, DelayPolicy, PhaseReport, RecipeError, RecipeRange}; +use crate::{AxisPipeline, Processed1D, Spectrum, TimeTrace}; +use nmr::axis::{AxisDomain, AxisUnit}; +use nmr::{ExecutionContext, processing::ProcessingOptions}; +use plotx_io::nmr_view::NmrSource; + +#[path = "nmr_execution_2d.rs"] +mod twod; +pub use twod::{NusRequest, Output2D, execute_2d, validate_2d_domains, view_2d}; + +/// One bounded ledger for the complete F2/NUS/F1 task and its frequency suffix. +/// A 512 x 1024 NUS acquisition needs over 40 billion preflight work units; +/// the library default is too small even though its memory use is modest. +pub fn processing_2d_work_ledger() -> nmr::resource::WorkLedger { + nmr::resource::WorkLedger::new(100_000_000_000) +} + +#[derive(Debug, thiserror::Error)] +pub enum ExecutionError { + #[error(transparent)] + Recipe(#[from] RecipeError), + #[error(transparent)] + View(#[from] plotx_io::IoError), +} + +impl ExecutionError { + pub fn is_cancelled(&self) -> bool { + matches!(self, Self::Recipe(error) if error.is_cancelled()) + } +} + +#[derive(Clone, Debug)] +pub struct Output1D { + pub source: NmrSource, + pub view: Processed1D, + pub phases: Vec, +} + +pub fn execute_1d( + source: &NmrSource, + pipeline: &AxisPipeline, + delay: DelayPolicy, + range: RecipeRange, + context: &mut ExecutionContext<'_>, +) -> Result { + let result = nmr_bridge::compile(source.dataset().clone(), pipeline, 0, delay, range)? + .execute_with_report(ProcessingOptions::default(), context)?; + let source = NmrSource::new(result.dataset)?.with_display_label(source.source().to_owned()); + let view = view_1d(&source)?; + Ok(Output1D { + source, + view, + phases: result.phases, + }) +} + +pub fn view_1d(source: &NmrSource) -> Result { + let axis = source.direct_axis()?; + check_view_size(1, axis.points)?; + let coordinates = axis.coordinate_values()?; + let values = source.trace()?; + let nucleus = axis.nucleus.clone().unwrap_or_default(); + match (axis.domain, axis.unit) { + (AxisDomain::Time, Some(AxisUnit::Second)) => Ok(Processed1D::Time(TimeTrace { + time_s: coordinates, + values, + nucleus, + source: source.source().to_owned(), + })), + (AxisDomain::Frequency, Some(unit @ (AxisUnit::Ppm | AxisUnit::Hertz))) => { + Ok(Processed1D::Frequency(Spectrum { + ppm: coordinates, + values, + unit, + hz_per_point: match &axis.coordinates { + nmr::axis::AxisCoordinates::Uniform { step, .. } => { + if unit == AxisUnit::Hertz { + Some(step.abs()) + } else { + source + .reference_frequency_mhz(0) + .map(|frequency| frequency * step.abs()) + } + } + _ => None, + }, + observe_freq_mhz: axis.observe_frequency_mhz(), + nucleus, + })) + } + _ => Err(plotx_io::IoError::NmrConversion( + "NMR display requires coordinates in seconds, hertz or ppm".into(), + )), + } +} + +/// Bound disposable host views separately from the library's scientific output. +/// This includes row vectors, axes, magnitude and the temporary flattening copy. +pub(super) fn check_view_size(rows: usize, cols: usize) -> Result<(), plotx_io::IoError> { + let bytes = rows + .checked_mul(cols) + .and_then(|points| points.checked_mul(40)) + .and_then(|bytes| rows.checked_mul(32)?.checked_add(bytes)) + .and_then(|bytes| cols.checked_mul(8)?.checked_add(bytes)); + if bytes.is_none_or(|bytes| bytes > 512 * 1024 * 1024) { + return Err(plotx_io::IoError::NmrConversion( + "NMR display exceeds the 512 MiB view allocation limit".into(), + )); + } + Ok(()) +} diff --git a/crates/processing/src/nmr_execution_2d.rs b/crates/processing/src/nmr_execution_2d.rs new file mode 100644 index 00000000..e18666ec --- /dev/null +++ b/crates/processing/src/nmr_execution_2d.rs @@ -0,0 +1,280 @@ +//! Two-dimensional execution keeps Cartesian lanes and sparse inputs in nmr. + +use super::{ExecutionError, nmr_bridge}; +use crate::nmr_bridge::{DelayPolicy, PhaseReport, RecipeError, RecipeRange}; +use crate::{AxisMeta, Layout2D, Params2D, Processed2D, Spectrum2D, StackSpectrum}; +use nmr::axis::{AxisDomain, AxisUnit}; +use nmr::processing::{AutoNusSettings, NusSettings, ProcessingOptions}; +use nmr::{Complex64, ExecutionContext}; +use plotx_io::{Domain, IoError, nmr_view::NmrSource}; +use std::sync::Arc; + +#[derive(Clone, Debug)] +pub struct Output2D { + pub source: NmrSource, + pub view: Processed2D, + pub phases: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct NusRequest { + /// Library iteration ceiling, 1..=2048; exhaustion is an error. + pub max_iterations: usize, + /// An independent noise estimate in the processed F2 spectrum amplitude + /// units. Absence selects library automatic estimation, never zero noise. + pub noise_standard_deviation: Option, +} + +impl Default for NusRequest { + fn default() -> Self { + Self { + max_iterations: 1000, + noise_standard_deviation: None, + } + } +} + +fn library(source: nmr::processing::ProcessingError) -> ExecutionError { + RecipeError::Library { step: None, source }.into() +} + +pub fn validate_2d_domains( + source: &plotx_io::nmr_series::NmrSeriesSource, + params: &Params2D, +) -> Result<(), String> { + for (index, name, pipeline) in [(1, "F2", ¶ms.f2), (0, "F1", ¶ms.f1)] { + if source.source_dataset().axes()[index].domain == AxisDomain::Parameter { + if pipeline.steps.iter().any(|step| step.enabled) { + return Err(format!( + "invalid {name} pipeline: parameter axes cannot accept spectral processing" + )); + } + } else { + pipeline + .output_domain( + source + .input_domain(index) + .map_err(|error| error.to_string())?, + ) + .map_err(|error| format!("invalid {name} pipeline: {error}"))?; + } + } + Ok(()) +} + +pub fn execute_2d( + source: &NmrSource, + params: &Params2D, + delay: DelayPolicy, + range: RecipeRange, + nus: Option, + context: &mut ExecutionContext<'_>, +) -> Result { + if source.axes().len() != 2 { + return Err(RecipeError::Invalid("2D processing requires two axes".into()).into()); + } + if matches!(range, RecipeRange::All) { + let base = execute_2d(source, params, delay, RecipeRange::Base, nus, context)?; + return execute_2d( + &base.source, + params, + DelayPolicy::Disabled, + RecipeRange::Frequency, + None, + context, + ); + } + let options = ProcessingOptions::default(); + let direct = nmr_bridge::compile(source.dataset().clone(), ¶ms.f2, 1, delay, range)?; + let sparse = source + .dataset() + .as_raw() + .is_some_and(|raw| raw.data().is_sparse()); + let mut phases = Vec::new(); + let mut output = + if sparse && params.f2.has_enabled_fft() && !matches!(range, RecipeRange::Frequency) { + let request = nus.unwrap_or_default(); + let plan = direct.deterministic_plan()?; + let prepared = if request.noise_standard_deviation.is_some() { + NusSettings { + max_iterations: request.max_iterations, + noise_standard_deviation: request.noise_standard_deviation, + } + .prepare_with_context(source.dataset(), plan, options, context) + } else { + AutoNusSettings { + max_iterations: request.max_iterations, + } + .prepare_with_context(source.dataset(), plan, options, context) + } + .map_err(library)?; + Arc::new(prepared.execute_with_context(context).map_err(library)?) + } else if sparse && !params.f2.has_enabled_fft() { + // Only an unchanged acquisition can be displayed before reconstruction. + if params.f2.steps.iter().any(|step| step.enabled) { + return Err(RecipeError::Invalid( + "Reconstruct NUS data before applying a time-domain recipe".into(), + ) + .into()); + } + source.dataset().clone() + } else { + let result = direct.execute_with_report(options, context)?; + phases.extend(result.phases); + result.dataset + }; + if params.layout == Layout2D::Ft { + let result = + nmr_bridge::compile(output.clone(), ¶ms.f1, 0, DelayPolicy::Disabled, range)? + .execute_with_report(options, context)?; + output = result.dataset; + phases.extend(result.phases); + } + let source = NmrSource::new(output)?.with_display_label(source.source().to_owned()); + let view = view_2d(&source, params.layout, context)?; + Ok(Output2D { + source, + view, + phases, + }) +} + +fn domain(axis: &plotx_io::nmr_view::NmrAxis) -> Result { + match (axis.domain, axis.unit) { + (AxisDomain::Time, Some(AxisUnit::Second)) => Ok(Domain::Time), + (AxisDomain::Frequency, Some(AxisUnit::Ppm | AxisUnit::Hertz)) => Ok(Domain::Frequency), + _ => Err(IoError::NmrConversion( + "Spectral display requires an axis in seconds, hertz or ppm".into(), + )), + } +} + +pub fn view_2d( + source: &NmrSource, + layout: Layout2D, + context: &mut ExecutionContext<'_>, +) -> Result { + let axes = source.axes(); + if axes.len() != 2 { + return Err(RecipeError::Invalid("2D view requires two axes".into()).into()); + } + let direct_domain = domain(&axes[1])?; + let cols = axes[1].points; + let rows = source + .dataset() + .as_raw() + .and_then(|raw| raw.data().sparse_traces()) + .map_or(axes[0].points, |traces| traces.len()); + super::check_view_size(rows, cols)?; + let direct = AxisMeta { + nucleus: axes[1].nucleus.clone().unwrap_or_default(), + observe_freq_mhz: axes[1].observe_frequency_mhz(), + unit: axes[1].unit, + }; + let ppm = axes[1].coordinate_values()?; + let mut traces = Vec::new(); + let mut magnitudes = Vec::new(); + if let Some(raw) = source.dataset().as_raw() { + let lanes = raw.descriptor().axes()[0].component_lanes(); + let rows = raw + .data() + .sparse_traces() + .map_or(axes[0].points, |traces| traces.len()); + if raw.data().is_sparse() && layout == Layout2D::Ft { + return Err(RecipeError::Invalid( + "Reconstruct the NUS grid before displaying contours".into(), + ) + .into()); + } + for row in 0..rows { + context.check_cancelled().map_err(|e| library(e.into()))?; + let trace = if raw.data().is_sparse() { + raw.read_observation(nmr::raw::ObservationOrdinal::new(row)) + } else { + raw.read_trace(&[row]) + } + .map_err(|error| IoError::NmrConversion(error.to_string()))?; + let samples = trace.samples(); + traces.push(samples[..cols].to_vec()); + for col in 0..cols { + if col % 4096 == 0 { + context + .check_cancelled() + .map_err(|error| library(error.into()))?; + } + let mut magnitude = 0.0_f64; + for lane in 0..lanes { + magnitude = magnitude.hypot(samples[lane * cols + col].norm()); + } + magnitudes.push(magnitude); + } + } + } else { + let data = source + .dataset() + .as_processed() + .ok_or_else(|| RecipeError::Invalid("unsupported 2D representation".into()))?; + let descriptor = data.descriptor(); + let sample = |row, col, indirect, direct| { + data.data() + .get(&[row, col], &[indirect, direct]) + .map_err(|error| IoError::NmrConversion(error.to_string())) + }; + for row in 0..axes[0].points { + context.check_cancelled().map_err(|e| library(e.into()))?; + let mut trace = Vec::with_capacity(cols); + for col in 0..cols { + if col % 4096 == 0 { + context + .check_cancelled() + .map_err(|error| library(error.into()))?; + } + trace.push(Complex64::new( + sample(row, col, 0, 0)?, + if descriptor.axes()[1].component_count() == 2 { + sample(row, col, 0, 1)? + } else { + 0.0 + }, + )); + // A display reduction of every Cartesian field. The canonical + // dataset and any requested magnitude operation stay in nmr. + let mut magnitude = 0.0_f64; + for indirect in 0..descriptor.axes()[0].component_count() { + for direct in 0..descriptor.axes()[1].component_count() { + magnitude = magnitude.hypot(sample(row, col, indirect, direct)?); + } + } + magnitudes.push(magnitude); + } + traces.push(trace); + } + } + let source_label = source.source().to_owned(); + Ok(match layout { + Layout2D::Stack => Processed2D::Stack(Arc::new(StackSpectrum { + ppm, + direct_domain, + traces, + direct, + source: source_label, + })), + Layout2D::Ft => Processed2D::Ft(Arc::new(Spectrum2D { + f2_ppm: ppm, + f1_ppm: axes[0].coordinate_values()?, + f2_domain: direct_domain, + f1_domain: domain(&axes[0])?, + data: traces.into_iter().flatten().collect(), + magnitude_plane: Some(Arc::from(magnitudes)), + f2_size: cols, + f1_size: axes[0].points, + direct, + indirect: AxisMeta { + nucleus: axes[0].nucleus.clone().unwrap_or_default(), + observe_freq_mhz: axes[0].observe_frequency_mhz(), + unit: axes[0].unit, + }, + source: source_label, + })), + }) +} diff --git a/crates/processing/src/nmr_test_execution.rs b/crates/processing/src/nmr_test_execution.rs new file mode 100644 index 00000000..dd213130 --- /dev/null +++ b/crates/processing/src/nmr_test_execution.rs @@ -0,0 +1,94 @@ +//! Execute the historical signal-quality fixtures through the production bridge. +use super::*; +use crate::nmr_bridge::{DelayPolicy, RecipeRange}; +use plotx_io::nmr_view::NmrSource; +fn run( + data: &NmrData, + pipeline: &AxisPipeline, + delay: bool, + range: RecipeRange, +) -> Result { + let source = NmrSource::try_from(data.clone()).map_err(|e| e.to_string())?; + crate::nmr_execution::execute_1d( + &source, + pipeline, + if delay { + DelayPolicy::AxisEvidence + } else { + DelayPolicy::Disabled + }, + range, + &mut nmr::ExecutionContext::default(), + ) + .map(|out| out.view) + .map_err(|e| e.to_string()) +} +pub fn process( + data: &NmrData, + pipeline: &AxisPipeline, + delay: bool, +) -> Result { + run(data, pipeline, delay, RecipeRange::All) +} +pub fn process_output( + data: &NmrData, + pipeline: &AxisPipeline, + delay: bool, +) -> Result { + process(data, pipeline, delay) +} +pub fn transform_base(data: &NmrData, pipeline: &AxisPipeline, delay: bool) -> Spectrum { + match run(data, pipeline, delay, RecipeRange::Base).unwrap() { + Processed1D::Frequency(s) => s, + _ => panic!("test requires FFT"), + } +} +pub fn process_up_to( + data: &NmrData, + pipeline: &AxisPipeline, + delay: bool, + step: StepId, +) -> Processed1D { + run(data, pipeline, delay, RecipeRange::Through(step)).unwrap() +} +pub fn apply_phase(spectrum: &mut Spectrum, method: AutoPhaseMethod) { + let axis = nmr::processed::ProcessedAxis::new( + nmr::axis::AxisRole::Signal, + nmr::axis::AxisDomain::Frequency, + Some(spectrum.unit), + spectrum.len(), + nmr::axis::AxisCoordinates::Explicit(spectrum.ppm.clone()), + nmr::processed::ComponentBasis::Cartesian, + ) + .unwrap(); + let input = nmr::processed::ProcessedDataset::from_complex_trace( + axis, + spectrum.values.clone(), + nmr::processed::ProcessedProvenance::new( + nmr::processed::ProcessedOrigin::Unknown, + Vec::new(), + ) + .unwrap(), + ) + .unwrap(); + let source = NmrSource::new(std::sync::Arc::new(input.into())).unwrap(); + let pipeline = AxisPipeline { + steps: vec![ProcessingStep::new( + StepId::new(0), + StepKind::Phase(PhaseParams { + auto: Some(method), + ..PhaseParams::MANUAL_ZERO + }), + StepSource::User, + )], + }; + let out = crate::nmr_execution::execute_1d( + &source, + &pipeline, + DelayPolicy::Disabled, + RecipeRange::All, + &mut nmr::ExecutionContext::default(), + ) + .unwrap(); + spectrum.values = out.view.as_frequency().unwrap().values.clone(); +} diff --git a/crates/processing/src/nus.rs b/crates/processing/src/nus.rs deleted file mode 100644 index ef4c3804..00000000 --- a/crates/processing/src/nus.rs +++ /dev/null @@ -1,188 +0,0 @@ -//! Non-uniform-sampling (NUS) indirect-axis reconstruction. -//! -//! JEOL echo/anti-echo HSQC (and other P/N experiments) select a single -//! coherence pathway per stored channel, so the two F1 channels are the echo -//! (P) and anti-echo (N) interferograms rather than States cosine/sine. Feeding -//! P/N straight into a `cos + i·sin` assembly places each peak at both ±Ω (the -//! F1 mirror); [`pn_to_shr`] first remaps them to States-Haberkorn-Ruben -//! channels so the assembly resolves a single frequency. -//! -//! Only a subset (M of N nominal) increments are acquired; [`ist`] fills the -//! gaps by iterative soft thresholding: transform, shrink all but the strongest -//! F1 components, inverse-transform, and re-impose the measured samples, so the -//! reconstruction stays consistent with the acquired data while favouring a -//! sparse (peak-like) spectrum. - -use num_complex::Complex64; -use rustfft::FftPlanner; - -/// Convert an echo/anti-echo channel pair `(P, N)` at one F1 increment and F2 -/// point into the complex States t1 sample. `cos = (P + N)/2`, `sin = (P − N)/2i`, -/// and the hypercomplex assembly `cos + i·sin` collapses to a single-frequency -/// sample (no ±Ω mirror). Sign conventions vary between spectrometers; the -/// caller applies the indirect conjugation that fixes the F1 sense. -#[inline] -pub fn pn_to_shr(p: Complex64, n: Complex64) -> Complex64 { - let cos = (p + n) * 0.5; - // sin = (P − N) / (2i) = −i·(P − N)/2. - let sin = (p - n) * Complex64::new(0.0, -0.5); - cos + Complex64::i() * sin -} - -/// Number of IST iterations when the recipe does not specify one. -pub const DEFAULT_IST_ITERS: usize = 100; - -/// Reconstruct one dense length-`grid` complex t1 interferogram from the sparse -/// measured samples by iterative soft thresholding. -/// -/// `measured[j]` holds the acquired sample for grid index `positions[j]`; all -/// other grid points start at zero and are filled by the iteration. `iters` -/// passes shrink the F1 spectrum toward the largest components (threshold -/// decays geometrically), inverse-transform, then restore the measured points. -pub fn ist( - positions: &[usize], - measured: &[Complex64], - grid: usize, - iters: usize, - planner: &mut FftPlanner, -) -> Vec { - let mut x = vec![Complex64::new(0.0, 0.0); grid]; - for (&pos, &m) in positions.iter().zip(measured) { - if pos < grid { - x[pos] = m; - } - } - if grid == 0 || iters == 0 { - return x; - } - let fwd = planner.plan_fft_forward(grid); - let inv = planner.plan_fft_inverse(grid); - let norm = 1.0 / grid as f64; - // Threshold starts just below the strongest component and decays - // geometrically so weaker peaks are admitted progressively, recovering - // essentially the full peak list by the last pass. - let decay = 0.98_f64; - let mut spec = vec![Complex64::new(0.0, 0.0); grid]; - for i in 0..iters { - spec.copy_from_slice(&x); - fwd.process(&mut spec); - let max_amp = spec.iter().map(|c| c.norm()).fold(0.0, f64::max); - if max_amp <= f64::MIN_POSITIVE { - break; - } - let threshold = max_amp * decay.powi(i as i32); - for c in spec.iter_mut() { - let amp = c.norm(); - if amp <= threshold { - *c = Complex64::new(0.0, 0.0); - } else { - // Soft threshold: shrink the surviving magnitude by `threshold`. - *c *= (amp - threshold) / amp; - } - } - inv.process(&mut spec); - for (dst, src) in x.iter_mut().zip(spec.iter()) { - *dst = src * norm; - } - // Data consistency: re-impose the measured samples exactly. - for (&pos, &m) in positions.iter().zip(measured) { - if pos < grid { - x[pos] = m; - } - } - } - x -} - -/// Reconstruct the full `grid × f2_n` complex t1 interferogram grid from the -/// acquired (F2-transformed) rows. For echo/anti-echo the stored rows are P/N -/// pairs remapped by [`pn_to_shr`]; otherwise each stored row is one measured -/// increment. Each F2 column is reconstructed independently along F1 by [`ist`]. -/// `positions` holds the 0-based grid index of each acquired increment. -#[allow(clippy::too_many_arguments)] -pub fn reconstruct_rows( - rows_ft: &[Vec], - echo_antiecho: bool, - positions: &[usize], - grid: usize, - f2_n: usize, - indirect_conjugate: bool, - iters: usize, - planner: &mut FftPlanner, -) -> Vec> { - let acquired = positions.len(); - let mut out = vec![vec![Complex64::new(0.0, 0.0); f2_n]; grid]; - let mut measured = vec![Complex64::new(0.0, 0.0); acquired]; - for c in 0..f2_n { - for (k, m) in measured.iter_mut().enumerate() { - let s = if echo_antiecho { - pn_to_shr(rows_ft[2 * k][c], rows_ft[2 * k + 1][c]) - } else { - rows_ft[k][c] - }; - // The P/N→SHR remap already fixes the F1 sense, so echo/anti-echo takes - // the opposite conjugation to the plain States/phase-modulated path. - *m = if indirect_conjugate ^ echo_antiecho { - s.conj() - } else { - s - }; - } - let col = ist(positions, &measured, grid, iters, planner); - for (g, v) in col.into_iter().enumerate() { - out[g][c] = v; - } - } - out -} - -#[cfg(test)] -mod tests { - use super::*; - use std::f64::consts::TAU; - - #[test] - fn pn_to_shr_resolves_single_frequency() { - // P = e^{+iΩ}, N = e^{-iΩ}: the States assembly of the raw pair peaks at - // both ±Ω, but pn_to_shr yields e^{+iΩ} (a single frequency). - let omega = 0.7; - let p = Complex64::from_polar(1.0, omega); - let n = Complex64::from_polar(1.0, -omega); - let t1 = pn_to_shr(p, n); - assert!((t1 - Complex64::from_polar(1.0, omega)).norm() < 1e-12); - // The naive States mix keeps a mirror term of comparable size. - let naive = p + Complex64::i() * n; - assert!(naive.norm() > 0.5); - } - - #[test] - fn ist_recovers_a_sparse_spectrum() { - // One F1 tone sampled at a NUS subset of a 32-point grid must reconstruct - // to a single peak at the right frequency with the gaps filled. - let grid = 32usize; - let k0 = 5usize; // target frequency bin - let full: Vec = (0..grid) - .map(|t| Complex64::from_polar(1.0, TAU * k0 as f64 * t as f64 / grid as f64)) - .collect(); - let positions = [0usize, 1, 2, 4, 7, 9, 13, 18, 21, 25, 29, 31]; - let measured: Vec = positions.iter().map(|&p| full[p]).collect(); - let mut planner = FftPlanner::::new(); - let recon = ist(&positions, &measured, grid, 200, &mut planner); - - let mut spec = recon.clone(); - planner.plan_fft_forward(grid).process(&mut spec); - let peak = spec - .iter() - .enumerate() - .max_by(|a, b| a.1.norm().partial_cmp(&b.1.norm()).unwrap()) - .unwrap() - .0; - assert_eq!( - peak, k0, - "reconstructed peak lands at the sampled frequency" - ); - for (&p, &m) in positions.iter().zip(measured.iter()) { - assert!((recon[p] - m).norm() < 1e-6); - } - } -} diff --git a/crates/processing/src/phase.rs b/crates/processing/src/phase.rs deleted file mode 100644 index 7aef5213..00000000 --- a/crates/processing/src/phase.rs +++ /dev/null @@ -1,114 +0,0 @@ -use crate::Spectrum; -use num_complex::Complex64; - -pub fn apply(spec: &mut Spectrum, phase0: f64, phase1: f64) { - apply_with_pivot(spec, phase0, phase1, 0.0); -} - -/// Fractional index (`0..=1`) of the largest-magnitude point — a sensible default -/// first-order phase pivot, so the ramp rotates about the tallest peak. Returns -/// `0.0` for an empty or single-point buffer. -pub fn peak_pivot_frac(values: &[Complex64]) -> f64 { - let n = values.len(); - if n < 2 { - return 0.0; - } - let peak = values - .iter() - .enumerate() - .max_by(|(_, a), (_, b)| a.norm().total_cmp(&b.norm())) - .map_or(0, |(i, _)| i); - peak as f64 / (n - 1) as f64 -} - -/// Zeroth- and first-order phase correction in place. The first-order ramp -/// rotates around `pivot_frac` (a `0..=1` fractional index): the phase there is -/// exactly `phase0`. `pivot_frac = 0.0` ramps from the first point. -pub fn apply_with_pivot(spec: &mut Spectrum, phase0: f64, phase1: f64, pivot_frac: f64) { - apply_slice(&mut spec.values, phase0, phase1, pivot_frac); -} - -/// The phase-correction kernel over a raw complex buffer, shared by the 1D -/// [`Spectrum`] path and each dimension of a 2D spectrum. Rotates point `i` by -/// `e^{-iφ}`, `φ = phase0 + phase1·(i/(n−1) − pivot_frac)`. -pub fn apply_slice(buf: &mut [Complex64], phase0: f64, phase1: f64, pivot_frac: f64) { - let n = buf.len(); - if n == 0 { - return; - } - let denom = (n - 1).max(1) as f64; - for (i, c) in buf.iter_mut().enumerate() { - let frac = i as f64 / denom; - let phi = phase0 + phase1 * (frac - pivot_frac); - *c *= Complex64::from_polar(1.0, -phi); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn spectrum_from(values: Vec) -> Spectrum { - let n = values.len(); - Spectrum { - ppm: (0..n).map(|i| i as f64).collect(), - values, - hz_per_point: 1.0, - observe_freq_mhz: 400.0, - nucleus: "1H".into(), - } - } - - #[test] - fn zero_phase_is_identity() { - let mut s = spectrum_from(vec![Complex64::new(1.0, 2.0), Complex64::new(-3.0, 0.5)]); - let before = s.values.clone(); - apply(&mut s, 0.0, 0.0); - for (a, b) in before.iter().zip(&s.values) { - assert!((a - b).norm() < 1e-12); - } - } - - #[test] - fn peak_pivot_frac_lands_on_tallest_point() { - let vals = vec![ - Complex64::new(0.1, 0.0), - Complex64::new(0.2, 0.0), - Complex64::new(5.0, 0.0), - Complex64::new(0.3, 0.0), - Complex64::new(0.1, 0.0), - ]; - assert!((peak_pivot_frac(&vals) - 0.5).abs() < 1e-12); - assert_eq!(peak_pivot_frac(&[]), 0.0); - assert_eq!(peak_pivot_frac(&[Complex64::new(9.0, 0.0)]), 0.0); - } - - #[test] - fn phase0_rotates_imag_into_real() { - // (0 + i)·e^(-iπ/2) = 1. - let mut s = spectrum_from(vec![Complex64::new(0.0, 1.0)]); - apply(&mut s, std::f64::consts::FRAC_PI_2, 0.0); - assert!((s.values[0].re - 1.0).abs() < 1e-12); - assert!(s.values[0].im.abs() < 1e-12); - } - - #[test] - fn repivot_preserves_the_phase_curve() { - let mut params = crate::PhaseParams { - phase0: 0.7, - phase1: -1.4, - pivot_frac: 0.2, - auto: None, - }; - let before = [0.0, 0.25, 0.8, 1.0] - .map(|frac| params.phase0 + params.phase1 * (frac - params.pivot_frac)); - - params.repivot(0.75); - - let after = [0.0, 0.25, 0.8, 1.0] - .map(|frac| params.phase0 + params.phase1 * (frac - params.pivot_frac)); - for (before, after) in before.into_iter().zip(after) { - assert!((before - after).abs() < 1e-12); - } - } -} diff --git a/crates/processing/src/preview.rs b/crates/processing/src/preview.rs deleted file mode 100644 index 23339eea..00000000 --- a/crates/processing/src/preview.rs +++ /dev/null @@ -1,68 +0,0 @@ -use crate::{AxisPipeline, Spectrum, StepId, StepKind, apply_freq_step, fft, transform_base}; -use num_complex::Complex64; - -/// An intermediate pipeline output: time-domain before FFT, frequency-domain -/// after an enabled FFT. -#[derive(Debug, Clone)] -pub enum Preview { - Time { fid: Vec, dt: f64 }, - Freq(Spectrum), -} - -/// Run enabled steps until (and including) `stop`. -pub fn process_up_to( - data: &plotx_io::NmrData, - pipe: &AxisPipeline, - group_delay_correct: bool, - stop: StepId, -) -> Preview { - let dt = if data.spectral_width_hz != 0.0 { - 1.0 / data.spectral_width_hz - } else { - 0.0 - }; - let stop_before_fft = pipe - .steps - .iter() - .take_while(|step| !(step.enabled && matches!(step.kind, StepKind::Fft))) - .any(|step| step.id == stop); - - if stop_before_fft || !pipe.has_enabled_fft() { - let mut buf = data.points.clone(); - for step in &pipe.steps { - if step.enabled { - match step.kind { - StepKind::Apodize(apodization) => { - fft::apply_apodization(&mut buf, apodization, dt); - } - StepKind::ZeroFill(zero_fill) => { - let size = zero_fill.target(buf.len()); - buf.resize(size, Complex64::new(0.0, 0.0)); - } - _ => {} - } - } - if step.id == stop { - break; - } - } - return Preview::Time { fid: buf, dt }; - } - - let mut spectrum = transform_base(data, pipe, group_delay_correct); - for step in &pipe.steps { - if step.kind.at_or_before_fft() { - if step.id == stop { - break; - } - continue; - } - if step.enabled { - apply_freq_step(&mut spectrum, &step.kind); - } - if step.id == stop { - break; - } - } - Preview::Freq(spectrum) -} diff --git a/crates/processing/src/slice.rs b/crates/processing/src/slice.rs index e8ae38b4..65872c9d 100644 --- a/crates/processing/src/slice.rs +++ b/crates/processing/src/slice.rs @@ -6,7 +6,7 @@ use num_complex::Complex64; use plotx_io::Domain; -use crate::{Spectrum2D, StackSpectrum}; +use crate::Spectrum2D; /// The orientation of a 1D cut through a true-2D spectrum. `Row`/`Column` name /// the axis the resulting trace runs *along*. @@ -35,7 +35,9 @@ pub struct Slice1D { pub domain: Domain, pub values: Vec, pub nucleus: String, - pub observe_freq_mhz: f64, + pub observe_freq_mhz: Option, + pub reference_freq_mhz: Option, + pub unit: nmr::axis::AxisUnit, /// The fixed-axis coordinate the cut was taken at, for labelling. /// `None` for a projection, which spans the whole axis. pub position: Option, @@ -52,83 +54,6 @@ impl Spectrum2D { pub fn nearest_f1(&self, ppm: f64) -> usize { nearest(&self.f1_ppm, ppm) } - - /// A single row/column cut at a grid index (clamped in range). - pub fn slice(&self, kind: SliceKind, index: usize) -> Slice1D { - match kind { - SliceKind::Row => { - let r = index.min(self.f1_size.saturating_sub(1)); - let start = r * self.f2_size; - Slice1D { - coordinates: self.f2_ppm.clone(), - domain: self.f2_domain, - values: self.data[start..start + self.f2_size].to_vec(), - nucleus: self.direct.nucleus.clone(), - observe_freq_mhz: self.direct.observe_freq_mhz, - position: self.f1_ppm.get(r).copied(), - position_domain: self.f1_domain, - } - } - SliceKind::Column => { - let c = index.min(self.f2_size.saturating_sub(1)); - Slice1D { - coordinates: self.f1_ppm.clone(), - domain: self.f1_domain, - values: (0..self.f1_size).map(|r| self.at(r, c)).collect(), - nucleus: self.indirect.nucleus.clone(), - observe_freq_mhz: self.indirect.observe_freq_mhz, - position: self.f2_ppm.get(c).copied(), - position_domain: self.f2_domain, - } - } - } - } - - /// A whole-axis projection. `kind` names the surviving axis (as for - /// [`Self::slice`]): a `Row` projection collapses F1 to give intensity vs F2, - /// a `Column` projection collapses F2 to give intensity vs F1. - pub fn project(&self, kind: SliceKind, mode: ProjectionMode) -> Slice1D { - match kind { - SliceKind::Row => Slice1D { - coordinates: self.f2_ppm.clone(), - domain: self.f2_domain, - values: (0..self.f2_size) - .map(|c| reduce((0..self.f1_size).map(|r| self.at(r, c)), mode)) - .collect(), - nucleus: self.direct.nucleus.clone(), - observe_freq_mhz: self.direct.observe_freq_mhz, - position: None, - position_domain: self.f1_domain, - }, - SliceKind::Column => Slice1D { - coordinates: self.f1_ppm.clone(), - domain: self.f1_domain, - values: (0..self.f1_size) - .map(|r| reduce((0..self.f2_size).map(|c| self.at(r, c)), mode)) - .collect(), - nucleus: self.indirect.nucleus.clone(), - observe_freq_mhz: self.indirect.observe_freq_mhz, - position: None, - position_domain: self.f2_domain, - }, - } - } -} - -impl StackSpectrum { - /// One increment's direct-dimension 1D trace (clamped in range). - pub fn slice(&self, increment: usize) -> Slice1D { - let i = increment.min(self.increments().saturating_sub(1)); - Slice1D { - coordinates: self.ppm.clone(), - domain: self.direct_domain, - values: self.traces.get(i).cloned().unwrap_or_default(), - nucleus: self.direct.nucleus.clone(), - observe_freq_mhz: self.direct.observe_freq_mhz, - position: None, - position_domain: self.direct_domain, - } - } } fn nearest(axis: &[f64], ppm: f64) -> usize { @@ -139,90 +64,82 @@ fn nearest(axis: &[f64], ppm: f64) -> usize { .unwrap_or(0) } -fn reduce(values: impl Iterator, mode: ProjectionMode) -> Complex64 { - match mode { - ProjectionMode::Sum => values.sum(), - ProjectionMode::Skyline => values.fold(Complex64::new(0.0, 0.0), |best, c| { - if c.norm() > best.norm() { c } else { best } - }), - } +/// A native reduction removes the selected dimension and its explicitly chosen +/// real component. The surviving axis retains all of its Cartesian components. +#[derive(Clone, Copy, Debug)] +pub enum Reduction { + Slice(usize), + Projection(ProjectionMode), } -#[cfg(test)] -mod tests { - use super::*; - use crate::AxisMeta; - - fn spectrum() -> Spectrum2D { - // 2 rows (F1) × 3 cols (F2): row r, col c carries value (r*10 + c). - let (f2_size, f1_size) = (3, 2); - let data = (0..f1_size) - .flat_map(|r| (0..f2_size).map(move |c| Complex64::new((r * 10 + c) as f64, 0.0))) - .collect(); - Spectrum2D { - f2_domain: plotx_io::Domain::Frequency, - f1_domain: plotx_io::Domain::Frequency, - f2_ppm: vec![1.0, 2.0, 3.0], - f1_ppm: vec![10.0, 20.0], - data, - f2_size, - f1_size, - direct: AxisMeta { - nucleus: "1H".into(), - observe_freq_mhz: 400.0, - }, - indirect: AxisMeta { - nucleus: "13C".into(), - observe_freq_mhz: 100.0, - }, - source: "t".into(), - } - } - - #[test] - fn row_slice_is_a_full_f2_trace_at_fixed_f1() { - let s = spectrum(); - let row = s.slice(SliceKind::Row, 1); - assert_eq!(row.coordinates, vec![1.0, 2.0, 3.0]); - assert_eq!(row.domain, Domain::Frequency); - assert_eq!( - row.values.iter().map(|c| c.re).collect::>(), - vec![10.0, 11.0, 12.0] - ); - assert_eq!(row.nucleus, "1H"); - assert_eq!(row.position, Some(20.0)); - assert_eq!(row.position_domain, Domain::Frequency); - } - - #[test] - fn column_slice_is_a_full_f1_trace_at_fixed_f2() { - let s = spectrum(); - let col = s.slice(SliceKind::Column, 2); - assert_eq!(col.coordinates, vec![10.0, 20.0]); - assert_eq!( - col.values.iter().map(|c| c.re).collect::>(), - vec![2.0, 12.0] - ); - assert_eq!(col.nucleus, "13C"); - assert_eq!(col.position, Some(3.0)); - } - - #[test] - fn sum_projection_collapses_the_other_axis() { - let s = spectrum(); - let proj = s.project(SliceKind::Row, ProjectionMode::Sum); - // Column c sums rows: (0+10), (1+11), (2+12). - assert_eq!( - proj.values.iter().map(|c| c.re).collect::>(), - vec![10.0, 12.0, 14.0] - ); - assert_eq!(proj.position, None); - } - - #[test] - fn nearest_index_snaps_to_the_grid() { - let s = spectrum(); - assert_eq!(s.nearest_f2(2.4), 1); - assert_eq!(s.nearest_f1(18.0), 1); +pub fn extract( + source: &plotx_io::nmr_view::NmrSource, + kind: SliceKind, + reduction: Reduction, +) -> Result<(plotx_io::nmr_view::NmrSource, Slice1D), String> { + use crate::Processed1D; + use nmr::processing::{ + ProcessingOperation, ProcessingOptions, ProcessingPlan, SpectrumOperation, + }; + let axis = match kind { + SliceKind::Row => 0, + SliceKind::Column => 1, + }; + let axes = source.axes(); + if axes.len() != 2 { + return Err("Slice extraction requires two axes".into()); } + let operation = match reduction { + Reduction::Slice(index) => SpectrumOperation::Slice { + index, + component: 0, + }, + Reduction::Projection(ProjectionMode::Sum) => SpectrumOperation::Sum { component: 0 }, + Reduction::Projection(ProjectionMode::Skyline) => { + SpectrumOperation::Skyline { component: 0 } + } + }; + let output = ProcessingPlan::new(vec![ProcessingOperation::Spectrum { axis, operation }]) + .and_then(|plan| { + plan.apply_with_context( + source.dataset(), + ProcessingOptions::default(), + &mut nmr::ExecutionContext::default(), + ) + }) + .map_err(|error| error.to_string())?; + let output = plotx_io::nmr_view::NmrSource::new(std::sync::Arc::new(output)) + .map_err(|error| error.to_string())?; + let view = crate::nmr_execution::view_1d(&output).map_err(|error| error.to_string())?; + let (coordinates, domain, values) = match view { + Processed1D::Frequency(s) => (s.ppm, Domain::Frequency, s.values), + Processed1D::Time(t) => (t.time_s, Domain::Time, t.values), + }; + let surviving = &output.axes()[0]; + let position = match reduction { + Reduction::Slice(index) => axes[axis] + .coordinate_values() + .map_err(|error| error.to_string())? + .get(index) + .copied(), + Reduction::Projection(_) => None, + }; + let slice = Slice1D { + coordinates, + domain, + values, + reference_freq_mhz: output.reference_frequency_mhz(0), + nucleus: surviving.nucleus.clone().unwrap_or_default(), + observe_freq_mhz: surviving.observe_frequency_mhz(), + unit: surviving + .unit + .ok_or_else(|| "Slice has no spectral unit".to_owned())?, + position, + position_domain: if axes[axis].domain == nmr::axis::AxisDomain::Time { + Domain::Time + } else { + Domain::Frequency + }, + }; + Ok((output, slice)) } diff --git a/crates/processing/src/tests.rs b/crates/processing/src/tests.rs index 80c959f2..b6c043e9 100644 --- a/crates/processing/src/tests.rs +++ b/crates/processing/src/tests.rs @@ -1,9 +1,12 @@ //! Unit tests for the processing pipeline and 2D transforms. use super::*; +#[path = "nmr_test_execution.rs"] +mod execution; +use execution::*; use plotx_io::{Dim, Domain, NmrData2D, QuadMode}; -fn data2d(exp: Option<&str>, f2_nuc: &str, f1_nuc: &str) -> NmrData2D { +fn data2d(exp: Option<&str>, f2_nuc: &str, f1_nuc: &str) -> plotx_io::nmr_series::NmrSeriesSource { let dim = |nuc: &str| Dim { spectral_width_hz: 1000.0, observe_freq_mhz: 400.0, @@ -12,9 +15,9 @@ fn data2d(exp: Option<&str>, f2_nuc: &str, f1_nuc: &str) -> NmrData2D { group_delay: 0.0, }; NmrData2D { - data: Vec::new(), - rows: 0, - cols: 0, + data: vec![Complex64::new(1.0, 0.0); 4], + rows: 2, + cols: 2, domain: Domain::Time, direct: dim(f2_nuc), indirect: dim(f1_nuc), @@ -26,6 +29,8 @@ fn data2d(exp: Option<&str>, f2_nuc: &str, f1_nuc: &str) -> NmrData2D { nus: None, source: String::new(), } + .try_into() + .unwrap() } #[test] @@ -279,8 +284,9 @@ mod groundtruth { Spectrum { ppm: (0..n).map(|i| i as f64).collect(), values, - hz_per_point: 1.0, - observe_freq_mhz: 400.0, + unit: nmr::axis::AxisUnit::Ppm, + hz_per_point: Some(1.0), + observe_freq_mhz: Some(400.0), nucleus: "1H".into(), } } @@ -318,8 +324,7 @@ mod groundtruth { ) -> f64 { let truth = clean(n, peaks); let mut s = spec(scramble(&truth, a0, a1, noise)); - let (p0, p1, piv) = auto_phase(&s, m); - phase::apply_with_pivot(&mut s, p0, p1, piv); + apply_phase(&mut s, m); residual(&s.values, &truth) } @@ -566,15 +571,15 @@ fn process_up_to_returns_time_then_freq() { steps: vec![apo, fft], }; match process_up_to(&data, &pipe, true, apo_id) { - Preview::Time { fid, dt } => { - assert_eq!(fid.len(), data.len()); - assert!((dt - 1.0 / data.spectral_width_hz).abs() < 1e-12); + Processed1D::Time(trace) => { + assert_eq!(trace.values.len(), data.len()); + assert!((trace.time_s[1] - 1.0 / data.spectral_width_hz).abs() < 1e-12); } _ => panic!("expected time-domain preview"), } assert!(matches!( process_up_to(&data, &pipe, true, fft_id), - Preview::Freq(_) + Processed1D::Frequency(_) )); } diff --git a/crates/processing/src/twod.rs b/crates/processing/src/twod.rs index 76b10474..a90d1c56 100644 --- a/crates/processing/src/twod.rs +++ b/crates/processing/src/twod.rs @@ -8,14 +8,22 @@ use std::sync::Arc; #[derive(Debug, Clone)] pub struct AxisMeta { pub nucleus: String, - pub observe_freq_mhz: f64, + pub observe_freq_mhz: Option, + pub unit: Option, +} + +impl AxisMeta { + pub fn unit_label(&self) -> &'static str { + crate::axis_unit_label(self.unit) + } } impl From<&plotx_io::Dim> for AxisMeta { fn from(d: &plotx_io::Dim) -> Self { Self { nucleus: d.nucleus.clone(), - observe_freq_mhz: d.observe_freq_mhz, + observe_freq_mhz: Some(d.observe_freq_mhz), + unit: Some(nmr::axis::AxisUnit::Ppm), } } } @@ -81,7 +89,7 @@ impl Preset2D { /// Best-guess preset for a dataset from its experiment hint and nuclei. Pseudo-2D /// families (DOSY, relaxation) are matched first; otherwise homo- vs /// heteronuclear is decided from the two axes' nuclei. -pub fn recommend_preset(data: &plotx_io::NmrData2D) -> Preset2D { +pub fn recommend_preset(data: &plotx_io::nmr_series::NmrSeriesSource) -> Preset2D { // A recovered indirect ruler is the strongest signal: some JEOL relaxation // arrays carry no relaxation keyword in the experiment name, but do embed a // delay/gradient `y_acq` axis. Trust it over the hint. @@ -123,7 +131,9 @@ pub fn recommend_preset(data: &plotx_io::NmrData2D) -> Preset2D { if has(&["cosy"]) { return Preset2D::Cosy; } - if data.direct.nucleus == data.indirect.nucleus { + if data.direct.nucleus.is_empty() || data.indirect.nucleus.is_empty() { + Preset2D::Generic + } else if data.direct.nucleus == data.indirect.nucleus { Preset2D::Cosy } else { Preset2D::Hsqc @@ -178,11 +188,11 @@ pub fn needs_retransform_2d(a: &Params2D, b: &Params2D) -> bool { /// indirect one. #[derive(Debug, Clone)] pub struct Spectrum2D { - /// Coordinate values for F2. The historical field name is retained for - /// project-internal compatibility; `f2_domain` decides whether values are - /// ppm or acquisition seconds. + /// Full Cartesian magnitude projected for display, including indirect lanes. + pub magnitude_plane: Option>, + /// Coordinate values for F2, interpreted through `direct.unit`. pub f2_ppm: Vec, - /// Coordinate values for F1; interpreted through `f1_domain`. + /// Coordinate values for F1, interpreted through `indirect.unit`. pub f1_ppm: Vec, pub f2_domain: plotx_io::Domain, pub f1_domain: plotx_io::Domain, @@ -207,7 +217,18 @@ impl Spectrum2D { /// Row-major magnitude grid, `f1_size × f2_size`. pub fn magnitude(&self) -> Vec { - self.data.iter().map(|c| c.norm() as f32).collect() + self.magnitude_plane.as_ref().map_or_else( + || self.data.iter().map(|c| c.norm() as f32).collect(), + |plane| plane.iter().map(|value| *value as f32).collect(), + ) + } + + /// Full Cartesian magnitude at a checked row-major view position. + pub fn magnitude_at(&self, index: usize) -> Option { + self.magnitude_plane.as_ref().map_or_else( + || self.data.get(index).map(|value| value.norm()), + |plane| plane.get(index).copied(), + ) } /// Row-major real (absorption) grid, `f1_size × f2_size`. Meaningful once @@ -225,7 +246,10 @@ impl Spectrum2D { } pub fn max_magnitude(&self) -> f64 { - self.data.iter().map(|c| c.norm()).fold(0.0, f64::max) + self.magnitude_plane.as_ref().map_or_else( + || self.data.iter().map(|c| c.norm()).fold(0.0, f64::max), + |plane| plane.iter().copied().fold(0.0, f64::max), + ) } /// Default first-order phase pivots `(f2_frac, f1_frac)` at the tallest peak. @@ -315,161 +339,6 @@ pub enum Processed2D { Stack(Arc), } -/// Transform a 2D acquisition into an *unphased* frequency-domain result. This -/// is the expensive stage (FFT + window + zero-fill); the app caches it as the -/// `base` and re-derives the phased, display-ready spectrum with [`reapply_2d`]. -pub fn process_2d(data: &plotx_io::NmrData2D, params: &Params2D) -> Processed2D { - process_2d_cancellable(data, params, &|| false).expect("non-cancelling 2D transform") -} - -pub fn process_2d_cancellable( - data: &plotx_io::NmrData2D, - params: &Params2D, - cancelled: &impl Fn() -> bool, -) -> Option { - match params.layout { - Layout2D::Ft => fft2::transform_cancellable(data, params, cancelled) - .map(Arc::new) - .map(Processed2D::Ft), - Layout2D::Stack => fft2::stack_cancellable(data, params, cancelled) - .map(Arc::new) - .map(Processed2D::Stack), - } -} - -/// Cheap stage: apply the enabled frequency-domain steps in `params` to an -/// unphased `base` from [`process_2d`], producing the display-ready spectrum. -/// Baseline steps are not supported for 2D and are ignored. No FFT is run. -pub fn reapply_2d(base: &Processed2D, params: &Params2D) -> Processed2D { - reapply_2d_cancellable(base, params, &|| false).expect("non-cancelling 2D reapply") -} - -pub fn reapply_2d_cancellable( - base: &Processed2D, - params: &Params2D, - cancelled: &impl Fn() -> bool, -) -> Option { - match base { - Processed2D::Ft(s) => reapply_ft(s, params, cancelled) - .map(Arc::new) - .map(Processed2D::Ft), - Processed2D::Stack(s) => reapply_stack(s, params, cancelled) - .map(Arc::new) - .map(Processed2D::Stack), - } -} - -// Reduce an axis pipeline's enabled Phase steps to one `(phase0, phase1, pivot)`: -// stored terms sum, and any auto step contributes the phase from `auto`. -fn axis_phase( - pipe: &AxisPipeline, - auto: impl Fn() -> (f64, f64), - default_pivot: f64, -) -> (f64, f64, f64) { - let (mut p0, mut p1, mut pivot) = (0.0, 0.0, default_pivot); - for step in &pipe.steps { - if !step.enabled { - continue; - } - if let StepKind::Phase(p) = &step.kind { - match p.auto { - Some(_) => { - let (a0, a1) = auto(); - p0 += a0; - p1 += a1; - } - None => { - p0 += p.phase0; - p1 += p.phase1; - pivot = p.pivot_frac; - } - } - } - } - (p0, p1, pivot) -} - -fn has_magnitude(pipe: &AxisPipeline) -> bool { - pipe.steps - .iter() - .any(|s| s.enabled && matches!(s.kind, StepKind::Magnitude)) -} - -fn shift_reference(ppm: &mut [f64], pipe: &AxisPipeline) { - let delta: f64 = pipe - .steps - .iter() - .filter(|s| s.enabled) - .filter_map(|s| match &s.kind { - StepKind::Reference(r) => Some(r.target_ppm - r.at_ppm), - _ => None, - }) - .sum(); - if delta != 0.0 { - for p in ppm.iter_mut() { - *p += delta; - } - } -} - -fn reapply_ft( - s: &Spectrum2D, - params: &Params2D, - cancelled: &impl Fn() -> bool, -) -> Option { - if cancelled() { - return None; - } - let (f2_pivot, f1_pivot) = s.peak_pivot_fracs(); - let peak_arg = s - .data - .iter() - .max_by(|a, b| a.norm().total_cmp(&b.norm())) - .map_or(0.0, |c| c.arg()); - let f2 = axis_phase(¶ms.f2, || (peak_arg, 0.0), f2_pivot); - let f1 = axis_phase(¶ms.f1, || (peak_arg, 0.0), f1_pivot); - let mut out = fft2::reapply_phase_2d_cancellable(s, f2, f1, cancelled)?; - if has_magnitude(¶ms.f2) || has_magnitude(¶ms.f1) { - for row in out.data.chunks_mut(out.f2_size.max(1)) { - if cancelled() { - return None; - } - for c in row { - *c = Complex64::new(c.norm(), 0.0); - } - } - } - shift_reference(&mut out.f2_ppm, ¶ms.f2); - shift_reference(&mut out.f1_ppm, ¶ms.f1); - Some(out) -} - -fn reapply_stack( - s: &StackSpectrum, - params: &Params2D, - cancelled: &impl Fn() -> bool, -) -> Option { - if cancelled() { - return None; - } - let pivot = s.peak_pivot_frac(); - let auto = fft2::absorptive_phase(&s.traces).unwrap_or((0.0, 0.0)); - let f2 = axis_phase(¶ms.f2, || auto, pivot); - let mut out = fft2::reapply_phase_stack_cancellable(s, f2, cancelled)?; - if has_magnitude(¶ms.f2) { - for t in &mut out.traces { - if cancelled() { - return None; - } - for c in t { - *c = Complex64::new(c.norm(), 0.0); - } - } - } - shift_reference(&mut out.ppm, ¶ms.f2); - Some(out) -} - /// Index `i` as a `0..=1` fraction of a `size`-point axis (`0.0` if degenerate). fn frac_of(i: usize, size: usize) -> f64 { if size < 2 { diff --git a/crates/processing/src/xps.rs b/crates/processing/src/xps.rs index c3b2c1a4..582c11db 100644 --- a/crates/processing/src/xps.rs +++ b/crates/processing/src/xps.rs @@ -1,5 +1,6 @@ use crate::{NormalizeMethod, SmoothMethod, StepId, StepSource}; -use num_complex::Complex64; +#[path = "xps_signal.rs"] +mod signal; #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub enum XpsStepKind { @@ -81,18 +82,7 @@ pub fn process_region( ) { return Err("XPS normalization divisor must be finite and non-zero"); } - let mut spectrum = crate::Spectrum { - ppm: energy.clone(), - values: values - .iter() - .map(|value| Complex64::new(*value, 0.0)) - .collect(), - hz_per_point: 1.0, - observe_freq_mhz: 1.0, - nucleus: "XPS".into(), - }; - crate::cleanup::normalize(&mut spectrum, method); - values = spectrum.values.into_iter().map(|value| value.re).collect(); + signal::normalize(&energy, &mut values, method); } } if values.iter().any(|value| !value.is_finite()) { @@ -124,7 +114,7 @@ pub fn estimate_charge_shift( return Err("the C 1s reference region is invalid"); } let edge = 3.min(energy_ev.len() / 4); - let smoothed = crate::cleanup::gaussian_smooth_real(intensity, 3.0) + let smoothed = signal::gaussian_smooth_real(intensity, 3.0) .ok_or("the C 1s reference region cannot be smoothed")?; let index = smoothed[edge..smoothed.len() - edge] .iter() @@ -135,19 +125,8 @@ pub fn estimate_charge_shift( Ok(reference_ev - energy_ev[index]) } -fn smooth_values(energy: &[f64], values: &[f64], method: SmoothMethod) -> Vec { - let mut spectrum = crate::Spectrum { - ppm: energy.to_vec(), - values: values - .iter() - .map(|value| Complex64::new(*value, 0.0)) - .collect(), - hz_per_point: 1.0, - observe_freq_mhz: 1.0, - nucleus: "XPS".into(), - }; - crate::cleanup::smooth(&mut spectrum, method); - spectrum.values.into_iter().map(|value| value.re).collect() +fn smooth_values(_energy: &[f64], values: &[f64], method: SmoothMethod) -> Vec { + signal::smooth(values, method) } #[cfg(test)] diff --git a/crates/processing/src/xps_signal.rs b/crates/processing/src/xps_signal.rs new file mode 100644 index 00000000..66757f69 --- /dev/null +++ b/crates/processing/src/xps_signal.rs @@ -0,0 +1,170 @@ +//! Real-valued XPS intensity processing in binding-energy coordinates. +use crate::{NormalizeMethod, SmoothMethod}; + +/// Gaussian smoothing for real-valued detection helpers that need a stable, +/// symmetric kernel but are not persisted processing steps. +pub fn gaussian_smooth_real(values: &[f64], sigma: f64) -> Option> { + if values.is_empty() + || !sigma.is_finite() + || sigma <= 0.0 + || values.iter().any(|value| !value.is_finite()) + { + return None; + } + let radius = (3.0 * sigma).ceil() as isize; + Some( + (0..values.len()) + .map(|index| { + let mut weighted = 0.0; + let mut total = 0.0; + for offset in -radius..=radius { + let source = + (index as isize + offset).clamp(0, values.len() as isize - 1) as usize; + let weight = (-0.5 * (offset as f64 / sigma).powi(2)).exp(); + weighted += values[source] * weight; + total += weight; + } + weighted / total + }) + .collect(), + ) +} + +fn moving_average(values: &mut Vec, window: usize) { + let n = values.len(); + let w = (window.max(3) | 1).min(if n % 2 == 1 { n } else { n.saturating_sub(1) }); + if n < 3 || w < 3 { + return; + } + let h = w / 2; + let mut out = Vec::with_capacity(n); + for i in 0..n { + let lo = i.saturating_sub(h); + let hi = (i + h + 1).min(n); + let sum: f64 = values[lo..hi].iter().sum(); + out.push(sum / (hi - lo) as f64); + } + *values = out; +} + +/// Least-squares polynomial smoothing: each point is replaced by the value of a +/// degree-`order` polynomial fitted over an odd `window` around it. Edge points +/// reuse the boundary window, evaluated off-center, so a polynomial signal of +/// degree ≤ `order` is reproduced exactly everywhere. +fn savitzky_golay(values: &mut Vec, window: usize, order: usize) { + let n = values.len(); + let w = (window.max(3) | 1).min(if n % 2 == 1 { n } else { n.saturating_sub(1) }); + if n < 3 || w < 3 { + return; + } + let m = order.clamp(1, w - 1) + 1; + let h = w / 2; + let x = |i: usize| i as f64 - h as f64; + + let mut gram = vec![vec![0.0; m]; m]; + for i in 0..w { + let mut powers = vec![1.0; m]; + for k in 1..m { + powers[k] = powers[k - 1] * x(i); + } + for r in 0..m { + for c in 0..m { + gram[r][c] += powers[r] * powers[c]; + } + } + } + let mut gram_inv = vec![vec![0.0; m]; m]; + for k in 0..m { + let mut e = vec![0.0; m]; + e[k] = 1.0; + let Some(col) = plotx_analysis::fit::solve_linear(&gram, &e) else { + return; + }; + for r in 0..m { + gram_inv[r][k] = col[r]; + } + } + let sample_powers: Vec> = (0..w) + .map(|i| { + let mut powers = vec![1.0; m]; + for k in 1..m { + powers[k] = powers[k - 1] * x(i); + } + powers + }) + .collect(); + // projection[k][i]: coefficient k of the fitted polynomial from sample i. + let projection: Vec> = (0..m) + .map(|r| { + sample_powers + .iter() + .map(|powers| (0..m).map(|k| gram_inv[r][k] * powers[k]).sum()) + .collect() + }) + .collect(); + // weights[p][i]: smoothing weights when evaluating at offset p in the window. + let weights: Vec> = (0..w) + .map(|p| { + let mut powers = vec![1.0; m]; + for k in 1..m { + powers[k] = powers[k - 1] * x(p); + } + (0..w) + .map(|i| (0..m).map(|k| powers[k] * projection[k][i]).sum()) + .collect() + }) + .collect(); + + let mut out = Vec::with_capacity(n); + for i in 0..n { + let (start, p) = if i < h { + (0, i) + } else if i + h >= n { + (n - w, i - (n - w)) + } else { + (i - h, h) + }; + let mut acc = 0.0; + for (j, &weight) in weights[p].iter().enumerate() { + acc += values[start + j] * weight; + } + out.push(acc); + } + *values = out; +} + +pub fn normalize(axis: &[f64], values: &mut [f64], method: NormalizeMethod) { + let scale = match method { + NormalizeMethod::MaxPeak => values.iter().map(|c| c.abs()).fold(0.0, f64::max), + NormalizeMethod::TotalArea => values.iter().map(|c| c.abs()).sum::() * axis_step(axis), + NormalizeMethod::Constant { divisor } => divisor, + }; + if scale.is_finite() && scale.abs() > f64::MIN_POSITIVE { + for c in values { + *c /= scale; + } + } +} + +pub fn axis_step(ppm: &[f64]) -> f64 { + if ppm.len() < 2 { + return 1.0; + } + let span = (ppm[ppm.len() - 1] - ppm[0]).abs(); + if span > 0.0 { + span / (ppm.len() - 1) as f64 + } else { + 1.0 + } +} + +pub fn smooth(values: &[f64], method: SmoothMethod) -> Vec { + let mut values = values.to_vec(); + match method { + SmoothMethod::MovingAverage { window } => moving_average(&mut values, window as usize), + SmoothMethod::SavitzkyGolay { window, poly_order } => { + savitzky_golay(&mut values, window as usize, poly_order as usize) + } + } + values +} diff --git a/crates/processing/tests/auto_correction_quality.rs b/crates/processing/tests/auto_correction_quality.rs index 2bb9f659..d01d9dbd 100644 --- a/crates/processing/tests/auto_correction_quality.rs +++ b/crates/processing/tests/auto_correction_quality.rs @@ -6,7 +6,59 @@ //! preserved. use num_complex::Complex64; -use plotx_processing::{AutoPhaseMethod, BaselineMethod, Spectrum, auto_phase, baseline, phase}; +use plotx_processing::{ + AutoPhaseMethod, AxisPipeline, BaselineMethod, PhaseParams, ProcessingStep, Spectrum, StepId, + StepKind, StepSource, nmr_bridge, +}; + +fn apply(spectrum: &mut Spectrum, kind: StepKind) { + let axis = nmr::processed::ProcessedAxis::new( + nmr::axis::AxisRole::Signal, + nmr::axis::AxisDomain::Frequency, + Some(nmr::axis::AxisUnit::Ppm), + spectrum.values.len(), + nmr::axis::AxisCoordinates::Explicit(spectrum.ppm.clone()), + nmr::processed::ComponentBasis::Cartesian, + ) + .unwrap(); + let input = std::sync::Arc::new(nmr::Dataset::from_processed( + nmr::processed::ProcessedDataset::from_complex_trace( + axis, + spectrum.values.clone(), + nmr::processed::ProcessedProvenance::new( + nmr::processed::ProcessedOrigin::Unknown, + vec![], + ) + .unwrap(), + ) + .unwrap(), + )); + let pipeline = AxisPipeline { + steps: vec![ProcessingStep::new(StepId::new(1), kind, StepSource::User)], + }; + let output = nmr_bridge::compile( + input, + &pipeline, + 0, + nmr_bridge::DelayPolicy::Disabled, + nmr_bridge::RecipeRange::All, + ) + .unwrap() + .execute( + nmr::processing::ProcessingOptions::new(), + &mut nmr::ExecutionContext::default(), + ) + .unwrap(); + spectrum.values = output + .as_dense_processed() + .unwrap() + .samples() + .as_chunks::<2>() + .0 + .iter() + .map(|v| Complex64::new(v[0], v[1])) + .collect(); +} const PHASE_POINTS: usize = 512; const BASELINE_POINTS: usize = 640; @@ -16,8 +68,9 @@ fn spectrum(values: Vec) -> Spectrum { Spectrum { ppm: (0..n).map(|i| i as f64).collect(), values, - hz_per_point: 1.0, - observe_freq_mhz: 400.0, + unit: nmr::axis::AxisUnit::Ppm, + hz_per_point: Some(1.0), + observe_freq_mhz: Some(400.0), nucleus: "1H".into(), } } @@ -120,8 +173,13 @@ fn assess_phase_quality(corrected: &[Complex64], reference: &[Complex64]) -> Pha fn robust_phase_quality(phase0: f64, phase1: f64, scale: f64) -> PhaseQuality { let reference = ideal_phase_spectrum(scale); let mut observed = spectrum(inject_phase(&reference, phase0, phase1)); - let correction = auto_phase(&observed, AutoPhaseMethod::RobustConsensus); - phase::apply_with_pivot(&mut observed, correction.0, correction.1, correction.2); + apply( + &mut observed, + StepKind::Phase(PhaseParams { + auto: Some(AutoPhaseMethod::RobustConsensus), + ..PhaseParams::MANUAL_ZERO + }), + ); assess_phase_quality(&observed.values, &reference) } @@ -254,7 +312,7 @@ fn asls_quality(shape: BaselineShape, scale: f64) -> BaselineQuality { } let observed = values.clone(); let mut corrected = spectrum(values); - baseline::apply(&mut corrected, BaselineMethod::AUTO); + apply(&mut corrected, StepKind::Baseline(BaselineMethod::AUTO)); // Baseline correction is a real-channel operation. Treating the imaginary // channel as immutable is part of its public signal-preservation contract. @@ -397,7 +455,7 @@ fn asls_non_target_peak_shapes_remain_numerically_safe() { .map(|value| value.re.abs()) .fold(0.0_f64, f64::max); let mut corrected = spectrum(values); - baseline::apply(&mut corrected, BaselineMethod::AUTO); + apply(&mut corrected, StepKind::Baseline(BaselineMethod::AUTO)); assert!(corrected.values.iter().all(|value| value.re.is_finite())); for (value, expected_imaginary) in corrected.values.iter().zip(&imaginary_before) { diff --git a/crates/processing/tests/nmr_axis_evidence.rs b/crates/processing/tests/nmr_axis_evidence.rs new file mode 100644 index 00000000..04d286e9 --- /dev/null +++ b/crates/processing/tests/nmr_axis_evidence.rs @@ -0,0 +1,99 @@ +use nmr::processed::{ + ComponentBasis, ProcessedAxis, ProcessedData, ProcessedDataset, ProcessedDescriptor, + ProcessedOrigin, ProcessedProvenance, +}; +use nmr::processing::{FrequencyFrame, ProcessingOperation as Op, ProcessingPlan, ReferenceSource}; +use nmr::{ + ExecutionContext, + axis::{AxisCoordinates, AxisDomain, AxisRole, AxisUnit, FrequencyEvidence}, +}; +use plotx_io::{nmr_bridge, nmr_view::NmrSource}; +use plotx_processing::{ + arithmetic::{SpectrumBinaryOp, combine_spectra}, + slice::{Reduction, SliceKind, extract}, +}; +use std::sync::Arc; + +fn two_axes(indirect_reference: f64) -> NmrSource { + let axes = [2, 3] + .into_iter() + .map(|points| { + ProcessedAxis::new( + AxisRole::Signal, + AxisDomain::Frequency, + Some(AxisUnit::Hertz), + points, + AxisCoordinates::Uniform { + start: 0.0, + step: 1.0, + }, + ComponentBasis::Cartesian, + ) + .unwrap() + .with_frequency_evidence(Some(FrequencyEvidence::new(Some(500.005), None).unwrap())) + .unwrap() + }) + .collect(); + let descriptor = ProcessedDescriptor::new(axes).unwrap(); + let data = + ProcessedData::from_descriptor(&descriptor, (1..=24).map(f64::from).collect()).unwrap(); + let input = ProcessedDataset::new( + descriptor, + data, + ProcessedProvenance::new(ProcessedOrigin::Unknown, vec![]).unwrap(), + ) + .unwrap(); + let ops = [indirect_reference, 400.0] + .into_iter() + .enumerate() + .map(|(axis, mhz)| Op::ResolveFrequencyFrame { + axis, + frame: FrequencyFrame::Ppm(ReferenceSource::Explicit( + nmr::raw::ChemicalShiftReference::user_constructed(5.0, mhz).unwrap(), + )), + }) + .collect(); + NmrSource::new(Arc::new( + ProcessingPlan::new(ops) + .unwrap() + .apply(&input.into()) + .unwrap(), + )) + .unwrap() +} + +#[test] +fn column_slice_reindexes_reference_and_binary_output_keeps_a_reference_offline() { + let (a, _) = extract(&two_axes(100.0), SliceKind::Column, Reduction::Slice(1)).unwrap(); + let (b, _) = extract(&two_axes(200.0), SliceKind::Column, Reduction::Slice(1)).unwrap(); + assert_eq!(a.reference_frequency_mhz(0), Some(100.0)); + assert_eq!(a.reference_frequency_mhz(1), None); + assert_eq!(b.reference_frequency_mhz(0), Some(200.0)); + let result = combine_spectra(&a, &b, SpectrumBinaryOp::Add, 1.0).unwrap(); + assert_eq!(result.reference_frequency_mhz(0), Some(100.0)); + assert_eq!(result.axes()[0].observe_frequency_mhz(), Some(500.005)); + assert_eq!( + result.axes()[0].coordinate_values().unwrap(), + a.axes()[0].coordinate_values().unwrap() + ); + let mut bytes = Vec::new(); + nmr_bridge::snapshot::write( + result.dataset(), + &mut bytes, + Default::default(), + &mut ExecutionContext::default(), + ) + .unwrap(); + let restored = NmrSource::new( + nmr_bridge::snapshot::read( + &mut bytes.as_slice(), + Default::default(), + &mut ExecutionContext::default(), + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(restored.reference_frequency_mhz(0), Some(100.0)); + assert_eq!(restored.axes()[0].observe_frequency_mhz(), Some(500.005)); + assert_eq!(restored.trace().unwrap(), result.trace().unwrap()); +} diff --git a/crates/processing/tests/nmr_group_delay.rs b/crates/processing/tests/nmr_group_delay.rs new file mode 100644 index 00000000..a966f985 --- /dev/null +++ b/crates/processing/tests/nmr_group_delay.rs @@ -0,0 +1,104 @@ +//! Preserve the signal inputs and tolerances from the former fft.rs tests. +use nmr::{Complex64, ExecutionContext}; +use plotx_io::{Domain, NmrData, nmr_view::NmrSource}; +use plotx_processing::nmr_bridge::{DelayPolicy, RecipeRange}; +use plotx_processing::{AxisPipeline, ProcessingStep, StepId, StepKind, StepSource}; +use std::f64::consts::TAU; + +fn fid() -> NmrData { + NmrData { + points: (0..1024) + .map(|k| { + let t = k as f64 / 4000.0; + Complex64::from_polar((-t / 1.0).exp(), TAU * 800.0 * t) + }) + .collect(), + domain: Domain::Time, + spectral_width_hz: 4000.0, + observe_freq_mhz: 400.0, + carrier_ppm: 0.0, + nucleus: "1H".into(), + source: "original group-delay regression".into(), + group_delay: 0.0, + } +} + +fn corrected(data: NmrData) -> Vec { + let source = NmrSource::try_from(data).unwrap(); + let pipe = AxisPipeline { + steps: vec![ProcessingStep::new( + StepId::new(0), + StepKind::Fft, + StepSource::User, + )], + }; + plotx_processing::nmr_execution::execute_1d( + &source, + &pipe, + DelayPolicy::AxisEvidence, + RecipeRange::Base, + &mut ExecutionContext::default(), + ) + .unwrap() + .source + .trace() + .unwrap() +} + +#[test] +fn group_delay_is_removed_with_the_original_seven_point_shift_and_tolerance() { + let ideal = fid(); + let n = ideal.points.len(); + let d = 7usize; + let mut delayed = ideal.clone(); + delayed.points = (0..n).map(|k| ideal.points[(k + n - d) % n]).collect(); + delayed.group_delay = d as f64; + let a = corrected(ideal); + let b = corrected(delayed); + let max_err = a + .iter() + .zip(&b) + .map(|(x, y)| (x.re - y.re).abs()) + .fold(0.0f64, f64::max); + assert!(max_err < 1e-9, "group delay not removed: max_err={max_err}"); +} + +#[test] +fn fractional_group_delay_uses_the_original_signed_bins_and_tolerance() { + let n = 16usize; + let delay = 3.25; + let negative_start = n.div_ceil(2); + let phase_per_bin = TAU * delay / n as f64; + let delayed: Vec = (0..n) + .map(|m| { + let signed_bin = if m < negative_start { + m as f64 + } else { + m as f64 - n as f64 + }; + Complex64::from_polar(1.0, -phase_per_bin * signed_bin) + }) + .collect(); + // Independent inverse DFT feeds the original spectrum into the public FFT + // bridge. The expected corrected spectrum remains exactly one at every bin. + let mut input = fid(); + input.points = (0..n) + .map(|k| { + delayed + .iter() + .enumerate() + .map(|(m, value)| { + value * Complex64::from_polar(1.0, TAU * (m * k) as f64 / n as f64) + }) + .sum::() + / n as f64 + }) + .collect(); + input.group_delay = delay; + assert!( + corrected(input) + .iter() + .all(|value| (*value - Complex64::new(1.0, 0.0)).norm() < 1e-12), + "fractional delay correction must not introduce a phase jump at DC" + ); +} diff --git a/crates/processing/tests/nmr_nus.rs b/crates/processing/tests/nmr_nus.rs new file mode 100644 index 00000000..459d98ad --- /dev/null +++ b/crates/processing/tests/nmr_nus.rs @@ -0,0 +1,126 @@ +use nmr::axis::{AxisCoordinates, AxisDomain, AxisUnit}; +use nmr::processing::{ + FourierTransform, NusSettings, ProcessingOperation, ProcessingOptions, ProcessingPlan, +}; +use nmr::raw::*; +use nmr::{Complex64, Dataset, ExecutionContext}; +use plotx_processing::{AxisPipeline, ProcessingStep, StepId, StepKind, StepSource, nmr_bridge}; + +/// An independent 4x3 separable tone, with one missing indirect observation. +#[test] +fn sparse_tone_reconstructs_and_round_trips() -> Result<(), Box> { + let indirect = RawAxis::new( + RawAxisKind::Indirect(IndirectComponents::Cartesian( + ComponentEvidence::user_constructed(), + )), + AxisDomain::Time, + Some(AxisUnit::Second), + 4, + AxisCoordinates::Uniform { + start: 0.0, + step: 0.01, + }, + )?; + let direct = RawAxis::new( + RawAxisKind::Direct(DirectSamples::Complex), + AxisDomain::Time, + Some(AxisUnit::Second), + 3, + AxisCoordinates::Uniform { + start: 0.0, + step: 0.001, + }, + )?; + let indices = [3, 0, 1]; + let coordinates: Vec<_> = indices + .iter() + .map(|i| SamplingCoordinate::new(vec![*i])) + .collect(); + let traces = indices + .iter() + .enumerate() + .map(|(ordinal, i)| { + let angle = std::f64::consts::TAU * *i as f64 / 4.0; + let samples = [angle.cos(), angle.sin()] + .into_iter() + .flat_map(|lane| { + (0..3).map(move |j| { + Complex64::from_polar(lane, std::f64::consts::TAU * j as f64 / 3.0) + }) + }) + .collect(); + SparseTrace::new( + ObservationOrdinal::new(ordinal), + coordinates[ordinal].clone(), + samples, + ) + }) + .collect(); + let input: Dataset = RawDatasetBuilder::new(vec![indirect, direct], RawMetadata::default())? + .sparse(traces, SamplingSchedule::new(vec![4], coordinates)?)? + .into(); + let plan = ProcessingPlan::new(vec![ProcessingOperation::FourierTransform { + axis: 1, + transform: FourierTransform::default(), + }])?; + let mut context = ExecutionContext::default(); + let options = ProcessingOptions::new(); + let prepared = NusSettings { + max_iterations: 1000, + noise_standard_deviation: Some(0.0), + } + .prepare(&input, plan, options)?; + if prepared.measured_indices() != indices { + return Err("observation order changed".into()); + } + let mixed = prepared.execute_with_context(&mut context)?; + let data = mixed + .as_dense_processed() + .ok_or("missing reconstructed samples")?; + for row in 0..4 { + let angle = std::f64::consts::TAU * row as f64 / 4.0; + for (lane, expected) in [3.0 * angle.cos(), 3.0 * angle.sin()] + .into_iter() + .enumerate() + { + if (data.get(&[row, 2], &[lane, 0])? - expected).abs() > 1e-5 { + return Err("NUS tone reconstruction exceeded 1e-5 amplitude error".into()); + } + } + } + let pipeline = AxisPipeline { + steps: vec![ProcessingStep::new( + StepId::new(77), + StepKind::Fft, + StepSource::User, + )], + }; + let frequency = nmr_bridge::compile( + std::sync::Arc::new(mixed), + &pipeline, + 0, + nmr_bridge::DelayPolicy::Disabled, + nmr_bridge::RecipeRange::All, + )? + .execute(options, &mut context)?; + let values = frequency.as_dense_processed().ok_or("missing F1 output")?; + if (values.get(&[3, 2], &[0, 0])? - 12.0).abs() > 1e-5 { + return Err("incorrect 2D peak amplitude".into()); + } + let mut bytes = vec![]; + plotx_io::nmr_bridge::snapshot::write( + &frequency, + &mut bytes, + Default::default(), + &mut context, + )?; + let restored = plotx_io::nmr_bridge::snapshot::read( + &mut bytes.as_slice(), + Default::default(), + &mut context, + )?; + if restored.canonical_digests() != frequency.canonical_digests() { + return Err("NUS snapshot identity changed".into()); + } + Ok(()) +} diff --git a/crates/processing/tests/nmr_operations.rs b/crates/processing/tests/nmr_operations.rs new file mode 100644 index 00000000..fc1d5273 --- /dev/null +++ b/crates/processing/tests/nmr_operations.rs @@ -0,0 +1,138 @@ +//! Every supported recipe operation executes through the production bridge. + +use nmr::processed::{ + ComponentBasis, ProcessedAxis, ProcessedDataset, ProcessedOrigin, ProcessedProvenance, +}; +use nmr::{ + Complex64, Dataset, ExecutionContext, + axis::{AxisCoordinates, AxisDomain, AxisRole, AxisUnit}, +}; +use plotx_processing::nmr_bridge::{self, DelayPolicy, RecipeRange}; +use plotx_processing::{ + Apodization, AutoPhaseMethod, AxisPipeline, BaselineMethod, BinParams, NormalizeMethod, + PhaseParams, ProcessingStep, ReferenceParams, SmoothMethod, StepId, StepKind, StepSource, +}; +use std::{error::Error, path::PathBuf, sync::Arc}; + +#[test] +fn supported_recipe_operations() -> Result<(), Box> { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../io/tests/fixtures/nmr"); + let raw = + plotx_io::nmr_bridge::read(&root.join("bruker-1d"), &mut ExecutionContext::default())?; + let axis = ProcessedAxis::new( + AxisRole::Signal, + AxisDomain::Frequency, + Some(AxisUnit::Ppm), + 128, + AxisCoordinates::Uniform { + start: 1.0, + step: 0.1, + }, + ComponentBasis::Cartesian, + )?; + let spectrum = Arc::new(Dataset::from_processed( + ProcessedDataset::from_complex_trace( + axis, + (0..128) + .map(|i| { + let mut value = Complex64::new(0.0, 0.0); + for (center, height) in [(24.0, 1.0), (67.0, 0.7), (105.0, 0.5)] { + let d = (i as f64 - center) / 2.0; + value += Complex64::new(height, height * d) / (1.0 + d * d); + } + value * Complex64::from_polar(1.0, 0.3 + 0.4 * i as f64 / 127.0) + }) + .collect(), + ProcessedProvenance::new(ProcessedOrigin::Unknown, vec![])?, + )?, + )); + let mut steps = vec![ + StepKind::Apodize(Apodization::Gaussian { + lb_hz: 1.0, + gb_hz: 2.0, + }), + StepKind::Baseline(BaselineMethod::Offset), + StepKind::Baseline(BaselineMethod::Polynomial { order: 2 }), + StepKind::Baseline(BaselineMethod::AUTO), + StepKind::Reference(ReferenceParams { + at_ppm: 1.0, + target_ppm: 2.0, + }), + StepKind::Smooth(SmoothMethod::MovingAverage { window: 3 }), + StepKind::Smooth(SmoothMethod::DEFAULT), + StepKind::Normalize(NormalizeMethod::MaxPeak), + StepKind::Normalize(NormalizeMethod::TotalArea), + StepKind::Normalize(NormalizeMethod::Constant { divisor: 2.0 }), + StepKind::Bin(BinParams::DEFAULT), + StepKind::Reverse, + StepKind::Invert, + ]; + steps.extend( + [ + AutoPhaseMethod::RobustConsensus, + AutoPhaseMethod::AbsorptivePeak, + AutoPhaseMethod::Entropy, + AutoPhaseMethod::NegativeMinimization, + AutoPhaseMethod::PeakRegression, + ] + .map(|method| { + StepKind::Phase(PhaseParams { + auto: Some(method), + ..PhaseParams::MANUAL_ZERO + }) + }), + ); + for (index, kind) in steps.into_iter().enumerate() { + let input = if matches!(kind, StepKind::Apodize(_)) { + &raw + } else { + &spectrum + }; + let label = format!("{kind:?}"); + let pipe = AxisPipeline { + steps: vec![ProcessingStep::new( + StepId::new(index as u64), + kind, + StepSource::User, + )], + }; + let result = nmr_bridge::compile( + Arc::clone(input), + &pipe, + 0, + DelayPolicy::Disabled, + RecipeRange::All, + ) + .and_then(|recipe| { + recipe.execute( + nmr::processing::ProcessingOptions::new(), + &mut ExecutionContext::default(), + ) + }); + result.unwrap_or_else(|error| panic!("{label}: {error}")); + } + Ok(()) +} + +#[test] +fn sparse_preparation_preserves_observation_order() -> Result<(), Box> { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../io/tests/fixtures/nmr"); + let sparse = + plotx_io::nmr_bridge::read(&root.join("bruker-nus"), &mut ExecutionContext::default())?; + let plan = nmr::processing::ProcessingPlan::new(vec![ + nmr::processing::ProcessingOperation::ComponentTransform { axis: 0 }, + nmr::processing::ProcessingOperation::FourierTransform { + axis: 1, + transform: nmr::processing::FourierTransform::default(), + }, + ])?; + // This fixture's arbitrary integer samples are not a sparse-spectrum oracle. + // Check preparation here; analytic reconstruction is verified separately. + let prepared = nmr::processing::NusSettings { + max_iterations: 2048, + noise_standard_deviation: Some(0.0), + } + .prepare(&sparse, plan, nmr::processing::ProcessingOptions::new())?; + assert_eq!(prepared.measured_indices(), [3, 1]); + Ok(()) +} diff --git a/crates/processing/tests/nmr_phase_quality.rs b/crates/processing/tests/nmr_phase_quality.rs new file mode 100644 index 00000000..6cdb5716 --- /dev/null +++ b/crates/processing/tests/nmr_phase_quality.rs @@ -0,0 +1,211 @@ +//! Retained scientific phase gates from src/tests.rs, executed through nmr. +use nmr::axis::{AxisCoordinates, AxisDomain, AxisRole, AxisUnit}; +use nmr::processed::{ + ComponentBasis, ProcessedAxis, ProcessedDataset, ProcessedOrigin, ProcessedProvenance, +}; +use nmr::processing::{PhaseMethod as AutoPhaseMethod, ProcessingOptions}; +use nmr::{Complex64, Dataset, ExecutionContext}; +type Result = std::result::Result>; + +/// Ground-truth auto-phase harness: build a known clean (absorptive) spectrum, +/// scramble it by a known `(phase0, phase1)`, and score how well a method's +/// correction recovers the original real part. `residual` is normalized RMS +/// against the clean spectrum, so 0 is a perfect recovery. These guard against +/// selecting a phase method on a p1=0-only benchmark, where any zero-order-only +/// method scores perfectly for the wrong reason. +mod groundtruth { + use super::*; + + pub fn clean(n: usize, peaks: &[(f64, f64, f64)]) -> Vec { + (0..n) + .map(|i| { + let mut c = Complex64::new(0.0, 0.0); + for &(frac_c, h, w) in peaks { + let d = (i as f64 - (frac_c * (n - 1) as f64).round()) / w; + c += Complex64::new(h / (1.0 + d * d), h * d / (1.0 + d * d)); + } + c + }) + .collect() + } + + pub fn scramble(vals: &[Complex64], a0: f64, a1: f64, noise: f64) -> Vec { + let denom = (vals.len() - 1) as f64; + vals.iter() + .enumerate() + .map(|(i, c)| { + let frac = i as f64 / denom; + let mut v = c * Complex64::from_polar(1.0, a0 + a1 * frac); + if noise > 0.0 { + let h = |k: f64| (((k * 12.9898).sin() * 43758.5453).fract() - 0.5) * 2.0; + v += Complex64::new(noise * h(i as f64), noise * h(i as f64 + 7.0)); + } + v + }) + .collect() + } + + fn residual(recovered: &[Complex64], truth: &[Complex64]) -> f64 { + let num: f64 = recovered + .iter() + .zip(truth) + .map(|(r, t)| (r.re - t.re).powi(2)) + .sum(); + let den: f64 = truth.iter().map(|t| t.re * t.re).sum(); + (num / den).sqrt() + } + + /// Measure recovery at the original sample resolution. + pub fn recover_n( + n: usize, + peaks: &[(f64, f64, f64)], + a0: f64, + a1: f64, + noise: f64, + m: AutoPhaseMethod, + ) -> Result<(f64, f64)> { + let truth = clean(n, peaks); + let axis = ProcessedAxis::new( + AxisRole::Signal, + AxisDomain::Frequency, + Some(AxisUnit::Ppm), + n, + AxisCoordinates::Uniform { + start: 0.0, + step: 1.0, + }, + ComponentBasis::Cartesian, + )?; + let input = Dataset::from_processed(ProcessedDataset::from_complex_trace( + axis, + scramble(&truth, a0, a1, noise), + ProcessedProvenance::new(ProcessedOrigin::Unknown, vec![])?, + )?); + let mut context = ExecutionContext::default(); + let options = ProcessingOptions::new(); + let estimate = m + .prepare(&input, 0, options)? + .estimate_with_context(&mut context)?; + let p1 = -estimate.correction().p1_degrees() * (n - 1) as f64 / n as f64; + let output = estimate.apply_with_context(&input, options, &mut context)?; + let data = output + .as_dense_processed() + .ok_or("missing processed result")?; + let values = (0..n) + .map(|i| Ok(Complex64::new(data.get(&[i], &[0])?, data.get(&[i], &[1])?))) + .collect::>>()?; + Ok((residual(&values, &truth), p1)) + } + + pub fn one() -> Vec<(f64, f64, f64)> { + vec![(0.5, 1.0, 4.0)] + } + pub fn many() -> Vec<(f64, f64, f64)> { + vec![ + (0.15, 1.0, 4.0), + (0.4, 0.7, 4.0), + (0.62, 0.9, 4.0), + (0.86, 0.5, 4.0), + ] + } +} + +#[test] +fn retained_phase_quality_gates() { + use groundtruth::*; + let cases = [ + ( + "AbsorptivePeak-zero", + 1024, + many(), + 0.3, + 0.0, + 0.0, + AutoPhaseMethod::AbsorptivePeak, + 0.05, + ), + ( + "Entropy-first-order", + 1024, + many(), + 0.3, + 3.0, + 0.0, + AutoPhaseMethod::Entropy, + 0.15, + ), + ( + "Entropy-negative-ramp", + 1024, + many(), + -0.5, + -4.5, + 0.0, + AutoPhaseMethod::Entropy, + 0.2, + ), + ( + "Entropy-single", + 1024, + one(), + 0.9, + 0.0, + 0.0, + AutoPhaseMethod::Entropy, + 0.1, + ), + ( + "Entropy-large-narrow", + 32768, + vec![ + (0.15, 1.0, 2.0), + (0.4, 0.7, 2.0), + (0.62, 0.9, 2.0), + (0.86, 0.5, 2.0), + ], + 0.3, + 220f64.to_radians(), + 0.0, + AutoPhaseMethod::Entropy, + 0.2, + ), + ( + "Entropy-90deg", + 1024, + many(), + 2.0, + 90f64.to_radians(), + 0.01, + AutoPhaseMethod::Entropy, + 0.35, + ), + ( + "Entropy-270deg", + 1024, + many(), + 2.0, + 270f64.to_radians(), + 0.01, + AutoPhaseMethod::Entropy, + 0.35, + ), + ( + "Entropy-500deg", + 1024, + many(), + 2.0, + 500f64.to_radians(), + 0.01, + AutoPhaseMethod::Entropy, + 0.35, + ), + ]; + for (label, n, peaks, p0, p1, noise, method, limit) in cases { + let (residual, estimated_p1) = recover_n(n, &peaks, p0, p1, noise, method) + .unwrap_or_else(|error| panic!("{label}: {error}")); + assert!( + residual < limit, + "{label}: residual={residual}, required <{limit}; endpoint p1={estimated_p1}deg" + ); + } +} diff --git a/crates/processing/tests/nmr_prepare_control.rs b/crates/processing/tests/nmr_prepare_control.rs new file mode 100644 index 00000000..cfed9e34 --- /dev/null +++ b/crates/processing/tests/nmr_prepare_control.rs @@ -0,0 +1,107 @@ +//! Host cancellation must reach the production NUS preparation path. +use nmr::axis::{AxisCoordinates, AxisDomain, AxisUnit}; +use nmr::execution::ExecutionStage; +use nmr::raw::*; +use nmr::{CancellationToken, Complex64, ExecutionContext}; +use plotx_io::nmr_view::NmrSource; +use plotx_processing::nmr_bridge::{DelayPolicy, RecipeRange}; +use plotx_processing::nmr_execution::{NusRequest, execute_2d}; +use plotx_processing::{ + AxisPipeline, Layout2D, Params2D, ProcessingStep, StepId, StepKind, StepSource, +}; +use std::sync::Arc; + +fn input() -> NmrSource { + let axis = |kind, points| { + RawAxis::new( + kind, + AxisDomain::Time, + Some(AxisUnit::Second), + points, + AxisCoordinates::Uniform { + start: 0.0, + step: 0.001, + }, + ) + .unwrap() + }; + let axes = vec![ + axis( + RawAxisKind::Indirect(IndirectComponents::Cartesian( + ComponentEvidence::user_constructed(), + )), + 4, + ), + axis(RawAxisKind::Direct(DirectSamples::Complex), 3), + ]; + let coordinates: Vec<_> = [3, 0, 1] + .into_iter() + .map(|i| SamplingCoordinate::new(vec![i])) + .collect(); + let traces = coordinates + .iter() + .enumerate() + .map(|(ordinal, coordinate)| { + SparseTrace::new( + ObservationOrdinal::new(ordinal), + coordinate.clone(), + vec![Complex64::new(1.0, 0.0); 6], + ) + }) + .collect(); + let raw = RawDatasetBuilder::new(axes, RawMetadata::default()) + .unwrap() + .sparse(traces, SamplingSchedule::new(vec![4], coordinates).unwrap()) + .unwrap(); + NmrSource::new(Arc::new(raw.into())).unwrap() +} + +#[test] +fn production_nus_can_cancel_before_and_during_preparation() { + let input = input(); + let params = Params2D { + layout: Layout2D::Ft, + f2: AxisPipeline { + steps: vec![ProcessingStep::new( + StepId::new(1), + StepKind::Fft, + StepSource::User, + )], + }, + f1: AxisPipeline { steps: vec![] }, + }; + for pre_cancelled in [true, false] { + let token = CancellationToken::new(); + let cancel = token.clone(); + if pre_cancelled { + token.cancel(); + } + let mut saw_preparation = false; + let mut progress = |event: nmr::execution::ProgressEvent| { + if event.stage == ExecutionStage::Preflight && event.completed > 0 { + saw_preparation = true; + cancel.cancel(); + } + }; + let mut context = ExecutionContext::default() + .with_cancellation(token) + .with_progress(&mut progress); + let error = execute_2d( + &input, + ¶ms, + DelayPolicy::Disabled, + RecipeRange::Base, + Some(NusRequest { + max_iterations: 1000, + noise_standard_deviation: Some(0.0), + }), + &mut context, + ) + .unwrap_err(); + assert!(error.is_cancelled(), "{error}"); + assert_eq!(context.ledger().used(), 0); + if !pre_cancelled { + assert!(saw_preparation); + } + } +} diff --git a/crates/processing/tests/nmr_shared_complex.rs b/crates/processing/tests/nmr_shared_complex.rs new file mode 100644 index 00000000..06e7e731 --- /dev/null +++ b/crates/processing/tests/nmr_shared_complex.rs @@ -0,0 +1,322 @@ +use nmr::axis::{AxisCoordinates, AxisDomain, AxisUnit}; +use nmr::raw::{ + ComponentEvidence, DirectSamples, IndirectComponents, RawAxis, RawAxisKind, RawDatasetBuilder, + RawMetadata, +}; +use nmr::{Complex64, ExecutionContext}; +use plotx_io::{nmr_bridge::snapshot, nmr_view::NmrSource}; +use plotx_processing::nmr_bridge::{DelayPolicy, RecipeRange}; +use plotx_processing::{ + AutoPhaseMethod, AxisPipeline, Layout2D, Params2D, PhaseParams, Processed2D, ProcessingStep, + StepId, StepKind, StepSource, nmr_execution, +}; +use std::{f64::consts::TAU, sync::Arc}; + +fn shared_tone() -> NmrSource { + let axis = |kind, points| { + RawAxis::new( + kind, + AxisDomain::Time, + Some(AxisUnit::Second), + points, + AxisCoordinates::Uniform { + start: 0.0, + step: 1.0 / 32.0, + }, + ) + .unwrap() + }; + let input = RawDatasetBuilder::new( + vec![ + axis( + RawAxisKind::Indirect(IndirectComponents::SharedComplex { + conjugated: true, + evidence: ComponentEvidence::user_constructed(), + }), + 8, + ), + axis(RawAxisKind::Direct(DirectSamples::Complex), 16), + ], + RawMetadata::default(), + ) + .unwrap() + .dense( + (0..8) + .flat_map(|row| { + (0..16).map(move |col| { + Complex64::from_polar(1.0, TAU * (-row as f64 / 8.0 + 3.0 * col as f64 / 16.0)) + }) + }) + .collect::>(), + ) + .unwrap(); + NmrSource::new(Arc::new(input.into())).unwrap() +} + +#[test] +fn shared_auto_phase_selects_the_pair_when_the_imaginary_field_is_strongest() { + let input = nmr_execution::execute_2d( + &shared_tone(), + &Params2D { + layout: Layout2D::Ft, + f2: pipeline(0, 1.0), + f1: pipeline(2, 0.0), + }, + DelayPolicy::Disabled, + RecipeRange::All, + None, + &mut ExecutionContext::default(), + ) + .unwrap(); + for axis in 0..2 { + let pipe = AxisPipeline { + steps: vec![ProcessingStep::new( + StepId::new(9), + StepKind::Phase(PhaseParams { + auto: Some(AutoPhaseMethod::AbsorptivePeak), + ..PhaseParams::MANUAL_ZERO + }), + StepSource::User, + )], + }; + let output = plotx_processing::nmr_bridge::compile( + input.source.dataset().clone(), + &pipe, + axis, + DelayPolicy::Disabled, + RecipeRange::Frequency, + ) + .unwrap() + .execute_with_report(Default::default(), &mut ExecutionContext::default()) + .unwrap(); + let report = &output.phases[0]; + assert_eq!(report.representative.as_ref().unwrap().component, 0); + let processed = output.dataset.as_processed().unwrap(); + let re = processed.data().get(&[5, 11], &[0, 0]).unwrap(); + let im = processed.data().get(&[5, 11], &[0, 1]).unwrap(); + assert!((re - 128.0).abs() < 1e-10); + assert!(im.abs() < 1e-10); + let (phase0, phase1, pivot_frac) = report.recipe_parameters(); + let mut manual = pipe.clone(); + manual.steps[0].kind = StepKind::Phase(PhaseParams { + phase0, + phase1, + pivot_frac, + auto: None, + }); + let replayed = plotx_processing::nmr_bridge::compile( + input.source.dataset().clone(), + &manual, + axis, + DelayPolicy::Disabled, + RecipeRange::Frequency, + ) + .unwrap() + .execute(Default::default(), &mut ExecutionContext::default()) + .unwrap(); + for (actual, expected) in replayed + .as_processed() + .unwrap() + .data() + .samples() + .iter() + .zip(processed.data().samples()) + { + assert!((actual - expected).abs() < 1e-10); + } + } +} + +fn pipeline(start: u64, phase: f64) -> AxisPipeline { + AxisPipeline { + steps: vec![ + ProcessingStep::new(StepId::new(start), StepKind::Fft, StepSource::User), + ProcessingStep::new( + StepId::new(start + 1), + StepKind::Phase(PhaseParams { + phase0: phase, + ..PhaseParams::MANUAL_ZERO + }), + StepSource::User, + ), + ], + } +} + +#[test] +fn shared_pair_keeps_phase_capability_signed_peak_and_magnitude_through_snapshot() { + let input = shared_tone(); + assert!(input.has_imaginary(0)); + assert!(input.has_imaginary(1)); + assert!(!input.has_imaginary(2)); + let output = nmr_execution::execute_2d( + &input, + &Params2D { + layout: Layout2D::Ft, + f2: pipeline(0, 0.2), + f1: pipeline(2, 0.5), + }, + DelayPolicy::Disabled, + RecipeRange::All, + None, + &mut ExecutionContext::default(), + ) + .unwrap(); + assert!(output.source.has_imaginary(0)); + assert!(output.source.has_imaginary(1)); + assert_eq!( + output + .source + .dataset() + .as_processed() + .unwrap() + .descriptor() + .component_counts(), + [1, 2] + ); + let Processed2D::Ft(view) = &output.view else { + panic!("expected frequency plane"); + }; + assert_eq!(view.f1_ppm[5], 4.0); + assert_eq!(view.f2_ppm[11], 6.0); + // F1's imaginary orientation reverses its phase rotation on the stored pair. + let expected = Complex64::from_polar(128.0, 0.5 - 0.2); + assert!((view.data[5 * 16 + 11] - expected).norm() < 1e-10); + assert!(view.data[3 * 16 + 11].norm() < 1e-10); + let magnitude = view.magnitude_plane.as_ref().unwrap(); + assert!((magnitude[5 * 16 + 11] - 128.0).abs() < 1e-10); + assert!(magnitude[3 * 16 + 11] < 1e-10); + + let mut bytes = Vec::new(); + snapshot::write( + output.source.dataset(), + &mut bytes, + Default::default(), + &mut ExecutionContext::default(), + ) + .unwrap(); + let restored = NmrSource::new( + snapshot::read( + &mut bytes.as_slice(), + Default::default(), + &mut ExecutionContext::default(), + ) + .unwrap(), + ) + .unwrap(); + assert!(restored.has_imaginary(0)); + assert!(restored.has_imaginary(1)); + let Processed2D::Ft(restored_view) = + nmr_execution::view_2d(&restored, Layout2D::Ft, &mut ExecutionContext::default()).unwrap() + else { + panic!("expected restored frequency plane"); + }; + assert_eq!(restored_view.data, view.data); + assert_eq!(restored_view.magnitude_plane, view.magnitude_plane); + assert_eq!(restored_view.f1_ppm, view.f1_ppm); + assert_eq!(restored_view.f2_ppm, view.f2_ppm); +} + +#[test] +fn shared_reference_and_slices_retain_complex_values_and_axis_coordinates() { + use nmr::processing::{ + FrequencyFrame, PhaseMethod, ProcessingOperation as Op, ProcessingPlan, ReferenceSource, + }; + use plotx_processing::{ReferenceParams, SliceKind, slice::Reduction}; + + let output = nmr_execution::execute_2d( + &shared_tone(), + &Params2D { + layout: Layout2D::Ft, + f2: pipeline(0, 0.2), + f1: pipeline(2, 0.5), + }, + DelayPolicy::Disabled, + RecipeRange::All, + None, + &mut ExecutionContext::default(), + ) + .unwrap(); + let input = ProcessingPlan::new( + [100.0, 400.0] + .into_iter() + .enumerate() + .map(|(axis, mhz)| Op::ResolveFrequencyFrame { + axis, + frame: FrequencyFrame::Ppm(ReferenceSource::Explicit( + nmr::raw::ChemicalShiftReference::user_constructed(4.0, mhz).unwrap(), + )), + }) + .collect(), + ) + .unwrap() + .apply(output.source.dataset()) + .unwrap(); + let input = NmrSource::new(Arc::new(input)).unwrap(); + let original = input.dataset().canonical_digests(); + for (axis, kind, index) in [(0, SliceKind::Column, 11), (1, SliceKind::Row, 5)] { + let pipe = AxisPipeline { + steps: vec![ProcessingStep::new( + StepId::new(4), + StepKind::Reference(ReferenceParams { + at_ppm: 1.0, + target_ppm: 1.1, + }), + StepSource::User, + )], + }; + let shifted = plotx_processing::nmr_bridge::compile( + input.dataset().clone(), + &pipe, + axis, + DelayPolicy::Disabled, + RecipeRange::Frequency, + ) + .unwrap() + .execute(Default::default(), &mut ExecutionContext::default()) + .unwrap(); + let shifted = NmrSource::new(shifted).unwrap(); + assert_eq!( + shifted.dataset().as_processed().unwrap().data().samples(), + input.dataset().as_processed().unwrap().data().samples() + ); + for a in 0..2 { + let delta = if a == axis { 0.1 } else { 0.0 }; + for (before, after) in input.axes()[a] + .coordinate_values() + .unwrap() + .iter() + .zip(shifted.axes()[a].coordinate_values().unwrap()) + { + assert!((after - before - delta).abs() < 1e-12); + } + } + let (slice_source, slice) = + plotx_processing::slice::extract(&shifted, kind, Reduction::Slice(index)).unwrap(); + assert!(slice_source.has_imaginary(0)); + assert_eq!( + slice.coordinates, + shifted.axes()[axis].coordinate_values().unwrap() + ); + assert_eq!(slice.reference_freq_mhz, Some([100.0, 400.0][axis])); + let data = shifted.dataset().as_processed().unwrap().data(); + for (point, actual) in slice.values.iter().enumerate() { + let coordinates = if axis == 0 { + [point, index] + } else { + [index, point] + }; + let expected = Complex64::new( + data.get(&coordinates, &[0, 0]).unwrap(), + data.get(&coordinates, &[0, 1]).unwrap(), + ); + assert_eq!(*actual, if axis == 0 { expected.conj() } else { expected }); + } + PhaseMethod::AbsorptivePeak + .prepare(slice_source.dataset(), 0, Default::default()) + .unwrap() + .estimate() + .unwrap(); + assert_eq!(input.dataset().canonical_digests(), original); + } +} diff --git a/docs/src/content/docs/guides/importing-data.md b/docs/src/content/docs/guides/importing-data.md index e6881c0b..29b8e400 100644 --- a/docs/src/content/docs/guides/importing-data.md +++ b/docs/src/content/docs/guides/importing-data.md @@ -10,9 +10,9 @@ no conversion step is needed. | Format | Extension | Notes | | --- | --- | --- | -| JEOL Delta | `.jdf` | 1D, 2D, and pseudo-2D (DOSY / T1 / T2) | -| Bruker TopSpin | `fid` / `ser` directories | 1D and 2D | -| Varian/Agilent VnmrJ | `.fid` directory | Raw time-domain 1D and conventional 2D | +| JEOL Delta | `.jdf` | Raw and processed 1D/2D and parameter series; experimental support | +| Bruker TopSpin | `fid` / `ser` / `pdata` | Raw and processed 1D/2D; NUS support is experimental | +| Varian/Agilent VnmrJ | `.fid` directory | Raw 1D, 2D with `phase=[1,2]`, or a series varying one parameter | | 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 | @@ -46,6 +46,45 @@ CasaXPS `.txt` files are recognized from their structured header, not from the extension alone. Other `.txt` files continue through table import. See the [XPS workflow](/guides/xps/) for energy-axis and fitting details. +## Follow import progress + +Scientific data files and acquisition folders load one dataset at a time in the +background, so you can continue working. Completed datasets appear on the board +without changing your current page or selection. The status bar shows the current +file and success/failure counts; review failures in the diagnostic history. +Additional imports wait for the current batch. Opening, closing, or creating a +project cancels unfinished imports for the previous project. + +NMR import includes default processing. NUS reconstruction can take substantially +longer than reading the file. Project files, table previews, ZIP files, and imports +with a manually supplied sampling table use separate import workflows. + +## Supplying a missing NMR sampling table + +Use this option for supported 2D Bruker NUS or JEOL acquisitions that sampled only +part of the indirect grid. You need the original sampling table and acquisition +files with enough grid and calibration information to check it. + +1. Choose **File → Import NMR with Sampling Table…** (also available in the + command palette), then select the Bruker `ser` or JEOL `.jdf` file. +2. Enter the table's source or an explanation, and the full original indirect + grid size, including unsampled points. +3. Enter **Lanes per observation**: the number of component records acquired at + each listed indirect point. Use the acquisition's value, not the number of + points in the table. +4. Select **Zero-based** or **One-based** to match the original table. Enter one + indirect index per line, in acquisition order. Each line represents all lanes + for that observation; keep repeated observations. +5. Click **Validate and import**. If validation fails, check the reported mismatch + against the acquisition records. Conflicts with an existing sampling list, + incorrect observation or lane counts, and missing grid or calibration information + prevent import. + +Vendor files are not modified. Save the project to retain the table and its source; +reopening the project does not require the original files. Repeated coordinates +can be imported but currently prevent [NUS reconstruction](/guides/processing/#reconstruct-a-non-uniformly-sampled-spectrum). +For scripts, see the [CLI declaration format](/reference/cli/#sampling-declarations). + ## Varian/Agilent VnmrJ To import a raw 1D or conventional 2D acquisition, choose **Open Folder…** and @@ -53,9 +92,11 @@ select its `.fid` directory. You can instead choose **Open File…** and select the `fid` file inside. Keep the `fid` and `procpar` files together in the same directory. -Processed spectra, 3D or 4D experiments, imaging, pseudo-2D experiments, -non-uniform sampling, and other arrayed experiments are not supported. See -[File formats](/reference/file-formats/) for compatibility details. +You can also import a series varying one ungrouped parameter. Supported 2D data +requires `phase=[1,2]`. Grouped or multiple parameter arrays, other phase orders, +Varian NUS, processed spectra, and more than two dimensions are unsupported. +See [File formats](/reference/file-formats/#nmr-data-and-projects) for calibration +requirements and format limitations. ## mzML diff --git a/docs/src/content/docs/guides/processing.md b/docs/src/content/docs/guides/processing.md index f2f8d882..ef6d35a0 100644 --- a/docs/src/content/docs/guides/processing.md +++ b/docs/src/content/docs/guides/processing.md @@ -18,7 +18,7 @@ charge correction is shared by all regions at one measurement position. See ## A typical 1D spectrum -A newly imported time-domain 1D dataset already carries the standard pipeline — +A time-domain 1D dataset with known digital-filter delay carries the standard pipeline — apodization, zero filling, FFT, phase correction, and baseline correction, in that order — with automatic phasing enabled. In most cases the spectrum on screen is immediately usable, and a session touches at most three things: @@ -40,6 +40,72 @@ order they are processed. A dataset that arrives already transformed is marked **Imported spectrum** and has no time-domain steps and no FFT: PlotX does not invent an FID for data it never acquired. +**Reset to default** restores the import settings. Raw data with unknown +filter delay remains uncorrected; imported spectra have no FFT, and spectra +containing only real values have no phase-correction step. + +## Check calibration before analysis + +Check the axis units before choosing an analysis: FIDs use seconds, while spectra +use Hz or ppm. Missing calibration is not treated as zero. + +- **Reference** requires a ppm axis and shifts coordinates without changing intensities. +- **CRAFT** requires a complex FID with known spectral width, observe frequency, + chemical-shift reference, and digital-filter delay. +- **DOSY maps** require a frequency-domain series calibrated in ppm. +- **Multiplet analysis** requires a ppm spectrum and a known chemical-shift reference + frequency to report coupling constants in Hz. + +The observe frequency and chemical-shift reference frequency serve different +purposes; do not substitute one for the other when interpreting ppm-to-Hz +conversions. For imported Bruker processed spectra, `SF` provides the reference +for converting ppm intervals to Hz. It does not supply missing acquisition +frequency or carrier information, or confirm that digital-filter correction was applied. + +## Reconstruct a non-uniformly sampled spectrum + +Non-uniform sampling (NUS) records only selected points along the indirect time +axis. PlotX uses the sampling table to reconstruct the full grid before the F1 +FFT produces a 2D spectrum. The grid size need not be a power of two. + +1. Import a supported Bruker NUS or JEOL acquisition with its sampling table. + If the table is missing, restore the vendor companion files or use + [Import NMR with Sampling Table](/guides/importing-data/#supplying-a-missing-nmr-sampling-table). +2. Allow the default processing to finish. PlotX estimates noise automatically; + you do not need to select a noise region or enter a noise value. +3. Check the status bar for errors before interpreting the result. If reconstruction + fails during import, the original observations remain available, but their + display is **not a reconstructed spectrum**. If a later processing change + fails, the last successful display remains visible. + +### Noise and convergence settings + +The automatic noise estimate uses the spectrum after the current F2 processing +steps and updates when those steps change. It requires at least 48 observations +and 32 F2 frequency points, or 32 observations and 128 points for an estimate +checked against held-out observations. Meeting these sizes alone does not +ensure a reliable estimate; additional noise-quality checks must also pass. + +If you have an independently determined noise standard deviation, enable +**Override automatic noise estimate** under **Non-uniform sampling**. Enter it +in the amplitude units of the spectrum after F2 processing. A value of 0 means +noiseless input, not automatic estimation. Update the value if you change F2 +processing in a way that changes the noise scale. + +Set the maximum iteration count between 1 and 2048. Reaching this limit without +convergence reports an error. Keep the F2 and F1 FFT steps enabled to obtain +both frequency axes. Noise settings are saved in the project but are not +transferred to other acquisitions by reusable processing recipes. + +### Input limitations + +Reconstruction requires a sampling table without repeated coordinates and +enough real and imaginary signal information to reconstruct the indirect axis. +Not all acquisition arrangements are supported; an unsupported arrangement +reports an error. Repeated observations can +be imported and saved, but cannot currently be reconstructed; do not delete +repeats from the sampling table to bypass this restriction. + ## Where processing lives Processing opens as a card at the upper right of the canvas — from the @@ -122,6 +188,13 @@ Processing card's ⋮ menu, applied before the pipeline. It governs 1D and 2D data alike: switch it off on a 2D dataset and the direct dimension is left uncorrected too. +For Bruker data, a nonnegative `GRPDLY` supplies the delay, including zero. +If it is missing or -1, PlotX uses supported `DSPFVS`/`DECIM` settings to determine +the delay. Otherwise the delay remains unknown and raw data initially appears +as an FID. To view an uncorrected spectrum, turn off **Group-delay correction** +under **Advanced** and enable FFT. Review the result for filter-related distortion; +disabling correction does not establish the delay required by CRAFT. + ## Apodization Click the **Apodize** step to open its settings. All of them are shown at once, @@ -190,6 +263,15 @@ automatic method each row says which switch to flip first. Through [Automation](/guides/automation/) these values keep their own units: phase angles in radians, the pivot as a fraction. +For 2D data, the selected automatic method estimates a correction from the trace +containing the strongest real or imaginary signal and applies the same correction +across the series, preserving relative row signs. If it fails, review the error +and choose another method or adjust the phase manually. + +For JEOL COSY, both F2 and F1 support phase correction and Reference. An extracted +row follows F2; a column follows F1. Both retain real and imaginary values for +further phase correction. + ## Baseline correction Baseline correction is off by default. Enable the step when your spectrum diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index 978547fc..302816f0 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -18,9 +18,9 @@ use the in-app Automation window, which runs the same workflows. ## Inspect and process data ```sh -plotx-cli inspect [--json] +plotx-cli inspect [--json] [--sampling-declaration ] plotx-cli craft --output [--region ]... [--expected-ratio ]... -plotx-cli process --scheme --output [--format svg|pdf|png|tiff|jpeg] +plotx-cli process --scheme --output [--format svg|pdf|png|tiff|jpeg] [--sampling-declaration ] ``` `inspect` detects, loads, and describes one supported dataset; `--json` emits a @@ -30,6 +30,17 @@ protocol name. For XPS it reports measurement, region and point counts, the region names, and how many regions have a binding-energy axis or remain kinetic-only. +For NMR, `inspect` describes the source data without processing it. Bruker +experiment directories prefer raw data; select a processed file explicitly to +inspect that spectrum. If a directory is ambiguous, specify a file or processing +directory. For NUS data, the reported shape includes unsampled grid points; it +is not the number of acquired observations. A combination of time, frequency, +or parameter axes is reported as `domain: "mixed"`. + +Check reported warnings before processing. Missing calibration or digital-filter +delay remains unknown; see [NMR format limitations](/reference/file-formats/#nmr-data-and-projects). +Processing failures identify the failing step and return a nonzero exit code. + `process` is the convenience path for a single import, one [processing recipe](/guides/templates/), and one figure export. When `--format` is omitted, the format is inferred from the output file's @@ -45,6 +56,14 @@ report containing each input's fitted components, per-region coherent amplitudes an amplitude ratio when exactly two regions are supplied, diagnostics, and quality checks. Wide selections are still reported under the regions you gave. +CRAFT requires known spectral width, observe frequency, chemical-shift reference, +and digital-filter delay. Missing information produces a failed entry in the report. +The ppm reference frequency is retained separately from the observe frequency; +the report's `chemical_shift_reference.reference_frequency_mhz` defines Hz-to-ppm +conversion. FFT cross-check magnitudes depend on the modeling interval, +exponential window, and zero filling. Use them to assess the fit; use the reported +coherent amplitudes for region amplitude ratios. + When exactly two regions are supplied, repeat `--expected-ratio` once per input to compare the measured ratio with a reference value. The report records the relative error and passes the check when it is within 5%. `all_succeeded` tells @@ -54,6 +73,55 @@ selected region, or reaches a diagnostic limit. Treat a false quality result as an indication that the data needs scientific review, even when the command completed. +## Sampling declarations + +`inspect` and `process` accept `--sampling-declaration sampling.json` for a +supported 2D Bruker NUS or JEOL acquisition with only part of the indirect grid sampled. The JSON file is limited +to 8 MiB. Supply every field explicitly; for example: + +```json +{ + "assertion_id": "my-sampling-table-1", + "source": "user-provided sampling table from experiment notes", + "grid_shape": [4], + "coordinates": [[4], [2]], + "index_base": "one", + "component_counts": [2] +} +``` + +Set the fields from the original acquisition records: + +| Field | Value to supply | +| --- | --- | +| `assertion_id` | An identifier for this sampling declaration. | +| `source` | Where the table came from, such as an acquisition log. | +| `grid_shape` | The full indirect grid size, including unsampled points, as a one-element array. | +| `coordinates` | One indirect index per observation, each in its own array, in acquisition order. | +| `index_base` | `"zero"` for indices starting at 0, or `"one"` for indices starting at 1. | +| `component_counts` | The number of component records (lanes) per observation, as a one-element array. | + +The example describes a four-point grid with two lanes per observation, sampled +at one-based indices 4 then 2. Each coordinate row represents all lanes for that +observation. Preserve acquisition order and repeated observations. Repeats can +be imported, but currently prevent NUS reconstruction. + +PlotX checks the declaration against the acquisition. Missing grid or calibration +information, incorrect lane or observation counts, and conflicts with existing +sampling lists cause an error. This option does not support Varian data, +processed spectra, or acquisitions outside the supported 2D layouts. + +For example, save the declaration as `sampling.json`, then inspect the acquisition: + +```sh +plotx-cli inspect experiment/ser --sampling-declaration sampling.json --json +``` + +The workflow tool `data.import` accepts the same JSON object in its optional +`sampling_declaration` parameter. The declaration must be valid for every path +in that import node; use separate nodes for different tables. Saving the project +retains the declaration without changing the vendor files. + ## Run a workflow ```sh diff --git a/docs/src/content/docs/reference/file-formats.md b/docs/src/content/docs/reference/file-formats.md index 81142a20..1cfe88e8 100644 --- a/docs/src/content/docs/reference/file-formats.md +++ b/docs/src/content/docs/reference/file-formats.md @@ -59,19 +59,46 @@ A `.plotxproc` file stores one processing pipeline, without any data — save a recipe once and apply it to a whole series of similar experiments, on any machine. See [Recipes and templates](/guides/templates/). +## NMR data and projects + +PlotX supports 1D and 2D NMR, including series with a parameter axis. +Choose the input that matches the data you want to work with: + +- **Bruker:** select an experiment directory for raw data, or a spectrum inside + `pdata` for processed data. If a directory contains ambiguous candidates, + select a specific file or processing directory. States 2D (`FnMODE=4`) is supported. + Processed spectra retain the real and imaginary components present in the files. +- **JEOL:** select a `.jdf` file containing a supported raw or processed acquisition. +- **JCAMP-DX:** ordinary XYDATA spectra with one intensity per coordinate, + version 5.00 or 5.01, are supported with Hz or ppm axes. LINK, NTUPLES, and + peak tables are not supported. + +Missing nucleus, frequency-reference, or digital-filter-delay information remains +unknown. Hz spectra can be displayed directly, but some analyses need additional +calibration. Raw data with unknown filter delay initially appears as an FID; +see [Group-delay correction](/guides/processing/#group-delay-correction) to view +an uncorrected spectrum, and [calibration requirements](/guides/processing/#check-calibration-before-analysis) +before choosing an analysis. Unknown coordinates or unsupported data arrangements +produce an import error or diagnostic. + +JEOL and Bruker NUS support is experimental and does not cover every instrument +or acquisition setting. Check import diagnostics and compare results with a trusted +reference before relying on an unfamiliar acquisition type. + +Saving a `.plotx` project retains the imported NMR components, sampling order, +source information, warnings, and processing settings. Reopening recomputes spectra +from the saved settings without requiring the original vendor files. + ## Varian/Agilent VnmrJ raw NMR -PlotX imports raw time-domain 1D and conventional 2D acquisitions. Select the -`.fid` directory or the `fid` file inside it; the `fid` and `procpar` files must -both be present in that directory. A `.fid` directory name by itself is not -enough to identify a dataset. - -The importer accepts the common 16-bit integer, 32-bit integer, and 32-bit -floating-point sample formats, including conventional States 2D data. -Processed spectra, 3D or 4D experiments, imaging, pseudo-2D experiments, -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. +Select a `.fid` directory containing both `fid` and `procpar`, or the `fid` file +inside it. Supported acquisitions are raw 1D, a series varying one ungrouped +parameter, and 2D with `phase=[1,2]`. Samples may be big-endian 16-bit integers, +32-bit integers, or 32-bit floating-point values. + +Grouped or multiple parameter arrays, other phase orders, Varian NUS, more than +two dimensions, and processed spectra are unsupported. Import stops if the +recorded dimensions or component arrangement do not match the data. ## SCIEX legacy WIFF 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 2d73c24f..1e7d6860 100644 --- a/docs/src/content/docs/zh-cn/guides/importing-data.md +++ b/docs/src/content/docs/zh-cn/guides/importing-data.md @@ -9,9 +9,9 @@ PlotX 直接读取厂商 LC–MS、NMR、XPS、AFM 与电生理格式,无需 | 格式 | 扩展名 | 说明 | | --- | --- | --- | -| JEOL Delta | `.jdf` | 1D、2D 及伪 2D(DOSY / T1 / T2) | -| Bruker TopSpin | `fid` / `ser` 目录 | 1D 与 2D | -| Varian/Agilent VnmrJ | `.fid` 目录 | 原始时域 1D 与常规 2D | +| JEOL Delta | `.jdf` | 原始与已处理的 1D、2D 及参数系列;实验性支持 | +| Bruker TopSpin | `fid` / `ser` / `pdata` | 原始及已处理的 1D、2D;NUS 为实验性支持 | +| Varian/Agilent VnmrJ | `.fid` 目录 | 原始 1D、`phase=[1,2]` 的 2D,或单参数变化系列 | | Waters MassLynx RAW | `.raw` 目录 | 已验证的低分辨率数据,包括 SQD2 数据 | | SCIEX legacy WIFF | `.wiff` + `.wiff.scan` | 支持单样本与多样本 legacy 数据;两个文件必须放在一起 | | Rigaku 粉末 XRD | `.rasx`、FI `.raw`、RAS_RAW `.txt` | 衍射图样、采集元数据,以及文件提供的衰减系数 | @@ -40,14 +40,44 @@ PlotX 直接读取厂商 LC–MS、NMR、XPS、AFM 与电生理格式,无需 CasaXPS `.txt` 按结构头内容识别,而不是只看扩展名;其他 `.txt` 仍进入表格导入。 能量轴与拟合细节见 [XPS 工作流](/zh-cn/guides/xps/)。 +## 查看导入进度 + +科学数据文件与采集目录在后台逐个加载,期间可以继续操作。完成的数据集会出现在 +画板上,不会切换当前页面或选择。状态栏显示当前文件及成功、失败数量;错误可在 +诊断历史中查看。追加导入会排在当前批次之后。打开、关闭或新建项目会取消旧项目 +尚未完成的导入。 + +NMR 导入包含默认处理,NUS 重建可能比读取文件耗时长得多。项目文件、表格预览、 +ZIP 文件和手动补录采样表的导入使用各自的导入流程。 + +## 补录缺失的 NMR 采样表 + +此选项适用于受支持的二维 Bruker NUS 或只采集了部分间接网格点的 JEOL 数据。 +请准备原始采样表,以及含有足够网格与校准信息、可用于核对采样表的采集文件。 + +1. 选择 **File → Import NMR with Sampling Table…**(命令面板中也可搜索), + 再选择 Bruker `ser` 或 JEOL `.jdf` 文件。 +2. 填写采样表来源或说明,以及原始间接轴的完整网格点数(包括未采样点)。 +3. 填写 **Lanes per observation**:每个所列间接点采集的分量记录数。 + 请按采集设置填写,不是填写采样表的点数。 +4. 根据原表选择 **Zero-based**(从 0 开始)或 **One-based**(从 1 开始)。 + 按采集顺序每行填写一个间接索引;每行代表该次观测的全部分量,并保留重复观测。 +5. 点击 **Validate and import**。校验失败时,根据提示与采集记录核对。 + 与已有采样表冲突、观测或分量数量不符、缺少网格或校准信息,都会阻止导入。 + +导入不会修改厂商文件。保存项目后,采样表及其来源会一并保留,重开无需原文件。 +重复坐标可以导入,但目前不能进行 [NUS 重建](/zh-cn/guides/processing/#重建非均匀采样谱)。 +脚本用法见 [CLI 声明格式](/zh-cn/reference/cli/#采样声明)。 + ## Varian/Agilent VnmrJ 要导入原始 1D 或常规 2D 采集,请选择 **Open Folder…** 并选中 `.fid` 目录。也可以选择 **Open File…**,再选中目录内的 `fid` 文件。请将 `fid` 和 `procpar` 保持在同一目录中。 -暂不支持处理后的谱图、3D 或 4D 实验、成像、伪 2D 实验、非均匀采样及 -其他数组实验。兼容性详情见[文件格式](/zh-cn/reference/file-formats/)。 +也支持仅改变一个非分组参数的系列。二维数据要求 `phase=[1,2]`。分组或多个参数数组、 +其他 phase 顺序、Varian NUS、已处理谱和超过二维的数据不受支持。 +校准要求与格式限制见[文件格式](/zh-cn/reference/file-formats/#nmr-数据与项目)。 ## mzML diff --git a/docs/src/content/docs/zh-cn/guides/processing.md b/docs/src/content/docs/zh-cn/guides/processing.md index 111ed1b5..a8290ae9 100644 --- a/docs/src/content/docs/zh-cn/guides/processing.md +++ b/docs/src/content/docs/zh-cn/guides/processing.md @@ -14,7 +14,7 @@ XPS 为每个谱区使用独立的有序 recipe,而不是 NMR 管线。recipe ## 典型的 1D 谱 -新导入的时域 1D 数据集已带有标准管线——切趾、零填充、FFT、相位校正、基线 +数字滤波延迟已知的时域 1D 数据集带有标准管线——切趾、零填充、FFT、相位校正、基线 校正,按此顺序——并默认启用自动相位。多数情况下屏幕上的谱图立即可用, 一次会话最多只需调整三处: @@ -30,6 +30,53 @@ XPS 为每个谱区使用独立的有序 recipe,而不是 NMR 管线。recipe **Imported spectrum**,没有时域步骤,也没有 FFT——PlotX 不会为它没有采集过的 自由感应衰减凭空造一个 FID。 +**Reset to default** 恢复导入时的设置。滤波延迟未知的原始数据保持未校正; +已导入的频谱不添加 FFT,仅含实部的频谱不添加相位校正步骤。 + +## 分析前检查校准条件 + +选择分析功能前,先检查坐标单位:FID 使用秒,频谱使用 Hz 或 ppm。缺失的校准信息 +不会按零处理。 + +- **Reference** 需要 ppm 坐标,只平移坐标,不改变强度。 +- **CRAFT** 需要复数 FID,以及已知的谱宽、观测频率、化学位移参考和数字滤波延迟。 +- **DOSY 图**需要已校准为 ppm 的频域谱系列。 +- **多重峰分析**需要 ppm 谱和已知的化学位移参考频率,才能报告以 Hz 为单位的耦合常数。 + +观测频率与化学位移参考频率用途不同,解读 ppm 与 Hz 的换算时不能混用。 +对导入的 Bruker 已处理谱,`SF` 提供 ppm 间隔换算为 Hz 的参考频率,但不能补全 +缺失的采集频率或载频信息,也不能证明数据已完成数字滤波校正。 + +## 重建非均匀采样谱 + +非均匀采样(NUS)只采集间接时间轴上的部分点。PlotX 根据采样表重建完整网格, +再通过 F1 FFT 得到二维频谱。网格点数不必为 2 的幂。 + +1. 导入受支持的 Bruker NUS 或 JEOL 采集及其采样表。若缺少采样表,请补齐厂商配套 + 文件,或使用 [Import NMR with Sampling Table](/zh-cn/guides/importing-data/#补录缺失的-nmr-采样表)。 +2. 等待默认处理完成。PlotX 自动估计噪声,无需选择噪声区或输入噪声值。 +3. 解读结果前,检查状态栏是否报错。导入时若重建失败,原始观测仍可查看,但该视图 + **不是重建后的频谱**。后续修改处理设置若失败,画面会保留上一次成功的结果。 + +### 噪声与收敛设置 + +自动噪声估计基于当前 F2 处理后的频谱,修改 F2 步骤后会重新计算。 +至少需要 48 条观测和 32 个 F2 频率点;若用留出的观测检验估计结果,则至少需要 +32 条观测和 128 个频率点。满足数量要求不保证估计可靠,还须通过噪声质量检查。 + +如果已有独立测得的噪声标准差,可在 **Non-uniform sampling** 下启用 +**Override automatic noise estimate**。输入值的单位应与 F2 处理后频谱的幅度一致。 +0 表示无噪声输入,不表示自动估计。若修改 F2 处理改变了噪声幅度,须相应更新此值。 + +最大迭代次数可设为 1–2048;达到上限仍未收敛时会报错。要得到两个频率轴,请保持 +F2 和 F1 的 FFT 步骤启用。噪声设置随项目保存,但不会通过可复用处理配方应用到其他采集。 + +### 输入限制 + +重建要求采样表没有重复坐标,且具有重建间接轴所需的实部和虚部信号信息。 +并非所有采集排列都受支持;不支持的排列会报错。 +重复观测可以导入和保存,但目前不能重建;不要通过删除采样表中的重复项绕过此限制。 + ## 处理界面在哪里 处理以卡片形式出现在画布右上角,可从 Ribbon 的 **Process** 页签打开,或用 @@ -104,6 +151,11 @@ FFT 是一个普通的 *Time to Frequency* 类型步骤,而不是列表中固 **Group-delay correction** 开关,按数据集设置,在管线之前应用。它对 1D 与 2D 数据一视同仁:在 2D 数据集上关掉它,直接维同样保持 未校正。 +Bruker 数据优先使用非负的 `GRPDLY` 作为延迟值,包括零。缺少该参数或其值为 -1 时, +PlotX 根据受支持的 `DSPFVS`/`DECIM` 设置确定延迟;否则延迟保持未知,原始数据首先 +显示为 FID。若要查看未校正的频谱,请在 **Advanced** 中关闭 **Group-delay correction** +并启用 FFT。检查结果是否存在滤波引起的失真;关闭校正不能补全 CRAFT 所需的延迟信息。 + ## 切趾 点击 **Apodize** 步骤即可展开它的设置。所有控件都直接显示在 @@ -161,6 +213,13 @@ FFT 是一个普通的 *Time to Frequency* 类型步骤,而不是列表中固 通过[自动化](/zh-cn/guides/automation/)读写时,这些值保持各自的单位:相位角 为弧度,pivot 为分数。 +二维数据使用所选自动方法,从最强实部或虚部信号所在的谱线估计相位,再对整个系列 +施加相同的校正,保留各行的相对符号。若自动方法失败,请查看错误,选择其他方法或 +手动调整相位。 + +JEOL COSY 的 F2 和 F1 均支持相位校正与 Reference。提取行得到 F2 谱线,提取列得到 +F1 谱线;两者均保留实部和虚部,可继续进行相位校正。 + ## 基线校正 基线校正默认关闭。谱图需要时启用该步骤即可。 diff --git a/docs/src/content/docs/zh-cn/reference/cli.md b/docs/src/content/docs/zh-cn/reference/cli.md index c2553f90..df9f3ca5 100644 --- a/docs/src/content/docs/zh-cn/reference/cli.md +++ b/docs/src/content/docs/zh-cn/reference/cli.md @@ -16,9 +16,9 @@ description: 不打开应用即可运行导入、处理、导出和已保存的 ## 检查与处理数据 ```sh -plotx-cli inspect [--json] +plotx-cli inspect [--json] [--sampling-declaration ] plotx-cli craft --output [--region ]... [--expected-ratio ]... -plotx-cli process --scheme --output [--format svg|pdf|png|tiff|jpeg] +plotx-cli process --scheme --output [--format svg|pdf|png|tiff|jpeg] [--sampling-declaration ] ``` `inspect` 检测、加载并描述一个受支持的数据集;`--json` 输出稳定的机器 @@ -27,6 +27,15 @@ plotx-cli process --scheme --output [--format 对 XPS 还会报告测量位置数、谱区数、总点数、谱区名称,以及具有结合能轴或仅有 动能轴的谱区数量。 +对 NMR,`inspect` 描述源数据,不执行处理。Bruker 实验目录优先选择原始数据; +要检查已处理谱,请明确指定谱文件。目录有歧义时,请指定具体文件或处理目录。 +NUS 数据报告的形状包含未采样网格点,不等于实际观测数。时间、频率或参数轴混合时, +报告中的 `domain` 为 `"mixed"`。 + +处理前请检查报告中的警告。缺失的校准或数字滤波延迟保持未知,详见 +[NMR 格式限制](/zh-cn/reference/file-formats/#nmr-数据与项目)。处理失败会指出 +具体步骤,并返回非零退出码。 + `process` 是"一次导入、一个[处理配方](/zh-cn/guides/templates/)、一次 图形导出"的便捷路径。省略 `--format` 时按输出文件扩展名推断格式。 @@ -38,12 +47,63 @@ plotx-cli process --scheme --output [--format 各区域相干振幅、恰好两个区域时的振幅比、诊断和质量检查。即使选择范围较宽, 结果也仍按你给出的区域汇总。 +CRAFT 要求已知谱宽、观测频率、化学位移参考和数字滤波延迟。信息缺失时, +报告会为该输入记录失败。ppm 参考频率与观测频率分别保留;报告中的 +`chemical_shift_reference.reference_frequency_mhz` 决定 Hz 到 ppm 的换算。 +FFT 交叉检查幅度受建模区间、指数窗和零填充影响,用于评估拟合; +比较区域振幅比时,应使用报告中的相干振幅。 + 恰好指定两个区域时,可按输入顺序重复 `--expected-ratio`,将测得的振幅比与参考值 比较。报告会记录相对误差,误差不超过 5% 时通过检查。`all_succeeded` 表示所有 计算是否完成;`all_quality_checks_passed` 的要求更严格:输入或拟合出现警告、 选定区域没有分量,或达到诊断限制时都会为 `false`。即使命令完成,质量结果为 `false` 也表示数据需要进一步的科学复核。 +## 采样声明 + +`inspect` 和 `process` 可使用 `--sampling-declaration sampling.json`,为支持范围内的 +二维 Bruker NUS 或仅采集部分间接网格点的 JEOL 数据补录采样表。JSON 文件上限为 8 MiB, +每个字段都必须明确提供,例如: + +```json +{ + "assertion_id": "my-sampling-table-1", + "source": "user-provided sampling table from experiment notes", + "grid_shape": [4], + "coordinates": [[4], [2]], + "index_base": "one", + "component_counts": [2] +} +``` + +请根据原始采集记录填写各字段: + +| 字段 | 填写内容 | +| --- | --- | +| `assertion_id` | 此采样声明的标识。 | +| `source` | 采样表来源,例如采集日志。 | +| `grid_shape` | 间接轴完整网格点数(包括未采样点),写成单元素数组。 | +| `coordinates` | 按采集顺序填写每次观测的间接索引,每个索引各占一个数组。 | +| `index_base` | 索引从 0 开始填 `"zero"`,从 1 开始填 `"one"`。 | +| `component_counts` | 每次观测的分量记录数(lane 数),写成单元素数组。 | + +示例表示完整网格有 4 点,每次观测有 2 个分量,按从 1 开始的索引依次采集第 4、2 点。 +每行坐标代表该次观测的全部分量。必须保留采集顺序和重复观测;重复观测可以导入, +但目前不能进行 NUS 重建。 + +PlotX 会将声明与采集数据核对。缺少网格或校准信息、分量或观测数量不符、与已有采样表 +冲突,都会报错。此选项不适用于 Varian 数据、已处理谱或支持范围以外的二维采集形式。 + +例如,将声明保存为 `sampling.json`,再检查采集数据: + +```sh +plotx-cli inspect experiment/ser --sampling-declaration sampling.json --json +``` + +工作流工具 `data.import` 的可选参数 `sampling_declaration` 接受同一个 JSON 对象。 +声明必须适用于该导入节点中的每个路径;不同采样表应使用不同节点。保存项目会保留 +声明,不会修改厂商文件。 + ## 运行工作流 ```sh 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 0d6c39a5..83670ea0 100644 --- a/docs/src/content/docs/zh-cn/reference/file-formats.md +++ b/docs/src/content/docs/zh-cn/reference/file-formats.md @@ -48,16 +48,37 @@ TIFF Pages…** 导入 PlotX 能够读取的所有页面。导入后的各页可 任何机器上应用到一整个系列的同类实验。见 [配方与模板](/zh-cn/guides/templates/)。 +## NMR 数据与项目 + +PlotX 支持一维和二维 NMR,包括带参数轴的系列。请根据要使用的数据选择输入: + +- **Bruker:**选择实验目录可读取原始数据,选择 `pdata` 内的谱文件可读取已处理谱。 + 若目录中有多个无法区分的候选,请指定具体文件或处理目录。支持 States 二维数据 + (`FnMODE=4`)。已处理谱保留文件中提供的实部和虚部分量。 +- **JEOL:**选择含有受支持的原始或已处理采集数据的 `.jdf` 文件。 +- **JCAMP-DX:**支持 5.00 或 5.01 版普通 XYDATA 频谱,每个坐标对应一个强度值, + 坐标单位可为 Hz 或 ppm。不支持 LINK、NTUPLES 和峰表。 + +缺失的核种、频率参考或数字滤波延迟信息保持未知。Hz 谱可以直接显示,但部分分析 +需要额外校准。滤波延迟未知的原始数据首先显示为 FID;查看未校正频谱的方法见 +[群延迟校正](/zh-cn/guides/processing/#群延迟校正),选择分析功能前请核对 +[校准条件](/zh-cn/guides/processing/#分析前检查校准条件)。坐标未知或数据排列不受支持时, +导入会给出错误或诊断信息。 + +JEOL 与 Bruker NUS 为实验性支持,未覆盖所有仪器和采集设置。使用不熟悉的采集类型时, +请检查导入诊断,并与可信参考结果比较后再使用分析结果。 + +保存 `.plotx` 项目会保留导入的 NMR 分量、采样顺序、来源信息、警告和处理设置。 +重开项目时,PlotX 根据保存的设置重新计算频谱,无需原始厂商文件。 + ## Varian/Agilent VnmrJ 原始 NMR -PlotX 可导入原始时域 1D 和常规 2D 采集。请选择 `.fid` 目录或其中的 -`fid` 文件;该目录中必须同时存在 `fid` 和 `procpar`。仅有 `.fid` -目录名不足以识别数据。 +选择同时含有 `fid` 与 `procpar` 的 `.fid` 目录,或其中的 `fid` 文件。支持原始一维 +数据、仅改变一个非分组参数的系列,以及 `phase=[1,2]` 的二维数据。样本格式可为 +大端字节序的 16 位整数、32 位整数或 32 位浮点数。 -导入器支持常见的 16 位整数、32 位整数和 32 位浮点样本格式,包括常规 -States 2D 数据。暂不支持处理后的谱图、3D 或 4D 实验、成像、伪 2D 实验、 -非均匀采样,以及除 phase 以外的参数数组。如果文件记录的维度与数据不一致, -导入也会停止。 +不支持分组或多个参数数组、其他 phase 顺序、Varian NUS、超过二维的数据和已处理谱。 +记录的维度或分量排列与实际数据不符时,导入会停止。 ## SCIEX legacy WIFF