Skip to content

Commit 060a27e

Browse files
authored
feat: peaks to nmrium endpoint (#100)
* docs: improve OpenAPI descriptions, examples, and response codes Add detailed descriptions, request/response examples, and proper HTTP response codes across all routers. Add OpenAPI tag metadata and a rich API description to the FastAPI app configuration. * feat(spectra): add parse-publication-string endpoint Add POST /spectra/parse/publication-string endpoint that resurrects an NMR spectrum from an ACS-style publication string. The endpoint accepts the publication string as a plain text body and invokes the nmr-cli parse-publication-string command via Docker exec. Uses StreamingResponse with Content-Disposition attachment header to prevent Swagger UI from hanging on the large spectrum JSON response. Also improves OpenAPI docs for existing spectra endpoints. * feat(spectra): add peaks-to-nmrium endpoint and CLI command Add a new POST /spectra/parse/peaks endpoint that converts a list of NMR peaks (chemical shift, intensity, width) into a full NMRium-compatible spectrum object. The endpoint delegates to a new `peaks-to-nmrium` CLI command in nmr-cli which uses `peaksToXY` from nmr-processing to generate the simulated 1D spectrum data.
1 parent e989ce5 commit 060a27e

3 files changed

Lines changed: 313 additions & 0 deletions

File tree

app/routers/spectra.py

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import io
44
from app.schemas import HealthCheck
55
from pydantic import BaseModel, HttpUrl, Field
6+
from typing import Optional
67
import subprocess
78
import tempfile
89
import os
@@ -225,6 +226,103 @@ def remove_file_from_container(container_path: str) -> None:
225226
pass
226227

227228

229+
class PeakItem(BaseModel):
230+
"""A single NMR peak."""
231+
x: float = Field(..., description="Chemical shift in ppm")
232+
y: Optional[float] = Field(1.0, description="Peak intensity (default: 1.0)")
233+
width: Optional[float] = Field(1.0, description="Peak width in Hz (default: 1.0)")
234+
235+
236+
class PeaksToNMRiumOptions(BaseModel):
237+
"""Options for peaks-to-NMRium conversion."""
238+
nucleus: Optional[str] = Field("1H", description="Nucleus type (e.g. '1H', '13C')")
239+
solvent: Optional[str] = Field("", description="NMR solvent")
240+
frequency: Optional[float] = Field(400, description="NMR frequency in MHz")
241+
nbPoints: Optional[int] = Field(131072, description="Number of points for spectrum generation", alias="nb_points")
242+
243+
model_config = {"populate_by_name": True}
244+
245+
246+
class PeaksToNMRiumRequest(BaseModel):
247+
"""Request model for converting peaks to NMRium object."""
248+
peaks: list[PeakItem] = Field(
249+
...,
250+
min_length=1,
251+
description="List of NMR peaks with chemical shift (x), intensity (y), and width",
252+
)
253+
options: Optional[PeaksToNMRiumOptions] = Field(
254+
None,
255+
description="Spectrum generation options",
256+
)
257+
258+
model_config = {
259+
"json_schema_extra": {
260+
"examples": [
261+
{
262+
"peaks": [
263+
{"x": 7.26, "y": 1, "width": 1},
264+
{"x": 2.10, "y": 1, "width": 1},
265+
],
266+
"options": {
267+
"nucleus": "1H",
268+
"frequency": 400,
269+
},
270+
}
271+
]
272+
}
273+
}
274+
275+
276+
def run_peaks_to_nmrium_command(payload: dict) -> str:
277+
"""Execute nmr-cli peaks-to-nmrium command in Docker container via stdin."""
278+
279+
cmd = ["docker", "exec", "-i", NMR_CLI_CONTAINER, "nmr-cli", "peaks-to-nmrium"]
280+
stdin_data = json.dumps(payload)
281+
282+
try:
283+
result = subprocess.run(
284+
cmd,
285+
input=stdin_data.encode("utf-8"),
286+
capture_output=True,
287+
timeout=120,
288+
)
289+
except subprocess.TimeoutExpired:
290+
raise HTTPException(
291+
status_code=408,
292+
detail="Processing timeout exceeded",
293+
)
294+
except FileNotFoundError:
295+
raise HTTPException(
296+
status_code=500,
297+
detail="Docker not found or nmr-converter container not running.",
298+
)
299+
300+
if result.returncode != 0:
301+
error_msg = result.stderr.decode("utf-8") if result.stderr else "Unknown error"
302+
raise HTTPException(
303+
status_code=422,
304+
detail=f"NMR CLI error: {error_msg}",
305+
)
306+
307+
stdout = result.stdout.decode("utf-8").strip()
308+
309+
if not stdout:
310+
raise HTTPException(
311+
status_code=422,
312+
detail="NMR CLI returned empty output. The peak list may be invalid.",
313+
)
314+
315+
try:
316+
json.loads(stdout)
317+
except json.JSONDecodeError as e:
318+
raise HTTPException(
319+
status_code=500,
320+
detail=f"Invalid JSON from NMR CLI: {e}",
321+
)
322+
323+
return stdout
324+
325+
228326
@router.post(
229327
"/parse/file",
230328
tags=["spectra"],
@@ -446,3 +544,98 @@ async def parse_publication_string(
446544
status_code=422,
447545
detail=f"Error parsing publication string: {e}"
448546
)
547+
548+
549+
@router.post(
550+
"/parse/peaks",
551+
tags=["spectra"],
552+
summary="Convert a peak list to an NMRium-compatible spectrum",
553+
description=(
554+
"Convert a list of NMR peaks (chemical shifts with optional intensity and "
555+
"width) into a full NMRium-compatible spectrum object. Each peak is defined "
556+
"by its chemical shift position (`x` in ppm), intensity (`y`), and width "
557+
"(in Hz).\n\n"
558+
"The peaks are used to generate a simulated 1D spectrum using the "
559+
"**nmr-cli** `peaks-to-nmrium` command running inside a Docker container.\n\n"
560+
"### Example input\n"
561+
"```json\n"
562+
"{\n"
563+
' "peaks": [\n'
564+
' {"x": 7.26, "y": 1, "width": 1},\n'
565+
' {"x": 2.10, "y": 1, "width": 1}\n'
566+
" ],\n"
567+
' "options": {\n'
568+
' "nucleus": "1H",\n'
569+
' "frequency": 400\n'
570+
" }\n"
571+
"}\n"
572+
"```"
573+
),
574+
response_description="Generated spectrum in NMRium-compatible JSON format",
575+
status_code=status.HTTP_200_OK,
576+
responses={
577+
200: {"description": "Successfully generated spectrum from peak list"},
578+
408: {"description": "Processing timeout exceeded (120s limit)"},
579+
422: {"description": "Invalid peak list or NMR CLI error"},
580+
500: {"description": "Docker or nmr-converter container not available"},
581+
},
582+
)
583+
async def parse_peaks(request: PeaksToNMRiumRequest):
584+
"""
585+
## Convert a peak list to NMRium spectrum
586+
587+
Provide a list of NMR peaks and optional generation parameters to produce
588+
an NMRium-compatible spectrum object.
589+
590+
### Peak fields
591+
| Field | Type | Required | Description |
592+
|---------|-------|----------|--------------------------------|
593+
| `x` | float | Yes | Chemical shift in ppm |
594+
| `y` | float | No | Peak intensity (default: 1.0) |
595+
| `width` | float | No | Peak width in Hz (default: 1.0)|
596+
597+
### Options
598+
| Option | Type | Default | Description |
599+
|-------------|--------|---------|----------------------------|
600+
| `nucleus` | string | `1H` | Nucleus type |
601+
| `solvent` | string | `""` | NMR solvent |
602+
| `frequency` | float | `400` | NMR frequency in MHz |
603+
| `nb_points` | int | `131072`| Number of spectrum points |
604+
605+
### Returns
606+
NMRium-compatible JSON with spectrum data and metadata.
607+
"""
608+
if not request.peaks:
609+
raise HTTPException(
610+
status_code=422,
611+
detail="Peaks list cannot be empty.",
612+
)
613+
614+
payload = {
615+
"peaks": [peak.model_dump() for peak in request.peaks],
616+
}
617+
if request.options:
618+
payload["options"] = {
619+
"nucleus": request.options.nucleus,
620+
"solvent": request.options.solvent,
621+
"frequency": request.options.frequency,
622+
"nbPoints": request.options.nbPoints,
623+
}
624+
625+
try:
626+
raw_json = run_peaks_to_nmrium_command(payload)
627+
return StreamingResponse(
628+
io.BytesIO(raw_json.encode("utf-8")),
629+
media_type="application/json",
630+
headers={
631+
"Content-Disposition": "attachment; filename=nmrium-peaks.json",
632+
},
633+
)
634+
635+
except HTTPException:
636+
raise
637+
except Exception as e:
638+
raise HTTPException(
639+
status_code=422,
640+
detail=f"Error converting peaks to NMRium: {e}",
641+
)

app/scripts/nmr-cli/src/index.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22
import yargs, { type Argv, type CommandModule, type Options } from 'yargs'
33
import { loadSpectrumFromURL, loadSpectrumFromFilePath } from './parse/prase-spectra'
44
import { generateSpectrumFromPublicationString } from './publication-string'
5+
import { generateNMRiumFromPeaks } from './peaks-to-nmrium'
6+
import type { PeaksToNMRiumInput } from './peaks-to-nmrium'
57
import { hideBin } from 'yargs/helpers'
68
import { parsePredictionCommand } from './prediction'
9+
import { readFileSync } from 'fs'
710

811
const usageMessage = `
912
Usage: nmr-cli <command> [options]
@@ -12,6 +15,7 @@ Commands:
1215
parse-spectra Parse a spectra file to NMRium file
1316
parse-publication-string resurrect spectrum from the publication string
1417
predict Predict spectrum from Mol
18+
peaks-to-nmrium Convert a peak list to NMRium object
1519
1620
Options for 'parse-spectra' command:
1721
-u, --url File URL
@@ -61,12 +65,27 @@ nmrshift engine options:
6165
6266
6367
68+
Arguments for 'peaks-to-nmrium' command:
69+
Reads JSON from stdin with the following structure:
70+
{
71+
"peaks": [{ "x": 7.26, "y": 1, "width": 1 }, ...],
72+
"options": {
73+
"nucleus": "1H", (default: "1H")
74+
"solvent": "", (default: "")
75+
"frequency": 400, (default: 400)
76+
"from": -1, (optional, auto-computed from peaks)
77+
"to": 12, (optional, auto-computed from peaks)
78+
"nbPoints": 131072 (default: 131072)
79+
}
80+
}
81+
6482
Examples:
6583
nmr-cli parse-spectra -u file-url -s // Process spectra files from a URL and capture an image for the spectra
6684
nmr-cli parse-spectra -dir directory-path -s // process a spectra files from a directory and capture an image for the spectra
6785
nmr-cli parse-spectra -u file-url // Process spectra files from a URL
6886
nmr-cli parse-spectra -dir directory-path // Process spectra files from a directory
6987
nmr-cli parse-publication-string "your publication string"
88+
echo '{"peaks":[{"x":7.26},{"x":2.10}]}' | nmr-cli peaks-to-nmrium // Convert peaks to NMRium object
7089
`
7190

7291
export interface FileOptionsArgs {
@@ -177,11 +196,32 @@ const parsePublicationCommand: CommandModule = {
177196
},
178197
}
179198

199+
// Define the peaks-to-nmrium command
200+
const peaksToNMRiumCommand: CommandModule = {
201+
command: ['peaks-to-nmrium', 'ptn'],
202+
describe: 'Convert a peak list to NMRium object (reads JSON from stdin)',
203+
handler: () => {
204+
try {
205+
const stdinData = readFileSync(0, 'utf-8')
206+
const input: PeaksToNMRiumInput = JSON.parse(stdinData)
207+
const nmriumObject = generateNMRiumFromPeaks(input)
208+
console.log(JSON.stringify(nmriumObject))
209+
} catch (error) {
210+
console.error(
211+
'Error:',
212+
error instanceof Error ? error.message : String(error),
213+
)
214+
process.exit(1)
215+
}
216+
},
217+
}
218+
180219
yargs(hideBin(process.argv))
181220
.usage(usageMessage)
182221
.command(parseFileCommand)
183222
.command(parsePublicationCommand)
184223
.command(parsePredictionCommand)
224+
.command(peaksToNMRiumCommand)
185225
.showHelpOnFail(true)
186226
.help()
187227
.parse()
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { peaksToXY } from 'nmr-processing'
2+
import { CURRENT_EXPORT_VERSION } from '@zakodium/nmrium-core'
3+
import type { NMRPeak1D } from '@zakodium/nmr-types'
4+
import { castToArray } from './utilities/castToArray'
5+
6+
interface PeakInput {
7+
x: number
8+
y?: number
9+
width?: number
10+
}
11+
12+
interface PeaksToNMRiumOptions {
13+
nucleus?: string
14+
solvent?: string
15+
frequency?: number
16+
from?: number
17+
to?: number
18+
nbPoints?: number
19+
}
20+
21+
interface PeaksToNMRiumInput {
22+
peaks: PeakInput[]
23+
options?: PeaksToNMRiumOptions
24+
}
25+
26+
function generateNMRiumFromPeaks(input: PeaksToNMRiumInput) {
27+
const { peaks, options = {} } = input
28+
const {
29+
nucleus = '1H',
30+
solvent = '',
31+
frequency = 400,
32+
from,
33+
to,
34+
nbPoints = 131072,
35+
} = options
36+
37+
if (!peaks || peaks.length === 0) {
38+
throw new Error('Peaks array is empty or not provided')
39+
}
40+
41+
const defaultWidth = 1
42+
const nmrPeaks: NMRPeak1D[] = peaks.map((peak) => ({
43+
x: peak.x,
44+
y: peak.y ?? 1,
45+
width: peak.width ?? defaultWidth,
46+
}))
47+
48+
const xyOptions: Parameters<typeof peaksToXY>[1] = {
49+
frequency,
50+
nbPoints,
51+
...(from !== undefined && { from }),
52+
...(to !== undefined && { to }),
53+
}
54+
55+
const { x, y } = peaksToXY(nmrPeaks, xyOptions)
56+
57+
const info = {
58+
isFid: false,
59+
isComplex: false,
60+
dimension: 1,
61+
nucleus,
62+
originFrequency: frequency,
63+
baseFrequency: frequency,
64+
pulseSequence: '',
65+
solvent,
66+
isFt: true,
67+
name: '',
68+
}
69+
70+
const spectrum = {
71+
id: crypto.randomUUID(),
72+
data: { x: castToArray(x), im: undefined, re: castToArray(y) },
73+
info,
74+
}
75+
76+
return { data: { spectra: [spectrum] }, version: CURRENT_EXPORT_VERSION }
77+
}
78+
79+
export { generateNMRiumFromPeaks }
80+
export type { PeaksToNMRiumInput, PeakInput, PeaksToNMRiumOptions }

0 commit comments

Comments
 (0)