DICOM Files Made for Halcyon QA - RapidArc T3 Leaf Speed

Copyright 2019 Nick Harding of Queen's Centre for Oncology, Castle Hill Hospital.

Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License here.

Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.

Introduction

The original RapidArc leaf speed test is described as Test 3 in Ling et al. The purpose of this test is evaluate the ability of the MLCs to move at varying speeds. It does this by irradiating different parts of a film (or EPID) to the same dose by using dynamic MLC fields with different MLC speeds and dose rates.

The original paper describes the tests on the Clinac model. This test requries adapting for the Halcyon for several reasons:

  1. The maximum MLC speed is faster (5cm/s);
  2. The gantry speed is faster (4 rot/min);
  3. The maximum dose rate (DR) is different and dependent on calibration conditions (740 MU/min locally);
  4. The Halcyon typically does not vary DR as will vary gantry speed preferentially over DR

This test is also described in the RapidArc Commissioning guide (available on myVarian) In brief, the analysis is designed to measure the normalised dose in each region of interest (ROI) which should equal unity with a tolerance of ± 2%.

The Halcyon was delivered with a RP DICOM file (labelled RP.Phantom.T3_LS.dcm) to perform this test. During commissioning however we reproducibily measured a maximum deviation in the ROI mean value of +2.1%, exceeding the manufacturer tolerance. This is shown in the image and table below.

RAT3_wrong.png

Date 16/04/2019 25/04/2019
Mean value ROI 1 0.983 0.984
Mean value ROI 2 0.997 0.998
Mean value ROI 3 1.021 1.021
Mean value ROI 4 1.014 1.015
Mean value ROI 5 0.985 0.983

After discussion with Varian it was determined that this was because in order to deliver the same dose to each region but without varying the dose rate as in the original test, a fixed width gap was delivered, with the width altered depending on the leaf speed. The original test moves the A bank first then the B bank. Consequently this test is dependant on the MLC accuracy and output factors for narrow gaps and is not considered a suitable test for the purpose of evaluating MLC speed i.e. this test exceeding the tolerance does not indicate the MLCs are not moving at the correct speed.

Developing Halcyon Leaf Speed Test

In order to create a Leaf Speed test as per the original paper, the Halcyon dose rate needs to be varied. To achieve this, a RapidArc plan needs creating which holds the gantry rotation at maximum speed, but delivering low MU in order to force the DR to drop. The cell below imports all required libraries and sets up functions required in the code.

In [1]:
#import all required libraries
import math
import pydicom
from pydicom.dataset import Dataset
from pydicom.sequence import Sequence
import tkinter as tk
from tkinter import filedialog

#This function is required in the code below.
def writeMLC(distal,posA,posB):
    """returns a list of MLC positions
    
    if distal is True then a return a string list of 28 leaves in posA
    and 28 leaves in posB
    if distal is False i.e. proximal return 29 leaves in each pos
    Pos A should be in mm
    Pos B should be in mm
    """
    if distal:
        return [str(round(posA,2))]*28 + [str(round(posB,2))]*28
    else:
        return [str(round(posA,2))]*29 + [str(round(posB,2))]*29

Set some Halcyon specific constants. NB DR will depend on calibration conditions.

In [2]:
#set CONSTANTS:
DR = 740.0 # Dose rate const in MU/min
GR = 24.0 # Gantry rate const in deg/s
MAX_LEAF_SPEED = 5.0 # in cm/s

To keep maximum gantry speed means the maximum time for delivery is:

In [3]:
print (f"{360/GR} seconds")
15.0 seconds

This is not sufficient time to deliver 5 ROIs in a single arc. Instead we will build five separate plans which deliver ROIs in the same area on the panel but with different MLC speeds. The mean value of these ROIs can then be measured and analysed. Since only one arc is being used for each ROI it seems sensible to move the MLCs the maximum distance possible to evaluate the MLC speed over a large range of travel.

For a minimum speed of 1cm / s the maximum distance MLCs could travel is 15cm. However to move 15 cm would take the entire gantry rotation and may be influenced by gantry deceleration towards the end of the arc. Instead plans will be made with the full distance of travel being 14cm.

The following code will make five separate plans five MLC speeds ranging equally from 1 to 5 cm/s, with the total distance being travelled = 14cm.

In [4]:
#CONSTANTS
DISTANCE_LEAF_TRAVEL = 14.0 # const (cm) Total cumulative distance travelled by a leaf pair
START_GA = 179.0 #in deg. Start just off 180 to prevent extended 180 issues. Dependent on DIRECTION 
DIRECTION = 'CC' #Counterclockwise CC or Clockwise CW for gantry rotation
START_MLC_POSITION = -18 # in mm. The MLC centre position at the first Control Point
#suggest this value is approx the DISTANCE_LEAF_TRAVEL/8 value.
#The MLCs will move to more positive values from this value.

