"""
Define :class:`Prescription` and methods for parsing prescription data
from python objects as well as JSON- or YAML-formatted files.
Parsing methods expect the following formats.
YAML::
- name : PTV
label : 1
is_target: Yes
dose : 35.
constraints:
- "D90 >= 32.3Gy"
- "D1 <= 1.1rx"
- name : OAR1
label : 2
is_target: No
dose :
constraints:
- "D95 <= 20Gy"
- "V30 Gy <= 20%"
Python :obj:`list` of :obj:`dict` (JSON approximately the same)::
[{
"name" : "PTV",
"label" : 1,
"is_target" : True,
"dose" : 35.,
"constraints" : ["D1 <= 1.1rx", "D90 >= 32.3Gy"]
}, {
"name" : "OAR1",
"label" : 2,
"is_target" : False,
"dose" : None,
"constraints" : ["D95 <= 20Gy"]
}]
JSON verus Python syntax differences:
- ``true``/``false`` instead of ``True``/``False``
- ``null`` instead of ``None``
"""
"""
Copyright 2016 Baris Ungun, Anqi Fu
This file is part of CONRAD.
CONRAD is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
CONRAD is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with CONRAD. If not, see <http://www.gnu.org/licenses/>.
"""
from conrad.compat import *
import os
import json, yaml
import traceback
from conrad.physics.units import percent, Gy, cGy, cm3, Gray, DeliveredDose
from conrad.physics.string import *
from conrad.medicine.structure import Structure
from conrad.medicine.anatomy import Anatomy
from conrad.medicine.dose import D, ConstraintList
[docs]def v_strip(input_string):
"""
Strip 'v', 'V' and 'to' from input string.
Preprocessing step for handling of string constraints of type
"V20 Gy < 30 %" or "20 Gy to < 30%".
"""
return input_string.replace('to', '').replace('V', '').replace('v', '')
[docs]def d_strip(input_string):
"""
Strip 'd', and 'D' from input string.
Preprocessing step for handling of string constraints of type
"D70 < 20 Gy".
"""
return input_string.replace('D', '').replace('d', '')
[docs]def eval_constraint(string_constraint, rx_dose=None):
"""
Parse input string to form a new :class:`Constraint` instance.
This method handles the following input cases.
Absolute dose constraints:
- "min > x Gy"
- variants: "Min", "min"
- meaning: minimum dose greater than x Gy
- "mean < x Gy" ("mean > x Gy")
- variants: "Mean, mean"
- meaning: mean dose less than (more than) than x Gy
- "max < x Gy"
- variants: "Max", "max"
- meaning: maximum dose less than x Gy
- "D __ < x Gy" ("D __ > x Gy")
- variants: "D __%", "d __%", "D __", "d __"
- meaning: dose to __ percent of volume less than (greater than) x Gy
- "V __ Gy < p %" ("V __ Gy > p %")
- variants: "V __", "v __", "__ Gy to", "__ to"
- meaning: no more than (at least) __ Gy to p percent of volume.
Relative dose constraints:
- "V __ %rx < p %" ("V __ %rx > p %")
- variants: "V __%", "v __%", "V __", "v __"
- meaning: at most (at least) p percent of structure receives __ percent of rx dose.
- "D __ < {frac} rx", "D __ > {frac} rx"
- variants: "D __%", "d __%", "D __", "d __"
- meaning: dose to __ percent of volume less than (greater than) frac * rx
Absolute volume constraints:
- "V __ Gy > x cm3" ("V __ Gy < x cm3"), "V __ rx > x cm3" ("V __ rx < x cm3")
- variants: "cc" vs. "cm3" vs. "cm^3"; "V __ _" vs. "v __ _"
- error: convert to relative volume terms
Arguments:
string_constraint (:obj:`str`): Parsable string representation
of dose constraint.
rx_dose (:class:`DeliveredDose`, optional): Prescribed dose
level to associate with dose constraint, required for
relative dose constraints.
Returns:
:class:`Constraint`: Dose constraint specified by input.
Raises:
TypeError: If ``rx_dose`` not of type :class:`DeliveredDose`.
ValueError: If input string specifies an absolute volume
constraint, or if input is not well-formed (e.g., a dose
quantity appears on LHS and RHS of inequality).
"""
string_constraint = str(string_constraint)
if not isinstance(rx_dose, (type(None), DeliveredDose)):
raise TypeError(
'if provided, argument "rx_dose" must be of type {}, '
'e.g., {} or {}'
''.format(DeliveredDose, type(Gy), type(cGy)))
if volume_unit_from_string(string_constraint) is not None:
raise ValueError(
'Detected dose volume constraint with absolute volume '
'units. Convert to percentage.\n(input = {})'
''.format(string_constraint))
leq = '<' in string_constraint
if leq:
left, right = string_constraint.replace('=', '').split('<')
else:
left, right = string_constraint.replace('=', '').split('>')
rdose = dose_unit_from_string(right) is not None
ldose = dose_unit_from_string(left) is not None
if rdose and ldose:
raise ValueError(
'Dose constraint cannot have a dose value on both '
'sides of inequality.\n(input = {})'
''.format(string_constraint))
if rdose:
tokens = ['mean', 'Mean', 'min', 'Min', 'max', 'Max', 'D', 'd']
if not any(listmap(lambda t : t in left, tokens)):
raise ValueError(
'If dose specified on right side of inequality, '
'left side must contain one of the following '
'strings: \n.\ninput={}'
''.format(tokens, string_constraint))
relative = not rdose and not ldose
relative &= rx_dose is not None
if relative and (rdose or ldose):
raise ValueError(
'Dose constraint mixes relative and absolute volume '
'constraint syntax. \n(input = {})'
''.format(string_constraint))
if not (rdose or ldose or relative):
raise ValueError(
'Dose constraint dose not specify a dose level in Gy '
'or cGy, and no prescription\ndose was provided '
'(argument "rx_dose") for parsing a relative dose '
'constraint. \n(input = {})'
''.format(string_constraint))
try:
# cases:
# - "min > x Gy"
# - "mean < x Gy"
# - "max < x Gy"
# - "D __% < x Gy"
# - "D __% > x Gy"
if rdose:
#-----------------------------------------------------#
# constraint in form "{LHS} <> {x} Gy"
#
# conversion to canonical form:
# (none required)
#-----------------------------------------------------#
# parse dose
dose = dose_from_string(right)
# parse threshold (min, mean, max or percentile)
if 'mean' in left or 'Mean' in left:
threshold = 'mean'
elif 'min' in left or 'Min' in left:
threshold = 'min'
elif 'max' in left or 'Max' in left:
threshold = 'max'
else:
threshold = percent_from_string(d_strip(left))
# cases:
# - "V __ Gy < p %" ( == "x Gy to < p %")
# - "V __ Gy > p %" ( == "x Gy to > p %")
elif ldose:
#-----------------------------------------------------#
# constraint in form "V{x} Gy <> {p} %"
#
# conversion to canonical form:
# {x} Gy < {p} % ---> D{100 - p} < {x} Gy
# {x} Gy > {p} % ---> D{p} > {x} Gy
#-----------------------------------------------------#
# parse dose
dose = dose_from_string(v_strip(left))
# parse percentile
threshold = percent_from_string(right)
# <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
# VOLUME AT X GY < P % of STRUCTURE
#
# ~equals~
#
# X Gy to < P% of structure
#
# ~equivalent to~
#
# D(100 - P) < X Gy
# <<<<<<<<<<<<<<<<>>>>>>>>>>>>>>>>
# VOLUME AT X GY > P% of STRUCTURE
#
# ~equals~
#
# X Gy to > P% of structure
#
# ~equivalent to~
#
# D(P) > X Gy
# >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
if leq:
threshold.value = 100 - threshold.value
# cases:
# - "V __% < p %"
# - "V __% > p %"
# - "D __% < {frac} rx"
# - "D __% > {frac} rx"
else:
#-----------------------------------------------------#
# constraint in form "V __% <> p%"
#
# conversion to canonical form:
# V{x}% < {p} % ---> D{100 - p} < {x/100} * {rx_dose} Gy
# V{x}% > {p} % ---> D{p} > {x/100} * {rx_dose} Gy
#-----------------------------------------------------#
if not 'rx' in right:
# parse dose
reldose = fraction_or_percent_from_string(
v_strip(left.replace('rx', '')))
dose = reldose * rx_dose
# parse percentile
threshold = percent_from_string(right)
if leq:
threshold.value = 100 - threshold.value
#-----------------------------------------------------#
# constraint in form "D{p}% <> {frac} rx" OR
# "D{p}% <> {100 * frac}% rx"
#
# conversion to canonical form:
# D{p}% < {frac} rx ---> D{p} < {frac} * {rx_dose} Gy
# D{p}% >{frac} rx ---> D{p} > {frac} * {rx_dose} Gy
#-----------------------------------------------------#
else:
# parse dose
dose = fraction_or_percent_from_string(
right.replace('rx', '')) * rx_dose
# parse percentile
threshold = percent_from_string(d_strip(left))
if leq:
return D(threshold) <= dose
else:
return D(threshold) >= dose
except:
print(str(
'Unknown parsing error. Input = {}'.format(string_constraint)))
raise
[docs]class Prescription(object):
"""
Class for specifying structures with dose targets and constraints.
Attributes:
constraint_dict (:obj:`dict`): Dictionary of
:class:`ConstraintList` objects, keyed by structure labels.
structure_dict (:obj:`dict`): Diciontionary of
:class:`Structure` objects, keyed by structure labels.
rx_list (:obj:`list`): List of dictionaries representation of
prescription.
"""
def __init__(self, prescription_data=None):
"""
Intialize empty or populated :class:`Prescription` instance.
Arguments:
prescription_data (optional): Data to parse as prescription.
If input is of type :class:`Prescription`, intializer
acts as a copy constructor.
"""
self.constraint_dict = {}
self.structure_dict = {}
self.rx_list = []
if isinstance(prescription_data, Prescription):
self.constraint_dict = prescription_data.constraint_dict
self.structure_dict = prescription_data.structure_dict
self.rx_list = prescription_data.rx_list
elif prescription_data:
self.digest(prescription_data)
[docs] def add_structure_to_dictionaries(self, structure):
"""
Add a new structure to internal representation of prescription.
Arguments:
structure (:class:`Structure`): Structure added to
:attr:`Prescription.structure_dict`. An corresponding,
empty constraint list is added to
:attr:`Prescription.constraint_dict`.
Returns:
None
Raises:
TypeError: If ``structure`` not a :class:`Structure`.
"""
if not isinstance(structure, Structure):
raise TypeError('argumet "Structure" must be of type {}'
''.format(Structure))
self.structure_dict[structure.label] = structure
self.constraint_dict[structure.label] = ConstraintList()
[docs] def digest(self, prescription_data):
"""
Populate :class:`Prescription`'s structures and dose constraints.
Specifically, for each entry in ``prescription_data``, construct
a :class:`Structure` to capture structure data (e.g., name,
label), as well as a corresponding but separate
:class:`ConstraintList` object to capture any dose constraints
specified for the structure.
Add each such structure to :attr:`Prescription.structure_dict`,
and each such constraint list to
:attr:`Prescription.constraint_dict`. Build or copy a "list of
dictionaries" representation of the prescription data, assign to
:attr:`Prescription.rx_list`.
Arguments:
prescription_data: Input to be parsed for structure and dose
constraint data. Accepted formats include :obj:`str`
specifying a valid path to a suitably-formatted JSON or
YAML file, or a suitably-formatted :obj:`list` of
:obj:`dict` objects.
Returns:
None
Raises:
TypeError: If input not of type :obj:`list` or a :obj:`str`
specfying a valid path to file that can be loaded with
the :meth:`json.load` or :meth:`yaml.safe_load` methods.
"""
err = None
data_valid = False
rx_list = []
# read prescription data from list
if isinstance(prescription_data, list):
rx_list = prescription_data
data_valid = True
# read presription data from file
if isinstance(prescription_data, str):
if os.path.exists(prescription_data):
try:
f = open(prescription_data)
if '.json' in prescription_data:
rx_list = json.load(f)
else:
rx_list = yaml.safe_load(f)
f.close
data_valid = True
except:
err = traceback.format_exc()
if not data_valid:
if err is not None:
print(err)
raise TypeError(
'input prescription_data expected to be a list or '
'the path to a valid JSON or YAML file.')
try:
for item in rx_list:
rx_dose = None
label = item['label']
name = item['name']
dose = 0 * Gy
is_target = bool(item['is_target'])
if is_target:
if isinstance(item['dose'], (float, int)):
rx_dose = dose = float(item['dose']) * Gy
else:
rx_dose = dose = dose_from_string(item['dose'])
s = Structure(label, name, is_target, dose=dose)
self.add_structure_to_dictionaries(s)
if 'constraints' in item:
if item['constraints'] is not None:
for string in item['constraints']:
self.constraint_dict[label] += eval_constraint(
string, rx_dose=rx_dose)
self.rx_list = rx_list
except:
print(str('Unknown error: prescription_data could not be '
'converted to conrad.Prescription() datatype.'))
raise
@property
def list(self):
""" List of structures in prescription """
return self.rx_list
@property
def dict(self):
""" Dictionary of structures in prescription, by label. """
return {structure.label: structure for structure in self.rx_list}
@property
def constraints_by_label(self):
"""
Dictionary of constraints in prescription, by structure label.
"""
return self.constraint_dict
[docs] def __str__(self):
"""
String of structures in prescription with attached constraints.
"""
return str(self.rx_list)
[docs] def report(self, anatomy):
"""
Reports whether ``anatomy`` fulfills all prescribed constraints.
Arguments:
anatomy (:class:`Antomy`): Container of structures to
compare against prescribed constraints.
Returns:
:obj:`dict`: Dictionary keyed by structure label, with data
on each dose constraint associated with that structure in
this :class:`Prescription`. Reported data includes the
constraint, whether it was satisfied, and the actual dose
achieved at the percentile/threshold specified by the
constraint.
Raises:
TypeError: If ``anatomy`` not an :class:`Anatomy`.
"""
if not isinstance(anatomy, Anatomy):
raise TypeError('argument "anatomy" must be of type{}'.format(
Anatomy))
rx_constraints = self.constraints_by_label
report = {}
for label, s in anatomy.structures.items():
sat = []
for constr in rx_constraints[label].itervalues():
status, dose_achieved = s.satisfies(constr)
sat.append({'constraint': constr, 'status': status,
'dose_achieved': dose_achieved})
report[label] = sat
return report
[docs] def report_string(self, anatomy):
"""
Reports whether ``anatomy`` fulfills all prescribed constraints.
Arguments:
anatomy (:class:`Anatomy`): Container of structures to
compare against prescribed constraints.
Returns:
:obj:`str`: Stringified version of output from
:attr:`Presription.report`.
"""
report = self.report(anatomy)
out = ''
for label, replist in report.items():
sname = structures[label].name
sname = '' if sname is None else ' ({})\n'.format(sname)
for item in replist:
out += str(
'{}\tachieved? {}\tdose at level: {}\n'.format(
str(item['constraint']), item['status'],
item['dose_achieved']))
return out