Skip to content

Commit

Permalink
Add fieldparams tests
Browse files Browse the repository at this point in the history
  • Loading branch information
alanphys committed Dec 17, 2020
1 parent 2538743 commit ab75e6f
Show file tree
Hide file tree
Showing 3 changed files with 249 additions and 13 deletions.
2 changes: 1 addition & 1 deletion pylinac/core/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ def distance_to_dose(self, x: int=50, norm='max grounded', interpolate=True) ->
if norm == 'cax':
ylen = len(ydata)
if ylen % 2 == 0: # ylen is even and central detectors straddle CAX
cax = (ydata[ylen/2] + ydata[(ylen - 1)/2])/2.0
cax = (ydata[int(ylen/2)] + ydata[int(ylen/2) - 1])/2.0
else: # ylen is odd and we have a central detector
cax = ydata[int((ylen - 1)/2)]
ymax = ydata.max()
Expand Down
34 changes: 22 additions & 12 deletions pylinac/fieldparams.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import io
import os.path as osp
from typing import Union
from typing import Union, Optional

import matplotlib.pyplot as plt
import numpy as np
Expand Down Expand Up @@ -38,7 +38,8 @@ def right_edge_50(profile: SingleProfile, *args):


def field_size_50(profile: SingleProfile, *args):
"""Return the field size at 50% of max dose"""
"""Return the field size at 50% of max dose. Not affected by the normalisation mode.
Included for testing purposes"""
return profile.fwxm(50)/profile.dpmm


Expand All @@ -48,7 +49,8 @@ def field_size_edge_50(profile: SingleProfile, *args):


def field_center_fwhm(profile: SingleProfile, *args):
"""Field center as given by the center of the profile FWHM"""
"""Field center as given by the center of the profile FWHM. Not affected by the normalisation mode.
Included for testing purposes"""
field_center = (profile.fwxm_center(50, interpolate)[0] - profile.profile_center)/profile.dpmm
return field_center

Expand All @@ -72,7 +74,7 @@ def penumbra_right_80_20(profile: SingleProfile, *args):
return right_penum


def flatness_dose_difference(profile: SingleProfile, ifa:float=0.8):
def flatness_dose_difference(profile: SingleProfile, ifa: float=0.8):
"""The Varian specification for calculating flatness"""
try:
dmax = profile.field_calculation(field_width=ifa, calculation='max')
Expand All @@ -83,7 +85,7 @@ def flatness_dose_difference(profile: SingleProfile, ifa:float=0.8):
return flatness


def flatness_dose_ratio(profile: SingleProfile, ifa:float=0.8):
def flatness_dose_ratio(profile: SingleProfile, ifa: float=0.8):
"""The Elekta specification for calculating flatness"""
try:
dmax = profile.field_calculation(field_width=ifa, calculation='max')
Expand All @@ -94,8 +96,9 @@ def flatness_dose_ratio(profile: SingleProfile, ifa:float=0.8):
return flatness


def symmetry_point_difference(profile: SingleProfile, ifa:float=0.8):
"""Calculation of symmetry by way of point difference equidistant from the CAX"""
def symmetry_point_difference(profile: SingleProfile, ifa: float=0.8):
"""Calculation of symmetry by way of point difference equidistant from the CAX. Field calculation is
automatically centred."""
values = profile.field_values(field_width=ifa)
_, cax_val = profile.fwxm_center()
sym_array = []
Expand All @@ -106,8 +109,8 @@ def symmetry_point_difference(profile: SingleProfile, ifa:float=0.8):
return symmetry


def symmetry_pdq_iec(profile: SingleProfile, ifa:float = 0.8):
"""Symmetry calculation by way of PDQ IEC"""
def symmetry_pdq_iec(profile: SingleProfile, ifa: float = 0.8):
"""Symmetry calculation by way of PDQ IEC. Field calculation is automatically centred"""
values = profile.field_values(field_width=ifa)
max_val = 0
for lt_pt, rt_pt in zip(values, values[::-1]):
Expand All @@ -132,8 +135,10 @@ def symmetry_pdq_iec(profile: SingleProfile, ifa:float = 0.8):
'Field center FWHM': field_center_fwhm,
'Penumbra 80-20% left': penumbra_left_80_20,
'Penumbra 80-20% right': penumbra_right_80_20,
'Flatness': flatness_dose_difference,
'Symmetry': symmetry_point_difference,
'Flatness diff': flatness_dose_difference,
'Flatness ratio': flatness_dose_ratio,
'Symmetry diff': symmetry_point_difference,
'Symmetry ratio': symmetry_pdq_iec
}

