diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 57cdcc1a..d87bde09 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -33,11 +33,17 @@ TRS-398 CT ^^ -* bdg-success:`Feature` All CT-like algorithms (CatPhan, Quart, Cheese, ACR) now have global ROI adjustment parameters in ``analyze``. See :ref:`adjusting-roi-locations`. +* :bdg-success:`Feature` All CT-like algorithms (CatPhan, Quart, Cheese, ACR) now have global ROI adjustment parameters in ``analyze``. See :ref:`adjusting-roi-locations`. * :bdg-primary:`Refactor` There is a new parameter for CT-like constructor classes: ``is_zip``. This is mostly an internal flag and is used when calling the ``.from_zip`` method. The default is ``False``. This is backwards-compatible and should not affect users. This was done for internal refactoring reasons. +Plan Generator +^^^^^^^^^^^^^^ + +* :bdg-success:`Feature` The plan generator has a new field method: :meth:`~pylinac.plan_generator.dicom.PlanGenerator.add_mlc_transmission`. This adds an MLC transmission field to the plan + where the leaves of a given bank are fully closed and the MLC kiss is underneath the jaws. + Gamma ^^^^^ diff --git a/pylinac/plan_generator/dicom.py b/pylinac/plan_generator/dicom.py index 3b270406..ff37ddb7 100644 --- a/pylinac/plan_generator/dicom.py +++ b/pylinac/plan_generator/dicom.py @@ -5,6 +5,7 @@ from collections.abc import Iterable from enum import Enum from pathlib import Path +from typing import Literal import numpy as np import pydicom @@ -1015,6 +1016,108 @@ def add_picketfence_beam( ) self.add_beam(beam.as_dicom(), mu=mu) + def add_mlc_transmission( + self, + bank: Literal["A", "B"], + mu: int = 50, + overreach: float = 10, + beam_name: str = "MLC Tx", + energy: int = 6, + dose_rate: int = 600, + x1: float = -50, + x2: float = 50, + y1: float = -100, + y2: float = 100, + gantry_angle: float = 0, + coll_angle: float = 0, + couch_vrt: float = 0, + couch_lat: float = 0, + couch_lng: float = 1000, + couch_rot: float = 0, + fluence_mode: FluenceMode = FluenceMode.STANDARD, + ): + """Add a single-image MLC transmission beam to the plan. + The beam is delivered with the MLCs closed and moved to one side underneath the jaws. + + Parameters + ---------- + bank : str + The MLC bank to move. Either "A" or "B". + mu : int + The monitor units to deliver. + overreach : float + The amount to tuck the MLCs under the jaws in mm. + beam_name : str + The name of the beam. + energy : int + The energy of the beam. + dose_rate : int + The dose rate of the beam. + x1 : float + The left jaw position. Usually negative. More negative is left. + x2 : float + The right jaw position. Usually positive. More positive is right. + y1 : float + The bottom jaw position. Usually negative. More negative is lower. + y2 : float + The top jaw position. Usually positive. More positive is higher. + gantry_angle : float + The gantry angle of the beam in degrees. + coll_angle : float + The collimator angle of the beam in degrees. + couch_vrt : float + The couch vertical position. + couch_lat : float + The couch lateral position. + couch_lng : float + The couch longitudinal position. + couch_rot : float + The couch rotation in degrees. + fluence_mode : FluenceMode + The fluence mode of the beam. + """ + mlc = self._create_mlc() + if bank == "A": + mlc_tips = x2 + overreach + elif bank == "B": + mlc_tips = x1 - overreach + else: + raise ValueError("Bank must be 'A' or 'B'") + # test for overtravel + if abs(x2 - x1) + overreach > self.max_overtravel_mm: + raise OvertravelError( + "The MLC overtravel is too large for the given jaw positions and overreach. Reduce the x-jaw opening size and/or overreach value." + ) + mlc.add_strip( + position_mm=mlc_tips, + strip_width_mm=1, + meterset_at_target=1, + ) + beam = Beam( + plan_dataset=self.ds, + beam_name=f"{beam_name} {bank}", + beam_type=BeamType.DYNAMIC, + energy=energy, + dose_rate=dose_rate, + x1=x1, + x2=x2, + y1=y1, + y2=y2, + gantry_angles=gantry_angle, + gantry_direction=GantryDirection.NONE, + coll_angle=coll_angle, + couch_vrt=couch_vrt, + couch_lat=couch_lat, + couch_lng=couch_lng, + couch_rot=couch_rot, + mlc_positions=mlc.as_control_points(), + metersets=mlc.as_metersets(), + fluence_mode=fluence_mode, + mlc_boundaries=self.leaf_config, + machine_name=self.machine_name, + ) + self.add_beam(beam.as_dicom(), mu=mu) + def add_dose_rate_beams( self, dose_rates: tuple[int, ...] = (100, 300, 500, 600), @@ -2037,3 +2140,7 @@ def add_winston_lutz_beams( raise NotImplementedError( "Winston-Lutz beams are not yet implemented for Halcyon plans" ) + + +class OvertravelError(ValueError): + pass diff --git a/tests_basic/test_plan_generator.py b/tests_basic/test_plan_generator.py index a472966c..7dafb8e6 100644 --- a/tests_basic/test_plan_generator.py +++ b/tests_basic/test_plan_generator.py @@ -4,6 +4,7 @@ import numpy as np import pydicom from matplotlib.figure import Figure +from parameterized import parameterized from pylinac.plan_generator.dicom import ( STACK, @@ -12,6 +13,7 @@ FluenceMode, GantryDirection, HalcyonPlanGenerator, + OvertravelError, PlanGenerator, ) from pylinac.plan_generator.mlc import ( @@ -378,6 +380,70 @@ def test_open_field_jaws(self): [-110, 110], ) + @parameterized.expand( + [ + ("valid A", "A", 39.5, -30, None), + ("valid B", "B", -40.5, -30, None), + ("Invalid Bank", "C", None, None, ValueError), + ("Overtravel", "A", None, -150, OvertravelError), + ] + ) + def test_transmission_beam(self, name, bank, leaf_pos, x1_pos, expected_error): + if expected_error: + with self.assertRaises(expected_error): + self.pg.add_mlc_transmission( + bank=bank, + x1=x1_pos, + x2=30, + y1=-110, + y2=110, + mu=44, + beam_name="MLC Txx", + ) + dcm = self.pg.as_dicom() + else: + self.pg.add_mlc_transmission( + bank=bank, + x1=-30, + x2=30, + y1=-110, + y2=110, + mu=44, + beam_name="MLC Txx", + ) + dcm = self.pg.as_dicom() + self.assertEqual(len(dcm.BeamSequence), 1) + self.assertEqual(dcm.BeamSequence[0].BeamName, f"MLC Txx {bank}") + self.assertEqual(dcm.BeamSequence[0].BeamNumber, 0) + self.assertEqual(dcm.FractionGroupSequence[0].NumberOfBeams, 1) + self.assertEqual( + dcm.FractionGroupSequence[0].ReferencedBeamSequence[0].BeamMeterset, 44 + ) + # check X jaws + self.assertEqual( + dcm.BeamSequence[0] + .ControlPointSequence[0] + .BeamLimitingDevicePositionSequence[0] + .LeafJawPositions, + [-30, 30], + ) + # check Y jaws + self.assertEqual( + dcm.BeamSequence[0] + .ControlPointSequence[0] + .BeamLimitingDevicePositionSequence[1] + .LeafJawPositions, + [-110, 110], + ) + # check first MLC position is tucked under the jaws + self.assertEqual( + dcm.BeamSequence[0] + .ControlPointSequence[0] + .BeamLimitingDevicePositionSequence[-1] + .LeafJawPositions[0], + leaf_pos, + ) + def test_create_picket_fence(self): self.pg.add_picketfence_beam( y1=-10,