From d5ac29fa2f174d0a37906d87729fb475958544b6 Mon Sep 17 00:00:00 2001 From: jobo322 Date: Mon, 27 Jul 2026 11:01:10 -0500 Subject: [PATCH 1/4] feat: enhance contour level determination with percentile-based noise calculation --- src/data/data2d/Spectrum2D/contours.ts | 32 ++++++++++++++++++++------ src/data/utilities/calculateSanPlot.ts | 3 ++- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/src/data/data2d/Spectrum2D/contours.ts b/src/data/data2d/Spectrum2D/contours.ts index d6d2c76f39..f93e63b293 100644 --- a/src/data/data2d/Spectrum2D/contours.ts +++ b/src/data/data2d/Spectrum2D/contours.ts @@ -2,7 +2,6 @@ import type { Spectrum2D } from '@zakodium/nmrium-core'; import { isSpectrum2DFt } from '@zakodium/nmrium-core'; import type { NmrData2DFt } from 'cheminfo-types'; import { Conrec } from 'ml-conrec'; -import { xMaxAbsoluteValue } from 'ml-spectra-processing'; import type { Spectrum } from 'nmr-correlation'; import type { SpectrumFTData } from '../../../component/hooks/use2DReducer.tsx'; @@ -27,6 +26,7 @@ interface BaseWheelOptions { altKey: boolean; invertScroll?: boolean; } + interface WheelOptions extends BaseWheelOptions { contourOptions: ContourOptions; } @@ -41,6 +41,7 @@ const DEFAULT_CONTOURS_OPTIONS: ContourOptions = { numberOfLayers: 10, }, }; + type LevelSign = keyof Level; const LEVEL_SIGNS: Readonly<[LevelSign, LevelSign]> = ['positive', 'negative']; @@ -52,24 +53,41 @@ interface ContoursManagerReturn { } function getDefaultContoursLevel(spectrum: Spectrum2D, quadrant = 'rr') { - const { data, info } = spectrum; + const { data, info, filters } = spectrum; // @ts-expect-error type of NmrData2D should have a discriminator field to separate fid and ft const quadrantData = data[quadrant]; + + const { acquisitionScheme } = info; //@ts-expect-error will be included in nexts versions - const { noise = calculateSanPlot('2D', quadrantData) } = info; + const { noise = calculateSanPlot('2D', quadrantData, { magnitudeMode: acquisitionScheme === 'notPhaseSensitive' }) } = info; - const { positive = 0, negative = 0 } = noise; + const { positive = 0, negative = 0, percentiles } = noise; + const minAllowedFromNoise = 10 * Math.max(positive, negative); const max = Math.max( Math.abs(quadrantData.minZ), Math.abs(quadrantData.maxZ), ); - const minAbsPeakBase = 0.005 * max; - const minAllowed = 3 * xMaxAbsoluteValue([positive, negative]); + const isSymmetrized = filters.some((filter) => filter.name === 'symmetrizeCosyLike' && filter.enabled); + const isNUS = filters.some((filter) => filter.name === 'nusDimension2' && filter.enabled); + + const { positive: pPositive, negative: pNegative } = percentiles + + const percentileValue = isSymmetrized ? isNUS ? 60 : 90 : 99; + const pPositiveIndex = pPositive(percentileValue) //getClosestYIndex(pPositive, 50, 1 * 50 / pPositive.length, percentileValue); + const pNegativeIndex = pNegative(percentileValue) //getClosestYIndex(pNegative, 50, 1 * 50 / pNegative.length, percentileValue); + + const minAllowedByPercentile = Math.max(pPositive[pPositiveIndex] ?? 0, pNegative[pNegativeIndex] ?? 0); + + const { log10 } = Math; + const inLogScaleNoise = log10(minAllowedFromNoise) / log10(2); + const maxValue = log10(max) / log10(2); + const middleValue = 10 ** (log10(2) * ((maxValue - inLogScaleNoise) / 2 + inLogScaleNoise)); + + const minLevel = isNUS ? Math.min(middleValue, minAllowedByPercentile) : Math.max(middleValue, minAllowedByPercentile); - const minLevel = Math.max(minAbsPeakBase, minAllowed); const minContourLevel = Math.min( calculateValueOfLevel(minLevel, max, true), DEFAULT_CONTOURS_OPTIONS.positive.contourLevels[1] - diff --git a/src/data/utilities/calculateSanPlot.ts b/src/data/utilities/calculateSanPlot.ts index c3548797f5..9a27f5e82a 100644 --- a/src/data/utilities/calculateSanPlot.ts +++ b/src/data/utilities/calculateSanPlot.ts @@ -4,13 +4,14 @@ import { xNoiseSanPlot } from 'ml-spectra-processing'; export function calculateSanPlot( dimension: T, data: T extends '1D' ? NmrData1D : NmrData2DFt['rr'], + options?: { magnitudeMode?: boolean }, ) { const input = dimension === '1D' ? prepare1DData(data as NmrData1D) : prepare2DData(data as NmrData2DFt['rr']); - return xNoiseSanPlot(input); + return xNoiseSanPlot(input, options); } function prepare1DData(data: NmrData1D) { From 4949864d8c0a7c9719b3575616e426ba9cb16cf7 Mon Sep 17 00:00:00 2001 From: jobo322 Date: Thu, 30 Jul 2026 10:33:55 -0500 Subject: [PATCH 2/4] chore: uses percentiles as a simple array 0-100 percentiles --- src/data/data2d/Spectrum2D/contours.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/data/data2d/Spectrum2D/contours.ts b/src/data/data2d/Spectrum2D/contours.ts index f93e63b293..57dbf01de9 100644 --- a/src/data/data2d/Spectrum2D/contours.ts +++ b/src/data/data2d/Spectrum2D/contours.ts @@ -76,8 +76,8 @@ function getDefaultContoursLevel(spectrum: Spectrum2D, quadrant = 'rr') { const { positive: pPositive, negative: pNegative } = percentiles const percentileValue = isSymmetrized ? isNUS ? 60 : 90 : 99; - const pPositiveIndex = pPositive(percentileValue) //getClosestYIndex(pPositive, 50, 1 * 50 / pPositive.length, percentileValue); - const pNegativeIndex = pNegative(percentileValue) //getClosestYIndex(pNegative, 50, 1 * 50 / pNegative.length, percentileValue); + const pPositiveIndex = pPositive[percentileValue]; + const pNegativeIndex = pNegative[percentileValue]; const minAllowedByPercentile = Math.max(pPositive[pPositiveIndex] ?? 0, pNegative[pNegativeIndex] ?? 0); From 218a6780a9477b36bd6ddd30e08c5cd560015369 Mon Sep 17 00:00:00 2001 From: jobo322 Date: Fri, 31 Jul 2026 11:09:53 -0500 Subject: [PATCH 3/4] feat: enhance minimum contour threshold determination with percentile-based calculations --- src/data/data2d/Spectrum2D/contours.ts | 156 ++++++++++++++++++++++--- 1 file changed, 140 insertions(+), 16 deletions(-) diff --git a/src/data/data2d/Spectrum2D/contours.ts b/src/data/data2d/Spectrum2D/contours.ts index 57dbf01de9..88b16f49ce 100644 --- a/src/data/data2d/Spectrum2D/contours.ts +++ b/src/data/data2d/Spectrum2D/contours.ts @@ -1,6 +1,6 @@ import type { Spectrum2D } from '@zakodium/nmrium-core'; import { isSpectrum2DFt } from '@zakodium/nmrium-core'; -import type { NmrData2DFt } from 'cheminfo-types'; +import type { DataXY, NmrData2DFt } from 'cheminfo-types'; import { Conrec } from 'ml-conrec'; import type { Spectrum } from 'nmr-correlation'; @@ -63,11 +63,13 @@ function getDefaultContoursLevel(spectrum: Spectrum2D, quadrant = 'rr') { //@ts-expect-error will be included in nexts versions const { noise = calculateSanPlot('2D', quadrantData, { magnitudeMode: acquisitionScheme === 'notPhaseSensitive' }) } = info; - const { positive = 0, negative = 0, percentiles } = noise; - const minAllowedFromNoise = 10 * Math.max(positive, negative); + const {percentiles, sanplot } = noise; + const sanPlotMax = getSanPlotMinMax(sanplot ?? {}); + const positiveSanPlotMax = sanPlotMax.positive?.max ?? 0; + const negativeSanPlotMax = sanPlotMax.negative?.max ?? 0; const max = Math.max( - Math.abs(quadrantData.minZ), - Math.abs(quadrantData.maxZ), + positiveSanPlotMax, + negativeSanPlotMax, ); const isSymmetrized = filters.some((filter) => filter.name === 'symmetrizeCosyLike' && filter.enabled); @@ -75,19 +77,19 @@ function getDefaultContoursLevel(spectrum: Spectrum2D, quadrant = 'rr') { const { positive: pPositive, negative: pNegative } = percentiles - const percentileValue = isSymmetrized ? isNUS ? 60 : 90 : 99; - const pPositiveIndex = pPositive[percentileValue]; - const pNegativeIndex = pNegative[percentileValue]; - - const minAllowedByPercentile = Math.max(pPositive[pPositiveIndex] ?? 0, pNegative[pNegativeIndex] ?? 0); - - const { log10 } = Math; - const inLogScaleNoise = log10(minAllowedFromNoise) / log10(2); - const maxValue = log10(max) / log10(2); - const middleValue = 10 ** (log10(2) * ((maxValue - inLogScaleNoise) / 2 + inLogScaleNoise)); + const percentileValue = isSymmetrized ? (isNUS ? 60 : 90) : 99; + const pPositiveValue = pPositive[percentileValue]; + const pNegativeValue = pNegative[percentileValue]; + + const optimalContourLevel = getContourThresholdFromPercentiles(pPositive, pNegative, { + minP: 80, + maxP: 99, + madScale: 1, + }); - const minLevel = isNUS ? Math.min(middleValue, minAllowedByPercentile) : Math.max(middleValue, minAllowedByPercentile); + const minAllowedByPercentile = Math.max(pPositiveValue ?? 0, pNegativeValue ?? 0); + const minLevel = isNUS ? Math.min(optimalContourLevel, minAllowedByPercentile) : Math.max(optimalContourLevel, minAllowedByPercentile); const minContourLevel = Math.min( calculateValueOfLevel(minLevel, max, true), DEFAULT_CONTOURS_OPTIONS.positive.contourLevels[1] - @@ -113,6 +115,128 @@ function getDefaultContoursLevel(spectrum: Spectrum2D, quadrant = 'rr') { return defaultLevel; } +function getSanPlotMinMax( + sanplot: Record, + options: { logBaseY?: number } = {}, +): Record { + const { logBaseY = 2 } = options; + + const result: Record = {}; + + for (const [key, series] of Object.entries(sanplot)) { + const y = series.y; + if (y.length === 0) { + result[key] = { min: Number.MIN_SAFE_INTEGER, max: Number.MIN_SAFE_INTEGER }; + continue; + } + + const first = logBaseY ** y[0]; + const last = logBaseY ** (y.at(-1) ?? 1); + + result[key] = { + min: Math.min(first, last), + max: Math.max(first, last), + }; + } + + return result; +} + + function getContourThresholdFromPercentiles( + positivePercentiles: readonly number[], + negativePercentiles: readonly number[], + options: ContourThresholdOptions = {}, +): number { + const positiveContourLevel = findOptimalContourThreshold(positivePercentiles, options); + const negativeContourLevel = findOptimalContourThreshold(negativePercentiles, options); + + const positiveThreshold = positivePercentiles[positiveContourLevel.optimalPercentile]; + const negativeThreshold = negativePercentiles[negativeContourLevel.optimalPercentile]; + + return Math.max(positiveThreshold, negativeThreshold); +} +/** + * Finds the optimal minimum contour threshold for a 2D NMR spectrum + * using the Robust Median-MAD formulation on a percentile-intensity array. + * + * @param {number[]} percentiles - Array where index = percentile (0-100), + * value = intensity at that percentile. + * @param {object} options - Configuration options + * @returns {object} - { optimalPercentile, optimalThreshold, maxSNR } + */ +interface ContourThresholdOptions { + minP?: number; + maxP?: number; + madScale?: number; + scoreRatio?: number; +} + +interface OptimalContourMinLevel { + optimalPercentile: number; + optimalThreshold: number; + maxSNR: number; +} + +function interpolate(arr: readonly number[], idx: number): number { + const i = Math.floor(idx); + const j = Math.ceil(idx); + if (i === j || i < 0 || j >= arr.length) { + return arr[Math.max(0, Math.min(arr.length - 1, Math.round(idx)))]; + } + return arr[i] + (idx - i) * (arr[j] - arr[i]); +} + +function findOptimalContourThreshold( + percentiles: readonly number[], + options: ContourThresholdOptions = {} +): OptimalContourMinLevel { + const { minP = 80, maxP = 99, madScale = 1 } = options; + + if (madScale <= 0) { + throw new Error('madScale must be > 0 to avoid division by zero.'); + } + + // Linear interpolation for non-integer percentile indices + + let bestP = minP; + let bestSNR = -Infinity; + + for (let p = minP; p <= maxP; p++) { + const idxMedian = p / 2; + const idxQ1 = p / 4; + const idxQ3 = (3 * p) / 4; + const idxMeanAbove = (p + 100) / 2; + + const medianBelow = interpolate(percentiles, idxMedian); + const q1Below = interpolate(percentiles, idxQ1); + const q3Below = interpolate(percentiles, idxQ3); + const meanAbove = interpolate(percentiles, idxMeanAbove); + + const madBelow = (q3Below - q1Below) / 2; + if (madBelow <= 0) continue; + + const snr = (meanAbove - medianBelow) / (madBelow * madScale); + + // ✦ Coverage weight: fraction of points ABOVE the threshold + // At p=80 → weight=0.20, at p=99 → weight=0.01 + // This penalizes thresholds that exclude too much. + const coverage = (100 - p) / 100; + + // Optional: sharpen the penalty with an exponent + const score = snr * coverage ** 0.5; // sqrt softens it + + if (score > bestSNR) { + bestSNR = score; + bestP = p; + } +} + return { + optimalPercentile: bestP, + optimalThreshold: interpolate(percentiles, bestP), + maxSNR: bestSNR + }; +} + function contoursManager(options: ContourOptions): ContoursManagerReturn { const contourOptions = { ...options }; return { From a6d99b50069a640f29eae6c0f4503234510d3dcc Mon Sep 17 00:00:00 2001 From: jobo322 Date: Tue, 4 Aug 2026 15:20:16 -0500 Subject: [PATCH 4/4] chore(contours): default min level percentile 70 for symmetrized COSY spectra --- src/data/data2d/Spectrum2D/contours.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/data/data2d/Spectrum2D/contours.ts b/src/data/data2d/Spectrum2D/contours.ts index 88b16f49ce..ed4a79ca39 100644 --- a/src/data/data2d/Spectrum2D/contours.ts +++ b/src/data/data2d/Spectrum2D/contours.ts @@ -77,7 +77,7 @@ function getDefaultContoursLevel(spectrum: Spectrum2D, quadrant = 'rr') { const { positive: pPositive, negative: pNegative } = percentiles - const percentileValue = isSymmetrized ? (isNUS ? 60 : 90) : 99; + const percentileValue = isSymmetrized ? (isNUS ? 60 : 70) : 99; const pPositiveValue = pPositive[percentileValue]; const pNegativeValue = pNegative[percentileValue]; @@ -89,7 +89,7 @@ function getDefaultContoursLevel(spectrum: Spectrum2D, quadrant = 'rr') { const minAllowedByPercentile = Math.max(pPositiveValue ?? 0, pNegativeValue ?? 0); - const minLevel = isNUS ? Math.min(optimalContourLevel, minAllowedByPercentile) : Math.max(optimalContourLevel, minAllowedByPercentile); + const minLevel = isSymmetrized || isNUS ? Math.min(optimalContourLevel, minAllowedByPercentile) : Math.max(optimalContourLevel, minAllowedByPercentile); const minContourLevel = Math.min( calculateValueOfLevel(minLevel, max, true), DEFAULT_CONTOURS_OPTIONS.positive.contourLevels[1] -