From 73b844cdb4a45826233b38c32c93850aa7588925 Mon Sep 17 00:00:00 2001 From: Jiekang Tian Date: Sun, 26 Jul 2026 18:39:08 +0800 Subject: [PATCH] feat(core): keep DOSY results in the project and give ILT a value lifecycle Pseudo-2D analysis state lived only in memory: reopening a project always returned to the stack display and forgot the DOSY method, its parameters and both maps. Because `figure()` falls back to the stack plot when the selected map is absent, the failure was a silently different picture rather than an empty one. Both maps now persist as one binary blob under a new `plotx_dosy_v1` storage kind, alongside the display, the method and a provenance record naming the algorithm, its version, the invocation inputs and a fingerprint of the numeric data the fit consumed. Cached contour geometry stays derived, like `processed_figure`. On load a mismatched fingerprint keeps and shows the stored map rather than recomputing it: `fit_ilt` is the only implementation and there is no multi-version algorithm registry, so recomputing would silently redraw every stored project after any kernel change. The fingerprint covers every numeric input the fit consumes, not only the trace samples. Referencing moves the chemical-shift axis without touching a sample, and the diffusion metadata reaches the per-column fit through the b-factor conversion, so both are in the digest and both methods hash the same inputs. The ILT regularization weight now has all three lifecycle stages in place: a persistent application default, an explicit input for the next build, and the stored result provenance, resolved in that priority order. The explicit input is scoped to one dataset and absent by default, so the fallbacks stay reachable from every entry point rather than only from the panel. Its bounds move out of an egui widget into `settings/model.rs`, shared by the Preferences dialog, the Experiment card and the property schema; `dataset.analysis.ilt.result_lambda` exposes the stored value read-only. Because one of those stages reads a project file, the parameters are validated before the inversion: the grid size sizes a dense `n_grid x n_grid` system, so an unchecked value would size an unbounded allocation instead of producing an error. Anything the user must be told is derived where it can go stale and stored only where it cannot. Whether the selected map is missing is a function of the display, the method and which maps exist, so it is computed on demand; only genuine load-time facts are stored. Both reach the load report and the status bar, not just the Experiment card, because a project that opens showing a different plot than it saved is exactly what must not be quiet. A view snapshot is bypassed when the map it depicts did not survive the load, so the canvas and the report cannot disagree. `serde_json` gains the `float_roundtrip` feature. Its default parser returns a value one ULP away for roughly a tenth of all `f64`s, and every number in a project travels that path, so a reopened but unmodified project could report a false provenance mismatch. A round-trip test pins it. Three modules were split first, as pure moves, to stay under the source-size limit: view conversion out of `project/convert.rs`, the `Dataset` dispatch out of `state/datasets.rs`, and the table workflow out of `state/app_impl_analysis.rs`. Notes for later work: - Undoing a processing change discards a built map, as any reprocessing does. Making maps survive undo means putting them in the processing snapshot. - `d_min`, `d_max` and `n_grid` gained validation bounds but no catalog properties; only the regularization weight was migrated. - A DOSY map is a 2D scalar field and would be better addressed as a dataset field than through the dataset itself, which is why its provenance property targets the dataset for now. --- Cargo.toml | 2 +- crates/app/src/ui/properties/mod.rs | 17 +- crates/app/src/ui/settings_dialog.rs | 18 +- crates/app/src/ui/tools/pseudo.rs | 73 +- .../core/src/automation/properties_tests.rs | 62 +- crates/core/src/lib.rs | 19 +- crates/core/src/operation.rs | 2 + crates/core/src/project/convert.rs | 681 ++++++------------ crates/core/src/project/convert_recipes.rs | 8 +- crates/core/src/project/convert_views.rs | 494 +++++++++++++ crates/core/src/project/dosy_convert.rs | 277 +++++++ crates/core/src/project/dosy_convert_tests.rs | 118 +++ crates/core/src/project/mod.rs | 16 +- crates/core/src/project/pseudo_tests.rs | 398 +++++++++- crates/core/src/properties/ilt.rs | 209 ++++++ crates/core/src/properties/ilt_tests.rs | 149 ++++ crates/core/src/properties/mod.rs | 8 + crates/core/src/settings/model.rs | 31 +- crates/core/src/state/app_impl.rs | 4 + crates/core/src/state/app_impl_analysis.rs | 555 ++++---------- .../src/state/app_impl_analysis_tables.rs | 416 +++++++++++ crates/core/src/state/app_impl_compute.rs | 47 +- crates/core/src/state/app_impl_io.rs | 63 +- crates/core/src/state/compute.rs | 12 +- crates/core/src/state/compute/tests.rs | 2 + crates/core/src/state/compute_worker.rs | 10 +- crates/core/src/state/datasets.rs | 412 +---------- .../core/src/state/datasets/pseudo_tests.rs | 313 ++++++++ crates/core/src/state/datasets_2d_figure.rs | 24 + crates/core/src/state/datasets_2d_maps.rs | 102 ++- crates/core/src/state/datasets_dispatch.rs | 398 ++++++++++ crates/core/src/state/mod.rs | 11 +- crates/core/src/state/ui_state.rs | 15 +- docs/src/content/docs/guides/pseudo-2d.md | 36 +- .../content/docs/reference/file-formats.md | 7 + .../src/content/docs/reference/preferences.md | 8 + .../content/docs/zh-cn/guides/pseudo-2d.md | 26 +- .../docs/zh-cn/reference/file-formats.md | 5 + .../docs/zh-cn/reference/preferences.md | 7 + 39 files changed, 3677 insertions(+), 1378 deletions(-) create mode 100644 crates/core/src/project/convert_views.rs create mode 100644 crates/core/src/project/dosy_convert.rs create mode 100644 crates/core/src/project/dosy_convert_tests.rs create mode 100644 crates/core/src/properties/ilt.rs create mode 100644 crates/core/src/properties/ilt_tests.rs create mode 100644 crates/core/src/state/app_impl_analysis_tables.rs create mode 100644 crates/core/src/state/datasets_dispatch.rs diff --git a/Cargo.toml b/Cargo.toml index 5e477d4..af1d7e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,7 +44,7 @@ colorous = "1" num-complex = "0.4" rustfft = "6.4" serde = { version = "1.0", features = ["derive", "rc"] } -serde_json = "1.0" +serde_json = { version = "1.0", features = ["float_roundtrip"] } semver = "1" sha2 = "0.10" ureq = { version = "2", default-features = false, features = ["tls"] } diff --git a/crates/app/src/ui/properties/mod.rs b/crates/app/src/ui/properties/mod.rs index 67cc917..8890be3 100644 --- a/crates/app/src/ui/properties/mod.rs +++ b/crates/app/src/ui/properties/mod.rs @@ -18,7 +18,7 @@ pub(crate) mod fixture; pub(crate) use search::property_hits; use plotx_core::properties::{ - PropertyDefinition, PropertyId, Tier, apodization, contour, definition, export_dpi, line, + PropertyDefinition, PropertyId, Tier, apodization, contour, definition, export_dpi, ilt, line, typography, }; use plotx_core::state::{SettingsCategory, WorkflowTab}; @@ -211,6 +211,11 @@ const EXPORT_PREFERENCES_HOME: HomeRoute = HomeRoute { section: SettingsCategory::Export.section_id(), }; +const PROCESSING_PREFERENCES_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::Preferences, + section: SettingsCategory::Processing.section_id(), +}; + pub const PRESENTATIONS: &[PropertyPresentation] = &[ PropertyPresentation { id: contour::BASE_MAGNITUDE, @@ -319,6 +324,16 @@ pub const PRESENTATIONS: &[PropertyPresentation] = &[ home_route: EXPORT_PREFERENCES_HOME, canvas_step: false, }, + PropertyPresentation { + id: ilt::DEFAULT_LAMBDA, + localized_label: LocalizedText("Default ILT regularization"), + localized_aliases: &[ + LocalizedText("ILT lambda"), + LocalizedText("DOSY regularization"), + ], + home_route: PROCESSING_PREFERENCES_HOME, + canvas_step: false, + }, ]; pub fn presentation(id: PropertyId) -> Option<&'static PropertyPresentation> { diff --git a/crates/app/src/ui/settings_dialog.rs b/crates/app/src/ui/settings_dialog.rs index c4700e3..7999afb 100644 --- a/crates/app/src/ui/settings_dialog.rs +++ b/crates/app/src/ui/settings_dialog.rs @@ -2,7 +2,8 @@ use super::*; use egui::{Align, Align2, CornerRadius, FontId, Layout, RichText, pos2, vec2}; use egui_phosphor::regular as icon; use plotx_core::settings::{ - GraphicsPowerPreference, MAX_EXPORT_DPI, MIN_EXPORT_DPI, Settings, ThemeMode, + GraphicsPowerPreference, MAX_EXPORT_DPI, MAX_ILT_LAMBDA, MIN_EXPORT_DPI, MIN_ILT_LAMBDA, + Settings, ThemeMode, }; use plotx_core::state::{MonitorScaleStatus, SettingsCategory, SettingsDialog}; use plotx_core::update::{UpdateChannelSetting, UpdateService, UpdateStatus}; @@ -312,7 +313,20 @@ fn render_category( ); } SettingsCategory::Processing => { - empty_state(ui, "Nothing to configure here yet."); + setting_row( + ui, + "Default ILT regularization (λ)", + Some( + "Regularization used to build an ILT DOSY map for a dataset that has no earlier ILT result.", + ), + |ui| { + ui.add( + egui::DragValue::new(&mut draft.processing.ilt_lambda) + .speed(0.001) + .range(MIN_ILT_LAMBDA..=MAX_ILT_LAMBDA), + ); + }, + ); } SettingsCategory::Export => { setting_row( diff --git a/crates/app/src/ui/tools/pseudo.rs b/crates/app/src/ui/tools/pseudo.rs index d4a3e6b..f5745c7 100644 --- a/crates/app/src/ui/tools/pseudo.rs +++ b/crates/app/src/ui/tools/pseudo.rs @@ -1,5 +1,6 @@ use egui::{Button, DragValue, Ui}; use plotx_core::actions::{Action, DatasetProcessingState}; +use plotx_core::settings::{MAX_ILT_LAMBDA, MIN_ILT_LAMBDA}; use plotx_core::state::PlotxApp; use plotx_processing::{Layout2D, Preset2D}; @@ -160,7 +161,7 @@ fn parse_indices(text: &str) -> Result, String> { fn pseudo_group(app: &mut PlotxApp, di: usize, ui: &mut Ui) { use plotx_core::{DosyMethod, PseudoDisplay}; - let (is_dosy, is_gradient, is_ilt, axis_summary, diff_summary, cur_display) = { + let (is_dosy, is_gradient, is_ilt, axis_summary, diff_summary, provenance_warning, cur_display) = { let n = app.doc.datasets[di].as_nmr2d().unwrap(); let axis_summary = n.data.pseudo_axis.as_ref().map(|axis| { format!( @@ -192,6 +193,9 @@ fn pseudo_group(app: &mut PlotxApp, di: usize, ui: &mut Ui) { matches!(n.dosy_method, DosyMethod::Ilt(_)), axis_summary, diff_summary, + n.dosy_provenance_warning + .clone() + .or_else(|| n.missing_selected_map_note().map(str::to_owned)), n.display, ) }; @@ -202,6 +206,9 @@ fn pseudo_group(app: &mut PlotxApp, di: usize, ui: &mut Ui) { if let Some(s) = &diff_summary { ui.small(s); } + if let Some(warning) = &provenance_warning { + ui.colored_label(ui.visuals().warn_fg_color, warning); + } ui.horizontal(|ui| { ui.label("Show"); @@ -228,9 +235,7 @@ fn pseudo_group(app: &mut PlotxApp, di: usize, ui: &mut Ui) { ui.horizontal(|ui| { ui.label("DOSY method"); if ui.selectable_label(!is_ilt, "Per-column").clicked() && is_ilt { - if let Some(d2) = app.doc.datasets[di].as_nmr2d_mut() { - d2.dosy_method = DosyMethod::MonoExp; - } + app.set_pseudo_dosy_method(di, DosyMethod::MonoExp); app.set_pseudo_display(di, PseudoDisplay::DosyMap); } if ui @@ -242,44 +247,56 @@ fn pseudo_group(app: &mut PlotxApp, di: usize, ui: &mut Ui) { .clicked() && !is_ilt { - let params = app.session.ui.ilt_params; - if let Some(d2) = app.doc.datasets[di].as_nmr2d_mut() { - d2.dosy_method = DosyMethod::Ilt(params); - } + let params = app.resolve_ilt_params_for(di, app.explicit_ilt_input_for(di)); + app.set_pseudo_dosy_method(di, DosyMethod::Ilt(params)); app.set_pseudo_display(di, PseudoDisplay::DosyMap); } }); if is_ilt { + // Show what this build would actually use — the explicit input if the + // user has entered one for this dataset, otherwise what the lifecycle + // resolves to. Only an edit here records an explicit input, so simply + // opening the panel never turns a resolved value into an override. + let resolved = app.resolve_ilt_params_for(di, app.explicit_ilt_input_for(di)); + let mut draft = resolved; + let mut edited = false; ui.horizontal(|ui| { ui.label("λ"); - ui.add( - DragValue::new(&mut app.session.ui.ilt_params.lambda) - .speed(0.001) - .range(1e-6..=1e3), - ); + edited |= ui + .add( + DragValue::new(&mut draft.lambda) + .speed(0.001) + .range(MIN_ILT_LAMBDA..=MAX_ILT_LAMBDA), + ) + .changed(); }); ui.horizontal(|ui| { ui.label("D min"); - ui.add( - DragValue::new(&mut app.session.ui.ilt_params.d_min) - .speed(1e-12) - .range(1e-13..=1e-7), - ); + edited |= ui + .add( + DragValue::new(&mut draft.d_min) + .speed(1e-12) + .range(1e-13..=1e-7), + ) + .changed(); ui.label("D max"); - ui.add( - DragValue::new(&mut app.session.ui.ilt_params.d_max) - .speed(1e-10) - .range(1e-12..=1e-6), - ); + edited |= ui + .add( + DragValue::new(&mut draft.d_max) + .speed(1e-10) + .range(1e-12..=1e-6), + ) + .changed(); }); ui.horizontal(|ui| { ui.label("Grid points"); - ui.add( - DragValue::new(&mut app.session.ui.ilt_params.n_grid) - .speed(1.0) - .range(16..=512), - ); + edited |= ui + .add(DragValue::new(&mut draft.n_grid).speed(1.0).range(16..=512)) + .changed(); }); + if edited { + app.set_explicit_ilt_input(di, draft); + } } } diff --git a/crates/core/src/automation/properties_tests.rs b/crates/core/src/automation/properties_tests.rs index a667ba5..4ead83d 100644 --- a/crates/core/src/automation/properties_tests.rs +++ b/crates/core/src/automation/properties_tests.rs @@ -5,9 +5,10 @@ //! planner the panel controls use. use super::*; +use crate::properties::ilt_tests::ilt_app; use crate::properties::tests::{contour_app, contour_spec}; use crate::properties::{ - AggregateValue, PropertyAddress, PropertyValue, apodization, contour, definition_by_key, + AggregateValue, PropertyAddress, PropertyValue, apodization, contour, definition_by_key, ilt, typography, }; use crate::state::{ @@ -69,6 +70,43 @@ fn add_line_series(app: &mut PlotxApp) { plot.binding.series.push(series); } +#[test] +fn automation_inspects_stored_ilt_provenance_and_refuses_set_and_reset_as_read_only() { + let (mut app, target) = ilt_app(0.07); + let id = target.resource.id.clone(); + let inspect = request( + &app, + TOOL_INSPECT, + serde_json::json!({"key": ilt::RESULT_LAMBDA.as_str()}), + vec![id.clone()], + CallerType::Agent, + ); + let result = run(&mut app, inspect).expect("properties.inspect reads provenance"); + let value = result.value.to_string(); + assert!(value.contains("0.07"), "{value}"); + assert!(value.contains("read_only"), "{value}"); + + for (tool, parameters) in [ + ( + TOOL_SET, + serde_json::json!({"key": ilt::RESULT_LAMBDA.as_str(), "value": 0.2}), + ), + ( + TOOL_RESET, + serde_json::json!({"key": ilt::RESULT_LAMBDA.as_str()}), + ), + ] { + let error = plan_tool( + &app, + request(&app, tool, parameters, vec![id.clone()], CallerType::Agent), + ) + .expect_err("non-inspect automation must refuse read-only provenance"); + let message = error.to_string(); + assert!(message.contains("read-only"), "{message}"); + assert!(message.contains(ilt::RESULT_LAMBDA.as_str()), "{message}"); + } +} + #[test] fn an_unknown_property_key_is_refused_rather_than_skipped() { let (mut app, _) = contour_app(); @@ -425,14 +463,22 @@ fn dataset_property_tools_expand_processing_steps_and_report_non_apodization_ski /// before anything is planned. #[test] fn a_read_only_property_cannot_be_written() { - // Every catalog entry is currently writable, so this asserts the guard - // exists rather than exercising a read-only entry that does not yet exist. - assert!( - crate::properties::catalog() - .iter() - .all(|definition| definition.access == crate::properties::PropertyAccess::ReadWrite), - "add a read-only case here once the catalog has one" + let (app, target) = ilt_app(0.07); + let error = plan_tool( + &app, + request( + &app, + TOOL_SET, + serde_json::json!({"key": ilt::RESULT_LAMBDA.as_str(), "value": 0.2}), + vec![target.resource.id], + CallerType::Agent, + ), ); + let message = error + .expect_err("the read-only definition must be refused before planning") + .to_string(); + assert!(message.contains("read-only"), "{message}"); + assert!(message.contains(ilt::RESULT_LAMBDA.as_str()), "{message}"); } // --------------------------------------------------------------------------- diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 8a95535..dd29a69 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -125,7 +125,7 @@ pub struct IltParams { impl Default for IltParams { fn default() -> Self { Self { - lambda: 1e-2, + lambda: crate::settings::DEFAULT_ILT_LAMBDA, d_min: 1e-11, d_max: 1e-8, n_grid: 128, @@ -143,6 +143,23 @@ pub enum DosyMethod { Ilt(IltParams), } +/// The invocation inputs recorded beside a persisted DOSY result. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "method", rename_all = "snake_case")] +pub enum DosyInvocation { + MonoExp { snr_frac: f64 }, + Ilt { params: IltParams }, +} + +/// Reproducibility metadata for one persisted DOSY result. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct DosyResultProvenance { + pub algorithm: String, + pub version: u32, + pub input: DosyInvocation, + pub data_fingerprint: String, +} + pub use plotx_analysis::series::ReduceOp; pub fn extract_region_series( diff --git a/crates/core/src/operation.rs b/crates/core/src/operation.rs index 4fe3601..6419a0a 100644 --- a/crates/core/src/operation.rs +++ b/crates/core/src/operation.rs @@ -36,6 +36,7 @@ pub enum DiagnosticCode { TableImportWarning, TableImportFailed, ProjectLoadSucceeded, + ProjectLoadWarning, ProjectLoadFailed, ProjectSaveSucceeded, ProjectSaveFailed, @@ -69,6 +70,7 @@ impl DiagnosticCode { Self::TableImportWarning => "table.import.warning", Self::TableImportFailed => "table.import.failed", Self::ProjectLoadSucceeded => "project.load.succeeded", + Self::ProjectLoadWarning => "project.load.warning", Self::ProjectLoadFailed => "project.load.failed", Self::ProjectSaveSucceeded => "project.save.succeeded", Self::ProjectSaveFailed => "project.save.failed", diff --git a/crates/core/src/project/convert.rs b/crates/core/src/project/convert.rs index 0aeb841..967a0d8 100644 --- a/crates/core/src/project/convert.rs +++ b/crates/core/src/project/convert.rs @@ -1,16 +1,36 @@ -use super::axis_overrides::AxisOverridesDto; use super::convert_recipes::{nmr2d_recipe_extensions, read_regions}; use super::electrophysiology_convert::{ electrophysiology_from_object, electrophysiology_to_objects, }; -use super::field_catalog::{read as read_field_catalog, validate_series}; +use super::field_catalog::read as read_field_catalog; use super::*; -use crate::state::SeriesId; +use crate::{DosyMethod, PseudoDisplay}; +use plotx_processing::Processed2D; + +pub struct DatasetObjects { + pub data: DataObject, + pub blob: Vec, + pub recipe: RecipeObject, + /// Extra blobs this dataset owns, as (zip path, bytes). Written verbatim. + pub extra_blobs: Vec<(String, Vec)>, +} + +impl DatasetObjects { + fn primary(data: DataObject, blob: Vec, recipe: RecipeObject) -> Self { + Self { + data, + blob, + recipe, + extra_blobs: Vec::new(), + } + } +} + pub fn dataset_to_objects( dataset: &Dataset, data_id: &str, recipe_id: &str, -) -> Result<(DataObject, Vec, RecipeObject)> { +) -> Result { Ok(match dataset { Dataset::Nmr(n) => { let blob = complex_to_bytes(&n.data.points); @@ -56,7 +76,7 @@ pub fn dataset_to_objects( } }), }; - (data, blob, recipe) + DatasetObjects::primary(data, blob, recipe) } Dataset::Nmr2D(n) => { let blob = complex_to_bytes(&n.data.data); @@ -87,6 +107,50 @@ pub fn dataset_to_objects( "plotx.fields": &n.field_catalog }), }; + let has_dosy_state = n.display != PseudoDisplay::Stack + || n.dosy_method != DosyMethod::MonoExp + || n.dosy_map.is_some() + || n.ilt_map.is_some() + || n.dosy_provenance.is_some() + || n.ilt_provenance.is_some(); + let (dosy_extension, extra_blobs) = if has_dosy_state { + // Only the map-without-provenance direction is refused: a stored + // map nobody can attribute is the orphan this guard exists to + // prevent, and every builder installs both together, so reaching + // it means a bug rather than user state. The mirror case is + // legitimate and reachable — a load whose blob failed to decode + // keeps the provenance and drops the numbers — and refusing it + // would make that project permanently unsaveable. + if n.dosy_map.is_some() && n.dosy_provenance.is_none() { + return Err(ProjectError::Invalid( + "per-column DOSY map has no provenance to store with it".to_owned(), + )); + } + if n.ilt_map.is_some() && n.ilt_provenance.is_none() { + return Err(ProjectError::Invalid( + "ILT DOSY map has no provenance to store with it".to_owned(), + )); + } + let dosy_path = format!("objects/{data_id}/dosy.bin"); + let (bytes, shapes) = + super::dosy_convert::encode_dosy(n.dosy_map.as_ref(), n.ilt_map.as_ref())?; + let extension = super::dosy_convert::DosyExtensionDto::new( + n.display, + n.dosy_method, + super::dosy_convert::DosyProvenanceDto { + diffusion: n.dosy_provenance.clone(), + ilt: n.ilt_provenance.clone(), + }, + dosy_path.clone(), + shapes, + ); + ( + Some(serde_json::to_value(extension)?), + vec![(dosy_path, bytes)], + ) + } else { + (None, Vec::new()) + }; let recipe = RecipeObject { id: recipe_id.to_owned(), role: "recipe".to_owned(), @@ -99,9 +163,14 @@ pub fn dataset_to_objects( layout: Some(layout_to_str(n.params.layout).to_owned()), preset: Some(preset_to_str(n.preset).to_owned()), }, - extensions: nmr2d_recipe_extensions(n), + extensions: nmr2d_recipe_extensions(n, dosy_extension), }; - (data, blob, recipe) + DatasetObjects { + data, + blob, + recipe, + extra_blobs, + } } Dataset::Table(t) => { return Err(ProjectError::Invalid(format!( @@ -110,7 +179,8 @@ pub fn dataset_to_objects( ))); } Dataset::Electrophysiology(recording) => { - electrophysiology_to_objects(recording, data_id, recipe_id)? + let (data, blob, recipe) = electrophysiology_to_objects(recording, data_id, recipe_id)?; + DatasetObjects::primary(data, blob, recipe) } Dataset::Afm(afm) => { let blob = super::afm_convert::encode_afm(&afm.data)?; @@ -155,7 +225,7 @@ pub fn dataset_to_objects( parameters: RecipeParameters::default(), extensions: serde_json::Value::Null, }; - (data, blob, recipe) + DatasetObjects::primary(data, blob, recipe) } }) } @@ -303,6 +373,10 @@ pub fn object_to_dataset( read_integrals_2d(&mut dataset, recipe)?; dataset.name = data.label.clone(); dataset.retransform(); + // `retransform` deliberately invalidates every analysis map. Restore + // auxiliary DOSY state only after it, or a load will silently erase + // the stored result and serve the stack fallback instead. + restore_dosy(zip, &mut dataset, recipe); if let Err(error) = dataset.recompute_integrals() { dataset.integral_error = Some(error.to_string()); } @@ -317,480 +391,135 @@ pub fn object_to_dataset( ))), } } -use crate::state::{AxisProjection, AxisProjections, ProjectionSource}; -fn projections_to_dto(p: &AxisProjections, datasets: &[Dataset]) -> Result> { - if p.is_empty() { - return Ok(None); - } - Ok(Some(ProjectionsDto { - top: axis_projection_to_dto(&p.top, datasets)?, - left: axis_projection_to_dto(&p.left, datasets)?, - })) -} -fn axis_projection_to_dto( - a: &AxisProjection, - datasets: &[Dataset], -) -> Result> { - let (source, attached, slice_index) = match a.source { - ProjectionSource::None => return Ok(None), - ProjectionSource::Attached(d) => ( - "attached", - Some(format!( - "recipe_{}", - datasets - .iter() - .find(|dataset| dataset.resource_id() == d) - .ok_or_else(|| { - ProjectError::Invalid(format!( - "axis projection references missing dataset {d}" - )) - })? - .resource_id() - )), - 0, - ), - ProjectionSource::Sum => ("sum", None, 0), - ProjectionSource::Skyline => ("skyline", None, 0), - ProjectionSource::Slice(i) => ("slice", None, i), - }; - Ok(Some(AxisProjectionDto { - source: source.to_owned(), - attached, - slice_index, - visible: a.visible, - })) -} -fn projections_from_dto( - dto: &ProjectionsDto, - recipe_to_dataset: &HashMap, - datasets: &[Dataset], -) -> AxisProjections { - AxisProjections { - top: axis_projection_from_dto(dto.top.as_ref(), recipe_to_dataset, datasets), - left: axis_projection_from_dto(dto.left.as_ref(), recipe_to_dataset, datasets), - } -} -fn axis_projection_from_dto( - dto: Option<&AxisProjectionDto>, - recipe_to_dataset: &HashMap, - datasets: &[Dataset], -) -> AxisProjection { - let Some(dto) = dto else { - return AxisProjection::default(); - }; - let source = match dto.source.as_str() { - "attached" => dto - .attached - .as_ref() - .and_then(|id| recipe_to_dataset.get(id).copied()) - .and_then(|index| datasets.get(index)) - .map(Dataset::resource_id) - .map(ProjectionSource::Attached) - .unwrap_or(ProjectionSource::None), - "sum" => ProjectionSource::Sum, - "skyline" => ProjectionSource::Skyline, - "slice" => ProjectionSource::Slice(dto.slice_index), - _ => ProjectionSource::None, + +fn restore_dosy( + zip: &mut zip::ZipArchive, + dataset: &mut Nmr2DDataset, + recipe: &RecipeObject, +) { + let Some(value) = recipe.extensions.get("plotx.dosy") else { + return; }; - AxisProjection { - source, - visible: dto.visible, - } -} -pub fn canvas_to_view( - datasets: &[Dataset], - canvas: &CanvasDocument, - view_id: &str, -) -> Result { - let objects: Vec = canvas - .objects - .iter() - .map(|object| { - let base = |kind: &str| ViewCanvasObject { - id: object.id.to_string(), - name: object.name.clone(), - kind: kind.to_owned(), - input: String::new(), - next_series_id: 0, - series: Vec::new(), - chart_type: None, - chart_column: None, - chart_bins: None, - chart_stacked: false, - chart_colormap: None, - chart_view: None, - stack: None, - projections: None, - frame: FrameDto::from_frame(object.frame), - viewport: None, - axis_overrides: None, - panel: None, - title: None, - text: None, - shape: None, - locked: object.locked, - visible: object.visible, - group: object.group, - snapshot: None, - }; - match &object.kind { - CanvasObjectKind::Plot(plot) => { - let primary = plot.primary_dataset().ok_or_else(|| { - ProjectError::Invalid(format!( - "view {view_id} plot {} has no primary dataset", - object.id - )) - })?; - let primary_dataset = datasets - .iter() - .find(|dataset| dataset.resource_id() == primary) - .ok_or_else(|| { - ProjectError::Invalid(format!( - "view {view_id} plot {} references missing primary dataset {primary}", - object.id - )) - })?; - let kind = match primary_dataset { - Dataset::Nmr(_) => "line_plot", - Dataset::Nmr2D(n) => match n.params.layout { - Layout2D::Ft => "contour_plot", - Layout2D::Stack => "stack_plot", - }, - Dataset::Table(_) => "line_plot", - Dataset::Electrophysiology(_) => "line_plot", - Dataset::Afm(_) => "heatmap", - }; - let series = plot - .binding - .series - .iter() - .map(|sb| { - let dataset = datasets - .iter() - .find(|dataset| dataset.resource_id() == sb.source.resource) - .ok_or_else(|| { - ProjectError::Invalid(format!( - "view {view_id} plot {} references missing series dataset {}", - object.id, sb.source.resource - )) - })?; - validate_series( - dataset, - sb.source.field, - &sb.encoding, - &format!("view {view_id} plot {} series {}", object.id, sb.id), - )?; - Ok(SeriesBindingDto { - id: sb.id.get(), - input: format!("recipe_{}", dataset.resource_id()), - field: sb.source.field.get(), - label: sb.label.clone(), - encoding: sb.encoding.clone(), - visible: sb.visible, - }) - }) - .collect::>>()?; - let stack = (plot.stack != StackSpec::default()) - .then(|| StackDto::from_spec(&plot.stack)); - Ok(ViewCanvasObject { - input: format!("recipe_{}", primary_dataset.resource_id()), - next_series_id: plot.next_series_id.get(), - series, - chart_type: Some(plot.chart.type_id.clone()), - chart_column: plot.chart.column.map(|column| column.to_string()), - chart_bins: plot.chart.bins, - chart_stacked: plot.chart.stacked, - chart_colormap: (plot.chart.colormap - != plotx_figure::ColormapId::default()) - .then(|| plot.chart.colormap.id().to_owned()), - chart_view: (plot.chart.view_angles != crate::state::SURFACE_DEFAULT_VIEW) - .then_some(plot.chart.view_angles), - stack, - projections: projections_to_dto(&plot.projections, datasets)?, - viewport: Some(ViewportDto::from_viewport(&plot.viewport)), - axis_overrides: AxisOverridesDto::from_overrides(&plot.axis_overrides), - panel: Some(PanelDto::from_panel(&plot.panel)), - ..base(kind) - }) - } - CanvasObjectKind::Text(t) => Ok(ViewCanvasObject { - text: Some(TextBoxDto::from_text_box(t)), - ..base("text") - }), - CanvasObjectKind::PanelLabel(t) => Ok(ViewCanvasObject { - text: Some(TextBoxDto::from_text_box(t)), - ..base("panel_label") - }), - CanvasObjectKind::Shape(s) => Ok(ViewCanvasObject { - shape: Some(ShapeDto::from_shape(s)), - ..base("shape") - }), + let extension: super::dosy_convert::DosyExtensionDto = + match serde_json::from_value(value.clone()) { + Ok(extension) => extension, + Err(error) => { + dataset.dosy_provenance_warning = Some(format!( + "The stored DOSY state is malformed ({error}), so PlotX is showing the \ + reconstructed stack instead. Rebuild the DOSY map." + )); + return; } - }) - .collect::>>()?; - Ok(ViewObject { - id: view_id.to_owned(), - role: "view".to_owned(), - classification: Classification { - domain: "visualization".to_owned(), - technique: Some("spectral_plot".to_owned()), - object: "page".to_owned(), - }, - inputs: objects - .iter() - .map(|object| object.input.clone()) - .filter(|input| !input.is_empty()) - .collect(), - name: canvas.name.clone(), - next_object_id: canvas.next_object_id.get(), - caption: canvas.caption.clone(), - caption_visible: canvas.caption_visible, - panel_label_style: Some(canvas.panel_label_style.as_key().to_owned()), - layout: ViewLayout { - size_mm: canvas.size_mm, - size_preset: canvas.size_preset_id.clone(), - auto_height: canvas.auto_height, - grid: Some(PageLayoutDto::from_layout(&canvas.layout)), - background: Some([ - canvas.background.r, - canvas.background.g, - canvas.background.b, - ]), - board_pos: Some(canvas.board_pos), - }, - objects, - viewport: None, - snapshot: None, - }) -} -pub fn view_to_canvas( - app: &mut PlotxApp, - zip: &mut zip::ZipArchive, - view_id: &str, - view: &ViewObject, - index: usize, - recipe_to_dataset: &HashMap, -) -> Result { - let mut canvas = CanvasDocument::new(view.name.clone(), view.layout.size_mm); - canvas.size_preset_id = view.layout.size_preset.clone(); - canvas.auto_height = view.layout.auto_height; - canvas.board_pos = view - .layout - .board_pos - .unwrap_or_else(|| crate::state::default_board_layout(index)); - canvas.caption = view.caption.clone(); - canvas.caption_visible = view.caption_visible; - canvas.panel_label_style = view - .panel_label_style - .as_deref() - .map(crate::state::PanelLabelStyle::from_key) - .unwrap_or_default(); - canvas.layout = view - .layout - .grid - .map(PageLayoutDto::into_layout) - .unwrap_or_default(); - if let Some([r, g, b]) = view.layout.background { - canvas.background = plotx_figure::Color::rgb(r, g, b); + }; + dataset.display = extension.display; + dataset.dosy_method = extension.method; + dataset.dosy_provenance = extension.provenance.diffusion; + dataset.ilt_provenance = extension.provenance.ilt; + + let decoded = if extension.storage != STORAGE_DOSY_V1 { + Err(ProjectError::Unsupported(format!( + "DOSY payload storage {}", + extension.storage + ))) + } else { + read_bytes(zip, &extension.blob) + .and_then(|bytes| super::dosy_convert::decode_dosy(&bytes, &extension.shapes)) + }; + match decoded { + Ok(decoded) => { + dataset.dosy_map = decoded.dosy_map; + dataset.ilt_map = decoded.ilt_map; + } + Err(error) => { + dataset.dosy_provenance_warning = Some(unavailable_dosy_warning( + dataset.dosy_method, + &error.to_string(), + )); + return; + } } - let mut max_id = 0; - let mut max_group = 0; - for view_object in &view.objects { - let object_id = view_object - .id - .parse::() - .map_err(|_| ProjectError::Invalid(format!("invalid object id {}", view_object.id)))?; - let frame = view_object.frame.into_frame(); - let mut kind = match view_object.kind.as_str() { - "text" => CanvasObjectKind::Text(text_box_from(view_object, false)), - "panel_label" => CanvasObjectKind::PanelLabel(text_box_from(view_object, true)), - "shape" => CanvasObjectKind::Shape( - view_object - .shape - .clone() - .map(ShapeDto::into_shape) - .unwrap_or_else(|| ShapeObject::new(ShapeKind::Rect)), - ), - "line_plot" | "contour_plot" | "stack_plot" | "plot" => { - let resolve = |input: &str| { - recipe_to_dataset.get(input).copied().ok_or_else(|| { - ProjectError::Invalid(format!( - "view {view_id} references unknown recipe {input}" - )) - }) - }; - if view_object.series.is_empty() { - return Err(ProjectError::Invalid(format!( - "view {view_id} plot {} has no series bindings", - view_object.id - ))); - } - let mut series = Vec::with_capacity(view_object.series.len()); - for sb in &view_object.series { - let index = resolve(&sb.input)?; - let dataset = app.doc.datasets.get(index).ok_or_else(|| { - ProjectError::Invalid(format!( - "view {view_id} references unavailable dataset index {index}" - )) - })?; - let field = crate::state::FieldId::new(sb.field); - validate_series( - dataset, - field, - &sb.encoding, - &format!("view {view_id} series {}", sb.id), - )?; - series.push(SeriesBinding { - id: SeriesId::new(sb.id), - source: crate::state::SeriesSource { - resource: dataset.resource_id(), - field, - }, - label: sb.label.clone(), - encoding: sb.encoding.clone(), - visible: sb.visible, - }); - } - let binding = DataBinding { series }; - let stack = view_object - .stack - .clone() - .map(StackDto::into_spec) - .unwrap_or_default(); - let dataset_id = binding.primary_dataset().ok_or_else(|| { - ProjectError::Invalid(format!("view {} has an empty data binding", view.id)) - })?; - let di = app.doc.dataset_index(dataset_id).ok_or_else(|| { - ProjectError::Invalid(format!( - "view {} references missing dataset {dataset_id}", - view.id - )) - })?; - // `dataset_index` above searched this same immutable vector. - let domain = app.doc.datasets[di].domain(); - let chart = crate::state::ChartSpec { - type_id: view_object - .chart_type - .clone() - .unwrap_or_else(|| crate::state::default_chart_type(domain).id.to_owned()), - column: view_object - .chart_column - .as_deref() - .map(str::parse::) - .transpose() - .map_err(|error| { - ProjectError::Invalid(format!( - "view {} has an invalid chart column id: {error}", - view.id - )) - })?, - bins: view_object.chart_bins, - stacked: view_object.chart_stacked, - // Unknown ids (from a newer build) fall back to the default map. - colormap: view_object - .chart_colormap - .as_deref() - .and_then(plotx_figure::ColormapId::from_id) - .unwrap_or_default(), - view_angles: view_object - .chart_view - .unwrap_or(crate::state::SURFACE_DEFAULT_VIEW), - }; - let size_mm = [ - frame.width / crate::state::MM_TO_PT, - frame.height / crate::state::MM_TO_PT, - ]; - let projections = view_object - .projections - .as_ref() - .map(|dto| projections_from_dto(dto, recipe_to_dataset, &app.doc.datasets)) - .unwrap_or_default(); - let mut figure = if let Some(snapshot) = &view_object.snapshot { - read_json(zip, &snapshot.figure).unwrap_or_else(|_| { - app.build_object_figure(&binding, &chart, &stack, &projections, size_mm) - }) - } else { - app.build_object_figure(&binding, &chart, &stack, &projections, size_mm) - }; - let axis_overrides = view_object - .axis_overrides + + let mut warnings = Vec::new(); + let Processed2D::Stack(stack) = &dataset.processed else { + // Do not claim the stored map is being shown: `figure()` selects on the + // processed result before it consults `display`, so a true-2D result + // always draws the contour spectrum and never a DOSY map. + warnings.push( + "The stored DOSY map belongs to a pseudo-2D stack, but the processing recipe now \ + reconstructs a true-2D spectrum, so the map is not shown. Return the dataset to its \ + pseudo-2D layout and rebuild the DOSY map." + .to_owned(), + ); + dataset.dosy_provenance_warning = Some(warnings.join(" ")); + return; + }; + // Both methods hash the same inputs, so one reconstruction serves both. + if let (Some(axis), Some(meta)) = ( + dataset.data.pseudo_axis.as_ref(), + dataset.data.diffusion.as_ref(), + ) { + let reconstructed = crate::state::dosy_data_fingerprint(stack, &axis.values, meta); + for (label, provenance) in [ + ( + "per-column DOSY", + dataset + .dosy_map .as_ref() - .map(AxisOverridesDto::to_overrides) - .unwrap_or_default(); - let snapshot_backed = view_object.snapshot.is_some(); - if !snapshot_backed { - axis_overrides.apply_to(&mut figure); - } - let mut viewport = view_object - .viewport + .and(dataset.dosy_provenance.as_ref()), + ), + ( + "ILT DOSY", + dataset + .ilt_map .as_ref() - .map(ViewportDto::to_viewport) - .unwrap_or_else(|| CanvasViewport::from_figure(&figure)); - if !snapshot_backed { - if axis_overrides.y_range.is_some() && figure.y.categories.is_none() { - viewport.auto_y = false; - } - viewport.sync_full_from(&figure); - viewport.apply_to(&mut figure); - } - figure.title.clear(); - let panel = view_object - .panel - .clone() - .or_else(|| view_object.title.clone()) - .map(PanelDto::into_panel) - .unwrap_or_else(|| PanelMeta::new(app.default_plot_title(di), frame.width)); - CanvasObjectKind::Plot(Box::new(PlotObject { - next_series_id: SeriesId::new(view_object.next_series_id), - binding, - chart, - stack, - projections, - axis_overrides, - figure, - viewport, - panel, - })) + .and(dataset.ilt_provenance.as_ref()), + ), + ] { + let Some(provenance) = provenance else { + continue; + }; + if reconstructed != provenance.data_fingerprint { + warnings.push(fingerprint_warning( + label, + &provenance.data_fingerprint, + &reconstructed, + )); } - _ => continue, - }; - if let CanvasObjectKind::Plot(plot) = &mut kind { - plot.repair_series_allocator().ok_or_else(|| { - ProjectError::Invalid(format!( - "view {view_id} object {} exhausts the series id space", - view_object.id - )) - })?; } - canvas.objects.push(CanvasObject { - id: object_id, - name: view_object.name.clone(), - frame, - locked: view_object.locked, - visible: view_object.visible, - group: view_object.group, - kind, - }); - max_id = max_id.max(object_id.get()); - max_group = max_group.max(view_object.group.unwrap_or(0)); } - let repaired_next = ObjectId::new(max_id) - .try_advance(1) - .ok_or_else(|| ProjectError::Invalid("object id space exhausted".to_owned()))?; - canvas.next_object_id = ObjectId::new(view.next_object_id).max(repaired_next); - canvas.next_group_id = max_group + 1; - Ok(canvas) + // "The selected map is absent" is deliberately *not* stored here. It is a + // pure function of the display, the method and which maps exist, and + // `Nmr2DDataset::missing_selected_map_note` already derives it. A stored copy + // would survive the user switching to a method that does have a map, and + // would then keep claiming the stack is shown while the map is on screen — + // and because readers prefer the stored warning, the stale copy would win. + dataset.dosy_provenance_warning = (!warnings.is_empty()).then(|| warnings.join(" ")); } -fn text_box_from(view_object: &ViewCanvasObject, panel: bool) -> TextBox { - view_object - .text - .clone() - .map(TextBoxDto::into_text_box) - .unwrap_or_else(|| { - if panel { - TextBox::panel_label(String::new()) - } else { - TextBox::label(String::new()) - } - }) + +fn unavailable_dosy_warning(method: DosyMethod, reason: &str) -> String { + let (label, action) = match method { + DosyMethod::MonoExp => ("per-column DOSY", "Build the per-column DOSY map"), + DosyMethod::Ilt(_) => ("ILT DOSY", "Build the ILT DOSY map"), + }; + format!( + "The stored {label} map could not be loaded ({reason}), so PlotX is showing the stack \ + instead. {action} to replace it." + ) +} + +fn fingerprint_warning(label: &str, stored: &str, reconstructed: &str) -> String { + // A short prefix, not the full digest: two 64-character hashes bury the one + // sentence the reader has to act on, and nothing downstream reconstructs the + // input from them. The prefix still distinguishes one mismatch from another. + format!( + "The stored {label} map was produced from different data than the recipe now reconstructs \ + (saved data {}, rebuilt data {}). The stored map is being shown. Rebuild the {label} map \ + to update it.", + short_fingerprint(stored), + short_fingerprint(reconstructed), + ) +} + +fn short_fingerprint(value: &str) -> &str { + value.get(..12).unwrap_or(value) } diff --git a/crates/core/src/project/convert_recipes.rs b/crates/core/src/project/convert_recipes.rs index 6dcf4c1..d81d59f 100644 --- a/crates/core/src/project/convert_recipes.rs +++ b/crates/core/src/project/convert_recipes.rs @@ -107,7 +107,10 @@ pub(super) fn read_regions(dataset: &mut Nmr2DDataset, recipe: &RecipeObject) { .unwrap_or_else(|| dataset.regions.iter().map(|r| r.id + 1).max().unwrap_or(0)); } -pub(super) fn nmr2d_recipe_extensions(dataset: &Nmr2DDataset) -> serde_json::Value { +pub(super) fn nmr2d_recipe_extensions( + dataset: &Nmr2DDataset, + dosy: Option, +) -> serde_json::Value { let mut extensions = serde_json::Map::new(); extensions.insert( "plotx.step_allocator".to_owned(), @@ -129,6 +132,9 @@ pub(super) fn nmr2d_recipe_extensions(dataset: &Nmr2DDataset) -> serde_json::Val serde_json::json!({ "integrals_2d": &dataset.integrals }), ); } + if let Some(dosy) = dosy { + extensions.insert("plotx.dosy".to_owned(), dosy); + } if extensions.is_empty() { serde_json::Value::Null } else { diff --git a/crates/core/src/project/convert_views.rs b/crates/core/src/project/convert_views.rs new file mode 100644 index 0000000..8f41801 --- /dev/null +++ b/crates/core/src/project/convert_views.rs @@ -0,0 +1,494 @@ +use super::axis_overrides::AxisOverridesDto; +use super::field_catalog::validate_series; +use super::*; +use crate::state::SeriesId; +use crate::state::{AxisProjection, AxisProjections, ProjectionSource}; +fn projections_to_dto(p: &AxisProjections, datasets: &[Dataset]) -> Result> { + if p.is_empty() { + return Ok(None); + } + Ok(Some(ProjectionsDto { + top: axis_projection_to_dto(&p.top, datasets)?, + left: axis_projection_to_dto(&p.left, datasets)?, + })) +} +fn axis_projection_to_dto( + a: &AxisProjection, + datasets: &[Dataset], +) -> Result> { + let (source, attached, slice_index) = match a.source { + ProjectionSource::None => return Ok(None), + ProjectionSource::Attached(d) => ( + "attached", + Some(format!( + "recipe_{}", + datasets + .iter() + .find(|dataset| dataset.resource_id() == d) + .ok_or_else(|| { + ProjectError::Invalid(format!( + "axis projection references missing dataset {d}" + )) + })? + .resource_id() + )), + 0, + ), + ProjectionSource::Sum => ("sum", None, 0), + ProjectionSource::Skyline => ("skyline", None, 0), + ProjectionSource::Slice(i) => ("slice", None, i), + }; + Ok(Some(AxisProjectionDto { + source: source.to_owned(), + attached, + slice_index, + visible: a.visible, + })) +} +fn projections_from_dto( + dto: &ProjectionsDto, + recipe_to_dataset: &HashMap, + datasets: &[Dataset], +) -> AxisProjections { + AxisProjections { + top: axis_projection_from_dto(dto.top.as_ref(), recipe_to_dataset, datasets), + left: axis_projection_from_dto(dto.left.as_ref(), recipe_to_dataset, datasets), + } +} +fn axis_projection_from_dto( + dto: Option<&AxisProjectionDto>, + recipe_to_dataset: &HashMap, + datasets: &[Dataset], +) -> AxisProjection { + let Some(dto) = dto else { + return AxisProjection::default(); + }; + let source = match dto.source.as_str() { + "attached" => dto + .attached + .as_ref() + .and_then(|id| recipe_to_dataset.get(id).copied()) + .and_then(|index| datasets.get(index)) + .map(Dataset::resource_id) + .map(ProjectionSource::Attached) + .unwrap_or(ProjectionSource::None), + "sum" => ProjectionSource::Sum, + "skyline" => ProjectionSource::Skyline, + "slice" => ProjectionSource::Slice(dto.slice_index), + _ => ProjectionSource::None, + }; + AxisProjection { + source, + visible: dto.visible, + } +} +pub fn canvas_to_view( + datasets: &[Dataset], + canvas: &CanvasDocument, + view_id: &str, +) -> Result { + let objects: Vec = canvas + .objects + .iter() + .map(|object| { + let base = |kind: &str| ViewCanvasObject { + id: object.id.to_string(), + name: object.name.clone(), + kind: kind.to_owned(), + input: String::new(), + next_series_id: 0, + series: Vec::new(), + chart_type: None, + chart_column: None, + chart_bins: None, + chart_stacked: false, + chart_colormap: None, + chart_view: None, + stack: None, + projections: None, + frame: FrameDto::from_frame(object.frame), + viewport: None, + axis_overrides: None, + panel: None, + title: None, + text: None, + shape: None, + locked: object.locked, + visible: object.visible, + group: object.group, + snapshot: None, + }; + match &object.kind { + CanvasObjectKind::Plot(plot) => { + let primary = plot.primary_dataset().ok_or_else(|| { + ProjectError::Invalid(format!( + "view {view_id} plot {} has no primary dataset", + object.id + )) + })?; + let primary_dataset = datasets + .iter() + .find(|dataset| dataset.resource_id() == primary) + .ok_or_else(|| { + ProjectError::Invalid(format!( + "view {view_id} plot {} references missing primary dataset {primary}", + object.id + )) + })?; + let kind = match primary_dataset { + Dataset::Nmr(_) => "line_plot", + Dataset::Nmr2D(n) => match n.params.layout { + Layout2D::Ft => "contour_plot", + Layout2D::Stack => "stack_plot", + }, + Dataset::Table(_) => "line_plot", + Dataset::Electrophysiology(_) => "line_plot", + Dataset::Afm(_) => "heatmap", + }; + let series = plot + .binding + .series + .iter() + .map(|sb| { + let dataset = datasets + .iter() + .find(|dataset| dataset.resource_id() == sb.source.resource) + .ok_or_else(|| { + ProjectError::Invalid(format!( + "view {view_id} plot {} references missing series dataset {}", + object.id, sb.source.resource + )) + })?; + validate_series( + dataset, + sb.source.field, + &sb.encoding, + &format!("view {view_id} plot {} series {}", object.id, sb.id), + )?; + Ok(SeriesBindingDto { + id: sb.id.get(), + input: format!("recipe_{}", dataset.resource_id()), + field: sb.source.field.get(), + label: sb.label.clone(), + encoding: sb.encoding.clone(), + visible: sb.visible, + }) + }) + .collect::>>()?; + let stack = (plot.stack != StackSpec::default()) + .then(|| StackDto::from_spec(&plot.stack)); + Ok(ViewCanvasObject { + input: format!("recipe_{}", primary_dataset.resource_id()), + next_series_id: plot.next_series_id.get(), + series, + chart_type: Some(plot.chart.type_id.clone()), + chart_column: plot.chart.column.map(|column| column.to_string()), + chart_bins: plot.chart.bins, + chart_stacked: plot.chart.stacked, + chart_colormap: (plot.chart.colormap + != plotx_figure::ColormapId::default()) + .then(|| plot.chart.colormap.id().to_owned()), + chart_view: (plot.chart.view_angles != crate::state::SURFACE_DEFAULT_VIEW) + .then_some(plot.chart.view_angles), + stack, + projections: projections_to_dto(&plot.projections, datasets)?, + viewport: Some(ViewportDto::from_viewport(&plot.viewport)), + axis_overrides: AxisOverridesDto::from_overrides(&plot.axis_overrides), + panel: Some(PanelDto::from_panel(&plot.panel)), + ..base(kind) + }) + } + CanvasObjectKind::Text(t) => Ok(ViewCanvasObject { + text: Some(TextBoxDto::from_text_box(t)), + ..base("text") + }), + CanvasObjectKind::PanelLabel(t) => Ok(ViewCanvasObject { + text: Some(TextBoxDto::from_text_box(t)), + ..base("panel_label") + }), + CanvasObjectKind::Shape(s) => Ok(ViewCanvasObject { + shape: Some(ShapeDto::from_shape(s)), + ..base("shape") + }), + } + }) + .collect::>>()?; + Ok(ViewObject { + id: view_id.to_owned(), + role: "view".to_owned(), + classification: Classification { + domain: "visualization".to_owned(), + technique: Some("spectral_plot".to_owned()), + object: "page".to_owned(), + }, + inputs: objects + .iter() + .map(|object| object.input.clone()) + .filter(|input| !input.is_empty()) + .collect(), + name: canvas.name.clone(), + next_object_id: canvas.next_object_id.get(), + caption: canvas.caption.clone(), + caption_visible: canvas.caption_visible, + panel_label_style: Some(canvas.panel_label_style.as_key().to_owned()), + layout: ViewLayout { + size_mm: canvas.size_mm, + size_preset: canvas.size_preset_id.clone(), + auto_height: canvas.auto_height, + grid: Some(PageLayoutDto::from_layout(&canvas.layout)), + background: Some([ + canvas.background.r, + canvas.background.g, + canvas.background.b, + ]), + board_pos: Some(canvas.board_pos), + }, + objects, + viewport: None, + snapshot: None, + }) +} +pub fn view_to_canvas( + app: &mut PlotxApp, + zip: &mut zip::ZipArchive, + view_id: &str, + view: &ViewObject, + index: usize, + recipe_to_dataset: &HashMap, +) -> Result { + let mut canvas = CanvasDocument::new(view.name.clone(), view.layout.size_mm); + canvas.size_preset_id = view.layout.size_preset.clone(); + canvas.auto_height = view.layout.auto_height; + canvas.board_pos = view + .layout + .board_pos + .unwrap_or_else(|| crate::state::default_board_layout(index)); + canvas.caption = view.caption.clone(); + canvas.caption_visible = view.caption_visible; + canvas.panel_label_style = view + .panel_label_style + .as_deref() + .map(crate::state::PanelLabelStyle::from_key) + .unwrap_or_default(); + canvas.layout = view + .layout + .grid + .map(PageLayoutDto::into_layout) + .unwrap_or_default(); + if let Some([r, g, b]) = view.layout.background { + canvas.background = plotx_figure::Color::rgb(r, g, b); + } + let mut max_id = 0; + let mut max_group = 0; + for view_object in &view.objects { + let object_id = view_object + .id + .parse::() + .map_err(|_| ProjectError::Invalid(format!("invalid object id {}", view_object.id)))?; + let frame = view_object.frame.into_frame(); + let mut kind = match view_object.kind.as_str() { + "text" => CanvasObjectKind::Text(text_box_from(view_object, false)), + "panel_label" => CanvasObjectKind::PanelLabel(text_box_from(view_object, true)), + "shape" => CanvasObjectKind::Shape( + view_object + .shape + .clone() + .map(ShapeDto::into_shape) + .unwrap_or_else(|| ShapeObject::new(ShapeKind::Rect)), + ), + "line_plot" | "contour_plot" | "stack_plot" | "plot" => { + let resolve = |input: &str| { + recipe_to_dataset.get(input).copied().ok_or_else(|| { + ProjectError::Invalid(format!( + "view {view_id} references unknown recipe {input}" + )) + }) + }; + if view_object.series.is_empty() { + return Err(ProjectError::Invalid(format!( + "view {view_id} plot {} has no series bindings", + view_object.id + ))); + } + let mut series = Vec::with_capacity(view_object.series.len()); + for sb in &view_object.series { + let index = resolve(&sb.input)?; + let dataset = app.doc.datasets.get(index).ok_or_else(|| { + ProjectError::Invalid(format!( + "view {view_id} references unavailable dataset index {index}" + )) + })?; + let field = crate::state::FieldId::new(sb.field); + validate_series( + dataset, + field, + &sb.encoding, + &format!("view {view_id} series {}", sb.id), + )?; + series.push(SeriesBinding { + id: SeriesId::new(sb.id), + source: crate::state::SeriesSource { + resource: dataset.resource_id(), + field, + }, + label: sb.label.clone(), + encoding: sb.encoding.clone(), + visible: sb.visible, + }); + } + let binding = DataBinding { series }; + let stack = view_object + .stack + .clone() + .map(StackDto::into_spec) + .unwrap_or_default(); + let dataset_id = binding.primary_dataset().ok_or_else(|| { + ProjectError::Invalid(format!("view {} has an empty data binding", view.id)) + })?; + let di = app.doc.dataset_index(dataset_id).ok_or_else(|| { + ProjectError::Invalid(format!( + "view {} references missing dataset {dataset_id}", + view.id + )) + })?; + // `dataset_index` above searched this same immutable vector. + let domain = app.doc.datasets[di].domain(); + let chart = crate::state::ChartSpec { + type_id: view_object + .chart_type + .clone() + .unwrap_or_else(|| crate::state::default_chart_type(domain).id.to_owned()), + column: view_object + .chart_column + .as_deref() + .map(str::parse::) + .transpose() + .map_err(|error| { + ProjectError::Invalid(format!( + "view {} has an invalid chart column id: {error}", + view.id + )) + })?, + bins: view_object.chart_bins, + stacked: view_object.chart_stacked, + // Unknown ids (from a newer build) fall back to the default map. + colormap: view_object + .chart_colormap + .as_deref() + .and_then(plotx_figure::ColormapId::from_id) + .unwrap_or_default(), + view_angles: view_object + .chart_view + .unwrap_or(crate::state::SURFACE_DEFAULT_VIEW), + }; + let size_mm = [ + frame.width / crate::state::MM_TO_PT, + frame.height / crate::state::MM_TO_PT, + ]; + let projections = view_object + .projections + .as_ref() + .map(|dto| projections_from_dto(dto, recipe_to_dataset, &app.doc.datasets)) + .unwrap_or_default(); + // A snapshot is a picture of a figure the document could still + // produce. When the dataset's selected DOSY map did not survive + // the load, it no longer can: replaying the snapshot would leave + // the saved contours on the canvas while the load report says the + // stack is being shown. Rebuild from the restored document + // instead, so what is drawn and what is reported agree. + let map_unavailable = app.doc.datasets[di] + .as_nmr2d() + .is_some_and(|dataset| dataset.missing_selected_map_note().is_some()); + let mut figure = match &view_object.snapshot { + Some(snapshot) if !map_unavailable => read_json(zip, &snapshot.figure) + .unwrap_or_else(|_| { + app.build_object_figure(&binding, &chart, &stack, &projections, size_mm) + }), + _ => app.build_object_figure(&binding, &chart, &stack, &projections, size_mm), + }; + let axis_overrides = view_object + .axis_overrides + .as_ref() + .map(AxisOverridesDto::to_overrides) + .unwrap_or_default(); + // Must track the branch actually taken above: a snapshot has axis + // overrides and the viewport already baked in, a rebuilt figure + // does not, so a bypassed snapshot has to be treated as absent + // here or the rebuilt figure would lose both. + let snapshot_backed = view_object.snapshot.is_some() && !map_unavailable; + if !snapshot_backed { + axis_overrides.apply_to(&mut figure); + } + let mut viewport = view_object + .viewport + .as_ref() + .map(ViewportDto::to_viewport) + .unwrap_or_else(|| CanvasViewport::from_figure(&figure)); + if !snapshot_backed { + if axis_overrides.y_range.is_some() && figure.y.categories.is_none() { + viewport.auto_y = false; + } + viewport.sync_full_from(&figure); + viewport.apply_to(&mut figure); + } + figure.title.clear(); + let panel = view_object + .panel + .clone() + .or_else(|| view_object.title.clone()) + .map(PanelDto::into_panel) + .unwrap_or_else(|| PanelMeta::new(app.default_plot_title(di), frame.width)); + CanvasObjectKind::Plot(Box::new(PlotObject { + next_series_id: SeriesId::new(view_object.next_series_id), + binding, + chart, + stack, + projections, + axis_overrides, + figure, + viewport, + panel, + })) + } + _ => continue, + }; + if let CanvasObjectKind::Plot(plot) = &mut kind { + plot.repair_series_allocator().ok_or_else(|| { + ProjectError::Invalid(format!( + "view {view_id} object {} exhausts the series id space", + view_object.id + )) + })?; + } + canvas.objects.push(CanvasObject { + id: object_id, + name: view_object.name.clone(), + frame, + locked: view_object.locked, + visible: view_object.visible, + group: view_object.group, + kind, + }); + max_id = max_id.max(object_id.get()); + max_group = max_group.max(view_object.group.unwrap_or(0)); + } + let repaired_next = ObjectId::new(max_id) + .try_advance(1) + .ok_or_else(|| ProjectError::Invalid("object id space exhausted".to_owned()))?; + canvas.next_object_id = ObjectId::new(view.next_object_id).max(repaired_next); + canvas.next_group_id = max_group + 1; + Ok(canvas) +} +fn text_box_from(view_object: &ViewCanvasObject, panel: bool) -> TextBox { + view_object + .text + .clone() + .map(TextBoxDto::into_text_box) + .unwrap_or_else(|| { + if panel { + TextBox::panel_label(String::new()) + } else { + TextBox::label(String::new()) + } + }) +} diff --git a/crates/core/src/project/dosy_convert.rs b/crates/core/src/project/dosy_convert.rs new file mode 100644 index 0000000..5a5a1ed --- /dev/null +++ b/crates/core/src/project/dosy_convert.rs @@ -0,0 +1,277 @@ +use super::{ProjectError, Result, STORAGE_DOSY_V1}; +use crate::{DosyMethod, DosyResultProvenance, PseudoDisplay}; +use plotx_analysis::diffusion::DiffusionMap; +use plotx_analysis::ilt::IltResult; +use serde::{Deserialize, Serialize}; + +const MAGIC: &[u8; 8] = b"PXDOSY1\0"; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub(super) struct DiffusionMapShape { + pub len: usize, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub(super) struct IltMapShape { + pub ppm_len: usize, + pub d_grid_len: usize, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub(super) struct DosyShapes { + #[serde(skip_serializing_if = "Option::is_none")] + pub diffusion: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ilt: Option, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub(super) struct DosyProvenanceDto { + #[serde(skip_serializing_if = "Option::is_none")] + pub diffusion: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ilt: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub(super) struct DosyExtensionDto { + pub display: PseudoDisplay, + pub method: DosyMethod, + pub provenance: DosyProvenanceDto, + pub storage: String, + pub blob: String, + pub shapes: DosyShapes, +} + +impl DosyExtensionDto { + pub(super) fn new( + display: PseudoDisplay, + method: DosyMethod, + provenance: DosyProvenanceDto, + blob: String, + shapes: DosyShapes, + ) -> Self { + Self { + display, + method, + provenance, + storage: STORAGE_DOSY_V1.to_owned(), + blob, + shapes, + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +struct DosyBlobHeader { + shapes: DosyShapes, +} + +#[derive(Debug)] +pub(super) struct DecodedDosy { + pub dosy_map: Option, + pub ilt_map: Option, +} + +pub(super) fn encode_dosy( + dosy_map: Option<&DiffusionMap>, + ilt_map: Option<&IltResult>, +) -> Result<(Vec, DosyShapes)> { + let shapes = DosyShapes { + diffusion: dosy_map + .map(validate_diffusion_map) + .transpose()? + .map(|len| DiffusionMapShape { len }), + ilt: ilt_map + .map(validate_ilt_map) + .transpose()? + .map(|(ppm_len, d_grid_len)| IltMapShape { + ppm_len, + d_grid_len, + }), + }; + let json = serde_json::to_vec(&DosyBlobHeader { + shapes: shapes.clone(), + })?; + let mut output = Vec::with_capacity(json.len().saturating_add(128)); + output.extend_from_slice(MAGIC); + write_len(&mut output, json.len())?; + output.extend_from_slice(&json); + if let Some(map) = dosy_map { + write_f64s(&mut output, &map.ppm)?; + write_f64s(&mut output, &map.d)?; + write_f64s(&mut output, &map.amp)?; + } + if let Some(map) = ilt_map { + write_f64s(&mut output, &map.ppm)?; + write_f64s(&mut output, &map.d_grid)?; + for row in &map.amp { + write_f64s(&mut output, row)?; + } + } + Ok((output, shapes)) +} + +pub(super) fn decode_dosy(bytes: &[u8], expected_shapes: &DosyShapes) -> Result { + let mut reader = Reader::new(bytes); + if reader.take(MAGIC.len())? != MAGIC { + return Err(ProjectError::Invalid( + "DOSY payload has an invalid signature".to_owned(), + )); + } + let metadata_len = reader.read_len()?; + let header: DosyBlobHeader = serde_json::from_slice(reader.take(metadata_len)?)?; + if &header.shapes != expected_shapes { + return Err(ProjectError::Invalid(format!( + "DOSY payload shapes {:?} do not match expected shapes {:?}", + header.shapes, expected_shapes + ))); + } + + let dosy_map = match &header.shapes.diffusion { + Some(shape) => { + let ppm = reader.read_f64s()?; + require_len("DOSY ppm", ppm.len(), shape.len)?; + let d = reader.read_f64s()?; + require_len("DOSY diffusion", d.len(), shape.len)?; + let amp = reader.read_f64s()?; + require_len("DOSY amplitude", amp.len(), shape.len)?; + Some(DiffusionMap { ppm, d, amp }) + } + None => None, + }; + let ilt_map = match &header.shapes.ilt { + Some(shape) => { + let ppm = reader.read_f64s()?; + require_len("ILT ppm", ppm.len(), shape.ppm_len)?; + let d_grid = reader.read_f64s()?; + require_len("ILT diffusion grid", d_grid.len(), shape.d_grid_len)?; + // Deliberately not `with_capacity(shape.ppm_len)`: the row count comes + // from the file and nothing has yet proven the payload holds that many + // rows, so reserving up front lets a corrupt header abort the process + // on allocation failure. Growing per row caps the reservation at what + // the bytes actually contain, because each `read_f64s` proves its own + // extent first. + let mut amp: Vec> = Vec::new(); + for row in 0..shape.ppm_len { + let values = reader.read_f64s()?; + require_len( + &format!("ILT amplitude row {row}"), + values.len(), + shape.d_grid_len, + )?; + amp.push(values); + } + Some(IltResult { ppm, d_grid, amp }) + } + None => None, + }; + if !reader.is_empty() { + return Err(ProjectError::Invalid( + "DOSY payload contains trailing data".to_owned(), + )); + } + Ok(DecodedDosy { dosy_map, ilt_map }) +} + +fn validate_diffusion_map(map: &DiffusionMap) -> Result { + let expected = map.ppm.len(); + require_len("DOSY diffusion", map.d.len(), expected)?; + require_len("DOSY amplitude", map.amp.len(), expected)?; + Ok(expected) +} + +fn validate_ilt_map(map: &IltResult) -> Result<(usize, usize)> { + let ppm_len = map.ppm.len(); + let d_grid_len = map.d_grid.len(); + require_len("ILT amplitude rows", map.amp.len(), ppm_len)?; + for (row, values) in map.amp.iter().enumerate() { + require_len( + &format!("ILT amplitude row {row}"), + values.len(), + d_grid_len, + )?; + } + Ok((ppm_len, d_grid_len)) +} + +fn require_len(label: &str, actual: usize, expected: usize) -> Result<()> { + if actual != expected { + return Err(ProjectError::Invalid(format!( + "{label} length {actual} does not match expected length {expected}" + ))); + } + Ok(()) +} + +fn write_len(output: &mut Vec, len: usize) -> Result<()> { + let len = u64::try_from(len) + .map_err(|_| ProjectError::Invalid("DOSY array length exceeds u64".to_owned()))?; + output.extend_from_slice(&len.to_le_bytes()); + Ok(()) +} + +fn write_f64s(output: &mut Vec, values: &[f64]) -> Result<()> { + write_len(output, values.len())?; + for value in values { + output.extend_from_slice(&value.to_bits().to_le_bytes()); + } + Ok(()) +} + +struct Reader<'a> { + bytes: &'a [u8], + offset: usize, +} + +impl<'a> Reader<'a> { + fn new(bytes: &'a [u8]) -> Self { + Self { bytes, offset: 0 } + } + + fn take(&mut self, len: usize) -> Result<&'a [u8]> { + let end = self + .offset + .checked_add(len) + .ok_or_else(|| ProjectError::Invalid("DOSY payload offset overflow".to_owned()))?; + let result = self + .bytes + .get(self.offset..end) + .ok_or_else(|| ProjectError::Invalid("DOSY payload is truncated".to_owned()))?; + self.offset = end; + Ok(result) + } + + fn read_len(&mut self) -> Result { + let bytes: [u8; 8] = self + .take(8)? + .try_into() + .map_err(|_| ProjectError::Invalid("invalid DOSY length".to_owned()))?; + usize::try_from(u64::from_le_bytes(bytes)) + .map_err(|_| ProjectError::Invalid("DOSY length exceeds usize".to_owned())) + } + + fn read_f64s(&mut self) -> Result> { + let len = self.read_len()?; + let byte_len = len + .checked_mul(8) + .ok_or_else(|| ProjectError::Invalid("DOSY f64 array size overflow".to_owned()))?; + let bytes = self.take(byte_len)?; + Ok(bytes + .chunks_exact(8) + .map(|chunk| { + f64::from_bits(u64::from_le_bytes( + chunk.try_into().expect("eight-byte chunk"), + )) + }) + .collect()) + } + + fn is_empty(&self) -> bool { + self.offset == self.bytes.len() + } +} + +#[cfg(test)] +#[path = "dosy_convert_tests.rs"] +mod tests; diff --git a/crates/core/src/project/dosy_convert_tests.rs b/crates/core/src/project/dosy_convert_tests.rs new file mode 100644 index 0000000..3089fcb --- /dev/null +++ b/crates/core/src/project/dosy_convert_tests.rs @@ -0,0 +1,118 @@ +use super::*; + +fn maps() -> (DiffusionMap, IltResult) { + ( + DiffusionMap { + ppm: vec![1.0, 2.0], + d: vec![1.1e-9, 1.2e-9], + amp: vec![4.0, 5.0], + }, + IltResult { + ppm: vec![1.0, 2.0], + d_grid: vec![1e-10, 1e-9, 1e-8], + amp: vec![vec![1.0, 2.0, 3.0], vec![4.0, 5.0, 6.0]], + }, + ) +} + +#[test] +fn dosy_binary_round_trip_validates_shapes_truncation_and_trailing_data() { + let (dosy, ilt) = maps(); + let (encoded, shapes) = encode_dosy(Some(&dosy), Some(&ilt)).unwrap(); + let decoded = decode_dosy(&encoded, &shapes).unwrap(); + let decoded_dosy = decoded.dosy_map.unwrap(); + assert_eq!(decoded_dosy.ppm, dosy.ppm); + assert_eq!(decoded_dosy.d, dosy.d); + assert_eq!(decoded_dosy.amp, dosy.amp); + let decoded_ilt = decoded.ilt_map.unwrap(); + assert_eq!(decoded_ilt.ppm, ilt.ppm); + assert_eq!(decoded_ilt.d_grid, ilt.d_grid); + assert_eq!(decoded_ilt.amp, ilt.amp); + + let truncated = decode_dosy(&encoded[..encoded.len() - 1], &shapes) + .expect_err("a truncated final ILT row must be rejected") + .to_string(); + assert!(truncated.contains("truncated"), "{truncated}"); + + let mut trailing = encoded.clone(); + trailing.push(0); + let trailing = decode_dosy(&trailing, &shapes) + .expect_err("trailing data must be rejected") + .to_string(); + assert!(trailing.contains("trailing data"), "{trailing}"); + + let malformed_dosy = DiffusionMap { + ppm: vec![1.0, 2.0], + d: vec![1.0], + amp: vec![1.0, 2.0], + }; + let error = encode_dosy(Some(&malformed_dosy), None) + .expect_err("unequal diffusion vectors must be rejected") + .to_string(); + assert!( + error.contains("length 1 does not match expected length 2"), + "{error}" + ); + + let malformed_ilt = IltResult { + ppm: vec![1.0], + d_grid: vec![1.0, 2.0], + amp: vec![vec![1.0]], + }; + let error = encode_dosy(None, Some(&malformed_ilt)) + .expect_err("ragged ILT rows must be rejected") + .to_string(); + assert!( + error.contains("length 1 does not match expected length 2"), + "{error}" + ); +} + +/// The encode-side checks above all run on values we still own. These cover the +/// decoder's own integrity guards, which are the ones that face a file we did +/// not write: every length prefix inside the blob is untrusted input. +#[test] +fn decoding_rejects_a_payload_that_disagrees_with_itself() { + let (dosy, ilt) = maps(); + let (bytes, shapes) = encode_dosy(Some(&dosy), Some(&ilt)).expect("encode"); + assert!(decode_dosy(&bytes, &shapes).is_ok()); + + let mut wrong_magic = bytes.clone(); + wrong_magic[0] ^= 0xff; + let error = decode_dosy(&wrong_magic, &shapes) + .expect_err("a payload with a foreign signature must be rejected") + .to_string(); + assert!(error.contains("invalid signature"), "{error}"); + + // The shapes the recipe extension declares and the shapes inside the blob + // are two independent statements; a project where they disagree must not + // decode into whichever one happens to be read second. + let mut claimed = shapes.clone(); + claimed.diffusion = Some(DiffusionMapShape { len: 3 }); + let error = decode_dosy(&bytes, &claimed) + .expect_err("declared shapes that disagree with the blob must be rejected") + .to_string(); + assert!(error.contains("do not match expected shapes"), "{error}"); + + // A row count nothing has proven the payload can hold must fail as a project + // error rather than by attempting the allocation it names. + let huge = DosyShapes { + diffusion: None, + ilt: Some(IltMapShape { + ppm_len: usize::MAX / 16, + d_grid_len: 3, + }), + }; + assert!(decode_dosy(&bytes, &huge).is_err()); +} + +#[test] +fn decoding_rejects_a_truncated_array_inside_a_well_formed_header() { + let (dosy, _) = maps(); + let (bytes, shapes) = encode_dosy(Some(&dosy), None).expect("encode"); + let truncated = &bytes[..bytes.len() - 8]; + let error = decode_dosy(truncated, &shapes) + .expect_err("a truncated array must be rejected") + .to_string(); + assert!(error.contains("truncated"), "{error}"); +} diff --git a/crates/core/src/project/mod.rs b/crates/core/src/project/mod.rs index f20ca30..3b3a9c4 100644 --- a/crates/core/src/project/mod.rs +++ b/crates/core/src/project/mod.rs @@ -28,6 +28,8 @@ mod codec; mod convert; mod convert_dimensions; mod convert_recipes; +mod convert_views; +mod dosy_convert; mod dto; mod electrophysiology_convert; mod field_catalog; @@ -42,6 +44,7 @@ pub use codec::*; pub use convert::*; pub use convert_dimensions::*; pub use convert_recipes::*; +pub use convert_views::*; pub use dto::*; use integrals2d::read_integrals_2d; pub(crate) use persistence::commit_atomic_file; @@ -56,6 +59,7 @@ const SCHEMA_VERSION: u32 = 1; const STORAGE_COMPLEX_F64_LE: &str = "complex_f64_le"; const STORAGE_TABLE_V1: &str = "plotx_table_envelope_v1"; const STORAGE_AFM_V1: &str = "plotx_afm_v1"; +const STORAGE_DOSY_V1: &str = "plotx_dosy_v1"; const SNAPSHOT_KIND: &str = "editable_figure_v1"; type Result = std::result::Result; @@ -260,11 +264,13 @@ fn save_project_impl( )?; write_json(&mut zip, options, &recipe_path, &recipe)?; } else { - let (data_object, data_blob, recipe) = - dataset_to_objects(dataset, &data_id, &recipe_id)?; - write_json(&mut zip, options, &data_path, &data_object)?; - write_bytes(&mut zip, options, &data_object.payload.blob, &data_blob)?; - write_json(&mut zip, options, &recipe_path, &recipe)?; + let objects = dataset_to_objects(dataset, &data_id, &recipe_id)?; + write_json(&mut zip, options, &data_path, &objects.data)?; + write_bytes(&mut zip, options, &objects.data.payload.blob, &objects.blob)?; + for (path, bytes) in &objects.extra_blobs { + write_bytes(&mut zip, options, path, bytes)?; + } + write_json(&mut zip, options, &recipe_path, &objects.recipe)?; } manifest.objects.push(Entry { diff --git a/crates/core/src/project/pseudo_tests.rs b/crates/core/src/project/pseudo_tests.rs index dd5e7bf..01b0e72 100644 --- a/crates/core/src/project/pseudo_tests.rs +++ b/crates/core/src/project/pseudo_tests.rs @@ -1,6 +1,6 @@ use super::*; -use crate::PseudoDisplay; use crate::state::{Dataset, Nmr2DDataset, PlotxApp}; +use crate::{IltParams, PseudoDisplay}; use num_complex::Complex64; use plotx_io::{ AxisSource, DiffusionMeta, Dim, Domain, NmrData2D, PseudoAxis, PseudoKind, QuadMode, @@ -116,6 +116,51 @@ fn inject_fit_curve_pseudo_extension(path: &Path) { std::fs::rename(tmp, path).unwrap(); } +fn rewrite_project(path: &Path, mut edit: impl FnMut(&str, &mut Vec) -> bool) { + let file = std::fs::File::open(path).unwrap(); + let mut zip = zip::ZipArchive::new(file).unwrap(); + let mut entries = Vec::new(); + for i in 0..zip.len() { + let mut file = zip.by_index(i).unwrap(); + if file.is_dir() { + continue; + } + let name = file.name().to_owned(); + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes).unwrap(); + if edit(&name, &mut bytes) { + entries.push((name, bytes)); + } + } + drop(zip); + + let tmp = temporary_path(path); + let file = std::fs::File::create(&tmp).unwrap(); + let mut out = zip::ZipWriter::new(file); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + for (name, bytes) in entries { + out.start_file(name, options).unwrap(); + out.write_all(&bytes).unwrap(); + } + out.finish().unwrap(); + std::fs::remove_file(path).unwrap(); + std::fs::rename(tmp, path).unwrap(); +} + +fn assert_f64_bits_equal(actual: &[f64], expected: &[f64]) { + assert_eq!( + actual + .iter() + .map(|value| value.to_bits()) + .collect::>(), + expected + .iter() + .map(|value| value.to_bits()) + .collect::>() + ); +} + #[test] fn project_load_ignores_stored_pseudo_fit_curve() { let mut app = PlotxApp::new(); @@ -138,3 +183,354 @@ fn project_load_ignores_stored_pseudo_fit_curve() { assert_eq!(n.data.cols, 64); assert!(n.data.diffusion.is_some()); } + +#[test] +fn project_round_trip_restores_both_real_dosy_maps_after_retransform() { + let mut app = PlotxApp::new(); + let mut ds = Nmr2DDataset::load(synthetic_dosy_2d()); + assert!(ds.build_dosy_map(), "the real per-column fit must populate"); + let original_dosy = ds.dosy_map.clone().unwrap(); + let params = crate::IltParams { + lambda: 0.02, + d_min: 1e-11, + d_max: 1e-8, + n_grid: 32, + }; + assert!(ds.build_ilt_map(params), "the real ILT fit must populate"); + let original_ilt = ds.ilt_map.clone().unwrap(); + let plotx_processing::Processed2D::Stack(original_stack) = &ds.processed else { + panic!("synthetic DOSY must process as a stack"); + }; + let original_stack = original_stack.clone(); + app.doc.datasets.push(Dataset::Nmr2D(Box::new(ds))); + + let path = temp_project("dosy-results"); + let _ = std::fs::remove_file(&path); + save_project(&app, &path, false).unwrap(); + let mut loaded = load_project(&path).unwrap(); + let _ = std::fs::remove_file(&path); + + let Dataset::Nmr2D(restored) = &loaded.doc.datasets[0] else { + panic!("expected a 2D NMR dataset"); + }; + assert_eq!(restored.display, PseudoDisplay::DosyMap); + assert_eq!(restored.dosy_method, crate::DosyMethod::Ilt(params)); + let plotx_processing::Processed2D::Stack(restored_stack) = &restored.processed else { + panic!("restored DOSY must process as a stack"); + }; + assert_eq!(restored_stack.traces.len(), original_stack.traces.len()); + assert_f64_bits_equal(&restored_stack.ppm, &original_stack.ppm); + assert_f64_bits_equal( + &restored.data.pseudo_axis.as_ref().unwrap().values, + &app.doc.datasets[0] + .as_nmr2d() + .unwrap() + .data + .pseudo_axis + .as_ref() + .unwrap() + .values, + ); + for (actual, expected) in restored_stack.traces.iter().zip(&original_stack.traces) { + assert_eq!( + actual + .iter() + .map(|value| (value.re.to_bits(), value.im.to_bits())) + .collect::>(), + expected + .iter() + .map(|value| (value.re.to_bits(), value.im.to_bits())) + .collect::>() + ); + } + let dosy = restored.dosy_map.as_ref().expect("per-column map restored"); + assert_f64_bits_equal(&dosy.ppm, &original_dosy.ppm); + assert_f64_bits_equal(&dosy.d, &original_dosy.d); + assert_f64_bits_equal(&dosy.amp, &original_dosy.amp); + let ilt = restored.ilt_map.as_ref().expect("ILT map restored"); + assert_f64_bits_equal(&ilt.ppm, &original_ilt.ppm); + assert_f64_bits_equal(&ilt.d_grid, &original_ilt.d_grid); + assert_eq!(ilt.amp.len(), original_ilt.amp.len()); + for (actual, expected) in ilt.amp.iter().zip(&original_ilt.amp) { + assert_f64_bits_equal(actual, expected); + } + assert!(restored.dosy_provenance.is_some()); + assert!(restored.ilt_provenance.is_some()); + assert!( + restored.dosy_provenance_warning.is_none(), + "{:?}", + restored.dosy_provenance_warning + ); + let figure = restored.figure(); + assert!(figure.title.starts_with("DOSY (ILT)"), "{}", figure.title); + assert!( + !figure.contours.is_empty(), + "the restored ILT map, not the stack fallback, must be rendered" + ); + // The middle lifecycle stage, resolved exactly the way a real build resolves + // it: no explicit input has been entered for this dataset, so the reopened + // result's provenance must outrank the application default. + loaded.settings.processing.ilt_lambda = 0.9; + assert_eq!(loaded.explicit_ilt_input_for(0), None); + let resolved = loaded.resolve_ilt_params_for(0, loaded.explicit_ilt_input_for(0)); + assert_eq!( + resolved.lambda, params.lambda, + "reopening a result must offer its provenance before the app default" + ); + + // Third stage: with the provenance gone, the same call falls through to the + // application default rather than to whatever the panel last held. + loaded.doc.datasets[0] + .as_nmr2d_mut() + .unwrap() + .ilt_provenance = None; + assert_eq!( + loaded + .resolve_ilt_params_for(0, loaded.explicit_ilt_input_for(0)) + .lambda, + 0.9, + "with no provenance the application default must be used" + ); + + // First stage: an explicit input for this dataset outranks both. + loaded.set_explicit_ilt_input( + 0, + IltParams { + lambda: 0.37, + ..params + }, + ); + assert_eq!( + loaded + .resolve_ilt_params_for(0, loaded.explicit_ilt_input_for(0)) + .lambda, + 0.37, + "an explicit input must outrank provenance and the default" + ); +} + +#[test] +fn mismatched_fingerprint_keeps_the_stored_map_and_reports_both_fingerprints() { + let mut app = PlotxApp::new(); + let mut ds = Nmr2DDataset::load(synthetic_dosy_2d()); + assert!(ds.build_dosy_map()); + let original = ds.dosy_map.clone().unwrap(); + app.doc.datasets.push(Dataset::Nmr2D(Box::new(ds))); + + let path = temp_project("dosy-fingerprint"); + let _ = std::fs::remove_file(&path); + save_project(&app, &path, false).unwrap(); + rewrite_project(&path, |name, bytes| { + if name.ends_with("/data.bin") { + bytes[0] ^= 1; + } + true + }); + let loaded = load_project(&path).unwrap(); + let _ = std::fs::remove_file(&path); + + let Dataset::Nmr2D(restored) = &loaded.doc.datasets[0] else { + panic!("expected a 2D NMR dataset"); + }; + let map = restored + .dosy_map + .as_ref() + .expect("stored map remains present"); + assert_f64_bits_equal(&map.ppm, &original.ppm); + assert_f64_bits_equal(&map.d, &original.d); + assert_f64_bits_equal(&map.amp, &original.amp); + assert!(restored.figure().title.starts_with("DOSY —")); + let warning = restored + .dosy_provenance_warning + .as_deref() + .expect("mismatch is user-visible"); + // Assert on the evidence the message must carry, not on its phrasing: both + // the saved and the rebuilt identifier, and they must actually differ — a + // message naming the same value twice would read like a diagnosis while + // proving nothing. + let stored = &restored + .dosy_provenance + .as_ref() + .expect("stored provenance remains present") + .data_fingerprint; + let plotx_processing::Processed2D::Stack(stack) = &restored.processed else { + panic!("the reopened dataset must still process as a stack"); + }; + let reconstructed = crate::state::dosy_data_fingerprint( + stack, + &restored.data.pseudo_axis.as_ref().unwrap().values, + restored.data.diffusion.as_ref().unwrap(), + ); + assert_ne!(stored, &reconstructed); + assert!(warning.contains(&stored[..12]), "{warning}"); + assert!(warning.contains(&reconstructed[..12]), "{warning}"); + assert!(warning.contains("stored map is being shown"), "{warning}"); + assert!(warning.contains("Rebuild"), "{warning}"); +} + +#[test] +fn missing_selected_blob_explains_the_stack_fallback() { + let mut app = PlotxApp::new(); + let mut ds = Nmr2DDataset::load(synthetic_dosy_2d()); + assert!(ds.build_dosy_map()); + app.doc.datasets.push(Dataset::Nmr2D(Box::new(ds))); + + let path = temp_project("dosy-missing-blob"); + let _ = std::fs::remove_file(&path); + save_project(&app, &path, false).unwrap(); + rewrite_project(&path, |name, _| !name.ends_with("/dosy.bin")); + let loaded = load_project(&path).unwrap(); + let _ = std::fs::remove_file(&path); + + let Dataset::Nmr2D(restored) = &loaded.doc.datasets[0] else { + panic!("expected a 2D NMR dataset"); + }; + assert_eq!(restored.display, PseudoDisplay::DosyMap); + assert!(restored.dosy_map.is_none()); + assert!(restored.figure().title.starts_with("Pseudo-2D stack —")); + let warning = restored + .dosy_provenance_warning + .as_deref() + .expect("the fallback is explained"); + assert!(warning.contains("could not be loaded"), "{warning}"); + assert!(warning.contains("showing the stack"), "{warning}"); + assert!(warning.contains("Build"), "{warning}"); +} + +/// Guards the workspace's `serde_json/float_roundtrip` feature. +/// +/// Without it, serde_json's fast float parser returns a value one ULP away from +/// the one that was written for roughly a tenth of all `f64`s. Every number in a +/// project — gradient rulers, spectral widths, phases, region bounds, fit results +/// — travels through this path, and a drifted input silently invalidates the +/// analysis fingerprints derived from it. The failure is invisible without a bit +/// comparison, so it is pinned here rather than left to be rediscovered. +#[test] +fn project_json_numbers_survive_a_round_trip_bit_for_bit() { + let mut state: u64 = 0x243F_6A88_85A3_08D3; + let mut checked = 0usize; + for _ in 0..50_000 { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + // Spread across the magnitudes PlotX actually persists: gradient + // amplitudes, diffusion coefficients, ppm values, Hz frequencies. + let mantissa = (state >> 11) as f64 / (1u64 << 53) as f64; + for scale in [1e-11, 1e-3, 1.0, 1e3, 1e8] { + let value = mantissa * scale; + let text = serde_json::to_string(&value).expect("serialize"); + let back: f64 = serde_json::from_str(&text).expect("deserialize"); + assert_eq!( + back.to_bits(), + value.to_bits(), + "{value} round-tripped through JSON as {back}" + ); + checked += 1; + } + } + assert_eq!(checked, 250_000); +} + +/// A view snapshot is a picture of a figure the document can still produce. If +/// the stored map did not survive the load it cannot, so replaying the snapshot +/// would leave the saved DOSY contours on the canvas while the load report says +/// the stack is shown. The canvas and the report have to agree. +#[test] +fn a_snapshot_is_not_replayed_when_the_stored_map_could_not_be_restored() { + let mut app = PlotxApp::new(); + let mut ds = Nmr2DDataset::load(synthetic_dosy_2d()); + assert!(ds.build_dosy_map()); + let action = crate::actions::Action::insert_dataset_with_default_canvas( + &app, + Dataset::Nmr2D(Box::new(ds)), + "DOSY".to_owned(), + crate::state::DEFAULT_CANVAS_SIZE_MM, + ); + app.execute_action(action); + + let path = temp_project("dosy-snapshot-bypass"); + let _ = std::fs::remove_file(&path); + // `true` = write view snapshots, which is the configuration this guards. + save_project(&app, &path, true).unwrap(); + + let saved = load_project(&path).unwrap(); + let saved_contours = saved.doc.canvases[0] + .objects + .iter() + .find_map(|object| object.plot()) + .expect("the saved canvas has a plot") + .figure + .contours + .len(); + assert!(saved_contours > 0, "the snapshot must hold DOSY contours"); + + rewrite_project(&path, |name, _| !name.ends_with("dosy.bin")); + let loaded = load_project(&path).unwrap(); + let _ = std::fs::remove_file(&path); + + let restored = loaded.doc.datasets[0].as_nmr2d().unwrap(); + assert!(restored.dosy_map.is_none(), "the map must be gone"); + assert!(restored.missing_selected_map_note().is_some()); + let contours = loaded.doc.canvases[0] + .objects + .iter() + .find_map(|object| object.plot()) + .expect("the canvas still has a plot") + .figure + .contours + .len(); + assert_eq!( + contours, 0, + "the canvas must not keep drawing contours the document can no longer produce" + ); +} + +/// Reopening a project whose selected method has no map must not bake that fact +/// into stored state: switching to the method that *does* have a map has to stop +/// the complaint, or the panel keeps claiming the stack is shown while the map is +/// on screen. +#[test] +fn the_missing_map_complaint_does_not_survive_selecting_a_method_that_has_one() { + let mut app = PlotxApp::new(); + let mut ds = Nmr2DDataset::load(synthetic_dosy_2d()); + let params = IltParams { + lambda: 0.02, + d_min: 1e-11, + d_max: 1e-8, + n_grid: 32, + }; + assert!(ds.build_ilt_map(params)); + // Only the ILT map exists, but the per-column method is what gets saved. + ds.dosy_method = crate::DosyMethod::MonoExp; + ds.display = PseudoDisplay::DosyMap; + app.doc.datasets.push(Dataset::Nmr2D(Box::new(ds))); + + let path = temp_project("dosy-stale-complaint"); + let _ = std::fs::remove_file(&path); + save_project(&app, &path, false).unwrap(); + let mut loaded = load_project(&path).unwrap(); + let _ = std::fs::remove_file(&path); + + let restored = loaded.doc.datasets[0].as_nmr2d().unwrap(); + assert!(restored.ilt_map.is_some(), "the ILT map round-trips"); + assert!(restored.dosy_map.is_none()); + let note = restored + .missing_selected_map_note() + .expect("the selected per-column map is genuinely absent"); + assert!(note.contains("per-column"), "{note}"); + assert!( + restored.dosy_provenance_warning.is_none(), + "a derived condition must not be stored: {:?}", + restored.dosy_provenance_warning + ); + + loaded.set_pseudo_dosy_method(0, crate::DosyMethod::Ilt(params)); + let restored = loaded.doc.datasets[0].as_nmr2d().unwrap(); + assert_eq!( + restored.missing_selected_map_note(), + None, + "the ILT map is present, so nothing may still claim a fallback" + ); + assert!(restored.dosy_provenance_warning.is_none()); + assert!(restored.figure().title.starts_with("DOSY (ILT)")); +} diff --git a/crates/core/src/properties/ilt.rs b/crates/core/src/properties/ilt.rs new file mode 100644 index 0000000..4f26bb6 --- /dev/null +++ b/crates/core/src/properties/ilt.rs @@ -0,0 +1,209 @@ +//! Persistent ILT lambda defaults and read-only result provenance. + +use super::provider::PropertyProvider; +use super::target::require_app_target; +use super::{ + AggregateValue, Applicability, Availability, ComponentKind, DefaultPolicy, EditOp, FloatBounds, + PropertyAccess, PropertyAddress, PropertyDefinition, PropertyError, PropertyId, + PropertyTransaction, PropertyValue, ResolvedProperty, ResolvedSchema, ScopeKind, Tier, + ValueCopies, ValueSchema, definition, +}; +use crate::DosyInvocation; +use crate::settings::{DEFAULT_ILT_LAMBDA, MAX_ILT_LAMBDA, MIN_ILT_LAMBDA}; +use crate::state::{DatasetId, PlotxApp}; + +pub const DEFAULT_LAMBDA: PropertyId = PropertyId("settings.analysis.ilt.lambda"); +pub const RESULT_LAMBDA: PropertyId = PropertyId("dataset.analysis.ilt.result_lambda"); + +const LAMBDA_BOUNDS: FloatBounds = FloatBounds::inclusive(MIN_ILT_LAMBDA, MAX_ILT_LAMBDA); + +pub(crate) const DEFINITIONS: &[PropertyDefinition] = &[ + PropertyDefinition { + id: DEFAULT_LAMBDA, + scope_kind: ScopeKind::App, + value_schema: ValueSchema::Float { + bounds: LAMBDA_BOUNDS, + log: true, + drag_step: Some(0.001), + }, + access: PropertyAccess::ReadWrite, + applicability: Applicability::component(ComponentKind::None), + default_policy: DefaultPolicy::Fixed(PropertyValue::Float(DEFAULT_ILT_LAMBDA)), + tier: Tier::Essential, + copies: ValueCopies::PerTarget, + canonical_label: "Default ILT regularization", + canonical_aliases: &["ILT lambda", "regularization lambda", "DOSY lambda"], + }, + PropertyDefinition { + id: RESULT_LAMBDA, + scope_kind: ScopeKind::Dataset, + value_schema: ValueSchema::Float { + bounds: LAMBDA_BOUNDS, + log: true, + drag_step: None, + }, + access: PropertyAccess::ReadOnly, + applicability: Applicability::component(ComponentKind::None), + default_policy: DefaultPolicy::None, + tier: Tier::Expert, + copies: ValueCopies::PerTarget, + canonical_label: "Stored ILT result lambda", + canonical_aliases: &["ILT provenance", "result lambda", "DOSY provenance"], + }, +]; + +pub(crate) struct IltProvider; + +pub(crate) static PROVIDER: IltProvider = IltProvider; + +impl PropertyProvider for IltProvider { + fn definitions(&self) -> &'static [PropertyDefinition] { + DEFINITIONS + } + + fn read( + &self, + app: &PlotxApp, + address: &PropertyAddress, + ) -> Result { + let definition = property_definition(address.definition)?; + let (value, default_value, availability) = match definition.id { + DEFAULT_LAMBDA => { + require_app_target(&address.target, definition)?; + ( + app.settings.processing.ilt_lambda, + match definition.default_policy { + DefaultPolicy::Fixed(value) => Some(value), + DefaultPolicy::EncodingFactory + | DefaultPolicy::ProcessingFactory + | DefaultPolicy::None => None, + }, + Availability::Editable, + ) + } + RESULT_LAMBDA => ( + result_lambda(app, address, definition)?, + None, + Availability::ReadOnly, + ), + _ => return Err(PropertyError::UnknownProperty(definition.id.to_string())), + }; + Ok(ResolvedProperty { + address: address.clone(), + value: AggregateValue::Uniform(PropertyValue::Float(value)), + default_value, + availability, + schema: ResolvedSchema::Float { + bounds: LAMBDA_BOUNDS, + log: true, + unit: "", + }, + }) + } + + fn edit( + &self, + app: &PlotxApp, + transaction: &mut PropertyTransaction, + address: &PropertyAddress, + operation: EditOp, + ) -> Result<(), PropertyError> { + let definition = property_definition(address.definition)?; + // `PropertyService::plan_edit` already refuses read-only definitions before + // any provider is consulted, so this arm is unreachable today. It still + // reports `ReadOnly` rather than `NotApplicable`, because the service + // classifies `NotApplicable` as a skipped target: were the gate ever to + // move, the wrong variant would turn a refusal into a reported success + // that wrote nothing. + if definition.access == PropertyAccess::ReadOnly { + return Err(PropertyError::ReadOnly(definition.id)); + } + require_app_target(&address.target, definition)?; + let value = match operation { + EditOp::Set(PropertyValue::Float(value)) => checked_lambda(definition.id, value)?, + EditOp::Set(value) => { + return Err(PropertyError::InvalidValue { + property: definition.id, + message: format!("expected a float, got {}", value.kind()), + }); + } + EditOp::Reset => match definition.default_policy { + DefaultPolicy::Fixed(PropertyValue::Float(value)) => { + checked_lambda(definition.id, value)? + } + DefaultPolicy::Fixed(_) + | DefaultPolicy::EncodingFactory + | DefaultPolicy::ProcessingFactory + | DefaultPolicy::None => { + return Err(PropertyError::InvalidValue { + property: definition.id, + message: "the default policy has no float value".to_owned(), + }); + } + }, + EditOp::Step(_) => { + return Err(PropertyError::InvalidValue { + property: definition.id, + message: "this setting has no step gesture".to_owned(), + }); + } + }; + transaction.app_preferences(app).processing.ilt_lambda = value; + Ok(()) + } +} + +fn result_lambda( + app: &PlotxApp, + address: &PropertyAddress, + definition: &'static PropertyDefinition, +) -> Result { + let actual = ComponentKind::of(address.target.component.as_ref()); + if actual != ComponentKind::None { + return Err(PropertyError::ComponentKind { + property: definition.id, + expected: ComponentKind::None.as_str(), + actual: actual.as_str(), + }); + } + let dataset_id = DatasetId::try_from(&address.target.resource).map_err(|_| { + PropertyError::NotApplicable(format!( + "{} belongs to a dataset resource, not {}", + definition.canonical_label, address.target.resource.id + )) + })?; + let dataset = app + .doc + .dataset_by_id(dataset_id) + .ok_or_else(|| PropertyError::UnknownTarget(address.target.resource.id.clone()))?; + let nmr2d = dataset.as_nmr2d().ok_or_else(|| { + PropertyError::NotApplicable( + "Stored ILT result lambda applies to a pseudo-2D DOSY dataset.".to_owned(), + ) + })?; + let provenance = nmr2d.ilt_provenance.as_ref().ok_or_else(|| { + PropertyError::NotApplicable( + "Build an ILT DOSY map before inspecting its stored lambda.".to_owned(), + ) + })?; + match provenance.input { + DosyInvocation::Ilt { params } => Ok(params.lambda), + DosyInvocation::MonoExp { .. } => Err(PropertyError::NotApplicable( + "The stored ILT provenance does not contain an ILT invocation.".to_owned(), + )), + } +} + +fn property_definition(id: PropertyId) -> Result<&'static PropertyDefinition, PropertyError> { + definition(id).ok_or_else(|| PropertyError::UnknownProperty(id.as_str().to_owned())) +} + +fn checked_lambda(property: PropertyId, value: f64) -> Result { + if LAMBDA_BOUNDS.admits(value) { + return Ok(value); + } + Err(PropertyError::InvalidValue { + property, + message: format!("ILT lambda {value} is outside {MIN_ILT_LAMBDA}–{MAX_ILT_LAMBDA}"), + }) +} diff --git a/crates/core/src/properties/ilt_tests.rs b/crates/core/src/properties/ilt_tests.rs new file mode 100644 index 0000000..8425995 --- /dev/null +++ b/crates/core/src/properties/ilt_tests.rs @@ -0,0 +1,149 @@ +use super::*; +use crate::automation::{ResourceRef, TargetRef}; +use crate::properties::ilt; +use crate::settings::{MAX_ILT_LAMBDA, MIN_ILT_LAMBDA, Settings}; +use crate::state::{Dataset, Nmr2DDataset, PlotxApp}; +use crate::{DosyInvocation, DosyResultProvenance, IltParams}; +use num_complex::Complex64; +use plotx_io::{ + AxisSource, DiffusionMeta, Dim, Domain, NmrData2D, PseudoAxis, PseudoKind, QuadMode, +}; +use std::path::PathBuf; + +fn data() -> NmrData2D { + let dim = Dim { + spectral_width_hz: 4_000.0, + observe_freq_mhz: 400.0, + carrier_ppm: 0.0, + nucleus: "1H".to_owned(), + group_delay: 0.0, + }; + NmrData2D { + data: vec![Complex64::new(1.0, 0.0); 16], + rows: 4, + cols: 4, + domain: Domain::Frequency, + direct: dim.clone(), + indirect: dim, + quad: QuadMode::Complex, + indirect_conjugate: false, + experiment: Some("diffusion".to_owned()), + pseudo_axis: Some(PseudoAxis { + name: "g".to_owned(), + kind: PseudoKind::Gradient, + values: vec![0.1, 0.2, 0.3, 0.4], + unit: "T/m".to_owned(), + source: AxisSource::EmbeddedList, + }), + diffusion: Some(DiffusionMeta { + gamma: 2.675_222e8, + delta: 2e-3, + big_delta: 0.1, + tau: 0.0, + shape_factor: 1.0 / 3.0, + }), + nus: None, + source: "ILT property".to_owned(), + } +} + +pub(crate) fn ilt_app(lambda: f64) -> (PlotxApp, TargetRef) { + let mut app = PlotxApp::new_with_settings(Settings::default()); + let mut dataset = Nmr2DDataset::load(data()); + dataset.ilt_provenance = Some(DosyResultProvenance { + algorithm: "ilt_map".to_owned(), + version: 1, + input: DosyInvocation::Ilt { + params: IltParams { + lambda, + ..IltParams::default() + }, + }, + data_fingerprint: "fixture".to_owned(), + }); + let id = dataset.resource_id; + app.doc.datasets.push(Dataset::Nmr2D(Box::new(dataset))); + (app, TargetRef::resource(ResourceRef::from(id))) +} + +fn temp_settings(name: &str) -> PathBuf { + let base = std::env::var_os("CARGO_TARGET_TMPDIR") + .map(PathBuf::from) + .unwrap_or_else(std::env::temp_dir); + base.join(format!("plotx-ilt-property-{name}-{}", std::process::id())) +} + +#[test] +fn ilt_default_catalog_edit_uses_shared_bounds_and_persists() { + let path = temp_settings("roundtrip").with_extension("json"); + let _ = std::fs::remove_file(&path); + let mut app = PlotxApp::new_with_settings(Settings::default()); + let address = PropertyAddress::new(app.app_target(), ilt::DEFAULT_LAMBDA); + let resolved = app.resolve_property(&address).expect("default reads"); + assert_eq!( + resolved.schema, + ResolvedSchema::Float { + bounds: FloatBounds::inclusive(MIN_ILT_LAMBDA, MAX_ILT_LAMBDA), + log: true, + unit: "", + } + ); + + let commit = app + .plan_property_write( + ilt::DEFAULT_LAMBDA, + std::slice::from_ref(&app.app_target()), + &PropertyValue::Float(0.4), + ) + .expect("legal lambda plans"); + app.commit_property_with_settings_writer(commit, |settings| { + crate::settings::save_to_path(&path, settings) + }); + let loaded = crate::settings::load_from_paths(&path, None); + let _ = std::fs::remove_file(&path); + assert_eq!(loaded.processing.ilt_lambda, 0.4); +} + +#[test] +fn stored_ilt_lambda_reads_without_a_transaction_and_all_edits_stop_at_read_only_gate() { + let (app, target) = ilt_app(0.07); + let address = PropertyAddress::new(target.clone(), ilt::RESULT_LAMBDA); + let resolved = app + .resolve_property(&address) + .expect("reading uses the provider directly, without a transaction"); + assert_eq!( + resolved.value, + AggregateValue::Uniform(PropertyValue::Float(0.07)) + ); + assert_eq!(resolved.availability, Availability::ReadOnly); + assert!(resolved.default_value.is_none()); + // Assert on the address the provider echoed back, not on the `TargetRef` this + // fixture built: the latter cannot fail for any change to the provider. + assert!(resolved.address.target.component.is_none()); + assert_eq!( + resolved.address.target.resource.kind.0, + crate::automation::KIND_DATASET + ); + assert_eq!(resolved.address.definition, ilt::RESULT_LAMBDA); + + let errors = [ + app.plan_property_write( + ilt::RESULT_LAMBDA, + std::slice::from_ref(&target), + &PropertyValue::Float(0.2), + ) + .expect_err("Set must be rejected"), + app.plan_property_reset(ilt::RESULT_LAMBDA, std::slice::from_ref(&target)) + .expect_err("Reset must be rejected"), + app.plan_property_step( + ilt::RESULT_LAMBDA, + std::slice::from_ref(&target), + PropertyStep::Raise, + ) + .expect_err("Step must be rejected"), + ]; + for error in errors { + assert_eq!(error, PropertyError::ReadOnly(ilt::RESULT_LAMBDA)); + assert!(error.to_string().contains("read-only")); + } +} diff --git a/crates/core/src/properties/mod.rs b/crates/core/src/properties/mod.rs index bbca0dc..4255beb 100644 --- a/crates/core/src/properties/mod.rs +++ b/crates/core/src/properties/mod.rs @@ -11,6 +11,7 @@ pub mod apodization; pub mod contour; pub mod export_dpi; +pub mod ilt; pub mod line; mod model; mod provider; @@ -42,6 +43,9 @@ pub(crate) static GROUPS: &[PropertyProviderGroup] = &[ PropertyProviderGroup { provider: &export_dpi::PROVIDER, }, + PropertyProviderGroup { + provider: &ilt::PROVIDER, + }, PropertyProviderGroup { provider: &line::PROVIDER, }, @@ -168,3 +172,7 @@ mod apodization_tests; #[cfg(test)] #[path = "export_dpi_tests.rs"] mod export_dpi_tests; + +#[cfg(test)] +#[path = "ilt_tests.rs"] +pub(crate) mod ilt_tests; diff --git a/crates/core/src/settings/model.rs b/crates/core/src/settings/model.rs index 73c9979..732f993 100644 --- a/crates/core/src/settings/model.rs +++ b/crates/core/src/settings/model.rs @@ -65,6 +65,18 @@ pub struct GeneralSettings { pub const MAX_PROJECT_BACKUP_GENERATIONS: u8 = 5; pub const MIN_EXPORT_DPI: u16 = 72; pub const MAX_EXPORT_DPI: u16 = 1200; +pub const MIN_ILT_LAMBDA: f64 = 1e-6; +pub const MAX_ILT_LAMBDA: f64 = 1e3; +pub const DEFAULT_ILT_LAMBDA: f64 = 1e-2; +/// Diffusion-grid limits for the ILT inversion. These are not preferences and +/// have no catalog property; they exist because the inversion solves a dense +/// `n_grid x n_grid` system, so a grid size read back from a project file sizes +/// a quadratic allocation. Project content is external input and must not be +/// able to make that allocation unbounded. +pub const MIN_ILT_GRID: usize = 16; +pub const MAX_ILT_GRID: usize = 512; +pub const MIN_ILT_DIFFUSION: f64 = 1e-13; +pub const MAX_ILT_DIFFUSION: f64 = 1e-6; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] pub struct AppearanceSettings { @@ -151,8 +163,11 @@ impl ThemeMode { } } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] -pub struct ProcessingDefaults {} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ProcessingDefaults { + #[serde(default = "default_ilt_lambda")] + pub ilt_lambda: f64, +} #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ExportDefaults { @@ -271,6 +286,14 @@ impl Default for ExportDefaults { } } +impl Default for ProcessingDefaults { + fn default() -> Self { + Self { + ilt_lambda: default_ilt_lambda(), + } + } +} + fn current_schema_version() -> u32 { super::SETTINGS_SCHEMA_VERSION } @@ -287,6 +310,10 @@ fn default_project_backup_generations() -> u8 { 1 } +fn default_ilt_lambda() -> f64 { + DEFAULT_ILT_LAMBDA +} + fn default_export_dpi() -> u16 { crate::export::DEFAULT_BITMAP_DPI } diff --git a/crates/core/src/state/app_impl.rs b/crates/core/src/state/app_impl.rs index f3e758f..862843c 100644 --- a/crates/core/src/state/app_impl.rs +++ b/crates/core/src/state/app_impl.rs @@ -60,6 +60,10 @@ impl PlotxApp { canvas_accent: settings.appearance.canvas_accent, ui: UiState { snap_enabled: settings.general.snap_enabled, + // `ilt_params` deliberately starts empty rather than mirroring + // the preference here: a copy taken at construction would not + // follow later preference edits, and resolving at build time + // is what keeps the default reachable as a lifecycle stage. ..Default::default() }, project_backup_generations: settings diff --git a/crates/core/src/state/app_impl_analysis.rs b/crates/core/src/state/app_impl_analysis.rs index b486f49..a202343 100644 --- a/crates/core/src/state/app_impl_analysis.rs +++ b/crates/core/src/state/app_impl_analysis.rs @@ -1,84 +1,6 @@ use super::*; impl PlotxApp { - /// Create an empty editable data table from scratch: a small starter grid, - /// placed as a board sheet frame (right of the page grid), selected, with its - /// editable sheet window opened for immediate row/column authoring. - pub fn new_table_dataset(&mut self) { - let (mut x_schema, x) = - materialized_float_column("x", "", [Some(0.0), Some(1.0), Some(2.0)]); - x_schema.role = plotx_data::SemanticRole::Custom("space.nmrtist.plotx.axis.x".into()); - let x_binding = x_schema.id; - let (y_schema, y) = materialized_float_column("y", "", [Some(0.0), Some(0.0), Some(0.0)]); - let series = TableSeriesBinding { - value_column: y_schema.id, - uncertainty_column: None, - fit: None, - }; - let sheet_index = self - .doc - .datasets - .iter() - .filter(|d| matches!(d, Dataset::Table(_))) - .count(); - let mut tds = TableDataset::from_materialized( - vec![(x_schema, x), (y_schema, y)], - Vec::new(), - Some(x_binding), - vec![series], - "plotx.table.new.v1", - ) - .expect("the fixed starter table is valid"); - tds.name = Some(format!("Table {}", sheet_index + 1)); - tds.board_pos = next_sheet_pos_after_new_canvas(self); - self.doc.datasets.push(Dataset::Table(Box::new(tds))); - let di = self.doc.datasets.len() - 1; - self.focus_single(di); - self.session.view = PrimaryView::Data; - self.session.ui.frame_selection = vec![FrameRef::Sheet(di)]; - self.session.ui.sheet_open = Some(di); - self.doc.dirty = true; - self.session.status = "Created a data table.".to_owned(); - } - - pub fn insert_typed_table_dataset(&mut self, mut dataset: TableDataset, name: String) -> usize { - dataset.name = Some(name.clone()); - self.insert_table_dataset(dataset, name) - } - - pub fn import_table_dataset_typed( - &mut self, - name: String, - import_sources: Vec, - typed_state: TypedTableState, - x_binding: Option, - series_bindings: Vec, - ) -> usize { - let mut dataset = TableDataset::from_typed(typed_state); - dataset.name = Some(name.clone()); - dataset.import_sources = import_sources; - dataset.x_binding = x_binding; - dataset.series_bindings = series_bindings; - self.insert_table_dataset(dataset, name) - } - - fn insert_table_dataset(&mut self, mut dataset: TableDataset, name: String) -> usize { - dataset.board_pos = next_sheet_pos_after_new_canvas(self); - let dataset_index = self.doc.datasets.len(); - let action = Action::insert_dataset_with_default_canvas( - self, - Dataset::Table(Box::new(dataset)), - format!("Canvas {} - {name}", self.doc.canvases.len() + 1), - DEFAULT_CANVAS_SIZE_MM, - ); - self.execute_action(action); - self.focus_single(dataset_index); - self.session.view = PrimaryView::Data; - self.session.ui.frame_selection = vec![FrameRef::Sheet(dataset_index)]; - self.session.ui.sheet_open = Some(dataset_index); - dataset_index - } - /// Apply a user-entered non-uniform-sampling schedule to a 2D dataset and /// re-run the reconstruction. Returns the validation error (if any) so the /// caller can surface it next to the input field. @@ -120,9 +42,13 @@ impl PlotxApp { "This dataset has no diffusion parameters (not a DOSY array).".into(); return; } - if d2.build_dosy_map() { + let any = d2.build_dosy_map(); + // The builder installs the map and its provenance whether or not any + // column fitted, and both are persisted state. Dirtying only the populated + // branch would let an empty result be lost on close with no save prompt. + self.doc.dirty = true; + if any { self.rebuild_canvases_for(dataset); - self.doc.dirty = true; self.session.status = "Built DOSY map.".into(); } else { self.session.status = @@ -131,9 +57,17 @@ impl PlotxApp { } /// Build the regularized ILT/CONTIN DOSY contour (diffusion datasets with a - /// gradient ruler). Uses the current `ui.ilt_params`. + /// gradient ruler), resolving the parameters through the value lifecycle. pub fn build_ilt_map_for(&mut self, dataset: usize) { - let params = self.session.ui.ilt_params; + self.build_ilt_map_for_with_params(dataset, self.explicit_ilt_input_for(dataset)); + } + + pub fn build_ilt_map_for_with_params(&mut self, dataset: usize, explicit: Option) { + let params = self.resolve_ilt_params_for(dataset, explicit); + if let Err(message) = validate_ilt_params(params) { + self.session.status = message; + return; + } let Some(d2) = self .doc .datasets @@ -160,107 +94,77 @@ impl PlotxApp { .into(); return; } - if d2.build_ilt_map(params) { + let any = d2.build_ilt_map(params); + // See `build_dosy_map_for`: the method switch, the map and its provenance + // land whether or not the inversion produced anything. + self.doc.dirty = true; + if any { self.rebuild_canvases_for(dataset); - self.doc.dirty = true; self.session.status = "Built ILT DOSY map.".into(); } else { - self.session.status = - "ILT DOSY map is empty: no columns above the noise threshold.".into(); + self.session.status = format!( + "ILT DOSY map is empty with λ = {} (legal range {}–{}): no columns are above the \ + noise threshold.", + params.lambda, + crate::settings::MIN_ILT_LAMBDA, + crate::settings::MAX_ILT_LAMBDA + ); } } - /// Build a fresh series table from a pseudo-2D dataset's regions: one column - /// per region (x = the raw indirect ruler, y = the region reduced by its - /// metric). `None` when the dataset is not a series or has no regions. - fn build_region_table(&self, dataset: usize) -> Option { - let source_resource = self.doc.datasets.get(dataset)?.resource_id().to_string(); - let d2 = self.doc.datasets.get(dataset).and_then(Dataset::as_nmr2d)?; - let (Processed2D::Stack(stack), Some(axis)) = (&d2.processed, &d2.data.pseudo_axis) else { - return None; - }; - if d2.regions.is_empty() { - return None; + /// Resolve the invocation snapshot in lifecycle priority order: explicit + /// input, this dataset's last result provenance, then the app default. + /// + /// The result is not trusted: the provenance stage reads a project file, so + /// every caller must run it through [`validate_ilt_params`] before handing it + /// to the inversion. + pub fn resolve_ilt_params_for(&self, dataset: usize, explicit: Option) -> IltParams { + if let Some(params) = explicit { + return params; } - let x_label = match axis.kind { - plotx_io::PseudoKind::Gradient => "Gradient".to_owned(), - plotx_io::PseudoKind::Delay => "Delay".to_owned(), - plotx_io::PseudoKind::Generic if !axis.name.is_empty() => axis.name.clone(), - plotx_io::PseudoKind::Generic => "Ruler".to_owned(), - }; - let (mut x_schema, x_values) = - materialized_float_column(x_label, &axis.unit, axis.values.iter().copied().map(Some)); - x_schema.role = plotx_data::SemanticRole::Custom("space.nmrtist.plotx.axis.x".into()); - let x_binding = x_schema.id; - let mut columns = vec![(x_schema, x_values)]; - let mut series_bindings = Vec::with_capacity(d2.regions.len()); - let mut windows = Vec::with_capacity(d2.regions.len()); - for region in &d2.regions { - let op = region.metric.unwrap_or(d2.region_metric).into(); - let series = extract_region_series(stack, axis, (region.lo, region.hi), op); - let (schema, values) = - materialized_float_column(region.column_name(), "", series.y.into_iter().map(Some)); - series_bindings.push(TableSeriesBinding { - value_column: schema.id, - uncertainty_column: None, - fit: None, - }); - columns.push((schema, values)); - windows.push((region.lo_min(), region.hi_max())); + if let Some(params) = self + .doc + .datasets + .get(dataset) + .and_then(Dataset::as_nmr2d) + .and_then(|dataset| dataset.ilt_provenance.as_ref()) + .and_then(|provenance| match &provenance.input { + DosyInvocation::Ilt { params } => Some(*params), + DosyInvocation::MonoExp { .. } => None, + }) + { + return params; + } + // The grid fields come from the algorithm default, never from whatever the + // panel last held: carrying them over would hand this dataset the grid a + // different dataset was inverted on, which the user never chose for it. + IltParams { + lambda: self.settings.processing.ilt_lambda, + ..IltParams::default() } - let mut table = TableDataset::from_materialized( - columns, - Vec::new(), - Some(x_binding), - series_bindings, - "plotx.analysis.region-table.v1", - ) - .ok()?; - table.meta.diffusion = d2 - .data - .diffusion - .as_ref() - .map(DiffusionConstants::from_meta); - table.provenance = Some(TableProvenance { - source_resource, - regions: windows, - metric: match d2.region_metric { - RegionMetric::Area => TableMetric::Integral, - _ => TableMetric::PeakHeight, - }, - }); - Some(table) } - /// The `Dataset::Table` linked to `source` (its provenance points back), if any. - pub fn region_table_index(&self, source: usize) -> Option { - let source_resource = self.doc.datasets.get(source)?.resource_id().to_string(); - self.doc.datasets.iter().position(|d| { - d.as_table() - .and_then(|t| t.provenance.as_ref()) - .map(|p| p.source_resource == source_resource) - .unwrap_or(false) - }) + /// The parameters the user explicitly entered *for this dataset*, if any. + /// + /// An explicit input belongs to one target. Scoping it here is what keeps the + /// other two lifecycle stages reachable: every entry point asks this question + /// first and gets `None` whenever the user has not overridden anything for + /// this dataset, whether or not any panel has been painted. + pub fn explicit_ilt_input_for(&self, dataset: usize) -> Option { + let id = self.doc.datasets.get(dataset)?.resource_id(); + if self.session.ui.ilt_params_dataset != Some(id) { + return None; + } + self.session.ui.ilt_params } - /// Re-derive the linked series table from `source`'s regions in place. A no-op - /// when no table is linked yet (creation is explicit) or the regions cleared. - pub fn sync_region_table(&mut self, source: usize) { - let Some(tj) = self.region_table_index(source) else { - return; - }; - let Some(table) = self.build_region_table(source) else { + /// Record an explicit ILT input for one dataset, replacing any previous one. + pub fn set_explicit_ilt_input(&mut self, dataset: usize, params: IltParams) { + let Some(id) = self.doc.datasets.get(dataset).map(Dataset::resource_id) else { return; }; - if let Some(t) = self.doc.datasets[tj].as_table_mut() { - t.typed_state = table.typed_state; - t.x_binding = table.x_binding; - t.series_bindings = table.series_bindings; - t.provenance = table.provenance; - t.meta = table.meta; - t.curve_fit_analyses.clear(); - } - self.rebuild_canvases_for(tj); + self.session.ui.ilt_params = Some(params); + self.session.ui.ilt_params_dataset = Some(id); } /// Worker behind `SetRegions`: install the regions and re-derive the linked @@ -302,254 +206,44 @@ impl PlotxApp { )); } - /// Create the live series table for a dataset's regions. - pub fn create_region_table(&mut self, dataset: usize) { - if self.region_table_index(dataset).is_some() { - self.session.status = "This dataset already has a linked series table.".into(); - return; - } - let Some(table) = self.build_region_table(dataset) else { - self.session.status = "Add at least one region before creating a table.".into(); - return; - }; - let count = table.series_bindings.len(); - let mut tds = table; - tds.lineage = Some(DatasetLineage::new( - DerivationKind::LiveRegionTable, - [self.doc.datasets[dataset].resource_id()], - )); - tds.name = Some(format!( - "{} — regions", - self.doc.datasets[dataset].display_name() - )); - tds.board_pos = next_sheet_pos_after_new_canvas(self); - let ds = Dataset::Table(Box::new(tds)); - - let action = Action::insert_dataset_with_default_canvas( - self, - ds, - format!("Canvas {} — Data table", self.doc.canvases.len() + 1), - DEFAULT_CANVAS_SIZE_MM, - ); - self.execute_action(action); - self.session.status = format!("Created a live series table with {count} region(s)."); - } - - /// Place an independent, unlinked snapshot of the current region values as a - /// new table (no provenance), so later region edits leave it untouched. - pub fn freeze_region_table(&mut self, dataset: usize) { - let Some(mut tds) = self.build_region_table(dataset) else { - self.session.status = "Add at least one region before freezing a copy.".into(); - return; - }; - tds.provenance = None; - tds.lineage = Some(DatasetLineage::new( - DerivationKind::FrozenRegionTable, - [self.doc.datasets[dataset].resource_id()], - )); - tds.name = Some(format!( - "{} — regions (frozen)", - self.doc.datasets[dataset].display_name() - )); - tds.board_pos = crate::state::next_sheet_board_pos(self); - let ds = Dataset::Table(Box::new(tds)); - let action = Action::insert_dataset_with_default_canvas( - self, - ds, - format!("Canvas {} — Data table", self.doc.canvases.len() + 1), - DEFAULT_CANVAS_SIZE_MM, - ); - self.execute_action(action); - self.session.status = "Froze a static copy of the series table.".into(); - } - - /// Fit one or more table responses through a single declarative analysis. - /// Each selected column receives a reference into the shared snapshot. - pub fn fit_table_columns( - &mut self, - dataset: usize, - model_id: &str, - all_columns: bool, - column: plotx_data::ColumnId, - global_parameters: bool, - options: plotx_analysis::fit_model::FitOptions, - ) { - let model = match resolve_table_fit_model(model_id, global_parameters) { - Ok(model) => model, - Err(status) => { - self.session.status = status; - return; - } - }; - let Some(t) = self.doc.datasets.get(dataset).and_then(Dataset::as_table) else { - self.session.status = "Curve fitting needs a data table.".into(); - return; - }; - let Some(column) = t - .series_bindings - .iter() - .position(|binding| binding.value_column == column) - else { - self.session.status = "The selected fit column is no longer available.".into(); - return; - }; - let table = match t.fit_analysis_view() { - Ok(table) => table, - Err(status) => { - self.session.status = status; - return; - } - }; - let TableFitInputs { - model, - input_name, - response_name, - targets, - datasets: fit_datasets, - bindings, - } = match build_table_fit_inputs(&table, model, all_columns, column) { - Ok(inputs) => inputs, - Err(status) => { - self.session.status = status; - return; - } - }; - let before_refs: Vec> = t - .series_bindings - .iter() - .map(|binding| binding.fit.clone()) - .collect(); - let mut after_refs = before_refs.clone(); - let before_analyses = t.curve_fit_analyses.clone(); - let mut after_analyses = before_analyses.clone(); - let model_name = model.name.clone(); - let result = match plotx_analysis::fit_model::fit_model(model, fit_datasets, &[], options) { - Ok(result) => result, - Err(error) => { - self.session.status = format!("Curve fit failed: {error}"); - return; - } - }; - let selection = match fit_selection::snapshot(&table, &bindings, &result) { - Ok(selection) => selection, - Err(error) => { - self.session.status = format!("Could not record the fit selection: {error}"); - return; - } - }; - let plot_samples = match table_fit_plot_samples(&result, &input_name, &table, &targets) { - Ok(samples) => samples, - Err(error) => { - self.session.status = format!("Could not evaluate the fitted curve: {error}"); - return; + /// Switch how a pseudo-2D dataset is displayed and rebuild its figure. + pub fn set_pseudo_display(&mut self, dataset: usize, display: PseudoDisplay) { + let mut changed = false; + if let Some(d2) = self + .doc + .datasets + .get_mut(dataset) + .and_then(Dataset::as_nmr2d_mut) + { + changed = d2.display != display; + if changed { + d2.display = display; } - }; - let analysis_id = t.next_curve_fit_id(); - let instance_ids: Vec = bindings - .iter() - .map(|binding| binding.dataset_id.clone()) - .collect(); - after_analyses.push(StoredCurveFitAnalysis { - id: analysis_id, - name: model_name.clone(), - bindings, - result, - selection: Some(selection), - plot_samples, - }); - for (&index, instance_id) in targets.iter().zip(instance_ids) { - after_refs[index] = Some(CurveFitReference { - analysis_id, - instance_id, - response: response_name.clone(), - }); } - // Refitting replaces column references, so drop superseded snapshots — - // each embeds a full copy of the fitted data and would otherwise grow - // the project and the diagnostics list on every refit. - after_analyses.retain(|analysis| { - after_refs - .iter() - .flatten() - .any(|reference| reference.analysis_id == analysis.id) - }); - self.execute_action(Action::set_curve_fit_analyses( - self.doc.datasets[dataset].resource_id(), - (before_refs, before_analyses), - (after_refs, after_analyses), - )); - self.session.status = format!("Fitted {} curve(s) with {model_name}.", targets.len()); - } - - /// Validate and evaluate the initial curve without running optimisation. - pub fn preview_table_fit( - &mut self, - dataset: usize, - model_id: &str, - all_columns: bool, - column: plotx_data::ColumnId, - global_parameters: bool, - options: plotx_analysis::fit_model::FitOptions, - ) { - let model = match resolve_table_fit_model(model_id, global_parameters) { - Ok(model) => model, - Err(status) => { - self.session.status = status; - return; - } - }; - let Some(dataset) = self.doc.datasets.get(dataset).and_then(Dataset::as_table) else { - self.session.status = "Initial preview needs a data table.".into(); - return; - }; - let Some(column) = dataset - .series_bindings - .iter() - .position(|binding| binding.value_column == column) - else { - self.session.status = "The selected fit column is no longer available.".into(); - return; - }; - let table = match dataset.fit_analysis_view() { - Ok(table) => table, - Err(status) => { - self.session.status = status; - return; - } - }; - let inputs = match build_table_fit_inputs(&table, model, all_columns, column) { - Ok(inputs) => inputs, - Err(status) => { - self.session.status = status; - return; - } - }; - match plotx_analysis::fit_model::preview_initial_model( - inputs.model, - inputs.datasets, - &[], - options, - ) { - Ok(preview) => { - self.session.status = format!( - "Initial curve is valid for {} point(s).", - preview.points.len() - ) - } - Err(error) => self.session.status = format!("Initial curve is invalid: {error}"), + if changed { + self.rebuild_canvases_for(dataset); + self.doc.dirty = true; } } - /// Switch how a pseudo-2D dataset is displayed and rebuild its figure. - pub fn set_pseudo_display(&mut self, dataset: usize, display: PseudoDisplay) { + /// Select the DOSY result family through core state, so the persisted method + /// cannot be changed without the document being marked dirty. + pub fn set_pseudo_dosy_method(&mut self, dataset: usize, method: DosyMethod) { + let mut changed = false; if let Some(d2) = self .doc .datasets .get_mut(dataset) .and_then(Dataset::as_nmr2d_mut) { - d2.display = display; + changed = d2.dosy_method != method; + if changed { + d2.dosy_method = method; + } + } + if changed { self.rebuild_canvases_for(dataset); + self.doc.dirty = true; } } @@ -581,6 +275,53 @@ impl PlotxApp { } } +/// Reject ILT parameters the inversion cannot safely run on. +/// +/// One stage of the value lifecycle reads a project file, so these values are +/// external input rather than something a widget has already constrained. The +/// grid size in particular sizes a dense `n_grid x n_grid` system inside the +/// inversion, so an unchecked value read back from a malformed project would +/// size an unbounded allocation rather than produce an error. Each message names +/// both the value that was set and the boundary it missed. +pub fn validate_ilt_params(params: IltParams) -> Result<(), String> { + use crate::settings::{ + MAX_ILT_DIFFUSION, MAX_ILT_GRID, MAX_ILT_LAMBDA, MIN_ILT_DIFFUSION, MIN_ILT_GRID, + MIN_ILT_LAMBDA, + }; + if !params.lambda.is_finite() || !(MIN_ILT_LAMBDA..=MAX_ILT_LAMBDA).contains(¶ms.lambda) { + return Err(format!( + "ILT lambda {} is outside {MIN_ILT_LAMBDA}–{MAX_ILT_LAMBDA}; choose a value within \ + that range.", + params.lambda + )); + } + if !(MIN_ILT_GRID..=MAX_ILT_GRID).contains(¶ms.n_grid) { + return Err(format!( + "ILT grid size {} is outside {MIN_ILT_GRID}–{MAX_ILT_GRID}; choose a value within \ + that range.", + params.n_grid + )); + } + // Diffusion coefficients span decades, so report them in scientific notation: + // `{}` renders 1e-11 as a run of zeros nobody can read back against a bound. + for (label, value) in [("D min", params.d_min), ("D max", params.d_max)] { + if !value.is_finite() || !(MIN_ILT_DIFFUSION..=MAX_ILT_DIFFUSION).contains(&value) { + return Err(format!( + "ILT {label} {value:e} is outside {MIN_ILT_DIFFUSION:e}–{MAX_ILT_DIFFUSION:e}; \ + choose a value within that range." + )); + } + } + if params.d_min >= params.d_max { + return Err(format!( + "ILT D min {:e} must be below D max {:e}; the diffusion grid would otherwise be empty \ + or reversed.", + params.d_min, params.d_max + )); + } + Ok(()) +} + /// Everything the table fit/preview workflow derives from a model and a table. pub(super) struct TableFitInputs { pub(super) model: plotx_analysis::fit_model::FitModelDefinition, @@ -598,7 +339,7 @@ struct ResolvedTableConstants { /// Resolve a model id against the builtins and the on-disk library, applying /// the shared-parameters override. `Err` is the user-facing status message. -fn resolve_table_fit_model( +pub(super) fn resolve_table_fit_model( model_id: &str, global_parameters: bool, ) -> Result { @@ -745,7 +486,7 @@ fn resolve_table_constants( Ok(ResolvedTableConstants { values, bindings }) } -fn table_fit_plot_samples( +pub(super) fn table_fit_plot_samples( result: &plotx_analysis::fit_model::FitResult, input_name: &str, table: &super::table_fit::FitAnalysisTable, diff --git a/crates/core/src/state/app_impl_analysis_tables.rs b/crates/core/src/state/app_impl_analysis_tables.rs new file mode 100644 index 0000000..6fde379 --- /dev/null +++ b/crates/core/src/state/app_impl_analysis_tables.rs @@ -0,0 +1,416 @@ +use super::app_impl_analysis::{ + TableFitInputs, build_table_fit_inputs, next_sheet_pos_after_new_canvas, + resolve_table_fit_model, table_fit_plot_samples, +}; +use super::*; + +impl PlotxApp { + /// Create an empty editable data table from scratch: a small starter grid, + /// placed as a board sheet frame (right of the page grid), selected, with its + /// editable sheet window opened for immediate row/column authoring. + pub fn new_table_dataset(&mut self) { + let (mut x_schema, x) = + materialized_float_column("x", "", [Some(0.0), Some(1.0), Some(2.0)]); + x_schema.role = plotx_data::SemanticRole::Custom("space.nmrtist.plotx.axis.x".into()); + let x_binding = x_schema.id; + let (y_schema, y) = materialized_float_column("y", "", [Some(0.0), Some(0.0), Some(0.0)]); + let series = TableSeriesBinding { + value_column: y_schema.id, + uncertainty_column: None, + fit: None, + }; + let sheet_index = self + .doc + .datasets + .iter() + .filter(|d| matches!(d, Dataset::Table(_))) + .count(); + let mut tds = TableDataset::from_materialized( + vec![(x_schema, x), (y_schema, y)], + Vec::new(), + Some(x_binding), + vec![series], + "plotx.table.new.v1", + ) + .expect("the fixed starter table is valid"); + tds.name = Some(format!("Table {}", sheet_index + 1)); + tds.board_pos = next_sheet_pos_after_new_canvas(self); + self.doc.datasets.push(Dataset::Table(Box::new(tds))); + let di = self.doc.datasets.len() - 1; + self.focus_single(di); + self.session.view = PrimaryView::Data; + self.session.ui.frame_selection = vec![FrameRef::Sheet(di)]; + self.session.ui.sheet_open = Some(di); + self.doc.dirty = true; + self.session.status = "Created a data table.".to_owned(); + } + + pub fn insert_typed_table_dataset(&mut self, mut dataset: TableDataset, name: String) -> usize { + dataset.name = Some(name.clone()); + self.insert_table_dataset(dataset, name) + } + + pub fn import_table_dataset_typed( + &mut self, + name: String, + import_sources: Vec, + typed_state: TypedTableState, + x_binding: Option, + series_bindings: Vec, + ) -> usize { + let mut dataset = TableDataset::from_typed(typed_state); + dataset.name = Some(name.clone()); + dataset.import_sources = import_sources; + dataset.x_binding = x_binding; + dataset.series_bindings = series_bindings; + self.insert_table_dataset(dataset, name) + } + + fn insert_table_dataset(&mut self, mut dataset: TableDataset, name: String) -> usize { + dataset.board_pos = next_sheet_pos_after_new_canvas(self); + let dataset_index = self.doc.datasets.len(); + let action = Action::insert_dataset_with_default_canvas( + self, + Dataset::Table(Box::new(dataset)), + format!("Canvas {} - {name}", self.doc.canvases.len() + 1), + DEFAULT_CANVAS_SIZE_MM, + ); + self.execute_action(action); + self.focus_single(dataset_index); + self.session.view = PrimaryView::Data; + self.session.ui.frame_selection = vec![FrameRef::Sheet(dataset_index)]; + self.session.ui.sheet_open = Some(dataset_index); + dataset_index + } + + /// Build a fresh series table from a pseudo-2D dataset's regions: one column + /// per region (x = the raw indirect ruler, y = the region reduced by its + /// metric). `None` when the dataset is not a series or has no regions. + fn build_region_table(&self, dataset: usize) -> Option { + let source_resource = self.doc.datasets.get(dataset)?.resource_id().to_string(); + let d2 = self.doc.datasets.get(dataset).and_then(Dataset::as_nmr2d)?; + let (Processed2D::Stack(stack), Some(axis)) = (&d2.processed, &d2.data.pseudo_axis) else { + return None; + }; + if d2.regions.is_empty() { + return None; + } + let x_label = match axis.kind { + plotx_io::PseudoKind::Gradient => "Gradient".to_owned(), + plotx_io::PseudoKind::Delay => "Delay".to_owned(), + plotx_io::PseudoKind::Generic if !axis.name.is_empty() => axis.name.clone(), + plotx_io::PseudoKind::Generic => "Ruler".to_owned(), + }; + let (mut x_schema, x_values) = + materialized_float_column(x_label, &axis.unit, axis.values.iter().copied().map(Some)); + x_schema.role = plotx_data::SemanticRole::Custom("space.nmrtist.plotx.axis.x".into()); + let x_binding = x_schema.id; + let mut columns = vec![(x_schema, x_values)]; + let mut series_bindings = Vec::with_capacity(d2.regions.len()); + let mut windows = Vec::with_capacity(d2.regions.len()); + for region in &d2.regions { + let op = region.metric.unwrap_or(d2.region_metric).into(); + let series = extract_region_series(stack, axis, (region.lo, region.hi), op); + let (schema, values) = + materialized_float_column(region.column_name(), "", series.y.into_iter().map(Some)); + series_bindings.push(TableSeriesBinding { + value_column: schema.id, + uncertainty_column: None, + fit: None, + }); + columns.push((schema, values)); + windows.push((region.lo_min(), region.hi_max())); + } + let mut table = TableDataset::from_materialized( + columns, + Vec::new(), + Some(x_binding), + series_bindings, + "plotx.analysis.region-table.v1", + ) + .ok()?; + table.meta.diffusion = d2 + .data + .diffusion + .as_ref() + .map(DiffusionConstants::from_meta); + table.provenance = Some(TableProvenance { + source_resource, + regions: windows, + metric: match d2.region_metric { + RegionMetric::Area => TableMetric::Integral, + _ => TableMetric::PeakHeight, + }, + }); + Some(table) + } + + /// The `Dataset::Table` linked to `source` (its provenance points back), if any. + pub fn region_table_index(&self, source: usize) -> Option { + let source_resource = self.doc.datasets.get(source)?.resource_id().to_string(); + self.doc.datasets.iter().position(|d| { + d.as_table() + .and_then(|t| t.provenance.as_ref()) + .map(|p| p.source_resource == source_resource) + .unwrap_or(false) + }) + } + + /// Re-derive the linked series table from `source`'s regions in place. A no-op + /// when no table is linked yet (creation is explicit) or the regions cleared. + pub fn sync_region_table(&mut self, source: usize) { + let Some(tj) = self.region_table_index(source) else { + return; + }; + let Some(table) = self.build_region_table(source) else { + return; + }; + if let Some(t) = self.doc.datasets[tj].as_table_mut() { + t.typed_state = table.typed_state; + t.x_binding = table.x_binding; + t.series_bindings = table.series_bindings; + t.provenance = table.provenance; + t.meta = table.meta; + t.curve_fit_analyses.clear(); + } + self.rebuild_canvases_for(tj); + } + + /// Create the live series table for a dataset's regions. + pub fn create_region_table(&mut self, dataset: usize) { + if self.region_table_index(dataset).is_some() { + self.session.status = "This dataset already has a linked series table.".into(); + return; + } + let Some(table) = self.build_region_table(dataset) else { + self.session.status = "Add at least one region before creating a table.".into(); + return; + }; + let count = table.series_bindings.len(); + let mut tds = table; + tds.lineage = Some(DatasetLineage::new( + DerivationKind::LiveRegionTable, + [self.doc.datasets[dataset].resource_id()], + )); + tds.name = Some(format!( + "{} — regions", + self.doc.datasets[dataset].display_name() + )); + tds.board_pos = next_sheet_pos_after_new_canvas(self); + let ds = Dataset::Table(Box::new(tds)); + + let action = Action::insert_dataset_with_default_canvas( + self, + ds, + format!("Canvas {} — Data table", self.doc.canvases.len() + 1), + DEFAULT_CANVAS_SIZE_MM, + ); + self.execute_action(action); + self.session.status = format!("Created a live series table with {count} region(s)."); + } + + /// Place an independent, unlinked snapshot of the current region values as a + /// new table (no provenance), so later region edits leave it untouched. + pub fn freeze_region_table(&mut self, dataset: usize) { + let Some(mut tds) = self.build_region_table(dataset) else { + self.session.status = "Add at least one region before freezing a copy.".into(); + return; + }; + tds.provenance = None; + tds.lineage = Some(DatasetLineage::new( + DerivationKind::FrozenRegionTable, + [self.doc.datasets[dataset].resource_id()], + )); + tds.name = Some(format!( + "{} — regions (frozen)", + self.doc.datasets[dataset].display_name() + )); + tds.board_pos = crate::state::next_sheet_board_pos(self); + let ds = Dataset::Table(Box::new(tds)); + let action = Action::insert_dataset_with_default_canvas( + self, + ds, + format!("Canvas {} — Data table", self.doc.canvases.len() + 1), + DEFAULT_CANVAS_SIZE_MM, + ); + self.execute_action(action); + self.session.status = "Froze a static copy of the series table.".into(); + } + + /// Fit one or more table responses through a single declarative analysis. + /// Each selected column receives a reference into the shared snapshot. + pub fn fit_table_columns( + &mut self, + dataset: usize, + model_id: &str, + all_columns: bool, + column: plotx_data::ColumnId, + global_parameters: bool, + options: plotx_analysis::fit_model::FitOptions, + ) { + let model = match resolve_table_fit_model(model_id, global_parameters) { + Ok(model) => model, + Err(status) => { + self.session.status = status; + return; + } + }; + let Some(t) = self.doc.datasets.get(dataset).and_then(Dataset::as_table) else { + self.session.status = "Curve fitting needs a data table.".into(); + return; + }; + let Some(column) = t + .series_bindings + .iter() + .position(|binding| binding.value_column == column) + else { + self.session.status = "The selected fit column is no longer available.".into(); + return; + }; + let table = match t.fit_analysis_view() { + Ok(table) => table, + Err(status) => { + self.session.status = status; + return; + } + }; + let TableFitInputs { + model, + input_name, + response_name, + targets, + datasets: fit_datasets, + bindings, + } = match build_table_fit_inputs(&table, model, all_columns, column) { + Ok(inputs) => inputs, + Err(status) => { + self.session.status = status; + return; + } + }; + let before_refs: Vec> = t + .series_bindings + .iter() + .map(|binding| binding.fit.clone()) + .collect(); + let mut after_refs = before_refs.clone(); + let before_analyses = t.curve_fit_analyses.clone(); + let mut after_analyses = before_analyses.clone(); + let model_name = model.name.clone(); + let result = match plotx_analysis::fit_model::fit_model(model, fit_datasets, &[], options) { + Ok(result) => result, + Err(error) => { + self.session.status = format!("Curve fit failed: {error}"); + return; + } + }; + let selection = match fit_selection::snapshot(&table, &bindings, &result) { + Ok(selection) => selection, + Err(error) => { + self.session.status = format!("Could not record the fit selection: {error}"); + return; + } + }; + let plot_samples = match table_fit_plot_samples(&result, &input_name, &table, &targets) { + Ok(samples) => samples, + Err(error) => { + self.session.status = format!("Could not evaluate the fitted curve: {error}"); + return; + } + }; + let analysis_id = t.next_curve_fit_id(); + let instance_ids: Vec = bindings + .iter() + .map(|binding| binding.dataset_id.clone()) + .collect(); + after_analyses.push(StoredCurveFitAnalysis { + id: analysis_id, + name: model_name.clone(), + bindings, + result, + selection: Some(selection), + plot_samples, + }); + for (&index, instance_id) in targets.iter().zip(instance_ids) { + after_refs[index] = Some(CurveFitReference { + analysis_id, + instance_id, + response: response_name.clone(), + }); + } + // Refitting replaces column references, so drop superseded snapshots — + // each embeds a full copy of the fitted data and would otherwise grow + // the project and the diagnostics list on every refit. + after_analyses.retain(|analysis| { + after_refs + .iter() + .flatten() + .any(|reference| reference.analysis_id == analysis.id) + }); + self.execute_action(Action::set_curve_fit_analyses( + self.doc.datasets[dataset].resource_id(), + (before_refs, before_analyses), + (after_refs, after_analyses), + )); + self.session.status = format!("Fitted {} curve(s) with {model_name}.", targets.len()); + } + + /// Validate and evaluate the initial curve without running optimisation. + pub fn preview_table_fit( + &mut self, + dataset: usize, + model_id: &str, + all_columns: bool, + column: plotx_data::ColumnId, + global_parameters: bool, + options: plotx_analysis::fit_model::FitOptions, + ) { + let model = match resolve_table_fit_model(model_id, global_parameters) { + Ok(model) => model, + Err(status) => { + self.session.status = status; + return; + } + }; + let Some(dataset) = self.doc.datasets.get(dataset).and_then(Dataset::as_table) else { + self.session.status = "Initial preview needs a data table.".into(); + return; + }; + let Some(column) = dataset + .series_bindings + .iter() + .position(|binding| binding.value_column == column) + else { + self.session.status = "The selected fit column is no longer available.".into(); + return; + }; + let table = match dataset.fit_analysis_view() { + Ok(table) => table, + Err(status) => { + self.session.status = status; + return; + } + }; + let inputs = match build_table_fit_inputs(&table, model, all_columns, column) { + Ok(inputs) => inputs, + Err(status) => { + self.session.status = status; + return; + } + }; + match plotx_analysis::fit_model::preview_initial_model( + inputs.model, + inputs.datasets, + &[], + options, + ) { + Ok(preview) => { + self.session.status = format!( + "Initial curve is valid for {} point(s).", + preview.points.len() + ) + } + Err(error) => self.session.status = format!("Initial curve is invalid: {error}"), + } + } +} diff --git a/crates/core/src/state/app_impl_compute.rs b/crates/core/src/state/app_impl_compute.rs index 71909c1..2c1eca2 100644 --- a/crates/core/src/state/app_impl_compute.rs +++ b/crates/core/src/state/app_impl_compute.rs @@ -88,7 +88,15 @@ impl PlotxApp { /// Async twin of `build_ilt_map_for`: same validation and input prep, but hand /// the heavy regularized inversion to the compute worker off the UI thread. pub fn request_ilt_map(&mut self, dataset: usize) { - let params = self.session.ui.ilt_params; + self.request_ilt_map_with_params(dataset, self.explicit_ilt_input_for(dataset)); + } + + pub fn request_ilt_map_with_params(&mut self, dataset: usize, explicit: Option) { + let params = self.resolve_ilt_params_for(dataset, explicit); + if let Err(message) = crate::state::validate_ilt_params(params) { + self.session.status = message; + return; + } let Some(d2) = self.doc.datasets.get(dataset).and_then(Dataset::as_nmr2d) else { self.session.status = "ILT DOSY maps need a diffusion dataset.".into(); return; @@ -129,6 +137,8 @@ impl PlotxApp { d_grid, params.lambda, params, + axis.values.clone(), + *meta, nucleus, source, ); @@ -167,6 +177,7 @@ impl PlotxApp { epoch, result, params, + provenance, figure, } => { if epoch != self.session.dataset_epoch @@ -191,15 +202,27 @@ impl PlotxApp { }; d2.dosy_method = DosyMethod::Ilt(params); d2.ilt_map = Some(result); + d2.ilt_provenance = Some(provenance); d2.ilt_figure = Some(figure); + d2.dosy_provenance_warning = None; if any { d2.display = PseudoDisplay::DosyMap; + } + // The method, map and provenance above are persisted state and + // land on both branches; dirtying only the populated one would + // drop an empty result silently on close. + self.doc.dirty = true; + if any { self.rebuild_canvases_for(dataset); - self.doc.dirty = true; self.session.status = "Built ILT DOSY map.".into(); } else { - self.session.status = - "ILT DOSY map is empty: no columns above the noise threshold.".into(); + self.session.status = format!( + "ILT DOSY map is empty with λ = {} (legal range {}–{}): no columns are \ + above the noise threshold.", + params.lambda, + crate::settings::MIN_ILT_LAMBDA, + crate::settings::MAX_ILT_LAMBDA + ); } } Done::Dosy { @@ -207,6 +230,7 @@ impl PlotxApp { dataset, epoch, result, + provenance, figure, } => { if epoch != self.session.dataset_epoch @@ -230,12 +254,18 @@ impl PlotxApp { continue; }; d2.dosy_map = Some(result); + d2.dosy_provenance = Some(provenance); d2.dosy_figure = Some(figure); + d2.dosy_provenance_warning = None; if any { d2.dosy_method = DosyMethod::MonoExp; d2.display = PseudoDisplay::DosyMap; + } + // See the ILT arm: the map and provenance are persisted and + // land regardless of whether any column fitted. + self.doc.dirty = true; + if any { self.rebuild_canvases_for(dataset); - self.doc.dirty = true; self.session.status = "Built DOSY map.".into(); } else { self.session.status = @@ -275,10 +305,9 @@ impl PlotxApp { d2.processed = processed; d2.processed_figure = std::sync::Arc::new(build_processed_figure(&d2.processed, d2.preset)); - d2.dosy_map = None; - d2.ilt_map = None; - d2.dosy_figure = None; - d2.ilt_figure = None; + d2.invalidate_dosy_results( + "Processing changed and invalidated the selected DOSY map", + ); for field in fields { self.session .compute diff --git a/crates/core/src/state/app_impl_io.rs b/crates/core/src/state/app_impl_io.rs index fdac669..78cf7e4 100644 --- a/crates/core/src/state/app_impl_io.rs +++ b/crates/core/src/state/app_impl_io.rs @@ -12,23 +12,60 @@ impl PlotxApp { loaded.doc.dirty = false; loaded.clear_history(); self.install_loaded_project(loaded); - self.session.record_operation( - OperationReport::success( - operation_id, - OperationKind::ProjectLoad, - format!("Opened project {}", path.display()), - (), + // A dataset whose stored analysis result could not be restored is + // still a successful load, but the canvas then shows something + // other than what was saved. Reporting it only on the dataset + // would hide it behind a tool panel the user has no reason to + // open, so it is promoted to the load report and the status bar. + let restore_warnings = self + .doc + .datasets + .iter() + .filter_map(|dataset| { + let dataset = dataset.as_nmr2d()?; + // Same precedence the panel uses, including the derived + // "selected map is missing" note: that case is not stored + // on the dataset, but it is exactly the case where the + // canvas silently shows something other than what was + // saved, so it has to reach the load report too. + let warning = dataset + .dosy_provenance_warning + .clone() + .or_else(|| dataset.missing_selected_map_note().map(str::to_owned))?; + Some((dataset.name.clone(), warning)) + }) + .collect::>(); + let mut report = OperationReport::success( + operation_id, + OperationKind::ProjectLoad, + format!("Opened project {}", path.display()), + (), + ) + .with_diagnostic( + Diagnostic::new( + Severity::Info, + DiagnosticCode::ProjectLoadSucceeded, + "Project opened successfully.", ) - .with_diagnostic( + .with_source("core.project") + .with_context("path", path.display().to_string()), + ); + for (name, warning) in &restore_warnings { + report = report.with_diagnostic( Diagnostic::new( - Severity::Info, - DiagnosticCode::ProjectLoadSucceeded, - "Project opened successfully.", + Severity::Warning, + DiagnosticCode::ProjectLoadWarning, + warning.clone(), ) .with_source("core.project") - .with_context("path", path.display().to_string()), - ), - ); + .with_context("path", path.display().to_string()) + .with_context("dataset", name.clone().unwrap_or_default()), + ); + } + if let Some((_, first)) = restore_warnings.first() { + self.session.status = first.clone(); + } + self.session.record_operation(report); self.note_recent_file(path); } Err(e) => { diff --git a/crates/core/src/state/compute.rs b/crates/core/src/state/compute.rs index 24203b9..5a3436f 100644 --- a/crates/core/src/state/compute.rs +++ b/crates/core/src/state/compute.rs @@ -13,11 +13,11 @@ use plotx_processing::{ Params2D, Processed2D, StackSpectrum, process_2d_cancellable, reapply_2d_cancellable, }; -use super::DatasetId; use super::{ ContourGeometry, ContourGeometryCacheKey, EstimateKey, EstimateResult, FieldId, FieldRef, FieldRuntime, FieldSummary, FieldVersion, ScalarGrid2D, VersionedFieldRef, nmr_scalar_grid, }; +use super::{DatasetId, DosyResultProvenance}; use crate::{IltParams, build_dosy_figure_cancellable, build_ilt_figure_cancellable}; #[path = "compute_field.rs"] @@ -95,6 +95,10 @@ enum Job { d_grid: Vec, lambda: f64, params: IltParams, + /// Raw ruler and metadata kept beside the derived b-factors so the worker + /// can fingerprint the same inputs the per-column path does. + values: Vec, + meta: DiffusionMeta, nucleus: String, source: String, }, @@ -172,6 +176,7 @@ pub enum Done { epoch: u64, result: IltResult, params: IltParams, + provenance: DosyResultProvenance, figure: Arc
, }, Dosy { @@ -179,6 +184,7 @@ pub enum Done { dataset: DatasetId, epoch: u64, result: DiffusionMap, + provenance: DosyResultProvenance, figure: Arc
, }, Processing2D { @@ -274,6 +280,8 @@ impl ComputeService { d_grid: Vec, lambda: f64, params: IltParams, + values: Vec, + meta: DiffusionMeta, nucleus: String, source: String, ) -> Result<(), EnqueueError> { @@ -303,6 +311,8 @@ impl ComputeService { d_grid, lambda, params, + values, + meta, nucleus, source, }) diff --git a/crates/core/src/state/compute/tests.rs b/crates/core/src/state/compute/tests.rs index 4fe0258..5d889ba 100644 --- a/crates/core/src/state/compute/tests.rs +++ b/crates/core/src/state/compute/tests.rs @@ -331,6 +331,8 @@ fn cancelled_ilt_job_reports_acknowledgement_without_a_result() { d_grid: vec![1e-10, 1e-9], lambda: 0.01, params: IltParams::default(), + values: vec![0.0, 1.0, 2.0], + meta: diffusion_meta(), nucleus: "X".into(), source: "test".into(), }); diff --git a/crates/core/src/state/compute_worker.rs b/crates/core/src/state/compute_worker.rs index 2a45383..5b3fb94 100644 --- a/crates/core/src/state/compute_worker.rs +++ b/crates/core/src/state/compute_worker.rs @@ -1,6 +1,7 @@ //! Worker-only execution for jobs declared by `compute`. use super::*; +use crate::state::{MONO_EXP_SNR_FRAC, ilt_provenance, mono_exp_provenance}; pub(super) fn run_job(job: Job) -> Done { match job { @@ -14,10 +15,13 @@ pub(super) fn run_job(job: Job) -> Done { d_grid, lambda, params, + values, + meta, nucleus, source, } => { let cancelled = || token.load(Ordering::Relaxed); + let provenance = ilt_provenance(&stack, &values, &meta, params); match ilt_map_cancellable(&*stack, &b_factors, &d_grid, lambda, &cancelled) { Some(result) if !cancelled() => { let Some(figure) = @@ -36,6 +40,7 @@ pub(super) fn run_job(job: Job) -> Done { epoch, result, params, + provenance, figure, } } @@ -58,7 +63,9 @@ pub(super) fn run_job(job: Job) -> Done { source, } => { let cancelled = || token.load(Ordering::Relaxed); - match diffusion_map_cancellable(&*stack, &values, &meta, 0.05, &cancelled) { + let provenance = mono_exp_provenance(&stack, &values, &meta); + match diffusion_map_cancellable(&*stack, &values, &meta, MONO_EXP_SNR_FRAC, &cancelled) + { Some(result) if !cancelled() => { let Some(figure) = build_dosy_figure_cancellable(&result, &nucleus, &source, &cancelled) @@ -75,6 +82,7 @@ pub(super) fn run_job(job: Job) -> Done { dataset, epoch, result, + provenance, figure, } } diff --git a/crates/core/src/state/datasets.rs b/crates/core/src/state/datasets.rs index a5dba64..6c9cbd1 100644 --- a/crates/core/src/state/datasets.rs +++ b/crates/core/src/state/datasets.rs @@ -177,8 +177,12 @@ pub struct Nmr2DDataset { pub dosy_method: DosyMethod, /// Per-column mono-exponential DOSY map (`DosyMethod::MonoExp`). pub dosy_map: Option, + /// Invocation and data identity for `dosy_map`. + pub dosy_provenance: Option, /// Full ILT/CONTIN inversion map (`DosyMethod::Ilt`). pub ilt_map: Option, + /// Invocation and data identity for `ilt_map`. + pub ilt_provenance: Option, /// Cached contour geometry for `dosy_map`. Async analysis builds this beside /// the numeric result so contour extraction never lands on the UI thread. /// Kept per method: one shared slot would let a stale figure be served for @@ -197,6 +201,8 @@ pub struct Nmr2DDataset { pub next_integral_id: u64, /// Last volume-recompute failure for user-visible diagnostics. pub integral_error: Option, + /// Load/display diagnostic for a stale or unavailable stored DOSY result. + pub dosy_provenance_warning: Option, } impl Nmr2DDataset { pub fn load(data: NmrData2D) -> Self { @@ -237,7 +243,9 @@ impl Nmr2DDataset { display: PseudoDisplay::Stack, dosy_method: DosyMethod::MonoExp, dosy_map: None, + dosy_provenance: None, ilt_map: None, + ilt_provenance: None, dosy_figure: None, ilt_figure: None, regions: Vec::new(), @@ -246,6 +254,7 @@ impl Nmr2DDataset { integrals: Vec::new(), next_integral_id: 0, integral_error: None, + dosy_provenance_warning: None, }; result.remint_all_steps(); result @@ -254,10 +263,7 @@ impl Nmr2DDataset { pub fn rebuild(&mut self) { self.processed = reapply_2d(&self.base, &self.params); self.processed_figure = Arc::new(build_processed_figure(&self.processed, self.preset)); - self.dosy_map = None; - self.ilt_map = None; - self.dosy_figure = None; - self.ilt_figure = None; + self.invalidate_dosy_results("Processing changed and invalidated the selected DOSY map"); } /// Rebuild `base` from the FID (a time-domain step or the layout changed) then /// re-derive the display result. @@ -388,404 +394,6 @@ pub enum Dataset { Afm(Box), } -impl Dataset { - pub fn as_afm(&self) -> Option<&AfmDataset> { - match self { - Dataset::Afm(data) => Some(data), - _ => None, - } - } - - pub fn as_afm_mut(&mut self) -> Option<&mut AfmDataset> { - match self { - Dataset::Afm(data) => Some(data), - _ => None, - } - } - - pub fn kind_label(&self) -> &'static str { - match self { - Dataset::Nmr(_) => "NMR 1D", - Dataset::Nmr2D(_) => "NMR 2D", - Dataset::Table(_) => "Data Table", - Dataset::Electrophysiology(_) => "Electrophysiology", - Dataset::Afm(_) => "AFM", - } - } - - /// The chart/tool domain this dataset belongs to — a stable key the chart - /// registry dispatches on (see `state::charts`). A pseudo-2D array (a stack - /// with a recovered ruler) is its own domain, distinct from a true-2D contour. - pub fn domain(&self) -> DataDomain { - match self { - Dataset::Nmr(_) => DataDomain::Nmr1d, - Dataset::Nmr2D(n) if n.is_pseudo() => DataDomain::PseudoNmr, - Dataset::Nmr2D(_) => DataDomain::Nmr2d, - Dataset::Table(_) => DataDomain::Table, - Dataset::Electrophysiology(_) => DataDomain::Electrophysiology, - Dataset::Afm(_) => DataDomain::Afm, - } - } - - /// The user-facing label in the Data list: the custom name if one was set - /// via rename, otherwise the derived `[kind] summary`. - pub fn display_name(&self) -> String { - let custom = match self { - Dataset::Nmr(d) => d.name.clone(), - Dataset::Nmr2D(d) => d.name.clone(), - Dataset::Table(d) => d.name.clone(), - Dataset::Electrophysiology(d) => d.name.clone(), - Dataset::Afm(d) => d.name.clone(), - }; - custom.unwrap_or_else(|| format!("[{}] {}", self.kind_label(), self.summary())) - } - - pub fn set_name(&mut self, name: Option) { - match self { - Dataset::Nmr(d) => d.name = name, - Dataset::Nmr2D(d) => d.name = name, - Dataset::Table(d) => d.name = name, - Dataset::Electrophysiology(d) => d.name = name, - Dataset::Afm(d) => d.name = name, - } - } - - pub fn name(&self) -> Option { - match self { - Dataset::Nmr(d) => d.name.clone(), - Dataset::Nmr2D(d) => d.name.clone(), - Dataset::Table(d) => d.name.clone(), - Dataset::Electrophysiology(d) => d.name.clone(), - Dataset::Afm(d) => d.name.clone(), - } - } - - pub fn summary(&self) -> String { - match self { - Dataset::Nmr(d) => format!( - "{} · {} pts · {:.2} MHz", - d.data.nucleus, - d.data.len(), - d.data.observe_freq_mhz - ), - Dataset::Nmr2D(d) => d.summary(), - Dataset::Table(d) => d.summary(), - Dataset::Electrophysiology(d) => format!( - "{} channels · {} sweeps · {:.3} kHz", - d.data.channels.len(), - d.data.sweeps.len(), - d.data.sample_rate_hz / 1_000.0 - ), - Dataset::Afm(d) => { - let curves = d - .data - .forces - .as_ref() - .map_or(0, |f| f.grid_width * f.grid_height); - format!("{} channels · {curves} force curves", d.data.images.len()) - } - } - } - - pub fn as_nmr_mut(&mut self) -> Option<&mut NmrDataset> { - match self { - Dataset::Nmr(d) => Some(d), - _ => None, - } - } - - pub fn as_nmr(&self) -> Option<&NmrDataset> { - match self { - Dataset::Nmr(d) => Some(d), - _ => None, - } - } - - pub fn as_nmr2d_mut(&mut self) -> Option<&mut Nmr2DDataset> { - match self { - Dataset::Nmr2D(d) => Some(d), - _ => None, - } - } - - pub fn as_nmr2d(&self) -> Option<&Nmr2DDataset> { - match self { - Dataset::Nmr2D(d) => Some(d), - _ => None, - } - } - - pub fn as_table_mut(&mut self) -> Option<&mut TableDataset> { - match self { - Dataset::Table(d) => Some(d), - _ => None, - } - } - - pub fn as_table(&self) -> Option<&TableDataset> { - match self { - Dataset::Table(d) => Some(d), - _ => None, - } - } - - /// The dataset's peak set, for domains that carry one (1D spectra and tables). - pub fn peaks(&self) -> Option<&PeakSet> { - match self { - Dataset::Nmr(d) => Some(&d.peaks), - Dataset::Table(d) => Some(&d.peaks), - Dataset::Nmr2D(_) => None, - Dataset::Electrophysiology(_) => None, - Dataset::Afm(_) => None, - } - } - - pub fn peaks_mut(&mut self) -> Option<&mut PeakSet> { - match self { - Dataset::Nmr(d) => Some(&mut d.peaks), - Dataset::Table(d) => Some(&mut d.peaks), - Dataset::Nmr2D(_) => None, - Dataset::Electrophysiology(_) => None, - Dataset::Afm(_) => None, - } - } - - /// The dataset's stored lineshape deconvolutions, for domains with a 1D trace. - pub fn line_fits(&self) -> &[StoredLineFit] { - match self { - Dataset::Nmr(d) => &d.line_fits, - Dataset::Table(d) => &d.line_fits, - Dataset::Nmr2D(_) => &[], - Dataset::Electrophysiology(_) => &[], - Dataset::Afm(_) => &[], - } - } - - pub fn line_fits_mut(&mut self) -> Option<&mut Vec> { - match self { - Dataset::Nmr(d) => Some(&mut d.line_fits), - Dataset::Table(d) => Some(&mut d.line_fits), - Dataset::Nmr2D(_) => None, - Dataset::Electrophysiology(_) => None, - Dataset::Afm(_) => None, - } - } - - pub fn next_line_fit_id_mut(&mut self) -> Option<&mut u64> { - match self { - Dataset::Nmr(d) => Some(&mut d.next_line_fit_id), - Dataset::Table(d) => Some(&mut d.next_line_fit_id), - Dataset::Nmr2D(_) => None, - Dataset::Electrophysiology(_) => None, - Dataset::Afm(_) => None, - } - } - - /// The dataset's stored multiplet analyses (1D NMR only: J values need an - /// observe frequency to convert to Hz). - pub fn multiplets(&self) -> &[StoredMultiplet] { - match self { - Dataset::Nmr(d) => &d.multiplets, - _ => &[], - } - } - - pub fn multiplets_mut(&mut self) -> Option<&mut Vec> { - match self { - Dataset::Nmr(d) => Some(&mut d.multiplets), - _ => None, - } - } - - pub fn next_multiplet_id_mut(&mut self) -> Option<&mut u64> { - match self { - Dataset::Nmr(d) => Some(&mut d.next_multiplet_id), - _ => None, - } - } - - pub fn supports_region_analysis(&self) -> bool { - matches!(self, Dataset::Nmr2D(d) if d.is_pseudo()) - } - - pub fn tool_groups(&self) -> &'static [ToolGroup] { - match self { - Dataset::Nmr(_) => &[ - ToolGroup::Processing, - ToolGroup::Nmr1dAnalysis, - ToolGroup::Peaks, - ToolGroup::LineFit, - ], - Dataset::Nmr2D(_) if self.supports_region_analysis() => &[ - ToolGroup::Processing, - ToolGroup::Nmr2dExperiment, - ToolGroup::RegionAnalysis, - ], - Dataset::Nmr2D(_) => &[ToolGroup::Processing, ToolGroup::Nmr2dExperiment], - Dataset::Table(_) => &[ - ToolGroup::Peaks, - ToolGroup::CurveFit, - ToolGroup::LineFit, - ToolGroup::Statistics, - ], - Dataset::Electrophysiology(_) => &[ToolGroup::Electrophysiology], - Dataset::Afm(_) => &[], - } - } - - /// The phaseable/processable axes for this dataset: 1D and a stack expose the - /// direct axis only; a true-2D spectrum exposes both F2 and F1; a table has - /// no frequency axis to phase. - pub fn phase_axes(&self) -> &'static [PhaseAxis] { - match self { - Dataset::Nmr(_) => &[PhaseAxis::Direct], - Dataset::Nmr2D(n) if n.is_true_2d() => &[PhaseAxis::F2, PhaseAxis::F1], - Dataset::Nmr2D(_) => &[PhaseAxis::F2], - Dataset::Table(_) => &[], - Dataset::Electrophysiology(_) => &[], - Dataset::Afm(_) => &[], - } - } - - pub fn active_phase_axis(&self, requested: PhaseAxis) -> PhaseAxis { - let axes = self.phase_axes(); - if axes.contains(&requested) { - requested - } else { - *axes.first().unwrap_or(&PhaseAxis::Direct) - } - } - - pub fn axis_pipeline_mut(&mut self, axis: PhaseAxis) -> Option<&mut AxisPipeline> { - match self { - Dataset::Nmr(n) if axis == PhaseAxis::Direct => Some(n.pipeline_mut()), - Dataset::Nmr2D(n) => n.axis_mut(axis), - _ => None, - } - } - - pub fn axis_pipeline(&self, axis: PhaseAxis) -> Option<&AxisPipeline> { - match self { - Dataset::Nmr(n) if axis == PhaseAxis::Direct => Some(n.pipeline()), - Dataset::Nmr2D(n) => match (axis, &n.processed) { - (PhaseAxis::F2, _) => Some(&n.params.f2), - (PhaseAxis::F1, Processed2D::Ft(_)) => Some(&n.params.f1), - _ => None, - }, - _ => None, - } - } - - /// The recipe this dataset would be loaded with today, for one axis. - /// - /// Produced by re-running the very factory that built the live one, so "what - /// does reset give me" and "what does a new document get" stay one answer - /// rather than two derivations that agree only until one of them changes. - pub fn factory_pipeline(&self, axis: PhaseAxis) -> Option { - match self { - Dataset::Nmr(n) if axis == PhaseAxis::Direct => Some(match n.data.domain { - Domain::Time => AxisPipeline::default_1d(), - Domain::Frequency => AxisPipeline::frequency_1d(), - }), - Dataset::Nmr2D(n) => { - let params = match n.data.domain { - Domain::Time => Params2D::default_for(n.preset), - Domain::Frequency => Params2D::frequency_domain(n.preset), - }; - Some(match axis { - PhaseAxis::F1 => params.f1, - _ => params.f2, - }) - } - _ => None, - } - } - - pub fn phase_params_mut(&mut self, axis: PhaseAxis) -> Option<&mut PhaseParams> { - self.axis_pipeline_mut(axis).and_then(|pipe| { - pipe.steps - .iter_mut() - .filter(|s| s.enabled) - .find_map(|s| match &mut s.kind { - StepKind::Phase(p) => Some(p), - _ => None, - }) - }) - } - - /// Parameters produced by the currently enabled automatic Phase step. - /// This mirrors the processing kernels so switching to manual is lossless. - pub fn automatic_phase_params(&self, axis: PhaseAxis) -> Option<(f64, f64, f64)> { - let pipe = self.axis_pipeline(axis)?; - let method = - pipe.steps - .iter() - .filter(|step| step.enabled) - .find_map(|step| match &step.kind { - StepKind::Phase(params) => params.auto, - _ => None, - })?; - match self { - Dataset::Nmr(n) => Some(plotx_processing::auto_phase(&n.base, method)), - Dataset::Nmr2D(n) => match &n.base { - Processed2D::Ft(s) => { - let peak_arg = s - .data - .iter() - .max_by(|a, b| a.norm().total_cmp(&b.norm())) - .map_or(0.0, |value| value.arg()); - let (f2, f1) = s.peak_pivot_fracs(); - Some((peak_arg, 0.0, if axis == PhaseAxis::F1 { f1 } else { f2 })) - } - Processed2D::Stack(s) if axis == PhaseAxis::F2 => { - let (phase0, phase1) = - plotx_processing::fft2::absorptive_phase(&s.traces).unwrap_or((0.0, 0.0)); - Some((phase0, phase1, s.peak_pivot_frac())) - } - Processed2D::Stack(_) => None, - }, - _ => None, - } - } - - pub fn pivot_ppm(&self, axis: PhaseAxis) -> Option { - match self { - Dataset::Nmr(n) if axis == PhaseAxis::Direct => Some(n.pivot_ppm()), - Dataset::Nmr2D(n) => n.pivot_ppm(axis), - _ => None, - } - } - - pub fn set_pivot_ppm(&mut self, axis: PhaseAxis, ppm: f64) { - match self { - Dataset::Nmr(n) if axis == PhaseAxis::Direct => n.set_pivot_ppm(ppm), - Dataset::Nmr2D(n) => n.set_pivot_ppm(axis, ppm), - _ => {} - } - } - - /// Re-express the same manual phase curve around a new ppm pivot. - pub fn repivot_ppm(&mut self, axis: PhaseAxis, ppm: f64) -> bool { - let Some((old_pivot, is_manual)) = self - .phase_params_mut(axis) - .map(|params| (params.pivot_frac, params.auto.is_none())) - else { - return false; - }; - if !is_manual { - return false; - } - self.set_pivot_ppm(axis, ppm); - let Some(params) = self.phase_params_mut(axis) else { - return false; - }; - let new_pivot = params.pivot_frac; - params.pivot_frac = old_pivot; - params.repivot(new_pivot); - new_pivot != old_pivot - } -} - fn set_pipeline_pivot_frac(pipe: &mut AxisPipeline, frac: f64) { for step in pipe.steps.iter_mut().filter(|s| s.enabled) { if let StepKind::Phase(p) = &mut step.kind { diff --git a/crates/core/src/state/datasets/pseudo_tests.rs b/crates/core/src/state/datasets/pseudo_tests.rs index efc64ba..70dd443 100644 --- a/crates/core/src/state/datasets/pseudo_tests.rs +++ b/crates/core/src/state/datasets/pseudo_tests.rs @@ -221,3 +221,316 @@ fn entering_a_nus_schedule_forces_a_retransform_until_a_base_lands() { ds.retransform(); assert!(!ds.base_stale, "a fresh base clears the flag"); } + +#[test] +fn persisted_display_and_method_changes_mark_the_document_dirty() { + let mut app = PlotxApp::new_with_settings(crate::settings::Settings::default()); + app.doc + .datasets + .push(Dataset::Nmr2D(Box::new(Nmr2DDataset::load( + synthetic_dosy(1.2e-9), + )))); + + app.doc.dirty = false; + app.set_pseudo_display(0, PseudoDisplay::DosyMap); + assert!( + app.doc.dirty, + "changing the persisted display must be dirty" + ); + + app.doc.dirty = false; + let params = IltParams { + lambda: 0.03, + ..IltParams::default() + }; + app.set_pseudo_dosy_method(0, DosyMethod::Ilt(params)); + assert!(app.doc.dirty, "changing the persisted method must be dirty"); +} + +#[test] +fn processing_invalidation_explains_the_stack_fallback() { + let mut dataset = Nmr2DDataset::load(synthetic_dosy(1.2e-9)); + assert!(dataset.build_dosy_map()); + assert_eq!(dataset.display, PseudoDisplay::DosyMap); + + dataset.rebuild(); + + assert!(dataset.dosy_map.is_none()); + assert!(dataset.figure().title.starts_with("Pseudo-2D stack —")); + let warning = dataset + .dosy_provenance_warning + .as_deref() + .expect("processing invalidation must explain the stack fallback"); + assert!(warning.contains("Processing changed"), "{warning}"); + assert!(warning.contains("showing the stack"), "{warning}"); + assert!(warning.contains("Build"), "{warning}"); +} + +#[test] +fn ilt_invocation_resolution_obeys_explicit_provenance_default_and_reports_empty_explicit_runs() { + let mut settings = crate::settings::Settings::default(); + settings.processing.ilt_lambda = 0.8; + let mut app = PlotxApp::new_with_settings(settings); + let mut dataset = Nmr2DDataset::load(synthetic_dosy(1.2e-9)); + let previous = IltParams { + lambda: 0.03, + d_min: 1e-10, + d_max: 1e-8, + n_grid: 32, + }; + assert!(dataset.build_ilt_map(previous)); + app.doc.datasets.push(Dataset::Nmr2D(Box::new(dataset))); + + let explicit = IltParams { + lambda: 0.5, + ..IltParams::default() + }; + assert_eq!(app.resolve_ilt_params_for(0, Some(explicit)), explicit); + assert_eq!(app.resolve_ilt_params_for(0, None), previous); + app.doc.datasets[0].as_nmr2d_mut().unwrap().ilt_provenance = None; + assert_eq!(app.resolve_ilt_params_for(0, None).lambda, 0.8); + + let mut empty = synthetic_dosy(1.2e-9); + empty.data.fill(Complex64::new(0.0, 0.0)); + let mut empty_app = PlotxApp::new_with_settings(crate::settings::Settings::default()); + empty_app + .doc + .datasets + .push(Dataset::Nmr2D(Box::new(Nmr2DDataset::load(empty)))); + // Deliberately not a boundary value: at MIN or MAX the assertion below would + // be satisfied by the range text the same message prints, and would still + // pass with the value itself removed from the message. + let chosen = IltParams { + lambda: 0.37, + ..IltParams::default() + }; + assert!( + chosen.lambda != crate::settings::MIN_ILT_LAMBDA + && chosen.lambda != crate::settings::MAX_ILT_LAMBDA + ); + empty_app.build_ilt_map_for_with_params(0, Some(chosen)); + assert_eq!( + empty_app.doc.datasets[0].as_nmr2d().unwrap().dosy_method, + DosyMethod::Ilt(chosen), + "a legal explicit lambda is obeyed even when the result is empty" + ); + assert!( + empty_app + .session + .status + .contains(&chosen.lambda.to_string()), + "{}", + empty_app.session.status + ); + assert!( + empty_app + .session + .status + .contains(&crate::settings::MIN_ILT_LAMBDA.to_string()) + && empty_app + .session + .status + .contains(&crate::settings::MAX_ILT_LAMBDA.to_string()), + "{}", + empty_app.session.status + ); +} + +/// A build that fits nothing still installs the method, the map and its +/// provenance — all persisted state. Dirtying only the populated branch would let +/// those changes be lost on close with no save prompt, which is the silent form +/// of data loss this branch exists to remove. +#[test] +fn a_build_that_fits_nothing_still_marks_the_document_dirty() { + let mut app = PlotxApp::new_with_settings(crate::settings::Settings::default()); + // Zero signal: every column falls below the noise threshold, so both builders + // return `false` while still writing their results. + let mut empty = synthetic_dosy(1.2e-9); + empty + .data + .iter_mut() + .for_each(|value| *value = Complex64::ZERO); + app.doc + .datasets + .push(Dataset::Nmr2D(Box::new(Nmr2DDataset::load(empty)))); + + app.doc.dirty = false; + app.build_dosy_map_for(0); + let d2 = app.doc.datasets[0].as_nmr2d().unwrap(); + assert!(d2.dosy_map.is_some(), "the empty map is still installed"); + assert!(d2.dosy_provenance.is_some()); + assert!( + app.doc.dirty, + "an empty per-column build still changed persisted state" + ); + + app.doc.dirty = false; + let params = IltParams { + lambda: 0.03, + ..IltParams::default() + }; + app.build_ilt_map_for_with_params(0, Some(params)); + let d2 = app.doc.datasets[0].as_nmr2d().unwrap(); + assert_eq!(d2.dosy_method, DosyMethod::Ilt(params)); + assert!(d2.ilt_provenance.is_some()); + assert!( + app.doc.dirty, + "an empty ILT build still changed persisted state" + ); +} + +/// The "map is missing" note is derived, never stored, so it cannot outlive the +/// condition. A stored copy would keep claiming the map is unavailable while the +/// map is on screen. +#[test] +fn the_missing_map_note_tracks_the_current_selection_instead_of_persisting() { + let mut app = PlotxApp::new_with_settings(crate::settings::Settings::default()); + let mut ds = Nmr2DDataset::load(synthetic_dosy(1.2e-9)); + assert!(ds.build_dosy_map()); + app.doc.datasets.push(Dataset::Nmr2D(Box::new(ds))); + + let d2 = app.doc.datasets[0].as_nmr2d().unwrap(); + assert_eq!(d2.display, PseudoDisplay::DosyMap); + assert_eq!(d2.missing_selected_map_note(), None); + + // Selecting ILT, for which no map exists, must explain the stack fallback… + app.set_pseudo_dosy_method(0, DosyMethod::Ilt(IltParams::default())); + let note = app.doc.datasets[0] + .as_nmr2d() + .unwrap() + .missing_selected_map_note() + .expect("selecting a method with no map explains the fallback"); + assert!(note.contains("ILT DOSY map is not available"), "{note}"); + + // …and going back to the method that does have one must stop explaining it. + app.set_pseudo_dosy_method(0, DosyMethod::MonoExp); + assert_eq!( + app.doc.datasets[0] + .as_nmr2d() + .unwrap() + .missing_selected_map_note(), + None, + "the note must not outlive the condition it describes" + ); + + // Nor should it fire when no map is selected at all. + app.set_pseudo_dosy_method(0, DosyMethod::Ilt(IltParams::default())); + app.set_pseudo_display(0, PseudoDisplay::Stack); + assert_eq!( + app.doc.datasets[0] + .as_nmr2d() + .unwrap() + .missing_selected_map_note(), + None + ); +} + +/// The fingerprint has to cover every numeric input the fit consumes, not just +/// the trace samples. Referencing moves the chemical-shift axis without touching +/// a sample, and the diffusion metadata reaches the per-column fit through the +/// b-factor conversion; either change produces a different map, so either must +/// produce a different digest. +#[test] +fn the_data_fingerprint_covers_coordinates_and_diffusion_metadata() { + let mut ds = Nmr2DDataset::load(synthetic_dosy(1.2e-9)); + let Processed2D::Stack(stack) = &ds.processed else { + panic!("synthetic DOSY must process as a stack"); + }; + let stack = stack.clone(); + let axis = ds.data.pseudo_axis.clone().unwrap(); + let meta = ds.data.diffusion.unwrap(); + let base = crate::state::dosy_data_fingerprint(&stack, &axis.values, &meta); + + let mut shifted = (*stack).clone(); + shifted.ppm.iter_mut().for_each(|value| *value += 0.5); + assert_ne!( + crate::state::dosy_data_fingerprint(&shifted, &axis.values, &meta), + base, + "a reference change moves the ppm axis and must be detected" + ); + + let mut edited = meta; + edited.big_delta *= 2.0; + assert_ne!( + crate::state::dosy_data_fingerprint(&stack, &axis.values, &edited), + base, + "diffusion metadata reaches the fit and must be detected" + ); + + let mut ruler = axis.values.clone(); + ruler[0] += 1e-3; + assert_ne!( + crate::state::dosy_data_fingerprint(&stack, &ruler, &meta), + base, + "the gradient ruler must be detected" + ); + + // Guard against the digest becoming trivially input-insensitive. + assert_eq!( + crate::state::dosy_data_fingerprint(&stack, &axis.values, &meta), + base + ); + let _ = &mut ds; +} + +/// Grid parameters can arrive from a project file, so they are external input. +/// The inversion solves a dense `n_grid x n_grid` system; an unchecked value +/// would size that allocation instead of producing an error. +#[test] +fn ilt_parameters_from_a_project_are_validated_before_the_inversion() { + use crate::settings::{MAX_ILT_GRID, MAX_ILT_LAMBDA}; + let ok = IltParams::default(); + assert!(crate::state::validate_ilt_params(ok).is_ok()); + + let huge_grid = IltParams { + n_grid: 4_000_000, + ..ok + }; + let message = crate::state::validate_ilt_params(huge_grid) + .expect_err("an out-of-range grid size must be refused"); + assert!(message.contains("4000000"), "{message}"); + assert!(message.contains(&MAX_ILT_GRID.to_string()), "{message}"); + + let reversed = IltParams { + d_min: 1e-8, + d_max: 1e-11, + ..ok + }; + let message = crate::state::validate_ilt_params(reversed) + .expect_err("a reversed diffusion range must be refused"); + assert!( + message.contains("1e-8") && message.contains("1e-11"), + "{message}" + ); + + let unhinged = IltParams { + d_min: f64::NAN, + ..ok + }; + assert!(crate::state::validate_ilt_params(unhinged).is_err()); + + let bad_lambda = IltParams { + lambda: MAX_ILT_LAMBDA * 10.0, + ..ok + }; + assert!(crate::state::validate_ilt_params(bad_lambda).is_err()); + + // And the build path must actually consult it rather than reaching the + // inversion with the value. + let mut app = PlotxApp::new_with_settings(crate::settings::Settings::default()); + app.doc + .datasets + .push(Dataset::Nmr2D(Box::new(Nmr2DDataset::load( + synthetic_dosy(1.2e-9), + )))); + app.build_ilt_map_for_with_params(0, Some(huge_grid)); + assert!( + app.doc.datasets[0].as_nmr2d().unwrap().ilt_map.is_none(), + "the inversion must not have run" + ); + assert!( + app.session.status.contains("4000000"), + "{}", + app.session.status + ); +} diff --git a/crates/core/src/state/datasets_2d_figure.rs b/crates/core/src/state/datasets_2d_figure.rs index a8aa392..6c2d345 100644 --- a/crates/core/src/state/datasets_2d_figure.rs +++ b/crates/core/src/state/datasets_2d_figure.rs @@ -29,6 +29,30 @@ impl Nmr2DDataset { } } + /// Why `figure()` is drawing the stack even though a DOSY map is selected. + /// + /// Derived rather than stored, and deliberately kept beside the fallback it + /// explains: the condition is a pure function of the display, the method and + /// which maps exist, so a stored copy would outlive the state it describes + /// and claim a map is missing while the map is on screen. + pub fn missing_selected_map_note(&self) -> Option<&'static str> { + if !matches!(self.processed, Processed2D::Stack(_)) + || self.display != PseudoDisplay::DosyMap + { + return None; + } + match self.dosy_method { + DosyMethod::MonoExp => self.dosy_map.is_none().then_some( + "The per-column DOSY map is not available, so PlotX is showing the stack \ + instead. Build the per-column DOSY map to replace it.", + ), + DosyMethod::Ilt(_) => self.ilt_map.is_none().then_some( + "The ILT DOSY map is not available, so PlotX is showing the stack instead. \ + Build the ILT DOSY map to replace it.", + ), + } + } + pub fn summary(&self) -> String { format!( "{}–{} · {}×{} · {}", diff --git a/crates/core/src/state/datasets_2d_maps.rs b/crates/core/src/state/datasets_2d_maps.rs index 0ca2ed9..7a76131 100644 --- a/crates/core/src/state/datasets_2d_maps.rs +++ b/crates/core/src/state/datasets_2d_maps.rs @@ -3,9 +3,105 @@ //! and tests, and must keep the same numerical semantics as the async path. use super::*; +use sha2::{Digest, Sha256}; use std::sync::Arc; +pub(crate) const DIFFUSION_MAP_ALGORITHM_VERSION: u32 = 1; +pub(crate) const ILT_MAP_ALGORITHM_VERSION: u32 = 1; +pub(crate) const MONO_EXP_SNR_FRAC: f64 = 0.05; + +/// Hash contract for stored DOSY results, in order: increment count and trace +/// length as little-endian u64; every chemical-shift coordinate; every trace +/// sample in row-major order, real then imaginary; every raw ruler value; then +/// every Stejskal–Tanner b-factor. All values as little-endian f64 bits. +/// Changing this byte or field order changes what a stored fingerprint means and +/// requires a new persisted algorithm version. +/// +/// Both DOSY methods hash the same inputs on purpose. The coordinates belong in +/// the digest because they are carried into the result and a reference change +/// moves them without touching a single trace sample. The b-factors belong in it +/// because the diffusion metadata reaches the per-column fit too, so hashing only +/// the raw gradient ruler would let an edited δ or Δ produce a different map +/// under an unchanged fingerprint. +pub(crate) fn dosy_data_fingerprint( + stack: &plotx_processing::StackSpectrum, + ruler: &[f64], + meta: &plotx_io::DiffusionMeta, +) -> String { + let mut hash = Sha256::new(); + hash.update((stack.traces.len() as u64).to_le_bytes()); + hash.update((stack.ppm.len() as u64).to_le_bytes()); + for value in &stack.ppm { + hash.update(value.to_bits().to_le_bytes()); + } + for trace in &stack.traces { + for value in trace { + hash.update(value.re.to_bits().to_le_bytes()); + hash.update(value.im.to_bits().to_le_bytes()); + } + } + for value in ruler { + hash.update(value.to_bits().to_le_bytes()); + } + for &value in ruler { + hash.update(meta.b_factor(value).to_bits().to_le_bytes()); + } + hash.finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +pub(crate) fn mono_exp_provenance( + stack: &plotx_processing::StackSpectrum, + ruler: &[f64], + meta: &plotx_io::DiffusionMeta, +) -> DosyResultProvenance { + DosyResultProvenance { + algorithm: "diffusion_map".to_owned(), + version: DIFFUSION_MAP_ALGORITHM_VERSION, + input: DosyInvocation::MonoExp { + snr_frac: MONO_EXP_SNR_FRAC, + }, + data_fingerprint: dosy_data_fingerprint(stack, ruler, meta), + } +} + +pub(crate) fn ilt_provenance( + stack: &plotx_processing::StackSpectrum, + ruler: &[f64], + meta: &plotx_io::DiffusionMeta, + params: IltParams, +) -> DosyResultProvenance { + DosyResultProvenance { + algorithm: "ilt_map".to_owned(), + version: ILT_MAP_ALGORITHM_VERSION, + input: DosyInvocation::Ilt { params }, + data_fingerprint: dosy_data_fingerprint(stack, ruler, meta), + } +} + impl Nmr2DDataset { + pub(crate) fn invalidate_dosy_results(&mut self, reason: &str) { + self.dosy_map = None; + self.dosy_provenance = None; + self.ilt_map = None; + self.ilt_provenance = None; + self.dosy_figure = None; + self.ilt_figure = None; + self.dosy_provenance_warning = + (self.display == PseudoDisplay::DosyMap).then(|| match self.dosy_method { + DosyMethod::MonoExp => format!( + "{reason}, so PlotX is showing the stack instead. Build the per-column DOSY \ + map to replace it." + ), + DosyMethod::Ilt(_) => format!( + "{reason}, so PlotX is showing the stack instead. Build the ILT DOSY map to \ + replace it." + ), + }); + } + /// Fit every column to build a DOSY map. Only meaningful for diffusion /// datasets. pub fn build_dosy_map(&mut self) -> bool { @@ -16,7 +112,7 @@ impl Nmr2DDataset { ) else { return false; }; - let map = diffusion_map(&**stack, &axis.values, meta, 0.05); + let map = diffusion_map(&**stack, &axis.values, meta, MONO_EXP_SNR_FRAC); let any = map.d.iter().any(|d| d.is_finite()); self.dosy_figure = Some(Arc::new(build_dosy_figure( &map, @@ -24,6 +120,8 @@ impl Nmr2DDataset { &stack.source, ))); self.dosy_map = Some(map); + self.dosy_provenance = Some(mono_exp_provenance(stack, &axis.values, meta)); + self.dosy_provenance_warning = None; if any { self.dosy_method = DosyMethod::MonoExp; self.display = PseudoDisplay::DosyMap; @@ -55,6 +153,8 @@ impl Nmr2DDataset { ))); self.dosy_method = DosyMethod::Ilt(params); self.ilt_map = Some(result); + self.ilt_provenance = Some(ilt_provenance(stack, &axis.values, meta, params)); + self.dosy_provenance_warning = None; if any { self.display = PseudoDisplay::DosyMap; } diff --git a/crates/core/src/state/datasets_dispatch.rs b/crates/core/src/state/datasets_dispatch.rs new file mode 100644 index 0000000..c930df2 --- /dev/null +++ b/crates/core/src/state/datasets_dispatch.rs @@ -0,0 +1,398 @@ +use super::*; +impl Dataset { + pub fn as_afm(&self) -> Option<&AfmDataset> { + match self { + Dataset::Afm(data) => Some(data), + _ => None, + } + } + + pub fn as_afm_mut(&mut self) -> Option<&mut AfmDataset> { + match self { + Dataset::Afm(data) => Some(data), + _ => None, + } + } + + pub fn kind_label(&self) -> &'static str { + match self { + Dataset::Nmr(_) => "NMR 1D", + Dataset::Nmr2D(_) => "NMR 2D", + Dataset::Table(_) => "Data Table", + Dataset::Electrophysiology(_) => "Electrophysiology", + Dataset::Afm(_) => "AFM", + } + } + + /// The chart/tool domain this dataset belongs to — a stable key the chart + /// registry dispatches on (see `state::charts`). A pseudo-2D array (a stack + /// with a recovered ruler) is its own domain, distinct from a true-2D contour. + pub fn domain(&self) -> DataDomain { + match self { + Dataset::Nmr(_) => DataDomain::Nmr1d, + Dataset::Nmr2D(n) if n.is_pseudo() => DataDomain::PseudoNmr, + Dataset::Nmr2D(_) => DataDomain::Nmr2d, + Dataset::Table(_) => DataDomain::Table, + Dataset::Electrophysiology(_) => DataDomain::Electrophysiology, + Dataset::Afm(_) => DataDomain::Afm, + } + } + + /// The user-facing label in the Data list: the custom name if one was set + /// via rename, otherwise the derived `[kind] summary`. + pub fn display_name(&self) -> String { + let custom = match self { + Dataset::Nmr(d) => d.name.clone(), + Dataset::Nmr2D(d) => d.name.clone(), + Dataset::Table(d) => d.name.clone(), + Dataset::Electrophysiology(d) => d.name.clone(), + Dataset::Afm(d) => d.name.clone(), + }; + custom.unwrap_or_else(|| format!("[{}] {}", self.kind_label(), self.summary())) + } + + pub fn set_name(&mut self, name: Option) { + match self { + Dataset::Nmr(d) => d.name = name, + Dataset::Nmr2D(d) => d.name = name, + Dataset::Table(d) => d.name = name, + Dataset::Electrophysiology(d) => d.name = name, + Dataset::Afm(d) => d.name = name, + } + } + + pub fn name(&self) -> Option { + match self { + Dataset::Nmr(d) => d.name.clone(), + Dataset::Nmr2D(d) => d.name.clone(), + Dataset::Table(d) => d.name.clone(), + Dataset::Electrophysiology(d) => d.name.clone(), + Dataset::Afm(d) => d.name.clone(), + } + } + + pub fn summary(&self) -> String { + match self { + Dataset::Nmr(d) => format!( + "{} · {} pts · {:.2} MHz", + d.data.nucleus, + d.data.len(), + d.data.observe_freq_mhz + ), + Dataset::Nmr2D(d) => d.summary(), + Dataset::Table(d) => d.summary(), + Dataset::Electrophysiology(d) => format!( + "{} channels · {} sweeps · {:.3} kHz", + d.data.channels.len(), + d.data.sweeps.len(), + d.data.sample_rate_hz / 1_000.0 + ), + Dataset::Afm(d) => { + let curves = d + .data + .forces + .as_ref() + .map_or(0, |f| f.grid_width * f.grid_height); + format!("{} channels · {curves} force curves", d.data.images.len()) + } + } + } + + pub fn as_nmr_mut(&mut self) -> Option<&mut NmrDataset> { + match self { + Dataset::Nmr(d) => Some(d), + _ => None, + } + } + + pub fn as_nmr(&self) -> Option<&NmrDataset> { + match self { + Dataset::Nmr(d) => Some(d), + _ => None, + } + } + + pub fn as_nmr2d_mut(&mut self) -> Option<&mut Nmr2DDataset> { + match self { + Dataset::Nmr2D(d) => Some(d), + _ => None, + } + } + + pub fn as_nmr2d(&self) -> Option<&Nmr2DDataset> { + match self { + Dataset::Nmr2D(d) => Some(d), + _ => None, + } + } + + pub fn as_table_mut(&mut self) -> Option<&mut TableDataset> { + match self { + Dataset::Table(d) => Some(d), + _ => None, + } + } + + pub fn as_table(&self) -> Option<&TableDataset> { + match self { + Dataset::Table(d) => Some(d), + _ => None, + } + } + + /// The dataset's peak set, for domains that carry one (1D spectra and tables). + pub fn peaks(&self) -> Option<&PeakSet> { + match self { + Dataset::Nmr(d) => Some(&d.peaks), + Dataset::Table(d) => Some(&d.peaks), + Dataset::Nmr2D(_) => None, + Dataset::Electrophysiology(_) => None, + Dataset::Afm(_) => None, + } + } + + pub fn peaks_mut(&mut self) -> Option<&mut PeakSet> { + match self { + Dataset::Nmr(d) => Some(&mut d.peaks), + Dataset::Table(d) => Some(&mut d.peaks), + Dataset::Nmr2D(_) => None, + Dataset::Electrophysiology(_) => None, + Dataset::Afm(_) => None, + } + } + + /// The dataset's stored lineshape deconvolutions, for domains with a 1D trace. + pub fn line_fits(&self) -> &[StoredLineFit] { + match self { + Dataset::Nmr(d) => &d.line_fits, + Dataset::Table(d) => &d.line_fits, + Dataset::Nmr2D(_) => &[], + Dataset::Electrophysiology(_) => &[], + Dataset::Afm(_) => &[], + } + } + + pub fn line_fits_mut(&mut self) -> Option<&mut Vec> { + match self { + Dataset::Nmr(d) => Some(&mut d.line_fits), + Dataset::Table(d) => Some(&mut d.line_fits), + Dataset::Nmr2D(_) => None, + Dataset::Electrophysiology(_) => None, + Dataset::Afm(_) => None, + } + } + + pub fn next_line_fit_id_mut(&mut self) -> Option<&mut u64> { + match self { + Dataset::Nmr(d) => Some(&mut d.next_line_fit_id), + Dataset::Table(d) => Some(&mut d.next_line_fit_id), + Dataset::Nmr2D(_) => None, + Dataset::Electrophysiology(_) => None, + Dataset::Afm(_) => None, + } + } + + /// The dataset's stored multiplet analyses (1D NMR only: J values need an + /// observe frequency to convert to Hz). + pub fn multiplets(&self) -> &[StoredMultiplet] { + match self { + Dataset::Nmr(d) => &d.multiplets, + _ => &[], + } + } + + pub fn multiplets_mut(&mut self) -> Option<&mut Vec> { + match self { + Dataset::Nmr(d) => Some(&mut d.multiplets), + _ => None, + } + } + + pub fn next_multiplet_id_mut(&mut self) -> Option<&mut u64> { + match self { + Dataset::Nmr(d) => Some(&mut d.next_multiplet_id), + _ => None, + } + } + + pub fn supports_region_analysis(&self) -> bool { + matches!(self, Dataset::Nmr2D(d) if d.is_pseudo()) + } + + pub fn tool_groups(&self) -> &'static [ToolGroup] { + match self { + Dataset::Nmr(_) => &[ + ToolGroup::Processing, + ToolGroup::Nmr1dAnalysis, + ToolGroup::Peaks, + ToolGroup::LineFit, + ], + Dataset::Nmr2D(_) if self.supports_region_analysis() => &[ + ToolGroup::Processing, + ToolGroup::Nmr2dExperiment, + ToolGroup::RegionAnalysis, + ], + Dataset::Nmr2D(_) => &[ToolGroup::Processing, ToolGroup::Nmr2dExperiment], + Dataset::Table(_) => &[ + ToolGroup::Peaks, + ToolGroup::CurveFit, + ToolGroup::LineFit, + ToolGroup::Statistics, + ], + Dataset::Electrophysiology(_) => &[ToolGroup::Electrophysiology], + Dataset::Afm(_) => &[], + } + } + + /// The phaseable/processable axes for this dataset: 1D and a stack expose the + /// direct axis only; a true-2D spectrum exposes both F2 and F1; a table has + /// no frequency axis to phase. + pub fn phase_axes(&self) -> &'static [PhaseAxis] { + match self { + Dataset::Nmr(_) => &[PhaseAxis::Direct], + Dataset::Nmr2D(n) if n.is_true_2d() => &[PhaseAxis::F2, PhaseAxis::F1], + Dataset::Nmr2D(_) => &[PhaseAxis::F2], + Dataset::Table(_) => &[], + Dataset::Electrophysiology(_) => &[], + Dataset::Afm(_) => &[], + } + } + + pub fn active_phase_axis(&self, requested: PhaseAxis) -> PhaseAxis { + let axes = self.phase_axes(); + if axes.contains(&requested) { + requested + } else { + *axes.first().unwrap_or(&PhaseAxis::Direct) + } + } + + pub fn axis_pipeline_mut(&mut self, axis: PhaseAxis) -> Option<&mut AxisPipeline> { + match self { + Dataset::Nmr(n) if axis == PhaseAxis::Direct => Some(n.pipeline_mut()), + Dataset::Nmr2D(n) => n.axis_mut(axis), + _ => None, + } + } + + pub fn axis_pipeline(&self, axis: PhaseAxis) -> Option<&AxisPipeline> { + match self { + Dataset::Nmr(n) if axis == PhaseAxis::Direct => Some(n.pipeline()), + Dataset::Nmr2D(n) => match (axis, &n.processed) { + (PhaseAxis::F2, _) => Some(&n.params.f2), + (PhaseAxis::F1, Processed2D::Ft(_)) => Some(&n.params.f1), + _ => None, + }, + _ => None, + } + } + + /// The recipe this dataset would be loaded with today, for one axis. + /// + /// Produced by re-running the very factory that built the live one, so "what + /// does reset give me" and "what does a new document get" stay one answer + /// rather than two derivations that agree only until one of them changes. + pub fn factory_pipeline(&self, axis: PhaseAxis) -> Option { + match self { + Dataset::Nmr(n) if axis == PhaseAxis::Direct => Some(match n.data.domain { + Domain::Time => AxisPipeline::default_1d(), + Domain::Frequency => AxisPipeline::frequency_1d(), + }), + Dataset::Nmr2D(n) => { + let params = match n.data.domain { + Domain::Time => Params2D::default_for(n.preset), + Domain::Frequency => Params2D::frequency_domain(n.preset), + }; + Some(match axis { + PhaseAxis::F1 => params.f1, + _ => params.f2, + }) + } + _ => None, + } + } + + pub fn phase_params_mut(&mut self, axis: PhaseAxis) -> Option<&mut PhaseParams> { + self.axis_pipeline_mut(axis).and_then(|pipe| { + pipe.steps + .iter_mut() + .filter(|s| s.enabled) + .find_map(|s| match &mut s.kind { + StepKind::Phase(p) => Some(p), + _ => None, + }) + }) + } + + /// Parameters produced by the currently enabled automatic Phase step. + /// This mirrors the processing kernels so switching to manual is lossless. + pub fn automatic_phase_params(&self, axis: PhaseAxis) -> Option<(f64, f64, f64)> { + let pipe = self.axis_pipeline(axis)?; + let method = + pipe.steps + .iter() + .filter(|step| step.enabled) + .find_map(|step| match &step.kind { + StepKind::Phase(params) => params.auto, + _ => None, + })?; + match self { + Dataset::Nmr(n) => Some(plotx_processing::auto_phase(&n.base, method)), + Dataset::Nmr2D(n) => match &n.base { + Processed2D::Ft(s) => { + let peak_arg = s + .data + .iter() + .max_by(|a, b| a.norm().total_cmp(&b.norm())) + .map_or(0.0, |value| value.arg()); + let (f2, f1) = s.peak_pivot_fracs(); + Some((peak_arg, 0.0, if axis == PhaseAxis::F1 { f1 } else { f2 })) + } + Processed2D::Stack(s) if axis == PhaseAxis::F2 => { + let (phase0, phase1) = + plotx_processing::fft2::absorptive_phase(&s.traces).unwrap_or((0.0, 0.0)); + Some((phase0, phase1, s.peak_pivot_frac())) + } + Processed2D::Stack(_) => None, + }, + _ => None, + } + } + + pub fn pivot_ppm(&self, axis: PhaseAxis) -> Option { + match self { + Dataset::Nmr(n) if axis == PhaseAxis::Direct => Some(n.pivot_ppm()), + Dataset::Nmr2D(n) => n.pivot_ppm(axis), + _ => None, + } + } + + pub fn set_pivot_ppm(&mut self, axis: PhaseAxis, ppm: f64) { + match self { + Dataset::Nmr(n) if axis == PhaseAxis::Direct => n.set_pivot_ppm(ppm), + Dataset::Nmr2D(n) => n.set_pivot_ppm(axis, ppm), + _ => {} + } + } + + /// Re-express the same manual phase curve around a new ppm pivot. + pub fn repivot_ppm(&mut self, axis: PhaseAxis, ppm: f64) -> bool { + let Some((old_pivot, is_manual)) = self + .phase_params_mut(axis) + .map(|params| (params.pivot_frac, params.auto.is_none())) + else { + return false; + }; + if !is_manual { + return false; + } + self.set_pivot_ppm(axis, ppm); + let Some(params) = self.phase_params_mut(axis) else { + return false; + }; + let new_pivot = params.pivot_frac; + params.pivot_frac = old_pivot; + params.repivot(new_pivot); + new_pivot != old_pivot + } +} diff --git a/crates/core/src/state/mod.rs b/crates/core/src/state/mod.rs index 4cea14e..d5b1509 100644 --- a/crates/core/src/state/mod.rs +++ b/crates/core/src/state/mod.rs @@ -4,8 +4,9 @@ use crate::actions::{ }; use crate::export::{ExportDialogState, ExportFormat, ExportSettings}; use crate::{ - DosyMethod, IltParams, Integral2D, IntegralResult, PseudoDisplay, apply_peak_labels, - build_dosy_figure, build_figure, build_ilt_figure, build_stack_figure, extract_region_series, + DosyInvocation, DosyMethod, DosyResultProvenance, IltParams, Integral2D, IntegralResult, + PseudoDisplay, apply_peak_labels, build_dosy_figure, build_figure, build_ilt_figure, + build_stack_figure, extract_region_series, }; use plotx_analysis::diffusion::{DiffusionMap, diffusion_map}; use plotx_analysis::ilt::{IltResult, ilt_map, log_grid}; @@ -22,6 +23,7 @@ mod afm; mod app_impl; mod app_impl_align; mod app_impl_analysis; +mod app_impl_analysis_tables; #[cfg(test)] mod app_impl_analysis_tests; mod app_impl_arithmetic; @@ -47,6 +49,7 @@ mod dataset_trace; mod datasets; mod datasets_2d_figure; mod datasets_2d_maps; +mod datasets_dispatch; mod document; mod document_identity; mod electrophysiology; @@ -92,6 +95,7 @@ mod workflow_tab; pub use afm::*; pub use app_impl::*; pub use app_impl_align::*; +pub use app_impl_analysis::validate_ilt_params; pub use app_impl_linefit::LineFitJob; pub use app_state::*; pub use axis_overrides::*; @@ -100,6 +104,9 @@ pub use charts::*; pub use compute::*; pub use datasets::*; pub(crate) use datasets_2d_figure::build_processed_figure; +pub(crate) use datasets_2d_maps::{ + MONO_EXP_SNR_FRAC, dosy_data_fingerprint, ilt_provenance, mono_exp_provenance, +}; pub use document::*; pub use electrophysiology::*; pub use field::*; diff --git a/crates/core/src/state/ui_state.rs b/crates/core/src/state/ui_state.rs index b1074c0..87f998d 100644 --- a/crates/core/src/state/ui_state.rs +++ b/crates/core/src/state/ui_state.rs @@ -405,9 +405,15 @@ pub struct UiState { /// Parse/compile result for the exact editor source, so the DSL is not /// recompiled every frame while the text is unchanged. pub fit_model_editor_validation: Option, - /// Editable parameters for the ILT/CONTIN DOSY inversion (shared across - /// pseudo-2D datasets; baked into the map when a build runs). - pub ilt_params: IltParams, + /// The ILT/CONTIN parameters the user explicitly entered for the next build, + /// or `None` when they have not overridden anything. `None` is the load-bearing + /// state: it is what lets a build fall through to the target's stored result + /// provenance and then to the application default, so the three lifecycle + /// stages stay reachable on every entry point rather than only in the panel. + pub ilt_params: Option, + /// The dataset `ilt_params` was entered for. An explicit input belongs to one + /// target; without this, focusing another dataset would silently inherit it. + pub ilt_params_dataset: Option, /// The live position of the Slice tool, or `None` before it is placed. pub slice: Option, /// The chosen slice orientation for a true-2D spectrum (ignored for a stack, @@ -530,7 +536,8 @@ impl Default for UiState { fit_model_editor: None, fit_model_editor_status: String::new(), fit_model_editor_validation: None, - ilt_params: IltParams::default(), + ilt_params: None, + ilt_params_dataset: None, slice: None, slice_kind: plotx_processing::SliceKind::Row, proc_expanded_step: None, diff --git a/docs/src/content/docs/guides/pseudo-2d.md b/docs/src/content/docs/guides/pseudo-2d.md index 1acce1a..050aaa1 100644 --- a/docs/src/content/docs/guides/pseudo-2d.md +++ b/docs/src/content/docs/guides/pseudo-2d.md @@ -27,13 +27,35 @@ as relaxation (T1 or T2 — which one is your choice of fit model). - Bi-exponential and stretched-exponential - Linear -For DOSY, a regularized inverse Laplace transform (ILT) is also available -and can produce a full chemical-shift × diffusion map. Map builds run in the -background and you can keep working; **Cancel** discards one. A map cannot be -rebuilt for the same dataset until the current build finishes or is cancelled. -Changing the dataset's processing while a map is building cancels the build — -the map would no longer match the spectrum. The status bar tells you when -that happens; simply rebuild the map after the processing change. +For DOSY, the **Experiment** card on the **Analyze** tab also offers a +regularized inverse Laplace transform (ILT), which produces a full +chemical-shift × diffusion map. Map builds run in the background and you can +keep working; **Cancel** discards one. A map cannot be rebuilt for the same +dataset until the current build finishes or is cancelled. Changing the +dataset's processing while a map is building cancels the build — the map would +no longer match the spectrum. The status bar tells you when that happens; +simply rebuild the map after the processing change. Changing the processing +also discards a finished map for the same reason, so the plot falls back to the +stack until you rebuild. + +The ILT settings offered for the next build resolve in this order: a value you +type into the **Experiment** card for this dataset wins; otherwise the dataset's +previous ILT result supplies the settings it was built with; otherwise **λ** +comes from **Preferences → Processing** and the grid settings from PlotX's own +defaults. **λ** accepts `0.000001` to `1000`. + +Both kinds of DOSY map are saved in the `.plotx` project along with your +**Show** and **DOSY method** choices, so reopening a project puts the same plot +back on screen without rebuilding. If a saved map was built from data that the +project's stored processing no longer reproduces, PlotX keeps showing the saved +map and warns you — in the status bar as the project opens, and in the +**Experiment** card. If a saved map cannot be read back at all, PlotX shows the +stack instead and says so in the same two places. Rebuilding the map clears the +warning. + +Choosing **DOSY map** under **Show** while the selected **DOSY method** has no +map yet also draws the stack, and the **Experiment** card names the map to +build. ## Notes on intensities diff --git a/docs/src/content/docs/reference/file-formats.md b/docs/src/content/docs/reference/file-formats.md index 31abe29..fe89404 100644 --- a/docs/src/content/docs/reference/file-formats.md +++ b/docs/src/content/docs/reference/file-formats.md @@ -20,6 +20,13 @@ migrated silently. If that happens, update PlotX and reopen. **Preferences → General → Project backup copies** keeps previous saves as hidden files beside the project, so an accidental overwrite can be recovered. +For pseudo-2D DOSY, a project also holds the per-column and ILT maps +themselves, the ILT settings each was built with, your **Show** and **DOSY +method** choices, and enough about how each map was produced for PlotX to warn +you on reopening if the map no longer matches the data the project +reconstructs. The contour drawing is not stored — PlotX redraws it from the +saved map when the project opens. + ## `.plotxproc` processing recipes A `.plotxproc` file stores one processing pipeline, without any data — save a diff --git a/docs/src/content/docs/reference/preferences.md b/docs/src/content/docs/reference/preferences.md index c0bc06d..4e00d02 100644 --- a/docs/src/content/docs/reference/preferences.md +++ b/docs/src/content/docs/reference/preferences.md @@ -40,6 +40,14 @@ restores everything except your recent-files list. effect after a restart. Change it only if you see rendering problems on a multi-GPU machine. +## Processing + +- **Default ILT regularization (λ)** — the λ offered when you build an ILT DOSY + map for a dataset with no earlier ILT result to take it from. Accepts + `0.000001` to `1000`; the default is `0.01`. A λ typed into the **Experiment** + card applies to that dataset and leaves this default unchanged. See + [Pseudo-2D analysis](/guides/pseudo-2d/). + ## Export - **Embed view snapshots** — save each plot's on-screen view into the diff --git a/docs/src/content/docs/zh-cn/guides/pseudo-2d.md b/docs/src/content/docs/zh-cn/guides/pseudo-2d.md index 851a9da..a41fbc3 100644 --- a/docs/src/content/docs/zh-cn/guides/pseudo-2d.md +++ b/docs/src/content/docs/zh-cn/guides/pseudo-2d.md @@ -24,11 +24,27 @@ description: DOSY、T1、T2 弛豫分析与曲线拟合。 - 双指数与拉伸指数 - 线性 -DOSY 还可以使用正则化反拉普拉斯变换(ILT),可生成完整的 -化学位移 × 扩散系数图。图在后台构建,期间可继续操作;按**取消**可丢弃 -该构建。同一数据集在当前构建完成或取消前不能重复构建同类图。 -构建期间修改该数据集的处理参数会取消构建——否则图与谱图将不再对应。 -状态栏会在发生取消时提示;处理修改完成后重新构建即可。 +对 DOSY,**分析**选项卡的 **Experiment**(实验)卡片还提供正则化反拉普拉斯 +变换(ILT),可生成完整的化学位移 × 扩散系数图。图在后台构建,期间可继续 +操作;按**取消**可丢弃该构建。同一数据集在当前构建完成或取消前不能重复构建 +同类图。构建期间修改该数据集的处理参数会取消构建——否则图与谱图将不再对应。 +状态栏会在发生取消时提示;处理修改完成后重新构建即可。出于同样的原因,修改 +处理参数也会丢弃已经算好的图,画面会退回堆叠图,直到你重新构建。 + +下一次 ILT 构建所用的设置按以下顺序确定:你在 **Experiment** 卡片中为该数据集 +输入的值优先;其次采用该数据集上一次 ILT 结果所用的设置;都没有时,**λ** 取 +**Preferences → Processing** 中的默认值,网格参数取 PlotX 内置默认值。 +**λ** 的允许范围是 `0.000001` 到 `1000`。 + +两种 DOSY 图都会随 `.plotx` 项目一起保存,**Show** 和 **DOSY method** 的选择 +也一并保存,因此重新打开项目时无需重算即可看到同一张图。如果保存的图所依据的 +数据与项目中保存的处理流程重建出的数据不再一致,PlotX 会继续显示保存的图并给出 +警告——打开项目时出现在状态栏,同时也出现在 **Experiment** 卡片中。如果保存的图 +完全无法读回,PlotX 会改为显示堆叠图,并在同样两处说明。重新构建该图即可消除 +警告。 + +在 **Show** 中选择 **DOSY map**、而当前 **DOSY method** 尚无对应结果时,同样会 +显示堆叠图,**Experiment** 卡片会指明需要构建哪张图。 ## 关于强度的说明 diff --git a/docs/src/content/docs/zh-cn/reference/file-formats.md b/docs/src/content/docs/zh-cn/reference/file-formats.md index 23628e4..3d8cbc0 100644 --- a/docs/src/content/docs/zh-cn/reference/file-formats.md +++ b/docs/src/content/docs/zh-cn/reference/file-formats.md @@ -17,6 +17,11 @@ PlotX(或相反)时,文件会被拒绝并给出明确的"不支持的版 **Preferences → General → Project backup copies** 会在项目旁以隐藏文件 保留历史保存,误覆盖后可以恢复。 +对于伪 2D 的 DOSY,项目还会保存逐列图和 ILT 图本身、各自构建时所用的 ILT +设置、**Show** 与 **DOSY method** 的选择,以及关于各图如何得出的足够信息—— +重新打开项目时,若某张图与项目重建出的数据不再一致,PlotX 就据此发出警告。 +等高线绘图本身不保存;项目打开时,PlotX 会根据保存的图重新绘制。 + ## `.plotxproc` 处理配方 `.plotxproc` 文件保存一条处理管线,不含任何数据——配方保存一次即可在 diff --git a/docs/src/content/docs/zh-cn/reference/preferences.md b/docs/src/content/docs/zh-cn/reference/preferences.md index 0c8f669..706a7ae 100644 --- a/docs/src/content/docs/zh-cn/reference/preferences.md +++ b/docs/src/content/docs/zh-cn/reference/preferences.md @@ -33,6 +33,13 @@ description: 偏好设置窗口中的每一项设置,按类别列出。 - **Graphics processor**——PlotX 启动时申请的 GPU 类别;重启后生效。 仅在多 GPU 机器上出现渲染问题时才需要改动。 +## Processing(处理) + +- **Default ILT regularization (λ)**——为没有以往 ILT 结果可参照的数据集 + 构建 ILT DOSY 图时所用的 λ。允许范围 `0.000001` 到 `1000`,默认 `0.01`。 + 在 **Experiment**(实验)卡片中输入的 λ 只作用于该数据集,不会改动这里的 + 默认值。参见[伪 2D 分析](/zh-cn/guides/pseudo-2d/)。 + ## Export(导出) - **Embed view snapshots**——把每个图的屏幕视图保存进 `.plotx` 文件。