diff --git a/app/scripts/nmr-cli/package-lock.json b/app/scripts/nmr-cli/package-lock.json index 6f47fd6..91f5f24 100644 --- a/app/scripts/nmr-cli/package-lock.json +++ b/app/scripts/nmr-cli/package-lock.json @@ -19,6 +19,7 @@ "lodash.merge": "^4.6.2", "mf-parser": "^3.9.2", "ml-spectra-processing": "^14.34.0", + "nmr-correlation": "^3.0.2", "nmr-processing": "^22.23.6", "openchemlib": "^9.25.0", "playwright": "1.62.1", @@ -128,11 +129,12 @@ } }, "node_modules/@types/node": { - "version": "26.2.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", - "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "version": "26.4.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz", + "integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~8.3.0" } @@ -230,9 +232,9 @@ "license": "MIT" }, "node_modules/@zip.js/zip.js": { - "version": "2.8.57", - "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.57.tgz", - "integrity": "sha512-cd5a0s7CS0MOD52f7FW4ju84wc/+b+YNu5XIG72PfXRLk6rInEdvIrB/BjR8D8hS4fMKuYJOBjv6FoLh3e7DHw==", + "version": "2.8.61", + "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.61.tgz", + "integrity": "sha512-F9RnQJXgvCSdAJfMtSrH5Uh/LcLg8fZlg5ARC4u5N4w9v/DG27cN3C/ngUrY1JdJGX2LRv5DDViXeZJgbcppWA==", "license": "BSD-3-Clause", "engines": { "bun": ">=0.7.0", @@ -322,9 +324,9 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.19.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", - "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", @@ -1259,6 +1261,17 @@ "integrity": "sha512-+z6QY1SxkDk6CQJAeaIZKmcNubBCRP7J8DMQUBglz/sSkNsZoJ1kULjqk9skNPPplzs4i9PFhYrvNDdtQleF/A==", "license": "MIT" }, + "node_modules/nmr-correlation": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/nmr-correlation/-/nmr-correlation-3.0.2.tgz", + "integrity": "sha512-0nEUMNIENB+l9C+gERvefWg4grByUVzwVnOl9RGcLNKDnfsvbLDm44ejkMG/Vw2Lp4+i04WJRZThhX/L1UHaqg==", + "license": "MIT", + "dependencies": { + "cheminfo-types": "^1.15.0", + "ml-matrix-peaks-finder": "^2.0.0", + "ml-peak-shape-generator": "^5.2.0" + } + }, "node_modules/nmr-processing": { "version": "22.23.6", "resolved": "https://registry.npmjs.org/nmr-processing/-/nmr-processing-22.23.6.tgz", @@ -1323,7 +1336,8 @@ "version": "9.25.0", "resolved": "https://registry.npmjs.org/openchemlib/-/openchemlib-9.25.0.tgz", "integrity": "sha512-FGTaZLJRTGXNC7khx8QvX/EiQBHpH1ncUbT7YXDJhw/Y/aDUKe5WrUIqTguMEtSs3GUHcRUjNUhfkpxulx2UXw==", - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/openchemlib-utils": { "version": "8.18.0", @@ -1496,6 +1510,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -1607,6 +1622,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/app/scripts/nmr-cli/package.json b/app/scripts/nmr-cli/package.json index c795b3a..9180a20 100644 --- a/app/scripts/nmr-cli/package.json +++ b/app/scripts/nmr-cli/package.json @@ -25,6 +25,7 @@ "lodash.merge": "^4.6.2", "mf-parser": "^3.9.2", "ml-spectra-processing": "^14.34.0", + "nmr-correlation": "^3.0.2", "nmr-processing": "^22.23.6", "openchemlib": "^9.25.0", "playwright": "1.62.1", diff --git a/app/scripts/nmr-cli/src/correlation.ts b/app/scripts/nmr-cli/src/correlation.ts new file mode 100644 index 0000000..add9d6c --- /dev/null +++ b/app/scripts/nmr-cli/src/correlation.ts @@ -0,0 +1,76 @@ +import { buildCorrelationData } from 'nmr-correlation' +import type { Options as CorrelationOptions, Spectra } from 'nmr-correlation' +import { FifoLogger } from 'fifo-logger' +import { + buildWebSource, + core, + parsingOptions, + processSpectra, +} from './parse/prase-spectra' + +// Default tolerances +const DEFAULT_TOLERANCE_H = 0.02 +const DEFAULT_TOLERANCE_C = 0.25 + +export interface CorrelationInput { + url: string + mf: string + toleranceH?: number + toleranceC?: number +} + +function resolveTolerance(value: number | undefined, fallback: number): number { + return value === undefined || Number.isNaN(value) ? fallback : value +} + +export async function generateCorrelationData(input: CorrelationInput) { + const { url, mf, toleranceH, toleranceC } = input + const logger = new FifoLogger() + + const source = buildWebSource(url) + + const { state } = await core.readFromWebSource(source, { + ...parsingOptions, + logger, + }) + + const spectraBeforeProcessing = state.data ? [...state.data.spectra] : [] + + if (state.data) { + processSpectra( + state.data, + { autoProcessing: true, autoDetection: true }, + logger + ) + } + + // processSpectra replaces a spectrum's array slot with a new object only + // when it successfully parses it; on failure it leaves the original raw + // object in place (see its catch block) instead of removing it. Compare + // by reference against the pre-processing snapshot to filter those out, + // so buildCorrelationData never sees a spectrum it can't actually read. + // Note: a pre-existing bug (see https://github.com/NFDI4Chem/nmrkit/issues/139) + // currently makes every spectrum fail this step, so real cross-spectrum correlation links are untested here. + const spectra = (state.data?.spectra ?? []).filter( + (spectrum, index) => spectrum !== spectraBeforeProcessing[index] + ) + + const options: CorrelationOptions = { + mf, + tolerance: { + H: resolveTolerance(toleranceH, DEFAULT_TOLERANCE_H), + C: resolveTolerance(toleranceC, DEFAULT_TOLERANCE_C), + }, + } + + let correlationData + try { + correlationData = buildCorrelationData(spectra as Spectra, options) + } catch (error) { + throw new Error( + `Failed to build correlation data: ${error instanceof Error ? error.message : String(error)}` + ) + } + + return { ...correlationData, logs: logger.getLogs() } +} diff --git a/app/scripts/nmr-cli/src/index.ts b/app/scripts/nmr-cli/src/index.ts index ec4c449..215831e 100755 --- a/app/scripts/nmr-cli/src/index.ts +++ b/app/scripts/nmr-cli/src/index.ts @@ -4,6 +4,7 @@ import { parseSpectra } from './parse/prase-spectra' import { generateSpectrumFromPublicationString } from './publication-string' import { generateNMRiumFromPeaks } from './peaks-to-nmrium' import type { PeaksToNMRiumInput } from './peaks-to-nmrium' +import { generateCorrelationData } from './correlation' import { hideBin } from 'yargs/helpers' import { parsePredictionCommand } from './prediction' import { readFileSync } from 'fs' @@ -15,8 +16,15 @@ Usage: nmr-cli [options] Commands: parse-spectra Parse a spectra file to NMRium file parse-publication-string resurrect spectrum from the publication string - predict Predict spectrum from Mol + predict Predict spectrum from Mol peaks-to-nmrium Convert a peak list to NMRium object + correlation Build correlation data from NMR spectra fetched from a URL + +Options for 'correlation' command: + -u, --url Spectra ZIP file URL + --mf Molecular formula + --tolerance-h H tolerance override (default: 0.02) + --tolerance-c C tolerance override (default: 0.25) Options for 'parse-spectra' command: -u, --url File URL @@ -250,12 +258,61 @@ const peaksToNMRiumCommand: CommandModule = { }, } +// Define the correlation command +const correlationCommand: CommandModule = { + command: ['correlation', 'corr'], + describe: 'Build correlation data from NMR spectra fetched from a URL', + builder: yargs => { + return yargs.options({ + u: { + alias: 'url', + describe: 'Spectra ZIP file URL', + type: 'string', + demandOption: true, + nargs: 1, + }, + mf: { + describe: 'Molecular formula', + type: 'string', + demandOption: true, + nargs: 1, + }, + 'tolerance-h': { + describe: 'H tolerance override (default: 0.02)', + type: 'number', + }, + 'tolerance-c': { + describe: 'C tolerance override (default: 0.25)', + type: 'number', + }, + }) + }, + handler: async argv => { + try { + const result = await generateCorrelationData({ + url: argv.u as string, + mf: argv.mf as string, + toleranceH: argv['tolerance-h'] as number | undefined, + toleranceC: argv['tolerance-c'] as number | undefined, + }) + console.log(JSON.stringify(result)) + } catch (error) { + console.error( + 'Error:', + error instanceof Error ? error.message : String(error), + ) + process.exit(1) + } + }, +} + yargs(hideBin(process.argv)) .usage(usageMessage) .command(parseFileCommand) .command(parsePublicationCommand) .command(parsePredictionCommand) .command(peaksToNMRiumCommand) + .command(correlationCommand) .showHelpOnFail(true) .help() .parse() diff --git a/app/scripts/nmr-cli/src/parse/prase-spectra.ts b/app/scripts/nmr-cli/src/parse/prase-spectra.ts index d4d24c4..27c6971 100644 --- a/app/scripts/nmr-cli/src/parse/prase-spectra.ts +++ b/app/scripts/nmr-cli/src/parse/prase-spectra.ts @@ -196,11 +196,9 @@ async function processAndSerialize( outputResult({ nmriumState: { data, version }, images, logs }, o); } -async function loadSpectrumFromURL(options: RequiredKey, logger: FifoLogger) { - const { u: url, include, exclude } = options; - +function buildWebSource(url: string) { const { pathname: relativePath, origin: baseURL } = new URL(url) - const source = { + return { entries: [ { relativePath, @@ -208,7 +206,12 @@ async function loadSpectrumFromURL(options: RequiredKey, l ], baseURL, } +} + +async function loadSpectrumFromURL(options: RequiredKey, logger: FifoLogger) { + const { u: url, include, exclude } = options; + const source = buildWebSource(url) const { state } = await core.readFromWebSource(source, { ...parsingOptions, fileFilter: { include, exclude }, logger }); @@ -257,4 +260,4 @@ function parseSpectra(argv: yargs.ArgumentsCamelCase -export { loadSpectrumFromFilePath, loadSpectrumFromURL, parseSpectra } +export { loadSpectrumFromFilePath, loadSpectrumFromURL, parseSpectra, processSpectra, parsingOptions, core, buildWebSource }