Running the cell below opens a File Open dialog to navigate to an existing Halcyon VMAT RP.dcm file. As mentioned in the Snooker Cue notebook, the plan needs to match the calibration of your existing unit and needs to be a VMAT plan. One option, and that used below, is to create a single arc Halcyon VMAT plan in Eclipse, export the RP.dcm file and edit it. As an added benefit, if the test patient used to create the plan is the one the plan will be delivered through, this will prevent changing the patient ID and machine ID before importing into Eclipse.

In [5]:
root = tk.Tk()
root.withdraw()
ds = pydicom.dcmread(filedialog.askopenfilename())

The cell below will open a Folder Select Dialog box to choose where you want the Leaf Speed dcm files saving to.

In [6]:
#set an output directory with a GUI
output_dir = filedialog.askdirectory()

The MU needs to be the same for all the different leaf speed fields. The MU delivered needs to be kept low to force the DR to drop and is equal the MU delivered in the time for the leaves to travel the required distance at max speed:

In [7]:
dRperS = DR/60 # dose rate per sec in MU/s
TOTAL_MU = dRperS * DISTANCE_LEAF_TRAVEL / MAX_LEAF_SPEED
#required MU to force max gantry speed

#set Mu fo each beam. Determined by how many MU are required for the given DR.
ds.FractionGroupSequence[0].ReferencedBeamSequence[0].BeamMeterset = str(round(TOTAL_MU,4))

Set the required MLC leaf speeds to be tested. Keeping with the original test plan speeds of 1,2,3,4 and 5 cm/s are tested here.

In [8]:
#Set required times in cm/s:
req_speeds = [1.0,2.0,3.0,4.0,5.0]

For Halycon type plans, Eclipse has various rules on what constitues a VMAT plan. If a VMAT style plan is imported into a Halcyon machine and the parameters are not all correct it will convert the plan MLCs into MLC dynamic instead of VMAT. This will prevent the plan from being Planning Approved and hence delivered in Treatment QA mode. The plan will still be deliverable through Machine QA mode however. To make a VMAT plan, the MLCs need to change direction at least once in a plan. The number of control points also seems to affect it. To be safe, forcing the number of Control Points to 178 will work. Because of the requirement for the MLCs to change direction the methodology of original Ling et al. paper has been adapted so the leaves will run in both directions in four motions:

  1. Bank A stationary, Bank B moving +ve direction at required speed
  2. Bank B stationary, Bank A moving +ve direction at required speed
  3. Bank B stationary, Bank A moving -ve direction at required speed
  4. Bank A stationary, Bank B moving -ve direction at required speed

See this link for video of these MLC motions

Note that for these plans the distal and proximal banks are all moving in unison.

The following code will loop through all the required speeds and create RP.dcm plans for each leaf speed.

