From 428d99f1e5bc50cd52d9c3d37fffbccc538c8a90 Mon Sep 17 00:00:00 2001 From: Dongcheng Lin Date: Tue, 4 Aug 2026 14:16:40 +0800 Subject: [PATCH] feat(xrd): add import and processing --- README.md | 6 +- crates/app/src/ui/file_dialogs.rs | 3 +- crates/app/src/ui/file_dialogs/discovery.rs | 26 +- crates/app/src/ui/file_dialogs/origin.rs | 2 +- crates/app/src/ui/file_dialogs/recent.rs | 2 + crates/app/src/ui/tools/curve_fit.rs | 106 ++-- crates/app/src/ui/tools/mod.rs | 7 +- crates/app/src/ui/tools/processing/mod.rs | 8 +- crates/app/src/ui/tools/processing/surface.rs | 334 ++++++++--- crates/app/src/ui/tools/region_analysis.rs | 138 +++-- crates/app/src/ui/tools/statistics.rs | 100 ++-- crates/app/src/ui/tools/task_card.rs | 199 ++++++- crates/cli/src/main.rs | 16 + crates/core/src/actions/app_impl/apply.rs | 25 +- crates/core/src/actions/app_impl/mod.rs | 19 +- .../core/src/actions/app_impl/processing.rs | 36 +- crates/core/src/actions/app_impl/revert.rs | 6 +- crates/core/src/actions/app_impl/validate.rs | 11 +- crates/core/src/actions/mod.rs | 2 + crates/core/src/actions/processing_state.rs | 21 +- crates/core/src/actions/tests/mod.rs | 2 +- crates/core/src/actions/tests/xrd.rs | 186 ++++++ crates/core/src/automation/resources.rs | 4 + crates/core/src/automation/resources/xrd.rs | 29 + crates/core/src/data_export.rs | 15 +- crates/core/src/data_export/write.rs | 19 + crates/core/src/project/codec.rs | 1 + crates/core/src/project/convert.rs | 83 +++ crates/core/src/project/convert_views.rs | 1 + crates/core/src/project/mod.rs | 4 + crates/core/src/project/scheme.rs | 7 + crates/core/src/project/xrd_convert.rs | 220 ++++++++ crates/core/src/project/xrd_convert_tests.rs | 91 +++ crates/core/src/project/xrd_tests.rs | 51 ++ crates/core/src/properties/group_delay.rs | 3 +- .../core/src/properties/processing_common.rs | 3 +- crates/core/src/properties/service.rs | 6 +- crates/core/src/properties/step_enabled.rs | 3 +- .../src/state/app_impl_analysis_tables.rs | 6 +- crates/core/src/state/app_impl_interaction.rs | 6 +- crates/core/src/state/charts.rs | 17 + crates/core/src/state/dataset_identity.rs | 2 + crates/core/src/state/dataset_trace.rs | 7 + crates/core/src/state/datasets.rs | 1 + crates/core/src/state/datasets_dispatch.rs | 38 +- crates/core/src/state/field.rs | 11 +- crates/core/src/state/field_payload.rs | 23 + crates/core/src/state/lineage.rs | 2 + crates/core/src/state/mod.rs | 2 + crates/core/src/state/nmr_integrals_2d.rs | 2 +- crates/core/src/state/xrd.rs | 109 ++++ crates/core/src/workflow.rs | 57 ++ crates/io/src/bruker.rs | 4 +- crates/io/src/jcamp_dx/tests.rs | 2 +- crates/io/src/jeol/tests.rs | 8 +- crates/io/src/lib.rs | 93 ++- crates/io/src/xrd.rs | 533 ++++++++++++++++++ crates/io/tests/bruker_processed.rs | 4 +- crates/processing/src/lib.rs | 1 + crates/processing/src/xrd.rs | 316 +++++++++++ .../src/content/docs/guides/importing-data.md | 18 + .../docs/zh-cn/guides/importing-data.md | 14 + 62 files changed, 2745 insertions(+), 326 deletions(-) create mode 100644 crates/core/src/actions/tests/xrd.rs create mode 100644 crates/core/src/automation/resources/xrd.rs create mode 100644 crates/core/src/project/xrd_convert.rs create mode 100644 crates/core/src/project/xrd_convert_tests.rs create mode 100644 crates/core/src/project/xrd_tests.rs create mode 100644 crates/core/src/state/xrd.rs create mode 100644 crates/io/src/xrd.rs create mode 100644 crates/processing/src/xrd.rs diff --git a/README.md b/README.md index 74d8bd5..2d86467 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,9 @@ preparation. ## Highlights - **Bring scientific data together.** Current import support includes Axon - ABF2 patch-clamp recordings, mzML and Waters MassLynx LC–MS runs, JEOL Delta - and Bruker TopSpin experiments, JCAMP-DX spectra, archives, and delimited - tables. + ABF2 patch-clamp recordings, Rigaku powder XRD patterns, mzML and Waters + MassLynx LC–MS runs, JEOL Delta and Bruker TopSpin experiments, JCAMP-DX + spectra, archives, and delimited tables. - **Process and analyze interactively.** Build ordered processing pipelines, then pick peaks, integrate regions, and fit data. NMR workflows also include DOSY and relaxation analysis, plus sweep statistics and IV analysis for diff --git a/crates/app/src/ui/file_dialogs.rs b/crates/app/src/ui/file_dialogs.rs index 7a95190..0581dc1 100644 --- a/crates/app/src/ui/file_dialogs.rs +++ b/crates/app/src/ui/file_dialogs.rs @@ -344,9 +344,10 @@ pub(crate) fn load_and_note(app: &mut PlotxApp, path: &std::path::Path) { pub(crate) fn open_file(app: &mut PlotxApp) { if let Some(paths) = rfd::FileDialog::new() .add_filter( - "All supported data (*.mzML, *.spm, *.pfc, *.abf, *.jdf, fid, ser, *.zip, *.opj)", + "All supported data (*.mzML, *.rasx, *.raw, *.spm, *.pfc, *.abf, *.jdf, fid, ser, *.zip, *.opj)", origin::OPEN_FILE_FILTER_EXTENSIONS, ) + .add_filter("Rigaku XRD (*.rasx, *.raw, *.txt)", &["rasx", "raw", "txt"]) .add_filter( origin::ORIGIN_PROJECT_FILTER_LABEL, origin::ORIGIN_PROJECT_FILTER_EXTENSIONS, diff --git a/crates/app/src/ui/file_dialogs/discovery.rs b/crates/app/src/ui/file_dialogs/discovery.rs index c0a0594..f90f5bf 100644 --- a/crates/app/src/ui/file_dialogs/discovery.rs +++ b/crates/app/src/ui/file_dialogs/discovery.rs @@ -22,10 +22,12 @@ pub(super) fn collect_data_files(folder: &Path, output: &mut Vec) { .extension() .and_then(|value| value.to_str()) .unwrap_or(""); - if ["abf", "spm", "pfc"] + let supported_extension = ["abf", "spm", "pfc", "rasx"] .iter() - .any(|supported| extension.eq_ignore_ascii_case(supported)) - { + .any(|supported| extension.eq_ignore_ascii_case(supported)); + let recognized_raw = + extension.eq_ignore_ascii_case("raw") && plotx_io::xrd::is_rigaku_raw(&path); + if supported_extension || recognized_raw { output.push(path); } } @@ -50,4 +52,22 @@ mod tests { assert_eq!(found.as_slice(), std::slice::from_ref(&root)); std::fs::remove_dir_all(root).unwrap(); } + + #[test] + fn folder_scan_keeps_only_recognized_raw_files() { + let root = + std::env::temp_dir().join(format!("plotx-xrd-discovery-{}", uuid::Uuid::new_v4())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir(&root).unwrap(); + let xrd = root.join("pattern.raw"); + let unrelated = root.join("unrelated.raw"); + std::fs::write(&xrd, b"FI\0\0").unwrap(); + std::fs::write(&unrelated, b"not an XRD file").unwrap(); + + let mut found = Vec::new(); + collect_data_files(&root, &mut found); + + assert_eq!(found, vec![xrd]); + std::fs::remove_dir_all(root).unwrap(); + } } diff --git a/crates/app/src/ui/file_dialogs/origin.rs b/crates/app/src/ui/file_dialogs/origin.rs index 2db4a0b..0f936c0 100644 --- a/crates/app/src/ui/file_dialogs/origin.rs +++ b/crates/app/src/ui/file_dialogs/origin.rs @@ -21,7 +21,7 @@ pub(super) const ORIGIN_PROJECT_FILTER_LABEL: &str = "Origin projects (experimental: OPJ import; OPJU recognition only)"; pub(super) const ORIGIN_PROJECT_FILTER_EXTENSIONS: &[&str] = &["opj", "opju"]; pub(super) const OPEN_FILE_FILTER_EXTENSIONS: &[&str] = &[ - "mzML", "spm", "pfc", "abf", "jdf", "fid", "ser", "zip", "opj", + "mzML", "rasx", "raw", "spm", "pfc", "abf", "jdf", "fid", "ser", "zip", "opj", ]; const ORIGIN_MEDIA_TYPE: &str = "application/x-origin-project"; diff --git a/crates/app/src/ui/file_dialogs/recent.rs b/crates/app/src/ui/file_dialogs/recent.rs index 12e834e..e15182f 100644 --- a/crates/app/src/ui/file_dialogs/recent.rs +++ b/crates/app/src/ui/file_dialogs/recent.rs @@ -260,6 +260,8 @@ fn extension_open_kind(path: &Path) -> RecentOpenKind { }; if has_extension("plotx") { RecentOpenKind::Project + } else if has_extension("txt") && plotx_io::xrd::is_rigaku_profile(path) { + RecentOpenKind::DataFile } else if has_extension("csv") || has_extension("tsv") || has_extension("txt") { RecentOpenKind::DelimitedTable } else if has_extension("xlsx") { diff --git a/crates/app/src/ui/tools/curve_fit.rs b/crates/app/src/ui/tools/curve_fit.rs index 0b546fc..b173fcb 100644 --- a/crates/app/src/ui/tools/curve_fit.rs +++ b/crates/app/src/ui/tools/curve_fit.rs @@ -1,4 +1,4 @@ -use egui::{Area, Button, Order, Ui}; +use egui::{Button, Ui}; use egui_phosphor::regular as icon; use plotx_core::state::{Dataset, PlotxApp, TableDataset, TaskDockTab}; @@ -60,65 +60,63 @@ pub(crate) fn render_task(app: &mut PlotxApp, host: &mut Ui) { let mut close = false; let mut toggle_collapse = false; - Area::new(egui::Id::new("curve_fit_task_card")) - .order(Order::Foreground) - .fixed_pos(pos) - .show(host.ctx(), |ui| { - ui.set_width(width); - crate::ui::card_frame(dark, egui::Margin::ZERO).show(ui, |ui| { - if task_card::tab_bar(app, TaskDockTab::CurveFit, ui) { - ui.separator(); - } - let table = app.doc.datasets[di].as_table().unwrap(); - let curves = table.series_bindings.len(); - let points = table.typed_state.envelope.revision.snapshot.row_count; - ui.horizontal(|ui| { - crate::typography::headline_label(ui, "Curve Fit"); - let curve_count = if curves == 1 { - "1 curve".to_owned() + let area_id = egui::Id::new("curve_fit_task_card"); + task_card::area(host, area_id, pos).show(host.ctx(), |ui| { + ui.set_width(width); + crate::ui::card_frame(dark, egui::Margin::ZERO).show(ui, |ui| { + if task_card::tab_bar(app, TaskDockTab::CurveFit, ui) { + ui.separator(); + } + let table = app.doc.datasets[di].as_table().unwrap(); + let curves = table.series_bindings.len(); + let points = table.typed_state.envelope.revision.snapshot.row_count; + task_card::header(ui, area_id, |ui| { + crate::typography::headline_label(ui, "Curve Fit"); + let curve_count = if curves == 1 { + "1 curve".to_owned() + } else { + format!("{curves} curves") + }; + ui.weak(format!("{curve_count} · {points} points each")); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .small_button(icon::X) + .on_hover_text("Close Curve Fit") + .clicked() + { + close = true; + } + let glyph = if collapsed { + icon::CARET_DOWN } else { - format!("{curves} curves") + icon::CARET_UP }; - ui.weak(format!("{curve_count} · {points} points each")); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui - .small_button(icon::X) - .on_hover_text("Close Curve Fit") - .clicked() - { - close = true; - } - let glyph = if collapsed { - icon::CARET_DOWN + if ui + .small_button(glyph) + .on_hover_text(if collapsed { + "Expand Curve Fit" } else { - icon::CARET_UP - }; - if ui - .small_button(glyph) - .on_hover_text(if collapsed { - "Expand Curve Fit" - } else { - "Collapse Curve Fit" - }) - .clicked() - { - toggle_collapse = true; - } - }); + "Collapse Curve Fit" + }) + .clicked() + { + toggle_collapse = true; + } }); - if !collapsed { - ui.separator(); - egui::Resize::default() - .id_salt("curve_fit_task_body_resize") - .default_size([ui.available_width(), default_body_height]) - .min_size([ui.available_width(), min_body_height]) - .max_size([ui.available_width(), max_body_height]) - .resizable([false, true]) - .with_stroke(false) - .show(ui, |ui| curve_fit_task_body(app, di, ui)); - } }); + if !collapsed { + ui.separator(); + task_card::resizable_body( + ui, + "curve_fit_task_body_resize", + default_body_height, + min_body_height, + max_body_height, + |ui| curve_fit_task_body(app, di, ui), + ); + } }); + }); if toggle_collapse { app.session.ui.curve_fit_task_collapsed = !collapsed; diff --git a/crates/app/src/ui/tools/mod.rs b/crates/app/src/ui/tools/mod.rs index 09af9bf..e15e7ef 100644 --- a/crates/app/src/ui/tools/mod.rs +++ b/crates/app/src/ui/tools/mod.rs @@ -87,8 +87,11 @@ pub(crate) fn expand_processing_surface(app: &mut PlotxApp) { else { return; }; - if !matches!(dataset, Dataset::Nmr(_) | Dataset::Nmr2D(_)) { - app.session.status = "Select an NMR dataset before opening Processing.".to_owned(); + if !matches!( + dataset, + Dataset::Nmr(_) | Dataset::Nmr2D(_) | Dataset::Xrd(_) + ) { + app.session.status = "Select a processable dataset before opening Processing.".to_owned(); return; } app.session.ui.processing_task_dataset = Some(dataset.resource_id()); diff --git a/crates/app/src/ui/tools/processing/mod.rs b/crates/app/src/ui/tools/processing/mod.rs index d7e5ac2..e585588 100644 --- a/crates/app/src/ui/tools/processing/mod.rs +++ b/crates/app/src/ui/tools/processing/mod.rs @@ -8,6 +8,7 @@ use egui_phosphor::regular as icon; use plotx_core::actions::DatasetProcessingState; use plotx_core::automation::{ResourceRef, TargetRef}; use plotx_core::state::{Dataset, DatasetId, PhaseAxis, PlotxApp}; +use plotx_processing::xrd::XrdProcessing; use plotx_processing::{ Apodization, AxisPipeline, BaselineMethod, BinParams, NormalizeMethod, PhaseParams, ProcessingStep, ReferenceParams, SmoothMethod, StepId, StepKind, StepSource, ZeroFill, @@ -176,7 +177,8 @@ fn add_step_menu(app: &mut PlotxApp, di: usize, axis: PhaseAxis, ui: &mut Ui) { Dataset::Table(_) | Dataset::Electrophysiology(_) | Dataset::Afm(_) - | Dataset::MassSpec(_) => return, + | Dataset::MassSpec(_) + | Dataset::Xrd(_) => return, }; let Some(pipeline) = dataset.axis_pipeline(axis) else { return; @@ -325,7 +327,8 @@ fn apply_row_op(app: &mut PlotxApp, di: usize, axis: PhaseAxis, id: StepId, op: Dataset::Table(_) | Dataset::Electrophysiology(_) | Dataset::Afm(_) - | Dataset::MassSpec(_) => return, + | Dataset::MassSpec(_) + | Dataset::Xrd(_) => return, }; let before = DatasetProcessingState::from_dataset(dataset); let mut after = before.clone(); @@ -472,6 +475,7 @@ fn is_default_processing(dataset: &Dataset) -> bool { .. }, ) => ga == gb && a.layout == b.layout && pipe_eq(&a.f2, &b.f2) && pipe_eq(&a.f1, &b.f1), + (DatasetProcessingState::Xrd(a), DatasetProcessingState::Xrd(b)) => a == b, _ => false, } } diff --git a/crates/app/src/ui/tools/processing/surface.rs b/crates/app/src/ui/tools/processing/surface.rs index 5f3c9c8..de00cf4 100644 --- a/crates/app/src/ui/tools/processing/surface.rs +++ b/crates/app/src/ui/tools/processing/surface.rs @@ -5,9 +5,13 @@ use super::*; use crate::ui::tools::task_card::{self, TaskCardGeometry}; -use egui::{Area, Key, Order, RichText}; +use egui::{Key, RichText}; use plotx_core::state::TaskDockTab; use plotx_io::Domain; +use plotx_processing::xrd::{ + MAX_SAVGOL_ORDER, MAX_SAVGOL_WINDOW, MAX_SNIP_ITERATIONS, SavitzkyGolay, SnipBackground, + XrdNormalization, +}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum SourceShape { @@ -57,7 +61,8 @@ fn surface_shape(dataset: &Dataset) -> Option { Dataset::Table(_) | Dataset::Electrophysiology(_) | Dataset::Afm(_) - | Dataset::MassSpec(_) => return None, + | Dataset::MassSpec(_) + | Dataset::Xrd(_) => return None, }; let axes = dataset .phase_axes() @@ -90,6 +95,10 @@ pub(super) fn render(app: &mut PlotxApp, host: &mut Ui) { if app.active_dataset() != Some(di) { return; } + if app.doc.datasets[di].as_xrd().is_some() { + render_xrd(app, host, di, owner); + return; + } let Some(shape) = surface_shape(&app.doc.datasets[di]) else { app.session.ui.close_task_tab(TaskDockTab::Processing); return; @@ -105,88 +114,259 @@ pub(super) fn render(app: &mut PlotxApp, host: &mut Ui) { let mut close = false; let mut toggle = false; - Area::new(egui::Id::new("processing_task_card")) - .order(Order::Foreground) - .fixed_pos(pos) - .show(host.ctx(), |ui| { - ui.set_width(width); - crate::ui::card_frame(dark, egui::Margin::ZERO).show(ui, |ui| { - if task_card::tab_bar(app, TaskDockTab::Processing, ui) { - ui.separator(); + let area_id = egui::Id::new("processing_task_card"); + task_card::area(host, area_id, pos).show(host.ctx(), |ui| { + ui.set_width(width); + crate::ui::card_frame(dark, egui::Margin::ZERO).show(ui, |ui| { + if task_card::tab_bar(app, TaskDockTab::Processing, ui) { + ui.separator(); + } + let name = app.doc.datasets[di].display_name(); + let output = shape + .axes + .iter() + .map(|axis| { + axis.pipeline + .output_domain(shape.input_domain) + .unwrap_or(shape.input_domain) + }) + .collect::>(); + task_card::header(ui, area_id, |ui| { + ui.label(crate::typography::headline("Processing")); + ui.weak(if output.iter().all(|d| *d == Domain::Time) { + "Time-domain output" + } else { + "Frequency-domain output" + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .small_button(icon::X) + .on_hover_text("Close Processing") + .clicked() + { + close = true; + } + let glyph = if collapsed { + icon::CARET_DOWN + } else { + icon::CARET_UP + }; + if ui.small_button(glyph).clicked() { + toggle = true; + } + ui.menu_button(icon::DOTS_THREE_VERTICAL, |ui| panel_menu(app, di, ui)); + }); + }); + ui.add(egui::Label::new(RichText::new(name).small()).truncate()); + ui.small(format!( + "{} · {}", + shape.source.label(), + if is_default_processing(&app.doc.datasets[di]) { + "default recipe" + } else { + "modified recipe" } - let name = app.doc.datasets[di].display_name(); - let output = shape - .axes - .iter() - .map(|axis| { - axis.pipeline - .output_domain(shape.input_domain) - .unwrap_or(shape.input_domain) - }) - .collect::>(); - ui.horizontal(|ui| { - ui.label(crate::typography::headline("Processing")); - ui.weak(if output.iter().all(|d| *d == Domain::Time) { - "Time-domain output" + )); + if !collapsed { + ui.separator(); + task_card::resizable_body( + ui, + "processing_task_body_resize", + 430.0, + min_body_height, + max_body_height, + |ui| { + egui::ScrollArea::vertical() + .id_salt(("processing_task", owner)) + .auto_shrink([false, true]) + .show(ui, |ui| { + if shape.axes.len() == 2 { + ui.small("Axes are processed F2 direct, then F1 indirect."); + } + for axis in &shape.axes { + render_axis(app, di, owner, shape.input_domain, axis, ui); + } + action_bar(app, ui); + analysis_card(app, di, ui); + }); + }, + ); + } + }); + }); + if toggle { + app.session.ui.processing_task_collapsed = !collapsed; + } + if close { + app.session.ui.close_task_tab(TaskDockTab::Processing); + } +} + +fn render_xrd(app: &mut PlotxApp, host: &mut Ui, di: usize, owner: DatasetId) { + let Some(dataset) = app.doc.datasets[di].as_xrd() else { + return; + }; + let before = DatasetProcessingState::from_dataset(&app.doc.datasets[di]); + let mut params = dataset.params; + let name = app.doc.datasets[di].display_name(); + let TaskCardGeometry { + pos, + width, + min_body_height, + max_body_height, + } = task_card::geometry(host, 300.0); + let collapsed = app.session.ui.processing_task_collapsed; + let dark = host.visuals().dark_mode; + let mut close = false; + let mut toggle = false; + let mut changed = false; + let mut reset = false; + + let area_id = egui::Id::new("processing_task_card"); + task_card::area(host, area_id, pos).show(host.ctx(), |ui| { + ui.set_width(width); + crate::ui::card_frame(dark, egui::Margin::ZERO).show(ui, |ui| { + if task_card::tab_bar(app, TaskDockTab::Processing, ui) { + ui.separator(); + } + task_card::header(ui, area_id, |ui| { + ui.label(crate::typography::headline("XRD Processing")); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .small_button(icon::X) + .on_hover_text("Close Processing") + .clicked() + { + close = true; + } + let glyph = if collapsed { + icon::CARET_DOWN } else { - "Frequency-domain output" - }); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + icon::CARET_UP + }; + if ui.small_button(glyph).clicked() { + toggle = true; + } + ui.menu_button(icon::DOTS_THREE_VERTICAL, |ui| panel_menu(app, di, ui)); + if ui + .small_button(icon::ARROW_ARC_LEFT) + .on_hover_text("Reset processing") + .clicked() + { + reset = true; + } + }); + }); + ui.add(egui::Label::new(RichText::new(name).small()).truncate()); + if !collapsed { + ui.separator(); + task_card::resizable_body( + ui, + ("xrd_processing_task_body_resize", owner), + 250.0, + min_body_height, + max_body_height, + |ui| { + let mut background = params.background.is_some(); if ui - .small_button(icon::X) - .on_hover_text("Close Processing") - .clicked() + .checkbox(&mut background, "SNIP background subtraction") + .changed() { - close = true; + params.background = + background.then_some(SnipBackground { iterations: 40 }); + changed = true; } - let glyph = if collapsed { - icon::CARET_DOWN - } else { - icon::CARET_UP - }; - if ui.small_button(glyph).clicked() { - toggle = true; + if let Some(settings) = &mut params.background { + ui.horizontal(|ui| { + ui.label("Iterations"); + changed |= ui + .add( + egui::DragValue::new(&mut settings.iterations) + .range(1..=MAX_SNIP_ITERATIONS), + ) + .changed(); + }); } - ui.menu_button(icon::DOTS_THREE_VERTICAL, |ui| panel_menu(app, di, ui)); - }); - }); - ui.add(egui::Label::new(RichText::new(name).small()).truncate()); - ui.small(format!( - "{} · {}", - shape.source.label(), - if is_default_processing(&app.doc.datasets[di]) { - "default recipe" - } else { - "modified recipe" - } - )); - if !collapsed { - ui.separator(); - egui::Resize::default() - .id_salt("processing_task_body_resize") - .default_size([ui.available_width(), 430.0]) - .min_size([ui.available_width(), min_body_height]) - .max_size([ui.available_width(), max_body_height]) - .resizable([false, true]) - .with_stroke(false) - .show(ui, |ui| { - egui::ScrollArea::vertical() - .id_salt(("processing_task", owner)) - .auto_shrink([false, true]) - .show(ui, |ui| { - if shape.axes.len() == 2 { - ui.small("Axes are processed F2 direct, then F1 indirect."); - } - for axis in &shape.axes { - render_axis(app, di, owner, shape.input_domain, axis, ui); - } - action_bar(app, ui); - analysis_card(app, di, ui); - }); - }); - } - }); + let mut smoothing = params.smoothing.is_some(); + if ui + .checkbox(&mut smoothing, "Savitzky-Golay smoothing") + .changed() + { + params.smoothing = smoothing.then_some(SavitzkyGolay { + window: 11, + polynomial_order: 3, + }); + changed = true; + } + if let Some(settings) = &mut params.smoothing { + ui.horizontal(|ui| { + ui.label("Window"); + let mut window = settings.window; + if ui + .add( + egui::DragValue::new(&mut window) + .range(3..=MAX_SAVGOL_WINDOW) + .speed(2), + ) + .changed() + { + settings.window = window | 1; + changed = true; + } + ui.label("Order"); + changed |= ui + .add( + egui::DragValue::new(&mut settings.polynomial_order) + .range(1..=MAX_SAVGOL_ORDER), + ) + .changed(); + }); + if settings.polynomial_order >= settings.window as u8 { + settings.polynomial_order = (settings.window - 1) as u8; + } + } + egui::ComboBox::from_label("Normalize") + .selected_text(match params.normalization { + XrdNormalization::None => "None", + XrdNormalization::Maximum => "Maximum intensity", + XrdNormalization::Area => "Integrated area", + }) + .show_ui(ui, |ui| { + changed |= ui + .selectable_value( + &mut params.normalization, + XrdNormalization::None, + "None", + ) + .changed(); + changed |= ui + .selectable_value( + &mut params.normalization, + XrdNormalization::Maximum, + "Maximum intensity", + ) + .changed(); + changed |= ui + .selectable_value( + &mut params.normalization, + XrdNormalization::Area, + "Integrated area", + ) + .changed(); + }); + action_bar(app, ui); + }, + ); + } }); + }); + if reset { + params = XrdProcessing::default(); + changed = true; + } + if changed { + app.commit_processing_edit(di, before, DatasetProcessingState::Xrd(params)); + } if toggle { app.session.ui.processing_task_collapsed = !collapsed; } diff --git a/crates/app/src/ui/tools/region_analysis.rs b/crates/app/src/ui/tools/region_analysis.rs index 7029e1d..a6e3a7b 100644 --- a/crates/app/src/ui/tools/region_analysis.rs +++ b/crates/app/src/ui/tools/region_analysis.rs @@ -1,4 +1,4 @@ -use egui::{Area, Button, Order, Ui}; +use egui::{Button, Ui}; use egui_phosphor::regular as icon; use plotx_core::actions::Action; use plotx_core::state::{ @@ -70,82 +70,80 @@ pub(crate) fn render_task(app: &mut PlotxApp, host: &mut Ui) { let mut toggle_collapse = false; let mut open_table = false; - Area::new(egui::Id::new("region_task_card")) - .order(Order::Foreground) - .fixed_pos(pos) - .show(host.ctx(), |ui| { - ui.set_width(width); - crate::ui::card_frame(dark, egui::Margin::ZERO).show(ui, |ui| { - if task_card::tab_bar(app, TaskDockTab::Regions, ui) { - ui.separator(); - } - let count = app.doc.datasets[di] - .region_analysis() - .map_or(0, |state| state.regions.len()); - ui.horizontal(|ui| { - ui.label(crate::typography::headline("Regions")); - let state = if app.session.tool == Tool::Regions { - if count == 0 { - "Drawing".to_owned() - } else { - format!("Drawing · {count}") - } - } else if count == 1 { - "1 region".to_owned() + let area_id = egui::Id::new("region_task_card"); + task_card::area(host, area_id, pos).show(host.ctx(), |ui| { + ui.set_width(width); + crate::ui::card_frame(dark, egui::Margin::ZERO).show(ui, |ui| { + if task_card::tab_bar(app, TaskDockTab::Regions, ui) { + ui.separator(); + } + let count = app.doc.datasets[di] + .region_analysis() + .map_or(0, |state| state.regions.len()); + task_card::header(ui, area_id, |ui| { + ui.label(crate::typography::headline("Regions")); + let state = if app.session.tool == Tool::Regions { + if count == 0 { + "Drawing".to_owned() + } else { + format!("Drawing · {count}") + } + } else if count == 1 { + "1 region".to_owned() + } else { + format!("{count} regions") + }; + ui.weak(state); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .small_button(icon::X) + .on_hover_text("Close region tools") + .clicked() + { + close = true; + } + let glyph = if collapsed { + icon::CARET_DOWN } else { - format!("{count} regions") + icon::CARET_UP }; - ui.weak(state); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui - .small_button(icon::X) - .on_hover_text("Close region tools") - .clicked() - { - close = true; - } - let glyph = if collapsed { - icon::CARET_DOWN + if ui + .small_button(glyph) + .on_hover_text(if collapsed { + "Expand region tools" } else { - icon::CARET_UP - }; - if ui - .small_button(glyph) - .on_hover_text(if collapsed { - "Expand region tools" - } else { - "Collapse region tools" - }) + "Collapse region tools" + }) + .clicked() + { + toggle_collapse = true; + } + if collapsed + && count > 0 + && ui + .small_button(icon::TABLE) + .on_hover_text("View extracted curves") .clicked() - { - toggle_collapse = true; - } - if collapsed - && count > 0 - && ui - .small_button(icon::TABLE) - .on_hover_text("View extracted curves") - .clicked() - { - open_table = true; - } - }); + { + open_table = true; + } }); - if !collapsed { - ui.separator(); - egui::Resize::default() - .id_salt("region_task_body_resize") - .default_size([ui.available_width(), default_body_height]) - .min_size([ui.available_width(), min_body_height]) - .max_size([ui.available_width(), max_body_height]) - .resizable([false, true]) - .with_stroke(false) - .show(ui, |ui| { - region_task_body(app, di, ui); - }); - } }); + if !collapsed { + ui.separator(); + task_card::resizable_body( + ui, + "region_task_body_resize", + default_body_height, + min_body_height, + max_body_height, + |ui| { + region_task_body(app, di, ui); + }, + ); + } }); + }); if toggle_collapse { app.session.ui.region_task_collapsed = !collapsed; diff --git a/crates/app/src/ui/tools/statistics.rs b/crates/app/src/ui/tools/statistics.rs index 0db022a..04b4d00 100644 --- a/crates/app/src/ui/tools/statistics.rs +++ b/crates/app/src/ui/tools/statistics.rs @@ -4,7 +4,7 @@ //! question to data roles, options, an early feasibility check, and a persisted //! result list — without requiring any statistics vocabulary to begin. -use egui::{Area, Button, Order, Ui}; +use egui::{Button, Ui}; use egui_phosphor::regular as icon; use plotx_core::state::{Dataset, PlotxApp, StatDraft, TaskDockTab}; @@ -66,60 +66,58 @@ pub(crate) fn render_task(app: &mut PlotxApp, host: &mut Ui) { let mut close = false; let mut toggle_collapse = false; - Area::new(egui::Id::new("statistics_task_card")) - .order(Order::Foreground) - .fixed_pos(pos) - .show(host.ctx(), |ui| { - ui.set_width(width); - crate::ui::card_frame(dark, egui::Margin::ZERO).show(ui, |ui| { - if task_card::tab_bar(app, TaskDockTab::Statistics, ui) { - ui.separator(); - } - let table = app.doc.datasets[di].as_table().unwrap(); - let columns = table.numeric_analysis_columns().len(); - let points = table.typed_state.envelope.revision.snapshot.row_count; - ui.horizontal(|ui| { - ui.label(crate::typography::headline("Statistics")); - ui.weak(format!("{columns} columns · {points} rows")); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui - .small_button(icon::X) - .on_hover_text("Close Statistics") - .clicked() - { - close = true; - } - let glyph = if collapsed { - icon::CARET_DOWN + let area_id = egui::Id::new("statistics_task_card"); + task_card::area(host, area_id, pos).show(host.ctx(), |ui| { + ui.set_width(width); + crate::ui::card_frame(dark, egui::Margin::ZERO).show(ui, |ui| { + if task_card::tab_bar(app, TaskDockTab::Statistics, ui) { + ui.separator(); + } + let table = app.doc.datasets[di].as_table().unwrap(); + let columns = table.numeric_analysis_columns().len(); + let points = table.typed_state.envelope.revision.snapshot.row_count; + task_card::header(ui, area_id, |ui| { + ui.label(crate::typography::headline("Statistics")); + ui.weak(format!("{columns} columns · {points} rows")); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .small_button(icon::X) + .on_hover_text("Close Statistics") + .clicked() + { + close = true; + } + let glyph = if collapsed { + icon::CARET_DOWN + } else { + icon::CARET_UP + }; + if ui + .small_button(glyph) + .on_hover_text(if collapsed { + "Expand Statistics" } else { - icon::CARET_UP - }; - if ui - .small_button(glyph) - .on_hover_text(if collapsed { - "Expand Statistics" - } else { - "Collapse Statistics" - }) - .clicked() - { - toggle_collapse = true; - } - }); + "Collapse Statistics" + }) + .clicked() + { + toggle_collapse = true; + } }); - if !collapsed { - ui.separator(); - egui::Resize::default() - .id_salt("statistics_task_body_resize") - .default_size([ui.available_width(), default_body_height]) - .min_size([ui.available_width(), min_body_height]) - .max_size([ui.available_width(), max_body_height]) - .resizable([false, true]) - .with_stroke(false) - .show(ui, |ui| statistics_task_body(app, di, ui)); - } }); + if !collapsed { + ui.separator(); + task_card::resizable_body( + ui, + "statistics_task_body_resize", + default_body_height, + min_body_height, + max_body_height, + |ui| statistics_task_body(app, di, ui), + ); + } }); + }); if toggle_collapse { app.session.ui.stat_task_collapsed = !collapsed; diff --git a/crates/app/src/ui/tools/task_card.rs b/crates/app/src/ui/tools/task_card.rs index f4992f8..48a767a 100644 --- a/crates/app/src/ui/tools/task_card.rs +++ b/crates/app/src/ui/tools/task_card.rs @@ -1,9 +1,12 @@ -//! Shared geometry for the canvas task cards (Regions, Curve Fit). Both anchor -//! to the same corner of the canvas, so the sizing rules live in one place. +//! Shared geometry for canvas task cards. They start in the same canvas corner, +//! so their initial position and sizing rules live in one place. -use egui::{Pos2, RichText, Ui}; +use egui::{ + Align, Area, CursorIcon, Id, Layout, Order, Pos2, RichText, Sense, Ui, UiBuilder, Vec2, +}; use egui_phosphor::regular as icon; use plotx_core::state::{PlotxApp, TaskDockTab, Tool}; +use std::hash::Hash; /// Width shared by every task card, and the gap it keeps from the canvas edges. const WIDTH: f32 = 310.0; @@ -109,6 +112,91 @@ pub(crate) fn safe_fit_rect(app: &PlotxApp, host: egui::Rect) -> egui::Rect { egui::Rect::from_min_max(host.min, egui::pos2(right, host.bottom())) } +/// A foreground task card that starts at `pos` and follows the shared title-bar +/// drag position maintained by [`header`]. +pub(super) fn area(host: &Ui, id: Id, pos: Pos2) -> Area { + let stored = host + .ctx() + .data(|data| data.get_temp::(id.with("position"))); + let area = Area::new(id) + .order(Order::Foreground) + .movable(false) + .constrain_to(host.max_rect()); + if let Some(stored) = stored { + area.current_pos(stored) + } else { + area.default_pos(pos) + } +} + +/// Renders a draggable title row. The drag zone is registered before its child +/// buttons so close, collapse and menu controls retain priority. +pub(super) fn header(ui: &mut Ui, area_id: Id, add_contents: impl FnOnce(&mut Ui) -> R) -> R { + let (drag_rect, drag) = ui.allocate_exact_size( + Vec2::new(ui.available_width(), ui.spacing().interact_size.y), + Sense::drag(), + ); + let drag = drag.on_hover_cursor(CursorIcon::Grab); + update_drag_position(ui, area_id, &drag); + let mut header = ui.new_child( + UiBuilder::new() + .id_salt(area_id.with("title_contents")) + .max_rect(drag_rect) + .layout(Layout::left_to_right(Align::Center)), + ); + add_contents(&mut header) +} + +fn update_drag_position(ui: &Ui, area_id: Id, drag: &egui::Response) { + let origin_id = area_id.with("drag_origin"); + let position_id = area_id.with("position"); + if drag.drag_started() + && let Some(origin) = ui + .ctx() + .memory(|memory| memory.area_rect(area_id).map(|rect| rect.min)) + { + ui.ctx() + .data_mut(|data| data.insert_temp(origin_id, origin)); + } + if drag.dragged() + && let Some(origin) = ui.ctx().data(|data| data.get_temp::(origin_id)) + && let Some(delta) = drag.total_drag_delta() + { + ui.ctx() + .data_mut(|data| data.insert_temp(position_id, origin + delta)); + ui.ctx().request_repaint(); + } + if drag.drag_stopped() { + ui.ctx().data_mut(|data| data.remove::(origin_id)); + } +} + +/// Renders the vertically resizable body shared by every task card. +/// +/// `egui::Resize` normally collapses a non-resizable axis to its content size. +/// Keeping the content UI at the card width aligns its handle with the card. +pub(super) fn resizable_body( + ui: &mut Ui, + id_salt: impl Hash, + default_height: f32, + min_height: f32, + max_height: f32, + add_contents: impl FnOnce(&mut Ui) -> R, +) -> R { + let width = ui.available_width(); + egui::Resize::default() + .id_salt(id_salt) + .default_size([width, default_height]) + .min_size([width, min_height]) + .max_size([width, max_height]) + .resizable([false, true]) + .with_stroke(false) + .show(ui, |ui| { + ui.set_min_width(width); + add_contents(ui) + }) +} + pub(super) fn is_active(app: &PlotxApp, tab: TaskDockTab) -> bool { app.session.ui.task_dock_active == Some(tab) } @@ -191,6 +279,7 @@ mod tests { use super::*; use plotx_core::state::{CanvasDocument, Dataset, NmrDataset}; use plotx_io::{Domain, NmrData}; + use std::cell::Cell; fn app_with_task(tab: TaskDockTab, collapsed: bool) -> PlotxApp { let mut app = PlotxApp::new(); @@ -277,4 +366,108 @@ mod tests { assert_eq!(safe, host); assert!(safe.is_finite()); } + + #[test] + fn title_drag_moves_the_shared_task_card() { + let ctx = egui::Context::default(); + let screen = egui::Rect::from_min_size(Pos2::ZERO, egui::vec2(640.0, 480.0)); + let area_id = Id::new("test_task_card"); + let start = Pos2::new(100.0, 80.0); + + let frame = |events| { + let input = egui::RawInput { + screen_rect: Some(screen), + events, + ..Default::default() + }; + let _ = ctx.run_ui(input, |ui| { + egui::CentralPanel::default().show_inside(ui, |ui| { + area(ui, area_id, start).show(ui.ctx(), |ui| { + ui.set_width(WIDTH); + header(ui, area_id, |ui| { + ui.label("Processing"); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let _ = ui.button("Close"); + }); + }); + ui.label("Body"); + }); + }); + }); + }; + + frame(Vec::new()); + frame(Vec::new()); + let initial = ctx + .memory(|memory| memory.area_rect(area_id)) + .expect("laid-out task card"); + let pointer_start = initial.min + egui::vec2(80.0, 12.0); + let pointer_end = pointer_start + egui::vec2(120.0, 100.0); + frame(vec![egui::Event::PointerMoved(pointer_start)]); + frame(vec![ + egui::Event::PointerMoved(pointer_start), + egui::Event::PointerButton { + pos: pointer_start, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::default(), + }, + ]); + frame(vec![egui::Event::PointerMoved(pointer_end)]); + frame(vec![egui::Event::PointerButton { + pos: pointer_end, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::default(), + }]); + + let moved = ctx + .memory(|memory| memory.area_rect(area_id)) + .expect("task card area"); + assert_eq!(moved.min, initial.min + (pointer_end - pointer_start)); + } + + #[test] + fn resize_handle_stays_at_the_task_card_right_edge() { + let ctx = egui::Context::default(); + let screen = egui::Rect::from_min_size(Pos2::ZERO, egui::vec2(640.0, 480.0)); + let expected_right = Cell::new(0.0); + let actual_right = Cell::new(None); + + let frame = || { + let _ = ctx.run_ui( + egui::RawInput { + screen_rect: Some(screen), + ..Default::default() + }, + |ui| { + egui::CentralPanel::default().show_inside(ui, |ui| { + Area::new(Id::new("resize_test_card")) + .fixed_pos(Pos2::new(100.0, 80.0)) + .show(ui.ctx(), |ui| { + ui.set_width(WIDTH); + let body_id = Id::new("resize_test_body"); + let corner_id = ui + .make_persistent_id(Id::new(body_id)) + .with("__resize_corner"); + expected_right + .set(ui.next_widget_position().x + ui.available_width()); + resizable_body(ui, body_id, 200.0, 120.0, 300.0, |ui| { + ui.label("Short content"); + }); + actual_right.set( + ui.ctx() + .read_response(corner_id) + .map(|response| response.rect.right()), + ); + }); + }); + }, + ); + }; + + frame(); + frame(); + assert_eq!(actual_right.get(), Some(expected_right.get())); + } } diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 706163a..499d50a 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -505,6 +505,22 @@ fn text_report(report: &InspectionReport) -> String { ephys.protocol.as_deref().unwrap_or("unknown") )); } + if let Some(xrd) = &report.xrd { + lines.push(format!("xrd.points: {}", xrd.point_count)); + lines.push(format!( + "xrd.two_theta_range_deg: {}..{}", + xrd.two_theta_range_deg[0], xrd.two_theta_range_deg[1] + )); + if let Some(instrument) = &xrd.instrument { + lines.push(format!("xrd.instrument: {instrument}")); + } + if let Some(target) = &xrd.target { + lines.push(format!("xrd.target: {target}")); + } + if let Some(wavelength) = xrd.wavelength_angstrom { + lines.push(format!("xrd.wavelength_angstrom: {wavelength}")); + } + } for warning in &report.warnings { lines.push(format!("warning.{}: {}", warning.code, warning.message)); } diff --git a/crates/core/src/actions/app_impl/apply.rs b/crates/core/src/actions/app_impl/apply.rs index 49121d3..765b3cb 100644 --- a/crates/core/src/actions/app_impl/apply.rs +++ b/crates/core/src/actions/app_impl/apply.rs @@ -6,23 +6,29 @@ impl PlotxApp { /// Apply an action's `after` state to the live document without touching /// history. Callers that record the step themselves — a paused processing /// commit, a coalesced gesture — use this and then record once. - pub(crate) fn apply_action(&mut self, action: &Action) { + pub(crate) fn apply_action(&mut self, action: &Action) -> Result<(), ActionApplyError> { macro_rules! dataset_index { ($id:expr) => { match self.doc.dataset_index($id) { Some(index) => index, - None => return, + None => return Err(ActionApplyError::StaleTarget(format!("dataset {}", $id))), } }; } match action { Action::Composite(actions) => { - for action in actions { - self.apply_action(action); + for (index, action) in actions.iter().enumerate() { + if let Err(error) = self.apply_action(action) { + for applied in actions[..index].iter().rev() { + self.revert_action(applied); + } + return Err(error); + } } } Action::UpdateDatasetProcessing { dataset, after, .. } => { - self.set_dataset_processing_state(dataset_index!(*dataset), after); + self.set_dataset_processing_state(dataset_index!(*dataset), after) + .map_err(ActionApplyError::InvalidValue)?; } Action::SetObjectViewport { canvas, @@ -303,15 +309,15 @@ impl PlotxApp { .. } => { if *dataset_index != self.doc.datasets.len() { - return; + return Ok(()); } if !self.register_loaded_dataset_fields(dataset.as_ref()) { - return; + return Ok(()); } self.doc.datasets.push(dataset.as_ref().clone()); if let Some(ci) = inserted_into_existing_canvas { let Some(canvas) = self.doc.canvases.get(*ci) else { - return; + return Ok(()); }; let page = canvas.size_pt(); let offset = 18.0 * canvas.objects.len() as f32; @@ -330,7 +336,7 @@ impl PlotxApp { self.session.active_canvas = Some(*ci); } else { if *canvas_index != self.doc.canvases.len() { - return; + return Ok(()); } let mut canvas = crate::workflow::build_default_canvas_for_dataset( &self.doc.datasets[*dataset_index], @@ -355,5 +361,6 @@ impl PlotxApp { Action::TransferObjects { .. } => self.apply_transfer(action), Action::TileDrop { .. } => self.apply_tile_drop(action), } + Ok(()) } } diff --git a/crates/core/src/actions/app_impl/mod.rs b/crates/core/src/actions/app_impl/mod.rs index 75f5c06..3cad604 100644 --- a/crates/core/src/actions/app_impl/mod.rs +++ b/crates/core/src/actions/app_impl/mod.rs @@ -23,7 +23,7 @@ impl PlotxApp { return Ok(()); } validate_action(self, &action, &mut ValidationShape::from_app(self))?; - self.apply_action(&action); + self.apply_action(&action)?; self.session.undo_stack.push(action); if self.session.undo_stack.len() > self.session.history_limit { self.session.undo_stack.remove(0); @@ -59,11 +59,18 @@ impl PlotxApp { return; }; let label = action.undo_label(); - self.apply_action(&action); - self.session.undo_stack.push(action); - self.mark_document_dirty(); - self.doc.automation_revision = self.doc.automation_revision.saturating_add(1); - self.session.status = format!("Redid {label}."); + match self.apply_action(&action) { + Ok(()) => { + self.session.undo_stack.push(action); + self.mark_document_dirty(); + self.doc.automation_revision = self.doc.automation_revision.saturating_add(1); + self.session.status = format!("Redid {label}."); + } + Err(error) => { + self.session.redo_stack.push(action); + self.session.status = error.to_string(); + } + } } pub fn can_undo(&self) -> bool { diff --git a/crates/core/src/actions/app_impl/processing.rs b/crates/core/src/actions/app_impl/processing.rs index a2afad8..5c160a8 100644 --- a/crates/core/src/actions/app_impl/processing.rs +++ b/crates/core/src/actions/app_impl/processing.rs @@ -51,6 +51,9 @@ impl PlotxApp { n.preset = *preset; n.group_delay_correct = *group_delay_correct; } + (Dataset::Xrd(data), DatasetProcessingState::Xrd(processing)) => { + data.params = *processing; + } _ => {} } } @@ -59,14 +62,15 @@ impl PlotxApp { /// that means a full retransform or a cheap re-apply is decided by comparing /// the recipes, not by the caller. Lives beside the pause gate because it is /// the other half of it: this is what "not paused" does. - pub fn set_dataset_processing_state(&mut self, dataset: usize, state: &DatasetProcessingState) { + pub fn set_dataset_processing_state( + &mut self, + dataset: usize, + state: &DatasetProcessingState, + ) -> Result<(), String> { let Some(current) = self.doc.datasets.get(dataset) else { - return; + return Err(format!("Dataset index {dataset} is no longer available.")); }; - if let Err(error) = validate_processing_state(current, state) { - self.session.status = error; - return; - } + validate_processing_state(current, state)?; if let ( Some(Dataset::Nmr2D(current)), DatasetProcessingState::Nmr2D { @@ -81,15 +85,13 @@ impl PlotxApp { current.preset = *preset; current.group_delay_correct = *group_delay_correct; self.schedule_2d_processing(dataset, force_full); - return; + return Ok(()); } let current = &mut self.doc.datasets[dataset]; - if let Err(error) = state.apply_to(current) { - self.session.status = error.to_string(); - return; - } + state.apply_to(current).map_err(|error| error.to_string())?; self.recompute_integrals_2d_after_processing(dataset); self.rebuild_canvases_for(dataset); + Ok(()) } /// Commit a processing edit through the pause gate: recompute now when @@ -111,8 +113,10 @@ impl PlotxApp { .is_some_and(|edit| edit.dataset == dataset_id) && !self.session.ui.proc_paused { - if DatasetProcessingState::from_dataset(&self.doc.datasets[dataset]) != after { - self.set_dataset_processing_state(dataset, &after); + if DatasetProcessingState::from_dataset(&self.doc.datasets[dataset]) != after + && let Err(error) = self.set_dataset_processing_state(dataset, &after) + { + self.session.status = error; } return; } @@ -299,7 +303,7 @@ impl PlotxApp { } } -fn validate_processing_state( +pub(super) fn validate_processing_state( dataset: &Dataset, state: &DatasetProcessingState, ) -> Result<(), String> { @@ -319,6 +323,10 @@ fn validate_processing_state( .map_err(|error| format!("Cannot apply invalid F1 processing pipeline: {error}"))?; Ok(()) } + (Dataset::Xrd(_), DatasetProcessingState::Xrd(processing)) => { + plotx_processing::xrd::validate(*processing) + .map_err(|error| format!("Cannot apply invalid XRD processing pipeline: {error}")) + } _ => Ok(()), } } diff --git a/crates/core/src/actions/app_impl/revert.rs b/crates/core/src/actions/app_impl/revert.rs index e6e35d2..4509732 100644 --- a/crates/core/src/actions/app_impl/revert.rs +++ b/crates/core/src/actions/app_impl/revert.rs @@ -20,7 +20,11 @@ impl PlotxApp { Action::UpdateDatasetProcessing { dataset, before, .. } => { - self.set_dataset_processing_state(dataset_index!(*dataset), before); + if let Err(error) = + self.set_dataset_processing_state(dataset_index!(*dataset), before) + { + self.session.status = format!("Could not restore processing state: {error}"); + } } Action::SetObjectViewport { canvas, diff --git a/crates/core/src/actions/app_impl/validate.rs b/crates/core/src/actions/app_impl/validate.rs index d59d768..0e9068d 100644 --- a/crates/core/src/actions/app_impl/validate.rs +++ b/crates/core/src/actions/app_impl/validate.rs @@ -44,11 +44,20 @@ pub(super) fn validate_action( validate_action(app, child, shape)?; } } - Action::RenameDataset { dataset, .. } | Action::UpdateDatasetProcessing { dataset, .. } => { + Action::RenameDataset { dataset, .. } => { if !shape.has_dataset(app, *dataset) { return Err(ActionApplyError::StaleTarget(format!("dataset {dataset}"))); } } + Action::UpdateDatasetProcessing { dataset, after, .. } => { + if !shape.has_dataset(app, *dataset) { + return Err(ActionApplyError::StaleTarget(format!("dataset {dataset}"))); + } + if let Some(index) = app.doc.dataset_index(*dataset) { + super::processing::validate_processing_state(&app.doc.datasets[index], after) + .map_err(ActionApplyError::InvalidValue)?; + } + } Action::SetMassSpecStream { dataset, before, diff --git a/crates/core/src/actions/mod.rs b/crates/core/src/actions/mod.rs index 17dfcb7..0588d41 100644 --- a/crates/core/src/actions/mod.rs +++ b/crates/core/src/actions/mod.rs @@ -10,6 +10,7 @@ use crate::state::{ use crate::theme::ThemeSnapshot; use crate::{Integral2D, IntegralResult}; use plotx_io::AcquisitionStreamId; +use plotx_processing::xrd::XrdProcessing; use plotx_processing::{AxisPipeline, Params2D, Preset2D}; mod app_impl; @@ -40,6 +41,7 @@ pub enum DatasetProcessingState { Table, Electrophysiology(crate::state::ElectrophysiologyProcessing), Afm, + Xrd(XrdProcessing), } #[derive(Clone)] diff --git a/crates/core/src/actions/processing_state.rs b/crates/core/src/actions/processing_state.rs index 5fc7da9..6d72e08 100644 --- a/crates/core/src/actions/processing_state.rs +++ b/crates/core/src/actions/processing_state.rs @@ -11,7 +11,11 @@ impl DatasetProcessingState { PhaseAxis::F1 => Some(&mut params.f1), PhaseAxis::Direct => None, }, - Self::Nmr { .. } | Self::Table | Self::Electrophysiology(_) | Self::Afm => None, + Self::Nmr { .. } + | Self::Table + | Self::Electrophysiology(_) + | Self::Afm + | Self::Xrd(_) => None, } } @@ -25,7 +29,7 @@ impl DatasetProcessingState { group_delay_correct, .. } => Some(group_delay_correct), - Self::Table | Self::Electrophysiology(_) | Self::Afm => None, + Self::Table | Self::Electrophysiology(_) | Self::Afm | Self::Xrd(_) => None, } } @@ -44,6 +48,7 @@ impl DatasetProcessingState { Dataset::Electrophysiology(d) => Self::Electrophysiology(d.processing), Dataset::Afm(_) => Self::Afm, Dataset::MassSpec(_) => Self::Table, + Dataset::Xrd(data) => Self::Xrd(data.params), } } @@ -58,7 +63,7 @@ impl DatasetProcessingState { let pipelines: Vec<&mut AxisPipeline> = match self { Self::Nmr { pipeline, .. } => vec![pipeline], Self::Nmr2D { params, .. } => vec![&mut params.f2, &mut params.f1], - Self::Table | Self::Electrophysiology(_) | Self::Afm => Vec::new(), + Self::Table | Self::Electrophysiology(_) | Self::Afm | Self::Xrd(_) => Vec::new(), }; pipelines .into_iter() @@ -145,6 +150,15 @@ impl DatasetProcessingState { Ok(ProcessingRebuild::Rebuilt) } (Dataset::Afm(_), Self::Afm) => Ok(ProcessingRebuild::Unchanged), + (Dataset::Xrd(data), Self::Xrd(processing)) => { + data.apply_processing(*processing).map_err(|error| { + ProcessingStateError::InvalidPipeline { + axis: "2theta", + details: error.to_string(), + } + })?; + Ok(ProcessingRebuild::Rebuilt) + } (dataset, state) => Err(ProcessingStateError::KindMismatch { dataset_kind: dataset.kind_label(), state_kind: state.kind_label(), @@ -159,6 +173,7 @@ impl DatasetProcessingState { Self::Table => "Data Table", Self::Electrophysiology(_) => "Electrophysiology", Self::Afm => "AFM", + Self::Xrd(_) => "XRD", } } } diff --git a/crates/core/src/actions/tests/mod.rs b/crates/core/src/actions/tests/mod.rs index 45cc6a5..6c31c3f 100644 --- a/crates/core/src/actions/tests/mod.rs +++ b/crates/core/src/actions/tests/mod.rs @@ -3,7 +3,6 @@ use crate::state::{ ACS_DOUBLE_COLUMN, AxisRange, CanvasDocument, CanvasSizeUnit, DEFAULT_CANVAS_SIZE_MM, NATURE_SINGLE_COLUMN, NmrDataset, PAPER_A4, PRESENTATION_16X9, matching_preset, }; - mod align; mod arithmetic; mod authoring; @@ -19,6 +18,7 @@ mod stable_identity; mod stack; mod symmetry; mod tiling; +mod xrd; use num_complex::Complex64; use plotx_io::{Domain, NmrData}; use std::f64::consts::TAU; diff --git a/crates/core/src/actions/tests/xrd.rs b/crates/core/src/actions/tests/xrd.rs new file mode 100644 index 0000000..3ffc081 --- /dev/null +++ b/crates/core/src/actions/tests/xrd.rs @@ -0,0 +1,186 @@ +use super::*; +use crate::actions::DatasetProcessingState; +use crate::state::XrdDataset; +use plotx_processing::xrd::{MAX_SNIP_ITERATIONS, SavitzkyGolay, SnipBackground, XrdProcessing}; + +fn xrd_app() -> PlotxApp { + xrd_app_with_intensity(vec![10.0, 25.0, 12.0]) +} + +fn xrd_app_with_intensity(intensity: Vec) -> PlotxApp { + let mut app = PlotxApp::new(); + let two_theta_deg = (0..intensity.len()) + .map(|index| 3.0 + index as f64 * 0.1) + .collect(); + let data = plotx_io::XrdData { + two_theta_deg, + intensity, + attenuation: None, + source: "test.rasx".to_owned(), + instrument: None, + target: None, + wavelength_angstrom: None, + voltage_kv: None, + current_ma: None, + scan_step_deg: Some(0.1), + scan_speed_deg_min: None, + }; + app.doc + .datasets + .push(Dataset::Xrd(Box::new(XrdDataset::load(data)))); + app +} + +#[test] +fn invalid_xrd_processing_does_not_mutate_or_enter_history() { + let mut app = xrd_app(); + let dataset_id = app.doc.datasets[0].resource_id(); + let before = DatasetProcessingState::from_dataset(&app.doc.datasets[0]); + let before_processed = app.doc.datasets[0].as_xrd().unwrap().processed.clone(); + let invalid = DatasetProcessingState::Xrd(XrdProcessing { + background: Some(SnipBackground { + iterations: MAX_SNIP_ITERATIONS + 1, + }), + ..XrdProcessing::default() + }); + + let error = app + .try_execute_action(Action::update_dataset_processing( + dataset_id, + before.clone(), + invalid, + )) + .unwrap_err(); + + assert!(error.to_string().contains("invalid XRD processing")); + assert_eq!( + DatasetProcessingState::from_dataset(&app.doc.datasets[0]), + before + ); + assert_eq!( + app.doc.datasets[0].as_xrd().unwrap().processed, + before_processed + ); + assert!(app.session.undo_stack.is_empty()); + assert!(!app.doc.dirty); +} + +#[test] +fn invalid_xrd_processing_state_apply_is_transactional() { + let mut app = xrd_app(); + let before = DatasetProcessingState::from_dataset(&app.doc.datasets[0]); + let before_processed = app.doc.datasets[0].as_xrd().unwrap().processed.clone(); + let invalid = DatasetProcessingState::Xrd(XrdProcessing { + background: Some(SnipBackground { + iterations: MAX_SNIP_ITERATIONS + 1, + }), + ..XrdProcessing::default() + }); + + invalid.apply_to(&mut app.doc.datasets[0]).unwrap_err(); + + assert_eq!( + DatasetProcessingState::from_dataset(&app.doc.datasets[0]), + before + ); + assert_eq!( + app.doc.datasets[0].as_xrd().unwrap().processed, + before_processed + ); +} + +#[test] +fn numerical_xrd_processing_failure_does_not_enter_history() { + let mut app = xrd_app_with_intensity(vec![0.0, f64::MAX, f64::MAX, f64::MAX, 0.0]); + let dataset_id = app.doc.datasets[0].resource_id(); + let before = DatasetProcessingState::from_dataset(&app.doc.datasets[0]); + let before_processed = app.doc.datasets[0].as_xrd().unwrap().processed.clone(); + let after = DatasetProcessingState::Xrd(XrdProcessing { + smoothing: Some(SavitzkyGolay { + window: 5, + polynomial_order: 2, + }), + ..XrdProcessing::default() + }); + + let error = app + .try_execute_action(Action::update_dataset_processing( + dataset_id, + before.clone(), + after, + )) + .unwrap_err(); + + assert!(error.to_string().contains("non-finite")); + assert_eq!( + DatasetProcessingState::from_dataset(&app.doc.datasets[0]), + before + ); + assert_eq!( + app.doc.datasets[0].as_xrd().unwrap().processed, + before_processed + ); + assert!(app.session.undo_stack.is_empty()); + assert!(!app.doc.dirty); +} + +#[test] +fn composite_rolls_back_when_xrd_processing_fails() { + let mut app = xrd_app_with_intensity(vec![0.0, f64::MAX, f64::MAX, f64::MAX, 0.0]); + app.doc + .canvases + .push(CanvasDocument::new("Before".to_owned(), [100.0, 80.0])); + let dataset_id = app.doc.datasets[0].resource_id(); + let before = DatasetProcessingState::from_dataset(&app.doc.datasets[0]); + let failing = DatasetProcessingState::Xrd(XrdProcessing { + smoothing: Some(SavitzkyGolay { + window: 5, + polynomial_order: 2, + }), + ..XrdProcessing::default() + }); + let action = Action::Composite(vec![ + Action::rename_canvas(0, "Before".to_owned(), "After".to_owned()), + Action::update_dataset_processing(dataset_id, before, failing), + ]); + + app.try_execute_action(action).unwrap_err(); + + assert_eq!(app.doc.canvases[0].name, "Before"); + assert!(app.session.undo_stack.is_empty()); + assert!(!app.doc.dirty); +} + +#[test] +fn paused_xrd_processing_uses_the_shared_apply_path() { + let mut app = xrd_app(); + let before = DatasetProcessingState::from_dataset(&app.doc.datasets[0]); + let before_processed = app.doc.datasets[0].as_xrd().unwrap().processed.clone(); + let after = DatasetProcessingState::Xrd(XrdProcessing { + background: Some(SnipBackground { iterations: 1 }), + ..XrdProcessing::default() + }); + app.session.ui.proc_paused = true; + + app.commit_processing_edit(0, before.clone(), after.clone()); + + assert_eq!( + DatasetProcessingState::from_dataset(&app.doc.datasets[0]), + after + ); + assert!(app.has_pending_processing()); + assert!(app.session.undo_stack.is_empty()); + + app.apply_paused_processing(); + + assert_eq!( + DatasetProcessingState::from_dataset(&app.doc.datasets[0]), + after + ); + assert!(!app.has_pending_processing()); + assert_eq!(app.session.undo_stack.len(), 1); + assert_ne!( + app.doc.datasets[0].as_xrd().unwrap().processed, + before_processed + ); +} diff --git a/crates/core/src/automation/resources.rs b/crates/core/src/automation/resources.rs index 048b497..ec9b26e 100644 --- a/crates/core/src/automation/resources.rs +++ b/crates/core/src/automation/resources.rs @@ -7,6 +7,7 @@ use crate::state::{Dataset, PlotxApp}; use std::collections::BTreeMap; mod mass_spec; +mod xrd; pub const KIND_DATASET: &str = "plotx.dataset"; pub const KIND_APP: &str = "plotx.app"; @@ -54,6 +55,7 @@ pub const CAP_FIELD_AFM_MAP: &str = "field.afm.map"; pub const CAP_FIELD_REGION_SERIES: &str = "field.region_series"; pub const CAP_FIELD_MASS_CHROMATOGRAM: &str = "field.mass_spectrometry.chromatogram"; pub const CAP_FIELD_MASS_SPECTRUM: &str = "field.mass_spectrometry.spectrum"; +pub const CAP_FIELD_XRD_PATTERN: &str = "field.xrd.pattern"; /// Capability-oriented resource access. New resource types can participate by /// implementing this trait; query and tool orchestration do not dispatch on a @@ -181,6 +183,7 @@ impl<'a> ProjectResourceProvider<'a> { (dimensions, units, Vec::new()) } Dataset::MassSpec(dataset) => mass_spec::descriptor(dataset), + Dataset::Xrd(dataset) => (vec![dataset.data.len()], vec!["deg".to_owned()], Vec::new()), }; children.extend( dataset @@ -674,6 +677,7 @@ fn preview_dataset( ) } Dataset::MassSpec(dataset) => mass_spec::preview(dataset, target, limit, &mut statistics), + Dataset::Xrd(dataset) => xrd::preview(dataset, limit, &mut statistics), }; let returned = total.min(limit); Ok(DataPreview { diff --git a/crates/core/src/automation/resources/xrd.rs b/crates/core/src/automation/resources/xrd.rs new file mode 100644 index 0000000..991c5c2 --- /dev/null +++ b/crates/core/src/automation/resources/xrd.rs @@ -0,0 +1,29 @@ +use super::add_statistics; +use crate::state::XrdDataset; +use std::collections::BTreeMap; + +pub(super) fn preview( + dataset: &XrdDataset, + limit: usize, + statistics: &mut BTreeMap, +) -> (Vec, serde_json::Value, usize) { + add_statistics(statistics, &dataset.processed.intensity); + let values = dataset + .processed + .intensity + .iter() + .take(limit) + .map(|value| { + if value.is_finite() { + serde_json::json!(value) + } else { + serde_json::Value::Null + } + }) + .collect::>(); + ( + vec![dataset.data.len()], + serde_json::Value::Array(values), + dataset.data.len(), + ) +} diff --git a/crates/core/src/data_export.rs b/crates/core/src/data_export.rs index 50c28ca..2b2cdf6 100644 --- a/crates/core/src/data_export.rs +++ b/crates/core/src/data_export.rs @@ -14,7 +14,7 @@ mod write; mod xlsx; use write::{ safe_name, write_1d, write_electrophysiology, write_fits, write_integrals_1d, - write_integrals_2d, write_peaks, write_pseudo_2d, write_true_2d, + write_integrals_2d, write_peaks, write_pseudo_2d, write_true_2d, write_xrd, }; pub use xlsx::delimited_sidecar_path; @@ -202,6 +202,7 @@ fn processed_data_available(dataset: &Dataset) -> bool { // LC–MS export snapshots are not implemented yet. Do not advertise an // option whose capture path can only return `ContentUnavailable`. Dataset::MassSpec(_) => false, + Dataset::Xrd(xrd) => !xrd.processed.intensity.is_empty(), } } @@ -290,6 +291,10 @@ enum SnapshotData { channel_label: String, traces: Vec<(usize, Vec)>, }, + Xrd { + two_theta_deg: Vec, + intensity: Vec, + }, } impl DataExportSnapshot { @@ -396,6 +401,10 @@ impl DataExportSnapshot { channel_label, traces, } => write_electrophysiology(&mut writer, *sample_rate_hz, channel_label, traces)?, + SnapshotData::Xrd { + two_theta_deg, + intensity, + } => write_xrd(&mut writer, two_theta_deg, intensity)?, } Ok(()) } @@ -466,6 +475,10 @@ fn capture_processed(dataset: &Dataset) -> Result Dataset::Table(_) => Err(DataExportError::ContentUnavailable), Dataset::Afm(_) => Err(DataExportError::ContentUnavailable), Dataset::MassSpec(_) => Err(DataExportError::ContentUnavailable), + Dataset::Xrd(xrd) => Ok(SnapshotData::Xrd { + two_theta_deg: xrd.data.two_theta_deg.clone(), + intensity: xrd.processed.intensity.clone(), + }), } } diff --git a/crates/core/src/data_export/write.rs b/crates/core/src/data_export/write.rs index 9fd2f4e..16e7fd6 100644 --- a/crates/core/src/data_export/write.rs +++ b/crates/core/src/data_export/write.rs @@ -49,6 +49,25 @@ pub(super) fn write_1d( Ok(()) } +pub(super) fn write_xrd( + writer: &mut DelimitedWriter, + two_theta_deg: &[f64], + intensity: &[f64], +) -> io::Result<()> { + writer.write_record(&[Field::Text("two_theta_deg"), Field::Text("intensity")])?; + for index in 0..two_theta_deg.len().max(intensity.len()) { + writer.write_record(&[ + two_theta_deg + .get(index) + .map_or(Field::Empty, |value| Field::Number(*value)), + intensity + .get(index) + .map_or(Field::Empty, |value| Field::Number(*value)), + ])?; + } + Ok(()) +} + pub(super) fn write_true_2d( writer: &mut DelimitedWriter, spectrum: &Spectrum2D, diff --git a/crates/core/src/project/codec.rs b/crates/core/src/project/codec.rs index 2260b0c..c5f2dfd 100644 --- a/crates/core/src/project/codec.rs +++ b/crates/core/src/project/codec.rs @@ -258,6 +258,7 @@ pub fn write_dataset_blob( } DatasetBlob::Afm(data) => super::afm_convert::write_afm(zip, data), DatasetBlob::MassSpec(dataset) => super::mass_spec_convert::write(zip, dataset), + DatasetBlob::Xrd(data) => super::xrd_convert::write(zip, data), } } diff --git a/crates/core/src/project/convert.rs b/crates/core/src/project/convert.rs index e612cad..ca30f1c 100644 --- a/crates/core/src/project/convert.rs +++ b/crates/core/src/project/convert.rs @@ -12,6 +12,7 @@ pub enum DatasetBlob<'a> { Electrophysiology(&'a crate::state::ElectrophysiologyDataset), Afm(&'a plotx_io::AfmData), MassSpec(&'a crate::state::MassSpecDataset), + Xrd(&'a plotx_io::XrdData), } pub struct DatasetObjects<'a> { @@ -279,6 +280,39 @@ pub fn dataset_to_objects<'a>( }; DatasetObjects::primary(data, DatasetBlob::MassSpec(mass_spec), recipe) } + Dataset::Xrd(xrd) => { + let data = DataObject { + id: data_id.to_owned(), + role: "data".to_owned(), + classification: Classification { + domain: "diffraction".to_owned(), + technique: Some("powder_xrd".to_owned()), + object: "pattern".to_owned(), + }, + label: xrd.name.clone(), + dimensions: Vec::new(), + payload: Payload { + storage: STORAGE_XRD_V1.to_owned(), + blob: format!("objects/{data_id}/data.bin"), + shape: vec![xrd.data.len()], + domain: "two_theta".to_owned(), + }, + extensions: serde_json::json!({ "plotx.fields": &xrd.field_catalog }), + }; + let recipe = RecipeObject { + id: recipe_id.to_owned(), + role: "recipe".to_owned(), + classification: Classification { + domain: "diffraction".to_owned(), + technique: Some("powder_xrd".to_owned()), + object: "processing_recipe".to_owned(), + }, + input: data_id.to_owned(), + parameters: RecipeParameters::default(), + extensions: serde_json::json!({ "plotx.xrd": { "processing": &xrd.params } }), + }; + DatasetObjects::primary(data, DatasetBlob::Xrd(&xrd.data), recipe) + } }) } pub fn object_to_dataset( @@ -286,6 +320,55 @@ pub fn object_to_dataset( data: &DataObject, recipe: &RecipeObject, ) -> Result { + if data.classification.domain == "diffraction" + && data.classification.technique.as_deref() == Some("powder_xrd") + && data.classification.object == "pattern" + { + if data.payload.storage != STORAGE_XRD_V1 { + return Err(ProjectError::Unsupported(format!( + "XRD payload storage {}", + data.payload.storage + ))); + } + // Named generic decoder functions do not satisfy the higher-ranked + // lifetime required by `ZipFile`; the closure reborrows each entry. + #[allow(clippy::redundant_closure)] + let decoded = read_entry( + zip, + &data.payload.blob, + "XRD payload", + ProjectLoadLimits::default().max_entry_bytes, + |reader| super::xrd_convert::decode(reader), + )?; + if data.payload.shape.as_slice() != [decoded.len()] { + return Err(ProjectError::Invalid(format!( + "XRD payload shape {:?} does not match {} decoded points", + data.payload.shape, + decoded.len() + ))); + } + decoded + .validate() + .map_err(|error| ProjectError::Invalid(error.to_owned()))?; + let mut dataset = crate::state::XrdDataset::load(decoded); + dataset.field_catalog = read_field_catalog(data)?; + dataset.name = data.label.clone(); + if let Some(value) = recipe + .extensions + .get("plotx.xrd") + .and_then(|value| value.get("processing")) + { + let processing = serde_json::from_value(value.clone())?; + dataset + .apply_processing(processing) + .map_err(|error| ProjectError::Invalid(error.to_string()))?; + } + let dataset = Dataset::Xrd(Box::new(dataset)); + dataset + .validate_field_catalog() + .map_err(ProjectError::Invalid)?; + return Ok(dataset); + } // Named generic decoder functions do not satisfy the higher-ranked lifetime // required by `ZipFile`; closures let the compiler reborrow each entry. #[allow(clippy::redundant_closure)] diff --git a/crates/core/src/project/convert_views.rs b/crates/core/src/project/convert_views.rs index f97265c..5a2a2a5 100644 --- a/crates/core/src/project/convert_views.rs +++ b/crates/core/src/project/convert_views.rs @@ -145,6 +145,7 @@ pub fn canvas_to_view( Dataset::Electrophysiology(_) => "line_plot", Dataset::Afm(_) => "heatmap", Dataset::MassSpec(_) => "line_plot", + Dataset::Xrd(_) => "line_plot", }; let series = plot .binding diff --git a/crates/core/src/project/mod.rs b/crates/core/src/project/mod.rs index 382c26b..5c2784e 100644 --- a/crates/core/src/project/mod.rs +++ b/crates/core/src/project/mod.rs @@ -41,6 +41,7 @@ mod pipeline_conv; mod scheme; mod templates; mod typed_table; +mod xrd_convert; pub use codec::*; pub use convert::*; @@ -64,6 +65,7 @@ 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"; const STORAGE_MASS_SPEC_V1: &str = "plotx_mass_spec_v1"; +const STORAGE_XRD_V1: &str = "plotx_xrd_v1"; const SNAPSHOT_KIND: &str = "editable_figure_v1"; type Result = std::result::Result; @@ -741,3 +743,5 @@ mod symmetry_tests; mod tests; #[cfg(test)] mod tests_charts; +#[cfg(test)] +mod xrd_tests; diff --git a/crates/core/src/project/scheme.rs b/crates/core/src/project/scheme.rs index e4e34d3..a6e3cc5 100644 --- a/crates/core/src/project/scheme.rs +++ b/crates/core/src/project/scheme.rs @@ -258,6 +258,9 @@ pub fn apply_scheme( Dataset::MassSpec(_) => Err(incompatible( "the imported data has no PlotX LC–MS processing pipeline", )), + Dataset::Xrd(_) => Err(incompatible( + "XRD processing recipes use XRD-specific parameters", + )), } } @@ -293,6 +296,9 @@ pub fn reset_processing(dataset: &Dataset) -> Option { Dataset::Electrophysiology(_) => None, Dataset::Afm(_) => None, Dataset::MassSpec(_) => None, + Dataset::Xrd(_) => Some(DatasetProcessingState::Xrd( + plotx_processing::xrd::XrdProcessing::default(), + )), }?; let mut next = dataset_next_step_id(dataset); match &mut state { @@ -339,6 +345,7 @@ fn scheme_from_dataset(dataset: &Dataset) -> Option { Dataset::Electrophysiology(_) => None, Dataset::Afm(_) => None, Dataset::MassSpec(_) => None, + Dataset::Xrd(_) => None, } } diff --git a/crates/core/src/project/xrd_convert.rs b/crates/core/src/project/xrd_convert.rs new file mode 100644 index 0000000..dd5e6a2 --- /dev/null +++ b/crates/core/src/project/xrd_convert.rs @@ -0,0 +1,220 @@ +use super::{EntryReader, ProjectError, ProjectLoadLimits, Result}; +use plotx_io::XrdData; +use std::io::{Read, Write}; + +const MAGIC: &[u8; 8] = b"PXXRD1\0\0"; +const VALUES_PER_CHUNK: usize = 4096; + +pub(super) fn write(output: &mut impl Write, data: &XrdData) -> Result<()> { + data.validate() + .map_err(|error| ProjectError::Invalid(error.to_owned()))?; + let limits = ProjectLoadLimits::default(); + validate_array_len(data.len(), limits)?; + validate_materialized_size(data.len(), data.attenuation.is_some(), limits)?; + + let mut metadata = data.clone(); + metadata.two_theta_deg.clear(); + metadata.intensity.clear(); + if let Some(attenuation) = &mut metadata.attenuation { + attenuation.clear(); + } + let json = serde_json::to_vec(&metadata)?; + if json.len() as u64 > limits.max_metadata_bytes { + return Err(ProjectError::Invalid( + "XRD metadata exceeds the configured limit".to_owned(), + )); + } + + let array_count = 2 + usize::from(data.attenuation.is_some()); + let arrays_bytes = data + .len() + .checked_mul(std::mem::size_of::()) + .and_then(|bytes| bytes.checked_mul(array_count)) + .ok_or_else(|| ProjectError::Invalid("XRD payload size overflows usize".to_owned()))?; + let encoded_bytes = MAGIC + .len() + .checked_add(8) + .and_then(|bytes| bytes.checked_add(json.len())) + .and_then(|bytes| bytes.checked_add(array_count * 8)) + .and_then(|bytes| bytes.checked_add(arrays_bytes)) + .ok_or_else(|| ProjectError::Invalid("XRD payload size overflows usize".to_owned()))?; + if encoded_bytes as u64 > limits.max_entry_bytes { + return Err(ProjectError::Invalid( + "XRD payload exceeds the configured project-entry limit".to_owned(), + )); + } + + output.write_all(MAGIC)?; + write_len(output, json.len())?; + output.write_all(&json)?; + write_f64s(output, &data.two_theta_deg)?; + write_f64s(output, &data.intensity)?; + if let Some(attenuation) = &data.attenuation { + write_f64s(output, attenuation)?; + } + Ok(()) +} + +pub(super) fn decode(input: &mut EntryReader<'_, R>) -> Result { + let mut reader = Reader::new(input); + if reader.read_array::<8>()? != *MAGIC { + return Err(ProjectError::Invalid( + "XRD payload has an invalid signature".to_owned(), + )); + } + + let metadata_len = reader.read_len("metadata length")?; + if metadata_len > ProjectLoadLimits::default().max_metadata_bytes as usize { + return Err(reader + .input + .invalid("XRD metadata exceeds the configured limit")); + } + let metadata = reader.read_bytes(metadata_len, "XRD metadata")?; + let mut data: XrdData = serde_json::from_slice(&metadata)?; + if !data.two_theta_deg.is_empty() + || !data.intensity.is_empty() + || data + .attenuation + .as_ref() + .is_some_and(|values| !values.is_empty()) + { + return Err(reader + .input + .invalid("XRD metadata contains inline scientific arrays")); + } + + let points = reader.read_len("2theta length")?; + validate_array_len(points, ProjectLoadLimits::default())?; + validate_materialized_size( + points, + data.attenuation.is_some(), + ProjectLoadLimits::default(), + )?; + data.two_theta_deg = reader.read_f64_values(points, "2theta")?; + data.intensity = reader.read_f64s(Some(points), "intensity")?; + if data.attenuation.is_some() { + data.attenuation = Some(reader.read_f64s(Some(points), "attenuation")?); + } + Ok(data) +} + +fn validate_array_len(len: usize, limits: ProjectLoadLimits) -> Result<()> { + if len > limits.max_collection_items { + return Err(ProjectError::Invalid(format!( + "XRD array contains {len} values, exceeding the {}-item limit", + limits.max_collection_items + ))); + } + Ok(()) +} + +fn validate_materialized_size( + len: usize, + has_attenuation: bool, + limits: ProjectLoadLimits, +) -> Result<()> { + let array_count = 2 + usize::from(has_attenuation); + let bytes = len + .checked_mul(std::mem::size_of::()) + .and_then(|bytes| bytes.checked_mul(array_count)) + .ok_or_else(|| ProjectError::Invalid("XRD materialized size overflows usize".to_owned()))?; + if bytes as u64 > limits.max_materialized_bytes { + return Err(ProjectError::Invalid(format!( + "XRD arrays require {bytes} bytes, exceeding the {}-byte materialized-data limit", + limits.max_materialized_bytes + ))); + } + Ok(()) +} + +fn write_len(output: &mut impl Write, len: usize) -> Result<()> { + let len = u64::try_from(len) + .map_err(|_| ProjectError::Invalid("XRD length exceeds u64".to_owned()))?; + output.write_all(&len.to_le_bytes())?; + Ok(()) +} + +fn write_f64s(output: &mut impl Write, values: &[f64]) -> Result<()> { + write_len(output, values.len())?; + let mut buffer = Vec::with_capacity(VALUES_PER_CHUNK * std::mem::size_of::()); + for chunk in values.chunks(VALUES_PER_CHUNK) { + buffer.clear(); + for value in chunk { + buffer.extend_from_slice(&value.to_le_bytes()); + } + output.write_all(&buffer)?; + } + Ok(()) +} + +struct Reader<'a, 'p, R: Read> { + input: &'a mut EntryReader<'p, R>, +} + +impl<'a, 'p, R: Read> Reader<'a, 'p, R> { + fn new(input: &'a mut EntryReader<'p, R>) -> Self { + Self { input } + } + + fn read_array(&mut self) -> Result<[u8; N]> { + self.input.require_bytes(N, "XRD field")?; + let mut bytes = [0_u8; N]; + self.input.read_exact(&mut bytes).map_err(|error| { + self.input + .invalid(format!("XRD payload is truncated: {error}")) + })?; + Ok(bytes) + } + + fn read_len(&mut self, label: &str) -> Result { + usize::try_from(u64::from_le_bytes(self.read_array()?)) + .map_err(|_| self.input.invalid(format!("XRD {label} exceeds usize"))) + } + + fn read_bytes(&mut self, len: usize, label: &str) -> Result> { + self.input.require_bytes(len, label)?; + let mut bytes = Vec::new(); + bytes + .try_reserve_exact(len) + .map_err(|_| self.input.invalid(format!("could not reserve {label}")))?; + bytes.resize(len, 0); + self.input.read_exact(&mut bytes).map_err(|error| { + self.input + .invalid(format!("XRD payload is truncated: {error}")) + })?; + Ok(bytes) + } + + fn read_f64s(&mut self, expected: Option, label: &str) -> Result> { + let len = self.read_len(label)?; + validate_array_len(len, ProjectLoadLimits::default())?; + if let Some(expected) = expected + && expected != len + { + return Err(self.input.invalid(format!( + "XRD {label} length {len} does not match expected length {expected}" + ))); + } + self.read_f64_values(len, label) + } + + fn read_f64_values(&mut self, len: usize, label: &str) -> Result> { + let bytes = len.checked_mul(std::mem::size_of::()).ok_or_else(|| { + self.input + .invalid(format!("XRD {label} size overflows usize")) + })?; + self.input.require_bytes(bytes, label)?; + let mut values = Vec::new(); + values + .try_reserve_exact(len) + .map_err(|_| self.input.invalid(format!("could not reserve XRD {label}")))?; + for _ in 0..len { + values.push(f64::from_le_bytes(self.read_array()?)); + } + Ok(values) + } +} + +#[cfg(test)] +#[path = "xrd_convert_tests.rs"] +mod tests; diff --git a/crates/core/src/project/xrd_convert_tests.rs b/crates/core/src/project/xrd_convert_tests.rs new file mode 100644 index 0000000..25495d9 --- /dev/null +++ b/crates/core/src/project/xrd_convert_tests.rs @@ -0,0 +1,91 @@ +use super::*; + +fn fixture() -> XrdData { + XrdData { + two_theta_deg: vec![3.0, 3.1, 3.2], + intensity: vec![10.0, 25.0, 12.0], + attenuation: Some(vec![1.0, 1.0, 2.0]), + source: "sample.rasx".to_owned(), + instrument: Some("MiniFlex".to_owned()), + target: Some("Cu".to_owned()), + wavelength_angstrom: Some(1.540593), + voltage_kv: Some(40.0), + current_ma: Some(15.0), + scan_step_deg: Some(0.1), + scan_speed_deg_min: Some(5.0), + } +} + +fn encode(data: &XrdData) -> Vec { + let mut bytes = Vec::new(); + write(&mut bytes, data).unwrap(); + bytes +} + +fn decode_bytes(bytes: &[u8]) -> Result { + let mut reader = EntryReader::new( + std::io::Cursor::new(bytes), + "test.bin", + "XRD", + bytes.len() as u64, + bytes.len() as u64, + )?; + let data = decode(&mut reader)?; + reader.finish()?; + Ok(data) +} + +#[test] +fn binary_round_trip_preserves_arrays_and_metadata() { + let original = fixture(); + let decoded = decode_bytes(&encode(&original)).unwrap(); + + assert_eq!(decoded.two_theta_deg, original.two_theta_deg); + assert_eq!(decoded.intensity, original.intensity); + assert_eq!(decoded.attenuation, original.attenuation); + assert_eq!(decoded.instrument, original.instrument); + assert_eq!(decoded.wavelength_angstrom, original.wavelength_angstrom); +} + +#[test] +fn binary_decoder_rejects_truncation_and_trailing_bytes() { + let encoded = encode(&fixture()); + assert!(decode_bytes(&encoded[..encoded.len() - 1]).is_err()); + + let mut trailing = encoded; + trailing.push(0); + assert!(decode_bytes(&trailing).is_err()); +} + +#[test] +fn scientific_arrays_are_not_stored_in_metadata_json() { + let encoded = encode(&fixture()); + let metadata_len = u64::from_le_bytes(encoded[8..16].try_into().unwrap()) as usize; + let metadata: XrdData = serde_json::from_slice(&encoded[16..16 + metadata_len]).unwrap(); + + assert!(metadata.two_theta_deg.is_empty()); + assert!(metadata.intensity.is_empty()); + assert_eq!(metadata.attenuation, Some(Vec::new())); +} + +#[test] +fn rejects_implausible_array_length_before_allocation() { + let mut encoded = encode(&fixture()); + let metadata_len = u64::from_le_bytes(encoded[8..16].try_into().unwrap()) as usize; + let first_array_len = 16 + metadata_len; + encoded[first_array_len..first_array_len + 8].copy_from_slice(&u64::MAX.to_le_bytes()); + + assert!(decode_bytes(&encoded).is_err()); +} + +#[test] +fn rejects_aggregate_arrays_over_materialized_limit_before_allocation() { + let mut encoded = encode(&fixture()); + let metadata_len = u64::from_le_bytes(encoded[8..16].try_into().unwrap()) as usize; + let first_array_len = 16 + metadata_len; + let points = ProjectLoadLimits::default().max_materialized_bytes / (3 * 8) + 1; + encoded[first_array_len..first_array_len + 8].copy_from_slice(&points.to_le_bytes()); + + let error = decode_bytes(&encoded).unwrap_err(); + assert!(error.to_string().contains("materialized-data limit")); +} diff --git a/crates/core/src/project/xrd_tests.rs b/crates/core/src/project/xrd_tests.rs new file mode 100644 index 0000000..bce93fc --- /dev/null +++ b/crates/core/src/project/xrd_tests.rs @@ -0,0 +1,51 @@ +use super::{load_project, save_project}; +use crate::state::{Dataset, PlotxApp, XrdDataset}; +use plotx_processing::xrd::{SnipBackground, XrdNormalization, XrdProcessing}; + +#[test] +fn data_and_processing_round_trip() { + let mut app = PlotxApp::new(); + let data = plotx_io::XrdData { + two_theta_deg: vec![3.0, 3.1, 3.2], + intensity: vec![10.0, 25.0, 12.0], + attenuation: Some(vec![1.0, 1.0, 2.0]), + source: "sample.rasx".to_owned(), + instrument: Some("MiniFlex".to_owned()), + target: Some("Cu".to_owned()), + wavelength_angstrom: Some(1.540593), + voltage_kv: Some(40.0), + current_ma: Some(15.0), + scan_step_deg: Some(0.1), + scan_speed_deg_min: Some(5.0), + }; + let mut dataset = XrdDataset::load(data); + dataset.params = XrdProcessing { + background: Some(SnipBackground { iterations: 1 }), + smoothing: None, + normalization: XrdNormalization::Maximum, + }; + dataset.rebuild().unwrap(); + app.doc.datasets.push(Dataset::Xrd(Box::new(dataset))); + + let path = std::env::temp_dir().join(format!("plotx-xrd-{}.plotx", uuid::Uuid::new_v4())); + save_project(&app, &path, false).unwrap(); + let loaded = load_project(&path).unwrap(); + std::fs::remove_file(path).unwrap(); + let Dataset::Xrd(restored) = &loaded.doc.datasets[0] else { + panic!("expected XRD") + }; + assert_eq!(restored.data.instrument.as_deref(), Some("MiniFlex")); + assert_eq!( + restored.data.attenuation.as_deref(), + Some(&[1.0, 1.0, 2.0][..]) + ); + assert_eq!( + restored.params, + XrdProcessing { + background: Some(SnipBackground { iterations: 1 }), + smoothing: None, + normalization: XrdNormalization::Maximum, + } + ); + assert_eq!(restored.processed.intensity.len(), 3); +} diff --git a/crates/core/src/properties/group_delay.rs b/crates/core/src/properties/group_delay.rs index 5237d09..b119398 100644 --- a/crates/core/src/properties/group_delay.rs +++ b/crates/core/src/properties/group_delay.rs @@ -106,7 +106,8 @@ fn dataset_context<'a>( Dataset::Table(_) | Dataset::Electrophysiology(_) | Dataset::Afm(_) - | Dataset::MassSpec(_) => Err(PropertyError::NotApplicable( + | Dataset::MassSpec(_) + | Dataset::Xrd(_) => Err(PropertyError::NotApplicable( "Group-delay correction applies only to NMR datasets.".to_owned(), )), } diff --git a/crates/core/src/properties/processing_common.rs b/crates/core/src/properties/processing_common.rs index 2f61992..0b24276 100644 --- a/crates/core/src/properties/processing_common.rs +++ b/crates/core/src/properties/processing_common.rs @@ -163,7 +163,8 @@ pub(super) fn raw_point_count(dataset: &Dataset, axis: PhaseAxis) -> usize { Dataset::Table(_) | Dataset::Electrophysiology(_) | Dataset::Afm(_) - | Dataset::MassSpec(_) => 0, + | Dataset::MassSpec(_) + | Dataset::Xrd(_) => 0, } } diff --git a/crates/core/src/properties/service.rs b/crates/core/src/properties/service.rs index 9ea6ab1..756133e 100644 --- a/crates/core/src/properties/service.rs +++ b/crates/core/src/properties/service.rs @@ -466,8 +466,10 @@ impl PlotxApp { } else { gesture.last = Some(action.clone()); } - self.apply_action(&action); - self.mark_document_dirty(); + match self.apply_action(&action) { + Ok(()) => self.mark_document_dirty(), + Err(error) => self.session.status = error.to_string(), + } } fn plan_edit( diff --git a/crates/core/src/properties/step_enabled.rs b/crates/core/src/properties/step_enabled.rs index 38d29bb..152e374 100644 --- a/crates/core/src/properties/step_enabled.rs +++ b/crates/core/src/properties/step_enabled.rs @@ -83,7 +83,8 @@ impl PropertyProvider for StepEnabledProvider { crate::state::Dataset::Table(_) | crate::state::Dataset::Electrophysiology(_) | crate::state::Dataset::Afm(_) - | crate::state::Dataset::MassSpec(_) => { + | crate::state::Dataset::MassSpec(_) + | crate::state::Dataset::Xrd(_) => { return Err(PropertyError::NotApplicable( "this dataset has no spectral processing pipeline".to_owned(), )); diff --git a/crates/core/src/state/app_impl_analysis_tables.rs b/crates/core/src/state/app_impl_analysis_tables.rs index 30647d8..3ca1d86 100644 --- a/crates/core/src/state/app_impl_analysis_tables.rs +++ b/crates/core/src/state/app_impl_analysis_tables.rs @@ -225,7 +225,11 @@ impl PlotxApp { units, ) } - Dataset::Nmr(_) | Dataset::Table(_) | Dataset::Afm(_) | Dataset::MassSpec(_) => { + Dataset::Nmr(_) + | Dataset::Table(_) + | Dataset::Afm(_) + | Dataset::MassSpec(_) + | Dataset::Xrd(_) => { return Err("The selected field does not contain an ordered series.".to_owned()); } }; diff --git a/crates/core/src/state/app_impl_interaction.rs b/crates/core/src/state/app_impl_interaction.rs index 7c938f0..f3f123c 100644 --- a/crates/core/src/state/app_impl_interaction.rs +++ b/crates/core/src/state/app_impl_interaction.rs @@ -33,7 +33,11 @@ impl PlotxApp { pub fn cancel_interaction(&mut self) { match self.take_interaction() { Interaction::Phase(drag) => { - self.set_dataset_processing_state(drag.dataset, &drag.gesture_before); + if let Err(error) = + self.set_dataset_processing_state(drag.dataset, &drag.gesture_before) + { + self.session.status = error; + } } Interaction::Region(drag) => { if let Some(state) = self diff --git a/crates/core/src/state/charts.rs b/crates/core/src/state/charts.rs index f12a03f..3ed708d 100644 --- a/crates/core/src/state/charts.rs +++ b/crates/core/src/state/charts.rs @@ -13,6 +13,7 @@ pub enum DataDomain { Electrophysiology, Afm, MassSpectrometry, + Xrd, } /// How a domain's datasets combine when several are stacked onto one plot: @@ -34,6 +35,7 @@ impl DataDomain { | DataDomain::Table | DataDomain::Electrophysiology | DataDomain::MassSpectrometry => Some(StackKind::Line), + DataDomain::Xrd => Some(StackKind::Line), DataDomain::Nmr2d => Some(StackKind::Field), DataDomain::PseudoNmr | DataDomain::Afm => None, } @@ -80,6 +82,17 @@ impl ChartDescriptor { /// The catalog. The first entry for a domain is that domain's default chart, so /// old `.plotx` files (no recorded chart type) map to it. static CHART_TYPES: &[ChartDescriptor] = &[ + ChartDescriptor { + id: "xrd_pattern", + name: "XRD pattern", + recommended_domains: &[DataDomain::Xrd], + required_capabilities: &[ + crate::automation::CAP_FIELD_CURVE_1D, + crate::automation::CAP_FIELD_XRD_PATTERN, + ], + needs_column: false, + build: build_xrd_pattern, + }, ChartDescriptor { id: "mass_chromatogram", name: "Mass chromatogram", @@ -392,6 +405,10 @@ fn build_nmr_spectrum(dataset: &Dataset, _ctx: &ChartContext) -> Option
)) } +fn build_xrd_pattern(dataset: &Dataset, _ctx: &ChartContext) -> Option
{ + Some(dataset.as_xrd()?.figure()) +} + fn build_mass_chromatogram(dataset: &Dataset, _ctx: &ChartContext) -> Option
{ let dataset = dataset.as_mass_spec()?; let id = dataset diff --git a/crates/core/src/state/dataset_identity.rs b/crates/core/src/state/dataset_identity.rs index e044c33..7a7c9b6 100644 --- a/crates/core/src/state/dataset_identity.rs +++ b/crates/core/src/state/dataset_identity.rs @@ -9,6 +9,7 @@ impl Dataset { Dataset::Electrophysiology(dataset) => dataset.resource_id, Dataset::Afm(dataset) => dataset.resource_id, Dataset::MassSpec(dataset) => dataset.resource_id, + Dataset::Xrd(dataset) => dataset.resource_id, } } @@ -20,6 +21,7 @@ impl Dataset { Dataset::Electrophysiology(dataset) => dataset.resource_id = id, Dataset::Afm(dataset) => dataset.resource_id = id, Dataset::MassSpec(dataset) => dataset.resource_id = id, + Dataset::Xrd(dataset) => dataset.resource_id = id, } } } diff --git a/crates/core/src/state/dataset_trace.rs b/crates/core/src/state/dataset_trace.rs index 03d89c8..b215a56 100644 --- a/crates/core/src/state/dataset_trace.rs +++ b/crates/core/src/state/dataset_trace.rs @@ -25,6 +25,7 @@ impl Dataset { Self::Electrophysiology(_) => "s".into(), Self::Afm(_) => String::new(), Self::MassSpec(_) => "min".into(), + Self::Xrd(_) => "deg".into(), } } @@ -44,6 +45,7 @@ impl Dataset { Self::Electrophysiology(data) => !data.data.sweeps.is_empty(), Self::Afm(_) => false, Self::MassSpec(_) => true, + Self::Xrd(_) => true, } } @@ -87,6 +89,11 @@ impl Dataset { x_reversed: false, }) } + Self::Xrd(data) => Some(Trace1d { + xs: data.data.two_theta_deg.clone(), + ys: data.processed.intensity.clone(), + x_reversed: false, + }), } } } diff --git a/crates/core/src/state/datasets.rs b/crates/core/src/state/datasets.rs index a428ffe..52cb686 100644 --- a/crates/core/src/state/datasets.rs +++ b/crates/core/src/state/datasets.rs @@ -448,6 +448,7 @@ pub enum Dataset { Electrophysiology(Box), Afm(Box), MassSpec(Box), + Xrd(Box), } fn set_pipeline_pivot_frac(pipe: &mut AxisPipeline, frac: f64) { diff --git a/crates/core/src/state/datasets_dispatch.rs b/crates/core/src/state/datasets_dispatch.rs index 7ff8b6a..97455af 100644 --- a/crates/core/src/state/datasets_dispatch.rs +++ b/crates/core/src/state/datasets_dispatch.rs @@ -28,6 +28,20 @@ impl Dataset { } } + pub fn as_xrd(&self) -> Option<&XrdDataset> { + match self { + Dataset::Xrd(data) => Some(data), + _ => None, + } + } + + pub fn as_xrd_mut(&mut self) -> Option<&mut XrdDataset> { + match self { + Dataset::Xrd(data) => Some(data), + _ => None, + } + } + pub fn kind_label(&self) -> &'static str { match self { Dataset::Nmr(_) => "NMR 1D", @@ -36,6 +50,7 @@ impl Dataset { Dataset::Electrophysiology(_) => "Electrophysiology", Dataset::Afm(_) => "AFM", Dataset::MassSpec(_) => "LC–MS", + Dataset::Xrd(_) => "XRD", } } @@ -51,6 +66,7 @@ impl Dataset { Dataset::Electrophysiology(_) => DataDomain::Electrophysiology, Dataset::Afm(_) => DataDomain::Afm, Dataset::MassSpec(_) => DataDomain::MassSpectrometry, + Dataset::Xrd(_) => DataDomain::Xrd, } } @@ -64,6 +80,7 @@ impl Dataset { Dataset::Electrophysiology(d) => d.name.clone(), Dataset::Afm(d) => d.name.clone(), Dataset::MassSpec(d) => d.name.clone(), + Dataset::Xrd(d) => d.name.clone(), }; custom.unwrap_or_else(|| format!("[{}] {}", self.kind_label(), self.summary())) } @@ -76,6 +93,7 @@ impl Dataset { Dataset::Electrophysiology(d) => d.name = name, Dataset::Afm(d) => d.name = name, Dataset::MassSpec(d) => d.name = name, + Dataset::Xrd(d) => d.name = name, } } @@ -87,6 +105,7 @@ impl Dataset { Dataset::Electrophysiology(d) => d.name.clone(), Dataset::Afm(d) => d.name.clone(), Dataset::MassSpec(d) => d.name.clone(), + Dataset::Xrd(d) => d.name.clone(), } } @@ -124,6 +143,12 @@ impl Dataset { .sum::(), d.run.chromatograms.len() ), + Dataset::Xrd(d) => format!( + "{} points · {:.2}–{:.2} deg 2theta", + d.data.len(), + d.data.two_theta_deg.first().copied().unwrap_or(0.0), + d.data.two_theta_deg.last().copied().unwrap_or(0.0) + ), } } @@ -178,6 +203,7 @@ impl Dataset { Dataset::Electrophysiology(_) => None, Dataset::Afm(_) => None, Dataset::MassSpec(_) => None, + Dataset::Xrd(_) => None, } } @@ -189,6 +215,7 @@ impl Dataset { Dataset::Electrophysiology(_) => None, Dataset::Afm(_) => None, Dataset::MassSpec(_) => None, + Dataset::Xrd(_) => None, } } @@ -201,6 +228,7 @@ impl Dataset { Dataset::Electrophysiology(_) => &[], Dataset::Afm(_) => &[], Dataset::MassSpec(_) => &[], + Dataset::Xrd(_) => &[], } } @@ -212,6 +240,7 @@ impl Dataset { Dataset::Electrophysiology(_) => None, Dataset::Afm(_) => None, Dataset::MassSpec(_) => None, + Dataset::Xrd(_) => None, } } @@ -223,6 +252,7 @@ impl Dataset { Dataset::Electrophysiology(_) => None, Dataset::Afm(_) => None, Dataset::MassSpec(_) => None, + Dataset::Xrd(_) => None, } } @@ -266,7 +296,11 @@ impl Dataset { Dataset::Electrophysiology(dataset) => { !dataset.data.channels.is_empty() && !dataset.data.sweeps.is_empty() } - Dataset::Nmr(_) | Dataset::Table(_) | Dataset::Afm(_) | Dataset::MassSpec(_) => false, + Dataset::Nmr(_) + | Dataset::Table(_) + | Dataset::Afm(_) + | Dataset::MassSpec(_) + | Dataset::Xrd(_) => false, } } @@ -337,6 +371,7 @@ impl Dataset { Dataset::Electrophysiology(_) => &[ToolGroup::Electrophysiology], Dataset::Afm(_) => &[], Dataset::MassSpec(_) => &[ToolGroup::MassSpectrometry], + Dataset::Xrd(_) => &[ToolGroup::Processing], } } @@ -352,6 +387,7 @@ impl Dataset { Dataset::Electrophysiology(_) => &[], Dataset::Afm(_) => &[], Dataset::MassSpec(_) => &[], + Dataset::Xrd(_) => &[], } } diff --git a/crates/core/src/state/field.rs b/crates/core/src/state/field.rs index 2739646..7a6e3c0 100644 --- a/crates/core/src/state/field.rs +++ b/crates/core/src/state/field.rs @@ -332,6 +332,7 @@ impl super::Dataset { } fields } + Self::Xrd(dataset) => dataset.field_descriptors(), } } @@ -417,6 +418,7 @@ impl super::Dataset { | SeriesEncoding::Heatmap(_) | SeriesEncoding::Image(_) => None, }, + Self::Xrd(dataset) => dataset.encoded_field_figure(encoding), } } @@ -432,7 +434,7 @@ impl super::Dataset { match self { Self::Nmr2D(nmr) => nmr.contour_figure_from_geometry(id, geometry, style), Self::Afm(afm) => afm.contour_figure_from_geometry(id, geometry, style), - Self::Nmr(_) | Self::Table(_) | Self::Electrophysiology(_) | Self::MassSpec(_) => None, + _ => None, } } @@ -452,15 +454,15 @@ impl super::Dataset { Self::Electrophysiology(dataset) => &dataset.field_catalog, Self::Afm(dataset) => &dataset.field_catalog, Self::MassSpec(dataset) => &dataset.field_catalog, + Self::Xrd(dataset) => &dataset.field_catalog, } } fn all_field_keys(&self) -> Vec { match self { Self::Nmr(_) => vec!["nmr.real".to_owned()], - // These keys stay allocated while the processing state is pseudo-2D. - // A binding to an inactive field is rejected on save/load instead of - // being reassigned to the stack field. + // Keep inactive 2D keys allocated so save/load rejects stale bindings + // instead of silently reassigning them to the stack field. Self::Nmr2D(_) => vec![ "nmr.real".to_owned(), "nmr.magnitude".to_owned(), @@ -483,6 +485,7 @@ impl super::Dataset { ) .collect(), Self::MassSpec(dataset) => mass_spec_dataset_field_keys(dataset), + Self::Xrd(_) => vec!["xrd.intensity".to_owned()], } } } diff --git a/crates/core/src/state/field_payload.rs b/crates/core/src/state/field_payload.rs index 862b03c..6c7a702 100644 --- a/crates/core/src/state/field_payload.rs +++ b/crates/core/src/state/field_payload.rs @@ -144,6 +144,19 @@ impl super::Dataset { ), }) }), + Self::Xrd(dataset) => (dataset.field_id() == Some(id)).then(|| { + FieldPayload::Curve1D(Curve1D { + x: Arc::from(dataset.data.two_theta_deg.clone()), + values: Arc::from( + dataset + .processed + .intensity + .iter() + .map(|value| *value as f32) + .collect::>(), + ), + }) + }), } } @@ -196,6 +209,9 @@ impl super::Dataset { .then_some(FieldRepresentation::Curve1D) } Self::MassSpec(dataset) => dataset.field_representation(id), + Self::Xrd(dataset) => { + (dataset.field_id() == Some(id)).then_some(FieldRepresentation::Curve1D) + } } } @@ -246,6 +262,13 @@ impl super::Dataset { Self::Electrophysiology(dataset) => (dataset.data.source.as_str(), None), Self::Afm(dataset) => (dataset.data.source.as_str(), None), Self::MassSpec(dataset) => (dataset.run.source.as_str(), None), + Self::Xrd(dataset) => ( + dataset.data.source.as_str(), + Some(FieldAlgorithmProvenance { + algorithm: "xrd.process".to_owned(), + version: 1, + }), + ), }; FieldCatalog::make_provenance(source, id, algorithm) }) diff --git a/crates/core/src/state/lineage.rs b/crates/core/src/state/lineage.rs index f351935..41b49d7 100644 --- a/crates/core/src/state/lineage.rs +++ b/crates/core/src/state/lineage.rs @@ -73,6 +73,7 @@ impl Dataset { Dataset::Electrophysiology(data) => data.lineage.as_ref(), Dataset::Afm(data) => data.lineage.as_ref(), Dataset::MassSpec(data) => data.lineage.as_ref(), + Dataset::Xrd(data) => data.lineage.as_ref(), } } @@ -84,6 +85,7 @@ impl Dataset { Dataset::Electrophysiology(data) => data.lineage = lineage, Dataset::Afm(data) => data.lineage = lineage, Dataset::MassSpec(data) => data.lineage = lineage, + Dataset::Xrd(data) => data.lineage = lineage, } } } diff --git a/crates/core/src/state/mod.rs b/crates/core/src/state/mod.rs index 749c531..5372238 100644 --- a/crates/core/src/state/mod.rs +++ b/crates/core/src/state/mod.rs @@ -105,6 +105,7 @@ mod ui_drag; mod ui_state; mod units; mod workflow_tab; +mod xrd; pub use afm::*; pub use app_impl::*; @@ -174,6 +175,7 @@ pub use ui_drag::*; pub use ui_state::*; pub use units::*; pub use workflow_tab::WorkflowTab; +pub use xrd::*; /// Points per millimetre (72 pt/inch ÷ 25.4 mm/inch), for sizing print figures. pub const MM_TO_PT: f32 = 72.0 / 25.4; diff --git a/crates/core/src/state/nmr_integrals_2d.rs b/crates/core/src/state/nmr_integrals_2d.rs index 144b9ce..9bf567d 100644 --- a/crates/core/src/state/nmr_integrals_2d.rs +++ b/crates/core/src/state/nmr_integrals_2d.rs @@ -219,7 +219,7 @@ mod tests { phase.auto = None; phase.phase0 = PI; - app.set_dataset_processing_state(0, &state); + app.set_dataset_processing_state(0, &state).unwrap(); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); while app.compute_busy() && std::time::Instant::now() < deadline { app.poll_compute(); diff --git a/crates/core/src/state/xrd.rs b/crates/core/src/state/xrd.rs new file mode 100644 index 0000000..ae3a927 --- /dev/null +++ b/crates/core/src/state/xrd.rs @@ -0,0 +1,109 @@ +use super::{ + DatasetId, DatasetLineage, FieldCapabilities, FieldCatalog, FieldDescriptor, FieldId, + FieldMetadata, +}; +use crate::automation::{CAP_FIELD_CURVE_1D, CAP_FIELD_XRD_PATTERN, CapabilityId}; +use plotx_figure::{Axis, Figure, Series, SeriesEncoding}; +use plotx_io::XrdData; +use plotx_processing::xrd::{ProcessedXrd, XrdProcessing, XrdProcessingError, process}; +use std::collections::BTreeMap; + +#[derive(Clone)] +pub struct XrdDataset { + pub resource_id: DatasetId, + pub field_catalog: FieldCatalog, + pub data: XrdData, + pub params: XrdProcessing, + pub processed: ProcessedXrd, + pub name: Option, + pub lineage: Option, +} + +impl XrdDataset { + pub fn load(data: XrdData) -> Self { + let params = XrdProcessing::default(); + let processed = process(&data.two_theta_deg, &data.intensity, params) + .expect("validated XRD acquisition has matching axes"); + let mut field_catalog = FieldCatalog::for_keys(["xrd.intensity".to_owned()]); + field_catalog.attach_provenance(&data.source, None); + Self { + resource_id: DatasetId::new(), + field_catalog, + data, + params, + processed, + name: None, + lineage: None, + } + } + + pub fn rebuild(&mut self) -> Result<(), XrdProcessingError> { + self.apply_processing(self.params)?; + Ok(()) + } + + pub fn apply_processing(&mut self, params: XrdProcessing) -> Result<(), XrdProcessingError> { + let processed = process(&self.data.two_theta_deg, &self.data.intensity, params)?; + self.params = params; + self.processed = processed; + Ok(()) + } + + pub fn field_id(&self) -> Option { + self.field_catalog.id_for_key("xrd.intensity") + } + + pub fn figure(&self) -> Figure { + let x_bounds = bounds(&self.data.two_theta_deg); + let y_bounds = bounds(&self.processed.intensity); + Figure::new( + "", + Axis::new("2theta (deg)", x_bounds.0, x_bounds.1), + Axis::new("Intensity (a.u.)", y_bounds.0.min(0.0), y_bounds.1), + ) + .with_series(Series::line( + "Observed", + self.data + .two_theta_deg + .iter() + .zip(&self.processed.intensity) + .map(|(&x, &y)| [x, y]) + .collect(), + )) + } + + pub(crate) fn field_descriptors(&self) -> Vec { + self.field_id() + .into_iter() + .map(|id| FieldDescriptor { + id, + local_id: "xrd.intensity".to_owned(), + name: "Intensity".to_owned(), + capabilities: FieldCapabilities::new([ + CapabilityId::new(CAP_FIELD_CURVE_1D), + CapabilityId::new(CAP_FIELD_XRD_PATTERN), + ]), + dimensions: vec![self.data.len()], + units: vec!["deg".to_owned(), "a.u.".to_owned()], + metadata: FieldMetadata(BTreeMap::from([( + "recommended_encoding".to_owned(), + "line".to_owned(), + )])), + }) + .collect() + } + + pub(crate) fn encoded_field_figure(&self, encoding: &SeriesEncoding) -> Option
{ + matches!(encoding, SeriesEncoding::Line(_)).then(|| self.figure()) + } +} + +fn bounds(values: &[f64]) -> (f64, f64) { + let low = values.iter().copied().fold(f64::INFINITY, f64::min); + let high = values.iter().copied().fold(f64::NEG_INFINITY, f64::max); + if low.is_finite() && high.is_finite() && low < high { + (low, high) + } else { + (0.0, 1.0) + } +} diff --git a/crates/core/src/workflow.rs b/crates/core/src/workflow.rs index f56fad9..e3ba23e 100644 --- a/crates/core/src/workflow.rs +++ b/crates/core/src/workflow.rs @@ -30,6 +30,17 @@ pub struct InspectionReport { pub afm: Option, #[serde(skip_serializing_if = "Option::is_none")] pub mass_spectrometry: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub xrd: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct XrdReport { + pub instrument: Option, + pub target: Option, + pub wavelength_angstrom: Option, + pub two_theta_range_deg: [f64; 2], + pub point_count: usize, } #[derive(Clone, Debug, Serialize)] @@ -245,6 +256,13 @@ pub fn dataset_from_acquisition_with_equal_scale_preference( source, ) } + Acquisition::Xrd(data) => { + let source = data.source.clone(); + ( + Dataset::Xrd(Box::new(crate::state::XrdDataset::load(*data))), + source, + ) + } } } @@ -271,6 +289,10 @@ pub fn dataset_title(dataset: &Dataset) -> String { .name .clone() .unwrap_or_else(|| short_name(&data.run.source)), + Dataset::Xrd(data) => data + .name + .clone() + .unwrap_or_else(|| short_name(&data.data.source)), } } @@ -527,6 +549,7 @@ fn inspection_report( }), afm: None, mass_spectrometry: None, + xrd: None, }; } Acquisition::Afm(data) => { @@ -564,6 +587,7 @@ fn inspection_report( samples_per_curve: force.map(|force| force.samples_per_curve), }), mass_spectrometry: None, + xrd: None, }; } Acquisition::MassSpec(run) => { @@ -600,6 +624,38 @@ fn inspection_report( .map(|channel| channel.description.clone()) .collect(), }), + xrd: 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(), + }), }; } }; @@ -618,6 +674,7 @@ fn inspection_report( electrophysiology: None, afm: None, mass_spectrometry: None, + xrd: None, } } diff --git a/crates/io/src/bruker.rs b/crates/io/src/bruker.rs index 51613b9..f9c2a45 100644 --- a/crates/io/src/bruker.rs +++ b/crates/io/src/bruker.rs @@ -623,7 +623,7 @@ mod tests { Acquisition::D2(_) => panic!("expected 1D"), Acquisition::Electrophysiology(_) => panic!("expected NMR"), Acquisition::Afm(_) => panic!("expected NMR"), - Acquisition::MassSpec(_) => panic!("expected NMR"), + Acquisition::MassSpec(_) | Acquisition::Xrd(_) => panic!("expected NMR"), }; let from_dir = unwrap1d(read_bruker(&dir).unwrap()); let from_file = unwrap1d(read_bruker(&dir.join("fid")).unwrap()); @@ -672,7 +672,7 @@ mod tests { Acquisition::D1(_) => panic!("expected 2D"), Acquisition::Electrophysiology(_) => panic!("expected NMR"), Acquisition::Afm(_) => panic!("expected NMR"), - Acquisition::MassSpec(_) => panic!("expected NMR"), + Acquisition::MassSpec(_) | Acquisition::Xrd(_) => panic!("expected NMR"), }; assert_eq!((two.cols, two.rows), (2, 2)); assert_eq!( diff --git a/crates/io/src/jcamp_dx/tests.rs b/crates/io/src/jcamp_dx/tests.rs index fb170d1..3cbb4be 100644 --- a/crates/io/src/jcamp_dx/tests.rs +++ b/crates/io/src/jcamp_dx/tests.rs @@ -27,7 +27,7 @@ fn data(text: &str) -> NmrData { Acquisition::D2(_) => panic!("expected 1D data"), Acquisition::Electrophysiology(_) => panic!("expected NMR"), Acquisition::Afm(_) => panic!("expected NMR"), - Acquisition::MassSpec(_) => panic!("expected NMR"), + Acquisition::MassSpec(_) | Acquisition::Xrd(_) => panic!("expected NMR"), } } diff --git a/crates/io/src/jeol/tests.rs b/crates/io/src/jeol/tests.rs index cfd04b9..1a7da09 100644 --- a/crates/io/src/jeol/tests.rs +++ b/crates/io/src/jeol/tests.rs @@ -143,7 +143,7 @@ fn round_trips_a_hand_built_1d_le_file() { Acquisition::D2(_) => panic!("expected 1D"), Acquisition::Electrophysiology(_) => panic!("expected NMR"), Acquisition::Afm(_) => panic!("expected NMR"), - Acquisition::MassSpec(_) => panic!("expected NMR"), + Acquisition::MassSpec(_) | Acquisition::Xrd(_) => panic!("expected NMR"), }; assert_eq!(data.len(), 4); // FID conjugated on read (imaginary channel negated). @@ -205,7 +205,7 @@ fn uses_real_point_count_over_padded_count_for_1d() { Acquisition::D2(_) => panic!("expected 1D"), Acquisition::Electrophysiology(_) => panic!("expected NMR"), Acquisition::Afm(_) => panic!("expected NMR"), - Acquisition::MassSpec(_) => panic!("expected NMR"), + Acquisition::MassSpec(_) | Acquisition::Xrd(_) => 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)); @@ -351,7 +351,7 @@ fn de_tiles_a_hand_built_2d_across_tile_blocks() { Acquisition::D1(_) => panic!("expected 2D"), Acquisition::Electrophysiology(_) => panic!("expected NMR"), Acquisition::Afm(_) => panic!("expected NMR"), - Acquisition::MassSpec(_) => panic!("expected NMR"), + Acquisition::MassSpec(_) | Acquisition::Xrd(_) => panic!("expected NMR"), }; assert_eq!((two.cols, two.rows), (cols_real, rows_real)); assert_eq!(two.data.len(), cols_real * rows_real); @@ -433,7 +433,7 @@ fn de_tiles_a_hand_built_hypercomplex_2d() { Acquisition::D1(_) => panic!("expected 2D"), Acquisition::Electrophysiology(_) => panic!("expected NMR"), Acquisition::Afm(_) => panic!("expected NMR"), - Acquisition::MassSpec(_) => panic!("expected NMR"), + Acquisition::MassSpec(_) | Acquisition::Xrd(_) => panic!("expected NMR"), }; assert_eq!(two.quad, QuadMode::States); assert_eq!(two.cols, cols_real); diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 3f4bc43..40e400f 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -12,6 +12,7 @@ pub mod nanoscope; pub mod origin; pub mod waters; pub mod xlsx; +pub mod xrd; pub use mass_spec::*; @@ -32,6 +33,9 @@ pub enum DataFormat { BrukerPeakForceCapture, WatersMassLynxRaw, MzMl, + RigakuRasx, + RigakuRaw, + RigakuProfile, } impl DataFormat { @@ -47,6 +51,9 @@ impl DataFormat { Self::BrukerPeakForceCapture => "bruker-peakforce-capture", Self::WatersMassLynxRaw => "waters-masslynx-raw", Self::MzMl => "mzml", + Self::RigakuRasx => "rigaku-rasx", + Self::RigakuRaw => "rigaku-raw-fi", + Self::RigakuProfile => "rigaku-profile", } } } @@ -346,6 +353,77 @@ pub enum Acquisition { Electrophysiology(Box), Afm(Box), MassSpec(Box), + Xrd(Box), +} + +/// A one-dimensional powder X-ray diffraction pattern. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct XrdData { + /// Diffraction angle 2theta in degrees, strictly increasing. + pub two_theta_deg: Vec, + /// Observed intensity in counts per second when the source declares it. + pub intensity: Vec, + /// Per-point attenuation multiplier retained from Rigaku profiles. + pub attenuation: Option>, + pub source: String, + pub instrument: Option, + pub target: Option, + pub wavelength_angstrom: Option, + pub voltage_kv: Option, + pub current_ma: Option, + pub scan_step_deg: Option, + pub scan_speed_deg_min: Option, +} + +impl XrdData { + pub fn len(&self) -> usize { + self.two_theta_deg.len() + } + + pub fn is_empty(&self) -> bool { + self.two_theta_deg.is_empty() + } + + pub fn validate(&self) -> Result<(), &'static str> { + if self.len() < 2 || self.len() != self.intensity.len() { + return Err("XRD payload has inconsistent axes"); + } + if self + .two_theta_deg + .iter() + .zip(&self.intensity) + .any(|(&angle, &intensity)| { + !angle.is_finite() || !intensity.is_finite() || intensity < 0.0 + }) + { + return Err("XRD payload contains invalid numeric values"); + } + if self.two_theta_deg.windows(2).any(|pair| pair[0] >= pair[1]) { + return Err("XRD 2theta values must increase strictly"); + } + if self.attenuation.as_ref().is_some_and(|values| { + values.len() != self.len() + || values + .iter() + .any(|value| !value.is_finite() || *value <= 0.0) + }) { + return Err("XRD attenuation values are invalid"); + } + if [ + self.wavelength_angstrom, + self.voltage_kv, + self.current_ma, + self.scan_step_deg, + self.scan_speed_deg_min, + ] + .into_iter() + .flatten() + .any(|value| !value.is_finite() || value <= 0.0) + { + return Err("XRD acquisition metadata contains an invalid numeric value"); + } + Ok(()) + } } /// Linear calibration applied lazily to an AFM integer signal. @@ -513,6 +591,9 @@ pub enum IoError { #[error("invalid Waters MassLynx RAW bundle: {0}")] InvalidWatersRaw(String), + #[error("invalid XRD data: {0}")] + InvalidXrd(String), + #[error( "unsupported Waters encoding for function {native_function}: IDX stride {idx_stride}, pair width {pair_width}; instrument {instrument}" )] @@ -547,6 +628,13 @@ pub fn detect_format(path: impl AsRef) -> Result { .unwrap_or("") .to_ascii_lowercase(); match ext.as_str() { + "rasx" => Ok(DataFormat::RigakuRasx), + "raw" if xrd::is_rigaku_raw(path) => Ok(DataFormat::RigakuRaw), + "txt" if xrd::is_rigaku_profile(path) => Ok(DataFormat::RigakuProfile), + "raw" if path.is_file() => Err(IoError::Unsupported(format!( + "unsupported XRD .raw variant; this build recognizes the Rigaku FI layout, .rasx, and exported profile .txt ({})", + path.display() + ))), "spm" if nanoscope::is_nanoscope(path) => Ok(DataFormat::BrukerNanoScopeSpm), "pfc" if nanoscope::is_nanoscope(path) => Ok(DataFormat::BrukerPeakForceCapture), "abf" if abf2::is_abf2(path) => Ok(DataFormat::Abf2), @@ -558,7 +646,7 @@ pub fn detect_format(path: impl AsRef) -> Result { _ if abf2::is_abf2(path) => Ok(DataFormat::Abf2), _ if jeol::is_jdf(path) => Ok(DataFormat::JeolDelta), _ => Err(IoError::Unsupported(format!( - "unrecognised path {}: expected mzML, a Waters .raw directory, NanoScope .spm/.pfc, ABF2 .abf, JEOL .jdf, JCAMP-DX .dx/.jdx/.jcamp, Bruker fid/ser, or Bruker pdata", + "unrecognised path {}: expected mzML, Rigaku FI .raw/.rasx/profile .txt, a Waters .raw directory, NanoScope .spm/.pfc, ABF2 .abf, JEOL .jdf, JCAMP-DX .dx/.jdx/.jcamp, Bruker fid/ser, or Bruker pdata", path.display() ))), } @@ -589,5 +677,8 @@ pub fn load_path(path: impl AsRef) -> Result { } DataFormat::WatersMassLynxRaw => waters::load(path), DataFormat::MzMl => mzml::load(path), + DataFormat::RigakuRasx => xrd::load_rasx(path), + DataFormat::RigakuRaw => xrd::load_raw(path), + DataFormat::RigakuProfile => xrd::load_profile(path), } } diff --git a/crates/io/src/xrd.rs b/crates/io/src/xrd.rs new file mode 100644 index 0000000..f708928 --- /dev/null +++ b/crates/io/src/xrd.rs @@ -0,0 +1,533 @@ +use crate::{Acquisition, DataFormat, IoError, LoadResult, Provenance, XrdData}; +use quick_xml::Reader; +use quick_xml::events::Event; +use std::fs::File; +use std::io::{BufRead, BufReader, Cursor, Read, Seek}; +use std::path::Path; +use zip::ZipArchive; + +const PROFILE_PATH: &str = "Data0/Profile0.txt"; +const CONDITIONS_PATH: &str = "Data0/MesurementConditions0.xml"; +const MAX_RASX_PROFILE_BYTES: u64 = 64 * 1024 * 1024; +const MAX_RASX_CONDITIONS_BYTES: u64 = 4 * 1024 * 1024; +const RAW_MAGIC: &[u8; 4] = b"FI\0\0"; +const RAW_HEADER_LEN: usize = 0xc56; +const RAW_WAVELENGTH_OFFSET: usize = 0x4bc; +const RAW_TARGET_OFFSET: usize = 0x4d6; +const RAW_VOLTAGE_OFFSET: usize = 0xb86; +const RAW_CURRENT_OFFSET: usize = 0xb88; +const RAW_START_OFFSET: usize = 0xb92; +const RAW_END_OFFSET: usize = 0xb96; +const RAW_STEP_OFFSET: usize = 0xb9a; +const RAW_SPEED_OFFSET: usize = 0xb9e; +const RAW_POINT_COUNT_OFFSET: usize = 0xc52; +const MAX_RAW_POINTS: usize = 10_000_000; + +#[derive(Default)] +struct Conditions { + instrument: Option, + target: Option, + wavelength: Option, + voltage: Option, + current: Option, + step: Option, + speed: Option, +} + +#[derive(Debug)] +struct Profile { + two_theta_deg: Vec, + intensity: Vec, + attenuation: Option>, +} + +pub fn is_rigaku_profile(path: &Path) -> bool { + let Ok(file) = File::open(path) else { + return false; + }; + BufReader::new(file) + .lines() + .take(64) + .filter_map(Result::ok) + .any(|line| { + line.trim_start_matches('\u{feff}') + .starts_with("*FILE_TYPE \"RAS_RAW\"") + }) +} + +pub fn is_rigaku_raw(path: &Path) -> bool { + let Ok(mut file) = File::open(path) else { + return false; + }; + let mut magic = [0_u8; RAW_MAGIC.len()]; + file.read_exact(&mut magic).is_ok() && &magic == RAW_MAGIC +} + +pub fn load_raw(path: &Path) -> Result { + let file = File::open(path)?; + let file_len = file.metadata()?.len(); + let mut reader = BufReader::new(file); + let mut header = [0_u8; RAW_HEADER_LEN]; + reader.read_exact(&mut header).map_err(|error| { + if error.kind() == std::io::ErrorKind::UnexpectedEof { + IoError::InvalidXrd("invalid Rigaku FI RAW header".into()) + } else { + IoError::Io(error) + } + })?; + if header.get(..RAW_MAGIC.len()) != Some(RAW_MAGIC) { + return Err(IoError::InvalidXrd("invalid Rigaku FI RAW header".into())); + } + + let start = raw_f32(&header, RAW_START_OFFSET)? as f64; + let end = raw_f32(&header, RAW_END_OFFSET)? as f64; + let step = raw_f32(&header, RAW_STEP_OFFSET)? as f64; + let point_count = raw_u32(&header, RAW_POINT_COUNT_OFFSET)? as usize; + if point_count > MAX_RAW_POINTS { + return Err(IoError::InvalidXrd(format!( + "Rigaku FI RAW declares {point_count} points, exceeding the {MAX_RAW_POINTS}-point limit" + ))); + } + let expected_len = point_count + .checked_mul(4) + .and_then(|len| RAW_HEADER_LEN.checked_add(len)) + .ok_or_else(|| IoError::InvalidXrd("Rigaku FI RAW point count overflows".into()))?; + if point_count < 2 || file_len != expected_len as u64 { + return Err(IoError::InvalidXrd(format!( + "Rigaku FI RAW length does not match its point count ({point_count})" + ))); + } + if !start.is_finite() || !end.is_finite() || !step.is_finite() || start >= end || step <= 0.0 { + return Err(IoError::InvalidXrd( + "Rigaku FI RAW has invalid scan bounds".into(), + )); + } + let expected_points = ((end - start) / step).round() as usize + 1; + if expected_points != point_count { + return Err(IoError::InvalidXrd(format!( + "Rigaku FI RAW scan bounds imply {expected_points} points, header declares {point_count}" + ))); + } + + let mut intensity = Vec::new(); + intensity + .try_reserve_exact(point_count) + .map_err(|_| IoError::InvalidXrd("could not reserve Rigaku FI RAW intensity".into()))?; + let mut encoded = [0_u8; 4]; + for _ in 0..point_count { + reader.read_exact(&mut encoded)?; + intensity.push(f32::from_le_bytes(encoded) as f64); + } + let exact_step = (end - start) / (point_count - 1) as f64; + let two_theta_deg = (0..point_count) + .map(|index| start + index as f64 * exact_step) + .collect::>(); + let data = XrdData { + two_theta_deg, + intensity, + attenuation: None, + source: path.display().to_string(), + instrument: None, + target: raw_text(&header, RAW_TARGET_OFFSET, 16), + wavelength_angstrom: Some(raw_f64(&header, RAW_WAVELENGTH_OFFSET)?), + voltage_kv: Some(raw_u16(&header, RAW_VOLTAGE_OFFSET)? as f64), + current_ma: Some(raw_u16(&header, RAW_CURRENT_OFFSET)? as f64), + scan_step_deg: Some(step), + scan_speed_deg_min: Some(raw_f32(&header, RAW_SPEED_OFFSET)? as f64), + }; + result(path, DataFormat::RigakuRaw, data) +} + +fn raw_u16(bytes: &[u8], offset: usize) -> Result { + let value = bytes + .get(offset..offset + 2) + .ok_or_else(|| IoError::InvalidXrd("truncated Rigaku FI RAW header".into()))?; + Ok(u16::from_le_bytes( + value.try_into().expect("two-byte slice"), + )) +} + +fn raw_u32(bytes: &[u8], offset: usize) -> Result { + let value = bytes + .get(offset..offset + 4) + .ok_or_else(|| IoError::InvalidXrd("truncated Rigaku FI RAW header".into()))?; + Ok(u32::from_le_bytes( + value.try_into().expect("four-byte slice"), + )) +} + +fn raw_f32(bytes: &[u8], offset: usize) -> Result { + Ok(f32::from_bits(raw_u32(bytes, offset)?)) +} + +fn raw_f64(bytes: &[u8], offset: usize) -> Result { + let value = bytes + .get(offset..offset + 8) + .ok_or_else(|| IoError::InvalidXrd("truncated Rigaku FI RAW header".into()))?; + Ok(f64::from_le_bytes( + value.try_into().expect("eight-byte slice"), + )) +} + +fn raw_text(bytes: &[u8], offset: usize, len: usize) -> Option { + let value = bytes.get(offset..offset + len)?; + let end = value + .iter() + .position(|byte| *byte == 0) + .unwrap_or(value.len()); + std::str::from_utf8(&value[..end]).ok().and_then(nonempty) +} + +pub fn load_profile(path: &Path) -> Result { + let reader = BufReader::new(File::open(path)?); + let profile = parse_profile(reader)?; + result( + path, + DataFormat::RigakuProfile, + XrdData { + two_theta_deg: profile.two_theta_deg, + intensity: profile.intensity, + attenuation: profile.attenuation, + source: path.display().to_string(), + instrument: None, + target: None, + wavelength_angstrom: None, + voltage_kv: None, + current_ma: None, + scan_step_deg: None, + scan_speed_deg_min: None, + }, + ) +} + +pub fn load_rasx(path: &Path) -> Result { + let mut archive = + ZipArchive::new(File::open(path)?).map_err(|error| IoError::Archive(error.to_string()))?; + let profile = read_rasx_entry(&mut archive, PROFILE_PATH, MAX_RASX_PROFILE_BYTES)?; + let profile = parse_profile(Cursor::new(profile))?; + + let xml = read_rasx_entry(&mut archive, CONDITIONS_PATH, MAX_RASX_CONDITIONS_BYTES)?; + let conditions = parse_conditions(Cursor::new(xml))?; + result( + path, + DataFormat::RigakuRasx, + XrdData { + two_theta_deg: profile.two_theta_deg, + intensity: profile.intensity, + attenuation: profile.attenuation, + source: path.display().to_string(), + instrument: conditions.instrument, + target: conditions.target, + wavelength_angstrom: conditions.wavelength, + voltage_kv: conditions.voltage, + current_ma: conditions.current, + scan_step_deg: conditions.step, + scan_speed_deg_min: conditions.speed, + }, + ) +} + +fn read_rasx_entry( + archive: &mut ZipArchive, + path: &str, + limit: u64, +) -> Result, IoError> { + let mut entry = archive + .by_name(path) + .map_err(|_| IoError::InvalidXrd(format!("missing {path}")))?; + let declared = entry.size(); + if declared > limit { + return Err(IoError::InvalidXrd(format!( + "RASX entry {path} declares {declared} uncompressed bytes, exceeding the {limit}-byte limit" + ))); + } + let capacity = usize::try_from(declared) + .map_err(|_| IoError::InvalidXrd(format!("RASX entry {path} is too large to address")))?; + let mut bytes = Vec::with_capacity(capacity); + entry + .by_ref() + .take(limit.saturating_add(1)) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 > limit { + return Err(IoError::InvalidXrd(format!( + "RASX entry {path} exceeds the {limit}-byte read limit" + ))); + } + Ok(bytes) +} + +fn result(path: &Path, format: DataFormat, data: XrdData) -> Result { + data.validate() + .map_err(|error| IoError::InvalidXrd(error.into()))?; + Ok(LoadResult { + acquisition: Acquisition::Xrd(Box::new(data)), + format, + provenance: Provenance { + selected_path: path.to_path_buf(), + data_path: path.to_path_buf(), + parameter_paths: Vec::new(), + companion_paths: Vec::new(), + }, + warnings: Vec::new(), + }) +} + +fn parse_profile(reader: impl BufRead) -> Result { + let mut angles = Vec::new(); + let mut intensities = Vec::new(); + let mut attenuations = Vec::new(); + let mut has_attenuation = true; + let mut data_started = false; + for (line_index, line) in reader.lines().enumerate() { + let line = line?; + let line = line.trim_start_matches('\u{feff}').trim(); + if line.is_empty() || line.starts_with('*') || line.starts_with('#') { + continue; + } + let columns: Vec<_> = line.split_whitespace().collect(); + if columns.len() < 2 { + if data_started { + return Err(IoError::InvalidXrd(format!( + "malformed profile row on line {}", + line_index + 1 + ))); + } + continue; + } + let angle = match columns[0].parse::() { + Ok(angle) => angle, + Err(_) if data_started => { + return Err(IoError::InvalidXrd(format!( + "non-numeric 2theta value on line {}", + line_index + 1 + ))); + } + Err(_) => continue, + }; + let intensity = columns[1].parse::().map_err(|_| { + IoError::InvalidXrd(format!("non-numeric intensity on line {}", line_index + 1)) + })?; + if !angle.is_finite() || !intensity.is_finite() || intensity < 0.0 { + return Err(IoError::InvalidXrd(format!( + "invalid numeric value on line {}", + line_index + 1 + ))); + } + if angles.last().is_some_and(|previous| angle <= *previous) { + return Err(IoError::InvalidXrd(format!( + "2theta values must increase strictly (line {})", + line_index + 1 + ))); + } + data_started = true; + angles.push(angle); + intensities.push(intensity); + match columns.get(2).and_then(|value| value.parse::().ok()) { + Some(value) if value.is_finite() && value > 0.0 => attenuations.push(value), + _ => has_attenuation = false, + } + } + if angles.len() < 2 { + return Err(IoError::InvalidXrd( + "profile must contain at least two numeric 2theta/intensity rows".into(), + )); + } + let attenuation = has_attenuation.then_some(attenuations); + Ok(Profile { + two_theta_deg: angles, + intensity: intensities, + attenuation, + }) +} + +fn parse_conditions(reader: impl BufRead) -> Result { + let mut xml = Reader::from_reader(reader); + xml.config_mut().trim_text(true); + let mut conditions = Conditions::default(); + let mut active = None::>; + let mut buffer = Vec::new(); + loop { + match xml.read_event_into(&mut buffer) { + Ok(Event::Start(element)) => active = Some(element.name().as_ref().to_vec()), + Ok(Event::Text(text)) => { + let value = text + .decode() + .map_err(|error| IoError::InvalidXrd(error.to_string()))?; + match active.as_deref() { + Some(b"SystemName") => conditions.instrument = nonempty(&value), + Some(b"TargetName") => conditions.target = nonempty(&value), + Some(b"WavelengthKalpha1") => conditions.wavelength = number(&value), + Some(b"Voltage") => conditions.voltage = number(&value), + Some(b"Current") => conditions.current = number(&value), + Some(b"Step") => conditions.step = number(&value), + Some(b"Speed") => conditions.speed = number(&value), + _ => {} + } + } + Ok(Event::End(_)) => active = None, + Ok(Event::Eof) => break, + Err(error) => { + return Err(IoError::InvalidXrd(format!( + "invalid conditions XML: {error}" + ))); + } + _ => {} + } + buffer.clear(); + } + Ok(conditions) +} + +fn nonempty(value: &str) -> Option { + (!value.trim().is_empty()).then(|| value.trim().to_owned()) +} + +fn number(value: &str) -> Option { + value + .trim() + .parse() + .ok() + .filter(|value: &f64| value.is_finite()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn parses_rigaku_profile_with_bom_and_header() { + let source = "\u{feff}*FILE_TYPE \"RAS_RAW\"\r\n3.00 10 1\r\n3.01 12 2\r\n"; + let profile = parse_profile(Cursor::new(source)).unwrap(); + assert_eq!(profile.two_theta_deg, vec![3.0, 3.01]); + assert_eq!(profile.intensity, vec![10.0, 12.0]); + assert_eq!(profile.attenuation.unwrap(), vec![1.0, 2.0]); + } + + #[test] + fn rejects_non_monotonic_profile() { + let error = parse_profile(Cursor::new("3.0 1\n2.0 2\n")).unwrap_err(); + assert!(error.to_string().contains("increase strictly")); + } + + #[test] + fn rejects_malformed_rows_after_profile_data_starts() { + let error = parse_profile(Cursor::new("3.0 1\ncorrupt row\n3.1 2\n")).unwrap_err(); + assert!(error.to_string().contains("non-numeric 2theta")); + } + + #[test] + fn rejects_rasx_entry_over_read_limit() { + let cursor = Cursor::new(Vec::new()); + let mut writer = zip::ZipWriter::new(cursor); + writer + .start_file(PROFILE_PATH, zip::write::SimpleFileOptions::default()) + .unwrap(); + writer.write_all(b"12345").unwrap(); + let bytes = writer.finish().unwrap().into_inner(); + let mut archive = ZipArchive::new(Cursor::new(bytes)).unwrap(); + + let error = read_rasx_entry(&mut archive, PROFILE_PATH, 4).unwrap_err(); + + assert!(error.to_string().contains("exceeding the 4-byte limit")); + } + + #[test] + fn rejects_invalid_acquisition_metadata() { + let data = XrdData { + two_theta_deg: vec![3.0, 3.1], + intensity: vec![10.0, 12.0], + attenuation: None, + source: "pattern.raw".to_owned(), + instrument: None, + target: None, + wavelength_angstrom: Some(f64::NAN), + voltage_kv: Some(40.0), + current_ma: Some(15.0), + scan_step_deg: Some(0.1), + scan_speed_deg_min: Some(5.0), + }; + + assert_eq!( + data.validate(), + Err("XRD acquisition metadata contains an invalid numeric value") + ); + } + + #[test] + fn loads_rigaku_fi_raw_profile_and_metadata() { + let path = std::env::temp_dir().join(format!("plotx-xrd-{}.raw", std::process::id())); + let mut bytes = vec![0_u8; RAW_HEADER_LEN + 12]; + bytes[..4].copy_from_slice(RAW_MAGIC); + bytes[RAW_WAVELENGTH_OFFSET..RAW_WAVELENGTH_OFFSET + 8] + .copy_from_slice(&1.540593_f64.to_le_bytes()); + bytes[RAW_TARGET_OFFSET..RAW_TARGET_OFFSET + 2].copy_from_slice(b"Cu"); + bytes[RAW_VOLTAGE_OFFSET..RAW_VOLTAGE_OFFSET + 2].copy_from_slice(&40_u16.to_le_bytes()); + bytes[RAW_CURRENT_OFFSET..RAW_CURRENT_OFFSET + 2].copy_from_slice(&15_u16.to_le_bytes()); + bytes[RAW_START_OFFSET..RAW_START_OFFSET + 4].copy_from_slice(&3.0_f32.to_le_bytes()); + bytes[RAW_END_OFFSET..RAW_END_OFFSET + 4].copy_from_slice(&3.02_f32.to_le_bytes()); + bytes[RAW_STEP_OFFSET..RAW_STEP_OFFSET + 4].copy_from_slice(&0.01_f32.to_le_bytes()); + bytes[RAW_SPEED_OFFSET..RAW_SPEED_OFFSET + 4].copy_from_slice(&5.0_f32.to_le_bytes()); + bytes[RAW_POINT_COUNT_OFFSET..RAW_POINT_COUNT_OFFSET + 4] + .copy_from_slice(&3_u32.to_le_bytes()); + for (chunk, value) in bytes[RAW_HEADER_LEN..] + .chunks_exact_mut(4) + .zip([10.0_f32, 20.0, 12.0]) + { + chunk.copy_from_slice(&value.to_le_bytes()); + } + std::fs::write(&path, bytes).unwrap(); + + let loaded = load_raw(&path).unwrap(); + std::fs::remove_file(path).unwrap(); + let Acquisition::Xrd(data) = loaded.acquisition else { + panic!("expected XRD") + }; + assert_eq!(loaded.format, DataFormat::RigakuRaw); + assert_eq!(data.intensity, vec![10.0, 20.0, 12.0]); + assert!((data.two_theta_deg[2] - 3.02).abs() < 1e-6); + assert_eq!(data.target.as_deref(), Some("Cu")); + assert_eq!(data.voltage_kv, Some(40.0)); + assert_eq!(data.current_ma, Some(15.0)); + } + + #[test] + fn rejects_rigaku_fi_raw_point_count_over_limit_before_payload_read() { + let path = std::env::temp_dir().join(format!("plotx-xrd-limit-{}.raw", std::process::id())); + let mut header = vec![0_u8; RAW_HEADER_LEN]; + header[..4].copy_from_slice(RAW_MAGIC); + header[RAW_POINT_COUNT_OFFSET..RAW_POINT_COUNT_OFFSET + 4] + .copy_from_slice(&u32::try_from(MAX_RAW_POINTS + 1).unwrap().to_le_bytes()); + std::fs::write(&path, header).unwrap(); + + let error = load_raw(&path).unwrap_err(); + std::fs::remove_file(path).unwrap(); + + assert!(error.to_string().contains("exceeding the")); + assert!(error.to_string().contains("point limit")); + } + + #[test] + fn rasx_loads_profile_and_instrument_metadata() { + let path = std::env::temp_dir().join(format!("plotx-xrd-{}.rasx", std::process::id())); + let file = File::create(&path).unwrap(); + let mut zip = zip::ZipWriter::new(file); + let options = zip::write::SimpleFileOptions::default(); + zip.start_file(PROFILE_PATH, options).unwrap(); + zip.write_all(b"3.0 10 1\n3.1 20 2\n").unwrap(); + zip.start_file(CONDITIONS_PATH, options).unwrap(); + zip.write_all(br#"MiniFlexCu1.54059340150.15"#).unwrap(); + zip.finish().unwrap(); + + let loaded = load_rasx(&path).unwrap(); + std::fs::remove_file(path).unwrap(); + let Acquisition::Xrd(data) = loaded.acquisition else { + panic!("expected XRD") + }; + assert_eq!(data.instrument.as_deref(), Some("MiniFlex")); + assert_eq!(data.target.as_deref(), Some("Cu")); + assert_eq!(data.wavelength_angstrom, Some(1.540593)); + assert_eq!(data.intensity, vec![10.0, 20.0]); + assert_eq!(data.attenuation.unwrap(), vec![1.0, 2.0]); + } +} diff --git a/crates/io/tests/bruker_processed.rs b/crates/io/tests/bruker_processed.rs index 466c684..7c46297 100644 --- a/crates/io/tests/bruker_processed.rs +++ b/crates/io/tests/bruker_processed.rs @@ -50,7 +50,7 @@ fn loads_big_endian_scaled_1r_from_experiment_directory() { Acquisition::D2(_) => panic!("expected 1D"), Acquisition::Electrophysiology(_) => panic!("expected NMR"), Acquisition::Afm(_) => panic!("expected NMR"), - Acquisition::MassSpec(_) => panic!("expected NMR"), + Acquisition::MassSpec(_) | Acquisition::Xrd(_) => panic!("expected NMR"), }; assert_eq!(data.domain, Domain::Frequency); assert_eq!( @@ -92,7 +92,7 @@ fn loads_2rr_and_reverses_both_frequency_axes() { Acquisition::D1(_) => panic!("expected 2D"), Acquisition::Electrophysiology(_) => panic!("expected NMR"), Acquisition::Afm(_) => panic!("expected NMR"), - Acquisition::MassSpec(_) => panic!("expected NMR"), + Acquisition::MassSpec(_) | Acquisition::Xrd(_) => panic!("expected NMR"), }; assert_eq!(data.domain, Domain::Frequency); assert_eq!((data.rows, data.cols), (2, 3)); diff --git a/crates/processing/src/lib.rs b/crates/processing/src/lib.rs index b9baff4..78de1c7 100644 --- a/crates/processing/src/lib.rs +++ b/crates/processing/src/lib.rs @@ -13,6 +13,7 @@ pub mod phase; mod preview; pub mod slice; pub mod timeseries; +pub mod xrd; pub use output::{Processed1D, TimeTrace}; pub use preview::{Preview, process_up_to}; diff --git a/crates/processing/src/xrd.rs b/crates/processing/src/xrd.rs new file mode 100644 index 0000000..172aa7e --- /dev/null +++ b/crates/processing/src/xrd.rs @@ -0,0 +1,316 @@ +pub const MAX_SNIP_ITERATIONS: u16 = 200; +pub const MAX_SAVGOL_WINDOW: u16 = 101; +pub const MAX_SAVGOL_ORDER: u8 = 6; + +#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct XrdProcessing { + pub background: Option, + pub smoothing: Option, + pub normalization: XrdNormalization, +} + +impl Default for XrdProcessing { + fn default() -> Self { + Self { + background: None, + smoothing: None, + normalization: XrdNormalization::None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct SnipBackground { + pub iterations: u16, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct SavitzkyGolay { + pub window: u16, + pub polynomial_order: u8, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum XrdNormalization { + None, + Maximum, + Area, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ProcessedXrd { + pub intensity: Vec, + pub background: Option>, +} + +#[derive(Debug, thiserror::Error, PartialEq)] +pub enum XrdProcessingError { + #[error("2theta and intensity lengths differ")] + LengthMismatch, + #[error("SNIP background iterations must be between 1 and {MAX_SNIP_ITERATIONS}")] + InvalidBackground, + #[error( + "Savitzky-Golay window must be odd, between 3 and {MAX_SAVGOL_WINDOW}, and longer than a polynomial order no greater than {MAX_SAVGOL_ORDER}" + )] + InvalidSmoothing, + #[error("XRD processing produced a non-finite numeric result")] + NonFiniteResult, +} + +pub fn process( + two_theta_deg: &[f64], + intensity: &[f64], + params: XrdProcessing, +) -> Result { + if two_theta_deg.len() != intensity.len() { + return Err(XrdProcessingError::LengthMismatch); + } + validate(params)?; + let (mut values, background) = match params.background { + Some(settings) => { + let background = snip_background(intensity, settings.iterations); + let corrected = intensity + .iter() + .zip(&background) + .map(|(observed, baseline)| (observed - baseline).max(0.0)) + .collect(); + (corrected, Some(background)) + } + None => (intensity.to_vec(), None), + }; + if let Some(settings) = params.smoothing { + values = savitzky_golay(&values, settings)?; + } + normalize(two_theta_deg, &mut values, params.normalization); + if !values.iter().all(|value| value.is_finite()) + || background + .as_ref() + .is_some_and(|values| !values.iter().all(|value| value.is_finite())) + { + return Err(XrdProcessingError::NonFiniteResult); + } + Ok(ProcessedXrd { + intensity: values, + background, + }) +} + +pub fn validate(params: XrdProcessing) -> Result<(), XrdProcessingError> { + if params.background.is_some_and(|settings| { + settings.iterations == 0 || settings.iterations > MAX_SNIP_ITERATIONS + }) { + return Err(XrdProcessingError::InvalidBackground); + } + if params.smoothing.is_some_and(|settings| { + settings.window < 3 + || settings.window > MAX_SAVGOL_WINDOW + || settings.window % 2 == 0 + || settings.polynomial_order > MAX_SAVGOL_ORDER + || settings.polynomial_order >= settings.window as u8 + }) { + return Err(XrdProcessingError::InvalidSmoothing); + } + Ok(()) +} + +fn snip_background(input: &[f64], iterations: u16) -> Vec { + let mut transformed: Vec<_> = input + .iter() + .map(|value| value.max(0.0).sqrt().sqrt()) + .collect(); + let max_iteration = usize::from(iterations).min(input.len().saturating_sub(1) / 2); + for offset in 1..=max_iteration { + let previous = transformed.clone(); + for index in offset..input.len() - offset { + transformed[index] = + previous[index].min((previous[index - offset] + previous[index + offset]) * 0.5); + } + } + transformed.into_iter().map(|value| value.powi(4)).collect() +} + +fn savitzky_golay(input: &[f64], settings: SavitzkyGolay) -> Result, XrdProcessingError> { + let window = usize::from(settings.window); + let order = usize::from(settings.polynomial_order); + if input.len() < window { + return Ok(input.to_vec()); + } + let half = window / 2; + let coefficients = smoothing_coefficients(window, order); + if !coefficients.iter().all(|value| value.is_finite()) { + return Err(XrdProcessingError::InvalidSmoothing); + } + Ok((0..input.len()) + .map(|index| { + coefficients + .iter() + .enumerate() + .map(|(offset, coefficient)| { + let source = index + .saturating_add(offset) + .saturating_sub(half) + .min(input.len() - 1); + coefficient * input[source] + }) + .sum() + }) + .collect()) +} + +fn smoothing_coefficients(window: usize, order: usize) -> Vec { + let half = (window / 2) as f64; + let columns = order + 1; + let mut normal = vec![vec![0.0; columns]; columns]; + for (row_index, row) in normal.iter_mut().enumerate() { + for (column_index, value) in row.iter_mut().enumerate() { + *value = (0..window) + .map(|index| (index as f64 - half).powi((row_index + column_index) as i32)) + .sum(); + } + } + let mut rhs = vec![0.0; columns]; + rhs[0] = 1.0; + let solution = solve(normal, rhs); + (0..window) + .map(|index| { + let x = index as f64 - half; + solution + .iter() + .enumerate() + .map(|(power, value)| value * x.powi(power as i32)) + .sum() + }) + .collect() +} + +fn solve(mut matrix: Vec>, mut rhs: Vec) -> Vec { + for pivot in 0..rhs.len() { + let best = (pivot..rhs.len()) + .max_by(|&a, &b| matrix[a][pivot].abs().total_cmp(&matrix[b][pivot].abs())) + .unwrap_or(pivot); + matrix.swap(pivot, best); + rhs.swap(pivot, best); + let divisor = matrix[pivot][pivot]; + for value in matrix[pivot].iter_mut().skip(pivot) { + *value /= divisor; + } + rhs[pivot] /= divisor; + let pivot_values = matrix[pivot].clone(); + for (row_index, row) in matrix.iter_mut().enumerate() { + if row_index == pivot { + continue; + } + let factor = row[pivot]; + for (column, value) in row.iter_mut().enumerate().skip(pivot) { + *value -= factor * pivot_values[column]; + } + rhs[row_index] -= factor * rhs[pivot]; + } + } + rhs +} + +fn normalize(x: &[f64], values: &mut [f64], method: XrdNormalization) { + let divisor = match method { + XrdNormalization::None => return, + XrdNormalization::Maximum => values.iter().copied().fold(0.0, f64::max), + XrdNormalization::Area => x + .windows(2) + .zip(values.windows(2)) + .map(|(x, y)| (x[1] - x[0]) * (y[0] + y[1]) * 0.5) + .sum(), + }; + if divisor.is_finite() && divisor > 0.0 { + values.iter_mut().for_each(|value| *value /= divisor); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maximum_normalization_scales_peak_to_one() { + let result = process( + &[1.0, 2.0, 3.0], + &[2.0, 4.0, 1.0], + XrdProcessing { + normalization: XrdNormalization::Maximum, + ..XrdProcessing::default() + }, + ) + .unwrap(); + assert_eq!(result.intensity, vec![0.5, 1.0, 0.25]); + } + + #[test] + fn smoothing_preserves_a_quadratic_interior() { + let values = vec![4.0, 1.0, 0.0, 1.0, 4.0]; + let result = savitzky_golay( + &values, + SavitzkyGolay { + window: 5, + polynomial_order: 2, + }, + ) + .unwrap(); + assert!(result[2].abs() < 1e-10); + } + + #[test] + fn rejects_zero_background_iterations() { + let error = process( + &[1.0, 2.0, 3.0], + &[2.0, 4.0, 1.0], + XrdProcessing { + background: Some(SnipBackground { iterations: 0 }), + ..XrdProcessing::default() + }, + ) + .unwrap_err(); + + assert_eq!(error, XrdProcessingError::InvalidBackground); + } + + #[test] + fn rejects_processing_parameters_above_supported_limits() { + let background = process( + &[1.0, 2.0, 3.0], + &[2.0, 4.0, 1.0], + XrdProcessing { + background: Some(SnipBackground { + iterations: MAX_SNIP_ITERATIONS + 1, + }), + ..XrdProcessing::default() + }, + ) + .unwrap_err(); + assert_eq!(background, XrdProcessingError::InvalidBackground); + + let smoothing = process( + &[1.0, 2.0, 3.0], + &[2.0, 4.0, 1.0], + XrdProcessing { + smoothing: Some(SavitzkyGolay { + window: MAX_SAVGOL_WINDOW + 2, + polynomial_order: 3, + }), + ..XrdProcessing::default() + }, + ) + .unwrap_err(); + assert_eq!(smoothing, XrdProcessingError::InvalidSmoothing); + } + + #[test] + fn rejects_non_finite_results() { + let error = process( + &[1.0, 2.0, 3.0], + &[2.0, f64::INFINITY, 1.0], + XrdProcessing::default(), + ) + .unwrap_err(); + + assert_eq!(error, XrdProcessingError::NonFiniteResult); + } +} diff --git a/docs/src/content/docs/guides/importing-data.md b/docs/src/content/docs/guides/importing-data.md index af9d44a..d12d6e2 100644 --- a/docs/src/content/docs/guides/importing-data.md +++ b/docs/src/content/docs/guides/importing-data.md @@ -13,6 +13,7 @@ no conversion step is needed. | JEOL Delta | `.jdf` | 1D, 2D, and pseudo-2D (DOSY / T1 / T2) | | Bruker TopSpin | `fid` / `ser` directories | 1D and 2D | | Waters MassLynx RAW | `.raw` directory | Validated low-resolution runs, including SQD2 data | +| Rigaku powder XRD | `.rasx`, FI `.raw`, RAS_RAW `.txt` | Diffraction pattern, acquisition metadata, and attenuation when available | | mzML | `.mzML` | Centroided or profile LC–MS spectra with 32-bit or 64-bit arrays, uncompressed or zlib-compressed | | Bruker NanoScope AFM | `.spm` / `.pfc` | Images, force curves, force-volume and PeakForce Capture cubes | | JCAMP-DX | `.dx` / `.jdx` / `.jcamp` | 1D frequency-domain NMR spectra | @@ -46,6 +47,23 @@ The importer accepts little-endian 32-bit and 64-bit floating-point m/z and intensity arrays with no compression or zlib compression. Numpress, big-endian arrays, and spectra without both required arrays stop the import with an error. +## Rigaku powder XRD + +Open the `.rasx` file when it is available. PlotX reads the measured 2theta, +intensity, and attenuation columns together with the instrument name, X-ray +target, Kalpha1 wavelength, tube voltage/current, scan step, and scan speed. +The initial page is an XRD pattern. **Processing** provides optional SNIP +background subtraction, Savitzky-Golay smoothing, and normalization by maximum +intensity or integrated area; these settings and the original observations are +saved in the PlotX project. + +Rigaku `FI`-layout `.raw` files and profile `.txt` exports whose header identifies +`RAS_RAW` also open as XRD. The binary reader retains the scan ruler, intensity, +X-ray target, Kalpha1 wavelength, tube voltage/current, and scan speed. Other +`.raw` signatures are rejected explicitly because the extension is shared by +incompatible vendor formats. Other `.txt` files keep the normal table-import +preview, so a generic numeric table is not silently reclassified. + ## Waters MassLynx RAW Open or drop the `.raw` directory itself. PlotX imports its supported MS 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 b5eeda7..99f4e53 100644 --- a/docs/src/content/docs/zh-cn/guides/importing-data.md +++ b/docs/src/content/docs/zh-cn/guides/importing-data.md @@ -12,6 +12,7 @@ PlotX 直接读取厂商 LC–MS、NMR、AFM 与电生理格式,无需任何 | JEOL Delta | `.jdf` | 1D、2D 及伪 2D(DOSY / T1 / T2) | | Bruker TopSpin | `fid` / `ser` 目录 | 1D 与 2D | | Waters MassLynx RAW | `.raw` 目录 | 已验证的低分辨率数据,包括 SQD2 数据 | +| Rigaku 粉末 XRD | `.rasx`、FI `.raw`、RAS_RAW `.txt` | 衍射图样、采集元数据,以及文件提供的衰减系数 | | mzML | `.mzML` | 使用 32 位或 64 位、未压缩或 zlib 压缩数组的质心或轮廓 LC–MS 谱图 | | Bruker NanoScope AFM | `.spm` / `.pfc` | 图像、力曲线、Force Volume 与 PeakForce Capture 数据立方体 | | JCAMP-DX | `.dx` / `.jdx` / `.jcamp` | 1D 频域 NMR 谱 | @@ -41,6 +42,19 @@ PlotX 直接读取厂商 LC–MS、NMR、AFM 与电生理格式,无需任何 导入器支持小端 32 位和 64 位浮点 m/z 与强度数组,可不压缩或使用 zlib 压缩。 Numpress、大端数组以及缺少任一必需数组的谱图会使导入停止并显示错误。 +## Rigaku 粉末 XRD + +有 `.rasx` 时请优先打开该文件。PlotX 会同时读取实测 2theta、强度、衰减系数, +以及仪器名称、X 射线靶材、Kalpha1 波长、管电压/电流、扫描步长和扫描速度。 +初始页面显示 XRD pattern。**Processing** 中可选择 SNIP 背景扣除、 +Savitzky-Golay 平滑,以及按最大强度或积分面积归一化;这些设置与原始观测值 +都会保存在 PlotX 项目中。 + +采用 `FI` 布局的 Rigaku `.raw`,以及文件头标明 `RAS_RAW` 的 profile `.txt`, +也会作为 XRD 打开。二进制读取器会保留扫描坐标、强度、X 射线靶材、Kalpha1 +波长、管电压/电流和扫描速度。由于不同厂商会使用互不兼容的 `.raw` 格式, +PlotX 会明确拒绝其他文件签名。普通 `.txt` 仍进入表格导入预览,不会被静默改判。 + ## Waters MassLynx RAW 请打开或拖入 `.raw` 目录本身。PlotX 会导入其中受支持的 MS 功能和光学检测器