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.
Files changed (26) hide show
  1. {lcmodel_wrapper-0.3.0 → lcmodel_wrapper-0.3.1}/PKG-INFO +10 -28
  2. {lcmodel_wrapper-0.3.0 → lcmodel_wrapper-0.3.1}/README.md +9 -27
  3. {lcmodel_wrapper-0.3.0 → lcmodel_wrapper-0.3.1}/lcmodel_wrapper/__init__.py +1 -0
  4. {lcmodel_wrapper-0.3.0 → lcmodel_wrapper-0.3.1}/lcmodel_wrapper/_version.py +1 -1
  5. {lcmodel_wrapper-0.3.0 → lcmodel_wrapper-0.3.1}/lcmodel_wrapper/basis.py +3 -3
  6. {lcmodel_wrapper-0.3.0 → lcmodel_wrapper-0.3.1}/lcmodel_wrapper/binaries.py +24 -37
  7. {lcmodel_wrapper-0.3.0 → lcmodel_wrapper-0.3.1}/lcmodel_wrapper/control.py +35 -50
  8. {lcmodel_wrapper-0.3.0 → lcmodel_wrapper-0.3.1}/lcmodel_wrapper/convert.py +7 -14
  9. lcmodel_wrapper-0.3.1/lcmodel_wrapper/coord.py +115 -0
  10. {lcmodel_wrapper-0.3.0 → lcmodel_wrapper-0.3.1}/lcmodel_wrapper/core.py +73 -118
  11. {lcmodel_wrapper-0.3.0 → lcmodel_wrapper-0.3.1}/lcmodel_wrapper/io.py +64 -97
  12. {lcmodel_wrapper-0.3.0 → lcmodel_wrapper-0.3.1}/lcmodel_wrapper.egg-info/PKG-INFO +10 -28
  13. lcmodel_wrapper-0.3.0/lcmodel_wrapper/coord.py +0 -136
  14. {lcmodel_wrapper-0.3.0 → lcmodel_wrapper-0.3.1}/LICENSE +0 -0
  15. {lcmodel_wrapper-0.3.0 → lcmodel_wrapper-0.3.1}/LICENSE.lcmodel +0 -0
  16. {lcmodel_wrapper-0.3.0 → lcmodel_wrapper-0.3.1}/MANIFEST.in +0 -0
  17. {lcmodel_wrapper-0.3.0 → lcmodel_wrapper-0.3.1}/NOTICE +0 -0
  18. {lcmodel_wrapper-0.3.0 → lcmodel_wrapper-0.3.1}/lcmodel_wrapper/container.py +0 -0
  19. {lcmodel_wrapper-0.3.0 → lcmodel_wrapper-0.3.1}/lcmodel_wrapper.egg-info/SOURCES.txt +0 -0
  20. {lcmodel_wrapper-0.3.0 → lcmodel_wrapper-0.3.1}/lcmodel_wrapper.egg-info/dependency_links.txt +0 -0
  21. {lcmodel_wrapper-0.3.0 → lcmodel_wrapper-0.3.1}/lcmodel_wrapper.egg-info/requires.txt +0 -0
  22. {lcmodel_wrapper-0.3.0 → lcmodel_wrapper-0.3.1}/lcmodel_wrapper.egg-info/top_level.txt +0 -0
  23. {lcmodel_wrapper-0.3.0 → lcmodel_wrapper-0.3.1}/pyproject.toml +0 -0
  24. {lcmodel_wrapper-0.3.0 → lcmodel_wrapper-0.3.1}/setup.cfg +0 -0
  25. {lcmodel_wrapper-0.3.0 → lcmodel_wrapper-0.3.1}/tests/test_binaries.py +0 -0
  26. {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.0
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
- The LCModel program is **not** part of this package and is **not** shipped in the wheel. On first use, the binary is resolved in this order:
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. an explicit `path2exec="/path/to/lcmodel"` you pass to `PyLCModel`,
72
- 2. a previously cached download/build (under `~/.cache/lcmodel_wrapper/<os>-<arch>/`, or `%LOCALAPPDATA%` on Windows; override the root with `LCMODEL_CACHE_DIR`),
73
- 3. a download of the matching binary for your OS/architecture from [schorschinho/LCModel](https://github.com/schorschinho/LCModel),
74
- 4. a download of the binary built by this repository's CI and attached to the [GitHub release](https://github.com/julianmer/PyLCModel/releases) matching the installed package version (Linux x86_64/aarch64 fully static, macOS arm64/x86_64 with libgfortran linked statically; each verified against its published SHA-256),
75
- 5. **a container** — if `docker` (or `podman`) is installed and running, the image `ghcr.io/julianmer/lcmodel` is pulled and a small launcher script is cached that runs LCModel from it (Linux, macOS, and Windows),
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
- Every candidate is **run once before it is accepted** — LCModel is asked to identify itself, and anything that cannot execute or does not answer is moved to `<cache>/quarantine/` so the next source gets a turn. This is what stops a wrong-architecture download from being cached and served forever. Set `LCMODEL_SKIP_VERIFY=1` to bypass the check, or `LCMODEL_VERIFY_TIMEOUT` to change its 60 s bound.
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
- The cache is keyed by architecture, so a home directory shared across a mixed-architecture cluster does not have nodes fighting over one file.
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:latest < control.file
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
- The LCModel program is **not** part of this package and is **not** shipped in the wheel. On first use, the binary is resolved in this order:
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. an explicit `path2exec="/path/to/lcmodel"` you pass to `PyLCModel`,
49
- 2. a previously cached download/build (under `~/.cache/lcmodel_wrapper/<os>-<arch>/`, or `%LOCALAPPDATA%` on Windows; override the root with `LCMODEL_CACHE_DIR`),
50
- 3. a download of the matching binary for your OS/architecture from [schorschinho/LCModel](https://github.com/schorschinho/LCModel),
51
- 4. a download of the binary built by this repository's CI and attached to the [GitHub release](https://github.com/julianmer/PyLCModel/releases) matching the installed package version (Linux x86_64/aarch64 fully static, macOS arm64/x86_64 with libgfortran linked statically; each verified against its published SHA-256),
52
- 5. **a container** — if `docker` (or `podman`) is installed and running, the image `ghcr.io/julianmer/lcmodel` is pulled and a small launcher script is cached that runs LCModel from it (Linux, macOS, and Windows),
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
- Every candidate is **run once before it is accepted** — LCModel is asked to identify itself, and anything that cannot execute or does not answer is moved to `<cache>/quarantine/` so the next source gets a turn. This is what stops a wrong-architecture download from being cached and served forever. Set `LCMODEL_SKIP_VERIFY=1` to bypass the check, or `LCMODEL_VERIFY_TIMEOUT` to change its 60 s bound.
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
- The cache is keyed by architecture, so a home directory shared across a mixed-architecture cluster does not have nodes fighting over one file.
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:latest < control.file
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
@@ -24,6 +24,7 @@ __all__ = [
24
24
  "from_nifti_mrs",
25
25
  "load_signals",
26
26
  "binaries",
27
+ "container",
27
28
  "io",
28
29
  "control",
29
30
  "coord",
@@ -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.0"
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.append(line)
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 the download path starts working with no release on our side; until then the
53
- # fetch 404s and resolution falls through to the gfortran build. The directory name
54
- # is what "uname -m" reports, matching the path the upstream Makefile derives.
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 _container_ready(cli: str, timeout: float = 30.0) -> Tuple[bool, str]:
512
- """Is the engine actually usable - daemon running, socket reachable, permissions ok?"""
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, "info"], stdin=subprocess.DEVNULL,
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 False, f"'{cli} info' did not answer within {timeout:.0f}s"
519
+ return -1, f"'{cli} {args[0]}' did not finish within {timeout:.0f}s"
519
520
  except OSError as err:
520
- return False, f"cannot run '{cli}': {err}"
521
- if proc.returncode != 0:
522
- lines = (proc.stderr or b"").decode("utf-8", "replace").strip().splitlines()
523
- return False, lines[-1] if lines else f"'{cli} info' exited {proc.returncode}"
524
- return True, "ok"
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
- if not _image_present(cli, image):
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
- try:
569
- proc = subprocess.run([cli, "pull", image], stdin=subprocess.DEVNULL,
570
- stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
571
- timeout=_PULL_TIMEOUT)
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
- ignore = resolve_ignore(ignore)
49
- lines = []
50
- lines.append("$LCMODL")
51
- lines.append(f"nunfil={n_points}") # data points
52
- lines.append(f"deltat={1.0 / bandwidth}") # dwell time
53
- lines.append(f"hzpppm={central_freq}") # field strength in MHz
54
- lines.append(f"ppmst={ppmlim[1]}")
55
- lines.append(f"ppmend={ppmlim[0]}")
56
-
57
- lines.append(f"dows={'T' if dows else 'F'}") # water scaling
58
- lines.append("neach=99") # plot each metabolite fit
59
-
60
- lines.append(f"filbas='{os.path.abspath(path2basis)}'")
61
- lines.append("filraw='example.raw'")
62
- lines.append("filps='example.ps'")
63
- lines.append("filcoo='example.coord'")
64
- lines.append("filh2o='example.h2o'")
65
-
66
- lines.append("lcoord=9") # 9 -> write coord file
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().split("\n")
84
-
85
- for i, line in enumerate(control):
86
- if line.startswith("filbas="):
87
- control[i] = f"filbas='{os.path.abspath(path2basis)}'"
88
-
89
- for i, line in enumerate(control):
90
- if line.startswith("ppmst="):
91
- control[i] = f"ppmst={ppmlim[1]}"
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 (before "$END")."""
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
- # insert before $END if present, else append
115
- for i, line in enumerate(control):
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 _stack(parsed: ParsedBasis) -> None:
89
- """Validate a ParsedBasis in place (consistent, non-empty point counts)."""
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 = echot = None
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, e = jmrui_metadata(meta)
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, echot)
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
- try:
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
- _stack(parsed)
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