diff --git a/app/routers/predict.py b/app/routers/predict.py index a64bb3b..2a289db 100644 --- a/app/routers/predict.py +++ b/app/routers/predict.py @@ -1,11 +1,15 @@ -import subprocess -from fastapi import APIRouter, HTTPException, status, Response, Body +from fastapi import APIRouter, HTTPException, status, UploadFile, File, Form from app.schemas import HealthCheck -from app.schemas.respredict_response_schema import ResPredictModel -from app.schemas.error import ErrorResponse, BadRequestModel, NotFoundModel -import uuid -from typing import Annotated +from pydantic import BaseModel, Field, model_validator +from typing import Annotated, List, Literal, Optional, Union +from enum import Enum +import subprocess +import json +import tempfile import os +import uuid +import time +from pathlib import Path router = APIRouter( prefix="/predict", @@ -14,6 +18,432 @@ responses={404: {"description": "Not Found"}}, ) +# Container name for nmr-cli (from docker-compose.yml) +NMR_CLI_CONTAINER = "nmr-converter" +SHARED_VOLUME_PATH = "/shared" + + +# ============================================================================ +# ENUMS +# ============================================================================ + + +class SpectraType(str, Enum): + PROTON = "proton" + CARBON = "carbon" + COSY = "cosy" + HSQC = "hsqc" + HMBC = "hmbc" + + +class PeakShape(str, Enum): + GAUSSIAN = "gaussian" + LORENTZIAN = "lorentzian" + + +class Solvent(str, Enum): + ANY = "Any" + CHLOROFORM = "Chloroform-D1 (CDCl3)" + DMSO = "Dimethylsulphoxide-D6 (DMSO-D6, C2D6SO)" + METHANOL = "Methanol-D4 (CD3OD)" + D2O = "Deuteriumoxide (D2O)" + ACETONE = "Acetone-D6 ((CD3)2CO)" + CCL4 = "TETRACHLORO-METHANE (CCl4)" + PYRIDINE = "Pyridin-D5 (C5D5N)" + BENZENE = "Benzene-D6 (C6D6)" + NEAT = "neat" + THF = "Tetrahydrofuran-D8 (THF-D8, C4D4O)" + + +# ============================================================================ +# ENGINE-SPECIFIC OPTIONS +# ============================================================================ + + +class FromTo(BaseModel): + """Range with from/to values in ppm""" + from_: float = Field(..., alias="from", description="From in ppm") + to: float = Field(..., description="To in ppm") + + model_config = {"populate_by_name": True} + + +class NbPoints2D(BaseModel): + x: int = Field(default=1024, description="2D spectrum X-axis points") + y: int = Field(default=1024, description="2D spectrum Y-axis points") + + +class Options1D(BaseModel): + """1D spectrum generation options for nmrdb.org""" + proton: FromTo = Field( + default=FromTo.model_validate({"from": -1, "to": 12}), + description="Proton (1H) range in ppm", + ) + carbon: FromTo = Field( + default=FromTo.model_validate({"from": -5, "to": 220}), + description="Carbon (13C) range in ppm", + ) + nbPoints: int = Field(default=2**17, description="1D number of points") + lineWidth: float = Field(default=1, description="1D line width") + + model_config = {"populate_by_name": True} + + +class Options2D(BaseModel): + """2D spectrum generation options for nmrdb.org""" + nbPoints: NbPoints2D = Field( + default_factory=NbPoints2D, + description="2D number of points", + ) + + +class NmrdbOptions(BaseModel): + """Options for the nmrdb.org prediction engine""" + name: str = Field(default="", description="Compound name") + frequency: float = Field(default=400, description="NMR frequency (MHz)") + one_d: Options1D = Field( + alias="1d", + default_factory=Options1D, + description="1D spectrum options", + ) + two_d: Options2D = Field( + alias="2d", + default_factory=Options2D, + description="2D spectrum options", + ) + autoExtendRange: bool = Field( + default=True, + description="Auto extend range if signals fall outside", + ) + + model_config = {"populate_by_name": True} + + +class NmrshiftOptions(BaseModel): + """Options for the nmrshift prediction engine""" + id: int = Field(default=1, description="Input ID") + shifts: str = Field(default="1", description="Chemical shifts") + solvent: Solvent = Field(default=Solvent.DMSO, description="NMR solvent") + from_ppm: Optional[float] = Field( + default=None, + alias="from", + description="From in (ppm) for spectrum generation", + ) + to_ppm: Optional[float] = Field( + default=None, + alias="to", + description="To in (ppm) for spectrum generation", + ) + nbPoints: int = Field(default=1024, description="Number of points") + lineWidth: float = Field(default=1, description="Line width") + frequency: float = Field(default=400, description="NMR frequency (MHz)") + tolerance: float = Field( + default=0.001, description="Tolerance to group peaks") + peakShape: PeakShape = Field( + default=PeakShape.LORENTZIAN, description="Peak shape") + + model_config = {"populate_by_name": True} + + +# ============================================================================ +# REQUEST MODELS +# ============================================================================ + + +NMRDB_SUPPORTED_SPECTRA = {"proton", "carbon", "cosy", "hsqc", "hmbc"} +NMRSHIFT_SUPPORTED_SPECTRA = {"proton", "carbon"} + + +class NmrdbPredictRequest(BaseModel): + """Prediction request using the nmrdb.org engine""" + engine: Literal["nmrdb.org"] = Field(..., description="Prediction engine") + structure: str = Field(..., description="MOL file content") + spectra: List[SpectraType] = Field(..., + description="Spectra types", min_length=1) + options: NmrdbOptions = Field(default_factory=NmrdbOptions) + + @model_validator(mode="after") + def validate_spectra(self): + unsupported = [ + s.value for s in self.spectra if s.value not in NMRDB_SUPPORTED_SPECTRA] + if unsupported: + raise ValueError( + f"nmrdb.org does not support: {unsupported}. " + f"Supported: {sorted(NMRDB_SUPPORTED_SPECTRA)}" + ) + return self + + +class NmrshiftPredictRequest(BaseModel): + """Prediction request using the nmrshift engine""" + engine: Literal["nmrshift"] = Field(..., description="Prediction engine") + structure: str = Field(..., description="MOL file content") + spectra: List[SpectraType] = Field(..., + description="Spectra types", min_length=1) + options: NmrshiftOptions = Field(default_factory=NmrshiftOptions) + + @model_validator(mode="after") + def validate_spectra(self): + unsupported = [ + s.value for s in self.spectra if s.value not in NMRSHIFT_SUPPORTED_SPECTRA] + if unsupported: + raise ValueError( + f"nmrshift does not support: {unsupported}. " + f"Supported: {sorted(NMRSHIFT_SUPPORTED_SPECTRA)}" + ) + return self + + +# File upload request models - same options as structure models +class NmrdbFileRequest(BaseModel): + """File upload prediction request using the nmrdb.org engine""" + engine: Literal["nmrdb.org"] = Field(..., description="Prediction engine") + spectra: List[SpectraType] = Field(..., + description="Spectra types", min_length=1) + options: NmrdbOptions = Field(default_factory=NmrdbOptions) + + @model_validator(mode="after") + def validate_spectra(self): + unsupported = [ + s.value for s in self.spectra if s.value not in NMRDB_SUPPORTED_SPECTRA] + if unsupported: + raise ValueError( + f"nmrdb.org does not support: {unsupported}. " + f"Supported: {sorted(NMRDB_SUPPORTED_SPECTRA)}" + ) + return self + + +class NmrshiftFileRequest(BaseModel): + """File upload prediction request using the nmrshift engine""" + engine: Literal["nmrshift"] = Field(..., description="Prediction engine") + spectra: List[SpectraType] = Field(..., + description="Spectra types", min_length=1) + options: NmrshiftOptions = Field(default_factory=NmrshiftOptions) + + @model_validator(mode="after") + def validate_spectra(self): + unsupported = [ + s.value for s in self.spectra if s.value not in NMRSHIFT_SUPPORTED_SPECTRA] + if unsupported: + raise ValueError( + f"nmrshift does not support: {unsupported}. " + f"Supported: {sorted(NMRSHIFT_SUPPORTED_SPECTRA)}" + ) + return self + + +PredictRequest = Annotated[ + Union[NmrdbPredictRequest, NmrshiftPredictRequest], + Field(discriminator="engine"), +] + +FileRequest = Annotated[ + Union[NmrdbFileRequest, NmrshiftFileRequest], + Field(discriminator="engine"), +] + + +# ============================================================================ +# CLI BUILDERS +# ============================================================================ + + +def build_nmrdb_args(options: NmrdbOptions, spectra: List[SpectraType]) -> list[str]: + """Build CLI arguments for nmrdb.org""" + one_d = options.one_d + two_d = options.two_d + + args = [ + "--engine", "nmrdb.org", + "--spectra", *[s.value for s in spectra], + "--frequency", str(options.frequency), + "--protonFrom", str(one_d.proton.from_), + "--protonTo", str(one_d.proton.to), + "--carbonFrom", str(one_d.carbon.from_), + "--carbonTo", str(one_d.carbon.to), + "--nbPoints1d", str(one_d.nbPoints), + "--lineWidth", str(one_d.lineWidth), + "--nbPoints2dX", str(two_d.nbPoints.x), + "--nbPoints2dY", str(two_d.nbPoints.y), + ] + + if options.name: + args.extend(["--name", options.name]) + if not options.autoExtendRange: + args.append("--no-autoExtendRange") + + return args + + +def build_nmrshift_args(options: NmrshiftOptions, spectra: List[SpectraType]) -> list[str]: + """Build CLI arguments for nmrshift""" + args = [ + "--engine", "nmrshift", + "--spectra", *[s.value for s in spectra], + "--id", str(options.id), + "--shifts", options.shifts, + "--solvent", options.solvent.value, + "--nbPoints", str(options.nbPoints), + "--lineWidth", str(options.lineWidth), + "--frequency", str(options.frequency), + "--tolerance", str(options.tolerance), + "--peakShape", options.peakShape.value, + ] + + if options.from_ppm is not None: + args.extend(["--from", str(options.from_ppm)]) + if options.to_ppm is not None: + args.extend(["--to", str(options.to_ppm)]) + + return args + + +def build_cli_args(request: Union[NmrdbPredictRequest, NmrshiftPredictRequest, + NmrdbFileRequest, NmrshiftFileRequest]) -> list[str]: + """Build CLI args from any request type""" + if isinstance(request, (NmrdbPredictRequest, NmrdbFileRequest)): + return build_nmrdb_args(request.options, request.spectra) + elif isinstance(request, (NmrshiftPredictRequest, NmrshiftFileRequest)): + return build_nmrshift_args(request.options, request.spectra) + else: + raise HTTPException( + status_code=400, detail=f"Unknown engine type: {type(request)}") + + +# ============================================================================ +# HELPERS +# ============================================================================ + + +def copy_file_to_container(local_path: str, container_path: str) -> None: + """Copy file to container""" + try: + subprocess.run( + ["docker", "cp", local_path, + f"{NMR_CLI_CONTAINER}:{container_path}"], + check=True, + capture_output=True, + timeout=30 + ) + except subprocess.CalledProcessError as e: + error_msg = e.stderr.decode("utf-8") if e.stderr else "Unknown error" + raise HTTPException( + status_code=500, detail=f"Failed to copy file: {error_msg}") + + +def remove_file_from_container(container_path: str) -> None: + """Remove file from container""" + try: + subprocess.run( + ["docker", "exec", NMR_CLI_CONTAINER, "rm", "-f", container_path], + capture_output=True, + timeout=10 + ) + except Exception: + pass + + +def execute_cli(cmd: list[str], engine: str) -> dict: + """Execute CLI command and return parsed JSON""" + timeout = 300 if engine == "nmrdb.org" else 120 + start_time = time.time() + + try: + result = subprocess.run( + ["docker", "exec", NMR_CLI_CONTAINER] + cmd, + capture_output=True, + text=False, + timeout=timeout + ) + except subprocess.TimeoutExpired: + raise HTTPException( + status_code=408, + detail={ + "message": f"Prediction timed out after {timeout}s", + "engine": engine, + "hint": "nmrdb.org predictions can take 30-60s, try again or use nmrshift for faster results", + } + ) + except FileNotFoundError: + raise HTTPException( + status_code=500, + detail={ + "message": "Docker not found or nmr-converter container is not running", + "hint": "Run: docker compose up -d", + } + ) + + elapsed = round(time.time() - start_time, 2) + stdout = result.stdout.decode("utf-8", errors="replace").strip() + stderr = result.stderr.decode("utf-8", errors="replace").strip() + + if result.returncode != 0: + raise HTTPException( + status_code=422, + detail={ + "message": "NMR CLI command failed", + "engine": engine, + "exit_code": result.returncode, + "error": stderr or "No error output from CLI", + "elapsed_seconds": elapsed, + } + ) + + if not stdout: + raise HTTPException( + status_code=500, + detail={ + "message": "NMR CLI returned empty output", + "engine": engine, + "exit_code": result.returncode, + "stderr": stderr or "No error output from CLI", + "elapsed_seconds": elapsed, + "hint": "Check that all required CLI arguments are valid", + } + ) + + # Strip any warning/info messages printed before the JSON output + json_start = stdout.find('{') + if json_start > 0: + warnings = stdout[:json_start].strip() + print(f"[WARN] CLI warnings before JSON: {warnings}") + stdout = stdout[json_start:] + + try: + return json.loads(stdout) + except json.JSONDecodeError as e: + raise HTTPException( + status_code=500, + detail={ + "message": "NMR CLI returned invalid JSON", + "engine": engine, + "parse_error": str(e), + "stdout_preview": stdout[:500], + "stderr": stderr or "No error output from CLI", + "elapsed_seconds": elapsed, + } + ) + + +def run_predict_command(structure: str, cli_args: list[str], engine: str) -> dict: + """Execute nmr-cli predict with structure string""" + # CRITICAL: Escape newlines for CLI + structure_escaped = structure.replace('\n', '\\n') + cmd = ["nmr-cli", "predict", "-s", structure_escaped] + cli_args + return execute_cli(cmd, engine) + + +def run_predict_command_with_file(file_path: str, cli_args: list[str], engine: str) -> dict: + """Execute nmr-cli predict with file""" + cmd = ["nmr-cli", "predict", "--file", file_path] + cli_args + return execute_cli(cmd, engine) + + +# ============================================================================ +# HEALTH CHECK +# ============================================================================ + @router.get("/", include_in_schema=False) @router.get( @@ -26,120 +456,190 @@ response_model=HealthCheck, ) def get_health() -> HealthCheck: - """ - ## Perform a Health Check - Endpoint to perform a healthcheck on. This endpoint can primarily be used by Docker - to ensure a robust container orchestration and management are in place. Other - services that rely on the proper functioning of the API service will not deploy if this - endpoint returns any other HTTP status code except 200 (OK). - Returns: - HealthCheck: Returns a JSON response with the health status - """ + """Health check endpoint""" return HealthCheck(status="OK") +# ============================================================================ +# ENDPOINTS +# ============================================================================ + + @router.post( - "/respredict", - summary="", - responses={ - 200: { - "description": "Successful response", - "model": ResPredictModel, - }, - 400: {"description": "Bad Request", "model": BadRequestModel}, - 404: {"description": "Not Found", "model": NotFoundModel}, - 422: {"description": "Unprocessable Entity", "model": ErrorResponse}, - }, + "/", + tags=["predict"], + summary="Predict NMR spectra from MOL string", + response_description="Predicted spectra in NMRium JSON format", + status_code=status.HTTP_200_OK, ) -async def predict_mol( - data: Annotated[ - str, - Body( - embed=False, - media_type="text/plain", - openapi_examples={ - "example1": { - "summary": "Example: C", - "value": """ - CDK 09012310592D - - 1 0 0 0 0 0 0 0 0 0999 V2000 - 0.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0 -M END""", - }, +async def predict_from_structure(request: PredictRequest): + """ + ## Predict NMR spectra from MOL string + + **Note:** nmrdb.org predictions take 30-60s. Use curl/Postman, not Swagger. + + **Engines:** + - **nmrshift** — Supports: proton, carbon + - **nmrdb.org** — Supports: proton, carbon, cosy, hsqc, hmbc + + **Example (nmrshift):** + ```json + { + "engine": "nmrshift", + "structure": "\\n Mrv2311...\\nM END", + "spectra": ["proton"], + "options": { + "solvent": "Chloroform-D1 (CDCl3)", + "frequency": 400, + "nbPoints": 1024, + "lineWidth": 1, + "peakShape": "lorentzian" + } + } + ``` + + **Example (nmrdb.org):** + ```json + { + "engine": "nmrdb.org", + "structure": "\\n Mrv2311...\\nM END", + "spectra": ["proton", "carbon"], + "options": { + "name": "Benzene", + "frequency": 400, + "1d": { + "proton": {"from": -1, "to": 12}, + "carbon": {"from": -5, "to": 220}, + "nbPoints": 131072, + "lineWidth": 1 }, - ), - ] + "autoExtendRange": true + } + } + ``` + """ + try: + cli_args = build_cli_args(request) + return run_predict_command(request.structure, cli_args, request.engine) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=422, detail=f"Error: {e}") + + +@router.post( + "/file", + tags=["predict"], + summary="Predict NMR spectra from uploaded MOL file", + response_description="Predicted spectra in NMRium JSON format", + status_code=status.HTTP_200_OK, +) +async def predict_from_file( + file: UploadFile = File(..., description="MOL file"), + request: str = Form(..., description="""JSON string with engine, spectra and options. Examples: + +nmrshift: {"engine": "nmrshift", "spectra": ["proton"], "options": {"solvent": "Chloroform-D1 (CDCl3)", "frequency": 400, "nbPoints": 1024, "lineWidth": 1, "peakShape": "lorentzian"}} + +nmrdb.org: {"engine": "nmrdb.org", "spectra": ["proton", "carbon"], "options": {"name": "Benzene", "frequency": 400, "1d": {"proton": {"from": -1, "to": 12}, "carbon": {"from": -5, "to": 220}, "nbPoints": 131072, "lineWidth": 1}, "autoExtendRange": true}} +"""), ): """ - Standardize molblock using the ChEMBL curation pipeline - and return the standardized molecule, SMILES, InChI, and InCHI-Key. + ## Predict NMR spectra from uploaded MOL file - Parameters: - - **molblock**: The request body containing the "molblock" string representing the molecule to be standardized. + Upload a MOL file and pass engine options as a JSON string in the `request` field. - Returns: - - dict: A dictionary containing the following keys: - - "standardized_mol" (str): The standardized molblock of the molecule. - - "canonical_smiles" (str): The canonical SMILES representation of the molecule. - - "inchi" (str): The InChI representation of the molecule. - - "inchikey" (str): The InChI-Key of the molecule. + **Note:** nmrdb.org predictions take 30-60s. Use curl/Postman, not Swagger. - Raises: - - ValueError: If the SMILES string is not provided or is invalid. + **nmrshift example request field:** + ```json + { + "engine": "nmrshift", + "spectra": ["proton"], + "options": { + "solvent": "Chloroform-D1 (CDCl3)", + "frequency": 400, + "nbPoints": 1024, + "lineWidth": 1, + "peakShape": "lorentzian" + } + } + ``` + **nmrdb.org example request field:** + ```json + { + "engine": "nmrdb.org", + "spectra": ["proton", "carbon"], + "options": { + "name": "Benzene", + "frequency": 400, + "1d": { + "proton": {"from": -1, "to": 12}, + "carbon": {"from": -5, "to": 220}, + "nbPoints": 131072, + "lineWidth": 1 + }, + "autoExtendRange": true + } + } + ``` """ + local_file_path = None + container_file_path = None + use_shared_volume = os.path.exists( + SHARED_VOLUME_PATH) and os.access(SHARED_VOLUME_PATH, os.W_OK) + try: - if data: - file_name = "/shared/" + str(uuid.uuid4()) + ".mol" - f = open(file_name, "a") - f.write(data) - f.close() - process = subprocess.Popen( - [ - "docker exec nmr-respredict python3 predict_standalone.py --filename " - + file_name - ], - stdout=subprocess.PIPE, - shell=True, - ) - (output, err) = process.communicate() - process.wait() - if err: - raise HTTPException(status_code=500, detail=err) - else: - if os.path.exists(file_name): - os.remove(file_name) - return Response(content=output, media_type="application/json") + # Parse the JSON request field + try: + request_data = json.loads(request) + except json.JSONDecodeError as e: + raise HTTPException( + status_code=422, detail=f"Invalid JSON in request field: {e}") + + # Validate against the correct model based on engine + engine = request_data.get("engine") + if engine == "nmrdb.org": + parsed_request = NmrdbFileRequest(**request_data) + elif engine == "nmrshift": + parsed_request = NmrshiftFileRequest(**request_data) + else: + raise HTTPException( + status_code=400, detail=f"Unknown engine: {engine}. Use 'nmrdb.org' or 'nmrshift'") + + # Build CLI args using same builders as structure endpoint + cli_args = build_cli_args(parsed_request) + + # Read and save uploaded file + contents = await file.read() + + if use_shared_volume: + # FAST: Write directly to shared volume + filename = f"predict_{uuid.uuid4().hex[:8]}.mol" + local_file_path = os.path.join(SHARED_VOLUME_PATH, filename) + container_file_path = f"/shared/{filename}" + with open(local_file_path, 'wb') as f: + f.write(contents) + else: + # Fallback: Use temp file + docker cp + with tempfile.NamedTemporaryFile(delete=False, suffix=".mol") as tmp_file: + tmp_file.write(contents) + local_file_path = tmp_file.name + container_file_path = f"/tmp/{Path(local_file_path).name}" + copy_file_to_container(local_file_path, container_file_path) + + return run_predict_command_with_file(container_file_path, cli_args, parsed_request.engine) + + except HTTPException: + raise except Exception as e: - raise HTTPException(status_code=422, detail=str(e)) - - -# @router.get( -# "/predict", -# tags=["predict"], -# summary="Load and convert NMR raw data", -# #response_model=List[int], -# response_description="Load and convert NMR raw data", -# status_code=status.HTTP_200_OK, -# ) -# async def nmr_respredict(url: str): -# """ -# ## Return nmrium json - -# Returns: -# Return nmrium json -# """ -# file = "/shared" + str(uuid.uuid4()) + ".mol" - -# process = subprocess.Popen( -# ["docker exec nmr-respredict python3 ./predict_standalone.py --filename " + file], -# stdout=subprocess.PIPE, -# shell=True, -# ) -# (output, err) = process.communicate() -# process.wait() -# if err: -# raise HTTPException(status_code=500, detail=err) -# else: -# return Response(content=output, media_type="application/json") + raise HTTPException(status_code=422, detail=f"Error: {e}") + finally: + if local_file_path and os.path.exists(local_file_path): + try: + os.unlink(local_file_path) + except Exception: + pass + if not use_shared_volume and container_file_path: + remove_file_from_container(container_file_path) + await file.close() diff --git a/app/scripts/nmr-cli/package-lock.json b/app/scripts/nmr-cli/package-lock.json index 9e8e05d..536b76a 100644 --- a/app/scripts/nmr-cli/package-lock.json +++ b/app/scripts/nmr-cli/package-lock.json @@ -18,7 +18,8 @@ "mf-parser": "^3.6.0", "ml-spectra-processing": "^14.19.0", "nmr-processing": "^22.1.0", - "playwright": "^1.51.0", + "openchemlib": "^9.19.0", + "playwright": "^1.56.1", "yargs": "^18.0.0" }, "bin": { @@ -1298,9 +1299,9 @@ } }, "node_modules/openchemlib": { - "version": "9.18.2", - "resolved": "https://registry.npmjs.org/openchemlib/-/openchemlib-9.18.2.tgz", - "integrity": "sha512-amgDEgH7lLOBGg3sS2XmxjY+n6zC8M+ohJqNgifKACkbjPuzmnzs85rbMHcAndMzn7e6hh7IwJ8FByWKdBhGSg==", + "version": "9.19.0", + "resolved": "https://registry.npmjs.org/openchemlib/-/openchemlib-9.19.0.tgz", + "integrity": "sha512-rA/8tQ7SltRaAf4YfzBp447pnHd/+6aXEIX8JpBNF849fuzoHVuYN4inMOL5KKWgLWYqRLi+FnjfRZI0puYtog==", "license": "BSD-3-Clause", "peer": true }, @@ -1558,4 +1559,4 @@ } } } -} \ No newline at end of file +} diff --git a/app/scripts/nmr-cli/package.json b/app/scripts/nmr-cli/package.json index c511322..d837bc0 100644 --- a/app/scripts/nmr-cli/package.json +++ b/app/scripts/nmr-cli/package.json @@ -24,7 +24,8 @@ "mf-parser": "^3.6.0", "ml-spectra-processing": "^14.19.0", "nmr-processing": "^22.1.0", - "playwright": "1.56.1", + "openchemlib": "^9.19.0", + "playwright": "^1.56.1", "yargs": "^18.0.0" }, "devDependencies": { @@ -34,4 +35,4 @@ "ts-node": "^10.9.2", "typescript": "^5.9.3" } -} \ No newline at end of file +} diff --git a/app/scripts/nmr-cli/src/index.ts b/app/scripts/nmr-cli/src/index.ts index 4368b21..f257c41 100755 --- a/app/scripts/nmr-cli/src/index.ts +++ b/app/scripts/nmr-cli/src/index.ts @@ -2,8 +2,8 @@ import yargs, { type Argv, type CommandModule, type Options } from 'yargs' import { loadSpectrumFromURL, loadSpectrumFromFilePath } from './parse/prase-spectra' import { generateSpectrumFromPublicationString } from './publication-string' -import { parsePredictionCommand } from './prediction/parsePredictionCommand' import { hideBin } from 'yargs/helpers' +import { parsePredictionCommand } from './prediction' const usageMessage = ` Usage: nmr-cli [options] @@ -24,19 +24,40 @@ Arguments for 'parse-publication-string' command: publicationString Publication string Options for 'predict' command: - -ps,--peakShape Peak shape algorithm (default: "lorentzian") choices: ["gaussian", "lorentzian"] - -n, --nucleus Predicted nucleus, choices: ["1H","13C"] (required) + +Common options: + -e, --engine Prediction engine (required) choices: ["nmrdb.org", "nmrshift"] + --spectra Spectra types to predict (required) choices: ["proton", "carbon", "cosy", "hsqc", "hmbc"] + -s, --structure MOL file content (structure) (required) + +nmrdb.org engine options: + --name Compound name (default: "") + --frequency NMR frequency (MHz) (default: 400) + --protonFrom Proton (1H) from in ppm (default: -1) + --protonTo Proton (1H) to in ppm (default: 12) + --carbonFrom Carbon (13C) from in ppm (default: -5) + --carbonTo Carbon (13C) to in ppm (default: 220) + --nbPoints1d 1D number of points (default: 131072) + --lineWidth 1D line width (default: 1) + --nbPoints2dX 2D spectrum X-axis points (default: 1024) + --nbPoints2dY 2D spectrum Y-axis points (default: 1024) + --autoExtendRange Auto extend range (default: true) + +nmrshift engine options: -i, --id Input ID (default: 1) - -t, --type NMR type (default: "nmr;1H;1d") - -s, --shifts Chemical shifts (default: "1") + --shifts Chemical shifts (default: "1") --solvent NMR solvent (default: "Dimethylsulphoxide-D6 (DMSO-D6, C2D6SO)") - -m, --molText MOL text (required) - --from From in (ppm) - --to To in (ppm) + choices: ["Any", "Chloroform-D1 (CDCl3)", "Dimethylsulphoxide-D6 (DMSO-D6, C2D6SO)", + "Methanol-D4 (CD3OD)", "Deuteriumoxide (D2O)", "Acetone-D6 ((CD3)2CO)", + "TETRACHLORO-METHANE (CCl4)", "Pyridin-D5 (C5D5N)", "Benzene-D6 (C6D6)", + "neat", "Tetrahydrofuran-D8 (THF-D8, C4D4O)"] + --from From in (ppm) for spectrum generation + --to To in (ppm) for spectrum generation --nbPoints Number of points (default: 1024) --lineWidth Line width (default: 1) --frequency NMR frequency (MHz) (default: 400) --tolerance Tolerance to group peaks with close shift (default: 0.001) + -ps,--peakShape Peak shape algorithm (default: "lorentzian") choices: ["gaussian", "lorentzian"] diff --git a/app/scripts/nmr-cli/src/prediction/engines/base.ts b/app/scripts/nmr-cli/src/prediction/engines/base.ts new file mode 100644 index 0000000..c60c1bb --- /dev/null +++ b/app/scripts/nmr-cli/src/prediction/engines/base.ts @@ -0,0 +1,64 @@ +import type { Options } from 'yargs' +import type { Spectrum } from '@zakodium/nmrium-core' + +/** + * Supported experiment types + */ +export type Experiment = 'proton' | 'carbon' | 'cosy' | 'hsqc' | 'hmbc' + +/** + * Nucleus types used in NMR + */ +export type Nucleus = '1H' | '13C' + +/** + * Map from experiment name to nucleus + */ +export const experimentToNucleus: Record = { + proton: '1H', + carbon: '13C', +} + +/** + * Base interface that all engines must implement + */ +export interface Engine { + /** Unique engine identifier (e.g., 'nmrdb.org') */ + readonly id: string + + readonly name: string + readonly description: string + readonly supportedSpectra: readonly Experiment[] + + /** Command-line options specific to this engine */ + readonly options: Record + + /** List of required option keys */ + readonly requiredOptions: readonly string[] + + /** + * Build the payload options for the API request + * @param argv - Command line arguments + * @returns Options object to send in the API payload + */ + buildPayloadOptions(argv: Record): any + + /** + * Predict and generate spectra + * This is the main entry point for prediction + * @param structure - MOL file content + * @param options - Command line options + * @returns Array of generated spectra + */ + predict( + structure: string, + options: Record, + ): Promise + + /** + * Optional: Custom validation beyond required options + * @param argv - Command line arguments + * @returns true if valid, error message if invalid + */ + validate?(argv: Record): true | string +} \ No newline at end of file diff --git a/app/scripts/nmr-cli/src/prediction/engines/index.ts b/app/scripts/nmr-cli/src/prediction/engines/index.ts new file mode 100644 index 0000000..c1bfe8c --- /dev/null +++ b/app/scripts/nmr-cli/src/prediction/engines/index.ts @@ -0,0 +1,5 @@ +import './nmrdb/nmrdb.engine' +import './nmrshift/nmrshift.engine' + +export { engineRegistry } from './registry'; +export type { Engine } from './base'; diff --git a/app/scripts/nmr-cli/src/prediction/engines/nmrdb/core/checkFromTo.ts b/app/scripts/nmr-cli/src/prediction/engines/nmrdb/core/checkFromTo.ts new file mode 100644 index 0000000..1720c0c --- /dev/null +++ b/app/scripts/nmr-cli/src/prediction/engines/nmrdb/core/checkFromTo.ts @@ -0,0 +1,91 @@ +import { xMinMaxValues } from "ml-spectra-processing" +import { Experiment } from "../../base" +import { isProton } from "../../../../utilities/isProton" +import { Prediction1D, Prediction2D } from "nmr-processing" +import { PredictedSpectraResult, PredictionOptions } from "../nmrdb.engine" + +export function checkFromTo( + predictedSpectra: PredictedSpectraResult, + inputOptions: PredictionOptions, +) { + const setFromTo = (inputOptions: any, nucleus: any, fromTo: any) => { + inputOptions['1d'][nucleus].to = fromTo.to + inputOptions['1d'][nucleus].from = fromTo.from + if (fromTo.signalsOutOfRange) { + signalsOutOfRange[nucleus] = true + } + } + + const { autoExtendRange, spectra } = inputOptions + const signalsOutOfRange: Record = {} + + for (const exp in predictedSpectra) { + const experiment = exp as Experiment + if (!spectra[experiment]) continue + if (predictedSpectra[experiment]?.signals.length === 0) continue + + if (['carbon', 'proton'].includes(experiment)) { + const spectrum = predictedSpectra[experiment] as Prediction1D + const { signals, nucleus } = spectrum + const { from, to } = (inputOptions['1d'] as any)[nucleus] + const fromTo = getNewFromTo({ + deltas: signals.map((s) => s.delta), + from, + to, + nucleus, + autoExtendRange, + }) + setFromTo(inputOptions, nucleus, fromTo) + } else { + const { signals, nuclei } = predictedSpectra[experiment] as Prediction2D + for (const nucleus of nuclei) { + const axis = isProton(nucleus) ? 'x' : 'y' + const { from, to } = (inputOptions['1d'] as any)[nucleus] + const fromTo = getNewFromTo({ + deltas: signals.map((s) => s[axis].delta), + from, + to, + nucleus, + autoExtendRange, + }) + setFromTo(inputOptions, nucleus, fromTo) + } + } + } + + for (const nucleus of ['1H', '13C']) { + if (signalsOutOfRange[nucleus]) { + const { from, to } = (inputOptions['1d'] as any)[nucleus] + if (autoExtendRange) { + console.log( + `There are ${nucleus} signals out of the range, it was extended to ${from}-${to}.`, + ) + } else { + console.log(`There are ${nucleus} signals out of the range.`) + } + } + } +} + + + +function getNewFromTo(params: { + deltas: number[] + from: number + to: number + nucleus: string + autoExtendRange: boolean +}) { + const { deltas, nucleus, autoExtendRange } = params + let { from, to } = params + const { min, max } = xMinMaxValues(deltas) + const signalsOutOfRange = from > min || to < max + + if (autoExtendRange && signalsOutOfRange) { + const spread = isProton(nucleus) ? 0.2 : 2 + if (from > min) from = min - spread + if (to < max) to = max + spread + } + + return { from, to, signalsOutOfRange } +} \ No newline at end of file diff --git a/app/scripts/nmr-cli/src/prediction/engines/nmrdb/core/generateName.ts b/app/scripts/nmr-cli/src/prediction/engines/nmrdb/core/generateName.ts new file mode 100644 index 0000000..6c4f623 --- /dev/null +++ b/app/scripts/nmr-cli/src/prediction/engines/nmrdb/core/generateName.ts @@ -0,0 +1,8 @@ +export function generateName( + name: string, + options: { frequency: number | number[]; experiment: string }, +) { + const { frequency, experiment } = options + const freq = Array.isArray(frequency) ? frequency[0] : frequency + return name || `${experiment.toUpperCase()}_${freq}MHz` +} \ No newline at end of file diff --git a/app/scripts/nmr-cli/src/prediction/engines/nmrdb/core/generated1DSpectrum.ts b/app/scripts/nmr-cli/src/prediction/engines/nmrdb/core/generated1DSpectrum.ts new file mode 100644 index 0000000..fe62616 --- /dev/null +++ b/app/scripts/nmr-cli/src/prediction/engines/nmrdb/core/generated1DSpectrum.ts @@ -0,0 +1,71 @@ +import { getRelativeFrequency, mapRanges, signalsToRanges, signalsToXY, updateIntegralsRelativeValues } from "nmr-processing" +import { generateName } from "./generateName" +import { initiateDatum1D } from "../../../../parse/data/data1D/initiateDatum1D" +import { PredictionOptions } from "../nmrdb.engine" + +export function generated1DSpectrum(params: { + options: PredictionOptions + spectrum: any + experiment: string + color: string +}) { + const { spectrum, options, experiment, color } = params + const { signals, joinedSignals, nucleus } = spectrum + + const { + name, + '1d': { nbPoints, lineWidth }, + frequency: freq, + } = options + + const SpectrumName = generateName(name, { frequency: freq, experiment }) + const frequency = getRelativeFrequency(nucleus, { + frequency: freq, + nucleus, + }) + + const { x, y } = signalsToXY(signals, { + ...(options['1d'] as any)[nucleus], + frequency, + nbPoints, + lineWidth, + }) + + const first = x[0] ?? 0 + const last = x.at(-1) ?? 0 + const getFreqOffset = (freq: any) => { + return (first + last) * freq * 0.5 + } + + const datum = initiateDatum1D( + { + data: { x, im: null, re: y }, + display: { color }, + info: { + nucleus, + originFrequency: frequency, + baseFrequency: frequency, + frequencyOffset: Array.isArray(frequency) + ? frequency.map(getFreqOffset) + : getFreqOffset(frequency), + pulseSequence: 'prediction', + spectralWidth: Math.abs(first - last), + solvent: '', + experiment, + isFt: true, + name: SpectrumName, + title: SpectrumName, + }, + }, + {}, + ) + + datum.ranges.values = mapRanges( + signalsToRanges(joinedSignals, { frequency }), + datum, + ) + updateIntegralsRelativeValues(datum) + + return datum +} + diff --git a/app/scripts/nmr-cli/src/prediction/engines/nmrdb/core/generated2DSpectrum.ts b/app/scripts/nmr-cli/src/prediction/engines/nmrdb/core/generated2DSpectrum.ts new file mode 100644 index 0000000..c308f1b --- /dev/null +++ b/app/scripts/nmr-cli/src/prediction/engines/nmrdb/core/generated2DSpectrum.ts @@ -0,0 +1,60 @@ +import { calculateRelativeFrequency, PredictionBase2D, signals2DToZ } from "nmr-processing" +import { generateName } from "./generateName" +import { getSpectralWidth } from "./getSpectralWidth" +import { initiateDatum2D } from "../../../../parse/data/data2d/initiateDatum2D" +import { adjustAlpha } from "../../../../utilities/adjustAlpha" +import { mapZones } from "./mapZones" +import { PredictionOptions } from "../nmrdb.engine" + +export function generated2DSpectrum(params: { + options: PredictionOptions + spectrum: PredictionBase2D + experiment: string + color: string +}) { + const { spectrum, options, experiment, color } = params + const { signals, zones, nuclei } = spectrum + const xOption = (options['1d'] as any)[nuclei[0]] + const yOption = (options['1d'] as any)[nuclei[1]] + + const width = nuclei[0] === nuclei[1] ? 0.02 : { x: 0.02, y: 0.2133 } + const frequency = calculateRelativeFrequency(nuclei, options.frequency) + + const minMaxContent = signals2DToZ(signals, { + from: { x: xOption.from, y: yOption.from }, + to: { x: xOption.to, y: yOption.to }, + nbPoints: { + x: options['2d'].nbPoints.x, + y: options['2d'].nbPoints.y, + }, + width, + factor: 3, + }) + + const SpectrumName = generateName(options.name, { + frequency, + experiment, + }) + + const spectralWidth = getSpectralWidth(experiment, options) + const datum = initiateDatum2D({ + data: { rr: { ...minMaxContent, noise: 0.01 } }, + display: { + positiveColor: color, + negativeColor: adjustAlpha(color, 40), + }, + info: { + name: SpectrumName, + title: SpectrumName, + nucleus: nuclei, + originFrequency: frequency, + baseFrequency: frequency, + pulseSequence: 'prediction', + spectralWidth, + experiment, + }, + }) + + datum.zones.values = mapZones(zones) + return datum +} diff --git a/app/scripts/nmr-cli/src/prediction/engines/nmrdb/core/getSpectralWidth.ts b/app/scripts/nmr-cli/src/prediction/engines/nmrdb/core/getSpectralWidth.ts new file mode 100644 index 0000000..305fbf5 --- /dev/null +++ b/app/scripts/nmr-cli/src/prediction/engines/nmrdb/core/getSpectralWidth.ts @@ -0,0 +1,23 @@ +import { PredictionOptions } from "../nmrdb.engine" + +export function getSpectralWidth(experiment: string, options: PredictionOptions) { + const formTo = options['1d'] + + switch (experiment) { + case 'cosy': { + const { from, to } = formTo['1H'] + const diff = to - from + return [diff, diff] + } + case 'hsqc': + case 'hmbc': { + const proton = formTo['1H'] + const carbon = formTo['13C'] + const protonDiff = proton.to - proton.from + const carbonDiff = carbon.to - carbon.from + return [protonDiff, carbonDiff] + } + default: + return [] + } +} \ No newline at end of file diff --git a/app/scripts/nmr-cli/src/prediction/engines/nmrdb/core/mapZones.ts b/app/scripts/nmr-cli/src/prediction/engines/nmrdb/core/mapZones.ts new file mode 100644 index 0000000..338d790 --- /dev/null +++ b/app/scripts/nmr-cli/src/prediction/engines/nmrdb/core/mapZones.ts @@ -0,0 +1,30 @@ +import { Peak2D, Signal2D, Zone } from "@zakodium/nmr-types" +import { NMRZone } from "nmr-processing" + +export function mapZones(zones: NMRZone[]): Zone[] { + return zones.map((zone): Zone => { + const { signals, ...resZone } = zone + const newSignals = signals.map((signal): Signal2D => { + const { x, y, id, peaks, kind, ...resSignal } = signal + return { + ...resSignal, + id: id || crypto.randomUUID(), + kind: kind || 'signal', + x: { ...x, originalDelta: x.delta || 0 }, + y: { ...y, originalDelta: y.delta || 0 }, + peaks: peaks?.map( + (peak): Peak2D => ({ + ...peak, + id: peak.id || crypto.randomUUID(), + }), + ), + } + }) + return { + ...resZone, + id: crypto.randomUUID(), + signals: newSignals, + kind: 'signal', + } + }) +} \ No newline at end of file diff --git a/app/scripts/nmr-cli/src/prediction/engines/nmrdb/nmrdb.engine.ts b/app/scripts/nmr-cli/src/prediction/engines/nmrdb/nmrdb.engine.ts new file mode 100644 index 0000000..ea7b90b --- /dev/null +++ b/app/scripts/nmr-cli/src/prediction/engines/nmrdb/nmrdb.engine.ts @@ -0,0 +1,227 @@ +import type { Options } from 'yargs' +import type { Spectrum } from '@zakodium/nmrium-core' +import type { + Predicted, + PredictionBase1D, + PredictionBase2D, + PredictionOptionsByExperiment, +} from 'nmr-processing' +import { predict } from 'nmr-processing' +import { Molecule } from 'openchemlib' + +import { defineEngine } from '../registry' +import type { Experiment } from '../base' +import { checkFromTo } from './core/checkFromTo' +import { generated1DSpectrum } from './core/generated1DSpectrum' +import { generated2DSpectrum } from './core/generated2DSpectrum' +export type PredictedSpectraResult = Partial< + Record +> + +export interface PredictionOptions { + name: string + frequency: number + '1d': { + '1H': { from: number; to: number } + '13C': { from: number; to: number } + nbPoints: number + lineWidth: number + } + '2d': { + nbPoints: { x: number; y: number } + } + autoExtendRange: boolean + spectra: Record +} + +// ============================================================================ +// Map experiment names to prediction keys +// ============================================================================ + +const experimentToPredictKey: Record = { + proton: 'H', + carbon: 'C', +} + +// ============================================================================ +// HELPER FUNCTIONS +// ============================================================================ + +export async function predictSpectra( + molfile: string, + spectra: Record, +): Promise { + const molecule = Molecule.fromMolfile(molfile) + + const predictOptions: Record = {} + for (const [experiment, enabled] of Object.entries(spectra)) { + if (!enabled) continue + const key = experimentToPredictKey[experiment] ?? experiment + predictOptions[key] = {} + } + + return predict(molecule, { predictOptions }) +} + +export function generateSpectra( + predictedSpectra: PredictedSpectraResult, + options: PredictionOptions, + color: string, +): Spectrum[] { + const clonedOptions = structuredClone(options) + checkFromTo(predictedSpectra, clonedOptions) + + const spectra: Spectrum[] = [] + + for (const [experiment, spectrum] of Object.entries(predictedSpectra)) { + if (!clonedOptions.spectra[experiment as Experiment]) continue + + switch (experiment) { + case 'proton': + case 'carbon': { + spectra.push( + generated1DSpectrum({ spectrum, options: clonedOptions, experiment, color }), + ) + break + } + case 'cosy': + case 'hsqc': + case 'hmbc': { + spectra.push( + generated2DSpectrum({ + spectrum: spectrum as PredictionBase2D, + options: clonedOptions, + experiment, + color, + }), + ) + break + } + default: + break + } + } + + return spectra +} + +// ============================================================================ +// ENGINE DEFINITION +// ============================================================================ + +export const nmrdbEngine = defineEngine({ + id: 'nmrdb.org', + name: 'NMRDB.org', + description: 'NMRDB.org prediction engine with 1D and 2D NMR support', + supportedSpectra: ['proton', 'carbon', 'cosy', 'hmbc', 'hsqc'], + + options: { + name: { + type: 'string', + description: 'Compound name', + default: '', + }, + frequency: { + type: 'number', + description: 'NMR frequency (MHz)', + default: 400, + }, + protonFrom: { + type: 'number', + description: 'Proton (1H) from in ppm', + default: -1, + }, + protonTo: { + type: 'number', + description: 'Proton (1H) to in ppm', + default: 12, + }, + carbonFrom: { + type: 'number', + description: 'Carbon (13C) from in ppm', + default: -5, + }, + carbonTo: { + type: 'number', + description: 'Carbon (13C) to in ppm', + default: 220, + }, + nbPoints1d: { + type: 'number', + description: '1D number of points', + default: 2 ** 17, + }, + lineWidth: { + type: 'number', + description: '1D line width', + default: 1, + }, + nbPoints2dX: { + type: 'number', + description: '2D spectrum X-axis points', + default: 1024, + }, + nbPoints2dY: { + type: 'number', + description: '2D spectrum Y-axis points', + default: 1024, + }, + autoExtendRange: { + type: 'boolean', + description: 'Auto extend range', + default: true, + }, + } as Record, + + requiredOptions: [], + + buildPayloadOptions(argv: Record): PredictionOptions { + const spectraObj: Record = { + carbon: false, + proton: false, + cosy: false, + hmbc: false, + hsqc: false, + } + + for (const experiment of argv.spectra as string[]) { + spectraObj[experiment as Experiment] = true + } + + return { + name: (argv.name as string) || '', + frequency: (argv.frequency as number) || 400, + '1d': { + '1H': { + from: (argv.protonFrom as number) ?? -1, + to: (argv.protonTo as number) ?? 12, + }, + '13C': { + from: (argv.carbonFrom as number) ?? -5, + to: (argv.carbonTo as number) ?? 220, + }, + nbPoints: (argv.nbPoints1d as number) || 2 ** 17, + lineWidth: (argv.lineWidth as number) || 1, + }, + '2d': { + nbPoints: { + x: (argv.nbPoints2dX as number) || 1024, + y: (argv.nbPoints2dY as number) || 1024, + }, + }, + spectra: spectraObj, + autoExtendRange: argv.autoExtendRange !== false, + } + }, + + async predict(structure, options) { + const predictionOptions = this.buildPayloadOptions(options) + + const { spectra } = await predictSpectra( + structure, + predictionOptions.spectra, + ) + + return generateSpectra(spectra, predictionOptions, 'red') + }, +}) \ No newline at end of file diff --git a/app/scripts/nmr-cli/src/prediction/engines/nmrshift/core/extractInfoFromSpectra.ts b/app/scripts/nmr-cli/src/prediction/engines/nmrshift/core/extractInfoFromSpectra.ts new file mode 100644 index 0000000..56a666d --- /dev/null +++ b/app/scripts/nmr-cli/src/prediction/engines/nmrshift/core/extractInfoFromSpectra.ts @@ -0,0 +1,15 @@ +import { Experiment } from "../../base"; +import { spectraTypeMap, SpectraTypeMapItem } from "./spectraTypeMap"; + + + +export function extractInfoFromSpectra(spectra: Experiment[]) { + const info: SpectraTypeMapItem[] = []; + for (const experiment of spectra) { + const data = spectraTypeMap[experiment]; + if (!data) continue; + + info.push(data) + } + return info; +} \ No newline at end of file diff --git a/app/scripts/nmr-cli/src/prediction/generatePredictedSpectrumData.ts b/app/scripts/nmr-cli/src/prediction/engines/nmrshift/core/generatePredictedSpectrumData.ts similarity index 100% rename from app/scripts/nmr-cli/src/prediction/generatePredictedSpectrumData.ts rename to app/scripts/nmr-cli/src/prediction/engines/nmrshift/core/generatePredictedSpectrumData.ts diff --git a/app/scripts/nmr-cli/src/prediction/engines/nmrshift/core/getNucleusFromSpectra.ts b/app/scripts/nmr-cli/src/prediction/engines/nmrshift/core/getNucleusFromSpectra.ts new file mode 100644 index 0000000..c23bffb --- /dev/null +++ b/app/scripts/nmr-cli/src/prediction/engines/nmrshift/core/getNucleusFromSpectra.ts @@ -0,0 +1,13 @@ +import { Experiment } from "../../base" +import { spectraTypeMap } from "./spectraTypeMap" + +export function getNucleusFromSpectra(spectra: Experiment[]): string { + const nuclei = new Set() + for (const spectrum of spectra) { + const entry = spectraTypeMap[spectrum] + if (entry) { + nuclei.add(entry.nucleus) + } + } + return nuclei.size > 0 ? [...nuclei].join(',') : '1H' +} \ No newline at end of file diff --git a/app/scripts/nmr-cli/src/prediction/engines/nmrshift/core/spectraTypeMap.ts b/app/scripts/nmr-cli/src/prediction/engines/nmrshift/core/spectraTypeMap.ts new file mode 100644 index 0000000..729345f --- /dev/null +++ b/app/scripts/nmr-cli/src/prediction/engines/nmrshift/core/spectraTypeMap.ts @@ -0,0 +1,12 @@ +import { Experiment } from "../../base"; + +export interface SpectraTypeMapItem { + type: string; nucleus: string +} + +export const spectraTypeMap: Partial> = { + proton: { type: 'nmr;1H;1d', nucleus: '1H' }, + carbon: + + { type: 'nmr;13C;1d', nucleus: '13C' }, +} diff --git a/app/scripts/nmr-cli/src/prediction/engines/nmrshift/nmrshift.engine.ts b/app/scripts/nmr-cli/src/prediction/engines/nmrshift/nmrshift.engine.ts new file mode 100644 index 0000000..46788eb --- /dev/null +++ b/app/scripts/nmr-cli/src/prediction/engines/nmrshift/nmrshift.engine.ts @@ -0,0 +1,264 @@ +import type { Options } from 'yargs' +import type { Spectrum } from '@zakodium/nmrium-core' +import https from 'https' +import axios from 'axios' + +import { defineEngine } from '../registry' +import type { Experiment } from '../base' +import type { GenerateSpectrumOptions, ShiftsItem } from './core/generatePredictedSpectrumData' +import { generatePredictedSpectrumData } from './core/generatePredictedSpectrumData' +import { extractInfoFromSpectra } from './core/extractInfoFromSpectra' +import type { SpectraTypeMapItem } from './core/spectraTypeMap' + +interface NMRShiftPayload { + id: number + type: string + shifts: string + solvent: string +} + +interface PredictionResponseItem { + id: number + type: string + statistics: { + accept: number + warning: number + reject: number + missing: number + total: number + } + shifts: ShiftsItem[] +} + +interface PredictionResponse { + result: PredictionResponseItem[] +} + +interface NMRShiftOptions { + id: number + shifts: string + solvent: string + spectra: Experiment[] +} + +type PredictionArgs = NMRShiftOptions & GenerateSpectrumOptions + +// ============================================================================ +// HELPERS +// ============================================================================ + +function getBaseUrl(): string { + const url = process.env['NMR_PREDICTION_URL'] + if (!url) { + throw new Error('Environment variable NMR_PREDICTION_URL is not defined.') + } + try { + new URL(url) + } catch { + throw new Error(`Invalid URL in NMR_PREDICTION_URL: "${url}"`) + } + return url +} + +async function callPredict( + structure: string, + options: { + id: number + shifts: string + solvent: string + experiments: SpectraTypeMapItem[] + }, +): Promise[]> { + const url = getBaseUrl() + const { id, shifts, solvent, experiments } = options + + const httpsAgent = new https.Agent({ rejectUnauthorized: false }) + + const requests = experiments.map((experimentInfo) => { + const payload: NMRShiftPayload = { + id, + type: experimentInfo.type, + shifts, + solvent, + } + + return axios.post(url, { + inputs: [payload], + moltxt: structure, + }, { + headers: { 'Content-Type': 'application/json' }, + httpsAgent, + }) + }) + + return Promise.all(requests) +} + +async function predictNMR( + structure: string, + options: PredictionArgs, +): Promise { + const { + id = 1, + shifts = '1', + solvent = 'Dimethylsulphoxide-D6 (DMSO-D6, C2D6SO)', + from, + to, + nbPoints = 1024, + frequency = 400, + lineWidth = 1, + tolerance = 0.001, + peakShape = 'lorentzian', + spectra, + } = options + + // Derive experiments (type + nucleus) from the --spectra list + const experiments = extractInfoFromSpectra(spectra) + + if (experiments.length === 0) { + throw new Error( + `No supported experiments found for spectra [${spectra.join(', ')}]. ` + + `Supported: proton, carbon.`, + ) + } + + const results = await callPredict(structure, { id, shifts, solvent, experiments }) + + const outputSpectra: Spectrum[] = [] + + for (let i = 0; i < results.length; i++) { + const response: PredictionResponse = results[i].data + const experimentInfo = experiments[i] + + for (const item of response.result) { + const data = generatePredictedSpectrumData(item.shifts, { + from, + to, + nbPoints, + lineWidth, + frequency, + tolerance, + peakShape, + }) + + if (!data) continue + + const name = crypto.randomUUID() + + outputSpectra.push({ + id: crypto.randomUUID(), + data, + info: { + isFid: false, + isComplex: false, + dimension: 1, + originFrequency: frequency, + baseFrequency: frequency, + pulseSequence: '', + solvent, + isFt: true, + name, + nucleus: experimentInfo.nucleus, + }, + } as unknown as Spectrum) + } + } + + return outputSpectra +} + +// ============================================================================ +// ENGINE DEFINITION +// ============================================================================ + +const SOLVENT_CHOICES = [ + 'Any', + 'Chloroform-D1 (CDCl3)', + 'Dimethylsulphoxide-D6 (DMSO-D6, C2D6SO)', + 'Methanol-D4 (CD3OD)', + 'Deuteriumoxide (D2O)', + 'Acetone-D6 ((CD3)2CO)', + 'TETRACHLORO-METHANE (CCl4)', + 'Pyridin-D5 (C5D5N)', + 'Benzene-D6 (C6D6)', + 'neat', + 'Tetrahydrofuran-D8 (THF-D8, C4D4O)', +] as const + +export const nmrshiftEngine = defineEngine({ + id: 'nmrshift', + name: 'NMRShift', + description: 'NMRShift prediction engine', + + supportedSpectra: ['proton', 'carbon'], + + options: { + id: { + alias: 'i', + type: 'number', + description: 'Input ID', + default: 1, + }, + shifts: { + type: 'string', + description: 'Chemical shifts', + default: '1', + }, + solvent: { + type: 'string', + description: 'NMR solvent', + default: 'Dimethylsulphoxide-D6 (DMSO-D6, C2D6SO)', + choices: SOLVENT_CHOICES, + }, + from: { + type: 'number', + description: 'From in (ppm) for spectrum generation', + }, + to: { + type: 'number', + description: 'To in (ppm) for spectrum generation', + }, + nbPoints: { + type: 'number', + description: 'Number of points for spectrum generation', + default: 1024, + }, + lineWidth: { + type: 'number', + description: 'Line width for spectrum generation', + default: 1, + }, + frequency: { + type: 'number', + description: 'NMR frequency (MHz) for spectrum generation', + default: 400, + }, + tolerance: { + type: 'number', + description: 'Tolerance to group peaks with close shift', + default: 0.001, + }, + peakShape: { + alias: 'ps', + type: 'string', + description: 'Peak shape algorithm', + default: 'lorentzian', + choices: ['gaussian', 'lorentzian'], + }, + } as Record, + + requiredOptions: ['solvent'], + + buildPayloadOptions(argv: Record): NMRShiftOptions { + return { + id: (argv.id as number) ?? 1, + shifts: (argv.shifts as string) ?? '1', + solvent: (argv.solvent as string) ?? 'Dimethylsulphoxide-D6 (DMSO-D6, C2D6SO)', + spectra: argv.spectra as Experiment[], + } + }, + + async predict(structure, options) { + return predictNMR(structure, options as unknown as PredictionArgs) + }, +}) \ No newline at end of file diff --git a/app/scripts/nmr-cli/src/prediction/engines/registry.ts b/app/scripts/nmr-cli/src/prediction/engines/registry.ts new file mode 100644 index 0000000..c6ea442 --- /dev/null +++ b/app/scripts/nmr-cli/src/prediction/engines/registry.ts @@ -0,0 +1,60 @@ +import type { Engine } from './base' + +/** + * Auto-discovered engine registry + * Engines are automatically registered when imported + */ +class EngineRegistry { + private engines = new Map() + + /** + * Register an engine + * Called automatically when engine files are imported + */ + register(engine: Engine): void { + if (this.engines.has(engine.id)) { + console.warn(`Engine ${engine.id} is already registered, overwriting...`) + } + this.engines.set(engine.id, engine) + } + + /** + * Get an engine by ID + */ + get(id: string): Engine | undefined { + return this.engines.get(id) + } + + /** + * Get all registered engines + */ + getAll(): Engine[] { + return Array.from(this.engines.values()) + } + + /** + * Get all engine IDs + */ + getIds(): string[] { + return Array.from(this.engines.keys()) + } + + /** + * Check if an engine exists + */ + has(id: string): boolean { + return this.engines.has(id) + } +} + +// Singleton instance +export const engineRegistry = new EngineRegistry() + +/** + * Helper function to create and auto-register an engine + * Just call this at the bottom of your engine file! + */ +export function defineEngine(engine: Engine): Engine { + engineRegistry.register(engine) + return engine +} \ No newline at end of file diff --git a/app/scripts/nmr-cli/src/prediction/index.ts b/app/scripts/nmr-cli/src/prediction/index.ts new file mode 100644 index 0000000..4041a77 --- /dev/null +++ b/app/scripts/nmr-cli/src/prediction/index.ts @@ -0,0 +1,251 @@ +import { Argv, CommandModule } from 'yargs' +import { readFileSync, existsSync, writeFileSync } from 'fs' +import { CURRENT_EXPORT_VERSION } from '@zakodium/nmrium-core' +import { engineRegistry } from './engines' +import type { Experiment } from './engines/base' + +// ============================================================================ +// STRUCTURE INPUT HANDLING +// ============================================================================ + +/** + * Resolves structure input from multiple sources: + * 1. File path (if file exists) + * 2. Stdin (if --stdin flag is used) + * 3. Inline MOL content (as fallback) + */ +function resolveStructureInput(options: { + structure?: string + stdin?: boolean + file?: string +}): string { + // Priority 1: Explicit file flag + if (options.file) { + if (!existsSync(options.file)) { + throw new Error(`File not found: ${options.file}`) + } + return readFileSync(options.file, 'utf-8') + } + + // Priority 2: Explicit stdin flag + if (options.stdin) { + return readStdinSync() + } + + // Priority 3: Structure argument (-s flag) - ALWAYS treat as inline content + if (options.structure) { + // Do NOT check existsSync here - just return it as inline MOL content + return options.structure.trimEnd().replaceAll(/\\n/g, '\n') + } + + throw new Error('No structure input provided. Use --file, --stdin, or -s') +} + +/** + * Synchronously read from stdin + * This works because yargs has already parsed args, so stdin is available + */ +function readStdinSync(): string { + try { + // File descriptor 0 is stdin + return readFileSync(0, 'utf-8') + } catch (error) { + throw new Error('Failed to read from stdin. Is data being piped?') + } +} + +// ============================================================================ +// COMMON OPTIONS +// ============================================================================ + +const commonOptions = { + engine: { + alias: 'e', + type: 'string', + description: 'Prediction engine', + demandOption: true, + choices: engineRegistry.getIds(), + }, + spectra: { + type: 'array', + description: 'Spectra types to predict', + demandOption: true, + choices: ['proton', 'carbon', 'cosy', 'hsqc', 'hmbc'], + }, + // Option 1: File path (most explicit) + file: { + alias: 'f', + type: 'string', + description: 'Path to MOL file', + conflicts: ['stdin', 'structure'], + }, + // Option 2: Stdin flag + stdin: { + type: 'boolean', + description: 'Read structure from stdin', + conflicts: ['file', 'structure'], + }, + // Option 3: Structure argument (inline MOL content only) + structure: { + alias: 's', + type: 'string', + description: 'Inline MOL content (use --file for file paths)', + conflicts: ['file', 'stdin'], + }, + output: { + alias: 'o', + type: 'string', + description: 'Output file path (default: stdout)', + } +} as const + +// ============================================================================ +// VALIDATION +// ============================================================================ + +function validateEngineOptions(argv: Record): void { + const engineId = argv.engine as string + const spectra = argv.spectra as string[] + const engine = engineRegistry.get(engineId) + + if (!engine) { + const available = engineRegistry.getIds().join(', ') + throw new Error(`Unknown engine "${engineId}". Available engines: ${available}`) + } + + const unsupportedSpectra = spectra.filter( + (s) => !engine.supportedSpectra.includes(s as Experiment), + ) + + if (unsupportedSpectra.length > 0) { + throw new Error( + `Engine "${engineId}" does not support: ${unsupportedSpectra.join(', ')}.\n` + + `Supported spectra: ${engine.supportedSpectra.join(', ')}`, + ) + } + + const missing = engine.requiredOptions.filter((opt) => !argv[opt]) + if (missing.length > 0) { + throw new Error( + `Engine "${engineId}" requires: ${missing.join(', ')}\n` + + `Usage: --${missing.join(' --')}`, + ) + } + + if (engine.validate) { + const result = engine.validate(argv) + if (result !== true) { + throw new Error(result) + } + } +} + +// ============================================================================ +// MAIN PREDICTION FUNCTION +// ============================================================================ + +async function predictNMR(options: Record): Promise { + const engineId = options.engine as string + const engine = engineRegistry.get(engineId)! + + // Resolve structure from input + const structure = resolveStructureInput({ + structure: options.structure as string | undefined, + stdin: options.stdin as boolean | undefined, + file: options.file as string | undefined, + }) + + // DEBUG LOGGING + console.error('[DEBUG] Received structure:', structure ? `${structure.length} chars` : 'undefined') + console.error('[DEBUG] Structure type:', typeof structure) + console.error('[DEBUG] Structure preview:', structure?.substring(0, 100)) + + // Validate structure is not empty + if (!structure || !structure.trim()) { + throw new Error('Structure input is empty or undefined') + } + + // Run prediction + const spectraResults = await engine.predict(structure, options) + + // Build NMRium output + const nmrium = { + data: { spectra: spectraResults }, + version: CURRENT_EXPORT_VERSION, + } + const output = JSON.stringify(nmrium) + + // Handle output destination + if (options.output) { + const outputPath = options.output as string + writeFileSync(outputPath, output, 'utf-8') + console.error(`Results written to ${outputPath}`) + } + + return output +} + +// ============================================================================ +// COMMAND MODULE +// ============================================================================ + +export const parsePredictionCommand: CommandModule<{}, Record> = { + command: ['predict', 'p'], + describe: 'Predict NMR spectrum from mol text', + builder: (yargs: Argv): Argv => { + let y = yargs.options(commonOptions) + + for (const engine of engineRegistry.getAll()) { + y = y.options(engine.options) as Argv + } + + return y + .check((argv) => { + // Ensure at least one input method is provided + if (!argv.file && !argv.stdin && !argv.structure) { + throw new Error( + 'Must provide structure input via --file, --stdin, or -s' + ) + } + return true + }) + .example( + '$0 predict -e myengine --spectra proton -f molecule.mol', + 'Predict from file path' + ) + .example( + '$0 predict -e myengine --spectra proton --stdin < molecule.mol', + 'Predict from stdin (redirect)' + ) + .example( + 'cat molecule.mol | $0 predict -e myengine --spectra proton --stdin', + 'Predict from stdin (pipe)' + ) + .example( + '$0 predict -e myengine --spectra proton -s "\\n MOL content..."', + 'Predict using -s with inline MOL content' + ) + .example( + '$0 predict -e myengine --spectra proton -f mol.mol -o results.json', + 'Save output to file' + ) + }, + handler: async (argv) => { + // DEBUG: See ALL arguments + console.error('[DEBUG] Full argv:', JSON.stringify(argv, null, 2)) + console.error('[DEBUG] argv.structure exists?', argv.structure !== undefined) + console.error('[DEBUG] argv.engine:', argv.engine) + console.error('[DEBUG] argv.spectra:', argv.spectra) + + try { + validateEngineOptions(argv) + const output = await predictNMR(argv) + console.log(output) + } catch (error) { + console.error('Error:', error instanceof Error ? error.message : String(error)) + console.error('Error stack:', error instanceof Error ? error.stack : '') + process.exit(1) + } + + }, +} \ No newline at end of file diff --git a/app/scripts/nmr-cli/src/prediction/parsePredictionCommand.ts b/app/scripts/nmr-cli/src/prediction/parsePredictionCommand.ts deleted file mode 100644 index 4387a18..0000000 --- a/app/scripts/nmr-cli/src/prediction/parsePredictionCommand.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { Argv, CommandModule, Options } from 'yargs' -import { - generatePredictedSpectrumData, - GenerateSpectrumOptions, - ShiftsItem, -} from './generatePredictedSpectrumData' -import { CURRENT_EXPORT_VERSION } from '@zakodium/nmrium-core' - -import https from 'https' -import axios from 'axios' - -interface PredictionParameters { - molText: string - id: number - type: string - shifts: string - solvent: string - nucleus: string -} - -const predictionOptions: { [key in keyof GenerateSpectrumOptions]: Options } = { - from: { - type: 'number', - description: 'From in (ppm)', - }, - to: { - type: 'number', - description: 'To in (ppm)', - }, - nbPoints: { - type: 'number', - description: 'Number of points', - default: 2 ** 18, // 256k points - }, - lineWidth: { - type: 'number', - description: 'Line width', - default: 1, - }, - frequency: { - type: 'number', - description: 'NMR frequency (MHz)', - default: 400, - }, - tolerance: { - type: 'number', - description: 'Tolerance', - default: 0.001, - }, - peakShape: { - alias: 'ps', - type: 'string', - description: 'Peak shape algorithm', - default: 'lorentzian', - choices: ['gaussian', 'lorentzian'], - }, -} as const - -const nmrOptions: { [key in keyof PredictionParameters]: Options } = { - id: { - alias: 'i', - type: 'number', - description: 'Input ID', - default: 1, - }, - type: { - alias: 't', - type: 'string', - description: 'NMR type', - default: 'nmr;1H;1d', - choices: ['nmr;1H;1d', 'nmr;13C;1d'], - }, - shifts: { - alias: 's', - type: 'string', - description: 'Chemical shifts', - default: '1', - }, - solvent: { - type: 'string', - description: 'NMR solvent', - default: 'Dimethylsulphoxide-D6 (DMSO-D6, C2D6SO)', - choices: [ - 'Any', - 'Chloroform-D1 (CDCl3)', - 'Dimethylsulphoxide-D6 (DMSO-D6, C2D6SO)', - 'Methanol-D4 (CD3OD)', - 'Deuteriumoxide (D2O)', - 'Acetone-D6 ((CD3)2CO)', - 'TETRACHLORO-METHANE (CCl4)', - 'Pyridin-D5 (C5D5N)', - 'Benzene-D6 (C6D6)', - 'neat', - 'Tetrahydrofuran-D8 (THF-D8, C4D4O)', - ], - }, - molText: { - alias: 'm', - type: 'string', - description: 'MOL file content', - requiresArg: true, - }, - nucleus: { - alias: 'n', - type: 'string', - description: 'Predicted nucleus', - requiresArg: true, - choices: ['1H', '13C'], - }, -} as const - -interface PredictionResponseItem { - id: number - type: string - statistics: { - accept: number - warning: number - reject: number - missing: number - total: number - } - shifts: ShiftsItem[] -} -interface PredictionResponse { - result: PredictionResponseItem[] -} - -async function predictNMR(options: PredictionArgs): Promise { - const url = process.env['NMR_PREDICTION_URL'] - - if (!url) { - throw new Error('Environment variable NMR_PREDICTION_URL is not defined.') - } - - try { - new URL(url).toString() - } catch { - throw new Error(`Invalid URL in NMR_PREDICTION_URL: "${url}"`) - } - - try { - const { - id, - type, - shifts, - solvent, - from, - to, - nbPoints = 2 ** 18, // 256K - frequency = 400, - lineWidth = 1, - tolerance = 0.001, - molText, - nucleus, - peakShape = 'lorentzian', - } = options - - const payload: any = { - inputs: [ - { - id, - type, - shifts, - solvent, - }, - ], - moltxt: molText.replaceAll(/\\n/g, '\n'), - } - - const httpsAgent = new https.Agent({ - rejectUnauthorized: false, - }) - - // Axios POST request with httpsAgent - const response = await axios.post(url, payload, { - headers: { - 'Content-Type': 'application/json', - }, - httpsAgent, - }) - - const responseResult: PredictionResponse = response.data - const spectra = [] - - for (const result of responseResult.result) { - const name = crypto.randomUUID() - const data = generatePredictedSpectrumData(result.shifts, { - from, - to, - nbPoints, - lineWidth, - frequency, - tolerance, - peakShape, - }) - - const info = { - isFid: false, - isComplex: false, - dimension: 1, - originFrequency: frequency, - baseFrequency: frequency, - pulseSequence: '', - solvent, - isFt: true, - name, - nucleus, - } - - spectra.push({ - id: crypto.randomUUID(), - data, - info, - }) - } - - const nmrium = { data: { spectra }, version: CURRENT_EXPORT_VERSION } - console.log(JSON.stringify(nmrium, null, 2)) - } catch (error) { - console.error( - 'Error:', - error instanceof Error ? error.message : String(error) - ) - - if (axios.isAxiosError(error) && error.response) { - console.error('Response data:', error.response.data) - } else if (error instanceof Error && error.cause) { - console.error('Network Error:', error.cause) - } - } -} - -type PredictionArgs = PredictionParameters & GenerateSpectrumOptions - -// Define the prediction string command -export const parsePredictionCommand: CommandModule<{}, PredictionArgs> = { - command: ['predict', 'p'], - describe: 'Predict NMR spectrum from mol text', - builder: (yargs: Argv<{}>): Argv => { - return yargs.options({ - ...nmrOptions, - ...predictionOptions, - }) as Argv - }, - handler: async argv => { - await predictNMR(argv) - }, -} diff --git a/app/scripts/nmr-cli/src/utilities/adjustAlpha.ts b/app/scripts/nmr-cli/src/utilities/adjustAlpha.ts new file mode 100644 index 0000000..9223381 --- /dev/null +++ b/app/scripts/nmr-cli/src/utilities/adjustAlpha.ts @@ -0,0 +1,10 @@ +function percentToHex(p: number): string { + const percent = Math.max(0, Math.min(100, p)); + const intValue = Math.round((percent / 100) * 255); + const hexValue = intValue.toString(16); + return percent === 100 ? '' : hexValue.padStart(2, '0'); +} + +export function adjustAlpha(color: string, factor: number): string { + return color + percentToHex(factor); +} \ No newline at end of file diff --git a/app/scripts/nmr-cli/src/utilities/isProton.ts b/app/scripts/nmr-cli/src/utilities/isProton.ts new file mode 100644 index 0000000..ad8c5e9 --- /dev/null +++ b/app/scripts/nmr-cli/src/utilities/isProton.ts @@ -0,0 +1,3 @@ +export function isProton(nucleus: string) { + return nucleus === '1H'; +} diff --git a/docker-compose.yml b/docker-compose.yml index baabbd4..4f62bc9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,12 +28,14 @@ services: env_file: - ./.env nmr-load-save: - #build: ./app/scripts/nmr-cli + # build: ./app/scripts/nmr-cli image: nfdi4chem/nmr-cli:dev-latest entrypoint: /bin/sh stdin_open: true tty: true container_name: nmr-converter + volumes: + - shared-data:/shared nmr-respredict: #build: ./app/scripts/nmr-respredict image: nfdi4chem/nmr-respredict:dev-latest diff --git a/package-lock.json b/package-lock.json index 9d5820d..e581cdc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -120,6 +120,7 @@ "version": "4.19.1", "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-4.19.1.tgz", "integrity": "sha512-mBecfMFS4N+yK/p0ZbK53vrZbL6OtWMk8YmnOv1i0LXx4pelY8TFhqKoTit3NPVPwoSNN0vdSN9dTu1xr1XOVw==", + "peer": true, "dependencies": { "@algolia/client-common": "4.19.1", "@algolia/requester-common": "4.19.1", @@ -991,6 +992,7 @@ "version": "4.19.1", "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-4.19.1.tgz", "integrity": "sha512-IJF5b93b2MgAzcE/tuzW0yOPnuUyRgGAtaPv5UUywXM8kzqfdwZTO4sPJBzoGz1eOy6H9uEchsJsBFTELZSu+g==", + "peer": true, "dependencies": { "@algolia/cache-browser-local-storage": "4.19.1", "@algolia/cache-common": "4.19.1", @@ -1123,6 +1125,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001517", "electron-to-chromium": "^1.4.477", @@ -1342,6 +1345,7 @@ "version": "7.5.2", "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.5.2.tgz", "integrity": "sha512-p6vGNNWLDGwJCiEjkSK6oERj/hEyI9ITsSwIUICBoKLlWiTWXJRfQibCwcoi50rTZdbi87qDtUlMCmQwsGSgPw==", + "peer": true, "dependencies": { "tabbable": "^6.2.0" } @@ -1713,6 +1717,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", @@ -2009,6 +2014,7 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.3.tgz", "integrity": "sha512-A0KgSkef7eE4Mf+nKJ83i75TMyq8HqY3qmFIJSWy8bNt0v1lG7jUcpGpoTFxAwYcWOphcTBLPPJg+bDfhDf52w==", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -2126,6 +2132,7 @@ "version": "4.4.8", "resolved": "https://registry.npmjs.org/vite/-/vite-4.4.8.tgz", "integrity": "sha512-LONawOUUjxQridNWGQlNizfKH89qPigK36XhMI7COMGztz8KNY0JHim7/xDd71CZwGT4HtSRgI7Hy+RlhG0Gvg==", + "peer": true, "dependencies": { "esbuild": "^0.18.10", "postcss": "^8.4.26", @@ -2180,6 +2187,7 @@ "version": "1.0.0-beta.7", "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.0.0-beta.7.tgz", "integrity": "sha512-P9Rw+FXatKIU4fVdtKxqwHl6fby8E/8zE3FIfep6meNgN4BxbWqoKJ6yfuuQQR9IrpQqwnyaBh4LSabyll6tWg==", + "peer": true, "dependencies": { "@docsearch/css": "^3.5.1", "@docsearch/js": "^3.5.1", @@ -2213,6 +2221,7 @@ "version": "3.3.4", "resolved": "https://registry.npmjs.org/vue/-/vue-3.3.4.tgz", "integrity": "sha512-VTyEYn3yvIeY1Py0WaYGZsXnz3y5UnGi62GjVEqvEGPl6nxbOrCXbVOTQWBEJUqAyTUk2uJ5JLVnYJ6ZzGbrSw==", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.3.4", "@vue/compiler-sfc": "3.3.4", @@ -2332,6 +2341,7 @@ "version": "4.19.1", "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-4.19.1.tgz", "integrity": "sha512-mBecfMFS4N+yK/p0ZbK53vrZbL6OtWMk8YmnOv1i0LXx4pelY8TFhqKoTit3NPVPwoSNN0vdSN9dTu1xr1XOVw==", + "peer": true, "requires": { "@algolia/client-common": "4.19.1", "@algolia/requester-common": "4.19.1", @@ -2824,6 +2834,7 @@ "version": "4.19.1", "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-4.19.1.tgz", "integrity": "sha512-IJF5b93b2MgAzcE/tuzW0yOPnuUyRgGAtaPv5UUywXM8kzqfdwZTO4sPJBzoGz1eOy6H9uEchsJsBFTELZSu+g==", + "peer": true, "requires": { "@algolia/cache-browser-local-storage": "4.19.1", "@algolia/cache-common": "4.19.1", @@ -2914,6 +2925,7 @@ "version": "4.21.10", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.10.tgz", "integrity": "sha512-bipEBdZfVH5/pwrvqc+Ub0kUPVfGUhlKxbvfD+z1BDnPEO/X98ruXGA1WP5ASpAFKan7Qr6j736IacbZQuAlKQ==", + "peer": true, "requires": { "caniuse-lite": "^1.0.30001517", "electron-to-chromium": "^1.4.477", @@ -3072,6 +3084,7 @@ "version": "7.5.2", "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.5.2.tgz", "integrity": "sha512-p6vGNNWLDGwJCiEjkSK6oERj/hEyI9ITsSwIUICBoKLlWiTWXJRfQibCwcoi50rTZdbi87qDtUlMCmQwsGSgPw==", + "peer": true, "requires": { "tabbable": "^6.2.0" } @@ -3331,6 +3344,7 @@ "version": "8.4.27", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.27.tgz", "integrity": "sha512-gY/ACJtJPSmUFPDCHtX78+01fHa64FaU4zaaWfuh1MhGJISufJAH4cun6k/8fwsHYeK4UQmENQK+tRLCFJE8JQ==", + "peer": true, "requires": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", @@ -3504,6 +3518,7 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.3.tgz", "integrity": "sha512-A0KgSkef7eE4Mf+nKJ83i75TMyq8HqY3qmFIJSWy8bNt0v1lG7jUcpGpoTFxAwYcWOphcTBLPPJg+bDfhDf52w==", + "peer": true, "requires": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -3587,6 +3602,7 @@ "version": "4.4.8", "resolved": "https://registry.npmjs.org/vite/-/vite-4.4.8.tgz", "integrity": "sha512-LONawOUUjxQridNWGQlNizfKH89qPigK36XhMI7COMGztz8KNY0JHim7/xDd71CZwGT4HtSRgI7Hy+RlhG0Gvg==", + "peer": true, "requires": { "esbuild": "^0.18.10", "fsevents": "~2.3.2", @@ -3598,6 +3614,7 @@ "version": "1.0.0-beta.7", "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.0.0-beta.7.tgz", "integrity": "sha512-P9Rw+FXatKIU4fVdtKxqwHl6fby8E/8zE3FIfep6meNgN4BxbWqoKJ6yfuuQQR9IrpQqwnyaBh4LSabyll6tWg==", + "peer": true, "requires": { "@docsearch/css": "^3.5.1", "@docsearch/js": "^3.5.1", @@ -3628,6 +3645,7 @@ "version": "3.3.4", "resolved": "https://registry.npmjs.org/vue/-/vue-3.3.4.tgz", "integrity": "sha512-VTyEYn3yvIeY1Py0WaYGZsXnz3y5UnGi62GjVEqvEGPl6nxbOrCXbVOTQWBEJUqAyTUk2uJ5JLVnYJ6ZzGbrSw==", + "peer": true, "requires": { "@vue/compiler-dom": "3.3.4", "@vue/compiler-sfc": "3.3.4",