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
8 changes: 4 additions & 4 deletions CONTRIBUTOR-LICENSE-AGREEMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,10 +171,10 @@ text does not change an Agreement already accepted.

You accept this Agreement by posting the signature statement requested by the
CLA assistant in a pull request in the official PlotX repository. Acceptance is
recorded in `signatures/version1/cla.json` together with your GitHub account,
the pull request, and the date. You accept once; the acceptance then covers
Your Contributions under this version, including any Contribution Submitted
before the date of acceptance.
recorded in the `cla-signatures` branch at `signatures/version1/cla.json`,
together with your GitHub account, the pull request, and the date. You accept
once; the acceptance then covers Your Contributions under this version,
including any Contribution Submitted before the date of acceptance.

## Attribution

Expand Down
19 changes: 19 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ preparation.
## Highlights

- **Bring scientific data together.** Current import support includes Axon
ABF2 patch-clamp recordings, Rigaku powder XRD patterns, mzML and Waters
MassLynx LC–MS runs, JEOL Delta, Bruker TopSpin, and Varian/Agilent VnmrJ
experiments, JCAMP-DX spectra, archives, and delimited tables.
ABF2 patch-clamp recordings, Rigaku powder XRD patterns, mzML, Waters
MassLynx, and legacy SCIEX WIFF LC–MS runs, JEOL Delta, Bruker TopSpin,
and Varian/Agilent VnmrJ experiments, JCAMP-DX spectra, archives, and
delimited tables.
- **Process and analyze interactively.** Build ordered processing pipelines,
then pick peaks, integrate regions, and fit data. NMR workflows also include
DOSY and relaxation analysis, plus sweep statistics and IV analysis for
Expand Down
84 changes: 84 additions & 0 deletions crates/analysis/src/craft.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,90 @@ pub enum CraftFitError {
Singular,
}

/// Replace the leading samples of a complex record by backward linear
/// prediction from the immediately following observed samples.
///
/// CRAFT uses this after digital filtering because a finite FIR must invent a
/// prehistory at the acquisition boundary. The prediction is fitted in reverse
/// time, so the supplied autoregressive order has the same meaning as a
/// conventional forward linear predictor.
pub fn backward_linear_predict(
samples: &mut [Complex64],
predicted_count: usize,
training_count: usize,
order: usize,
) -> Result<(), CraftFitError> {
if predicted_count == 0 {
return Ok(());
}
if order == 0
|| training_count <= order
|| predicted_count
.checked_add(training_count)
.is_none_or(|required| required > samples.len())
|| samples
.iter()
.take(predicted_count + training_count)
.any(|value| !value.re.is_finite() || !value.im.is_finite())
{
return Err(CraftFitError::InvalidInput);
}

let training = &samples[predicted_count..predicted_count + training_count];
let reversed = training.iter().rev().copied().collect::<Vec<_>>();
let scale = reversed
.iter()
.map(|value| value.norm())
.fold(0.0_f64, f64::max);
if scale <= f64::MIN_POSITIVE {
return Err(CraftFitError::Singular);
}
let equation_count = reversed.len() - order;
let mut design = DMatrix::<f64>::zeros(equation_count * 2, order * 2);
let mut observed = DVector::<f64>::zeros(equation_count * 2);
for row in 0..equation_count {
let target = reversed[row + order];
observed[row * 2] = target.re / scale;
observed[row * 2 + 1] = target.im / scale;
for lag in 0..order {
let basis = reversed[row + order - lag - 1] / scale;
design[(row * 2, lag * 2)] = basis.re;
design[(row * 2, lag * 2 + 1)] = -basis.im;
design[(row * 2 + 1, lag * 2)] = basis.im;
design[(row * 2 + 1, lag * 2 + 1)] = basis.re;
}
}
// Scale the singular-value cutoff by the design energy so rank detection
// remains stable across differently normalized input records.
let rank_tolerance = (5e-14 * design.norm_squared()).sqrt().max(1e-12);
let solution = design
.svd(true, true)
.solve(&observed, rank_tolerance)
.map_err(|_| CraftFitError::Singular)?;
let coefficients = solution
.as_slice()
.as_chunks::<2>()
.0
.iter()
.map(|pair| Complex64::new(pair[0], pair[1]))
.collect::<Vec<_>>();
let mut history = reversed;
for index in 0..predicted_count {
let predicted = coefficients
.iter()
.enumerate()
.fold(Complex64::new(0.0, 0.0), |sum, (lag, coefficient)| {
sum + coefficient * history[history.len() - lag - 1]
});
if !predicted.re.is_finite() || !predicted.im.is_finite() {
return Err(CraftFitError::Singular);
}
samples[predicted_count - index - 1] = predicted;
history.push(predicted);
}
Ok(())
}

