Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions crates/app/src/ui/tools/protein.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -460,3 +461,6 @@ use scientific_context_editor::scientific_context;

#[path = "protein_reference.rs"]
mod reference_editor;

#[path = "protein_shifts.rs"]
mod shifts_editor;
111 changes: 111 additions & 0 deletions crates/app/src/ui/tools/protein_shifts.rs
Original file line number Diff line number Diff line change
@@ -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)
);
}
}
1 change: 1 addition & 0 deletions crates/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 5 additions & 1 deletion crates/core/src/protein.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ id!(CandidateId);

#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct ProteinState {
#[serde(default)]
pub shift_reviews: Vec<crate::protein_shifts::ShiftReview>,
#[serde(default)]
pub context: crate::protein_context::ScientificContext,
pub molecules: Vec<Molecule>,
Expand Down Expand Up @@ -195,7 +197,8 @@ pub fn import_preview(source: &str, fasta: &str) -> Result<Molecule, String> {

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()
Expand All @@ -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(),
Expand Down
155 changes: 155 additions & 0 deletions crates/core/src/protein_shifts.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
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<f64>,
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<String>,
pub observations: Vec<ShiftObservation>,
}
impl PlotiumApp {
pub fn chemical_shifts(&self) -> Result<Vec<Resonance>, String> {
let mut rows: Vec<Resonance> = 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)
}
}
Loading
Loading