In [9]:
for speed in req_speeds:
    leaf_beam = ds.BeamSequence[0]
    #set beam name
    leaf_beam.BeamName = f"LS{int(speed)}"
    #ensure beam direction matches
    leaf_beam.ControlPointSequence[0].GantryRotationDirection = DIRECTION
        
    #time taken, t, for this mlc speed
    t = DISTANCE_LEAF_TRAVEL / speed
    #gantry angle change in this time, ga_delta
    ga_delta = t * GR
    #alter sign of ga_delta dependant on gantry rotation
    if DIRECTION == 'CC':
        ga_delta = ga_delta * -1
    
    #set 178 cps as per above
    no_of_cp = 178
    leaf_beam.NumberOfControlPoints = str(no_of_cp)
    
    #now create the leaf speed field:
    #first create a control point sequence to append to
    cp_sequence = Sequence()
    leaf_beam.ControlPointSequence = cp_sequence
    
    #the first CP (index 0)is the same for all leaf speeds
    #CP0
    cp0 = Dataset()
    cp0.ControlPointIndex = "0"
    cp0.NominalBeamEnergy = "6"
    #alter the DR for differnt cals
    cp0.DoseRateSet = str(int(DR))

    # make Beam Limiting Device Position Sequence
    beam_limiting_device_position_sequence = Sequence()
    cp0.BeamLimitingDevicePositionSequence = beam_limiting_device_position_sequence

    # Beam Limiting Device Position Sequence: Beam Limiting Device Position 1
    beam_limiting_device_position1 = Dataset()
    beam_limiting_device_position1.RTBeamLimitingDeviceType = 'X'
    beam_limiting_device_position1.LeafJawPositions = ['-140', '140']
    beam_limiting_device_position_sequence.append(beam_limiting_device_position1)

    # Beam Limiting Device Position Sequence: Beam Limiting Device Position 2
    beam_limiting_device_position2 = Dataset()
    beam_limiting_device_position2.RTBeamLimitingDeviceType = 'Y'
    beam_limiting_device_position2.LeafJawPositions = ['-140', '140']
    beam_limiting_device_position_sequence.append(beam_limiting_device_position2)

    # distal bank
    beam_limiting_device_position3 = Dataset()
    beam_limiting_device_position3.RTBeamLimitingDeviceType = 'MLCX1'
    #set MLCs to starting position
    beam_limiting_device_position3.LeafJawPositions = writeMLC(True,START_MLC_POSITION,START_MLC_POSITION)
    beam_limiting_device_position_sequence.append(beam_limiting_device_position3)

    # proximal bank
    beam_limiting_device_position4 = Dataset()
    beam_limiting_device_position4.RTBeamLimitingDeviceType = 'MLCX2'
    #set MLCs to starting position
    beam_limiting_device_position4.LeafJawPositions = writeMLC(False,START_MLC_POSITION,START_MLC_POSITION)
    beam_limiting_device_position_sequence.append(beam_limiting_device_position4)
    
    #set gantry angle and direction plus various other parameters
    cp0.GantryAngle = str(START_GA)
    cp0.GantryRotationDirection = DIRECTION
    cp0.BeamLimitingDeviceAngle = "0"
    cp0.BeamLimitingDeviceRotationDirection = 'NONE'
    cp0.PatientSupportAngle = "0"
    cp0.PatientSupportRotationDirection = 'NONE'
    cp0.TableTopEccentricAngle = "0"
    cp0.TableTopEccentricRotationDirection = 'NONE'
    cp0.TableTopVerticalPosition = ''
    cp0.TableTopLongitudinalPosition = ''
    cp0.TableTopLateralPosition = ''
    cp0.IsocenterPosition = ['-5.3710938e-1', '-5.3710938e-1', '0']
    cp0.CumulativeMetersetWeight = "0"
    cp0.TableTopPitchAngle = 0.0
    cp0.TableTopPitchRotationDirection = 'NONE'
    cp0.TableTopRollAngle = 0.0
    cp0.TableTopRollRotationDirection = 'NONE'

    #some of the above may be superflous but will help ensure loads ok
    # Referenced Dose Reference Sequence
    refd_dose_ref_sequence = Sequence()
    cp0.ReferencedDoseReferenceSequence = refd_dose_ref_sequence

    # Referenced Dose Reference Sequence: Referenced Dose Reference 1
    refd_dose_ref1 = Dataset()
    refd_dose_ref1.CumulativeDoseReferenceCoefficient = "0"
    refd_dose_ref1.ReferencedDoseReferenceNumber = "1"
    refd_dose_ref_sequence.append(refd_dose_ref1)
    #add cp0 to sequence
    cp_sequence.append(cp0)
    
    
    #create lists of gantry angles and cumulative meterset required per CP
    g_angle = [START_GA]
    req_meterset = [0.0]
    #these lists will be added to below
    
    #there are no_of_cp - 1 moving cps
    moving_cp = no_of_cp - 1
    
    #determine how much the gantry & meterset needs to increment per control point for the
    #total gantry rotation
    g_inc = ga_delta / float(moving_cp)
    meterset_inc = float(1) / float(moving_cp)
    
    #convert total distance moved by mlcs from cm to mm (DICOM files in mm)
    mlc_travel_mm = DISTANCE_LEAF_TRAVEL * 10
    #each bank has half the cps moving
    moving_cps_per_bank = (moving_cp) / 2
    
    #determine how much the mlcs need to increment per control point rounded to 0.01mm (displayed accuracy in Eclipse)
    mlc_inc = round((mlc_travel_mm / moving_cps_per_bank)/2,2)
    
    #start lists of MLC bank A and B positions per cp
    mlc_A = [START_MLC_POSITION]
    mlc_B = [START_MLC_POSITION]
    #run through moving cps and increment gantry,meterset and MLC position
    for i in range(1,no_of_cp):
        #gantry first
        new_ga = g_angle[i-1] + g_inc
        #gantry angle must be in range 0 <= ga < 360 deg
        if new_ga >= 360.0:
            new_ga += -360
        if new_ga < 0.0:
            new_ga += 360
        g_angle.append(round(new_ga,4))
        
        #next set the cumulative meterset
        req_meterset.append(round(req_meterset[i-1] + meterset_inc,4))
        
        #then mlc position
        #will increment MLC position positively so MLCs will move left to right as BEV
        #hence Bank B moves first quarted of cps
        #Bank A moves left to right second quarter
        #Bank A move right to left third quarter
        #and Bank B moves right to left last quarter
        if i <= (moving_cp)/4:
            mlc_A.append(START_MLC_POSITION)
            mlc_B.append(round(mlc_B[i-1] + mlc_inc,4))
        elif i <= (moving_cp)/2:
            mlc_A.append(round(mlc_A[i-1] + mlc_inc,4))
            mlc_B.append(round(mlc_B[i-1],4))
        elif i <= (moving_cp) * 3/4:
            mlc_A.append(round(mlc_A[i-1] - mlc_inc,4))
            mlc_B.append(round(mlc_B[i-1],4))
        else:
            mlc_A.append(round(mlc_A[i-1],4))
            mlc_B.append(round(mlc_B[i-1]- mlc_inc,4))
    
    #now have lists of gantry, metersets and mlc positions for all cps
    #need to ensure final cumulative meterset is unity - DICOM requirement
    req_meterset[-1] = '1.0000'    
    
    #loop through all CPs (except 0 and set parameters)
    for i in range(1,len(mlc_A)):
        # Control Point Sequence: Control Point i
        cp = Dataset()
        cp.ControlPointIndex = str(i)
        # Beam Limiting Device Position Sequence
        beam_limiting_device_position_sequence = Sequence()
        cp.BeamLimitingDevicePositionSequence = beam_limiting_device_position_sequence

        # distal bank
        beam_limiting_device_position1 = Dataset()
        beam_limiting_device_position1.RTBeamLimitingDeviceType = 'MLCX1'
        beam_limiting_device_position1.LeafJawPositions = writeMLC(True,mlc_A[i],mlc_B[i])
        beam_limiting_device_position_sequence.append(beam_limiting_device_position1)
        
        # proximal bank
        beam_limiting_device_position2 = Dataset()
        beam_limiting_device_position2.RTBeamLimitingDeviceType = 'MLCX2'
        beam_limiting_device_position2.LeafJawPositions = writeMLC(False,mlc_A[i],mlc_B[i])
        beam_limiting_device_position_sequence.append(beam_limiting_device_position2)
        
        #set gantry angle
        cp.GantryAngle = str(round(g_angle[i],4))
        #Cumulative meterset
        cp.CumulativeMetersetWeight = str(req_meterset[i])
        cp_sequence.append(cp)
    
    #save DICOM files for each leaf speed. Then loop starts over agin with next leaf speed
    save_name = f"ls_{int(speed)}"
    output_name = f"{output_dir}/{save_name}.dcm"
    ds.save_as(output_name)
    print (f"File saved as {output_name}")
