Skip to content

Commit 2f49528

Browse files
committed
Merge branch 'develop' into 'main'
Patch Release v0.2.2 See merge request e040/e0404/pyRadPlan!65
2 parents f3d8785 + b31e2ae commit 2f49528

File tree

15 files changed

+393
-57
lines changed

15 files changed

+393
-57
lines changed

.github/workflows/tests.yml

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
name: Python package
2+
3+
on:
4+
push:
5+
branches:
6+
- '**'
7+
pull_request:
8+
branches:
9+
- main
10+
- develop
11+
workflow_dispatch:
12+
13+
jobs:
14+
test:
15+
runs-on: ubuntu-latest
16+
strategy:
17+
fail-fast: false
18+
matrix:
19+
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
20+
21+
steps:
22+
- uses: actions/checkout@v4
23+
24+
- name: Setup Python ${{ matrix.python-version }}
25+
uses: actions/setup-python@v5
26+
with:
27+
python-version: ${{ matrix.python-version }}
28+
cache: pip
29+
30+
- name: Install dependencies
31+
run: |
32+
python -m pip install --upgrade pip
33+
python -m pip install pytest pytest-cov ruff
34+
python -m pip install -e .
35+
36+
- name: Run tests with pytest
37+
run: pytest test --junitxml=.testreports/report.xml -o junit_family=legacy --cov=pyRadPlan --cov-report term --cov-report xml:.testreports/coverage.xml --cov-report html:.testreports/html
38+
39+
- name: Upload test reports to Codecov
40+
uses: codecov/test-results-action@v1
41+
with:
42+
token: ${{ secrets.CODECOV_TOKEN }}
43+
flags: 'py${{ matrix.python-version }}'
44+
files: .testreports/report.xml
45+
46+
- name: Upload coverage reports to Codecov
47+
if: matrix.python-version == '3.11'
48+
uses: codecov/codecov-action@v5
49+
with:
50+
token: ${{ secrets.CODECOV_TOKEN }}
51+
files: .testreports/coverage.xml # optional
52+
53+
lint:
54+
runs-on: ubuntu-latest
55+
steps:
56+
- uses: actions/checkout@v4
57+
- name: Setup Python 3.11
58+
uses: actions/setup-python@v5
59+
with:
60+
python-version: 3.12
61+
cache: pip
62+
- name: Install dependencies
63+
run: |
64+
python -m pip install --upgrade pip
65+
python -m pip install ruff
66+
python -m pip install -e .
67+
- name: Lint with ruff
68+
run: ruff check --output-format=github
69+
continue-on-error: true
70+
- name: Check formatting with ruff
71+
run: ruff format --check

