diff --git a/crates/app/src/ui/tools/protein.rs b/crates/app/src/ui/tools/protein.rs index ec227d2..810af27 100644 --- a/crates/app/src/ui/tools/protein.rs +++ b/crates/app/src/ui/tools/protein.rs @@ -15,6 +15,7 @@ struct Panel { confirmed_only: bool, feedback: String, clear_spectrum: Option<(DatasetId, u64)>, + context_draft: Option, } #[derive(Clone)] @@ -242,6 +243,7 @@ fn body(app: &mut PlotiumApp, ui: &mut Ui, panel: &mut Panel) { sequence(app, ui, panel, &m) }); } + scientific_context(app, ui, panel, active); clear_review(app, ui, panel); ui.separator(); if panel.draft.is_some() { @@ -448,3 +450,7 @@ use context::{assignment_context, clear_review, spectrum_name}; #[cfg(test)] #[path = "protein_tests.rs"] mod tests; + +#[path = "protein_scientific_context.rs"] +mod scientific_context_editor; +use scientific_context_editor::scientific_context; diff --git a/crates/app/src/ui/tools/protein_scientific_context.rs b/crates/app/src/ui/tools/protein_scientific_context.rs new file mode 100644 index 0000000..aa604dd --- /dev/null +++ b/crates/app/src/ui/tools/protein_scientific_context.rs @@ -0,0 +1,260 @@ +use super::*; +use plotium_core::protein_context::{ + Experiment, ExperimentAxisRole, ExperimentKind, SampleCondition, SampleConditionId, + ScientificContext, +}; + +#[derive(Clone)] +pub(super) struct ContextDraft { + baseline: ScientificContext, + context: ScientificContext, + dataset: Option, + sample: Option, + json: Option, +} +impl ContextDraft { + fn new(app: &PlotiumApp, dataset: Option) -> Self { + let dataset = dataset.filter(|&d| protein::volume(&app.doc, d).is_ok()); + let context = app.doc.protein.context.clone(); + let sample = dataset + .and_then(|d| context.experiments.get(&d).map(|e| e.sample)) + .or_else(|| context.samples.first().map(|s| s.id)); + Self { + baseline: context.clone(), + context, + dataset, + sample, + json: None, + } + } + fn missing_target(&self, app: &PlotiumApp) -> bool { + self.dataset + .is_some_and(|d| protein::volume(&app.doc, d).is_err()) + } + fn conflicted(&self, app: &PlotiumApp) -> bool { + self.missing_target(app) || self.baseline != app.doc.protein.context + } + fn keep_samples_only(&mut self) { + if let Some(dataset) = self.dataset.take() { + self.context.experiments.remove(&dataset); + } + } + fn save(&self, app: &mut PlotiumApp) -> Result<(), String> { + if self.missing_target(app) { + return Err( + "The draft spectrum was deleted. Continue with sample conditions only or reload." + .into(), + ); + } + if self.json.is_some() { + return Err("Apply or cancel advanced JSON edits before saving.".into()); + } + app.save_scientific_context_draft(&self.baseline, self.context.clone()) + } +} + +pub(super) fn scientific_context( + app: &mut PlotiumApp, + ui: &mut Ui, + panel: &mut Panel, + active: Option, +) { + ui.collapsing("Scientific context", |ui| { + ui.label("Initial scope: single standard-residue chain, single major conformer, dense 3D spectra."); + if let Some(dataset) = active { + ui.label(format!("Scope check: {}", app.doc.protein.context + .check_structure_scope(&app.doc, dataset) + .map_or_else(|e| e, |()| "Within initial scope; scientific evidence is not yet validated".into()))); + } + if panel.context_draft.is_none() && ui.button("Edit sample conditions and experiments").clicked() { + panel.context_draft = Some(ContextDraft::new(app, active)); + } + let Some(draft) = &mut panel.context_draft else { return }; + if draft.conflicted(app) { + if draft.missing_target(app) { + ui.label("The draft spectrum was deleted. Reload, or keep sample edits and continue without this spectrum. Apply or cancel JSON edits first."); + if ui.add_enabled(draft.json.is_none(), egui::Button::new("Keep sample edits (sample-only)")).clicked() { + draft.keep_samples_only(); + panel.feedback.clear(); + } + } else { + ui.label("Scientific context changed since editing began. Saving is blocked. Reload current context or discard this draft."); + } + if ui.button("Reload current context (discard draft)").clicked() { + *draft = ContextDraft::new(app, draft.dataset); + panel.feedback.clear(); + } + } + if let Some(dataset) = draft.dataset { + ui.label(format!("Editing spectrum: {}", spectrum_name(app, dataset))); + } else { + ui.label("Editing sample conditions only. Select a 3D spectrum before opening a new draft to edit its experiment."); + } + let advanced = draft.json.is_some(); + ui.add_enabled_ui(!advanced, |ui| form(ui, draft)); + if draft.json.is_none() && ui.button("Advanced JSON").clicked() { + match serde_json::to_string_pretty(&draft.context) { + Ok(text) => draft.json = Some(text), + Err(e) => panel.feedback = e.to_string(), + } + } + if let Some(text) = &mut draft.json { + ui.label("Advanced edits affect all sample and experiment records. Apply JSON to the draft before saving."); + ui.add(egui::TextEdit::multiline(text).code_editor().desired_rows(14)); + if ui.button("Apply JSON to draft").clicked() { + match serde_json::from_str::(text) + .map_err(|e| e.to_string()) + .and_then(|context| { context.validate(&app.doc)?; Ok(context) }) + { + Ok(context) => { + draft.context = context; + draft.sample = draft.dataset.and_then(|d| draft.context.experiments.get(&d).map(|e| e.sample)) + .or_else(|| draft.context.samples.first().map(|s| s.id)); + draft.json = None; + panel.feedback.clear(); + } + Err(e) => panel.feedback = e, + } + } + if ui.button("Cancel JSON edits").clicked() { draft.json = None; } + } + let save = ui.add_enabled(!draft.conflicted(app) && draft.json.is_none(), egui::Button::new("Save scientific context")).clicked(); + if save { + match draft.save(app) { + Ok(()) => { panel.context_draft = None; panel.feedback.clear(); } + Err(e) => panel.feedback = e, + } + } + if ui.button("Discard context edits").clicked() { + panel.context_draft = None; + panel.feedback.clear(); + } + }); +} + +fn form(ui: &mut Ui, draft: &mut ContextDraft) { + ui.label("Reuse a sample only for identical conditions. Editing a shared sample affects every linked spectrum."); + ComboBox::from_id_salt("context_sample") + .selected_text( + draft + .sample + .and_then(|id| draft.context.samples.iter().find(|s| s.id == id)) + .map_or("Select sample", |s| s.name.as_str()), + ) + .show_ui(ui, |ui| { + for (i, sample) in draft.context.samples.iter().enumerate() { + ui.selectable_value( + &mut draft.sample, + Some(sample.id), + format!("{} ({})", sample.name, i + 1), + ); + } + }); + if ui.button("New sample conditions").clicked() { + let sample = SampleCondition { + name: format!("Sample conditions {}", draft.context.samples.len() + 1), + ..Default::default() + }; + draft.sample = Some(sample.id); + draft.context.samples.push(sample); + } + if let Some(sample) = draft + .sample + .and_then(|id| draft.context.samples.iter_mut().find(|s| s.id == id)) + { + field(ui, "Sample name", &mut sample.name); + optional_number(ui, "Temperature (K)", &mut sample.temperature_kelvin, 298.0); + optional_number(ui, "pH (0–14)", &mut sample.ph, 7.0); + field(ui, "Solvent", &mut sample.solvent); + field(ui, "Isotope labeling", &mut sample.isotope_labeling); + field(ui, "Construct", &mut sample.construct); + field( + ui, + "Nonstandard chemistry / ligands / special bonding", + &mut sample.nonstandard_chemistry, + ); + ui.checkbox( + &mut sample.single_major_conformer, + "Reviewed: single major conformer", + ); + } + let Some(dataset) = draft.dataset else { return }; + let Some(sample) = draft.sample else { + ui.label("Select or create sample conditions to record this experiment."); + return; + }; + let experiment = draft + .context + .experiments + .entry(dataset) + .or_insert(Experiment { + sample, + kind: ExperimentKind::Unknown, + roles: Default::default(), + source: String::new(), + }); + experiment.sample = sample; + ComboBox::from_id_salt("context_experiment") + .selected_text(kind_name(experiment.kind)) + .show_ui(ui, |ui| { + for kind in [ + ExperimentKind::Unknown, + ExperimentKind::Noesy, + ExperimentKind::Hnca, + ExperimentKind::Hnco, + ] { + ui.selectable_value(&mut experiment.kind, kind, kind_name(kind)); + } + }); + ui.label("Axis roles follow acquisition F1/F2/F3 order. Acquired nuclei remain unchanged."); + for (axis, role) in experiment.roles.iter_mut().enumerate() { + ComboBox::from_id_salt(("context_role", axis)) + .selected_text(format!("F{}: {}", axis + 1, role_name(*role))) + .show_ui(ui, |ui| { + for value in [ + ExperimentAxisRole::Unknown, + ExperimentAxisRole::ObservedProton, + ExperimentAxisRole::IndirectProton, + ExperimentAxisRole::Nitrogen, + ExperimentAxisRole::Carbon, + ] { + ui.selectable_value(role, value, role_name(value)); + } + }); + } + field(ui, "Experiment metadata source", &mut experiment.source); +} +fn field(ui: &mut Ui, label: &str, text: &mut String) { + ui.label(label); + ui.text_edit_singleline(text); +} +fn optional_number(ui: &mut Ui, label: &str, value: &mut Option, initial: f64) { + let mut known = value.is_some(); + if ui.checkbox(&mut known, label).changed() { + *value = known.then_some(initial); + } + if let Some(value) = value { + ui.add(DragValue::new(value).speed(0.1)); + } +} +fn kind_name(kind: ExperimentKind) -> &'static str { + match kind { + ExperimentKind::Unknown => "Unknown experiment", + ExperimentKind::Noesy => "NOESY", + ExperimentKind::Hnca => "HNCA", + ExperimentKind::Hnco => "HNCO", + } +} +fn role_name(role: ExperimentAxisRole) -> &'static str { + match role { + ExperimentAxisRole::Unknown => "Unknown role", + ExperimentAxisRole::ObservedProton => "Observed proton", + ExperimentAxisRole::IndirectProton => "Indirect proton", + ExperimentAxisRole::Nitrogen => "Nitrogen", + ExperimentAxisRole::Carbon => "Carbon", + } +} + +#[cfg(test)] +#[path = "protein_scientific_context_tests.rs"] +mod tests; diff --git a/crates/app/src/ui/tools/protein_scientific_context_tests.rs b/crates/app/src/ui/tools/protein_scientific_context_tests.rs new file mode 100644 index 0000000..9cdfe41 --- /dev/null +++ b/crates/app/src/ui/tools/protein_scientific_context_tests.rs @@ -0,0 +1,386 @@ +use super::*; + +#[test] +fn draft_conflicts_after_edits_undo_redo_and_reloads_without_overwriting() { + let mut app = PlotiumApp::new(); + let mut draft = ContextDraft::new(&app, None); + draft.context.samples.push(SampleCondition::default()); + let mut concurrent = ScientificContext::default(); + concurrent.samples.push(SampleCondition { + name: "Concurrent edit".into(), + ..Default::default() + }); + app.edit_scientific_context(concurrent.clone()).unwrap(); + assert!(draft.conflicted(&app)); + assert!(draft.save(&mut app).unwrap_err().contains("changed")); + assert_eq!(app.doc.protein.context, concurrent); + let current_draft = ContextDraft::new(&app, None); + app.undo(); + assert!(current_draft.conflicted(&app)); + assert!(current_draft.save(&mut app).is_err()); + assert!(app.doc.protein.context.is_empty()); + let undo_draft = ContextDraft::new(&app, None); + app.redo(); + assert!(undo_draft.save(&mut app).is_err()); + let mut reloaded = ContextDraft::new(&app, current_draft.dataset); + assert!(!reloaded.conflicted(&app)); + reloaded.context.samples[0].name = "Reviewed".into(); + reloaded.save(&mut app).unwrap(); + assert_eq!(app.doc.protein.context.samples[0].name, "Reviewed"); +} + +#[test] +fn form_creates_and_reuses_samples_without_changing_document_before_save() { + let mut app = PlotiumApp::new(); + let ctx = egui::Context::default(); + let mut draft = ContextDraft::new(&app, None); + let sample = SampleCondition::default(); + let id = sample.id; + draft.context.samples.push(sample); + draft.sample = Some(id); + let _output = ctx.run_ui(Default::default(), |ui| form(ui, &mut draft)); + assert_eq!(draft.context.samples.len(), 1); + assert_eq!(draft.sample, Some(id)); + assert!(app.doc.protein.context.is_empty()); + draft.context.samples[0].ph = Some(14.01); + assert!(draft.save(&mut app).is_err()); + assert!(app.doc.protein.context.is_empty()); +} + +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| scientific_context(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 deleted_unsaved_spectrum_offers_recovery_preserving_sample_edits() { + let mut app = PlotiumApp::new(); + let dataset = spectrum(&mut app); + let ctx = egui::Context::default(); + let mut panel = Panel::default(); + click( + &mut app, + &mut panel, + &ctx, + Some(dataset), + "Scientific context", + ); + click( + &mut app, + &mut panel, + &ctx, + Some(dataset), + "Edit sample conditions and experiments", + ); + click( + &mut app, + &mut panel, + &ctx, + Some(dataset), + "New sample conditions", + ); + let samples = panel + .context_draft + .as_ref() + .unwrap() + .context + .samples + .clone(); + let baseline = app.doc.protein.context.clone(); + app.try_execute_action(app.delete_dataset_action(0).unwrap()) + .unwrap(); + assert_eq!(app.doc.protein.context, baseline); + assert!(panel.context_draft.as_ref().unwrap().conflicted(&app)); + assert!( + panel + .context_draft + .as_ref() + .unwrap() + .save(&mut app) + .unwrap_err() + .contains("deleted") + ); + click(&mut app, &mut panel, &ctx, None, "Save scientific context"); + assert_eq!(app.doc.protein.context, baseline); + click( + &mut app, + &mut panel, + &ctx, + None, + "Keep sample edits (sample-only)", + ); + assert!(panel.context_draft.as_ref().unwrap().dataset.is_none()); + click(&mut app, &mut panel, &ctx, None, "Save scientific context"); + assert_eq!(app.doc.protein.context.samples, samples); + assert!(app.doc.protein.context.experiments.is_empty()); +} + +#[test] +fn widgets_create_switch_share_and_save_samples_and_roundtrip_advanced_json() { + let mut app = PlotiumApp::new(); + let first = spectrum(&mut app); + let second = spectrum(&mut app); + let ctx = egui::Context::default(); + let mut panel = Panel::default(); + click( + &mut app, + &mut panel, + &ctx, + Some(first), + "Scientific context", + ); + click( + &mut app, + &mut panel, + &ctx, + Some(first), + "Edit sample conditions and experiments", + ); + click( + &mut app, + &mut panel, + &ctx, + Some(first), + "New sample conditions", + ); + let shared = panel.context_draft.as_ref().unwrap().sample.unwrap(); + click( + &mut app, + &mut panel, + &ctx, + Some(first), + "New sample conditions", + ); + click( + &mut app, + &mut panel, + &ctx, + Some(first), + "Sample conditions 2", + ); + click( + &mut app, + &mut panel, + &ctx, + Some(first), + "Sample conditions 1 (1)", + ); + click( + &mut app, + &mut panel, + &ctx, + Some(first), + "Unknown experiment", + ); + click(&mut app, &mut panel, &ctx, Some(first), "NOESY"); + click(&mut app, &mut panel, &ctx, Some(first), "F1: Unknown role"); + click(&mut app, &mut panel, &ctx, Some(first), "Indirect proton"); + click( + &mut app, + &mut panel, + &ctx, + Some(first), + "Save scientific context", + ); + assert!(panel.context_draft.is_none()); + assert_eq!(app.doc.protein.context.experiments[&first].sample, shared); + assert_eq!( + app.doc.protein.context.experiments[&first].roles[0], + ExperimentAxisRole::IndirectProton + ); + click( + &mut app, + &mut panel, + &ctx, + Some(second), + "Edit sample conditions and experiments", + ); + click( + &mut app, + &mut panel, + &ctx, + Some(second), + "Sample conditions 1", + ); + click( + &mut app, + &mut panel, + &ctx, + Some(second), + "Sample conditions 2 (2)", + ); + click( + &mut app, + &mut panel, + &ctx, + Some(second), + "Sample conditions 2", + ); + click( + &mut app, + &mut panel, + &ctx, + Some(second), + "Sample conditions 1 (1)", + ); + click(&mut app, &mut panel, &ctx, Some(second), "Advanced JSON"); + let before = app.doc.protein.context.clone(); + // Replace the focused JSON text through keyboard events, then use the real Apply button. + let output = frame(&mut app, &mut panel, &ctx, Some(second), vec![]); + let pos = output + .shapes + .iter() + .find_map(|s| match &s.shape { + egui::Shape::Text(t) if t.galley.text().starts_with('{') => { + Some(t.pos + egui::vec2(4., 5.)) + } + _ => None, + }) + .unwrap(); + for pressed in [true, false] { + frame( + &mut app, + &mut panel, + &ctx, + Some(second), + vec![ + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed, + modifiers: Default::default(), + }, + ], + ); + } + let mut edited = panel.context_draft.as_ref().unwrap().context.clone(); + edited.samples[0].solvent = "D2O".into(); + let modifiers = egui::Modifiers { + ctrl: true, + command: true, + ..Default::default() + }; + frame( + &mut app, + &mut panel, + &ctx, + Some(second), + vec![ + egui::Event::Key { + key: egui::Key::A, + physical_key: None, + pressed: true, + repeat: false, + modifiers, + }, + egui::Event::Text(serde_json::to_string_pretty(&edited).unwrap()), + ], + ); + click( + &mut app, + &mut panel, + &ctx, + Some(second), + "Save scientific context", + ); + assert_eq!(app.doc.protein.context, before); + click( + &mut app, + &mut panel, + &ctx, + Some(second), + "Apply JSON to draft", + ); + assert!( + panel.context_draft.as_ref().unwrap().json.is_none(), + "{}", + panel.feedback + ); + assert_eq!( + panel.context_draft.as_ref().unwrap().context.samples[0].solvent, + "D2O" + ); + assert_eq!(app.doc.protein.context, before); + click(&mut app, &mut panel, &ctx, Some(second), "Advanced JSON"); + click( + &mut app, + &mut panel, + &ctx, + Some(second), + "Cancel JSON edits", + ); + click( + &mut app, + &mut panel, + &ctx, + Some(second), + "Save scientific context", + ); + assert!(panel.context_draft.is_none()); + assert_eq!(app.doc.protein.context.experiments[&second].sample, shared); + assert_eq!(app.doc.protein.context.samples.len(), 2); + assert_eq!(app.doc.protein.context.samples[0].solvent, "D2O"); +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 0918fbd..475884a 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -19,6 +19,7 @@ mod origin_tests; pub mod project; pub mod properties; pub mod protein; +pub mod protein_context; pub mod settings; pub mod state; pub mod templates; diff --git a/crates/core/src/protein.rs b/crates/core/src/protein.rs index 1d033e9..2f1ea0c 100644 --- a/crates/core/src/protein.rs +++ b/crates/core/src/protein.rs @@ -20,6 +20,8 @@ id!(CandidateId); #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct ProteinState { + #[serde(default)] + pub context: crate::protein_context::ScientificContext, pub molecules: Vec, pub bindings: BTreeMap, pub assignments: Vec, @@ -193,7 +195,10 @@ pub fn import_preview(source: &str, fasta: &str) -> Result { impl ProteinState { pub fn is_empty(&self) -> bool { - self.molecules.is_empty() && self.bindings.is_empty() && self.assignments.is_empty() + self.context.is_empty() + && self.molecules.is_empty() + && self.bindings.is_empty() + && self.assignments.is_empty() } pub fn molecule(&self, id: MoleculeId) -> Result<&Molecule, String> { self.molecules @@ -207,11 +212,13 @@ impl ProteinState { .find(|a| a.dataset == dataset && a.peak == peak) } pub fn remove_dataset(&mut self, dataset: DatasetId) { + self.context.experiments.remove(&dataset); self.bindings.remove(&dataset); self.assignments.retain(|a| a.dataset != dataset); } pub fn validate(&self, doc: &Document) -> Result<(), String> { + self.context.validate(doc)?; let (mut molecules, mut chains, mut residues, mut peaks, mut candidates) = ( HashSet::new(), HashSet::new(), @@ -518,7 +525,7 @@ impl PlotiumApp { self.commit_protein(state) } - fn commit_protein(&mut self, after: ProteinState) -> Result<(), String> { + pub(crate) fn commit_protein(&mut self, after: ProteinState) -> Result<(), String> { after.validate(&self.doc)?; if after != self.doc.protein { self.try_execute_action(self.protein_action(after)) diff --git a/crates/core/src/protein_context.rs b/crates/core/src/protein_context.rs new file mode 100644 index 0000000..0ab5b54 --- /dev/null +++ b/crates/core/src/protein_context.rs @@ -0,0 +1,205 @@ +//! Scientific context is independent of manual assignment confirmation. +use crate::protein::{self, nucleus_element}; +use crate::state::{DatasetId, Document, PlotiumApp}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, HashSet}; +use uuid::Uuid; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct SampleConditionId(pub Uuid); + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SampleCondition { + pub id: SampleConditionId, + pub name: String, + pub temperature_kelvin: Option, + pub ph: Option, + pub solvent: String, + pub isotope_labeling: String, + pub construct: String, + /// Record unsupported chemistry without preventing browsing or annotation. + pub nonstandard_chemistry: String, + pub single_major_conformer: bool, +} + +impl Default for SampleCondition { + fn default() -> Self { + Self { + id: SampleConditionId(Uuid::new_v4()), + name: "Sample conditions".into(), + temperature_kelvin: None, + ph: None, + solvent: String::new(), + isotope_labeling: String::new(), + construct: String::new(), + nonstandard_chemistry: String::new(), + single_major_conformer: false, + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExperimentKind { + #[default] + Unknown, + Noesy, + Hnca, + Hnco, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExperimentAxisRole { + #[default] + Unknown, + ObservedProton, + IndirectProton, + Nitrogen, + Carbon, +} +impl ExperimentAxisRole { + fn element(self) -> Option<&'static str> { + match self { + Self::Unknown => None, + Self::ObservedProton | Self::IndirectProton => Some("H"), + Self::Nitrogen => Some("N"), + Self::Carbon => Some("C"), + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Experiment { + pub sample: SampleConditionId, + pub kind: ExperimentKind, + /// Acquisition order F1/F2/F3, never display-axis order. + pub roles: [ExperimentAxisRole; 3], + pub source: String, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ScientificContext { + pub samples: Vec, + pub experiments: BTreeMap, +} +impl ScientificContext { + pub fn is_empty(&self) -> bool { + self.samples.is_empty() && self.experiments.is_empty() + } + pub fn validate(&self, doc: &Document) -> Result<(), String> { + let mut ids = HashSet::new(); + for s in &self.samples { + if !ids.insert(s.id) || s.name.trim().is_empty() { + return Err("Sample conditions need a unique ID and nonempty name".into()); + } + if s.temperature_kelvin + .is_some_and(|v| !v.is_finite() || v <= 0.0) + || s.ph + .is_some_and(|v| !v.is_finite() || !(0.0..=14.0).contains(&v)) + { + return Err("Enter positive temperature in kelvin and pH between 0 and 14".into()); + } + } + for (&dataset, e) in &self.experiments { + let v = protein::volume(doc, dataset)?; + if !ids.contains(&e.sample) { + return Err("Experiment references missing sample conditions".into()); + } + for (axis, role) in e.roles.iter().enumerate() { + if let Some(element) = role.element() + && nucleus_element(v.processed.axes()[axis].nucleus().unwrap_or_default()) + .is_none_or(|n| !n.eq_ignore_ascii_case(element)) + { + return Err(format!( + "F{} role requires a known {element} nucleus", + axis + 1 + )); + } + } + } + Ok(()) + } + + /// Scope gate only: this does not certify assignments or quantitative evidence. + pub fn check_structure_scope(&self, doc: &Document, dataset: DatasetId) -> Result<(), String> { + self.validate(doc)?; + let e = self + .experiments + .get(&dataset) + .ok_or("Record experiment metadata first")?; + let s = self + .samples + .iter() + .find(|s| s.id == e.sample) + .ok_or("Missing sample conditions")?; + let m = doc.protein.molecule( + *doc.protein + .bindings + .get(&dataset) + .ok_or("Bind a molecule first")?, + )?; + if m.chains.len() != 1 { + return Err("Structure workflow supports a single standard-residue chain only".into()); + } + if !s.nonstandard_chemistry.trim().is_empty() { + return Err("Modified residues, ligands and special bonding are outside the first structure workflow".into()); + } + if !s.single_major_conformer { + return Err( + "Confirm a single major conformer before entering the structure workflow".into(), + ); + } + if e.kind == ExperimentKind::Unknown || e.roles.contains(&ExperimentAxisRole::Unknown) { + return Err("Specify the experiment and every acquisition-axis role first".into()); + } + Ok(()) + } + + pub fn check_noe_roles(&self, doc: &Document, dataset: DatasetId) -> Result<(), String> { + self.check_structure_scope(doc, dataset)?; + let e = self.experiments.get(&dataset).ok_or("Missing experiment")?; + if e.kind != ExperimentKind::Noesy + || e.roles + .iter() + .filter(|&&r| r == ExperimentAxisRole::ObservedProton) + .count() + != 1 + || e.roles + .iter() + .filter(|&&r| r == ExperimentAxisRole::IndirectProton) + .count() + != 1 + { + return Err( + "NOE generation requires NOESY with distinct observed and indirect proton axes" + .into(), + ); + } + Ok(()) + } +} +impl PlotiumApp { + pub fn save_scientific_context_draft( + &mut self, + baseline: &ScientificContext, + context: ScientificContext, + ) -> Result<(), String> { + if baseline != &self.doc.protein.context { + return Err( + "Scientific context changed. Reload current context or discard this draft.".into(), + ); + } + self.edit_scientific_context(context) + } + + pub fn edit_scientific_context(&mut self, context: ScientificContext) -> Result<(), String> { + let mut state = self.doc.protein.clone(); + state.context = context; + self.commit_protein(state) + } +} diff --git a/crates/core/src/protein_tests.rs b/crates/core/src/protein_tests.rs index 5c2f131..62291ea 100644 --- a/crates/core/src/protein_tests.rs +++ b/crates/core/src/protein_tests.rs @@ -468,3 +468,193 @@ fn group_interpretation_is_explicit_and_survives_confirmation_undo_and_export() } } } +#[test] +fn scientific_context_roundtrip_undo_and_scope_boundaries() { + use crate::protein_context::*; + let mut app = PlotiumApp::new(); + let m = import(&mut app, "context"); + let (dataset, _, _) = add_spectrum(&mut app); + app.edit_protein(Edit::Bind { + dataset, + molecule: Some(m.id), + }) + .unwrap(); + let mut context = ScientificContext::default(); + let sample = SampleCondition::default(); + context.experiments.insert( + dataset, + Experiment { + sample: sample.id, + kind: ExperimentKind::Unknown, + roles: Default::default(), + source: "manual experimental record".into(), + }, + ); + context.samples.push(sample); + app.edit_scientific_context(context.clone()).unwrap(); + assert!( + context + .check_structure_scope(&app.doc, dataset) + .unwrap_err() + .contains("single") + ); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("context.plotium"); + save_project(&app, &path, false).unwrap(); + let loaded = load_project(&path).unwrap(); + assert_eq!(loaded.doc.protein.context, context); + let mut invalid = context.clone(); + invalid.samples[0].temperature_kelvin = Some(-1.0); + assert!(app.edit_scientific_context(invalid).is_err()); + assert_eq!(app.doc.protein.context, context); + app.undo(); + assert!(app.doc.protein.context.is_empty()); + app.redo(); + assert_eq!(app.doc.protein.context, context); + app.doc + .protein + .context + .experiments + .get_mut(&dataset) + .unwrap() + .sample = SampleConditionId(uuid::Uuid::new_v4()); + assert!(app.doc.protein.validate(&app.doc).is_err()); +} +#[test] +fn structure_scope_requires_explicit_roles_and_supported_chemistry() { + use crate::protein_context::*; + let mut app = PlotiumApp::new(); + app.edit_protein(Edit::Import { + source: "single.fasta".into(), + fasta: include_str!("../../io/tests/fixtures/protein/ubiquitin-bmrb-6457.fasta").into(), + name: None, + chains: None, + }) + .unwrap(); + let molecule = app.doc.protein.molecules[0].id; + let (dataset, _, _) = add_spectrum(&mut app); + app.edit_protein(Edit::Bind { + dataset, + molecule: Some(molecule), + }) + .unwrap(); + let sample = SampleCondition { + single_major_conformer: true, + ..Default::default() + }; + let mut context = ScientificContext::default(); + context.experiments.insert( + dataset, + Experiment { + sample: sample.id, + kind: ExperimentKind::Unknown, + roles: Default::default(), + source: String::new(), + }, + ); + context.samples.push(sample); + assert!( + context + .check_structure_scope(&app.doc, dataset) + .unwrap_err() + .contains("role") + ); + let e = context.experiments.get_mut(&dataset).unwrap(); + e.kind = ExperimentKind::Noesy; + e.roles = [ + ExperimentAxisRole::IndirectProton, + ExperimentAxisRole::Nitrogen, + ExperimentAxisRole::ObservedProton, + ]; + context.check_noe_roles(&app.doc, dataset).unwrap(); + context.experiments.get_mut(&dataset).unwrap().roles[0] = ExperimentAxisRole::ObservedProton; + assert!(context.check_noe_roles(&app.doc, dataset).is_err()); + context.experiments.get_mut(&dataset).unwrap().roles[0] = ExperimentAxisRole::Carbon; + assert!(context.validate(&app.doc).unwrap_err().contains("F1")); + context.experiments.get_mut(&dataset).unwrap().roles[0] = ExperimentAxisRole::IndirectProton; + context.samples[0].nonstandard_chemistry = "ligand".into(); + context.validate(&app.doc).unwrap(); + assert!( + context + .check_structure_scope(&app.doc, dataset) + .unwrap_err() + .contains("ligands") + ); +} +#[test] +fn sample_validation_is_atomic_at_ph_boundaries_and_duplicate_ids() { + use crate::protein_context::*; + let mut app = PlotiumApp::new(); + let mut context = ScientificContext::default(); + context.samples.push(SampleCondition::default()); + for ph in [0.0, 14.0] { + context.samples[0].ph = Some(ph); + app.edit_scientific_context(context.clone()).unwrap(); + assert_eq!(app.doc.protein.context, context); + } + let before = app.doc.protein.clone(); + for ph in [-0.001, 14.001, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + let mut invalid = context.clone(); + invalid.samples[0].ph = Some(ph); + assert!(app.edit_scientific_context(invalid).is_err()); + assert_eq!(app.doc.protein, before); + } + context.samples.push(context.samples[0].clone()); + assert!( + app.edit_scientific_context(context) + .unwrap_err() + .contains("unique") + ); + assert_eq!(app.doc.protein, before); + app.undo(); + assert_eq!(app.doc.protein.context.samples[0].ph, Some(0.0)); +} + +#[test] +fn deleting_spectrum_cleans_only_its_metadata_and_undo_restores_it() { + use crate::protein_context::*; + let mut app = PlotiumApp::new(); + let (dataset, _, _) = add_spectrum(&mut app); + let (other, _, _) = add_spectrum(&mut app); + let mut context = ScientificContext::default(); + let sample = SampleCondition::default(); + for d in [dataset, other] { + context.experiments.insert( + d, + Experiment { + sample: sample.id, + kind: ExperimentKind::Unknown, + roles: Default::default(), + source: d.to_string(), + }, + ); + } + context.samples.push(sample); + app.edit_scientific_context(context.clone()).unwrap(); + let index = app + .doc + .datasets + .iter() + .position(|d| d.resource_id() == dataset) + .unwrap(); + app.try_execute_action(app.delete_dataset_action(index).unwrap()) + .unwrap(); + let after = app.doc.protein.context.clone(); + assert!(!after.experiments.contains_key(&dataset)); + assert_eq!( + after.experiments.get(&other), + context.experiments.get(&other) + ); + assert_eq!(after.samples, context.samples); + app.doc.protein.validate(&app.doc).unwrap(); + assert!( + app.save_scientific_context_draft(&context, context.clone()) + .is_err() + ); + assert_eq!(app.doc.protein.context, after); + app.undo(); + assert_eq!(app.doc.protein.context, context); + app.doc.protein.validate(&app.doc).unwrap(); + app.redo(); + assert_eq!(app.doc.protein.context, after); +} diff --git a/crates/io/tests/fixtures/protein/ubiquitin-bmrb-6457.fasta b/crates/io/tests/fixtures/protein/ubiquitin-bmrb-6457.fasta new file mode 100644 index 0000000..48d2b13 --- /dev/null +++ b/crates/io/tests/fixtures/protein/ubiquitin-bmrb-6457.fasta @@ -0,0 +1,2 @@ +>ubiquitin_BMRB_6457 +MQIFVKTLTGKTITLEVEPSDTIENVKAKIQDKEGIPPDQQRLIFAGKQLEDGRTLSDYNIQKESTLHLVLRLRGG diff --git a/docs/src/content/docs/guides/protein-assignments.md b/docs/src/content/docs/guides/protein-assignments.md index cef0f10..a0b3d8f 100644 --- a/docs/src/content/docs/guides/protein-assignments.md +++ b/docs/src/content/docs/guides/protein-assignments.md @@ -22,3 +22,13 @@ Automation uses the document target. The CLI accepts FASTA text in a `protein.ed Inspection defaults to compact molecule summaries, bindings, and associated peaks. To read a sequence, request `view: "chains"` with a molecule ID, then `view: "residues"` with the molecule and chain IDs. Residue rows contain the stable ID, one-letter code, and displayed number. Use `limit` and follow `next_offset` until it is null. The JSON export includes the full sequence. Each atom target has `residue`, `members`, and `group_meaning`. The latter is `unspecified` by default; use `one_of` for one unidentified member or `shared` when all members share the observed resonance. Single atoms use `unspecified`. Changing this value withdraws confirmation just like changing the atoms. JSON exports include this value and an `assignment_semantics` explanation; observations are never automatically merged or averaged. For example, an alanine methyl resonance can use `members: ["HB1", "HB2", "HB3"]` with `group_meaning: "shared"`. + +## Scientific context and initial structure scope + +Expand **Scientific context** in the Protein assignments panel. **Edit sample conditions and experiments** opens a form for the spectrum active when editing began. Select existing sample conditions or choose **New sample conditions**; IDs are managed automatically. Record temperature in kelvin, pH (inclusive 0–14), solvent, isotope labeling and construct. Uncheck temperature or pH to leave it unknown. Describe modifications, ligands or special bonding in **Nonstandard chemistry**. Confirm **Reviewed: single major conformer** only after review. Choose the experiment type, each acquisition-axis role in F1/F2/F3 order, and record the metadata source. Editing a shared sample changes the conditions used by every linked spectrum; create a separate record for different conditions. Saving validates the draft, supports Undo/Redo and embeds the records in the project. + +The draft retains its original spectrum and context baseline. If Undo/Redo or another operation changes the current context, saving is blocked. **Reload current context (discard draft)** replaces the draft with current records; **Discard context edits** closes it without saving. Unrelated document edits do not invalidate the baseline. Deletion of the draft spectrum also blocks saving, even if that spectrum had no saved experiment metadata. Reloading opens sample-only editing; **Keep sample edits (sample-only)** removes the deleted spectrum association while retaining the sample edits. Apply or cancel advanced JSON first. If the saved context also changed, its conflict must still be resolved. **Advanced JSON** is optional: apply or cancel its edits before saving; applying JSON only updates the draft and does not bypass validation or conflict protection. + +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. 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 8fbec4a..aa50243 100644 --- a/docs/src/content/docs/zh-cn/guides/protein-assignments.md +++ b/docs/src/content/docs/zh-cn/guides/protein-assignments.md @@ -22,3 +22,13 @@ description: 将蛋白序列绑定到三维谱图并人工确认峰指认。 查询默认返回精简的分子摘要、谱图绑定和关联峰。读取序列时,先用 `view: "chains"` 和分子 ID 查询链,再用 `view: "residues"`、分子 ID 和链 ID 查询残基。每条残基记录包含稳定 ID、单字母代码和显示编号。用 `limit` 控制页大小,并按 `next_offset` 翻页,直到其为 null。JSON 导出包含完整序列。 每个原子目标包含 `residue`、`members` 和 `group_meaning`。后者默认为 `unspecified`;`one_of` 表示其中一个原子、身份未定,`shared` 表示所有成员共享该观测共振。单个原子使用 `unspecified`。改变该值与改变原子一样会撤回确认。JSON 导出包含此字段及 `assignment_semantics` 解释;不会自动合并或平均观测。例如丙氨酸甲基共振可使用 `members: ["HB1", "HB2", "HB3"]` 和 `group_meaning: "shared"`。 + +## 科学上下文与首版结构流程范围 + +在蛋白指认面板展开 **Scientific context**,点击 **Edit sample conditions and experiments**,打开针对开始编辑时活动谱图的表单。选择已有样品条件,或点击 **New sample conditions** 新建,ID 自动维护。记录开尔文温度、pH(含边界 0–14)、溶剂、同位素标记和构建体;取消温度或 pH 的勾选可保留为未知。在 **Nonstandard chemistry** 中注明修饰、配体或特殊成键,仅在审阅后确认 **Reviewed: single major conformer**。选择实验类型,按采集 F1/F2/F3 顺序选择轴角色,并记录元数据来源。修改共享样品会影响所有关联谱图;不同条件应新建独立记录。保存会校验草稿,支持撤销/重做,并随项目内嵌保存。 + +草稿保留原始谱图及上下文基线。如果撤销/重做或其他操作改变了当前上下文,保存将被阻止。点击 **Reload current context (discard draft)** 用当前记录替换草稿,或点击 **Discard context edits** 放弃草稿。无关文档编辑不会使基线失效。草稿目标谱图被删除时,即使该谱图尚未保存实验元数据,也会阻止保存。重新加载后仅编辑样品;点击 **Keep sample edits (sample-only)** 可移除已删除谱图的关联并保留样品编辑。需先应用或取消高级 JSON 编辑;若已保存上下文也发生变化,仍需解决该冲突。可选的 **Advanced JSON** 高级入口需先应用或取消 JSON 编辑才能保存;应用 JSON 只更新草稿,不绕过校验或冲突保护。 + +实验通过稳定的样品条件 ID 关联样品。仅在条件完全相同时复用 ID,不同条件使用不同记录。角色按采集轴 F1/F2/F3 排列,独立于显示顺序和采集核种。`observed_proton` 与 `indirect_proton` 区分 NOESY 的两个质子轴。未知实验和角色仍可浏览及指认,但不能进入依赖角色的结构操作;已知角色必须匹配采集核种。 + +首版结构流程限定单链标准残基、单一主要构象和受支持的稠密 3D 谱,采用人工指认与外部计算。范围检查报告缺失上下文及不支持的化学体系,不认证实验连通性、指认或 NOE 测量;本步骤尚未提供结构计算或约束生成。