VARIAN = {
Expand Down Expand Up @@ -225,14 +230,19 @@ class FieldParams:
The position ratio used for analysis for vertical and horizontal.
"""

def __init__(self, path: str):
def __init__(self, path: str, filter: Optional[int]=None):
"""
Parameters
----------
path : str
The path to the image.
filter : None or int
If None, no filter is applied. If an int, a median filter of size n pixels is applied. Generally, a good idea.
Default is None for backwards compatibility.
"""
self.image = image.load(path)
if filter:
self.image.filter(size=filter)
self.vert_profile = SingleProfile(np.empty(0))
self.horiz_profile = SingleProfile(np.empty(0))
self.infield_area: float = 0.8
Expand Down
226 changes: 226 additions & 0 deletions tests_basic/test_fieldparams.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
"""Tests for the flatsym module of pylinac."""
from unittest import TestCase
import os.path as osp
from functools import partial
import numpy as np

from pylinac.core.exceptions import NotAnalyzed
from pylinac.core.io import retrieve_demo_file
from pylinac.core.profile import SingleProfile
from pylinac.fieldparams import FieldParams
import pylinac.fieldparams as fp

from tests_basic.utils import has_www_connection, LocationMixin, save_file

TEST_DIR = osp.join(osp.dirname(__file__), 'test_files', 'Flatness & Symmetry')


def create_instance():
fs = FieldParams.from_demo_image()
fs.analyze('varian')
return fs


class CalcParamTests(TestCase):
"""Values verified from BeamScheme file 2-Sep-2011-A.txt X profile"""
profile = SingleProfile(np.array([
2.18,2.68,3.27,4.36,5.83,9.12,15.44,63.73,94.96,97.26,98.38,98.78,98.86,99,98.89,98.98,98.8,98.95,98.9,98.52,
98.05,97.31,96.26,95.38,94.59,94.53,94.47,94.46,94.49,94.57,94.7,95.18,95.51,96.51,97.32,97.82,97.95,97.99,
97.98,98.2,98.33,98.31,98.33,98.1,97.7,95.9,92.2,36.68,12.18,8.02,4.92,3.97,3.01]))
profile.dpmm = 0.2
delta = 0.2

def test_left_edge_50(self):
fp.interpolate = True
fp.norm = 'cax'
self.assertAlmostEqual(fp.left_edge_50(self.profile), 96.71, delta=self.delta)

def test_right_edge_50(self):
fp.interpolate = True
fp.norm = 'cax'
self.assertAlmostEqual(fp.right_edge_50(self.profile), 104.04, delta=self.delta)

def test_field_size_edge_50(self):
fp.interpolate = True
fp.norm = 'cax'
self.assertAlmostEqual(fp.field_size_edge_50(self.profile), 200.75, delta=self.delta)

def test_field_center_edge_50(self):
fp.interpolate = True
fp.norm = 'cax'
self.assertAlmostEqual(fp.field_center_edge_50(self.profile), 3.67, delta=self.delta)

def test_penumbra_left_80_20(self):
fp.interpolate = True
fp.norm = 'cax'
self.assertAlmostEqual(fp.penumbra_left_80_20(self.profile), 6.54, delta=self.delta)

def test_penumbra_right_80_20(self):
fp.interpolate = True
fp.norm = 'cax'
self.assertAlmostEqual(fp.penumbra_right_80_20(self.profile), 7.13, delta=self.delta)

def test_flatness_dose_difference(self):
self.assertAlmostEqual(fp.flatness_dose_difference(self.profile, 0.8), 2.35, delta=self.delta)

def test_flatness_dose_ratio(self):
self.assertAlmostEqual(fp.flatness_dose_ratio(self.profile, 0.8), 104.80, delta=self.delta)

def test_symmetry_point_difference(self):
self.assertAlmostEqual(fp.symmetry_point_difference(self.profile, 0.8), 1.91, delta=self.delta)

def test_symmetry_pdq_iec(self):
self.assertAlmostEqual(fp.symmetry_pdq_iec(self.profile, 0.8), 101.88, delta=self.delta)


class FieldParamTests(TestCase):

def test_demo_is_reachable(self):
if has_www_connection():
file = retrieve_demo_file(url='flatsym_demo.dcm')
self.assertTrue(osp.isfile(file))

def test_demo_loads_properly(self):
"""Loading the demo shouldn't raise an error"""
FieldParams.from_demo_image() # shouldn't raise

def test_demo_runs(self):
FieldParams.run_demo()

def test_profile_limits(self):
"""Extreme profile limits should not raise an error"""
fs = FieldParams.from_demo_image()
fs.analyze(protocol="varian", vert_position=0.5, horiz_position=0.5, vert_width=0, horiz_width=0)
fs.analyze(protocol="varian", vert_position=0.0, horiz_position=0.0, vert_width=1, horiz_width=1)
fs.analyze(protocol="varian", vert_position=1.0, horiz_position=1.0, vert_width=1, horiz_width=1)

def test_analyze_sets_analyzed_flag(self):
fs = create_instance()
self.assertTrue(fs._is_analyzed)

# profile.find peak does not raise error if peak is inverted.
# def test_analyze_fails_when_incorrectly_inverted(self):
# fs = create_instance()
# with self.assertRaises(ValueError):
# fs.analyze('varian', invert=True)
# fs = create_instance()
# with self.assertRaises(ValueError):
# fs.analyze('elekta', invert=True)

def test_protocols(self):
fs = FieldParams.from_demo_image()
analyze = partial(fs.analyze, protocol='varian')
for method in ('all', 'varian', 'elekta', 'siemens', 'vom80', 'iec9076'):
analyze(protocol=method) # shouldn't raise

def test_results(self):
fs = create_instance()
self.assertIsInstance(fs.results(), str)

def test_results_fails_if_not_analyzed(self):
fs = FieldParams.from_demo_image()
with self.assertRaises(NotAnalyzed):
fs.results()

def test_plot_works(self):
fs = create_instance()
fs.plot_analyzed_image()
fs.plot_analyzed_image(show=True)

def test_plot_fails_if_not_analysed(self):
fs = FieldParams.from_demo_image()
with self.assertRaises(NotAnalyzed):
fs.plot_analyzed_image()

def test_pdf_gets_generated(self):
fs = create_instance()
save_file(fs.publish_pdf)

def test_pdf_fails_if_not_analyzed(self):
fs = FieldParams.from_demo_image()
with self.assertRaises(NotAnalyzed):
fs.publish_pdf('dummy.pdf')


class FieldParamsBase(LocationMixin):
dir_location = TEST_DIR
sym_tolerance = 0.05
flat_tolerance = 0.05
apply_smoothing = None
vert_symmetry = 0
vert_flatness = 0
horiz_symmetry = 0
horiz_flatness = 0
protocol = 'varian'
vert_position = 0.5
horiz_position = 0.5
vert_width = 0
horiz_width = 0
print_results = False

@classmethod
def setUpClass(cls):
fp.norm = 'max grounded'
cls.fs = FieldParams(cls.get_filename(), filter=cls.apply_smoothing)
cls.fs.analyze(protocol=cls.protocol, vert_position=cls.vert_position, horiz_position=cls.horiz_position,
vert_width=cls.vert_width, horiz_width=cls.horiz_width)
if cls.print_results:
print(cls.fs.results())

def test_vert_symmetry(self):
self.assertAlmostEqual(self.fs.parameters['vertical']['Symmetry'], self.vert_symmetry, delta=self.sym_tolerance)

def test_horiz_symmetry(self):
self.assertAlmostEqual(self.fs.parameters['horizontal']['Symmetry'], self.horiz_symmetry, delta=self.sym_tolerance)

def test_vert_flatness(self):
self.assertAlmostEqual(self.fs.parameters['vertical']['Flatness'], self.vert_flatness, delta=self.flat_tolerance)

def test_horiz_flatness(self):
self.assertAlmostEqual(self.fs.parameters['horizontal']['Flatness'], self.horiz_flatness, delta=self.flat_tolerance)


class FieldParamsDemo(FieldParamsBase, TestCase):
fp.norm = 'max grounded'
vert_flatness = 1.93
vert_symmetry = 2.46
horiz_flatness = 1.86
horiz_symmetry = 2.99

@classmethod
def get_filename(cls):
return retrieve_demo_file(url='flatsym_demo.dcm')


class FlatSym6X(FieldParamsBase, TestCase):
file_path = ['6x auto bulb 2.dcm']
apply_smoothing = 5
# independently verified
vert_flatness = 1.5
vert_symmetry = 0.4
horiz_flatness = 1.4
horiz_symmetry = 0.5


class FlatSym18X(FieldParamsBase, TestCase):
file_path = ['18x auto bulb2.dcm']
# independently verified
apply_smoothing = 5
vert_flatness = 1.4
vert_symmetry = 0.5
horiz_flatness = 1.5
horiz_symmetry = 0.5


class FieldParamsWideDemo(FieldParamsBase, TestCase):
vert_width = 0.025
horiz_width = 0.025
vert_flatness = 1.84
vert_symmetry = 2.47
horiz_flatness = 1.84
horiz_symmetry = 2.96

@classmethod
def get_filename(cls):
return retrieve_demo_file(url='flatsym_demo.dcm')

0 comments on commit ab75e6f

Please sign in to comment.