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
9 changes: 9 additions & 0 deletions crates/analysis/src/robust.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
//! Robust scale estimators shared by scientific field providers.

/// Stable identity and implementation version for the 2D NMR noise estimator.
pub const ROBUST_DIFFERENCE_MAD_ID: &str = "robust_difference_mad";
pub const ROBUST_DIFFERENCE_MAD_VERSION: u32 = 1;

/// Stable identity and implementation version for the de-planed background
/// estimator used by scalar height-like fields.
pub const DEPLANED_LOCATION_SCALE_ID: &str = "deplaned_location_scale";
pub const DEPLANED_LOCATION_SCALE_VERSION: u32 = 1;

/// Estimate white-noise scale from horizontal and vertical first differences.
/// Differences never cross a row boundary; combining both axes avoids giving a
/// preferred direction to a regular 2D spectrum.
Expand Down
7 changes: 4 additions & 3 deletions crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -449,9 +449,10 @@ fn fail_automation(error: AutomationError) -> Status {
fn fail(error: WorkflowError) -> Status {
let status = match &error {
WorkflowError::Load(_) => Status::Input,
WorkflowError::Scheme(_) | WorkflowError::Processing(_) | WorkflowError::Integration(_) => {
Status::Scheme
}
WorkflowError::Scheme(_)
| WorkflowError::Processing(_)
| WorkflowError::Integration(_)
| WorkflowError::FieldRuntime(_) => Status::Scheme,
WorkflowError::FigureUnavailable(_) => Status::Canvas,
WorkflowError::Export(_) => Status::Export,
};
Expand Down
10 changes: 5 additions & 5 deletions crates/core/src/actions/app_impl/mod.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
use super::*;

mod axis_overrides;
mod meta_edits;
mod processing;
mod revert;
mod table_edit;
mod validate;

pub use validate::ActionApplyError;
use validate::{ValidationShape, validate_action};

Expand All @@ -18,9 +16,7 @@ impl PlotxApp {
}
}

/// Validate and atomically commit one action transaction. Validation walks
/// composites before the first child is applied, so a stale later child can
/// never leave a partially modified document.
/// Validate a whole action before applying it, preventing partial stale composites.
pub fn try_execute_action(&mut self, action: Action) -> Result<(), ActionApplyError> {
if action.is_noop() {
return Ok(());
Expand Down Expand Up @@ -400,6 +396,9 @@ impl PlotxApp {
if *dataset_index != self.doc.datasets.len() {
return;
}
if !self.register_loaded_dataset_fields(dataset.as_ref()) {
return;
}
self.doc.datasets.push(dataset.as_ref().clone());
if let Some(ci) = inserted_into_existing_canvas {
let Some(canvas) = self.doc.canvases.get(*ci) else {
Expand Down Expand Up @@ -438,6 +437,7 @@ impl PlotxApp {
}
}
self.doc.canvases.push(canvas);
self.rebuild_canvases_for(*dataset_index);
self.session.active_canvas = Some(*canvas_index);
}
self.focus_single(*dataset_index);
Expand Down
4 changes: 2 additions & 2 deletions crates/core/src/actions/tests/more.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ fn stacked_binding_builds_distinctly_coloured_series_with_legend() {
#[test]
fn single_table_color_override_recolors_points_and_error_bars() {
use crate::state::{ChartSpec, DataBinding, DataDomain, SeriesBinding, StackSpec};
let (app, _) = table_app_with_sigma(vec![0.1, 0.1, 0.1]);
let (mut app, _) = table_app_with_sigma(vec![0.1, 0.1, 0.1]);
let color = plotx_figure::Color::rgb(0xaa, 0x22, 0x44);
let mut series = SeriesBinding::from_dataset(&app.doc.datasets[0]).unwrap();
series.set_primary_color(color);
Expand All @@ -65,7 +65,7 @@ fn single_table_color_override_recolors_points_and_error_bars() {
#[test]
fn single_table_color_override_recolors_bar_polygons() {
use crate::state::{ChartSpec, DataBinding, SeriesBinding, StackSpec};
let (app, _) = table_app_with_sigma(vec![0.1, 0.1, 0.1]);
let (mut app, _) = table_app_with_sigma(vec![0.1, 0.1, 0.1]);
let color = plotx_figure::Color::rgb(0xaa, 0x22, 0x44);
let mut series = SeriesBinding::from_dataset(&app.doc.datasets[0]).unwrap();
series.set_primary_color(color);
Expand Down
33 changes: 31 additions & 2 deletions crates/core/src/actions/tests/stack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ fn stacked_figure_is_domain_generic_with_offset_scale_and_hide() {
let second = second_table_with_sigma(vec![0.2, 0.2, 0.2]);
table.doc.datasets.push(Dataset::Table(Box::new(second)));

for (app, domain) in [(&nmr, DataDomain::Nmr1d), (&table, DataDomain::Table)] {
for (app, domain) in [
(&mut nmr, DataDomain::Nmr1d),
(&mut table, DataDomain::Table),
] {
let chart = ChartSpec::default_for(domain);
let binding = DataBinding {
series: vec![
Expand Down Expand Up @@ -72,7 +75,13 @@ fn field_overlay_stacks_two_2d_contours_in_distinct_colors() {
signed_grid.domain = plotx_io::Domain::Frequency;
for (index, value) in signed_grid.data.iter_mut().enumerate() {
let column = index % signed_grid.cols;
*value = num_complex::Complex64::new(column as f64 - 15.5, 0.0);
let row = index / signed_grid.cols;
// A non-zero robust noise scale keeps this signed test field on the
// concrete NoiseSigma path rather than exercising a degenerate plane.
*value = num_complex::Complex64::new(
column as f64 - 15.5 + ((row * 17 + column * 13) % 11) as f64 * 0.037,
0.0,
);
}
app.doc
.datasets
Expand All @@ -96,6 +105,26 @@ fn field_overlay_stacks_two_2d_contours_in_distinct_colors() {
mode: StackMode::ColorOverlay,
..StackSpec::default()
};
let initial = app.build_binding_figure(&binding, &chart, &stack, [120.0, 80.0]);
assert!(
initial.contours.is_empty(),
"contours are resolved asynchronously"
);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
while app.compute_busy() && std::time::Instant::now() < deadline {
app.poll_compute();
std::thread::sleep(std::time::Duration::from_millis(5));
}
app.poll_compute();
// The first completion supplies the estimate. Resolving the binding again
// then queues its geometry, which is a separate worker stage.
let geometry_pending = app.build_binding_figure(&binding, &chart, &stack, [120.0, 80.0]);
assert!(geometry_pending.contours.is_empty());
while app.compute_busy() && std::time::Instant::now() < deadline {
app.poll_compute();
std::thread::sleep(std::time::Duration::from_millis(5));
}
app.poll_compute();
let fig = app.build_binding_figure(&binding, &chart, &stack, [120.0, 80.0]);

assert!(fig.show_legend, "a Field overlay shows a legend");
Expand Down
85 changes: 85 additions & 0 deletions crates/core/src/contour_ladder.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
//! The one contour level-ladder policy, shared by every contour path.
//!
//! `plotx_render::contour::geometric_levels` is a pure ladder generator: given a
//! usable base it emits levels and truncates at the peak. Deciding what to do
//! when the base is *not* usable is policy rather than rendering, so it lives
//! here instead of in `plotx-render` — and in exactly one place, so the legacy
//! ILT/DOSY analysis-map path and the versioned field resolver cannot drift.

use plotx_figure::{ContourBasePolicy, ContourLevelSpec};

/// One contour half's ladder, together with the reason it may be empty.
pub(crate) struct ContourLadder {
pub levels: Vec<f64>,
/// The user's own [`ContourBasePolicy::Absolute`] threshold, recorded only
/// when this half's peak never reaches it so nothing could be drawn. Callers
/// that can talk to the user must say so; a blank half with no explanation
/// is the failure this carries the numbers to prevent.
pub threshold_above_peak: Option<f64>,
}

/// Resolve one contour half's level ladder.
///
/// `base` and `peak` are unsigned magnitudes belonging to a single half: a base
/// policy never produces a signed absolute value, and the caller applies its
/// half's sign to the returned magnitudes.
///
/// An unusable base is handled according to *where the base came from*, because
/// the two cases mean opposite things:
///
/// - A base a policy *derived* — most often a zero scale estimate on a flat or
/// ideal synthetic grid — is not something the user typed, and would otherwise
/// leave a permanently blank plot with no indication of why. It falls back to
/// a base derived from the spec the user actually selected, never to a hidden
/// peak fraction, so `count` and `ratio` still control the output. A fallback
/// that is itself unusable (a non-finite peak, or a ratio ladder that
/// overflows) draws nothing.
/// - [`ContourBasePolicy::Absolute`] *is* the user's explicit input, the
/// strongest term of the value-resolution order. Rewriting it would silently
/// draw a ladder at levels the user never asked for, so it is obeyed
/// literally: a threshold the field never reaches yields no levels, and
/// `threshold_above_peak` carries the numbers needed to explain that.
pub(crate) fn contour_level_ladder(
base: f64,
peak: f64,
level: &ContourLevelSpec,
) -> ContourLadder {
let usable = |value: f64| value.is_finite() && value > 0.0 && value < peak;
let base = if usable(base) {
base
} else if matches!(level.base, ContourBasePolicy::Absolute(_)) {
// `Absolute` wraps a `PositiveFiniteF64`, and both callers reject a
// non-positive peak before reaching here, so the only way an explicit
// threshold is unusable is that it sits at or above the peak. The
// comparison is still written out rather than assumed, so a future
// caller with a non-finite peak reports nothing instead of a bad number.
return ContourLadder {
levels: Vec::new(),
threshold_above_peak: (base >= peak).then_some(base),
};
} else if level.count == 1 {
// One level always means one visible, interior contour: a lone level at
// or beyond the peak has no crossing at all.
peak / 2.0
} else {
peak / level
.ratio
.get()
.powi(i32::from(level.count.saturating_sub(1)))
};
if !usable(base) {
return ContourLadder {
levels: Vec::new(),
threshold_above_peak: None,
};
}
ContourLadder {
levels: plotx_render::contour::geometric_levels(
base,
peak,
usize::from(level.count),
level.ratio.get(),
),
threshold_above_peak: None,
}
}
69 changes: 69 additions & 0 deletions crates/core/src/contour_probe.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
//! Test-only probes proving where derived contour work happens — which thread
//! runs marching squares, and whether resolving a warm cache still materializes
//! a full field payload.
//!
//! The counters are deliberately `thread_local`, and that is the whole point:
//! a `BuildContour` worker increments `MARCHING_SQUARES` on *its* thread, which
//! the test thread cannot see. "Zero on the calling thread" therefore means
//! exactly "the caller did not run marching squares itself". Promoting these to
//! an `AtomicUsize` (or any process-global counter) would make the assertions
//! count worker work as caller work and quietly destroy the property they
//! check, so keep them thread-local.
//!
//! Every marching-squares call site inside `plotx-core` must record here.
//! `ilt_figure_runs_marching_squares_on_the_calling_thread` is the positive
//! control: it fails if the instrumentation is ever dropped, so a "0 builds"
//! assertion can never pass merely because nothing counts.

use std::cell::Cell;

thread_local! {
static MARCHING_SQUARES: Cell<usize> = const { Cell::new(0) };
static QUEUED_CONTOUR_BUILDS: Cell<usize> = const { Cell::new(0) };
static QUEUED_ESTIMATES: Cell<usize> = const { Cell::new(0) };
static FIELD_PAYLOADS: Cell<usize> = const { Cell::new(0) };
}

pub(crate) fn reset() {
MARCHING_SQUARES.with(|count| count.set(0));
QUEUED_CONTOUR_BUILDS.with(|count| count.set(0));
QUEUED_ESTIMATES.with(|count| count.set(0));
FIELD_PAYLOADS.with(|count| count.set(0));
}

/// Record one marching-squares invocation on the current thread.
pub(crate) fn record_marching_squares() {
MARCHING_SQUARES.with(|count| count.set(count.get().saturating_add(1)));
}

/// Marching-squares invocations made *on this thread* since [`reset`].
pub(crate) fn marching_squares_on_this_thread() -> usize {
MARCHING_SQUARES.with(Cell::get)
}

pub(crate) fn record_queued_contour_build() {
QUEUED_CONTOUR_BUILDS.with(|count| count.set(count.get().saturating_add(1)));
}

pub(crate) fn queued_contour_builds() -> usize {
QUEUED_CONTOUR_BUILDS.with(Cell::get)
}

/// Record one `EstimateField` job actually handed to the workers. A result that
/// is cached — including a degenerate one — must never make this grow again.
pub(crate) fn record_queued_estimate() {
QUEUED_ESTIMATES.with(|count| count.set(count.get().saturating_add(1)));
}

pub(crate) fn queued_estimates() -> usize {
QUEUED_ESTIMATES.with(Cell::get)
}

/// Record one `Dataset::field_payload` call, i.e. one O(rows × cols) buffer.
pub(crate) fn record_field_payload() {
FIELD_PAYLOADS.with(|count| count.set(count.get().saturating_add(1)));
}

pub(crate) fn field_payload_materializations() -> usize {
FIELD_PAYLOADS.with(Cell::get)
}
Loading
Loading