From 2a200ab47595ae0f63bd19325d7da0edd55cb018 Mon Sep 17 00:00:00 2001 From: hamed-musallam <35760236+hamed-musallam@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:13:25 +0200 Subject: [PATCH 1/7] feat: add include/exclude file filters to parse-spectra (#138) * feat(nmr-cli): update NMRium core packages to v2.6.0 * chore(nmr-cli): update dependencies * feat(nmr-cli): add include/exclude file filters to parse-spectra * feat(API): add include/exclude file filters to spectra parse endpoints * feat(nmr-cli): update NMRium core packages to v2.7.0 --- app/routers/spectra.py | 28 +- app/scripts/nmr-cli/Dockerfile | 16 +- app/scripts/nmr-cli/package-lock.json | 454 +++++++++--------- app/scripts/nmr-cli/package.json | 26 +- app/scripts/nmr-cli/src/index.ts | 24 + .../nmr-cli/src/parse/prase-spectra.ts | 7 +- 6 files changed, 297 insertions(+), 258 deletions(-) diff --git a/app/routers/spectra.py b/app/routers/spectra.py index 1e46e3c..1fa8ef9 100644 --- a/app/routers/spectra.py +++ b/app/routers/spectra.py @@ -3,7 +3,7 @@ import io from app.schemas import HealthCheck from pydantic import BaseModel, HttpUrl, Field -from typing import Optional +from typing import Optional, List import subprocess import tempfile import os @@ -50,6 +50,10 @@ class UrlParseRequest(BaseModel): ) raw_data: bool = Field( False, description="Include raw data in the output (default: data source)") + include: Optional[List[str]] = Field( + None, description="Only include files matching pattern(s) (glob/regex string)") + exclude: Optional[List[str]] = Field( + None, description="Exclude files matching pattern(s) (glob/regex string)") model_config = { "json_schema_extra": { @@ -95,6 +99,8 @@ def run_command( auto_processing: bool = False, auto_detection: bool = False, raw_data: bool = False, + include: Optional[List[str]] = None, + exclude: Optional[List[str]] = None, ) -> StreamingResponse: """Execute nmr-cli parse-spectra command in Docker container.""" @@ -113,6 +119,12 @@ def run_command( cmd.append("-d") if raw_data: cmd.append("-r") + if include: + cmd.append("--include") + cmd.extend(include) + if exclude: + cmd.append("--exclude") + cmd.extend(exclude) try: result = subprocess.run( @@ -362,7 +374,11 @@ async def parse_spectra_from_file( description="Enable ranges and zones automatic detection", ), raw_data: bool = Form( - False, description="Include raw data in the output (default: data source references)") + False, description="Include raw data in the output (default: data source references)"), + include: Optional[List[str]] = Form( + None, description="Only include files matching pattern(s) (glob/regex string)"), + exclude: Optional[List[str]] = Form( + None, description="Exclude files matching pattern(s) (glob/regex string)"), ): """ ## Parse spectra from an uploaded file @@ -376,6 +392,8 @@ async def parse_spectra_from_file( | `auto_processing` | Automatically process FID → FT spectra | | `auto_detection` | Automatically detect ranges and zones | | `raw_data` | Include raw data in the output (default: data source) | + | `include` | Only include files matching pattern(s) | + | `exclude` | Exclude files matching pattern(s) | ### Returns Parsed spectra data in NMRium-compatible JSON format. """ @@ -405,6 +423,8 @@ async def parse_spectra_from_file( auto_processing=auto_processing, auto_detection=auto_detection, raw_data=raw_data, + include=include, + exclude=exclude, ) except HTTPException: @@ -452,6 +472,8 @@ async def parse_spectra_from_url(request: UrlParseRequest): | `auto_processing` | Automatically process FID → FT spectra | | `auto_detection` | Automatically detect ranges and zones | | `raw_data` | Include raw data in the output (default: data source) | + | `include` | Only include files matching pattern(s) | + | `exclude` | Exclude files matching pattern(s) | ### Returns Parsed spectra data in NMRium-compatible JSON format. @@ -463,6 +485,8 @@ async def parse_spectra_from_url(request: UrlParseRequest): auto_processing=request.auto_processing, auto_detection=request.auto_detection, raw_data=request.raw_data, + include=request.include, + exclude=request.exclude, ) except HTTPException: diff --git a/app/scripts/nmr-cli/Dockerfile b/app/scripts/nmr-cli/Dockerfile index 2e38276..f7877d5 100644 --- a/app/scripts/nmr-cli/Dockerfile +++ b/app/scripts/nmr-cli/Dockerfile @@ -1,13 +1,21 @@ # build the image ` docker build --tag nmr-cli . ` # run the container ` docker run -it nmr-cli bash ` -FROM mcr.microsoft.com/playwright:v1.58.2-noble +# NOTE: if `docker run` prints +# "Error while loading conda entry point: conda-libmamba-solver (module 'libmambapy' has no attribute 'QueryFormat')" +# this is unrelated to this image/container — it comes from a version mismatch between +# conda-libmamba-solver and libmambapy in your HOST shell's conda (base) environment. +# It does not affect the container. To fix it on the host, run: +# conda update -n base -c conda-forge conda conda-libmamba-solver libmambapy +# or, if that doesn't resolve it: +# conda install -n base -c conda-forge --force-reinstall conda-libmamba-solver libmambapy +# or, to bypass libmamba entirely: +# conda config --set solver classic + +FROM mcr.microsoft.com/playwright:v1.62.1-noble SHELL ["/bin/bash", "-o", "pipefail", "-c"] -# Downgrade to Node 22 -RUN npm install -g n && n 22 && hash -r - WORKDIR /app #ENV BASE_NMRIUM_URL=https://nmrium.nmrxiv.org/ diff --git a/app/scripts/nmr-cli/package-lock.json b/app/scripts/nmr-cli/package-lock.json index dd4eebf..6f47fd6 100644 --- a/app/scripts/nmr-cli/package-lock.json +++ b/app/scripts/nmr-cli/package-lock.json @@ -9,27 +9,27 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "@zakodium/nmr-types": "^0.5.12", - "@zakodium/nmrium-core": "0.7.30", - "@zakodium/nmrium-core-plugins": "0.7.39", - "axios": "^1.13.6", + "@zakodium/nmr-types": "^0.5.26", + "@zakodium/nmrium-core": "0.7.65", + "@zakodium/nmrium-core-plugins": "0.7.87", + "axios": "^1.19.0", "fifo-logger": "^2.0.1", - "file-collection": "^6.6.1", - "json-stream-stringify": "^3.1.6", + "file-collection": "^6.7.0", + "json-stream-stringify": "^3.1.7", "lodash.merge": "^4.6.2", - "mf-parser": "^3.7.1", - "ml-spectra-processing": "^14.22.0", - "nmr-processing": "^22.5.2", - "openchemlib": "^9.20.0", - "playwright": "1.58.2", - "yargs": "^18.0.0" + "mf-parser": "^3.9.2", + "ml-spectra-processing": "^14.34.0", + "nmr-processing": "^22.23.6", + "openchemlib": "^9.25.0", + "playwright": "1.62.1", + "yargs": "^18.1.0" }, "bin": { "nmr-cli": "build/index.js" }, "devDependencies": { "@types/lodash.merge": "^4.6.9", - "@types/node": "^25.3.5", + "@types/node": "^26.2.0", "@types/yargs": "^17.0.35", "ts-node": "^10.9.2", "typescript": "^5.9.3" @@ -128,13 +128,13 @@ } }, "node_modules/@types/node": { - "version": "25.3.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.5.tgz", - "integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==", + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "undici-types": "~8.3.0" } }, "node_modules/@types/yargs": { @@ -155,73 +155,84 @@ "license": "MIT" }, "node_modules/@zakodium/nmr-types": { - "version": "0.5.23", - "resolved": "https://registry.npmjs.org/@zakodium/nmr-types/-/nmr-types-0.5.23.tgz", - "integrity": "sha512-a8zmplUHOevUeOQ69yFwzCNNiUi867q2RY63GXg4Clhm+3BnXonBeHXicSycOoJzSz2V0pQ4LFPuqJMxKNjLvw==", + "version": "0.5.26", + "resolved": "https://registry.npmjs.org/@zakodium/nmr-types/-/nmr-types-0.5.26.tgz", + "integrity": "sha512-Da7K23q8O3EK8gslzyx5Hgd3k4TXq0w8JW+Qt2pDBZNY1M+uyVE97R9vcQKtcZiGVl/O0f57+qJ3kxvqVEnRgQ==", "license": "CC-BY-NC-SA-4.0", "dependencies": { + "cheminfo-types": "^1.15.0", "ml-peak-shape-generator": "^5.5.0", "ml-signal-processing": "^2.2.2", - "ml-spectra-processing": "^14.33.0" - } - }, - "node_modules/@zakodium/nmr-types/node_modules/ml-peak-shape-generator": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/ml-peak-shape-generator/-/ml-peak-shape-generator-5.5.0.tgz", - "integrity": "sha512-DyBq/u5S+/0F49Hm0OWHwWvPPM1aaCZIs/eI7/fUTWfIm86Yu03YXWMQ3874eq33MLZVgM8KguGkfeGDffCLog==", - "license": "MIT", - "dependencies": { - "cheminfo-types": "^1.15.0" + "ml-spectra-processing": "^14.33.0", + "zod": "^4.4.3" } }, "node_modules/@zakodium/nmrium-core": { - "version": "0.7.30", - "resolved": "https://registry.npmjs.org/@zakodium/nmrium-core/-/nmrium-core-0.7.30.tgz", - "integrity": "sha512-uds3UysKrAgm38Yt2swNnt8nmf5SHqt1LuVnewwDTolztl3Lead0RtgOYsRjnL30k6gpvGrgaoENkIMxSY0/kQ==", + "version": "0.7.65", + "resolved": "https://registry.npmjs.org/@zakodium/nmrium-core/-/nmrium-core-0.7.65.tgz", + "integrity": "sha512-mXXylnGVMbYRdz23kalRziwMK6OCkJkS3HW9izjatH/6/VZgnbiSa5DBm+SazBuNe5QAutrXIFUWa+t2D/5Esw==", "license": "CC-BY-NC-SA-4.0", "dependencies": { + "@zakodium/utils": "^0.5.1", "cheminfo-types": "^1.15.0", "fifo-logger": "^2.0.1", - "file-collection": "^6.6.1", + "file-collection": "^6.7.0", "is-any-array": "^3.0.0", "lodash.merge": "^4.6.2", - "ml-spectra-processing": "^14.28.1", - "nmr-correlation": "^2.3.5" + "ml-spectra-processing": "^14.33.0" }, "peerDependencies": { "zod": "^4.4.3" } }, "node_modules/@zakodium/nmrium-core-plugins": { - "version": "0.7.39", - "resolved": "https://registry.npmjs.org/@zakodium/nmrium-core-plugins/-/nmrium-core-plugins-0.7.39.tgz", - "integrity": "sha512-WFO8UmUB4ZGwO+eCTjC1RfUBeCtv+raQmsuVx6L7zf1/BuD0N3Lq0q+JpKkdn85sfFGckyejJr1yo2qd91EVMQ==", + "version": "0.7.87", + "resolved": "https://registry.npmjs.org/@zakodium/nmrium-core-plugins/-/nmrium-core-plugins-0.7.87.tgz", + "integrity": "sha512-g/JwP7tgfndzQAy8xmVBWb0PASoQc/IabMHONC7uZ+juXpYizX/XM31WwNSnmAWlbGVmKHM53xU7F+mCJX2Imw==", "license": "CC-BY-NC-SA-4.0", "dependencies": { "@date-fns/utc": "^2.1.1", - "@zakodium/nmrium-core": "^0.7.30", + "@zakodium/nmrium-core": "^0.7.65", "cheminfo-types": "^1.15.0", - "convert-to-jcamp": "^7.0.0", - "date-fns": "^4.1.0", - "file-collection": "^6.6.1", + "convert-to-jcamp": "^7.0.1", + "date-fns": "^4.4.0", + "file-collection": "^6.7.0", "gyromagnetic-ratio": "^2.0.0", "is-any-array": "^3.0.0", - "jcampconverter": "^12.3.3", + "jcampconverter": "^12.5.5", "linear-sum-assignment": "^1.0.9", "lodash.merge": "^4.6.2", - "ml-spectra-processing": "^14.28.1", - "nmr-processing": "^22.13.0", - "openchemlib": "^9.22.0", - "openchemlib-utils": "^8.15.0", - "sdf-parser": "^8.0.0", + "ml-spectra-processing": "^14.33.0", + "nmr-processing": "^22.23.6", + "openchemlib": "^9.24.0", + "openchemlib-utils": "^8.17.1", + "sdf-parser": "^9.0.0", "ts-pattern": "^5.9.0", "zod": "^4.4.3" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } } }, + "node_modules/@zakodium/utils": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@zakodium/utils/-/utils-0.5.1.tgz", + "integrity": "sha512-aRtHXol0w/nL4ZYiVNicw/7batBasftRDmYSCXj54AWf1MvLrKZNk9hthzp8I+wm7jL938HAIv8eg/45J+XyOg==", + "license": "MIT" + }, "node_modules/@zip.js/zip.js": { - "version": "2.8.23", - "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.23.tgz", - "integrity": "sha512-RB+RLnxPJFPrGvQ9rgO+4JOcsob6lD32OcF0QE0yg24oeW9q8KnTTNlugcDaIveEcCbclobJcZP+fLQ++sH0bw==", + "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==", "license": "BSD-3-Clause", "engines": { "bun": ">=0.7.0", @@ -255,6 +266,18 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -299,14 +322,15 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.13.6", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", - "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", + "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", - "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" } }, "node_modules/baselines": { @@ -341,9 +365,9 @@ } }, "node_modules/chemical-elements": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/chemical-elements/-/chemical-elements-2.2.2.tgz", - "integrity": "sha512-EL7cKE8FSyTcsWbs3uECKfYtdZW6Z8tD2OAeRXK4+rRc3+AMsJUqu/Sl790Ej4qbcy11/35ErdfnI5Yy7zQopw==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/chemical-elements/-/chemical-elements-2.3.2.tgz", + "integrity": "sha512-3q1h/yFUqTFitGAEShu5AcC9KWHZqtFDuiqxgnViVx77zIvrVJH1jSNHPzQGSsCkCe+czVcFdF/E9XyexOIuaw==", "license": "MIT" }, "node_modules/chemical-groups": { @@ -431,15 +455,32 @@ } }, "node_modules/date-fns": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", - "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", + "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/kossnocorp" } }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -513,9 +554,9 @@ } }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -564,19 +605,19 @@ } }, "node_modules/file-collection": { - "version": "6.6.1", - "resolved": "https://registry.npmjs.org/file-collection/-/file-collection-6.6.1.tgz", - "integrity": "sha512-YJtg82Xo3pqhfXdM3tT7+vYAYj2GgC+J4jQvhYT2GDk3UsochfrHr7BxGEiQQmjIPu05ogQx9ZJ1VclhNViPJQ==", + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/file-collection/-/file-collection-6.7.0.tgz", + "integrity": "sha512-IQEIgbRZIJgU91DVQXMjq6zSK0lcwsG/p1Sx0HqfvdsSrCnjtvcME9xsNB5qzNVZH8TyJ9sYJDt1QvIWoVvnNQ==", "license": "MIT", "dependencies": { - "@zip.js/zip.js": "^2.8.15", + "@zip.js/zip.js": "^2.8.23", "cheminfo-types": "^1.10.0" } }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -594,16 +635,16 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -642,9 +683,9 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", - "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", "license": "MIT", "engines": { "node": ">=18" @@ -742,9 +783,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -759,6 +800,19 @@ "integrity": "sha512-2bsegYkkHO+h/9MGbn6KWcE45cHZgPANo5LXF7EvWdT0yT2EguSVO1nDgU5c8+ZOPwp2vMNa7YFsJhVcDR9Sdg==", "license": "MIT" }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/is-any-array": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-any-array/-/is-any-array-3.0.0.tgz", @@ -766,9 +820,9 @@ "license": "MIT" }, "node_modules/jcampconverter": { - "version": "12.5.3", - "resolved": "https://registry.npmjs.org/jcampconverter/-/jcampconverter-12.5.3.tgz", - "integrity": "sha512-KoUW2KSG561Mz/rt8Jg1SNZInqRgdltU5ZDn0Xdl7AfC5b90ExF+LYzBdgbcgDf1cBFc+WA9eJFysijkhRmquA==", + "version": "12.5.5", + "resolved": "https://registry.npmjs.org/jcampconverter/-/jcampconverter-12.5.5.tgz", + "integrity": "sha512-D5s2gsklqBv6IASEjp1gze7d0gG1BsRcNDm7ZC8esMfv1iVVVoVuehkp0Kg3SRJ09k3N9r1RSTUe1KQeYlxQ2g==", "license": "CC-BY-NC-SA-4.0", "dependencies": { "cheminfo-types": "^1.15.0", @@ -780,9 +834,9 @@ } }, "node_modules/json-stream-stringify": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/json-stream-stringify/-/json-stream-stringify-3.1.6.tgz", - "integrity": "sha512-x7fpwxOkbhFCaJDJ8vb1fBY3DdSa4AlITaz+HHILQJzdPMnHEFjxPwVUi1ALIbcIxDE0PNe/0i7frnY8QnBQog==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/json-stream-stringify/-/json-stream-stringify-3.1.7.tgz", + "integrity": "sha512-F4MWetLtY42YMaAKw5cV4e47zMD5aOT+tjjQWjX18ACtdkQ5Y/vrcfbcQ107Rh+MXjOCIx4KhW0wPmOvG8iQ5w==", "license": "MIT", "engines": { "node": ">=7.10.1" @@ -828,13 +882,13 @@ "license": "ISC" }, "node_modules/mf-parser": { - "version": "3.7.1", - "resolved": "https://registry.npmjs.org/mf-parser/-/mf-parser-3.7.1.tgz", - "integrity": "sha512-JgVo43b7rkb/miykgXpE0eT+udACCvKWg2BYPysLnkOOQtosI3wP2dq7mUS8guGx2I75PnVs8/HLXbXM4K+IdQ==", + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/mf-parser/-/mf-parser-3.9.2.tgz", + "integrity": "sha512-b/ZweyvktEly+zg3DMI/9UmwX3RriuwNh6kB0xd+NgI0wzDgl3fqyZGa4E8Gbp149N0jAXfT70FQgqGb53GvBg==", "license": "MIT", "dependencies": { "atom-sorter": "^2.2.1", - "chemical-elements": "^2.2.2", + "chemical-elements": "^2.3.2", "chemical-groups": "^2.2.3" } }, @@ -984,15 +1038,6 @@ "ml-spectra-processing": "^14.29.0" } }, - "node_modules/ml-gsd/node_modules/ml-peak-shape-generator": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/ml-peak-shape-generator/-/ml-peak-shape-generator-5.5.0.tgz", - "integrity": "sha512-DyBq/u5S+/0F49Hm0OWHwWvPPM1aaCZIs/eI7/fUTWfIm86Yu03YXWMQ3874eq33MLZVgM8KguGkfeGDffCLog==", - "license": "MIT", - "dependencies": { - "cheminfo-types": "^1.15.0" - } - }, "node_modules/ml-hash-table": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/ml-hash-table/-/ml-hash-table-1.0.0.tgz", @@ -1036,31 +1081,31 @@ } }, "node_modules/ml-matrix-convolution": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/ml-matrix-convolution/-/ml-matrix-convolution-1.0.0.tgz", - "integrity": "sha512-+gS36VTRjBMtUDXlKlAgSs+pZLfOEY0JF/64QRRNq+6KIPCfPlzVO3P41iSEjp1NPwofhLcIJBgKIZyH6ylSVA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ml-matrix-convolution/-/ml-matrix-convolution-2.0.0.tgz", + "integrity": "sha512-XuEZf4ZTffAz7oDMG4olkh9aZmsIMr343gPTY+ZvnLWTlMiG+TegRbP4fpA1ju7/IK9q8u3TcC2cxf/N3ydtRA==", "license": "MIT", "dependencies": { - "ml-fft": "1.3.5" + "ml-fft": "^1.3.5" } }, "node_modules/ml-matrix-peaks-finder": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/ml-matrix-peaks-finder/-/ml-matrix-peaks-finder-1.0.0.tgz", - "integrity": "sha512-a3SZrvADI6kAbOvICnnNbacC+UB6xPRbgbKxGhFeISUCoe4M3g3RMp9UaUFNFA/rRjWNtW+rGioeOH8UNPZT/Q==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ml-matrix-peaks-finder/-/ml-matrix-peaks-finder-2.0.1.tgz", + "integrity": "sha512-PSMwStdTMtnLAhnKlFuKU0PCeQhSZxyNuKPx/t9qQ2N/00+ZkQRoY/+pGlJSG3V7BG5Rz8xz90tyYDno7FDlhw==", "license": "MIT", "dependencies": { "ml-disjoint-set": "^1.0.0", - "ml-matrix-convolution": "^1.0.0" + "ml-matrix-convolution": "^2.0.0" } }, "node_modules/ml-peak-shape-generator": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/ml-peak-shape-generator/-/ml-peak-shape-generator-4.2.0.tgz", - "integrity": "sha512-BDtR0rhUor5/4J9pJOEMRnD+QQ5v6ohx+o6MfRRg2e2IOTeZfp/uJcy5Y852v5CsNec1GmYMkd5PYrY0245qlQ==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/ml-peak-shape-generator/-/ml-peak-shape-generator-5.5.0.tgz", + "integrity": "sha512-DyBq/u5S+/0F49Hm0OWHwWvPPM1aaCZIs/eI7/fUTWfIm86Yu03YXWMQ3874eq33MLZVgM8KguGkfeGDffCLog==", "license": "MIT", "dependencies": { - "cheminfo-types": "^1.1.0" + "cheminfo-types": "^1.15.0" } }, "node_modules/ml-regression-base": { @@ -1164,19 +1209,10 @@ "ml-spectra-processing": "^14.29.0" } }, - "node_modules/ml-spectra-fitting/node_modules/ml-peak-shape-generator": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/ml-peak-shape-generator/-/ml-peak-shape-generator-5.5.0.tgz", - "integrity": "sha512-DyBq/u5S+/0F49Hm0OWHwWvPPM1aaCZIs/eI7/fUTWfIm86Yu03YXWMQ3874eq33MLZVgM8KguGkfeGDffCLog==", - "license": "MIT", - "dependencies": { - "cheminfo-types": "^1.15.0" - } - }, "node_modules/ml-spectra-processing": { - "version": "14.33.0", - "resolved": "https://registry.npmjs.org/ml-spectra-processing/-/ml-spectra-processing-14.33.0.tgz", - "integrity": "sha512-zg71v6exRvPpzDh9SiN5iNAIG8yTFpzfJrRCRSb/hWf5gMsik7q60DkO5ew4knR7xF4DbjYmNWeqLq8gWcQdEQ==", + "version": "14.34.0", + "resolved": "https://registry.npmjs.org/ml-spectra-processing/-/ml-spectra-processing-14.34.0.tgz", + "integrity": "sha512-sNM3nOg7s4tyinRnU5FyYZFV//cPHMvjwfxQvFh3GyrvWNuZmPIZTPS+kiT/SPXMymxA4+qx8oFSZ9K03clGyQ==", "license": "MIT", "dependencies": { "binary-search": "^1.3.6", @@ -1199,6 +1235,12 @@ "integrity": "sha512-Fz2q6dwgzGM8wYKGArTUTZDGa4lQFA2Vi6orjGeTVRy22ZnQFKlJuwS9n8NRviqz1KHAHAzdKJwbnYhdo38uYg==", "license": "MIT" }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/multiplet-analysis": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/multiplet-analysis/-/multiplet-analysis-3.0.2.tgz", @@ -1217,21 +1259,10 @@ "integrity": "sha512-+z6QY1SxkDk6CQJAeaIZKmcNubBCRP7J8DMQUBglz/sSkNsZoJ1kULjqk9skNPPplzs4i9PFhYrvNDdtQleF/A==", "license": "MIT" }, - "node_modules/nmr-correlation": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/nmr-correlation/-/nmr-correlation-2.3.5.tgz", - "integrity": "sha512-WmJXckcF+epK0u2DVv+LBahvtlbFiCbTtXQsYmq07fze6i6XLPHnFcE1q6HKtiYYbePd6ks2PMOmWQEDdU2XbA==", - "license": "MIT", - "dependencies": { - "cheminfo-types": "^1.8.1", - "ml-matrix-peaks-finder": "^1.0.0", - "ml-peak-shape-generator": "^4.1.4" - } - }, "node_modules/nmr-processing": { - "version": "22.23.0", - "resolved": "https://registry.npmjs.org/nmr-processing/-/nmr-processing-22.23.0.tgz", - "integrity": "sha512-DNmi9ybVqnNDTGp8UlJx4ZLqACuynz4AlBVhzi3M4Vp2jy7K7C2wbJRVuBMH5rI3ZkZBHe11nm+JWW56Qtvrpw==", + "version": "22.23.6", + "resolved": "https://registry.npmjs.org/nmr-processing/-/nmr-processing-22.23.6.tgz", + "integrity": "sha512-Psz5P8ZFR4m6szehlvRRqVPxemV5XD6KW+AVKC1DleC7xnyVdivLR+L+4IyoxzWI6709OyjYdXBndHfpoeG2Sw==", "license": "CC-BY-NC-SA-4.0", "dependencies": { "binary-search": "^1.3.6", @@ -1261,61 +1292,12 @@ "ml-spectra-processing": "^14.33.0", "ml-tree-set": "^1.0.1", "multiplet-analysis": "^3.0.2", - "nmr-correlation": "^3.0.0", "numeral": "^2.0.6", "openchemlib": "^9.24.0", "openchemlib-utils": "^8.17.1", "spectrum-generator": "^8.2.1" } }, - "node_modules/nmr-processing/node_modules/ml-matrix-convolution": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ml-matrix-convolution/-/ml-matrix-convolution-2.0.0.tgz", - "integrity": "sha512-XuEZf4ZTffAz7oDMG4olkh9aZmsIMr343gPTY+ZvnLWTlMiG+TegRbP4fpA1ju7/IK9q8u3TcC2cxf/N3ydtRA==", - "license": "MIT", - "dependencies": { - "ml-fft": "^1.3.5" - } - }, - "node_modules/nmr-processing/node_modules/ml-matrix-peaks-finder": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ml-matrix-peaks-finder/-/ml-matrix-peaks-finder-2.0.1.tgz", - "integrity": "sha512-PSMwStdTMtnLAhnKlFuKU0PCeQhSZxyNuKPx/t9qQ2N/00+ZkQRoY/+pGlJSG3V7BG5Rz8xz90tyYDno7FDlhw==", - "license": "MIT", - "dependencies": { - "ml-disjoint-set": "^1.0.0", - "ml-matrix-convolution": "^2.0.0" - } - }, - "node_modules/nmr-processing/node_modules/ml-peak-shape-generator": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/ml-peak-shape-generator/-/ml-peak-shape-generator-5.5.0.tgz", - "integrity": "sha512-DyBq/u5S+/0F49Hm0OWHwWvPPM1aaCZIs/eI7/fUTWfIm86Yu03YXWMQ3874eq33MLZVgM8KguGkfeGDffCLog==", - "license": "MIT", - "dependencies": { - "cheminfo-types": "^1.15.0" - } - }, - "node_modules/nmr-processing/node_modules/nmr-correlation": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/nmr-correlation/-/nmr-correlation-3.0.1.tgz", - "integrity": "sha512-0iuce3dLBpdcHn0Q/SX3gHvshRCUyN8X6iL6Y97VcK7JS3g8yZSQCIKjft8jsAOUzWP1rX8c1aCxw3BnCjrBEQ==", - "license": "MIT", - "dependencies": { - "cheminfo-types": "^1.8.1", - "ml-matrix-peaks-finder": "^2.0.0", - "ml-peak-shape-generator": "^4.2.0" - } - }, - "node_modules/nmr-processing/node_modules/nmr-correlation/node_modules/ml-peak-shape-generator": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/ml-peak-shape-generator/-/ml-peak-shape-generator-4.2.0.tgz", - "integrity": "sha512-BDtR0rhUor5/4J9pJOEMRnD+QQ5v6ohx+o6MfRRg2e2IOTeZfp/uJcy5Y852v5CsNec1GmYMkd5PYrY0245qlQ==", - "license": "MIT", - "dependencies": { - "cheminfo-types": "^1.1.0" - } - }, "node_modules/num-sort": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/num-sort/-/num-sort-2.1.0.tgz", @@ -1361,62 +1343,55 @@ "openchemlib": ">=9.25.0" } }, - "node_modules/openchemlib-utils/node_modules/sdf-parser": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/sdf-parser/-/sdf-parser-9.0.0.tgz", - "integrity": "sha512-yi69gjBudryiKrLsT35OkyoGmskvXbVpUjkcd9tX4M4sLVmqeptsZtcx6Etf/bvdk9Ts1Wy6PuyXVIPktn6ZNQ==", - "license": "MIT", - "dependencies": { - "dynamic-typing": "^2.0.0", - "ensure-string": "^2.0.0" - } - }, "node_modules/papaparse": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.6.0.tgz", - "integrity": "sha512-N2vuNQAYGK1/4vs6HJX86+VYU6OkiSTgdJz3JQfTk1y51cFCO/U8gnaeTF4iNE4r57Tt0sV47dUua1/19pxO6Q==", + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.7.0.tgz", + "integrity": "sha512-qBGxg/7Q3Kl9Wfhrz2Z74UnvnHTXLNG6jmKJFeBvP2+y4lV7So+7SR62+Zd47JvdrCkX+nDcnr0ObPzek/+6RA==", "license": "MIT" }, "node_modules/playwright": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", - "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.58.2" + "playwright-core": "1.62.1" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" }, "optionalDependencies": { "fsevents": "2.3.2" } }, "node_modules/playwright-core": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", - "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/sdf-parser": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/sdf-parser/-/sdf-parser-8.0.0.tgz", - "integrity": "sha512-Mh+FbnUGfu+I7/xN0JOiuQ9GP3H36USFXg3ZpeuUMXpGmYHmzmv763poF1u152Xz76nmqRLTL2ibA4dfU0yzjw==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/sdf-parser/-/sdf-parser-9.0.0.tgz", + "integrity": "sha512-yi69gjBudryiKrLsT35OkyoGmskvXbVpUjkcd9tX4M4sLVmqeptsZtcx6Etf/bvdk9Ts1Wy6PuyXVIPktn6ZNQ==", "license": "MIT", "dependencies": { "dynamic-typing": "^2.0.0", @@ -1433,15 +1408,6 @@ "ml-spectra-processing": "^14.28.1" } }, - "node_modules/spectrum-generator/node_modules/ml-peak-shape-generator": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/ml-peak-shape-generator/-/ml-peak-shape-generator-5.5.0.tgz", - "integrity": "sha512-DyBq/u5S+/0F49Hm0OWHwWvPPM1aaCZIs/eI7/fUTWfIm86Yu03YXWMQ3874eq33MLZVgM8KguGkfeGDffCLog==", - "license": "MIT", - "dependencies": { - "cheminfo-types": "^1.15.0" - } - }, "node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", @@ -1545,9 +1511,9 @@ "license": "MIT" }, "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "dev": true, "license": "MIT" }, @@ -1585,15 +1551,15 @@ } }, "node_modules/yargs": { - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", - "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", + "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", "license": "MIT", "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", - "string-width": "^7.2.0", + "string-width": "^8.2.1", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" }, @@ -1610,6 +1576,22 @@ "node": "^20.19.0 || ^22.12.0 || >=23" } }, + "node_modules/yargs/node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/app/scripts/nmr-cli/package.json b/app/scripts/nmr-cli/package.json index 0d3bd4d..c795b3a 100644 --- a/app/scripts/nmr-cli/package.json +++ b/app/scripts/nmr-cli/package.json @@ -15,24 +15,24 @@ "nmr-cli": "./build/index.js" }, "dependencies": { - "@zakodium/nmr-types": "^0.5.12", - "@zakodium/nmrium-core": "0.7.30", - "@zakodium/nmrium-core-plugins": "0.7.39", - "axios": "^1.13.6", + "@zakodium/nmr-types": "^0.5.26", + "@zakodium/nmrium-core": "0.7.65", + "@zakodium/nmrium-core-plugins": "0.7.87", + "axios": "^1.19.0", "fifo-logger": "^2.0.1", - "file-collection": "^6.6.1", - "json-stream-stringify": "^3.1.6", + "file-collection": "^6.7.0", + "json-stream-stringify": "^3.1.7", "lodash.merge": "^4.6.2", - "mf-parser": "^3.7.1", - "ml-spectra-processing": "^14.22.0", - "nmr-processing": "^22.5.2", - "openchemlib": "^9.20.0", - "playwright": "1.58.2", - "yargs": "^18.0.0" + "mf-parser": "^3.9.2", + "ml-spectra-processing": "^14.34.0", + "nmr-processing": "^22.23.6", + "openchemlib": "^9.25.0", + "playwright": "1.62.1", + "yargs": "^18.1.0" }, "devDependencies": { "@types/lodash.merge": "^4.6.9", - "@types/node": "^25.3.5", + "@types/node": "^26.2.0", "@types/yargs": "^17.0.35", "ts-node": "^10.9.2", "typescript": "^5.9.3" diff --git a/app/scripts/nmr-cli/src/index.ts b/app/scripts/nmr-cli/src/index.ts index 74c7000..ec4c449 100755 --- a/app/scripts/nmr-cli/src/index.ts +++ b/app/scripts/nmr-cli/src/index.ts @@ -26,6 +26,8 @@ Options for 'parse-spectra' command: -d, --auto-detection Enable ranges and zones automatic detection. -o, --output Output file path (optional) -r, --raw-data Include raw data in the output instead of data source + --include Include only files matching pattern(s) (glob/regex string, repeatable) + --exclude Exclude files matching pattern(s) (glob/regex string, repeatable) Arguments for 'parse-publication-string' command: publicationString Publication string @@ -133,6 +135,18 @@ export interface FileOptionsArgs { */ r?: boolean; + /** + * --include + * Only include files matching these pattern(s) when reading a directory (file-collection's filter.include). + */ + include?: string[]; + + /** + * --exclude + * Exclude files matching these pattern(s) when reading a directory (file-collection's filter.exclude). + */ + exclude?: string[]; + } // Define options for parsing a spectra file @@ -175,6 +189,16 @@ const fileOptions: { [key in keyof FileOptionsArgs]: Options } = { default: false, description: 'Include raw data in the output (default: dataSource)', }, + include: { + type: 'array', + string: true, + description: 'Only include files matching pattern(s) when reading a directory (glob/regex string)', + }, + exclude: { + type: 'array', + string: true, + description: 'Exclude files matching pattern(s) when reading a directory (glob/regex string)', + }, } as const const parseFileCommand: CommandModule<{}, FileOptionsArgs> = { diff --git a/app/scripts/nmr-cli/src/parse/prase-spectra.ts b/app/scripts/nmr-cli/src/parse/prase-spectra.ts index 06b521d..d4d24c4 100644 --- a/app/scripts/nmr-cli/src/parse/prase-spectra.ts +++ b/app/scripts/nmr-cli/src/parse/prase-spectra.ts @@ -197,7 +197,7 @@ async function processAndSerialize( } async function loadSpectrumFromURL(options: RequiredKey, logger: FifoLogger) { - const { u: url } = options; + const { u: url, include, exclude } = options; const { pathname: relativePath, origin: baseURL } = new URL(url) const source = { @@ -210,19 +210,20 @@ async function loadSpectrumFromURL(options: RequiredKey, l } - const { state } = await core.readFromWebSource(source, { ...parsingOptions, logger }); + const { state } = await core.readFromWebSource(source, { ...parsingOptions, fileFilter: { include, exclude }, logger }); processAndSerialize(state, options, logger) } async function loadSpectrumFromFilePath(options: RequiredKey, logger: FifoLogger) { - const { dir: path } = options; + const { dir: path, include, exclude } = options; const dirPath = isAbsolute(path) ? path : join(process.cwd(), path) const fileCollection = await FileCollection.fromPath(dirPath, { unzip: { zipExtensions: ['zip', 'nmredata'] }, + filter: { include, exclude }, }) const { From 1c3998602d8ec573288ca3e72e0c32fb8287449a Mon Sep 17 00:00:00 2001 From: hamed-musallam <35760236+hamed-musallam@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:05:15 +0200 Subject: [PATCH 2/7] refactor: parallelize parse spectra pipeline across concurrent worker/browser lanes (#141) Replace the single-threaded, one-browser-per-spectrum flow with a fixed number of concurrent lanes, each owning a persistent worker thread (CPU processing/detection) and, when snapshots are enabled, a persistent browser page: - run-pipeline.ts / run-concurrency.ts: drive parse -> process -> detect -> snapshot across N lanes instead of one spectrum at a time. - spectrum-worker.ts / worker-entry.ts: offload processing, detection, and serialization to worker threads so CPU work no longer blocks the main thread. - browser-manager.ts: reuse one browser/context per lane across every spectrum it handles, instead of relaunching per spectrum. - prase-spectra.ts: delegate to the new pipeline; stream output via outputResult instead of buffering the full result in memory. --- app/scripts/nmr-cli/Dockerfile | 9 +- app/scripts/nmr-cli/package-lock.json | 8 +- app/scripts/nmr-cli/package.json | 2 +- .../nmr-cli/src/parse/browser-manager.ts | 163 +++++++++++ .../nmr-cli/src/parse/prase-spectra.ts | 258 ++++-------------- .../nmr-cli/src/parse/run-concurrency.ts | 39 +++ app/scripts/nmr-cli/src/parse/run-pipeline.ts | 143 ++++++++++ .../nmr-cli/src/parse/spectrum-snapshot.ts | 117 ++++++++ .../nmr-cli/src/parse/spectrum-worker.ts | 71 +++++ .../src/parse/type/MoleculeExtended.ts | 17 +- .../nmr-cli/src/parse/type/ParsedSpectrum.ts | 6 + .../nmr-cli/src/parse/type/Snapshot.ts | 4 + .../src/parse/type/SpectrumPipelineOptions.ts | 5 + .../nmr-cli/src/parse/type/WorkerRequest.ts | 8 + .../nmr-cli/src/parse/type/WorkerResponse.ts | 7 + .../nmr-cli/src/parse/utility/getAtom.ts | 2 +- .../nmr-cli/src/parse/utility/isProton.ts | 2 +- .../nmr-cli/src/parse/utility/outputResult.ts | 24 ++ .../nmr-cli/src/parse/utility/toMessage.ts | 3 + app/scripts/nmr-cli/src/parse/worker-entry.ts | 70 +++++ 20 files changed, 736 insertions(+), 222 deletions(-) create mode 100644 app/scripts/nmr-cli/src/parse/browser-manager.ts create mode 100644 app/scripts/nmr-cli/src/parse/run-concurrency.ts create mode 100644 app/scripts/nmr-cli/src/parse/run-pipeline.ts create mode 100644 app/scripts/nmr-cli/src/parse/spectrum-snapshot.ts create mode 100644 app/scripts/nmr-cli/src/parse/spectrum-worker.ts create mode 100644 app/scripts/nmr-cli/src/parse/type/ParsedSpectrum.ts create mode 100644 app/scripts/nmr-cli/src/parse/type/Snapshot.ts create mode 100644 app/scripts/nmr-cli/src/parse/type/SpectrumPipelineOptions.ts create mode 100644 app/scripts/nmr-cli/src/parse/type/WorkerRequest.ts create mode 100644 app/scripts/nmr-cli/src/parse/type/WorkerResponse.ts create mode 100644 app/scripts/nmr-cli/src/parse/utility/outputResult.ts create mode 100644 app/scripts/nmr-cli/src/parse/utility/toMessage.ts create mode 100644 app/scripts/nmr-cli/src/parse/worker-entry.ts diff --git a/app/scripts/nmr-cli/Dockerfile b/app/scripts/nmr-cli/Dockerfile index f7877d5..45b9abf 100644 --- a/app/scripts/nmr-cli/Dockerfile +++ b/app/scripts/nmr-cli/Dockerfile @@ -32,6 +32,9 @@ RUN apt-get update && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* +# Downgrade to Node 22 for nmr-processing compatibility +RUN npm install -g n && n 22 && hash -r + COPY package.json ./ RUN npm install @@ -40,9 +43,11 @@ COPY . ./ RUN npm run build +# Install nmr-cli as a global package +RUN npm install -g . -# Install the nmr-cli as a global package +# Usage examples (run after `docker run -it nmr-cli bash`): +# nmr-cli parse-spectra -u https://s3.uni-jena.de/nmrxiv/production/archive/1368b8fd-3af7-48f7-8b6d-13de9c1ecf8b/492-62-6.zip -s # For example, nmr-cli parse-spectra -u https://s3.uni-jena.de/nmrxiv/production/archive/d5ec45db-d984-46e9-bb72-c43fd4a463fa/Sali_Eth.zip -s # For example, nmr-cli parse-spectra -u https://cheminfo.github.io/bruker-data-test/data/zipped/aspirin-1h.zip # nmr-cli predict -n "1H" --id 1 --type "nmr;1H;1d" --shifts "1" --solvent "Dimethylsulphoxide-D6 (DMSO-D6, C2D6SO)" -m $"\n Ketcher 6122516162D 1 1.00000 0.00000 0\n\n 16 17 0 0 0 0 0 0 0 0999 V2000\n 1.1954 -4.6484 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 2.9258 -4.6479 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 2.0622 -4.1483 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 2.9258 -5.6488 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 1.1954 -5.6533 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 2.0644 -6.1483 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 3.7902 -4.1495 0.0000 N 0 0 0 0 0 0 0 0 0 0 0 0\n 4.6574 -4.6498 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 3.7964 -6.1512 0.0000 N 0 0 0 0 0 0 0 0 0 0 0 0\n 4.6596 -5.6458 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 5.5228 -4.1488 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 5.5277 -6.1421 0.0000 O 0 0 0 0 0 0 0 0 0 0 0 0\n 6.3895 -4.6477 0.0000 O 0 0 0 0 0 0 0 0 0 0 0 0\n 5.5216 -3.1488 0.0000 O 0 0 0 0 0 0 0 0 0 0 0 0\n 7.2548 -4.1466 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 8.1215 -4.6455 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 3 1 2 0 0 0 0\n 1 5 1 0 0 0 0\n 5 6 2 0 0 0 0\n 6 4 1 0 0 0 0\n 4 2 1 0 0 0 0\n 2 3 1 0 0 0 0\n 4 9 1 0 0 0 0\n 9 10 2 0 0 0 0\n 10 8 1 0 0 0 0\n 8 7 2 0 0 0 0\n 7 2 1 0 0 0 0\n 8 11 1 0 0 0 0\n 10 12 1 0 0 0 0\n 11 13 1 0 0 0 0\n 11 14 2 0 0 0 0\n 13 15 1 0 0 0 0\n 15 16 1 0 0 0 0\nM END" -RUN npm install . -g diff --git a/app/scripts/nmr-cli/package-lock.json b/app/scripts/nmr-cli/package-lock.json index 6f47fd6..3ae8706 100644 --- a/app/scripts/nmr-cli/package-lock.json +++ b/app/scripts/nmr-cli/package-lock.json @@ -29,7 +29,7 @@ }, "devDependencies": { "@types/lodash.merge": "^4.6.9", - "@types/node": "^26.2.0", + "@types/node": "^26.4.0", "@types/yargs": "^17.0.35", "ts-node": "^10.9.2", "typescript": "^5.9.3" @@ -128,9 +128,9 @@ } }, "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", "dependencies": { diff --git a/app/scripts/nmr-cli/package.json b/app/scripts/nmr-cli/package.json index c795b3a..09dafd2 100644 --- a/app/scripts/nmr-cli/package.json +++ b/app/scripts/nmr-cli/package.json @@ -32,7 +32,7 @@ }, "devDependencies": { "@types/lodash.merge": "^4.6.9", - "@types/node": "^26.2.0", + "@types/node": "^26.4.0", "@types/yargs": "^17.0.35", "ts-node": "^10.9.2", "typescript": "^5.9.3" diff --git a/app/scripts/nmr-cli/src/parse/browser-manager.ts b/app/scripts/nmr-cli/src/parse/browser-manager.ts new file mode 100644 index 0000000..4a070c4 --- /dev/null +++ b/app/scripts/nmr-cli/src/parse/browser-manager.ts @@ -0,0 +1,163 @@ +import playwright from 'playwright'; +import type { FifoLogger } from 'fifo-logger'; +import type { SpectrumWorker } from './spectrum-worker'; +import { Spectrum } from '@zakodium/nmr-types'; +import { Snapshot } from './spectrum-snapshot'; +import { toMessage } from './utility/toMessage'; + +// How long to wait for the NMRium "Loading" indicator to appear/disappear +// before giving up on a single spectrum's snapshot. Prevents one stuck page +// from hanging the entire pipeline. +export const SNAPSHOT_LOADING_TIMEOUT_MS = 30_000; + +// A single flaky failure (a slow spectrum tipping over the loading timeout, +// a one-off page hiccup) shouldn't permanently cost a spectrum its snapshot, +// so each capture gets one retry on a fresh page before giving up. +export const SNAPSHOT_MAX_ATTEMPTS = 2; + +// Owns the single shared Firefox process. Multiple lanes each get their own +// context/page from it. `reset` is guarded so that if several lanes hit a +// dead browser at once, only the first one actually relaunches — the rest +// just see `current` has already changed and pick up the fresh instance. +export class BrowserManager { + private current: Promise | null = null; + + async get(): Promise { + if (!this.current) { + this.current = playwright.firefox.launch(); + } + return this.current; + } + + async reset(stale: playwright.Browser): Promise { + if (this.current && (await this.current) === stale) { + const toClose = this.current; + this.current = null; + await (await toClose).close().catch(() => { }); + } + } + + async closeAll(): Promise { + if (this.current) { + const toClose = this.current; + this.current = null; + await (await toClose).close().catch(() => { }); + } + } +} + +// One snapshot lane = one browser tab, reused across every spectrum it's +// assigned. Launching the browser/context happens once per lane; every +// spectrum after the first gets a `page.reload()` (not a new context or +// browser) before its `nmr-wrapper:load` message, so each snapshot starts +// from a genuinely empty NMRium instance instead of relying on `load` +// merging vs. replacing the previous spectrum's state. A reload of an +// already-booted SPA is far cheaper than relaunching the browser/context, +// so this keeps the speed win while removing the state-leak risk. +export class SnapshotLane { + private context: playwright.BrowserContext | null = null; + private page: playwright.Page | null = null; + private hasLoadedSpectrum = false; + + constructor(private manager: BrowserManager, private url: string) { } + + private async ensurePage(): Promise { + if (this.page && !this.page.isClosed()) { + if (this.hasLoadedSpectrum) { + await this.page.reload(); + await this.page.locator('text=Loading').waitFor({ state: 'hidden', timeout: SNAPSHOT_LOADING_TIMEOUT_MS }); + } + return this.page; + } + const browser = await this.manager.get(); + this.context = await browser.newContext(playwright.devices['Desktop Chrome HiDPI']); + this.page = await this.context.newPage(); + await this.page.goto(this.url); + await this.page.locator('text=Loading').waitFor({ state: 'hidden', timeout: SNAPSHOT_LOADING_TIMEOUT_MS }); + this.hasLoadedSpectrum = false; + return this.page; + } + + // Discards this lane's page/context so the next attempt (or the next + // spectrum, if we're giving up) starts from a clean page instead of + // whatever broken state caused the failure. Only escalates to a full + // browser relaunch if the browser process itself is gone. + private async recover(): Promise { + await this.context?.close().catch(() => { }); + this.context = null; + this.page = null; + this.hasLoadedSpectrum = false; + + const browser = await this.manager.get(); + if (!browser.isConnected()) { + await this.manager.reset(browser); + } + } + + private async attemptCapture( + spectrum: Spectrum, + version: unknown, + spectrumWorker: SpectrumWorker + ): Promise { + const page = await this.ensurePage(); + + const stringObject = await spectrumWorker.run('serialize', spectrum, version); + + // Passed as a Playwright function argument rather than spliced into an + // evaluated script string, so a backtick or `${...}` sequence anywhere + // in the spectrum data can't break (or hijack) the script. + await page.evaluate( + ({ data }) => { + window.postMessage({ type: 'nmr-wrapper:load', data: { data, type: 'nmrium' } }, '*'); + }, + { data: JSON.parse(stringObject) } + ); + + await page.locator('text=Loading').waitFor({ state: 'hidden', timeout: SNAPSHOT_LOADING_TIMEOUT_MS }); + + const snapshot = await page.locator('#nmrSVG .container').screenshot(); + this.hasLoadedSpectrum = true; + return snapshot.toString('base64'); + } + + async capture( + spectrum: Spectrum, + id: string, + version: unknown, + spectrumWorker: SpectrumWorker, + logger: FifoLogger + ): Promise { + let lastError: unknown; + + for (let attempt = 1; attempt <= SNAPSHOT_MAX_ATTEMPTS; attempt++) { + const start = Date.now(); + try { + const image = await this.attemptCapture(spectrum, version, spectrumWorker); + logger.info( + { id, stage: 'snapshot', attempt, durationMs: Date.now() - start }, + `Captured snapshot for spectrum: ${id}` + ); + return { id, image }; + } catch (e) { + lastError = e; + logger.error( + { id, stage: 'snapshot', attempt, durationMs: Date.now() - start, details: toMessage(e) }, + `Snapshot attempt ${attempt}/${SNAPSHOT_MAX_ATTEMPTS} failed for spectrum: ${id}` + ); + await this.recover(); + } + } + + logger.error( + { id, stage: 'snapshot', details: toMessage(lastError) }, + `Giving up on snapshot for spectrum: ${id} after ${SNAPSHOT_MAX_ATTEMPTS} attempts` + ); + return { id, image: null }; + } + + async dispose(): Promise { + await this.context?.close().catch(() => { }); + this.context = null; + this.page = null; + } +} \ No newline at end of file diff --git a/app/scripts/nmr-cli/src/parse/prase-spectra.ts b/app/scripts/nmr-cli/src/parse/prase-spectra.ts index d4d24c4..011b2bd 100644 --- a/app/scripts/nmr-cli/src/parse/prase-spectra.ts +++ b/app/scripts/nmr-cli/src/parse/prase-spectra.ts @@ -1,166 +1,26 @@ -import { join, isAbsolute } from 'path' -import { NmriumData, ParsingOptions, type NmriumState } from '@zakodium/nmrium-core' -import init from '@zakodium/nmrium-core-plugins' -import playwright from 'playwright' -import { FileCollection } from 'file-collection' -import { FileOptionsArgs } from '..' -import { isSpectrum2D } from './data/data2d/isSpectrum2D' -import { initiateDatum2D } from './data/data2d/initiateDatum2D' -import { initiateDatum1D } from './data/data1D/initiateDatum1D' -import { detectZones } from './data/data2d/detectZones' -import { detectRanges } from './data/data1D/detectRanges' -import { Filters1DManager, Filters2DManager } from 'nmr-processing' -import yargs from 'yargs' -import { createWriteStream } from 'fs' -import { JsonStreamStringify } from 'json-stream-stringify'; -import { FifoLogger } from 'fifo-logger' +import { join, isAbsolute } from 'node:path'; +import type { ParsingOptions, NmriumState } from '@zakodium/nmrium-core'; +import init from '@zakodium/nmrium-core-plugins'; +import { FileCollection } from 'file-collection'; +import yargs from 'yargs'; +import { FifoLogger } from 'fifo-logger'; +import { FileOptionsArgs } from '..'; +import { runSpectraPipeline } from './run-pipeline'; +import { outputResult } from './utility/outputResult'; +import { toMessage } from './utility/toMessage'; -type RequiredKey = Omit & Required>; - -function toMessage(e: unknown): string { - return e instanceof Error ? e.message : String(e) -} - - -const parsingOptions: ParsingOptions = { - onLoadProcessing: { autoProcessing: true }, - selector: { general: { dataSelection: 'preferFT' } }, - experimentalFeatures: true, -}; - - -interface Snapshot { - id: string; - image: string | null; -} - -const core = init() - -function generateNMRiumURL() { - const baseURL = process.env['BASE_NMRIUM_URL'] || '' - const url = new URL(baseURL) - url.searchParams.append('workspace', 'embedded') - return url.toString() -} - -async function launchBrowser() { - return playwright.firefox.launch(); -} - -async function captureSpectraViewAsBase64(nmriumState: Partial, logger: FifoLogger): Promise { - const { data: { spectra } = { spectra: [] }, version } = nmriumState; - - if (!spectra?.length) return []; - - const url = generateNMRiumURL(); - const snapshots: Snapshot[] = []; - let browser = await launchBrowser(); - - for (const spectrum of spectra) { - let context = null; - - try { - // recreate browser if it has crashed - if (!browser.isConnected()) { - browser = await launchBrowser(); - } - - context = await browser.newContext(playwright.devices['Desktop Chrome HiDPI']); - const page = await context.newPage(); - - await page.goto(url); - await page.locator('text=Loading').waitFor({ state: 'hidden' }); - - const stringObject = JSON.stringify( - { version, data: { spectra: [{ ...spectrum }] } }, - (key, value: unknown) => ArrayBuffer.isView(value) ? Array.from(value as unknown as Iterable) : value - ); - - await page.evaluate(` - window.postMessage({ type: "nmr-wrapper:load", data: { data: ${stringObject}, type: "nmrium" } }, '*'); - `); - - await page.locator('text=Loading').waitFor({ state: 'hidden' }); - - const snapshot = await page.locator('#nmrSVG .container').screenshot(); - snapshots.push({ id: spectrum.id, image: snapshot.toString('base64') }); - - } catch (e) { - logger.error({ id: spectrum.id, stage: 'snapshot', details: toMessage(e) }, `Failed to capture snapshot for spectrum: ${spectrum.id}`); - // browser crashed — close and recreate for next spectrum - await browser.close().catch(() => { }); - browser = await launchBrowser(); - - } finally { - await context?.close().catch(() => { }); - } - } - - await browser.close().catch(() => { }); - return snapshots; -} - - -interface ProcessSpectraOptions { - autoDetection: boolean; autoProcessing: boolean; -} - -function processSpectra(data: NmriumData, options: ProcessSpectraOptions, logger: FifoLogger) { - - const { autoDetection = false, autoProcessing = false } = options - for (let index = 0; index < data.spectra.length; index++) { - const inputSpectrum = data.spectra[index] - const is2D = isSpectrum2D(inputSpectrum); - let spectrum = null; +const core = init(); - try { - spectrum = is2D ? initiateDatum2D(inputSpectrum) : initiateDatum1D(inputSpectrum); - } catch (e) { - logger.error({ id: inputSpectrum.id, stage: 'parsing', details: toMessage(e) }, `Failed to parse spectrum: ${inputSpectrum.id}`); - continue; - } - - if (autoProcessing) { - try { - - isSpectrum2D(spectrum) ? Filters2DManager.reapplyFilters(spectrum) : Filters1DManager.reapplyFilters(spectrum); - logger.info({ id: inputSpectrum.id, stage: 'processing' }, `Processed spectrum: ${inputSpectrum.id}`); - - } catch (e) { - logger.error({ id: inputSpectrum.id, stage: 'processing', details: toMessage(e) }, `Failed to process spectrum: ${inputSpectrum.id}`); - } - } - - if (autoDetection && spectrum.info.isFt) { - try { - isSpectrum2D(spectrum) ? detectZones(spectrum) : detectRanges(spectrum); - logger.info({ id: inputSpectrum.id, stage: 'detection' }, `Detected peaks for spectrum: ${inputSpectrum.id}`); - } catch (e) { - logger.error({ id: inputSpectrum.id, stage: 'detection', details: toMessage(e) }, `Failed to detect peaks for spectrum: ${inputSpectrum.id}`); - } - } - - if (!spectrum) continue; - - data.spectra[index] = spectrum; - } - - -} +type RequiredKey = Omit & Required>; -function outputResult(result: any, outputPath?: string) { - const stream = new JsonStreamStringify(result); - if (outputPath) { - const writeStream = createWriteStream(outputPath); - stream.pipe(writeStream); - writeStream.on('finish', () => { - process.stderr.write(`Output written to: ${outputPath}\n`); - }); - } else { - stream.pipe(process.stdout); - } +function getParsingOptions(autoProcessing: boolean): ParsingOptions { + return { + onLoadProcessing: { autoProcessing }, + selector: { general: { dataSelection: 'preferFT' } }, + experimentalFeatures: true, + }; } async function processAndSerialize( @@ -170,18 +30,11 @@ async function processAndSerialize( ) { const { s: enableSnapshot = false, p: autoProcessing = false, d: autoDetection = false, o, r } = options; - if (nmriumState.data) { - processSpectra(nmriumState.data, { autoDetection, autoProcessing }, logger); - } - - const images: Snapshot[] = enableSnapshot - ? await captureSpectraViewAsBase64(nmriumState, logger) - : []; + const images = await runSpectraPipeline(nmriumState, { autoProcessing, autoDetection, enableSnapshot }, logger); const { data, version } = core.serializeNmriumState( nmriumState as NmriumState, - { includeData: r ? 'rawData' : 'dataSource', }, - + { includeData: r ? 'rawData' : 'dataSource' }, ); // include the meta and info object in case of serialize as dataSource @@ -189,72 +42,67 @@ async function processAndSerialize( if (!r) { for (let i = 0; i < spectra.length; i++) { const { info = {}, meta = {} } = nmriumState.data?.spectra[i] || {}; - spectra[i] = { ...spectra[i], info, meta } + spectra[i] = { ...spectra[i], info, meta }; } } + // Drop the raw processed spectra (typed arrays, filter history) now that + // everything needed from them has been copied into `spectra` above — + // otherwise they stay resident in memory alongside the serialized copy + // for the rest of the (potentially large, streamed) output write. + if (nmriumState.data) nmriumState.data.spectra = []; const logs = logger.getLogs(); - outputResult({ nmriumState: { data, version }, images, logs }, o); + await outputResult({ nmriumState: { data, version }, images, logs }, o); } async function loadSpectrumFromURL(options: RequiredKey, logger: FifoLogger) { const { u: url, include, exclude } = options; - const { pathname: relativePath, origin: baseURL } = new URL(url) + const { pathname: relativePath, origin: baseURL } = new URL(url); const source = { - entries: [ - { - relativePath, - }, - ], + entries: [{ relativePath }], baseURL, - } - + }; - const { state } = await core.readFromWebSource(source, { ...parsingOptions, fileFilter: { include, exclude }, logger }); - - processAndSerialize(state, options, logger) + const { state } = await core.readFromWebSource(source, { ...getParsingOptions(true), fileFilter: { include, exclude }, logger }); + await processAndSerialize(state, options, logger); } async function loadSpectrumFromFilePath(options: RequiredKey, logger: FifoLogger) { const { dir: path, include, exclude } = options; - const dirPath = isAbsolute(path) ? path : join(process.cwd(), path) + const dirPath = isAbsolute(path) ? path : join(process.cwd(), path); const fileCollection = await FileCollection.fromPath(dirPath, { unzip: { zipExtensions: ['zip', 'nmredata'] }, filter: { include, exclude }, - }) - - const { - state - } = await core.read(fileCollection, { ...parsingOptions, logger }) + }); - processAndSerialize(state, options, logger) + const { state } = await core.read(fileCollection, { ...getParsingOptions(true), logger }); + await processAndSerialize(state, options, logger); } - -function parseSpectra(argv: yargs.ArgumentsCamelCase -) { +async function parseSpectra(argv: yargs.ArgumentsCamelCase) { const logger = new FifoLogger(); - const { u, dir } = argv; - // Handle parsing the spectra file logic based on argv options - if (u) { - loadSpectrumFromURL({ u, ...argv }, logger); - } - - if (dir) { - loadSpectrumFromFilePath({ dir, ...argv }, logger); + try { + // Branches are mutually exclusive and awaited so a rejection is caught + // here instead of becoming an unhandled promise rejection, and so -u + // and --dir can't race to write the same output. + if (u) { + await loadSpectrumFromURL({ u, ...argv }, logger); + } else if (dir) { + await loadSpectrumFromFilePath({ dir, ...argv }, logger); + } else { + throw new Error('Either --u (URL) or --dir (directory) must be provided.'); + } + } catch (e) { + logger.error({ stage: 'fatal', details: toMessage(e) }, `Pipeline failed: ${toMessage(e)}`); + process.stderr.write(`${toMessage(e)}\n`); + process.exitCode = 1; } - - - } - - - -export { loadSpectrumFromFilePath, loadSpectrumFromURL, parseSpectra } +export { loadSpectrumFromFilePath, loadSpectrumFromURL, parseSpectra }; \ No newline at end of file diff --git a/app/scripts/nmr-cli/src/parse/run-concurrency.ts b/app/scripts/nmr-cli/src/parse/run-concurrency.ts new file mode 100644 index 0000000..9d1f615 --- /dev/null +++ b/app/scripts/nmr-cli/src/parse/run-concurrency.ts @@ -0,0 +1,39 @@ +import os from 'node:os'; + +// --- Concurrency tuning ----------------------------------------------- +// CPU-bound work (auto-processing / peak detection) is parallelized across +// worker threads: one worker per lane, reused for every spectrum that lane +// handles (spawning a worker reloads the whole nmr-processing module graph, +// so lanes are long-lived, not spawned per spectrum). +// +// Snapshot capture is parallelized across browser tabs, but each tab is far +// more memory-hungry than a worker thread, so it gets its own (lower) cap +// regardless of CPU count. +// +// Both are overridable via env vars for tuning on a given machine. +export const CPU_CONCURRENCY = Number(process.env['NMR_CLI_CPU_CONCURRENCY']) || Math.max(1, os.cpus().length - 1); +export const SNAPSHOT_CONCURRENCY = Number(process.env['NMR_CLI_SNAPSHOT_CONCURRENCY']) || 3; + +// Runs `handler` over `items` using a fixed number of long-lived "lanes" +// rather than firing off one promise per item. `laneIndex` is stable for the +// lifetime of a lane, so handlers can lazily attach an expensive, reusable +// resource (a worker thread, a browser page) to a given lane instead of +// creating one per item. +export async function runWithConcurrency( + items: T[], + concurrency: number, + handler: (item: T, laneIndex: number) => Promise +): Promise { + if (items.length === 0) return; + let cursor = 0; + const laneCount = Math.max(1, Math.min(concurrency, items.length)); + const lanes = Array.from({ length: laneCount }, (_, laneIndex) => + (async () => { + while (cursor < items.length) { + const item = items[cursor++]; + await handler(item, laneIndex); + } + })() + ); + await Promise.all(lanes); +} \ No newline at end of file diff --git a/app/scripts/nmr-cli/src/parse/run-pipeline.ts b/app/scripts/nmr-cli/src/parse/run-pipeline.ts new file mode 100644 index 0000000..688a2af --- /dev/null +++ b/app/scripts/nmr-cli/src/parse/run-pipeline.ts @@ -0,0 +1,143 @@ +import { isSpectrum2D, type NmriumState } from '@zakodium/nmrium-core'; +import type { FifoLogger } from 'fifo-logger'; +import { runWithConcurrency, CPU_CONCURRENCY, SNAPSHOT_CONCURRENCY } from './run-concurrency'; +import { SpectrumWorker } from './spectrum-worker'; +import { BrowserManager, SnapshotLane } from './browser-manager'; +import { Snapshot } from './spectrum-snapshot'; +import { toMessage } from './utility/toMessage'; +import { SpectrumPipelineOptions } from './type/SpectrumPipelineOptions'; +import { ParsedSpectrum } from './type/ParsedSpectrum'; +import { initiateDatum2D } from './data/data2d/initiateDatum2D'; +import { initiateDatum1D } from './data/data1D/initiateDatum1D'; + +function getDurationMs(start: number): number { + return Date.now() - start; +} + +function generateNMRiumURL(): string { + const baseURL = process.env['BASE_NMRIUM_URL'] || ''; + const url = new URL(baseURL); + url.searchParams.append('workspace', 'embedded'); + return url.toString(); +} + +// Each spectrum flows through the same stages — process -> detect -> snapshot +// — but stages now run across a fixed number of concurrent "lanes" instead +// of one spectrum at a time. Every lane owns its own worker thread (CPU +// stages) and, if snapshots are enabled, its own persistent browser page. +// +// Every stage logs `durationMs` alongside its existing pass/fail log, so a +// run's logs can be aggregated afterwards to see where time actually goes +// (parse vs. process vs. detect vs. snapshot) instead of guessing. +export async function runSpectraPipeline( + nmriumState: Partial, + options: SpectrumPipelineOptions, + logger: FifoLogger +): Promise { + const data = nmriumState.data; + const { version } = nmriumState; + if (!data) return []; + + const parsed = parseSpectraList(data.spectra, logger); + + // Write the parsed set back so serialization later only sees spectra that + // actually parsed successfully. + data.spectra = parsed.map((d) => d.spectrum); + const indexById = new Map(parsed.map((d, i) => [d.id, i])); + + const { autoProcessing, autoDetection, enableSnapshot } = options; + const snapshots: Snapshot[] = []; + if (parsed.length === 0) return snapshots; + + const concurrency = enableSnapshot ? SNAPSHOT_CONCURRENCY : CPU_CONCURRENCY; + const browserManager = enableSnapshot ? new BrowserManager() : null; + const url = enableSnapshot ? generateNMRiumURL() : ''; + + const spectrumWorkers: SpectrumWorker[] = []; + const snapshotLanes: SnapshotLane[] = []; + + const getWorker = (laneIndex: number): SpectrumWorker => { + if (!spectrumWorkers[laneIndex]) spectrumWorkers[laneIndex] = new SpectrumWorker(); + return spectrumWorkers[laneIndex]; + }; + const getSnapshotLane = (laneIndex: number): SnapshotLane => { + if (!snapshotLanes[laneIndex]) snapshotLanes[laneIndex] = new SnapshotLane(browserManager!, url); + return snapshotLanes[laneIndex]; + }; + + try { + await runWithConcurrency(parsed, concurrency, async ({ id, spectrum: initial }, laneIndex) => { + const spectrumWorker = getWorker(laneIndex); + let spectrum = initial; + + if (autoProcessing) { + const start = Date.now(); + try { + spectrum = await spectrumWorker.run('process', spectrum); + logger.info({ id, stage: 'processing', durationMs: getDurationMs(start) }, `Processed spectrum: ${id}`); + } catch (e) { + logger.error( + { id, stage: 'processing', durationMs: getDurationMs(start), details: toMessage(e) }, + `Failed to process spectrum: ${id}` + ); + } + } + + if (autoDetection && spectrum.info.isFt) { + const start = Date.now(); + try { + spectrum = await spectrumWorker.run('detect', spectrum); + logger.info({ id, stage: 'detection', durationMs: getDurationMs(start) }, `Detected peaks for spectrum: ${id}`); + } catch (e) { + logger.error( + { id, stage: 'detection', durationMs: getDurationMs(start), details: toMessage(e) }, + `Failed to detect peaks for spectrum: ${id}` + ); + } + } + + data.spectra[indexById.get(id)!] = spectrum; + + if (enableSnapshot) { + const lane = getSnapshotLane(laneIndex); + // Timing + retry for this stage live inside SnapshotLane.capture + // itself, since a retry needs its own per-attempt timing. + const snapshot = await lane.capture(spectrum, id, version, spectrumWorker, logger); + snapshots.push(snapshot); + } + }); + } finally { + await Promise.all(spectrumWorkers.filter(Boolean).map((w) => w.terminate())); + await Promise.all(snapshotLanes.filter(Boolean).map((l) => l.dispose())); + await browserManager?.closeAll(); + } + + return snapshots; +} + + + + + + + +function parseSpectraList(rawSpectra: any[], logger: FifoLogger): ParsedSpectrum[] { + const parsed: ParsedSpectrum[] = []; + for (const inputSpectrum of rawSpectra) { + const id = inputSpectrum.id; + const start = Date.now(); + try { + const spectrum = isSpectrum2D(inputSpectrum) + ? initiateDatum2D(inputSpectrum) + : initiateDatum1D(inputSpectrum); + logger.info({ id, stage: 'parsing', durationMs: Date.now() - start }, `Parsed spectrum: ${id}`); + parsed.push({ id, spectrum }); + } catch (e) { + logger.error( + { id, stage: 'parsing', durationMs: Date.now() - start, details: toMessage(e) }, + `Failed to parse spectrum: ${id}` + ); + } + } + return parsed; +} diff --git a/app/scripts/nmr-cli/src/parse/spectrum-snapshot.ts b/app/scripts/nmr-cli/src/parse/spectrum-snapshot.ts new file mode 100644 index 0000000..400f9da --- /dev/null +++ b/app/scripts/nmr-cli/src/parse/spectrum-snapshot.ts @@ -0,0 +1,117 @@ +import playwright, { + type Browser, + type BrowserContext, + type Page, +} from 'playwright' + +import { SpectrumWorker } from './spectrum-worker' + +export interface Snapshot { + id: string + image: string | null +} + +const SNAPSHOT_LOADING_TIMEOUT_MS = 30_000 + +function generateNMRiumURL(): string { + const baseURL = process.env.BASE_NMRIUM_URL + + if (!baseURL) { + throw new Error( + 'BASE_NMRIUM_URL environment variable is not defined', + ) + } + + const url = new URL(baseURL) + url.searchParams.set('workspace', 'embedded') + + return url.toString() +} + +async function waitForNMRium(page: Page): Promise { + await page.locator('text=Loading').waitFor({ + state: 'hidden', + timeout: SNAPSHOT_LOADING_TIMEOUT_MS, + }) +} + +export class SpectrumSnapshot { + private browser: Browser | null = null + private context: BrowserContext | null = null + private page: Page | null = null + + async start(): Promise { + if (this.page) return + + this.browser = await playwright.firefox.launch() + + this.context = await this.browser.newContext( + playwright.devices['Desktop Chrome HiDPI'], + ) + + this.page = await this.context.newPage() + + await this.page.goto(generateNMRiumURL()) + + await waitForNMRium(this.page) + } + + async capture( + id: string, + spectrum: any, + version: unknown, + worker: SpectrumWorker, + ): Promise { + if (!this.page) { + throw new Error( + 'SpectrumSnapshot has not been started', + ) + } + + const stringObject = await worker.run( + 'serialize', + spectrum, + version, + ) + + /* + * Parse the serialized data here rather than injecting + * the JSON string into JavaScript source code. + */ + const data = JSON.parse(stringObject) + + await this.page.evaluate((nmriumData) => { + window.postMessage( + { + type: 'nmr-wrapper:load', + data: { + data: nmriumData, + type: 'nmrium', + }, + }, + '*', + ) + }, data) + + await waitForNMRium(this.page) + + const image = await this.page + .locator('#nmrSVG .container') + .screenshot() + + return { + id, + image: image.toString('base64'), + } + } + + async close(): Promise { + await this.context?.close().catch(() => { }) + await this.browser?.close().catch(() => { }) + + this.page = null + this.context = null + this.browser = null + } +} + diff --git a/app/scripts/nmr-cli/src/parse/spectrum-worker.ts b/app/scripts/nmr-cli/src/parse/spectrum-worker.ts new file mode 100644 index 0000000..bb7f715 --- /dev/null +++ b/app/scripts/nmr-cli/src/parse/spectrum-worker.ts @@ -0,0 +1,71 @@ +import { Spectrum } from '@zakodium/nmr-types'; +import { join } from 'node:path'; +import { Worker } from 'node:worker_threads'; +import { WorkerResponse } from './type/WorkerResponse'; + +// Runs processing/detection/serialization in a separate worker thread (its +// own V8 heap) so a heap-OOM there only terminates the worker instead of the +// whole CLI process. One instance is created per concurrency lane (see +// run-pipeline.ts). + +export class SpectrumWorker { + private worker: Worker | null = null; + + private spawn(): Worker { + const worker = new Worker(join(__dirname, 'worker-entry.js'), { + resourceLimits: { maxOldGenerationSizeMb: 3072 }, + }); + worker.on('error', () => { this.worker = null; }); + worker.on('exit', () => { this.worker = null; }); + return worker; + } + + private get(): Worker { + if (!this.worker) this.worker = this.spawn(); + return this.worker; + } + + run(task: 'process' | 'detect', spectrum: Spectrum): Promise; + run(task: 'serialize', spectrum: Spectrum, version: unknown): Promise; + run(task: 'process' | 'detect' | 'serialize', spectrum: Spectrum, version?: unknown): Promise { + return new Promise((resolve, reject) => { + const worker = this.get(); + let settled = false; + + const onMessage = (msg: WorkerResponse) => { + settled = true; + cleanup(); + if (msg.error) reject(new Error(msg.error)); + else resolve(task === 'serialize' ? msg.stringObject : msg.spectrum); + }; + const onError = (err: Error) => { + settled = true; + this.worker = null; + cleanup(); + reject(err); + }; + const onExit = (code: number) => { + this.worker = null; + if (!settled && code !== 0) { + cleanup(); + reject(new Error(`Worker exited with code ${code} (likely out of memory)`)); + } + }; + const cleanup = () => { + worker.off('message', onMessage); + worker.off('error', onError); + worker.off('exit', onExit); + }; + + worker.on('message', onMessage); + worker.on('error', onError); + worker.on('exit', onExit); + worker.postMessage({ spectrum, task, version }); + }); + } + + async terminate() { + await this.worker?.terminate().catch(() => { }); + this.worker = null; + } +} \ No newline at end of file diff --git a/app/scripts/nmr-cli/src/parse/type/MoleculeExtended.ts b/app/scripts/nmr-cli/src/parse/type/MoleculeExtended.ts index 7e71a00..601a66f 100644 --- a/app/scripts/nmr-cli/src/parse/type/MoleculeExtended.ts +++ b/app/scripts/nmr-cli/src/parse/type/MoleculeExtended.ts @@ -1,11 +1,12 @@ -import { StateMolecule } from "@zakodium/nmrium-core"; +import { StateMolecule } from '@zakodium/nmrium-core' export interface MoleculeExtended - extends Required>, + extends + Required>, Omit { - mf: string; - em: number; - mw: number; - svg: string; - atoms: Record; -} \ No newline at end of file + mf: string + em: number + mw: number + svg: string + atoms: Record +} diff --git a/app/scripts/nmr-cli/src/parse/type/ParsedSpectrum.ts b/app/scripts/nmr-cli/src/parse/type/ParsedSpectrum.ts new file mode 100644 index 0000000..911550c --- /dev/null +++ b/app/scripts/nmr-cli/src/parse/type/ParsedSpectrum.ts @@ -0,0 +1,6 @@ +import { Spectrum } from "@zakodium/nmr-types"; + +export interface ParsedSpectrum { + id: string; + spectrum: Spectrum; +} \ No newline at end of file diff --git a/app/scripts/nmr-cli/src/parse/type/Snapshot.ts b/app/scripts/nmr-cli/src/parse/type/Snapshot.ts new file mode 100644 index 0000000..0ed48cb --- /dev/null +++ b/app/scripts/nmr-cli/src/parse/type/Snapshot.ts @@ -0,0 +1,4 @@ +export interface Snapshot { + id: string; + image: string | null; +} \ No newline at end of file diff --git a/app/scripts/nmr-cli/src/parse/type/SpectrumPipelineOptions.ts b/app/scripts/nmr-cli/src/parse/type/SpectrumPipelineOptions.ts new file mode 100644 index 0000000..001b4e7 --- /dev/null +++ b/app/scripts/nmr-cli/src/parse/type/SpectrumPipelineOptions.ts @@ -0,0 +1,5 @@ +export interface SpectrumPipelineOptions { + autoProcessing: boolean; + autoDetection: boolean; + enableSnapshot: boolean; +} \ No newline at end of file diff --git a/app/scripts/nmr-cli/src/parse/type/WorkerRequest.ts b/app/scripts/nmr-cli/src/parse/type/WorkerRequest.ts new file mode 100644 index 0000000..fb39179 --- /dev/null +++ b/app/scripts/nmr-cli/src/parse/type/WorkerRequest.ts @@ -0,0 +1,8 @@ +import { Spectrum } from "@zakodium/nmr-types"; + +// Message contract shared with detectWorkerEntry.ts — keep both in sync. +export interface WorkerRequest { + spectrum: Spectrum; + task: 'process' | 'detect' | 'serialize'; + version?: unknown; +} \ No newline at end of file diff --git a/app/scripts/nmr-cli/src/parse/type/WorkerResponse.ts b/app/scripts/nmr-cli/src/parse/type/WorkerResponse.ts new file mode 100644 index 0000000..7caaa46 --- /dev/null +++ b/app/scripts/nmr-cli/src/parse/type/WorkerResponse.ts @@ -0,0 +1,7 @@ +import { Spectrum } from "@zakodium/nmr-types"; + +export interface WorkerResponse { + spectrum?: Spectrum; + stringObject?: string; + error?: string; +} \ No newline at end of file diff --git a/app/scripts/nmr-cli/src/parse/utility/getAtom.ts b/app/scripts/nmr-cli/src/parse/utility/getAtom.ts index 68e66ec..da09e78 100644 --- a/app/scripts/nmr-cli/src/parse/utility/getAtom.ts +++ b/app/scripts/nmr-cli/src/parse/utility/getAtom.ts @@ -1,3 +1,3 @@ export default function getAtom(nucleus: string): string { - return nucleus?.replaceAll(/\d/g, '') || ''; + return nucleus?.replaceAll(/\d/g, '') || '' } diff --git a/app/scripts/nmr-cli/src/parse/utility/isProton.ts b/app/scripts/nmr-cli/src/parse/utility/isProton.ts index 85f6e6e..00f3e6e 100644 --- a/app/scripts/nmr-cli/src/parse/utility/isProton.ts +++ b/app/scripts/nmr-cli/src/parse/utility/isProton.ts @@ -1,3 +1,3 @@ export function isProton(nucleus: string) { - return nucleus === '1H'; + return nucleus === '1H' } diff --git a/app/scripts/nmr-cli/src/parse/utility/outputResult.ts b/app/scripts/nmr-cli/src/parse/utility/outputResult.ts new file mode 100644 index 0000000..40de4b6 --- /dev/null +++ b/app/scripts/nmr-cli/src/parse/utility/outputResult.ts @@ -0,0 +1,24 @@ + +import { createWriteStream } from 'node:fs'; +import { JsonStreamStringify } from 'json-stream-stringify'; + +export function outputResult(result: any, outputPath?: string): Promise { + return new Promise((resolve, reject) => { + const stream = new JsonStreamStringify(result); + + if (outputPath) { + const writeStream = createWriteStream(outputPath); + stream.pipe(writeStream); + writeStream.on('finish', () => { + process.stderr.write(`Output written to: ${outputPath}\n`); + resolve(); + }); + writeStream.on('error', reject); + stream.on('error', reject); + } else { + stream.pipe(process.stdout); + stream.on('end', () => resolve()); + stream.on('error', reject); + } + }); +} diff --git a/app/scripts/nmr-cli/src/parse/utility/toMessage.ts b/app/scripts/nmr-cli/src/parse/utility/toMessage.ts new file mode 100644 index 0000000..6516c2c --- /dev/null +++ b/app/scripts/nmr-cli/src/parse/utility/toMessage.ts @@ -0,0 +1,3 @@ +export function toMessage(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} \ No newline at end of file diff --git a/app/scripts/nmr-cli/src/parse/worker-entry.ts b/app/scripts/nmr-cli/src/parse/worker-entry.ts new file mode 100644 index 0000000..b78df71 --- /dev/null +++ b/app/scripts/nmr-cli/src/parse/worker-entry.ts @@ -0,0 +1,70 @@ +import { parentPort } from 'node:worker_threads'; +import { Filters1DManager, Filters2DManager } from 'nmr-processing'; +import { detectZones } from './data/data2d/detectZones'; +import { detectRanges } from './data/data1D/detectRanges'; +import { Spectrum } from '@zakodium/nmr-types'; +import { isSpectrum2D } from '@zakodium/nmrium-core'; +import { WorkerRequest } from './type/WorkerRequest'; +import { WorkerResponse } from './type/WorkerResponse'; + +if (!parentPort) { + throw new Error('detectWorkerEntry must be run inside a worker_threads Worker'); +} + +// Both mutate the spectrum in place (same as the original single-threaded +// implementation) — the return is just for a uniform call signature. +async function runAutoProcessing(spectrum: Spectrum): Promise { + if (isSpectrum2D(spectrum)) { + Filters2DManager.reapplyFilters(spectrum); + } else { + Filters1DManager.reapplyFilters(spectrum); + } + return spectrum; +} + +async function runAutoDetection(spectrum: Spectrum): Promise { + if (isSpectrum2D(spectrum)) { + detectZones(spectrum); + } else { + detectRanges(spectrum); + } + return spectrum; +} + +// Spectra hold typed-array data (Float64Array etc.), which JSON.stringify +// otherwise mangles into `{0: ..., 1: ...}` objects instead of arrays — the +// replacer below converts any ArrayBuffer view to a plain array first. No +// nmrium-core-plugins init needed here: this is a plain JSON encoding of +// { version, data: { spectra: [spectrum] } }, matching exactly what the +// browser's `nmr-wrapper:load` message expects. +async function serializeSpectrum(spectrum: Spectrum, version: unknown): Promise { + return JSON.stringify( + { version, data: { spectra: [spectrum] } }, + (_key, value: unknown) => (ArrayBuffer.isView(value) ? Array.from(value as unknown as Iterable) : value) + ); +} + +parentPort.on('message', async (msg: WorkerRequest) => { + const { spectrum, task, version } = msg; + const reply = (response: WorkerResponse) => parentPort!.postMessage(response); + + try { + switch (task) { + case 'process': + reply({ spectrum: await runAutoProcessing(spectrum) }); + break; + case 'detect': + reply({ spectrum: await runAutoDetection(spectrum) }); + break; + case 'serialize': + reply({ stringObject: await serializeSpectrum(spectrum, version) }); + break; + default: { + const exhaustive: never = task; + throw new Error(`Unknown task: ${exhaustive}`); + } + } + } catch (e) { + reply({ error: e instanceof Error ? e.message : String(e) }); + } +}); \ No newline at end of file From 36ae226671e18617c0c955f79445611218da1715 Mon Sep 17 00:00:00 2001 From: Nisha Sharma Date: Tue, 1 Sep 2026 15:41:26 +0200 Subject: [PATCH 3/7] =?UTF-8?q?Revert=20"refactor:=20parallelize=20parse?= =?UTF-8?q?=20spectra=20pipeline=20across=20concurrent=20worker=E2=80=A6"?= =?UTF-8?q?=20(#142)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 1c3998602d8ec573288ca3e72e0c32fb8287449a. --- app/scripts/nmr-cli/Dockerfile | 9 +- app/scripts/nmr-cli/package-lock.json | 8 +- app/scripts/nmr-cli/package.json | 2 +- .../nmr-cli/src/parse/browser-manager.ts | 163 ----------- .../nmr-cli/src/parse/prase-spectra.ts | 258 ++++++++++++++---- .../nmr-cli/src/parse/run-concurrency.ts | 39 --- app/scripts/nmr-cli/src/parse/run-pipeline.ts | 143 ---------- .../nmr-cli/src/parse/spectrum-snapshot.ts | 117 -------- .../nmr-cli/src/parse/spectrum-worker.ts | 71 ----- .../src/parse/type/MoleculeExtended.ts | 17 +- .../nmr-cli/src/parse/type/ParsedSpectrum.ts | 6 - .../nmr-cli/src/parse/type/Snapshot.ts | 4 - .../src/parse/type/SpectrumPipelineOptions.ts | 5 - .../nmr-cli/src/parse/type/WorkerRequest.ts | 8 - .../nmr-cli/src/parse/type/WorkerResponse.ts | 7 - .../nmr-cli/src/parse/utility/getAtom.ts | 2 +- .../nmr-cli/src/parse/utility/isProton.ts | 2 +- .../nmr-cli/src/parse/utility/outputResult.ts | 24 -- .../nmr-cli/src/parse/utility/toMessage.ts | 3 - app/scripts/nmr-cli/src/parse/worker-entry.ts | 70 ----- 20 files changed, 222 insertions(+), 736 deletions(-) delete mode 100644 app/scripts/nmr-cli/src/parse/browser-manager.ts delete mode 100644 app/scripts/nmr-cli/src/parse/run-concurrency.ts delete mode 100644 app/scripts/nmr-cli/src/parse/run-pipeline.ts delete mode 100644 app/scripts/nmr-cli/src/parse/spectrum-snapshot.ts delete mode 100644 app/scripts/nmr-cli/src/parse/spectrum-worker.ts delete mode 100644 app/scripts/nmr-cli/src/parse/type/ParsedSpectrum.ts delete mode 100644 app/scripts/nmr-cli/src/parse/type/Snapshot.ts delete mode 100644 app/scripts/nmr-cli/src/parse/type/SpectrumPipelineOptions.ts delete mode 100644 app/scripts/nmr-cli/src/parse/type/WorkerRequest.ts delete mode 100644 app/scripts/nmr-cli/src/parse/type/WorkerResponse.ts delete mode 100644 app/scripts/nmr-cli/src/parse/utility/outputResult.ts delete mode 100644 app/scripts/nmr-cli/src/parse/utility/toMessage.ts delete mode 100644 app/scripts/nmr-cli/src/parse/worker-entry.ts diff --git a/app/scripts/nmr-cli/Dockerfile b/app/scripts/nmr-cli/Dockerfile index 45b9abf..f7877d5 100644 --- a/app/scripts/nmr-cli/Dockerfile +++ b/app/scripts/nmr-cli/Dockerfile @@ -32,9 +32,6 @@ RUN apt-get update && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* -# Downgrade to Node 22 for nmr-processing compatibility -RUN npm install -g n && n 22 && hash -r - COPY package.json ./ RUN npm install @@ -43,11 +40,9 @@ COPY . ./ RUN npm run build -# Install nmr-cli as a global package -RUN npm install -g . -# Usage examples (run after `docker run -it nmr-cli bash`): -# nmr-cli parse-spectra -u https://s3.uni-jena.de/nmrxiv/production/archive/1368b8fd-3af7-48f7-8b6d-13de9c1ecf8b/492-62-6.zip -s +# Install the nmr-cli as a global package # For example, nmr-cli parse-spectra -u https://s3.uni-jena.de/nmrxiv/production/archive/d5ec45db-d984-46e9-bb72-c43fd4a463fa/Sali_Eth.zip -s # For example, nmr-cli parse-spectra -u https://cheminfo.github.io/bruker-data-test/data/zipped/aspirin-1h.zip # nmr-cli predict -n "1H" --id 1 --type "nmr;1H;1d" --shifts "1" --solvent "Dimethylsulphoxide-D6 (DMSO-D6, C2D6SO)" -m $"\n Ketcher 6122516162D 1 1.00000 0.00000 0\n\n 16 17 0 0 0 0 0 0 0 0999 V2000\n 1.1954 -4.6484 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 2.9258 -4.6479 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 2.0622 -4.1483 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 2.9258 -5.6488 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 1.1954 -5.6533 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 2.0644 -6.1483 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 3.7902 -4.1495 0.0000 N 0 0 0 0 0 0 0 0 0 0 0 0\n 4.6574 -4.6498 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 3.7964 -6.1512 0.0000 N 0 0 0 0 0 0 0 0 0 0 0 0\n 4.6596 -5.6458 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 5.5228 -4.1488 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 5.5277 -6.1421 0.0000 O 0 0 0 0 0 0 0 0 0 0 0 0\n 6.3895 -4.6477 0.0000 O 0 0 0 0 0 0 0 0 0 0 0 0\n 5.5216 -3.1488 0.0000 O 0 0 0 0 0 0 0 0 0 0 0 0\n 7.2548 -4.1466 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 8.1215 -4.6455 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 3 1 2 0 0 0 0\n 1 5 1 0 0 0 0\n 5 6 2 0 0 0 0\n 6 4 1 0 0 0 0\n 4 2 1 0 0 0 0\n 2 3 1 0 0 0 0\n 4 9 1 0 0 0 0\n 9 10 2 0 0 0 0\n 10 8 1 0 0 0 0\n 8 7 2 0 0 0 0\n 7 2 1 0 0 0 0\n 8 11 1 0 0 0 0\n 10 12 1 0 0 0 0\n 11 13 1 0 0 0 0\n 11 14 2 0 0 0 0\n 13 15 1 0 0 0 0\n 15 16 1 0 0 0 0\nM END" +RUN npm install . -g diff --git a/app/scripts/nmr-cli/package-lock.json b/app/scripts/nmr-cli/package-lock.json index 3ae8706..6f47fd6 100644 --- a/app/scripts/nmr-cli/package-lock.json +++ b/app/scripts/nmr-cli/package-lock.json @@ -29,7 +29,7 @@ }, "devDependencies": { "@types/lodash.merge": "^4.6.9", - "@types/node": "^26.4.0", + "@types/node": "^26.2.0", "@types/yargs": "^17.0.35", "ts-node": "^10.9.2", "typescript": "^5.9.3" @@ -128,9 +128,9 @@ } }, "node_modules/@types/node": { - "version": "26.4.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz", - "integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==", + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/app/scripts/nmr-cli/package.json b/app/scripts/nmr-cli/package.json index 09dafd2..c795b3a 100644 --- a/app/scripts/nmr-cli/package.json +++ b/app/scripts/nmr-cli/package.json @@ -32,7 +32,7 @@ }, "devDependencies": { "@types/lodash.merge": "^4.6.9", - "@types/node": "^26.4.0", + "@types/node": "^26.2.0", "@types/yargs": "^17.0.35", "ts-node": "^10.9.2", "typescript": "^5.9.3" diff --git a/app/scripts/nmr-cli/src/parse/browser-manager.ts b/app/scripts/nmr-cli/src/parse/browser-manager.ts deleted file mode 100644 index 4a070c4..0000000 --- a/app/scripts/nmr-cli/src/parse/browser-manager.ts +++ /dev/null @@ -1,163 +0,0 @@ -import playwright from 'playwright'; -import type { FifoLogger } from 'fifo-logger'; -import type { SpectrumWorker } from './spectrum-worker'; -import { Spectrum } from '@zakodium/nmr-types'; -import { Snapshot } from './spectrum-snapshot'; -import { toMessage } from './utility/toMessage'; - -// How long to wait for the NMRium "Loading" indicator to appear/disappear -// before giving up on a single spectrum's snapshot. Prevents one stuck page -// from hanging the entire pipeline. -export const SNAPSHOT_LOADING_TIMEOUT_MS = 30_000; - -// A single flaky failure (a slow spectrum tipping over the loading timeout, -// a one-off page hiccup) shouldn't permanently cost a spectrum its snapshot, -// so each capture gets one retry on a fresh page before giving up. -export const SNAPSHOT_MAX_ATTEMPTS = 2; - -// Owns the single shared Firefox process. Multiple lanes each get their own -// context/page from it. `reset` is guarded so that if several lanes hit a -// dead browser at once, only the first one actually relaunches — the rest -// just see `current` has already changed and pick up the fresh instance. -export class BrowserManager { - private current: Promise | null = null; - - async get(): Promise { - if (!this.current) { - this.current = playwright.firefox.launch(); - } - return this.current; - } - - async reset(stale: playwright.Browser): Promise { - if (this.current && (await this.current) === stale) { - const toClose = this.current; - this.current = null; - await (await toClose).close().catch(() => { }); - } - } - - async closeAll(): Promise { - if (this.current) { - const toClose = this.current; - this.current = null; - await (await toClose).close().catch(() => { }); - } - } -} - -// One snapshot lane = one browser tab, reused across every spectrum it's -// assigned. Launching the browser/context happens once per lane; every -// spectrum after the first gets a `page.reload()` (not a new context or -// browser) before its `nmr-wrapper:load` message, so each snapshot starts -// from a genuinely empty NMRium instance instead of relying on `load` -// merging vs. replacing the previous spectrum's state. A reload of an -// already-booted SPA is far cheaper than relaunching the browser/context, -// so this keeps the speed win while removing the state-leak risk. -export class SnapshotLane { - private context: playwright.BrowserContext | null = null; - private page: playwright.Page | null = null; - private hasLoadedSpectrum = false; - - constructor(private manager: BrowserManager, private url: string) { } - - private async ensurePage(): Promise { - if (this.page && !this.page.isClosed()) { - if (this.hasLoadedSpectrum) { - await this.page.reload(); - await this.page.locator('text=Loading').waitFor({ state: 'hidden', timeout: SNAPSHOT_LOADING_TIMEOUT_MS }); - } - return this.page; - } - const browser = await this.manager.get(); - this.context = await browser.newContext(playwright.devices['Desktop Chrome HiDPI']); - this.page = await this.context.newPage(); - await this.page.goto(this.url); - await this.page.locator('text=Loading').waitFor({ state: 'hidden', timeout: SNAPSHOT_LOADING_TIMEOUT_MS }); - this.hasLoadedSpectrum = false; - return this.page; - } - - // Discards this lane's page/context so the next attempt (or the next - // spectrum, if we're giving up) starts from a clean page instead of - // whatever broken state caused the failure. Only escalates to a full - // browser relaunch if the browser process itself is gone. - private async recover(): Promise { - await this.context?.close().catch(() => { }); - this.context = null; - this.page = null; - this.hasLoadedSpectrum = false; - - const browser = await this.manager.get(); - if (!browser.isConnected()) { - await this.manager.reset(browser); - } - } - - private async attemptCapture( - spectrum: Spectrum, - version: unknown, - spectrumWorker: SpectrumWorker - ): Promise { - const page = await this.ensurePage(); - - const stringObject = await spectrumWorker.run('serialize', spectrum, version); - - // Passed as a Playwright function argument rather than spliced into an - // evaluated script string, so a backtick or `${...}` sequence anywhere - // in the spectrum data can't break (or hijack) the script. - await page.evaluate( - ({ data }) => { - window.postMessage({ type: 'nmr-wrapper:load', data: { data, type: 'nmrium' } }, '*'); - }, - { data: JSON.parse(stringObject) } - ); - - await page.locator('text=Loading').waitFor({ state: 'hidden', timeout: SNAPSHOT_LOADING_TIMEOUT_MS }); - - const snapshot = await page.locator('#nmrSVG .container').screenshot(); - this.hasLoadedSpectrum = true; - return snapshot.toString('base64'); - } - - async capture( - spectrum: Spectrum, - id: string, - version: unknown, - spectrumWorker: SpectrumWorker, - logger: FifoLogger - ): Promise { - let lastError: unknown; - - for (let attempt = 1; attempt <= SNAPSHOT_MAX_ATTEMPTS; attempt++) { - const start = Date.now(); - try { - const image = await this.attemptCapture(spectrum, version, spectrumWorker); - logger.info( - { id, stage: 'snapshot', attempt, durationMs: Date.now() - start }, - `Captured snapshot for spectrum: ${id}` - ); - return { id, image }; - } catch (e) { - lastError = e; - logger.error( - { id, stage: 'snapshot', attempt, durationMs: Date.now() - start, details: toMessage(e) }, - `Snapshot attempt ${attempt}/${SNAPSHOT_MAX_ATTEMPTS} failed for spectrum: ${id}` - ); - await this.recover(); - } - } - - logger.error( - { id, stage: 'snapshot', details: toMessage(lastError) }, - `Giving up on snapshot for spectrum: ${id} after ${SNAPSHOT_MAX_ATTEMPTS} attempts` - ); - return { id, image: null }; - } - - async dispose(): Promise { - await this.context?.close().catch(() => { }); - this.context = null; - this.page = null; - } -} \ No newline at end of file diff --git a/app/scripts/nmr-cli/src/parse/prase-spectra.ts b/app/scripts/nmr-cli/src/parse/prase-spectra.ts index 011b2bd..d4d24c4 100644 --- a/app/scripts/nmr-cli/src/parse/prase-spectra.ts +++ b/app/scripts/nmr-cli/src/parse/prase-spectra.ts @@ -1,26 +1,166 @@ -import { join, isAbsolute } from 'node:path'; -import type { ParsingOptions, NmriumState } from '@zakodium/nmrium-core'; -import init from '@zakodium/nmrium-core-plugins'; -import { FileCollection } from 'file-collection'; -import yargs from 'yargs'; -import { FifoLogger } from 'fifo-logger'; -import { FileOptionsArgs } from '..'; -import { runSpectraPipeline } from './run-pipeline'; -import { outputResult } from './utility/outputResult'; -import { toMessage } from './utility/toMessage'; +import { join, isAbsolute } from 'path' +import { NmriumData, ParsingOptions, type NmriumState } from '@zakodium/nmrium-core' +import init from '@zakodium/nmrium-core-plugins' +import playwright from 'playwright' +import { FileCollection } from 'file-collection' +import { FileOptionsArgs } from '..' +import { isSpectrum2D } from './data/data2d/isSpectrum2D' +import { initiateDatum2D } from './data/data2d/initiateDatum2D' +import { initiateDatum1D } from './data/data1D/initiateDatum1D' +import { detectZones } from './data/data2d/detectZones' +import { detectRanges } from './data/data1D/detectRanges' +import { Filters1DManager, Filters2DManager } from 'nmr-processing' +import yargs from 'yargs' +import { createWriteStream } from 'fs' +import { JsonStreamStringify } from 'json-stream-stringify'; +import { FifoLogger } from 'fifo-logger' -const core = init(); +type RequiredKey = Omit & Required>; +function toMessage(e: unknown): string { + return e instanceof Error ? e.message : String(e) +} + + +const parsingOptions: ParsingOptions = { + onLoadProcessing: { autoProcessing: true }, + selector: { general: { dataSelection: 'preferFT' } }, + experimentalFeatures: true, +}; -type RequiredKey = Omit & Required>; +interface Snapshot { + id: string; + image: string | null; +} + +const core = init() + +function generateNMRiumURL() { + const baseURL = process.env['BASE_NMRIUM_URL'] || '' + const url = new URL(baseURL) + url.searchParams.append('workspace', 'embedded') + return url.toString() +} -function getParsingOptions(autoProcessing: boolean): ParsingOptions { - return { - onLoadProcessing: { autoProcessing }, - selector: { general: { dataSelection: 'preferFT' } }, - experimentalFeatures: true, - }; +async function launchBrowser() { + return playwright.firefox.launch(); +} + +async function captureSpectraViewAsBase64(nmriumState: Partial, logger: FifoLogger): Promise { + const { data: { spectra } = { spectra: [] }, version } = nmriumState; + + if (!spectra?.length) return []; + + const url = generateNMRiumURL(); + const snapshots: Snapshot[] = []; + let browser = await launchBrowser(); + + for (const spectrum of spectra) { + let context = null; + + try { + // recreate browser if it has crashed + if (!browser.isConnected()) { + browser = await launchBrowser(); + } + + context = await browser.newContext(playwright.devices['Desktop Chrome HiDPI']); + const page = await context.newPage(); + + await page.goto(url); + await page.locator('text=Loading').waitFor({ state: 'hidden' }); + + const stringObject = JSON.stringify( + { version, data: { spectra: [{ ...spectrum }] } }, + (key, value: unknown) => ArrayBuffer.isView(value) ? Array.from(value as unknown as Iterable) : value + ); + + await page.evaluate(` + window.postMessage({ type: "nmr-wrapper:load", data: { data: ${stringObject}, type: "nmrium" } }, '*'); + `); + + await page.locator('text=Loading').waitFor({ state: 'hidden' }); + + const snapshot = await page.locator('#nmrSVG .container').screenshot(); + snapshots.push({ id: spectrum.id, image: snapshot.toString('base64') }); + + } catch (e) { + logger.error({ id: spectrum.id, stage: 'snapshot', details: toMessage(e) }, `Failed to capture snapshot for spectrum: ${spectrum.id}`); + // browser crashed — close and recreate for next spectrum + await browser.close().catch(() => { }); + browser = await launchBrowser(); + + } finally { + await context?.close().catch(() => { }); + } + } + + await browser.close().catch(() => { }); + return snapshots; +} + + +interface ProcessSpectraOptions { + autoDetection: boolean; autoProcessing: boolean; +} + +function processSpectra(data: NmriumData, options: ProcessSpectraOptions, logger: FifoLogger) { + + const { autoDetection = false, autoProcessing = false } = options + for (let index = 0; index < data.spectra.length; index++) { + const inputSpectrum = data.spectra[index] + const is2D = isSpectrum2D(inputSpectrum); + let spectrum = null; + + try { + + spectrum = is2D ? initiateDatum2D(inputSpectrum) : initiateDatum1D(inputSpectrum); + } catch (e) { + logger.error({ id: inputSpectrum.id, stage: 'parsing', details: toMessage(e) }, `Failed to parse spectrum: ${inputSpectrum.id}`); + continue; + } + + if (autoProcessing) { + try { + + isSpectrum2D(spectrum) ? Filters2DManager.reapplyFilters(spectrum) : Filters1DManager.reapplyFilters(spectrum); + logger.info({ id: inputSpectrum.id, stage: 'processing' }, `Processed spectrum: ${inputSpectrum.id}`); + + } catch (e) { + logger.error({ id: inputSpectrum.id, stage: 'processing', details: toMessage(e) }, `Failed to process spectrum: ${inputSpectrum.id}`); + } + } + + if (autoDetection && spectrum.info.isFt) { + try { + isSpectrum2D(spectrum) ? detectZones(spectrum) : detectRanges(spectrum); + logger.info({ id: inputSpectrum.id, stage: 'detection' }, `Detected peaks for spectrum: ${inputSpectrum.id}`); + } catch (e) { + logger.error({ id: inputSpectrum.id, stage: 'detection', details: toMessage(e) }, `Failed to detect peaks for spectrum: ${inputSpectrum.id}`); + } + } + + if (!spectrum) continue; + + data.spectra[index] = spectrum; + } + + +} + +function outputResult(result: any, outputPath?: string) { + const stream = new JsonStreamStringify(result); + + if (outputPath) { + const writeStream = createWriteStream(outputPath); + stream.pipe(writeStream); + writeStream.on('finish', () => { + process.stderr.write(`Output written to: ${outputPath}\n`); + }); + } else { + stream.pipe(process.stdout); + } } async function processAndSerialize( @@ -30,11 +170,18 @@ async function processAndSerialize( ) { const { s: enableSnapshot = false, p: autoProcessing = false, d: autoDetection = false, o, r } = options; - const images = await runSpectraPipeline(nmriumState, { autoProcessing, autoDetection, enableSnapshot }, logger); + if (nmriumState.data) { + processSpectra(nmriumState.data, { autoDetection, autoProcessing }, logger); + } + + const images: Snapshot[] = enableSnapshot + ? await captureSpectraViewAsBase64(nmriumState, logger) + : []; const { data, version } = core.serializeNmriumState( nmriumState as NmriumState, - { includeData: r ? 'rawData' : 'dataSource' }, + { includeData: r ? 'rawData' : 'dataSource', }, + ); // include the meta and info object in case of serialize as dataSource @@ -42,67 +189,72 @@ async function processAndSerialize( if (!r) { for (let i = 0; i < spectra.length; i++) { const { info = {}, meta = {} } = nmriumState.data?.spectra[i] || {}; - spectra[i] = { ...spectra[i], info, meta }; + spectra[i] = { ...spectra[i], info, meta } } } - // Drop the raw processed spectra (typed arrays, filter history) now that - // everything needed from them has been copied into `spectra` above — - // otherwise they stay resident in memory alongside the serialized copy - // for the rest of the (potentially large, streamed) output write. - if (nmriumState.data) nmriumState.data.spectra = []; const logs = logger.getLogs(); - await outputResult({ nmriumState: { data, version }, images, logs }, o); + outputResult({ nmriumState: { data, version }, images, logs }, o); } async function loadSpectrumFromURL(options: RequiredKey, logger: FifoLogger) { const { u: url, include, exclude } = options; - const { pathname: relativePath, origin: baseURL } = new URL(url); + const { pathname: relativePath, origin: baseURL } = new URL(url) const source = { - entries: [{ relativePath }], + entries: [ + { + relativePath, + }, + ], baseURL, - }; + } + - const { state } = await core.readFromWebSource(source, { ...getParsingOptions(true), fileFilter: { include, exclude }, logger }); + const { state } = await core.readFromWebSource(source, { ...parsingOptions, fileFilter: { include, exclude }, logger }); + + processAndSerialize(state, options, logger) - await processAndSerialize(state, options, logger); } async function loadSpectrumFromFilePath(options: RequiredKey, logger: FifoLogger) { const { dir: path, include, exclude } = options; - const dirPath = isAbsolute(path) ? path : join(process.cwd(), path); + const dirPath = isAbsolute(path) ? path : join(process.cwd(), path) const fileCollection = await FileCollection.fromPath(dirPath, { unzip: { zipExtensions: ['zip', 'nmredata'] }, filter: { include, exclude }, - }); + }) + + const { + state + } = await core.read(fileCollection, { ...parsingOptions, logger }) - const { state } = await core.read(fileCollection, { ...getParsingOptions(true), logger }); + processAndSerialize(state, options, logger) - await processAndSerialize(state, options, logger); } -async function parseSpectra(argv: yargs.ArgumentsCamelCase) { + +function parseSpectra(argv: yargs.ArgumentsCamelCase +) { const logger = new FifoLogger(); + const { u, dir } = argv; + // Handle parsing the spectra file logic based on argv options + if (u) { + loadSpectrumFromURL({ u, ...argv }, logger); + } - try { - // Branches are mutually exclusive and awaited so a rejection is caught - // here instead of becoming an unhandled promise rejection, and so -u - // and --dir can't race to write the same output. - if (u) { - await loadSpectrumFromURL({ u, ...argv }, logger); - } else if (dir) { - await loadSpectrumFromFilePath({ dir, ...argv }, logger); - } else { - throw new Error('Either --u (URL) or --dir (directory) must be provided.'); - } - } catch (e) { - logger.error({ stage: 'fatal', details: toMessage(e) }, `Pipeline failed: ${toMessage(e)}`); - process.stderr.write(`${toMessage(e)}\n`); - process.exitCode = 1; + + if (dir) { + loadSpectrumFromFilePath({ dir, ...argv }, logger); } + + + } -export { loadSpectrumFromFilePath, loadSpectrumFromURL, parseSpectra }; \ No newline at end of file + + + +export { loadSpectrumFromFilePath, loadSpectrumFromURL, parseSpectra } diff --git a/app/scripts/nmr-cli/src/parse/run-concurrency.ts b/app/scripts/nmr-cli/src/parse/run-concurrency.ts deleted file mode 100644 index 9d1f615..0000000 --- a/app/scripts/nmr-cli/src/parse/run-concurrency.ts +++ /dev/null @@ -1,39 +0,0 @@ -import os from 'node:os'; - -// --- Concurrency tuning ----------------------------------------------- -// CPU-bound work (auto-processing / peak detection) is parallelized across -// worker threads: one worker per lane, reused for every spectrum that lane -// handles (spawning a worker reloads the whole nmr-processing module graph, -// so lanes are long-lived, not spawned per spectrum). -// -// Snapshot capture is parallelized across browser tabs, but each tab is far -// more memory-hungry than a worker thread, so it gets its own (lower) cap -// regardless of CPU count. -// -// Both are overridable via env vars for tuning on a given machine. -export const CPU_CONCURRENCY = Number(process.env['NMR_CLI_CPU_CONCURRENCY']) || Math.max(1, os.cpus().length - 1); -export const SNAPSHOT_CONCURRENCY = Number(process.env['NMR_CLI_SNAPSHOT_CONCURRENCY']) || 3; - -// Runs `handler` over `items` using a fixed number of long-lived "lanes" -// rather than firing off one promise per item. `laneIndex` is stable for the -// lifetime of a lane, so handlers can lazily attach an expensive, reusable -// resource (a worker thread, a browser page) to a given lane instead of -// creating one per item. -export async function runWithConcurrency( - items: T[], - concurrency: number, - handler: (item: T, laneIndex: number) => Promise -): Promise { - if (items.length === 0) return; - let cursor = 0; - const laneCount = Math.max(1, Math.min(concurrency, items.length)); - const lanes = Array.from({ length: laneCount }, (_, laneIndex) => - (async () => { - while (cursor < items.length) { - const item = items[cursor++]; - await handler(item, laneIndex); - } - })() - ); - await Promise.all(lanes); -} \ No newline at end of file diff --git a/app/scripts/nmr-cli/src/parse/run-pipeline.ts b/app/scripts/nmr-cli/src/parse/run-pipeline.ts deleted file mode 100644 index 688a2af..0000000 --- a/app/scripts/nmr-cli/src/parse/run-pipeline.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { isSpectrum2D, type NmriumState } from '@zakodium/nmrium-core'; -import type { FifoLogger } from 'fifo-logger'; -import { runWithConcurrency, CPU_CONCURRENCY, SNAPSHOT_CONCURRENCY } from './run-concurrency'; -import { SpectrumWorker } from './spectrum-worker'; -import { BrowserManager, SnapshotLane } from './browser-manager'; -import { Snapshot } from './spectrum-snapshot'; -import { toMessage } from './utility/toMessage'; -import { SpectrumPipelineOptions } from './type/SpectrumPipelineOptions'; -import { ParsedSpectrum } from './type/ParsedSpectrum'; -import { initiateDatum2D } from './data/data2d/initiateDatum2D'; -import { initiateDatum1D } from './data/data1D/initiateDatum1D'; - -function getDurationMs(start: number): number { - return Date.now() - start; -} - -function generateNMRiumURL(): string { - const baseURL = process.env['BASE_NMRIUM_URL'] || ''; - const url = new URL(baseURL); - url.searchParams.append('workspace', 'embedded'); - return url.toString(); -} - -// Each spectrum flows through the same stages — process -> detect -> snapshot -// — but stages now run across a fixed number of concurrent "lanes" instead -// of one spectrum at a time. Every lane owns its own worker thread (CPU -// stages) and, if snapshots are enabled, its own persistent browser page. -// -// Every stage logs `durationMs` alongside its existing pass/fail log, so a -// run's logs can be aggregated afterwards to see where time actually goes -// (parse vs. process vs. detect vs. snapshot) instead of guessing. -export async function runSpectraPipeline( - nmriumState: Partial, - options: SpectrumPipelineOptions, - logger: FifoLogger -): Promise { - const data = nmriumState.data; - const { version } = nmriumState; - if (!data) return []; - - const parsed = parseSpectraList(data.spectra, logger); - - // Write the parsed set back so serialization later only sees spectra that - // actually parsed successfully. - data.spectra = parsed.map((d) => d.spectrum); - const indexById = new Map(parsed.map((d, i) => [d.id, i])); - - const { autoProcessing, autoDetection, enableSnapshot } = options; - const snapshots: Snapshot[] = []; - if (parsed.length === 0) return snapshots; - - const concurrency = enableSnapshot ? SNAPSHOT_CONCURRENCY : CPU_CONCURRENCY; - const browserManager = enableSnapshot ? new BrowserManager() : null; - const url = enableSnapshot ? generateNMRiumURL() : ''; - - const spectrumWorkers: SpectrumWorker[] = []; - const snapshotLanes: SnapshotLane[] = []; - - const getWorker = (laneIndex: number): SpectrumWorker => { - if (!spectrumWorkers[laneIndex]) spectrumWorkers[laneIndex] = new SpectrumWorker(); - return spectrumWorkers[laneIndex]; - }; - const getSnapshotLane = (laneIndex: number): SnapshotLane => { - if (!snapshotLanes[laneIndex]) snapshotLanes[laneIndex] = new SnapshotLane(browserManager!, url); - return snapshotLanes[laneIndex]; - }; - - try { - await runWithConcurrency(parsed, concurrency, async ({ id, spectrum: initial }, laneIndex) => { - const spectrumWorker = getWorker(laneIndex); - let spectrum = initial; - - if (autoProcessing) { - const start = Date.now(); - try { - spectrum = await spectrumWorker.run('process', spectrum); - logger.info({ id, stage: 'processing', durationMs: getDurationMs(start) }, `Processed spectrum: ${id}`); - } catch (e) { - logger.error( - { id, stage: 'processing', durationMs: getDurationMs(start), details: toMessage(e) }, - `Failed to process spectrum: ${id}` - ); - } - } - - if (autoDetection && spectrum.info.isFt) { - const start = Date.now(); - try { - spectrum = await spectrumWorker.run('detect', spectrum); - logger.info({ id, stage: 'detection', durationMs: getDurationMs(start) }, `Detected peaks for spectrum: ${id}`); - } catch (e) { - logger.error( - { id, stage: 'detection', durationMs: getDurationMs(start), details: toMessage(e) }, - `Failed to detect peaks for spectrum: ${id}` - ); - } - } - - data.spectra[indexById.get(id)!] = spectrum; - - if (enableSnapshot) { - const lane = getSnapshotLane(laneIndex); - // Timing + retry for this stage live inside SnapshotLane.capture - // itself, since a retry needs its own per-attempt timing. - const snapshot = await lane.capture(spectrum, id, version, spectrumWorker, logger); - snapshots.push(snapshot); - } - }); - } finally { - await Promise.all(spectrumWorkers.filter(Boolean).map((w) => w.terminate())); - await Promise.all(snapshotLanes.filter(Boolean).map((l) => l.dispose())); - await browserManager?.closeAll(); - } - - return snapshots; -} - - - - - - - -function parseSpectraList(rawSpectra: any[], logger: FifoLogger): ParsedSpectrum[] { - const parsed: ParsedSpectrum[] = []; - for (const inputSpectrum of rawSpectra) { - const id = inputSpectrum.id; - const start = Date.now(); - try { - const spectrum = isSpectrum2D(inputSpectrum) - ? initiateDatum2D(inputSpectrum) - : initiateDatum1D(inputSpectrum); - logger.info({ id, stage: 'parsing', durationMs: Date.now() - start }, `Parsed spectrum: ${id}`); - parsed.push({ id, spectrum }); - } catch (e) { - logger.error( - { id, stage: 'parsing', durationMs: Date.now() - start, details: toMessage(e) }, - `Failed to parse spectrum: ${id}` - ); - } - } - return parsed; -} diff --git a/app/scripts/nmr-cli/src/parse/spectrum-snapshot.ts b/app/scripts/nmr-cli/src/parse/spectrum-snapshot.ts deleted file mode 100644 index 400f9da..0000000 --- a/app/scripts/nmr-cli/src/parse/spectrum-snapshot.ts +++ /dev/null @@ -1,117 +0,0 @@ -import playwright, { - type Browser, - type BrowserContext, - type Page, -} from 'playwright' - -import { SpectrumWorker } from './spectrum-worker' - -export interface Snapshot { - id: string - image: string | null -} - -const SNAPSHOT_LOADING_TIMEOUT_MS = 30_000 - -function generateNMRiumURL(): string { - const baseURL = process.env.BASE_NMRIUM_URL - - if (!baseURL) { - throw new Error( - 'BASE_NMRIUM_URL environment variable is not defined', - ) - } - - const url = new URL(baseURL) - url.searchParams.set('workspace', 'embedded') - - return url.toString() -} - -async function waitForNMRium(page: Page): Promise { - await page.locator('text=Loading').waitFor({ - state: 'hidden', - timeout: SNAPSHOT_LOADING_TIMEOUT_MS, - }) -} - -export class SpectrumSnapshot { - private browser: Browser | null = null - private context: BrowserContext | null = null - private page: Page | null = null - - async start(): Promise { - if (this.page) return - - this.browser = await playwright.firefox.launch() - - this.context = await this.browser.newContext( - playwright.devices['Desktop Chrome HiDPI'], - ) - - this.page = await this.context.newPage() - - await this.page.goto(generateNMRiumURL()) - - await waitForNMRium(this.page) - } - - async capture( - id: string, - spectrum: any, - version: unknown, - worker: SpectrumWorker, - ): Promise { - if (!this.page) { - throw new Error( - 'SpectrumSnapshot has not been started', - ) - } - - const stringObject = await worker.run( - 'serialize', - spectrum, - version, - ) - - /* - * Parse the serialized data here rather than injecting - * the JSON string into JavaScript source code. - */ - const data = JSON.parse(stringObject) - - await this.page.evaluate((nmriumData) => { - window.postMessage( - { - type: 'nmr-wrapper:load', - data: { - data: nmriumData, - type: 'nmrium', - }, - }, - '*', - ) - }, data) - - await waitForNMRium(this.page) - - const image = await this.page - .locator('#nmrSVG .container') - .screenshot() - - return { - id, - image: image.toString('base64'), - } - } - - async close(): Promise { - await this.context?.close().catch(() => { }) - await this.browser?.close().catch(() => { }) - - this.page = null - this.context = null - this.browser = null - } -} - diff --git a/app/scripts/nmr-cli/src/parse/spectrum-worker.ts b/app/scripts/nmr-cli/src/parse/spectrum-worker.ts deleted file mode 100644 index bb7f715..0000000 --- a/app/scripts/nmr-cli/src/parse/spectrum-worker.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { Spectrum } from '@zakodium/nmr-types'; -import { join } from 'node:path'; -import { Worker } from 'node:worker_threads'; -import { WorkerResponse } from './type/WorkerResponse'; - -// Runs processing/detection/serialization in a separate worker thread (its -// own V8 heap) so a heap-OOM there only terminates the worker instead of the -// whole CLI process. One instance is created per concurrency lane (see -// run-pipeline.ts). - -export class SpectrumWorker { - private worker: Worker | null = null; - - private spawn(): Worker { - const worker = new Worker(join(__dirname, 'worker-entry.js'), { - resourceLimits: { maxOldGenerationSizeMb: 3072 }, - }); - worker.on('error', () => { this.worker = null; }); - worker.on('exit', () => { this.worker = null; }); - return worker; - } - - private get(): Worker { - if (!this.worker) this.worker = this.spawn(); - return this.worker; - } - - run(task: 'process' | 'detect', spectrum: Spectrum): Promise; - run(task: 'serialize', spectrum: Spectrum, version: unknown): Promise; - run(task: 'process' | 'detect' | 'serialize', spectrum: Spectrum, version?: unknown): Promise { - return new Promise((resolve, reject) => { - const worker = this.get(); - let settled = false; - - const onMessage = (msg: WorkerResponse) => { - settled = true; - cleanup(); - if (msg.error) reject(new Error(msg.error)); - else resolve(task === 'serialize' ? msg.stringObject : msg.spectrum); - }; - const onError = (err: Error) => { - settled = true; - this.worker = null; - cleanup(); - reject(err); - }; - const onExit = (code: number) => { - this.worker = null; - if (!settled && code !== 0) { - cleanup(); - reject(new Error(`Worker exited with code ${code} (likely out of memory)`)); - } - }; - const cleanup = () => { - worker.off('message', onMessage); - worker.off('error', onError); - worker.off('exit', onExit); - }; - - worker.on('message', onMessage); - worker.on('error', onError); - worker.on('exit', onExit); - worker.postMessage({ spectrum, task, version }); - }); - } - - async terminate() { - await this.worker?.terminate().catch(() => { }); - this.worker = null; - } -} \ No newline at end of file diff --git a/app/scripts/nmr-cli/src/parse/type/MoleculeExtended.ts b/app/scripts/nmr-cli/src/parse/type/MoleculeExtended.ts index 601a66f..7e71a00 100644 --- a/app/scripts/nmr-cli/src/parse/type/MoleculeExtended.ts +++ b/app/scripts/nmr-cli/src/parse/type/MoleculeExtended.ts @@ -1,12 +1,11 @@ -import { StateMolecule } from '@zakodium/nmrium-core' +import { StateMolecule } from "@zakodium/nmrium-core"; export interface MoleculeExtended - extends - Required>, + extends Required>, Omit { - mf: string - em: number - mw: number - svg: string - atoms: Record -} + mf: string; + em: number; + mw: number; + svg: string; + atoms: Record; +} \ No newline at end of file diff --git a/app/scripts/nmr-cli/src/parse/type/ParsedSpectrum.ts b/app/scripts/nmr-cli/src/parse/type/ParsedSpectrum.ts deleted file mode 100644 index 911550c..0000000 --- a/app/scripts/nmr-cli/src/parse/type/ParsedSpectrum.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Spectrum } from "@zakodium/nmr-types"; - -export interface ParsedSpectrum { - id: string; - spectrum: Spectrum; -} \ No newline at end of file diff --git a/app/scripts/nmr-cli/src/parse/type/Snapshot.ts b/app/scripts/nmr-cli/src/parse/type/Snapshot.ts deleted file mode 100644 index 0ed48cb..0000000 --- a/app/scripts/nmr-cli/src/parse/type/Snapshot.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface Snapshot { - id: string; - image: string | null; -} \ No newline at end of file diff --git a/app/scripts/nmr-cli/src/parse/type/SpectrumPipelineOptions.ts b/app/scripts/nmr-cli/src/parse/type/SpectrumPipelineOptions.ts deleted file mode 100644 index 001b4e7..0000000 --- a/app/scripts/nmr-cli/src/parse/type/SpectrumPipelineOptions.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface SpectrumPipelineOptions { - autoProcessing: boolean; - autoDetection: boolean; - enableSnapshot: boolean; -} \ No newline at end of file diff --git a/app/scripts/nmr-cli/src/parse/type/WorkerRequest.ts b/app/scripts/nmr-cli/src/parse/type/WorkerRequest.ts deleted file mode 100644 index fb39179..0000000 --- a/app/scripts/nmr-cli/src/parse/type/WorkerRequest.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Spectrum } from "@zakodium/nmr-types"; - -// Message contract shared with detectWorkerEntry.ts — keep both in sync. -export interface WorkerRequest { - spectrum: Spectrum; - task: 'process' | 'detect' | 'serialize'; - version?: unknown; -} \ No newline at end of file diff --git a/app/scripts/nmr-cli/src/parse/type/WorkerResponse.ts b/app/scripts/nmr-cli/src/parse/type/WorkerResponse.ts deleted file mode 100644 index 7caaa46..0000000 --- a/app/scripts/nmr-cli/src/parse/type/WorkerResponse.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Spectrum } from "@zakodium/nmr-types"; - -export interface WorkerResponse { - spectrum?: Spectrum; - stringObject?: string; - error?: string; -} \ No newline at end of file diff --git a/app/scripts/nmr-cli/src/parse/utility/getAtom.ts b/app/scripts/nmr-cli/src/parse/utility/getAtom.ts index da09e78..68e66ec 100644 --- a/app/scripts/nmr-cli/src/parse/utility/getAtom.ts +++ b/app/scripts/nmr-cli/src/parse/utility/getAtom.ts @@ -1,3 +1,3 @@ export default function getAtom(nucleus: string): string { - return nucleus?.replaceAll(/\d/g, '') || '' + return nucleus?.replaceAll(/\d/g, '') || ''; } diff --git a/app/scripts/nmr-cli/src/parse/utility/isProton.ts b/app/scripts/nmr-cli/src/parse/utility/isProton.ts index 00f3e6e..85f6e6e 100644 --- a/app/scripts/nmr-cli/src/parse/utility/isProton.ts +++ b/app/scripts/nmr-cli/src/parse/utility/isProton.ts @@ -1,3 +1,3 @@ export function isProton(nucleus: string) { - return nucleus === '1H' + return nucleus === '1H'; } diff --git a/app/scripts/nmr-cli/src/parse/utility/outputResult.ts b/app/scripts/nmr-cli/src/parse/utility/outputResult.ts deleted file mode 100644 index 40de4b6..0000000 --- a/app/scripts/nmr-cli/src/parse/utility/outputResult.ts +++ /dev/null @@ -1,24 +0,0 @@ - -import { createWriteStream } from 'node:fs'; -import { JsonStreamStringify } from 'json-stream-stringify'; - -export function outputResult(result: any, outputPath?: string): Promise { - return new Promise((resolve, reject) => { - const stream = new JsonStreamStringify(result); - - if (outputPath) { - const writeStream = createWriteStream(outputPath); - stream.pipe(writeStream); - writeStream.on('finish', () => { - process.stderr.write(`Output written to: ${outputPath}\n`); - resolve(); - }); - writeStream.on('error', reject); - stream.on('error', reject); - } else { - stream.pipe(process.stdout); - stream.on('end', () => resolve()); - stream.on('error', reject); - } - }); -} diff --git a/app/scripts/nmr-cli/src/parse/utility/toMessage.ts b/app/scripts/nmr-cli/src/parse/utility/toMessage.ts deleted file mode 100644 index 6516c2c..0000000 --- a/app/scripts/nmr-cli/src/parse/utility/toMessage.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function toMessage(e: unknown): string { - return e instanceof Error ? e.message : String(e); -} \ No newline at end of file diff --git a/app/scripts/nmr-cli/src/parse/worker-entry.ts b/app/scripts/nmr-cli/src/parse/worker-entry.ts deleted file mode 100644 index b78df71..0000000 --- a/app/scripts/nmr-cli/src/parse/worker-entry.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { parentPort } from 'node:worker_threads'; -import { Filters1DManager, Filters2DManager } from 'nmr-processing'; -import { detectZones } from './data/data2d/detectZones'; -import { detectRanges } from './data/data1D/detectRanges'; -import { Spectrum } from '@zakodium/nmr-types'; -import { isSpectrum2D } from '@zakodium/nmrium-core'; -import { WorkerRequest } from './type/WorkerRequest'; -import { WorkerResponse } from './type/WorkerResponse'; - -if (!parentPort) { - throw new Error('detectWorkerEntry must be run inside a worker_threads Worker'); -} - -// Both mutate the spectrum in place (same as the original single-threaded -// implementation) — the return is just for a uniform call signature. -async function runAutoProcessing(spectrum: Spectrum): Promise { - if (isSpectrum2D(spectrum)) { - Filters2DManager.reapplyFilters(spectrum); - } else { - Filters1DManager.reapplyFilters(spectrum); - } - return spectrum; -} - -async function runAutoDetection(spectrum: Spectrum): Promise { - if (isSpectrum2D(spectrum)) { - detectZones(spectrum); - } else { - detectRanges(spectrum); - } - return spectrum; -} - -// Spectra hold typed-array data (Float64Array etc.), which JSON.stringify -// otherwise mangles into `{0: ..., 1: ...}` objects instead of arrays — the -// replacer below converts any ArrayBuffer view to a plain array first. No -// nmrium-core-plugins init needed here: this is a plain JSON encoding of -// { version, data: { spectra: [spectrum] } }, matching exactly what the -// browser's `nmr-wrapper:load` message expects. -async function serializeSpectrum(spectrum: Spectrum, version: unknown): Promise { - return JSON.stringify( - { version, data: { spectra: [spectrum] } }, - (_key, value: unknown) => (ArrayBuffer.isView(value) ? Array.from(value as unknown as Iterable) : value) - ); -} - -parentPort.on('message', async (msg: WorkerRequest) => { - const { spectrum, task, version } = msg; - const reply = (response: WorkerResponse) => parentPort!.postMessage(response); - - try { - switch (task) { - case 'process': - reply({ spectrum: await runAutoProcessing(spectrum) }); - break; - case 'detect': - reply({ spectrum: await runAutoDetection(spectrum) }); - break; - case 'serialize': - reply({ stringObject: await serializeSpectrum(spectrum, version) }); - break; - default: { - const exhaustive: never = task; - throw new Error(`Unknown task: ${exhaustive}`); - } - } - } catch (e) { - reply({ error: e instanceof Error ? e.message : String(e) }); - } -}); \ No newline at end of file From 63d1e48c8ff4f307523d4f6d69c7b642e10f3861 Mon Sep 17 00:00:00 2001 From: "abeer.dev" <56149548+MuhammadAbeerAkmal@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:29:22 +0200 Subject: [PATCH 4/7] feat(nmr-cli): add nmr-correlation as a new command (#140) * feat: expose nmr-correlation package as a new nmr-cli command * Address review: support local directory input, filter for FT spectra, add real yargs defaults/aliases * Address round 2 review: explicit if/else branching, clarify spectrum filter rationale * Address review: filter correlation spectra by ranges/zones instead of reference equality * Address review: extract readSpectra and filterSpectra helper functions --- app/scripts/nmr-cli/package-lock.json | 36 ++++-- app/scripts/nmr-cli/package.json | 1 + app/scripts/nmr-cli/src/correlation.ts | 115 ++++++++++++++++++ app/scripts/nmr-cli/src/index.ts | 72 ++++++++++- .../nmr-cli/src/parse/prase-spectra.ts | 25 ++-- 5 files changed, 229 insertions(+), 20 deletions(-) create mode 100644 app/scripts/nmr-cli/src/correlation.ts 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 } From 7238a4e8e2dc714de0a5c1725fa7d9f961a689b9 Mon Sep 17 00:00:00 2001 From: Nisha Sharma Date: Fri, 25 Sep 2026 11:29:56 +0200 Subject: [PATCH 5/7] chore: upgrade actions and dependencies in workflow files to latest versions --- .github/workflows/dev-build.yml | 14 +++++++------- .github/workflows/doc-deploy.yml | 2 +- .github/workflows/prod-build.yml | 14 +++++++------- .github/workflows/test.yml | 2 +- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/workflows/dev-build.yml b/.github/workflows/dev-build.yml index 583a0f5..336817e 100644 --- a/.github/workflows/dev-build.yml +++ b/.github/workflows/dev-build.yml @@ -35,19 +35,19 @@ jobs: steps: # Clone repository code to runner - name: Check out the repo - uses: actions/checkout@v4 + uses: actions/checkout@v7 # Enable multi-architecture builds via QEMU emulation - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 # Enable advanced Docker build features (required for caching) - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 # Authenticate with Docker Hub for image push access - name: Log in to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} @@ -55,7 +55,7 @@ jobs: # Detect changes in nmr-cli folder to skip unnecessary builds - name: Check for file changes id: changes - uses: dorny/paths-filter@v3 + uses: dorny/paths-filter@v4 with: filters: | nmr-cli: @@ -63,7 +63,7 @@ jobs: # Build main nmrKit image with registry caching for faster builds - name: Build and push nmrKit Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . file: ./Dockerfile @@ -77,7 +77,7 @@ jobs: # Build nmr-cli image only if files in app/scripts/nmr-cli/ changed - name: Build and push nmr-cli Docker image if: steps.changes.outputs.nmr-cli == 'true' - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: ./app/scripts/nmr-cli/ file: ./app/scripts/nmr-cli/Dockerfile diff --git a/.github/workflows/doc-deploy.yml b/.github/workflows/doc-deploy.yml index 8c6678c..319710e 100644 --- a/.github/workflows/doc-deploy.yml +++ b/.github/workflows/doc-deploy.yml @@ -29,7 +29,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 # Not needed if lastUpdated is not enabled # - uses: pnpm/action-setup@v2 # Uncomment this if you're using pnpm diff --git a/.github/workflows/prod-build.yml b/.github/workflows/prod-build.yml index 1cfdcc2..628048b 100644 --- a/.github/workflows/prod-build.yml +++ b/.github/workflows/prod-build.yml @@ -71,19 +71,19 @@ jobs: steps: # Clone repository code to runner - name: Check out the repo - uses: actions/checkout@v4 + uses: actions/checkout@v7 # Enable multi-architecture builds via QEMU emulation - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 # Enable advanced Docker build features (required for caching) - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 # Authenticate with Docker Hub for image push access - name: Log in to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} @@ -91,7 +91,7 @@ jobs: # Detect changes in nmr-cli folder to skip unnecessary builds - name: Check for file changes id: changes - uses: dorny/paths-filter@v3 + uses: dorny/paths-filter@v4 with: filters: | nmr-cli: @@ -107,7 +107,7 @@ jobs: # Build main nmrKit image with registry caching for faster builds - name: Build and push nmrKit Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . file: ./Dockerfile @@ -123,7 +123,7 @@ jobs: # Build nmr-cli image only if files in app/scripts/nmr-cli/ changed - name: Build and push nmr-cli Docker image if: steps.changes.outputs.nmr-cli == 'true' - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: ./app/scripts/nmr-cli/ file: ./app/scripts/nmr-cli/Dockerfile diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1ef0a94..845d3a0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,7 +31,7 @@ jobs: python-version: ["3.10"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: From 17a322f8ca89b8584da74efa14b2aec34afb1fef Mon Sep 17 00:00:00 2001 From: Nisha Sharma Date: Mon, 28 Sep 2026 09:27:20 +0200 Subject: [PATCH 6/7] =?UTF-8?q?fix:=20update=20miniconda3=20version=20and?= =?UTF-8?q?=20improve=20nodejs=20installation=20proces=E2=80=A6=20(#145)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: update miniconda3 version and improve nodejs installation process in Dockerfile * fix: use conda-forge as the only conda channel --------- Co-authored-by: hamed musallam --- Dockerfile | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index e9f73d1..0964b23 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM continuumio/miniconda3:24.1.2-0 AS nmrkit-ms +FROM continuumio/miniconda3:25.3.1-1 AS nmrkit-ms ARG TARGETARCH=amd64 ENV PYTHON_VERSION=3.10 @@ -24,8 +24,13 @@ RUN apt-get update && \ RUN apt-get update && apt-get -y install docker.io -RUN conda install -c conda-forge python>=PYTHON_VERSION -RUN conda install -c conda-forge openbabel>=OPENBABEL_VERSION +RUN conda config --remove-key channels && \ + conda config --add channels conda-forge && \ + conda config --set channel_priority strict + +RUN conda install -y \ + "python=${PYTHON_VERSION}" \ + "openbabel>=${OPENBABEL_VERSION}" RUN pip3 install rdkit @@ -51,8 +56,11 @@ RUN python3 -m pip install uvicorn[standard] COPY ./app /code/app -RUN curl -sL https://deb.nodesource.com/setup_current.x | bash - -RUN apt-get install -y nodejs +# Download the setup script first so a failure fails the build instead of being masked by the pipe +RUN curl -fsSL https://deb.nodesource.com/setup_24.x -o /tmp/nodesource_setup.sh && \ + bash /tmp/nodesource_setup.sh && \ + apt-get install -y nodejs && \ + rm -rf /var/lib/apt/lists/* /tmp/nodesource_setup.sh RUN npm install -g npm@latest RUN npm install -g /code/app/scripts/nmr-cli From 2ac511cb47af4a8d9bfe8c46433b7efee3e9ed67 Mon Sep 17 00:00:00 2001 From: Nisha Sharma Date: Mon, 28 Sep 2026 13:53:25 +0200 Subject: [PATCH 7/7] fix: correct GitHub contributors badge link and minor text adjustments in README --- README.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 80a5b7a..76a686e 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ -

NMRKit Logo

+

NMRKit Logo

[![License](https://img.shields.io/badge/License-MIT%202.0-blue.svg)](https://opensource.org/licenses/MIT) [![Maintenance](https://img.shields.io/badge/Maintained%3F-yes-blue.svg)](https://github.com/NFDI4Chem/nmrkit/graphs/commit-activity) [![GitHub issues](https://img.shields.io/github/issues/NFDI4Chem/nmrkit.svg)](https://github.com/NFDI4Chem/nmrkit/issues) -[![GitHub contributors](https://img.shields.io/github/contributors/NFDI4Chem/nmrkit.svg)]([https://GitHub.com/Steinbeck-Lab/cheminformatics-python-microservice/graphs/contributors/](https://github.com/NFDI4Chem/nmrkit/graphs/contributors)) +[![GitHub contributors](https://img.shields.io/github/contributors/NFDI4Chem/nmrkit.svg)](<[https://GitHub.com/Steinbeck-Lab/cheminformatics-python-microservice/graphs/contributors/](https://github.com/NFDI4Chem/nmrkit/graphs/contributors)>) [![Powered by CDK](https://img.shields.io/badge/Powered%20by-CDK-blue.svg?style=flat&logo=chem)](https://cdk.github.io) [![RDKit badge](https://img.shields.io/badge/Powered%20by-RDKit-3838ff.svg?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQBAMAAADt3eJSAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAFVBMVEXc3NwUFP8UPP9kZP+MjP+0tP////9ZXZotAAAAAXRSTlMAQObYZgAAAAFiS0dEBmFmuH0AAAAHdElNRQfmAwsPGi+MyC9RAAAAQElEQVQI12NgQABGQUEBMENISUkRLKBsbGwEEhIyBgJFsICLC0iIUdnExcUZwnANQWfApKCK4doRBsKtQFgKAQC5Ww1JEHSEkAAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyMi0wMy0xMVQxNToyNjo0NyswMDowMDzr2J4AAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjItMDMtMTFUMTU6MjY6NDcrMDA6MDBNtmAiAAAAAElFTkSuQmCC)](https://www.rdkit.org/) ![Workflow](https://github.com/NFDI4Chem/nmrkit/actions/workflows/dev-build.yml/badge.svg) @@ -12,7 +12,6 @@ [![framework](https://img.shields.io/badge/Framework-FastAPI-blue?style)](https://fastapi.tiangolo.com/) [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.8112749.svg)](https://doi.org/10.5281/zenodo.8211374) - # Welcome to NMRKit 🚀 NMRKit features a collection of powerful microservices designed to simplify your NMR data processing and analysis. Whether you're a seasoned researcher or a curious chemist, our suite of tools offers NMR Prediction, Validation, and Depiction via the nmrium library, along with seamless Format Conversion using the nmr-load-save package. With our robust API, functionalities, and developer-friendly documentation, exploring and interpreting NMR spectra has never been easier. @@ -27,7 +26,7 @@ Key Features: API Reference (Scalar) - https://dev.nmrkit.nmrxiv.org/latest/docs 📢 Found a bug or have a feature request? We'd love to hear from you! Please open an issue: [https://github.com/NFDI4Chem/nmrkit/issues] -Happy NMR exploring! 🧪🌟" +Happy NMR exploring..! 🧪🌟" ## Help Desk @@ -46,9 +45,9 @@ Licensed under the [MIT license](https://opensource.org/licenses/MIT). Venkata, C., Sharma, N. , Stefan Kuhn, Hamed Musallam nmrKit [Computer software]. https://doi.org/10.5281/zenodo.8211374 ## Maintained by -NMRKit is developed and maintained by the [NFDI4Chem partners](https://www.nfdi4chem.de/) at the [Friedrich Schiller University](https://www.uni-jena.de/en/) Jena, Germany. -The code for this web application is released under the [MIT license](https://opensource.org/licenses/MIT). +NMRKit is developed and maintained by the [NFDI4Chem partners](https://www.nfdi4chem.de/) at the [Friedrich Schiller University](https://www.uni-jena.de/en/) Jena, Germany. +The code for this web application is released under the [MIT license](https://opensource.org/licenses/MIT).

NFDI4Chem Logo

NFDI4Chem Logo