diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..69ff892 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + pull_request: + +jobs: + lint-and-test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev,test]" + + - name: Run pre-commit + run: pre-commit run --all-files + + - name: Run pytest + run: pytest diff --git a/.readthedocs.yaml b/.readthedocs.yaml index eeb06bc..ca0cf3c 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -17,7 +17,7 @@ build: # Build documentation in the "docs/" directory with Sphinx sphinx: - configuration: pytheranostics/docs/source/conf.py + configuration: docs/source/conf.py # Optionally build your docs in additional formats such as PDF and ePub # formats: @@ -29,9 +29,7 @@ sphinx: # See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html python: install: - - requirements: requirements.txt - - requirements: pytheranostics/docs/requirements.txt - method: pip path: . - # - method: pip - # requirements: -r pytheranostics/docs/requirements.txt + extra_requirements: + - docs diff --git a/pytheranostics/docs/Makefile b/docs/Makefile similarity index 100% rename from pytheranostics/docs/Makefile rename to docs/Makefile diff --git a/pytheranostics/docs/make.bat b/docs/make.bat similarity index 100% rename from pytheranostics/docs/make.bat rename to docs/make.bat diff --git a/docs/source/API/modules.rst b/docs/source/API/modules.rst new file mode 100644 index 0000000..19e2f09 --- /dev/null +++ b/docs/source/API/modules.rst @@ -0,0 +1,84 @@ +DICOM Tools +=========== + +.. automodule:: pytheranostics.dicomtools.dicomtools + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: pytheranostics.dicomtools.dicom_receiver + :members: + :undoc-members: + :show-inheritance: + + +Calibrations +============ + +.. automodule:: pytheranostics.calibrations.gamma_camera + :members: + :undoc-members: + :show-inheritance: + + +Dosimetry +========= + +.. automodule:: pytheranostics.dosimetry.base_dosimetry + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: pytheranostics.dosimetry.bone_marrow + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: pytheranostics.dosimetry.dosiomicsclass + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: pytheranostics.dosimetry.dvk + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: pytheranostics.dosimetry.image_analysis + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: pytheranostics.dosimetry.mc + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: pytheranostics.dosimetry.olinda + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: pytheranostics.dosimetry.organ_s_dosimetry + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: pytheranostics.dosimetry.voxel_s_dosimetry + :members: + :undoc-members: + :show-inheritance: + + +Fitting +======= + +.. automodule:: pytheranostics.fits.fits + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: pytheranostics.fits.functions + :members: + :undoc-members: + :show-inheritance: diff --git a/pytheranostics/docs/source/_static/dosimetry_workflow.png b/docs/source/_static/dosimetry_workflow.png similarity index 100% rename from pytheranostics/docs/source/_static/dosimetry_workflow.png rename to docs/source/_static/dosimetry_workflow.png diff --git a/pytheranostics/docs/source/_static/logo.png b/docs/source/_static/logo.png similarity index 100% rename from pytheranostics/docs/source/_static/logo.png rename to docs/source/_static/logo.png diff --git a/pytheranostics/docs/source/changelog.rst b/docs/source/changelog.rst similarity index 100% rename from pytheranostics/docs/source/changelog.rst rename to docs/source/changelog.rst diff --git a/pytheranostics/docs/source/conf.py b/docs/source/conf.py similarity index 52% rename from pytheranostics/docs/source/conf.py rename to docs/source/conf.py index 186e511..e6201ff 100644 --- a/pytheranostics/docs/source/conf.py +++ b/docs/source/conf.py @@ -4,6 +4,7 @@ import sys sys.path.insert(0, os.path.abspath("../..")) +sys.path.insert(0, os.path.abspath(".")) # -- Project information ----------------------------------------------------- @@ -25,10 +26,20 @@ "sphinx.ext.viewcode", "sphinx.ext.githubpages", "sphinx.ext.mathjax", + "myst_parser", + "nbsphinx", + "sphinx_copybutton", ] templates_path = ["_templates"] -exclude_patterns = [] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store", "**/.ipynb_checkpoints"] + +source_suffix = { + ".rst": "restructuredtext", + ".md": "markdown", +} + +autodoc_mock_imports = ["radiomics", "gatetools", "itk"] # The name of the Pygments (syntax highlighting) style to use. pygments_style = "sphinx" @@ -52,3 +63,45 @@ napoleon_use_param = True napoleon_use_rtype = True napoleon_type_aliases = None + +nbsphinx_execute = "never" +nbsphinx_allow_errors = False +nbsphinx_codecell_lexer = "python" + +myst_enable_extensions = [ + "colon_fence", + "deflist", +] + +_CONTRIB_EXTENSION = "sphinxcontrib.contributors" +try: + __import__(_CONTRIB_EXTENSION) +except ImportError: # pragma: no cover - optional dependency + _contributors_available = False +else: + _contributors_available = True + extensions.append(_CONTRIB_EXTENSION) + + +def setup(app): + """Register a fallback contributors directive when the extension is missing.""" + if _contributors_available: + return + + from docutils import nodes + from docutils.parsers.rst import Directive + + class _ContributorsDirective(Directive): + has_content = False + required_arguments = 1 + + def run(self): + repo = self.arguments[0] + paragraph = nodes.paragraph() + paragraph += nodes.Text( + "Install 'sphinx-contributors' to render the contributors list. " + f"In the meantime see https://github.com/{repo}/graphs/contributors." + ) + return [paragraph] + + app.add_directive("contributors", _ContributorsDirective) diff --git a/pytheranostics/documentation/016/test016.dcm b/docs/source/examples/data/016/test016.dcm similarity index 100% rename from pytheranostics/documentation/016/test016.dcm rename to docs/source/examples/data/016/test016.dcm diff --git a/pytheranostics/documentation/test.dcm b/docs/source/examples/data/test.dcm similarity index 100% rename from pytheranostics/documentation/test.dcm rename to docs/source/examples/data/test.dcm diff --git a/pytheranostics/documentation/test0034_2.dcm b/docs/source/examples/data/test0034_2.dcm similarity index 100% rename from pytheranostics/documentation/test0034_2.dcm rename to docs/source/examples/data/test0034_2.dcm diff --git a/pytheranostics/documentation/test016.dcm b/docs/source/examples/data/test016.dcm similarity index 100% rename from pytheranostics/documentation/test016.dcm rename to docs/source/examples/data/test016.dcm diff --git a/pytheranostics/documentation/testimages/0034.dcm b/docs/source/examples/data/testimages/0034.dcm similarity index 100% rename from pytheranostics/documentation/testimages/0034.dcm rename to docs/source/examples/data/testimages/0034.dcm diff --git a/pytheranostics/documentation/testimages/016.dcm b/docs/source/examples/data/testimages/016.dcm similarity index 100% rename from pytheranostics/documentation/testimages/016.dcm rename to docs/source/examples/data/testimages/016.dcm diff --git a/pytheranostics/documentation/testimages/spect_counts.dcm b/docs/source/examples/data/testimages/spect_counts.dcm similarity index 100% rename from pytheranostics/documentation/testimages/spect_counts.dcm rename to docs/source/examples/data/testimages/spect_counts.dcm diff --git a/pytheranostics/documentation/testimages/spect_counts_out.dcm b/docs/source/examples/data/testimages/spect_counts_out.dcm similarity index 100% rename from pytheranostics/documentation/testimages/spect_counts_out.dcm rename to docs/source/examples/data/testimages/spect_counts_out.dcm diff --git a/pytheranostics/docs/source/extensions/sphinx_github_contributors.py b/docs/source/extensions/sphinx_github_contributors.py similarity index 91% rename from pytheranostics/docs/source/extensions/sphinx_github_contributors.py rename to docs/source/extensions/sphinx_github_contributors.py index 51b3df2..b1d6952 100644 --- a/pytheranostics/docs/source/extensions/sphinx_github_contributors.py +++ b/docs/source/extensions/sphinx_github_contributors.py @@ -5,6 +5,7 @@ def fetch_github_contributors(app): + """Fetch contributors via the GitHub API and write a simple RST list.""" username = app.config.github_username repository = app.config.github_repository output_file = app.config.contributors_output_file @@ -40,6 +41,7 @@ def fetch_github_contributors(app): def setup(app): + """Register config values and connect the fetch hook.""" app.add_config_value("github_username", None, "env") app.add_config_value("github_repository", None, "env") app.add_config_value("contributors_output_file", "../contributors.rst", "env") diff --git a/pytheranostics/docs/source/index.rst b/docs/source/index.rst similarity index 75% rename from pytheranostics/docs/source/index.rst rename to docs/source/index.rst index 8a32ea6..c7199d9 100644 --- a/pytheranostics/docs/source/index.rst +++ b/docs/source/index.rst @@ -3,20 +3,30 @@ You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. -Welcome to PyTheranostics's documentation! -======================================= +PyTheranostics Documentation +============================ PyTheranostics is a comprehensive Python library for nuclear medicine image processing and dosimetry calculations. It provides a complete workflow from image processing to absorbed dose calculations in target organs. .. toctree:: :maxdepth: 2 - :caption: Contents: + :caption: Getting Started - installation - quickstart - modules - api - contributing + intro/overview + intro/installation + usage/basic_usage + +.. toctree:: + :maxdepth: 1 + :caption: Tutorials + + tutorials/index + +.. toctree:: + :maxdepth: 2 + :caption: Reference + + API/modules changelog Features @@ -31,7 +41,7 @@ Features * Visualization and plotting capabilities Installation ------------ +------------ You can install PyTheranostics using pip: @@ -46,7 +56,7 @@ For development installation: pip install -e ".[dev]" Quick Start ----------- +----------- .. code-block:: python @@ -76,11 +86,9 @@ This project is licensed under the terms of the MIT license. See the `LICENSE `_ +for the up-to-date list of collaborators. .. footer:: diff --git a/pytheranostics/docs/source/intro/installation.rst b/docs/source/intro/installation.rst similarity index 100% rename from pytheranostics/docs/source/intro/installation.rst rename to docs/source/intro/installation.rst diff --git a/pytheranostics/docs/source/intro/overview.rst b/docs/source/intro/overview.rst similarity index 100% rename from pytheranostics/docs/source/intro/overview.rst rename to docs/source/intro/overview.rst diff --git a/pytheranostics/documentation/Data_Ingestion_Examples.ipynb b/docs/source/tutorials/Data_Ingestion_Examples/Data_Ingestion_Examples.ipynb similarity index 100% rename from pytheranostics/documentation/Data_Ingestion_Examples.ipynb rename to docs/source/tutorials/Data_Ingestion_Examples/Data_Ingestion_Examples.ipynb diff --git a/pytheranostics/documentation/ROI_Mapping_Tutorial.ipynb b/docs/source/tutorials/ROI_Mapping_Tutorial/ROI_Mapping_Tutorial.ipynb similarity index 100% rename from pytheranostics/documentation/ROI_Mapping_Tutorial.ipynb rename to docs/source/tutorials/ROI_Mapping_Tutorial/ROI_Mapping_Tutorial.ipynb diff --git a/pytheranostics/docs/source/tutorials/SPECT2SUV/SPECT2SUV.ipynb b/docs/source/tutorials/SPECT2SUV/SPECT2SUV.ipynb similarity index 87% rename from pytheranostics/docs/source/tutorials/SPECT2SUV/SPECT2SUV.ipynb rename to docs/source/tutorials/SPECT2SUV/SPECT2SUV.ipynb index 87195f3..478a918 100644 --- a/pytheranostics/docs/source/tutorials/SPECT2SUV/SPECT2SUV.ipynb +++ b/docs/source/tutorials/SPECT2SUV/SPECT2SUV.ipynb @@ -36,9 +36,21 @@ "metadata": {}, "outputs": [], "source": [ - "spect_counts='/mnt/c/Users/curibe/Nextcloud/BCCancer/CodeRepositories/doodle/doodle/documentation/testimages/016.dcm'\n", + "from pathlib import Path\n", "\n", - "output_path='./test016.dcm'" + "def _find_examples_dir() -> Path:\n", + " candidates = [\n", + " Path.cwd() / \"examples\" / \"data\",\n", + " Path.cwd() / \"docs\" / \"source\" / \"examples\" / \"data\",\n", + " ]\n", + " for candidate in candidates:\n", + " if candidate.exists():\n", + " return candidate\n", + " raise FileNotFoundError(\"Could not locate docs example data directory\")\n", + "\n", + "EXAMPLES_DIR = _find_examples_dir()\n", + "spect_counts = str(EXAMPLES_DIR / \"testimages\" / \"016.dcm\")\n", + "output_path = str(Path.cwd() / \"test016.dcm\")\n" ] }, { @@ -168,4 +180,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} +} \ No newline at end of file diff --git a/docs/source/tutorials/index.rst b/docs/source/tutorials/index.rst new file mode 100644 index 0000000..58ef2a4 --- /dev/null +++ b/docs/source/tutorials/index.rst @@ -0,0 +1,12 @@ +Tutorials +========= + +Hands-on walkthroughs that demonstrate common PyTheranostics workflows. The +notebooks are rendered directly in the documentation via nbsphinx. + +.. toctree:: + :maxdepth: 1 + + SPECT2SUV/SPECT2SUV + ROI_Mapping_Tutorial/ROI_Mapping_Tutorial + Data_Ingestion_Examples/Data_Ingestion_Examples diff --git a/pytheranostics/docs/source/usage/basic_usage.rst b/docs/source/usage/basic_usage.rst similarity index 100% rename from pytheranostics/docs/source/usage/basic_usage.rst rename to docs/source/usage/basic_usage.rst diff --git a/pyproject.toml b/pyproject.toml index f93e7cc..5cbc512 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,22 @@ dependencies = [ "Bug Tracker" = "https://github.com/qurit/PyTheranostics/issues" [project.optional-dependencies] +test = [ + "pytest>=7.0", + "pytest-cov>=4.0", +] +docs = [ + "sphinx>=7.0", + "sphinx-rtd-theme>=1.0", + "sphinx-autodoc-typehints>=2.0", + "nbsphinx>=0.9", + "nbconvert>=7.0", + "myst-parser>=2.0", + "sphinx-copybutton>=0.5", + "sphinx-contributors>=0.1", + "pandoc>=2.4", + "ipython>=8.0", +] dev = [ "pytest>=7.0", "pytest-cov>=4.0", @@ -50,16 +66,28 @@ dev = [ "pre-commit>=3.0.0", "sphinx>=7.0", "sphinx-rtd-theme>=1.0", + "sphinx-autodoc-typehints>=2.0", + "nbsphinx>=0.9", + "nbconvert>=7.0", + "myst-parser>=2.0", + "sphinx-copybutton>=0.5", + "sphinx-contributors>=0.1", + "pandoc>=2.4", + "ipython>=8.0", "pydocstyle>=6.0", "flake8-docstrings>=1.7" ] +[tool.hatch.build] +include = ["pytheranostics/data/**/*"] + [tool.hatch.build.targets.wheel] packages = [ "pytheranostics", "pytheranostics.calibrations", "pytheranostics.dicomtools", "pytheranostics.dosimetry", + "pytheranostics.data", "pytheranostics.fits", "pytheranostics.plots", "pytheranostics.qc", diff --git a/pytheranostics/calibrations/__init__.py b/pytheranostics/calibrations/__init__.py index e69de29..3731650 100644 --- a/pytheranostics/calibrations/__init__.py +++ b/pytheranostics/calibrations/__init__.py @@ -0,0 +1 @@ +"""PyTheranostics package.""" diff --git a/pytheranostics/calibrations/gamma_camera.py b/pytheranostics/calibrations/gamma_camera.py index cb44ce0..39013b3 100644 --- a/pytheranostics/calibrations/gamma_camera.py +++ b/pytheranostics/calibrations/gamma_camera.py @@ -1,3 +1,5 @@ +"""Gamma camera calibration utilities.""" + import json import math import pprint @@ -17,11 +19,14 @@ class GammaCamera(PlanarQC): + """Encapsulate gamma camera QC and sensitivity calculations.""" def __init__(self, isotope, dicomfile, db_dic, cal_type="planar"): + """Initialize the planar QC base class and load site metadata.""" super().__init__(isotope, dicomfile, db_dic=db_dic, cal_type=cal_type) def get_sensitivity(self, source_id="C", **kwargs): + """Calculate camera sensitivity for the provided calibration source.""" # ser_date = self.ds.SeriesDate # ser_time = self.ds.SeriesTime @@ -195,7 +200,7 @@ def get_sensitivity(self, source_id="C", **kwargs): pprint.pprint(self.cal_dic) def calculate_uncertainty(self, site_id, camera_model, uncertainty_activity): - + """Compute calibration factor and sensitivity uncertainties.""" u_prim_list = [] for detector in ["Detector1", "Detector2"]: u_pw = math.sqrt(self.win_check["photopeak"]["counts"][detector]) @@ -231,6 +236,7 @@ def calculate_uncertainty(self, site_id, camera_model, uncertainty_activity): return uncertainty_cf, uncertainty_sensitivity def calfactor_to_database(self, **kwargs): + """Persist calibration factors to the shared JSON database.""" if "site_id" in kwargs: site_id = kwargs["site_id"] diff --git a/pytheranostics/data/README.md b/pytheranostics/data/README.md new file mode 100644 index 0000000..83f1f19 --- /dev/null +++ b/pytheranostics/data/README.md @@ -0,0 +1,23 @@ +# PyTheranostics Data Layout + +All reference assets that ship with the library live under this directory so +they are available to both library code and tests via `importlib.resources`. + +``` +pytheranostics/data/ +├── phantom/ +│ ├── human/ # ICRP organ masses, literature tables, supporting workbooks +│ └── mouse/ # Preclinical phantom masses, scaling factors, literature data +├── olinda/ +│ └── templates/ +│ ├── human/ # Adult male/female OLINDA case templates +│ └── mouse/ # Mouse-specific case templates (e.g., mouse25g) +├── s-values/ +│ ├── organ/ # Radionuclide/sex-specific organ S-value tables +│ └── spheres.json +├── monte_carlo/ # Geant4/GATE templates used by voxel dosimetry +└── phantom/ # (additional imaging phantoms, e.g., skeleton masks) +``` + +When adding new assets, prefer extending these folders (or adding a clearly +named subdirectory) instead of nesting data inside individual subpackages. diff --git a/pytheranostics/data/__init__.py b/pytheranostics/data/__init__.py new file mode 100644 index 0000000..1e67676 --- /dev/null +++ b/pytheranostics/data/__init__.py @@ -0,0 +1 @@ +"""Package containing data assets distributed with PyTheranostics.""" diff --git a/pytheranostics/dosimetry/olindaTemplates/adult_female.cas b/pytheranostics/data/olinda/templates/human/adult_female.cas similarity index 100% rename from pytheranostics/dosimetry/olindaTemplates/adult_female.cas rename to pytheranostics/data/olinda/templates/human/adult_female.cas diff --git a/pytheranostics/dosimetry/olindaTemplates/adult_male.cas b/pytheranostics/data/olinda/templates/human/adult_male.cas similarity index 100% rename from pytheranostics/dosimetry/olindaTemplates/adult_male.cas rename to pytheranostics/data/olinda/templates/human/adult_male.cas diff --git a/pytheranostics/preclinical_dosimetry/mouse25g.cas b/pytheranostics/data/olinda/templates/mouse/mouse25g.cas similarity index 100% rename from pytheranostics/preclinical_dosimetry/mouse25g.cas rename to pytheranostics/data/olinda/templates/mouse/mouse25g.cas diff --git a/pytheranostics/data/output.json b/pytheranostics/data/output.json index 7ebdbdd..db61af9 100644 --- a/pytheranostics/data/output.json +++ b/pytheranostics/data/output.json @@ -4,8 +4,8 @@ "ClinicalTrial": "MyClinicalTrial", "Radionuclide": "NA", "PatientID": "NA", - "Gender": "NA", - + "Gender": "NA", + "No_of_completed_cycles": "NA", "Cycle_01": [ { diff --git a/pytheranostics/dosimetry/phantomdata/PhantomMasses.xlsx b/pytheranostics/data/phantom/human/PhantomMasses.xlsx similarity index 100% rename from pytheranostics/dosimetry/phantomdata/PhantomMasses.xlsx rename to pytheranostics/data/phantom/human/PhantomMasses.xlsx diff --git a/pytheranostics/dosimetry/phantomdata/human_notinphantom_masses.csv b/pytheranostics/data/phantom/human/human_notinphantom_masses.csv similarity index 100% rename from pytheranostics/dosimetry/phantomdata/human_notinphantom_masses.csv rename to pytheranostics/data/phantom/human/human_notinphantom_masses.csv diff --git a/pytheranostics/preclinical_dosimetry/phantomdata/human_phantom_masses.csv b/pytheranostics/data/phantom/human/human_phantom_masses.csv similarity index 100% rename from pytheranostics/preclinical_dosimetry/phantomdata/human_phantom_masses.csv rename to pytheranostics/data/phantom/human/human_phantom_masses.csv diff --git a/pytheranostics/preclinical_dosimetry/phantomdata/Organ_weights_mice.xlsx b/pytheranostics/data/phantom/mouse/Organ_weights_mice.xlsx similarity index 100% rename from pytheranostics/preclinical_dosimetry/phantomdata/Organ_weights_mice.xlsx rename to pytheranostics/data/phantom/mouse/Organ_weights_mice.xlsx diff --git a/pytheranostics/preclinical_dosimetry/phantomdata/mouse_notinphantom_masses.csv b/pytheranostics/data/phantom/mouse/mouse_notinphantom_masses.csv similarity index 100% rename from pytheranostics/preclinical_dosimetry/phantomdata/mouse_notinphantom_masses.csv rename to pytheranostics/data/phantom/mouse/mouse_notinphantom_masses.csv diff --git a/pytheranostics/preclinical_dosimetry/phantomdata/mouse_phantom_masses.csv b/pytheranostics/data/phantom/mouse/mouse_phantom_masses.csv similarity index 100% rename from pytheranostics/preclinical_dosimetry/phantomdata/mouse_phantom_masses.csv rename to pytheranostics/data/phantom/mouse/mouse_phantom_masses.csv diff --git a/pytheranostics/preclinical_dosimetry/phantomdata/rMSF_factor.csv b/pytheranostics/data/phantom/mouse/rMSF_factor.csv similarity index 100% rename from pytheranostics/preclinical_dosimetry/phantomdata/rMSF_factor.csv rename to pytheranostics/data/phantom/mouse/rMSF_factor.csv diff --git a/pytheranostics/dicomtools/__init__.py b/pytheranostics/dicomtools/__init__.py index e69de29..1ab4f90 100644 --- a/pytheranostics/dicomtools/__init__.py +++ b/pytheranostics/dicomtools/__init__.py @@ -0,0 +1 @@ +"""DICOM utilities exposed at the package level.""" diff --git a/pytheranostics/dicomtools/dicomtools.py b/pytheranostics/dicomtools/dicomtools.py index 197f6e6..cf837d8 100644 --- a/pytheranostics/dicomtools/dicomtools.py +++ b/pytheranostics/dicomtools/dicomtools.py @@ -1,3 +1,5 @@ +"""Utility functions for reading and modifying nuclear medicine DICOM files.""" + import time from datetime import datetime from pathlib import Path @@ -14,8 +16,10 @@ class DicomModify: + """Helper that edits DICOM headers/pixel data for quantitative SPECT studies.""" def __init__(self, fname, CF): + """Load the DICOM file and store calibration info.""" self.ds = pydicom.dcmread(fname) self.CF = CF self.fname = fname @@ -35,6 +39,7 @@ def make_bqml_suv( radiopharmaceutical="Lutetium-PSMA-617", n_detectors=2, ): + """Convert raw counts to BQML/SUV units and update the header accordingly.""" # Half-life is in seconds # Siemens has an issue setting up the times. We are using the Acquisition time which is the time of the start of the last bed to harmonize. @@ -186,30 +191,18 @@ def make_bqml_suv( return inj_df def save(self): + """Persist the modified dataset alongside the original file.""" self.ds.save_as(f"{self.fname.split('.dcm')[0]}_out.dcm") def dicom_slope_intercept(img): - """This function calculates the slope and intercept for a DICOM image in the way that GE does it. - - GE PET images are stored in DICOM files that are signed int16. This allows for a maximum value of 32767. - The slope is calculated such that the maximum value in the pixel array (before multiplying by slope) is 32767. - - Parameters - ---------- - img: numpy array - contains the float values of the image (e.g. MBq/ml in our case) - - Returns - ------- - slope: float - The slope to be set in the dicom header - - intercept: float - The intercept for the dicom header + """Calculate GE-style slope/intercept for converting floats to signed int16. + GE PET images are stored as signed int16 values with magnitude limited to + 32767. The computed slope ensures the largest absolute voxel value in the + floating-point array (e.g., MBq/mL) maps to this range once quantized, while + the intercept remains zero (GE convention). """ - max_val = np.max(img) min_val = np.min(img) @@ -228,26 +221,7 @@ def generate_basic_dcm_tags( date: str, time: str, ) -> List[Any]: - """This function generates basic DICOM tags. Useful to build simple DICOM datasets from images. - - Parameters - ---------- - img_size: - - slice_thickness: - - name: - - direction: - - date: - - time: - - Returns - ------- - series_tag_values: - """ + """Generate the minimal tag set needed for a synthetic DICOM series.""" series_tag_values = [ ("0008|0031", time), # Series Time ("0008|0021", date), # Series Date @@ -293,26 +267,14 @@ def numpy_to_dcm_basic( patien_name: str = "Patient", scale: int = 1, ) -> None: - """Write a numpy array as a .dcm image for visualization purposes. Borrowed from: - - R. Fedrigo, et al., “Development of the Lymphatic System in the 4D XCAT Phantom for - Improved Multimodality Imaging Research”, J. Nuc. Med., vol. 62, publication 113, 2021. - - Parameters - ---------- - array: - - voxel_spacing: - - output_dir: - - scale: - - Returns: - None + """Write a NumPy array as a basic DICOM series for visualization/testing. + Notes + ----- + Adapted from: R. Fedrigo et al., "Development of the Lymphatic System in the + 4D XCAT Phantom for Improved Multimodality Imaging Research," J. Nucl. Med., + 62, 113 (2021). """ - # Create SimpleITK image from array array = array * scale sitk_image = SimpleITK.GetImageFromArray(array.astype(np.int16)) @@ -371,17 +333,7 @@ def numpy_to_dcm_basic( def sitk_load_dcm_series(dcm_dir: Path) -> SimpleITK.Image: - """Load Series from DICOM folder, and return SITK image - - Parameters - ---------- - dcm_dir: - - Returns - -------- - dicom_dataset: - """ - + """Load a DICOM series using SimpleITK and return it as an image volume.""" reader = SimpleITK.ImageSeriesReader() dcm_file_names = reader.GetGDCMSeriesFileNames(str(dcm_dir)) reader.SetFileNames(dcm_file_names) diff --git a/pytheranostics/docs/requirements.txt b/pytheranostics/docs/requirements.txt deleted file mode 100644 index dd5adc6..0000000 --- a/pytheranostics/docs/requirements.txt +++ /dev/null @@ -1,7 +0,0 @@ -sphinx-autodoc-typehints -sphinx_rtd_theme -nbsphinx -nbconvert -pandoc -sphinx-contributors -sphinx-copybutton diff --git a/pytheranostics/docs/source/API/modules.rst b/pytheranostics/docs/source/API/modules.rst deleted file mode 100644 index 5b6cdb1..0000000 --- a/pytheranostics/docs/source/API/modules.rst +++ /dev/null @@ -1,87 +0,0 @@ -DICOM Tools -==================== - -.. autoclass:: dicomtools - :members: - :undoc-members: - .. :show-inheritance: - - -Calibrations -==================== - -.. automodule:: calibrations.gamma_camera - :members: - :undoc-members: - :show-inheritance: - -Dosimetry -==================== - -.. autoclass:: dosimetry - :members: - :undoc-members: - -.. .. automodule:: dosimetry.BaseDosimetry -.. :members: -.. :undoc-members: - - - -.. automodule:: dosimetry.BoneMarrow - :members: - :undoc-members: - :show-inheritance: - -.. automodule:: dosimetry.dosiomicsclass - :members: - :undoc-members: - :show-inheritance: - -.. automodule:: dosimetry.dvk - :members: - :undoc-members: - :show-inheritance: - -.. automodule:: dosimetry.image_analysis - :members: - :undoc-members: - :show-inheritance: - -.. automodule:: dosimetry.mc - :members: - :undoc-members: - :show-inheritance: - -.. automodule:: dosimetry.olinda - :members: - :undoc-members: - :show-inheritance: - -.. automodule:: dosimetry.OrganSDosimetry - :members: - :undoc-members: - :show-inheritance: - -.. automodule:: dosimetry.patientdosimetry - :members: - :undoc-members: - :show-inheritance: - -.. automodule:: dosimetry.VoxelSDosimetry - :members: - :undoc-members: - :show-inheritance: - -Fitting -==================== - - .. automodule:: fits.fits - :members: - :undoc-members: - :show-inheritance: - - .. automodule:: fits.functions - :members: - :undoc-members: - :show-inheritance: diff --git a/pytheranostics/documentation/SPECT2SUV.ipynb b/pytheranostics/documentation/SPECT2SUV.ipynb deleted file mode 100644 index 832972b..0000000 --- a/pytheranostics/documentation/SPECT2SUV.ipynb +++ /dev/null @@ -1,170 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": 1, - "id": "6d072b76-26c2-4789-9d4a-7be2f0170f5c", - "metadata": {}, - "outputs": [], - "source": [ - "from doodle.dicomtools.dicomtools import DicomModify\n", - "import numpy as np\n", - "import matplotlib.pyplot as plt\n", - "from pydicom.dataset import Dataset\n", - "from pydicom.uid import generate_uid\n", - "\n", - "%load_ext autoreload\n", - "%autoreload 2\n", - "%matplotlib inline" - ] - }, - { - "cell_type": "markdown", - "id": "0c6f9dbd-0afa-4f6a-87ec-a81120df22ec", - "metadata": {}, - "source": [ - "### Point to the SPECT image in counts (the one that you want to make quantitative)\n", - "\n", - "### and set the output path (the location where you want the QSPECT image to be saved at the end" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "154c4542-32ab-4da3-89d1-170e10a539db", - "metadata": {}, - "outputs": [], - "source": [ - "spect_counts='/mnt/c/Users/curibe/Nextcloud/BCCancer/CodeRepositories/doodle/doodle/documentation/testimages/016.dcm'\n", - "\n", - "output_path='./test016.dcm'" - ] - }, - { - "cell_type": "markdown", - "id": "82c8ff82-32de-43f5-936a-f63597c35bc5", - "metadata": {}, - "source": [ - "### Set the calibration factor for the camera of the centre that you're using" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "14c487d1-3d64-4794-a7e2-02e0ec98ba67", - "metadata": {}, - "outputs": [], - "source": [ - "CF = 0.10800584442987242\n", - "img=DicomModify(spect_counts,CF)" - ] - }, - { - "cell_type": "markdown", - "id": "15ff709a-66de-45be-960c-c7b0ab1da03d", - "metadata": {}, - "source": [ - "### Specify the following information from the filled in form from the injection" - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "id": "3a7b4268-5548-4100-8173-95eae051ec75", - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
injected_activity_MBqinjection_datetime
07395.7448952022-06-16 09:18:00
\n", - "
" - ], - "text/plain": [ - " injected_activity_MBq injection_datetime\n", - "0 7395.744895 2022-06-16 09:18:00" - ] - }, - "execution_count": 12, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "weight = 113.4\n", - "height = 1.785\n", - "injection_date = '20220616'\n", - "pre_inj_activity = 7450\n", - "pre_inj_time = '0804'\n", - "post_inj_activity = 14.4\n", - "post_inj_time = '0955'\n", - "injection_time = '0918'\n", - "\n", - "#The activity meteer scale factor is a factor to multiply activity values if the calibration setting of the dose calibrator has changed\n", - "activity_meter_scale_factor = 1\n", - "\n", - "\n", - "inj_df = img.make_bqml_suv(weight=weight,height=height,injection_date=injection_date,pre_inj_activity=pre_inj_activity,pre_inj_time=pre_inj_time,post_inj_activity=post_inj_activity,post_inj_time=post_inj_time,injection_time=injection_time,activity_meter_scale_factor=activity_meter_scale_factor)\n", - "img.ds.save_as(output_path)\n", - "inj_df" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c4cbee90-0ee5-4915-8a4b-fd875a69d804", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "DOODLE", - "language": "python", - "name": "doodle" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.9.7" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/pytheranostics/dosimetry/base_dosimetry.py b/pytheranostics/dosimetry/base_dosimetry.py index 3380a44..f599f86 100644 --- a/pytheranostics/dosimetry/base_dosimetry.py +++ b/pytheranostics/dosimetry/base_dosimetry.py @@ -2,7 +2,6 @@ import abc import json -from os import path from pathlib import Path from typing import Any, Dict, List, Optional, Tuple @@ -16,6 +15,7 @@ from pytheranostics.imaging_tools.tools import extract_masks from pytheranostics.misc_tools.tools import calculate_time_difference from pytheranostics.plots.plots import plot_tac_residuals +from pytheranostics.shared.resources import resource_path class BaseDosimetry(metaclass=abc.ABCMeta): @@ -101,10 +101,11 @@ def __init__( self.clinical_data = clinical_data - with open( - path.dirname(__file__) + "/../data/s-values/spheres.json", "r" - ) as file: - self.mass_and_s_values = json.load(file) + with resource_path( + "pytheranostics.data", "s-values/spheres.json" + ) as spheres_path: + with spheres_path.open("r", encoding="utf-8") as file: + self.mass_and_s_values = json.load(file) if ( self.clinical_data is not None @@ -322,9 +323,9 @@ def check_nm_data(self) -> Dict[str, Any]: Also verify that radionuclide data (e.g., half-life) is available in internal database. """ # Load Radionuclide data - rad_data_path = path.dirname(__file__) + "/../data/isotopes.json" - with open(rad_data_path, "r") as rad_data: - radionuclide_data = json.load(rad_data) + with resource_path("pytheranostics.data", "isotopes.json") as rad_data_path: + with rad_data_path.open("r", encoding="utf-8") as rad_data: + radionuclide_data = json.load(rad_data) if self.nm_data.meta[0].Radionuclide is None: raise ValueError("Nuclear Medicine Data missing radionuclide") @@ -742,11 +743,12 @@ def write_json_data( """ # Open empty json to load its structure: if create_new: - json_path = path.dirname(__file__) + "/../data/output.json" + with resource_path("pytheranostics.data", "output.json") as template_json: + with template_json.open("r", encoding="utf-8") as file: + data = json.load(file) else: - json_path = file_path - with open(json_path, "r") as file: - data = json.load(file) + with open(file_path, "r", encoding="utf-8") as file: + data = json.load(file) data["PatientID"] = self.config["PatientID"] data["InstitutionName"] = InstitutionName diff --git a/pytheranostics/dosimetry/dosiomicsclass.py b/pytheranostics/dosimetry/dosiomicsclass.py index 840931e..8b6c5d9 100644 --- a/pytheranostics/dosimetry/dosiomicsclass.py +++ b/pytheranostics/dosimetry/dosiomicsclass.py @@ -1,3 +1,5 @@ +"""Radiomics feature extraction utilities.""" + from __future__ import print_function import os @@ -9,7 +11,10 @@ class Radiomics: + """Generate radiomics features for longitudinal studies.""" + def __init__(self, imagemodality, patient_id, cycle, image, mask, organslist): + """Store study metadata, image arrays, and ROI masks.""" self.imagemodality = imagemodality self.patient_id = patient_id self.cycle = cycle @@ -18,6 +23,7 @@ def __init__(self, imagemodality, patient_id, cycle, image, mask, organslist): self.organslist = organslist def prepareimages(self): + """Export the image and ROI masks to NRRD files for PyRadiomics.""" img = sitk.GetImageFromArray(self.image) sitk.WriteImage( @@ -33,6 +39,7 @@ def prepareimages(self): ) def featureextractor(self): + """Run PyRadiomics using the configured parameter set.""" paramPath = os.path.join("..", "data", "Params.yaml") extractor = featureextractor.RadiomicsFeatureExtractor(paramPath) diff --git a/pytheranostics/dosimetry/dvk.py b/pytheranostics/dosimetry/dvk.py index 923fc69..96822f8 100644 --- a/pytheranostics/dosimetry/dvk.py +++ b/pytheranostics/dosimetry/dvk.py @@ -1,12 +1,12 @@ """Dose voxel kernel module for convolution-based dosimetry.""" -import os from typing import Optional import numpy from scipy import signal from pytheranostics.misc_tools.tools import hu_to_rho +from pytheranostics.shared.resources import resource_path class DoseVoxelKernel: @@ -20,22 +20,22 @@ def __init__(self, isotope: str, voxel_size_mm: float) -> None: isotope (str): The isotope name (e.g., 'Lu177'). voxel_size_mm (float): Voxel size in millimeters. """ + kernel_filename = ( + f"voxel_kernels/{isotope}-{voxel_size_mm:1.2f}-mm-mGyperMBqs-SoftICRP.img" + ) try: - self.kernel = numpy.fromfile( - os.path.dirname(__file__) - + f"/../data/voxel_kernels/{isotope}-{voxel_size_mm:1.2f}-mm-mGyperMBqs-SoftICRP.img", - dtype=numpy.float32, - ) + with resource_path("pytheranostics.data", kernel_filename) as kernel_path: + self.kernel = numpy.fromfile(kernel_path, dtype=numpy.float32) except FileNotFoundError: print( f" >> Voxel Kernel for SPECT voxel size ({voxel_size_mm:2.2f} mm) not found. Using default kernel for 4.8 mm voxels..." ) - self.kernel = numpy.fromfile( - os.path.dirname(__file__) - + f"/../data/voxel_kernels/{isotope}-4.80-mm-mGyperMBqs-SoftICRP.img", - dtype=numpy.float32, + fallback_filename = ( + f"voxel_kernels/{isotope}-4.80-mm-mGyperMBqs-SoftICRP.img" ) + with resource_path("pytheranostics.data", fallback_filename) as kernel_path: + self.kernel = numpy.fromfile(kernel_path, dtype=numpy.float32) self.kernel = self.kernel.reshape((51, 51, 51)).astype(numpy.float64) diff --git a/pytheranostics/dosimetry/image_analysis.py b/pytheranostics/dosimetry/image_analysis.py index 80a40e8..5a938fb 100644 --- a/pytheranostics/dosimetry/image_analysis.py +++ b/pytheranostics/dosimetry/image_analysis.py @@ -1,3 +1,5 @@ +"""Visualization and summary utilities for volumetric dosimetry images.""" + import gatetools as gt import itk import matplotlib.pyplot as plt @@ -5,7 +7,10 @@ class Image: + """Convenience wrapper to compute statistics on organ masks.""" + def __init__(self, df, patient_id, cycle, image, roi_masks_resampled): + """Store metadata, image array, and resampled ROI masks.""" self.df = df self.patient_id = patient_id self.cycle = int(cycle) @@ -14,6 +19,7 @@ def __init__(self, df, patient_id, cycle, image, roi_masks_resampled): self.organlist = self.roi_masks_resampled.keys() def SPECT_image_array(self, SPECT, scalefactor, xspacing, yspacing, zspacing): + """Convert a DICOM SPECT series into an MBq volume and cache it.""" SPECT_image = SPECT.pixel_array SPECT_image = np.transpose( SPECT_image, (1, 2, 0) @@ -35,6 +41,7 @@ def SPECT_image_array(self, SPECT, scalefactor, xspacing, yspacing, zspacing): return SPECTMBq def image_visualisation(self, image): + """Display three orthogonal slices for quick sanity checks.""" fig, axs = plt.subplots(1, 3, figsize=(15, 5)) axs[0].imshow(image[:, :, 50]) @@ -48,7 +55,7 @@ def image_visualisation(self, image): plt.show() def show_mean_statistics(self, output): - + """Compute mean activity per organ and store in the dataframe.""" self.ad_mean = {} for organ in self.organlist: mask = self.roi_masks_resampled[organ] @@ -58,12 +65,14 @@ def show_mean_statistics(self, output): self.df[output] = self.df["Contour"].map(self.ad_mean) def show_max_statistics(self): + """Print the maximum activity observed within each organ mask.""" for organ in self.organlist: mask = self.roi_masks_resampled[organ] x = self.image[mask].max() print(f"{organ}", x) def add(self, output): + """Compute total activity per organ and map it back into the dataframe.""" self.sum = {} for organ in self.organlist: mask = self.roi_masks_resampled[organ] @@ -73,6 +82,7 @@ def add(self, output): self.df[output] = self.df["Contour"].map(self.sum) def voxels_and_volume(self, output1, output2, voxel_volume): + """Record nonzero voxel counts and physical volumes per organ.""" self.no_voxels = {} self.volume = {} for organ in self.organlist: @@ -84,6 +94,7 @@ def voxels_and_volume(self, output1, output2, voxel_volume): self.df[output2] = self.df["Contour"].map(self.volume) def dose_volume_histogram(self): + """Generate and log dose-volume histograms for each organ.""" doseimage = self.image.astype(float) doseimage = itk.image_from_array(doseimage) diff --git a/pytheranostics/dosimetry/mc.py b/pytheranostics/dosimetry/mc.py index 24b0b1e..82a5f66 100644 --- a/pytheranostics/dosimetry/mc.py +++ b/pytheranostics/dosimetry/mc.py @@ -1,13 +1,19 @@ +"""Helpers to orchestrate Monte Carlo batch jobs.""" + import os class MonteCarlo: + """Split and execute Monte Carlo runs across multiple CPUs.""" + def __init__(self, n_cpu, n_primaries, output_dir): + """Store execution parameters for the simulation batch.""" self.n_cpu = n_cpu self.n_primaries = n_primaries self.output_dir = output_dir def split_simulations(self): + """Split total primaries across CPUs and write per-core macro files.""" n_primaries_per_mac = int(self.n_primaries / self.n_cpu) with open("./main_template.mac", "r") as mac_file: @@ -26,6 +32,7 @@ def split_simulations(self): output_mac.write(new_mac) def run_MC(self): + """Invoke the shell script that runs the Monte Carlo jobs.""" os.system( f"bash {self.output_dir}/runsimulation1.sh {self.output_dir} {self.n_cpu}" ) diff --git a/pytheranostics/dosimetry/olinda.py b/pytheranostics/dosimetry/olinda.py index e3c5783..9b2f2a6 100644 --- a/pytheranostics/dosimetry/olinda.py +++ b/pytheranostics/dosimetry/olinda.py @@ -1,19 +1,21 @@ -from os import path -from pathlib import Path +"""Helpers for reading Olinda/EXM phantom tables shipped with PyTheranostics.""" import pandas +from pytheranostics.shared.resources import resource_path + def load_s_values(gender: str, radionuclide: str) -> pandas.DataFrame: - """Load S-values Dataframes""" - path_to_sv = Path(f"./phantomdata/{radionuclide}-{gender}-Svalues.csv") - if not path_to_sv.exists(): - raise FileExistsError( - f"S-values for {gender}, {radionuclide} not found. Please make sure" - " gender is ['Male', 'Female'] and radionuclide SymbolMass e.g., Lu177" - ) - - s_df = pandas.read_csv(path_to_sv) + """Load the S-value table for a gender/radionuclide pair.""" + relative_path = f"s-values/organ/{radionuclide}-{gender}-Svalues.csv" + try: + with resource_path("pytheranostics.data", relative_path) as path_to_sv: + s_df = pandas.read_csv(path_to_sv) + except FileNotFoundError as exc: # pragma: no cover - defensive + raise FileNotFoundError( + f"S-values for {gender}, {radionuclide} not found. Ensure gender is " + "one of ['Male', 'Female'] and radionuclide uses the SymbolMass format (e.g., Lu177)." + ) from exc s_df.set_index(keys=["Target"], drop=True, inplace=True) s_df = s_df.drop(labels=["Target"], axis=1) @@ -21,9 +23,11 @@ def load_s_values(gender: str, radionuclide: str) -> pandas.DataFrame: def load_phantom_mass(gender: str, organ: str) -> float: - """Load the mass of organs in the standar ICRP Male/Female phantom""" - phantom_data_path = path.dirname(__file__) + "/phantomdata/human_phantom_masses.csv" - masses = pandas.read_csv(phantom_data_path) + """Return the ICRP phantom mass for the requested organ and gender.""" + with resource_path( + "pytheranostics.data", "phantom/human/human_phantom_masses.csv" + ) as phantom_data_path: + masses = pandas.read_csv(phantom_data_path) if organ not in masses["Organ"].to_list(): raise ValueError(f"Organ {organ} not found in phantom data.") diff --git a/pytheranostics/dosimetry/organ_s_dosimetry.py b/pytheranostics/dosimetry/organ_s_dosimetry.py index 0670872..062cbec 100644 --- a/pytheranostics/dosimetry/organ_s_dosimetry.py +++ b/pytheranostics/dosimetry/organ_s_dosimetry.py @@ -15,10 +15,7 @@ from pytheranostics.dosimetry.base_dosimetry import BaseDosimetry from pytheranostics.imaging_ds.longitudinal_study import LongitudinalStudy - -parent_dir = path.dirname(path.dirname(__file__)) -SVALUES_PATH = path.join(parent_dir, "data", "s-values") -MASSES_PATH = path.join(parent_dir, "data", "ICRP_phantom_masses") +from pytheranostics.shared.resources import resource_path class OrganSDosimetry(BaseDosimetry): @@ -57,6 +54,15 @@ def check_mandatory_fields_organ(self) -> None: return None + @staticmethod + def _load_human_mass_table() -> pandas.DataFrame: + """Load the reference human phantom masses.""" + with resource_path( + "pytheranostics.data", "phantom/human/human_phantom_masses.csv" + ) as masses_path: + masses = pandas.read_csv(masses_path, index_col=0) + return masses + def composition_and_density_from_HU(self, density: float) -> Tuple[str, float]: """Determine composition and density for a given CT HU value.""" if density <= 100: @@ -136,24 +142,14 @@ def calculate_ttb(self): def prepare_data(self) -> None: """ - Prepare data for dosimetry calculations or export based on the current configuration. - - The behavior depends on the 'Level' setting: - - 1. Organ-level: - - If Output Type is 'Export': - - Generates files compatible with the selected software: - - 'Olinda' → creates a .cas file for Olinda/EXM. - - 'MirdCalc' → creates files compatible with MIRDcalc. - - If Output Type is 'Calculate': - - Performs organ-level dosimetry calculations using the specified method. - - Uses S-values from the chosen source (e.g., Olinda, MirdCalc, OpenDDose). - - Additional options like ROB, lesion dosimetry, or salivary gland handling are applied as configured. - - 2. Voxel-level: - - Prepares voxel-based dosimetry data. - - The chosen calculation method (e.g., Dose Kernel) is applied. - - Output is formatted according to the specified voxel-level output format (e.g., NIfTI). + Prepare data for dosimetry calculations or export based on the configuration. + + For organ-level workflows the method either exports data compatible with + Olinda/MIRDcalc or performs the configured calculation, sourcing S-values + from the selected tables and honoring options such as ROB, lesions, or + salivary gland handling. For voxel-level workflows it assembles the inputs + for kernel-based calculations and writes the data using the requested + voxel-level format (for example, NIfTI). """ self.results_fitting = self.results[["Volume_CT_mL", "TIA_h"]].copy() # Average Volume over time points. @@ -351,12 +347,16 @@ def calculate_absorbed_dose(self) -> pandas.DataFrame: }, } - svalues_beta = self.load_svalues( - path.join(SVALUES_PATH, model_files[self.config["Gender"]]["beta"]) - ) - svalues_gamma = self.load_svalues( - path.join(SVALUES_PATH, model_files[self.config["Gender"]]["gamma"]) - ) + with resource_path( + "pytheranostics.data", + f"s-values/{model_files[self.config['Gender']]['beta']}", + ) as beta_path: + svalues_beta = self.load_svalues(beta_path) + with resource_path( + "pytheranostics.data", + f"s-values/{model_files[self.config['Gender']]['gamma']}", + ) as gamma_path: + svalues_gamma = self.load_svalues(gamma_path) print("Source organs available in the model:", svalues_beta.columns.tolist()) print("Source organs present :", self.results_fitting.index.tolist()) @@ -476,8 +476,11 @@ def redistribute_ROB_into_source_organs_missing( def apply_s_value(self, tia_df, s_values, radiation_type) -> pandas.DataFrame: """Multiply S-values by TIA to compute dose matrix for radiation type.""" # Path to organ masses - masses_path = path.join(MASSES_PATH, "ICRP_mass_male.csv") - self.organ_masses = pandas.read_csv(masses_path, index_col=0) + masses = self._load_human_mass_table() + gender = self.config.get("Gender", "Male") + if gender not in masses.columns: + raise ValueError(f"Unknown gender '{gender}' for mass table.") + self.organ_masses = masses[[gender]].rename(columns={gender: "Mass_g"}) # Handle remainder of the body # Redistribute ROB TIA into missing source organs if needed - approach consistent with MIRDcalc software @@ -606,8 +609,10 @@ def perform_mass_scaling( self, df: pandas.DataFrame, gender: str ) -> pandas.DataFrame: """Apply mass scaling to absorbed dose calculations based on patient-specific organ masses.""" - masses_path = path.join(MASSES_PATH, f"ICRP_mass_{gender.lower()}_target.csv") - model_masses_df = pandas.read_csv(masses_path, index_col=0) + masses = self._load_human_mass_table() + if gender not in masses.columns: + raise ValueError(f"Unknown gender '{gender}' for mass table.") + model_masses_df = masses[[gender]].rename(columns={gender: "Mass_g"}) print("Performing mass scaling...") @@ -646,17 +651,20 @@ def perform_mass_scaling( def create_Olinda_file(self, dirname: str, savefile: bool = False) -> None: """Create .cas file that can be exported to Olinda/EXM.""" - this_dir = path.dirname(__file__) - TEMPLATE_PATH = path.join(this_dir, "olindaTemplates") - if self.config["Gender"] == "Male": - template = pandas.read_csv(path.join(TEMPLATE_PATH, "adult_male.cas")) + template_file = "adult_male.cas" elif self.config["Gender"] == "Female": - template = pandas.read_csv(path.join(TEMPLATE_PATH, "adult_female.cas")) + template_file = "adult_female.cas" else: print( "Ensure that you correctly wrote patient gender in config file. Olinda supports: Male and Female." ) + return + + with resource_path( + "pytheranostics.data", f"olinda/templates/human/{template_file}" + ) as template_path: + template = pandas.read_csv(template_path) template.columns = ["Data"] match = re.match(r"([a-zA-Z]+)([0-9]+)", self.config["Radionuclide"]) diff --git a/pytheranostics/dosimetry/phantomdata/Lu177-Female-Svalues.csv b/pytheranostics/dosimetry/phantomdata/Lu177-Female-Svalues.csv deleted file mode 100644 index f7c4665..0000000 --- a/pytheranostics/dosimetry/phantomdata/Lu177-Female-Svalues.csv +++ /dev/null @@ -1,27 +0,0 @@ -Target,Adrenals ,Brain,Breasts ,Esophagus,Eyes,GB Cont,LLI Cont ,SI Cont,StomCont,ULI Cont,Rectum,HeartCon,Hrt Wall,Kidneys ,Liver,Lungs,Ovaries,Pancreas,Salivary,Red Mar.,CortBone,TrabBone,Spleen,Thymus,Thyroid,UB Cont,Uterus,Tot Body -Adrenals,1.81E-03,5.72E-10,1.36E-08,1.10E-07,5.84E-10,2.83E-07,7.67E-08,6.74E-08,3.09E-07,4.45E-08,7.93E-09,5.82E-08,5.82E-08,7.16E-07,3.25E-07,5.44E-08,1.66E-08,3.21E-07,1.93E-09,7.85E-08,3.80E-08,3.80E-08,2.38E-06,2.25E-08,8.45E-09,5.97E-09,1.14E-08,4.41E-07 -Brain,5.72E-10,1.89E-05,2.74E-09,6.45E-09,2.03E-07,2.33E-10,2.17E-10,8.38E-11,7.48E-10,1.25E-10,1.52E-11,2.42E-09,2.42E-09,2.86E-10,8.10E-10,4.99E-09,1.96E-11,3.93E-10,1.72E-07,3.45E-08,3.99E-08,3.99E-08,6.75E-10,5.55E-09,1.84E-08,7.15E-12,1.92E-11,4.26E-07 -Breasts,1.36E-08,2.74E-09,4.78E-05,3.76E-08,6.99E-09,1.06E-08,5.54E-09,5.24E-09,2.83E-08,6.70E-09,5.45E-10,7.79E-08,7.79E-08,6.27E-09,2.94E-08,6.15E-08,7.10E-10,1.98E-08,9.83E-09,1.67E-08,1.08E-08,1.08E-08,1.15E-08,8.07E-08,3.18E-08,3.18E-10,1.01E-09,4.19E-07 -Esophagus,1.10E-07,6.45E-09,3.76E-08,6.71E-04,6.11E-09,4.29E-08,1.92E-08,1.24E-08,1.09E-07,1.28E-08,7.10E-10,3.53E-07,4.30E-07,4.15E-08,1.34E-07,2.09E-07,3.33E-09,1.33E-07,2.89E-08,2.50E-08,2.39E-08,2.39E-08,7.79E-08,2.98E-07,5.79E-07,1.62E-09,2.94E-09,4.27E-07 -Eyes,5.84E-10,2.18E-07,6.99E-09,6.11E-09,1.57E-03,1.91E-10,1.83E-10,1.35E-10,8.35E-10,6.17E-11,1.29E-10,3.24E-09,3.24E-09,3.16E-10,1.01E-09,4.84E-09,7.54E-11,5.67E-10,1.40E-07,3.45E-08,3.99E-08,3.99E-08,6.92E-10,6.92E-09,1.94E-08,7.23E-11,1.91E-10,4.26E-07 -Gallbladder Wall,2.87E-07,2.33E-10,1.07E-08,4.30E-08,1.91E-10,2.52E-04,1.91E-07,1.86E-07,6.41E-08,2.68E-07,1.78E-08,2.35E-08,2.35E-08,3.04E-07,2.52E-07,2.28E-08,3.76E-08,2.85E-07,7.16E-10,2.78E-08,1.49E-08,1.49E-08,4.78E-08,1.20E-08,4.52E-09,1.65E-08,3.29E-08,4.43E-07 -LLI Wall,7.67E-08,2.17E-10,5.54E-09,1.92E-08,1.83E-10,1.91E-07,1.49E-04,1.82E-07,1.67E-07,5.20E-08,7.10E-08,1.91E-07,1.91E-07,1.04E-07,3.81E-08,1.51E-08,7.27E-08,1.04E-07,2.66E-10,7.15E-08,2.48E-08,2.48E-08,2.00E-07,5.82E-09,1.96E-09,5.05E-08,8.96E-08,4.42E-07 -Small Intestine,6.74E-08,1.23E-10,5.24E-09,1.83E-08,1.98E-10,1.86E-07,1.82E-07,4.30E-05,1.09E-07,1.79E-07,7.55E-08,1.54E-08,1.54E-08,1.04E-07,4.88E-08,1.11E-08,1.45E-07,2.60E-07,4.64E-10,5.89E-08,1.95E-08,1.95E-08,7.53E-08,5.14E-09,2.04E-09,8.54E-08,2.07E-07,4.39E-07 -Stomach Wall,3.20E-07,7.48E-10,2.83E-08,1.00E-07,8.35E-10,6.51E-08,1.67E-07,1.02E-07,5.31E-05,1.81E-08,5.41E-09,1.23E-07,1.23E-07,9.67E-08,7.98E-08,9.81E-08,9.41E-09,4.40E-07,2.52E-09,2.42E-08,1.47E-08,1.47E-08,5.14E-07,2.67E-08,1.20E-08,4.29E-09,7.90E-09,4.38E-07 -ULI Wall,4.45E-08,4.45E-08,6.70E-09,1.28E-08,6.17E-11,2.68E-07,5.20E-08,1.79E-07,1.81E-08,7.54E-05,2.48E-08,1.13E-08,1.13E-08,9.46E-08,7.90E-08,8.82E-09,6.66E-08,8.87E-08,3.39E-10,5.01E-08,1.65E-08,1.65E-08,2.55E-08,4.83E-09,2.21E-09,2.52E-08,4.86E-08,4.42E-07 -Rectum,7.93E-09,1.52E-11,5.45E-10,7.10E-10,1.29E-10,1.78E-08,7.10E-08,7.35E-08,5.41E-09,2.48E-08,1.50E-04,1.31E-09,1.31E-09,1.60E-08,5.26E-09,1.03E-09,4.40E-07,1.00E-08,3.17E-11,7.15E-08,2.48E-08,2.48E-08,6.97E-09,3.68E-10,1.45E-10,5.82E-07,1.26E-06,4.42E-07 -Heart Wall,5.82E-08,2.76E-09,8.89E-08,4.12E-07,3.24E-09,2.43E-08,2.20E-08,1.42E-08,1.23E-07,1.13E-08,1.31E-09,3.32E-05,9.57E-05,2.39E-08,7.33E-08,2.58E-07,2.35E-09,6.89E-08,9.01E-09,3.01E-08,1.91E-08,1.91E-08,6.24E-08,4.00E-07,6.83E-08,1.00E-09,1.88E-09,4.39E-07 -Kidneys,6.90E-07,2.86E-10,6.86E-09,4.15E-08,3.16E-10,2.96E-07,1.04E-07,9.43E-08,9.63E-08,9.07E-08,1.60E-08,2.31E-08,2.32E-08,8.70E-05,1.51E-07,2.30E-08,3.02E-08,1.76E-07,8.82E-10,5.63E-08,2.33E-08,2.33E-08,4.19E-07,8.85E-09,4.26E-09,1.12E-08,2.31E-08,4.36E-07 -Liver,3.24E-07,8.80E-10,2.94E-08,1.34E-07,1.01E-09,2.50E-07,3.81E-08,4.55E-08,7.76E-08,7.54E-08,5.26E-09,7.24E-08,7.25E-08,1.52E-07,1.76E-05,1.07E-07,1.03E-08,2.41E-07,2.75E-09,2.74E-08,1.72E-08,1.72E-08,3.97E-08,4.19E-08,1.61E-08,4.52E-09,8.46E-09,4.37E-07 -Lungs,5.71E-08,5.02E-09,6.17E-08,2.09E-07,4.84E-09,2.28E-08,1.51E-08,1.02E-08,8.77E-08,8.81E-09,1.03E-09,2.15E-07,2.52E-07,2.30E-08,1.07E-07,2.50E-05,1.99E-09,4.71E-08,1.64E-08,3.64E-08,2.37E-08,2.37E-08,5.24E-08,3.25E-07,1.28E-07,8.33E-10,1.56E-09,4.34E-07 -Ovaries,1.66E-08,1.96E-11,7.10E-10,3.33E-09,7.54E-11,3.75E-08,7.26E-08,1.45E-07,9.41E-09,6.65E-08,4.39E-07,2.35E-09,2.35E-09,3.01E-08,1.03E-08,1.99E-09,2.14E-03,1.91E-08,2.72E-11,7.11E-08,2.21E-08,2.21E-08,1.17E-08,1.09E-09,2.04E-10,2.51E-07,9.10E-07,4.43E-07 -Pancreas,3.08E-07,3.93E-10,1.98E-08,1.33E-07,5.67E-10,2.76E-07,1.04E-07,2.14E-07,4.05E-07,8.85E-08,9.99E-09,6.56E-08,6.58E-08,1.72E-07,2.34E-07,4.64E-08,1.91E-08,2.00E-04,1.42E-09,4.19E-08,2.21E-08,2.21E-08,2.28E-07,2.10E-08,7.18E-09,7.93E-09,1.65E-08,4.44E-07 -Salivary Glands,1.93E-09,1.67E-07,9.83E-09,2.89E-08,1.41E-07,7.16E-10,2.66E-10,4.64E-10,2.52E-09,3.59E-10,3.17E-11,8.78E-09,8.78E-09,8.82E-10,2.75E-09,1.61E-08,2.72E-11,1.42E-09,3.38E-04,2.50E-08,2.39E-08,2.39E-08,1.60E-09,2.20E-08,8.60E-08,3.36E-10,1.98E-11,4.27E-07 -Red Marrow,4.73E-08,1.93E-08,1.69E-08,9.52E-08,1.49E-08,4.47E-08,3.64E-08,3.69E-08,3.27E-08,2.81E-08,5.44E-08,4.88E-08,4.88E-08,4.52E-08,3.60E-08,6.26E-08,8.62E-08,4.99E-08,2.89E-08,1.49E-05,6.81E-08,5.40E-06,3.96E-08,6.65E-08,6.58E-08,4.14E-08,5.94E-08,3.28E-07 -Osteogenic Cells,6.45E-08,1.27E-07,2.17E-08,1.02E-07,1.75E-07,6.17E-08,3.84E-08,4.57E-08,4.42E-08,3.15E-08,5.82E-08,5.80E-08,5.80E-08,5.69E-08,4.65E-08,6.93E-08,7.54E-08,7.23E-08,1.33E-07,5.64E-06,1.37E-05,1.73E-05,5.30E-08,6.93E-08,7.32E-08,4.00E-08,5.93E-08,3.75E-07 -Spleen,2.37E-06,6.75E-10,1.28E-08,7.80E-08,6.92E-10,4.78E-08,1.99E-07,6.88E-08,4.43E-07,2.55E-08,6.97E-09,6.14E-08,6.15E-08,4.09E-07,3.81E-08,5.02E-08,1.17E-08,2.31E-07,1.60E-09,2.93E-08,1.79E-08,1.79E-08,1.84E-04,1.80E-08,7.31E-09,4.45E-09,9.55E-09,4.37E-07 -Thymus,2.25E-08,5.55E-09,9.65E-08,2.98E-07,6.92E-09,1.14E-08,5.82E-09,5.13E-09,2.67E-08,4.83E-09,4.49E-10,3.65E-07,4.20E-07,4.26E-09,4.58E-08,3.54E-07,1.09E-09,2.23E-08,2.23E-08,2.48E-08,1.65E-08,1.65E-08,1.80E-08,1.18E-03,4.53E-07,3.38E-10,3.77E-10,4.33E-07 -Thyroid,8.45E-09,1.84E-08,3.18E-08,5.78E-07,1.94E-08,4.52E-09,1.96E-09,1.39E-09,1.20E-08,2.21E-09,1.45E-10,7.18E-08,7.18E-08,4.16E-09,1.61E-08,1.25E-07,2.04E-10,7.18E-09,8.56E-08,2.50E-08,2.39E-08,2.39E-08,7.31E-09,4.10E-07,1.38E-03,1.14E-10,6.41E-10,4.27E-07 -Urinary Bladder Wall,5.97E-09,1.04E-11,3.18E-10,1.62E-09,7.23E-11,1.65E-08,5.05E-08,7.02E-08,4.29E-09,2.52E-08,5.82E-07,1.00E-09,1.00E-09,1.00E-08,4.52E-09,8.33E-10,2.09E-07,7.93E-09,3.36E-10,2.82E-08,1.44E-08,1.44E-08,4.45E-09,3.38E-10,1.14E-10,7.54E-05,6.25E-07,4.40E-07 -Uterus,1.14E-08,1.92E-11,1.01E-09,2.94E-09,1.91E-10,3.19E-08,8.93E-08,1.93E-07,7.90E-09,4.83E-08,1.04E-06,1.88E-09,1.88E-09,2.37E-08,8.43E-09,1.56E-09,8.77E-07,1.65E-08,1.98E-11,5.19E-08,1.70E-08,1.70E-08,9.53E-09,3.77E-10,6.41E-10,6.09E-07,3.00E-04,4.42E-07 -Total Body,4.43E-07,4.25E-07,4.19E-07,4.18E-07,4.05E-07,4.39E-07,4.39E-07,4.38E-07,4.32E-07,4.37E-07,4.28E-07,4.32E-07,4.32E-07,4.38E-07,4.38E-07,4.34E-07,4.45E-07,4.46E-07,4.12E-07,4.35E-07,4.31E-07,4.31E-07,4.39E-07,4.33E-07,4.28E-07,4.34E-07,4.45E-07,4.30E-07 diff --git a/pytheranostics/dosimetry/phantomdata/Lu177-Male-Svalues.csv b/pytheranostics/dosimetry/phantomdata/Lu177-Male-Svalues.csv deleted file mode 100644 index a736b7b..0000000 --- a/pytheranostics/dosimetry/phantomdata/Lu177-Male-Svalues.csv +++ /dev/null @@ -1,26 +0,0 @@ -Target,Adrenals ,Brain,Esophagus,Eyes,GB Cont,LLI Cont ,SI Cont,StomCont,ULI Cont,Rectum,HeartCon,Hrt Wall,Kidneys ,Liver,Lungs,Pancreas,Prostate,Salivary,Red Mar.,CortBone,TrabBone,Spleen,Testes,Thymus,Thyroid,UB Cont,Tot Body -Adrenals,1.68E-03,4.12E-10,7.41E-08,1.79E-10,1.74E-07,1.92E-07,7.91E-08,1.60E-07,8.42E-08,1.24E-08,4.95E-08,4.95E-08,1.03E-06,2.87E-07,4.07E-08,2.15E-07,1.73E-08,1.47E-09,6.73E-08,2.98E-08,2.98E-08,4.42E-07,8.32E-10,1.73E-08,8.26E-09,8.10E-09,3.62E-07 -Brain,4.12E-10,1.70E-05,9.46E-09,1.57E-07,3.06E-10,3.08E-10,9.00E-11,9.63E-10,1.70E-10,1.94E-11,1.47E-09,1.47E-09,2.22E-10,6.58E-10,3.31E-09,4.69E-10,1.86E-11,1.55E-07,2.29E-08,3.34E-08,3.34E-08,5.59E-10,1.27E-12,3.14E-09,1.39E-08,6.54E-12,3.49E-07 -Esophagus,7.53E-08,9.45E-09,5.88E-04,7.98E-09,4.41E-08,3.47E-08,1.76E-08,2.11E-07,2.14E-08,8.17E-10,2.49E-07,2.68E-07,3.22E-08,9.98E-08,1.75E-07,1.07E-07,2.22E-09,4.40E-08,2.07E-08,2.10E-08,2.10E-08,6.81E-08,1.57E-10,1.61E-07,4.76E-07,1.02E-09,3.56E-07 -Eyes,1.79E-10,1.69E-07,7.98E-09,1.57E-03,6.83E-10,3.41E-10,1.61E-10,9.40E-10,8.49E-11,1.19E-10,1.95E-09,1.95E-09,2.95E-10,8.29E-10,2.75E-09,5.75E-10,6.92E-11,7.44E-08,2.29E-08,3.34E-08,3.34E-08,4.14E-10,1.35E-11,3.96E-09,1.00E-08,1.19E-11,3.49E-07 -Gallbladder Wall,1.77E-07,3.06E-10,4.41E-08,6.83E-10,2.09E-04,8.33E-08,7.29E-08,5.70E-08,4.03E-07,8.06E-09,5.65E-08,5.67E-08,9.27E-08,4.20E-07,3.31E-08,1.07E-07,1.31E-08,1.05E-09,3.09E-08,1.20E-08,1.20E-08,2.45E-08,6.67E-10,2.13E-08,7.05E-09,6.17E-09,3.64E-07 -LLI Wall,1.92E-07,3.08E-10,3.47E-08,3.41E-10,8.33E-08,1.59E-04,2.63E-07,1.64E-07,5.96E-08,7.69E-08,4.02E-08,4.03E-08,1.24E-07,3.70E-08,2.59E-08,4.20E-07,4.61E-08,6.60E-10,5.50E-08,2.02E-08,2.02E-08,1.38E-07,2.19E-09,1.08E-08,4.13E-09,2.39E-08,3.63E-07 -Small Intestine,7.92E-08,9.00E-11,1.76E-08,2.47E-10,7.29E-08,2.63E-07,3.45E-05,6.93E-08,1.97E-07,1.21E-07,2.21E-08,2.21E-08,7.10E-08,3.69E-08,1.09E-08,2.32E-07,9.27E-08,3.64E-10,5.04E-08,1.57E-08,1.57E-08,3.99E-08,6.56E-09,6.44E-09,2.24E-09,6.84E-08,3.64E-07 -Stomach Wall,1.49E-07,9.23E-10,2.24E-07,9.40E-10,5.45E-08,1.64E-07,4.50E-08,4.88E-05,1.47E-08,5.36E-09,1.89E-07,1.91E-07,6.00E-08,8.11E-08,1.23E-07,6.71E-07,5.84E-09,2.71E-09,2.25E-08,1.09E-08,1.09E-08,2.51E-07,4.01E-10,4.30E-08,1.76E-08,2.60E-09,3.59E-07 -ULI Wall,8.43E-08,1.70E-10,2.14E-08,8.49E-11,4.03E-07,5.96E-08,1.28E-07,1.47E-08,8.05E-05,2.42E-08,3.10E-08,3.10E-08,8.21E-08,9.96E-08,1.56E-08,1.20E-07,3.89E-08,4.72E-10,4.26E-08,1.38E-08,1.38E-08,2.43E-08,3.47E-09,9.04E-09,1.53E-09,2.75E-08,3.63E-07 -Rectum,1.24E-08,1.94E-11,2.38E-09,1.19E-10,8.06E-09,7.69E-08,1.21E-07,5.36E-09,2.42E-08,1.60E-04,2.03E-09,2.03E-09,2.35E-08,5.20E-09,1.34E-09,1.10E-08,4.93E-07,3.24E-11,5.50E-08,2.02E-08,2.02E-08,8.46E-09,3.48E-08,4.94E-10,3.35E-10,2.57E-07,3.63E-07 -Heart Wall,4.58E-08,1.71E-09,2.40E-07,1.95E-09,5.45E-08,4.02E-08,2.21E-08,1.99E-07,3.10E-08,2.03E-09,2.42E-05,7.27E-05,2.18E-08,1.08E-07,1.91E-07,1.36E-07,2.45E-09,5.43E-09,2.97E-08,1.57E-08,1.57E-08,3.95E-08,1.64E-10,4.65E-07,4.65E-08,1.05E-09,3.60E-07 -Kidneys,9.83E-07,2.77E-10,2.98E-08,2.92E-10,8.94E-08,1.24E-07,7.09E-08,6.24E-08,8.20E-08,2.35E-08,2.20E-08,2.21E-08,7.76E-05,1.20E-07,2.02E-08,8.98E-08,3.28E-08,6.39E-10,4.71E-08,1.73E-08,1.73E-08,2.65E-07,1.85E-09,8.05E-09,3.80E-09,1.18E-08,3.58E-07 -Liver,2.66E-07,7.89E-10,8.57E-08,8.29E-10,3.67E-07,3.63E-08,3.69E-08,8.35E-08,9.90E-08,5.20E-09,1.07E-07,1.08E-07,1.18E-07,1.37E-05,8.93E-08,1.14E-07,8.30E-09,2.16E-09,2.44E-08,1.33E-08,1.33E-08,3.12E-08,5.50E-10,3.84E-08,1.42E-08,3.78E-09,3.58E-07 -Lungs,3.93E-08,3.39E-09,1.56E-07,2.75E-09,3.26E-08,2.59E-08,1.09E-08,1.11E-07,1.56E-08,1.34E-09,1.64E-07,1.86E-07,1.94E-08,8.77E-08,1.99E-05,5.50E-08,1.72E-09,1.10E-08,3.04E-08,1.85E-08,1.85E-08,4.75E-08,1.07E-10,1.56E-07,1.12E-07,7.61E-10,3.54E-07 -Pancreas,2.06E-07,4.69E-10,9.57E-08,5.75E-10,1.08E-07,3.75E-07,2.09E-07,6.20E-07,1.34E-07,1.10E-08,1.37E-07,1.37E-07,9.16E-08,1.16E-07,5.67E-08,1.71E-04,1.21E-08,1.36E-09,4.05E-08,1.78E-08,1.78E-08,1.49E-07,8.56E-10,3.18E-08,9.58E-09,5.30E-09,3.65E-07 -Prostate,1.73E-08,1.86E-11,2.22E-09,6.92E-11,1.31E-08,4.60E-08,6.00E-08,5.84E-09,3.88E-08,4.92E-07,2.39E-09,2.39E-09,3.28E-08,8.30E-09,1.72E-09,1.21E-08,1.39E-03,2.51E-10,2.44E-08,1.08E-08,1.08E-08,1.02E-08,2.85E-08,7.20E-10,5.62E-10,3.63E-07,3.62E-07 -Salivary Glands,1.47E-09,1.53E-07,4.41E-08,7.76E-08,9.17E-10,6.60E-10,2.37E-10,2.93E-09,4.72E-10,3.40E-11,5.35E-09,5.35E-09,6.39E-10,2.16E-09,1.10E-08,1.36E-09,2.35E-11,2.78E-04,2.07E-08,2.10E-08,2.10E-08,1.80E-09,3.64E-11,1.22E-08,5.80E-08,3.17E-11,3.56E-07 -Red Marrow,3.78E-08,2.89E-08,6.03E-08,1.69E-08,1.90E-08,2.46E-08,2.14E-08,3.40E-08,1.98E-08,5.61E-08,3.60E-08,3.60E-08,3.35E-08,3.09E-08,5.53E-08,2.51E-08,7.74E-08,2.23E-08,1.15E-05,5.60E-08,4.16E-06,3.67E-08,8.38E-09,5.88E-08,4.69E-08,3.96E-08,2.74E-07 -Osteogenic Cells,6.09E-08,1.05E-07,7.57E-08,1.13E-07,2.66E-08,3.52E-08,3.21E-08,4.63E-08,2.99E-08,5.07E-08,3.77E-08,3.77E-08,5.34E-08,3.74E-08,5.47E-08,3.93E-08,7.17E-08,1.06E-07,5.62E-06,1.36E-05,1.73E-05,5.07E-08,1.77E-08,4.32E-08,5.63E-08,4.30E-08,4.00E-07 -Spleen,4.15E-07,5.59E-10,5.91E-08,4.14E-10,2.37E-08,1.38E-07,3.99E-08,2.51E-07,2.43E-08,8.46E-09,3.83E-08,3.83E-08,2.58E-07,3.03E-08,4.64E-08,1.41E-07,9.88E-09,1.53E-09,2.48E-08,1.35E-08,1.35E-08,1.60E-04,5.26E-10,1.54E-08,1.01E-08,3.47E-09,3.58E-07 -Testes,8.32E-10,1.27E-12,1.52E-10,1.35E-11,6.67E-10,2.19E-09,4.26E-09,4.01E-10,3.47E-09,3.48E-08,1.77E-10,1.77E-10,1.85E-09,5.50E-10,1.07E-10,8.56E-10,2.75E-08,3.64E-11,8.50E-09,1.12E-08,1.12E-08,5.26E-10,6.77E-04,2.43E-10,8.58E-11,7.77E-08,3.51E-07 -Thymus,1.39E-08,3.14E-09,1.41E-07,3.19E-09,1.89E-08,1.08E-08,4.18E-09,4.27E-08,9.04E-09,4.94E-10,3.47E-07,4.26E-07,7.62E-09,3.97E-08,1.49E-07,2.91E-08,7.20E-10,1.29E-08,2.35E-08,1.34E-08,1.34E-08,1.63E-08,2.43E-10,9.42E-04,2.03E-07,4.49E-10,3.55E-07 -Thyroid,8.26E-09,1.39E-08,4.24E-07,1.00E-08,7.05E-09,4.13E-09,1.46E-09,1.75E-08,1.53E-09,1.95E-10,4.45E-08,4.45E-08,3.80E-09,1.42E-08,1.18E-07,9.57E-09,1.55E-10,6.14E-08,2.07E-08,2.10E-08,2.10E-08,1.01E-08,8.58E-11,2.22E-07,1.18E-03,2.24E-10,3.56E-07 -Urinary Bladder Wall,8.10E-09,6.54E-12,1.02E-09,1.19E-10,6.17E-09,2.39E-08,4.44E-08,2.60E-09,2.75E-08,2.57E-07,1.05E-09,1.05E-09,1.18E-08,3.78E-09,7.61E-10,5.30E-09,3.83E-07,3.17E-11,2.44E-08,1.08E-08,1.08E-08,3.47E-09,7.78E-08,4.49E-10,2.24E-10,5.78E-05,3.62E-07 -Total Body,3.47E-07,3.30E-07,3.44E-07,3.32E-07,3.42E-07,3.49E-07,3.48E-07,3.43E-07,3.47E-07,3.54E-07,3.38E-07,3.38E-07,3.49E-07,3.40E-07,3.41E-07,3.45E-07,3.57E-07,3.38E-07,3.57E-07,3.54E-07,3.54E-07,3.46E-07,3.49E-07,3.41E-07,3.46E-07,3.52E-07,3.54E-07 diff --git a/pytheranostics/dosimetry/phantomdata/human_phantom_masses.csv b/pytheranostics/dosimetry/phantomdata/human_phantom_masses.csv deleted file mode 100644 index 0a67d39..0000000 --- a/pytheranostics/dosimetry/phantomdata/human_phantom_masses.csv +++ /dev/null @@ -1,30 +0,0 @@ -Organ,Female,Male -Adrenals,13,14 -Brain,1300,1450 -Breasts,500,25 -Esophagus,35,40 -Eyes,15,15 -Gallbladder,8,10 -Left Colon,145,150 -Small Intestine,600,650 -Stomach,140,150 -Right Colon,145,150 -Heart,250,330 -Heart Contents,370,510 -Kidneys,275,310 -Liver,1400,1800 -Lungs,950,1200 -Ovaries,11,0 -Pancreas,120,140 -Prostate,0,17 -Rectum,70,70 -Salivary Glands,70,85 -Red Marrow,900,1170 -Bone Surfaces,120,120 -Spleen,130,150 -Testes,0,35 -Thymus,20,25 -Thyroid,17,20 -Bladder,40,50 -Uterus,80,0 -Body,60000,73000 diff --git a/pytheranostics/dosimetry/voxel_s_dosimetry.py b/pytheranostics/dosimetry/voxel_s_dosimetry.py index a9d5064..3dd719e 100644 --- a/pytheranostics/dosimetry/voxel_s_dosimetry.py +++ b/pytheranostics/dosimetry/voxel_s_dosimetry.py @@ -14,6 +14,7 @@ from pytheranostics.fits.fits import get_exponential from pytheranostics.imaging_ds.longitudinal_study import LongitudinalStudy from pytheranostics.imaging_tools.tools import itk_image_from_array, resample_to_target +from pytheranostics.shared.resources import resource_path class VoxelSDosimetry(BaseDosimetry): @@ -173,14 +174,11 @@ def run_MC(self) -> None: # TODO: finish the code!!!!! # ============================================================================= n_primaries_per_mac = int(n_primaries / n_cpu) - file_path = os.path.join( - os.path.dirname(__file__), "../data/monte_carlo/main_template.mac" - ) - - mac_file = numpy.fromfile(file_path, dtype=numpy.float32) - - with open(file_path, "r") as mac_file: - filedata = mac_file.read() + with resource_path( + "pytheranostics.data", "monte_carlo/main_template.mac" + ) as template_path: + with template_path.open("r", encoding="utf-8") as mac_file: + filedata = mac_file.read() for i in range(0, n_cpu): new_mac = filedata @@ -198,17 +196,13 @@ def run_MC(self) -> None: # TODO: finish the code!!!!! os.makedirs(os.path.join(output_dir, "data"), exist_ok=True) os.makedirs(os.path.join(output_dir, "output"), exist_ok=True) - folder_path = os.path.join( - os.path.dirname(__file__), "../data/monte_carlo/data" - ) - # Copy files from the source directory to the destination directory - for file_name in os.listdir(folder_path): - full_file_name = os.path.join(folder_path, file_name) - if os.path.isfile(full_file_name): - shutil.copy(full_file_name, os.path.join(output_dir, "data")) - - # List the files in the destination directory to confirm the copy operation - os.listdir(os.path.join(output_dir, "data")) + with resource_path("pytheranostics.data", "monte_carlo/data") as folder_path: + for entry in folder_path.iterdir(): + if entry.is_file(): + shutil.copy( + entry, + os.path.join(output_dir, "data", entry.name), + ) # TODO: Below is still work in progress diff --git a/pytheranostics/fits/__init__.py b/pytheranostics/fits/__init__.py index e69de29..3731650 100644 --- a/pytheranostics/fits/__init__.py +++ b/pytheranostics/fits/__init__.py @@ -0,0 +1 @@ +"""PyTheranostics package.""" diff --git a/pytheranostics/fits/fits.py b/pytheranostics/fits/fits.py index e99b27c..a5cc94c 100644 --- a/pytheranostics/fits/fits.py +++ b/pytheranostics/fits/fits.py @@ -1,3 +1,5 @@ +"""Curve-fitting utilities built on top of lmfit.""" + from typing import Any, Callable, Dict, Optional, Tuple import lmfit @@ -69,7 +71,6 @@ def exponential_fit_lmfit( - For bi-exponential: B1 = -A1 - For tri-exponential: C1 = -(A1 + B1) """ - if num_exponentials not in [1, 2, 3]: raise ValueError( f"num_exponentials must be 1, 2, or 3., found {num_exponentials}" @@ -172,8 +173,7 @@ def fitted_model(x): def calculate_r_squared( time: numpy.ndarray, activity: numpy.ndarray, popt: numpy.ndarray, func: Callable ) -> Tuple[float, numpy.ndarray]: - """Calculate r_squared and residuals between fit and data-points.""" - + """Calculate r-squared and residuals between the fit and data points.""" residuals = activity - func(time, *popt) ss_res = numpy.sum(residuals**2) @@ -186,9 +186,7 @@ def calculate_r_squared( def get_exponential( order: int, param_init: Optional[Tuple[float, ...]], decayconst: float ) -> Tuple[Callable, Tuple[float, ...], Optional[Tuple[Any, ...]]]: - """Retrieve an exponential function given an input order 'order', initial parameters and a decay-constant - value (for defatult constrains)""" - + """Retrieve an exponential model, default parameters, and bounds.""" # Default initial parameters: default_initial = { 1: (1, 1), diff --git a/pytheranostics/fits/functions.py b/pytheranostics/fits/functions.py index 04af59f..513b849 100644 --- a/pytheranostics/fits/functions.py +++ b/pytheranostics/fits/functions.py @@ -1,3 +1,5 @@ +"""Elementary exponential functions used by the fitting module.""" + import math import numpy @@ -6,31 +8,37 @@ # Function definitions def monoexp_fun(x: numpy.ndarray, a: float, b: float) -> numpy.ndarray: + """Return a single exponential evaluated at ``x``.""" return a * exp(-b * x) def biexp_fun( x: numpy.ndarray, a: float, b: float, c: float, d: float ) -> numpy.ndarray: + """Return the sum of two exponential decay terms.""" return a * exp(-b * x) + c * exp(-d * x) def biexp_fun_uptake(x: numpy.ndarray, a: float, b: float, c: float) -> numpy.ndarray: + """Return the uptake-style biexponential curve.""" return a * exp(-b * x) - a * exp(-c * x) def triexp_fun( x: numpy.ndarray, a: float, b: float, c: float, d: float, f: float ) -> numpy.ndarray: + """Return the tri-exponential washout model.""" return a * exp(-b * x) + c * exp(-d * x) - (a + c) * exp(-f * x) def find_a_initial( f: numpy.ndarray, b: numpy.ndarray, t: numpy.ndarray ) -> numpy.ndarray: + """Estimate the initial amplitude given decay constants and time samples.""" return f * exp(b * t) # TODO: Review Hanscheid inputs/outputs. def Hanscheid(a, t): + """Compute the Hanscheid approximation for cumulated activity.""" return a * ((2 * t) / (math.log(2))) diff --git a/pytheranostics/imaging_tools/tools.py b/pytheranostics/imaging_tools/tools.py index 1f6cc47..0cb3538 100644 --- a/pytheranostics/imaging_tools/tools.py +++ b/pytheranostics/imaging_tools/tools.py @@ -78,24 +78,35 @@ def load_metadata(dir: str, modality: str) -> ImagingMetadata: radionuclide = modality.split("_")[0] # This only applies to Q-SPECT TODO: replace for something more generic. - try: - injected_activity = ( - dicom_slices[0] - .RadiopharmaceuticalInformationSequence[0] - .RadionuclideTotalDose - ) - - # Currently we don't have a way to know the units ... so we use common sense. - if injected_activity > 20000: # Activity likely in Bq instead of MBq - injected_activity /= 1e6 - print( - f"Injected activity found in DICOM Header: {injected_activity:2.1f} MBq. Please verify." - ) + injected_activity = None + + if hasattr(dicom_slices[0], "RadiopharmaceuticalInformationSequence"): + rp_seq = dicom_slices[0].RadiopharmaceuticalInformationSequence + if len(rp_seq) > 0: + try: + injected_activity = rp_seq[0].RadionuclideTotalDose + + # Currently we don't have a way to know the units ... so we use common sense. + if ( + injected_activity > 20000 + ): # Activity likely in Bq instead of MBq + injected_activity /= 1e6 + print( + f"Injected activity found in DICOM Header: {injected_activity:2.1f} MBq. Please verify." + ) + except AttributeError: + # Sequence exists but RadionuclideTotalDose attribute is missing + print( + "RadiopharmaceuticalInformationSequence found but RadionuclideTotalDose is missing." + ) + else: + # Sequence exists but is empty - this may indicate a data quality issue + print( + "Warning: RadiopharmaceuticalInformationSequence is empty. This may indicate a data quality issue." + ) - except AttributeError: - print( - "Injected activity not found in DICOM header. Using default: 7400 MBq" - ) + if injected_activity is None: + print("Using default injected activity: 7400 MBq") injected_activity = 7400.0 # Global attributes. Should be the same in all slices! diff --git a/pytheranostics/plots/__init__.py b/pytheranostics/plots/__init__.py index e69de29..3731650 100644 --- a/pytheranostics/plots/__init__.py +++ b/pytheranostics/plots/__init__.py @@ -0,0 +1 @@ +"""PyTheranostics package.""" diff --git a/pytheranostics/plots/plots.py b/pytheranostics/plots/plots.py index bee8c8d..1d9f1ec 100644 --- a/pytheranostics/plots/plots.py +++ b/pytheranostics/plots/plots.py @@ -1,3 +1,5 @@ +"""Plotting utilities for PyTheranostics workflows.""" + from pathlib import Path from typing import Optional @@ -30,7 +32,6 @@ def ewin_montage(img: numpy.ndarray, ewin: dict) -> None: - Colorbars are added to each subplot. - The layout is automatically adjusted using tight_layout(). """ - plt.figure(figsize=(22, 6)) for ind, i in enumerate(range(0, int(img.shape[0]), 2)): keys = list(ewin.keys()) @@ -58,22 +59,8 @@ def plot_tac_residuals( y_label: str = "Activity [MBq]", output_dir: Optional[Path] = None, ) -> None: - """Plot Time activity curve and residuals. - - Parameters - ---------- - result : lmfit.model.ModelResult - The fitted lmfit model results. - region : str - The region (e.g., organ, tumor) where fit happened. - x_label: str - The label in X Axis. Defaults to "Time [hr]" - y_label: str - The label in Y Axis. Defaults to "Activity [MBq]" - output_dir: Optional[str] - A path to a directory where figure will be saved. - """ - + """Plot time-activity curve and residuals.""" + # Create a figure with 3 subplots # Create a figure with 3 subplots _, axs = plt.subplots(1, 3, figsize=(12, 4), constrained_layout=True) @@ -109,15 +96,15 @@ def plot_tac_residuals( # Plot fitted model ax1.plot(x_fit, y_fit, color="red") ax1.set_xlim(left=0) # Start x-axis from zero - ax1.set_xlim(right = x_data[-1] * 2) # Start y-axis from zero + ax1.set_xlim(right=x_data[-1] * 2) # Start y-axis from zero ax1.set_ylim(bottom=0) # Start y-axis from zero ax1.set_title(region) ax1.set_xlabel(x_label) ax1.set_ylabel(y_label) # Add R-squared and AIC as text try: - ax1.text(0.7, 0.9, f'$R^2={result.rsquared:.3f}$', transform=ax1.transAxes) - ax1.text(0.7, 0.85, f'AIC={result.aic:.3f}', transform=ax1.transAxes) + ax1.text(0.7, 0.9, f"$R^2={result.rsquared:.3f}$", transform=ax1.transAxes) + ax1.text(0.7, 0.85, f"AIC={result.aic:.3f}", transform=ax1.transAxes) except AttributeError: pass # Remove legend if present @@ -150,7 +137,12 @@ def plot_tac_residuals( ax3.set_ylabel("Residuals") if output_dir is not None: - plt.savefig(output_dir / f"{region}_fit_Cycle_0{cycle}.png", format="png", bbox_inches="tight", dpi=300) + plt.savefig( + output_dir / f"{region}_fit_Cycle_0{cycle}.png", + format="png", + bbox_inches="tight", + dpi=300, + ) plt.show() diff --git a/pytheranostics/preclinical_dosimetry/biodose.py b/pytheranostics/preclinical_dosimetry/biodose.py index 1edcede..1b93d57 100644 --- a/pytheranostics/preclinical_dosimetry/biodose.py +++ b/pytheranostics/preclinical_dosimetry/biodose.py @@ -1,3 +1,5 @@ +"""Preclinical biodistribution analysis helpers for BioDose workflows.""" + import datetime from copy import deepcopy from os import makedirs, path @@ -14,25 +16,20 @@ monoexp_fun, ) from pytheranostics.plots.plots import plot_tac_residuals +from pytheranostics.shared.resources import resource_path + -this_dir = path.dirname(__file__) -parent_dir = path.dirname(this_dir) -TEMPLATE_PATH = path.join(this_dir, "olindaTemplates") -PHANTOM_PATH = path.join( - this_dir, "phantomdata" -) # These variables use only lowercases so "PhantomData" is in lowercase -SVALUES_PATH = path.join(parent_dir, "data", "s-values") +def _data_resource(relative_path: str): + return resource_path("pytheranostics.data", relative_path) class BioDose: - """ - This is a class for the analysis of biodistribution data in preclinical experiments - """ + """Analyze biodistribution data gathered from preclinical experiments.""" def __init__( self, isotope, half_life, phantom, mouse_mass, sex, uptake=None, timepoints=None ): - "half_life in hours" + """Initialize the biodistribution analysis (half_life expressed in hours).""" if uptake is None: uptake = [] if timepoints is None: @@ -49,7 +46,7 @@ def __init__( self.wb_m = int(self.mouse_mass[:-1]) def read_biodi(self, biodi_file): - """This method reads a biodi file and sets the data as a pandas dataframe in self.biodi""" + """Read a biodistribution CSV and populate ``self.biodi``.""" print( "Reading biodistribution information from the file: {}".format(biodi_file) ) @@ -102,9 +99,7 @@ def read_biodi(self, biodi_file): print("Decayed biodistribution stored in self.biodi") def initialize_results_df(self): - """ - This method initializes the results dataframe with the organ list and columns for the different fits. - """ + """Initialize the results DataFrame with the expected columns.""" columns = [ "Mono-Exponential", "Bi-Exponential", @@ -128,9 +123,7 @@ def update_fit_results( area_bi=None, area_uptake=None, ): - """ - This method updates the fit results for a given organ. - """ + """Update the stored fit parameters and AUC metrics for one organ.""" area_mono_val = ( area_mono[0] if isinstance(area_mono, (tuple, list)) else area_mono ) @@ -180,10 +173,7 @@ def curve_fits( append_zero=True, tps_to_skip_fit=0, ): - """ - This method fits the curves using lmfit and stores the results in self.fit_results. - The results can be seen in self.area organized in a pandas dataframe. - """ + """Fit exponential models (with optional uptake) using lmfit.""" decayconst = log(2) / self.half_life if organlist is None: @@ -359,7 +349,7 @@ def curve_fits( print(f"Error creating fitting parameters DataFrame: {e}") def num_decays(self, fit_accepted): - """Sets the number of decays in each of the organ based on the accepted fit (e.g. exponential or bi-exponential)""" + """Compute organ decays based on the accepted fit type.""" self.disintegrations = pd.DataFrame(index=self.biodi.index, columns=["%ID/g*h"]) self.fit_accepted = fit_accepted @@ -397,6 +387,7 @@ def num_decays(self, fit_accepted): self.disintegrations.loc["Remainder Body"] = np.nan def calculate_tumor_sink_effect(self): + """Compute the tumor sink effect factor for downstream corrections.""" tumor_value = self.disintegrations["h"]["Tumor"] wb_value = self.disintegrations["h"].sum() @@ -407,6 +398,7 @@ def calculate_tumor_sink_effect(self): print(f"Tumor sink effect: {self.tumor_sink_effect_factor}") def tumor_sink_effect_correction(self, df): + """Apply the tumor sink effect factor to a biodistribution DataFrame.""" df_corrected = df.copy() for organ in df_corrected.columns: @@ -416,11 +408,12 @@ def tumor_sink_effect_correction(self, df): return df_corrected def phantom_data(self): - print(PHANTOM_PATH) + """Load the reference phantom masses and reconcile them with biodistribution organs.""" if "mouse" in self.phantom.lower(): - self.phantom_mass = pd.read_csv( - path.join(PHANTOM_PATH, "mouse_phantom_masses.csv") - ) # TODO: CHANGE PATH + with _data_resource( + "phantom/mouse/mouse_phantom_masses.csv" + ) as phantom_path: + self.phantom_mass = pd.read_csv(phantom_path) # elif 'human' in self.phantom.lower(): # self.phantom_mass = pd.read_csv(path.join(PHANTOM_PATH,'human_phantom_masses.csv')) self.phantom_mass.set_index("Organ", inplace=True) @@ -463,7 +456,7 @@ def phantom_data(self): ) def remainder_body_uptake(self, tumor_name=None): - + """Redistribute activity from organs that are not modeled in the phantom.""" print("At this point we are ignoring the tumor") if tumor_name: self.not_inphantom_notumor = [ @@ -477,14 +470,16 @@ def remainder_body_uptake(self, tumor_name=None): # These organs that are not modelled in the phantom are now going to be scaled using mass information from the literature: if "mouse" in self.phantom.lower(): - self.literature_mass = pd.read_csv( - path.join(PHANTOM_PATH, "mouse_notinphantom_masses.csv") - ) # TODO: CHANGE PATH + with _data_resource( + "phantom/mouse/mouse_notinphantom_masses.csv" + ) as lit_path: + self.literature_mass = pd.read_csv(lit_path) elif "human" in self.phantom.lower(): - self.literature_mass = pd.read_csv( - path.join(PHANTOM_PATH, "human_notinphantom_masses.csv") - ) # TODO: CHANGE PATH + with _data_resource( + "phantom/human/human_notinphantom_masses.csv" + ) as lit_path: + self.literature_mass = pd.read_csv(lit_path) print(self.phantom.lower()) print(self.literature_mass) @@ -585,25 +580,29 @@ def remainder_body_uptake(self, tumor_name=None): ) # Only organs that are in the phantom will be kept in the disintegrations dataframe and passed to olinda def not_inphantom_notumor_fun(self): + """Drop leftover organs that the phantom model does not include.""" self.disintegrations.drop(self.not_inphantom_notumor, inplace=True) def add_tumor_mass(self, tumor_name, tumor_mass): - self.phantom_mass.loc[tumor_name] = ( - tumor_mass # grams Provided by average of biodi from Etienne - ) + """Register a tumor entry with the provided mass (grams).""" + self.phantom_mass.loc[tumor_name] = tumor_mass def calculate_absorbed_dose(self, model, disintegrations): """ - Calculate absorbed dose per target organ based on selected model and disintegration data. - - Args: - model (str): One of 'mouse25g', 'mouse30g', or 'mouse35g' - disintegrations (pd.DataFrame): DataFrame with source organ disintegrations in hours - - Returns: - pd.DataFrame: Absorbed dose in mGy and Gy for each target organ + Calculate absorbed dose per target organ based on the selected model. + + Parameters + ---------- + model : str + One of ``mouse25g``, ``mouse30g``, ``mouse35g``, ``Female``, or ``Male``. + disintegrations : pandas.DataFrame + Source-organ disintegrations expressed in hours. + + Returns + ------- + pandas.DataFrame + Absorbed dose in mGy and Gy for each target organ. """ - disintegrations = disintegrations.copy() model_files = { @@ -619,8 +618,8 @@ def calculate_absorbed_dose(self, model, disintegrations): f"Invalid model '{model}'. Expected one of: {list(model_files.keys())}" ) - svalues_path = path.join(SVALUES_PATH, model_files[model]) - svalues_df = pd.read_csv(svalues_path, index_col=0) # S-values in mSv/MBq-s + with _data_resource(f"s-values/{model_files[model]}") as svalues_path: + svalues_df = pd.read_csv(svalues_path, index_col=0) # S-values in mSv/MBq-s print(f"Loaded S-values from: {svalues_path}") print("Target organs (S-value index):", svalues_df.index.tolist()) @@ -675,16 +674,20 @@ def calculate_absorbed_dose(self, model, disintegrations): def apply_s_value(self, tia_df, s_values): """ - Multiply S-values by TIA to compute dose matrix. - - Args: - tia_df (pd.DataFrame): Disintegration times in seconds - s_values (pd.DataFrame): S-values table (target organs as index, source organs as columns) - - Returns: - pd.DataFrame: Dose matrix (target organ x source organ) + Multiply S-values by TIA to compute a dose matrix. + + Parameters + ---------- + tia_df : pandas.DataFrame + Disintegration times in seconds. + s_values : pandas.DataFrame + S-value table with target organs as rows and source organs as columns. + + Returns + ------- + pandas.DataFrame + Dose matrix indexed by target organ. """ - common_source_organs = tia_df.index.intersection(s_values.columns) print(f"{len(common_source_organs)} source organs: {common_source_organs}") @@ -708,11 +711,14 @@ def create_mousecase( savefile=False, dirname="./", ): - """This function creates a pandas dataframe that looks exactly as the case files generated by OLINDA for the g mouse. - The result can be viewed under self.mousecase, and the pandas methods can be used to finally save it if wanted. + """ + Create a pandas representation of the mouse case file used by OLINDA. + + The result is stored in ``self.mousecase`` and can optionally be persisted. """ filename = self.phantom.lower() + ".cas" - template = pd.read_csv(path.join(TEMPLATE_PATH, filename)) + with _data_resource(f"olinda/templates/mouse/{filename}") as template_path: + template = pd.read_csv(template_path) template.columns = ["Data"] # modify the isotope in the template @@ -779,6 +785,7 @@ def create_mousecase( print(f"The case file {filename} has been saved in\n{format(dirname)}") def rename_organ(self, oldname, newname): + """Rename an organ across biodistribution and disintegration tables.""" ind_list = self.disintegrations_all_organs.index.tolist() ind_pos = ind_list.index(oldname) ind_list[ind_pos] = newname @@ -790,6 +797,7 @@ def rename_organ(self, oldname, newname): self.biodi.index = ind_list def create_human(self, tumor_name=None): + """Convert the current biodistribution into the human phantom domain.""" # We are mostly using the disintegrations_all_organs dataframe, but we adjust the biodi dataframe as well to match the human phantom structure human = deepcopy(self) human.phantom = "AdultHuman" @@ -842,15 +850,17 @@ def create_human(self, tumor_name=None): "Tumor", axis=0 ) - human.phantom_mass = pd.read_csv( - path.join(PHANTOM_PATH, "human_phantom_masses.csv") - ) + with _data_resource( + "phantom/human/human_phantom_masses.csv" + ) as human_mass_path: + human.phantom_mass = pd.read_csv(human_mass_path) human.phantom_mass.set_index("Organ", inplace=True) human.phantom_mass.sort_index(inplace=True) - human.literature_mass = pd.read_csv( - path.join(PHANTOM_PATH, "human_notinphantom_masses.csv") - ) + with _data_resource( + "phantom/human/human_notinphantom_masses.csv" + ) as human_lit_path: + human.literature_mass = pd.read_csv(human_lit_path) human.literature_mass.set_index("Organ", inplace=True) human.disintegrations_all_organs.sort_index(inplace=True) @@ -927,9 +937,9 @@ def create_human(self, tumor_name=None): return human def apply_relative_mass_scaling(self, mouse_mass=25): - rMSF_data = pd.read_csv( - path.join(PHANTOM_PATH, "rMSF_factor.csv"), index_col=0 - ) # TODO: CHANGE PATH + """Apply relative mass scaling factors to mouse disintegrations.""" + with _data_resource("phantom/mouse/rMSF_factor.csv") as rmsf_path: + rMSF_data = pd.read_csv(rmsf_path, index_col=0) female_mass_sum = rMSF_data.loc[self.not_inphantom_notumor, "Female"].sum() male_mass_sum = rMSF_data.loc[self.not_inphantom_notumor, "Male"].sum() @@ -964,14 +974,18 @@ def apply_relative_mass_scaling(self, mouse_mass=25): ] *= remainder_correction_male def create_humancase(self, df, method, savefile=False, dirname="./"): - """This function creates a pandas dataframe that looks exactly as the case files generated by OLINDA for the human. - The result can be viewed under self.humancas, and the pandas methods can be used to finally save it if wanted. """ + Create a pandas representation of the human case file used by OLINDA. - if self.sex == "Male": - template = pd.read_csv(path.join(TEMPLATE_PATH, "adult_male.cas")) - else: - template = pd.read_csv(path.join(TEMPLATE_PATH, "adult_female.cas")) + The result is stored in ``self.humancas`` and can optionally be saved. + """ + template_filename = ( + "adult_male.cas" if self.sex == "Male" else "adult_female.cas" + ) + with _data_resource( + f"olinda/templates/human/{template_filename}" + ) as template_path: + template = pd.read_csv(template_path) template.columns = ["Data"] @@ -1035,7 +1049,7 @@ def create_humancase(self, df, method, savefile=False, dirname="./"): ) def scale_biexponential_tiac(self, row, biol_lambda_SF=0.25): - + """Scale biexponential fits to enforce biological clearance constraints.""" Cm_organ_t0_1 = row["bi_exp1:%ID"] / 100 Cm_organ_t0_2 = row["bi_exp2:%ID"] / 100 @@ -1076,7 +1090,7 @@ def scale_biexponential_tiac(self, row, biol_lambda_SF=0.25): return TIAC_h_bi_male, TIAC_h_bi_female def scale_monoexponential_tiac(self, row, biol_lambda_SF=0.25): - + """Scale monoexponential fits to enforce biological clearance constraints.""" lambda_effective = row["mono_exp:lambda_effective_1/h"] lambda_physical = log(2) / self.half_life # 1/h @@ -1099,7 +1113,7 @@ def scale_monoexponential_tiac(self, row, biol_lambda_SF=0.25): return TIAC_h_mono_male, TIAC_h_mono_female def lambda_biological_scaling(self, biol_lambda_SF=0.25, tumor_name=None): - + """Apply biological lambda scaling to the stored fits and return a new BioDose instance.""" print("At this point we are ignoring the tumor") if tumor_name: self.not_inphantom_notumor = [ diff --git a/pytheranostics/preclinical_dosimetry/olindaTemplates/adult_female.cas b/pytheranostics/preclinical_dosimetry/olindaTemplates/adult_female.cas deleted file mode 100644 index 35fe178..0000000 --- a/pytheranostics/preclinical_dosimetry/olindaTemplates/adult_female.cas +++ /dev/null @@ -1,127 +0,0 @@ -Saved on 06.06.2017 at 09:36:07 PDT -[BEGIN CASE FILE] -[BEGIN NUCLIDE SETTINGS] -USE_DECAY_SERIES|false -USE_LEGACY_DATA|false -[END NUCLIDE SETTINGS] -[BEGIN NUCLIDES] -Lu-177| -[END NUCLIDES] -[BEGIN MODEL SETTINGS] -[END MODEL SETTINGS] -[BEGIN MODELS] -[BEGIN ICRP 89 Adult Female] -ICRP 89 Adult Female|B -[BEGIN ICRP 89 Adult Female SOURCE ORGANS] -Adrenals|13.0|0.0 -Brain|1300.0|0.0 -Breasts|500.0|0.0 -Esophagus|35.0|0.0 -Eyes|15.0|0.0 -Gallbladder Contents|48.0|0.0 -LLI Contents|80.0|0.0 -Small Intestine|280.0|0.0 -Stomach Contents|230.0|0.0 -ULI Contents|160.0|0.0 -Rectum|80.0|0.0 -Heart Contents|370.0|0.0 -Heart Wall|250.0|0.0 -Kidneys|275.5|0.0 -Liver|1400.0|0.0 -Lungs|950.0|0.0 -Muscle|0.0|0.0 -Ovaries|11.0|0.0 -Pancreas|120.0|0.0 -Prostate|0.0|0.0 -Salivary Glands|70.0|0.0 -Red Marrow|900.0|0.0 -Cortical Bone|3200.0|0.0 -Trabecular Bone|800.0|0.0 -Spleen|130.0|0.0 -Testes|0.0|0.0 -Thymus|20.0|0.0 -Thyroid|17.0|0.0 -Urinary Bladder Contents|160.0|0.0 -Uterus|80.0|0.0 -Fetus|0.0|0.0 -Placenta|0.0|0.0 -Total Body|60000.0|0.0 -[END ICRP 89 Adult Female SOURCE ORGANS] -[BEGIN ICRP 89 Adult Female MODEL OPTIONS] -IS_BONE_ACTIVITY_ON_SURFACE|true -[END ICRP 89 Adult Female MODEL OPTIONS] -[BEGIN ICRP 89 Adult Female TARGET ORGANS] -Adrenals|13.0 -Brain|1300.0 -Breasts|500.0 -Esophagus|35.0 -Eyes|15.0 -Gallbladder Wall|8.0 -LLI Wall|145.0 -Small Intestine|600.0 -Stomach|140.0 -ULI Wall|145.0 -Rectum|70.0 -Heart Wall|250.0 -Kidneys|275.5 -Liver|1400.0 -Lungs|950.0 -Muscle|0.0 -Ovaries|11.0 -Pancreas|120.0 -Prostate|0.0 -Salivary Glands|70.0 -Red Marrow|900.0 -Bone Surfaces|120.0 -Skin|0.0 -Spleen|130.0 -Testes|0.0 -Thymus|20.0 -Thyroid|17.0 -Urinary Bladder Wall|40.0 -Uterus|80.0 -Fetus|0.0 -Placenta|0.0 -Total Body|60000.0 -[END ICRP 89 Adult Female TARGET ORGANS] -[BEGIN ICRP 89 Adult Female MODEL TARGET ORGANS] -TARGET_ORGAN_MASSES_ARE_FROM_USER_INPUT|FALSE -[END ICRP 89 Adult Female MODEL TARGET ORGANS] -[BEGIN KINETIC DATA] -Adrenals|0.0 -Brain|0.0 -Breasts|0.0 -Esophagus|0.0 -Eyes|0.0 -Gallbladder Contents|0.0 -LLI Contents|0.0 -Small Intestine|0.0 -Stomach Contents|0.0 -ULI Contents|0.0 -Rectum|0.0 -Heart Contents|0.0 -Heart Wall|0.0 -Kidneys|0.0 -Liver|0.0 -Lungs|0.0 -Muscle|0.0 -Ovaries|0.0 -Pancreas|0.0 -Prostate|0.0 -Salivary Glands|0.0 -Red Marrow|0.0 -Cortical Bone|0.0 -Trabecular Bone|0.0 -Spleen|0.0 -Testes|0.0 -Thymus|0.0 -Thyroid|0.0 -Urinary Bladder Contents|0.0 -Uterus|0.0 -Fetus|0.0 -Placenta|0.0 -Total Body|0.0 -[END KINETIC DATA] -[END ICRP 89 Adult Female] -[END MODELS] -[END CASE FILE] diff --git a/pytheranostics/preclinical_dosimetry/olindaTemplates/adult_male.cas b/pytheranostics/preclinical_dosimetry/olindaTemplates/adult_male.cas deleted file mode 100644 index ae0a178..0000000 --- a/pytheranostics/preclinical_dosimetry/olindaTemplates/adult_male.cas +++ /dev/null @@ -1,127 +0,0 @@ -Saved on 11.27.2017 at 11:50:52 PST -[BEGIN CASE FILE] -[BEGIN NUCLIDE SETTINGS] -USE_DECAY_SERIES|false -USE_LEGACY_DATA|false -[END NUCLIDE SETTINGS] -[BEGIN NUCLIDES] -Lu-177| -[END NUCLIDES] -[BEGIN MODEL SETTINGS] -[END MODEL SETTINGS] -[BEGIN MODELS] -[BEGIN ICRP 89 Adult Male] -ICRP 89 Adult Male|A -[BEGIN ICRP 89 Adult Male SOURCE ORGANS] -Adrenals|14.0|0.0 -Brain|1450.0|0.0 -Breasts|0.0|0.0 -Esophagus|40.0|0.0 -Eyes|15.0|0.0 -Gallbladder Contents|58.0|0.0 -LLI Contents|75.0|0.0 -Small Intestine|350.0|0.0 -Stomach Contents|250.0|0.0 -ULI Contents|150.0|0.0 -Rectum|75.0|0.0 -Heart Contents|510.0|0.0 -Heart Wall|330.0|0.0 -Kidneys|310.0|0.0 -Liver|1800.0|0.0 -Lungs|1200.0|0.0 -Muscle|0.0|0.0 -Ovaries|0.0|0.0 -Pancreas|140.0|0.0 -Prostate|17.0|0.0 -Salivary Glands|85.0|0.0 -Red Marrow|1170.0|0.0 -Cortical Bone|4400.0|0.0 -Trabecular Bone|1100.0|0.0 -Spleen|150.0|0.0 -Testes|35.0|0.0 -Thymus|25.0|0.0 -Thyroid|20.0|0.0 -Urinary Bladder Contents|211.0|0.0 -Uterus|0.0|0.0 -Fetus|0.0|0.0 -Placenta|0.0|0.0 -Total Body|73000.0|0.0 -[END ICRP 89 Adult Male SOURCE ORGANS] -[BEGIN ICRP 89 Adult Male MODEL OPTIONS] -IS_BONE_ACTIVITY_ON_SURFACE|true -[END ICRP 89 Adult Male MODEL OPTIONS] -[BEGIN ICRP 89 Adult Male TARGET ORGANS] -Adrenals|14.0 -Brain|1450.0 -Breasts|0.0 -Esophagus|40.0 -Eyes|15.0 -Gallbladder Wall|10.0 -LLI Wall|150.0 -Small Intestine|650.0 -Stomach|150.0 -ULI Wall|150.0 -Rectum|70.0 -Heart Wall|330.0 -Kidneys|310.0 -Liver|1800.0 -Lungs|1200.0 -Muscle|0.0 -Ovaries|0.0 -Pancreas|140.0 -Prostate|17.0 -Salivary Glands|85.0 -Red Marrow|1170.0 -Bone Surfaces|120.0 -Skin|0.0 -Spleen|150.0 -Testes|35.0 -Thymus|25.0 -Thyroid|20.0 -Urinary Bladder Wall|50.0 -Uterus|0.0 -Fetus|0.0 -Placenta|0.0 -Total Body|73000.0 -[END ICRP 89 Adult Male TARGET ORGANS] -[BEGIN ICRP 89 Adult Male MODEL TARGET ORGANS] -TARGET_ORGAN_MASSES_ARE_FROM_USER_INPUT|FALSE -[END ICRP 89 Adult Male MODEL TARGET ORGANS] -[BEGIN KINETIC DATA] -Adrenals|0.0 -Brain|0.0 -Breasts|0.0 -Esophagus|0.0 -Eyes|0.0 -Gallbladder Contents|0.0 -LLI Contents|0.0 -Small Intestine|0.0 -Stomach Contents|0.0 -ULI Contents|0.0 -Rectum|0.0 -Heart Contents|0.0 -Heart Wall|0.0 -Kidneys|0.0 -Liver|0.0 -Lungs|0.0 -Muscle|0.0 -Ovaries|0.0 -Pancreas|0.0 -Prostate|0.0 -Salivary Glands|0.0 -Red Marrow|0.0 -Cortical Bone|0.0 -Trabecular Bone|0.0 -Spleen|0.0 -Testes|0.0 -Thymus|0.0 -Thyroid|0.0 -Urinary Bladder Contents|0.0 -Uterus|0.0 -Fetus|0.0 -Placenta|0.0 -Total Body|0.0 -[END KINETIC DATA] -[END ICRP 89 Adult Male] -[END MODELS] -[END CASE FILE] diff --git a/pytheranostics/preclinical_dosimetry/olindaTemplates/mouse25g.cas b/pytheranostics/preclinical_dosimetry/olindaTemplates/mouse25g.cas deleted file mode 100644 index 6bfa44b..0000000 --- a/pytheranostics/preclinical_dosimetry/olindaTemplates/mouse25g.cas +++ /dev/null @@ -1,127 +0,0 @@ -Saved on 03.08.2018 at 13:04:40 -[BEGIN CASE FILE] -[BEGIN NUCLIDE SETTINGS] -USE_DECAY_SERIES|false -USE_LEGACY_DATA|false -[END NUCLIDE SETTINGS] -[BEGIN NUCLIDES] -Y-90| -[END NUCLIDES] -[BEGIN MODEL SETTINGS] -[END MODEL SETTINGS] -[BEGIN MODELS] -[BEGIN 25g Mouse] -25g Mouse|Z0 -[BEGIN 25g Mouse SOURCE ORGANS] -Adrenals|0.0|0.0 -Brain|0.46592|0.000233 -Breasts|0.0|0.0 -Esophagus|0.0|0.0 -Eyes|0.0|0.0 -Gallbladder Contents|0.0|0.0 -LLI Contents|0.58344|0.0 -Small Intestine|1.742|0.0 -Stomach Contents|0.055328|0.0 -ULI Contents|0.0|0.0 -Rectum|0.0|0.0 -Heart Contents|0.23504|0.0 -Heart Wall|0.0|0.0 -Kidneys|0.3016|0.0 -Liver|1.7368|0.0 -Lungs|0.087024|0.0 -Muscle|0.0|0.0 -Ovaries|0.0|0.0 -Pancreas|0.304928|0.0 -Prostate|0.0|0.0 -Salivary Glands|0.0|0.0 -Red Marrow|0.0|0.0 -Cortical Bone|2.18|0.0 -Trabecular Bone|0.0|0.0 -Spleen|0.11128|0.0 -Testes|0.16016|0.0 -Thymus|0.0|0.0 -Thyroid|0.014248|0.0 -Urinary Bladder Contents|0.0601536|0.0 -Uterus|0.0|0.0 -Fetus|0.0|0.0 -Placenta|0.0|0.0 -Total Body|24.109264|0.0 -[END 25g Mouse SOURCE ORGANS] -[BEGIN 25g Mouse MODEL OPTIONS] -IS_BONE_ACTIVITY_ON_SURFACE|true -[END 25g Mouse MODEL OPTIONS] -[BEGIN 25g Mouse TARGET ORGANS] -Adrenals|0.0 -Brain|0.46592 -Breasts|0.0 -Esophagus|0.0 -Eyes|0.0 -Gallbladder Wall|0.0 -LLI Wall|0.58344 -Small Intestine|1.742 -Stomach|0.055328 -ULI Wall|0.0 -Rectum|0.0 -Heart Wall|0.23504 -Kidneys|0.3016 -Liver|1.7368 -Lungs|0.087024 -Muscle|0.0 -Ovaries|0.0 -Pancreas|0.3049 -Prostate|0.0 -Salivary Glands|0.0 -Red Marrow|0.0 -Bone Surfaces|2.18 -Skin|0.0 -Spleen|0.11128 -Testes|0.16016 -Thymus|0.0 -Thyroid|0.014248 -Urinary Bladder Wall|0.0601536 -Uterus|0.0 -Fetus|0.0 -Placenta|0.0 -Total Body|24.109264 -[END 25g Mouse TARGET ORGANS] -[BEGIN 25g Mouse MODEL TARGET ORGANS] -TARGET_ORGAN_MASSES_ARE_FROM_USER_INPUT|FALSE -[END 25g Mouse MODEL TARGET ORGANS] -[BEGIN KINETIC DATA] -Adrenals|0.0 -Brain|0.0 -Breasts|0.0 -Esophagus|0.0 -Eyes|0.0 -Gallbladder Contents|0.0 -LLI Contents|0.0 -Small Intestine|0.0 -Stomach Contents|0.0 -ULI Contents|0.0 -Rectum|0.0 -Heart Contents|0.0 -Heart Wall|0.0 -Kidneys|0.0 -Liver|0.0 -Lungs|0.0 -Muscle|0.0 -Ovaries|0.0 -Pancreas|0.0 -Prostate|0.0 -Salivary Glands|0.0 -Red Marrow|0.0 -Cortical Bone|0.0 -Trabecular Bone|0.0 -Spleen|0.0 -Testes|0.0 -Thymus|0.0 -Thyroid|0.0 -Urinary Bladder Contents|0.0 -Uterus|0.0 -Fetus|0.0 -Placenta|0.0 -Total Body|0.0 -[END KINETIC DATA] -[END 25g Mouse] -[END MODELS] -[END CASE FILE] diff --git a/pytheranostics/preclinical_dosimetry/phantomdata/PhantomMasses.xlsx b/pytheranostics/preclinical_dosimetry/phantomdata/PhantomMasses.xlsx deleted file mode 100644 index f3f6d44..0000000 Binary files a/pytheranostics/preclinical_dosimetry/phantomdata/PhantomMasses.xlsx and /dev/null differ diff --git a/pytheranostics/preclinical_dosimetry/phantomdata/human_notinphantom_masses.csv b/pytheranostics/preclinical_dosimetry/phantomdata/human_notinphantom_masses.csv deleted file mode 100644 index 536466f..0000000 --- a/pytheranostics/preclinical_dosimetry/phantomdata/human_notinphantom_masses.csv +++ /dev/null @@ -1,6 +0,0 @@ -Organ,Female,Male -Fat,16989,15169.4 -Seminals,0,6.4 -Muscle,17500,29000 -Skin,9600,11680 -Trachea,, diff --git a/pytheranostics/qc/__init__.py b/pytheranostics/qc/__init__.py index e69de29..3731650 100644 --- a/pytheranostics/qc/__init__.py +++ b/pytheranostics/qc/__init__.py @@ -0,0 +1 @@ +"""PyTheranostics package.""" diff --git a/pytheranostics/qc/dosecal_qc.py b/pytheranostics/qc/dosecal_qc.py index 3ac002e..35ceab6 100644 --- a/pytheranostics/qc/dosecal_qc.py +++ b/pytheranostics/qc/dosecal_qc.py @@ -1,3 +1,5 @@ +"""Quality-control routines for dose calibrator submissions.""" + import numpy as np from pytheranostics.qc.qc import QC @@ -6,12 +8,14 @@ class DosecalQC(QC): + """Perform QC checks for dose calibrator calibration data.""" def __init__(self, isotope, db_dic, cal_type="dc"): + """Initialize the QC helper with isotope metadata and DB extracts.""" super().__init__(isotope, db_dic=db_dic, cal_type=cal_type) def check_calibration(self, accepted_percent=1.5, accepted_recovery=(97, 103)): - + """Run the full calibration workflow and append findings to the summary.""" # keep a flag to accept or reject depending on the different tests. Default is to accept (1): # If something fails it will be changed to 2 if needs to verify and 3 if it completely fails @@ -131,6 +135,7 @@ def check_calibration(self, accepted_percent=1.5, accepted_recovery=(97, 103)): self.print_summary() def check_source_decay(self, accepted_percent): + """Validate decay corrections for each shipped source.""" # find the shipped sources sources = self.db_df["shipped_data"].source_id.unique() @@ -194,7 +199,7 @@ def check_source_decay(self, accepted_percent): ) def check_syringe_recovery(self, syringe_name="syringe_20_mL"): - + """Verify the syringe recovery curve stays within allowed tolerances.""" self.db_df["cal_data"].loc[ self.db_df["cal_data"].source_id == syringe_name, "syringe_activity_calculated", diff --git a/pytheranostics/qc/planar_qc.py b/pytheranostics/qc/planar_qc.py index 6f33acb..176abb9 100644 --- a/pytheranostics/qc/planar_qc.py +++ b/pytheranostics/qc/planar_qc.py @@ -1,16 +1,20 @@ +"""QC checks for planar acquisitions.""" + import pydicom from pytheranostics.qc.qc import QC class PlanarQC(QC): + """QC checks specific to planar acquisitions.""" def __init__(self, isotope, dicomfile, db_dic, cal_type="planar"): + """Load the planar DICOM file and associated calibration forms.""" super().__init__(isotope, db_dic=db_dic, cal_type=cal_type) self.ds = pydicom.dcmread(dicomfile) def check_windows_energy(self): - + """Run the planar QC workflow and populate the summary.""" self.append_to_summary(f"QC for planar scan of {self.isotope}:\n\n") self.check_camera_parameters() @@ -18,6 +22,7 @@ def check_windows_energy(self): self.print_summary() def check_camera_parameters(self): + """Verify DICOM acquisition parameters match the expected protocol.""" camera_manufacturer = self.ds.Manufacturer camera_model = self.ds.ManufacturerModelName acquisition_date = self.ds.AcquisitionDate diff --git a/pytheranostics/qc/qc.py b/pytheranostics/qc/qc.py index b383b47..2e76b6f 100644 --- a/pytheranostics/qc/qc.py +++ b/pytheranostics/qc/qc.py @@ -1,3 +1,5 @@ +"""Shared utilities for QC workflows.""" + import json from io import StringIO from pathlib import Path @@ -13,17 +15,10 @@ class QC: - def __init__(self, isotope, **kwargs): - """ - - **kwargs: - db_dic: containing three keys - db_file - sheet_names (list) - header - site_id - """ + """Base class for QC workflows (planar, SPECT, dose calibrator).""" + def __init__(self, isotope, **kwargs): + """Load isotope metadata and qualifying site data required for QC.""" self.db_df = {} with open(ISOTOPE_DATA_FILE) as f: @@ -82,7 +77,7 @@ def __init__(self, isotope, **kwargs): self.db_df["shipped_data"] = ref_shipped def window_check(self, win_perdiff_max=2, type="planar"): - + """Compare configured energy windows against protocol tolerances.""" if type == "planar": ds = self.ds elif type == "spect": @@ -205,9 +200,11 @@ def window_check(self, win_perdiff_max=2, type="planar"): return window_check_df def append_to_summary(self, text): + """Append formatted text to the QC summary buffer.""" self.summary = self.summary + text def print_summary(self): + """Convert the text summary into a styled pandas DataFrame.""" # print(self.summary) summary = StringIO(self.summary) self.summary_df = pd.read_csv(summary, sep="\t") @@ -221,6 +218,7 @@ def print_summary(self): # print(self.summary) def update_db(self, syringe_name="syringe_20_mL"): + """Refresh calibration tables with decay-corrected references and recoveries.""" sources = self.db_df["shipped_data"].source_id.unique() centres = self.db_df["shipped_data"].site_id.unique() diff --git a/pytheranostics/qc/spect_qc.py b/pytheranostics/qc/spect_qc.py index a6b6ecc..9f774d4 100644 --- a/pytheranostics/qc/spect_qc.py +++ b/pytheranostics/qc/spect_qc.py @@ -1,3 +1,5 @@ +"""QC checks for SPECT projections and reconstructions.""" + import numpy as np import pydicom @@ -5,14 +7,16 @@ class SPECTQC(QC): + """QC checks for raw SPECT projections and reconstructed images.""" def __init__(self, isotope, projections_file, recon_file, db_dic, cal_type="spect"): + """Load projection and reconstruction DICOM datasets for QC.""" super().__init__(isotope, db_dic=db_dic, cal_type=cal_type) self.proj_ds = pydicom.dcmread(projections_file) self.recon_ds = pydicom.dcmread(recon_file) def check_projs(self): - + """Run the full QC pipeline for projections and reconstructed images.""" self.window_check_df = {} self.append_to_summary(f"QC for SPECT RAW DATA of {self.isotope}:\n\n") @@ -26,6 +30,7 @@ def check_projs(self): self.print_summary() def check_camera_parameters(self, ds, projs=True): + """Append camera parameter checks for either projection or reconstruction.""" camera_manufacturer = ds.Manufacturer camera_model = ds.ManufacturerModelName acquisition_date = ds.AcquisitionDate diff --git a/pytheranostics/registration/demons.py b/pytheranostics/registration/demons.py index 4e59294..f7295c1 100644 --- a/pytheranostics/registration/demons.py +++ b/pytheranostics/registration/demons.py @@ -1,15 +1,10 @@ +"""Multiscale Demons registration helpers.""" + import SimpleITK def smooth_and_resample(image, shrink_factor, smoothing_sigma): - """ - Args: - image: The image we want to resample. - shrink_factor: A number greater than one, such that the new image's size is original_size/shrink_factor. - smoothing_sigma: Sigma for Gaussian smoothing, this is in physical (image spacing) units, not pixels. - Return: - Image which is a result of smoothing the input and then resampling it using the given sigma and shrink factor. - """ + """Gaussian smooth an image and resample it by the given shrink factor.""" smoothed_image = SimpleITK.SmoothingRecursiveGaussian(image, smoothing_sigma) original_spacing = image.GetSpacing() @@ -42,21 +37,7 @@ def multiscale_demons( shrink_factors=None, smoothing_sigmas=None, ): - """ - Run the given registration algorithm in a multiscale fashion. The original scale should not be given as input as the - original images are implicitly incorporated as the base of the pyramid. - Args: - registration_algorithm: Any registration algorithm that has an Execute(fixed_image, moving_image, displacement_field_image) - method. - fixed_image: Resulting transformation maps points from this image's spatial domain to the moving image spatial domain. - moving_image: Resulting transformation maps points from the fixed_image's spatial domain to this image's spatial domain. - initial_transform: Any SimpleITK transform, used to initialize the displacement field. - shrink_factors: Shrink factors relative to the original image's size. - smoothing_sigmas: Amount of smoothing which is done prior to resmapling the image using the given shrink factor. These - are in physical (image spacing) units. - Returns: - SimpleITK.DisplacementFieldTransform - """ + """Run a multiscale Demons registration on the provided fixed/moving pair.""" # Create image pyramid. fixed_images = [fixed_image] moving_images = [moving_image] diff --git a/pytheranostics/registration/phantom_to_ct.py b/pytheranostics/registration/phantom_to_ct.py index c49b975..7766fb6 100644 --- a/pytheranostics/registration/phantom_to_ct.py +++ b/pytheranostics/registration/phantom_to_ct.py @@ -11,6 +11,7 @@ from SimpleITK.SimpleITK import Transform from pytheranostics.registration.demons import multiscale_demons +from pytheranostics.shared.resources import resource_path class PhantomToCTBoneReg: @@ -32,7 +33,7 @@ class PhantomToCTBoneReg: def __init__( self, CT: SimpleITK.Image, - phantom_skeleton_path: Path = Path("../data/phantom/skeleton/Skeleton.nii.gz"), + phantom_skeleton_path: Optional[Path] = None, verbose: bool = False, ) -> None: """Initialize the Phantom-to-CT registration helper. @@ -42,13 +43,19 @@ def __init__( CT : SimpleITK.Image The reference patient CT image. phantom_skeleton_path : Path, optional - Path to the XCAT phantom skeleton image (NIfTI), by default - "../data/phantom/skeleton/Skeleton.nii.gz". + Path to the XCAT phantom skeleton image (NIfTI). If omitted, the + bundled phantom skeleton distributed with PyTheranostics is used. verbose : bool, optional Enable verbose logging during registration, by default False. """ self.CT = SimpleITK.Image(CT) # Make a Copy. - self.Phantom = SimpleITK.ReadImage(fileName=phantom_skeleton_path) + if phantom_skeleton_path is None: + with resource_path( + "pytheranostics.data", "phantom/skeleton/Skeleton.nii.gz" + ) as skeleton_path: + self.Phantom = SimpleITK.ReadImage(fileName=str(skeleton_path)) + else: + self.Phantom = SimpleITK.ReadImage(fileName=str(phantom_skeleton_path)) # Set Origin for Phantom to that of reference CT. self.Phantom.SetOrigin(self.CT.GetOrigin()) @@ -267,7 +274,7 @@ def register( def register_mask( self, fixed_image: SimpleITK.Image, - mask_path: Path = Path("../data/phantom/bone_marrow/Marrow.nii.gz"), + mask_path: Optional[Path] = None, ) -> SimpleITK.Image: """Register a phantom mask (e.g., bone marrow) to patient CT. @@ -276,14 +283,21 @@ def register_mask( fixed_image : SimpleITK.Image The reference CT image. mask_path : Path, optional - Path to the phantom mask file, by default "../data/phantom/bone_marrow/Marrow.nii.gz" + Path to the phantom mask file. If omitted, the packaged bone marrow + mask is used. Returns ------- SimpleITK.Image The registered mask in CT space. """ - mask_image = SimpleITK.ReadImage(fileName=mask_path) + if mask_path is None: + with resource_path( + "pytheranostics.data", "phantom/bone_marrow/Marrow.nii.gz" + ) as default_mask: + mask_image = SimpleITK.ReadImage(fileName=str(default_mask)) + else: + mask_image = SimpleITK.ReadImage(fileName=str(mask_path)) mask_image.SetOrigin(fixed_image.GetOrigin()) mask_image = SimpleITK.Cast(mask_image, SimpleITK.sitkFloat32) diff --git a/pytheranostics/segmentation/__init__.py b/pytheranostics/segmentation/__init__.py index e69de29..3731650 100644 --- a/pytheranostics/segmentation/__init__.py +++ b/pytheranostics/segmentation/__init__.py @@ -0,0 +1 @@ +"""PyTheranostics package.""" diff --git a/pytheranostics/segmentation/tools.py b/pytheranostics/segmentation/tools.py index e11d7c3..f18b29b 100644 --- a/pytheranostics/segmentation/tools.py +++ b/pytheranostics/segmentation/tools.py @@ -1,7 +1,10 @@ +"""Helpers for working with RT structure sets.""" + from rt_utils import RTStructBuilder def rtst_to_mask(dicom_series_path, rt_struct_path): + """Load an RTSTRUCT and return a dict of ROI masks keyed by ROI name.""" # Load existing RT Struct. Requires the series path and existing RT Struct path rtstruct = RTStructBuilder.create_from( dicom_series_path=dicom_series_path, rt_struct_path=rt_struct_path diff --git a/pytheranostics/shared/__init__.py b/pytheranostics/shared/__init__.py index e69de29..3731650 100644 --- a/pytheranostics/shared/__init__.py +++ b/pytheranostics/shared/__init__.py @@ -0,0 +1 @@ +"""PyTheranostics package.""" diff --git a/pytheranostics/shared/corrections.py b/pytheranostics/shared/corrections.py index adf6dd2..28b98f7 100644 --- a/pytheranostics/shared/corrections.py +++ b/pytheranostics/shared/corrections.py @@ -1,5 +1,8 @@ -def tew_scatt(window_dic): +"""Scatter-correction helpers.""" + +def tew_scatt(window_dic): + """Apply triple-energy-window scatter correction to window counts.""" Cp = {} ls_width = window_dic["low_scatter"]["width"] diff --git a/pytheranostics/shared/evaluation_metrics.py b/pytheranostics/shared/evaluation_metrics.py index c3f34ee..4fd7b58 100644 --- a/pytheranostics/shared/evaluation_metrics.py +++ b/pytheranostics/shared/evaluation_metrics.py @@ -1,6 +1,8 @@ +"""Simple evaluation metrics used in QC reports.""" + import numpy as np def perc_diff(measured_value, expected_value, decimals=2): - - return np.round((measured_value - expected_value) / expected_value * 100, 2) + """Return the percent difference between measured and expected values.""" + return np.round((measured_value - expected_value) / expected_value * 100, decimals) diff --git a/pytheranostics/shared/radioactive_decay.py b/pytheranostics/shared/radioactive_decay.py index 7c2223c..d259ec8 100644 --- a/pytheranostics/shared/radioactive_decay.py +++ b/pytheranostics/shared/radioactive_decay.py @@ -1,9 +1,12 @@ +"""Radioactive decay helpers shared across modules.""" + from datetime import datetime import numpy as np def decay_act(a_initial, delta_t, half_life): + """Return decayed activity after `delta_t` given the half-life.""" if np.any(np.asarray(a_initial) < 0): raise ValueError("a_initial must be positive") if np.any(np.asarray(delta_t) < 0): @@ -23,7 +26,7 @@ def get_activity_at_injection( injection_time, half_life, ): - + """Compute injection datetime and activity from pre/post syringe readings.""" # Pass half-life in seconds # Set the times and the time deltas to injection time diff --git a/pytheranostics/shared/resources.py b/pytheranostics/shared/resources.py new file mode 100644 index 0000000..0f37ea5 --- /dev/null +++ b/pytheranostics/shared/resources.py @@ -0,0 +1,22 @@ +"""Utility helpers for accessing package data via importlib.resources.""" + +from __future__ import annotations + +from contextlib import contextmanager +from importlib import resources +from pathlib import Path +from typing import Iterator + + +@contextmanager +def resource_path(package: str, relative_path: str) -> Iterator[Path]: + """Yield a filesystem path to a bundled resource. + + The helper works for both files and directories and hides the boilerplate + of using ``importlib.resources.as_file``. It ensures compatibility when the + package is installed as a wheel/zip where resources need to be extracted to + a temporary location before accessing them by path. + """ + resource = resources.files(package).joinpath(*relative_path.split("/")) + with resources.as_file(resource) as path_obj: + yield Path(path_obj) diff --git a/setup_dev.py b/setup_dev.py index 5bb8844..d9333b2 100644 --- a/setup_dev.py +++ b/setup_dev.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """ Development environment setup script. + Activate a virtual environment or conda environment before running. """ diff --git a/tests/conftest.py b/tests/conftest.py index 05f7bb0..2406a63 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,6 @@ """Test configuration and fixtures for PyTheranostics.""" -import os +from pathlib import Path import numpy as np import pytest @@ -8,7 +8,6 @@ def pytest_collection_modifyitems(config, items): """Modify test collection to run smoke tests first.""" - # Separate smoke tests from other tests smoke_tests = [] other_tests = [] @@ -29,12 +28,6 @@ def sample_image(): return np.random.rand(100, 100) -@pytest.fixture -def sample_dicom_path(): - """Return path to sample DICOM file.""" - return os.path.join(os.path.dirname(__file__), "data", "sample.dcm") - - @pytest.fixture def sample_activity(): """Create sample activity data.""" @@ -45,3 +38,17 @@ def sample_activity(): def sample_time_points(): """Create sample time points.""" return np.array([0, 1, 2, 3, 4]) + + +@pytest.fixture(scope="session") +def docs_examples_dir() -> Path: + """Return the path to the documentation example data directory.""" + return ( + Path(__file__).resolve().parent.parent / "docs" / "source" / "examples" / "data" + ) + + +@pytest.fixture(scope="session") +def spect_example_dir(docs_examples_dir: Path) -> Path: + """Directory containing sample SPECT DICOM images.""" + return docs_examples_dir / "testimages" diff --git a/tests/test_dosimetry_bone_marrow.py b/tests/test_dosimetry_bone_marrow.py new file mode 100644 index 0000000..8895aa9 --- /dev/null +++ b/tests/test_dosimetry_bone_marrow.py @@ -0,0 +1,26 @@ +"""Unit tests for bone marrow dosimetry helpers.""" + +import math + +import pytest + +from pytheranostics.dosimetry.bone_marrow import bm_scaling_factor + + +def test_bm_scaling_factor_uses_phantom_mass_by_default(): + """If no patient mass is provided, phantom data should be used.""" + result = bm_scaling_factor(gender="Female", mass_bm=None, hematocrit=None) + assert math.isclose(result, 900.0, rel_tol=1e-6) + + +@pytest.mark.parametrize( + ("gender", "mass_bm", "hematocrit", "expected"), + [ + ("Male", 1000.0, None, 1000.0), + ("Female", 900.0, 0.45, 0.19 / (1 - 0.45) * 900.0), + ], +) +def test_bm_scaling_factor_handles_custom_values(gender, mass_bm, hematocrit, expected): + """The scaling factor should respect custom masses and hematocrit.""" + result = bm_scaling_factor(gender=gender, mass_bm=mass_bm, hematocrit=hematocrit) + assert math.isclose(result, expected, rel_tol=1e-6) diff --git a/tests/test_fits_module.py b/tests/test_fits_module.py new file mode 100644 index 0000000..135a383 --- /dev/null +++ b/tests/test_fits_module.py @@ -0,0 +1,60 @@ +"""Tests for the fitting helper functions.""" + +import numpy as np +import pytest + +from pytheranostics.fits.fits import ( + calculate_r_squared, + exponential_fit_lmfit, + get_exponential, +) +from pytheranostics.fits.functions import monoexp_fun + + +def test_get_exponential_defaults(): + """Default configuration for mono-exponential fits should be stable.""" + func, params, bounds = get_exponential(order=1, param_init=None, decayconst=0.1) + assert func is monoexp_fun + assert params == (1, 1) + assert bounds[0][0] == 0 + assert pytest.approx(bounds[0][1]) == 0.1 + assert np.isinf(bounds[1]) + + +def test_calculate_r_squared_perfect_fit(): + """A perfect mono-exponential fit should have r^2 == 1.""" + x = np.linspace(0, 4, 5) + y = monoexp_fun(x, 2.0, 0.5) + r2, residuals = calculate_r_squared(x, y, (2.0, 0.5), monoexp_fun) + assert pytest.approx(r2, rel=1e-9) == 1.0 + assert np.allclose(residuals, 0.0) + + +def test_exponential_fit_lmfit_mono_handles_noise(): + """Mono-exponential fit should recover parameters from noisy data.""" + rng = np.random.default_rng(42) + x = np.linspace(0, 6, 20) + y_true = monoexp_fun(x, 5.0, 0.4) + y_noisy = y_true + rng.normal(scale=0.05, size=x.shape) + + result, fitted_model = exponential_fit_lmfit( + x_data=x, y_data=y_noisy, num_exponentials=1 + ) + + assert pytest.approx(result.params["A1"].value, rel=0.05) == 5.0 + assert pytest.approx(result.params["A2"].value, rel=0.1) == 0.4 + assert np.allclose( + fitted_model(x[:3]), + monoexp_fun(x[:3], result.params["A1"].value, result.params["A2"].value), + ) + + +def test_exponential_fit_lmfit_applies_uptake_constraint(): + """Bi-exponential fits with uptake should constrain the amplitudes.""" + x = np.linspace(0, 4, 15) + y = monoexp_fun(x, 2.0, 0.5) + monoexp_fun(x, -2.0, 1.5) + + result, _ = exponential_fit_lmfit(x, y, num_exponentials=2, with_uptake=True) + + assert result.params["B1"].expr == "-A1" + assert pytest.approx(result.params["A1"].value, rel=0.1) == 2.0 diff --git a/tests/test_imaging_tools.py b/tests/test_imaging_tools.py new file mode 100644 index 0000000..e2a55c1 --- /dev/null +++ b/tests/test_imaging_tools.py @@ -0,0 +1,45 @@ +"""Tests for imaging tools utilities.""" + +import shutil + +import numpy as np +import pytest +import SimpleITK + +from pytheranostics.imaging_tools import tools + + +def test_load_metadata_from_sample_spect_folder(spect_example_dir, tmp_path): + """Ensure metadata extraction works on bundled DICOM samples.""" + single_case_dir = tmp_path / "spect_case" + single_case_dir.mkdir() + shutil.copy(spect_example_dir / "016.dcm", single_case_dir / "case.dcm") + + meta = tools.load_metadata(str(single_case_dir), modality="Lu177_NM") + assert meta.PatientID == "PR21-CAVA-0016" + assert meta.AcquisitionDate == "20220617" + # DICOM lacks injected activity tag -> default should apply + assert meta.Injected_Activity_MBq == 7400.0 + assert meta.Radionuclide == "Lu177" + + +@pytest.mark.parametrize("is_mask", [True, False]) +def test_itk_image_from_array_preserves_metadata(is_mask): + """Array conversion should preserve spacing/origin/direction.""" + ref = SimpleITK.Image(2, 2, 2, SimpleITK.sitkFloat32) + ref.SetSpacing((2.0, 2.0, 5.0)) + ref.SetOrigin((1.0, 1.0, -3.0)) + ref.SetDirection((1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0)) + ref.SetMetaData("0010|0010", "Test Patient") + + array = np.ones((2, 2, 2), dtype=np.uint8 if is_mask else np.float32) + image = tools.itk_image_from_array(array, ref_image=ref, is_mask=is_mask) + + assert tuple(image.GetSpacing()) == pytest.approx(ref.GetSpacing()) + assert tuple(image.GetOrigin()) == pytest.approx(ref.GetOrigin()) + assert tuple(image.GetDirection()) == tuple(ref.GetDirection()) + assert image.GetMetaData("0010|0010") == "Test Patient" + if is_mask: + assert image.GetPixelID() == SimpleITK.sitkUInt8 + else: + assert image.GetPixelID() == ref.GetPixelID() diff --git a/tests/test_resource_loading.py b/tests/test_resource_loading.py new file mode 100644 index 0000000..57ec9f0 --- /dev/null +++ b/tests/test_resource_loading.py @@ -0,0 +1,19 @@ +"""Tests for data access via importlib.resources-based helpers.""" + +import numpy as np + +from pytheranostics.dosimetry.dvk import DoseVoxelKernel +from pytheranostics.dosimetry.olinda import load_phantom_mass + + +def test_load_phantom_mass_returns_expected_value(): + """The packaged phantom mass table should include standard organs.""" + liver_mass = load_phantom_mass(gender="Male", organ="Liver") + assert liver_mass == 1800 # matches bundled phantom data + + +def test_dose_voxel_kernel_falls_back_to_packaged_kernel(): + """DoseVoxelKernel should load the packaged default kernel if the exact voxel size is missing.""" + kernel = DoseVoxelKernel(isotope="Lu177", voxel_size_mm=5.5) + assert kernel.kernel.shape == (51, 51, 51) + assert kernel.kernel.dtype == np.float64