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..0c8382b --- /dev/null +++ b/app/scripts/nmr-cli/src/correlation.ts @@ -0,0 +1,115 @@ +import { buildCorrelationData } from 'nmr-correlation' +import type { Options as CorrelationOptions, Spectra } from 'nmr-correlation' +import { FifoLogger } from 'fifo-logger' +import type { NmriumState, Spectrum } from '@zakodium/nmrium-core' +import { + buildWebSource, + core, + loadFileCollection, + parsingOptions, + processSpectra, +} from './parse/prase-spectra' +import { isSpectrum2D } from './parse/data/data2d/isSpectrum2D' + +// Default tolerances confirmed by vcnainala on issue #66 +const DEFAULT_TOLERANCE_H = 0.02 +const DEFAULT_TOLERANCE_C = 0.25 + +export interface CorrelationInput { + url?: string + dir?: string + mf: string + toleranceH?: number + toleranceC?: number +} + +interface ReadSpectraOptions { + url?: string + dir?: string +} + +async function readSpectra( + options: ReadSpectraOptions, + logger: FifoLogger +): Promise> { + const { url, dir } = options + + if (url) { + const { state } = await core.readFromWebSource(buildWebSource(url), { + ...parsingOptions, + logger, + }) + return state + } + + if (dir) { + const { state } = await core.read(await loadFileCollection(dir), { + ...parsingOptions, + logger, + }) + return state + } + + throw new Error('Either a spectra URL or a local directory path is required') +} + +// buildCorrelationData needs detected ranges (1D) or zones (2D) to find +// correlations, so require isFt plus at least one detected range/zone. +// This also excludes spectra that failed to initiate or failed detection, +// since those never get ranges/zones populated either. +// Note: a pre-existing bug (see https://github.com/NFDI4Chem/nmrkit/issues/139) +// currently makes every spectrum fail initiation, so real cross-spectrum correlation links are untested here. +function filterSpectra(spectra: Spectrum[]): Spectrum[] { + return spectra.filter(spectrum => { + const { info } = spectrum + if (info.isFt !== true) return false + + if (isSpectrum2D(spectrum)) { + const { zones } = spectrum + return zones.values.length > 0 + } + + const { ranges } = spectrum + return ranges.values.length > 0 + }) +} + +function resolveTolerance(value: number | undefined, fallback: number): number { + return value === undefined || Number.isNaN(value) ? fallback : value +} + +export async function generateCorrelationData(input: CorrelationInput) { + const { url, dir, mf, toleranceH, toleranceC } = input + const logger = new FifoLogger() + + const state = await readSpectra({ url, dir }, logger) + + if (state.data) { + processSpectra( + state.data, + { autoProcessing: true, autoDetection: true }, + logger + ) + } + + const spectra = filterSpectra(state.data?.spectra ?? []) + + 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..7ca4b70 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,16 @@ 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 + -dir, --dir-path Local directory path + --mf Molecular formula + --tolerance-h, --th H tolerance override (default: 0.02) + --tolerance-c, --tc C tolerance override (default: 0.25) Options for 'parse-spectra' command: -u, --url File URL @@ -250,12 +259,73 @@ const peaksToNMRiumCommand: CommandModule = { }, } +// Define the correlation command +const correlationCommand: CommandModule = { + command: ['correlation', 'corr'], + describe: 'Build correlation data from NMR spectra fetched from a URL or a local directory', + builder: yargs => { + return yargs + .options({ + u: { + alias: 'url', + describe: 'Spectra ZIP file URL', + type: 'string', + nargs: 1, + }, + dir: { + alias: 'dir-path', + describe: 'Local directory path', + type: 'string', + nargs: 1, + }, + mf: { + describe: 'Molecular formula', + type: 'string', + demandOption: true, + nargs: 1, + }, + 'tolerance-h': { + alias: 'th', + describe: 'H tolerance override', + type: 'number', + default: 0.02, + }, + 'tolerance-c': { + alias: 'tc', + describe: 'C tolerance override', + type: 'number', + default: 0.25, + }, + }) + .conflicts('u', 'dir') + }, + handler: async argv => { + try { + const result = await generateCorrelationData({ + url: argv.u as string | undefined, + dir: argv.dir as string | undefined, + 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..e496b12 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 }); @@ -216,15 +219,19 @@ async function loadSpectrumFromURL(options: RequiredKey, l } -async function loadSpectrumFromFilePath(options: RequiredKey, logger: FifoLogger) { - const { dir: path, include, exclude } = options; - +function loadFileCollection(path: string, include?: string[], exclude?: string[]) { const dirPath = isAbsolute(path) ? path : join(process.cwd(), path) - const fileCollection = await FileCollection.fromPath(dirPath, { + return FileCollection.fromPath(dirPath, { unzip: { zipExtensions: ['zip', 'nmredata'] }, filter: { include, exclude }, }) +} + +async function loadSpectrumFromFilePath(options: RequiredKey, logger: FifoLogger) { + const { dir: path, include, exclude } = options; + + const fileCollection = await loadFileCollection(path, include, exclude) const { state @@ -257,4 +264,4 @@ function parseSpectra(argv: yargs.ArgumentsCamelCase -export { loadSpectrumFromFilePath, loadSpectrumFromURL, parseSpectra } +export { loadSpectrumFromFilePath, loadSpectrumFromURL, parseSpectra, processSpectra, parsingOptions, core, buildWebSource, loadFileCollection }