Skip to content

Commit

Permalink
Merged in bugfix/plan_prefab_energies (pull request #436)
Browse files Browse the repository at this point in the history
Bugfix/plan prefab energies

Approved-by: Randy Taylor
Approved-by: Hasan Ammar
  • Loading branch information
jrkerns committed Aug 30, 2024
2 parents 1c93855 + 1da8398 commit fb30659
Show file tree
Hide file tree
Showing 14 changed files with 272 additions and 140 deletions.
3 changes: 2 additions & 1 deletion bitbucket-pipelines.yml
Original file line number Diff line number Diff line change
Expand Up @@ -234,13 +234,14 @@ definitions:
name: Plan generator tests
script:
- source venv/bin/activate
- pytest tests_basic/test_plan_generator.py --cov-report term --junitxml=./test-reports/pytest_results.xml
- pytest tests_basic/test_plan_generator.py tests_basic/test_generated_plans.py --cov-report term --junitxml=./test-reports/pytest_results.xml
condition:
changesets:
includePaths:
- "pylinac/core/**"
- "pylinac/plan_generator/**"
- "tests_basic/test_plan_generator.py"
- "scripts/prefab_plan_generator.py"
- step: &core-module-tests
name: Run core module tests
script:
Expand Down
2 changes: 2 additions & 0 deletions docs/source/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ Plan Generator
* It is now possible to export generated images from the plan generator to DICOM files.
This can useful for end-to-end testing of workflows before delivering the plan itself on the machine.
See the new section :ref:`plan_generator_dicom_fluence` for details.
* Manual names can now be passed for Winston-Lutz beams.
* The R2 prefabricated plan files have been fixed to have the same energy and dose rate for all beams of a given plan.

Image Generator
^^^^^^^^^^^^^^^
Expand Down
46 changes: 45 additions & 1 deletion docs/source/plan_generator.rst
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,33 @@ If you're too "busy" to generate your own plans, you can use the following gener
be if you used the plan generator itself. On a Varian machine you will need to
perform a machine override authorization every time.

These plans contain a picket fence at each cardinal gantry angle, a Winston-Lutz batch, dose rate constancy, MLC speed tests, and gantry speed tests.
These plans contain the following fields:

* "PF 3mm G0": Picket fence, Gantry 0, 3mm strips, 100 MU
* "PF 3mm G90": Picket fence, Gantry 90, 3mm strips, 100 MU
* "PF 3mm G180": Picket fence, Gantry 180, 3mm strips, 100 MU
* "PF 3mm G270": Picket fence, Gantry 270, 3mm strips, 100 MU
* "G0C0P0": Winston-Lutz, Gantry 0, Collimator 0, Couch 0, 2x2cm, 5 MU, MLC-defined
* "G90C0P0": Winston-Lutz, Gantry 90, Collimator 0, Couch 0, 2x2cm, 5 MU, MLC-defined
* "G180C0P0": Winston-Lutz, Gantry 180, Collimator 0, Couch 0, 2x2cm, 5 MU, MLC-defined
* "G270C0P0": Winston-Lutz, Gantry 270, Collimator 0, Couch 0, 2x2cm, 5 MU, MLC-defined
* "G0C270P0": Winston-Lutz, Gantry 0, Collimator 270, Couch 0, 2x2cm, 5 MU, MLC-defined
* "G0C90P0": Winston-Lutz, Gantry 0, Collimator 90, Couch 0, 2x2cm, 5 MU, MLC-defined
* "G0C315P0": Winston-Lutz, Gantry 0, Collimator 315, Couch 0, 2x2cm, 5 MU, MLC-defined
* "G0C45P0": Winston-Lutz, Gantry 0, Collimator 45, Couch 0, 2x2cm, 5 MU, MLC-defined
* "G0C0P45": Winston-Lutz, Gantry 0, Collimator 0, Couch 45, 2x2cm, 5 MU, MLC-defined
* "G0C0P90": Winston-Lutz, Gantry 0, Collimator 0, Couch 90, 2x2cm, 5 MU, MLC-defined
* "G0C0P315": Winston-Lutz, Gantry 0, Collimator 0, Couch 315, 2x2cm, 5 MU, MLC-defined
* "G0C0P270": Winston-Lutz, Gantry 0, Collimator 0, Couch 270, 2x2cm, 5 MU, MLC-defined
* "G45C15P15": Winston-Lutz, Gantry 45, Collimator 15, Couch 15, 2x2cm, 5 MU, MLC-defined
* "G5C330P60": Winston-Lutz, Gantry 5, Collimator 330, Couch 60, 2x2cm, 5 MU, MLC-defined
* "G330C350P350": Winston-Lutz, Gantry 330, Collimator 350, Couch 350, 2x2cm, 5 MU, MLC-defined
* "DR100-600": Dose Rate Constancy, 4 ROIs, 100, 200, 400, 600 MU/min
* "DR Ref": Dose Rate Constancy, Reference Field
* "MLC Speed": MLC Speed, 4 ROIs, 5, 10, 15, 20 mm/s
* "MLC Speed Ref": MLC Speed, Reference Field
* "GS": Gantry Speed, 4 ROIs, 1, 2, 3, 4 deg/s
* "GS Ref": Gantry Speed, Reference Field

.. tab-set::
:sync-group: mlc-type
Expand Down Expand Up @@ -397,6 +423,24 @@ more efficiently. Adding a Winston-Lutz field can be done like so:
This will create 4 open fields of a 1x1cm, MLC-defined WL fields. See the :meth:`~pylinac.plan_generator.dicom.PlanGenerator.add_winston_lutz_beams` method for more information.

.. versionadded:: 3.27

You can pass your own beam name for a given axes position by adding a ``name`` key to the dictionary. If no name
key is passed the beam name will be of the form "G<gantry>C<collimator>P<couch>".

.. code-block:: python
pg.add_winston_lutz_beams(
axes_positions=(
{"gantry": 0, "collimator": 0, "couch": 0, "name": "Ref"},
{"gantry": 90, "collimator": 15, "couch": 0}, # auto-name: G90C15P0
{"gantry": 180, "collimator": 0, "couch": 90}, # auto-name: G180C0P90
{"gantry": 270, "collimator": 0, "couch": 0}, # auto-name: G270C0P0
),
...,
)
Picket Fence
^^^^^^^^^^^^

Expand Down
14 changes: 9 additions & 5 deletions pylinac/plan_generator/dicom.py
Original file line number Diff line number Diff line change
Expand Up @@ -1324,7 +1324,7 @@ def add_mlc_speed_beams(
)
ref_beam = Beam(
plan_dataset=self.ds,
beam_name="MLC Sp Ref",
beam_name=f"{beam_name} Ref",
beam_type=BeamType.DYNAMIC,
energy=energy,
dose_rate=default_dose_rate,
Expand Down Expand Up @@ -1410,7 +1410,7 @@ def add_winston_lutz_beams(
dose_rate : int
The dose rate of the beam.
axes_positions : Iterable[dict]
The positions of the axes. Each dict should have keys 'gantry', 'collimator', and 'couch'.
The positions of the axes. Each dict should have keys 'gantry', 'collimator', 'couch', and optionally 'name'.
couch_vrt : float
The couch vertical position.
couch_lng : float
Expand Down Expand Up @@ -1440,9 +1440,13 @@ def add_winston_lutz_beams(
meterset_at_target=1.0,
x_outfield_position=x1 - mlc_padding - jaw_padding - 20,
)
beam_name = (
axes.get("name")
or f"G{axes['gantry']:g}C{axes['collimator']:g}P{axes['couch']:g}"
)
beam = Beam(
plan_dataset=self.ds,
beam_name=f"G{axes['gantry']:g}C{axes['collimator']:g}P{axes['couch']:g}",
beam_name=beam_name,
beam_type=BeamType.DYNAMIC,
energy=energy,
dose_rate=dose_rate,
Expand Down Expand Up @@ -1554,7 +1558,7 @@ def add_gantry_speed_beams(
g_angles_uncorrected = [start_gantry_angle] + (
start_gantry_angle + gantry_sign * np.cumsum(gantry_deltas)
).tolist()
gantry_angles = [wrap360(angle) for angle in g_angles_uncorrected]
gantry_angles = [round(wrap360(angle), 2) for angle in g_angles_uncorrected]

if sum(gantry_deltas) >= 360:
raise ValueError(
Expand Down Expand Up @@ -1620,7 +1624,7 @@ def add_gantry_speed_beams(
self.add_beam(beam.as_dicom(), mu=mu)
ref_beam = Beam(
plan_dataset=self.ds,
beam_name="G Sp Ref",
beam_name=f"{beam_name} Ref",
beam_type=BeamType.DYNAMIC,
energy=energy,
dose_rate=max_dose_rate,
Expand Down
176 changes: 92 additions & 84 deletions scripts/prefab_plan_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,18 @@

from pylinac.plan_generator.dicom import FluenceMode, PlanGenerator

rt_plan_file = (
rt_plan_dir = (
Path(__file__).parent.parent.absolute()
/ "Files"
/ "RapidArc QA Test Procedures and Files for TrueBeam"
/ "Plans"
/ "HDMLC"
/ "T0.2_PicketFenceStatic_HD120_TB_Rev02.dcm"
)

MLC = "HD120"
REVISION = 1
REVISION = 2
MLCS = (
("HD120", rt_plan_dir / "HDMLC" / "T0.2_PicketFenceStatic_HD120_TB_Rev02.dcm"),
("M120", rt_plan_dir / "Millennium" / "T0.2_PicketFenceStatic_M120_TB_Rev02.dcm"),
)
CONTEXTS = [
# energy, mode, max dose rate
(6, FluenceMode.STANDARD, 600),
Expand All @@ -23,89 +24,96 @@
(18, FluenceMode.STANDARD, 600),
]

for context in CONTEXTS:
energy, fluence_mode, max_dose_rate = context
plan_name = f"{energy}MV{'' + fluence_mode.value if fluence_mode != FluenceMode.STANDARD else ''}"
generator = PlanGenerator.from_rt_plan_file(
rt_plan_file, plan_name=plan_name, plan_label=plan_name
)
# picket fence
for gantry_angle in (0, 90, 180, 270):
generator.add_picketfence_beam(
strip_width_mm=3,
strip_positions_mm=(-60, -30, 0, 30, 60), # 5 pickets
mu=100,
beam_name=f"PF 3mm G{gantry_angle}",
gantry_angle=gantry_angle,
fluence_mode=fluence_mode,
for mlc, rt_plan_file in MLCS:
for context in CONTEXTS:
energy, fluence_mode, max_dose_rate = context
plan_name = f"{energy}MV{'' + fluence_mode.value if fluence_mode != FluenceMode.STANDARD else ''}"
generator = PlanGenerator.from_rt_plan_file(
rt_plan_file, plan_name=plan_name, plan_label=plan_name
)
# picket fence
for gantry_angle in (0, 90, 180, 270):
generator.add_picketfence_beam(
strip_width_mm=3,
strip_positions_mm=(-60, -30, 0, 30, 60), # 5 pickets
mu=100,
beam_name=f"PF 3mm G{gantry_angle}",
gantry_angle=gantry_angle,
fluence_mode=fluence_mode,
energy=energy,
dose_rate=max_dose_rate,
)
# winston lutz
generator.add_winston_lutz_beams(
axes_positions=(
# basic gantry
{"gantry": 0, "collimator": 0, "couch": 0},
{"gantry": 90, "collimator": 0, "couch": 0},
{"gantry": 180, "collimator": 0, "couch": 0},
{"gantry": 270, "collimator": 0, "couch": 0},
# collimator-rotation
{"gantry": 0, "collimator": 270, "couch": 0},
{"gantry": 0, "collimator": 90, "couch": 0},
{"gantry": 0, "collimator": 315, "couch": 0},
{"gantry": 0, "collimator": 45, "couch": 0},
# couch-rotation
{"gantry": 0, "collimator": 0, "couch": 45},
{"gantry": 0, "collimator": 0, "couch": 90},
{"gantry": 0, "collimator": 0, "couch": 315},
{"gantry": 0, "collimator": 0, "couch": 270},
# combo
{"gantry": 45, "collimator": 15, "couch": 15},
{"gantry": 5, "collimator": 330, "couch": 60},
{"gantry": 330, "collimator": 350, "couch": 350},
),
x1=-10,
x2=10,
y1=-10,
y2=10,
defined_by_mlcs=True,
mu=5,
energy=energy,
dose_rate=max_dose_rate,
fluence_mode=fluence_mode,
)
# winston lutz
generator.add_winston_lutz_beams(
axes_positions=(
# basic gantry
{"gantry": 0, "collimator": 0, "couch": 0},
{"gantry": 90, "collimator": 0, "couch": 0},
{"gantry": 180, "collimator": 0, "couch": 0},
{"gantry": 270, "collimator": 0, "couch": 0},
# collimator-rotation
{"gantry": 0, "collimator": 270, "couch": 0},
{"gantry": 0, "collimator": 90, "couch": 0},
{"gantry": 0, "collimator": 315, "couch": 0},
{"gantry": 0, "collimator": 45, "couch": 0},
# couch-rotation
{"gantry": 0, "collimator": 0, "couch": 45},
{"gantry": 0, "collimator": 0, "couch": 90},
{"gantry": 0, "collimator": 0, "couch": 315},
{"gantry": 0, "collimator": 0, "couch": 270},
# combo
{"gantry": 45, "collimator": 15, "couch": 15},
{"gantry": 5, "collimator": 330, "couch": 60},
{"gantry": 330, "collimator": 350, "couch": 350},
),
x1=-10,
x2=10,
y1=-10,
y2=10,
defined_by_mlcs=True,
mu=5,
energy=energy,
dose_rate=max_dose_rate,
)

# dose rate
generator.add_dose_rate_beams(
dose_rates=(100, 200, 400, 600),
y1=-50,
y2=50,
default_dose_rate=600,
desired_mu=100,
)
# dose rate
generator.add_dose_rate_beams(
dose_rates=(100, 200, 400, 600),
y1=-50,
y2=50,
default_dose_rate=max_dose_rate,
desired_mu=100,
fluence_mode=fluence_mode,
energy=energy,
)

# MLC speed
generator.add_mlc_speed_beams(
speeds=(5, 10, 15, 20),
roi_size_mm=20,
y1=-50,
y2=50,
mu=100,
default_dose_rate=600,
beam_name="MLC Speed",
)
# MLC speed
generator.add_mlc_speed_beams(
speeds=(5, 10, 15, 20),
roi_size_mm=20,
y1=-50,
y2=50,
mu=100,
default_dose_rate=max_dose_rate,
fluence_mode=fluence_mode,
energy=energy,
)

# gantry speed
generator.add_gantry_speed_beams(
speeds=(1, 2, 3, 4),
max_dose_rate=600,
start_gantry_angle=179,
roi_size_mm=20,
y1=-50,
y2=50,
mu=100,
)
# gantry speed
generator.add_gantry_speed_beams(
speeds=(1, 2, 3, 4),
max_dose_rate=max_dose_rate,
start_gantry_angle=179,
roi_size_mm=20,
y1=-50,
y2=50,
mu=100,
fluence_mode=fluence_mode,
energy=energy,
)

# save file
generator.to_file(
filename=f"R{REVISION}_{plan_name}_{MLC}_prefab.dcm".replace(" ", "_")
)
# save file
file_name = f"R{REVISION}_{plan_name}_{mlc}_prefab.dcm".replace(" ", "_")
generator.to_file(filename=file_name)
print(f"Generated {file_name} plan")
6 changes: 2 additions & 4 deletions tests_basic/contrib/test_orthogonality.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,13 @@
from unittest import TestCase

from pylinac.contrib.orthogonality import JawOrthogonality
from tests_basic.utils import get_folder_from_cloud_test_repo
from tests_basic.utils import get_folder_from_cloud_repo


class TestOrthogonality(TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.dir = Path(
get_folder_from_cloud_test_repo(["planar_imaging", "Orthogonality"])
)
cls.dir = Path(get_folder_from_cloud_repo(["planar_imaging", "Orthogonality"]))

def test_analyze(self):
for file in self.dir.iterdir():
Expand Down
4 changes: 2 additions & 2 deletions tests_basic/contrib/test_quasar.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@
from unittest import TestCase

from pylinac.contrib.quasar import QuasarLightRadScaling
from tests_basic.utils import get_folder_from_cloud_test_repo
from tests_basic.utils import get_folder_from_cloud_repo


class TestQuasar(TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.dir = Path(get_folder_from_cloud_test_repo(["planar_imaging", "Quasar"]))
cls.dir = Path(get_folder_from_cloud_repo(["planar_imaging", "Quasar"]))

def test_analyze(self):
for file in self.dir.iterdir():
Expand Down
6 changes: 3 additions & 3 deletions tests_basic/core/test_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
from pylinac.core.io import TemporaryZipDirectory
from tests_basic.utils import (
get_file_from_cloud_test_repo,
get_folder_from_cloud_test_repo,
get_folder_from_cloud_repo,
save_file,
)

Expand Down Expand Up @@ -763,7 +763,7 @@ class TestTiff(TestCase):

def test_all_tiffs_have_tags_and_are_2d(self):
"""Test all tiffs will load. Just raw ingestion"""
all_starshot_files = get_folder_from_cloud_test_repo(["Starshot"])
all_starshot_files = get_folder_from_cloud_repo(["Starshot"])
for img in Path(all_starshot_files).iterdir():
if img.suffix in (".tif", ".tiff"):
fimg = FileImage(img)
Expand Down Expand Up @@ -896,7 +896,7 @@ def test_conversion_goes_to_uint16(self):

def test_mass_conversion(self):
"""Mass conversion; shouldn't fail. All images have dpi tag"""
all_starshot_files = get_folder_from_cloud_test_repo(["Starshot"])
all_starshot_files = get_folder_from_cloud_repo(["Starshot"])
for img in Path(all_starshot_files).iterdir():
if img.suffix in (".tif", ".tiff"):
tiff_to_dicom(img, sid=1000, gantry=10, coll=11, couch=12)
Loading

0 comments on commit fb30659

Please sign in to comment.