/// Fit a fixed set of initial component frequencies. Model-order selection and
/// residual candidate discovery live in `plotx-processing`, beside its FFT.
pub fn fit_damped_sinusoids_cancellable(
Expand Down
50 changes: 50 additions & 0 deletions crates/analysis/src/craft_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,56 @@ fn synthetic(
(times, samples)
}

#[test]
fn backward_prediction_restores_filtered_record_leading_points() {
let components = [
(13.0, 4.0, 0.3, 0.8),
(-21.0, 2.5, -0.4, 1.7),
(37.0, 1.2, 1.1, 2.4),
];
let (_, expected) = synthetic(&components, 300, 500.0);
let mut samples = expected.clone();
samples[..5].fill(Complex64::new(100.0, -50.0));

backward_linear_predict(&mut samples, 5, 256, 32).unwrap();

for index in 0..5 {
assert!(
(samples[index] - expected[index]).norm() < 1e-7,
"index={index} predicted={:?} expected={:?}",
samples[index],
expected[index]
);
}
}

#[test]
fn backward_prediction_restores_a_short_single_exponential() {
let (_, expected) = synthetic(&[(0.0, 3.0, 0.4, 5.0)], 192, 4_000.0);
let mut samples = expected.clone();
samples[..5].fill(Complex64::new(100.0, -50.0));

backward_linear_predict(&mut samples, 5, 187, 16).unwrap();

for index in 0..5 {
assert!(
(samples[index] - expected[index]).norm() < 1e-7,
"index={index} predicted={:?} expected={:?}",
samples[index],
expected[index]
);
}
}

#[test]
fn backward_prediction_rejects_an_underspecified_fit() {
let mut samples = vec![Complex64::new(1.0, 0.0); 12];
assert_eq!(
backward_linear_predict(&mut samples, 5, 7, 7),
Err(CraftFitError::InvalidInput)
);
}

#[test]
fn recovers_single_damped_sinusoid() {
let (times, samples) = synthetic(&[(123.4, 7.5, 0.37, 2.2)], 2048, 2000.0);
Expand Down
3 changes: 1 addition & 2 deletions crates/app/src/shot/craft_shot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@ pub(super) fn setup(app: &mut PlotxApp, ctx: &egui::Context) -> Result<(), Strin
.data
.clone();
let mut params = CraftParams::conventional();
params.max_fit_window_width_hz = data.spectral_width_hz;
params.max_components_per_fit_window = 8;
params.maximum_model_order = 8;
let invocation = CraftInvocation::acquisition(&data, params);
let result = process_craft_cancellable(&data, &invocation, &|| false)
.map_err(|error| format!("CRAFT screenshot analysis failed: {error}"))?;
Expand Down
68 changes: 68 additions & 0 deletions crates/app/src/ui/affordance.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
//! Shared visual language for click-to-enter surfaces.
//!
//! Ribbon buttons signal clickability by tinting their leading glyph with the
//! theme accent (`Visuals::hyperlink_color`, see `ribbon_button`). Task-card
//! rows that open an editor or reveal content on click reuse the same colour
//! through these helpers, so "this text is clickable" reads identically on
//! every surface instead of each card inventing its own (or, worse, plain
//! text that gives no signal until hovered).

use egui::{Color32, Response, TextFormat, TextStyle, Ui, Visuals, text::LayoutJob};

/// The accent that marks a clickable surface — the exact colour the Ribbon
/// paints its enabled, unchecked button glyphs with.
pub(crate) fn clickable_tint(visuals: &Visuals) -> Color32 {
visuals.hyperlink_color
}

/// A selectable row that reads as clickable while idle: the leading glyph
/// carries the clickable accent while the label keeps the theme text colour,
/// mirroring Ribbon buttons. A selected row falls back to the selection
/// styling wholesale so the accent never fights the checked state.
pub(crate) fn selectable_row(
ui: &mut Ui,
selected: bool,
glyph: &str,
label: impl Into<String>,
) -> Response {
let font_id = TextStyle::Body.resolve(ui.style());
let glyph_color = if selected {
Color32::PLACEHOLDER
} else {
clickable_tint(ui.visuals())
};
let mut job = LayoutJob::default();
job.append(
glyph,
0.0,
TextFormat {
font_id: font_id.clone(),
color: glyph_color,
..Default::default()
},
);
job.append(
&format!(" {}", label.into()),
0.0,
TextFormat {
font_id,
color: Color32::PLACEHOLDER,
..Default::default()
},
);
ui.selectable_label(selected, job)
}

#[cfg(test)]
mod tests {
use super::*;

/// The clickable accent must stay the colour Ribbon glyphs use, in both
/// themes, so every "this is clickable" mark reads as one language.
#[test]
fn clickable_tint_matches_the_ribbon_glyph_colour() {
for visuals in [Visuals::light(), Visuals::dark()] {
assert_eq!(clickable_tint(&visuals), visuals.hyperlink_color);
}
}
}
Loading
Loading