From 7e8d444c6514565d066830edb5e17c5fe41488d3 Mon Sep 17 00:00:00 2001 From: Jiekang Tian Date: Thu, 5 Nov 2026 23:06:43 +0800 Subject: [PATCH] feat(protein): add reviewable chemical-shift lists --- crates/app/src/ui/tools/protein.rs | 4 + crates/app/src/ui/tools/protein_shifts.rs | 111 +++++++++ crates/core/src/lib.rs | 1 + crates/core/src/protein.rs | 6 +- crates/core/src/protein_shifts.rs | 155 +++++++++++++ crates/core/src/protein_shifts_tests.rs | 217 ++++++++++++++++++ crates/core/src/protein_tests.rs | 3 + .../docs/guides/protein-assignments.md | 6 + .../docs/zh-cn/guides/protein-assignments.md | 6 + 9 files changed, 508 insertions(+), 1 deletion(-) create mode 100644 crates/app/src/ui/tools/protein_shifts.rs create mode 100644 crates/core/src/protein_shifts.rs create mode 100644 crates/core/src/protein_shifts_tests.rs diff --git a/crates/app/src/ui/tools/protein.rs b/crates/app/src/ui/tools/protein.rs index 0c2a72c..39fb230 100644 --- a/crates/app/src/ui/tools/protein.rs +++ b/crates/app/src/ui/tools/protein.rs @@ -247,6 +247,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); + shifts_editor::render(app, ui, panel); clear_review(app, ui, panel); ui.separator(); if panel.draft.is_some() { @@ -460,3 +461,6 @@ use scientific_context_editor::scientific_context; #[path = "protein_reference.rs"] mod reference_editor; + +#[path = "protein_shifts.rs"] +mod shifts_editor; diff --git a/crates/app/src/ui/tools/protein_shifts.rs b/crates/app/src/ui/tools/protein_shifts.rs new file mode 100644 index 0000000..6ad4428 --- /dev/null +++ b/crates/app/src/ui/tools/protein_shifts.rs @@ -0,0 +1,111 @@ +use super::*; +use plotium_core::protein_shifts::{ShiftDecision, ShiftReview}; + +pub(super) fn render(app: &mut PlotiumApp, ui: &mut Ui, panel: &mut Panel) { + ui.collapsing("Chemical-shift list", |ui| { + ui.label("By sample conditions and atom target. Different observations require review; no averaging or automatic chemical validation."); + let rows = match app.chemical_shifts() { + Ok(rows) => rows, + Err(e) => { ui.label(e); return; } + }; + if rows.is_empty() { ui.label("No observations — assign peaks and record sample conditions first."); } + for row in rows { + let sample = app.doc.protein.context.samples.iter().find(|s| s.id == row.sample); + ui.label(format!("{} · {} · {:?} · {:?}", sample.map_or("Unknown sample", |s| s.name.as_str()), residue_label(app, row.target.residue), row.target.members, row.target.group_meaning)); + if row.observations.len() > 1 { ui.label("Multiple observations: compare each value and explicitly select or exclude; no default resolution."); } + for o in row.observations { + ui.push_id(format!("{:?}", o.id), |ui| { + ui.label(format!("{} · peak {} · F{} · {:.6} ppm · candidate {} · assignment {} / reference {}", spectrum_name(app, o.id.dataset), o.id.peak.0, o.id.axis + 1, o.ppm, o.id.candidate.0, o.assignment_confirmed, o.reference.confirmed)); + ui.label(format!("Nucleus: {:?} · reference source: {}", o.nucleus, o.reference.source)); + let mut review = app.shift_review(&o).cloned().unwrap_or(ShiftReview { observation: o, uncertainty_ppm: None, decision: ShiftDecision::Pending, note: String::new() }); + ui.label(format!("Decision: {:?} (missing/stale review is Pending)", review.decision)); + let key = ui.id().with("shift_review_draft"); + let baseline = review.clone(); + let mut draft = ui.ctx().data(|d| d.get_temp::<(ShiftReview, ShiftReview)>(key)).filter(|(base, _)| *base == baseline).map(|(_, draft)| draft).unwrap_or(review.clone()); + let mut known = draft.uncertainty_ppm.is_some(); + ui.checkbox(&mut known, "Known uncertainty (ppm)"); + if known { ui.add(DragValue::new(draft.uncertainty_ppm.get_or_insert(0.)).speed(0.001).range(0.0..=f64::MAX)); } + else { draft.uncertainty_ppm = None; } + ui.text_edit_singleline(&mut draft.note); + ui.horizontal(|ui| { + for (label, decision) in [("Select", ShiftDecision::Selected), ("Exclude", ShiftDecision::Excluded), ("Reset / save error", ShiftDecision::Pending)] { + if ui.button(label).clicked() { + review.decision = decision; + review.note = draft.note.clone(); + review.uncertainty_ppm = draft.uncertainty_ppm; + if let Err(e) = app.review_shift(review.clone()) { app.session.status = e.clone(); panel.feedback = e; } + } + } + }); + ui.ctx().data_mut(|d| d.insert_temp(key, (baseline, draft))); + }); + } + } + }); +} + +fn residue_label(app: &PlotiumApp, id: ResidueId) -> String { + for molecule in &app.doc.protein.molecules { + for chain in &molecule.chains { + if let Some((index, residue)) = + chain.residues.iter().enumerate().find(|(_, r)| r.id == id) + { + return format!( + "{} / {} / {}{}", + molecule.name, + chain.name, + residue.code, + i64::from(chain.start) + index as i64 + ); + } + } + } + format!("Unknown residue ({})", id.0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shift_residue_labels_resolve_molecule_chain_and_sequence_number() { + let mut app = PlotiumApp::new(); + for name in ["First protein", "Second protein"] { + app.edit_protein(Edit::Import { + source: "labels.fasta".into(), + fasta: ">first\nAG\n>second\nAG\n".into(), + name: Some(name.into()), + chains: Some(vec![ + ChainLabel { + name: "A".into(), + start: -2, + }, + ChainLabel { + name: "B".into(), + start: 10, + }, + ]), + }) + .unwrap(); + } + let first = &app.doc.protein.molecules[0]; + assert_eq!( + residue_label(&app, first.chains[0].residues[1].id), + "First protein / A / G-1" + ); + assert_eq!( + residue_label(&app, first.chains[1].residues[1].id), + "First protein / B / G11" + ); + let second = &app.doc.protein.molecules[1]; + assert_eq!( + residue_label(&app, second.chains[1].residues[1].id), + "Second protein / B / G11" + ); + let missing = ResidueId(app.doc.protein.molecules[0].id.0); + assert_eq!( + residue_label(&app, missing), + format!("Unknown residue ({})", missing.0) + ); + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 10b00e8..b44485f 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -21,6 +21,7 @@ pub mod properties; pub mod protein; pub mod protein_context; pub mod protein_reference; +pub mod protein_shifts; pub mod settings; pub mod state; pub mod templates; diff --git a/crates/core/src/protein.rs b/crates/core/src/protein.rs index 2f1ea0c..05ec054 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 shift_reviews: Vec, #[serde(default)] pub context: crate::protein_context::ScientificContext, pub molecules: Vec, @@ -195,7 +197,8 @@ pub fn import_preview(source: &str, fasta: &str) -> Result { impl ProteinState { pub fn is_empty(&self) -> bool { - self.context.is_empty() + self.shift_reviews.is_empty() + && self.context.is_empty() && self.molecules.is_empty() && self.bindings.is_empty() && self.assignments.is_empty() @@ -219,6 +222,7 @@ impl ProteinState { pub fn validate(&self, doc: &Document) -> Result<(), String> { self.context.validate(doc)?; + crate::protein_shifts::validate_reviews(&self.shift_reviews)?; let (mut molecules, mut chains, mut residues, mut peaks, mut candidates) = ( HashSet::new(), HashSet::new(), diff --git a/crates/core/src/protein_shifts.rs b/crates/core/src/protein_shifts.rs new file mode 100644 index 0000000..1e20f48 --- /dev/null +++ b/crates/core/src/protein_shifts.rs @@ -0,0 +1,155 @@ +//! Reviewable chemical shifts derived from acquisition-axis observations, never averaged. +use crate::protein::{AtomTarget, CandidateId}; +use crate::protein_context::{SampleCondition, SampleConditionId}; +use crate::protein_reference::ShiftReference; +use crate::state::{DatasetId, Peak3DId, PlotiumApp}; +use serde::{Deserialize, Serialize}; + +/// Stable source identity; axis is an acquisition F1/F2/F3, never a display axis. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ObservationId { + pub dataset: DatasetId, + pub peak: Peak3DId, + pub candidate: CandidateId, + pub axis: usize, +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ShiftObservation { + pub id: ObservationId, + /// Full conditions are evidence: editing a record in place invalidates review. + pub sample: SampleCondition, + pub target: AtomTarget, + pub ppm: f64, + pub nucleus: Option, + pub reference: ShiftReference, + pub assignment_confirmed: bool, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum ShiftDecision { + Pending, + Selected, + Excluded, +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ShiftReview { + pub observation: ShiftObservation, + /// Unknown uncertainty stays None; it is never inferred from peak height. + pub uncertainty_ppm: Option, + pub decision: ShiftDecision, + pub note: String, +} +pub(crate) fn validate_reviews(reviews: &[ShiftReview]) -> Result<(), String> { + for (i, r) in reviews.iter().enumerate() { + if r.observation.id.axis >= 3 + || !r.observation.ppm.is_finite() + || r.uncertainty_ppm.is_some_and(|v| !v.is_finite() || v < 0.) + || reviews[..i] + .iter() + .any(|a| a.observation.id == r.observation.id) + { + return Err("Invalid or duplicate chemical-shift review".into()); + } + r.observation.reference.validate()?; + } + Ok(()) +} +/// One target (including its group meaning) under one explicitly identified condition. +#[derive(Clone, Debug)] +pub struct Resonance { + pub sample: SampleConditionId, + pub target: AtomTarget, + pub nucleus: Option, + pub observations: Vec, +} +impl PlotiumApp { + pub fn chemical_shifts(&self) -> Result, String> { + let mut rows: Vec = Vec::new(); + for a in &self.doc.protein.assignments { + let experiment = self.doc.protein.context.experiments.get(&a.dataset).ok_or( + "Record sample conditions for every assigned spectrum before listing shifts", + )?; + let sample = self + .doc + .protein + .context + .samples + .iter() + .find(|s| s.id == experiment.sample) + .ok_or("Sample conditions are missing")?; + let v = crate::protein::volume(&self.doc, a.dataset)?; + let peak = v + .peaks + .marks + .iter() + .find(|p| p.id == a.peak) + .ok_or("Assignment peak is missing")?; + for c in &a.candidates { + for (axis, target) in c.targets.iter().enumerate() { + let Some(target) = target else { continue }; + let nucleus = v.processed.axes()[axis].nucleus().map(str::to_owned); + let observation = ShiftObservation { + id: ObservationId { + dataset: a.dataset, + peak: a.peak, + candidate: c.id, + axis, + }, + sample: sample.clone(), + target: target.clone(), + ppm: peak.ppm[axis], + nucleus: nucleus.clone(), + reference: v.peaks.reference.clone(), + assignment_confirmed: a.confirmed == Some(c.id), + }; + let index = rows.iter().position(|r| { + r.sample == experiment.sample && r.target == *target && r.nucleus == nucleus + }); + if let Some(i) = index { + rows[i].observations.push(observation); + } else { + rows.push(Resonance { + sample: experiment.sample, + target: target.clone(), + nucleus, + observations: vec![observation], + }); + } + } + } + } + Ok(rows) + } + /// A review applies only while its full observation snapshot still matches. + pub fn shift_review(&self, observation: &ShiftObservation) -> Option<&ShiftReview> { + self.doc + .protein + .shift_reviews + .iter() + .find(|r| r.observation == *observation) + } + pub fn review_shift(&mut self, review: ShiftReview) -> Result<(), String> { + if !self + .chemical_shifts()? + .iter() + .flat_map(|r| &r.observations) + .any(|o| *o == review.observation) + { + return Err( + "Observation changed or was deleted; reload the chemical-shift list".into(), + ); + } + if review.decision == ShiftDecision::Selected + && (!review.observation.assignment_confirmed || !review.observation.reference.confirmed) + { + return Err( + "Confirm the assignment and spectrum reference before selecting a shift".into(), + ); + } + let mut state = self.doc.protein.clone(); + state + .shift_reviews + .retain(|r| r.observation.id != review.observation.id); + state.shift_reviews.push(review); + self.commit_protein(state) + } +} diff --git a/crates/core/src/protein_shifts_tests.rs b/crates/core/src/protein_shifts_tests.rs new file mode 100644 index 0000000..711f0a1 --- /dev/null +++ b/crates/core/src/protein_shifts_tests.rs @@ -0,0 +1,217 @@ +use super::*; +use crate::protein_shifts::*; + +#[test] +fn shifts_preserve_sources_reviews_and_stale_decisions() { + use crate::protein_context::*; + let mut app = PlotiumApp::new(); + let m = import(&mut app, "shifts"); + let (dataset, peak, ppm) = add_spectrum(&mut app); + app.edit_protein(Edit::Bind { + dataset, + molecule: Some(m.id), + }) + .unwrap(); + let second = app + .add_peak_3d(dataset, [ppm[0] + 0.125, ppm[1], ppm[2]]) + .unwrap(); + let sample = SampleCondition::default(); + let mut context = ScientificContext { + samples: vec![sample.clone()], + ..Default::default() + }; + context.experiments.insert( + dataset, + Experiment { + sample: sample.id, + kind: ExperimentKind::Unknown, + roles: Default::default(), + source: "test".into(), + }, + ); + app.edit_scientific_context(context).unwrap(); + for peak in [peak, second] { + app.edit_assignment( + dataset, + peak, + AssignmentEdit::Add { + targets: [atom(m.chains[0].residues[0].id, &["H"]), None, None], + note: "partial".into(), + }, + ) + .unwrap(); + } + let rows = app.chemical_shifts().unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].observations.len(), 2); + assert!((rows[0].observations[1].ppm - rows[0].observations[0].ppm - 0.125).abs() < 1e-10); + let o = rows[0].observations[0].clone(); + let review = ShiftReview { + observation: o.clone(), + uncertainty_ppm: Some(0.02), + decision: ShiftDecision::Excluded, + note: "overlap".into(), + }; + app.review_shift(review.clone()).unwrap(); + app.undo(); + assert!(app.shift_review(&o).is_none()); + app.redo(); + assert_eq!(app.shift_review(&o), Some(&review)); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("shifts.plotium"); + save_project(&app, &path, false).unwrap(); + let loaded = load_project(&path).unwrap(); + assert_eq!(loaded.doc.protein, app.doc.protein); + let mut invalid = review.clone(); + invalid.uncertainty_ppm = Some(-1.); + assert!(app.review_shift(invalid).is_err()); + let mut selected = review.clone(); + selected.decision = ShiftDecision::Selected; + assert!(app.review_shift(selected).is_err()); + app.set_shift_reference( + dataset, + crate::protein_reference::ShiftReference { + offsets_ppm: [0.1, 0., 0.], + source: "standard".into(), + confirmed: true, + }, + ) + .unwrap(); + let fresh = app.chemical_shifts().unwrap()[0].observations[0].clone(); + assert!(app.shift_review(&fresh).is_none()); + assert!(app.review_shift(review).is_err()); + app.edit_assignment( + dataset, + peak, + AssignmentEdit::Confirm { + candidate: fresh.id.candidate, + }, + ) + .unwrap(); + let current = app.chemical_shifts().unwrap()[0].observations[0].clone(); + app.review_shift(ShiftReview { + observation: current.clone(), + uncertainty_ppm: None, + decision: ShiftDecision::Selected, + note: "chosen, not averaged".into(), + }) + .unwrap(); + assert_eq!( + app.shift_review(¤t).unwrap().decision, + ShiftDecision::Selected + ); + + sample_edits_invalidate_reviews(&mut app); + + // Alternative group meanings stay separate, even with identical members. + for meaning in [GroupMeaning::OneOf, GroupMeaning::Shared] { + let mut group = atom(m.chains[0].residues[0].id, &["HB1", "HB2", "HB3"]).unwrap(); + group.group_meaning = meaning; + app.edit_assignment( + dataset, + peak, + AssignmentEdit::Add { + targets: [Some(group), None, None], + note: "alternative".into(), + }, + ) + .unwrap(); + } + let rows = app.chemical_shifts().unwrap(); + assert_eq!(rows.len(), 3); + assert_eq!(rows[1].target.group_meaning, GroupMeaning::OneOf); + assert_eq!(rows[2].target.group_meaning, GroupMeaning::Shared); + assert_eq!(rows[1].observations.len(), 1); + assert_eq!(rows[1].target.members.len(), 3); + assert!(!rows[1].observations[0].assignment_confirmed); + let (other, other_peak, _) = add_spectrum(&mut app); + app.edit_protein(Edit::Bind { + dataset: other, + molecule: Some(m.id), + }) + .unwrap(); + app.edit_assignment( + other, + other_peak, + AssignmentEdit::Add { + targets: [atom(m.chains[0].residues[0].id, &["H"]), None, None], + note: String::new(), + }, + ) + .unwrap(); + assert!(app.chemical_shifts().is_err()); + let mut context = app.doc.protein.context.clone(); + let other_sample = SampleCondition::default(); + context.samples.push(other_sample.clone()); + context.experiments.insert( + other, + Experiment { + sample: other_sample.id, + kind: ExperimentKind::Unknown, + roles: Default::default(), + source: "test".into(), + }, + ); + app.edit_scientific_context(context).unwrap(); + let rows = app.chemical_shifts().unwrap(); + assert_eq!(rows.len(), 4); + assert_eq!(rows[3].sample, other_sample.id); + assert_eq!(rows[3].observations.len(), 1); + save_project(&app, &path, false).unwrap(); + assert_eq!(load_project(&path).unwrap().doc.protein, app.doc.protein); +} + +fn sample_edits_invalidate_reviews(app: &mut PlotiumApp) { + let original = app.doc.protein.context.clone(); + let observation = app.chemical_shifts().unwrap()[0].observations[0].clone(); + let mut changes = Vec::new(); + for field in 0..7 { + let mut changed = original.clone(); + let sample = &mut changed.samples[0]; + match field { + 0 => sample.ph = Some(6.5), + 1 => sample.temperature_kelvin = Some(298.), + 2 => sample.solvent = "D2O".into(), + 3 => sample.isotope_labeling = "15N".into(), + 4 => sample.construct = "construct B".into(), + 5 => sample.nonstandard_chemistry = "recorded chemistry".into(), + _ => sample.single_major_conformer = !sample.single_major_conformer, + } + changes.push(changed); + } + for decision in [ShiftDecision::Selected, ShiftDecision::Excluded] { + let review = ShiftReview { + observation: observation.clone(), + uncertainty_ppm: Some(0.01), + decision, + note: "review baseline".into(), + }; + app.review_shift(review.clone()).unwrap(); + for changed in &changes { + app.edit_scientific_context(changed.clone()).unwrap(); + let fresh = app.chemical_shifts().unwrap()[0].observations[0].clone(); + assert_eq!(fresh.sample.id, observation.sample.id); + assert_ne!(fresh, observation); + assert!(app.shift_review(&fresh).is_none()); + // Unsaved drafts carry the same old snapshot and cannot be applied. + assert!(app.review_shift(review.clone()).is_err()); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("changed-conditions.plotium"); + save_project(app, &path, false).unwrap(); + let reopened = load_project(&path).unwrap(); + let restored = reopened.chemical_shifts().unwrap()[0].observations[0].clone(); + assert_eq!(restored, fresh); + assert!(reopened.shift_review(&restored).is_none()); + assert_eq!( + reopened.doc.protein.shift_reviews, + app.doc.protein.shift_reviews + ); + app.undo(); + assert_eq!(app.shift_review(&observation), Some(&review)); + app.redo(); + let fresh = app.chemical_shifts().unwrap()[0].observations[0].clone(); + assert!(app.shift_review(&fresh).is_none()); + app.undo(); + } + } +} diff --git a/crates/core/src/protein_tests.rs b/crates/core/src/protein_tests.rs index c11a100..9e64146 100644 --- a/crates/core/src/protein_tests.rs +++ b/crates/core/src/protein_tests.rs @@ -728,3 +728,6 @@ fn reference_comparison_preserves_individual_corrected_observations() { #[path = "protein_reference_index_tests.rs"] mod reference_index_tests; + +#[path = "protein_shifts_tests.rs"] +mod shifts_tests; diff --git a/docs/src/content/docs/guides/protein-assignments.md b/docs/src/content/docs/guides/protein-assignments.md index 123f8c0..7cfca54 100644 --- a/docs/src/content/docs/guides/protein-assignments.md +++ b/docs/src/content/docs/guides/protein-assignments.md @@ -42,3 +42,9 @@ The panel reports reference readiness against other spectra with experiment meta 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. + +## Chemical-shift list + +Open **Chemical-shift list** in the Protein assignments panel after recording sample conditions for every assigned spectrum. Rows identify each residue by molecule, chain, residue code and sequence number, and group the same atom target and nucleus within one sample-condition ID. Each acquisition-axis observation retains its peak and candidate identity, corrected ppm, reference source and confirmation; partial assignments and OneOf/Shared groups remain intact. Groups are never expanded into independent atoms. + +Compare all observations, enter a known uncertainty in ppm (otherwise leave it unknown), and add a review note. **Select** requires a confirmed assignment and reference; **Exclude** retains the original evidence. **Reset / save error** saves uncertainty and notes with a pending decision. Different values are never averaged, and multiple selections do not certify consistency. Editing sample conditions (including pH, temperature or solvent) under the same sample ID also invalidates decisions and unsaved review drafts. Missing or changed observation snapshots show Pending and require review again. Decisions support undo/redo and project save/reopen. This panel does not assess chemical correctness or experimental connectivity. 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 40efd20..75f8e96 100644 --- a/docs/src/content/docs/zh-cn/guides/protein-assignments.md +++ b/docs/src/content/docs/zh-cn/guides/protein-assignments.md @@ -42,3 +42,9 @@ description: 将蛋白序列绑定到三维谱图并人工确认峰指认。 对原子身份和核种相同的已确认单原子观测,逐条显示左谱减右谱的校正后 ppm 差值。排除歧义原子组;没有匹配项时显示未评估。不做平均、容差判定或自动校正。 参考表单显示草稿目标谱名。切换谱图后会提示:保存仍修改该草稿目标。参考坐标变化(包括撤销/重做)会清除受影响的 Inspect/Delta 固定游标及切片游标,因为原有读数已失效。 + +## 化学位移列表 + +为所有已有指认的谱图记录样品条件后,在 Protein assignments 面板打开 **Chemical-shift list**。每行以分子、链、残基代码和序号标识残基。列表按同一样品条件 ID、原子目标及核种分组。每个采集轴观测保留峰与候选身份、校正 ppm、参考来源及确认状态;部分指认与 OneOf/Shared 组语义原样保留,不展开成独立原子。 + +逐条比较观测,可填写已知的 ppm 误差(未知则留空)及审阅备注。**Select** 要求指认和参考均已确认;**Exclude** 保留原始证据。**Reset / save error** 保存误差和备注并将裁决设为待审。不同值不自动平均,多个选择不代表一致性通过。即使样品 ID 不变,编辑 pH、温度、溶剂等样品条件也会使裁决及未保存的审阅草稿失效。观测快照缺失或变化时显示 Pending,须重新审阅。裁决支持撤销、重做及项目保存重开;此面板不评估化学正确性或实验连通性。