Skip to content

Commit

Permalink
Merged in feature/RAM-4167-mlc-transmission-field (pull request #477)
Browse files Browse the repository at this point in the history
add MLC transmission field to generator.

Approved-by: Randy Taylor
  • Loading branch information
jrkerns committed Nov 13, 2024
2 parents c367ca5 + dfac1eb commit 21a0d8a
Show file tree
Hide file tree
Showing 3 changed files with 180 additions and 1 deletion.
8 changes: 7 additions & 1 deletion docs/source/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
^^^^^

Expand Down
107 changes: 107 additions & 0 deletions pylinac/plan_generator/dicom.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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
66 changes: 66 additions & 0 deletions tests_basic/test_plan_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -12,6 +13,7 @@
FluenceMode,
GantryDirection,
HalcyonPlanGenerator,
OvertravelError,
PlanGenerator,
)
from pylinac.plan_generator.mlc import (
Expand Down Expand Up @@ -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,
Expand Down

0 comments on commit 21a0d8a

Please sign in to comment.