lcmodel-wrapper 0.3.0__tar.gz → 0.3.1__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.3.0 → lcmodel_wrapper-0.3.1}/PKG-INFO +10 -28
- {lcmodel_wrapper-0.3.0 → lcmodel_wrapper-0.3.1}/README.md +9 -27
- {lcmodel_wrapper-0.3.0 → lcmodel_wrapper-0.3.1}/lcmodel_wrapper/__init__.py +1 -0
- {lcmodel_wrapper-0.3.0 → lcmodel_wrapper-0.3.1}/lcmodel_wrapper/_version.py +1 -1
- {lcmodel_wrapper-0.3.0 → lcmodel_wrapper-0.3.1}/lcmodel_wrapper/basis.py +3 -3
- {lcmodel_wrapper-0.3.0 → lcmodel_wrapper-0.3.1}/lcmodel_wrapper/binaries.py +24 -37
- {lcmodel_wrapper-0.3.0 → lcmodel_wrapper-0.3.1}/lcmodel_wrapper/control.py +35 -50
- {lcmodel_wrapper-0.3.0 → lcmodel_wrapper-0.3.1}/lcmodel_wrapper/convert.py +7 -14
- lcmodel_wrapper-0.3.1/lcmodel_wrapper/coord.py +115 -0
- {lcmodel_wrapper-0.3.0 → lcmodel_wrapper-0.3.1}/lcmodel_wrapper/core.py +73 -118
- {lcmodel_wrapper-0.3.0 → lcmodel_wrapper-0.3.1}/lcmodel_wrapper/io.py +64 -97
- {lcmodel_wrapper-0.3.0 → lcmodel_wrapper-0.3.1}/lcmodel_wrapper.egg-info/PKG-INFO +10 -28
- lcmodel_wrapper-0.3.0/lcmodel_wrapper/coord.py +0 -136
- {lcmodel_wrapper-0.3.0 → lcmodel_wrapper-0.3.1}/LICENSE +0 -0
- {lcmodel_wrapper-0.3.0 → lcmodel_wrapper-0.3.1}/LICENSE.lcmodel +0 -0
- {lcmodel_wrapper-0.3.0 → lcmodel_wrapper-0.3.1}/MANIFEST.in +0 -0
- {lcmodel_wrapper-0.3.0 → lcmodel_wrapper-0.3.1}/NOTICE +0 -0
- {lcmodel_wrapper-0.3.0 → lcmodel_wrapper-0.3.1}/lcmodel_wrapper/container.py +0 -0
- {lcmodel_wrapper-0.3.0 → lcmodel_wrapper-0.3.1}/lcmodel_wrapper.egg-info/SOURCES.txt +0 -0
- {lcmodel_wrapper-0.3.0 → lcmodel_wrapper-0.3.1}/lcmodel_wrapper.egg-info/dependency_links.txt +0 -0
- {lcmodel_wrapper-0.3.0 → lcmodel_wrapper-0.3.1}/lcmodel_wrapper.egg-info/requires.txt +0 -0
- {lcmodel_wrapper-0.3.0 → lcmodel_wrapper-0.3.1}/lcmodel_wrapper.egg-info/top_level.txt +0 -0
- {lcmodel_wrapper-0.3.0 → lcmodel_wrapper-0.3.1}/pyproject.toml +0 -0
- {lcmodel_wrapper-0.3.0 → lcmodel_wrapper-0.3.1}/setup.cfg +0 -0
- {lcmodel_wrapper-0.3.0 → lcmodel_wrapper-0.3.1}/tests/test_binaries.py +0 -0
- {lcmodel_wrapper-0.3.0 → lcmodel_wrapper-0.3.1}/tests/test_lcm.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lcmodel_wrapper
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Lightweight Python wrapper for LCModel MRS fitting
|
|
5
5
|
Author-email: Julian Merkofer <j.p.merkofer@tue.nl>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -66,39 +66,21 @@ example data used by the tests.
|
|
|
66
66
|
|
|
67
67
|
## How the LCModel binary is handled
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
LCModel is **not** shipped in the wheel. On first use it is found in this order, and the first one that works is cached:
|
|
70
70
|
|
|
71
|
-
1.
|
|
72
|
-
2.
|
|
73
|
-
3.
|
|
74
|
-
4.
|
|
75
|
-
5.
|
|
76
|
-
6. a build from the LCModel Fortran source via `gfortran` (source fetched on demand).
|
|
71
|
+
1. `path2exec` you pass to `PyLCModel`,
|
|
72
|
+
2. the community binary for your OS/architecture from [schorschinho/LCModel](https://github.com/schorschinho/LCModel),
|
|
73
|
+
3. the binary built by this repository's CI for the installed version ([releases](https://github.com/julianmer/PyLCModel/releases); Linux x86_64/aarch64 and macOS arm64/x86_64, all statically linked),
|
|
74
|
+
4. the container image `ghcr.io/julianmer/lcmodel`, if Docker or podman is running,
|
|
75
|
+
5. a build from source with `gfortran`.
|
|
77
76
|
|
|
78
|
-
|
|
77
|
+
Each candidate is run once before it is accepted, so a binary that cannot run on your machine is skipped rather than cached. Useful switches: `allow_download`, `allow_docker`, `allow_build` on `PyLCModel`, and the `LCMODEL_EXEC` / `LCMODEL_CACHE_DIR` environment variables.
|
|
79
78
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
### Running from a container
|
|
83
|
-
|
|
84
|
-
The container is the one option that behaves identically everywhere: inside it LCModel is always the same statically linked Linux binary, so nothing depends on your macOS version, Homebrew, or which Apple-silicon generation you have (upstream's macOS builds are tied to the machine they were compiled on, which is why an M1 build does not run on an M4). Docker Desktop, OrbStack, Colima, or rootless podman all work.
|
|
85
|
-
|
|
86
|
-
On Linux and macOS the launcher bind-mounts your **working directory** and your **home directory** at the same paths inside the container, so the absolute paths in the control file need no translation. On Windows it mounts the **drives** holding those two at `/host/<LETTER>` and the wrapper rewrites the file paths in the control file to match (`C:\Users\me\x.basis` → `/host/C/Users/me/x.basis`); UNC paths are not supported. The one constraint: the basis set and any absolute `save_path` must live under the working or home directory (on Windows: on one of their drives); `PyLCModel` raises a clear error otherwise.
|
|
87
|
-
|
|
88
|
-
```python
|
|
89
|
-
lcmodel = PyLCModel(path2basis="~/basis/press_3t.basis") # container used automatically if needed
|
|
90
|
-
lcmodel = PyLCModel(path2basis="...", allow_docker=False) # never use a container
|
|
91
|
-
```
|
|
92
|
-
|
|
93
|
-
Environment knobs: `LCMODEL_NO_DOCKER=1` disables the rung, `LCMODEL_DOCKER_IMAGE` overrides the image (e.g. a locally built one), `LCMODEL_PULL_TIMEOUT` bounds the pull (default 900 s), and `LCMODEL_RELEASE_TAG` selects which release (and matching image tag) steps 4 and 5 use instead of the default `v<package version>`. Delete `<cache>/lcmodel-container` to make the resolver try the native sources again.
|
|
94
|
-
|
|
95
|
-
You can also use the image directly, without Python:
|
|
79
|
+
With the container, LCModel sees your working directory and your home directory; keep the basis set and any `save_path` under one of them. The image also works on its own:
|
|
96
80
|
```bash
|
|
97
|
-
docker run --rm -i -v "$PWD:$PWD" -w "$PWD" ghcr.io/julianmer/lcmodel
|
|
81
|
+
docker run --rm -i -v "$PWD:$PWD" -w "$PWD" ghcr.io/julianmer/lcmodel < control.file
|
|
98
82
|
```
|
|
99
83
|
|
|
100
|
-
No LCModel code or binary is bundled — keeping both the repository and the PyPI wheel small. (The only git submodule in this repository is the optional example data under `example_data/`.)
|
|
101
|
-
|
|
102
84
|
---
|
|
103
85
|
|
|
104
86
|
## Getting Started
|
|
@@ -43,39 +43,21 @@ example data used by the tests.
|
|
|
43
43
|
|
|
44
44
|
## How the LCModel binary is handled
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
LCModel is **not** shipped in the wheel. On first use it is found in this order, and the first one that works is cached:
|
|
47
47
|
|
|
48
|
-
1.
|
|
49
|
-
2.
|
|
50
|
-
3.
|
|
51
|
-
4.
|
|
52
|
-
5.
|
|
53
|
-
6. a build from the LCModel Fortran source via `gfortran` (source fetched on demand).
|
|
48
|
+
1. `path2exec` you pass to `PyLCModel`,
|
|
49
|
+
2. the community binary for your OS/architecture from [schorschinho/LCModel](https://github.com/schorschinho/LCModel),
|
|
50
|
+
3. the binary built by this repository's CI for the installed version ([releases](https://github.com/julianmer/PyLCModel/releases); Linux x86_64/aarch64 and macOS arm64/x86_64, all statically linked),
|
|
51
|
+
4. the container image `ghcr.io/julianmer/lcmodel`, if Docker or podman is running,
|
|
52
|
+
5. a build from source with `gfortran`.
|
|
54
53
|
|
|
55
|
-
|
|
54
|
+
Each candidate is run once before it is accepted, so a binary that cannot run on your machine is skipped rather than cached. Useful switches: `allow_download`, `allow_docker`, `allow_build` on `PyLCModel`, and the `LCMODEL_EXEC` / `LCMODEL_CACHE_DIR` environment variables.
|
|
56
55
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
### Running from a container
|
|
60
|
-
|
|
61
|
-
The container is the one option that behaves identically everywhere: inside it LCModel is always the same statically linked Linux binary, so nothing depends on your macOS version, Homebrew, or which Apple-silicon generation you have (upstream's macOS builds are tied to the machine they were compiled on, which is why an M1 build does not run on an M4). Docker Desktop, OrbStack, Colima, or rootless podman all work.
|
|
62
|
-
|
|
63
|
-
On Linux and macOS the launcher bind-mounts your **working directory** and your **home directory** at the same paths inside the container, so the absolute paths in the control file need no translation. On Windows it mounts the **drives** holding those two at `/host/<LETTER>` and the wrapper rewrites the file paths in the control file to match (`C:\Users\me\x.basis` → `/host/C/Users/me/x.basis`); UNC paths are not supported. The one constraint: the basis set and any absolute `save_path` must live under the working or home directory (on Windows: on one of their drives); `PyLCModel` raises a clear error otherwise.
|
|
64
|
-
|
|
65
|
-
```python
|
|
66
|
-
lcmodel = PyLCModel(path2basis="~/basis/press_3t.basis") # container used automatically if needed
|
|
67
|
-
lcmodel = PyLCModel(path2basis="...", allow_docker=False) # never use a container
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
Environment knobs: `LCMODEL_NO_DOCKER=1` disables the rung, `LCMODEL_DOCKER_IMAGE` overrides the image (e.g. a locally built one), `LCMODEL_PULL_TIMEOUT` bounds the pull (default 900 s), and `LCMODEL_RELEASE_TAG` selects which release (and matching image tag) steps 4 and 5 use instead of the default `v<package version>`. Delete `<cache>/lcmodel-container` to make the resolver try the native sources again.
|
|
71
|
-
|
|
72
|
-
You can also use the image directly, without Python:
|
|
56
|
+
With the container, LCModel sees your working directory and your home directory; keep the basis set and any `save_path` under one of them. The image also works on its own:
|
|
73
57
|
```bash
|
|
74
|
-
docker run --rm -i -v "$PWD:$PWD" -w "$PWD" ghcr.io/julianmer/lcmodel
|
|
58
|
+
docker run --rm -i -v "$PWD:$PWD" -w "$PWD" ghcr.io/julianmer/lcmodel < control.file
|
|
75
59
|
```
|
|
76
60
|
|
|
77
|
-
No LCModel code or binary is bundled — keeping both the repository and the PyPI wheel small. (The only git submodule in this repository is the optional example data under `example_data/`.)
|
|
78
|
-
|
|
79
61
|
---
|
|
80
62
|
|
|
81
63
|
## Getting Started
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
# Single source of truth for the package version. pyproject.toml reads it at build time,
|
|
2
2
|
# and binaries.py derives the GitHub release / container image tag ("v<version>") from
|
|
3
3
|
# it, so every wheel pairs with the LCModel artifacts built alongside it.
|
|
4
|
-
__version__ = "0.3.
|
|
4
|
+
__version__ = "0.3.1"
|
|
@@ -77,14 +77,14 @@ def read_basis(path: str) -> LCModelBasis:
|
|
|
77
77
|
with open(path, "r", errors="ignore") as fh:
|
|
78
78
|
header_lines = []
|
|
79
79
|
for line in fh:
|
|
80
|
-
header_lines
|
|
80
|
+
if len(header_lines) < 200: # scalars live near the top
|
|
81
|
+
header_lines.append(line)
|
|
81
82
|
# "METABO" lines are sparse; keep scanning the whole file for them.
|
|
82
83
|
# Match exactly "METABO =" (not "METABO_CONTAM" / "METABO_SINGLET").
|
|
83
84
|
m = re.match(r"\s*METABO\s*=\s*'(.*?)'", line)
|
|
84
85
|
if m:
|
|
85
86
|
basis.names.append(m.group(1).strip())
|
|
86
|
-
|
|
87
|
-
header = "".join(header_lines[:200]) # scalars live near the top
|
|
87
|
+
header = "".join(header_lines)
|
|
88
88
|
|
|
89
89
|
basis.central_freq = _find_scalar(header, "HZPPPM")
|
|
90
90
|
basis.dwell = _find_scalar(header, "BADELT")
|
|
@@ -49,9 +49,9 @@ _RAW_BASE = "https://raw.githubusercontent.com/schorschinho/LCModel/main"
|
|
|
49
49
|
_BINARIES = {
|
|
50
50
|
"linux-x86_64": ("binaries/linux/lcmodel.xz", "xz", "lcmodel"),
|
|
51
51
|
# Upstream has no aarch64 Linux build yet. The entry is kept so that the moment one
|
|
52
|
-
# lands
|
|
53
|
-
#
|
|
54
|
-
#
|
|
52
|
+
# lands it is used with no release on our side; until then the fetch 404s and the
|
|
53
|
+
# PyLCModel release below covers it. The directory name is what "uname -m" reports,
|
|
54
|
+
# matching the path the upstream Makefile derives.
|
|
55
55
|
"linux-aarch64": ("binaries/linux/aarch64/lcmodel.xz", "xz", "lcmodel"),
|
|
56
56
|
"darwin-arm64": ("binaries/macos/sequoia/m4/lcmodel.zip", "zip", "lcmodel"),
|
|
57
57
|
"darwin-arm64-monterey": ("binaries/macos/monterey/m1/lcmodel.zip", "zip", "lcmodel"),
|
|
@@ -508,20 +508,25 @@ def _container_cli() -> Optional[str]:
|
|
|
508
508
|
return None
|
|
509
509
|
|
|
510
510
|
|
|
511
|
-
def
|
|
512
|
-
"""
|
|
511
|
+
def _engine(cli: str, *args: str, timeout: float) -> Tuple[int, str]:
|
|
512
|
+
"""Run a container-engine command. Returns (exit code, last stderr line); the code
|
|
513
|
+
is -1 when the command could not run or time out."""
|
|
513
514
|
try:
|
|
514
|
-
proc = subprocess.run([cli,
|
|
515
|
+
proc = subprocess.run([cli, *args], stdin=subprocess.DEVNULL,
|
|
515
516
|
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
|
|
516
517
|
timeout=timeout)
|
|
517
518
|
except subprocess.TimeoutExpired:
|
|
518
|
-
return
|
|
519
|
+
return -1, f"'{cli} {args[0]}' did not finish within {timeout:.0f}s"
|
|
519
520
|
except OSError as err:
|
|
520
|
-
return
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
521
|
+
return -1, f"cannot run '{cli}': {err}"
|
|
522
|
+
lines = (proc.stderr or b"").decode("utf-8", "replace").strip().splitlines()
|
|
523
|
+
return proc.returncode, lines[-1] if lines else f"'{cli} {args[0]}' exited {proc.returncode}"
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _container_ready(cli: str, timeout: float = 30.0) -> Tuple[bool, str]:
|
|
527
|
+
"""Is the engine actually usable - daemon running, socket reachable, permissions ok?"""
|
|
528
|
+
code, why = _engine(cli, "info", timeout=timeout)
|
|
529
|
+
return code == 0, "ok" if code == 0 else why
|
|
525
530
|
|
|
526
531
|
|
|
527
532
|
def is_container_shim(path) -> bool:
|
|
@@ -563,37 +568,19 @@ def _container_shim(cache: Path) -> Optional[Path]:
|
|
|
563
568
|
return None
|
|
564
569
|
|
|
565
570
|
image = _container_image()
|
|
566
|
-
|
|
571
|
+
# already in the local store (pulled earlier, or built by hand): no network needed,
|
|
572
|
+
# which keeps this rung working offline
|
|
573
|
+
if _engine(cli, "image", "inspect", image, timeout=30)[0] != 0:
|
|
567
574
|
print(f"[lcmodel_wrapper] Pulling LCModel container image {image}")
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
except subprocess.TimeoutExpired:
|
|
573
|
-
print(f"[lcmodel_wrapper] Pulling {image} did not finish within "
|
|
574
|
-
f"{_PULL_TIMEOUT:.0f}s (raise LCMODEL_PULL_TIMEOUT to wait longer)")
|
|
575
|
-
return None
|
|
576
|
-
if proc.returncode != 0:
|
|
577
|
-
lines = (proc.stderr or b"").decode("utf-8", "replace").strip().splitlines()
|
|
578
|
-
print(f"[lcmodel_wrapper] Could not pull {image}: "
|
|
579
|
-
f"{lines[-1] if lines else proc.returncode}")
|
|
575
|
+
code, why = _engine(cli, "pull", image, timeout=_PULL_TIMEOUT)
|
|
576
|
+
if code != 0:
|
|
577
|
+
print(f"[lcmodel_wrapper] Could not pull {image}: {why} "
|
|
578
|
+
f"(LCMODEL_PULL_TIMEOUT raises the {_PULL_TIMEOUT:.0f}s bound)")
|
|
580
579
|
return None
|
|
581
580
|
|
|
582
581
|
return _write_shim(cache, cli, image)
|
|
583
582
|
|
|
584
583
|
|
|
585
|
-
def _image_present(cli: str, image: str) -> bool:
|
|
586
|
-
"""Already in the local store (pulled earlier, or built by hand)? Then no network
|
|
587
|
-
is needed, which keeps the container rung working offline."""
|
|
588
|
-
try:
|
|
589
|
-
proc = subprocess.run([cli, "image", "inspect", image], stdin=subprocess.DEVNULL,
|
|
590
|
-
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
591
|
-
timeout=30)
|
|
592
|
-
except (OSError, subprocess.TimeoutExpired):
|
|
593
|
-
return False
|
|
594
|
-
return proc.returncode == 0
|
|
595
|
-
|
|
596
|
-
|
|
597
584
|
#*********************#
|
|
598
585
|
# cache management #
|
|
599
586
|
#*********************#
|
|
@@ -38,6 +38,10 @@ def resolve_ignore(ignore) -> List[str]:
|
|
|
38
38
|
raise ValueError("ignore must be a list of metabolite names or a preset string.")
|
|
39
39
|
|
|
40
40
|
|
|
41
|
+
def _omit_lines(ignore: List[str]) -> List[str]:
|
|
42
|
+
return [f"nomit={len(ignore)}"] + [f"chomit({i + 1})='{m}'" for i, m in enumerate(ignore)]
|
|
43
|
+
|
|
44
|
+
|
|
41
45
|
#*************************#
|
|
42
46
|
# build a control set #
|
|
43
47
|
#*************************#
|
|
@@ -45,31 +49,25 @@ def build_control(path2basis: str, n_points: int, bandwidth: float, central_freq
|
|
|
45
49
|
ppmlim: Tuple[float, float] = (0.5, 4.2), ignore=DEFAULT_IGNORE,
|
|
46
50
|
dows: bool = False) -> List[str]:
|
|
47
51
|
"""Create a default LCModel control file as a list of lines."""
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
lines.append(f"nomit={len(ignore)}")
|
|
68
|
-
for i, met in enumerate(ignore):
|
|
69
|
-
lines.append(f"chomit({i + 1})='{met}'")
|
|
70
|
-
lines.append("namrel='Cr+PCr'")
|
|
71
|
-
lines.append("$END")
|
|
72
|
-
return lines
|
|
52
|
+
return [
|
|
53
|
+
"$LCMODL",
|
|
54
|
+
f"nunfil={n_points}", # data points
|
|
55
|
+
f"deltat={1.0 / bandwidth}", # dwell time
|
|
56
|
+
f"hzpppm={central_freq}", # field strength in MHz
|
|
57
|
+
f"ppmst={ppmlim[1]}",
|
|
58
|
+
f"ppmend={ppmlim[0]}",
|
|
59
|
+
f"dows={'T' if dows else 'F'}", # water scaling
|
|
60
|
+
"neach=99", # plot each metabolite fit
|
|
61
|
+
f"filbas='{os.path.abspath(path2basis)}'",
|
|
62
|
+
"filraw='example.raw'",
|
|
63
|
+
"filps='example.ps'",
|
|
64
|
+
"filcoo='example.coord'",
|
|
65
|
+
"filh2o='example.h2o'",
|
|
66
|
+
"lcoord=9", # 9 -> write coord file
|
|
67
|
+
*_omit_lines(resolve_ignore(ignore)),
|
|
68
|
+
"namrel='Cr+PCr'",
|
|
69
|
+
"$END",
|
|
70
|
+
]
|
|
73
71
|
|
|
74
72
|
|
|
75
73
|
#**************************#
|
|
@@ -80,24 +78,15 @@ def load_control(control_path: str, path2basis: str, ppmlim: Tuple[float, float]
|
|
|
80
78
|
"""Read an existing control file and override basis, ppm limits and ignored metabolites."""
|
|
81
79
|
ignore = resolve_ignore(ignore)
|
|
82
80
|
with open(control_path, "r") as fh:
|
|
83
|
-
control = fh.read().
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
for
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
if line.startswith("ppmend="):
|
|
93
|
-
control[i] = f"ppmend={ppmlim[0]}"
|
|
94
|
-
|
|
95
|
-
for i, line in enumerate(control):
|
|
96
|
-
if line.startswith("nomit="):
|
|
97
|
-
control[i] = f"nomit={len(ignore)}"
|
|
98
|
-
for j, met in enumerate(ignore):
|
|
99
|
-
control.insert(i + j + 1, f"chomit({j + 1})='{met}'")
|
|
100
|
-
break
|
|
81
|
+
control = [line for line in fh.read().splitlines()
|
|
82
|
+
if not line.startswith(("chomit(", "nomit="))]
|
|
83
|
+
|
|
84
|
+
set_key(control, "filbas", f"'{os.path.abspath(path2basis)}'")
|
|
85
|
+
set_key(control, "ppmst", ppmlim[1])
|
|
86
|
+
set_key(control, "ppmend", ppmlim[0])
|
|
87
|
+
for line in _omit_lines(ignore):
|
|
88
|
+
key, _, value = line.partition("=")
|
|
89
|
+
set_key(control, key, value)
|
|
101
90
|
return control
|
|
102
91
|
|
|
103
92
|
|
|
@@ -105,16 +94,12 @@ def load_control(control_path: str, path2basis: str, ppmlim: Tuple[float, float]
|
|
|
105
94
|
# set a key in a control set #
|
|
106
95
|
#********************************#
|
|
107
96
|
def set_key(control: List[str], key: str, value) -> List[str]:
|
|
108
|
-
"""Set "key=value" in place; append if the key is absent
|
|
97
|
+
"""Set "key=value" in place; insert before "$END" (or append) if the key is absent."""
|
|
109
98
|
prefix = f"{key}="
|
|
110
99
|
for i, line in enumerate(control):
|
|
111
100
|
if line.startswith(prefix):
|
|
112
101
|
control[i] = f"{key}={value}"
|
|
113
102
|
return control
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
if line.strip() == "$END":
|
|
117
|
-
control.insert(i, f"{key}={value}")
|
|
118
|
-
return control
|
|
119
|
-
control.append(f"{key}={value}")
|
|
103
|
+
end = next((i for i, line in enumerate(control) if line.strip() == "$END"), len(control))
|
|
104
|
+
control.insert(end, f"{key}={value}")
|
|
120
105
|
return control
|
|
@@ -26,8 +26,6 @@ import numpy as np
|
|
|
26
26
|
from .io import read_jmrui_txt, jmrui_metadata
|
|
27
27
|
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
29
|
#*******************#
|
|
32
30
|
# .basis writer #
|
|
33
31
|
#*******************#
|
|
@@ -85,8 +83,8 @@ class ParsedBasis(NamedTuple):
|
|
|
85
83
|
echot: Optional[float]
|
|
86
84
|
|
|
87
85
|
|
|
88
|
-
def
|
|
89
|
-
"""
|
|
86
|
+
def _validate(parsed: ParsedBasis) -> None:
|
|
87
|
+
"""Consistent, non-empty point counts across all metabolites."""
|
|
90
88
|
if not parsed.fids:
|
|
91
89
|
raise ValueError("No metabolite FIDs were found in the input.")
|
|
92
90
|
n = parsed.fids[0].size
|
|
@@ -108,18 +106,17 @@ def _read_jmrui_folder(folder: str) -> ParsedBasis:
|
|
|
108
106
|
raise ValueError(f"No .txt files found in jMRUI folder: {folder}")
|
|
109
107
|
|
|
110
108
|
names, fids = [], []
|
|
111
|
-
dwell = central =
|
|
109
|
+
dwell = central = None
|
|
112
110
|
for f in files:
|
|
113
111
|
fid, meta = read_jmrui_txt(os.path.join(folder, f))
|
|
114
112
|
if fid.size == 0:
|
|
115
113
|
continue
|
|
116
|
-
d, c
|
|
114
|
+
d, c = jmrui_metadata(meta)
|
|
117
115
|
dwell = dwell if dwell is not None else d
|
|
118
116
|
central = central if central is not None else c
|
|
119
|
-
echot = echot if echot is not None else e
|
|
120
117
|
names.append(os.path.splitext(f)[0])
|
|
121
118
|
fids.append(fid)
|
|
122
|
-
return ParsedBasis(names, fids, dwell, central,
|
|
119
|
+
return ParsedBasis(names, fids, dwell, central, None)
|
|
123
120
|
|
|
124
121
|
|
|
125
122
|
#**************************#
|
|
@@ -209,10 +206,7 @@ def _mat_attr(struct, *candidates):
|
|
|
209
206
|
|
|
210
207
|
|
|
211
208
|
def _read_mat(path: str) -> ParsedBasis:
|
|
212
|
-
|
|
213
|
-
from scipy.io import loadmat
|
|
214
|
-
except ImportError as exc: # pragma: no cover
|
|
215
|
-
raise ImportError("Reading .mat basis sets requires scipy.") from exc
|
|
209
|
+
from scipy.io import loadmat
|
|
216
210
|
try:
|
|
217
211
|
mat = loadmat(path, squeeze_me=True, struct_as_record=False)
|
|
218
212
|
except NotImplementedError as exc: # MATLAB v7.3 (HDF5)
|
|
@@ -331,7 +325,7 @@ def convert_to_basis(path: str, out_path: Optional[str] = None, fmt: Optional[st
|
|
|
331
325
|
raise ValueError(f"Unknown basis format '{fmt}'. Choose from {sorted(_READERS)}.")
|
|
332
326
|
|
|
333
327
|
parsed = _READERS[key](path)
|
|
334
|
-
|
|
328
|
+
_validate(parsed)
|
|
335
329
|
|
|
336
330
|
d = parsed.dwell if parsed.dwell is not None else dwell
|
|
337
331
|
c = parsed.central if parsed.central is not None else central_freq
|
|
@@ -361,4 +355,3 @@ def ensure_basis(path: str, out_path: Optional[str] = None, fmt: Optional[str] =
|
|
|
361
355
|
return convert_to_basis(
|
|
362
356
|
path, out_path=out_path, fmt=fmt, dwell=dwell, central_freq=central_freq
|
|
363
357
|
)
|
|
364
|
-
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
####################################################################################################
|
|
2
|
+
# coord.py #
|
|
3
|
+
####################################################################################################
|
|
4
|
+
# #
|
|
5
|
+
# Authors: J. P. Merkofer (j.p.merkofer@tue.nl) #
|
|
6
|
+
# #
|
|
7
|
+
# Created: 26/06/26 #
|
|
8
|
+
# #
|
|
9
|
+
# Purpose: Parsers for LCModel ".coord" output files: the concentration / CRLB table, basic QC #
|
|
10
|
+
# metrics, and the fitted spectral series (data, fit, baseline, ppm axis). #
|
|
11
|
+
# #
|
|
12
|
+
####################################################################################################
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
_NUMBER = r"[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?"
|
|
19
|
+
|
|
20
|
+
# One row of the concentration table: " 7.20E-08 166% 3.9E-02 Ala". The columns are
|
|
21
|
+
# fixed-width, so a wide ratio runs straight into the metabolite name ("0.659Cr+PCr");
|
|
22
|
+
# the regex therefore allows no whitespace between the two.
|
|
23
|
+
_CONC_ROW = re.compile(rf"^\s*({_NUMBER})\s+(\d+)%\s+({_NUMBER})\s*(\S+)\s*$")
|
|
24
|
+
|
|
25
|
+
# Markers that open a block of numbers in the fitted-series part of the file.
|
|
26
|
+
_SERIES = (
|
|
27
|
+
("ppm", re.compile(r"points on ppm-axis = NY")),
|
|
28
|
+
("data", re.compile(r"NY phased data points follow")),
|
|
29
|
+
("completeFit", re.compile(r"NY points of the fit to the data follow")),
|
|
30
|
+
("baseline", re.compile(r"NY background values follow")),
|
|
31
|
+
)
|
|
32
|
+
_SERIES_END = re.compile(r"lines in following|^[ ]+[a-zA-Z0-9]+[ ]+Conc\. = [-+.E0-9]+$")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
#*****************************#
|
|
36
|
+
# load LCModel coord data #
|
|
37
|
+
#*****************************#
|
|
38
|
+
def read_coord(path, coord=True, meta=True):
|
|
39
|
+
"""Read an LCModel ".coord" file.
|
|
40
|
+
|
|
41
|
+
Returns the concentration table (metabolites, concentrations, %SD/CRLBs, /ref ratios)
|
|
42
|
+
and/or the misc. QC metrics (FWHM, S/N, shift, phase) depending on "coord"/"meta".
|
|
43
|
+
"""
|
|
44
|
+
metabs, concs, crlbs, tcr = [], [], [], []
|
|
45
|
+
fwhm = snr = shift = phase = None
|
|
46
|
+
|
|
47
|
+
with open(path, "r") as fh:
|
|
48
|
+
conc_rows = misc_rows = 0
|
|
49
|
+
for line in fh:
|
|
50
|
+
if "lines in following concentration table" in line:
|
|
51
|
+
conc_rows = int(line.split(" lines")[0])
|
|
52
|
+
elif conc_rows > 0:
|
|
53
|
+
conc_rows -= 1
|
|
54
|
+
if line.split()[:1] == ["Conc."]: # header row
|
|
55
|
+
continue
|
|
56
|
+
m = _CONC_ROW.match(line)
|
|
57
|
+
if m is None:
|
|
58
|
+
raise ValueError(f"Could not parse concentration row: {line.strip()!r}")
|
|
59
|
+
concs.append(float(m.group(1)))
|
|
60
|
+
crlbs.append(int(m.group(2)))
|
|
61
|
+
tcr.append(float(m.group(3)))
|
|
62
|
+
metabs.append(m.group(4))
|
|
63
|
+
elif "lines in following misc. output table" in line:
|
|
64
|
+
misc_rows = int(line.split(" lines")[0])
|
|
65
|
+
elif misc_rows > 0:
|
|
66
|
+
misc_rows -= 1
|
|
67
|
+
values = line.split()
|
|
68
|
+
if "FWHM" in values:
|
|
69
|
+
fwhm = float(values[2])
|
|
70
|
+
snr = float(values[-1].split("=")[-1])
|
|
71
|
+
elif "shift" in values:
|
|
72
|
+
# a negative shift fuses with the "=": "shift =-0.012 ppm"
|
|
73
|
+
shift = float(values[2][1:]) if values[3] == "ppm" else float(values[3])
|
|
74
|
+
elif "Ph" in values:
|
|
75
|
+
phase = float(values[1])
|
|
76
|
+
|
|
77
|
+
if coord and meta:
|
|
78
|
+
return metabs, concs, crlbs, tcr, fwhm, snr, shift, phase
|
|
79
|
+
if coord:
|
|
80
|
+
return metabs, concs, crlbs, tcr
|
|
81
|
+
return fwhm, snr, shift, phase
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
#**************************************#
|
|
85
|
+
# load LCModel fit from coord data #
|
|
86
|
+
#**************************************#
|
|
87
|
+
def read_fit(path):
|
|
88
|
+
"""Read the fitted spectral series from an LCModel ".coord" file.
|
|
89
|
+
|
|
90
|
+
Returns a dict with keys "ppm", "data", "completeFit" and "baseline".
|
|
91
|
+
Source: https://gist.github.com/alexcraven/3db2c09f14ec489a31df81dc7b5a0f9c
|
|
92
|
+
"""
|
|
93
|
+
series = {}
|
|
94
|
+
current, values = None, []
|
|
95
|
+
|
|
96
|
+
def flush():
|
|
97
|
+
if current and values:
|
|
98
|
+
series[current] = np.array(values)
|
|
99
|
+
|
|
100
|
+
with open(path) as fh:
|
|
101
|
+
for line in fh:
|
|
102
|
+
new = next((key for key, pat in _SERIES if pat.search(line)), current)
|
|
103
|
+
if _SERIES_END.search(line):
|
|
104
|
+
new = None
|
|
105
|
+
if new != current:
|
|
106
|
+
flush()
|
|
107
|
+
current, values = new, []
|
|
108
|
+
elif current:
|
|
109
|
+
for token in re.findall(r"[-+.E0-9]+", line):
|
|
110
|
+
try:
|
|
111
|
+
values.append(float(token))
|
|
112
|
+
except ValueError:
|
|
113
|
+
pass
|
|
114
|
+
flush()
|
|
115
|
+
return series
|