From 3b5749ac1ce0eaa43f86bb0c784ce45fc16934de Mon Sep 17 00:00:00 2001 From: James Kerns Date: Fri, 8 Nov 2024 09:16:51 -0600 Subject: [PATCH 01/11] Fix magnification doubling and add plot method --- docs/source/changelog.rst | 8 +++++++ docs/source/image_generator.rst | 26 +++++++++++++++++++--- pylinac/core/image_generator/layers.py | 16 ++++++++++--- pylinac/core/image_generator/simulators.py | 25 +++++++++++++++++++++ tests_basic/core/test_image_generator.py | 16 +++++++++---- 5 files changed, 81 insertions(+), 10 deletions(-) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 0c6a714e..1cf8227c 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -30,6 +30,14 @@ TRS-398 This change will affect absorbed dose TRS-398 calculations if you rely on the ``k_tp`` function. If you are using TRS-398, please verify that your results are still accurate. We apologize for this oversight. +Image Generator +^^^^^^^^^^^^^^^ + +* :bdg-warning:`Fixed` The image generator suffered from a double magnification error of field/cone size when the SID was not at 1000. + I.e. a field size of 100x100mm at 1500mm would be 1.5**2 = 2.25x instead of 1.5x (1500/1000). This has been fixed. +* * :bdg-success:`Feature` The ``Simulator`` class and its subclasses (AS500, AS1000, etc) have a new method: + ``plot``. It does what it says on the tin. + CT ^^ diff --git a/docs/source/image_generator.rst b/docs/source/image_generator.rst index a8c80364..3de83a4c 100644 --- a/docs/source/image_generator.rst +++ b/docs/source/image_generator.rst @@ -14,8 +14,6 @@ images. This module is different than other modules in that the goal here is non analysis routines here. What is here started as a testing concept for pylinac itself, but has uses for advanced users of pylinac who wish to build their own tools. -.. warning:: This feature is currently experimental and untested. - The module allows users to create a pipeline ala keras, where layers are added to an empty image. The user can add as many layers as they wish. @@ -53,7 +51,7 @@ Extending Layers & Simulators ----------------------------- This module is meant to be extensible. That's why the structures are defined so simply. To create a custom simulator, -inherit from ``Simulator`` and define the pixel size and shape. Note that generating DICOM does not come for free: +inherit from ``Simulator`` and define the pixel size and shape: .. code-block:: python @@ -87,6 +85,28 @@ To implement a custom layer, inherit from ``Layer`` and implement the ``apply`` as1200.add_layer(MyAwesomeLayer()) ... +Exporting Images to DICOM +------------------------- + +The ``Simulator`` class has two methods for generating DICOM. One returns a Dataset and another fully saves it out to a file. + +.. code-block:: python + + from pylinac.core.image_generator import AS1200Image + from pylinac.core.image_generator.layers import FilteredFieldLayer, GaussianFilterLayer + + as1200 = AS1200Image() + as1200.add_layer(FilteredFieldLayer(field_size_mm=(50, 50))) + as1200.add_layer(GaussianFilterLayer(sigma_mm=2)) + + # generate a pydicom Dataset + ds = as1200.as_dicom(gantry_angle=45) + # do something with that dataset as needed + ds.PatientID = "12345" + + # or save it out to a file + as1200.generate_dicom(file_out_name="my_AS1200.dcm", gantry_angle=45) + Examples -------- diff --git a/pylinac/core/image_generator/layers.py b/pylinac/core/image_generator/layers.py index 4f5b97c3..f8331b4e 100644 --- a/pylinac/core/image_generator/layers.py +++ b/pylinac/core/image_generator/layers.py @@ -62,7 +62,17 @@ class Layer(ABC): def apply( self, image: np.ndarray, pixel_size: float, mag_factor: float ) -> np.ndarray: - """Apply the layer. Takes a 2D array and pixel size value in and returns a modified array.""" + """Apply the layer. Takes a 2D array and pixel size value in and returns a modified array. + + Parameters + ---------- + image : np.ndarray + The image to modify. + pixel_size : float + The pixel size of the image AT SAD. + mag_factor : float + The magnification factor of the image. SID/SAD. E.g. 1.5 for 150 cm SID and 100 cm SAD. + """ pass @@ -103,7 +113,7 @@ def apply( def _create_perfect_field( self, image: np.ndarray, pixel_size: float, mag_factor: float ) -> (np.ndarray, ...): - cone_size_pix = ((self.cone_size_mm / 2) / pixel_size) * mag_factor**2 + cone_size_pix = mag_factor * (self.cone_size_mm / 2) / pixel_size # we rotate the point around the center of the image offset_pix_y, offset_pix_x = rotate_point( x=self.cax_offset_mm[0] * mag_factor / pixel_size, @@ -207,7 +217,7 @@ def _create_perfect_field( self, image: np.ndarray, pixel_size: float, mag_factor: float ) -> (np.ndarray, ...): field_size_pix = [ - even_round(f * mag_factor**2 / pixel_size) for f in self.field_size_mm + even_round(f * mag_factor / pixel_size) for f in self.field_size_mm ] cax_offset_pix_mag = [v * mag_factor / pixel_size for v in self.cax_offset_mm] field_center = [ diff --git a/pylinac/core/image_generator/simulators.py b/pylinac/core/image_generator/simulators.py index 4a250c12..2966e665 100644 --- a/pylinac/core/image_generator/simulators.py +++ b/pylinac/core/image_generator/simulators.py @@ -3,10 +3,12 @@ from abc import ABC import numpy as np +from plotly import graph_objects as go from pydicom.dataset import Dataset, FileMetaDataset from pydicom.uid import UID from ..array_utils import array_to_dicom +from ..plotly_utils import add_title from .layers import Layer @@ -72,6 +74,29 @@ def generate_dicom(self, file_out_name: str, *args, **kwargs) -> None: ds = self.as_dicom(*args, **kwargs) ds.save_as(file_out_name, write_like_original=False) + def plot(self, show: bool = True) -> go.Figure: + """Plot the simulated image.""" + fig = go.Figure() + fig.add_heatmap( + z=self.image, + colorscale="gray", + x0=-self.image.shape[1] / 2 * self.pixel_size, + dx=self.pixel_size, + y0=-self.image.shape[0] / 2 * self.pixel_size, + dy=self.pixel_size, + ) + fig.update_layout( + yaxis_constrain="domain", + xaxis_scaleanchor="y", + xaxis_constrain="domain", + xaxis_title="Crossplane (mm)", + yaxis_title="Inplane (mm)", + ) + add_title(fig, f"Simulated {self.__class__.__name__} @{self.sid}mm SID") + if show: + fig.show() + return fig + class AS500Image(Simulator): """Simulates an AS500 EPID image.""" diff --git a/tests_basic/core/test_image_generator.py b/tests_basic/core/test_image_generator.py index 56d68b6e..84daa625 100644 --- a/tests_basic/core/test_image_generator.py +++ b/tests_basic/core/test_image_generator.py @@ -78,15 +78,18 @@ def profiles_from_simulator( img = load(stream) y_pixel = int(round(simulator.shape[0] * y_position)) x_pixel = int(round(simulator.shape[1] * x_position)) + # The dpmm property is always scaled to SAD!!!! + # Thus, we do dpmm / mag_factor because we are taking the profile effectively at the SID + # and the dpmm at SID (vs SAD where we normally define it) is different if SID != SAD inplane_profile = SingleProfile( img[:, x_pixel].copy(), - dpmm=img.dpmm, + dpmm=img.dpmm / simulator.mag_factor, interpolation=interpolation, normalization_method=Normalization.NONE, ) cross_profile = SingleProfile( img[y_pixel, :].copy(), - dpmm=img.dpmm, + dpmm=img.dpmm / simulator.mag_factor, interpolation=interpolation, normalization_method=Normalization.NONE, ) @@ -450,8 +453,13 @@ def test_10mm_150sid(self): stream.seek(0) img = load(stream) img.invert() # we invert so the BB looks like a profile, not a dip - inplane_profile = SingleProfile(img[:, int(as1200.shape[1] / 2)], dpmm=img.dpmm) - cross_profile = SingleProfile(img[int(as1200.shape[0] / 2), :], dpmm=img.dpmm) + # correct for the dpmm via the mag factor because we are effectively at the SID (1500 per above) + inplane_profile = SingleProfile( + img[:, int(as1200.shape[1] / 2)], dpmm=img.dpmm / as1200.mag_factor + ) + cross_profile = SingleProfile( + img[int(as1200.shape[0] / 2), :], dpmm=img.dpmm / as1200.mag_factor + ) self.assertAlmostEqual( inplane_profile.fwxm_data()["width (exact) mm"], 15, delta=1 ) From 5d934d5924853442bd22d832ab12cbcb56033e52 Mon Sep 17 00:00:00 2001 From: James Kerns Date: Fri, 8 Nov 2024 09:36:52 -0600 Subject: [PATCH 02/11] fix formatting --- docs/source/changelog.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 1cf8227c..2302bf91 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -35,8 +35,8 @@ Image Generator * :bdg-warning:`Fixed` The image generator suffered from a double magnification error of field/cone size when the SID was not at 1000. I.e. a field size of 100x100mm at 1500mm would be 1.5**2 = 2.25x instead of 1.5x (1500/1000). This has been fixed. -* * :bdg-success:`Feature` The ``Simulator`` class and its subclasses (AS500, AS1000, etc) have a new method: - ``plot``. It does what it says on the tin. +* * :bdg-success:`Feature` The ``Simulator`` class and its subclasses (AS500, AS1000, etc) have a new method: ``plot``. + It does what it says on the tin. CT ^^ From 5f8bc71fddaa53326c12aef147000d4c4c41a04e Mon Sep 17 00:00:00 2001 From: James Kerns Date: Fri, 8 Nov 2024 10:16:41 -0600 Subject: [PATCH 03/11] typo --- docs/source/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 2302bf91..60559759 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -35,7 +35,7 @@ Image Generator * :bdg-warning:`Fixed` The image generator suffered from a double magnification error of field/cone size when the SID was not at 1000. I.e. a field size of 100x100mm at 1500mm would be 1.5**2 = 2.25x instead of 1.5x (1500/1000). This has been fixed. -* * :bdg-success:`Feature` The ``Simulator`` class and its subclasses (AS500, AS1000, etc) have a new method: ``plot``. +* :bdg-success:`Feature` The ``Simulator`` class and its subclasses (AS500, AS1000, etc) have a new method: ``plot``. It does what it says on the tin. CT From b6b84e311b69ac34642a7b5a8b53409c5b00dfe5 Mon Sep 17 00:00:00 2001 From: James Kerns Date: Mon, 11 Nov 2024 13:06:04 -0600 Subject: [PATCH 04/11] fix tests; set sid to 1000 --- tests_basic/core/test_profile.py | 113 ++++++++++++++----------------- 1 file changed, 51 insertions(+), 62 deletions(-) diff --git a/tests_basic/core/test_profile.py b/tests_basic/core/test_profile.py index 98e2afcf..20bcfaa3 100644 --- a/tests_basic/core/test_profile.py +++ b/tests_basic/core/test_profile.py @@ -54,7 +54,7 @@ def generate_open_field( imager: type[Simulator] = AS1000Image, alpha: float = 1.0, ) -> Simulator: - as1000 = imager() # this will set the pixel size and shape automatically + as1000 = imager(sid=1000) # this will set the pixel size and shape automatically as1000.add_layer( field(field_size_mm=field_size, cax_offset_mm=center, alpha=alpha) ) # create a 50x50mm square field @@ -65,16 +65,24 @@ def generate_open_field( def generate_profile( - field_size=100, sigma=2, center=0, field: type[Layer] = FilteredFieldLayer -) -> np.ndarray: - img = generate_open_field( + field_size=100, + sigma=2, + center=0, + field: type[Layer] = FilteredFieldLayer, + profile_type: type[FWXMProfilePhysical | FWXMProfile] = FWXMProfilePhysical, +) -> FWXMProfilePhysical | FWXMProfile: + simulator = generate_open_field( field_size=(field_size, field_size), sigma=sigma, center=(center, center), field=field, - ).image + ) + img = simulator.image arr = normalize(img[:, img.shape[1] // 2]) - return arr + if issubclass(profile_type, FWXMProfilePhysical): + return profile_type(arr, dpmm=1 / simulator.pixel_size) + else: + return profile_type(arr) def create_simple_9_profile() -> np.array: @@ -536,8 +544,7 @@ def test_physical_values_without_dpmm(self): def test_converting_to_simple_profile(self): pixel_size = 0.390625 # as1000 pixel size - profile = generate_profile() - fwxm = FWXMProfilePhysical(values=profile, dpmm=1 / pixel_size) + fwxm = generate_profile() abs_fwxm = fwxm.as_simple_profile() # ensure the field widths are the same self.assertAlmostEqual(fwxm.field_width_mm, abs_fwxm.field_width_px, delta=0.01) @@ -1748,70 +1755,62 @@ def test_electron_pdd(self): class TestProfilePlugins(TestCase): def test_plot_without_metric_is_fine(self): - array = generate_profile() - profile = FWXMProfile(array, fwxm_height=50) + profile = generate_profile(profile_type=FWXMProfile) profile.plot() def test_analyze_method(self): # tests the .analyze method, not the plugin itself - array = generate_profile() - profile = FWXMProfile(array, fwxm_height=50) + profile = generate_profile(profile_type=FWXMProfile) profile.compute(metrics=[SymmetryPointDifferenceMetric()]) self.assertIsInstance(profile.metric_values, dict) self.assertEqual(profile.metric_values["Point Difference Symmetry (%)"], 0) def test_symmetry_point_difference_perfect(self): - array = generate_profile() - profile = FWXMProfile(array) + profile = generate_profile(profile_type=FWXMProfile) profile.compute(metrics=[SymmetryPointDifferenceMetric()]) self.assertEqual(profile.metric_values["Point Difference Symmetry (%)"], 0) def test_symmetry_point_difference_right_negative(self): """When the profile skews higher on the right, the symmetry should be negative""" - array = generate_profile(center=5) - profile = FWXMProfile(array) + profile = generate_profile(center=5, profile_type=FWXMProfile) profile.compute(metrics=[SymmetryPointDifferenceMetric()]) self.assertAlmostEqual( - profile.metric_values["Point Difference Symmetry (%)"], -0.85, delta=0.01 + profile.metric_values["Point Difference Symmetry (%)"], -0.586, delta=0.01 ) def test_symmetry_point_difference_left_positive(self): """When the profile skews higher on the left, the symmetry should be positive""" - array = generate_profile(center=-5) - profile = FWXMProfile(array) + profile = generate_profile(center=-5, profile_type=FWXMProfile) profile.compute(metrics=[SymmetryPointDifferenceMetric()]) self.assertAlmostEqual( - profile.metric_values["Point Difference Symmetry (%)"], 0.85, delta=0.01 + profile.metric_values["Point Difference Symmetry (%)"], 0.586, delta=0.01 ) def test_top_distance_perfect(self): """A perfect profile should have the top position at 0 for FFF""" - array = generate_profile(field=FilterFreeFieldLayer) - profile = FWXMProfilePhysical(array, dpmm=1) + profile = generate_profile(field=FilterFreeFieldLayer) profile.compute(metrics=[TopDistanceMetric()]) self.assertEqual(profile.metric_values["Top Distance (mm)"], 0) def test_top_distance_left(self): - array = generate_profile(field=FilterFreeFieldLayer, center=5) - profile = FWXMProfilePhysical(array, dpmm=1) + # note the signs are inverted because the distance is the inversion of the shift of the field. + # I.e. a 5mm right shift means the top is 5mm to the left. + profile = generate_profile(field=FilterFreeFieldLayer, center=5) profile.compute(metrics=[TopDistanceMetric()]) self.assertAlmostEqual( - profile.metric_values["Top Distance (mm)"], -18.8, delta=0.1 + profile.metric_values["Top Distance (mm)"], -5, delta=0.1 ) def test_top_distance_right(self): - """A perfect profile should have the top position at 0 for FFF""" - array = generate_profile(field=FilterFreeFieldLayer, center=-5) - profile = FWXMProfilePhysical(array, dpmm=1) + # note the signs are inverted because the distance is the inversion of the shift of the field. + # I.e. a 5mm right shift means the top is 5mm to the left. + profile = generate_profile(field=FilterFreeFieldLayer, center=-5) profile.compute(metrics=[TopDistanceMetric()]) - self.assertAlmostEqual( - profile.metric_values["Top Distance (mm)"], 18.8, delta=0.1 - ) + self.assertAlmostEqual(profile.metric_values["Top Distance (mm)"], 5, delta=0.1) def test_symmetry_quotient_perfect(self): """A perfectly symmetric profile should have a symmetry quotient of 100""" - array = generate_profile() - profile = FWXMProfilePhysical(array, dpmm=1) + profile = generate_profile() profile.compute(metrics=[SymmetryPointDifferenceQuotientMetric()]) self.assertEqual( profile.metric_values["Point Difference Quotient Symmetry (%)"], 100 @@ -1819,89 +1818,79 @@ def test_symmetry_quotient_perfect(self): def test_symmetry_quotient_offset(self): """The quotient will always be 100 or above""" - array = generate_profile(center=5) - profile = FWXMProfilePhysical(array, dpmm=1) + profile = generate_profile(center=5) profile.compute(metrics=[SymmetryPointDifferenceQuotientMetric()]) self.assertAlmostEqual( profile.metric_values["Point Difference Quotient Symmetry (%)"], - 100.84, + 100.58, delta=0.01, ) def test_flatness_ratio_perfect(self): """A perfectly flat profile should have a flatness ratio of 1""" - array = generate_profile(field=PerfectFieldLayer) - profile = FWXMProfile(array) + profile = generate_profile(field=PerfectFieldLayer, profile_type=FWXMProfile) profile.compute(metrics=[FlatnessRatioMetric()]) self.assertEqual(profile.metric_values["Flatness (Ratio) (%)"], 100) def test_flatness_ratio_normal(self): - array = generate_profile() - profile = FWXMProfile(array) + profile = generate_profile(profile_type=FWXMProfile) profile.compute(metrics=[FlatnessRatioMetric()]) self.assertAlmostEqual( - profile.metric_values["Flatness (Ratio) (%)"], 103.02, delta=0.01 + profile.metric_values["Flatness (Ratio) (%)"], 101.667, delta=0.01 ) def test_flatness_difference_perfect(self): """A perfectly flat profile should have a flatness ratio of 1""" - array = generate_profile(field=PerfectFieldLayer) - profile = FWXMProfile(array) + profile = generate_profile(field=PerfectFieldLayer, profile_type=FWXMProfile) profile.compute(metrics=[FlatnessDifferenceMetric()]) self.assertEqual(profile.metric_values["Flatness (Difference) (%)"], 0) def test_flatness_difference_normal(self): """A perfectly flat profile should have a flatness ratio of 1""" - array = generate_profile() - profile = FWXMProfile(array) + profile = generate_profile(profile_type=FWXMProfile) profile.compute(metrics=[FlatnessDifferenceMetric()]) self.assertAlmostEqual( - profile.metric_values["Flatness (Difference) (%)"], 1.49, delta=0.01 + profile.metric_values["Flatness (Difference) (%)"], 0.82, delta=0.01 ) def test_symmetry_area_perfect(self): """A perfectly symmetric profile should have a symmetry area of 0""" - array = generate_profile(field=PerfectFieldLayer) - profile = FWXMProfile(array) + profile = generate_profile(field=PerfectFieldLayer, profile_type=FWXMProfile) profile.compute(metrics=[SymmetryAreaMetric()]) self.assertEqual(profile.metric_values["Symmetry (Area)"], 0) def test_symmetry_area_right_higher(self): - array = generate_profile(center=5) - profile = FWXMProfile(array) + profile = generate_profile(center=5, profile_type=FWXMProfile) profile.compute(metrics=[SymmetryAreaMetric()]) self.assertAlmostEqual( - profile.metric_values["Symmetry (Area)"], -0.24, delta=0.01 + profile.metric_values["Symmetry (Area)"], -0.208, delta=0.01 ) def test_symmetry_area_left_higher(self): - array = generate_profile(center=-5) - profile = FWXMProfile(array) + profile = generate_profile(center=-5, profile_type=FWXMProfile) profile.compute(metrics=[SymmetryAreaMetric()]) self.assertAlmostEqual( - profile.metric_values["Symmetry (Area)"], 0.24, delta=0.01 + profile.metric_values["Symmetry (Area)"], 0.208, delta=0.01 ) def test_penumbra_left(self): - array = generate_profile(field=PerfectFieldLayer) - profile = FWXMProfilePhysical(array, dpmm=1) + profile = generate_profile(field=PerfectFieldLayer) profile.compute(metrics=[PenumbraLeftMetric()]) self.assertAlmostEqual( - profile.metric_values["Left Penumbra (mm)"], 8.63, delta=0.01 + profile.metric_values["Left Penumbra (mm)"], 3.37, delta=0.01 ) def test_penumbra_right(self): - array = generate_profile(field=PerfectFieldLayer) - profile = FWXMProfilePhysical(array, dpmm=1) + profile = generate_profile(field=PerfectFieldLayer) profile.compute(metrics=[PenumbraRightMetric()]) self.assertAlmostEqual( - profile.metric_values["Right Penumbra (mm)"], 8.63, delta=0.01 + profile.metric_values["Right Penumbra (mm)"], 3.37, delta=0.01 ) def test_penumbra_is_based_on_field_height(self): """The penumbra should be based on the field height, not the profile height""" array = generate_profile(field=PerfectFieldLayer) - profile = FWXMProfilePhysical(array, dpmm=1, fwxm_height=30) + profile = FWXMProfilePhysical(array.values, dpmm=1, fwxm_height=30) profile.compute(metrics=[PenumbraLeftMetric()]) self.assertAlmostEqual( profile.metric_values["Left Penumbra (mm)"], 5.78, delta=0.01 @@ -1992,7 +1981,7 @@ def test_beam_center(self): p = SingleProfile( field.image[:, int(field.shape[1] / 2)], interpolation=Interpolation.NONE ) - self.assertAlmostEqual(p.beam_center()["index (exact)"], 422, delta=1) + self.assertAlmostEqual(p.beam_center()["index (exact)"], 409.5, delta=1) def test_field_values_length(self): field = generate_open_field() From a8bbe5574f19bdf184b2589db8100e12e76b2543 Mon Sep 17 00:00:00 2001 From: James Kerns Date: Mon, 11 Nov 2024 14:00:21 -0600 Subject: [PATCH 05/11] fix test --- tests_basic/core/test_gamma.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/tests_basic/core/test_gamma.py b/tests_basic/core/test_gamma.py index 5e036d2c..a6159ebf 100644 --- a/tests_basic/core/test_gamma.py +++ b/tests_basic/core/test_gamma.py @@ -735,10 +735,7 @@ def test_low_density_eval(self): def test_different_epids(self): """This test the same profile but with different EPIDs (i.e. pixel size)""" - # we offset the reference by 1% to ensure we have a realistic gamma value - img1200 = generate_open_field( - field_size=(100, 100), imager=AS1200Image, alpha=0.99 - ) + img1200 = generate_open_field(field_size=(100, 100), imager=AS1200Image) img1000 = generate_open_field(field_size=(100, 100), imager=AS1000Image) p1200 = img1200.image[640, :] p1000 = img1000.image[384, :] @@ -748,4 +745,4 @@ def test_different_epids(self): reference_profile=p1200_prof, dose_to_agreement=1, gamma_cap_value=2 ) # gamma is very low; just pixel noise from the image generator - self.assertAlmostEqual(np.nanmean(gamma), 0.938, delta=0.01) + self.assertLessEqual(np.nanmean(gamma), 0.005) From 52b7270a17e3af22717c39e83766310e61af25c5 Mon Sep 17 00:00:00 2001 From: James Kerns Date: Tue, 12 Nov 2024 07:50:47 -0600 Subject: [PATCH 06/11] cleanup finished WL datasets --- tests_basic/test_winstonlutz.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests_basic/test_winstonlutz.py b/tests_basic/test_winstonlutz.py index 95c83874..fa8229f3 100644 --- a/tests_basic/test_winstonlutz.py +++ b/tests_basic/test_winstonlutz.py @@ -989,6 +989,7 @@ def setUpClass(cls): @classmethod def tearDownClass(cls): plt.close("all") + super().tearDownClass() def test_number_of_images(self): self.assertEqual(self.num_images, len(self.wl.images)) @@ -1103,6 +1104,7 @@ def tearDownClass(cls): # clean up the folder we created; # in BB space can be at a premium. shutil.rmtree(cls.tmp_path, ignore_errors=True) + super().tearDownClass() @classmethod def get_filename(cls) -> str: From 1c31fc425e9b53d471162b5769fbf43f6b17a8df Mon Sep 17 00:00:00 2001 From: James Kerns Date: Tue, 12 Nov 2024 12:06:15 -0600 Subject: [PATCH 07/11] add test tracker --- conftest.py | 37 +++++++++++++++++++++++++++++++++++++ memory_monitor.sh | 24 ++++++++++++++++++++---- 2 files changed, 57 insertions(+), 4 deletions(-) create mode 100644 conftest.py diff --git a/conftest.py b/conftest.py new file mode 100644 index 00000000..72d84995 --- /dev/null +++ b/conftest.py @@ -0,0 +1,37 @@ +import json +import os +import threading + +import pytest + +ACTIVE_TESTS_FILE = "active_tests.json" + +active_tests_lock = threading.Lock() +active_tests = set() + + +def update_active_tests_file(): + with active_tests_lock: + data = list(active_tests) + with open(ACTIVE_TESTS_FILE, "w") as f: + json.dump(data, f) + + +@pytest.hookimpl(hookwrapper=True) +def pytest_runtest_protocol(item, nextitem): + with active_tests_lock: + active_tests.add(item.nodeid) + update_active_tests_file() + + # outcome = yield + + with active_tests_lock: + active_tests.discard(item.nodeid) + update_active_tests_file() + + +@pytest.fixture(scope="session", autouse=True) +def ensure_active_tests_file_cleanup(): + yield + if os.path.exists(ACTIVE_TESTS_FILE): + os.remove(ACTIVE_TESTS_FILE) diff --git a/memory_monitor.sh b/memory_monitor.sh index b53c7ecd..2e49eaa0 100644 --- a/memory_monitor.sh +++ b/memory_monitor.sh @@ -1,14 +1,30 @@ #!/bin/bash LOG_FILE="memory_usage.log" -echo "Timestamp,MemoryUsage(MB),MemoryLimit(MB)" > $LOG_FILE +ACTIVE_TESTS_FILE="active_tests.json" + +# Initialize the log file with headers +echo "Timestamp,MemoryUsage(MB),MemoryLimit(MB),ActiveTests" > "$LOG_FILE" while true; do TIMESTAMP=$(date +"%Y-%m-%d %H:%M:%S") - MEMORY_USAGE=$(cat /sys/fs/cgroup/memory/memory.usage_in_bytes) - MEMORY_LIMIT=$(cat /sys/fs/cgroup/memory/memory.limit_in_bytes) + + # Read memory usage and limit + MEMORY_USAGE=$(cat /sys/fs/cgroup/memory/memory.usage_in_bytes 2>/dev/null || echo 0) + MEMORY_LIMIT=$(cat /sys/fs/cgroup/memory/memory.limit_in_bytes 2>/dev/null || echo 0) MEMORY_USAGE_MB=$((MEMORY_USAGE / 1024 / 1024)) MEMORY_LIMIT_MB=$((MEMORY_LIMIT / 1024 / 1024)) - echo "$TIMESTAMP,$MEMORY_USAGE_MB,$MEMORY_LIMIT_MB" >> $LOG_FILE + + # Read active tests + if [ -f "$ACTIVE_TESTS_FILE" ]; then + ACTIVE_TESTS=$(jq -r '.[]' "$ACTIVE_TESTS_FILE" | paste -sd "," -) + if [ -z "$ACTIVE_TESTS" ]; then + ACTIVE_TESTS="None" + fi + else + ACTIVE_TESTS="No tests running" + fi + + echo "$TIMESTAMP,$MEMORY_USAGE_MB,$MEMORY_LIMIT_MB,\"$ACTIVE_TESTS\"" >> "$LOG_FILE" sleep 10 done From 91f3e3759d19798bdfbc29dd196c5a943922806a Mon Sep 17 00:00:00 2001 From: James Kerns Date: Tue, 12 Nov 2024 12:16:23 -0600 Subject: [PATCH 08/11] add temp dir tracker --- memory_monitor.sh | 47 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/memory_monitor.sh b/memory_monitor.sh index 2e49eaa0..043d77f4 100644 --- a/memory_monitor.sh +++ b/memory_monitor.sh @@ -1,23 +1,37 @@ #!/bin/bash +# Configuration LOG_FILE="memory_usage.log" ACTIVE_TESTS_FILE="active_tests.json" +# Dynamically set TEMP_DIR based on TMPDIR environment variable; default to /tmp if TMPDIR is not set +TEMP_DIR="${TMPDIR:-/tmp}" +POLL_INTERVAL=10 # Polling interval in seconds # Initialize the log file with headers -echo "Timestamp,MemoryUsage(MB),MemoryLimit(MB),ActiveTests" > "$LOG_FILE" +echo "Timestamp,MemoryUsage(MB),MemoryLimit(MB),ActiveTests,TempDirSize(MB)" > "$LOG_FILE" while true; do + # Capture the current timestamp TIMESTAMP=$(date +"%Y-%m-%d %H:%M:%S") # Read memory usage and limit - MEMORY_USAGE=$(cat /sys/fs/cgroup/memory/memory.usage_in_bytes 2>/dev/null || echo 0) - MEMORY_LIMIT=$(cat /sys/fs/cgroup/memory/memory.limit_in_bytes 2>/dev/null || echo 0) + if [ -f /sys/fs/cgroup/memory/memory.usage_in_bytes ] && [ -f /sys/fs/cgroup/memory/memory.limit_in_bytes ]; then + MEMORY_USAGE=$(cat /sys/fs/cgroup/memory/memory.usage_in_bytes 2>/dev/null || echo 0) + MEMORY_LIMIT=$(cat /sys/fs/cgroup/memory/memory.limit_in_bytes 2>/dev/null || echo 0) + else + # Fallback for systems without cgroup memory files + MEMORY_USAGE=$(free -b | awk '/Mem:/ {print $3}' || echo 0) + MEMORY_LIMIT=$(free -b | awk '/Mem:/ {print $2}' || echo 0) + fi + MEMORY_USAGE_MB=$((MEMORY_USAGE / 1024 / 1024)) MEMORY_LIMIT_MB=$((MEMORY_LIMIT / 1024 / 1024)) # Read active tests if [ -f "$ACTIVE_TESTS_FILE" ]; then + # Use jq to parse the JSON array and concatenate test names ACTIVE_TESTS=$(jq -r '.[]' "$ACTIVE_TESTS_FILE" | paste -sd "," -) + # Handle empty active tests if [ -z "$ACTIVE_TESTS" ]; then ACTIVE_TESTS="None" fi @@ -25,6 +39,29 @@ while true; do ACTIVE_TESTS="No tests running" fi - echo "$TIMESTAMP,$MEMORY_USAGE_MB,$MEMORY_LIMIT_MB,\"$ACTIVE_TESTS\"" >> "$LOG_FILE" - sleep 10 + # Determine the temporary directory to monitor + # Prefer TMPDIR if set; else default to /tmp + if [ -n "$TMPDIR" ]; then + CURRENT_TEMP_DIR="$TMPDIR" + else + CURRENT_TEMP_DIR="/tmp" + fi + + # Calculate the size of the temporary directory in MB + if [ -d "$CURRENT_TEMP_DIR" ]; then + # Use du to calculate the size. Suppress errors for directories with restricted permissions. + TEMP_DIR_SIZE=$(du -sm "$CURRENT_TEMP_DIR" 2>/dev/null | awk '{print $1}') + # Handle cases where du fails + if [ -z "$TEMP_DIR_SIZE" ]; then + TEMP_DIR_SIZE="Unknown" + fi + else + TEMP_DIR_SIZE="Directory not found" + fi + + # Log the data + echo "$TIMESTAMP,$MEMORY_USAGE_MB,$MEMORY_LIMIT_MB,\"$ACTIVE_TESTS\",$TEMP_DIR_SIZE" >> "$LOG_FILE" + + # Wait for the next poll + sleep "$POLL_INTERVAL" done From 342a563cf9f6b8b7a7860f8fd6d17db7fe143814 Mon Sep 17 00:00:00 2001 From: James Kerns Date: Tue, 12 Nov 2024 12:29:25 -0600 Subject: [PATCH 09/11] fix conftest --- conftest.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/conftest.py b/conftest.py index 72d84995..e74dc759 100644 --- a/conftest.py +++ b/conftest.py @@ -1,16 +1,21 @@ +# conftest.py + import json import os import threading import pytest +# Define the path for the active tests file ACTIVE_TESTS_FILE = "active_tests.json" +# Initialize a thread-safe set to store active tests active_tests_lock = threading.Lock() active_tests = set() def update_active_tests_file(): + """Writes the current active tests to a JSON file.""" with active_tests_lock: data = list(active_tests) with open(ACTIVE_TESTS_FILE, "w") as f: @@ -19,19 +24,25 @@ def update_active_tests_file(): @pytest.hookimpl(hookwrapper=True) def pytest_runtest_protocol(item, nextitem): + """Hook to track test start and end, and trace memory allocations.""" + # Before the test runs with active_tests_lock: active_tests.add(item.nodeid) update_active_tests_file() - # outcome = yield - - with active_tests_lock: - active_tests.discard(item.nodeid) - update_active_tests_file() + try: + # Run the actual test + yield + finally: + # After the test runs + with active_tests_lock: + active_tests.discard(item.nodeid) + update_active_tests_file() @pytest.fixture(scope="session", autouse=True) def ensure_active_tests_file_cleanup(): + """Ensure that the active tests file is removed after the test session.""" yield if os.path.exists(ACTIVE_TESTS_FILE): os.remove(ACTIVE_TESTS_FILE) From c83884cb3e8ad44180003de7d25143818a2be066 Mon Sep 17 00:00:00 2001 From: James Kerns Date: Tue, 12 Nov 2024 12:45:21 -0600 Subject: [PATCH 10/11] add jq --- bitbucket-pipelines.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bitbucket-pipelines.yml b/bitbucket-pipelines.yml index 39e77434..7d911bd2 100644 --- a/bitbucket-pipelines.yml +++ b/bitbucket-pipelines.yml @@ -12,7 +12,7 @@ definitions: script: - apt-get update - apt-get -y install git - - uv sync --frozen + - uv sync --frozen --quiet - uv tool run pre-commit run --all-files caches: - precommit @@ -41,6 +41,7 @@ definitions: size: 2x script: # set up memory monitoring + - apt-get install -y jq - chmod +x memory_monitor.sh - nohup ./memory_monitor.sh & - MONITOR_PID=$! @@ -198,6 +199,7 @@ definitions: size: 2x script: # set up memory monitoring + - apt-get install -y jq - chmod +x memory_monitor.sh - nohup ./memory_monitor.sh & - MONITOR_PID=$! @@ -217,6 +219,7 @@ definitions: size: 2x script: # set up memory monitoring + - apt-get install -y jq - chmod +x memory_monitor.sh - nohup ./memory_monitor.sh & - MONITOR_PID=$! From 18a860e22d66162d067e229fa5e8e30fca8266ed Mon Sep 17 00:00:00 2001 From: James Kerns Date: Tue, 12 Nov 2024 12:53:42 -0600 Subject: [PATCH 11/11] update --- bitbucket-pipelines.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bitbucket-pipelines.yml b/bitbucket-pipelines.yml index 7d911bd2..953c7273 100644 --- a/bitbucket-pipelines.yml +++ b/bitbucket-pipelines.yml @@ -41,6 +41,7 @@ definitions: size: 2x script: # set up memory monitoring + - apt-get update - apt-get install -y jq - chmod +x memory_monitor.sh - nohup ./memory_monitor.sh & @@ -199,6 +200,7 @@ definitions: size: 2x script: # set up memory monitoring + - apt-get update - apt-get install -y jq - chmod +x memory_monitor.sh - nohup ./memory_monitor.sh & @@ -219,6 +221,7 @@ definitions: size: 2x script: # set up memory monitoring + - apt-get update - apt-get install -y jq - chmod +x memory_monitor.sh - nohup ./memory_monitor.sh &