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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,3 @@
1
+ numpy>=1.22
2
+ scipy>=1.10
3
+ fsl_mrs>=2.1.0
@@ -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()