diff --git a/crates/agent/src/capabilities.rs b/crates/agent/src/capabilities.rs index 355da879..4ef2d80d 100644 --- a/crates/agent/src/capabilities.rs +++ b/crates/agent/src/capabilities.rs @@ -4,7 +4,7 @@ use plotium_core::automation::{ }; use serde_json::{Value, json}; -pub(crate) const EDIT_OPERATIONS: [&str; 28] = [ +pub(crate) const EDIT_OPERATIONS: [&str; 30] = [ "analysis.diffusion.plot", "plot.field.create", "analysis.dosy.build", @@ -12,6 +12,8 @@ pub(crate) const EDIT_OPERATIONS: [&str; 28] = [ "analysis.diffusion.fit", "nmr.volume.edit", "nmr.peaks3d", + "protein.edit", + "protein.assignments.edit", "processing.edit", "processing.apply_scheme", "diffusion.set_input", @@ -37,7 +39,17 @@ pub(crate) const EDIT_OPERATIONS: [&str; 28] = [ /// One line per editable operation, so a caller can map a task to an operation /// before fetching its schema. -const PURPOSES: [(&str, &str); 32] = [ +const PURPOSES: [(&str, &str); 36] = [ + ("protein.edit", "Import and bind protein sequences"), + ( + "protein.assignments.edit", + "Edit and confirm 3D assignments", + ), + ("protein.inspect", "Read with plotium_protein_inspect"), + ( + "protein.assignments.export", + "Read with plotium_protein_assignments_export", + ), ("nmr.peaks3d", "Add, edit, delete or locate manual 3D peaks"), ("nmr.peaks3d.list", "Read with plotium_peaks3d_inspect"), ( @@ -172,7 +184,9 @@ const KEYED: [&str; 5] = [ "limits", ]; /// Operations that name their targets inside parameters and omit ids/targets. -pub(crate) const NO_SELECTOR: [&str; 11] = [ +pub(crate) const NO_SELECTOR: [&str; 13] = [ + "protein.edit", + "protein.assignments.edit", "data.import", "project.save", "figure.export", @@ -196,6 +210,8 @@ fn tool_index() -> Vec { "plotium_tasks_cancel"=>(vec!["tasks.cancel"],"{task:TaskReceipt}; task_not_found never authorizes replay","TaskId; callable while busy or after project change; requires reversible_modify"), "plotium_analysis_results_inspect"=>(vec!["analysis.results.inspect"],"ToolResult.value: paginated native DOSY/ILT columns, validity, counts and provenance","one result field ID"), "plotium_diffusion_inspect"=>(vec!["diffusion.inspect"],"ToolResult.value: {resolution_source,increment_count,input:DiffusionInputReport,field_requirement,executable,acquisition_evidence}","one dataset or field ID"), + "plotium_protein_inspect"=>(vec!["protein.inspect"],"Protein sequences, bindings and paginated assignments","document"), + "plotium_protein_assignments_export"=>(vec!["protein.assignments.export"],"One molecule's JSON assignment report","document"), "plotium_peaks3d_inspect"=>(vec!["nmr.peaks3d.list"],"ToolResult.value: {dataset,dataset_name,source,processing,axes,peaks,position,intensity}","one 3D dataset ID"), "plotium_processing_inspect"=>(vec!["processing.inspect"],"ToolResult.value: {dataset,axes:[{axis,pipeline|settings,pending_apply}]}","one dataset ID"), "plotium_get_state"=>(vec!["state.get"],"{project_context,revision,availability,project:{dataset_count,canvas_count},selection:{items,total,next_offset,canvas:{items:[{target,kind,name,visible}],total,part}},hand_edits:{items:[{target,key,label,value}],total,next_offset},permissions,capability_fingerprint,save_state}; latest state per call, not a frozen selection snapshot","application; project, selection and hand_edits are null while busy"), @@ -246,7 +262,8 @@ fn operation_constraints() -> serde_json::Map { let scientific = id.starts_with("processing.") || id.starts_with("diffusion.") || id.starts_with("analysis.") - || id.starts_with("nmr."); + || id.starts_with("nmr.") + || id.starts_with("protein."); constraints.insert(id.into(), json!({"schema":schema,"preflight_permission":if scientific {"reversible_modify"} else {"read"},"execute_permission":"reversible_modify"})); } constraints["dataset.delete"]["result_contract"] = json!( @@ -263,7 +280,7 @@ fn sections() -> serde_json::Map { let constraints = operation_constraints(); let details = crate::operation_details::build(&constraints["figure.edit"]); let scientific = plotium_core::automation::ToolRegistry::built_in().descriptors() - .filter(|d|d.id.starts_with("processing.")||d.id.starts_with("diffusion.")||d.id.starts_with("analysis.")||d.id.starts_with("nmr.")) + .filter(|d|d.id.starts_with("processing.")||d.id.starts_with("diffusion.")||d.id.starts_with("analysis.")||d.id.starts_with("nmr.")||d.id.starts_with("protein.")) .map(|d| { let mut entry = json!({"descriptor":d,"required_permission":if d.effect==plotium_core::automation::EffectLevel::ReadOnly{"read"}else{"reversible_modify"}}); if EDIT_OPERATIONS.contains(&d.id.as_str()) { @@ -349,12 +366,12 @@ fn build_index(sections: &serde_json::Map) -> Value { }).collect::>(); json!({"tools":tools,"editable_operations":operations,"scientific_operations":scientific, "sections":listing, - "properties":"Property definitions and ids are listed by plotium_properties_list.", - "property_batch":{"operation":"properties.set_many","writes_min":1,"writes_max":200,"atomic":true,"undo_steps":1,"selectors":"exact target/key/value per write; omit top-level ids/targets","target_source":"plot.create ToolResult.value.bindings[].target and value.creation.plot; preserve full resource and component identity","unsupported_property":"error.details.write_index and key identify the rejected write"}, - "recovery_contract":{"location":"error.recovery or receipt.recovery","unknown":"null plan_consumed/committed and unknown submission/completion are not evidence of non-execution","retryable":"false: follow next_action, never replay the failed request","authorization":"recovery actions and annotations grant no permissions; file requests require fresh exact-path desktop consent"}, - "resource_capabilities":"Core resource capabilities describe data/rendering semantics, not operations exposed by this connection. Only the operations above can be called here.", + "properties":"See plotium_properties_list.", + "property_batch":{"operation":"properties.set_many","writes_min":1,"writes_max":200,"atomic":true,"undo_steps":1,"selectors":"exact target/key/value; omit ids/targets","target_source":"plot.create value.bindings[].target or value.creation.plot","unsupported_property":"see error.details.write_index and key"}, + "recovery_contract":{"location":"error.recovery or receipt.recovery","unknown":"null plan_consumed/committed or unknown submission/completion do not rule out execution","retryable":"false: follow next_action; never replay","authorization":"no permission granted; file requests need exact-path desktop consent"}, + "resource_capabilities":"Core resource capabilities describe data and rendering. Callable operations are listed above.", "document_root":document_root_target(), - "revision_contract":"Committed synchronous execute results include project_context and after_revision. Reuse them for known exact targets in that context; refresh state after Undo, desktop changes, uncertain outcomes or when current permissions/selection are needed. Submitted tasks are not committed; only matching succeeded terminal receipts with end_revision permit reuse. Revision-bound reads and plans reject stale revisions; execute rechecks permission and revision. Queries do not append project run records or change dirty state, edit generation or revision."}) + "revision_contract":"Reuse project_context and after_revision from a committed result, or end_revision from a matching succeeded task. Refresh after Undo, desktop changes, uncertain outcomes, or to check permission or selection. Reads and plans reject stale revisions; execute rechecks revision and permission. Queries leave the document and audit unchanged."}) } /// Built-in themes are fixed in the binary, so they belong to the static diff --git a/crates/agent/src/capabilities_tests.rs b/crates/agent/src/capabilities_tests.rs index bdc6cd1d..f8b9b4ce 100644 --- a/crates/agent/src/capabilities_tests.rs +++ b/crates/agent/src/capabilities_tests.rs @@ -67,7 +67,8 @@ fn fingerprint_covers_nested_contracts_and_ignores_object_order() { #[test] fn index_is_compact_and_names_every_detail_section() { let index = respond(json!({})).unwrap(); - assert!(serde_json::to_vec(&index).unwrap().len() <= 12 * 1024); + let bytes = serde_json::to_vec(&index).unwrap().len(); + assert!(bytes <= 12 * 1024, "capability index is {bytes} bytes"); let caps = &index["connection_capabilities"]; for key in [ "property_definitions", diff --git a/crates/agent/src/peaks3d_tests.rs b/crates/agent/src/peaks3d_tests.rs index 1cc48702..d7a975b5 100644 --- a/crates/agent/src/peaks3d_tests.rs +++ b/crates/agent/src/peaks3d_tests.rs @@ -81,3 +81,135 @@ fn mcp_peak_edits_and_readonly_export_match_desktop_core() { direct.peak_table_3d(owner).unwrap() ); } + +#[test] +fn mcp_protein_uses_document_target_permissions_and_revision() { + let mut app = PlotiumApp::new(); + let mut session = Session::default(); + let context = app.session.project_context; + let request = json!({ + "tool_id":"protein.edit", + "parameters":{"operation":"import","source":"synthetic.fasta","fasta":">A\nAG\n>B\nG"}, + "expected_revision":0,"expected_project_context":context + }); + assert!( + session + .call(&mut app, "plotium_plan", request.clone()) + .is_err() + ); + session.set_allow_modify(true); + let plan = session + .call(&mut app, "plotium_plan", request.clone()) + .unwrap(); + session + .call( + &mut app, + "plotium_execute", + json!({"plan_id":plan["plan_id"]}), + ) + .unwrap(); + assert!(session.call(&mut app, "plotium_plan", request).is_err()); + assert_eq!(app.doc.protein.molecules.len(), 1); + let id = app.doc.protein.molecules[0].id; + let residue = app.doc.protein.molecules[0].chains[0].residues[0].id; + 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 d = dataset.resource_id(); + app.doc.datasets.push(dataset); + let v = plotium_core::protein::volume(&app.doc, d).unwrap(); + let ppm = std::array::from_fn(|axis| v.processed.axis_coordinate(axis, 1).unwrap()); + let peak = app.add_peak_3d(d, ppm).unwrap(); + let edit = |session: &mut Session, app: &mut PlotiumApp, tool: &str, parameters: Value| { + let request = json!({"tool_id":tool,"parameters":parameters, + "expected_revision":app.doc.automation_revision,"expected_project_context":context}); + let plan = session.call(app, "plotium_plan", request).unwrap(); + session + .call(app, "plotium_execute", json!({"plan_id":plan["plan_id"]})) + .unwrap() + }; + edit( + &mut session, + &mut app, + "protein.edit", + json!({"operation":"bind","dataset":d,"molecule":id}), + ); + let added = edit( + &mut session, + &mut app, + "protein.assignments.edit", + json!({"dataset":d,"peak":peak,"edit":{"operation":"add","targets":[{"residue":residue,"members":["H"]},null,null]}}), + ); + let candidate = &added["value"]["assignment"]["candidates"][0]["id"]; + edit( + &mut session, + &mut app, + "protein.assignments.edit", + json!({"dataset":d,"peak":peak,"edit":{"operation":"confirm","candidate":candidate}}), + ); + assert_eq!( + json!(app.doc.protein.assignment(d, peak).unwrap().confirmed), + *candidate + ); + session.set_allow_modify(false); + for (name, parameters) in [ + ("plotium_protein_inspect", json!({"molecule":id})), + ("plotium_protein_assignments_export", json!({"molecule":id})), + ] { + let query = json!({"parameters":parameters,"expected_revision":app.doc.automation_revision,"expected_project_context":context}); + let result = session.call(&mut app, name, query).unwrap(); + if name.ends_with("export") { + assert_eq!(result["value"], app.export_protein(id, false).unwrap()); + } else { + assert_eq!(result["value"]["molecules"][0]["id"], json!(id)); + } + } +} + +#[test] +fn mcp_protein_inspection_can_page_a_sequence_larger_than_the_response_budget() { + use plotium_core::protein::Edit; + let mut app = PlotiumApp::new(); + app.edit_protein(Edit::Import { + source: "large.fasta".into(), + fasta: format!(">large\n{}G", "A".repeat(6_000)), + name: None, + chains: None, + }) + .unwrap(); + let m = &app.doc.protein.molecules[0]; + assert!( + serde_json::to_vec(m).unwrap().len() > plotium_core::automation::DOCUMENT_RESPONSE_BYTES + ); + let molecule = m.id; + let chain = m.chains[0].id; + let last = m.chains[0].residues.last().unwrap().id; + let mut session = Session::default(); + let mut read = |parameters: Value| { + let query = json!({"parameters":parameters,"expected_revision":app.doc.automation_revision, + "expected_project_context":app.session.project_context}); + session + .call(&mut app, "plotium_protein_inspect", query) + .unwrap()["value"] + .clone() + }; + let summary = read(json!({})); + assert_eq!(summary["molecules"][0]["id"], json!(molecule)); + assert_eq!(summary["molecules"][0]["chain_count"], 1); + assert!(summary["molecules"][0].get("chains").is_none()); + let chains = read(json!({"view":"chains","molecule":molecule})); + assert_eq!(chains["chains"][0]["id"], json!(chain)); + let first = read(json!({"view":"residues","molecule":molecule,"chain":chain,"limit":1})); + assert_eq!(first["next_offset"], 1); + assert_eq!(first["residues"][0]["number"], 1); + let last_page = + read(json!({"view":"residues","molecule":molecule,"chain":chain,"offset":6000,"limit":1})); + assert_eq!( + last_page["residues"][0], + json!({"id":last,"code":"G","number":6001}) + ); + assert!(last_page["next_offset"].is_null()); +} diff --git a/crates/agent/src/protocol.rs b/crates/agent/src/protocol.rs index 86bf1f3e..4918cce4 100644 --- a/crates/agent/src/protocol.rs +++ b/crates/agent/src/protocol.rs @@ -208,6 +208,27 @@ pub(crate) fn definitions() -> Vec { schema.as_object().expect("local scientific schema").clone(), )); } + for (name, id) in [ + ("plotium_protein_inspect", "protein.inspect"), + ( + "plotium_protein_assignments_export", + "protein.assignments.export", + ), + ] { + let descriptor = registry.get(id).expect("registered protein operation"); + tools.push(Tool::new( + name, + descriptor.description.clone(), + json!({ + "type":"object","additionalProperties":false, + "required":["expected_revision","parameters"], + "properties":{"expected_revision":revision,"parameters":descriptor.parameter_schema} + }) + .as_object() + .expect("local schema") + .clone(), + )); + } for name in [ "plotium_tasks_get", "plotium_tasks_list", diff --git a/crates/agent/src/session.rs b/crates/agent/src/session.rs index b0fc183b..881537b4 100644 --- a/crates/agent/src/session.rs +++ b/crates/agent/src/session.rs @@ -411,6 +411,8 @@ impl Session { name, "plotium_diffusion_inspect" | "plotium_processing_inspect" + | "plotium_protein_inspect" + | "plotium_protein_assignments_export" | "plotium_peaks3d_inspect" | "plotium_analysis_results_inspect" ) { diff --git a/crates/agent/src/session/science.rs b/crates/agent/src/session/science.rs index 51e4a61e..2cc4e48b 100644 --- a/crates/agent/src/session/science.rs +++ b/crates/agent/src/session/science.rs @@ -7,6 +7,8 @@ pub(super) fn is_scientific_edit(tool: &str) -> bool { PROCESSING_EDIT | "nmr.volume.edit" | "nmr.peaks3d" + | "protein.edit" + | "protein.assignments.edit" | DIFFUSION_SET_INPUT | DOSY_BUILD | REGIONS_EXTRACT @@ -79,7 +81,15 @@ impl Session { "Execute a pending plan before preparing another.", )); } - let targets = selector(args.ids, args.targets)?; + let targets = + if args.tool_id.starts_with("protein.") && args.ids.is_none() && args.targets.is_none() + { + TargetSelector::Explicit { + ids: vec![document_root_target().resource.id], + } + } else { + selector(args.ids, args.targets)? + }; if !matches!(&targets,TargetSelector::Explicit {ids} if !ids.is_empty()) { return Err(Failure::new( "invalid_parameters", @@ -115,6 +125,44 @@ impl Session { name: &str, args: Value, ) -> Result { + if matches!( + name, + "plotium_protein_inspect" | "plotium_protein_assignments_export" + ) { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct ProteinRead { + expected_revision: DocumentRevision, + parameters: Value, + } + let query: ProteinRead = decode(args)?; + let id = if name == "plotium_protein_inspect" { + "protein.inspect" + } else { + "protein.assignments.export" + }; + let plan = plan_tool( + app, + request( + id, + query.parameters, + vec![document_root_target().resource.id], + query.expected_revision, + ), + )?; + let result = json!(execute_tool(app, plan, ExecutionAuthority::Read)?); + if serde_json::to_vec(&result) + .map_err(|e| Failure::new("execution_failed", e.to_string()))? + .len() + > DOCUMENT_RESPONSE_BYTES + { + return Err(Failure::new( + "response_too_large", + "Protein report exceeds the response budget; use CLI export or paginate inspection.", + )); + } + return Ok(result); + } #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct Query { diff --git a/crates/agent/src/tool_effects.rs b/crates/agent/src/tool_effects.rs index c2cfd349..277e6f96 100644 --- a/crates/agent/src/tool_effects.rs +++ b/crates/agent/src/tool_effects.rs @@ -26,6 +26,8 @@ impl ToolEffect { | "plotium_analysis_results_inspect" | "plotium_diffusion_inspect" | "plotium_processing_inspect" + | "plotium_protein_inspect" + | "plotium_protein_assignments_export" | "plotium_peaks3d_inspect" | "plotium_tasks_get" | "plotium_tasks_list" diff --git a/crates/agent/src/tool_effects_tests.rs b/crates/agent/src/tool_effects_tests.rs index cc24c1dd..20845a10 100644 --- a/crates/agent/src/tool_effects_tests.rs +++ b/crates/agent/src/tool_effects_tests.rs @@ -18,6 +18,8 @@ fn every_advertised_tool_has_explicit_conservative_annotations() { "plotium_analysis_results_inspect", "plotium_diffusion_inspect", "plotium_processing_inspect", + "plotium_protein_inspect", + "plotium_protein_assignments_export", "plotium_peaks3d_inspect", "plotium_tasks_get", "plotium_tasks_list", diff --git a/crates/app/src/ui/command_exec.rs b/crates/app/src/ui/command_exec.rs index 00a241e7..0894a561 100644 --- a/crates/app/src/ui/command_exec.rs +++ b/crates/app/src/ui/command_exec.rs @@ -66,6 +66,8 @@ fn execute_inner( | CommandId::Peak3DLocate | CommandId::Peak3DPrevious | CommandId::Peak3DNext => super::peaks3d::command(app, ctx, id), + CommandId::ImportProtein => super::tools::protein::import(app, ctx), + CommandId::ProteinAssignments => super::tools::protein::open(app), CommandId::ApplyNmrVolumePhase => super::nmr_volume::apply_phase(app, ctx), CommandId::PreviousNmrPlane | CommandId::NextNmrPlane => { super::nmr_volume::step(app, id == CommandId::NextNmrPlane); diff --git a/crates/app/src/ui/commands.rs b/crates/app/src/ui/commands.rs index 5f2045d6..dda331bc 100644 --- a/crates/app/src/ui/commands.rs +++ b/crates/app/src/ui/commands.rs @@ -9,7 +9,7 @@ use plotium_core::state::{Dataset, PlotiumApp, Tool, ToolGroup}; mod craft; mod identity; use identity::command_identity; -pub(crate) use identity::recent_entry_label; +pub(crate) use identity::{MANUAL_URL, recent_entry_label}; mod helpers; pub(super) use helpers::chart_plot_target; use helpers::{has_active_plot, requires, selected_paths_unlocked}; @@ -21,7 +21,6 @@ use ribbon::ribbon_placement; pub(crate) use ribbon::stack_row; pub use ribbon::{Applicability, RibbonPlacement}; use roster::command_ids; -pub(crate) const MANUAL_URL: &str = "https://plotium.nmrtist.space/"; pub(crate) const REPOSITORY_URL: &str = "https://github.com/nmrtist/plotium"; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum CommandId { @@ -39,6 +38,8 @@ pub enum CommandId { PreviousNmrPlane, NextNmrPlane, Peak3DExport, + ImportProtein, + ProteinAssignments, Peak3DDelete, Peak3DLocate, Peak3DPrevious, diff --git a/crates/app/src/ui/commands/identity.rs b/crates/app/src/ui/commands/identity.rs index 99cec38a..92f48dd2 100644 --- a/crates/app/src/ui/commands/identity.rs +++ b/crates/app/src/ui/commands/identity.rs @@ -2,6 +2,8 @@ //! stable IDs the palette and shortcut map key on. Split from `commands.rs` //! to keep both halves within the repository source-size limit. +pub(crate) const MANUAL_URL: &str = "https://plotium.nmrtist.space/"; + use egui_phosphor::regular as icon; use plotium_core::actions::ZOrder; use plotium_core::layout::{Align, Distribute, GutterPreset, SpacingMode}; @@ -59,6 +61,8 @@ pub(super) fn command_identity( CommandId::CancelNmrVolumePhase => plain("Cancel 3D NMR Phase", Some(icon::X)), CommandId::ApplyNmrVolumePhase => plain("Apply 3D NMR Phase", Some(icon::PLAY)), CommandId::PreviousNmrPlane => plain("Previous NMR Plane", Some(icon::CARET_LEFT)), + CommandId::ImportProtein => plain("Import protein sequence…", None), + CommandId::ProteinAssignments => plain("Protein assignments", None), CommandId::Peak3DExport => plain("Export 3D Peak Table…", None), CommandId::Peak3DDelete => plain("Delete 3D Peak", None), CommandId::Peak3DLocate => plain("Locate 3D Peak", None), @@ -476,6 +480,8 @@ fn simple_stable_id(id: CommandId) -> &'static str { CommandId::ApplyNmrVolumePhase => "nmr.phase.apply", CommandId::PreviousNmrPlane => "nmr.plane.previous", CommandId::NextNmrPlane => "nmr.plane.next", + CommandId::ImportProtein => "protein.import", + CommandId::ProteinAssignments => "protein.assignments", CommandId::Peak3DExport => "nmr.peak3d.export", CommandId::Peak3DDelete => "nmr.peak3d.delete", CommandId::Peak3DLocate => "nmr.peak3d.locate", diff --git a/crates/app/src/ui/commands/ribbon.rs b/crates/app/src/ui/commands/ribbon.rs index 7307b93f..9a05644f 100644 --- a/crates/app/src/ui/commands/ribbon.rs +++ b/crates/app/src/ui/commands/ribbon.rs @@ -50,7 +50,9 @@ pub(super) fn ribbon_placement(id: CommandId) -> Option { | CommandId::ToggleGrid | CommandId::Present | CommandId::Preferences => (View, "Display", 1, Always), - CommandId::OpenFile + CommandId::ProteinAssignments => (Analyze, "Protein", 1, Always), + CommandId::ImportProtein + | CommandId::OpenFile | CommandId::ImportTable | CommandId::ImportImage | CommandId::OpenFolder diff --git a/crates/app/src/ui/commands/roster.rs b/crates/app/src/ui/commands/roster.rs index 77ec7933..3173adbd 100644 --- a/crates/app/src/ui/commands/roster.rs +++ b/crates/app/src/ui/commands/roster.rs @@ -26,6 +26,8 @@ pub(super) fn command_ids(recent_files: usize) -> Vec { CommandId::PreviousNmrPlane, CommandId::NextNmrPlane, CommandId::Peak3DExport, + CommandId::ImportProtein, + CommandId::ProteinAssignments, CommandId::Peak3DDelete, CommandId::Peak3DLocate, CommandId::Peak3DPrevious, diff --git a/crates/app/src/ui/menus.rs b/crates/app/src/ui/menus.rs index 54acb9f8..41af2314 100644 --- a/crates/app/src/ui/menus.rs +++ b/crates/app/src/ui/menus.rs @@ -74,6 +74,7 @@ pub(crate) fn menu_bar_spec() -> Vec<(&'static str, Vec)> { ), Separator, Command(CommandId::ImportTable), + Command(CommandId::ImportProtein), Command(CommandId::ImportResults), Command(CommandId::CancelDataImport), Command(CommandId::ImportImage), diff --git a/crates/app/src/ui/mod.rs b/crates/app/src/ui/mod.rs index f42f8f15..7fe19578 100644 --- a/crates/app/src/ui/mod.rs +++ b/crates/app/src/ui/mod.rs @@ -215,6 +215,7 @@ pub fn render( tools::render_curve_fit_task(app, ui); tools::render_statistics_task(app, ui); tools::render_craft_task(app, ui); + tools::protein::render(app, ui); }, ); }); diff --git a/crates/app/src/ui/peaks3d.rs b/crates/app/src/ui/peaks3d.rs index c8b4f9dc..7b12ff73 100644 --- a/crates/app/src/ui/peaks3d.rs +++ b/crates/app/src/ui/peaks3d.rs @@ -157,10 +157,38 @@ pub(super) fn panel(app: &mut PlotiumApp, di: usize, ui: &mut Ui) { )); for mark in &marks { let selected = app.session.ui.selected_peak_3d == Some((dataset, mark.id)); + let summary = app + .doc + .protein + .assignment(dataset, mark.id) + .map_or_else(String::new, |a| { + let status = a + .confirmed + .and_then(|id| a.candidates.iter().find(|c| c.id == id)) + .map_or_else( + || format!("{} candidate(s)", a.candidates.len()), + |c| { + format!( + "Confirmed · {}{}", + if c.targets.iter().all(Option::is_some) { + "complete" + } else { + "partial" + }, + if c.targets.iter().flatten().any(|t| t.members.len() > 1) { + " · group" + } else { + "" + } + ) + }, + ); + format!(" · {status}") + }); let response = ui.selectable_label( selected, format!( - "{} {:.3} / {:.3} / {:.3} {:+.3e}", + "{} {:.3} / {:.3} / {:.3} {:+.3e}{summary}", mark.id.0, mark.ppm[0], mark.ppm[1], mark.ppm[2], mark.intensity ), ); @@ -176,7 +204,13 @@ pub(super) fn panel(app: &mut PlotiumApp, di: usize, ui: &mut Ui) { edit_fields(app, dataset, mark, &axes, ui); ui.horizontal(|ui| { button(app, ui, CommandId::Peak3DLocate, "Locate"); - button(app, ui, CommandId::Peak3DDelete, "Delete"); + let label = if app.doc.protein.assignment(dataset, id).is_some() { + "Delete peak and assignments" + } else { + "Delete" + }; + button(app, ui, CommandId::Peak3DDelete, label); + button(app, ui, CommandId::ProteinAssignments, "Assignments"); }); } } diff --git a/crates/app/src/ui/ribbon/layout.rs b/crates/app/src/ui/ribbon/layout.rs index 08f16d58..e55b9ebf 100644 --- a/crates/app/src/ui/ribbon/layout.rs +++ b/crates/app/src/ui/ribbon/layout.rs @@ -394,6 +394,7 @@ pub(super) fn group_order(tab: WorkflowTab, group: &str) -> u8 { "Recipes", ], WorkflowTab::Analyze => &[ + "Protein", "Range", "XPS", "Extract", diff --git a/crates/app/src/ui/tools/mod.rs b/crates/app/src/ui/tools/mod.rs index 8ca63aac..08e6c71a 100644 --- a/crates/app/src/ui/tools/mod.rs +++ b/crates/app/src/ui/tools/mod.rs @@ -9,6 +9,7 @@ mod line_fit; mod mass_spec; mod mass_spec_browser; mod processing; +pub(super) mod protein; mod pseudo; mod region_analysis; mod result_navigation; diff --git a/crates/app/src/ui/tools/protein.rs b/crates/app/src/ui/tools/protein.rs new file mode 100644 index 00000000..ec227d2e --- /dev/null +++ b/crates/app/src/ui/tools/protein.rs @@ -0,0 +1,450 @@ +use super::task_card; +use egui::{ComboBox, DragValue, Ui}; +use plotium_core::protein::{ + self, AssignmentEdit, AtomTarget, Candidate, ChainLabel, Edit, GroupMeaning, Molecule, + MoleculeId, ResidueId, Targets, +}; +use plotium_core::state::{DatasetId, Peak3DId, PlotiumApp, TaskDockTab}; + +#[derive(Clone, Default)] +struct Panel { + molecule: Option, + residue: Option, + import: Option, + draft: Option, + confirmed_only: bool, + feedback: String, + clear_spectrum: Option<(DatasetId, u64)>, +} + +#[derive(Clone)] +struct Import { + fasta: String, + preview: Molecule, +} + +#[derive(Clone)] +struct Draft { + dataset: DatasetId, + peak: Peak3DId, + baseline: Option, + residues: [Option; 3], + targets: Targets, + note: String, +} + +fn key(app: &PlotiumApp) -> egui::Id { + egui::Id::new(("protein_panel", app.session.project_context.to_string())) +} + +pub(in crate::ui) fn open(app: &mut PlotiumApp) { + app.session.ui.protein_open = true; + app.session.ui.open_task_tab(TaskDockTab::Protein); +} + +pub(in crate::ui) fn import(app: &mut PlotiumApp, ctx: &egui::Context) { + open(app); + let key = key(app); + let mut panel = ctx.data(|d| d.get_temp::(key)).unwrap_or_default(); + if panel.import.is_some() { + app.session.status = "Finish or discard the current sequence preview first.".into(); + return; + } + let Some(path) = rfd::FileDialog::new() + .add_filter("Protein FASTA", &["fasta", "fa", "faa", "txt"]) + .pick_file() + else { + return; + }; + let source = path + .file_name() + .map_or_else(|| "Protein".into(), |s| s.to_string_lossy().into_owned()); + let result = std::fs::read_to_string(path) + .map_err(|e| e.to_string()) + .and_then(|fasta| prepare(&source, fasta)); + match result { + Ok(import) => panel.import = Some(import), + Err(e) => panel.feedback = e, + } + ctx.data_mut(|d| d.insert_temp(key, panel)); +} + +fn prepare(source: &str, fasta: String) -> Result { + Ok(Import { + preview: protein::import_preview(source, &fasta)?, + fasta, + }) +} + +pub(in crate::ui) fn render(app: &mut PlotiumApp, host: &mut Ui) { + let tab = TaskDockTab::Protein; + if !task_card::is_active(app, tab) { + return; + } + let geometry = task_card::geometry(app, host, tab, 340.0, false); + let area = egui::Id::new("protein_task_card"); + let key = key(app); + let mut panel = host + .ctx() + .data(|d| d.get_temp::(key)) + .unwrap_or_default(); + task_card::area(host, area, geometry.pos).show(host.ctx(), |ui| { + ui.set_width(geometry.width); + crate::ui::card_frame(host.visuals().dark_mode, egui::Margin::ZERO).show(ui, |ui| { + let mut close = false; + task_card::header(ui, area, "Protein assignments", None::, |ui| { + close = crate::ui::panel_chrome::close(ui, "Close Protein assignments").clicked(); + }); + if close { + app.session.ui.close_task_tab(tab); + } + if task_card::tab_bar(app, tab, ui) { + ui.separator(); + } + task_card::sized_body(ui, geometry.body_height, |ui| { + egui::ScrollArea::vertical() + .id_salt("protein_body_scroll") + .auto_shrink([false, false]) + .show(ui, |ui| body(app, ui, &mut panel)); + }); + }); + task_card::resize_handles(app, ui, area, tab, geometry.width, geometry.body_height); + }); + host.ctx().data_mut(|d| d.insert_temp(key, panel)); +} + +fn body(app: &mut PlotiumApp, ui: &mut Ui, panel: &mut Panel) { + if !panel.feedback.is_empty() { + ui.label(&panel.feedback); + } + if let Some(import) = &mut panel.import { + ui.heading("Sequence import preview"); + ui.label(&import.preview.source); + ui.text_edit_singleline(&mut import.preview.name); + for chain in &mut import.preview.chains { + ui.label(format!( + "{} · {} residues", + chain.title, + chain.residues.len() + )); + label_fields(ui, &mut chain.name, &mut chain.start); + } + if ui.button("Import sequence").clicked() { + let result = app.edit_protein(Edit::Import { + source: import.preview.source.clone(), + fasta: import.fasta.clone(), + name: Some(import.preview.name.clone()), + chains: Some( + import + .preview + .chains + .iter() + .map(|c| ChainLabel { + name: c.name.clone(), + start: c.start, + }) + .collect(), + ), + }); + match result { + Ok(()) => { + panel.molecule = app.doc.protein.molecules.last().map(|m| m.id); + panel.import = None; + panel.feedback.clear(); + } + Err(e) => panel.feedback = e, + } + } + if ui.button("Discard import preview").clicked() { + panel.import = None; + } + return; + } + if app.doc.protein.molecules.is_empty() { + ui.label("No protein sequences — choose Import protein sequence on the Data tab."); + } + let active = app + .active_dataset() + .and_then(|i| app.doc.datasets.get(i)) + .map(|d| d.resource_id()) + .filter(|d| protein::volume(&app.doc, *d).is_ok()); + let selected_peak = app.session.ui.selected_peak_3d.filter(|(d, p)| { + Some(*d) == active + && protein::volume(&app.doc, *d).is_ok_and(|v| v.peaks.marks.iter().any(|m| m.id == *p)) + }); + let bound = active.and_then(|d| app.doc.protein.bindings.get(&d).copied()); + let selected = panel + .molecule + .filter(|id| app.doc.protein.molecule(*id).is_ok()) + .or(bound) + .or_else(|| app.doc.protein.molecules.first().map(|m| m.id)); + panel.molecule = selected; + ComboBox::from_id_salt("protein_molecule") + .selected_text( + selected + .and_then(|id| app.doc.protein.molecule(id).ok()) + .map_or("Select molecule", |m| &m.name), + ) + .show_ui(ui, |ui| { + for m in &app.doc.protein.molecules { + if ui + .selectable_value(&mut panel.molecule, Some(m.id), &m.name) + .clicked() + { + panel.residue = None; + } + } + }); + if let Some(m) = panel + .molecule + .and_then(|id| app.doc.protein.molecule(id).ok()) + .cloned() + { + if let Some(d) = active { + let label = bound + .and_then(|id| app.doc.protein.molecule(id).ok()) + .map_or("Unbound", |m| &m.name); + ui.label(format!("Active spectrum: {}", spectrum_name(app, d))); + ui.label(format!("Bound molecule: {label}")); + if bound.is_some_and(|id| id != m.id) { + ui.label("The sequence browser shows a different molecule from the active spectrum. Assignments use the bound molecule."); + } + if bound != Some(m.id) && ui.button("Bind active spectrum to this molecule").clicked() { + panel.feedback = app + .edit_protein(Edit::Bind { + dataset: d, + molecule: Some(m.id), + }) + .err() + .unwrap_or_default(); + } + if app.doc.protein.assignments.iter().any(|a| a.dataset == d) + && ui + .button("Clear all assignments on active spectrum") + .clicked() + { + panel.clear_spectrum = Some((d, app.doc.automation_revision)); + } + if bound.is_some() && ui.button("Unbind active spectrum").clicked() { + panel.feedback = app + .edit_protein(Edit::Bind { + dataset: d, + molecule: None, + }) + .err() + .unwrap_or_default(); + } + } else { + ui.label("Select a 3D spectrum to bind it."); + } + ui.collapsing("Molecule details", |ui| details(app, ui, panel, &m)); + ui.collapsing("Sequence and associated peaks", |ui| { + sequence(app, ui, panel, &m) + }); + } + clear_review(app, ui, panel); + ui.separator(); + if panel.draft.is_some() { + draft_editor(app, ui, panel); + } else if let Some((d, p)) = selected_peak { + if bound.is_some() { + candidates(app, ui, panel, d, p); + } else { + ui.label("Bind the spectrum to enter assignments."); + } + } else { + ui.label("Select a peak in the 3D peak table to enter assignments."); + } +} + +fn label_fields(ui: &mut Ui, name: &mut String, start: &mut i32) { + ui.horizontal(|ui| { + ui.label("Chain"); + ui.text_edit_singleline(name); + ui.label("Start"); + ui.add(DragValue::new(start)); + }); +} + +fn details(app: &mut PlotiumApp, ui: &mut Ui, panel: &mut Panel, m: &Molecule) { + ui.push_id(m.id, |ui| { + let key = ui.id().with("name"); + let mut name = ui + .ctx() + .data(|d| d.get_temp::(key)) + .unwrap_or_else(|| m.name.clone()); + ui.text_edit_singleline(&mut name); + if ui.button("Save molecule name").clicked() { + panel.feedback = app + .edit_protein(Edit::Rename { + molecule: m.id, + name: name.clone(), + }) + .err() + .unwrap_or_default(); + } + ui.ctx().data_mut(|d| { + if name == m.name { + d.remove::(key); + } else { + d.insert_temp(key, name); + } + }); + for c in &m.chains { + ui.push_id(c.id, |ui| { + let key = ui.id().with("label"); + let mut label = ui + .ctx() + .data(|d| d.get_temp::(key)) + .unwrap_or_else(|| ChainLabel { + name: c.name.clone(), + start: c.start, + }); + label_fields(ui, &mut label.name, &mut label.start); + if ui.button("Save chain label").clicked() { + panel.feedback = app + .edit_protein(Edit::LabelChain { + molecule: m.id, + chain: c.id, + label: label.clone(), + }) + .err() + .unwrap_or_default(); + } + ui.ctx().data_mut(|d| { + if label.name == c.name && label.start == c.start { + d.remove::(key); + } else { + d.insert_temp(key, label); + } + }); + }); + } + for (&d, _) in app + .doc + .protein + .bindings + .iter() + .filter(|(_, id)| **id == m.id) + { + if let Some(dataset) = app.doc.dataset_by_id(d) { + ui.label(format!("Bound: {}", dataset.display_name())); + } + } + ui.checkbox( + &mut panel.confirmed_only, + "Export only confirmed assignments", + ); + if ui.button("Export JSON…").clicked() + && let Some(path) = rfd::FileDialog::new() + .set_file_name("protein-assignments.json") + .save_file() + { + panel.feedback = app + .export_protein(m.id, panel.confirmed_only) + .and_then(|value| serde_json::to_vec_pretty(&value).map_err(|e| e.to_string())) + .and_then(|bytes| std::fs::write(path, bytes).map_err(|e| e.to_string())) + .err() + .unwrap_or_default(); + } + if ui.button("Delete molecule").clicked() { + panel.feedback = app + .edit_protein(Edit::Delete { molecule: m.id }) + .err() + .unwrap_or_default(); + } + }); +} + +fn sequence(app: &mut PlotiumApp, ui: &mut Ui, panel: &mut Panel, m: &Molecule) { + for chain in &m.chains { + ui.collapsing(format!("Chain {} · {}", chain.name, chain.title), |ui| { + ui.horizontal_wrapped(|ui| { + for (i, r) in chain.residues.iter().enumerate() { + ui.selectable_value( + &mut panel.residue, + Some(r.id), + format!("{}{}", r.code, i64::from(chain.start) + i as i64), + ); + } + }); + }); + } + let Some(residue) = panel.residue else { + ui.label("Select a residue to find associated peaks."); + return; + }; + let rows: Vec<_> = app + .doc + .protein + .assignments + .iter() + .filter(|a| app.doc.protein.bindings.get(&a.dataset) == Some(&m.id)) + .filter_map(|a| { + let matches: Vec<_> = a + .candidates + .iter() + .filter(|c| c.targets.iter().flatten().any(|t| t.residue == residue)) + .collect(); + (!matches.is_empty()).then(|| { + ( + a.dataset, + a.peak, + matches.iter().any(|c| Some(c.id) == a.confirmed), + ) + }) + }) + .collect(); + if rows.is_empty() { + ui.label("No associated peaks for this residue."); + } + for (d, p, confirmed) in rows { + let name = app + .doc + .dataset_by_id(d) + .map_or_else(|| d.to_string(), |d| d.display_name()); + if ui + .button(format!( + "{name} · Peak {} · {}", + p.0, + if confirmed { "Confirmed" } else { "Candidate" } + )) + .clicked() + { + match app.locate_peak_3d(d, p) { + Ok(()) => { + let owns_plot = |canvas: &plotium_core::state::CanvasDocument| { + canvas + .objects + .iter() + .any(|o| o.plot().is_some_and(|p| p.display_owner == Some(d))) + }; + if !app + .session + .active_canvas + .and_then(|i| app.doc.canvases.get(i)) + .is_some_and(owns_plot) + && let Some(i) = app.doc.canvases.iter().position(owns_plot) + { + app.activate_canvas(i); + } + app.set_active_dataset(app.doc.dataset_index(d)); + app.session.ui.selected_peak_3d = Some((d, p)); + } + Err(e) => panel.feedback = e, + } + } + } +} + +#[path = "protein_editor.rs"] +mod editor; +use editor::{candidates, draft_editor}; + +#[path = "protein_context.rs"] +mod context; +use context::{assignment_context, clear_review, spectrum_name}; + +#[cfg(test)] +#[path = "protein_tests.rs"] +mod tests; diff --git a/crates/app/src/ui/tools/protein_context.rs b/crates/app/src/ui/tools/protein_context.rs new file mode 100644 index 00000000..58dbf8cf --- /dev/null +++ b/crates/app/src/ui/tools/protein_context.rs @@ -0,0 +1,69 @@ +use super::*; + +pub(super) fn spectrum_name(app: &PlotiumApp, dataset: DatasetId) -> String { + app.doc + .dataset_by_id(dataset) + .map_or_else(|| dataset.to_string(), |d| d.display_name()) +} + +pub(super) fn assignment_context(app: &PlotiumApp, ui: &mut Ui, dataset: DatasetId) { + ui.label(format!( + "Assignment spectrum: {}", + spectrum_name(app, dataset) + )) + .on_hover_text(dataset.to_string()); + if let Some(m) = app + .doc + .protein + .bindings + .get(&dataset) + .and_then(|id| app.doc.protein.molecule(*id).ok()) + { + ui.label(format!("Assignment molecule: {}", m.name)); + } +} + +pub(super) fn clear_review(app: &mut PlotiumApp, ui: &mut Ui, panel: &mut Panel) { + let Some((dataset, revision)) = panel.clear_spectrum else { + return; + }; + ui.separator(); + ui.label(format!( + "Clear assignments from {}?", + spectrum_name(app, dataset) + )) + .on_hover_text(dataset.to_string()); + let rows: Vec<_> = app + .doc + .protein + .assignments + .iter() + .filter(|a| a.dataset == dataset) + .collect(); + let candidates: usize = rows.iter().map(|a| a.candidates.len()).sum(); + let confirmed = rows.iter().filter(|a| a.confirmed.is_some()).count(); + ui.label(format!("{} peaks · {candidates} candidates · {confirmed} confirmed. Peak observations will remain. Undo restores the assignments.", rows.len())); + let current = revision == app.doc.automation_revision; + if !current { + ui.label("The project changed. Review the updated counts before clearing."); + if ui.button("Review updated counts").clicked() { + panel.clear_spectrum = Some((dataset, app.doc.automation_revision)); + } + } + if ui + .add_enabled( + current && !rows.is_empty(), + egui::Button::new("Confirm clear spectrum assignments"), + ) + .clicked() + { + panel.feedback = app + .edit_protein(Edit::ClearSpectrumAssignments { dataset }) + .err() + .unwrap_or_default(); + panel.clear_spectrum = None; + } + if ui.button("Cancel clearing").clicked() { + panel.clear_spectrum = None; + } +} diff --git a/crates/app/src/ui/tools/protein_editor.rs b/crates/app/src/ui/tools/protein_editor.rs new file mode 100644 index 00000000..74d119c1 --- /dev/null +++ b/crates/app/src/ui/tools/protein_editor.rs @@ -0,0 +1,305 @@ +use super::*; + +pub(super) fn candidates( + app: &mut PlotiumApp, + ui: &mut Ui, + panel: &mut Panel, + d: DatasetId, + p: Peak3DId, +) { + ui.heading(format!("Peak {} assignments", p.0)); + assignment_context(app, ui, d); + ui.small("Confirmed means manually accepted, not automatically validated."); + if let Some(a) = app.doc.protein.assignment(d, p).cloned() { + for (i, c) in a.candidates.iter().enumerate() { + let complete = c.targets.iter().all(Option::is_some); + let confirmed = a.confirmed == Some(c.id); + ui.label(format!( + "{} {} · {}", + if confirmed { "Confirmed" } else { "Candidate" }, + i + 1, + if complete { "Complete" } else { "Partial" } + )); + if let Some(m) = app + .doc + .protein + .bindings + .get(&d) + .and_then(|id| app.doc.protein.molecule(*id).ok()) + { + ui.small( + c.targets + .iter() + .map(|t| describe_target(m, t.as_ref())) + .collect::>() + .join(" / "), + ); + } + if !c.note.is_empty() { + ui.small(&c.note); + } + ui.horizontal_wrapped(|ui| { + if ui.button("Edit").clicked() { + panel.draft = Some(Draft::new(d, p, Some(c.clone()))); + } + let edit = if confirmed { + ui.button("Withdraw confirmation") + .clicked() + .then_some(AssignmentEdit::Withdraw) + } else { + ui.button("Confirm") + .clicked() + .then_some(AssignmentEdit::Confirm { candidate: c.id }) + }; + let edit = if ui.button("Delete candidate").clicked() { + Some(AssignmentEdit::Delete { candidate: c.id }) + } else { + edit + }; + if let Some(edit) = edit { + panel.feedback = app.edit_assignment(d, p, edit).err().unwrap_or_default(); + } + }); + } + if ui.button("Clear peak assignments").clicked() { + panel.feedback = app + .edit_assignment(d, p, AssignmentEdit::Clear) + .err() + .unwrap_or_default(); + } + } + if ui.button("New candidate").clicked() { + panel.draft = Some(Draft::new(d, p, None)); + } +} + +impl Draft { + pub(super) fn new(dataset: DatasetId, peak: Peak3DId, baseline: Option) -> Self { + let targets = baseline + .as_ref() + .map_or([None, None, None], |c| c.targets.clone()); + let residues = std::array::from_fn(|i| targets[i].as_ref().map(|t| t.residue)); + let note = baseline + .as_ref() + .map_or_else(String::new, |c| c.note.clone()); + Self { + dataset, + peak, + baseline, + residues, + targets, + note, + } + } +} + +fn describe_target(m: &Molecule, target: Option<&AtomTarget>) -> String { + let Some(t) = target else { + return "Unassigned".into(); + }; + let atom = t.members.iter().cloned().collect::>().join(", "); + let atom = if t.members.len() > 1 { + format!( + "{} {{{atom}}}", + match t.group_meaning { + GroupMeaning::Unspecified => "Group meaning unspecified", + GroupMeaning::OneOf => "One of", + GroupMeaning::Shared => "Shared resonance", + } + ) + } else { + atom + }; + for c in &m.chains { + if let Some((i, r)) = c + .residues + .iter() + .enumerate() + .find(|(_, r)| r.id == t.residue) + { + return format!( + "{}:{}{} {atom}", + c.name, + r.code, + i64::from(c.start) + i as i64 + ); + } + } + atom +} + +pub(super) fn draft_editor(app: &mut PlotiumApp, ui: &mut Ui, panel: &mut Panel) { + let Some(draft) = &mut panel.draft else { + return; + }; + if ui.button("Discard candidate draft").clicked() { + panel.draft = None; + return; + } + let (d, p) = (draft.dataset, draft.peak); + let Some(m) = app + .doc + .protein + .bindings + .get(&d) + .and_then(|id| app.doc.protein.molecule(*id).ok()) + else { + ui.label("The draft spectrum is no longer bound."); + return; + }; + let Some((v, mark)) = protein::volume(&app.doc, d) + .ok() + .and_then(|v| v.peaks.marks.iter().find(|m| m.id == p).map(|m| (v, m))) + else { + ui.label("The draft peak no longer exists."); + return; + }; + ui.heading(format!("Candidate draft · Peak {}", p.0)); + assignment_context(app, ui, d); + if app + .active_dataset() + .and_then(|i| app.doc.datasets.get(i)) + .map(|d| d.resource_id()) + != Some(d) + { + ui.label( + "Editing a draft for a different spectrum. Save applies to the spectrum named above.", + ); + } + ui.small("Atoms follow standard peptide-chain positions and the axis nucleus; protonation is not inferred."); + for i in 0..3 { + ui.push_id(i, |ui| { + let nucleus = v.processed.axes()[i].nucleus().unwrap_or("unknown"); + ui.label(format!("F{} · {nucleus} · {:.3} ppm", i + 1, mark.ppm[i])); + let old = draft.residues[i]; + ComboBox::from_id_salt("residue") + .selected_text(draft.targets[i].as_ref().map_or_else( + || { + if old.is_some() { + "Select atoms".into() + } else { + "Unassigned".into() + } + }, + |t| describe_target(m, Some(t)), + )) + .show_ui(ui, |ui| { + ui.selectable_value(&mut draft.residues[i], None, "Unassigned"); + for c in &m.chains { + for (n, r) in c.residues.iter().enumerate() { + ui.selectable_value( + &mut draft.residues[i], + Some(r.id), + format!("{}:{}{}", c.name, r.code, i64::from(c.start) + n as i64), + ); + } + } + }); + if old != draft.residues[i] { + draft.targets[i] = None; + } + if let Some(r) = m + .chains + .iter() + .flat_map(|c| &c.residues) + .find(|r| Some(r.id) == draft.residues[i]) + { + let mut members = draft.targets[i] + .as_ref() + .map(|t| t.members.clone()) + .unwrap_or_default(); + let mut meaning = draft.targets[i].as_ref().map_or(GroupMeaning::Unspecified, |t| t.group_meaning); + let allowed = match protein::residue_atoms(m, r.id) { + Ok(atoms) => atoms.collect::>(), + Err(e) => { ui.label(e); return; } + }; + ui.horizontal_wrapped(|ui| { + for (name, element) in allowed { + if !protein::atom_matches_nucleus(element, nucleus) { + continue; + } + let mut selected = members.contains(name); + if ui.checkbox(&mut selected, name).changed() { + if selected { + members.insert(name.into()); + } else { + members.remove(name); + } + } + } + }); + if members.len() > 1 { + ui.label("Atom group meaning"); + ui.radio_value(&mut meaning, GroupMeaning::Unspecified, "Unspecified (candidate only)"); + ui.radio_value(&mut meaning, GroupMeaning::OneOf, "One of these atoms (identity unresolved)"); + ui.radio_value(&mut meaning, GroupMeaning::Shared, "All members share this resonance"); + ui.small("A shared resonance does not imply chemical equivalence or stereospecific assignment."); + } else { + meaning = GroupMeaning::Unspecified; + } + draft.targets[i] = (!members.is_empty()).then_some(AtomTarget { + residue: r.id, + members, + group_meaning: meaning, + }); + } + }); + } + ui.label("Candidate note"); + ui.add( + egui::TextEdit::multiline(&mut draft.note) + .hint_text("Optional note") + .desired_rows(2), + ); + let missing_atoms = draft + .residues + .iter() + .zip(&draft.targets) + .any(|(residue, target)| { + residue.is_some_and(|id| { + target + .as_ref() + .is_none_or(|t| t.residue != id || t.members.is_empty()) + }) + }); + if missing_atoms { + ui.label("Choose atoms for each selected residue, or set the axis to Unassigned."); + } + if ui + .add_enabled(!missing_atoms, egui::Button::new("Save candidate")) + .clicked() + { + let mut withdrew = false; + let edit = if let Some(base) = &draft.baseline { + let a = app.doc.protein.assignment(d, p); + if a.and_then(|a| a.candidates.iter().find(|c| c.id == base.id)) != Some(base) { + panel.feedback = + "The candidate changed while editing. Discard this draft and reopen it.".into(); + return; + } + withdrew = + a.is_some_and(|a| a.confirmed == Some(base.id)) && base.targets != draft.targets; + AssignmentEdit::Update { + candidate: base.id, + targets: Some(draft.targets.clone()), + note: Some(draft.note.clone()), + } + } else { + AssignmentEdit::Add { + targets: draft.targets.clone(), + note: draft.note.clone(), + } + }; + match app.edit_assignment(d, p, edit) { + Ok(()) => { + panel.draft = None; + panel.feedback = if withdrew { + "Targets changed. Confirm the candidate again.".into() + } else { + String::new() + }; + } + Err(e) => panel.feedback = e, + } + } +} diff --git a/crates/app/src/ui/tools/protein_tests.rs b/crates/app/src/ui/tools/protein_tests.rs new file mode 100644 index 00000000..24b0444e --- /dev/null +++ b/crates/app/src/ui/tools/protein_tests.rs @@ -0,0 +1,374 @@ +use super::*; +use egui::{Event, PointerButton, Pos2, RawInput, Rect, vec2}; + +fn frame( + app: &mut PlotiumApp, + panel: &mut Panel, + ctx: &egui::Context, + events: Vec, +) -> egui::FullOutput { + ctx.run_ui( + RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(900., 1000.))), + events, + ..Default::default() + }, + |ui| body(app, ui, panel), + ) +} + +fn click(app: &mut PlotiumApp, panel: &mut Panel, ctx: &egui::Context, text: &str) { + click_label(app, panel, ctx, text, false); +} + +fn click_label( + app: &mut PlotiumApp, + panel: &mut Panel, + ctx: &egui::Context, + text: &str, + popup: bool, +) { + let output = frame(app, panel, ctx, vec![]); + let mut positions = output.shapes.iter().filter_map(|s| match &s.shape { + egui::Shape::Text(t) if t.galley.text() == text => Some(t.pos + vec2(3., 5.)), + _ => None, + }); + let pos = if popup { + positions.next_back() + } else { + positions.next() + } + .unwrap_or_else(|| panic!("missing label {text}")); + for pressed in [true, false] { + frame( + app, + panel, + ctx, + vec![ + Event::PointerMoved(pos), + Event::PointerButton { + pos, + button: PointerButton::Primary, + pressed, + modifiers: Default::default(), + }, + ], + ); + } +} + +#[test] +fn preview_binding_candidate_confirmation_and_residue_navigation_use_widgets() { + let mut app = PlotiumApp::new(); + let ctx = egui::Context::default(); + let mut panel = Panel { + import: Some(prepare("synthetic.fasta", ">alpha\nAG\n>beta\nGV".into()).unwrap()), + ..Default::default() + }; + click(&mut app, &mut panel, &ctx, "Import sequence"); + let m = app.doc.protein.molecules[0].clone(); + let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../io/tests/fixtures/nmr/bruker-3d-qf"); + let mut peaks = Vec::new(); + for i in 0..2 { + let (dataset, _) = plotium_core::workflow::dataset_from_acquisition( + plotium_io::load_path(&fixture).unwrap().acquisition, + ) + .unwrap(); + let d = dataset.resource_id(); + let name = dataset.display_name(); + app.doc.datasets.push(dataset); + app.set_active_dataset(Some(i)); + click( + &mut app, + &mut panel, + &ctx, + "Bind active spectrum to this molecule", + ); + assert_eq!(app.doc.protein.bindings.get(&d), Some(&m.id)); + let v = protein::volume(&app.doc, d).unwrap(); + let ppm = std::array::from_fn(|axis| v.processed.axis_coordinate(axis, 1).unwrap()); + let p = app.add_peak_3d(d, ppm).unwrap(); + app.session.ui.selected_peak_3d = Some((d, p)); + click(&mut app, &mut panel, &ctx, "New candidate"); + // The popup has its own layer; open it before selecting the first residue. + click(&mut app, &mut panel, &ctx, "Unassigned"); + click(&mut app, &mut panel, &ctx, "A:A1"); + click(&mut app, &mut panel, &ctx, "HB1"); + click(&mut app, &mut panel, &ctx, "HB2"); + click( + &mut app, + &mut panel, + &ctx, + "One of these atoms (identity unresolved)", + ); + // An unfinished second axis must not silently become unassigned. + click(&mut app, &mut panel, &ctx, "Unassigned"); + click(&mut app, &mut panel, &ctx, "B:G1"); + click(&mut app, &mut panel, &ctx, "Save candidate"); + assert!(panel.draft.is_some()); + assert!(app.doc.protein.assignment(d, p).is_none()); + click(&mut app, &mut panel, &ctx, "Select atoms"); + click_label(&mut app, &mut panel, &ctx, "Unassigned", true); + assert!(panel.draft.as_ref().unwrap().residues[1].is_none()); + + click(&mut app, &mut panel, &ctx, "Optional note"); + frame( + &mut app, + &mut panel, + &ctx, + vec![Event::Text("Keyboard note".into())], + ); + click(&mut app, &mut panel, &ctx, "Save candidate"); + click(&mut app, &mut panel, &ctx, "Confirm"); + let a = app.doc.protein.assignment(d, p).unwrap(); + assert_eq!(a.confirmed, Some(a.candidates[0].id)); + assert_eq!(a.candidates[0].note, "Keyboard note"); + assert_eq!( + a.candidates[0].targets[0].as_ref().unwrap().members.len(), + 2 + ); + peaks.push((d, p, name)); + } + click(&mut app, &mut panel, &ctx, "Sequence and associated peaks"); + for _ in 0..8 { + frame(&mut app, &mut panel, &ctx, vec![]); + } + click(&mut app, &mut panel, &ctx, "Chain A · alpha"); + for _ in 0..8 { + frame(&mut app, &mut panel, &ctx, vec![]); + } + click(&mut app, &mut panel, &ctx, "A1"); + let (d, p, name) = &peaks[0]; + click( + &mut app, + &mut panel, + &ctx, + &format!("{name} · Peak {} · Confirmed", p.0), + ); + assert_eq!(app.session.ui.selected_peak_3d, Some((*d, *p))); + assert_eq!(app.active_dataset(), Some(0)); +} + +fn setup_review_spectra() -> (PlotiumApp, Molecule, Vec<(DatasetId, Peak3DId)>) { + let mut app = PlotiumApp::new(); + app.edit_protein(Edit::Import { + source: "review.fasta".into(), + fasta: ">chain\nAWA".into(), + name: None, + chains: None, + }) + .unwrap(); + let m = app.doc.protein.molecules[0].clone(); + let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../io/tests/fixtures/nmr/bruker-3d-qf"); + let mut peaks = Vec::new(); + for i in 0..2 { + let (mut dataset, _) = plotium_core::workflow::dataset_from_acquisition( + plotium_io::load_path(&fixture).unwrap().acquisition, + ) + .unwrap(); + dataset.as_nmr2d_mut().unwrap().name = Some(format!("Review spectrum {i}")); + let d = dataset.resource_id(); + app.doc.datasets.push(dataset); + app.edit_protein(Edit::Bind { + dataset: d, + molecule: Some(m.id), + }) + .unwrap(); + let v = protein::volume(&app.doc, d).unwrap(); + let ppm = std::array::from_fn(|a| v.processed.axis_coordinate(a, 1).unwrap()); + let p = app.add_peak_3d(d, ppm).unwrap(); + let targets = ["H", "N", "H"].map(|name| { + Some(AtomTarget { + residue: m.chains[0].residues[1].id, + members: [name.into()].into_iter().collect(), + group_meaning: GroupMeaning::Unspecified, + }) + }); + app.edit_assignment( + d, + p, + AssignmentEdit::Add { + targets, + note: String::new(), + }, + ) + .unwrap(); + peaks.push((d, p)); + } + app.set_active_dataset(Some(0)); + app.session.ui.selected_peak_3d = Some(peaks[0]); + (app, m, peaks) +} + +fn real_card_frame( + app: &mut PlotiumApp, + ctx: &egui::Context, + events: Vec, +) -> egui::FullOutput { + ctx.run_ui( + RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1000., 700.))), + events, + ..Default::default() + }, + |ui| render(app, ui), + ) +} + +fn visible_text(output: &egui::FullOutput, label: &str) -> Option { + output.shapes.iter().find_map(|s| match &s.shape { + egui::Shape::Text(t) if t.galley.text() == label => { + let pos = t.pos + vec2(3., 5.); + s.clip_rect.contains(pos).then_some(pos) + } + _ => None, + }) +} + +#[test] +fn real_card_scroll_reaches_save_for_complete_group_candidate() { + let (mut app, m, peaks) = setup_review_spectra(); + let (d, p) = peaks[0]; + let base = app.doc.protein.assignment(d, p).unwrap().candidates[0].clone(); + let mut draft = Draft::new(d, p, Some(base)); + for axis in [0, 2] { + draft.targets[axis] = Some(AtomTarget { + residue: m.chains[0].residues[1].id, + members: ["HB2".into(), "HB3".into()].into_iter().collect(), + group_meaning: GroupMeaning::OneOf, + }); + } + let ctx = crate::typography::test_context(); + ctx.data_mut(|data| { + data.insert_temp( + key(&app), + Panel { + draft: Some(draft), + ..Default::default() + }, + ) + }); + open(&mut app); + for _ in 0..8 { + real_card_frame(&mut app, &ctx, vec![]); + } + let initial = real_card_frame(&mut app, &ctx, vec![]); + assert!(visible_text(&initial, "Save candidate").is_none()); + // Wheel input must scroll the real card, not just an unconstrained body. + real_card_frame( + &mut app, + &ctx, + vec![ + Event::PointerMoved(Pos2::new(820., 260.)), + Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + phase: egui::TouchPhase::Move, + delta: vec2(0., -2500.), + modifiers: Default::default(), + }, + ], + ); + let mut save = None; + for _ in 0..30 { + let output = real_card_frame(&mut app, &ctx, vec![]); + save = visible_text(&output, "Save candidate"); + } + let pos = save.expect("Save must be reachable by scrolling at 1000×700"); + assert!(pos.y < 700.); + for pressed in [true, false] { + real_card_frame( + &mut app, + &ctx, + vec![ + Event::PointerMoved(pos), + Event::PointerButton { + pos, + button: PointerButton::Primary, + pressed, + modifiers: Default::default(), + }, + ], + ); + } + assert!( + ctx.data(|data| data.get_temp::(key(&app))) + .unwrap() + .draft + .is_none() + ); + assert_eq!( + app.doc.protein.assignment(d, p).unwrap().candidates[0].targets[0] + .as_ref() + .unwrap() + .group_meaning, + GroupMeaning::OneOf + ); +} + +#[test] +fn clear_review_keeps_its_spectrum_and_requires_review_after_changes() { + let (mut app, _, peaks) = setup_review_spectra(); + let ctx = egui::Context::default(); + let mut panel = Panel::default(); + let original = app.doc.protein.clone(); + click( + &mut app, + &mut panel, + &ctx, + "Clear all assignments on active spectrum", + ); + assert_eq!(app.doc.protein, original); + click(&mut app, &mut panel, &ctx, "Cancel clearing"); + assert_eq!(app.doc.protein, original); + click( + &mut app, + &mut panel, + &ctx, + "Clear all assignments on active spectrum", + ); + let (d, p) = peaks[0]; + let candidate = app.doc.protein.assignment(d, p).unwrap().candidates[0].id; + app.edit_assignment(d, p, AssignmentEdit::Confirm { candidate }) + .unwrap(); + app.set_active_dataset(Some(1)); + app.session.ui.selected_peak_3d = Some(peaks[1]); + let output = frame(&mut app, &mut panel, &ctx, vec![]); + assert!( + visible_text( + &output, + &format!("Active spectrum: {}", spectrum_name(&app, peaks[1].0)) + ) + .is_some() + ); + assert!( + visible_text( + &output, + &format!("Clear assignments from {}?", spectrum_name(&app, d)) + ) + .is_some() + ); + assert!(visible_text(&output,"1 peaks · 1 candidates · 1 confirmed. Peak observations will remain. Undo restores the assignments.").is_some()); + click( + &mut app, + &mut panel, + &ctx, + "Confirm clear spectrum assignments", + ); + assert!(app.doc.protein.assignment(d, p).is_some()); + click(&mut app, &mut panel, &ctx, "Review updated counts"); + click( + &mut app, + &mut panel, + &ctx, + "Confirm clear spectrum assignments", + ); + assert!(app.doc.protein.assignment(d, p).is_none()); + assert!(app.doc.protein.assignment(peaks[1].0, peaks[1].1).is_some()); + app.undo(); + assert_eq!( + app.doc.protein.assignment(d, p).unwrap().confirmed, + Some(candidate) + ); +} diff --git a/crates/app/src/ui/tools/task_card.rs b/crates/app/src/ui/tools/task_card.rs index 9e7f788f..03fbbe1e 100644 --- a/crates/app/src/ui/tools/task_card.rs +++ b/crates/app/src/ui/tools/task_card.rs @@ -4,10 +4,9 @@ use egui::{ Align, Area, CursorIcon, Id, Layout, Order, Pos2, RichText, Sense, Ui, UiBuilder, Vec2, }; -use egui_phosphor::regular as icon; use plotium_core::{ settings::TaskCardSize, - state::{PlotiumApp, TaskDockTab, Tool}, + state::{PlotiumApp, TaskDockTab}, }; use super::task_card_layout::{CardLayout, HorizontalAnchor, VerticalAnchor, fit_layout}; @@ -123,6 +122,7 @@ fn area_id(tab: TaskDockTab) -> Id { TaskDockTab::Regions => Id::new("region_task_card"), TaskDockTab::CurveFit => Id::new("curve_fit_task_card"), TaskDockTab::Statistics => Id::new("statistics_task_card"), + TaskDockTab::Protein => Id::new("protein_task_card"), TaskDockTab::Craft => Id::new("craft_task_card"), } } @@ -149,6 +149,7 @@ fn preferred_size(app: &PlotiumApp, tab: TaskDockTab) -> TaskCardSize { TaskDockTab::CurveFit => cards.curve_fit, TaskDockTab::Statistics => cards.statistics, TaskDockTab::Craft => cards.craft, + TaskDockTab::Protein => cards.protein, } } @@ -161,6 +162,7 @@ fn preferred_size_mut(app: &mut PlotiumApp, tab: TaskDockTab) -> &mut TaskCardSi TaskDockTab::CurveFit => &mut cards.curve_fit, TaskDockTab::Statistics => &mut cards.statistics, TaskDockTab::Craft => &mut cards.craft, + TaskDockTab::Protein => &mut cards.protein, } } @@ -200,6 +202,7 @@ pub(crate) fn visible_area_id(app: &PlotiumApp) -> Option { .stat_task_dataset .filter(|dataset| Some(*dataset) == active) .map(|_| Id::new("statistics_task_card")), + TaskDockTab::Protein => Some(area_id(TaskDockTab::Protein)), TaskDockTab::Craft => app .session .ui @@ -665,134 +668,9 @@ pub(super) fn is_active(app: &PlotiumApp, tab: TaskDockTab) -> bool { app.session.ui.task_dock_active == Some(tab) } -/// Labelled switcher shared by every page in the one top-right task dock. -/// Hidden task pages keep their state; selecting a tab also restores the -/// dataset that owns it so controls never edit a dataset other than the canvas. -pub(super) fn tab_bar(app: &mut PlotiumApp, current: TaskDockTab, ui: &mut Ui) -> bool { - let tabs = [ - ( - TaskDockTab::Dosy, - icon::CHART_LINE, - "DOSY", - app.session - .ui - .dosy_task_dataset - .and_then(|id| app.doc.dataset_index(id)), - ), - ( - TaskDockTab::Processing, - icon::FLOW_ARROW, - "Process", - app.session - .ui - .processing_task_dataset - .and_then(|id| app.doc.dataset_index(id)), - ), - ( - TaskDockTab::Craft, - icon::WAVEFORM, - "CRAFT", - app.session - .ui - .craft_task_dataset - .and_then(|id| app.doc.dataset_index(id)), - ), - ( - TaskDockTab::Regions, - icon::SELECTION, - "Regions", - app.session - .ui - .region_task_dataset - .and_then(|id| app.doc.dataset_index(id)), - ), - ( - TaskDockTab::CurveFit, - icon::CHART_LINE_UP, - "Fit", - app.session.ui.curve_fit_task_dataset, - ), - ( - TaskDockTab::Statistics, - icon::FUNCTION, - "Stats", - app.session.ui.stat_task_dataset, - ), - ]; - let open = tabs - .into_iter() - .filter(|(_, _, _, dataset)| dataset.is_some()) - .collect::>(); - if open.len() < 2 { - return false; - } - let mut action = None; - ui.scope(|ui| { - ui.horizontal_wrapped(|ui| { - ui.spacing_mut().item_spacing.x = 4.0; - for (tab, glyph, label, dataset) in &open { - let selected = *tab == current; - let (fill, stroke) = if selected { - ( - ui.visuals().selection.bg_fill, - ui.visuals().selection.stroke, - ) - } else { - ( - ui.visuals().widgets.inactive.weak_bg_fill, - ui.visuals().widgets.inactive.bg_stroke, - ) - }; - egui::Frame::NONE - .fill(fill) - .stroke(stroke) - .corner_radius(ui.visuals().widgets.inactive.corner_radius) - .inner_margin(egui::Margin::symmetric(6, 2)) - .show(ui, |ui| { - ui.spacing_mut().item_spacing.x = 2.0; - ui.horizontal(|ui| { - if ui - .add( - egui::Button::selectable( - selected, - RichText::new(format!("{glyph} {label}")).small(), - ) - .frame(false), - ) - .clicked() - { - action = Some((false, *tab, *dataset)); - } - if ui - .add(egui::Button::new(RichText::new(icon::X).small()).frame(false)) - .on_hover_text(format!("Close {label}")) - .clicked() - { - action = Some((true, *tab, *dataset)); - } - }); - }); - } - }); - }); - if let Some((close, tab, dataset)) = action { - if close { - app.session.ui.close_task_tab(tab); - return true; - } - if app.session.ui.task_dock_active == Some(TaskDockTab::Regions) - && tab != TaskDockTab::Regions - && app.session.tool == Tool::Regions - { - app.set_tool(Tool::BrowseZoom); - } - app.session.ui.open_task_tab(tab); - if let Some(dataset) = dataset { - app.set_active_dataset(Some(dataset)); - } - } - true -} +#[path = "task_card_tabs.rs"] +mod tabs; +pub(super) use tabs::tab_bar; #[cfg(test)] #[path = "task_card_tests.rs"] diff --git a/crates/app/src/ui/tools/task_card_tabs.rs b/crates/app/src/ui/tools/task_card_tabs.rs new file mode 100644 index 00000000..6c17f53e --- /dev/null +++ b/crates/app/src/ui/tools/task_card_tabs.rs @@ -0,0 +1,139 @@ +use egui::{RichText, Ui}; +use egui_phosphor::regular as icon; +use plotium_core::state::{PlotiumApp, TaskDockTab, Tool}; + +/// Labelled switcher shared by every page in the one top-right task dock. +/// Hidden task pages keep their state; selecting a tab also restores the +/// dataset that owns it so controls never edit a dataset other than the canvas. +pub(in crate::ui::tools) fn tab_bar( + app: &mut PlotiumApp, + current: TaskDockTab, + ui: &mut Ui, +) -> bool { + let tabs = [ + (TaskDockTab::Protein, icon::SELECTION, "Protein", None), + ( + TaskDockTab::Dosy, + icon::CHART_LINE, + "DOSY", + app.session + .ui + .dosy_task_dataset + .and_then(|id| app.doc.dataset_index(id)), + ), + ( + TaskDockTab::Processing, + icon::FLOW_ARROW, + "Process", + app.session + .ui + .processing_task_dataset + .and_then(|id| app.doc.dataset_index(id)), + ), + ( + TaskDockTab::Craft, + icon::WAVEFORM, + "CRAFT", + app.session + .ui + .craft_task_dataset + .and_then(|id| app.doc.dataset_index(id)), + ), + ( + TaskDockTab::Regions, + icon::SELECTION, + "Regions", + app.session + .ui + .region_task_dataset + .and_then(|id| app.doc.dataset_index(id)), + ), + ( + TaskDockTab::CurveFit, + icon::CHART_LINE_UP, + "Fit", + app.session.ui.curve_fit_task_dataset, + ), + ( + TaskDockTab::Statistics, + icon::FUNCTION, + "Stats", + app.session.ui.stat_task_dataset, + ), + ]; + let open = tabs + .into_iter() + .filter(|(tab, _, _, dataset)| { + dataset.is_some() || (*tab == TaskDockTab::Protein && app.session.ui.protein_open) + }) + .collect::>(); + if open.len() < 2 { + return false; + } + let mut action = None; + ui.scope(|ui| { + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + for (tab, glyph, label, dataset) in &open { + let selected = *tab == current; + let (fill, stroke) = if selected { + ( + ui.visuals().selection.bg_fill, + ui.visuals().selection.stroke, + ) + } else { + ( + ui.visuals().widgets.inactive.weak_bg_fill, + ui.visuals().widgets.inactive.bg_stroke, + ) + }; + egui::Frame::NONE + .fill(fill) + .stroke(stroke) + .corner_radius(ui.visuals().widgets.inactive.corner_radius) + .inner_margin(egui::Margin::symmetric(6, 2)) + .show(ui, |ui| { + ui.spacing_mut().item_spacing.x = 2.0; + ui.horizontal(|ui| { + if ui + .add( + egui::Button::selectable( + selected, + RichText::new(format!("{glyph} {label}")).small(), + ) + .frame(false), + ) + .clicked() + { + action = Some((false, *tab, *dataset)); + } + if ui + .add(egui::Button::new(RichText::new(icon::X).small()).frame(false)) + .on_hover_text(format!("Close {label}")) + .clicked() + { + action = Some((true, *tab, *dataset)); + } + }); + }); + } + }); + }); + if let Some((close, tab, dataset)) = action { + if close { + app.session.ui.close_task_tab(tab); + return true; + } + if app.session.ui.task_dock_active == Some(TaskDockTab::Regions) + && tab != TaskDockTab::Regions + && app.session.tool == Tool::Regions + { + app.set_tool(Tool::BrowseZoom); + } + app.session.ui.open_task_tab(tab); + if let Some(dataset) = dataset { + app.set_active_dataset(Some(dataset)); + } + } + true +} diff --git a/crates/cli/src/tool.rs b/crates/cli/src/tool.rs index cbc6e4a5..7c46aeb5 100644 --- a/crates/cli/src/tool.rs +++ b/crates/cli/src/tool.rs @@ -175,6 +175,10 @@ fn run_operation( } let targets = if let Some(targets) = operation.targets { targets + } else if operation.tool_id.starts_with("protein.") { + TargetSelector::Explicit { + ids: vec![plotium_core::automation::document_root_target().resource.id], + } } else if native.is_some() { let dataset = &app.doc.datasets[0]; let resource = if let Some(key) = operation.field_key { diff --git a/crates/cli/tests/protein.rs b/crates/cli/tests/protein.rs new file mode 100644 index 00000000..a72eb183 --- /dev/null +++ b/crates/cli/tests/protein.rs @@ -0,0 +1,129 @@ +use serde_json::{Value, json}; +use std::process::Command; + +#[test] +fn cli_imports_fasta_and_exports_from_saved_document_without_selection() { + let tmp = tempfile::tempdir().unwrap(); + let input = tmp.path().join("input.plotium"); + let output = tmp.path().join("output.plotium"); + let request = tmp.path().join("request.json"); + let mut app = plotium_core::state::PlotiumApp::new(); + 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 d = dataset.resource_id(); + app.doc.datasets.push(dataset); + let volume = plotium_core::protein::volume(&app.doc, d).unwrap(); + let ppm = std::array::from_fn(|a| volume.processed.axis_coordinate(a, 1).unwrap()); + let peak = app.add_peak_3d(d, ppm).unwrap(); + plotium_core::project::save_project(&app, &input, false).unwrap(); + let run = |input: &std::path::Path, tool: &str, parameters: Value, save: bool| { + std::fs::write( + &request, + json!({"tool_id":tool,"parameters":parameters}).to_string(), + ) + .unwrap(); + let mut cmd = Command::new(env!("CARGO_BIN_EXE_plotium-cli")); + cmd.args(["tool", "run"]) + .arg(input) + .arg("--request") + .arg(&request); + if save { + cmd.arg("--output").arg(&output); + } + let result = cmd.output().unwrap(); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + let value: Value = serde_json::from_slice(&result.stdout).unwrap(); + assert!(value.get("error").is_none(), "{value}"); + value + }; + run( + &input, + "protein.edit", + json!({"operation":"import","source":"two-chains.fasta","fasta":include_str!("../../io/tests/fixtures/protein/two-chains.fasta")}), + true, + ); + let reopened = plotium_core::project::load_project(&output).unwrap(); + let molecule = &reopened.doc.protein.molecules[0]; + assert_eq!(molecule.chains.len(), 2); + let id = molecule.id; + let residue = molecule.chains[0].residues[0].id; + let mut direct = reopened; + use plotium_core::protein::{AssignmentEdit, Edit}; + for (tool, parameters) in [ + ( + "protein.edit", + json!({"operation":"bind","dataset":d,"molecule":id}), + ), + ( + "protein.assignments.edit", + json!({"dataset":d,"peak":peak,"edit":{"operation":"add","targets":[{"residue":residue,"members":["HB1","HB2"],"group_meaning":"one_of"},null,null]}}), + ), + ] { + std::fs::copy(&output, &input).unwrap(); + run(&input, tool, parameters, true); + } + let candidate_state = plotium_core::project::load_project(&output).unwrap(); + let c = candidate_state + .doc + .protein + .assignment(d, peak) + .unwrap() + .candidates[0] + .clone(); + assert_eq!(c.targets[0].as_ref().unwrap().members.len(), 2); + std::fs::copy(&output, &input).unwrap(); + run( + &input, + "protein.assignments.edit", + json!({"dataset":d,"peak":peak,"edit":{"operation":"confirm","candidate":c.id}}), + true, + ); + direct + .edit_protein(Edit::Bind { + dataset: d, + molecule: Some(id), + }) + .unwrap(); + direct + .edit_assignment( + d, + peak, + AssignmentEdit::Add { + targets: c.targets, + note: String::new(), + }, + ) + .unwrap(); + let direct_id = direct.doc.protein.assignment(d, peak).unwrap().candidates[0].id; + direct + .edit_assignment( + d, + peak, + AssignmentEdit::Confirm { + candidate: direct_id, + }, + ) + .unwrap(); + let report = run( + &output, + "protein.assignments.export", + json!({"molecule":id}), + false, + ); + let actual = &report["value"]["spectra"][0]; + let expected = &direct.export_protein(id, false).unwrap()["spectra"][0]; + assert_eq!(actual["observations"], expected["observations"]); + assert_eq!( + actual["assignments"][0]["candidates"][0]["targets"], + expected["assignments"][0]["candidates"][0]["targets"] + ); + assert_eq!(actual["assignments"][0]["confirmed"], json!(c.id)); +} diff --git a/crates/core/src/actions/app_impl/apply.rs b/crates/core/src/actions/app_impl/apply.rs index c5ab8188..001ce673 100644 --- a/crates/core/src/actions/app_impl/apply.rs +++ b/crates/core/src/actions/app_impl/apply.rs @@ -66,6 +66,7 @@ impl PlotiumApp { } } } + Action::SetProtein { after, .. } => self.doc.protein = after.clone(), Action::InstallScientificState { after, .. } => { self.install_processing_snapshots(after)? } diff --git a/crates/core/src/actions/app_impl/processing_snapshot.rs b/crates/core/src/actions/app_impl/processing_snapshot.rs index bdf4b17e..7f35f51d 100644 --- a/crates/core/src/actions/app_impl/processing_snapshot.rs +++ b/crates/core/src/actions/app_impl/processing_snapshot.rs @@ -33,7 +33,11 @@ impl PlotiumApp { next.next_step_id = next.next_step_id.max(current.next_step_id) } (Dataset::Nmr2D(current), Dataset::Nmr2D(next)) => { - next.next_step_id = next.next_step_id.max(current.next_step_id) + next.next_step_id = next.next_step_id.max(current.next_step_id); + if let (Some(current), Some(next)) = (¤t.volume, &mut next.volume) { + // Undo restores observations without making their IDs available again. + next.peaks.next_id = next.peaks.next_id.max(current.peaks.next_id); + } } (Dataset::Xps(current), Dataset::Xps(next)) => { next.next_step_id = next.next_step_id.max(current.next_step_id) diff --git a/crates/core/src/actions/app_impl/revert.rs b/crates/core/src/actions/app_impl/revert.rs index cce2c501..27ba78b3 100644 --- a/crates/core/src/actions/app_impl/revert.rs +++ b/crates/core/src/actions/app_impl/revert.rs @@ -29,6 +29,7 @@ impl PlotiumApp { self.revert_action(action); } } + Action::SetProtein { before, .. } => self.doc.protein = before.clone(), Action::InstallScientificState { before, .. } => { if let Err(error) = self.install_processing_snapshots(before) { self.session.status = error.to_string(); diff --git a/crates/core/src/actions/app_impl/validate.rs b/crates/core/src/actions/app_impl/validate.rs index 447cf9b1..1c5aac44 100644 --- a/crates/core/src/actions/app_impl/validate.rs +++ b/crates/core/src/actions/app_impl/validate.rs @@ -112,6 +112,14 @@ pub(super) fn validate_action( return Err(ActionApplyError::StaleTarget(format!("dataset {dataset}"))); } } + Action::SetProtein { before, after } => { + if *before != app.doc.protein { + return Err(ActionApplyError::StaleTarget("protein assignments".into())); + } + after + .validate(&app.doc) + .map_err(ActionApplyError::InvalidValue)?; + } Action::InstallScientificState { before, after } => { if before.is_empty() || before.len() != after.len() { return Err(ActionApplyError::InvalidValue( diff --git a/crates/core/src/actions/mod.rs b/crates/core/src/actions/mod.rs index 43590942..68e08655 100644 --- a/crates/core/src/actions/mod.rs +++ b/crates/core/src/actions/mod.rs @@ -209,6 +209,10 @@ impl PanelState { #[derive(Clone)] pub enum Action { + SetProtein { + before: crate::protein::ProteinState, + after: crate::protein::ProteinState, + }, InstallScientificState { before: Vec, after: Vec, @@ -653,6 +657,7 @@ mod build; impl Action { pub fn undo_label(&self) -> &'static str { match self { + Self::SetProtein { .. } => "edit protein assignments", Self::InstallScientificState { .. } => "apply scientific changes", Self::InsertTableData { .. } => "import table data", Self::CreateTableData { .. } => "add data table", @@ -752,6 +757,7 @@ impl Action { Self::SetHandEdits { before, after } => before == after, Self::DeleteCanvas { .. } | Self::InsertCanvas { .. } + | Self::SetProtein { .. } | Self::InstallScientificState { .. } | Self::InsertTableData { .. } | Self::CreateTableData { .. } diff --git a/crates/core/src/automation/dataset_delete.rs b/crates/core/src/automation/dataset_delete.rs index 49ad37a2..fe99cb55 100644 --- a/crates/core/src/automation/dataset_delete.rs +++ b/crates/core/src/automation/dataset_delete.rs @@ -28,16 +28,23 @@ pub(super) fn actions(app: &PlotiumApp, plan: &ToolPlan) -> Result, } indices.sort_unstable_by(|a, b| b.cmp(a)); indices.dedup(); - indices + let mut protein = app.doc.protein.clone(); + let mut actions = indices .into_iter() .map(|index| { - app.delete_dataset_action(index) - .map_err(|message| AutomationError::InvalidParameters { + protein.remove_dataset(app.doc.datasets[index].resource_id()); + app.delete_dataset_base_action(index).map_err(|message| { + AutomationError::InvalidParameters { tool_id: DATASET_DELETE.into(), message, - }) + } + }) }) - .collect() + .collect::, _>>()?; + if protein != app.doc.protein { + actions.insert(0, app.protein_action(protein)); + } + Ok(actions) } pub(super) fn execute( diff --git a/crates/core/src/automation/mod.rs b/crates/core/src/automation/mod.rs index 5667e464..63cf296b 100644 --- a/crates/core/src/automation/mod.rs +++ b/crates/core/src/automation/mod.rs @@ -96,3 +96,4 @@ mod tests; mod table_tests; mod peaks3d; +mod protein; diff --git a/crates/core/src/automation/protein.rs b/crates/core/src/automation/protein.rs new file mode 100644 index 00000000..c3957722 --- /dev/null +++ b/crates/core/src/automation/protein.rs @@ -0,0 +1,232 @@ +use super::*; +use crate::protein::{AssignmentEdit, ChainId, Edit, MoleculeId, ResidueId}; +use crate::state::{DatasetId, Peak3DId, PlotiumApp}; +use serde::Deserialize; +use serde_json::{Value, json}; + +pub const EDIT: &str = "protein.edit"; +pub const ASSIGN: &str = "protein.assignments.edit"; +pub const INSPECT: &str = "protein.inspect"; +pub const EXPORT: &str = "protein.assignments.export"; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct AssignmentParams { + dataset: DatasetId, + peak: Peak3DId, + edit: AssignmentEdit, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Inspect { + molecule: Option, + chain: Option, + #[serde(default)] + view: InspectView, + dataset: Option, + residue: Option, + #[serde(default)] + offset: usize, + limit: Option, +} + +#[derive(Default, Deserialize)] +#[serde(rename_all = "snake_case")] +enum InspectView { + #[default] + Overview, + Chains, + Residues, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Export { + molecule: MoleculeId, + #[serde(default)] + confirmed_only: bool, +} + +pub(super) fn validate(tool: &str, value: Value) -> Result<(), AutomationError> { + use super::registry::parse; + match tool { + EDIT => parse::(tool, value).map(drop), + ASSIGN => parse::(tool, value).map(drop), + INSPECT => parse::(tool, value).map(drop), + EXPORT => parse::(tool, value).map(drop), + _ => Err(AutomationError::Execution( + "Unknown protein operation".into(), + )), + } +} + +pub(super) fn execute( + app: &mut PlotiumApp, + plan: &ToolPlan, +) -> Result { + use super::registry::parse; + let tool = plan.request.tool_id.as_str(); + let params = plan.request.parameters.clone(); + let fail = AutomationError::Execution; + let value = match tool { + EDIT => { + app.edit_protein(parse(tool, params)?).map_err(fail)?; + json!({"molecules": app.doc.protein.molecules, "bindings": app.doc.protein.bindings}) + } + ASSIGN => { + let p: AssignmentParams = parse(tool, params)?; + app.edit_assignment(p.dataset, p.peak, p.edit) + .map_err(fail)?; + json!({"assignment": app.doc.protein.assignment(p.dataset, p.peak)}) + } + INSPECT => inspect(app, parse(tool, params)?).map_err(fail)?, + EXPORT => { + let p: Export = parse(tool, params)?; + app.export_protein(p.molecule, p.confirmed_only) + .map_err(fail)? + } + _ => return Err(fail("Unknown protein operation".into())), + }; + let mut result = + super::tool_executors::readonly_value(tool, plan.frozen_targets.revision, value); + result.after_revision = DocumentRevision(app.doc.automation_revision); + if result.after_revision != plan.frozen_targets.revision { + result.modified.clone_from(&plan.frozen_targets.targets); + } + Ok(result) +} + +fn inspect(app: &PlotiumApp, p: Inspect) -> Result { + let state = &app.doc.protein; + let molecule = p.molecule.map(|id| state.molecule(id)).transpose()?; + let limit = p.limit.unwrap_or(100).min(250); + let offset = p.offset; + let next = |total: usize| { + let end = offset.saturating_add(limit); + (limit > 0 && end < total).then_some(end) + }; + // Names and FASTA titles are user input; these labels are only previews. + let label = |text: &str| text.chars().take(64).collect::(); + match p.view { + InspectView::Chains | InspectView::Residues => { + if p.dataset.is_some() || p.residue.is_some() { + return Err("Dataset and residue filters apply to the overview".into()); + } + let m = molecule.ok_or("Select a molecule for sequence lookup")?; + if matches!(p.view, InspectView::Chains) { + if p.chain.is_some() { + return Err("Use the residues view to select a chain".into()); + } + let chains = m.chains.iter().skip(offset).take(limit).map(|c| { + json!({"id":c.id,"name":label(&c.name),"title":label(&c.title),"start":c.start,"residue_count":c.residues.len()}) + }).collect::>(); + return Ok( + json!({"molecule":m.id,"chains":chains,"total":m.chains.len(),"next_offset":next(m.chains.len())}), + ); + } + let id = p.chain.ok_or("Select a chain for residue lookup")?; + let c = m + .chains + .iter() + .find(|c| c.id == id) + .ok_or_else(|| format!("Unknown chain {} in molecule {}", id.0, m.id.0))?; + let residues = c.residues.iter().enumerate().skip(offset).take(limit).map(|(i,r)| { + json!({"id":r.id,"code":r.code,"number":i64::from(c.start) + i as i64}) + }).collect::>(); + Ok( + json!({"molecule":m.id,"chain":c.id,"residues":residues,"total":c.residues.len(),"next_offset":next(c.residues.len())}), + ) + } + InspectView::Overview => { + if p.chain.is_some() { + return Err("Use the residues view to select a chain".into()); + } + if let Some(d) = p.dataset { + crate::protein::volume(&app.doc, d)?; + } + let matches = state + .assignments + .iter() + .filter(|a| { + p.dataset.is_none_or(|d| a.dataset == d) + && p.molecule + .is_none_or(|m| state.bindings.get(&a.dataset) == Some(&m)) + && p.residue.is_none_or(|r| { + a.candidates + .iter() + .any(|c| c.targets.iter().flatten().any(|t| t.residue == r)) + }) + }) + .collect::>(); + let rows = matches.iter().skip(offset).take(limit).collect::>(); + let matching_molecules = state + .molecules + .iter() + .filter(|m| { + p.molecule.is_none_or(|id| m.id == id) + && p.dataset + .is_none_or(|d| state.bindings.get(&d) == Some(&m.id)) + && p.residue.is_none_or(|id| { + m.chains + .iter() + .any(|c| c.residues.iter().any(|r| r.id == id)) + }) + }) + .collect::>(); + let molecules = matching_molecules + .iter() + .skip(offset) + .take(limit) + .map(|m| json!({"id":m.id,"name":label(&m.name),"chain_count":m.chains.len()})) + .collect::>(); + let molecule_ids: std::collections::HashSet<_> = + matching_molecules.iter().map(|m| m.id).collect(); + let bindings = state + .bindings + .iter() + .filter(|(d, m)| p.dataset.is_none_or(|id| **d == id) && molecule_ids.contains(m)) + .collect::>(); + let page_bindings = bindings + .iter() + .skip(offset) + .take(limit) + .copied() + .collect::>(); + Ok( + json!({"molecules":molecules,"bindings":page_bindings,"assignments":rows, + "total":matches.len(),"molecule_total":matching_molecules.len(),"binding_total":bindings.len(), + "next_offset":next(matches.len().max(matching_molecules.len()).max(bindings.len()))}), + ) + } + } +} + +pub(super) fn descriptors() -> Vec { + let target_schema = json!({ + "type":["object","null"], + "properties": { + "residue":{"type":"string"}, + "members":{"type":"array","minItems":1,"uniqueItems":true,"items":{"type":"string"}}, + "group_meaning":{"enum":["unspecified","one_of","shared"],"default":"unspecified", + "description":"For multiple atoms: one_of means one unidentified member; shared means all members share the observed resonance. Unspecified groups may be saved but not confirmed. Single atoms use unspecified."} + }, + "required":["residue","members"] + }); + [ + (EDIT, "Edit protein sequence metadata and spectrum bindings", true, + json!({"operation":{"enum":["import","rename","label_chain","delete","bind","clear_spectrum_assignments"]},"source":{"type":"string"},"fasta":{"type":"string"},"name":{"type":"string"},"chains":{"type":"array"},"molecule":{"type":["string","null"]},"chain":{"type":"string"},"label":{"type":"object"},"dataset":{"type":"string"}}), vec!["operation"]), + (ASSIGN, "Edit or confirm a manual 3D peak assignment", true, + json!({"dataset":{"type":"string"},"peak":{"type":"integer","minimum":0},"edit":{"type":"object","properties":{"operation":{"enum":["add","update","delete","confirm","withdraw","clear"]},"targets":{"type":"array","minItems":3,"maxItems":3,"items":target_schema},"candidate":{"type":"string"},"note":{"type":"string"}},"required":["operation"]}}),vec!["dataset","peak","edit"]), + (INSPECT, "List protein summaries and associated peaks, or page chains and residues", false, + json!({"view":{"enum":["overview","chains","residues"],"default":"overview"},"molecule":{"type":"string","description":"Required for chains and residues"},"chain":{"type":"string","description":"Required for residues"},"dataset":{"type":"string"},"residue":{"type":"string"},"offset":{"type":"integer","minimum":0},"limit":{"type":"integer","minimum":0}}),vec![]), + (EXPORT, "Export a molecule's manual assignments with acquisition and processing provenance", false, + json!({"molecule":{"type":"string"},"confirmed_only":{"type":"boolean"}}),vec!["molecule"]), + ].into_iter().map(|(id,title,edit,properties,required)| ToolDescriptor { + id:id.into(),version:1,title:title.into(),description:title.into(), + parameter_schema:json!({"type":"object","additionalProperties":false,"properties":properties,"required":required}), + result_schema:json!({"type":"object"}),target_kinds:vec![ResourceKindId::new(KIND_DOCUMENT)], + required_capabilities:vec![],effect:if edit {EffectLevel::Reversible} else {EffectLevel::ReadOnly}, + undoable:edit,deterministic:!edit,task_kind:"synchronous".into() + }).collect() +} diff --git a/crates/core/src/automation/registry.rs b/crates/core/src/automation/registry.rs index 531e11f0..95bdc8a9 100644 --- a/crates/core/src/automation/registry.rs +++ b/crates/core/src/automation/registry.rs @@ -53,6 +53,10 @@ pub(super) fn validate_parameters( value: serde_json::Value, ) -> Result<(), AutomationError> { match tool { + super::protein::EDIT + | super::protein::ASSIGN + | super::protein::INSPECT + | super::protein::EXPORT => super::protein::validate(tool, value), super::peaks3d::PEAKS_3D_LIST => parse::(tool, value).map(drop), super::peaks3d::PEAKS_3D => parse::(tool, value).map(drop), NMR_VOLUME_INSPECT => parse::(tool, value).map(drop), @@ -618,6 +622,7 @@ fn descriptors() -> Vec { .chain(super::science::descriptors()) .chain(super::nmr_volume::descriptors()) .chain(super::peaks3d::descriptors()) + .chain(super::protein::descriptors()) .chain(super::dosy::descriptors()) .chain(super::region_fit::descriptors()) .chain(std::iter::once(super::processing_edit::descriptor())) diff --git a/crates/core/src/automation/tools.rs b/crates/core/src/automation/tools.rs index e6c26dc5..63ec4ba6 100644 --- a/crates/core/src/automation/tools.rs +++ b/crates/core/src/automation/tools.rs @@ -24,6 +24,18 @@ pub fn plan_tool(app: &PlotiumApp, request: ToolRequest) -> Result super::region_fit::execute(app, &plan)?, DOSY_BUILD => super::dosy::execute(app, &plan)?, ANALYSIS_RESULTS_INSPECT => super::dosy::inspect(app, &plan)?, + super::protein::EDIT + | super::protein::ASSIGN + | super::protein::INSPECT + | super::protein::EXPORT => super::protein::execute(app, &plan)?, super::peaks3d::PEAKS_3D_LIST | super::peaks3d::PEAKS_3D => { super::peaks3d::execute(app, &plan)? } diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index dd0cc3dd..0918fbd7 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -18,6 +18,7 @@ mod origin_hardening_tests; mod origin_tests; pub mod project; pub mod properties; +pub mod protein; pub mod settings; pub mod state; pub mod templates; diff --git a/crates/core/src/project/dto.rs b/crates/core/src/project/dto.rs index beb76ce7..903c7877 100644 --- a/crates/core/src/project/dto.rs +++ b/crates/core/src/project/dto.rs @@ -26,6 +26,8 @@ pub struct Manifest { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub runs: Vec, pub workspace: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub protein: Option, } #[derive(Clone, Serialize, Deserialize)] diff --git a/crates/core/src/project/mod.rs b/crates/core/src/project/mod.rs index 19e2075c..f1301421 100644 --- a/crates/core/src/project/mod.rs +++ b/crates/core/src/project/mod.rs @@ -318,6 +318,7 @@ fn save_project_impl( assets: Vec::new(), runs: Vec::new(), workspace: "workspace.json".to_owned(), + protein: None, }; let mut bindings = Vec::with_capacity(doc.datasets.len()); @@ -473,6 +474,12 @@ fn save_project_impl( hand_edits: doc.hand_edits.clone(), }; write_json(&mut zip, options, "workspace.json", &workspace)?; + if !doc.protein.is_empty() { + doc.protein.validate(doc).map_err(ProjectError::Invalid)?; + let path = "protein/state.json"; + write_json(&mut zip, options, path, &doc.protein)?; + manifest.protein = Some(path.into()); + } write_json(&mut zip, options, "manifest.json", &manifest)?; let file = zip.finish()?; file.sync_all()?; @@ -645,6 +652,11 @@ pub fn load_project(path: &Path) -> Result { }) .collect(); app.doc.save_include_view_snapshots = manifest.save_profile.include_view_snapshots; + if let Some(path) = &manifest.protein { + let protein: crate::protein::ProteinState = read_json(&mut zip, path)?; + protein.validate(&app.doc).map_err(ProjectError::Invalid)?; + app.doc.protein = protein; + } app.doc.project_revision = manifest.revision; app.doc.edit_generation = 0; app.doc.dirty = false; diff --git a/crates/core/src/project/persistence_tests.rs b/crates/core/src/project/persistence_tests.rs index 42b39249..f9048117 100644 --- a/crates/core/src/project/persistence_tests.rs +++ b/crates/core/src/project/persistence_tests.rs @@ -391,3 +391,24 @@ fn approved_replace_preserves_foreign_backup_copy_temporary() { fn approved_replace_preserves_foreign_backup_rotation_temporary() { approved_replace_preserves_foreign_backup_temporary(".tmp"); } + +#[test] +fn recovery_snapshot_keeps_protein_state_at_capture() { + let root = tempfile::tempdir().unwrap(); + let manager = RecoveryManager::new_in(root.path().join("slots")).unwrap(); + let mut app = crate::state::PlotiumApp::new(); + app.edit_protein(crate::protein::Edit::Import { + source: "two-chains.fasta".into(), + fasta: ">alpha\nAG\n>beta\nGV".into(), + name: None, + chains: None, + }) + .unwrap(); + let expected = app.doc.protein.clone(); + let request = super::super::prepare_recovery_snapshot(&app).unwrap(); + app.undo(); + super::super::save_recovery_snapshot(request, manager.target()).unwrap(); + let recovered = super::super::load_project(manager.target().path()).unwrap(); + assert_eq!(recovered.doc.protein, expected); + manager.shutdown().unwrap(); +} diff --git a/crates/core/src/project/schema_tests.rs b/crates/core/src/project/schema_tests.rs index e8458c78..31679bce 100644 --- a/crates/core/src/project/schema_tests.rs +++ b/crates/core/src/project/schema_tests.rs @@ -71,6 +71,7 @@ fn pre_release_loader_rejects_non_v1_schema_without_a_migration_chain() { views: Vec::new(), assets: Vec::new(), runs: Vec::new(), + protein: None, workspace: "workspace.json".to_owned(), }; diff --git a/crates/core/src/protein.rs b/crates/core/src/protein.rs new file mode 100644 index 00000000..1d033e94 --- /dev/null +++ b/crates/core/src/protein.rs @@ -0,0 +1,590 @@ +//! Protein sequence and manual 3D assignment state. +use crate::state::{DatasetId, Document, Peak3DId, PlotiumApp}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet, HashSet}; +use uuid::Uuid; + +macro_rules! id { + ($name:ident) => { + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] + #[serde(transparent)] + pub struct $name(pub Uuid); + }; +} + +id!(MoleculeId); +id!(ChainId); +id!(ResidueId); +id!(CandidateId); + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct ProteinState { + pub molecules: Vec, + pub bindings: BTreeMap, + pub assignments: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Molecule { + pub id: MoleculeId, + pub name: String, + pub source: String, + pub fasta_sha256: String, + pub chains: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Chain { + pub id: ChainId, + pub title: String, + pub name: String, + pub start: i32, + pub residues: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Residue { + pub id: ResidueId, + pub code: char, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AtomTarget { + pub residue: ResidueId, + pub members: BTreeSet, + #[serde(default)] + pub group_meaning: GroupMeaning, +} + +/// A shared resonance is an observation, not a claim of chemical equivalence. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GroupMeaning { + #[default] + Unspecified, + OneOf, + Shared, +} + +pub type Targets = [Option; 3]; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Candidate { + pub id: CandidateId, + pub targets: Targets, + pub note: String, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Assignment { + pub dataset: DatasetId, + pub peak: Peak3DId, + pub candidates: Vec, + pub confirmed: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ChainLabel { + pub name: String, + pub start: i32, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "operation", rename_all = "snake_case", deny_unknown_fields)] +pub enum Edit { + Import { + source: String, + fasta: String, + name: Option, + chains: Option>, + }, + Rename { + molecule: MoleculeId, + name: String, + }, + LabelChain { + molecule: MoleculeId, + chain: ChainId, + label: ChainLabel, + }, + Delete { + molecule: MoleculeId, + }, + ClearSpectrumAssignments { + dataset: DatasetId, + }, + Bind { + dataset: DatasetId, + molecule: Option, + }, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "operation", rename_all = "snake_case", deny_unknown_fields)] +pub enum AssignmentEdit { + Add { + targets: Targets, + #[serde(default)] + note: String, + }, + Update { + candidate: CandidateId, + targets: Option, + note: Option, + }, + Delete { + candidate: CandidateId, + }, + Confirm { + candidate: CandidateId, + }, + Withdraw, + Clear, +} + +#[path = "protein_catalog.rs"] +mod catalog; +pub use catalog::{atom_matches_nucleus, atoms, nucleus_element, residue_atoms}; + +fn chain_name(mut index: usize) -> String { + let mut reversed = Vec::new(); + loop { + reversed.push(char::from(b'A' + (index % 26) as u8)); + if index < 26 { + break; + } + index = index / 26 - 1; + } + reversed.into_iter().rev().collect() +} + +pub fn import_preview(source: &str, fasta: &str) -> Result { + let records = plotium_io::fasta::parse(fasta)?; + let name = std::path::Path::new(source) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("Protein"); + Ok(Molecule { + id: MoleculeId(Uuid::new_v4()), + name: name.into(), + source: source.into(), + fasta_sha256: format!("{:x}", Sha256::digest(fasta.as_bytes())), + chains: records + .into_iter() + .enumerate() + .map(|(i, r)| Chain { + id: ChainId(Uuid::new_v4()), + title: r.title, + name: chain_name(i), + start: 1, + residues: r + .sequence + .chars() + .map(|code| Residue { + id: ResidueId(Uuid::new_v4()), + code, + }) + .collect(), + }) + .collect(), + }) +} + +impl ProteinState { + pub fn is_empty(&self) -> bool { + self.molecules.is_empty() && self.bindings.is_empty() && self.assignments.is_empty() + } + pub fn molecule(&self, id: MoleculeId) -> Result<&Molecule, String> { + self.molecules + .iter() + .find(|m| m.id == id) + .ok_or_else(|| format!("Unknown molecule {}", id.0)) + } + pub fn assignment(&self, dataset: DatasetId, peak: Peak3DId) -> Option<&Assignment> { + self.assignments + .iter() + .find(|a| a.dataset == dataset && a.peak == peak) + } + pub fn remove_dataset(&mut self, dataset: DatasetId) { + self.bindings.remove(&dataset); + self.assignments.retain(|a| a.dataset != dataset); + } + + pub fn validate(&self, doc: &Document) -> Result<(), String> { + let (mut molecules, mut chains, mut residues, mut peaks, mut candidates) = ( + HashSet::new(), + HashSet::new(), + HashSet::new(), + HashSet::new(), + HashSet::new(), + ); + for m in &self.molecules { + if !molecules.insert(m.id) || m.chains.is_empty() || m.name.trim().is_empty() { + return Err(format!("Invalid or duplicate molecule {}", m.id.0)); + } + let mut labels = HashSet::new(); + for c in &m.chains { + if !chains.insert(c.id) + || c.name.trim().is_empty() + || !labels.insert(&c.name) + || c.residues.is_empty() + || i32::try_from(c.residues.len() - 1) + .ok() + .and_then(|n| c.start.checked_add(n)) + .is_none() + { + return Err(format!( + "Invalid or duplicate chain {} ({})", + c.name, c.id.0 + )); + } + for r in &c.residues { + if !residues.insert(r.id) + || !plotium_io::fasta::STANDARD_RESIDUES.contains(r.code) + { + return Err(format!("Invalid or duplicate residue {}", r.id.0)); + } + } + } + } + for (&dataset, &molecule) in &self.bindings { + self.molecule(molecule)?; + volume(doc, dataset)?; + } + for a in &self.assignments { + let check = || -> Result<(), String> { + if !peaks.insert((a.dataset, a.peak)) || a.candidates.is_empty() { + return Err("Duplicate peak assignment or empty candidate list".into()); + } + let molecule = self.molecule( + *self + .bindings + .get(&a.dataset) + .ok_or("Spectrum has no molecule binding")?, + )?; + let v = volume(doc, a.dataset)?; + if !v.peaks.marks.iter().any(|p| p.id == a.peak) { + return Err("3D peak no longer exists".into()); + } + if a.confirmed + .is_some_and(|id| !a.candidates.iter().any(|c| c.id == id)) + { + return Err("Confirmed candidate no longer exists".into()); + } + for c in &a.candidates { + if !candidates.insert(c.id) || c.targets.iter().all(Option::is_none) { + return Err(format!("Candidate {} is empty or duplicated", c.id.0)); + } + for (i, target) in c.targets.iter().enumerate() { + let Some(t) = target else { continue }; + let result = validate_target( + molecule, + t, + v.processed.axes()[i].nucleus().unwrap_or_default(), + a.confirmed == Some(c.id), + ); + result.map_err(|e| format!("Candidate {} F{}: {e}", c.id.0, i + 1))?; + } + } + Ok(()) + }; + let mut check = check; + check().map_err(|e| format!("Spectrum {}, peak {}: {e}", a.dataset, a.peak.0))?; + } + Ok(()) + } +} + +pub fn volume(doc: &Document, dataset: DatasetId) -> Result<&crate::state::NmrVolumeState, String> { + doc.dataset_by_id(dataset) + .and_then(|d| d.as_nmr2d()) + .and_then(|n| n.volume.as_ref()) + .ok_or_else(|| format!("Dataset {dataset} is missing or is not a 3D spectrum")) +} + +fn validate_target( + molecule: &Molecule, + target: &AtomTarget, + nucleus: &str, + confirmed: bool, +) -> Result<(), String> { + let residue = molecule + .chains + .iter() + .flat_map(|c| &c.residues) + .find(|r| r.id == target.residue) + .ok_or_else(|| { + format!( + "Residue {} does not belong to the bound molecule", + target.residue.0 + ) + })?; + let allowed: Vec<_> = residue_atoms(molecule, target.residue)?.collect(); + if target.members.len() == 1 && target.group_meaning != GroupMeaning::Unspecified { + return Err("Group meaning applies only to multiple atoms".into()); + } + if confirmed && target.members.len() > 1 && target.group_meaning == GroupMeaning::Unspecified { + return Err("Choose whether the group means one of the atoms or a shared resonance before confirming".into()); + } + let mut element = None; + if target.members.is_empty() { + return Err("Select at least one atom".into()); + } + for member in &target.members { + let (_, current) = atoms(residue.code) + .find(|(name, _)| *name == member) + .ok_or_else(|| format!("Unknown atom {member} for {}", residue.code))?; + if !allowed.iter().any(|(name, _)| *name == member) { + return Err(format!( + "Atom {member} is not applicable at this position in a standard peptide chain" + )); + } + if element.replace(current).is_some_and(|e| e != current) { + return Err("Atom group members must have the same element".into()); + } + } + match nucleus_element(nucleus) { + None if confirmed => { + return Err("Cannot confirm an assigned axis without a known nucleus".into()); + } + Some(nucleus) if !element.is_some_and(|e| e.eq_ignore_ascii_case(nucleus)) => { + return Err(format!("Atom element does not match nucleus {nucleus}")); + } + _ => {} + } + Ok(()) +} + +impl PlotiumApp { + pub fn edit_protein(&mut self, edit: Edit) -> Result<(), String> { + let mut state = self.doc.protein.clone(); + match edit { + Edit::Import { + source, + fasta, + name, + chains, + } => { + let mut m = import_preview(&source, &fasta)?; + if let Some(name) = name { + m.name = name; + } + if let Some(labels) = chains { + if labels.len() != m.chains.len() { + return Err("Supply one label per FASTA record".into()); + } + for (c, label) in m.chains.iter_mut().zip(labels) { + c.name = label.name; + c.start = label.start; + } + } + state.molecules.push(m); + } + Edit::Rename { molecule, name } => { + state + .molecules + .iter_mut() + .find(|m| m.id == molecule) + .ok_or("Unknown molecule")? + .name = name; + } + Edit::LabelChain { + molecule, + chain, + label, + } => { + let c = state + .molecules + .iter_mut() + .find(|m| m.id == molecule) + .and_then(|m| m.chains.iter_mut().find(|c| c.id == chain)) + .ok_or("Unknown chain in molecule")?; + c.name = label.name; + c.start = label.start; + } + Edit::Delete { molecule } => { + state.molecule(molecule)?; + let bound: Vec<_> = state + .bindings + .iter() + .filter(|(_, m)| **m == molecule) + .map(|(d, _)| d.to_string()) + .collect(); + if !bound.is_empty() { + return Err(format!("Unbind these spectra first: {}", bound.join(", "))); + } + state.molecules.retain(|m| m.id != molecule); + } + Edit::ClearSpectrumAssignments { dataset } => { + volume(&self.doc, dataset)?; + state.assignments.retain(|a| a.dataset != dataset); + } + Edit::Bind { dataset, molecule } => { + volume(&self.doc, dataset)?; + if state.bindings.get(&dataset).copied() != molecule + && state.assignments.iter().any(|a| a.dataset == dataset) + { + return Err( + "Clear this spectrum's assignments before changing its binding".into(), + ); + } + if let Some(molecule) = molecule { + state.bindings.insert(dataset, molecule); + } else { + state.bindings.remove(&dataset); + } + } + } + self.commit_protein(state) + } + + pub fn edit_assignment( + &mut self, + dataset: DatasetId, + peak: Peak3DId, + edit: AssignmentEdit, + ) -> Result<(), String> { + let mut state = self.doc.protein.clone(); + let v = volume(&self.doc, dataset)?; + if !v.peaks.marks.iter().any(|p| p.id == peak) { + return Err("Unknown 3D peak".into()); + } + let index = state + .assignments + .iter() + .position(|a| a.dataset == dataset && a.peak == peak); + if matches!(edit, AssignmentEdit::Clear) { + if let Some(i) = index { + state.assignments.remove(i); + } + return self.commit_protein(state); + } + let a = if let Some(i) = index { + &mut state.assignments[i] + } else if matches!(edit, AssignmentEdit::Add { .. }) { + state.assignments.push(Assignment { + dataset, + peak, + candidates: Vec::new(), + confirmed: None, + }); + state.assignments.last_mut().ok_or("Missing assignment")? + } else { + return Err("Peak has no assignments".into()); + }; + match edit { + AssignmentEdit::Add { targets, note } => a.candidates.push(Candidate { + id: CandidateId(Uuid::new_v4()), + targets, + note, + }), + AssignmentEdit::Update { + candidate, + targets, + note, + } => { + let c = a + .candidates + .iter_mut() + .find(|c| c.id == candidate) + .ok_or("Unknown candidate")?; + if let Some(targets) = targets { + if c.targets != targets && a.confirmed == Some(candidate) { + a.confirmed = None; + } + c.targets = targets; + } + if let Some(note) = note { + c.note = note; + } + } + AssignmentEdit::Delete { candidate } => { + let i = a + .candidates + .iter() + .position(|c| c.id == candidate) + .ok_or("Unknown candidate")?; + a.candidates.remove(i); + if a.confirmed == Some(candidate) { + a.confirmed = None; + } + } + AssignmentEdit::Confirm { candidate } => a.confirmed = Some(candidate), + AssignmentEdit::Withdraw => a.confirmed = None, + AssignmentEdit::Clear => return Err("Expected a candidate operation".into()), + } + state.assignments.retain(|a| !a.candidates.is_empty()); + self.commit_protein(state) + } + + 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)) + .map_err(|e| e.to_string())?; + } + Ok(()) + } + + pub(crate) fn protein_action(&self, after: ProteinState) -> crate::actions::Action { + crate::actions::Action::SetProtein { + before: self.doc.protein.clone(), + after, + } + } +} + +impl PlotiumApp { + pub fn export_protein( + &self, + id: MoleculeId, + confirmed_only: bool, + ) -> Result { + let state = &self.doc.protein; + let molecule = state.molecule(id)?; + let mut spectra = Vec::new(); + for (&dataset, _) in state.bindings.iter().filter(|(_, m)| **m == id) { + let mut assignments: Vec<_> = state + .assignments + .iter() + .filter(|a| a.dataset == dataset) + .filter(|a| !confirmed_only || a.confirmed.is_some()) + .cloned() + .collect(); + if confirmed_only { + for a in &mut assignments { + a.candidates.retain(|c| Some(c.id) == a.confirmed); + } + } + let mut observations = self.peak_table_3d(dataset)?; + if let Some(rows) = observations["peaks"].as_array_mut() { + rows.retain(|row| { + row["id"] + .as_u64() + .is_some_and(|id| assignments.iter().any(|a| a.peak.0 == id)) + }); + } + spectra.push( + serde_json::json!({"assignments": assignments, "observations": observations}), + ); + } + Ok( + serde_json::json!({"version":1,"molecule":molecule,"spectra":spectra, + "assignment_semantics": { + "confirmed": "Manually accepted interpretation; not automatic chemical or experimental validation", + "group_meaning": { + "unspecified": "Single atom, or an unresolved group interpretation that cannot be confirmed", + "one_of": "One member contributes this observation; its identity within the set is unresolved", + "shared": "All members share this observed resonance; no assertion of chemical equivalence or stereospecific assignment" + }, + "chain_model": "Standard linear peptide chains; terminal atoms are restricted by sequence position. Protonation and modifications are not inferred" + } + }), + ) + } +} + +#[cfg(test)] +#[path = "protein_tests.rs"] +mod tests; diff --git a/crates/core/src/protein_atoms.tsv b/crates/core/src/protein_atoms.tsv new file mode 100644 index 00000000..70045952 --- /dev/null +++ b/crates/core/src/protein_atoms.tsv @@ -0,0 +1,20 @@ +A N:N CA:C C:C O:O CB:C OXT:O H:H H2:H HA:H HB1:H HB2:H HB3:H HXT:H +R N:N CA:C C:C O:O CB:C CG:C CD:C NE:N CZ:C NH1:N NH2:N OXT:O H:H H2:H HA:H HB2:H HB3:H HG2:H HG3:H HD2:H HD3:H HE:H HH11:H HH12:H HH21:H HH22:H HXT:H +N N:N CA:C C:C O:O CB:C CG:C OD1:O ND2:N OXT:O H:H H2:H HA:H HB2:H HB3:H HD21:H HD22:H HXT:H +D N:N CA:C C:C O:O CB:C CG:C OD1:O OD2:O OXT:O H:H H2:H HA:H HB2:H HB3:H HD2:H HXT:H +C N:N CA:C C:C O:O CB:C SG:S OXT:O H:H H2:H HA:H HB2:H HB3:H HG:H HXT:H +Q N:N CA:C C:C O:O CB:C CG:C CD:C OE1:O NE2:N OXT:O H:H H2:H HA:H HB2:H HB3:H HG2:H HG3:H HE21:H HE22:H HXT:H +E N:N CA:C C:C O:O CB:C CG:C CD:C OE1:O OE2:O OXT:O H:H H2:H HA:H HB2:H HB3:H HG2:H HG3:H HE2:H HXT:H +G N:N CA:C C:C O:O OXT:O H:H H2:H HA2:H HA3:H HXT:H +H N:N CA:C C:C O:O CB:C CG:C ND1:N CD2:C CE1:C NE2:N OXT:O H:H H2:H HA:H HB2:H HB3:H HD1:H HD2:H HE1:H HE2:H HXT:H +I N:N CA:C C:C O:O CB:C CG1:C CG2:C CD1:C OXT:O H:H H2:H HA:H HB:H HG12:H HG13:H HG21:H HG22:H HG23:H HD11:H HD12:H HD13:H HXT:H +L N:N CA:C C:C O:O CB:C CG:C CD1:C CD2:C OXT:O H:H H2:H HA:H HB2:H HB3:H HG:H HD11:H HD12:H HD13:H HD21:H HD22:H HD23:H HXT:H +K N:N CA:C C:C O:O CB:C CG:C CD:C CE:C NZ:N OXT:O H:H H2:H HA:H HB2:H HB3:H HG2:H HG3:H HD2:H HD3:H HE2:H HE3:H HZ1:H HZ2:H HZ3:H HXT:H +M N:N CA:C C:C O:O CB:C CG:C SD:S CE:C OXT:O H:H H2:H HA:H HB2:H HB3:H HG2:H HG3:H HE1:H HE2:H HE3:H HXT:H +F N:N CA:C C:C O:O CB:C CG:C CD1:C CD2:C CE1:C CE2:C CZ:C OXT:O H:H H2:H HA:H HB2:H HB3:H HD1:H HD2:H HE1:H HE2:H HZ:H HXT:H +P N:N CA:C C:C O:O CB:C CG:C CD:C OXT:O H:H HA:H HB2:H HB3:H HG2:H HG3:H HD2:H HD3:H HXT:H +S N:N CA:C C:C O:O CB:C OG:O OXT:O H:H H2:H HA:H HB2:H HB3:H HG:H HXT:H +T N:N CA:C C:C O:O CB:C OG1:O CG2:C OXT:O H:H H2:H HA:H HB:H HG1:H HG21:H HG22:H HG23:H HXT:H +W N:N CA:C C:C O:O CB:C CG:C CD1:C CD2:C NE1:N CE2:C CE3:C CZ2:C CZ3:C CH2:C OXT:O H:H H2:H HA:H HB2:H HB3:H HD1:H HE1:H HE3:H HZ2:H HZ3:H HH2:H HXT:H +Y N:N CA:C C:C O:O CB:C CG:C CD1:C CD2:C CE1:C CE2:C CZ:C OH:O OXT:O H:H H2:H HA:H HB2:H HB3:H HD1:H HD2:H HE1:H HE2:H HH:H HXT:H +V N:N CA:C C:C O:O CB:C CG1:C CG2:C OXT:O H:H H2:H HA:H HB:H HG11:H HG12:H HG13:H HG21:H HG22:H HG23:H HXT:H diff --git a/crates/core/src/protein_catalog.rs b/crates/core/src/protein_catalog.rs new file mode 100644 index 00000000..2e43585b --- /dev/null +++ b/crates/core/src/protein_catalog.rs @@ -0,0 +1,66 @@ +//! CCD atom names and their applicability to standard linear peptide chains. +use super::{Molecule, ResidueId}; + +// wwPDB CCD standard peptide entries, atom_id and type_symbol: +// https://www.wwpdb.org/data/ccd (individual CIFs from files.rcsb.org). +// CCD lists complete components, including atoms lost on polymer linkage: +// https://www.wwpdb.org/documentation/peptides-remediation +pub fn atoms(code: char) -> impl Iterator { + include_str!("protein_atoms.tsv") + .lines() + .filter_map(|line| line.split_once(' ')) + .filter(move |(key, _)| key.starts_with(code)) + .flat_map(|(_, values)| { + values + .split_ascii_whitespace() + .filter_map(|atom| atom.split_once(':')) + }) +} + +/// Sequence position, not displayed numbering, determines peptide termini. +/// This does not infer protonation, capping, disulfides or other modifications. +pub fn residue_atoms( + molecule: &Molecule, + residue: ResidueId, +) -> Result, String> { + let (chain, position) = molecule + .chains + .iter() + .find_map(|c| { + c.residues + .iter() + .position(|r| r.id == residue) + .map(|i| (c, i)) + }) + .ok_or_else(|| { + format!( + "Residue {} does not belong to the bound molecule", + residue.0 + ) + })?; + let code = chain.residues[position].code; + let first = position == 0; + let last = position + 1 == chain.residues.len(); + Ok(atoms(code).filter(move |(name, _)| match *name { + "OXT" | "HXT" => last, + "H2" => first, + "H" if code == 'P' => first, + _ => true, + })) +} + +pub fn nucleus_element(nucleus: &str) -> Option<&str> { + let element = nucleus + .trim() + .trim_start_matches(|c: char| c.is_ascii_digit()) + .trim(); + if matches!(element, "" | "?" | "-") || element.eq_ignore_ascii_case("unknown") { + None + } else { + Some(element) + } +} + +pub fn atom_matches_nucleus(element: &str, nucleus: &str) -> bool { + nucleus_element(nucleus).is_none_or(|n| element.eq_ignore_ascii_case(n)) +} diff --git a/crates/core/src/protein_tests.rs b/crates/core/src/protein_tests.rs new file mode 100644 index 00000000..5c2f1318 --- /dev/null +++ b/crates/core/src/protein_tests.rs @@ -0,0 +1,470 @@ +use super::*; +use crate::project::{load_project, save_project}; + +const FASTA: &str = include_str!("../../io/tests/fixtures/protein/two-chains.fasta"); + +fn import(app: &mut PlotiumApp, name: &str) -> Molecule { + app.edit_protein(Edit::Import { + source: format!("{name}.fasta"), + fasta: FASTA.into(), + name: None, + chains: None, + }) + .unwrap(); + app.doc.protein.molecules.last().unwrap().clone() +} + +fn add_spectrum(app: &mut PlotiumApp) -> (DatasetId, Peak3DId, [f64; 3]) { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../io/tests/fixtures/nmr/bruker-3d-qf"); + let (dataset, _) = + crate::workflow::dataset_from_acquisition(plotium_io::load_path(path).unwrap().acquisition) + .unwrap(); + let id = dataset.resource_id(); + app.doc.datasets.push(dataset); + let v = volume(&app.doc, id).unwrap(); + let ppm = std::array::from_fn(|a| v.processed.axis_coordinate(a, 1).unwrap()); + (id, app.add_peak_3d(id, ppm).unwrap(), ppm) +} + +fn atom(residue: ResidueId, names: &[&str]) -> Option { + Some(AtomTarget { + residue, + group_meaning: if names.len() > 1 { + GroupMeaning::OneOf + } else { + GroupMeaning::Unspecified + }, + members: names.iter().map(|s| (*s).into()).collect(), + }) +} + +#[test] +fn manual_assignments_keep_scientific_identity_through_edits_and_project_roundtrip() { + let mut app = PlotiumApp::new(); + let m = import(&mut app, "first"); + let other = import(&mut app, "second"); + let (d, p, ppm) = add_spectrum(&mut app); + let (d2, p2, _) = add_spectrum(&mut app); + assert_ne!(d, d2); + for dataset in [d, d2] { + app.edit_protein(Edit::Bind { + dataset, + molecule: Some(m.id), + }) + .unwrap(); + } + let a = m.chains[0].residues[0].id; + let b = m.chains[1].residues[0].id; + assert_eq!(m.chains[0].start, m.chains[1].start); + assert_ne!(a, b); + let targets = [ + atom(a, &["HB1", "HB2", "HB3"]), + atom(b, &["N"]), + atom(b, &["HA2"]), + ]; + let partial = [atom(a, &["H"]), None, None]; + let second_full = [atom(b, &["HA3"]), atom(a, &["N"]), atom(a, &["HA"])]; + for (dataset, peak, targets) in [ + (d, p, targets.clone()), + (d, p, second_full), + (d2, p2, partial.clone()), + ] { + app.edit_assignment( + dataset, + peak, + AssignmentEdit::Add { + targets, + note: "review".into(), + }, + ) + .unwrap(); + } + let id = app.doc.protein.assignment(d, p).unwrap().candidates[0].id; + let partial_id = app.doc.protein.assignment(d2, p2).unwrap().candidates[0].id; + for (dataset, peak, candidate) in [(d, p, id), (d2, p2, partial_id)] { + app.edit_assignment(dataset, peak, AssignmentEdit::Confirm { candidate }) + .unwrap(); + } + let original = app.doc.protein.clone(); + let revision = app.doc.automation_revision; + for bad in [ + [atom(a, &["CA"]), None, None], + [atom(a, &["H", "CA"]), None, None], + [atom(a, &["missing"]), None, None], + [atom(other.chains[0].residues[0].id, &["H"]), None, None], + [None, None, None], + ] { + assert!( + app.edit_assignment( + d, + p, + AssignmentEdit::Add { + targets: bad, + note: String::new() + } + ) + .is_err() + ); + } + assert!( + app.edit_assignment(d, Peak3DId(u64::MAX), AssignmentEdit::Clear) + .is_err() + ); + assert!( + app.edit_protein(Edit::Bind { + dataset: d, + molecule: Some(other.id) + }) + .is_err() + ); + assert!(app.edit_protein(Edit::Delete { molecule: m.id }).is_err()); + assert_eq!(app.doc.protein, original); + assert_eq!(app.doc.automation_revision, revision); + + app.edit_assignment( + d, + p, + AssignmentEdit::Update { + candidate: id, + targets: None, + note: Some("new note".into()), + }, + ) + .unwrap(); + assert_eq!( + app.doc.protein.assignment(d, p).unwrap().confirmed, + Some(id) + ); + app.edit_assignment( + d, + p, + AssignmentEdit::Update { + candidate: id, + targets: Some(partial), + note: None, + }, + ) + .unwrap(); + assert_eq!(app.doc.protein.assignment(d, p).unwrap().confirmed, None); + app.undo(); + assert_eq!( + app.doc.protein.assignment(d, p).unwrap().confirmed, + Some(id) + ); + app.redo(); + assert_eq!(app.doc.protein.assignment(d, p).unwrap().confirmed, None); + app.undo(); + app.edit_assignment(d, p, AssignmentEdit::Withdraw).unwrap(); + assert_eq!(app.doc.protein.assignment(d, p).unwrap().confirmed, None); + app.undo(); + app.edit_assignment(d, p, AssignmentEdit::Delete { candidate: id }) + .unwrap(); + assert_eq!( + app.doc.protein.assignment(d, p).unwrap().candidates.len(), + 1 + ); + app.undo(); + + app.edit_protein(Edit::LabelChain { + molecule: m.id, + chain: m.chains[0].id, + label: ChainLabel { + name: "Alpha".into(), + start: 42, + }, + }) + .unwrap(); + assert_eq!( + app.doc.protein.molecule(m.id).unwrap().chains[0].residues[0].id, + a + ); + let assigned = app.doc.protein.clone(); + for axis in 0..3 { + app.select_nmr_volume_plane(d, axis, 0).unwrap(); + app.locate_peak_3d(d, p).unwrap(); + } + let baseline = volume(&app.doc, d).unwrap().phases; + let mut phases = baseline; + phases[2].p0_degrees = 180.; + let mut worker = app + .doc + .dataset_by_id(d) + .unwrap() + .as_nmr2d() + .unwrap() + .clone(); + worker.set_volume_phases(phases).unwrap(); + let moved = std::array::from_fn(|a| { + volume(&app.doc, d) + .unwrap() + .processed + .axis_coordinate(a, 0) + .unwrap() + }); + assert_ne!(moved, ppm); + app.edit_peak_3d(d, p, moved, "moved during worker".into()) + .unwrap(); + app.install_nmr_volume_result(d, baseline, worker).unwrap(); + assert_eq!(app.doc.protein, assigned); + assert_eq!(volume(&app.doc, d).unwrap().peaks.marks[0].ppm, moved); + let report = app.export_protein(m.id, false).unwrap(); + assert_eq!(report["spectra"].as_array().unwrap().len(), 2); + let confirmed = app.export_protein(m.id, true).unwrap(); + assert!( + confirmed["spectra"] + .as_array() + .unwrap() + .iter() + .all(|s| s["assignments"][0]["candidates"].as_array().unwrap().len() == 1) + ); + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("protein.plotium"); + save_project(&app, &path, false).unwrap(); + let reopened = load_project(&path).unwrap(); + assert_eq!(reopened.doc.protein, assigned); + assert_eq!(reopened.export_protein(m.id, false).unwrap(), report); + app.delete_peak_3d(d, p).unwrap(); + assert!(app.doc.protein.assignment(d, p).is_none()); + app.undo(); + assert_eq!(app.doc.protein, assigned); + app.redo(); + assert!(app.doc.protein.assignment(d, p).is_none()); + app.undo(); + let index = app.doc.dataset_index(d).unwrap(); + app.try_execute_action(app.delete_dataset_action(index).unwrap()) + .unwrap(); + assert!(!app.doc.protein.bindings.contains_key(&d)); + assert!(app.doc.protein.assignment(d, p).is_none()); + app.undo(); + assert_eq!(app.doc.protein, assigned); + app.redo(); + assert!(app.doc.protein.assignment(d, p).is_none()); +} + +#[test] +fn missing_nucleus_allows_recording_but_not_confirmation() { + let mut app = PlotiumApp::new(); + let m = import(&mut app, "unknown"); + let t = atom(m.chains[0].residues[0].id, &["H"]).unwrap(); + assert!(validate_target(&m, &t, "", false).is_ok()); + assert!( + validate_target(&m, &t, "", true) + .unwrap_err() + .contains("known nucleus") + ); +} + +#[test] +fn undoing_peak_creation_does_not_recycle_its_id() { + let mut app = PlotiumApp::new(); + let (dataset, original, ppm) = add_spectrum(&mut app); + app.undo(); + assert!(volume(&app.doc, dataset).unwrap().peaks.marks.is_empty()); + app.redo(); + assert_eq!( + volume(&app.doc, dataset).unwrap().peaks.marks[0].id, + original + ); + app.undo(); + let replacement_ppm = std::array::from_fn(|axis| { + volume(&app.doc, dataset) + .unwrap() + .processed + .axis_coordinate(axis, 0) + .unwrap() + }); + assert_ne!(replacement_ppm, ppm); + let replacement = app.add_peak_3d(dataset, replacement_ppm).unwrap(); + assert_ne!(replacement, original); + assert!( + app.edit_assignment(dataset, original, AssignmentEdit::Clear) + .is_err() + ); + app.undo(); + assert!(app.add_peak_3d(dataset, ppm).unwrap().0 > replacement.0); +} + +#[test] +fn peptide_positions_reject_leaving_atoms_without_using_display_numbers() { + let mut app = PlotiumApp::new(); + app.edit_protein(Edit::Import { + source: "positions.fasta".into(), + fasta: ">proline\nPAP\n>single\nA".into(), + name: None, + chains: None, + }) + .unwrap(); + let m = app.doc.protein.molecules[0].clone(); + let (d, p, _) = add_spectrum(&mut app); + app.edit_protein(Edit::Bind { + dataset: d, + molecule: Some(m.id), + }) + .unwrap(); + let revision = app.doc.automation_revision; + // Internal and C-terminal proline cannot carry the backbone amide H. + for (index, name) in [(1, "H2"), (1, "HXT"), (2, "H")] { + let error = app + .edit_assignment( + d, + p, + AssignmentEdit::Add { + targets: [atom(m.chains[0].residues[index].id, &[name]), None, None], + note: String::new(), + }, + ) + .unwrap_err(); + assert!(error.contains("standard peptide chain"), "{error}"); + } + assert_eq!(app.doc.automation_revision, revision); + assert!(app.doc.protein.assignment(d, p).is_none()); + for (chain, index, name, nucleus) in [ + (0, 0, "H", "1H"), + (0, 1, "H", "1H"), + (0, 2, "HXT", "1H"), + (0, 2, "OXT", "17O"), + (1, 0, "H2", "1H"), + (1, 0, "HXT", "1H"), + ] { + validate_target( + &m, + &atom(m.chains[chain].residues[index].id, &[name]).unwrap(), + nucleus, + true, + ) + .unwrap(); + } + for c in &m.chains { + let before: Vec<_> = c + .residues + .iter() + .map(|r| residue_atoms(&m, r.id).unwrap().collect::>()) + .collect(); + app.edit_protein(Edit::LabelChain { + molecule: m.id, + chain: c.id, + label: ChainLabel { + name: c.name.clone(), + start: -42, + }, + }) + .unwrap(); + let renamed = app.doc.protein.molecule(m.id).unwrap(); + let after: Vec<_> = c + .residues + .iter() + .map(|r| residue_atoms(renamed, r.id).unwrap().collect::>()) + .collect(); + assert_eq!(before, after); + } + // The original review case must fail as well, even for an unconfirmed candidate. + let internal_pro = import_preview("review.fasta", ">chain\nAPA").unwrap(); + for name in ["H", "HXT"] { + assert!( + validate_target( + &internal_pro, + &atom(internal_pro.chains[0].residues[1].id, &[name]).unwrap(), + "1H", + false + ) + .is_err() + ); + } +} + +#[test] +fn group_interpretation_is_explicit_and_survives_confirmation_undo_and_export() { + let mut app = PlotiumApp::new(); + let m = import(&mut app, "group"); + let (d, p, _) = add_spectrum(&mut app); + app.edit_protein(Edit::Bind { + dataset: d, + molecule: Some(m.id), + }) + .unwrap(); + let residue = m.chains[0].residues[0].id; + let unspecified: AtomTarget = serde_json::from_value(serde_json::json!({ + "residue":residue,"members":["HB1","HB2","HB3"] + })) + .unwrap(); + let targets = [Some(unspecified), None, None]; + app.edit_assignment( + d, + p, + AssignmentEdit::Add { + targets: targets.clone(), + note: String::new(), + }, + ) + .unwrap(); + let id = app.doc.protein.assignment(d, p).unwrap().candidates[0].id; + let revision = app.doc.automation_revision; + assert!( + app.edit_assignment(d, p, AssignmentEdit::Confirm { candidate: id }) + .unwrap_err() + .contains("shared resonance") + ); + assert_eq!(revision, app.doc.automation_revision); + for meaning in [GroupMeaning::Shared, GroupMeaning::OneOf] { + let mut targets = targets.clone(); + targets[0].as_mut().unwrap().group_meaning = meaning; + app.edit_assignment( + d, + p, + AssignmentEdit::Update { + candidate: id, + targets: Some(targets), + note: None, + }, + ) + .unwrap(); + assert!( + app.doc + .protein + .assignment(d, p) + .unwrap() + .confirmed + .is_none() + ); + app.edit_assignment(d, p, AssignmentEdit::Confirm { candidate: id }) + .unwrap(); + let report = app.export_protein(m.id, true).unwrap(); + assert_eq!( + report["spectra"][0]["assignments"][0]["candidates"][0]["targets"][0]["group_meaning"], + serde_json::to_value(meaning).unwrap() + ); + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("groups.plotium"); + save_project(&app, &path, false).unwrap(); + assert_eq!( + load_project(&path) + .unwrap() + .export_protein(m.id, true) + .unwrap(), + report + ); + if meaning == GroupMeaning::OneOf { + app.undo(); // confirmation + app.undo(); // meaning change + let restored = app.doc.protein.assignment(d, p).unwrap(); + assert_eq!(restored.confirmed, Some(id)); + assert_eq!( + restored.candidates[0].targets[0] + .as_ref() + .unwrap() + .group_meaning, + GroupMeaning::Shared + ); + app.redo(); + assert!( + app.doc + .protein + .assignment(d, p) + .unwrap() + .confirmed + .is_none() + ); + } + } +} diff --git a/crates/core/src/settings/model.rs b/crates/core/src/settings/model.rs index fdb5b8a1..ea0f3e35 100644 --- a/crates/core/src/settings/model.rs +++ b/crates/core/src/settings/model.rs @@ -286,6 +286,8 @@ pub struct TaskCardSettings { #[serde(default = "default_craft_card_size")] pub craft: TaskCardSize, #[serde(default = "default_standard_card_size")] + pub protein: TaskCardSize, + #[serde(default = "default_standard_card_size")] pub regions: TaskCardSize, #[serde(default = "default_standard_card_size")] pub curve_fit: TaskCardSize, @@ -311,6 +313,7 @@ impl Default for TaskCardSettings { dosy: default_standard_card_size(), processing: default_processing_card_size(), craft: default_craft_card_size(), + protein: default_standard_card_size(), regions: default_standard_card_size(), curve_fit: default_standard_card_size(), statistics: default_standard_card_size(), @@ -323,6 +326,7 @@ impl TaskCardSettings { sanitize_card_size(&mut self.dosy, default_standard_card_size()); sanitize_card_size(&mut self.processing, default_processing_card_size()); sanitize_card_size(&mut self.craft, default_craft_card_size()); + sanitize_card_size(&mut self.protein, default_standard_card_size()); sanitize_card_size(&mut self.regions, default_standard_card_size()); sanitize_card_size(&mut self.curve_fit, default_standard_card_size()); sanitize_card_size(&mut self.statistics, default_standard_card_size()); diff --git a/crates/core/src/state/app_impl.rs b/crates/core/src/state/app_impl.rs index b2a1289e..67362252 100644 --- a/crates/core/src/state/app_impl.rs +++ b/crates/core/src/state/app_impl.rs @@ -44,6 +44,7 @@ impl PlotiumApp { ) -> Self { Self { doc: SharedDocument::new(Document { + protein: Default::default(), datasets: Vec::new(), canvases: Vec::new(), assets: std::collections::BTreeMap::new(), diff --git a/crates/core/src/state/dataset_delete.rs b/crates/core/src/state/dataset_delete.rs index 9ac6f75c..4c677657 100644 --- a/crates/core/src/state/dataset_delete.rs +++ b/crates/core/src/state/dataset_delete.rs @@ -61,6 +61,18 @@ impl PlotiumApp { } pub fn delete_dataset_action(&self, index: usize) -> Result { + let delete = self.delete_dataset_base_action(index)?; + let dataset = &self.doc.datasets[index]; + let mut protein = self.doc.protein.clone(); + protein.remove_dataset(dataset.resource_id()); + Ok(if protein == self.doc.protein { + delete + } else { + Action::Composite(vec![self.protein_action(protein), delete]) + }) + } + + pub(crate) fn delete_dataset_base_action(&self, index: usize) -> Result { let dataset = self .doc .datasets diff --git a/crates/core/src/state/peaks3d.rs b/crates/core/src/state/peaks3d.rs index c632b913..fcd29e8d 100644 --- a/crates/core/src/state/peaks3d.rs +++ b/crates/core/src/state/peaks3d.rs @@ -222,11 +222,20 @@ impl PlotiumApp { .and_then(|n| n.volume.as_mut()) .ok_or("Select a three-dimensional spectrum")?; edit(v)?; - self.try_execute_action(crate::actions::Action::InstallScientificState { + let mut protein = self.doc.protein.clone(); + protein + .assignments + .retain(|a| a.dataset != dataset || v.peaks.marks.iter().any(|p| p.id == a.peak)); + let install = crate::actions::Action::InstallScientificState { before: vec![before], after: vec![after], - }) - .map_err(|e| e.to_string())?; + }; + let action = if protein == self.doc.protein { + install + } else { + crate::actions::Action::Composite(vec![self.protein_action(protein), install]) + }; + self.try_execute_action(action).map_err(|e| e.to_string())?; Ok(()) } } diff --git a/crates/core/src/state/ui_state.rs b/crates/core/src/state/ui_state.rs index 536eece4..ff92e24c 100644 --- a/crates/core/src/state/ui_state.rs +++ b/crates/core/src/state/ui_state.rs @@ -181,6 +181,7 @@ impl Default for ArrangeGridDraft { } pub struct UiState { + pub protein_open: bool, pub selected_peak_3d: Option<(DatasetId, super::Peak3DId)>, /// The single in-flight direct-manipulation gesture; see [`Interaction`]. pub interaction: Interaction, @@ -444,6 +445,7 @@ impl UiState { impl Default for UiState { fn default() -> Self { Self { + protein_open: false, selected_peak_3d: None, interaction: Interaction::Idle, phase_axis: PhaseAxis::F2, @@ -592,6 +594,7 @@ impl Default for UiState { /// Scientific project content and on-disk identity, persisted across sessions. #[derive(Clone)] pub struct Document { + pub protein: crate::protein::ProteinState, pub datasets: Vec, pub canvases: Vec, pub assets: std::collections::BTreeMap, diff --git a/crates/core/src/state/ui_state/task_dock.rs b/crates/core/src/state/ui_state/task_dock.rs index cef3f8b9..959070ba 100644 --- a/crates/core/src/state/ui_state/task_dock.rs +++ b/crates/core/src/state/ui_state/task_dock.rs @@ -8,6 +8,7 @@ pub enum TaskDockTab { CurveFit, Statistics, Craft, + Protein, } impl UiState { @@ -17,6 +18,7 @@ impl UiState { pub fn close_task_tab(&mut self, tab: TaskDockTab) { match tab { + TaskDockTab::Protein => self.protein_open = false, TaskDockTab::Dosy => { self.dosy_task_dataset = None; } @@ -59,6 +61,7 @@ impl UiState { (TaskDockTab::Regions, self.region_task_dataset.is_some()), (TaskDockTab::CurveFit, self.curve_fit_task_dataset.is_some()), (TaskDockTab::Statistics, self.stat_task_dataset.is_some()), + (TaskDockTab::Protein, self.protein_open), (TaskDockTab::Craft, self.craft_task_dataset.is_some()), ] .into_iter() @@ -68,6 +71,7 @@ impl UiState { /// Discard every transient page in the shared canvas task dock. pub fn close_task_cards(&mut self) { + self.protein_open = false; self.dosy_task_dataset = None; self.processing_task_dataset = None; self.processing_task_collapsed = false; diff --git a/crates/io/src/fasta.rs b/crates/io/src/fasta.rs new file mode 100644 index 00000000..4e360b34 --- /dev/null +++ b/crates/io/src/fasta.rs @@ -0,0 +1,65 @@ +//! Protein FASTA import for the twenty standard amino acids. +pub const STANDARD_RESIDUES: &str = "ARNDCQEGHILKMFPSTWYV"; + +#[derive(Debug, PartialEq, Eq)] +pub struct Record { + pub title: String, + pub sequence: String, +} + +pub fn parse(text: &str) -> Result, String> { + let mut records = Vec::new(); + let mut header = 0; + for (index, line) in text.lines().enumerate() { + let row = index + 1; + if let Some(title) = line.strip_prefix('>') { + if records + .last() + .is_some_and(|r: &Record| r.sequence.is_empty()) + { + return Err(format!("Empty FASTA record at line {header}")); + } + header = row; + records.push(Record { + title: title.into(), + sequence: String::new(), + }); + } else { + for (column, c) in line.chars().enumerate() { + if c.is_ascii_whitespace() { + continue; + } + let r = records + .last_mut() + .ok_or_else(|| format!("Expected a FASTA header before line {row}"))?; + let code = c.to_ascii_uppercase(); + if !STANDARD_RESIDUES.contains(code) { + return Err(format!( + "Unsupported residue {c:?} at line {row}, column {}", + column + 1 + )); + } + r.sequence.push(code); + } + } + } + match records.last() { + None => Err("No FASTA records".into()), + Some(r) if r.sequence.is_empty() => Err(format!("Empty FASTA record at line {header}")), + Some(_) => Ok(records), + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn parses_multiple_records_and_reports_positions() { + let parsed = parse(">one\r\na c\r\nD\r\n>two\r\ng").unwrap(); + assert_eq!(parsed[0].sequence, "ACD"); + assert_eq!(parsed[1].title, "two"); + assert_eq!(parsed[1].sequence, "G"); + assert!(parse(">one\n>x\nA").unwrap_err().contains("line 1")); + assert!(parse(">one\nA X").unwrap_err().contains("line 2, column 3")); + } +} diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 8d596f02..740f2e5d 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -4,6 +4,7 @@ pub mod abf2; pub mod archive; pub mod delimited; mod explicit_input; +pub mod fasta; pub use explicit_input::resolve_explicit_input; mod format; mod mass_spec; diff --git a/crates/io/tests/fixtures/protein/README.md b/crates/io/tests/fixtures/protein/README.md new file mode 100644 index 00000000..98ee3499 --- /dev/null +++ b/crates/io/tests/fixtures/protein/README.md @@ -0,0 +1,10 @@ +# Synthetic manual assignments + +Use `two-chains.fasta` with the public synthetic [3D QF fixture](../nmr/bruker-3d-qf). +Import two copies of the spectrum. Bind both to the molecule from this FASTA. +In acquisition order the nuclei are H/N/H. Create a candidate with +A:A1 {HB1, HB2, HB3} (all members share this resonance), B:G1 N, and B:G1 HA2; add an alternative with +B:G1 HA3, A:A1 N, and A:A1 HA. On the other spectrum add a partial +candidate with A:A1 H on F1. Confirm one interpretation on each spectrum. +Selecting A:A1 should locate both peaks. Change an atom and undo, phase the +spectrum, save, reopen and compare the exported IDs and confirmation state. diff --git a/crates/io/tests/fixtures/protein/two-chains.fasta b/crates/io/tests/fixtures/protein/two-chains.fasta new file mode 100644 index 00000000..f844551c --- /dev/null +++ b/crates/io/tests/fixtures/protein/two-chains.fasta @@ -0,0 +1,4 @@ +>synthetic alpha +AGST +>synthetic beta +GVLA diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index cc6ab12d..601fe5a6 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -75,6 +75,7 @@ export default defineConfig({ label: 'NMR', items: [ { slug: 'guides/craft' }, + { slug: 'guides/protein-assignments' }, { slug: 'guides/pseudo-2d' }, { slug: 'guides/dosy' }, { slug: 'guides/2d-integration' }, diff --git a/docs/src/content/docs/guides/protein-assignments.md b/docs/src/content/docs/guides/protein-assignments.md new file mode 100644 index 00000000..cef0f10f --- /dev/null +++ b/docs/src/content/docs/guides/protein-assignments.md @@ -0,0 +1,24 @@ +--- +title: Manual protein assignments +description: Bind protein sequences to 3D spectra and confirm manual peak assignments. +--- + +1. Choose **Data → Import protein sequence** and open a FASTA file. Each record becomes a chain in one molecule. Check the molecule name, chain names and starting numbers before importing. The project stores the sequence and no longer needs the source file. +2. Select a 3D spectrum and open **Analyze → Protein assignments**. Select the molecule and bind the active spectrum. Repeat for each spectrum that uses the same molecule. +3. Pick or select a peak in the 3D peak table. In **Assignments**, choose **New candidate**. For each acquisition axis (F1, F2, F3), choose a residue and one atom or a set of atoms of the same element. An axis can stay unassigned. Save each alternative interpretation as a separate candidate. +4. Use **Confirm** to accept one candidate. A partial confirmation leaves its other axes unassigned. For a group, choose **One of these atoms (identity unresolved)** or **All members share this resonance** before confirming. **Unspecified (candidate only)** can be saved as a draft interpretation but cannot be confirmed. A shared resonance does not assert chemical equivalence or stereospecific assignment. Plotium checks atom names against the local [wwPDB Chemical Component Dictionary](https://www.wwpdb.org/data/ccd) catalog and needs a known nucleus on each assigned axis to confirm. +5. Expand **Sequence and associated peaks**, select a residue and click a result to switch spectra and locate its peak. Both candidates and confirmed results appear here. + +The editor lists atoms compatible with the axis nucleus and their position in a standard linear peptide chain. `H2` is limited to the first residue, `OXT`/`HXT` to the last, and proline `H` to the first residue. These checks use sequence position, not displayed residue numbering. They apply to automation and project validation too. The catalog does not infer terminal protonation, capping, disulfides or other modifications. **Confirmed** means manually accepted, not automatic validation of chemistry or experiment connectivity. + +Editing the targets or group meaning of a confirmed candidate withdraws confirmation. Editing its note, moving a peak, changing planes or phase, and renumbering a chain preserve confirmation. Delete a peak to remove its assignments with it; Undo restores both. Clear a spectrum's assignments before changing its binding. Clearing opens a review with the spectrum name and the affected peak, candidate and confirmation counts; confirm or cancel it. The review keeps its original spectrum even if you select another spectrum. If the project changes, review the updated counts before confirming. Unbind all spectra before deleting a molecule. + +Save the project to keep the sequence, bindings and assignments. Under **Molecule details**, **Export JSON** includes the candidates and confirmation state alongside the measured coordinates, signed heights, nuclei and processing provenance. Select **Export only confirmed assignments** to omit other candidates. The report keeps each observation separate. + +The panel scrolls when its contents exceed the available height; scroll down to reach **Save candidate**. **Active spectrum** names the spectrum and **Bound molecule** identifies its binding. The sequence browser can show another molecule; the candidate editor separately names its own spectrum and molecule. An open draft keeps its original target when you select another peak or spectrum. + +Automation uses the document target. The CLI accepts FASTA text in a `protein.edit` import request via `plotium-cli tool run`. Use `protein.assignments.edit` with a dataset and peak ID, `protein.inspect` for lookup, and `protein.assignments.export` for a report. MCP clients use `plotium_plan` and `plotium_execute` for edits, or call `plotium_protein_inspect` and `plotium_protein_assignments_export` for reads. + +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"`. diff --git a/docs/src/content/docs/zh-cn/guides/protein-assignments.md b/docs/src/content/docs/zh-cn/guides/protein-assignments.md new file mode 100644 index 00000000..8fbec4a7 --- /dev/null +++ b/docs/src/content/docs/zh-cn/guides/protein-assignments.md @@ -0,0 +1,24 @@ +--- +title: 人工蛋白峰指认 +description: 将蛋白序列绑定到三维谱图并人工确认峰指认。 +--- + +1. 选择 **Data → Import protein sequence** 并打开 FASTA 文件。每条记录成为同一个分子中的一条链。导入前核对分子名、链名和起始编号。项目保存序列,重开时无需原文件。 +2. 选择一张三维谱,打开 **Analyze → Protein assignments**。选择分子并绑定当前谱图;为使用同一分子的其他谱图重复绑定。 +3. 在三维峰表中拾取或选择一个峰。在 **Assignments** 中选择 **New candidate**。为每条采集轴(F1、F2、F3)选择一个残基,再选择一个原子或一组同元素的原子。允许留空。每种解释应单独保存为一个候选。 +4. 点击 **Confirm** 接受一个候选。部分指认仍保留未指认轴;原子组确认前须选择 **One of these atoms (identity unresolved)**(其中一个原子,身份未定)或 **All members share this resonance**(所有成员共享该共振)。**Unspecified (candidate only)**(含义未定,仅候选)允许保存,但不能确认。共享共振不表示化学等价或立体专一指认。Plotium 按本地 [wwPDB 化学组分字典](https://www.wwpdb.org/data/ccd) 校验原子名。确认前,每条已指认轴都必须有已知核种。 +5. 展开 **Sequence and associated peaks**,选择残基后点击结果,可切换到对应谱图并定位峰。列表同时显示候选与已确认结果。 + +编辑器按轴核种和标准线性肽链中的位置提供原子:`H2` 仅用于首个残基,`OXT`/`HXT` 仅用于末个残基,Pro 的 `H` 仅用于首个残基。检查依据序列位置,而非显示编号;自动化和项目验证使用相同规则。目录不推断末端质子化、封端、二硫键或其他修饰。**Confirmed** 表示人工接受,不表示已自动验证化学合理性或实验连接关系。 + +修改已确认候选的原子目标或原子组含义会撤回确认。修改候选备注、移动峰、切换切片、调整相位或重新编号均保留确认。删除峰会一并删除其指认,撤销会一起恢复。更改绑定前先清除该谱的指认。清除前会显示谱名、受影响峰数、候选数和确认数,供确认或取消。即使选择另一张谱,清除仍针对最初的谱;若项目发生变化,须重新核对数量再确认。删除分子前先解除其所有谱图绑定。 + +保存项目以保留序列、绑定和指认。在 **Molecule details** 中选择 **Export JSON**,可导出候选、确认状态、坐标、带符号的峰高、核种和处理来源。选中 **Export only confirmed assignments** 可省略其他候选。报告保留每条独立观测。 + +面板内容超出可用高度时可滚动,向下滚动即可到达 **Save candidate**。**Active spectrum** 显示谱名,**Bound molecule** 显示绑定分子。序列浏览器可查看其他分子;候选编辑器会单独显示其目标谱和分子。切换峰或谱时,已有草稿仍保留原目标。 + +自动化接口使用文档目标。CLI 可通过 `plotium-cli tool run` 调用 `protein.edit` 导入 FASTA 文本。用 `protein.assignments.edit` 和明确的谱及峰 ID 编辑指认,用 `protein.inspect` 查询,用 `protein.assignments.export` 导出。MCP 客户端通过 `plotium_plan` 和 `plotium_execute` 执行编辑;读取使用 `plotium_protein_inspect` 和 `plotium_protein_assignments_export`。 + +查询默认返回精简的分子摘要、谱图绑定和关联峰。读取序列时,先用 `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"`。