rtp-connect 1.8 → 1.9
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +5 -13
- data/CHANGELOG.md +124 -114
- data/COPYING +674 -674
- data/Gemfile +2 -2
- data/Gemfile.lock +23 -19
- data/README.md +149 -149
- data/lib/rtp-connect/constants.rb +58 -58
- data/lib/rtp-connect/control_point.rb +38 -95
- data/lib/rtp-connect/dose_tracking.rb +19 -33
- data/lib/rtp-connect/extended_field.rb +1 -20
- data/lib/rtp-connect/extended_plan.rb +115 -134
- data/lib/rtp-connect/field.rb +30 -20
- data/lib/rtp-connect/methods.rb +85 -77
- data/lib/rtp-connect/plan.rb +645 -624
- data/lib/rtp-connect/plan_to_dcm.rb +668 -656
- data/lib/rtp-connect/prescription.rb +39 -20
- data/lib/rtp-connect/record.rb +201 -143
- data/lib/rtp-connect/simulation_field.rb +606 -625
- data/lib/rtp-connect/site_setup.rb +1 -20
- data/lib/rtp-connect/version.rb +5 -5
- data/rtp-connect.gemspec +27 -27
- metadata +32 -32
@@ -1,657 +1,669 @@
|
|
1
|
-
module RTP
|
2
|
-
|
3
|
-
class Plan < Record
|
4
|
-
|
5
|
-
attr_accessor :current_gantry
|
6
|
-
attr_accessor :current_collimator
|
7
|
-
attr_accessor :current_couch_angle
|
8
|
-
attr_accessor :current_couch_pedestal
|
9
|
-
attr_accessor :current_couch_lateral
|
10
|
-
attr_accessor :current_couch_longitudinal
|
11
|
-
attr_accessor :current_couch_vertical
|
12
|
-
|
13
|
-
# Converts the Plan (and child) records to a
|
14
|
-
# DICOM::DObject of modality RTPLAN.
|
15
|
-
#
|
16
|
-
# @note Only photon plans have been tested.
|
17
|
-
# Electron beams beams may give an invalid DICOM file.
|
18
|
-
# Also note that, due to limitations in the RTP file format, some original
|
19
|
-
# values can not be recreated, like e.g. Study UID or Series UID.
|
20
|
-
# @param [Hash] options the options to use for creating the DICOM object
|
21
|
-
# @option options [Boolean] :dose_ref if set, Dose Reference & Referenced Dose Reference sequences will be included in the generated DICOM file
|
22
|
-
# @option options [String] :manufacturer the value used for the manufacturer tag (0008,0070) in the beam sequence
|
23
|
-
# @option options [String] :model the value used for the manufacturer's model name tag (0008,1090) in the beam sequence
|
24
|
-
# @option options [Symbol] :scale if set, relevant device parameters are converted from native readout format to IEC1217 (supported values are :elekta & :varian)
|
25
|
-
# @option options [String] :serial_number the value used for the device serial number tag (0018,1000) in the beam sequence
|
26
|
-
# @return [DICOM::DObject] the converted DICOM object
|
27
|
-
#
|
28
|
-
def to_dcm(options={})
|
29
|
-
#
|
30
|
-
# FIXME: This method is rather big, with a few sections of somewhat similar, repeating code.
|
31
|
-
# Refactoring and simplifying it at some stage might be a good idea.
|
32
|
-
#
|
33
|
-
require 'dicom'
|
34
|
-
original_level = DICOM.logger.level
|
35
|
-
DICOM.logger.level = Logger::FATAL
|
36
|
-
p = @prescriptions.first
|
37
|
-
# If no prescription is present, we are not going to be able to make a valid DICOM object:
|
38
|
-
logger.error("No Prescription Record present. Unable to build a valid RTPLAN DICOM object.") unless p
|
39
|
-
dcm = DICOM::DObject.new
|
40
|
-
#
|
41
|
-
# TOP LEVEL TAGS:
|
42
|
-
#
|
43
|
-
# Specific Character Set:
|
44
|
-
DICOM::Element.new('0008,0005', 'ISO_IR 100', :parent => dcm)
|
45
|
-
# Instance Creation Date
|
46
|
-
DICOM::Element.new('0008,0012', Time.now.strftime("%Y%m%d"), :parent => dcm)
|
47
|
-
# Instance Creation Time:
|
48
|
-
DICOM::Element.new('0008,0013', Time.now.strftime("%H%M%S"), :parent => dcm)
|
49
|
-
# SOP Class UID:
|
50
|
-
DICOM::Element.new('0008,0016', '1.2.840.10008.5.1.4.1.1.481.5', :parent => dcm)
|
51
|
-
# SOP Instance UID (if an original UID is not present, we make up a UID):
|
52
|
-
begin
|
53
|
-
sop_uid = p.fields.first.extended_field.original_plan_uid.empty? ? DICOM.generate_uid : p.fields.first.extended_field.original_plan_uid
|
54
|
-
rescue
|
55
|
-
sop_uid = DICOM.generate_uid
|
56
|
-
end
|
57
|
-
DICOM::Element.new('0008,0018', sop_uid, :parent => dcm)
|
58
|
-
# Study Date
|
59
|
-
DICOM::Element.new('0008,0020', Time.now.strftime("%Y%m%d"), :parent => dcm)
|
60
|
-
# Study Time:
|
61
|
-
DICOM::Element.new('0008,0030', Time.now.strftime("%H%M%S"), :parent => dcm)
|
62
|
-
# Accession Number:
|
63
|
-
DICOM::Element.new('0008,0050', '', :parent => dcm)
|
64
|
-
# Modality:
|
65
|
-
DICOM::Element.new('0008,0060', 'RTPLAN', :parent => dcm)
|
66
|
-
# Manufacturer:
|
67
|
-
DICOM::Element.new('0008,0070', 'rtp-connect', :parent => dcm)
|
68
|
-
# Referring Physician's Name:
|
69
|
-
DICOM::Element.new('0008,0090', "#{@md_last_name}^#{@md_first_name}^#{@md_middle_name}^^", :parent => dcm)
|
70
|
-
# Operator's Name:
|
71
|
-
DICOM::Element.new('0008,1070', "#{@author_last_name}^#{@author_first_name}^#{@author_middle_name}^^", :parent => dcm)
|
72
|
-
# Patient's Name:
|
73
|
-
DICOM::Element.new('0010,0010', "#{@patient_last_name}^#{@patient_first_name}^#{@patient_middle_name}^^", :parent => dcm)
|
74
|
-
# Patient ID:
|
75
|
-
DICOM::Element.new('0010,0020', @patient_id, :parent => dcm)
|
76
|
-
# Patient's Birth Date:
|
77
|
-
DICOM::Element.new('0010,0030', '', :parent => dcm)
|
78
|
-
# Patient's Sex:
|
79
|
-
DICOM::Element.new('0010,0040', '', :parent => dcm)
|
80
|
-
# Manufacturer's Model Name:
|
81
|
-
DICOM::Element.new('0008,1090', 'RTP-to-DICOM', :parent => dcm)
|
82
|
-
# Software Version(s):
|
83
|
-
DICOM::Element.new('0018,1020', "RubyRTP#{VERSION}", :parent => dcm)
|
84
|
-
# Study Instance UID:
|
85
|
-
DICOM::Element.new('0020,000D', DICOM.generate_uid, :parent => dcm)
|
86
|
-
# Series Instance UID:
|
87
|
-
DICOM::Element.new('0020,000E', DICOM.generate_uid, :parent => dcm)
|
88
|
-
# Study ID:
|
89
|
-
DICOM::Element.new('0020,0010', '1', :parent => dcm)
|
90
|
-
# Series Number:
|
91
|
-
DICOM::Element.new('0020,0011', '1', :parent => dcm)
|
92
|
-
# Frame of Reference UID (if an original UID is not present, we make up a UID):
|
93
|
-
begin
|
94
|
-
for_uid = p.site_setup.frame_of_ref_uid.empty? ? DICOM.generate_uid : p.site_setup.frame_of_ref_uid
|
95
|
-
rescue
|
96
|
-
for_uid = DICOM.generate_uid
|
97
|
-
end
|
98
|
-
DICOM::Element.new('0020,0052', for_uid, :parent => dcm)
|
99
|
-
# Position Reference Indicator:
|
100
|
-
DICOM::Element.new('0020,1040', '', :parent => dcm)
|
101
|
-
# RT Plan Label (max 16 characters):
|
102
|
-
plan_label = p ? p.rx_site_name[0..15] : @course_id
|
103
|
-
DICOM::Element.new('300A,0002', plan_label, :parent => dcm)
|
104
|
-
# RT Plan Name:
|
105
|
-
plan_name = p ? p.rx_site_name : @course_id
|
106
|
-
DICOM::Element.new('300A,0003', plan_name, :parent => dcm)
|
107
|
-
# RT Plan Description:
|
108
|
-
plan_desc = p ? p.technique : @diagnosis
|
109
|
-
DICOM::Element.new('300A,0004', plan_desc, :parent => dcm)
|
110
|
-
# RT Plan Date:
|
111
|
-
plan_date = @plan_date.empty? ? Time.now.strftime("%Y%m%d") : @plan_date
|
112
|
-
DICOM::Element.new('300A,0006', plan_date, :parent => dcm)
|
113
|
-
# RT Plan Time:
|
114
|
-
plan_time = @plan_time.empty? ? Time.now.strftime("%H%M%S") : @plan_time
|
115
|
-
DICOM::Element.new('300A,0007', plan_time, :parent => dcm)
|
116
|
-
# Approval Status:
|
117
|
-
DICOM::Element.new('300E,0002', 'UNAPPROVED', :parent => dcm)
|
118
|
-
#
|
119
|
-
# SEQUENCES:
|
120
|
-
#
|
121
|
-
# Tolerance Table Sequence:
|
122
|
-
if p && p.fields.first && !p.fields.first.tolerance_table.empty?
|
123
|
-
tt_seq = DICOM::Sequence.new('300A,0040', :parent => dcm)
|
124
|
-
tt_item = DICOM::Item.new(:parent => tt_seq)
|
125
|
-
# Tolerance Table Number:
|
126
|
-
DICOM::Element.new('300A,0042', p.fields.first.tolerance_table, :parent => tt_item)
|
127
|
-
end
|
128
|
-
# Structure set information:
|
129
|
-
if p && p.site_setup && !p.site_setup.structure_set_uid.empty?
|
130
|
-
#
|
131
|
-
# Referenced Structure Set Sequence:
|
132
|
-
#
|
133
|
-
ss_seq = DICOM::Sequence.new('300C,0060', :parent => dcm)
|
134
|
-
ss_item = DICOM::Item.new(:parent => ss_seq)
|
135
|
-
# Referenced SOP Class UID:
|
136
|
-
DICOM::Element.new('0008,1150', '1.2.840.10008.5.1.4.1.1.481.3', :parent => ss_item)
|
137
|
-
DICOM::Element.new('0008,1155', p.site_setup.structure_set_uid, :parent => ss_item)
|
138
|
-
# RT Plan Geometry:
|
139
|
-
DICOM::Element.new('300A,000C', 'PATIENT', :parent => dcm)
|
140
|
-
else
|
141
|
-
# RT Plan Geometry:
|
142
|
-
DICOM::Element.new('300A,000C', 'TREATMENT_DEVICE', :parent => dcm)
|
143
|
-
end
|
144
|
-
#
|
145
|
-
# Patient Setup Sequence:
|
146
|
-
#
|
147
|
-
ps_seq = DICOM::Sequence.new('300A,0180', :parent => dcm)
|
148
|
-
ps_item = DICOM::Item.new(:parent => ps_seq)
|
149
|
-
# Patient Position:
|
150
|
-
begin
|
151
|
-
pat_pos = p.site_setup.patient_orientation.empty? ? 'HFS' : p.site_setup.patient_orientation
|
152
|
-
rescue
|
153
|
-
pat_pos = 'HFS'
|
154
|
-
end
|
155
|
-
DICOM::Element.new('0018,5100', pat_pos, :parent => ps_item)
|
156
|
-
# Patient Setup Number:
|
157
|
-
DICOM::Element.new('300A,0182', '1', :parent => ps_item)
|
158
|
-
# Setup Technique (assume Isocentric):
|
159
|
-
DICOM::Element.new('300A,01B0', 'ISOCENTRIC', :parent => ps_item)
|
160
|
-
#
|
161
|
-
# Dose Reference Sequence:
|
162
|
-
#
|
163
|
-
create_dose_reference(dcm, plan_name) if options[:dose_ref]
|
164
|
-
#
|
165
|
-
# Fraction Group Sequence:
|
166
|
-
#
|
167
|
-
fg_seq = DICOM::Sequence.new('300A,0070', :parent => dcm)
|
168
|
-
fg_item = DICOM::Item.new(:parent => fg_seq)
|
169
|
-
# Fraction Group Number:
|
170
|
-
DICOM::Element.new('300A,0071', '1', :parent => fg_item)
|
171
|
-
# Number of Fractions Planned (try to derive from total dose/fraction dose, or use 1 as default):
|
172
|
-
begin
|
173
|
-
num_frac = p.dose_ttl.empty? || p.dose_tx.empty? ? '1' : (p.dose_ttl.to_i / p.dose_tx.to_f).round.to_s
|
174
|
-
rescue
|
175
|
-
num_frac = '0'
|
176
|
-
end
|
177
|
-
DICOM::Element.new('300A,0078', num_frac, :parent => fg_item)
|
178
|
-
# Number of Brachy Application Setups:
|
179
|
-
DICOM::Element.new('300A,00A0', '0', :parent => fg_item)
|
180
|
-
# Referenced Beam Sequence (items created for each beam below):
|
181
|
-
rb_seq = DICOM::Sequence.new('300C,0004', :parent => fg_item)
|
182
|
-
#
|
183
|
-
# Beam Sequence:
|
184
|
-
#
|
185
|
-
b_seq = DICOM::Sequence.new('300A,00B0', :parent => dcm)
|
186
|
-
if p
|
187
|
-
# If no fields are present, we are not going to be able to make a valid DICOM object:
|
188
|
-
logger.error("No Field Record present. Unable to build a valid RTPLAN DICOM object.") unless p.fields.length > 0
|
189
|
-
p.fields.each_with_index do |field, i|
|
190
|
-
# Fields with modality 'Unspecified' (e.g. CT or 2dkV) must be skipped:
|
191
|
-
unless field.modality == 'Unspecified'
|
192
|
-
# If this is an electron beam, a warning should be printed, as these are less reliably converted:
|
193
|
-
logger.warn("This is not a photon beam (#{field.modality}). Beware that DICOM conversion of Electron beams are experimental, and other modalities are unsupported.") if field.modality != 'Xrays'
|
194
|
-
# Reset control point 'current value' attributes:
|
195
|
-
reset_cp_current_attributes
|
196
|
-
# Beam number and name:
|
197
|
-
beam_number = field.extended_field ? field.extended_field.original_beam_number : (i + 1).to_s
|
198
|
-
beam_name = field.extended_field ? field.extended_field.original_beam_name : field.field_name
|
199
|
-
# Ref Beam Item:
|
200
|
-
rb_item = DICOM::Item.new(:parent => rb_seq)
|
201
|
-
# Beam Dose (convert from cGy to Gy):
|
202
|
-
field_dose = field.field_dose.empty? ? '' : (field.field_dose.to_f * 0.01).round(4).to_s
|
203
|
-
DICOM::Element.new('300A,0084', field_dose, :parent => rb_item)
|
204
|
-
# Beam Meterset:
|
205
|
-
DICOM::Element.new('300A,0086', field.field_monitor_units, :parent => rb_item)
|
206
|
-
# Referenced Beam Number:
|
207
|
-
DICOM::Element.new('300C,0006', beam_number, :parent => rb_item)
|
208
|
-
# Beam Item:
|
209
|
-
b_item = DICOM::Item.new(:parent => b_seq)
|
210
|
-
# Optional method values:
|
211
|
-
# Manufacturer:
|
212
|
-
DICOM::Element.new('0008,0070', options[:manufacturer], :parent => b_item) if options[:manufacturer]
|
213
|
-
# Manufacturer's Model Name:
|
214
|
-
DICOM::Element.new('0008,1090', options[:model], :parent => b_item) if options[:model]
|
215
|
-
# Device Serial Number:
|
216
|
-
DICOM::Element.new('0018,1000', options[:serial_number], :parent => b_item) if options[:serial_number]
|
217
|
-
# Treatment Machine Name (max 16 characters):
|
218
|
-
DICOM::Element.new('300A,00B2', field.treatment_machine[0..15], :parent => b_item)
|
219
|
-
# Primary Dosimeter Unit:
|
220
|
-
DICOM::Element.new('300A,00B3', 'MU', :parent => b_item)
|
221
|
-
# Source-Axis Distance (convert to mm):
|
222
|
-
DICOM::Element.new('300A,00B4', "#{field.sad.to_f * 10}", :parent => b_item)
|
223
|
-
# Beam Number:
|
224
|
-
DICOM::Element.new('300A,00C0', beam_number, :parent => b_item)
|
225
|
-
# Beam Name:
|
226
|
-
DICOM::Element.new('300A,00C2', beam_name, :parent => b_item)
|
227
|
-
# Beam Description:
|
228
|
-
DICOM::Element.new('300A,00C3', field.field_note, :parent => b_item)
|
229
|
-
# Beam Type:
|
230
|
-
beam_type = case field.treatment_type
|
231
|
-
when 'Static' then 'STATIC'
|
232
|
-
when 'StepNShoot' then 'STATIC'
|
233
|
-
when 'VMAT' then 'DYNAMIC'
|
234
|
-
else logger.error("The beam type (treatment type) #{field.treatment_type} is not yet supported.")
|
235
|
-
end
|
236
|
-
DICOM::Element.new('300A,00C4', beam_type, :parent => b_item)
|
237
|
-
# Radiation Type:
|
238
|
-
rad_type = case field.modality
|
239
|
-
when 'Elect' then 'ELECTRON'
|
240
|
-
when 'Xrays' then 'PHOTON'
|
241
|
-
else logger.error("The radiation type (modality) #{field.modality} is not yet supported.")
|
242
|
-
end
|
243
|
-
DICOM::Element.new('300A,00C6', rad_type, :parent => b_item)
|
244
|
-
# Treatment Delivery Type:
|
245
|
-
DICOM::Element.new('300A,00CE', 'TREATMENT', :parent => b_item)
|
246
|
-
# Number of Wedges:
|
247
|
-
DICOM::Element.new('300A,00D0', (field.wedge.empty? ? '0' : '1'), :parent => b_item)
|
248
|
-
# Number of Compensators:
|
249
|
-
DICOM::Element.new('300A,00E0', (field.compensator.empty? ? '0' : '1'), :parent => b_item)
|
250
|
-
# Number of Boli:
|
251
|
-
DICOM::Element.new('300A,00ED', (field.bolus.empty? ? '0' : '1'), :parent => b_item)
|
252
|
-
# Number of Blocks:
|
253
|
-
DICOM::Element.new('300A,00F0', (field.block.empty? ? '0' : '1'), :parent => b_item)
|
254
|
-
# Final Cumulative Meterset Weight:
|
255
|
-
DICOM::Element.new('300A,010E', 1, :parent => b_item)
|
256
|
-
# Referenced Patient Setup Number:
|
257
|
-
DICOM::Element.new('300C,006A', '1', :parent => b_item)
|
258
|
-
#
|
259
|
-
# Beam Limiting Device Sequence:
|
260
|
-
#
|
261
|
-
create_beam_limiting_devices(b_item, field)
|
262
|
-
#
|
263
|
-
# Block Sequence (if any):
|
264
|
-
# FIXME: It seems that the Block Sequence (300A,00F4) may be
|
265
|
-
# difficult (impossible?) to reconstruct based on the RTP file's
|
266
|
-
# information, and thus it is skipped altogether.
|
267
|
-
#
|
268
|
-
#
|
269
|
-
# Applicator Sequence (if any):
|
270
|
-
#
|
271
|
-
unless field.e_applicator.empty?
|
272
|
-
app_seq = DICOM::Sequence.new('300A,0107', :parent => b_item)
|
273
|
-
app_item = DICOM::Item.new(:parent => app_seq)
|
274
|
-
# Applicator ID:
|
275
|
-
DICOM::Element.new('300A,0108', field.e_field_def_aperture, :parent => app_item)
|
276
|
-
# Applicator Type:
|
277
|
-
DICOM::Element.new('300A,0109', "ELECTRON_#{field.e_applicator.upcase}", :parent => app_item)
|
278
|
-
# Applicator Description:
|
279
|
-
DICOM::Element.new('300A,010A', "Appl. #{field.e_field_def_aperture}", :parent => app_item)
|
280
|
-
end
|
281
|
-
#
|
282
|
-
# Control Point Sequence:
|
283
|
-
#
|
284
|
-
# A field may have 0 (no MLC), 1 (conventional beam with MLC) or 2n (IMRT) control points.
|
285
|
-
# The DICOM file shall always contain 2n control points (minimum 2).
|
286
|
-
#
|
287
|
-
cp_seq = DICOM::Sequence.new('300A,0111', :parent => b_item)
|
288
|
-
if field.control_points.length < 2
|
289
|
-
# When we have 0 or 1 control point, use settings from field, and insert MLC settings if present:
|
290
|
-
# First CP:
|
291
|
-
cp_item = DICOM::Item.new(:parent => cp_seq)
|
292
|
-
# Control Point Index:
|
293
|
-
DICOM::Element.new('300A,0112', "0", :parent => cp_item)
|
294
|
-
# Nominal Beam Energy:
|
295
|
-
DICOM::Element.new('300A,0114', "#{field.energy.to_f}", :parent => cp_item)
|
296
|
-
# Dose Rate Set:
|
297
|
-
DICOM::Element.new('300A,0115', field.doserate, :parent => cp_item)
|
298
|
-
# Gantry Angle:
|
299
|
-
DICOM::Element.new('300A,011E', field.gantry_angle, :parent => cp_item)
|
300
|
-
# Gantry Rotation Direction:
|
301
|
-
DICOM::Element.new('300A,011F', (field.arc_direction.empty? ? 'NONE' : field.arc_direction), :parent => cp_item)
|
302
|
-
# Beam Limiting Device Angle:
|
303
|
-
DICOM::Element.new('300A,0120', field.collimator_angle, :parent => cp_item)
|
304
|
-
# Beam Limiting Device Rotation Direction:
|
305
|
-
DICOM::Element.new('300A,0121', 'NONE', :parent => cp_item)
|
306
|
-
# Patient Support Angle:
|
307
|
-
DICOM::Element.new('300A,0122', field.couch_pedestal, :parent => cp_item)
|
308
|
-
# Patient Support Rotation Direction:
|
309
|
-
DICOM::Element.new('300A,0123', 'NONE', :parent => cp_item)
|
310
|
-
# Table Top Eccentric Angle:
|
311
|
-
DICOM::Element.new('300A,0125', field.couch_angle, :parent => cp_item)
|
312
|
-
# Table Top Eccentric Rotation Direction:
|
313
|
-
DICOM::Element.new('300A,0126', 'NONE', :parent => cp_item)
|
314
|
-
# Table Top Vertical Position:
|
315
|
-
couch_vert = field.couch_vertical.empty? ? '' : (field.couch_vertical.to_f * 10).to_s
|
316
|
-
DICOM::Element.new('300A,0128', couch_vert, :parent => cp_item)
|
317
|
-
# Table Top Longitudinal Position:
|
318
|
-
couch_long = field.couch_longitudinal.empty? ? '' : (field.couch_longitudinal.to_f * 10).to_s
|
319
|
-
DICOM::Element.new('300A,0129', couch_long, :parent => cp_item)
|
320
|
-
# Table Top Lateral Position:
|
321
|
-
couch_lat = field.couch_lateral.empty? ? '' : (field.couch_lateral.to_f * 10).to_s
|
322
|
-
DICOM::Element.new('300A,012A', couch_lat, :parent => cp_item)
|
323
|
-
# Isocenter Position (x\y\z):
|
324
|
-
if p.site_setup
|
325
|
-
DICOM::Element.new('300A,012C', "#{(p.site_setup.iso_pos_x.to_f * 10).round(2)}\\#{(p.site_setup.iso_pos_y.to_f * 10).round(2)}\\#{(p.site_setup.iso_pos_z.to_f * 10).round(2)}", :parent => cp_item)
|
326
|
-
else
|
327
|
-
logger.warn("No Site Setup record exists for this plan. Unable to provide an isosenter position.")
|
328
|
-
DICOM::Element.new('300A,012C', '', :parent => cp_item)
|
329
|
-
end
|
330
|
-
# Source to Surface Distance:
|
331
|
-
add_ssd(field.ssd, cp_item)
|
332
|
-
# Cumulative Meterset Weight:
|
333
|
-
DICOM::Element.new('300A,0134', '0', :parent => cp_item)
|
334
|
-
# Beam Limiting Device Position Sequence:
|
335
|
-
if field.control_points.length > 0
|
336
|
-
create_beam_limiting_device_positions(cp_item, field.control_points.first, options)
|
337
|
-
else
|
338
|
-
create_beam_limiting_device_positions_from_field(cp_item, field, options)
|
339
|
-
end
|
340
|
-
# Referenced Dose Reference Sequence:
|
341
|
-
create_referenced_dose_reference(cp_item) if options[:dose_ref]
|
342
|
-
# Second CP:
|
343
|
-
cp_item = DICOM::Item.new(:parent => cp_seq)
|
344
|
-
# Control Point Index:
|
345
|
-
DICOM::Element.new('300A,0112', "1", :parent => cp_item)
|
346
|
-
# Cumulative Meterset Weight:
|
347
|
-
DICOM::Element.new('300A,0134', '1', :parent => cp_item)
|
348
|
-
else
|
349
|
-
# When we have multiple (2 or more) control points, iterate each control point:
|
350
|
-
field.control_points.each { |cp| create_control_point(cp, cp_seq, options) }
|
351
|
-
# Make sure that hte cumulative meterset weight of the last control
|
352
|
-
# point is '1' (exactly equal to final cumulative meterset weight):
|
353
|
-
cp_seq.items.last['300A,0134'].value = '1'
|
354
|
-
end
|
355
|
-
# Number of Control Points:
|
356
|
-
DICOM::Element.new('300A,0110', b_item['300A,0111'].items.length, :parent => b_item)
|
357
|
-
end
|
358
|
-
end
|
359
|
-
# Number of Beams:
|
360
|
-
DICOM::Element.new('300A,0080', fg_item['300C,0004'].items.length, :parent => fg_item)
|
361
|
-
end
|
362
|
-
# Restore the DICOM logger:
|
363
|
-
DICOM.logger.level = original_level
|
364
|
-
return dcm
|
365
|
-
end
|
366
|
-
|
367
|
-
|
368
|
-
private
|
369
|
-
|
370
|
-
|
371
|
-
# Adds an angular type value to a Control Point Item, by creating the
|
372
|
-
# necessary DICOM elements.
|
373
|
-
# Note that the element is only added if there is no 'current' attribute
|
374
|
-
# defined, or the given value is different form the current attribute.
|
375
|
-
#
|
376
|
-
# @param [DICOM::Item] item the DICOM control point item in which to create the elements
|
377
|
-
# @param [String] angle_tag the DICOM tag of the angle element
|
378
|
-
# @param [String] direction_tag the DICOM tag of the direction element
|
379
|
-
# @param [String, NilClass] angle the collimator angle attribute
|
380
|
-
# @param [String, NilClass] direction the collimator rotation direction attribute
|
381
|
-
# @param [Symbol] current_angle the instance variable that keeps track of the current value of this attribute
|
382
|
-
#
|
383
|
-
def add_angle(item, angle_tag, direction_tag, angle, direction, current_angle)
|
384
|
-
if !self.send(current_angle) || angle != self.send(current_angle)
|
385
|
-
self.send("#{current_angle}=", angle)
|
386
|
-
DICOM::Element.new(angle_tag, angle, :parent => item)
|
387
|
-
DICOM::Element.new(direction_tag, (direction.empty? ? 'NONE' : direction), :parent => item)
|
388
|
-
end
|
389
|
-
end
|
390
|
-
|
391
|
-
# Adds a Table Top Position element to a Control Point Item.
|
392
|
-
# Note that the element is only added if there is no 'current' attribute
|
393
|
-
# defined, or the given value is different form the current attribute.
|
394
|
-
#
|
395
|
-
# @param [DICOM::Item] item the DICOM control point item in which to create the element
|
396
|
-
# @param [String] tag the DICOM tag of the couch position element
|
397
|
-
# @param [String, NilClass] value the couch position
|
398
|
-
# @param [Symbol] current the instance variable that keeps track of the current value of this attribute
|
399
|
-
#
|
400
|
-
def add_couch_position(item, tag, value, current)
|
401
|
-
if !self.send(current) || value != self.send(current)
|
402
|
-
self.send("#{current}=", value)
|
403
|
-
DICOM::Element.new(tag, (value.empty? ? '' : value.to_f * 10), :parent => item)
|
404
|
-
end
|
405
|
-
end
|
406
|
-
|
407
|
-
# Adds a Dose Rate Set element to a Control Point Item.
|
408
|
-
# Note that the element is only added if there is no 'current' attribute
|
409
|
-
# defined, or the given value is different form the current attribute.
|
410
|
-
#
|
411
|
-
# @param [String, NilClass] value the doserate attribute
|
412
|
-
# @param [DICOM::Item] item the DICOM control point item in which to create an element
|
413
|
-
#
|
414
|
-
def add_doserate(value, item)
|
415
|
-
if !@current_doserate || value != @current_doserate
|
416
|
-
@current_doserate = value
|
417
|
-
DICOM::Element.new('300A,0115', value, :parent => item)
|
418
|
-
end
|
419
|
-
end
|
420
|
-
|
421
|
-
# Adds a Nominal Beam Energy element to a Control Point Item.
|
422
|
-
# Note that the element is only added if there is no 'current' attribute
|
423
|
-
# defined, or the given value is different form the current attribute.
|
424
|
-
#
|
425
|
-
# @param [String, NilClass] value the energy attribute
|
426
|
-
# @param [DICOM::Item] item the DICOM control point item in which to create an element
|
427
|
-
#
|
428
|
-
def add_energy(value, item)
|
429
|
-
if !@current_energy || value != @current_energy
|
430
|
-
@current_energy = value
|
431
|
-
DICOM::Element.new('300A,0114', "#{value.to_f}", :parent => item)
|
432
|
-
end
|
433
|
-
end
|
434
|
-
|
435
|
-
# Adds an Isosenter element to a Control Point Item.
|
436
|
-
# Note that the element is only added if there is a Site Setup record present,
|
437
|
-
# and it contains a real (non-empty) value. Also, the element is only added if there
|
438
|
-
# is no 'current' attribute defined, or the given value is different form the current attribute.
|
439
|
-
#
|
440
|
-
# @param [SiteSetup, NilClass] site_setup the associated site setup record
|
441
|
-
# @param [DICOM::Item] item the DICOM control point item in which to create an element
|
442
|
-
#
|
443
|
-
def add_isosenter(site_setup, item)
|
444
|
-
if site_setup
|
445
|
-
# Create an element if the value is new or unique:
|
446
|
-
if !@current_isosenter
|
447
|
-
iso = "#{(site_setup.iso_pos_x.to_f * 10).round(2)}\\#{(site_setup.iso_pos_y.to_f * 10).round(2)}\\#{(site_setup.iso_pos_z.to_f * 10).round(2)}"
|
448
|
-
if iso != @current_isosenter
|
449
|
-
@current_isosenter = iso
|
450
|
-
DICOM::Element.new('300A,012C', iso, :parent => item)
|
451
|
-
end
|
452
|
-
end
|
453
|
-
else
|
454
|
-
# Log a warning if this is the first control point:
|
455
|
-
unless @current_isosenter
|
456
|
-
logger.warn("No Site Setup record exists for this plan. Unable to provide an isosenter position.")
|
457
|
-
end
|
458
|
-
end
|
459
|
-
end
|
460
|
-
|
461
|
-
# Adds a Source to Surface Distance element to a Control Point Item.
|
462
|
-
# Note that the element is only added if the SSD attribute contains
|
463
|
-
# real (non-empty) value.
|
464
|
-
#
|
465
|
-
# @param [String, NilClass] value the SSD attribute
|
466
|
-
# @param [DICOM::Item] item the DICOM control point item in which to create an element
|
467
|
-
#
|
468
|
-
def add_ssd(value, item)
|
469
|
-
DICOM::Element.new('300A,0130', "#{value.to_f * 10}", :parent => item) if value && !value.empty?
|
470
|
-
end
|
471
|
-
|
472
|
-
# Creates a control point item in the given control point sequence, based
|
473
|
-
# on an RTP control point record.
|
474
|
-
#
|
475
|
-
# @param [ControlPoint] cp the RTP ControlPoint record to convert
|
476
|
-
# @param [DICOM::Sequence] sequence the DICOM parent sequence of the item to be created
|
477
|
-
# @param [Hash] options the options to use for creating the control point
|
478
|
-
# @option options [Boolean] :dose_ref if set, a Referenced Dose Reference sequence will be included in the generated control point item
|
479
|
-
# @return [DICOM::Item] the constructed control point DICOM item
|
480
|
-
#
|
481
|
-
def create_control_point(cp, sequence, options={})
|
482
|
-
cp_item = DICOM::Item.new(:parent => sequence)
|
483
|
-
# Some CP attributes will always be written (CP index, BLD positions & Cumulative meterset weight).
|
484
|
-
# The other attributes are only written if they are different from the previous control point.
|
485
|
-
# Control Point Index:
|
486
|
-
DICOM::Element.new('300A,0112', "#{cp.index}", :parent => cp_item)
|
487
|
-
# Beam Limiting Device Position Sequence:
|
488
|
-
create_beam_limiting_device_positions(cp_item, cp, options)
|
489
|
-
# Source to Surface Distance:
|
490
|
-
add_ssd(cp.ssd, cp_item)
|
491
|
-
# Cumulative Meterset Weight:
|
492
|
-
DICOM::Element.new('300A,0134', cp.monitor_units.to_f, :parent => cp_item)
|
493
|
-
# Referenced Dose Reference Sequence:
|
494
|
-
create_referenced_dose_reference(cp_item) if options[:dose_ref]
|
495
|
-
# Attributes that are only added if they carry an updated value:
|
496
|
-
# Nominal Beam Energy:
|
497
|
-
add_energy(cp.energy, cp_item)
|
498
|
-
# Dose Rate Set:
|
499
|
-
add_doserate(cp.doserate, cp_item)
|
500
|
-
# Gantry Angle & Rotation Direction:
|
501
|
-
add_angle(cp_item, '300A,011E', '300A,011F', cp.gantry_angle, cp.gantry_dir, :current_gantry)
|
502
|
-
# Beam Limiting Device Angle & Rotation Direction:
|
503
|
-
add_angle(cp_item, '300A,0120', '300A,0121', cp.collimator_angle, cp.collimator_dir, :current_collimator)
|
504
|
-
# Patient Support Angle & Rotation Direction:
|
505
|
-
add_angle(cp_item, '300A,0122', '300A,0123', cp.couch_pedestal, cp.couch_ped_dir, :current_couch_pedestal)
|
506
|
-
# Table Top Eccentric Angle & Rotation Direction:
|
507
|
-
add_angle(cp_item, '300A,0125', '300A,0126', cp.couch_angle, cp.couch_dir, :current_couch_angle)
|
508
|
-
# Table Top Vertical Position:
|
509
|
-
add_couch_position(cp_item, '300A,0128', cp.couch_vertical, :current_couch_vertical)
|
510
|
-
# Table Top Longitudinal Position:
|
511
|
-
add_couch_position(cp_item, '300A,0129', cp.couch_longitudinal, :current_couch_longitudinal)
|
512
|
-
# Table Top Lateral Position:
|
513
|
-
add_couch_position(cp_item, '300A,012A', cp.couch_lateral, :current_couch_lateral)
|
514
|
-
# Isocenter Position (x\y\z):
|
515
|
-
add_isosenter(cp.parent.parent.site_setup, cp_item)
|
516
|
-
cp_item
|
517
|
-
end
|
518
|
-
|
519
|
-
# Creates a beam limiting device sequence in the given DICOM object.
|
520
|
-
#
|
521
|
-
# @param [DICOM::Item] beam_item the DICOM beam item in which to insert the sequence
|
522
|
-
# @param [Field] field the RTP field to fetch device parameters from
|
523
|
-
# @return [DICOM::Sequence] the constructed beam limiting device sequence
|
524
|
-
#
|
525
|
-
def create_beam_limiting_devices(beam_item, field)
|
526
|
-
bl_seq = DICOM::Sequence.new('300A,00B6', :parent => beam_item)
|
527
|
-
# The ASYMX item ('backup jaws') doesn't exist on all models:
|
528
|
-
if ['SYM', 'ASY'].include?(field.field_x_mode.upcase)
|
529
|
-
bl_item_x = DICOM::Item.new(:parent => bl_seq)
|
530
|
-
DICOM::Element.new('300A,00B8', "ASYMX", :parent => bl_item_x)
|
531
|
-
DICOM::Element.new('300A,00BC', "1", :parent => bl_item_x)
|
532
|
-
end
|
533
|
-
# The ASYMY item is always created:
|
534
|
-
bl_item_y = DICOM::Item.new(:parent => bl_seq)
|
535
|
-
# RT Beam Limiting Device Type:
|
536
|
-
DICOM::Element.new('300A,00B8', "ASYMY", :parent => bl_item_y)
|
537
|
-
# Number of Leaf/Jaw Pairs:
|
538
|
-
DICOM::Element.new('300A,00BC', "1", :parent => bl_item_y)
|
539
|
-
# MLCX item is only created if leaves are defined:
|
540
|
-
# (NB: The RTP file doesn't specify leaf position boundaries, so we
|
541
|
-
# have to set these based on a set of known MLC types, their number
|
542
|
-
# of leaves, and their leaf boundary positions.)
|
543
|
-
if field.control_points.length > 0
|
544
|
-
bl_item_mlcx = DICOM::Item.new(:parent => bl_seq)
|
545
|
-
DICOM::Element.new('300A,00B8', "MLCX", :parent => bl_item_mlcx)
|
546
|
-
num_leaves = field.control_points.first.mlc_leaves.to_i
|
547
|
-
DICOM::Element.new('300A,00BC', num_leaves.to_s, :parent => bl_item_mlcx)
|
548
|
-
DICOM::Element.new('300A,00BE', "#{RTP.leaf_boundaries(num_leaves).join("\\")}", :parent => bl_item_mlcx)
|
549
|
-
end
|
550
|
-
bl_seq
|
551
|
-
end
|
552
|
-
|
553
|
-
# Creates a beam limiting device positions sequence in the given DICOM object.
|
554
|
-
#
|
555
|
-
# @param [DICOM::Item] cp_item the DICOM control point item in which to insert the sequence
|
556
|
-
# @param [ControlPoint] cp the RTP control point to fetch device parameters from
|
557
|
-
# @return [DICOM::Sequence] the constructed beam limiting device positions sequence
|
558
|
-
#
|
559
|
-
def create_beam_limiting_device_positions(cp_item, cp, options={})
|
560
|
-
dp_seq = DICOM::Sequence.new('300A,011A', :parent => cp_item)
|
561
|
-
# The ASYMX item ('backup jaws') doesn't exist on all models:
|
562
|
-
if ['SYM', 'ASY'].include?(cp.parent.field_x_mode.upcase)
|
563
|
-
dp_item_x =
|
564
|
-
|
565
|
-
|
566
|
-
|
567
|
-
#
|
568
|
-
|
569
|
-
# RT Beam Limiting Device Type:
|
570
|
-
DICOM::Element.new('300A,00B8', "
|
571
|
-
# Leaf/Jaw Positions:
|
572
|
-
DICOM::Element.new('300A,011C',
|
573
|
-
|
574
|
-
|
575
|
-
|
576
|
-
|
577
|
-
|
578
|
-
|
579
|
-
|
580
|
-
|
581
|
-
|
582
|
-
#
|
583
|
-
|
584
|
-
|
585
|
-
|
586
|
-
|
587
|
-
|
588
|
-
|
589
|
-
|
590
|
-
#
|
591
|
-
|
592
|
-
|
593
|
-
|
594
|
-
|
595
|
-
|
596
|
-
|
597
|
-
|
598
|
-
|
599
|
-
|
600
|
-
|
601
|
-
|
602
|
-
|
603
|
-
|
604
|
-
|
605
|
-
|
606
|
-
|
607
|
-
|
608
|
-
|
609
|
-
|
610
|
-
|
611
|
-
|
612
|
-
|
613
|
-
|
614
|
-
|
615
|
-
|
616
|
-
|
617
|
-
|
618
|
-
|
619
|
-
|
620
|
-
|
621
|
-
|
622
|
-
|
623
|
-
|
624
|
-
|
625
|
-
|
626
|
-
|
627
|
-
|
628
|
-
|
629
|
-
|
630
|
-
|
631
|
-
|
632
|
-
|
633
|
-
|
634
|
-
|
635
|
-
|
636
|
-
|
637
|
-
#
|
638
|
-
|
639
|
-
|
640
|
-
|
641
|
-
|
642
|
-
|
643
|
-
|
644
|
-
|
645
|
-
|
646
|
-
|
647
|
-
|
648
|
-
|
649
|
-
|
650
|
-
|
651
|
-
|
652
|
-
|
653
|
-
|
654
|
-
|
655
|
-
|
656
|
-
|
1
|
+
module RTP
|
2
|
+
|
3
|
+
class Plan < Record
|
4
|
+
|
5
|
+
attr_accessor :current_gantry
|
6
|
+
attr_accessor :current_collimator
|
7
|
+
attr_accessor :current_couch_angle
|
8
|
+
attr_accessor :current_couch_pedestal
|
9
|
+
attr_accessor :current_couch_lateral
|
10
|
+
attr_accessor :current_couch_longitudinal
|
11
|
+
attr_accessor :current_couch_vertical
|
12
|
+
|
13
|
+
# Converts the Plan (and child) records to a
|
14
|
+
# DICOM::DObject of modality RTPLAN.
|
15
|
+
#
|
16
|
+
# @note Only photon plans have been tested.
|
17
|
+
# Electron beams beams may give an invalid DICOM file.
|
18
|
+
# Also note that, due to limitations in the RTP file format, some original
|
19
|
+
# values can not be recreated, like e.g. Study UID or Series UID.
|
20
|
+
# @param [Hash] options the options to use for creating the DICOM object
|
21
|
+
# @option options [Boolean] :dose_ref if set, Dose Reference & Referenced Dose Reference sequences will be included in the generated DICOM file
|
22
|
+
# @option options [String] :manufacturer the value used for the manufacturer tag (0008,0070) in the beam sequence
|
23
|
+
# @option options [String] :model the value used for the manufacturer's model name tag (0008,1090) in the beam sequence
|
24
|
+
# @option options [Symbol] :scale if set, relevant device parameters are converted from native readout format to IEC1217 (supported values are :elekta & :varian)
|
25
|
+
# @option options [String] :serial_number the value used for the device serial number tag (0018,1000) in the beam sequence
|
26
|
+
# @return [DICOM::DObject] the converted DICOM object
|
27
|
+
#
|
28
|
+
def to_dcm(options={})
|
29
|
+
#
|
30
|
+
# FIXME: This method is rather big, with a few sections of somewhat similar, repeating code.
|
31
|
+
# Refactoring and simplifying it at some stage might be a good idea.
|
32
|
+
#
|
33
|
+
require 'dicom'
|
34
|
+
original_level = DICOM.logger.level
|
35
|
+
DICOM.logger.level = Logger::FATAL
|
36
|
+
p = @prescriptions.first
|
37
|
+
# If no prescription is present, we are not going to be able to make a valid DICOM object:
|
38
|
+
logger.error("No Prescription Record present. Unable to build a valid RTPLAN DICOM object.") unless p
|
39
|
+
dcm = DICOM::DObject.new
|
40
|
+
#
|
41
|
+
# TOP LEVEL TAGS:
|
42
|
+
#
|
43
|
+
# Specific Character Set:
|
44
|
+
DICOM::Element.new('0008,0005', 'ISO_IR 100', :parent => dcm)
|
45
|
+
# Instance Creation Date
|
46
|
+
DICOM::Element.new('0008,0012', Time.now.strftime("%Y%m%d"), :parent => dcm)
|
47
|
+
# Instance Creation Time:
|
48
|
+
DICOM::Element.new('0008,0013', Time.now.strftime("%H%M%S"), :parent => dcm)
|
49
|
+
# SOP Class UID:
|
50
|
+
DICOM::Element.new('0008,0016', '1.2.840.10008.5.1.4.1.1.481.5', :parent => dcm)
|
51
|
+
# SOP Instance UID (if an original UID is not present, we make up a UID):
|
52
|
+
begin
|
53
|
+
sop_uid = p.fields.first.extended_field.original_plan_uid.empty? ? DICOM.generate_uid : p.fields.first.extended_field.original_plan_uid
|
54
|
+
rescue
|
55
|
+
sop_uid = DICOM.generate_uid
|
56
|
+
end
|
57
|
+
DICOM::Element.new('0008,0018', sop_uid, :parent => dcm)
|
58
|
+
# Study Date
|
59
|
+
DICOM::Element.new('0008,0020', Time.now.strftime("%Y%m%d"), :parent => dcm)
|
60
|
+
# Study Time:
|
61
|
+
DICOM::Element.new('0008,0030', Time.now.strftime("%H%M%S"), :parent => dcm)
|
62
|
+
# Accession Number:
|
63
|
+
DICOM::Element.new('0008,0050', '', :parent => dcm)
|
64
|
+
# Modality:
|
65
|
+
DICOM::Element.new('0008,0060', 'RTPLAN', :parent => dcm)
|
66
|
+
# Manufacturer:
|
67
|
+
DICOM::Element.new('0008,0070', 'rtp-connect', :parent => dcm)
|
68
|
+
# Referring Physician's Name:
|
69
|
+
DICOM::Element.new('0008,0090', "#{@md_last_name}^#{@md_first_name}^#{@md_middle_name}^^", :parent => dcm)
|
70
|
+
# Operator's Name:
|
71
|
+
DICOM::Element.new('0008,1070', "#{@author_last_name}^#{@author_first_name}^#{@author_middle_name}^^", :parent => dcm)
|
72
|
+
# Patient's Name:
|
73
|
+
DICOM::Element.new('0010,0010', "#{@patient_last_name}^#{@patient_first_name}^#{@patient_middle_name}^^", :parent => dcm)
|
74
|
+
# Patient ID:
|
75
|
+
DICOM::Element.new('0010,0020', @patient_id, :parent => dcm)
|
76
|
+
# Patient's Birth Date:
|
77
|
+
DICOM::Element.new('0010,0030', '', :parent => dcm)
|
78
|
+
# Patient's Sex:
|
79
|
+
DICOM::Element.new('0010,0040', '', :parent => dcm)
|
80
|
+
# Manufacturer's Model Name:
|
81
|
+
DICOM::Element.new('0008,1090', 'RTP-to-DICOM', :parent => dcm)
|
82
|
+
# Software Version(s):
|
83
|
+
DICOM::Element.new('0018,1020', "RubyRTP#{VERSION}", :parent => dcm)
|
84
|
+
# Study Instance UID:
|
85
|
+
DICOM::Element.new('0020,000D', DICOM.generate_uid, :parent => dcm)
|
86
|
+
# Series Instance UID:
|
87
|
+
DICOM::Element.new('0020,000E', DICOM.generate_uid, :parent => dcm)
|
88
|
+
# Study ID:
|
89
|
+
DICOM::Element.new('0020,0010', '1', :parent => dcm)
|
90
|
+
# Series Number:
|
91
|
+
DICOM::Element.new('0020,0011', '1', :parent => dcm)
|
92
|
+
# Frame of Reference UID (if an original UID is not present, we make up a UID):
|
93
|
+
begin
|
94
|
+
for_uid = p.site_setup.frame_of_ref_uid.empty? ? DICOM.generate_uid : p.site_setup.frame_of_ref_uid
|
95
|
+
rescue
|
96
|
+
for_uid = DICOM.generate_uid
|
97
|
+
end
|
98
|
+
DICOM::Element.new('0020,0052', for_uid, :parent => dcm)
|
99
|
+
# Position Reference Indicator:
|
100
|
+
DICOM::Element.new('0020,1040', '', :parent => dcm)
|
101
|
+
# RT Plan Label (max 16 characters):
|
102
|
+
plan_label = p ? p.rx_site_name[0..15] : @course_id
|
103
|
+
DICOM::Element.new('300A,0002', plan_label, :parent => dcm)
|
104
|
+
# RT Plan Name:
|
105
|
+
plan_name = p ? p.rx_site_name : @course_id
|
106
|
+
DICOM::Element.new('300A,0003', plan_name, :parent => dcm)
|
107
|
+
# RT Plan Description:
|
108
|
+
plan_desc = p ? p.technique : @diagnosis
|
109
|
+
DICOM::Element.new('300A,0004', plan_desc, :parent => dcm)
|
110
|
+
# RT Plan Date:
|
111
|
+
plan_date = @plan_date.empty? ? Time.now.strftime("%Y%m%d") : @plan_date
|
112
|
+
DICOM::Element.new('300A,0006', plan_date, :parent => dcm)
|
113
|
+
# RT Plan Time:
|
114
|
+
plan_time = @plan_time.empty? ? Time.now.strftime("%H%M%S") : @plan_time
|
115
|
+
DICOM::Element.new('300A,0007', plan_time, :parent => dcm)
|
116
|
+
# Approval Status:
|
117
|
+
DICOM::Element.new('300E,0002', 'UNAPPROVED', :parent => dcm)
|
118
|
+
#
|
119
|
+
# SEQUENCES:
|
120
|
+
#
|
121
|
+
# Tolerance Table Sequence:
|
122
|
+
if p && p.fields.first && !p.fields.first.tolerance_table.empty?
|
123
|
+
tt_seq = DICOM::Sequence.new('300A,0040', :parent => dcm)
|
124
|
+
tt_item = DICOM::Item.new(:parent => tt_seq)
|
125
|
+
# Tolerance Table Number:
|
126
|
+
DICOM::Element.new('300A,0042', p.fields.first.tolerance_table, :parent => tt_item)
|
127
|
+
end
|
128
|
+
# Structure set information:
|
129
|
+
if p && p.site_setup && !p.site_setup.structure_set_uid.empty?
|
130
|
+
#
|
131
|
+
# Referenced Structure Set Sequence:
|
132
|
+
#
|
133
|
+
ss_seq = DICOM::Sequence.new('300C,0060', :parent => dcm)
|
134
|
+
ss_item = DICOM::Item.new(:parent => ss_seq)
|
135
|
+
# Referenced SOP Class UID:
|
136
|
+
DICOM::Element.new('0008,1150', '1.2.840.10008.5.1.4.1.1.481.3', :parent => ss_item)
|
137
|
+
DICOM::Element.new('0008,1155', p.site_setup.structure_set_uid, :parent => ss_item)
|
138
|
+
# RT Plan Geometry:
|
139
|
+
DICOM::Element.new('300A,000C', 'PATIENT', :parent => dcm)
|
140
|
+
else
|
141
|
+
# RT Plan Geometry:
|
142
|
+
DICOM::Element.new('300A,000C', 'TREATMENT_DEVICE', :parent => dcm)
|
143
|
+
end
|
144
|
+
#
|
145
|
+
# Patient Setup Sequence:
|
146
|
+
#
|
147
|
+
ps_seq = DICOM::Sequence.new('300A,0180', :parent => dcm)
|
148
|
+
ps_item = DICOM::Item.new(:parent => ps_seq)
|
149
|
+
# Patient Position:
|
150
|
+
begin
|
151
|
+
pat_pos = p.site_setup.patient_orientation.empty? ? 'HFS' : p.site_setup.patient_orientation
|
152
|
+
rescue
|
153
|
+
pat_pos = 'HFS'
|
154
|
+
end
|
155
|
+
DICOM::Element.new('0018,5100', pat_pos, :parent => ps_item)
|
156
|
+
# Patient Setup Number:
|
157
|
+
DICOM::Element.new('300A,0182', '1', :parent => ps_item)
|
158
|
+
# Setup Technique (assume Isocentric):
|
159
|
+
DICOM::Element.new('300A,01B0', 'ISOCENTRIC', :parent => ps_item)
|
160
|
+
#
|
161
|
+
# Dose Reference Sequence:
|
162
|
+
#
|
163
|
+
create_dose_reference(dcm, plan_name) if options[:dose_ref]
|
164
|
+
#
|
165
|
+
# Fraction Group Sequence:
|
166
|
+
#
|
167
|
+
fg_seq = DICOM::Sequence.new('300A,0070', :parent => dcm)
|
168
|
+
fg_item = DICOM::Item.new(:parent => fg_seq)
|
169
|
+
# Fraction Group Number:
|
170
|
+
DICOM::Element.new('300A,0071', '1', :parent => fg_item)
|
171
|
+
# Number of Fractions Planned (try to derive from total dose/fraction dose, or use 1 as default):
|
172
|
+
begin
|
173
|
+
num_frac = p.dose_ttl.empty? || p.dose_tx.empty? ? '1' : (p.dose_ttl.to_i / p.dose_tx.to_f).round.to_s
|
174
|
+
rescue
|
175
|
+
num_frac = '0'
|
176
|
+
end
|
177
|
+
DICOM::Element.new('300A,0078', num_frac, :parent => fg_item)
|
178
|
+
# Number of Brachy Application Setups:
|
179
|
+
DICOM::Element.new('300A,00A0', '0', :parent => fg_item)
|
180
|
+
# Referenced Beam Sequence (items created for each beam below):
|
181
|
+
rb_seq = DICOM::Sequence.new('300C,0004', :parent => fg_item)
|
182
|
+
#
|
183
|
+
# Beam Sequence:
|
184
|
+
#
|
185
|
+
b_seq = DICOM::Sequence.new('300A,00B0', :parent => dcm)
|
186
|
+
if p
|
187
|
+
# If no fields are present, we are not going to be able to make a valid DICOM object:
|
188
|
+
logger.error("No Field Record present. Unable to build a valid RTPLAN DICOM object.") unless p.fields.length > 0
|
189
|
+
p.fields.each_with_index do |field, i|
|
190
|
+
# Fields with modality 'Unspecified' (e.g. CT or 2dkV) must be skipped:
|
191
|
+
unless field.modality == 'Unspecified'
|
192
|
+
# If this is an electron beam, a warning should be printed, as these are less reliably converted:
|
193
|
+
logger.warn("This is not a photon beam (#{field.modality}). Beware that DICOM conversion of Electron beams are experimental, and other modalities are unsupported.") if field.modality != 'Xrays'
|
194
|
+
# Reset control point 'current value' attributes:
|
195
|
+
reset_cp_current_attributes
|
196
|
+
# Beam number and name:
|
197
|
+
beam_number = field.extended_field ? field.extended_field.original_beam_number : (i + 1).to_s
|
198
|
+
beam_name = field.extended_field ? field.extended_field.original_beam_name : field.field_name
|
199
|
+
# Ref Beam Item:
|
200
|
+
rb_item = DICOM::Item.new(:parent => rb_seq)
|
201
|
+
# Beam Dose (convert from cGy to Gy):
|
202
|
+
field_dose = field.field_dose.empty? ? '' : (field.field_dose.to_f * 0.01).round(4).to_s
|
203
|
+
DICOM::Element.new('300A,0084', field_dose, :parent => rb_item)
|
204
|
+
# Beam Meterset:
|
205
|
+
DICOM::Element.new('300A,0086', field.field_monitor_units, :parent => rb_item)
|
206
|
+
# Referenced Beam Number:
|
207
|
+
DICOM::Element.new('300C,0006', beam_number, :parent => rb_item)
|
208
|
+
# Beam Item:
|
209
|
+
b_item = DICOM::Item.new(:parent => b_seq)
|
210
|
+
# Optional method values:
|
211
|
+
# Manufacturer:
|
212
|
+
DICOM::Element.new('0008,0070', options[:manufacturer], :parent => b_item) if options[:manufacturer]
|
213
|
+
# Manufacturer's Model Name:
|
214
|
+
DICOM::Element.new('0008,1090', options[:model], :parent => b_item) if options[:model]
|
215
|
+
# Device Serial Number:
|
216
|
+
DICOM::Element.new('0018,1000', options[:serial_number], :parent => b_item) if options[:serial_number]
|
217
|
+
# Treatment Machine Name (max 16 characters):
|
218
|
+
DICOM::Element.new('300A,00B2', field.treatment_machine[0..15], :parent => b_item)
|
219
|
+
# Primary Dosimeter Unit:
|
220
|
+
DICOM::Element.new('300A,00B3', 'MU', :parent => b_item)
|
221
|
+
# Source-Axis Distance (convert to mm):
|
222
|
+
DICOM::Element.new('300A,00B4', "#{field.sad.to_f * 10}", :parent => b_item)
|
223
|
+
# Beam Number:
|
224
|
+
DICOM::Element.new('300A,00C0', beam_number, :parent => b_item)
|
225
|
+
# Beam Name:
|
226
|
+
DICOM::Element.new('300A,00C2', beam_name, :parent => b_item)
|
227
|
+
# Beam Description:
|
228
|
+
DICOM::Element.new('300A,00C3', field.field_note, :parent => b_item)
|
229
|
+
# Beam Type:
|
230
|
+
beam_type = case field.treatment_type
|
231
|
+
when 'Static' then 'STATIC'
|
232
|
+
when 'StepNShoot' then 'STATIC'
|
233
|
+
when 'VMAT' then 'DYNAMIC'
|
234
|
+
else logger.error("The beam type (treatment type) #{field.treatment_type} is not yet supported.")
|
235
|
+
end
|
236
|
+
DICOM::Element.new('300A,00C4', beam_type, :parent => b_item)
|
237
|
+
# Radiation Type:
|
238
|
+
rad_type = case field.modality
|
239
|
+
when 'Elect' then 'ELECTRON'
|
240
|
+
when 'Xrays' then 'PHOTON'
|
241
|
+
else logger.error("The radiation type (modality) #{field.modality} is not yet supported.")
|
242
|
+
end
|
243
|
+
DICOM::Element.new('300A,00C6', rad_type, :parent => b_item)
|
244
|
+
# Treatment Delivery Type:
|
245
|
+
DICOM::Element.new('300A,00CE', 'TREATMENT', :parent => b_item)
|
246
|
+
# Number of Wedges:
|
247
|
+
DICOM::Element.new('300A,00D0', (field.wedge.empty? ? '0' : '1'), :parent => b_item)
|
248
|
+
# Number of Compensators:
|
249
|
+
DICOM::Element.new('300A,00E0', (field.compensator.empty? ? '0' : '1'), :parent => b_item)
|
250
|
+
# Number of Boli:
|
251
|
+
DICOM::Element.new('300A,00ED', (field.bolus.empty? ? '0' : '1'), :parent => b_item)
|
252
|
+
# Number of Blocks:
|
253
|
+
DICOM::Element.new('300A,00F0', (field.block.empty? ? '0' : '1'), :parent => b_item)
|
254
|
+
# Final Cumulative Meterset Weight:
|
255
|
+
DICOM::Element.new('300A,010E', 1, :parent => b_item)
|
256
|
+
# Referenced Patient Setup Number:
|
257
|
+
DICOM::Element.new('300C,006A', '1', :parent => b_item)
|
258
|
+
#
|
259
|
+
# Beam Limiting Device Sequence:
|
260
|
+
#
|
261
|
+
create_beam_limiting_devices(b_item, field)
|
262
|
+
#
|
263
|
+
# Block Sequence (if any):
|
264
|
+
# FIXME: It seems that the Block Sequence (300A,00F4) may be
|
265
|
+
# difficult (impossible?) to reconstruct based on the RTP file's
|
266
|
+
# information, and thus it is skipped altogether.
|
267
|
+
#
|
268
|
+
#
|
269
|
+
# Applicator Sequence (if any):
|
270
|
+
#
|
271
|
+
unless field.e_applicator.empty?
|
272
|
+
app_seq = DICOM::Sequence.new('300A,0107', :parent => b_item)
|
273
|
+
app_item = DICOM::Item.new(:parent => app_seq)
|
274
|
+
# Applicator ID:
|
275
|
+
DICOM::Element.new('300A,0108', field.e_field_def_aperture, :parent => app_item)
|
276
|
+
# Applicator Type:
|
277
|
+
DICOM::Element.new('300A,0109', "ELECTRON_#{field.e_applicator.upcase}", :parent => app_item)
|
278
|
+
# Applicator Description:
|
279
|
+
DICOM::Element.new('300A,010A', "Appl. #{field.e_field_def_aperture}", :parent => app_item)
|
280
|
+
end
|
281
|
+
#
|
282
|
+
# Control Point Sequence:
|
283
|
+
#
|
284
|
+
# A field may have 0 (no MLC), 1 (conventional beam with MLC) or 2n (IMRT) control points.
|
285
|
+
# The DICOM file shall always contain 2n control points (minimum 2).
|
286
|
+
#
|
287
|
+
cp_seq = DICOM::Sequence.new('300A,0111', :parent => b_item)
|
288
|
+
if field.control_points.length < 2
|
289
|
+
# When we have 0 or 1 control point, use settings from field, and insert MLC settings if present:
|
290
|
+
# First CP:
|
291
|
+
cp_item = DICOM::Item.new(:parent => cp_seq)
|
292
|
+
# Control Point Index:
|
293
|
+
DICOM::Element.new('300A,0112', "0", :parent => cp_item)
|
294
|
+
# Nominal Beam Energy:
|
295
|
+
DICOM::Element.new('300A,0114', "#{field.energy.to_f}", :parent => cp_item)
|
296
|
+
# Dose Rate Set:
|
297
|
+
DICOM::Element.new('300A,0115', field.doserate, :parent => cp_item)
|
298
|
+
# Gantry Angle:
|
299
|
+
DICOM::Element.new('300A,011E', field.gantry_angle, :parent => cp_item)
|
300
|
+
# Gantry Rotation Direction:
|
301
|
+
DICOM::Element.new('300A,011F', (field.arc_direction.empty? ? 'NONE' : field.arc_direction), :parent => cp_item)
|
302
|
+
# Beam Limiting Device Angle:
|
303
|
+
DICOM::Element.new('300A,0120', field.collimator_angle, :parent => cp_item)
|
304
|
+
# Beam Limiting Device Rotation Direction:
|
305
|
+
DICOM::Element.new('300A,0121', 'NONE', :parent => cp_item)
|
306
|
+
# Patient Support Angle:
|
307
|
+
DICOM::Element.new('300A,0122', field.couch_pedestal, :parent => cp_item)
|
308
|
+
# Patient Support Rotation Direction:
|
309
|
+
DICOM::Element.new('300A,0123', 'NONE', :parent => cp_item)
|
310
|
+
# Table Top Eccentric Angle:
|
311
|
+
DICOM::Element.new('300A,0125', field.couch_angle, :parent => cp_item)
|
312
|
+
# Table Top Eccentric Rotation Direction:
|
313
|
+
DICOM::Element.new('300A,0126', 'NONE', :parent => cp_item)
|
314
|
+
# Table Top Vertical Position:
|
315
|
+
couch_vert = field.couch_vertical.empty? ? '' : (field.couch_vertical.to_f * 10).to_s
|
316
|
+
DICOM::Element.new('300A,0128', couch_vert, :parent => cp_item)
|
317
|
+
# Table Top Longitudinal Position:
|
318
|
+
couch_long = field.couch_longitudinal.empty? ? '' : (field.couch_longitudinal.to_f * 10).to_s
|
319
|
+
DICOM::Element.new('300A,0129', couch_long, :parent => cp_item)
|
320
|
+
# Table Top Lateral Position:
|
321
|
+
couch_lat = field.couch_lateral.empty? ? '' : (field.couch_lateral.to_f * 10).to_s
|
322
|
+
DICOM::Element.new('300A,012A', couch_lat, :parent => cp_item)
|
323
|
+
# Isocenter Position (x\y\z):
|
324
|
+
if p.site_setup
|
325
|
+
DICOM::Element.new('300A,012C', "#{(p.site_setup.iso_pos_x.to_f * 10).round(2)}\\#{(p.site_setup.iso_pos_y.to_f * 10).round(2)}\\#{(p.site_setup.iso_pos_z.to_f * 10).round(2)}", :parent => cp_item)
|
326
|
+
else
|
327
|
+
logger.warn("No Site Setup record exists for this plan. Unable to provide an isosenter position.")
|
328
|
+
DICOM::Element.new('300A,012C', '', :parent => cp_item)
|
329
|
+
end
|
330
|
+
# Source to Surface Distance:
|
331
|
+
add_ssd(field.ssd, cp_item)
|
332
|
+
# Cumulative Meterset Weight:
|
333
|
+
DICOM::Element.new('300A,0134', '0', :parent => cp_item)
|
334
|
+
# Beam Limiting Device Position Sequence:
|
335
|
+
if field.control_points.length > 0
|
336
|
+
create_beam_limiting_device_positions(cp_item, field.control_points.first, options)
|
337
|
+
else
|
338
|
+
create_beam_limiting_device_positions_from_field(cp_item, field, options)
|
339
|
+
end
|
340
|
+
# Referenced Dose Reference Sequence:
|
341
|
+
create_referenced_dose_reference(cp_item) if options[:dose_ref]
|
342
|
+
# Second CP:
|
343
|
+
cp_item = DICOM::Item.new(:parent => cp_seq)
|
344
|
+
# Control Point Index:
|
345
|
+
DICOM::Element.new('300A,0112', "1", :parent => cp_item)
|
346
|
+
# Cumulative Meterset Weight:
|
347
|
+
DICOM::Element.new('300A,0134', '1', :parent => cp_item)
|
348
|
+
else
|
349
|
+
# When we have multiple (2 or more) control points, iterate each control point:
|
350
|
+
field.control_points.each { |cp| create_control_point(cp, cp_seq, options) }
|
351
|
+
# Make sure that hte cumulative meterset weight of the last control
|
352
|
+
# point is '1' (exactly equal to final cumulative meterset weight):
|
353
|
+
cp_seq.items.last['300A,0134'].value = '1'
|
354
|
+
end
|
355
|
+
# Number of Control Points:
|
356
|
+
DICOM::Element.new('300A,0110', b_item['300A,0111'].items.length, :parent => b_item)
|
357
|
+
end
|
358
|
+
end
|
359
|
+
# Number of Beams:
|
360
|
+
DICOM::Element.new('300A,0080', fg_item['300C,0004'].items.length, :parent => fg_item)
|
361
|
+
end
|
362
|
+
# Restore the DICOM logger:
|
363
|
+
DICOM.logger.level = original_level
|
364
|
+
return dcm
|
365
|
+
end
|
366
|
+
|
367
|
+
|
368
|
+
private
|
369
|
+
|
370
|
+
|
371
|
+
# Adds an angular type value to a Control Point Item, by creating the
|
372
|
+
# necessary DICOM elements.
|
373
|
+
# Note that the element is only added if there is no 'current' attribute
|
374
|
+
# defined, or the given value is different form the current attribute.
|
375
|
+
#
|
376
|
+
# @param [DICOM::Item] item the DICOM control point item in which to create the elements
|
377
|
+
# @param [String] angle_tag the DICOM tag of the angle element
|
378
|
+
# @param [String] direction_tag the DICOM tag of the direction element
|
379
|
+
# @param [String, NilClass] angle the collimator angle attribute
|
380
|
+
# @param [String, NilClass] direction the collimator rotation direction attribute
|
381
|
+
# @param [Symbol] current_angle the instance variable that keeps track of the current value of this attribute
|
382
|
+
#
|
383
|
+
def add_angle(item, angle_tag, direction_tag, angle, direction, current_angle)
|
384
|
+
if !self.send(current_angle) || angle != self.send(current_angle)
|
385
|
+
self.send("#{current_angle}=", angle)
|
386
|
+
DICOM::Element.new(angle_tag, angle, :parent => item)
|
387
|
+
DICOM::Element.new(direction_tag, (direction.empty? ? 'NONE' : direction), :parent => item)
|
388
|
+
end
|
389
|
+
end
|
390
|
+
|
391
|
+
# Adds a Table Top Position element to a Control Point Item.
|
392
|
+
# Note that the element is only added if there is no 'current' attribute
|
393
|
+
# defined, or the given value is different form the current attribute.
|
394
|
+
#
|
395
|
+
# @param [DICOM::Item] item the DICOM control point item in which to create the element
|
396
|
+
# @param [String] tag the DICOM tag of the couch position element
|
397
|
+
# @param [String, NilClass] value the couch position
|
398
|
+
# @param [Symbol] current the instance variable that keeps track of the current value of this attribute
|
399
|
+
#
|
400
|
+
def add_couch_position(item, tag, value, current)
|
401
|
+
if !self.send(current) || value != self.send(current)
|
402
|
+
self.send("#{current}=", value)
|
403
|
+
DICOM::Element.new(tag, (value.empty? ? '' : value.to_f * 10), :parent => item)
|
404
|
+
end
|
405
|
+
end
|
406
|
+
|
407
|
+
# Adds a Dose Rate Set element to a Control Point Item.
|
408
|
+
# Note that the element is only added if there is no 'current' attribute
|
409
|
+
# defined, or the given value is different form the current attribute.
|
410
|
+
#
|
411
|
+
# @param [String, NilClass] value the doserate attribute
|
412
|
+
# @param [DICOM::Item] item the DICOM control point item in which to create an element
|
413
|
+
#
|
414
|
+
def add_doserate(value, item)
|
415
|
+
if !@current_doserate || value != @current_doserate
|
416
|
+
@current_doserate = value
|
417
|
+
DICOM::Element.new('300A,0115', value, :parent => item)
|
418
|
+
end
|
419
|
+
end
|
420
|
+
|
421
|
+
# Adds a Nominal Beam Energy element to a Control Point Item.
|
422
|
+
# Note that the element is only added if there is no 'current' attribute
|
423
|
+
# defined, or the given value is different form the current attribute.
|
424
|
+
#
|
425
|
+
# @param [String, NilClass] value the energy attribute
|
426
|
+
# @param [DICOM::Item] item the DICOM control point item in which to create an element
|
427
|
+
#
|
428
|
+
def add_energy(value, item)
|
429
|
+
if !@current_energy || value != @current_energy
|
430
|
+
@current_energy = value
|
431
|
+
DICOM::Element.new('300A,0114', "#{value.to_f}", :parent => item)
|
432
|
+
end
|
433
|
+
end
|
434
|
+
|
435
|
+
# Adds an Isosenter element to a Control Point Item.
|
436
|
+
# Note that the element is only added if there is a Site Setup record present,
|
437
|
+
# and it contains a real (non-empty) value. Also, the element is only added if there
|
438
|
+
# is no 'current' attribute defined, or the given value is different form the current attribute.
|
439
|
+
#
|
440
|
+
# @param [SiteSetup, NilClass] site_setup the associated site setup record
|
441
|
+
# @param [DICOM::Item] item the DICOM control point item in which to create an element
|
442
|
+
#
|
443
|
+
def add_isosenter(site_setup, item)
|
444
|
+
if site_setup
|
445
|
+
# Create an element if the value is new or unique:
|
446
|
+
if !@current_isosenter
|
447
|
+
iso = "#{(site_setup.iso_pos_x.to_f * 10).round(2)}\\#{(site_setup.iso_pos_y.to_f * 10).round(2)}\\#{(site_setup.iso_pos_z.to_f * 10).round(2)}"
|
448
|
+
if iso != @current_isosenter
|
449
|
+
@current_isosenter = iso
|
450
|
+
DICOM::Element.new('300A,012C', iso, :parent => item)
|
451
|
+
end
|
452
|
+
end
|
453
|
+
else
|
454
|
+
# Log a warning if this is the first control point:
|
455
|
+
unless @current_isosenter
|
456
|
+
logger.warn("No Site Setup record exists for this plan. Unable to provide an isosenter position.")
|
457
|
+
end
|
458
|
+
end
|
459
|
+
end
|
460
|
+
|
461
|
+
# Adds a Source to Surface Distance element to a Control Point Item.
|
462
|
+
# Note that the element is only added if the SSD attribute contains
|
463
|
+
# real (non-empty) value.
|
464
|
+
#
|
465
|
+
# @param [String, NilClass] value the SSD attribute
|
466
|
+
# @param [DICOM::Item] item the DICOM control point item in which to create an element
|
467
|
+
#
|
468
|
+
def add_ssd(value, item)
|
469
|
+
DICOM::Element.new('300A,0130', "#{value.to_f * 10}", :parent => item) if value && !value.empty?
|
470
|
+
end
|
471
|
+
|
472
|
+
# Creates a control point item in the given control point sequence, based
|
473
|
+
# on an RTP control point record.
|
474
|
+
#
|
475
|
+
# @param [ControlPoint] cp the RTP ControlPoint record to convert
|
476
|
+
# @param [DICOM::Sequence] sequence the DICOM parent sequence of the item to be created
|
477
|
+
# @param [Hash] options the options to use for creating the control point
|
478
|
+
# @option options [Boolean] :dose_ref if set, a Referenced Dose Reference sequence will be included in the generated control point item
|
479
|
+
# @return [DICOM::Item] the constructed control point DICOM item
|
480
|
+
#
|
481
|
+
def create_control_point(cp, sequence, options={})
|
482
|
+
cp_item = DICOM::Item.new(:parent => sequence)
|
483
|
+
# Some CP attributes will always be written (CP index, BLD positions & Cumulative meterset weight).
|
484
|
+
# The other attributes are only written if they are different from the previous control point.
|
485
|
+
# Control Point Index:
|
486
|
+
DICOM::Element.new('300A,0112', "#{cp.index}", :parent => cp_item)
|
487
|
+
# Beam Limiting Device Position Sequence:
|
488
|
+
create_beam_limiting_device_positions(cp_item, cp, options)
|
489
|
+
# Source to Surface Distance:
|
490
|
+
add_ssd(cp.ssd, cp_item)
|
491
|
+
# Cumulative Meterset Weight:
|
492
|
+
DICOM::Element.new('300A,0134', cp.monitor_units.to_f, :parent => cp_item)
|
493
|
+
# Referenced Dose Reference Sequence:
|
494
|
+
create_referenced_dose_reference(cp_item) if options[:dose_ref]
|
495
|
+
# Attributes that are only added if they carry an updated value:
|
496
|
+
# Nominal Beam Energy:
|
497
|
+
add_energy(cp.energy, cp_item)
|
498
|
+
# Dose Rate Set:
|
499
|
+
add_doserate(cp.doserate, cp_item)
|
500
|
+
# Gantry Angle & Rotation Direction:
|
501
|
+
add_angle(cp_item, '300A,011E', '300A,011F', cp.gantry_angle, cp.gantry_dir, :current_gantry)
|
502
|
+
# Beam Limiting Device Angle & Rotation Direction:
|
503
|
+
add_angle(cp_item, '300A,0120', '300A,0121', cp.collimator_angle, cp.collimator_dir, :current_collimator)
|
504
|
+
# Patient Support Angle & Rotation Direction:
|
505
|
+
add_angle(cp_item, '300A,0122', '300A,0123', cp.couch_pedestal, cp.couch_ped_dir, :current_couch_pedestal)
|
506
|
+
# Table Top Eccentric Angle & Rotation Direction:
|
507
|
+
add_angle(cp_item, '300A,0125', '300A,0126', cp.couch_angle, cp.couch_dir, :current_couch_angle)
|
508
|
+
# Table Top Vertical Position:
|
509
|
+
add_couch_position(cp_item, '300A,0128', cp.couch_vertical, :current_couch_vertical)
|
510
|
+
# Table Top Longitudinal Position:
|
511
|
+
add_couch_position(cp_item, '300A,0129', cp.couch_longitudinal, :current_couch_longitudinal)
|
512
|
+
# Table Top Lateral Position:
|
513
|
+
add_couch_position(cp_item, '300A,012A', cp.couch_lateral, :current_couch_lateral)
|
514
|
+
# Isocenter Position (x\y\z):
|
515
|
+
add_isosenter(cp.parent.parent.site_setup, cp_item)
|
516
|
+
cp_item
|
517
|
+
end
|
518
|
+
|
519
|
+
# Creates a beam limiting device sequence in the given DICOM object.
|
520
|
+
#
|
521
|
+
# @param [DICOM::Item] beam_item the DICOM beam item in which to insert the sequence
|
522
|
+
# @param [Field] field the RTP field to fetch device parameters from
|
523
|
+
# @return [DICOM::Sequence] the constructed beam limiting device sequence
|
524
|
+
#
|
525
|
+
def create_beam_limiting_devices(beam_item, field)
|
526
|
+
bl_seq = DICOM::Sequence.new('300A,00B6', :parent => beam_item)
|
527
|
+
# The ASYMX item ('backup jaws') doesn't exist on all models:
|
528
|
+
if ['SYM', 'ASY'].include?(field.field_x_mode.upcase)
|
529
|
+
bl_item_x = DICOM::Item.new(:parent => bl_seq)
|
530
|
+
DICOM::Element.new('300A,00B8', "ASYMX", :parent => bl_item_x)
|
531
|
+
DICOM::Element.new('300A,00BC', "1", :parent => bl_item_x)
|
532
|
+
end
|
533
|
+
# The ASYMY item is always created:
|
534
|
+
bl_item_y = DICOM::Item.new(:parent => bl_seq)
|
535
|
+
# RT Beam Limiting Device Type:
|
536
|
+
DICOM::Element.new('300A,00B8', "ASYMY", :parent => bl_item_y)
|
537
|
+
# Number of Leaf/Jaw Pairs:
|
538
|
+
DICOM::Element.new('300A,00BC', "1", :parent => bl_item_y)
|
539
|
+
# MLCX item is only created if leaves are defined:
|
540
|
+
# (NB: The RTP file doesn't specify leaf position boundaries, so we
|
541
|
+
# have to set these based on a set of known MLC types, their number
|
542
|
+
# of leaves, and their leaf boundary positions.)
|
543
|
+
if field.control_points.length > 0
|
544
|
+
bl_item_mlcx = DICOM::Item.new(:parent => bl_seq)
|
545
|
+
DICOM::Element.new('300A,00B8', "MLCX", :parent => bl_item_mlcx)
|
546
|
+
num_leaves = field.control_points.first.mlc_leaves.to_i
|
547
|
+
DICOM::Element.new('300A,00BC', num_leaves.to_s, :parent => bl_item_mlcx)
|
548
|
+
DICOM::Element.new('300A,00BE', "#{RTP.leaf_boundaries(num_leaves).join("\\")}", :parent => bl_item_mlcx)
|
549
|
+
end
|
550
|
+
bl_seq
|
551
|
+
end
|
552
|
+
|
553
|
+
# Creates a beam limiting device positions sequence in the given DICOM object.
|
554
|
+
#
|
555
|
+
# @param [DICOM::Item] cp_item the DICOM control point item in which to insert the sequence
|
556
|
+
# @param [ControlPoint] cp the RTP control point to fetch device parameters from
|
557
|
+
# @return [DICOM::Sequence] the constructed beam limiting device positions sequence
|
558
|
+
#
|
559
|
+
def create_beam_limiting_device_positions(cp_item, cp, options={})
|
560
|
+
dp_seq = DICOM::Sequence.new('300A,011A', :parent => cp_item)
|
561
|
+
# The ASYMX item ('backup jaws') doesn't exist on all models:
|
562
|
+
if ['SYM', 'ASY'].include?(cp.parent.field_x_mode.upcase)
|
563
|
+
dp_item_x = create_asym_item(cp, dp_seq, axis=:x, options)
|
564
|
+
end
|
565
|
+
# Always create one ASYMY item:
|
566
|
+
dp_item_y = create_asym_item(cp, dp_seq, axis=:y, options)
|
567
|
+
# MLCX:
|
568
|
+
dp_item_mlcx = DICOM::Item.new(:parent => dp_seq)
|
569
|
+
# RT Beam Limiting Device Type:
|
570
|
+
DICOM::Element.new('300A,00B8', "MLCX", :parent => dp_item_mlcx)
|
571
|
+
# Leaf/Jaw Positions:
|
572
|
+
DICOM::Element.new('300A,011C', cp.dcm_mlc_positions(options[:scale]), :parent => dp_item_mlcx)
|
573
|
+
dp_seq
|
574
|
+
end
|
575
|
+
|
576
|
+
# Creates an ASYMX or ASYMY item.
|
577
|
+
#
|
578
|
+
# @param [ControlPoint] cp the RTP control point to fetch device parameters from
|
579
|
+
# @param [DICOM::Sequence] dcm_parent the DICOM sequence in which to insert the item
|
580
|
+
# @param [Symbol] axis the axis for the item (:x or :y)
|
581
|
+
# @return [DICOM::Item] the constructed ASYMX or ASYMY item
|
582
|
+
#
|
583
|
+
def create_asym_item(cp, dcm_parent, axis, options={})
|
584
|
+
val1 = cp.send("dcm_collimator_#{axis.to_s}1", options[:scale])
|
585
|
+
val2 = cp.send("dcm_collimator_#{axis.to_s}2", options[:scale])
|
586
|
+
item = DICOM::Item.new(:parent => dcm_parent)
|
587
|
+
# RT Beam Limiting Device Type:
|
588
|
+
DICOM::Element.new('300A,00B8', "ASYM#{axis.to_s.upcase}", :parent => item)
|
589
|
+
# Leaf/Jaw Positions:
|
590
|
+
DICOM::Element.new('300A,011C', "#{val1}\\#{val2}", :parent => item)
|
591
|
+
item
|
592
|
+
end
|
593
|
+
|
594
|
+
# Creates a beam limiting device positions sequence in the given DICOM object.
|
595
|
+
#
|
596
|
+
# @param [DICOM::Item] cp_item the DICOM control point item in which to insert the sequence
|
597
|
+
# @param [Field] field the RTP treatment field to fetch device parameters from
|
598
|
+
# @return [DICOM::Sequence] the constructed beam limiting device positions sequence
|
599
|
+
#
|
600
|
+
def create_beam_limiting_device_positions_from_field(cp_item, field, options={})
|
601
|
+
dp_seq = DICOM::Sequence.new('300A,011A', :parent => cp_item)
|
602
|
+
# ASYMX:
|
603
|
+
dp_item_x = DICOM::Item.new(:parent => dp_seq)
|
604
|
+
DICOM::Element.new('300A,00B8', "ASYMX", :parent => dp_item_x)
|
605
|
+
DICOM::Element.new('300A,011C', "#{field.dcm_collimator_x1}\\#{field.dcm_collimator_x2}", :parent => dp_item_x)
|
606
|
+
# ASYMY:
|
607
|
+
dp_item_y = DICOM::Item.new(:parent => dp_seq)
|
608
|
+
DICOM::Element.new('300A,00B8', "ASYMY", :parent => dp_item_y)
|
609
|
+
DICOM::Element.new('300A,011C', "#{field.dcm_collimator_y1}\\#{field.dcm_collimator_y2}", :parent => dp_item_y)
|
610
|
+
dp_seq
|
611
|
+
end
|
612
|
+
|
613
|
+
# Creates a dose reference sequence in the given DICOM object.
|
614
|
+
#
|
615
|
+
# @param [DICOM::DObject] dcm the DICOM object in which to insert the sequence
|
616
|
+
# @param [String] description the value to use for Dose Reference Description
|
617
|
+
# @return [DICOM::Sequence] the constructed dose reference sequence
|
618
|
+
#
|
619
|
+
def create_dose_reference(dcm, description)
|
620
|
+
dr_seq = DICOM::Sequence.new('300A,0010', :parent => dcm)
|
621
|
+
dr_item = DICOM::Item.new(:parent => dr_seq)
|
622
|
+
# Dose Reference Number:
|
623
|
+
DICOM::Element.new('300A,0012', '1', :parent => dr_item)
|
624
|
+
# Dose Reference Structure Type:
|
625
|
+
DICOM::Element.new('300A,0014', 'SITE', :parent => dr_item)
|
626
|
+
# Dose Reference Description:
|
627
|
+
DICOM::Element.new('300A,0016', description, :parent => dr_item)
|
628
|
+
# Dose Reference Type:
|
629
|
+
DICOM::Element.new('300A,0020', 'TARGET', :parent => dr_item)
|
630
|
+
dr_seq
|
631
|
+
end
|
632
|
+
|
633
|
+
# Creates a referenced dose reference sequence in the given DICOM object.
|
634
|
+
#
|
635
|
+
# @param [DICOM::Item] cp_item the DICOM item in which to insert the sequence
|
636
|
+
# @return [DICOM::Sequence] the constructed referenced dose reference sequence
|
637
|
+
#
|
638
|
+
def create_referenced_dose_reference(cp_item)
|
639
|
+
# Referenced Dose Reference Sequence:
|
640
|
+
rd_seq = DICOM::Sequence.new('300C,0050', :parent => cp_item)
|
641
|
+
rd_item = DICOM::Item.new(:parent => rd_seq)
|
642
|
+
# Cumulative Dose Reference Coeffecient:
|
643
|
+
DICOM::Element.new('300A,010C', '', :parent => rd_item)
|
644
|
+
# Referenced Dose Reference Number:
|
645
|
+
DICOM::Element.new('300C,0051', '1', :parent => rd_item)
|
646
|
+
rd_seq
|
647
|
+
end
|
648
|
+
|
649
|
+
# Resets the types of control point attributes that are only written to the
|
650
|
+
# first control point item, and for following control point items only when
|
651
|
+
# they are different from the 'current' value. When a new field is reached,
|
652
|
+
# it is essential to reset these attributes, or else we could risk to start
|
653
|
+
# the field with a control point with missing attributes, if one of its first
|
654
|
+
# attributes is equal to the last attribute of the previous field.
|
655
|
+
#
|
656
|
+
def reset_cp_current_attributes
|
657
|
+
@current_gantry = nil
|
658
|
+
@current_collimator = nil
|
659
|
+
@current_couch_pedestal = nil
|
660
|
+
@current_couch_angle = nil
|
661
|
+
@current_couch_vertical = nil
|
662
|
+
@current_couch_longitudinal = nil
|
663
|
+
@current_couch_lateral = nil
|
664
|
+
@current_isosenter = nil
|
665
|
+
end
|
666
|
+
|
667
|
+
end
|
668
|
+
|
657
669
|
end
|