Skip to content
Closed
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
2 changes: 1 addition & 1 deletion crates/app/src/ui/canvas/painting.rs
Original file line number Diff line number Diff line change
Expand Up @@ -592,7 +592,7 @@ pub(crate) fn paint_peaks(
}
}

let resolved = peaks.resolve();
let resolved = peaks.resolve(app.doc.datasets[dataset].peak_reference_offset_ppm());
let selected = app.session.ui.selected_peak;
for peak in &resolved {
let p = Pos2::new(sx(peak.x), sy(peak.y));
Expand Down
2 changes: 1 addition & 1 deletion crates/app/src/ui/canvas/peaks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ pub(crate) fn handle_peaks(
)
});

let resolved = peaks.resolve();
let resolved = peaks.resolve(app.doc.datasets[dataset].peak_reference_offset_ppm());
peak_context_menu(app, dataset, &resolved, &sc, hover, resp);

if esc {
Expand Down
2 changes: 1 addition & 1 deletion crates/app/src/ui/tools/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ fn peaks_group(app: &mut PlotxApp, di: usize, ui: &mut Ui) -> bool {
}
});

let resolved = peaks.resolve();
let resolved = peaks.resolve(app.doc.datasets[di].peak_reference_offset_ppm());
ui.horizontal(|ui| {
ui.label(format!("Peaks: {}", resolved.len()));
if ui
Expand Down
2 changes: 1 addition & 1 deletion crates/core/src/data_export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,7 @@ impl DataExportSnapshot {
dataset
.peaks()
.ok_or(DataExportError::ContentUnavailable)?
.resolve(),
.resolve(dataset.peak_reference_offset_ppm()),
),
DataExportContent::Integrals => {
if let Some(nmr) = dataset.as_nmr() {
Expand Down
2 changes: 1 addition & 1 deletion crates/core/src/state/app_impl_linefit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ impl PlotxApp {

let mut positions: Vec<f64> = self.doc.datasets[dataset]
.peaks()
.map(|p| p.resolve())
.map(|p| p.resolve(self.doc.datasets[dataset].peak_reference_offset_ppm()))
.unwrap_or_default()
.iter()
.map(|p| p.x)
Expand Down
2 changes: 1 addition & 1 deletion crates/core/src/state/app_impl_multiplet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ impl PlotxApp {
if peaks.is_empty() {
let marks = self.doc.datasets[dataset]
.peaks()
.map(|p| p.resolve())
.map(|p| p.resolve(self.doc.datasets[dataset].peak_reference_offset_ppm()))
.unwrap_or_default();
for m in marks.iter().filter(|m| m.x >= lo && m.x <= hi) {
peaks.push(MultipletPeak {
Expand Down
11 changes: 8 additions & 3 deletions crates/core/src/state/app_impl_peaks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -231,12 +231,13 @@ impl PlotxApp {
return;
};
let (px, py) = trace.pick(x, snap);
let offset = self.doc.datasets[dataset].peak_reference_offset_ppm();
self.edit_peaks(dataset, |peaks| {
peaks.column = column_id;
let id = peaks.next_id();
peaks.marks.push(PeakMark {
id,
x: px,
x: px - offset,
y: py,
origin: PeakOrigin::Manual,
label: None,
Expand Down Expand Up @@ -269,10 +270,12 @@ impl PlotxApp {
return;
}
let tol = trace.tol();
let offset = self.doc.datasets[dataset].peak_reference_offset_ppm();
let mut added = 0;
self.edit_peaks(dataset, |peaks| {
peaks.column = column_id;
for (x, y) in found {
let x = x - offset;
if peaks.marks.iter().any(|m| (m.x - x).abs() <= tol) {
continue;
}
Expand Down Expand Up @@ -315,10 +318,11 @@ impl PlotxApp {
else {
return;
};
let offset = self.doc.datasets[dataset].peak_reference_offset_ppm();
self.edit_peaks(dataset, |peaks| {
peaks.column = column_id;
peaks.detector.threshold = threshold;
peaks.redetect(&trace);
peaks.redetect(&trace, offset);
});
let count = self
.doc
Expand All @@ -345,10 +349,11 @@ impl PlotxApp {
else {
return;
};
let offset = self.doc.datasets[dataset].peak_reference_offset_ppm();
self.edit_peaks(dataset, |peaks| {
peaks.column = column_id;
peaks.detector.max_count = max_count;
peaks.redetect(&trace);
peaks.redetect(&trace, offset);
});
}

Expand Down
5 changes: 3 additions & 2 deletions crates/core/src/state/charts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,8 @@ fn build_nmr_spectrum(dataset: &Dataset, _ctx: &ChartContext) -> Option<Figure>
Some(build_processed_1d_figure(
&n.data,
&n.processed,
&n.peaks.resolve(),
&n.peaks
.resolve(n.pipeline.chemical_shift_reference_offset_ppm()),
))
}

Expand Down Expand Up @@ -473,7 +474,7 @@ fn build_nmr_2d(dataset: &Dataset, _ctx: &ChartContext) -> Option<Figure> {

fn build_table_line(dataset: &Dataset, _ctx: &ChartContext) -> Option<Figure> {
let t = dataset.as_table()?;
Some(apply_peak_labels(t.figure(), &t.peaks.resolve()))
Some(apply_peak_labels(t.figure(), &t.peaks.resolve(0.0)))
}

fn build_table_bar(dataset: &Dataset, ctx: &ChartContext) -> Option<Figure> {
Expand Down
11 changes: 11 additions & 0 deletions crates/core/src/state/datasets_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,17 @@ impl Dataset {
}

/// The dataset's peak set, for domains that carry one (1D spectra and tables).
/// The net chemical-shift reference translation currently applied to the
/// dataset's finished 1D trace. Peak marks store uncalibrated positions
/// (finished x minus this value), so every reader resolves through it;
/// domains without reference steps calibrate by zero.
pub fn peak_reference_offset_ppm(&self) -> f64 {
match self {
Dataset::Nmr(d) => d.pipeline.chemical_shift_reference_offset_ppm(),
_ => 0.0,
}
}

pub fn peaks(&self) -> Option<&PeakSet> {
match self {
Dataset::Nmr(d) => Some(&d.peaks),
Expand Down
5 changes: 3 additions & 2 deletions crates/core/src/state/field.rs
Original file line number Diff line number Diff line change
Expand Up @@ -514,7 +514,8 @@ impl super::Dataset {
Some(crate::figures::build_processed_1d_figure(
&nmr.data,
&nmr.processed,
&nmr.peaks.resolve(),
&nmr.peaks
.resolve(nmr.pipeline.chemical_shift_reference_offset_ppm()),
))
},
|spec| nmr.craft_field_figure(spec),
Expand All @@ -527,7 +528,7 @@ impl super::Dataset {
Self::Table(table) => match encoding {
SeriesEncoding::Line(_) => Some(crate::figures::apply_peak_labels(
table.figure(),
&table.peaks.resolve(),
&table.peaks.resolve(0.0),
)),
SeriesEncoding::Contour(_)
| SeriesEncoding::Heatmap(_)
Expand Down
32 changes: 23 additions & 9 deletions crates/core/src/state/peaks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,12 @@ pub enum PeakOrigin {

/// One peak: a labelled `(x, y)` apex. `label` overrides the shift-formatted
/// default when set.
///
/// `x` is stored *uncalibrated*: the finished-spectrum position minus the
/// pipeline's chemical-shift reference offset at pick time. Readers add the
/// current offset back through [`PeakSet::resolve`], so marks follow the
/// spectrum when a Reference step is edited instead of freezing at the old
/// axis. `y` remains a pick-time intensity snapshot.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PeakMark {
pub id: u64,
Expand Down Expand Up @@ -186,11 +192,13 @@ impl PeakSet {

/// Re-run detection at the stored threshold: replace every detected mark with a
/// fresh set, leaving hand-placed marks (and any detection coincident with one)
/// untouched.
pub fn redetect(&mut self, trace: &Trace1d) {
/// untouched. `reference_offset_ppm` is the calibration currently applied
/// to the finished `trace`; detections are stored uncalibrated.
pub fn redetect(&mut self, trace: &Trace1d, reference_offset_ppm: f64) {
self.marks.retain(|m| m.origin == PeakOrigin::Manual);
let tol = x_tolerance(trace);
for (x, y) in Self::detect_at(trace, self.detector.threshold, self.detector.max_count) {
let x = x - reference_offset_ppm;
if self.marks.iter().any(|m| (m.x - x).abs() <= tol) {
continue;
}
Expand Down Expand Up @@ -234,15 +242,21 @@ impl PeakSet {
.collect()
}

pub fn resolve(&self) -> Vec<ResolvedPeak> {
/// Marks in finished-spectrum coordinates: the stored uncalibrated x plus
/// the pipeline's *current* chemical-shift reference offset, so labels
/// follow a Reference edit instead of pinning the pick-time axis.
pub fn resolve(&self, reference_offset_ppm: f64) -> Vec<ResolvedPeak> {
self.marks
.iter()
.map(|m| ResolvedPeak {
x: m.x,
y: m.y,
label: m.label.clone().unwrap_or_else(|| default_label(m.x)),
origin: m.origin,
mark_id: Some(m.id),
.map(|m| {
let x = m.x + reference_offset_ppm;
ResolvedPeak {
x,
y: m.y,
label: m.label.clone().unwrap_or_else(|| default_label(x)),
origin: m.origin,
mark_id: Some(m.id),
}
})
.collect()
}
Expand Down
97 changes: 97 additions & 0 deletions crates/core/src/state/peaks_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,100 @@ fn apex_snap_routes_through_pick() {
(10.0, 5.0)
);
}

/// A small frequency-domain spectrum with one clear line, loaded as a dataset.
fn frequency_app() -> crate::state::PlotxApp {
let mut points = vec![num_complex::Complex64::new(0.0, 0.0); 64];
points[32] = num_complex::Complex64::new(100.0, 0.0);
points[31] = num_complex::Complex64::new(40.0, 0.0);
points[33] = num_complex::Complex64::new(40.0, 0.0);
let data = plotx_io::NmrData {
points,
domain: plotx_io::Domain::Frequency,
spectral_width_hz: 640.0,
observe_freq_mhz: 100.0,
carrier_ppm: 5.0,
nucleus: "1H".into(),
source: "test".into(),
group_delay: 0.0,
};
let mut app = crate::state::PlotxApp::new();
app.doc.datasets.push(crate::state::Dataset::Nmr(Box::new(
crate::state::NmrDataset::load(data),
)));
app
}

fn apply_reference(app: &mut crate::state::PlotxApp, at_ppm: f64, target_ppm: f64) {
let nmr = app.doc.datasets[0].as_nmr_mut().expect("NMR dataset");
let id = plotx_processing::StepId::new(nmr.next_step_id);
nmr.next_step_id += 1;
nmr.pipeline
.steps
.push(plotx_processing::ProcessingStep::new(
id,
plotx_processing::StepKind::Reference(plotx_processing::ReferenceParams {
at_ppm,
target_ppm,
}),
plotx_processing::StepSource::User,
));
let nmr = app.doc.datasets[0].as_nmr_mut().expect("NMR dataset");
nmr.processed = plotx_processing::reapply_output(&nmr.base, &nmr.pipeline);
}

fn resolved_marks(app: &crate::state::PlotxApp) -> Vec<ResolvedPeak> {
let dataset = &app.doc.datasets[0];
dataset
.peaks()
.expect("peak set")
.resolve(dataset.peak_reference_offset_ppm())
}

/// The reported defect: mark a peak, then edit the Reference step — the mark
/// must follow the recalibrated axis instead of pinning the old coordinates.
#[test]
fn marks_follow_a_later_reference_edit() {
let mut app = frequency_app();
let apex_x = app.doc.datasets[0]
.displayed_trace(None)
.expect("1D trace")
.xs[32];
app.add_manual_peak(0, apex_x, None, ManualPeakSnap::NearestSample);
let before = resolved_marks(&app);
assert_eq!(before.len(), 1);
assert!((before[0].x - apex_x).abs() < 1e-12);

apply_reference(&mut app, apex_x, apex_x + 0.5);

let after = resolved_marks(&app);
assert!((after[0].x - (apex_x + 0.5)).abs() < 1e-12);
// The mark tracks the shifted trace: the same array position now reads
// the mark's resolved x.
let shifted = app.doc.datasets[0]
.displayed_trace(None)
.expect("1D trace")
.xs[32];
assert!((after[0].x - shifted).abs() < 1e-12);
// The default label reads the calibrated position.
assert_eq!(after[0].label, format!("{:.2}", after[0].x));
}

/// Picks made on an already-referenced spectrum resolve back to the clicked
/// finished coordinate (the stored value is uncalibrated).
#[test]
fn picks_on_a_referenced_spectrum_round_trip() {
let mut app = frequency_app();
apply_reference(&mut app, 0.0, 0.75);
let apex_x = app.doc.datasets[0]
.displayed_trace(None)
.expect("1D trace")
.xs[32];

app.add_manual_peak(0, apex_x, None, ManualPeakSnap::NearestSample);

let resolved = resolved_marks(&app);
assert!((resolved[0].x - apex_x).abs() < 1e-12);
let stored = &app.doc.datasets[0].peaks().expect("peak set").marks[0];
assert!((stored.x - (apex_x - 0.75)).abs() < 1e-12);
}
3 changes: 2 additions & 1 deletion docs/src/content/docs/guides/processing.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ screen is immediately usable, and a session touches at most three things:
2. **Baseline** — baseline correction is off by default; enable the step when
the baseline rolls or offsets.
3. **Reference** — add a Reference step to pin a known peak to its
chemical-shift position.
chemical-shift position. Peak marks are calibrated with the spectrum:
editing the Reference step moves existing marks along with the axis.

2D datasets get a cosine-bell apodization enabled by default. A true 2D
acquisition shows two pipelines, **F2 (direct)** then **F1 (indirect)**, in the
Expand Down
3 changes: 2 additions & 1 deletion docs/src/content/docs/zh-cn/guides/processing.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ XPS 为每个谱区使用独立的有序 recipe,而不是 NMR 管线。recipe
1. **相位**——自动结果不理想时,打开相位校正步骤手动调节 φ0 / φ1 并
实时预览,或切换自动算法。
2. **基线**——基线校正默认关闭;基线起伏或偏移时启用该步骤。
3. **参考**——添加参考步骤,把已知峰定标到其化学位移位置。
3. **参考**——添加参考步骤,把已知峰定标到其化学位移位置。峰标记与谱图共用
同一定标:修改参考步骤时,已有的峰标记会随坐标轴一起平移。

2D 数据集默认启用余弦钟形切趾。真 2D 谱会按处理顺序显示两条管线:先
**F2 (direct)**,后 **F1 (indirect)**。已经变换过的数据标记为
Expand Down
Loading