Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "ismrmrd-python conda",
"image": "condaforge/miniforge3:24.11.3-0",
"postCreateCommand": "conda install -y conda-build anaconda-client"
}
11 changes: 11 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
version: 2
updates:
- package-ecosystem: pip
directory: /
schedule:
interval: monthly

- package-ecosystem: github-actions
directory: /
schedule:
interval: monthly
21 changes: 15 additions & 6 deletions .github/workflows/ismrmrd_python_conda.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Publish Conda package
name: Conda

on:
push:
Expand All @@ -10,25 +10,34 @@ on:

jobs:
build-conda-packages:
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
name: Build and publish conda package
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5

- uses: conda-incubator/setup-miniconda@v3
with:
miniforge-version: latest
activate-environment: ismrmrd-python-build
environment-file: conda/environment.yml
python-version: 3.12
auto-activate-base: false

- name: Check generated schema files are up to date
shell: bash -l {0}
run: |
pip install ".[dev]" -q
python setup.py generate_schema
git diff --exit-code ismrmrd/xsd/ismrmrdschema/ || \
(echo "ERROR: Generated schema files are out of date. Run 'python setup.py generate_schema' and commit the result." && exit 1)

- name: Build conda package
shell: bash -l {0}
working-directory: conda
run: |
./package.sh
echo "Packages built: $(find build_pkg -name ismrmrd-python*.tar.bz2)"
echo "Packages built: $(find build_pkg -name 'ismrmrd-python*.conda' -or -name 'ismrmrd-python*.tar.bz2')"

- name: Push conda package
shell: bash -l {0}
if: github.event_name == 'workflow_dispatch' || github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
Expand Down
52 changes: 40 additions & 12 deletions .github/workflows/python-publish.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Publish Python 🐍 distributions 📦 to PyPI and TestPyPI
name: Python

on:
push:
Expand All @@ -9,35 +9,63 @@ on:
workflow_dispatch:

