diff --git a/crates/app/src/ui/canvas/phase.rs b/crates/app/src/ui/canvas/phase.rs index b700f8d..29389c5 100644 --- a/crates/app/src/ui/canvas/phase.rs +++ b/crates/app/src/ui/canvas/phase.rs @@ -21,7 +21,7 @@ pub(crate) fn displayed_phase_pivot_ppm( ) -> Option { match app.interaction() { Interaction::Phase(drag) - if drag.dataset == dataset + if drag.dataset == app.doc.datasets[dataset].resource_id() && drag.axis == axis && drag.kind == PhaseDragKind::Pivot => { @@ -150,7 +150,7 @@ pub(crate) fn handle_phase_drag( let gesture_before = DatasetProcessingState::from_dataset(&app.doc.datasets[di]); app.begin_interaction(Interaction::Phase(PhaseDrag { kind, - dataset: di, + dataset: app.doc.datasets[di].resource_id(), axis: ctx.axis, preview_pivot_ppm: (kind == PhaseDragKind::Pivot).then_some(ctx.pivot_ppm), gesture_before, @@ -161,7 +161,7 @@ pub(crate) fn handle_phase_drag( } if let Interaction::Phase(drag) = app.interaction() - && drag.dataset != di + && drag.dataset != app.doc.datasets[di].resource_id() { return false; } diff --git a/crates/app/src/ui/properties/readout.rs b/crates/app/src/ui/properties/readout.rs index f280d8a..3fe8368 100644 --- a/crates/app/src/ui/properties/readout.rs +++ b/crates/app/src/ui/properties/readout.rs @@ -53,6 +53,10 @@ fn peak_floor(readout: &ContourBaseReadout) -> String { pub(crate) fn summary(readout: &ContourBaseReadout) -> String { let expression = anchor_expression(readout); match readout.anchor { + ContourAnchor::PhasePreview => match readout.lowest_level { + Some(level) => format!("Preview level {} — fixed while phasing", number(level)), + None => "Preview threshold fixed while phasing".into(), + }, // The estimator measured no spread at all. Reporting `5 × σ = 0` would // describe a blank plot, and the plot is not blank: the ladder falls // back to one derived from the field's own peak. Say that instead. @@ -163,6 +167,7 @@ fn value_summary(value: &PropertyValue) -> String { /// edits. `None` when the number is the level and there is nothing to add. pub(crate) fn resolution_suffix(readout: &ContourBaseReadout) -> Option { match readout.anchor { + ContourAnchor::PhasePreview => Some("fixed while phasing".into()), ContourAnchor::Degenerate => Some("no spread measured".to_owned()), ContourAnchor::Measuring => Some("measuring…".to_owned()), // The row's own unit reads "× noise floor", which is true of both terms. @@ -195,6 +200,9 @@ pub(crate) fn explanation(readout: &ContourBaseReadout) -> String { ); } let anchor = match readout.anchor { + ContourAnchor::PhasePreview => { + "The contour threshold is fixed during this phase gesture. It is recalculated from the final data when the gesture ends." + } ContourAnchor::Direct => "This level is set directly, so it needs no measurement.", // Only an anchor that *has* a floor may mention one; a background anchor // has none, and inventing one here would describe a rule it does not diff --git a/crates/core/src/actions/tests/interaction.rs b/crates/core/src/actions/tests/interaction.rs index de63bde..687084b 100644 --- a/crates/core/src/actions/tests/interaction.rs +++ b/crates/core/src/actions/tests/interaction.rs @@ -116,7 +116,7 @@ fn gesture_active_covers_only_the_board_freezing_drags() { ( Interaction::Phase(PhaseDrag { kind: PhaseDragKind::Ph0, - dataset: 0, + dataset: app.doc.datasets[0].resource_id(), axis: PhaseAxis::Direct, preview_pivot_ppm: None, gesture_before: crate::actions::DatasetProcessingState::from_dataset( @@ -215,7 +215,7 @@ fn toggling_manual_phase_cancels_the_in_flight_drag() { app.apply_dataset_edit(0); app.set_interaction(Interaction::Phase(PhaseDrag { kind: PhaseDragKind::Ph0, - dataset: 0, + dataset: app.doc.datasets[0].resource_id(), axis: PhaseAxis::Direct, preview_pivot_ppm: None, gesture_before: before, diff --git a/crates/core/src/contour_probe.rs b/crates/core/src/contour_probe.rs index 3517a34..b5ff792 100644 --- a/crates/core/src/contour_probe.rs +++ b/crates/core/src/contour_probe.rs @@ -17,6 +17,26 @@ use std::cell::Cell; +pub(crate) struct Timer(&'static str, std::time::Instant); + +impl Timer { + pub(crate) fn new(stage: &'static str) -> Self { + Self(stage, std::time::Instant::now()) + } +} + +impl Drop for Timer { + fn drop(&mut self) { + if std::env::var_os("PLOTX_BENCH_PHASE_TIMING").is_some() { + eprintln!( + "{}: {:.3} ms", + self.0, + self.1.elapsed().as_secs_f64() * 1000.0 + ); + } + } +} + thread_local! { static MARCHING_SQUARES: Cell = const { Cell::new(0) }; static QUEUED_CONTOUR_BUILDS: Cell = const { Cell::new(0) }; diff --git a/crates/core/src/properties/readout.rs b/crates/core/src/properties/readout.rs index 70f4b73..3379ea8 100644 --- a/crates/core/src/properties/readout.rs +++ b/crates/core/src/properties/readout.rs @@ -74,6 +74,8 @@ pub(crate) fn uniform_readout( /// not when it cannot. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ContourAnchor { + /// A manual phase gesture is temporarily holding the resolved threshold. + PhasePreview, /// The magnitude needs no measurement: it is already a level, or a fraction /// of a range the field summary knows. Direct, @@ -149,39 +151,46 @@ pub(crate) fn contour_base_readout( })?, }; let summary = app.session.compute.peek_field_summary(source); - let anchor = match &spec.positive.base { - ContourBasePolicy::Absolute(_) | ContourBasePolicy::FractionOfRange(_) => { - ContourAnchor::Direct + let preview = summary.and_then(|summary| app.session.phase_preview.peek(source, spec, summary)); + let anchor = if preview.is_some() { + ContourAnchor::PhasePreview + } else { + match &spec.positive.base { + ContourBasePolicy::Absolute(_) | ContourBasePolicy::FractionOfRange(_) => { + ContourAnchor::Direct + } + ContourBasePolicy::NoiseFloor { + peak_fraction, + estimator, + .. + } => floored_anchor_of( + app, + &EstimateKey { + source, + kind: EstimateKind::Noise, + estimator: estimator.clone(), + }, + *peak_fraction, + summary, + ), + ContourBasePolicy::BackgroundScale { estimator, .. } => anchor_of( + app, + &EstimateKey { + source, + kind: EstimateKind::Background, + estimator: estimator.clone(), + }, + ), } - ContourBasePolicy::NoiseFloor { - peak_fraction, - estimator, - .. - } => floored_anchor_of( - app, - &EstimateKey { - source, - kind: EstimateKind::Noise, - estimator: estimator.clone(), - }, - *peak_fraction, - summary, - ), - ContourBasePolicy::BackgroundScale { estimator, .. } => anchor_of( - app, - &EstimateKey { - source, - kind: EstimateKind::Background, - estimator: estimator.clone(), - }, - ), }; // Resolution is pure arithmetic over a cached summary and cached // estimates; the payload is never touched. A miss simply yields // `Pending`, which is reported rather than acted on. let lowest_level = summary.and_then(|summary| { - match resolve_contour_levels(source, spec, summary, |key| { - app.session.compute.peek_estimate(key).cloned() + match preview.unwrap_or_else(|| { + resolve_contour_levels(source, spec, summary, |key| { + app.session.compute.peek_estimate(key).cloned() + }) }) { ContourResolution::Ready { levels, .. } => { levels.positive.first().map(|level| level.get()) diff --git a/crates/core/src/properties/readout_tests.rs b/crates/core/src/properties/readout_tests.rs index 11bd87c..f3a6fb0 100644 --- a/crates/core/src/properties/readout_tests.rs +++ b/crates/core/src/properties/readout_tests.rs @@ -133,6 +133,36 @@ fn a_cached_estimate_resolves_the_level_and_reading_it_stays_free() { ); } +#[test] +fn phase_preview_readout_reports_fixed_threshold_without_starting_measurements() { + let (mut app, target) = contour_app(); + warm(&mut app); + let before = contour_readout(&app, &target).lowest_level; + app.begin_property_gesture(crate::properties::phase::PHASE0); + app.doc.datasets[0] + .phase_params_mut(crate::state::PhaseAxis::F2) + .unwrap() + .phase0 = 0.2; + app.apply_dataset_edit(0); + warm(&mut app); + contour_probe::reset(); + for _ in 0..8 { + let readout = contour_readout(&app, &target); + assert_eq!(readout.anchor, ContourAnchor::PhasePreview); + assert_eq!(readout.lowest_level, before); + } + assert_eq!(contour_probe::queued_estimates(), 0); + assert_eq!(contour_probe::queued_contour_builds(), 0); + assert_eq!(contour_probe::field_payload_materializations(), 0); + app.end_property_gesture(); + app.poll_compute(); + warm(&mut app); + assert_ne!( + contour_readout(&app, &target).anchor, + ContourAnchor::PhasePreview + ); +} + /// §4.3, and the whole point of spelling the floor into the policy: when the /// floor is what the level came from, the readout says so rather than passing /// the level off as a multiple of an estimate that did not produce it. diff --git a/crates/core/src/state/app_impl.rs b/crates/core/src/state/app_impl.rs index f9a6b44..40e6038 100644 --- a/crates/core/src/state/app_impl.rs +++ b/crates/core/src/state/app_impl.rs @@ -79,6 +79,7 @@ impl PlotxApp { ..Default::default() }, compute: ComputeService::new(), + phase_preview: Default::default(), data_imports: DataImports::default(), updates: crate::update::UpdateService::new(&settings.updates), line_fit_job: None, @@ -266,6 +267,14 @@ impl PlotxApp { } pub fn rebuild_canvases_for(&mut self, dataset: usize) { + self.rebuild_canvases_matching(dataset, None); + } + + pub(super) fn rebuild_canvases_for_field(&mut self, dataset: usize, field: FieldRef) { + self.rebuild_canvases_matching(dataset, Some(field)); + } + + fn rebuild_canvases_matching(&mut self, dataset: usize, field: Option) { let Some(dataset_id) = self.doc.datasets.get(dataset).map(Dataset::resource_id) else { return; }; @@ -276,7 +285,17 @@ impl PlotxApp { .filter_map(|object| { object .plot() - .filter(|plot| plot.binding.contains_dataset(dataset_id)) + .filter(|plot| { + self.display_binding(plot.display_owner, &plot.binding) + .series + .iter() + .any(|series| { + series.visible + && series.source.resource == dataset_id + && field + .is_none_or(|field| field.field == series.source.field) + }) + }) .map(|_| object.id) }) .collect(); @@ -294,6 +313,28 @@ impl PlotxApp { ) }; let size = [frame.width / MM_TO_PT, frame.height / MM_TO_PT]; + let displayed = self.display_binding(owner, &binding); + // Queue missing artifacts before building projections/overlays: + // the entire new figure would be discarded while they wait. + for series in displayed.series.iter().filter(|series| { + series.visible + && matches!(series.encoding, plotx_figure::SeriesEncoding::Contour(_)) + }) { + self.prepare_contour_series(series); + } + // Processing promotes the field before its estimates and geometry + // are ready. Keep the complete displayed frame through both jobs, + // including projections and overlays, instead of flashing blank. + // Explicit plot edits use their own rebuild path and still apply + // immediately; this only defers background data refreshes. + if self.binding_contours_pending(&displayed) + && self.doc.canvases[ci] + .object(id) + .and_then(|object| object.plot()) + .is_some_and(|plot| !plot.figure().contours.is_empty()) + { + continue; + } let fig = self.build_object_figure(owner, &binding, &chart, &stack, &projections, size); self.apply_viewport_to_plot_object(ci, id, fig); diff --git a/crates/core/src/state/app_impl_compute.rs b/crates/core/src/state/app_impl_compute.rs index 9c85cec..a980cd4 100644 --- a/crates/core/src/state/app_impl_compute.rs +++ b/crates/core/src/state/app_impl_compute.rs @@ -363,6 +363,7 @@ impl PlotxApp { /// Returns whether work is still outstanding (so the shell keeps repainting /// until it lands). pub fn poll_compute(&mut self) -> bool { + self.finish_phase_previews(); for done in self.session.compute.try_drain() { match done { Done::Ilt { @@ -590,11 +591,15 @@ impl PlotxApp { "Processing changed and invalidated the selected DOSY map", ); for field in fields { + if let Some(grid) = field.grid { + self.session.compute.remember_field_grid(field.source, grid); + } self.session .compute .promote_field_version(field.source, field.summary); } self.initialize_nmr_result_bindings(dataset, previous_field); + self.thaw_phase_preview(self.doc.datasets[dataset].resource_id()); self.recompute_integrals_2d_after_processing(dataset); self.rebuild_canvases_for(dataset); self.mark_document_dirty(); @@ -603,6 +608,9 @@ impl PlotxApp { Done::Processing2DFailed { dataset, message, .. } => { + // A failed gesture has no new field to finalize. Ending it + // must not enqueue an old-field refresh over this error. + self.session.phase_preview.discard(dataset); if self.doc.dataset_index(dataset).is_some() { self.session.status = format!("2D processing failed: {message}"); } @@ -618,13 +626,14 @@ impl PlotxApp { }); let current = dataset .and_then(|_| self.session.compute.current_field_version(key.source.field)); + let field = key.source.field; if self.session.compute.finish_estimate(key, result, current) && let Some(dataset) = dataset { // The completed job only populated a content-addressed // cache. Rebuilding resolves each binding's current key; // it never writes a worker result into a plot directly. - self.rebuild_canvases_for(dataset); + self.rebuild_canvases_for_field(dataset, field); } } Done::EstimateFieldFailed { key, message } => { @@ -654,10 +663,11 @@ impl PlotxApp { }); let current = dataset .and_then(|_| self.session.compute.current_field_version(key.source.field)); + let field = key.source.field; if self.session.compute.finish_contour(key, geometry, current) && let Some(dataset) = dataset { - self.rebuild_canvases_for(dataset); + self.rebuild_canvases_for_field(dataset, field); } } Done::BuildContourFailed { key, message } => { @@ -678,6 +688,9 @@ impl PlotxApp { } Done::Cancelled { .. } => {} Done::Failed { dataset, kind, .. } => { + if kind == ComputeKind::Processing2D { + self.session.phase_preview.discard(dataset); + } let name = self .doc .dataset_index(dataset) @@ -705,6 +718,7 @@ impl PlotxApp { /// coalesced by `ComputeService`; a time-domain change requests a new base, /// while a frequency-only change shares the immutable cached base. pub fn schedule_2d_processing(&mut self, dataset: usize, force_full: bool) -> bool { + self.prepare_phase_preview(dataset); let Some(d2) = self.doc.datasets.get(dataset).and_then(Dataset::as_nmr2d) else { return false; }; diff --git a/crates/core/src/state/app_impl_figures.rs b/crates/core/src/state/app_impl_figures.rs index 9235539..83b4937 100644 --- a/crates/core/src/state/app_impl_figures.rs +++ b/crates/core/src/state/app_impl_figures.rs @@ -3,6 +3,56 @@ use plotx_figure::{Color, Figure, RangeAnnotation}; use std::sync::Arc; impl PlotxApp { + /// Only queued work for the displayed binding can defer a data refresh. + /// Empty completed geometry and unavailable fields must still replace it. + pub(super) fn binding_contours_pending(&mut self, binding: &DataBinding) -> bool { + binding + .series + .iter() + .filter(|series| series.visible) + .any(|series| { + let plotx_figure::SeriesEncoding::Contour(spec) = &series.encoding else { + return false; + }; + if series.source.item.is_some() + || !self + .doc + .dataset_by_id(series.source.resource) + .is_some_and(|dataset| { + dataset.supports_encoding(series.source.field, &series.encoding) + }) + { + return false; + } + let field = FieldRef { + resource: series.source.resource, + field: series.source.field, + }; + let Some(version) = self.session.compute.current_field_version(field) else { + return false; + }; + let source = VersionedFieldRef { field, version }; + let Some(summary) = self.session.compute.cached_field_summary(source) else { + return false; + }; + match self.session.phase_preview.resolve( + &mut self.session.compute, + source, + spec, + summary, + ) { + ContourResolution::Pending(keys) => keys + .iter() + .any(|key| self.session.compute.estimate_in_flight(key)), + ContourResolution::Ready { levels, .. } => self + .session + .compute + .geometry_in_flight(&ContourGeometryCacheKey { source, levels }), + ContourResolution::Unavailable => false, + } + }) + } + /// Project a live plot's persisted binding onto its current owner field. /// /// Alternate fields belonging to the owner remain persisted so switching @@ -369,6 +419,18 @@ impl PlotxApp { } pub(super) fn build_encoded_series_figure(&mut self, series: &SeriesBinding) -> Option
{ + self.resolve_encoded_series(series, false) + } + + pub(super) fn prepare_contour_series(&mut self, series: &SeriesBinding) { + drop(self.resolve_encoded_series(series, true)); + } + + fn resolve_encoded_series( + &mut self, + series: &SeriesBinding, + prepare_only: bool, + ) -> Option
{ let dataset = self.doc.dataset_by_id(series.source.resource)?; if let Some(item) = series.source.item { return dataset.trace_item_figure(series.source.field, item); @@ -403,9 +465,10 @@ impl PlotxApp { snapshot.summary? } }; - let resolution = resolve_contour_levels(source, contour, summary, |key| { - self.session.compute.estimate_for(key).cloned() - }); + let resolution = + self.session + .phase_preview + .resolve(&mut self.session.compute, source, contour, summary); match resolution { ContourResolution::Ready { levels, @@ -419,6 +482,9 @@ impl PlotxApp { } let key = ContourGeometryCacheKey { source, levels }; if let Some(geometry) = self.session.compute.geometry_for(&key) { + if prepare_only { + return None; + } // A capped build drew fewer levels than the panel lists. // Saying so is the difference between a contour the user // chose and one the renderer silently cut down. @@ -437,7 +503,9 @@ impl PlotxApp { // clone the whole plane each time only for the enqueue to // recognize the duplicate and drop it. if !self.session.compute.geometry_in_flight(&key) { - let grid = self.contour_grid(dataset, series.source.field, version, summary)?; + let grid = self.session.compute.cached_field_grid(source).or_else(|| { + self.contour_grid(dataset, series.source.field, version, summary) + })?; if let Err(error) = self.session.compute.enqueue_contour(key, grid) { self.session.status = field_enqueue_error_status(error); return dataset.encoded_field_figure(series.source.field, &series.encoding); @@ -462,7 +530,9 @@ impl PlotxApp { .iter() .any(|key| !self.session.compute.estimate_in_flight(key)) { - let grid = self.contour_grid(dataset, series.source.field, version, summary)?; + let grid = self.session.compute.cached_field_grid(source).or_else(|| { + self.contour_grid(dataset, series.source.field, version, summary) + })?; for key in keys { if let Err(error) = self .session @@ -481,6 +551,9 @@ impl PlotxApp { self.session.status = "Contour levels are unavailable for this field.".into(); } } + if prepare_only { + return None; + } dataset.encoded_field_figure(series.source.field, &series.encoding) } diff --git a/crates/core/src/state/app_impl_interaction.rs b/crates/core/src/state/app_impl_interaction.rs index bd0434d..bb83ff7 100644 --- a/crates/core/src/state/app_impl_interaction.rs +++ b/crates/core/src/state/app_impl_interaction.rs @@ -35,8 +35,9 @@ impl PlotxApp { pub fn cancel_interaction(&mut self) { match self.take_interaction() { Interaction::Phase(drag) => { - if let Err(error) = - self.set_dataset_processing_state(drag.dataset, &drag.gesture_before) + if let Some(dataset) = self.doc.dataset_index(drag.dataset) + && let Err(error) = + self.set_dataset_processing_state(dataset, &drag.gesture_before) { self.session.status = error; } diff --git a/crates/core/src/state/compute.rs b/crates/core/src/state/compute.rs index 3765ede..58c0866 100644 --- a/crates/core/src/state/compute.rs +++ b/crates/core/src/state/compute.rs @@ -29,6 +29,8 @@ pub(crate) use compute_field::FieldEnqueueError; #[path = "compute_worker.rs"] mod compute_worker; use compute_worker::run_job; +#[path = "compute_results.rs"] +mod compute_results; /// Which user-visible heavy operation is running. ILT/DOSY retain their own /// generation guard; scalar field artifacts use `FieldVersion` and @@ -83,10 +85,11 @@ struct VersionedProcessingField { component: ProcessedFieldComponent, } -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Debug)] pub struct ProcessedFieldArtifact { pub source: VersionedFieldRef, pub summary: Option, + pub grid: Option>, } enum Job { @@ -277,6 +280,7 @@ pub struct ComputeService { latest: HashMap<(DatasetId, ComputeKind), u64>, active: HashMap<(DatasetId, ComputeKind), ActiveJob>, deferred_processing: HashMap, + completed_processing: Vec, field_runtime: FieldRuntime, /// Dispatch failures awaiting collection by `try_drain`. failures: Vec, @@ -304,6 +308,7 @@ impl ComputeService { latest: HashMap::new(), active: HashMap::new(), deferred_processing: HashMap::new(), + completed_processing: Vec::new(), field_runtime: FieldRuntime::default(), failures: Vec::new(), } @@ -578,58 +583,6 @@ impl ComputeService { } } - pub fn try_drain(&mut self) -> Vec { - self.dispatch_ready_processing(); - let mut out = std::mem::take(&mut self.failures); - while let Ok(done) = self.done_rx.try_recv() { - match &done { - Done::EstimateField { key, .. } | Done::EstimateFieldFailed { key, .. } => { - self.field_runtime.finish_estimate_request(key); - out.push(done); - continue; - } - Done::BuildContour { key, .. } | Done::BuildContourFailed { key, .. } => { - self.field_runtime.finish_geometry_request(key); - out.push(done); - continue; - } - Done::Ilt { .. } - | Done::Dosy { .. } - | Done::Craft { .. } - | Done::CraftFailed { .. } - | Done::Processing2D { .. } - | Done::Processing2DFailed { .. } - | Done::Cancelled { .. } - | Done::Failed { .. } => {} - } - let Some((dataset, kind, generation)) = done_identity(&done) else { - continue; - }; - let matching_active = self - .active - .get(&(dataset, kind)) - .filter(|active| active.generation == generation); - if kind == ComputeKind::Processing2D && matching_active.is_none() { - continue; - } - // A worker can send success immediately before cancellation. Check - // the shared token again on the receiving side so explicit cancel, - // Full/Reapply replacement, and dataset invalidation cannot install - // that already-queued success. - let cancelled_after_send = - matching_active.is_some_and(|active| active.token.is_cancelled()); - if matching_active.is_some() { - self.active.remove(&(dataset, kind)); - } - if !cancelled_after_send && !matches!(done, Done::Cancelled { .. }) { - out.push(done); - } - } - self.dispatch_ready_processing(); - out.append(&mut self.failures); - out - } - pub fn is_busy(&self) -> bool { !self.active.is_empty() || !self.deferred_processing.is_empty() @@ -738,51 +691,6 @@ fn worker_loop(job_rx: Arc>>, done_tx: Sender) { } } -fn done_identity(done: &Done) -> Option<(DatasetId, ComputeKind, u64)> { - match done { - Done::Ilt { - dataset, - generation, - .. - } => Some((*dataset, ComputeKind::Ilt, *generation)), - Done::Dosy { - dataset, - generation, - .. - } => Some((*dataset, ComputeKind::Dosy, *generation)), - Done::Craft { - dataset, - generation, - .. - } - | Done::CraftFailed { - dataset, - generation, - .. - } => Some((*dataset, ComputeKind::Craft, *generation)), - Done::Processing2D { - dataset, version, .. - } - | Done::Processing2DFailed { - dataset, version, .. - } => Some((*dataset, ComputeKind::Processing2D, version.0)), - Done::Cancelled { - dataset, - generation, - kind, - } - | Done::Failed { - dataset, - generation, - kind, - } => Some((*dataset, *kind, *generation)), - Done::EstimateField { .. } - | Done::EstimateFieldFailed { .. } - | Done::BuildContour { .. } - | Done::BuildContourFailed { .. } => None, - } -} - #[cfg(test)] mod tests; diff --git a/crates/core/src/state/compute/tests.rs b/crates/core/src/state/compute/tests.rs index 2406510..64eb8e8 100644 --- a/crates/core/src/state/compute/tests.rs +++ b/crates/core/src/state/compute/tests.rs @@ -364,3 +364,167 @@ fn cancelled_ilt_job_reports_acknowledgement_without_a_result() { } if id == dataset(2) )); } + +#[test] +fn reapply_overlaps_geometry_but_waits_to_deliver_then_dispatches_latest_recipe() { + let mut service = ComputeService::new(); + let source = VersionedFieldRef { + field: FieldRef { + resource: dataset(0), + field: FieldId::new(0), + }, + version: FieldVersion(1), + }; + let key = ContourGeometryCacheKey { + source, + levels: crate::state::ResolvedContourLevels { + positive: Arc::from([]), + negative: Arc::from([]), + }, + }; + assert!(service.field_runtime.begin_geometry(key.clone())); + let base = execute_2d( + &data_2d().source, + &Params2D::default_for(Preset2D::Cosy), + DelayPolicy::Disabled, + RecipeRange::Base, + None, + &mut nmr::ExecutionContext::default(), + ) + .unwrap() + .source; + for _ in 0..20 { + service + .request_2d_reapply( + dataset(0), + &processing_fields(), + base.clone(), + Params2D::default_for(Preset2D::Cosy), + ) + .unwrap(); + assert!( + service + .active + .contains_key(&(dataset(0), ComputeKind::Processing2D)) + ); + } + let latest = service.deferred_processing[&dataset(0)].version; + // Another dataset is independent of this preview's downstream work. + service + .request_2d_reapply( + dataset(1), + &processing_fields(), + base, + Params2D::default_for(Preset2D::Cosy), + ) + .unwrap(); + assert!( + service + .active + .contains_key(&(dataset(1), ComputeKind::Processing2D)) + ); + let deadline = Instant::now() + Duration::from_secs(2); + while service.completed_processing.is_empty() { + assert!(Instant::now() < deadline); + assert!(!service.try_drain().iter().any( + |done| matches!(done, Done::Processing2D { dataset: id, .. } if *id == dataset(0)) + )); + thread::sleep(Duration::from_millis(1)); + } + service.field_runtime.finish_geometry_request(&key); + let delivered = service.try_drain(); + assert!( + delivered.iter().any( + |done| matches!(done, Done::Processing2D { dataset: id, .. } if *id == dataset(0)) + ) + ); + assert_eq!( + service.active[&(dataset(0), ComputeKind::Processing2D)].generation, + latest.0 + ); + assert!(!service.deferred_processing.contains_key(&dataset(0))); +} + +#[test] +fn held_processing_respects_estimate_to_geometry_boundary_and_cancellation() { + for cancel in [false, true] { + let mut service = ComputeService::new(); + let (done_tx, done_rx) = std::sync::mpsc::channel(); + service.done_rx = done_rx; + let params = Params2D::default_for(Preset2D::Cosy); + let processed = execute_2d( + &data_2d().source, + ¶ms, + DelayPolicy::Disabled, + RecipeRange::All, + None, + &mut nmr::ExecutionContext::default(), + ) + .unwrap(); + let source = VersionedFieldRef { + field: FieldRef { + resource: dataset(0), + field: FieldId::new(0), + }, + version: FieldVersion(1), + }; + let estimate = EstimateKey { + source, + kind: crate::state::EstimateKind::Noise, + estimator: plotx_figure::EstimatorSelection::FollowLatest, + }; + service.field_runtime.begin_estimate(estimate.clone()); + service.active.insert( + (dataset(0), ComputeKind::Processing2D), + ActiveJob { + generation: 2, + started_at: Instant::now(), + token: CancellationToken::new(), + processing_input: Some(ProcessingInputKind::Reapply), + }, + ); + done_tx + .send(Done::EstimateField { + key: estimate, + result: EstimateResult::Scale(crate::state::ScaleEstimate { + scale: crate::state::EstimatedScale::Degenerate, + provenance: crate::state::EstimateProvenance { + estimator: "test".into(), + version: 1, + }, + }), + }) + .unwrap(); + done_tx + .send(Done::Processing2D { + version: FieldVersion(2), + dataset: dataset(0), + base: None, + processed, + fields: vec![], + params, + }) + .unwrap(); + let delivered = service.try_drain(); + assert_eq!(delivered.len(), 1); + assert!(matches!(delivered[0], Done::EstimateField { .. })); + assert_eq!(service.completed_processing.len(), 1); + // The app resolves the estimate and queues geometry between polls. + let geometry = ContourGeometryCacheKey { + source, + levels: crate::state::ResolvedContourLevels { + positive: Arc::from([]), + negative: Arc::from([]), + }, + }; + service.field_runtime.begin_geometry(geometry.clone()); + assert!(service.try_drain().is_empty()); + if cancel { + assert!(service.cancel(dataset(0), ComputeKind::Processing2D)); + } + service.field_runtime.finish_geometry_request(&geometry); + let delivered = service.try_drain(); + assert_eq!(delivered.len(), usize::from(!cancel)); + assert!(!service.is_busy()); + } +} diff --git a/crates/core/src/state/compute_field.rs b/crates/core/src/state/compute_field.rs index e5f449e..d392d59 100644 --- a/crates/core/src/state/compute_field.rs +++ b/crates/core/src/state/compute_field.rs @@ -21,6 +21,21 @@ pub enum FieldEnqueueError { } impl ComputeService { + pub(crate) fn cached_field_grid( + &mut self, + source: VersionedFieldRef, + ) -> Option> { + self.field_runtime.grid(source) + } + + pub(crate) fn remember_field_grid( + &mut self, + source: VersionedFieldRef, + grid: Arc, + ) { + self.field_runtime.remember_grid(source, grid); + } + /// Import/load boundary for a field provider that has no processing /// pipeline (for example an RGB image). It still receives a session version /// from `ComputeService`; payload type decides whether it has a summary or @@ -167,6 +182,7 @@ impl ComputeService { } #[cfg(test)] crate::contour_probe::record_queued_estimate(); + self.remember_field_grid(key.source, Arc::clone(&grid)); if self .job_tx .send(Job::EstimateField { @@ -193,6 +209,7 @@ impl ComputeService { } #[cfg(test)] crate::contour_probe::record_queued_contour_build(); + self.remember_field_grid(key.source, Arc::clone(&grid)); if self .job_tx .send(Job::BuildContour { @@ -230,6 +247,8 @@ pub(super) fn run_estimate_field( key: EstimateKey, grid: Arc, ) -> Result { + #[cfg(test)] + let _timer = crate::contour_probe::Timer::new("estimate"); if !grid.has_valid_shape() { return Err("scalar grid dimensions do not match its row-major values".to_owned()); } @@ -307,6 +326,8 @@ pub(super) fn run_build_contour( key: ContourGeometryCacheKey, grid: Arc, ) -> Result { + #[cfg(test)] + let _timer = crate::contour_probe::Timer::new("contour"); if !grid.has_valid_shape() { return Err("scalar grid dimensions do not match its row-major values".to_owned()); } diff --git a/crates/core/src/state/compute_results.rs b/crates/core/src/state/compute_results.rs new file mode 100644 index 0000000..360b46e --- /dev/null +++ b/crates/core/src/state/compute_results.rs @@ -0,0 +1,122 @@ +//! Deliver complete processing frames without starving their derived geometry. +use super::*; + +impl ComputeService { + pub fn try_drain(&mut self) -> Vec { + self.dispatch_ready_processing(); + let mut out = std::mem::take(&mut self.failures); + let waiting = std::mem::take(&mut self.completed_processing); + let done: Vec<_> = self.done_rx.try_iter().chain(waiting).collect(); + let mut field_completions = std::collections::HashSet::new(); + for done in done { + match &done { + Done::EstimateField { key, .. } | Done::EstimateFieldFailed { key, .. } => { + field_completions.insert(key.source.field.resource); + self.field_runtime.finish_estimate_request(key); + out.push(done); + continue; + } + Done::BuildContour { key, .. } | Done::BuildContourFailed { key, .. } => { + field_completions.insert(key.source.field.resource); + self.field_runtime.finish_geometry_request(key); + out.push(done); + continue; + } + Done::Ilt { .. } + | Done::Dosy { .. } + | Done::Craft { .. } + | Done::CraftFailed { .. } + | Done::Processing2D { .. } + | Done::Processing2DFailed { .. } + | Done::Cancelled { .. } + | Done::Failed { .. } => {} + } + let Some((dataset, kind, generation)) = done_identity(&done) else { + continue; + }; + let matching_active = self + .active + .get(&(dataset, kind)) + .filter(|active| active.generation == generation); + if kind == ComputeKind::Processing2D && matching_active.is_none() { + continue; + } + // A worker can send success immediately before cancellation. Check + // the shared token again on the receiving side so explicit cancel, + // Full/Reapply replacement, and dataset invalidation cannot install + // that already-queued success. + let cancelled_after_send = + matching_active.is_some_and(|active| active.token.is_cancelled()); + if !cancelled_after_send + && matches!( + done, + Done::Processing2D { .. } | Done::Processing2DFailed { .. } + ) + && (self.field_runtime.has_in_flight_for(dataset) + || field_completions.contains(&dataset)) + { + // Keep the active slot until delivery, bounding the pipeline to + // one completed result and one newest deferred recipe. The next + // processing pass may overlap geometry, but must not obsolete it. + self.completed_processing.push(done); + continue; + } + if matching_active.is_some() { + self.active.remove(&(dataset, kind)); + } + if !cancelled_after_send && !matches!(done, Done::Cancelled { .. }) { + out.push(done); + } + } + // A field completion may enqueue the next derived stage in the app. + // `field_completions` keeps held results behind that boundary as well. + self.dispatch_ready_processing(); + out.append(&mut self.failures); + out + } +} + +fn done_identity(done: &Done) -> Option<(DatasetId, ComputeKind, u64)> { + match done { + Done::Ilt { + dataset, + generation, + .. + } => Some((*dataset, ComputeKind::Ilt, *generation)), + Done::Dosy { + dataset, + generation, + .. + } => Some((*dataset, ComputeKind::Dosy, *generation)), + Done::Craft { + dataset, + generation, + .. + } + | Done::CraftFailed { + dataset, + generation, + .. + } => Some((*dataset, ComputeKind::Craft, *generation)), + Done::Processing2D { + dataset, version, .. + } + | Done::Processing2DFailed { + dataset, version, .. + } => Some((*dataset, ComputeKind::Processing2D, version.0)), + Done::Cancelled { + dataset, + generation, + kind, + } + | Done::Failed { + dataset, + generation, + kind, + } => Some((*dataset, *kind, *generation)), + Done::EstimateField { .. } + | Done::EstimateFieldFailed { .. } + | Done::BuildContour { .. } + | Done::BuildContourFailed { .. } => None, + } +} diff --git a/crates/core/src/state/compute_worker.rs b/crates/core/src/state/compute_worker.rs index 6a89da4..c1ea96e 100644 --- a/crates/core/src/state/compute_worker.rs +++ b/crates/core/src/state/compute_worker.rs @@ -137,6 +137,8 @@ pub(super) fn run_job(job: Job) -> Done { let mut work = plotx_processing::nmr_execution::processing_2d_work_ledger(); let mut context = nmr::ExecutionContext::new(&mut work).with_cancellation(token.clone()); + #[cfg(test)] + let processing_timer = crate::contour_probe::Timer::new("processing + view"); let result = (|| match input { ProcessingInput::Full(input) => { let base = execute_2d( @@ -169,6 +171,8 @@ pub(super) fn run_job(job: Job) -> Done { Ok((None, processed)) } })(); + #[cfg(test)] + drop(processing_timer); let (base, processed) = match result { Ok(output) => output, Err(error) @@ -224,22 +228,25 @@ fn processed_field_artifacts( processed: &Processed2D, fields: &[VersionedProcessingField], ) -> Vec { + #[cfg(test)] + let _timer = crate::contour_probe::Timer::new("field artifacts"); fields .iter() .map(|field| { - let summary = match processed { + let grid = match processed { Processed2D::Ft(spectrum) => { let values = match field.component { ProcessedFieldComponent::Real => spectrum.real(), ProcessedFieldComponent::Magnitude => spectrum.magnitude(), }; - nmr_scalar_grid(spectrum, values).summary() + Some(Arc::new(nmr_scalar_grid(spectrum, values))) } Processed2D::Stack(_) => None, }; ProcessedFieldArtifact { source: field.source, - summary, + summary: grid.as_ref().and_then(|grid| grid.summary()), + grid, } }) .collect() diff --git a/crates/core/src/state/contour_resolution.rs b/crates/core/src/state/contour_resolution.rs new file mode 100644 index 0000000..7efdbad --- /dev/null +++ b/crates/core/src/state/contour_resolution.rs @@ -0,0 +1,131 @@ +use super::*; + +/// Resolve one half. Pure: it reads only its arguments and the caller's +/// `estimate` lookup, and reports both an unmet estimate and an unreachable +/// threshold by appending to caller-owned buffers rather than touching session +/// state, which the caller owns and knows how to word. +pub(super) fn resolve_half( + source: VersionedFieldRef, + level: &ContourLevelSpec, + summary: FieldSummary, + negative: bool, + estimate: &mut impl FnMut(&EstimateKey) -> Option, + pending: &mut Vec, + unreachable: &mut Vec, +) -> Option> { + let min = summary.min.get(); + let max = summary.max.get(); + let peak = if negative { + -min.min(0.0) + } else { + max.max(0.0) + }; + if peak <= 0.0 { + return Some(Vec::new()); + } + let base = resolve_contour_base(source, level, summary, negative, estimate, pending)?; + + // The ladder — including which policies may be rewritten when their base is + // unusable — is shared with the analysis-map path and speaks only in + // positive magnitudes; this half applies its own sign afterwards. Deciding + // there and reporting here keeps one policy: a half is blank for exactly the + // reason the ladder says it is. + let ladder = crate::contour_ladder::contour_level_ladder(base, peak, level); + if let Some(threshold) = ladder.threshold_above_peak + && let Some(threshold) = FiniteF64::new(threshold) + && let Some(peak) = FiniteF64::new(peak) + { + unreachable.push(UnreachableContourThreshold { + negative, + threshold, + peak, + }); + } + Some( + ladder + .levels + .into_iter() + .map(|value| if negative { -value } else { value }) + .filter_map(FiniteF64::new) + .collect(), + ) +} + +/// Resolve the anchor independently of whether this sign currently crosses it. +/// A phase preview freezes this magnitude, but still clips the ladder against +/// the new real plane so emerging lobes and new high levels remain visible. +pub(crate) fn resolve_contour_base( + source: VersionedFieldRef, + level: &ContourLevelSpec, + summary: FieldSummary, + negative: bool, + estimate: &mut impl FnMut(&EstimateKey) -> Option, + pending: &mut Vec, +) -> Option { + let min = summary.min.get(); + let max = summary.max.get(); + let peak = if negative { + -min.min(0.0) + } else { + max.max(0.0) + }; + Some(match &level.base { + ContourBasePolicy::Absolute(value) => value.get(), + ContourBasePolicy::FractionOfRange(fraction) => { + // A base policy never yields a signed magnitude (§4.3): this half + // owns the sign and applies it below. Working across the raw + // `min..max` span instead would hand a signed base to a field that + // has both signs — for a spectrum running -P..P the positive half's + // "four percent" came out at -0.92·P. Measuring from this half's own + // floor (the sample closest to zero on its side) up to its peak + // keeps the result an unsigned magnitude for every field, and is + // identical to the span form on the single-signed fields the + // `Bounded` capability admits. + let floor = if negative { + (-max).max(0.0) + } else { + min.max(0.0) + }; + floor + fraction.get() * (peak - floor) + } + ContourBasePolicy::NoiseFloor { + multiplier, + peak_fraction, + estimator, + } => { + let key = EstimateKey { + source, + kind: EstimateKind::Noise, + estimator: estimator.clone(), + }; + let Some(EstimateResult::Scale(result)) = estimate(&key) else { + pending.push(key); + return None; + }; + // The floor is measured against the *field's* peak, not this half's. + // Sampling artefacts are driven by the strongest feature whatever + // its sign, and a per-half floor would also split the two halves + // onto different ladders, which the geometry budget relies on them + // not doing. + multiplier.get() * resolved_noise_scale(result.scale, *peak_fraction, summary).0 + } + ContourBasePolicy::BackgroundScale { + multiplier, + estimator, + } => { + let key = EstimateKey { + source, + kind: EstimateKind::Background, + estimator: estimator.clone(), + }; + let Some(EstimateResult::LocationScale(result)) = estimate(&key) else { + pending.push(key); + return None; + }; + // Background fields carry a location as well as a spread. The + // contour policy expresses the physical level `location + k*scale`; + // a contour half later supplies its sign. + (result.location.get() + multiplier.get() * result.scale.get()).abs() + } + }) +} diff --git a/crates/core/src/state/datasets.rs b/crates/core/src/state/datasets.rs index 69fb9fe..5390add 100644 --- a/crates/core/src/state/datasets.rs +++ b/crates/core/src/state/datasets.rs @@ -13,7 +13,7 @@ pub enum PhaseDragKind { pub struct PhaseDrag { pub kind: PhaseDragKind, - pub dataset: usize, + pub dataset: DatasetId, pub axis: PhaseAxis, /// Canvas-only pivot preview. The processing recipe is updated once, on /// pointer release, so moving the handle never rebuilds the spectrum. diff --git a/crates/core/src/state/field_cache.rs b/crates/core/src/state/field_cache.rs index 23293a4..2d7996c 100644 --- a/crates/core/src/state/field_cache.rs +++ b/crates/core/src/state/field_cache.rs @@ -9,8 +9,8 @@ //! entries lazily is what keeps derived data free of invalidation fan-out. use super::{ - ContourGeometry, ContourGeometryCacheKey, ContourSegment, EstimateKey, EstimateResult, - FieldRef, FieldSummary, FieldVersion, VersionedFieldRef, + ContourGeometry, ContourGeometryCacheKey, ContourSegment, DatasetId, EstimateKey, + EstimateResult, FieldRef, FieldSummary, FieldVersion, ScalarGrid2D, VersionedFieldRef, }; use std::collections::{HashMap, HashSet}; use std::hash::Hash; @@ -130,6 +130,7 @@ pub(crate) struct FieldRuntime { summaries: LruMap, estimates: LruMap, geometry: LruMap>, + grids: LruMap>, estimates_in_flight: HashSet, geometry_in_flight: HashSet, } @@ -142,6 +143,7 @@ impl Default for FieldRuntime { summaries: LruMap::new(SUMMARY_ENTRY_LIMIT, SUMMARY_ENTRY_LIMIT), estimates: LruMap::new(ESTIMATE_ENTRY_LIMIT, ESTIMATE_ENTRY_LIMIT), geometry: LruMap::new(GEOMETRY_BYTE_BUDGET, GEOMETRY_ENTRY_LIMIT), + grids: LruMap::new(128 * 1024 * 1024, 16), estimates_in_flight: HashSet::new(), geometry_in_flight: HashSet::new(), } @@ -149,6 +151,25 @@ impl Default for FieldRuntime { } impl FieldRuntime { + pub(crate) fn grid(&mut self, source: VersionedFieldRef) -> Option> { + self.grids.get(&source).cloned() + } + + pub(crate) fn remember_grid(&mut self, source: VersionedFieldRef, grid: Arc) { + let bytes = grid.values.len().saturating_mul(size_of::()); + self.grids.insert(source, grid, bytes, |_| false); + } + + pub(crate) fn has_in_flight_for(&self, dataset: DatasetId) -> bool { + self.estimates_in_flight + .iter() + .any(|key| key.source.field.resource == dataset) + || self + .geometry_in_flight + .iter() + .any(|key| key.source.field.resource == dataset) + } + pub(crate) fn version_for(&mut self, field: FieldRef) -> Option { if let Some(version) = self.current.get(&field) { return Some(*version); diff --git a/crates/core/src/state/field_progress_tests.rs b/crates/core/src/state/field_progress_tests.rs index 826d6bf..d9f113d 100644 --- a/crates/core/src/state/field_progress_tests.rs +++ b/crates/core/src/state/field_progress_tests.rs @@ -163,3 +163,96 @@ fn a_pending_estimate_names_the_measurement_it_is_waiting_for() { assert!(figure.contours.is_empty()); assert_eq!(app.session.status, "Measuring this field's noise scale…"); } + +#[test] +fn data_refresh_keeps_complete_contours_until_replacement_is_ready() { + use crate::state::{CanvasDocument, ObjectFrame}; + + for needs_estimate in [false, true] { + let (mut app, mut binding) = app_with_contour(Some(absolute_spec())); + if needs_estimate { + let SeriesEncoding::Contour(spec) = &mut binding.series[0].encoding else { + unreachable!(); + }; + spec.positive.base = ContourBasePolicy::NoiseFloor { + multiplier: PositiveFiniteF64::new(0.1).unwrap(), + peak_fraction: plotx_figure::UnitInterval::new(0.0).unwrap(), + estimator: plotx_figure::EstimatorSelection::FollowLatest, + }; + } + let mut canvas = CanvasDocument::new("Phase refresh".into(), [120.0, 80.0]); + let id = canvas.allocate_object_id(); + let mut object = app.build_plot_object( + 0, + ObjectFrame::new(0.0, 0.0, 340.0, 220.0), + id, + "Spectrum".into(), + ); + object.plot_mut().unwrap().binding = binding.clone(); + canvas.objects.push(object); + app.doc.canvases.push(canvas); + app.rebuild_canvas(0); + wait_for_app_compute(&mut app); + let plot = app.doc.canvases[0].object(id).unwrap().plot().unwrap(); + assert!(!plot.figure().contours.is_empty()); + let generation = plot.figure_geometry_generation(); + + // Reproduce the processing completion boundary without worker timing: + // a new version has landed, but none of its derived artifacts have. + let field = FieldRef { + resource: binding.series[0].source.resource, + field: binding.series[0].source.field, + }; + let version = app.session.compute.reserve_field_version().unwrap(); + app.session + .compute + .promote_field_version(VersionedFieldRef { field, version }, None); + for _ in 0..3 { + app.rebuild_canvases_for(0); + let plot = app.doc.canvases[0].object(id).unwrap().plot().unwrap(); + assert_eq!(plot.figure_geometry_generation(), generation); + assert!(!plot.figure().contours.is_empty()); + } + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while app.compute_busy() { + assert!( + std::time::Instant::now() < deadline, + "refresh did not finish" + ); + app.poll_compute(); + let plot = app.doc.canvases[0].object(id).unwrap().plot().unwrap(); + assert!( + !plot.figure().contours.is_empty(), + "a refresh frame went blank" + ); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + let plot = app.doc.canvases[0].object(id).unwrap().plot().unwrap(); + assert_ne!(plot.figure_geometry_generation(), generation); + assert!(!plot.figure().contours.is_empty()); + + // A completed empty result must clear the old lines, not retain them. + let plot = app.doc.canvases[0] + .object_mut(id) + .unwrap() + .plot_mut() + .unwrap(); + let SeriesEncoding::Contour(spec) = &mut plot.binding.series[0].encoding else { + unreachable!(); + }; + spec.positive.base = ContourBasePolicy::Absolute(PositiveFiniteF64::new(100.0).unwrap()); + spec.negative = None; + app.rebuild_canvases_for(0); + wait_for_app_compute(&mut app); + assert!( + app.doc.canvases[0] + .object(id) + .unwrap() + .plot() + .unwrap() + .figure() + .contours + .is_empty() + ); + } +} diff --git a/crates/core/src/state/field_runtime.rs b/crates/core/src/state/field_runtime.rs index c30d2dd..8c14675 100644 --- a/crates/core/src/state/field_runtime.rs +++ b/crates/core/src/state/field_runtime.rs @@ -625,113 +625,10 @@ pub fn resolve_contour_levels( } } -/// Resolve one half. Pure: it reads only its arguments and the caller's -/// `estimate` lookup, and reports both an unmet estimate and an unreachable -/// threshold by appending to caller-owned buffers rather than touching session -/// state, which the caller owns and knows how to word. -fn resolve_half( - source: VersionedFieldRef, - level: &ContourLevelSpec, - summary: FieldSummary, - negative: bool, - estimate: &mut impl FnMut(&EstimateKey) -> Option, - pending: &mut Vec, - unreachable: &mut Vec, -) -> Option> { - let min = summary.min.get(); - let max = summary.max.get(); - let peak = if negative { - -min.min(0.0) - } else { - max.max(0.0) - }; - if peak <= 0.0 { - return Some(Vec::new()); - } - let base = match &level.base { - ContourBasePolicy::Absolute(value) => value.get(), - ContourBasePolicy::FractionOfRange(fraction) => { - // A base policy never yields a signed magnitude (§4.3): this half - // owns the sign and applies it below. Working across the raw - // `min..max` span instead would hand a signed base to a field that - // has both signs — for a spectrum running -P..P the positive half's - // "four percent" came out at -0.92·P. Measuring from this half's own - // floor (the sample closest to zero on its side) up to its peak - // keeps the result an unsigned magnitude for every field, and is - // identical to the span form on the single-signed fields the - // `Bounded` capability admits. - let floor = if negative { - (-max).max(0.0) - } else { - min.max(0.0) - }; - floor + fraction.get() * (peak - floor) - } - ContourBasePolicy::NoiseFloor { - multiplier, - peak_fraction, - estimator, - } => { - let key = EstimateKey { - source, - kind: EstimateKind::Noise, - estimator: estimator.clone(), - }; - let Some(EstimateResult::Scale(result)) = estimate(&key) else { - pending.push(key); - return None; - }; - // The floor is measured against the *field's* peak, not this half's. - // Sampling artefacts are driven by the strongest feature whatever - // its sign, and a per-half floor would also split the two halves - // onto different ladders, which the geometry budget relies on them - // not doing. - multiplier.get() * resolved_noise_scale(result.scale, *peak_fraction, summary).0 - } - ContourBasePolicy::BackgroundScale { - multiplier, - estimator, - } => { - let key = EstimateKey { - source, - kind: EstimateKind::Background, - estimator: estimator.clone(), - }; - let Some(EstimateResult::LocationScale(result)) = estimate(&key) else { - pending.push(key); - return None; - }; - // Background fields carry a location as well as a spread. The - // contour policy expresses the physical level `location + k*scale`; - // a contour half later supplies its sign. - (result.location.get() + multiplier.get() * result.scale.get()).abs() - } - }; - // The ladder — including which policies may be rewritten when their base is - // unusable — is shared with the analysis-map path and speaks only in - // positive magnitudes; this half applies its own sign afterwards. Deciding - // there and reporting here keeps one policy: a half is blank for exactly the - // reason the ladder says it is. - let ladder = crate::contour_ladder::contour_level_ladder(base, peak, level); - if let Some(threshold) = ladder.threshold_above_peak - && let Some(threshold) = FiniteF64::new(threshold) - && let Some(peak) = FiniteF64::new(peak) - { - unreachable.push(UnreachableContourThreshold { - negative, - threshold, - peak, - }); - } - Some( - ladder - .levels - .into_iter() - .map(|value| if negative { -value } else { value }) - .filter_map(FiniteF64::new) - .collect(), - ) -} +#[path = "contour_resolution.rs"] +mod contour_resolution; +pub(crate) use contour_resolution::resolve_contour_base; +use contour_resolution::resolve_half; #[cfg(test)] #[path = "field_runtime_tests.rs"] diff --git a/crates/core/src/state/mod.rs b/crates/core/src/state/mod.rs index 1383d0a..3d21c01 100644 --- a/crates/core/src/state/mod.rs +++ b/crates/core/src/state/mod.rs @@ -229,6 +229,11 @@ const MM_PER_IN: f32 = 25.4; pub type GroupId = u64; +mod phase_preview; + +#[cfg(test)] +mod phase_preview_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/crates/core/src/state/phase_preview.rs b/crates/core/src/state/phase_preview.rs new file mode 100644 index 0000000..f07a3e4 --- /dev/null +++ b/crates/core/src/state/phase_preview.rs @@ -0,0 +1,247 @@ +//! Gesture-only contour ladders. The processing result always retains full precision. +use super::*; +use plotx_figure::{ContourBasePolicy, ContourSpec, PositiveFiniteF64, SeriesEncoding}; +use std::collections::HashMap; + +#[derive(Default)] +pub(crate) struct PhasePreview { + datasets: HashMap>, +} + +struct FrozenLevels { + field: FieldRef, + spec: ContourSpec, + fixed: ContourSpec, +} + +impl PhasePreview { + pub(crate) fn discard(&mut self, resource: DatasetId) { + self.datasets.remove(&resource); + } + + pub(crate) fn peek( + &self, + source: VersionedFieldRef, + spec: &ContourSpec, + summary: FieldSummary, + ) -> Option { + let entry = self + .datasets + .get(&source.field.resource)? + .iter() + .find(|entry| { + entry.field == source.field + && entry.spec.positive == spec.positive + && entry.spec.negative == spec.negative + })?; + Some(resolve_fixed(source, spec, &entry.fixed, summary)) + } + + pub(crate) fn resolve( + &mut self, + compute: &mut ComputeService, + source: VersionedFieldRef, + spec: &ContourSpec, + summary: FieldSummary, + ) -> ContourResolution { + let frozen = self.datasets.get_mut(&source.field.resource); + if let Some(levels) = frozen.as_ref().and_then(|entries| { + entries.iter().find(|entry| { + entry.field == source.field + && entry.spec.positive == spec.positive + && entry.spec.negative == spec.negative + }) + }) { + return resolve_fixed(source, spec, &levels.fixed, summary); + } + if let Some(entries) = frozen + && let Some(fixed) = fixed_spec(compute, source, spec, summary) + { + let resolution = resolve_fixed(source, spec, &fixed, summary); + entries.push(FrozenLevels { + field: source.field, + spec: spec.clone(), + fixed, + }); + return resolution; + } + resolve_contour_levels(source, spec, summary, |key| { + compute.estimate_for(key).cloned() + }) + } +} + +fn resolve_fixed( + source: VersionedFieldRef, + authored: &ContourSpec, + fixed: &ContourSpec, + summary: FieldSummary, +) -> ContourResolution { + let mut resolution = resolve_contour_levels(source, fixed, summary, |_| None); + if let ContourResolution::Ready { unreachable, .. } = &mut resolution { + unreachable.retain(|threshold| { + let half = if threshold.negative { + authored.negative.as_ref() + } else { + Some(&authored.positive) + }; + half.is_some_and(|half| matches!(half.base, ContourBasePolicy::Absolute(_))) + }); + } + resolution +} + +fn fixed_spec( + compute: &mut ComputeService, + source: VersionedFieldRef, + spec: &ContourSpec, + summary: FieldSummary, +) -> Option { + let mut fixed = spec.clone(); + for (negative, level) in [ + (false, Some(&mut fixed.positive)), + (true, fixed.negative.as_mut()), + ] { + let Some(level) = level else { + continue; + }; + let base = resolve_contour_base( + source, + level, + summary, + negative, + &mut |key| compute.estimate_for(key).cloned(), + &mut Vec::new(), + )?; + let base = PositiveFiniteF64::new(base).or_else(|| { + // Zero measured scale uses the same fallback ladder as final + // rendering. An initially absent sign borrows the field's peak so + // rotating through zero can reveal its lobes during the gesture. + let peak = if negative { + -summary.min.get() + } else { + summary.max.get() + }; + let peak = if peak > 0.0 { + peak + } else { + summary.min.get().abs().max(summary.max.get().abs()) + }; + crate::contour_ladder::contour_level_ladder(base, peak, level) + .levels + .first() + .and_then(|value| PositiveFiniteF64::new(*value)) + })?; + level.base = ContourBasePolicy::Absolute(base); + } + Some(fixed) +} + +impl PlotxApp { + pub(super) fn thaw_phase_preview(&mut self, resource: DatasetId) -> bool { + if self.phase_gesture_for(resource) + || self.session.compute.blocking_work_for(resource) == Some(ComputeKind::Processing2D) + { + return false; + } + self.session + .phase_preview + .datasets + .remove(&resource) + .is_some() + } + + fn phase_gesture_for(&self, dataset: DatasetId) -> bool { + matches!(self.interaction(), Interaction::Phase(drag) + if drag.dataset == dataset && drag.kind != PhaseDragKind::Pivot) + || self + .session + .ui + .property_gesture + .as_ref() + .is_some_and(|gesture| { + matches!( + gesture.property, + crate::properties::phase::PHASE0 + | crate::properties::phase::PHASE1 + | crate::properties::phase::PIVOT + ) + }) + } + + pub(super) fn prepare_phase_preview(&mut self, dataset: usize) { + let Some(data) = self + .doc + .datasets + .get(dataset) + .filter(|data| data.as_nmr2d().is_some_and(Nmr2DDataset::is_true_2d)) + else { + return; + }; + let resource = data.resource_id(); + if !self.phase_gesture_for(resource) + || self.session.phase_preview.datasets.contains_key(&resource) + { + return; + } + self.session + .phase_preview + .datasets + .insert(resource, Vec::new()); + let series: Vec<_> = self + .doc + .canvases + .iter() + .flat_map(|canvas| &canvas.objects) + .filter_map(|object| object.plot()) + .flat_map(|plot| { + self.display_binding(plot.display_owner, &plot.binding) + .series + }) + .filter(|series| series.visible && series.source.resource == resource) + .collect(); + for series in series { + let SeriesEncoding::Contour(spec) = series.encoding else { + continue; + }; + let field = FieldRef { + resource, + field: series.source.field, + }; + let Some(version) = self.session.compute.current_field_version(field) else { + continue; + }; + let source = VersionedFieldRef { field, version }; + if let Some(summary) = self.session.compute.cached_field_summary(source) { + self.session.phase_preview.resolve( + &mut self.session.compute, + source, + &spec, + summary, + ); + } + } + } + + /// Releasing, cancelling or switching tools ends the frozen display policy. + /// The last recipe is already in the single deferred slot; do not reprocess + /// it merely to restore the normal final thresholds. + pub(super) fn finish_phase_previews(&mut self) { + let finished: Vec<_> = self + .session + .phase_preview + .datasets + .keys() + .copied() + .filter(|dataset| !self.phase_gesture_for(*dataset)) + .collect(); + for resource in finished { + if !self.thaw_phase_preview(resource) { + continue; + } + if let Some(dataset) = self.doc.dataset_index(resource) { + self.rebuild_canvases_for(dataset); + } + } + } +} diff --git a/crates/core/src/state/phase_preview_tests.rs b/crates/core/src/state/phase_preview_tests.rs new file mode 100644 index 0000000..ff44c1d --- /dev/null +++ b/crates/core/src/state/phase_preview_tests.rs @@ -0,0 +1,429 @@ +use super::*; +use num_complex::Complex64; +use std::time::{Duration, Instant}; + +fn spectrum(rows: usize, cols: usize) -> Nmr2DDataset { + let dim = plotx_io::Dim { + spectral_width_hz: 4000.0, + observe_freq_mhz: 400.0, + carrier_ppm: 5.0, + nucleus: "1H".into(), + group_delay: 0.0, + }; + let data = (0..rows * cols) + .map(|i| { + let x = (i % cols) as f64 / cols as f64; + let y = (i / cols) as f64 / rows as f64; + let peak = (-((x - 0.37) / 0.025).powi(2) - ((y - 0.61) / 0.04).powi(2)).exp(); + let noise = ((i.wrapping_mul(1_664_525).wrapping_add(1_013_904_223) % 65536) as f64 + / 65536.0 + - 0.5) + * 0.001; + Complex64::new(peak + noise, peak * (x - 0.37) * 40.0) + }) + .collect(); + crate::nmr_test_support::load_2d(plotx_io::NmrData2D { + data, + rows, + cols, + domain: plotx_io::Domain::Frequency, + direct: dim.clone(), + indirect: dim, + quad: plotx_io::QuadMode::Complex, + indirect_conjugate: false, + experiment: None, + pseudo_axis: None, + diffusion: None, + nus: None, + source: "Synthetic phase benchmark".into(), + }) + .unwrap() +} + +fn settle(app: &mut PlotxApp) { + let start = Instant::now(); + while app.poll_compute() { + assert!( + start.elapsed() < Duration::from_secs(120), + "{}", + app.session.status + ); + std::thread::sleep(Duration::from_millis(1)); + } +} + +#[test] +#[ignore = "release timing benchmark; synthetic data, no external files"] +fn bench_phase_pipeline() { + for (rows, cols) in [(512, 1024), (1024, 2048)] { + let mut app = app_with_spectrum(rows, cols); + app.begin_property_gesture(crate::properties::phase::PHASE0); + let mut times = Vec::new(); + crate::contour_probe::reset(); + for i in 0..5 { + app.doc.datasets[0] + .phase_params_mut(PhaseAxis::F2) + .unwrap() + .phase0 = 0.05 * (i + 1) as f64; + let start = Instant::now(); + app.apply_dataset_edit(0); + settle(&mut app); + times.push(start.elapsed().as_secs_f64() * 1000.0); + } + println!( + "{rows}x{cols}: end_to_end_ms={times:?} estimates={} contours={} UI_payloads={}", + crate::contour_probe::queued_estimates(), + crate::contour_probe::queued_contour_builds(), + crate::contour_probe::field_payload_materializations() + ); + let release = Instant::now(); + app.end_property_gesture(); + settle(&mut app); + println!( + "release_final_ms={:.3}", + release.elapsed().as_secs_f64() * 1000.0 + ); + benchmark_sampling_candidate(&mut app); + } +} + +fn benchmark_sampling_candidate(app: &mut PlotxApp) { + let series = app.doc.canvases[0].objects[0] + .plot() + .unwrap() + .binding + .series[0] + .clone(); + let plotx_figure::SeriesEncoding::Contour(spec) = series.encoding else { + unreachable!(); + }; + let field = FieldRef { + resource: series.source.resource, + field: series.source.field, + }; + let source = VersionedFieldRef { + field, + version: app.session.compute.current_field_version(field).unwrap(), + }; + let grid = app.session.compute.cached_field_grid(source).unwrap(); + let summary = grid.summary().unwrap(); + let ContourResolution::Ready { levels, .. } = + resolve_contour_levels(source, &spec, summary, |key| { + app.session.compute.estimate_for(key).cloned() + }) + else { + unreachable!(); + }; + let levels = levels + .positive + .iter() + .chain(levels.negative.iter()) + .map(|level| level.get()) + .collect::>(); + let [x0, x1, y0, y1] = grid.linear_bounds().unwrap(); + for (rows, cols) in [ + (grid.rows, grid.cols), + (grid.rows.min(512), grid.cols.min(512)), + ] { + let sampled = (0..rows) + .flat_map(|row| { + let grid = &grid; + (0..cols).map(move |col| { + grid.values[(row * (grid.rows - 1) / (rows - 1)) * grid.cols + + col * (grid.cols - 1) / (cols - 1)] + }) + }) + .collect::>(); + let start = Instant::now(); + let mut segments = 0; + for _ in 0..3 { + segments = std::hint::black_box(plotx_render::contour::segments( + &sampled, rows, cols, x0, x1, y0, y1, &levels, + )) + .len(); + } + println!( + "contour_sampling_candidate {rows}x{cols}: mean_ms={:.3} segments={segments}", + start.elapsed().as_secs_f64() * 1000.0 / 3.0 + ); + } +} + +fn app_with_spectrum(rows: usize, cols: usize) -> PlotxApp { + let mut app = PlotxApp::new_with_settings(crate::settings::Settings::default()); + app.doc + .datasets + .push(Dataset::Nmr2D(Box::new(spectrum(rows, cols)))); + let mut canvas = CanvasDocument::new("Phase".into(), [120.0, 80.0]); + let id = canvas.allocate_object_id(); + canvas.objects.push(app.build_plot_object( + 0, + ObjectFrame::new(0.0, 0.0, 340.0, 220.0), + id, + "Spectrum".into(), + )); + app.doc.canvases.push(canvas); + app.rebuild_canvas(0); + settle(&mut app); + app +} + +fn phase(app: &mut PlotxApp, value: f64) { + app.doc.datasets[0] + .phase_params_mut(PhaseAxis::F2) + .unwrap() + .phase0 = value; + app.apply_dataset_edit(0); +} + +fn segments(app: &PlotxApp) -> Vec { + app.doc.canvases[0].objects[0] + .plot() + .unwrap() + .figure() + .contours + .iter() + .flat_map(|contour| contour.segments.iter().copied()) + .collect() +} + +fn assert_final_matches_fresh_build(app: &mut PlotxApp) { + let actual = segments(app); + let mut fresh = PlotxApp::new_with_settings(crate::settings::Settings::default()); + fresh.doc.datasets.push(app.doc.datasets[0].clone()); + fresh.doc.canvases.push(app.doc.canvases[0].clone()); + fresh.rebuild_canvas(0); + settle(&mut fresh); + assert_eq!(actual, segments(&fresh)); +} + +#[test] +fn phase_preview_freezes_levels_without_freezing_the_contour_shape() { + let mut app = app_with_spectrum(32, 64); + let original = segments(&app); + assert!(!original.is_empty()); + app.begin_property_gesture(crate::properties::phase::PHASE0); + crate::contour_probe::reset(); + phase(&mut app, 0.4); + settle(&mut app); + assert_ne!(segments(&app), original); + assert_eq!(crate::contour_probe::queued_estimates(), 0); + assert_eq!(crate::contour_probe::queued_contour_builds(), 1); + assert_eq!(crate::contour_probe::field_payload_materializations(), 0); + // No final pointer movement: release alone must restore measured thresholds. + app.end_property_gesture(); + settle(&mut app); + assert_eq!(crate::contour_probe::queued_estimates(), 1); + assert_final_matches_fresh_build(&mut app); +} + +#[test] +fn phase_burst_keeps_latest_input_and_final_full_precision_result() { + let mut app = app_with_spectrum(32, 64); + app.begin_property_gesture(crate::properties::phase::PHASE0); + crate::contour_probe::reset(); + for i in 1..=40 { + phase(&mut app, i as f64 * 0.02); + } + app.end_property_gesture(); + settle(&mut app); + let data = app.doc.datasets[0].as_nmr2d().unwrap(); + let expected = plotx_processing::nmr_execution::execute_2d( + &data.native_base, + &data.params, + plotx_processing::nmr_bridge::DelayPolicy::Disabled, + plotx_processing::nmr_bridge::RecipeRange::Frequency, + None, + &mut nmr::ExecutionContext::default(), + ) + .unwrap(); + let (Processed2D::Ft(actual), Processed2D::Ft(expected)) = (&data.processed, expected.view) + else { + panic!("expected a plane"); + }; + assert_eq!((actual.f1_size, actual.f2_size), (32, 64)); + assert_eq!(actual.data, expected.data); + assert_eq!(crate::contour_probe::queued_estimates(), 1); + assert!( + crate::contour_probe::queued_contour_builds() <= 2, + "burst should compute only active and latest frames" + ); + assert_final_matches_fresh_build(&mut app); +} + +#[test] +fn phase_failure_keeps_display_and_reports_the_error_after_release() { + let mut app = app_with_spectrum(16, 32); + let original = segments(&app); + app.begin_property_gesture(crate::properties::phase::PHASE0); + phase(&mut app, f64::NAN); + settle(&mut app); + app.end_property_gesture(); + settle(&mut app); + app.poll_compute(); + assert!( + app.session.status.contains("2D processing failed"), + "{}", + app.session.status + ); + assert_eq!(segments(&app), original); +} + +#[test] +fn phase_cancel_follows_dataset_identity_after_reordering() { + let mut app = app_with_spectrum(16, 32); + let target = app.doc.datasets[0].resource_id(); + let before = crate::actions::DatasetProcessingState::from_dataset(&app.doc.datasets[0]); + app.set_interaction(Interaction::Phase(PhaseDrag { + kind: PhaseDragKind::Ph0, + dataset: target, + axis: PhaseAxis::F2, + preview_pivot_ppm: None, + gesture_before: before, + })); + phase(&mut app, 0.6); + app.doc + .datasets + .push(Dataset::Nmr2D(Box::new(spectrum(8, 16)))); + app.doc.datasets.swap(0, 1); + app.cancel_interaction(); + settle(&mut app); + assert_eq!(app.doc.datasets[1].resource_id(), target); + assert_eq!( + app.doc.datasets[1] + .phase_params_mut(PhaseAxis::F2) + .unwrap() + .phase0, + 0.0 + ); +} + +#[test] +fn phase_preview_draws_negative_lobes_that_were_absent_at_drag_start() { + let mut app = app_with_spectrum(32, 64); + app.begin_property_gesture(crate::properties::phase::PHASE0); + crate::contour_probe::reset(); + phase(&mut app, 2.5); + settle(&mut app); + let plot = app.doc.canvases[0].objects[0].plot().unwrap(); + let plotx_figure::SeriesEncoding::Contour(spec) = &plot.binding.series[0].encoding else { + panic!("expected contours"); + }; + assert!(plot.figure().contours.iter().any(|contour| contour.color + == spec.style.negative_color.resolve() + && !contour.segments.is_empty())); + assert_eq!(crate::contour_probe::queued_estimates(), 0); +} + +#[test] +fn contour_completion_does_not_rebuild_another_field_view() { + let mut app = app_with_spectrum(16, 32); + let resource = app.doc.datasets[0].resource_id(); + let magnitude = app.doc.datasets[0] + .as_nmr2d() + .unwrap() + .field_catalog + .id_for_key("nmr.magnitude") + .unwrap(); + let id = app.doc.canvases[0].allocate_object_id(); + let mut object = app.build_plot_object( + 0, + ObjectFrame::new(0.0, 0.0, 340.0, 220.0), + id, + "Magnitude".into(), + ); + let plot = object.plot_mut().unwrap(); + plot.binding.series[0].source.field = magnitude; + plot.binding.series[0].encoding = plotx_figure::SeriesEncoding::Heatmap(Default::default()); + app.doc.canvases[0].objects.push(object); + app.rebuild_canvas(0); + settle(&mut app); + let generation = app.doc.canvases[0] + .object(id) + .unwrap() + .plot() + .unwrap() + .figure_geometry_generation(); + let real = app.doc.datasets[0] + .as_nmr2d() + .unwrap() + .field_catalog + .id_for_key("nmr.real") + .unwrap(); + let source = VersionedFieldRef { + field: FieldRef { + resource, + field: real, + }, + version: app.session.compute.reserve_field_version().unwrap(), + }; + app.session.compute.promote_field_version(source, None); + app.rebuild_canvases_for_field(0, source.field); + settle(&mut app); + assert_eq!( + app.doc.canvases[0] + .object(id) + .unwrap() + .plot() + .unwrap() + .figure_geometry_generation(), + generation + ); +} + +#[test] +#[ignore = "release continuous-input benchmark; synthetic data"] +fn bench_phase_continuous_input() { + for (rows, cols) in [(512, 1024), (1024, 2048)] { + let mut app = app_with_spectrum(rows, cols); + app.begin_property_gesture(crate::properties::phase::PHASE0); + crate::contour_probe::reset(); + let start = Instant::now(); + let mut next_input = Duration::ZERO; + let mut last_frame = start; + let mut intervals = Vec::new(); + let mut inputs = 0; + let mut generation = app.doc.canvases[0].objects[0] + .plot() + .unwrap() + .figure_geometry_generation(); + while start.elapsed() < Duration::from_secs(3) { + if start.elapsed() >= next_input { + inputs += 1; + phase(&mut app, inputs as f64 * 0.001); + next_input += Duration::from_micros(16_667); + } + app.poll_compute(); + let next = app.doc.canvases[0].objects[0] + .plot() + .unwrap() + .figure_geometry_generation(); + if next != generation { + intervals.push(last_frame.elapsed().as_secs_f64() * 1000.0); + last_frame = Instant::now(); + generation = next; + } + std::thread::sleep(Duration::from_millis(1)); + } + let release = Instant::now(); + app.end_property_gesture(); + settle(&mut app); + intervals.sort_by(f64::total_cmp); + println!( + "continuous {rows}x{cols}: inputs={inputs} frames={} median_interval_ms={:.3} max_interval_ms={:.3} release_final_ms={:.3} estimates={} contours={}", + intervals.len(), + intervals[intervals.len() / 2], + intervals.last().unwrap(), + release.elapsed().as_secs_f64() * 1000.0, + crate::contour_probe::queued_estimates(), + crate::contour_probe::queued_contour_builds() + ); + assert_eq!( + app.doc.datasets[0] + .phase_params_mut(PhaseAxis::F2) + .unwrap() + .phase0, + inputs as f64 * 0.001 + ); + } +} diff --git a/crates/core/src/state/ui_state.rs b/crates/core/src/state/ui_state.rs index 6acbec3..6f85316 100644 --- a/crates/core/src/state/ui_state.rs +++ b/crates/core/src/state/ui_state.rs @@ -642,6 +642,7 @@ pub struct Session { /// Off-thread runner for the heaviest button-triggered DOSY computations. /// Not serialized; rebuilt fresh whenever a `PlotxApp` is constructed. pub compute: ComputeService, + pub(crate) phase_preview: super::phase_preview::PhasePreview, pub data_imports: super::DataImports, /// Background update checker/downloader. Not serialized. pub updates: crate::update::UpdateService, diff --git a/crates/processing/src/nmr_execution_2d.rs b/crates/processing/src/nmr_execution_2d.rs index e18666e..27d6b7b 100644 --- a/crates/processing/src/nmr_execution_2d.rs +++ b/crates/processing/src/nmr_execution_2d.rs @@ -214,40 +214,37 @@ pub fn view_2d( .dataset() .as_processed() .ok_or_else(|| RecipeError::Invalid("unsupported 2D representation".into()))?; - let descriptor = data.descriptor(); - let sample = |row, col, indirect, direct| { - data.data() - .get(&[row, col], &[indirect, direct]) - .map_err(|error| IoError::NmrConversion(error.to_string())) - }; - for row in 0..axes[0].points { - context.check_cancelled().map_err(|e| library(e.into()))?; - let mut trace = Vec::with_capacity(cols); - for col in 0..cols { - if col % 4096 == 0 { - context - .check_cancelled() - .map_err(|error| library(error.into()))?; - } - trace.push(Complex64::new( - sample(row, col, 0, 0)?, - if descriptor.axes()[1].component_count() == 2 { - sample(row, col, 0, 1)? - } else { - 0.0 - }, - )); - // A display reduction of every Cartesian field. The canonical - // dataset and any requested magnitude operation stay in nmr. - let mut magnitude = 0.0_f64; - for indirect in 0..descriptor.axes()[0].component_count() { - for direct in 0..descriptor.axes()[1].component_count() { - magnitude = magnitude.hypot(sample(row, col, indirect, direct)?); + // Validate each Cartesian component once, then stream its plane. Calling + // `get` for every scalar repeated rank/bounds/offset checks and read the + // displayed real/imaginary components again for magnitude reduction. + traces = vec![vec![Complex64::new(0.0, 0.0); cols]; rows]; + if layout == Layout2D::Ft { + magnitudes = vec![0.0_f64; rows * cols]; + } + for indirect in 0..data.descriptor().axes()[0].component_count() { + for direct in 0..data.descriptor().axes()[1].component_count() { + let plane = data + .data() + .component_plane(&[indirect, direct]) + .map_err(|error| IoError::NmrConversion(error.to_string()))?; + for (index, (value, output)) in plane.zip(traces.iter_mut().flatten()).enumerate() { + if index % 4096 == 0 { + context + .check_cancelled() + .map_err(|error| library(error.into()))?; + } + if indirect == 0 { + if direct == 0 { + output.re = *value; + } else if direct == 1 { + output.im = *value; + } + } + if let Some(magnitude) = magnitudes.get_mut(index) { + *magnitude = magnitude.hypot(*value); } } - magnitudes.push(magnitude); } - traces.push(trace); } } let source_label = source.source().to_owned(); diff --git a/crates/processing/tests/nmr_view_2d.rs b/crates/processing/tests/nmr_view_2d.rs new file mode 100644 index 0000000..fa35ca3 --- /dev/null +++ b/crates/processing/tests/nmr_view_2d.rs @@ -0,0 +1,119 @@ +use nmr::axis::{AxisCoordinates, AxisDomain, AxisRole, AxisUnit}; +use nmr::processed::{ + ComponentBasis, ProcessedAxis, ProcessedData, ProcessedDataset, ProcessedDescriptor, + ProcessedOrigin, ProcessedProvenance, +}; +use nmr::{Complex64, ExecutionContext}; +use plotx_io::nmr_view::NmrSource; +use plotx_processing::{Layout2D, Processed2D, nmr_execution::view_2d}; +use std::sync::Arc; + +#[test] +fn streamed_view_preserves_every_cartesian_component_and_scalar_axis() { + for indirect in [ComponentBasis::Scalar, ComponentBasis::Cartesian] { + for direct in [ComponentBasis::Scalar, ComponentBasis::Cartesian] { + let axes = [(3, indirect.clone()), (5, direct)] + .into_iter() + .map(|(points, basis)| { + ProcessedAxis::new( + AxisRole::Signal, + AxisDomain::Frequency, + Some(AxisUnit::Hertz), + points, + AxisCoordinates::Uniform { + start: 9.0, + step: -0.5, + }, + basis, + ) + .unwrap() + }) + .collect(); + let descriptor = ProcessedDescriptor::new(axes).unwrap(); + let counts = descriptor.component_counts(); + let size = 15 * counts.iter().product::(); + let samples = (0..size).map(|i| (i as f64 - 20.5) / 3.0).collect(); + let data = ProcessedData::from_descriptor(&descriptor, samples).unwrap(); + let source = NmrSource::new(Arc::new( + ProcessedDataset::new( + descriptor, + data, + ProcessedProvenance::new(ProcessedOrigin::Unknown, vec![]).unwrap(), + ) + .unwrap() + .into(), + )) + .unwrap(); + let Processed2D::Ft(view) = + view_2d(&source, Layout2D::Ft, &mut ExecutionContext::default()).unwrap() + else { + panic!("expected a plane"); + }; + let native = source.dataset().as_processed().unwrap().data(); + for row in 0..3 { + for col in 0..5 { + let re = native.get(&[row, col], &[0, 0]).unwrap(); + let im = if counts[1] == 2 { + native.get(&[row, col], &[0, 1]).unwrap() + } else { + 0.0 + }; + assert_eq!(view.at(row, col), Complex64::new(re, im)); + let mut magnitude = 0.0_f64; + for a in 0..counts[0] { + for b in 0..counts[1] { + magnitude = magnitude.hypot(native.get(&[row, col], &[a, b]).unwrap()); + } + } + assert_eq!(view.magnitude_at(row * 5 + col), Some(magnitude)); + } + } + let Processed2D::Stack(stack) = + view_2d(&source, Layout2D::Stack, &mut ExecutionContext::default()).unwrap() + else { + panic!("expected traces"); + }; + assert_eq!( + stack.traces.iter().flatten().copied().collect::>(), + view.data + ); + } + } +} + +#[test] +fn cancelled_view_is_not_returned_as_a_success() { + // Reuse the public source constructor exercised by the integration fixtures. + let dim = plotx_io::Dim { + spectral_width_hz: 10.0, + observe_freq_mhz: 100.0, + carrier_ppm: 5.0, + nucleus: "1H".into(), + group_delay: 0.0, + }; + let input = plotx_io::nmr_series::NmrSeriesSource::try_from(plotx_io::NmrData2D { + data: vec![Complex64::new(1.0, 2.0); 16], + rows: 4, + cols: 4, + domain: plotx_io::Domain::Frequency, + direct: dim.clone(), + indirect: dim, + quad: plotx_io::QuadMode::Complex, + indirect_conjugate: false, + experiment: None, + pseudo_axis: None, + diffusion: None, + nus: None, + source: "cancel".into(), + }) + .unwrap(); + let token = nmr::CancellationToken::new(); + token.cancel(); + let mut ledger = plotx_processing::nmr_execution::processing_2d_work_ledger(); + let result = view_2d( + input.source_dataset(), + Layout2D::Ft, + &mut ExecutionContext::new(&mut ledger).with_cancellation(token), + ); + assert!(result.unwrap_err().is_cancelled()); +} diff --git a/docs/src/content/docs/guides/processing.md b/docs/src/content/docs/guides/processing.md index ef6d35a..ca94f6e 100644 --- a/docs/src/content/docs/guides/processing.md +++ b/docs/src/content/docs/guides/processing.md @@ -251,6 +251,12 @@ until you press it. Automatic phase correction is enabled by default; you can switch methods or adjust φ0 / φ1 manually with live preview. +While dragging a 2D phase control, PlotX holds resolved contour thresholds fixed +and updates contour shapes as the real intensities change. Previews retain full +resolution; continuous inputs are coalesced and the last complete plot stays +visible while an update is computing. On release, PlotX finishes the latest +parameters and recalculates thresholds using the selected contour policy. + Open the **Phase** step and its four rows sit together: **Mode**, **φ0**, **φ1** and **Pivot**. φ0 and φ1 are in degrees. The pivot is a fraction of the axis from 0 to 1, with the ppm position it currently lands on shown beside it. While diff --git a/docs/src/content/docs/zh-cn/guides/processing.md b/docs/src/content/docs/zh-cn/guides/processing.md index a8290ae..d0f738f 100644 --- a/docs/src/content/docs/zh-cn/guides/processing.md +++ b/docs/src/content/docs/zh-cn/guides/processing.md @@ -202,6 +202,10 @@ PlotX 根据受支持的 `DSPFVS`/`DECIM` 设置确定延迟;否则延迟保 自动相位校正默认启用;也可以切换算法,或手动调节 φ0 / φ1 并实时预览。 +拖动二维相位时,PlotX 暂时固定已解析的等高线阈值,并随实部强度变化更新 +等高线形状。预览使用完整分辨率;连续输入会合并,后台计算期间保留上一幅 +完整谱图。松手后使用最后的参数完成处理,并按所选等高线策略重新估计阈值。 + 展开 **Phase** 步骤,四行设置集中在一起:**Mode**、**φ0**、**φ1** 与 **Pivot**。φ0 与 φ1 以度为单位。Pivot 是 0 到 1 的轴分数,旁边给出它当前 对应的 ppm 位置。步骤展开时谱图上会画出 pivot 手柄,在谱面拖动它即按 ppm