File saved as H:/Python/LS_Final/ls_1.dcm
File saved as H:/Python/LS_Final/ls_2.dcm
File saved as H:/Python/LS_Final/ls_3.dcm
File saved as H:/Python/LS_Final/ls_4.dcm
File saved as H:/Python/LS_Final/ls_5.dcm

These plans can then be imported into Eclipse following changing of the plan UIDs. If preferred the plans can all be combined into one plan for easier delivery.

NB in our plans the final control point MLCs violate some motions. This can easily be fixed in Eclipse by going to PLanning>Verify MLC Positions and accepting changes.

Results

An example screenshot of the test is shown below. Note that since the plans are all delivered to the same portion of the panel (unlike the original Ling test) no open beam is required to remove beam profile effects. RAT3_updated.PNG

Since these tests have been delivered to the EPID the output factor tool can be used to analyse the dose deviations. In the table below the measured values have been normalised to the average. It can be seen that the maximum dose deviation from unity is 0.4% inidcating the accuracy of the MLC speed during RapidArc. It is noted that this test is only analysing the central few leaves for speed. However other tools can be used to analyse the dose along the entire length of the MLC bank. This method is most convenient for routine QA.

Leaf Speed (cm/s) Value (cGy/MU) Normalised Deviation (%)
1 0.476121 1.001 0.1
2 0.473771 0.996 -0.4
3 0.475437 0.999 0.0
4 0.475901 1.001 0.1
5 0.475555 1.002 0.2

Conclusion

The Halcyon is able to deliver varying leaf speeds during RapidArc. The RapidArc Leaf Speed Test has been adapted and incorporated in routine QA.