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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions crates/app/src/ui/canvas/phase.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ pub(crate) fn displayed_phase_pivot_ppm(
) -> Option<f64> {
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 =>
{
Expand Down Expand Up @@ -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,
Expand All @@ -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;
}
Expand Down
8 changes: 8 additions & 0 deletions crates/app/src/ui/properties/readout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<String> {
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.
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions crates/core/src/actions/tests/interaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
20 changes: 20 additions & 0 deletions crates/core/src/contour_probe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<usize> = const { Cell::new(0) };
static QUEUED_CONTOUR_BUILDS: Cell<usize> = const { Cell::new(0) };
Expand Down
63 changes: 36 additions & 27 deletions crates/core/src/properties/readout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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())
Expand Down
30 changes: 30 additions & 0 deletions crates/core/src/properties/readout_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
43 changes: 42 additions & 1 deletion crates/core/src/state/app_impl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<FieldRef>) {
let Some(dataset_id) = self.doc.datasets.get(dataset).map(Dataset::resource_id) else {
return;
};
Expand All @@ -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();
Expand All @@ -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);
Expand Down
18 changes: 16 additions & 2 deletions crates/core/src/state/app_impl_compute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand All @@ -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}");
}
Expand All @@ -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 } => {
Expand Down Expand Up @@ -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 } => {
Expand All @@ -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)
Expand Down Expand Up @@ -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;
};
Expand Down
Loading
Loading