diff --git a/crates/app/src/ui/tools/protein.rs b/crates/app/src/ui/tools/protein.rs index 810af27..0c2a72c 100644 --- a/crates/app/src/ui/tools/protein.rs +++ b/crates/app/src/ui/tools/protein.rs @@ -15,6 +15,8 @@ struct Panel { confirmed_only: bool, feedback: String, clear_spectrum: Option<(DatasetId, u64)>, + reference_baseline: Option, + reference_draft: Option<(DatasetId, plotium_core::protein_reference::ShiftReference)>, context_draft: Option, } @@ -244,6 +246,7 @@ fn body(app: &mut PlotiumApp, ui: &mut Ui, panel: &mut Panel) { }); } scientific_context(app, ui, panel, active); + reference_editor::render(app, ui, panel, active); clear_review(app, ui, panel); ui.separator(); if panel.draft.is_some() { @@ -454,3 +457,6 @@ mod tests; #[path = "protein_scientific_context.rs"] mod scientific_context_editor; use scientific_context_editor::scientific_context; + +#[path = "protein_reference.rs"] +mod reference_editor; diff --git a/crates/app/src/ui/tools/protein_reference.rs b/crates/app/src/ui/tools/protein_reference.rs new file mode 100644 index 0000000..e5fa569 --- /dev/null +++ b/crates/app/src/ui/tools/protein_reference.rs @@ -0,0 +1,67 @@ +use super::Panel; +use plotium_core::state::{DatasetId, PlotiumApp}; +pub(super) fn render( + app: &mut PlotiumApp, + ui: &mut egui::Ui, + panel: &mut Panel, + active: Option, +) { + ui.collapsing("Chemical-shift reference", |ui| { + ui.label("Absolute offsets from acquisition reference: corrected ppm = original ppm + offset."); + if let Some(id) = active { + if ui.button("Edit reference").clicked() { + match plotium_core::protein::volume(&app.doc, id) { + Ok(v) => { + panel.reference_baseline = Some(v.peaks.reference.clone()); + panel.reference_draft = Some((id, v.peaks.reference.clone())); + }, + Err(e) => panel.feedback = e, + } + } + for &other in app.doc.protein.context.experiments.keys() { + if other != id { + let result = app.reference_differences(id, other); + if let Ok(rows) = &result { + if rows.is_empty() { ui.label("No matching confirmed single-atom observations; not evaluated."); } + for row in rows { + ui.label(format!("Peak {} F{} vs {} F{}: delta {:+.5} ppm", row.left_peak.0, row.left_axis + 1, row.right_peak.0, row.right_axis + 1, row.delta_ppm)); + } + } + ui.label(format!("{}: {}", other, result.err().unwrap_or_else(|| "References manually confirmed; inspect matching nuclei and axis roles before comparing peaks".into()))); + } + } + } + let mut close = false; + if let Some((id, draft)) = &mut panel.reference_draft { + let name = app.doc.dataset_index(*id).and_then(|i| app.doc.datasets[i].as_nmr2d()).map(|n| n.name.as_deref().unwrap_or("Unnamed spectrum")).unwrap_or("Deleted spectrum"); + ui.label(format!("Reference draft target: {name} ({id})")); + if active != Some(*id) { + ui.colored_label(ui.visuals().warn_fg_color, "The active spectrum differs from this draft. Saving edits the draft target shown above."); + } + + for (axis, value) in draft.offsets_ppm.iter_mut().enumerate() { + ui.horizontal(|ui| { ui.label(format!("F{} offset (ppm)", axis + 1)); ui.add(egui::DragValue::new(value).speed(0.001)); }); + } + ui.label("Reference source / standard / comparison evidence"); + ui.text_edit_singleline(&mut draft.source); + ui.checkbox(&mut draft.confirmed, "Manually reviewed reference (not scientific certification)"); + if ui.button("Save reference").clicked() { + let current = plotium_core::protein::volume(&app.doc, *id).map(|v| &v.peaks.reference); + let result = match current { + Ok(value) if Some(value) == panel.reference_baseline.as_ref() => app.set_shift_reference(*id, draft.clone()), + _ => Err("Reference changed or spectrum was removed. Discard and reopen the reference draft.".into()), + }; + match result { + Ok(()) => { close = true; panel.feedback = "Reference saved".into(); } + Err(e) => panel.feedback = e, + } + } + close |= ui.button("Discard reference draft").clicked(); + } + if close { panel.reference_draft = None; } + }); +} + +#[cfg(test)] +#[path = "protein_reference_tests.rs"] +mod tests; diff --git a/crates/app/src/ui/tools/protein_reference_tests.rs b/crates/app/src/ui/tools/protein_reference_tests.rs new file mode 100644 index 0000000..3e18198 --- /dev/null +++ b/crates/app/src/ui/tools/protein_reference_tests.rs @@ -0,0 +1,123 @@ +use super::*; +use crate::ui::tools::protein::Panel; +fn spectrum(app: &mut PlotiumApp) -> DatasetId { + let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../io/tests/fixtures/nmr/bruker-3d-qf"); + let (dataset, _) = plotium_core::workflow::dataset_from_acquisition( + plotium_io::load_path(fixture).unwrap().acquisition, + ) + .unwrap(); + let id = dataset.resource_id(); + app.doc.datasets.push(dataset); + id +} + +fn frame( + app: &mut PlotiumApp, + panel: &mut Panel, + ctx: &egui::Context, + active: Option, + events: Vec, +) -> egui::FullOutput { + ctx.run_ui( + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(1200., 2400.), + )), + events, + ..Default::default() + }, + |ui| render(app, ui, panel, active), + ) +} + +fn click( + app: &mut PlotiumApp, + panel: &mut Panel, + ctx: &egui::Context, + active: Option, + label: &str, +) { + let output = frame(app, panel, ctx, active, vec![]); + let pos = output + .shapes + .iter() + .find_map(|shape| match &shape.shape { + egui::Shape::Text(t) if t.galley.text() == label => Some(t.pos + egui::vec2(3., 5.)), + _ => None, + }) + .unwrap_or_else(|| panic!("Missing widget: {label}")); + for pressed in [true, false] { + frame( + app, + panel, + ctx, + active, + vec![ + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed, + modifiers: Default::default(), + }, + ], + ); + } +} + +#[test] +fn switching_spectra_displays_draft_target_and_saves_only_that_target() { + let mut app = PlotiumApp::new(); + let a = spectrum(&mut app); + let b = spectrum(&mut app); + app.doc.datasets[0].as_nmr2d_mut().unwrap().name = Some("Spectrum A".into()); + app.doc.datasets[1].as_nmr2d_mut().unwrap().name = Some("Spectrum B".into()); + let ctx = egui::Context::default(); + let mut panel = Panel::default(); + click( + &mut app, + &mut panel, + &ctx, + Some(a), + "Chemical-shift reference", + ); + click(&mut app, &mut panel, &ctx, Some(a), "Edit reference"); + let (_, draft) = panel.reference_draft.as_mut().unwrap(); + draft.offsets_ppm = [0.125, 0., 0.]; + draft.source = "External standard".into(); + draft.confirmed = true; + let output = frame(&mut app, &mut panel, &ctx, Some(b), vec![]); + let labels: Vec<_> = output + .shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::Text(t) => Some(t.galley.text().to_owned()), + _ => None, + }) + .collect(); + assert!( + labels + .iter() + .any(|t| t.contains("Reference draft target: Spectrum A")) + ); + assert!(labels.iter().any(|t| t.contains("active spectrum differs"))); + click(&mut app, &mut panel, &ctx, Some(b), "Save reference"); + assert!(panel.reference_draft.is_none()); + assert_eq!( + plotium_core::protein::volume(&app.doc, a) + .unwrap() + .peaks + .reference + .offsets_ppm, + [0.125, 0., 0.] + ); + assert_eq!( + plotium_core::protein::volume(&app.doc, b) + .unwrap() + .peaks + .reference, + Default::default() + ); +} diff --git a/crates/core/src/actions/app_impl/processing_snapshot.rs b/crates/core/src/actions/app_impl/processing_snapshot.rs index 89c64d2..e81a47c 100644 --- a/crates/core/src/actions/app_impl/processing_snapshot.rs +++ b/crates/core/src/actions/app_impl/processing_snapshot.rs @@ -27,6 +27,34 @@ impl PlotiumApp { .doc .dataset_index(snapshot.resource_id()) .ok_or_else(|| ActionApplyError::StaleTarget(snapshot.resource_id().to_string()))?; + let reference = |d: &Dataset| { + d.as_nmr2d() + .and_then(|n| n.volume.as_ref()) + .map(|v| v.peaks.reference.offsets_ppm) + }; + if reference(&self.doc.datasets[index]) != reference(snapshot) { + // Pins contain sampled coordinates/readings and cannot survive a reference change, + // including when a snapshot is installed by undo or redo. + let ui = &mut self.session.ui; + let owner = snapshot.resource_id(); + if ui.inspect_cursor_pin.is_some_and(|p| p.dataset == owner) { + ui.inspect_cursor_pin = None; + } + if ui.delta_cursor_anchor.is_some_and(|p| p.dataset == owner) { + ui.delta_cursor_anchor = None; + ui.delta_cursor_pin = None; + } + if ui + .delta_cursor_pin + .is_some_and(|p| p.first.dataset == owner || p.second.dataset == owner) + { + ui.delta_cursor_pin = None; + ui.delta_cursor_anchor = None; + } + if ui.slice.is_some_and(|p| p.dataset == index) { + ui.slice = None; + } + } let phase_basis = |dataset: &Dataset| { dataset .as_nmr2d() diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 475884a..10b00e8 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -20,6 +20,7 @@ pub mod project; pub mod properties; pub mod protein; pub mod protein_context; +pub mod protein_reference; pub mod settings; pub mod state; pub mod templates; diff --git a/crates/core/src/project/convert.rs b/crates/core/src/project/convert.rs index 3ac10d4..14b4add 100644 --- a/crates/core/src/project/convert.rs +++ b/crates/core/src/project/convert.rs @@ -539,7 +539,9 @@ pub fn object_to_dataset( if let Some(peaks) = recipe.extensions["plotium.volume"].get("peaks") { let volume = dataset.volume.as_mut().unwrap(); volume.peaks = serde_json::from_value(peaks.clone())?; - volume.refresh_peaks().map_err(ProjectError::Invalid)?; + dataset + .restore_volume_reference() + .map_err(ProjectError::Invalid)?; } dataset.acquisition_identity = read_acquisition_identity(data)?; dataset.field_catalog = read_field_catalog(data)?; diff --git a/crates/core/src/protein_reference.rs b/crates/core/src/protein_reference.rs new file mode 100644 index 0000000..035d7ee --- /dev/null +++ b/crates/core/src/protein_reference.rs @@ -0,0 +1,189 @@ +//! Absolute acquisition-axis reference settings, separate from phase processing. +use crate::state::{DatasetId, PlotiumApp}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ShiftReference { + pub offsets_ppm: [f64; 3], + pub source: String, + pub confirmed: bool, +} +impl ShiftReference { + pub fn validate(&self) -> Result<(), String> { + if self.offsets_ppm.iter().any(|v| !v.is_finite()) { + return Err("Enter finite reference offsets in ppm".into()); + } + if (self.confirmed || self.offsets_ppm != [0.0; 3]) && self.source.trim().is_empty() { + return Err("Record the reference standard or comparison source".into()); + } + Ok(()) + } +} +impl PlotiumApp { + pub fn set_shift_reference( + &mut self, + dataset: DatasetId, + reference: ShiftReference, + ) -> Result<(), String> { + reference.validate()?; + let di = self + .doc + .dataset_index(dataset) + .ok_or("Select an existing spectrum")?; + let mut after = self.doc.datasets[di].clone(); + let nmr = after.as_nmr2d_mut().ok_or("Select a 3D spectrum")?; + let v = nmr.volume.as_mut().ok_or("Select a 3D spectrum")?; + let delta = + std::array::from_fn(|a| reference.offsets_ppm[a] - v.peaks.reference.offsets_ppm[a]); + v.processed = v.processed.shifted(delta)?; + for peak in &mut v.peaks.marks { + for (coordinate, shift) in peak.ppm.iter_mut().zip(delta) { + *coordinate += shift; + } + } + v.peaks.reference = reference; + v.refresh_peaks()?; + let (axis, index) = (v.fixed_axis, v.index); + nmr.select_volume_plane(axis, index)?; + let full = crate::state::CanvasViewport::from_figure(&nmr.processed_figure); + let remaining: Vec<_> = (0..3).filter(|a| *a != axis).collect(); + let mut actions = vec![self.volume_install_action(dataset, after)?]; + for (canvas, page) in self.doc.canvases.iter().enumerate() { + for object in &page.objects { + if let Some(plot) = object.plot().filter(|p| p.display_owner == Some(dataset)) { + let before = plot.viewport.clone(); + let mut after = before.clone(); + after.full_x = full.full_x; + after.full_y = full.full_y; + after.view_x = crate::state::AxisRange::new( + before.view_x.min + delta[remaining[1]], + before.view_x.max + delta[remaining[1]], + ); + after.view_y = crate::state::AxisRange::new( + before.view_y.min + delta[remaining[0]], + before.view_y.max + delta[remaining[0]], + ); + actions.push(crate::actions::Action::SetObjectViewport { + canvas, + object: object.id, + before, + after, + }); + } + } + } + self.try_execute_action(crate::actions::Action::Composite(actions)) + .map_err(|e| e.to_string()) + } + + /// Compare every confirmed, unambiguous single-atom observation without averaging. + pub fn reference_differences( + &self, + left: DatasetId, + right: DatasetId, + ) -> Result, String> { + self.check_reference_comparison(left, right)?; + let observations = |id| -> Result, String> { + let v = crate::protein::volume(&self.doc, id)?; + let peaks: HashMap<_, _> = v.peaks.marks.iter().map(|p| (p.id, p)).collect(); + let mut rows = Vec::new(); + for a in self + .doc + .protein + .assignments + .iter() + .filter(|a| a.dataset == id) + { + let Some(c) = a.candidates.iter().find(|c| Some(c.id) == a.confirmed) else { + continue; + }; + let peak = peaks.get(&a.peak).ok_or("Assignment peak is missing")?; + for (axis, target) in c.targets.iter().enumerate() { + if let Some(target) = target.as_ref().filter(|t| t.members.len() == 1) { + rows.push(( + a.peak, + axis, + target.clone(), + peak.ppm[axis], + v.processed.axes()[axis].nucleus().map(str::to_owned), + )); + } + } + } + Ok(rows) + }; + let mut differences = Vec::new(); + let left = observations(left)?; + let right = observations(right)?; + let mut by_atom = HashMap::<_, Vec<_>>::new(); + for b in &right { + if let Some(nucleus) = &b.4 { + by_atom + .entry((b.2.residue, b.2.members.clone(), nucleus.clone())) + .or_default() + .push(b); + } + } + for a in left { + let Some(nucleus) = &a.4 else { + continue; + }; + let Some(matches) = by_atom.get(&(a.2.residue, a.2.members.clone(), nucleus.clone())) + else { + continue; + }; + for b in matches { + if a.2 == b.2 && a.4.is_some() && a.4 == b.4 { + differences.push(ReferenceDifference { + left_peak: a.0, + right_peak: b.0, + left_axis: a.1, + right_axis: b.1, + delta_ppm: a.3 - b.3, + }); + } + } + } + Ok(differences) + } + + /// Explicit sample identity is required even when names/conditions appear identical. + pub fn check_reference_comparison( + &self, + left: DatasetId, + right: DatasetId, + ) -> Result<(), String> { + let context = &self.doc.protein.context; + context.validate(&self.doc)?; + let a = context + .experiments + .get(&left) + .ok_or("Record sample conditions for both spectra")?; + let b = context + .experiments + .get(&right) + .ok_or("Record sample conditions for both spectra")?; + if a.sample != b.sample { + return Err("Compare spectra from the same sample conditions; different samples cannot be merged".into()); + } + for id in [left, right] { + let v = crate::protein::volume(&self.doc, id)?; + if !v.peaks.reference.confirmed { + return Err("Review and confirm both spectrum references first".into()); + } + } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ReferenceDifference { + pub left_peak: crate::state::Peak3DId, + pub right_peak: crate::state::Peak3DId, + pub left_axis: usize, + pub right_axis: usize, + /// Left corrected ppm minus right corrected ppm; no automatic verdict. + pub delta_ppm: f64, +} diff --git a/crates/core/src/protein_reference_index_tests.rs b/crates/core/src/protein_reference_index_tests.rs new file mode 100644 index 0000000..773e716 --- /dev/null +++ b/crates/core/src/protein_reference_index_tests.rs @@ -0,0 +1,69 @@ +use super::*; + +#[test] +fn reference_index_keeps_all_matching_peak_pairs() { + use crate::protein_context::*; + use crate::protein_reference::ShiftReference; + let mut app = PlotiumApp::new(); + let molecule = import(&mut app, "index"); + let (left, lp, _) = add_spectrum(&mut app); + let (right, rp, _) = add_spectrum(&mut app); + let sample = SampleCondition::default(); + let mut context = ScientificContext { + samples: vec![sample.clone()], + ..Default::default() + }; + for (dataset, peak) in [(left, lp), (right, rp)] { + app.edit_protein(Edit::Bind { + dataset, + molecule: Some(molecule.id), + }) + .unwrap(); + let ppm = volume(&app.doc, dataset).unwrap().peaks.marks[0].ppm; + let duplicate = app.add_peak_3d(dataset, ppm).unwrap(); + for peak in [peak, duplicate] { + app.edit_assignment( + dataset, + peak, + AssignmentEdit::Add { + targets: [atom(molecule.chains[0].residues[0].id, &["H"]), None, None], + note: String::new(), + }, + ) + .unwrap(); + let candidate = app + .doc + .protein + .assignment(dataset, peak) + .unwrap() + .candidates[0] + .id; + app.edit_assignment(dataset, peak, AssignmentEdit::Confirm { candidate }) + .unwrap(); + } + context.experiments.insert( + dataset, + Experiment { + sample: sample.id, + kind: ExperimentKind::Unknown, + roles: Default::default(), + source: "test".into(), + }, + ); + app.set_shift_reference( + dataset, + ShiftReference { + source: "Standard".into(), + confirmed: true, + ..Default::default() + }, + ) + .unwrap(); + } + app.edit_scientific_context(context).unwrap(); + let rows = app.reference_differences(left, right).unwrap(); + assert_eq!(rows.len(), 4); + let pairs: HashSet<_> = rows.iter().map(|r| (r.left_peak, r.right_peak)).collect(); + assert_eq!(pairs.len(), 4); + assert!(rows.iter().all(|r| r.delta_ppm == 0.)); +} diff --git a/crates/core/src/protein_tests.rs b/crates/core/src/protein_tests.rs index 62291ea..c11a100 100644 --- a/crates/core/src/protein_tests.rs +++ b/crates/core/src/protein_tests.rs @@ -658,3 +658,73 @@ fn deleting_spectrum_cleans_only_its_metadata_and_undo_restores_it() { app.redo(); assert_eq!(app.doc.protein.context, after); } + +#[test] +fn reference_comparison_preserves_individual_corrected_observations() { + use crate::protein_context::*; + use crate::protein_reference::ShiftReference; + let mut app = PlotiumApp::new(); + let molecule = import(&mut app, "references"); + let (left, lp, _) = add_spectrum(&mut app); + let (right, rp, _) = add_spectrum(&mut app); + let sample = SampleCondition::default(); + let mut context = ScientificContext { + samples: vec![sample.clone()], + ..Default::default() + }; + for (dataset, peak) in [(left, lp), (right, rp)] { + app.edit_protein(Edit::Bind { + dataset, + molecule: Some(molecule.id), + }) + .unwrap(); + app.edit_assignment( + dataset, + peak, + AssignmentEdit::Add { + targets: [atom(molecule.chains[0].residues[0].id, &["H"]), None, None], + note: String::new(), + }, + ) + .unwrap(); + let candidate = app + .doc + .protein + .assignment(dataset, peak) + .unwrap() + .candidates[0] + .id; + app.edit_assignment(dataset, peak, AssignmentEdit::Confirm { candidate }) + .unwrap(); + context.experiments.insert( + dataset, + Experiment { + sample: sample.id, + kind: ExperimentKind::Unknown, + roles: Default::default(), + source: "test".into(), + }, + ); + app.set_shift_reference( + dataset, + ShiftReference { + source: "Standard".into(), + confirmed: true, + offsets_ppm: if dataset == left { + [0.125, 0.0, 0.0] + } else { + [0.0; 3] + }, + }, + ) + .unwrap(); + } + app.edit_scientific_context(context).unwrap(); + let rows = app.reference_differences(left, right).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!((rows[0].left_peak, rows[0].right_peak), (lp, rp)); + assert!((rows[0].delta_ppm - 0.125).abs() < 1e-10); +} + +#[path = "protein_reference_index_tests.rs"] +mod reference_index_tests; diff --git a/crates/core/src/state/app_impl_nmr_volume.rs b/crates/core/src/state/app_impl_nmr_volume.rs index dad4b1a..b3a48ec 100644 --- a/crates/core/src/state/app_impl_nmr_volume.rs +++ b/crates/core/src/state/app_impl_nmr_volume.rs @@ -224,9 +224,14 @@ impl PlotiumApp { // Navigation may have advanced while this phase job was running. if let Some(incoming) = result.volume.as_mut() { incoming.anchor = volume.anchor; + let delta = std::array::from_fn(|a| { + volume.peaks.reference.offsets_ppm[a] - incoming.peaks.reference.offsets_ppm[a] + }); + incoming.processed = incoming.processed.shifted(delta)?; incoming.peaks = volume.peaks.clone(); incoming.refresh_peaks()?; } + result.select_volume_plane(volume.fixed_axis, volume.index)?; // Preserve edits made while the numerical worker was running. let mut replacement = current.clone(); replacement.volume = result.volume; diff --git a/crates/core/src/state/nmr_volume.rs b/crates/core/src/state/nmr_volume.rs index ce1f3ef..8e3feef 100644 --- a/crates/core/src/state/nmr_volume.rs +++ b/crates/core/src/state/nmr_volume.rs @@ -124,7 +124,8 @@ impl Nmr2DDataset { .volume .as_ref() .ok_or("Open a three-dimensional spectrum first")?; - let processed = NmrVolume::process(&volume.source, phases, Default::default(), context)?; + let processed = NmrVolume::process(&volume.source, phases, Default::default(), context)? + .shifted(volume.peaks.reference.offsets_ppm)?; let plane = processed.plane_source(volume.fixed_axis, volume.index)?; self.replace_volume_plane(plane)?; let volume = self @@ -137,6 +138,17 @@ impl Nmr2DDataset { Ok(()) } + pub(crate) fn restore_volume_reference(&mut self) -> Result<(), String> { + let volume = self.volume.as_mut().ok_or("Open a 3D spectrum first")?; + volume.peaks.reference.validate()?; + volume.processed = volume + .processed + .shifted(volume.peaks.reference.offsets_ppm)?; + volume.refresh_peaks()?; + let (axis, index) = (volume.fixed_axis, volume.index); + self.select_volume_plane(axis, index) + } + fn replace_volume_plane(&mut self, plane: NmrSource) -> Result<(), String> { let result = Self::load_with_pipeline( plane, diff --git a/crates/core/src/state/peaks3d.rs b/crates/core/src/state/peaks3d.rs index fcd29e8..f85f32e 100644 --- a/crates/core/src/state/peaks3d.rs +++ b/crates/core/src/state/peaks3d.rs @@ -15,6 +15,8 @@ pub struct Peak3DMark { pub struct Peak3DSet { pub marks: Vec, pub next_id: u64, + #[serde(default)] + pub reference: crate::protein_reference::ShiftReference, } impl NmrVolumeState { pub fn peak_indices(&self, ppm: [f64; 3]) -> Result<[usize; 3], String> { @@ -88,6 +90,7 @@ impl PlotiumApp { "phases":volume.phases, "canonical_dataset_sha256":digest(volume.processed.source()) }, + "reference":volume.peaks.reference, "axes":axes, "peaks":volume.peaks.marks, "position":"manual observation in acquisition-axis order; not a fitted centre", "intensity":"signed nearest-grid sample; not integrated volume" diff --git a/crates/core/src/state/peaks3d_tests.rs b/crates/core/src/state/peaks3d_tests.rs index 53bbeec..64cfb5c 100644 --- a/crates/core/src/state/peaks3d_tests.rs +++ b/crates/core/src/state/peaks3d_tests.rs @@ -227,3 +227,130 @@ fn phase_refresh_preserves_edits_made_during_worker_and_roundtrips_export() { std::fs::remove_file(path).unwrap(); assert_eq!(restored.peak_table_3d(owner).unwrap(), table); } + +#[test] +fn reference_is_absolute_and_survives_phase_undo_and_project() { + use crate::protein_reference::ShiftReference; + let (mut app, dataset) = fixture(); + let original = crate::protein::volume(&app.doc, dataset) + .unwrap() + .processed + .axis_coordinate(0, 1) + .unwrap(); + let ppm = std::array::from_fn(|a| { + crate::protein::volume(&app.doc, dataset) + .unwrap() + .processed + .axis_coordinate(a, 1) + .unwrap() + }); + let id = app.add_peak_3d(dataset, ppm).unwrap(); + let reference = ShiftReference { + offsets_ppm: [0.1, -0.2, 0.3], + source: "Manual standard comparison".into(), + confirmed: true, + }; + app.set_shift_reference(dataset, reference.clone()).unwrap(); + app.set_shift_reference(dataset, reference.clone()).unwrap(); + let check = |app: &PlotiumApp| { + let v = crate::protein::volume(&app.doc, dataset).unwrap(); + assert!((v.processed.axis_coordinate(0, 1).unwrap() - original - 0.1).abs() < 1e-10); + assert_eq!(v.peaks.marks[0].id, id); + assert!((v.peaks.marks[0].ppm[0] - ppm[0] - 0.1).abs() < 1e-10); + assert_eq!( + app.peak_table_3d(dataset).unwrap()["reference"]["confirmed"], + true + ); + }; + check(&app); + app.set_nmr_volume_phases( + dataset, + [plotium_processing::nmr_volume::VolumePhase::default(); 3], + ) + .unwrap(); + check(&app); + app.undo(); + check(&app); + app.redo(); + check(&app); + let path = std::env::temp_dir().join(format!( + "plotium-reference-{}.plotium", + uuid::Uuid::new_v4() + )); + crate::project::save_project(&app, &path, false).unwrap(); + let restored = crate::project::load_project(&path).unwrap(); + std::fs::remove_file(path).unwrap(); + check(&restored); + let before = crate::protein::volume(&app.doc, dataset) + .unwrap() + .peaks + .clone(); + assert!( + app.set_shift_reference( + dataset, + ShiftReference { + offsets_ppm: [f64::NAN; 3], + ..reference + } + ) + .is_err() + ); + assert_eq!( + crate::protein::volume(&app.doc, dataset).unwrap().peaks, + before + ); + assert!(app.check_reference_comparison(dataset, dataset).is_err()); +} + +#[test] +fn reference_comparison_requires_same_sample_and_explicit_review() { + use crate::protein_context::*; + use crate::protein_reference::ShiftReference; + let (mut app, first) = fixture(); + let (other, _) = fixture(); + app.doc.datasets.push(other.doc.datasets[0].clone()); + let second_actual = app.doc.datasets[1].resource_id(); + let sample = SampleCondition::default(); + let other_sample = SampleCondition::default(); + let mut context = ScientificContext { + samples: vec![sample.clone(), other_sample.clone()], + ..Default::default() + }; + let experiment = Experiment { + sample: sample.id, + kind: ExperimentKind::Unknown, + roles: Default::default(), + source: "test".into(), + }; + context.experiments.insert(first, experiment.clone()); + context.experiments.insert(second_actual, experiment); + app.edit_scientific_context(context.clone()).unwrap(); + assert!( + app.check_reference_comparison(first, second_actual) + .unwrap_err() + .contains("confirm") + ); + for id in [first, second_actual] { + app.set_shift_reference( + id, + ShiftReference { + source: "External standard".into(), + confirmed: true, + ..Default::default() + }, + ) + .unwrap(); + } + app.check_reference_comparison(first, second_actual) + .unwrap(); + context.experiments.get_mut(&second_actual).unwrap().sample = other_sample.id; + app.edit_scientific_context(context).unwrap(); + assert!( + app.check_reference_comparison(first, second_actual) + .unwrap_err() + .contains("different samples") + ); +} + +#[path = "reference_undo_tests.rs"] +mod reference_undo; diff --git a/crates/core/src/state/reference_undo_tests.rs b/crates/core/src/state/reference_undo_tests.rs new file mode 100644 index 0000000..e47621b --- /dev/null +++ b/crates/core/src/state/reference_undo_tests.rs @@ -0,0 +1,130 @@ +use super::*; +use crate::protein::{AssignmentEdit, AtomTarget, Edit, GroupMeaning}; +use crate::protein_reference::ShiftReference; + +#[test] +fn reference_undo_redo_restores_coordinates_provenance_assignments_and_viewport() { + let (mut app, owner) = fixture(); + app.edit_protein(Edit::Import { + source: "test".into(), + fasta: ">test\nAA".into(), + name: None, + chains: None, + }) + .unwrap(); + let molecule = app.doc.protein.molecules[0].clone(); + app.edit_protein(Edit::Bind { + dataset: owner, + molecule: Some(molecule.id), + }) + .unwrap(); + let original: [f64; 3] = + std::array::from_fn(|a| volume(&app).processed.axis_coordinate(a, 1).unwrap()); + let peak = app.add_peak_3d(owner, original).unwrap(); + app.edit_assignment( + owner, + peak, + AssignmentEdit::Add { + targets: [ + Some(AtomTarget { + residue: molecule.chains[0].residues[0].id, + members: ["H".into()].into(), + group_meaning: GroupMeaning::Unspecified, + }), + None, + None, + ], + note: "manual".into(), + }, + ) + .unwrap(); + let candidate = app.doc.protein.assignment(owner, peak).unwrap().candidates[0].id; + app.edit_assignment(owner, peak, AssignmentEdit::Confirm { candidate }) + .unwrap(); + let protein = app.doc.protein.clone(); + let mut canvas = CanvasDocument::new("Reference test".into(), [180., 90.]); + let object = canvas.allocate_object_id(); + canvas.objects.push(app.build_plot_object( + 0, + ObjectFrame::new(8., 22., 235., 195.), + object, + "Spectrum".into(), + )); + app.doc.canvases.push(canvas); + let before_view = app.doc.canvases[0].objects[0] + .plot() + .unwrap() + .viewport + .clone(); + let before_export = app.peak_table_3d(owner).unwrap(); + let reference = ShiftReference { + offsets_ppm: [0.1, -0.2, 0.3], + source: "Standard".into(), + confirmed: true, + }; + let pin = CursorPoint { + canvas: app.doc.canvases[0].resource_id, + object, + dataset: owner, + x: original[2], + y: Some(original[1]), + intensity: 12., + }; + for step in 0..3 { + app.session.ui.inspect_cursor_pin = Some(pin); + app.session.ui.delta_cursor_anchor = Some(pin); + app.session.ui.delta_cursor_pin = Some(CursorDelta { + first: pin, + second: pin, + }); + match step { + 0 => app.set_shift_reference(owner, reference.clone()).unwrap(), + 1 => app.undo(), + _ => app.redo(), + } + assert!(app.session.ui.inspect_cursor_pin.is_none()); + assert!(app.session.ui.delta_cursor_anchor.is_none()); + assert!(app.session.ui.delta_cursor_pin.is_none()); + let corrected = step != 1; + let v = volume(&app); + for (axis, original) in original.into_iter().enumerate() { + let expected = original + + if corrected { + reference.offsets_ppm[axis] + } else { + 0. + }; + assert!((v.processed.axis_coordinate(axis, 1).unwrap() - expected).abs() < 1e-10); + assert!((v.peaks.marks[0].ppm[axis] - expected).abs() < 1e-10); + } + assert_eq!(v.peaks.marks[0].id, peak); + assert_eq!(app.doc.protein, protein); + assert_eq!( + v.peaks.reference, + if corrected { + reference.clone() + } else { + ShiftReference::default() + } + ); + let export = app.peak_table_3d(owner).unwrap(); + assert_eq!( + export["reference"], + serde_json::to_value(&v.peaks.reference).unwrap() + ); + assert_eq!( + export["peaks"], + serde_json::to_value(&v.peaks.marks).unwrap() + ); + let view = &app.doc.canvases[0].objects[0].plot().unwrap().viewport; + if corrected { + assert!((view.view_x.min - before_view.view_x.min - 0.3).abs() < 1e-10); + assert!((view.view_y.min - before_view.view_y.min + 0.2).abs() < 1e-10); + assert!((view.full_x.min - before_view.full_x.min - 0.3).abs() < 1e-10); + assert!((view.full_y.min - before_view.full_y.min + 0.2).abs() < 1e-10); + } else { + assert_eq!(*view, before_view); + assert_eq!(export, before_export); + } + } +} diff --git a/crates/processing/src/nmr_volume.rs b/crates/processing/src/nmr_volume.rs index f097dd4..716c4ef 100644 --- a/crates/processing/src/nmr_volume.rs +++ b/crates/processing/src/nmr_volume.rs @@ -105,6 +105,35 @@ impl NmrVolume { Ok(Self { source, shape }) } + /// Apply a delta to the current grid; callers retain absolute reference settings. + pub fn shifted(&self, offsets: [f64; 3]) -> Result { + let mut operations = Vec::new(); + for (axis, delta_ppm) in offsets.into_iter().enumerate() { + if !delta_ppm.is_finite() { + return Err("Reference offsets must be finite".into()); + } + if delta_ppm != 0.0 { + operations.push(ProcessingOperation::Spectrum { + axis, + operation: nmr::processing::SpectrumOperation::Reference { delta_ppm }, + }); + } + } + if operations.is_empty() { + return Ok(self.clone()); + } + let output = ProcessingPlan::new(operations) + .and_then(|p| { + p.apply_with_context( + self.source.dataset(), + Default::default(), + &mut ExecutionContext::default(), + ) + }) + .map_err(|e| e.to_string())?; + Self::from_processed(NmrSource::new(Arc::new(output)).map_err(|e| e.to_string())?) + } + pub fn source(&self) -> &NmrSource { &self.source } diff --git a/docs/src/content/docs/guides/protein-assignments.md b/docs/src/content/docs/guides/protein-assignments.md index a0b3d8f..123f8c0 100644 --- a/docs/src/content/docs/guides/protein-assignments.md +++ b/docs/src/content/docs/guides/protein-assignments.md @@ -32,3 +32,13 @@ The draft retains its original spectrum and context baseline. If Undo/Redo or an Experiments reference a stable sample-condition ID. Reuse an ID only for identical conditions; use separate records for different conditions. Roles refer to acquisition F1/F2/F3, independently of display order and acquired nuclei. `observed_proton` and `indirect_proton` distinguish the two proton axes in NOESY. Unknown experiments/roles remain browsable and assignable, but block role-dependent structure operations. Known roles require matching acquired nuclei. The initial structure scope is one standard-residue chain, one major conformer and supported dense 3D spectra, with manual assignments and external calculation. The scope check reports missing context or unsupported chemistry; it does not validate experiment connectivity, assignments or NOE measurements. No structure calculation or constraint generation is supplied by this step. + +## Chemical-shift reference + +In the Protein assignments panel, expand **Chemical-shift reference** and select **Edit reference**. Enter absolute F1/F2/F3 offsets in acquisition order (ppm), record the standard or comparison evidence, and explicitly confirm your review before saving. Corrected coordinates equal acquisition coordinates plus the offset; saving the same offset twice does not add it twice. Spectrum planes, peak coordinates and JSON exports use corrected ppm. Peak IDs and assignments remain stable. Undo/redo and project saving retain the reference and its source. Phase changes retain the correction. + +The panel reports reference readiness against other spectra with experiment metadata. Both references require manual confirmation and the same sample-condition ID. Different conditions cannot be silently merged. This is a manual reference review, not an automatic peak alignment or certification of matching nuclei, axis roles or chemical shifts. Review those before combining observations. Reference changes may invalidate downstream scientific interpretations. + +Confirmed single-atom observations with matching atom identities and nuclei are shown individually as left-minus-right corrected ppm differences. Ambiguous groups are excluded; no matches means not evaluated. No averaging, tolerance verdict or automatic correction is performed. + +The reference form names its draft target. When you switch spectra, a warning explains that Save still edits that target. Reference coordinate changes, including undo/redo, clear affected Inspect/Delta pins and slice cursors because their stored readings are no longer valid. diff --git a/docs/src/content/docs/zh-cn/guides/protein-assignments.md b/docs/src/content/docs/zh-cn/guides/protein-assignments.md index aa50243..40efd20 100644 --- a/docs/src/content/docs/zh-cn/guides/protein-assignments.md +++ b/docs/src/content/docs/zh-cn/guides/protein-assignments.md @@ -32,3 +32,13 @@ description: 将蛋白序列绑定到三维谱图并人工确认峰指认。 实验通过稳定的样品条件 ID 关联样品。仅在条件完全相同时复用 ID,不同条件使用不同记录。角色按采集轴 F1/F2/F3 排列,独立于显示顺序和采集核种。`observed_proton` 与 `indirect_proton` 区分 NOESY 的两个质子轴。未知实验和角色仍可浏览及指认,但不能进入依赖角色的结构操作;已知角色必须匹配采集核种。 首版结构流程限定单链标准残基、单一主要构象和受支持的稠密 3D 谱,采用人工指认与外部计算。范围检查报告缺失上下文及不支持的化学体系,不认证实验连通性、指认或 NOE 测量;本步骤尚未提供结构计算或约束生成。 + +## 化学位移参考 + +在蛋白指认面板展开 **Chemical-shift reference**,点击 **Edit reference**。按采集顺序填写 F1/F2/F3 的绝对偏移量(ppm),记录参考标准或比较证据,并手工确认审阅后保存。校正坐标等于采集坐标加偏移量;重复保存同一偏移量不会重复叠加。谱图切片、峰坐标和 JSON 导出均使用校正后的 ppm。峰 ID 和指认保持稳定,撤销/重做及项目保存保留参考及来源,相位变化也会保留校正。 + +面板显示与其他已记录实验元数据的谱图进行参考比较的准备状态。双方必须手工确认参考,并关联同一个样品条件 ID;不同条件不能静默合并。这是人工参考审阅,不是自动峰对齐,也不认证核种、轴角色或化学位移一致性。合并观测前仍需检查这些信息。参考变更可能使下游科学解释失效。 + +对原子身份和核种相同的已确认单原子观测,逐条显示左谱减右谱的校正后 ppm 差值。排除歧义原子组;没有匹配项时显示未评估。不做平均、容差判定或自动校正。 + +参考表单显示草稿目标谱名。切换谱图后会提示:保存仍修改该草稿目标。参考坐标变化(包括撤销/重做)会清除受影响的 Inspect/Delta 固定游标及切片游标,因为原有读数已失效。