jobs:
build:
name: Build and publish Python 🐍 distributions 📦 to PyPI and TestPyPI
test:
name: Test with Python ${{ matrix.python-version }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v5
- name: Set up Python 3.x

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: 3.12
- name: Install runtime dependencies
run: python -m pip install -r requirements.txt --user
- name: Perform editable installation to generate the schema subpackage
run: python -m pip install -e .
python-version: ${{ matrix.python-version }}

- name: Install package and dev dependencies
run: python -m pip install -e ".[dev]"

- name: Check generated schema files are up to date
if: matrix.python-version == '3.12'
run: |
python setup.py generate_schema
git diff --exit-code ismrmrd/xsd/ismrmrdschema/ || \
(echo "ERROR: Generated schema files are out of date. Run 'python setup.py generate_schema' and commit the result." && exit 1)

- name: Run library tests
run: python -m pytest

- name: Run end-to-end tests
run: bash tests/end-to-end/test-reconstruction.sh
- name: Install pypa/build
run: python -m pip install build --user

build:
name: Build PyPI package
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v5

- name: Set up Python 3.x
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install package and dev dependencies
run: python -m pip install -e ".[dev]"

- name: Build a binary wheel and a source tarball
run: python -m build --sdist --wheel --outdir dist/ .

- name: Store the distribution packages
uses: actions/upload-artifact@v4
with:
name: python-package-distributions
path: dist/

publish-to-pypi:
name: Publish Python 🐍 distribution 📦 to PyPI
name: Publish PyPI package
if: github.event_name == 'workflow_dispatch' || github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
needs:
- build
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ dist/
*.egg-info
xsd.py
.eggs
.idea
.idea
.venv/
54 changes: 54 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Changelog

## v1.15.0 (unreleased)

### Breaking changes

- `Image.from_array()` default changed from `transpose=True` to `transpose=False`.
The old default silently transposed array data to match a legacy column-major
convention inconsistent with the C++ library. Passing `transpose=True`
explicitly now raises a `DeprecationWarning`.
- `Image.matrix_size` now returns the header's `(x, y, z)` tuple. Previously
returned `(z, y, x)` from the data array shape, inconsistent with the header
and the C++ API.
- `requires-python` bumped to `>=3.10` (`@dataclass(kw_only=True)` in the
xsdata-generated schema code requires 3.10+).

### New features

- `ismrmrd.util`: new module with `sign_of_directions`,
`directions_to_quaternion`, and `quaternion_to_directions` (all three
available at the top-level package).
- `AcquisitionHeader` and `Acquisition` gained channel-mask methods:
`isChannelActive`, `setChannelActive`, `setChannelNotActive`,
`setAllChannelsNotActive` (matches C++ `ISMRMRD_ChannelMask`).
- `ProtocolDeserializer` gained `peek()` and `peek_image_data_type()` for
non-consuming look-ahead.
- `ProtocolSerializer`/`ProtocolDeserializer` handle `CONFIG_FILE` (ID 1) and
`CONFIG_TEXT` (ID 2) message types.
- `ismrmrd.__version__` exposed via `importlib.metadata`.

### Bug fixes

- `Waveform.setHead()` called non-existent `active_channels` field; corrected
to `channels`.

### Infrastructure

- xsdata-generated schema files committed to the repo; no longer regenerated at
install time. Run `python setup.py generate_schema` to update them.
- CI (both PyPI and conda workflows) now fails fast if committed schema files
diverge from a fresh regeneration.
- `requirements.txt` removed; dev setup: `pip install -e ".[dev]"`.
- Python version matrix added to CI (3.10, 3.11, 3.12, 3.13).
- `dependabot.yml` added for monthly pip and GitHub Actions updates.

## v1.14.2

- Fix compatibility with xsdata 26.1.
- Enable re-building and publishing Conda package.
- Remove deprecated macos-13 runner.

## v1.14.1 and earlier

See git history.
3 changes: 0 additions & 3 deletions conda/conda_build_config.yaml

This file was deleted.

10 changes: 6 additions & 4 deletions conda/meta.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,20 @@ package:
source:
path: ../

build:
noarch: python

requirements:
build:
- python {{ python }}
- xsdata>=24.0
- python
- setuptools
- pip

run:
- python
- python >=3.10
- numpy>=1.22.0
- h5py>=2.3
- xsdata>=24.0
- xsdata>=26.1

test:
imports:
Expand Down
9 changes: 8 additions & 1 deletion ismrmrd/__init__.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
from importlib.metadata import version as _metadata_version, PackageNotFoundError as _PackageNotFoundError
try:
__version__ = _metadata_version("ismrmrd")
except _PackageNotFoundError:
__version__ = "unknown"

from .constants import *
from .acquisition import AcquisitionHeader, Acquisition, EncodingCounters
from .util import sign_of_directions, directions_to_quaternion, quaternion_to_directions
from .image import ImageHeader, Image
from .hdf5 import Dataset
from .meta import Meta
from .waveform import WaveformHeader, Waveform
from .file import File
from .serialization import ProtocolSerializer, ProtocolDeserializer
from .serialization import ProtocolSerializer, ProtocolDeserializer, ConfigFile, ConfigText

from . import xsd

Expand Down
6 changes: 3 additions & 3 deletions ismrmrd/acquisition.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import io

from .constants import *
from .flags import FlagsMixin
from .flags import FlagsMixin, ChannelMaskMixin
from .equality import EqualityMixin
from . import decorators

Expand Down Expand Up @@ -33,7 +33,7 @@ def __str__(self):
return retstr


class AcquisitionHeader(FlagsMixin, EqualityMixin, ctypes.Structure):
class AcquisitionHeader(FlagsMixin, ChannelMaskMixin, EqualityMixin, ctypes.Structure):
_pack_ = 2
_fields_ = [("version", ctypes.c_uint16),
("flags", ctypes.c_uint64),
Expand Down Expand Up @@ -71,7 +71,7 @@ def __str__(self):


@decorators.expose_header_fields(AcquisitionHeader)
class Acquisition(FlagsMixin):
class Acquisition(FlagsMixin, ChannelMaskMixin):
_readonly = ('number_of_samples', 'active_channels', 'trajectory_dimensions')

@staticmethod
Expand Down
33 changes: 33 additions & 0 deletions ismrmrd/flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,36 @@ def set_flag(self, flag):
def clear_flag(self, flag):
self.flags &= ~(1 << (flag - 1))


class ChannelMaskMixin(object):
"""Mixin providing channel mask helpers for structs with a channel_mask field.

channel_mask is uint64[16], supporting up to 1024 channels.
Channel N lives in word N // 64, bit position N % 64.
"""

_MAX_CHANNELS = 1024

def _check_channel_id(self, channel_id):
if not (0 <= channel_id < self._MAX_CHANNELS):
raise ValueError(f"channel_id {channel_id} is out of range [0, {self._MAX_CHANNELS})")

def isChannelActive(self, channel_id):
self._check_channel_id(channel_id)
word, bit = divmod(channel_id, 64)
return bool(self.channel_mask[word] & (1 << bit))

def setChannelActive(self, channel_id):
self._check_channel_id(channel_id)
word, bit = divmod(channel_id, 64)
self.channel_mask[word] |= (1 << bit)

def setChannelNotActive(self, channel_id):
Comment thread
naegelejd marked this conversation as resolved.
self._check_channel_id(channel_id)
word, bit = divmod(channel_id, 64)
self.channel_mask[word] &= ~(1 << bit)

def setAllChannelsNotActive(self):
for i in range(len(self.channel_mask)):
self.channel_mask[i] = 0

Loading
Loading