.gitlab-ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ docs:
111111
- .cache/pip/
112112
policy: pull
113113
script:
114+
- source .venv/bin/activate
114115
- pip install sphinx
115116
- pip install sphinx-autodoc-typehints
116117
- pip install autodoc_pydantic

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
11
# pyRadPlan
2+
[![Tests](https://github.com/e0404/pyRadPlan/actions/workflows/tests.yml/badge.svg)](https://github.com/e0404/pyRadPlan/actions/workflows/tests.yml)
3+
[![codecov](https://codecov.io/gh/e0404/pyRadPlan/graph/badge.svg?token=S1KCYDU17G)](https://codecov.io/gh/e0404/pyRadPlan)
4+
[![pypi version](https://img.shields.io/pypi/v/pyRadPlan)](https://pypi.org/project/pyRadPlan/)
5+
![pyversion](https://img.shields.io/pypi/pyversions/pyRadPlan)
6+
![contributors](https://img.shields.io/github/contributors-anon/e0404/pyRadPlan)
7+
[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
8+
![License](https://img.shields.io/github/license/e0404/pyRadPlan)
9+
10+
11+
212
pyRadPlan is an open-source radiotherapy treatment planning toolkit designed for interoperability with [matRad](http://www.matRad.org).
313

414
Development is lead by the [Radiotherapy Optimization group](https://www.dkfz.de/radopt) at the [German Cancer Research Center (DKFZ)](https://www.dkfz.de)

docs/conf.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,17 @@
77
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
88
import os
99
import sys
10+
from pyRadPlan import __version__
1011

1112
print(os.path.abspath("../pyRadPlan"))
1213
sys.path.insert(0, os.path.abspath("../pyRadPlan")) # Adjust to your source folder
1314

1415
project = "pyRadPlan"
1516
copyright = "2024, e0404"
1617
author = "e0404"
17-
release = "0.0.1"
18+
19+
version = __version__
20+
release = __version__
1821

1922
# -- General configuration ---------------------------------------------------
2023
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration

pyRadPlan/core/datamodel.py

Lines changed: 56 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
1-
"""Module datamodel.py
2-
Basic Model for all pyRadPlan Datastructures.
3-
"""
1+
"""Basic Model for all pyRadPlan Datastructures."""
42

53
from typing import Any, Union
4+
import numpy as np
65
from pydantic import (
76
AliasGenerator,
87
BaseModel,
@@ -14,16 +13,17 @@
1413

1514
class PyRadPlanBaseModel(BaseModel):
1615
"""
17-
Base class for all pyRadPlan data structures, especially the ones that
18-
should be matRad
19-
compatible
20-
This class extends Pydantic's BaseModel.
16+
Base class for all pyRadPlan data structures.
17+
18+
Especially useful for structures that should be matRad compatible.
19+
Extends Pydantic's BaseModel to use pydantic validation and serialization.
2120
2221
Attributes
2322
----------
2423
model_config : ConfigDict
25-
Configuration for the model, including alias generation, population by name, arbitrary
26-
types allowed, assignment validation, and attribute creation from dictionary.
24+
Configuration for the model, including alias generation, population by
25+
name, arbitrary types allowed, assignment validation, and attribute
26+
creation from dictionary.
2727
"""
2828

2929
model_config = ConfigDict(
@@ -35,15 +35,56 @@ class PyRadPlanBaseModel(BaseModel):
3535
from_attributes=True, # Allows to create a model from a dictionary
3636
)
3737

38+
def __eq__(self, other: Any) -> bool:
39+
"""
40+
Specialized __eq__ method to compare two pyRadPlanBaseModel instances.
41+
42+
It first tries to compare the instances using the super().__eq__ method.
43+
If this fails, it compares the dictionaries. This is due to some issues
44+
comparing numpy arrays within the models.
45+
"""
46+
try:
47+
return super().__eq__(other)
48+
except ValueError:
49+
if self.__dict__.keys() != other.__dict__.keys():
50+
return False
51+
stack = [(self.__dict__, other.__dict__)]
52+
while stack:
53+
dict_a, dict_b = stack.pop()
54+
if dict_a.keys() != dict_b.keys():
55+
return False
56+
for key in dict_a:
57+
if isinstance(dict_a[key], dict) and isinstance(dict_b[key], dict):
58+
stack.append((dict_a[key], dict_b[key]))
59+
elif isinstance(dict_a[key], np.ndarray) and isinstance(
60+
dict_b[key], np.ndarray
61+
):
62+
if not np.array_equal(dict_a[key], dict_b[key]):
63+
return False
64+
elif dict_a[key] != dict_b[key]:
65+
return False
66+
return True
67+
68+
def __ne__(self, other: Any) -> bool:
69+
"""
70+
Specialized __ne__ method to compare two PyRadPlanBaseModel instances.
71+
72+
This method returns the negation of the __eq__ method.
73+
"""
74+
if isinstance(other, self.__class__):
75+
return not self.__eq__(other)
76+
else:
77+
return True
78+
3879
def to_matrad(self, context: Union[str, dict] = "mat-file") -> Any:
3980
"""
40-
Interface method to serialize a pyradplan datastructure to be matRad
41-
compatible.
81+
Perform matRad compatible serialization.
4282
4383
Parameters
4484
----------
4585
context : str, optional
46-
The context in which the datastructure should be serialized, by default 'mat-file'.
86+
The context in which the datastructure should be serialized,
87+
by default 'mat-file'.
4788
4889
Returns
4990
-------
@@ -52,9 +93,9 @@ def to_matrad(self, context: Union[str, dict] = "mat-file") -> Any:
5293
5394
Notes
5495
-----
55-
Currently, the only supported context is 'mat-file'. In the future, this could be
56-
extended to support other contexts, such as direct calling via the matlab engine or
57-
oct2py.
96+
Currently, the only supported context is 'mat-file'. In the future,
97+
this could be extended to support other contexts, such as direct
98+
calling via the matlab engine or oct2py.
5899
"""
59100
self_copy = deepcopy(self)
60101
if isinstance(context, dict):

pyRadPlan/dij/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Module for Dij Datamodel and related functions."""
22

33
from pyRadPlan.dij._dij import Dij, create_dij, validate_dij
4+
from pyRadPlan.dij._compose_beam_dijs import compose_beam_dijs
45

5-
__all__ = ["Dij", "create_dij", "validate_dij"]
6+
__all__ = ["Dij", "create_dij", "validate_dij", "compose_beam_dijs"]
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import numpy as np
2+
3+
from scipy.sparse import hstack
4+
from ._dij import Dij
5+
6+
7+
def compose_beam_dijs(dijs: list[Dij]) -> Dij:
8+
"""Compose multiple Dij objects into a single Dij object.
9+
10+
Parameters
11+
----------
12+
dijs : list[Dij]
13+
List of Dij objects to be composed.
14+
15+
Returns
16+
-------
17+
Dij
18+
Composed Dij object.
19+
"""
20+
21+
qs = dijs[0].quantities
22+
23+
num_of_beams = 0
24+
beam_num = np.array([], dtype=int)
25+
ray_num = np.array([], dtype=int)
26+
bixel_num = np.array([], dtype=int)
27+
28+
for i, dij in enumerate(dijs):
29+
beam_num = np.append(beam_num, dij.beam_num + num_of_beams)
30+
ray_num = np.append(ray_num, dij.ray_num)
31+
bixel_num = np.append(bixel_num, dij.bixel_num)
32+
num_of_beams = num_of_beams + dij.num_of_beams
33+
34+
matrices = {}
35+
for q in qs:
36+
tmp_matrices = [getattr(dij, q) for dij in dijs]
37+
38+
new_matrix = np.empty_like(tmp_matrices[0], dtype=object)
39+
40+
for i, scen_matrix in enumerate(tmp_matrices[0].flat):
41+
if scen_matrix is not None:
42+
new_matrix.flat[i] = hstack([tmp_matrix.flat[i] for tmp_matrix in tmp_matrices])
43+
44+
matrices.update({q: new_matrix})
45+
46+
return Dij(
47+
ct_grid=dijs[0].ct_grid,
48+
dose_grid=dijs[0].dose_grid,
49+
matrices=matrices,
50+
num_of_beams=num_of_beams,
51+
beam_num=beam_num,
52+
ray_num=ray_num,
53+
bixel_num=bixel_num,
54+
**matrices
55+
)

pyRadPlan/dij/_dij.py

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,13 @@ def total_num_of_bixels(self) -> int:
6262
def num_of_voxels(self) -> int:
6363
"""Number of voxels in the dose influence matrix."""
6464
return self.physical_dose.flat[0].shape[0]
65+
66+
@computed_field
67+
@property
68+
def quantities(self) -> list[str]:
69+
"""Name of available uantities matrices."""
70+
potential_quantities = ["physical_dose", "let_dose", "alpha_dose", "sqrt_beta_dose"]
71+
return [q for q in potential_quantities if getattr(self, q) is not None]
6572

6673
@field_validator("physical_dose", "let_dose", "alpha_dose", "sqrt_beta_dose", mode="before")
6774
@classmethod
@@ -119,6 +126,25 @@ def validate_grid(cls, grid: Union[Grid, dict]) -> Union[Grid, dict]:
119126
grid = Grid.model_validate(grid)
120127
return grid
121128

129+
@field_validator("beam_num", mode="before")
130+
@classmethod
131+
def validate_unique_indices_in_beam_num(
132+
cls, v: np.ndarray, info: ValidationInfo
133+
) -> np.ndarray:
134+
"""
135+
Validate the number of unique indices in beam_num.
136+
137+
Raises
138+
------
139+
ValueError: Number of unique indices does not match number of beams.
140+
"""
141+
num_of_beams = info.data["num_of_beams"]
142+
if len(np.unique(v)) != num_of_beams:
143+
raise ValueError(
144+
"Number of unique indices in beam_num does not match number of beams."
145+
)
146+
return v
147+
122148
@field_validator("beam_num", "ray_num", "bixel_num", mode="after")
123149
@classmethod
124150
def validate_numbering_arrays(cls, v: np.ndarray, info: ValidationInfo) -> np.ndarray:
@@ -146,25 +172,8 @@ def validate_numbering_arrays(cls, v: np.ndarray, info: ValidationInfo) -> np.nd
146172
"Numbering arrays shape inconsistent with number of bixels"
147173
)
148174

149-
return v
150-
151-
@field_validator("beam_num")
152-
@classmethod
153-
def validate_unique_indices_in_beam_num(
154-
cls, v: np.ndarray, info: ValidationInfo
155-
) -> np.ndarray:
156-
"""
157-
Validate the number of unique indices in beam_num.
158-
159-
Raises
160-
------
161-
ValueError: Number of unique indices does not match number of beams.
162-
"""
163-
num_of_beams = info.data["num_of_beams"]
164-
if len(np.unique(v)) != num_of_beams:
165-
raise ValueError(
166-
"Number of unique indices in beam_num does not match number of beams."
167-
)
175+
if info.context and "from_matRad" in info.context and info.context["from_matRad"]:
176+
v -= 1
168177
return v
169178

170179
# Serialization
@@ -346,7 +355,12 @@ def create_dij(data: Union[dict[str, Any], Dij, None] = None, **kwargs) -> Dij:
346355
if isinstance(data, Dij):
347356
return data
348357

349-
return Dij.model_validate(data)
358+
if "beamNum" in data and np.min(data["beamNum"]) != 0:
359+
# add context when from matRad
360+
context = {"from_matRad": True}
361+
else:
362+
context = {"from_matRad": False}
363+
return Dij.model_validate(data, context=context)
350364

351365
return Dij(**kwargs)
352366

0 commit comments

Comments
 (0)