lcmodel-wrapper 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lcmodel_wrapper-0.1.0/PKG-INFO +62 -0
- lcmodel_wrapper-0.1.0/README.md +48 -0
- lcmodel_wrapper-0.1.0/pyproject.toml +31 -0
- lcmodel_wrapper-0.1.0/setup.cfg +4 -0
- lcmodel_wrapper-0.1.0/src/lcmodel_wrapper/__init__.py +1 -0
- lcmodel_wrapper-0.1.0/src/lcmodel_wrapper/lcmodel_wrapper.py +460 -0
- lcmodel_wrapper-0.1.0/src/lcmodel_wrapper.egg-info/PKG-INFO +62 -0
- lcmodel_wrapper-0.1.0/src/lcmodel_wrapper.egg-info/SOURCES.txt +10 -0
- lcmodel_wrapper-0.1.0/src/lcmodel_wrapper.egg-info/dependency_links.txt +1 -0
- lcmodel_wrapper-0.1.0/src/lcmodel_wrapper.egg-info/requires.txt +3 -0
- lcmodel_wrapper-0.1.0/src/lcmodel_wrapper.egg-info/top_level.txt +1 -0
- lcmodel_wrapper-0.1.0/tests/test_lcm.py +136 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lcmodel_wrapper
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python wrapper for LCModel MRS fitting
|
|
5
|
+
Author-email: Julian Merkofer <j.p.merkofer@tue.nl>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/yourusername/PyLCModel
|
|
8
|
+
Project-URL: Repository, https://github.com/yourusername/PyLCModel
|
|
9
|
+
Requires-Python: >=3.8
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: numpy>=1.22
|
|
12
|
+
Requires-Dist: scipy>=1.10
|
|
13
|
+
Requires-Dist: fsl_mrs>=2.1.0
|
|
14
|
+
|
|
15
|
+
# PyLCModel
|
|
16
|
+
|
|
17
|
+
**PyLCModel** is a Python wrapper designed to streamline the use of LCModel for least-squares spectral fitting in MRS. It takes the complexity out of setting up LCModel by automating control file generation, handling data conversions, and managing execution (with support for both single and multi-core processing).
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Features
|
|
22
|
+
|
|
23
|
+
- **Easy Setup:** Quickly run LCModel with minimal code.
|
|
24
|
+
- **Automated Control Files:** Dynamically generates and adjusts LCModel control files to suit your data.
|
|
25
|
+
- **Multiprocessing Support:** Leverage multiple cores to accelerate batch processing.
|
|
26
|
+
- **Data Conversion:** Seamlessly converts to .raw for execution. (Coming soon: conversion to .basis)
|
|
27
|
+
- **Robust Output Parsing:** Extracts metabolite concentrations and CRLBs from LCModel output.
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
### From Source
|
|
34
|
+
```bash
|
|
35
|
+
git clone https://github.com/julianmer/PyLCModel.git
|
|
36
|
+
cd PyLCModel
|
|
37
|
+
pip install -e .
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### From PyPI *(once published)*
|
|
41
|
+
```bash
|
|
42
|
+
pip install lcmodel
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Getting Started
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from lcmodel_wrapper import LCModel
|
|
51
|
+
|
|
52
|
+
# Initialize the LCModel wrapper with your basis set
|
|
53
|
+
lcmodel = LCModel(path2basis='/path/to/your/basis_set.basis')
|
|
54
|
+
|
|
55
|
+
# Assuming `data` is your MRS data in the frequency domain as a NumPy array
|
|
56
|
+
# Fit the data using LCModel
|
|
57
|
+
concentrations, crlbs = lcmodel(data)
|
|
58
|
+
|
|
59
|
+
# Print results
|
|
60
|
+
print("Fitted Metabolite Concentrations:", concentrations)
|
|
61
|
+
print("CRLBs:", crlbs)
|
|
62
|
+
```
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# PyLCModel
|
|
2
|
+
|
|
3
|
+
**PyLCModel** is a Python wrapper designed to streamline the use of LCModel for least-squares spectral fitting in MRS. It takes the complexity out of setting up LCModel by automating control file generation, handling data conversions, and managing execution (with support for both single and multi-core processing).
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Easy Setup:** Quickly run LCModel with minimal code.
|
|
10
|
+
- **Automated Control Files:** Dynamically generates and adjusts LCModel control files to suit your data.
|
|
11
|
+
- **Multiprocessing Support:** Leverage multiple cores to accelerate batch processing.
|
|
12
|
+
- **Data Conversion:** Seamlessly converts to .raw for execution. (Coming soon: conversion to .basis)
|
|
13
|
+
- **Robust Output Parsing:** Extracts metabolite concentrations and CRLBs from LCModel output.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
### From Source
|
|
20
|
+
```bash
|
|
21
|
+
git clone https://github.com/julianmer/PyLCModel.git
|
|
22
|
+
cd PyLCModel
|
|
23
|
+
pip install -e .
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### From PyPI *(once published)*
|
|
27
|
+
```bash
|
|
28
|
+
pip install lcmodel
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Getting Started
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from lcmodel_wrapper import LCModel
|
|
37
|
+
|
|
38
|
+
# Initialize the LCModel wrapper with your basis set
|
|
39
|
+
lcmodel = LCModel(path2basis='/path/to/your/basis_set.basis')
|
|
40
|
+
|
|
41
|
+
# Assuming `data` is your MRS data in the frequency domain as a NumPy array
|
|
42
|
+
# Fit the data using LCModel
|
|
43
|
+
concentrations, crlbs = lcmodel(data)
|
|
44
|
+
|
|
45
|
+
# Print results
|
|
46
|
+
print("Fitted Metabolite Concentrations:", concentrations)
|
|
47
|
+
print("CRLBs:", crlbs)
|
|
48
|
+
```
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "lcmodel_wrapper"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python wrapper for LCModel MRS fitting"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
|
|
13
|
+
authors = [
|
|
14
|
+
{ name = "Julian Merkofer", email = "j.p.merkofer@tue.nl" }
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
dependencies = [
|
|
18
|
+
"numpy>=1.22",
|
|
19
|
+
"scipy>=1.10",
|
|
20
|
+
"fsl_mrs>=2.1.0"
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/yourusername/PyLCModel"
|
|
25
|
+
Repository = "https://github.com/yourusername/PyLCModel"
|
|
26
|
+
|
|
27
|
+
[tool.setuptools]
|
|
28
|
+
package-dir = {"" = "src"}
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.packages.find]
|
|
31
|
+
where = ["src"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .lcmodel_wrapper import PyLCModel
|
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
####################################################################################################
|
|
2
|
+
# lcmodel_wrapper.py #
|
|
3
|
+
####################################################################################################
|
|
4
|
+
# #
|
|
5
|
+
# Authors: J. P. Merkofer (j.p.merkofer@tue.nl) #
|
|
6
|
+
# #
|
|
7
|
+
# Created: 20/06/24 #
|
|
8
|
+
# #
|
|
9
|
+
# Purpose: Python wrapper for the LCModel optimization framework for least-squares fitting #
|
|
10
|
+
# of spectra. #
|
|
11
|
+
# #
|
|
12
|
+
####################################################################################################
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
#*************#
|
|
16
|
+
# imports #
|
|
17
|
+
#*************#
|
|
18
|
+
import multiprocessing
|
|
19
|
+
import numpy as np
|
|
20
|
+
import os
|
|
21
|
+
import platform
|
|
22
|
+
import re
|
|
23
|
+
import shutil
|
|
24
|
+
import subprocess
|
|
25
|
+
import time
|
|
26
|
+
|
|
27
|
+
from fsl_mrs.utils import mrs_io
|
|
28
|
+
|
|
29
|
+
from scipy.optimize import minimize
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
#**************************************************************************************************#
|
|
34
|
+
# Class LCModel #
|
|
35
|
+
#**************************************************************************************************#
|
|
36
|
+
# #
|
|
37
|
+
# The framework wrapper for the LCModel fitting tool. #
|
|
38
|
+
# #
|
|
39
|
+
#**************************************************************************************************#
|
|
40
|
+
class LCModel():
|
|
41
|
+
def __init__(self, path2basis, control=None, multiprocessing=False, ppmlim=(0.5, 4.2),
|
|
42
|
+
conj=True, ignore='default', save_path='', path2exec=None, sample_points=None,
|
|
43
|
+
bandwidth=None, **kwargs):
|
|
44
|
+
|
|
45
|
+
self.basisFSL = mrs_io.read_basis(path2basis)
|
|
46
|
+
self.control = control
|
|
47
|
+
self.multiprocessing = multiprocessing
|
|
48
|
+
self.save_path = save_path
|
|
49
|
+
self.conj = conj
|
|
50
|
+
if sample_points is None: self.sample_points = self.basisFSL.original_points
|
|
51
|
+
else: self.sample_points = sample_points
|
|
52
|
+
if bandwidth is None: self.bandwidth = self.basisFSL.original_bw
|
|
53
|
+
else: self.bandwidth = bandwidth
|
|
54
|
+
|
|
55
|
+
if path2exec is None: # infer path to LCModel executable (and os system)
|
|
56
|
+
print(f'Warning -- path2exec not specified, trying to use internal binaries...')
|
|
57
|
+
print(f' It is recommended to provide an own executable for LCModel!')
|
|
58
|
+
if os.name == 'nt':
|
|
59
|
+
self.path2exec = (os.path.dirname(os.path.realpath(__file__)) + os.sep + 'lcmodel'
|
|
60
|
+
+ os.sep + 'executables' + os.sep + 'win' + os.sep + 'win10'
|
|
61
|
+
+ os.sep + 'LCModel.exe')
|
|
62
|
+
if platform.release() != '10': # warning if not win10
|
|
63
|
+
print(f'Warning -- LCModel binaries are for Windows 10, but you are using '
|
|
64
|
+
f'{platform.release()}!')
|
|
65
|
+
elif os.name == 'posix':
|
|
66
|
+
if platform.system() == 'Darwin':
|
|
67
|
+
self.path2exec = (f'{os.path.dirname(os.path.realpath(__file__))}'
|
|
68
|
+
f'/lcmodel/executables/mac/lcmodel')
|
|
69
|
+
os.chmod(self.path2exec, 0o755) # make executable
|
|
70
|
+
elif platform.system() == 'Linux':
|
|
71
|
+
self.path2exec = (f'{os.path.dirname(os.path.realpath(__file__))}'
|
|
72
|
+
f'/lcmodel/executables/linux/lcmodel')
|
|
73
|
+
os.chmod(self.path2exec, 0o755) # make executable
|
|
74
|
+
else: self.path2exec = path2exec
|
|
75
|
+
|
|
76
|
+
# ignore metabolites
|
|
77
|
+
if isinstance(ignore, str):
|
|
78
|
+
if ignore.lower() == 'default': ignore = ['Lip13a', 'Lip13b', 'Lip09', 'Lip20',
|
|
79
|
+
'MM09', 'MM12', 'MM14', 'MM17', 'MM20',
|
|
80
|
+
'-CrCH2', 'CrCH2']
|
|
81
|
+
elif ignore.lower() == 'none': ignore = []
|
|
82
|
+
else: raise ValueError('Unknown preset string... Please use one of the predefined '
|
|
83
|
+
'or provide a list of metabolite names!')
|
|
84
|
+
elif not isinstance(ignore, list):
|
|
85
|
+
raise ValueError('Ignore must be a list of metabolite names or a string!')
|
|
86
|
+
|
|
87
|
+
# TODO: convert basis to LCModel format (if necessary)
|
|
88
|
+
if not path2basis.lower().endswith('.basis'):
|
|
89
|
+
raise ValueError('Basis file must be in .basis format! (For now... Sorry!)')
|
|
90
|
+
|
|
91
|
+
# parse control file
|
|
92
|
+
if control is not None:
|
|
93
|
+
control = open(control, 'r').read()
|
|
94
|
+
self.control = control.split('\n')
|
|
95
|
+
|
|
96
|
+
# overwrite basis
|
|
97
|
+
for i, line in enumerate(self.control):
|
|
98
|
+
if line.startswith('filbas='):
|
|
99
|
+
if not line[1:-1].split(os.sep)[-1] == path2basis.split(os.sep)[-1]:
|
|
100
|
+
print(f'Warning -- overwriting filbas in control file with'
|
|
101
|
+
f' {os.path.abspath(path2basis)}')
|
|
102
|
+
self.control[i] = f"filbas='{os.path.abspath(path2basis)}'"
|
|
103
|
+
|
|
104
|
+
# adjust ppm limits
|
|
105
|
+
for i, line in enumerate(self.control):
|
|
106
|
+
if line.startswith('ppmst='):
|
|
107
|
+
if not line.split('=')[1] == str(ppmlim[1]): # warning if limits aren't equal
|
|
108
|
+
print(f'Warning -- overwriting ppmst in control file with {ppmlim[1]}')
|
|
109
|
+
self.control[i] = f'ppmst={ppmlim[1]}'
|
|
110
|
+
if line.startswith('ppmend='):
|
|
111
|
+
if not line.split('=')[1] == str(ppmlim[0]): # warning if limits aren't equal
|
|
112
|
+
print(f'Warning -- overwriting ppmend in control file with {ppmlim[0]}')
|
|
113
|
+
self.control[i] = f'ppmend={ppmlim[0]}'
|
|
114
|
+
|
|
115
|
+
# overwrite ignore metabolites
|
|
116
|
+
for i, line in enumerate(self.control):
|
|
117
|
+
if line.startswith('nomit='):
|
|
118
|
+
if not line.split('=')[1] == str(len(ignore)): # warning if ignore isn't equal
|
|
119
|
+
print(f'Warning -- overwriting nomit in control file with '
|
|
120
|
+
f'{len(ignore)} for {ignore}...')
|
|
121
|
+
self.control[i] = f'nomit={len(ignore)}'
|
|
122
|
+
for j, met in enumerate(ignore):
|
|
123
|
+
self.control.insert(i+j+1, f'chomit({j+1})=\'{met}\'')
|
|
124
|
+
break
|
|
125
|
+
|
|
126
|
+
else:
|
|
127
|
+
lines = []
|
|
128
|
+
lines.append(f"$LCMODL")
|
|
129
|
+
lines.append(f"nunfil={self.sample_points}") # data points
|
|
130
|
+
lines.append(f"deltat={1. / self.bandwidth}") # dwell time
|
|
131
|
+
lines.append(f"hzpppm={self.basisFSL.cf}") # field strength in MHz
|
|
132
|
+
lines.append(f"ppmst={ppmlim[1]}")
|
|
133
|
+
lines.append(f"ppmend={ppmlim[0]}")
|
|
134
|
+
|
|
135
|
+
lines.append(f"dows=F") # 'T' <-> do water scaling
|
|
136
|
+
lines.append(f"neach=99") # number of metabolites to plot fit individually
|
|
137
|
+
|
|
138
|
+
lines.append(f"filbas='{os.path.abspath(path2basis)}'")
|
|
139
|
+
|
|
140
|
+
lines.append(f"filraw='example.raw'")
|
|
141
|
+
lines.append(f"filps='example.ps'")
|
|
142
|
+
lines.append(f"filcoo='example.coord'")
|
|
143
|
+
lines.append(f"filh2o='example.h2o'")
|
|
144
|
+
|
|
145
|
+
lines.append(f"lcoord=9") # 0 <-> surpress creation of coord file, 9 <-> don't surpress
|
|
146
|
+
lines.append(f"nomit={len(ignore)}")
|
|
147
|
+
for i, met in enumerate(ignore):
|
|
148
|
+
lines.append(f"chomit({i+1})=\'{met}\'")
|
|
149
|
+
lines.append(f"namrel='Cr+PCr'")
|
|
150
|
+
|
|
151
|
+
# lines.append(f"nratio=0") # number of soft constraints (default 12, see manual)
|
|
152
|
+
# lines.append(f"sddegz=6") # 6 <-> eddy current correction
|
|
153
|
+
# lines.append(f"dkntmn=0.5") # limit knot spacing of baseline (max 1/3 of ppm range)
|
|
154
|
+
lines.append(f"$END")
|
|
155
|
+
|
|
156
|
+
self.control = lines
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
#**********************#
|
|
160
|
+
# forward function #
|
|
161
|
+
#**********************#
|
|
162
|
+
def __call__(self, *args, **kwargs):
|
|
163
|
+
return self.forward(*args, **kwargs)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
#*************************#
|
|
167
|
+
# optimal referencing #
|
|
168
|
+
#*************************#
|
|
169
|
+
def optimalReference(self, t, t_hat):
|
|
170
|
+
w = np.ones(t.shape[0])
|
|
171
|
+
for i in range(t.shape[0]):
|
|
172
|
+
def err(w):
|
|
173
|
+
w = np.clip(w, 0, None)
|
|
174
|
+
return np.abs(t[i] - w * t_hat[i]).mean()
|
|
175
|
+
|
|
176
|
+
w[i] = minimize(err, w[i], bounds=[(0, None)]).x
|
|
177
|
+
return w[..., np.newaxis]
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
#****************************#
|
|
181
|
+
# loss on concentrations #
|
|
182
|
+
#****************************#
|
|
183
|
+
def concsLoss(self, t, t_hat, type='ae'):
|
|
184
|
+
t = t[:, :self.basisFSL.n_metabs]
|
|
185
|
+
t_hat = t_hat[:, :self.basisFSL.n_metabs]
|
|
186
|
+
|
|
187
|
+
if type == 'ae': # absolute error
|
|
188
|
+
return np.abs(t - t_hat)
|
|
189
|
+
else:
|
|
190
|
+
raise ValueError('Unknown loss type... Please use one of the predefined!')
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
#*********************#
|
|
194
|
+
# input to output #
|
|
195
|
+
#*********************#
|
|
196
|
+
def forward(self, x, x_ref=None, frac=None, x0=None):
|
|
197
|
+
assert x0 is None, 'Initial values not supported... (please set x0=None)'
|
|
198
|
+
|
|
199
|
+
theta = self.lcmodel_minimize(x, x_ref, frac)
|
|
200
|
+
return theta
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
#*********************#
|
|
204
|
+
# LCModel fitting #
|
|
205
|
+
#*********************#
|
|
206
|
+
def lcmodel_minimize(self, x, x_ref=None, frac=None):
|
|
207
|
+
thetas, crlbs = [], []
|
|
208
|
+
x = x[:, 0] + 1j * x[:, 1]
|
|
209
|
+
fids = np.fft.ifft(x, axis=-1) # to time domain
|
|
210
|
+
if self.conj:
|
|
211
|
+
fids = np.conjugate(fids) # conjugate if necessary
|
|
212
|
+
if x_ref is not None: x_ref = np.conjugate(x_ref)
|
|
213
|
+
|
|
214
|
+
# create temporary directory
|
|
215
|
+
if self.save_path == '' or self.save_path is None:
|
|
216
|
+
path = os.getcwd() + os.sep + 'tmp' + os.sep
|
|
217
|
+
else:
|
|
218
|
+
path = os.getcwd() + os.sep + self.save_path + os.sep
|
|
219
|
+
if not os.path.exists(path): os.makedirs(path)
|
|
220
|
+
|
|
221
|
+
# run
|
|
222
|
+
if self.multiprocessing: # multi threading
|
|
223
|
+
tasks = [(fids[i], x_ref, frac, i, path) for i in range(fids.shape[0])]
|
|
224
|
+
with multiprocessing.Pool(None) as pool:
|
|
225
|
+
thetas, crlbs = zip(*pool.starmap(self.lcm_forward, tasks))
|
|
226
|
+
|
|
227
|
+
else: # loop
|
|
228
|
+
for i, fid in enumerate(fids):
|
|
229
|
+
theta, crlb = self.lcm_forward(fid, x_ref, frac, i, path)
|
|
230
|
+
thetas.append(theta)
|
|
231
|
+
crlbs.append(crlb)
|
|
232
|
+
|
|
233
|
+
# remove temporary folder
|
|
234
|
+
if self.save_path == '' or self.save_path is None:
|
|
235
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
236
|
+
else:
|
|
237
|
+
# ... or save control file to save path
|
|
238
|
+
with open(f'{path + os.sep}control', 'w') as file:
|
|
239
|
+
file.write('\n'.join(self.control))
|
|
240
|
+
|
|
241
|
+
return np.array(thetas), np.array(crlbs)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
#************************#
|
|
245
|
+
# write to .raw file #
|
|
246
|
+
#************************#
|
|
247
|
+
def to_raw(self, fid, file_path, header=" $NMID\n id='', fmtdat='(2E15.6)'\n $END\n"):
|
|
248
|
+
with open(file_path, 'w') as file:
|
|
249
|
+
file.write(header)
|
|
250
|
+
for num in fid:
|
|
251
|
+
file.write(f" {num.real: .6E} {num.imag: .6E}\n")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
#*************************#
|
|
255
|
+
# read from .raw file #
|
|
256
|
+
#*************************#
|
|
257
|
+
def from_raw(self, path):
|
|
258
|
+
with open(path, 'r') as f:
|
|
259
|
+
lines = f.readlines()
|
|
260
|
+
for i, line in enumerate(lines):
|
|
261
|
+
if line.split()[0] == '$END': break
|
|
262
|
+
fid = [complex(float(line.split()[0]),
|
|
263
|
+
float(line.split()[1])) for line in lines[i+1:]]
|
|
264
|
+
return np.array(fid)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
#*************************#
|
|
268
|
+
# run LCModel wrapper #
|
|
269
|
+
#*************************#
|
|
270
|
+
def lcm_forward(self, fid, h2o=None, frac=None, idx=0, path=os.getcwd() + os.sep + 'tmp' + os.sep):
|
|
271
|
+
# transform to a .raw file
|
|
272
|
+
assert fid.shape[0] == self.sample_points, \
|
|
273
|
+
'Number of points in FID does not match sample points!'
|
|
274
|
+
self.to_raw(fid, f'{path + os.sep}temp{idx}.raw')
|
|
275
|
+
|
|
276
|
+
# transform to a .h2o file
|
|
277
|
+
if h2o is not None:
|
|
278
|
+
self.to_raw(h2o[idx], f'{path + os.sep}temp{idx}.h2o')
|
|
279
|
+
|
|
280
|
+
# write control file
|
|
281
|
+
for i, line in enumerate(self.control):
|
|
282
|
+
if line.startswith('dows='): self.control[i] = 'dows=T'
|
|
283
|
+
|
|
284
|
+
# tissue correction
|
|
285
|
+
if frac is not None:
|
|
286
|
+
wconc = (43300 * frac[idx]['GM'] + 35880 * frac[idx]['WM'] +
|
|
287
|
+
55556 * frac[idx]['CSF']) / (1 - frac[idx]['CSF'])
|
|
288
|
+
|
|
289
|
+
# write control file
|
|
290
|
+
for i, line in enumerate(self.control):
|
|
291
|
+
if line.startswith('wconc='): self.control[i] = f'wconc={int(wconc)}'
|
|
292
|
+
|
|
293
|
+
# run LCModel
|
|
294
|
+
self.initiate(f'{path + os.sep}temp{idx}.raw')
|
|
295
|
+
|
|
296
|
+
# wait for .coord file
|
|
297
|
+
while not os.path.exists(f'{path + os.sep}temp{idx}.coord'): time.sleep(1e-3) # 1ms
|
|
298
|
+
|
|
299
|
+
# read .coord file
|
|
300
|
+
metabs, concs, crlbs, tcr = self.read_LCModel_coord(f'{path}temp{idx}.coord',
|
|
301
|
+
meta=False)
|
|
302
|
+
# sort concentrations by basis names
|
|
303
|
+
concs = [concs[metabs.index(met)] if met in metabs else 0.0
|
|
304
|
+
for met in self.basisFSL._names]
|
|
305
|
+
crlbs = [crlbs[metabs.index(met)] if met in metabs else 999.0
|
|
306
|
+
for met in self.basisFSL._names]
|
|
307
|
+
return concs, crlbs
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
#******************************#
|
|
311
|
+
# initiate routine on .raw #
|
|
312
|
+
#******************************#
|
|
313
|
+
def initiate(self, file_path):
|
|
314
|
+
# write control file
|
|
315
|
+
for i, line in enumerate(self.control):
|
|
316
|
+
if line.startswith('filraw='): self.control[i] = f'filraw=\'{file_path}\''
|
|
317
|
+
if line.startswith('filps='): self.control[i] = f'filps=\'{file_path[:-4]}.ps\''
|
|
318
|
+
if line.startswith('filcoo='): self.control[i] = f'filcoo=\'{file_path[:-4]}.coord\''
|
|
319
|
+
if line.startswith('filh2o='): self.control[i] = f'filh2o=\'{file_path[:-4]}.h2o\''
|
|
320
|
+
|
|
321
|
+
msg = '\n'.join(self.control)
|
|
322
|
+
msg = msg.encode('utf-8')
|
|
323
|
+
|
|
324
|
+
# run LCModel
|
|
325
|
+
proc = subprocess.Popen(
|
|
326
|
+
[self.path2exec, ],
|
|
327
|
+
shell=True,
|
|
328
|
+
stdin=subprocess.PIPE,
|
|
329
|
+
stdout=subprocess.PIPE,
|
|
330
|
+
)
|
|
331
|
+
stdout_value, stderr_value = proc.communicate(msg)
|
|
332
|
+
|
|
333
|
+
# error handling
|
|
334
|
+
if not (stdout_value == b'' or stdout_value is None): print(stdout_value)
|
|
335
|
+
if not (stderr_value == b'' or stderr_value is None): print(stderr_value)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
#**************************#
|
|
339
|
+
# setter for save path #
|
|
340
|
+
#**************************#
|
|
341
|
+
def set_save_path(self, path):
|
|
342
|
+
self.save_path = path
|
|
343
|
+
|
|
344
|
+
#*****************************#
|
|
345
|
+
# load LCModel coord data #
|
|
346
|
+
#*****************************#
|
|
347
|
+
def read_LCModel_coord(self, path, coord=True, meta=True):
|
|
348
|
+
metabs, concs, crlbs, tcr = [], [], [], []
|
|
349
|
+
fwhm, snr, shift, phase = None, None, None, None
|
|
350
|
+
|
|
351
|
+
# go through file and extract all info
|
|
352
|
+
with open(path, 'r') as file:
|
|
353
|
+
concReader = 0
|
|
354
|
+
miscReader = 0
|
|
355
|
+
|
|
356
|
+
for line in file:
|
|
357
|
+
if 'lines in following concentration table' in line:
|
|
358
|
+
concReader = int(line.split(' lines')[0])
|
|
359
|
+
elif concReader > 0: # read concentration table
|
|
360
|
+
concReader -= 1
|
|
361
|
+
values = line.split()
|
|
362
|
+
|
|
363
|
+
# check if in header of table
|
|
364
|
+
if values[0] == 'Conc.':
|
|
365
|
+
continue
|
|
366
|
+
else:
|
|
367
|
+
try: # sometimes the fields are fused together with '+'
|
|
368
|
+
m = values[3]
|
|
369
|
+
c = float(values[2])
|
|
370
|
+
except:
|
|
371
|
+
if 'E+' in values[2]: # catch scientific notation
|
|
372
|
+
c = values[2].split('E+')
|
|
373
|
+
m = str(c[1].split('+')[1:])
|
|
374
|
+
c = float(c[0] + 'e+' + c[1].split('+')[0])
|
|
375
|
+
else:
|
|
376
|
+
if len(values[2].split('+')) > 1:
|
|
377
|
+
m = str(values[2].split('+')[1:])
|
|
378
|
+
c = float(values[2].split('+')[0])
|
|
379
|
+
elif len(values[2].split('-')) > 1:
|
|
380
|
+
m = str(values[2].split('-')[1:])
|
|
381
|
+
c = float(values[2].split('-')[0])
|
|
382
|
+
else:
|
|
383
|
+
raise ValueError(f'Could not parse {values}')
|
|
384
|
+
|
|
385
|
+
# append to data
|
|
386
|
+
metabs.append(m)
|
|
387
|
+
concs.append(float(values[0]))
|
|
388
|
+
crlbs.append(int(values[1][:-1]))
|
|
389
|
+
tcr.append(c)
|
|
390
|
+
continue
|
|
391
|
+
|
|
392
|
+
if 'lines in following misc. output table' in line:
|
|
393
|
+
miscReader = int(line.split(' lines')[0])
|
|
394
|
+
elif miscReader > 0: # read misc. output table
|
|
395
|
+
miscReader -= 1
|
|
396
|
+
values = line.split()
|
|
397
|
+
|
|
398
|
+
# extract info
|
|
399
|
+
if 'FWHM' in values:
|
|
400
|
+
fwhm = float(values[2])
|
|
401
|
+
snr = float(values[-1].split('=')[-1])
|
|
402
|
+
elif 'shift' in values:
|
|
403
|
+
if values[3] == 'ppm':
|
|
404
|
+
shift = float(values[2][1:]) # negative fuses with '='
|
|
405
|
+
else:
|
|
406
|
+
shift = float(values[3])
|
|
407
|
+
elif 'Ph' in values:
|
|
408
|
+
phase = float(values[1])
|
|
409
|
+
|
|
410
|
+
if coord and meta:
|
|
411
|
+
return metabs, concs, crlbs, tcr, fwhm, snr, shift, phase
|
|
412
|
+
elif coord:
|
|
413
|
+
return metabs, concs, crlbs, tcr
|
|
414
|
+
elif meta:
|
|
415
|
+
return fwhm, snr, shift, phase
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
#**************************************#
|
|
419
|
+
# load LCModel fit from coord data #
|
|
420
|
+
#**************************************#
|
|
421
|
+
def read_LCModel_fit(self, path):
|
|
422
|
+
# Source: https://gist.github.com/alexcraven/3db2c09f14ec489a31df81dc7b5a0f9c
|
|
423
|
+
|
|
424
|
+
series_type = None
|
|
425
|
+
series_data = {}
|
|
426
|
+
|
|
427
|
+
with open(path) as f:
|
|
428
|
+
vals = []
|
|
429
|
+
|
|
430
|
+
for line in f:
|
|
431
|
+
prev_series_type = series_type
|
|
432
|
+
if re.match(".*[0-9]+ points on ppm-axis = NY.*", line):
|
|
433
|
+
series_type = "ppm"
|
|
434
|
+
elif re.match(".*NY phased data points follow.*", line):
|
|
435
|
+
series_type = "data"
|
|
436
|
+
elif re.match(".*NY points of the fit to the data follow.*", line):
|
|
437
|
+
series_type = "completeFit"
|
|
438
|
+
# completeFit implies baseline+fit
|
|
439
|
+
elif re.match(".*NY background values follow.*", line):
|
|
440
|
+
series_type = "baseline"
|
|
441
|
+
elif re.match(".*lines in following.*", line):
|
|
442
|
+
series_type = None
|
|
443
|
+
elif re.match("[ ]+[a-zA-Z0-9]+[ ]+Conc. = [-+.E0-9]+$", line):
|
|
444
|
+
series_type = None
|
|
445
|
+
|
|
446
|
+
if prev_series_type != series_type: # start/end of chunk...
|
|
447
|
+
if len(vals) > 0:
|
|
448
|
+
series_data[prev_series_type] = np.array(vals)
|
|
449
|
+
vals = []
|
|
450
|
+
else:
|
|
451
|
+
if series_type:
|
|
452
|
+
for x in re.finditer(r"([-+.E0-9]+)[ \t]*", line):
|
|
453
|
+
v = x.group(1)
|
|
454
|
+
try:
|
|
455
|
+
v = float(v)
|
|
456
|
+
vals.append(v)
|
|
457
|
+
except ValueError:
|
|
458
|
+
print("Error parsing line: %s" % (line,))
|
|
459
|
+
print(v)
|
|
460
|
+
return series_data
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lcmodel_wrapper
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python wrapper for LCModel MRS fitting
|
|
5
|
+
Author-email: Julian Merkofer <j.p.merkofer@tue.nl>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/yourusername/PyLCModel
|
|
8
|
+
Project-URL: Repository, https://github.com/yourusername/PyLCModel
|
|
9
|
+
Requires-Python: >=3.8
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: numpy>=1.22
|
|
12
|
+
Requires-Dist: scipy>=1.10
|
|
13
|
+
Requires-Dist: fsl_mrs>=2.1.0
|
|
14
|
+
|
|
15
|
+
# PyLCModel
|
|
16
|
+
|
|
17
|
+
**PyLCModel** is a Python wrapper designed to streamline the use of LCModel for least-squares spectral fitting in MRS. It takes the complexity out of setting up LCModel by automating control file generation, handling data conversions, and managing execution (with support for both single and multi-core processing).
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Features
|
|
22
|
+
|
|
23
|
+
- **Easy Setup:** Quickly run LCModel with minimal code.
|
|
24
|
+
- **Automated Control Files:** Dynamically generates and adjusts LCModel control files to suit your data.
|
|
25
|
+
- **Multiprocessing Support:** Leverage multiple cores to accelerate batch processing.
|
|
26
|
+
- **Data Conversion:** Seamlessly converts to .raw for execution. (Coming soon: conversion to .basis)
|
|
27
|
+
- **Robust Output Parsing:** Extracts metabolite concentrations and CRLBs from LCModel output.
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
### From Source
|
|
34
|
+
```bash
|
|
35
|
+
git clone https://github.com/julianmer/PyLCModel.git
|
|
36
|
+
cd PyLCModel
|
|
37
|
+
pip install -e .
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### From PyPI *(once published)*
|
|
41
|
+
```bash
|
|
42
|
+
pip install lcmodel
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Getting Started
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from lcmodel_wrapper import LCModel
|
|
51
|
+
|
|
52
|
+
# Initialize the LCModel wrapper with your basis set
|
|
53
|
+
lcmodel = LCModel(path2basis='/path/to/your/basis_set.basis')
|
|
54
|
+
|
|
55
|
+
# Assuming `data` is your MRS data in the frequency domain as a NumPy array
|
|
56
|
+
# Fit the data using LCModel
|
|
57
|
+
concentrations, crlbs = lcmodel(data)
|
|
58
|
+
|
|
59
|
+
# Print results
|
|
60
|
+
print("Fitted Metabolite Concentrations:", concentrations)
|
|
61
|
+
print("CRLBs:", crlbs)
|
|
62
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/lcmodel_wrapper/__init__.py
|
|
4
|
+
src/lcmodel_wrapper/lcmodel_wrapper.py
|
|
5
|
+
src/lcmodel_wrapper.egg-info/PKG-INFO
|
|
6
|
+
src/lcmodel_wrapper.egg-info/SOURCES.txt
|
|
7
|
+
src/lcmodel_wrapper.egg-info/dependency_links.txt
|
|
8
|
+
src/lcmodel_wrapper.egg-info/requires.txt
|
|
9
|
+
src/lcmodel_wrapper.egg-info/top_level.txt
|
|
10
|
+
tests/test_lcm.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
lcmodel_wrapper
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
####################################################################################################
|
|
2
|
+
# test_lcm.py #
|
|
3
|
+
####################################################################################################
|
|
4
|
+
# #
|
|
5
|
+
# Authors: J. P. Merkofer (j.p.merkofer@tue.nl) #
|
|
6
|
+
# #
|
|
7
|
+
# Created: 20/06/24 #
|
|
8
|
+
# #
|
|
9
|
+
# Purpose: Tests the PyLCModel class by fitting MRS data from the ISMRM 2016 fitting challenge. #
|
|
10
|
+
# #
|
|
11
|
+
####################################################################################################
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
#*************#
|
|
15
|
+
# imports #
|
|
16
|
+
#*************#
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
import pandas as pd
|
|
21
|
+
|
|
22
|
+
from fsl_mrs.utils import mrs_io
|
|
23
|
+
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
# own
|
|
27
|
+
from lcmodel_wrapper import LCModel
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
#*************#
|
|
31
|
+
# loading #
|
|
32
|
+
#*************#
|
|
33
|
+
def load_EXCEL_conc(path2conc: Path):
|
|
34
|
+
"""
|
|
35
|
+
Load a list of concentrations from an EXCEL file (ISMRM 2016 fitting challenge).
|
|
36
|
+
Returns a sorted dict of metabolite -> concentration
|
|
37
|
+
"""
|
|
38
|
+
truth = {"Ace": 0.0} # initialize, Ace is only partially present
|
|
39
|
+
|
|
40
|
+
df = pd.read_excel(str(path2conc), header=17)
|
|
41
|
+
for i, met in enumerate(df["Metabolites"]):
|
|
42
|
+
if not isinstance(met, str):
|
|
43
|
+
break
|
|
44
|
+
truth[met] = df["concentration"].iloc[i]
|
|
45
|
+
|
|
46
|
+
# rename MMBL to Mac if present
|
|
47
|
+
if "MMBL" in truth:
|
|
48
|
+
truth["Mac"] = truth.pop("MMBL")
|
|
49
|
+
return dict(sorted(truth.items()))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
#*************#
|
|
53
|
+
# imports #
|
|
54
|
+
#*************#
|
|
55
|
+
def main():
|
|
56
|
+
|
|
57
|
+
repo_root = Path(__file__).resolve().parents[1]
|
|
58
|
+
example_data = repo_root / "example_data"
|
|
59
|
+
|
|
60
|
+
config = {
|
|
61
|
+
"path2basis": example_data / "press3T_30ms.BASIS",
|
|
62
|
+
"path2concs": example_data / "ground_truth",
|
|
63
|
+
"path2data": example_data / "datasets_JMRUI_WS",
|
|
64
|
+
"path2water": example_data / "datasets_JMRUI_nWS",
|
|
65
|
+
"path2save": None,
|
|
66
|
+
"test_size": 10,
|
|
67
|
+
"sample_points": 2048,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# quick existence checks
|
|
71
|
+
if not config["path2basis"].exists():
|
|
72
|
+
raise FileNotFoundError(
|
|
73
|
+
f"Basis file not found: {config['path2basis']}\n"
|
|
74
|
+
"Make sure example_data contains your BASIS file or update path."
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
if not config["path2concs"].is_dir():
|
|
78
|
+
raise FileNotFoundError(
|
|
79
|
+
f"Concentration folder not found: {config['path2concs']}"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
if not config["path2data"].is_dir():
|
|
83
|
+
raise FileNotFoundError(f"Data folder not found: {config['path2data']}")
|
|
84
|
+
|
|
85
|
+
# initialize model
|
|
86
|
+
lcm = PyLCModel(str(config["path2basis"]), sample_points=config["sample_points"])
|
|
87
|
+
|
|
88
|
+
# load ground truth concentration files
|
|
89
|
+
conc_files = sorted([p for p in (Path(config["path2concs"])).iterdir() if p.suffix in (".xlsx", ".xls")])[: config["test_size"]]
|
|
90
|
+
if len(conc_files) == 0:
|
|
91
|
+
raise RuntimeError("No concentration excel files found in path2concs")
|
|
92
|
+
|
|
93
|
+
concs_list = [load_EXCEL_conc(p) for p in conc_files]
|
|
94
|
+
|
|
95
|
+
# align to basis names
|
|
96
|
+
try:
|
|
97
|
+
basis_names = lcm.basisFSL._names
|
|
98
|
+
n_metabs = lcm.basisFSL.n_metabs
|
|
99
|
+
except Exception as e:
|
|
100
|
+
raise AttributeError("Could not access basisFSL._names or n_metabs from LCM object") from e
|
|
101
|
+
|
|
102
|
+
concs_aligned = [[c.get(met, 0.0) for met in basis_names] for c in concs_list]
|
|
103
|
+
concs = np.array(concs_aligned)[:, :n_metabs]
|
|
104
|
+
|
|
105
|
+
# load data
|
|
106
|
+
data_files = sorted([p for p in Path(config["path2data"]).iterdir() if p.is_file()])[: config["test_size"]]
|
|
107
|
+
if len(data_files) == 0:
|
|
108
|
+
raise RuntimeError("No data files found in path2data")
|
|
109
|
+
|
|
110
|
+
data = np.array([mrs_io.read_FID(str(p)).mrs().FID for p in data_files])
|
|
111
|
+
|
|
112
|
+
# load water if available
|
|
113
|
+
water = None
|
|
114
|
+
if config.get("path2water") and Path(config["path2water"]).is_dir():
|
|
115
|
+
water_files = sorted(list(Path(config["path2water"]).iterdir()))[: config["test_size"]]
|
|
116
|
+
if len(water_files) > 0:
|
|
117
|
+
water = np.array([mrs_io.read_FID(str(p)).mrs().FID for p in water_files])
|
|
118
|
+
|
|
119
|
+
# to frequency domain (stack real and imaginary part)
|
|
120
|
+
data = np.fft.fft(data, axis=-1)
|
|
121
|
+
data = np.stack((data.real, data.imag), axis=1)
|
|
122
|
+
|
|
123
|
+
# fit
|
|
124
|
+
lcm.set_save_path(config["path2save"])
|
|
125
|
+
thetas, uncs = lcm(data, water) # data in freq. domain, shape (batch, 2, sample_points)
|
|
126
|
+
|
|
127
|
+
# loss: if water not provided, apply optimalReference
|
|
128
|
+
if water is None:
|
|
129
|
+
thetas = lcm.optimalReference(concs, thetas) * thetas
|
|
130
|
+
|
|
131
|
+
loss = lcm.concsLoss(concs, thetas, type="ae")
|
|
132
|
+
print("MAE:", float(loss.mean()))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
if __name__ == "__main__":
|
|
136
|
+
main()
|