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` 文件。