Skip to content

Commit 4dcf37e

Browse files
authored
Merge pull request #2526 from kif/2525_copy_result
Ensure all results containers are `copy`able and `deepcopy` able
2 parents e3aceaa + 3b120e3 commit 4dcf37e

File tree

2 files changed

+125
-13
lines changed

2 files changed

+125
-13
lines changed

src/pyFAI/containers.py

Lines changed: 61 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,11 @@
3030
__contact__ = "valentin.valls@esrf.eu"
3131
__license__ = "MIT"
3232
__copyright__ = "European Synchrotron Radiation Facility, Grenoble, France"
33-
__date__ = "04/04/2025"
33+
__date__ = "16/05/2025"
3434
__status__ = "development"
3535

3636
import sys
37+
import copy
3738
from dataclasses import fields, asdict, dataclass as _dataclass
3839
from collections import namedtuple
3940
from enum import IntEnum
@@ -55,7 +56,6 @@
5556
dataclass = _dataclass
5657

5758

58-
5959
class ErrorModel(IntEnum):
6060
NO = 0
6161
VARIANCE = 1
@@ -91,10 +91,45 @@ def as_str(self):
9191
return self.name.lower()
9292

9393

94-
class IntegrateResult(tuple):
94+
class _CopyableTuple(tuple):
95+
"Abstract class that can be copied using the copy module"
96+
COPYABLE_ATTR = tuple() # list of copyable attributes
97+
98+
def __copy__(self):
99+
"Helper function for copy.copy()"
100+
other = self.__class__(*self)
101+
for attr in self.COPYABLE_ATTR:
102+
setattr(other, attr, getattr(self, attr))
103+
return other
104+
105+
def __deepcopy__(self, memo=None):
106+
"Helper function for copy.deepcopy()"
107+
if memo is None:
108+
memo = {}
109+
args = []
110+
for i in self:
111+
cpy = copy.deepcopy(i, memo)
112+
memo[id(i)] = cpy
113+
args.append(cpy)
114+
other = self.__class__(*args)
115+
for attr in self.COPYABLE_ATTR:
116+
org = getattr(self, attr)
117+
cpy = copy.deepcopy(org, memo)
118+
memo[id(org)] = cpy
119+
setattr(other, attr, cpy)
120+
return other
121+
122+
123+
class IntegrateResult(_CopyableTuple):
95124
"""
96125
Class defining shared information between Integrate1dResult and Integrate2dResult.
97126
"""
127+
COPYABLE_ATTR = {"_sum_signal", "_sum_variance", "_sum_normalization", "_sum_normalization2",
128+
"_count", "_unit", "_has_mask_applied", "_has_dark_correction",
129+
"_has_flat_correction", "_has_solidangle_correction", "_normalization_factor",
130+
"_polarization_factor", "_metadata", "_npt_azim", "_percentile", "_method",
131+
"_method_called", "_compute_engine", "_error_model", "_std", "_sem",
132+
"_poni", "_weighted_average"}
98133

99134
def __init__(self):
100135
self._sum_signal = None # sum of signal
@@ -396,8 +431,7 @@ def _set_error_model(self, value):
396431

397432
@property
398433
def poni(self):
399-
"""content of the PONI-file
400-
"""
434+
"content of the PONI-file"
401435
return self._poni
402436

403437
def _set_poni(self, value):
@@ -594,14 +628,20 @@ def _set_azimuthal_unit(self, unit):
594628
self._azimuthal_unit = unit
595629

596630

597-
class SeparateResult(tuple):
631+
class SeparateResult(_CopyableTuple):
598632
"""
599633
Class containing the result of AzimuthalIntegrator.separte which separates the
600634
601635
* Amorphous isotropic signal (from a median filter or a sigma-clip)
602636
* Bragg peaks (signal > amorphous)
603637
* Shadow areas (signal < amorphous)
604638
"""
639+
COPYABLE_ATTR = {'_radial', '_intensity', '_sigma',
640+
'_sum_signal', '_sum_variance', '_sum_normalization',
641+
'_count', '_unit', '_has_mask_applied', '_has_dark_correction',
642+
'_has_flat_correction', '_normalization_factor', '_polarization_factor',
643+
'_metadata', '_npt_rad', '_npt_azim', '_percentile', '_method',
644+
'_method_called', '_compute_engine', '_shadow'}
605645

606646
def __new__(self, bragg, amorphous):
607647
return tuple.__new__(SeparateResult, (bragg, amorphous))
@@ -907,8 +947,17 @@ def _set_npt_azim(self, value):
907947
self._npt_azim = value
908948

909949

910-
class SparseFrame(tuple):
950+
class SparseFrame(_CopyableTuple):
911951
"""Result of the sparsification of a diffraction frame"""
952+
COPYABLE_ATTR = {'_shape', '_dtype', '_mask',
953+
'_radius', '_dummy', '_background_avg',
954+
'_background_std', '_unit', '_has_dark_correction',
955+
'_has_flat_correction', '_normalization_factor', '_polarization_factor',
956+
'_metadata', '_percentile', '_method',
957+
'_method_called', '_compute_engine',
958+
'_cutoff_clip', '_cutoff_pick', '_cutoff_peak',
959+
'_background_cycle', '_noise', '_radial_range', '_error_model',
960+
'_peaks', '_peak_patch_size', '_peak_connected'}
912961

913962
def __new__(self, index, intensity):
914963
return tuple.__new__(SparseFrame, (index, intensity))
@@ -1045,23 +1094,24 @@ def peak_connected(self):
10451094
def unit(self):
10461095
return self._unit
10471096

1097+
10481098
def rebin1d(res2d):
10491099
"""Function that rebins an Integrate2dResult into a Integrate1dResult
10501100
10511101
:param res2d: Integrate2dResult instance obtained from ai.integrate2d
10521102
:return: Integrate1dResult
10531103
"""
10541104
bins_rad = res2d.radial
1055-
sum_signal = res2d.sum_signal.sum(axis=0)
1056-
sum_normalization = res2d.sum_normalization.sum(axis=0)
1105+
sum_signal = res2d.sum_signal.sum(axis=0)
1106+
sum_normalization = res2d.sum_normalization.sum(axis=0)
10571107
I = sum_signal / sum_normalization
10581108
if res2d.sum_variance is not None:
1059-
sum_variance = res2d.sum_variance.sum(axis=0)
1109+
sum_variance = res2d.sum_variance.sum(axis=0)
10601110
sem = numpy.sqrt(sum_variance) / sum_normalization
10611111
result = Integrate1dResult(bins_rad, I, sem)
10621112
result._set_sum_normalization2(res2d.sum_normalization2.sum(axis=0))
10631113
result._set_sum_variance(sum_variance)
1064-
result._set_std(numpy.sqrt(sum_variance) / sum_normalization )
1114+
result._set_std(numpy.sqrt(sum_variance) / sum_normalization)
10651115
result._set_std(sem)
10661116
else:
10671117
result = Integrate1dResult(bins_rad, I)

src/pyFAI/test/test_bug_regression.py

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
__contact__ = "Jerome.Kieffer@esrf.fr"
3737
__license__ = "MIT"
3838
__copyright__ = "2015-2024 European Synchrotron Radiation Facility, Grenoble, France"
39-
__date__ = "27/09/2024"
39+
__date__ = "16/05/2025"
4040

4141
import sys
4242
import os
@@ -599,10 +599,72 @@ def test_bug_2151(self):
599599
Faulty detectors: S10
600600
"""
601601
ai = load({"detector": "imxpad_s10"})
602-
img=numpy.ones(ai.detector.shape);
602+
img = numpy.ones(ai.detector.shape)
603603
ai.integrate2d(img, 10, method=("full","csc","python"), unit="r_mm")
604604
#used to raise AssertionError assert self.size == len(indptr) - 1
605605

606+
def test_bug_2525(self):
607+
"""
608+
res1d_cp = copy.copy(res1d)
609+
used to raise TypeError: missing required positional argument
610+
"""
611+
ai = load({"detector": "imxpad_s10"})
612+
img = numpy.ones(ai.detector.shape)
613+
res1d = ai.integrate1d(img, 10, unit="r_mm")
614+
615+
res1d_cp = copy.copy(res1d)
616+
self.assertTrue(numpy.allclose(res1d.radial, res1d_cp.radial))
617+
self.assertTrue(numpy.allclose(res1d.intensity, res1d_cp.intensity))
618+
self.assertTrue(numpy.allclose(res1d.sum_signal, res1d_cp._sum_signal))
619+
self.assertTrue(numpy.allclose(res1d.sum_normalization, res1d_cp._sum_normalization))
620+
res1d_dp = copy.deepcopy(res1d)
621+
self.assertTrue(numpy.allclose(res1d.radial, res1d_dp.radial))
622+
self.assertTrue(numpy.allclose(res1d.intensity, res1d_dp.intensity))
623+
self.assertTrue(numpy.allclose(res1d.sum_signal, res1d_dp.sum_signal))
624+
self.assertTrue(numpy.allclose(res1d.sum_normalization, res1d_dp.sum_normalization))
625+
626+
res2d = ai.integrate2d(img, 10, unit="r_mm")
627+
res2d_cp = copy.copy(res2d)
628+
self.assertTrue(numpy.allclose(res2d.radial, res2d_cp.radial))
629+
self.assertTrue(numpy.allclose(res2d.intensity, res2d_cp.intensity))
630+
self.assertTrue(numpy.allclose(res2d.sum_signal, res2d_cp.sum_signal))
631+
self.assertTrue(numpy.allclose(res2d.sum_normalization, res2d_cp.sum_normalization))
632+
res2d_dp = copy.deepcopy(res2d)
633+
self.assertTrue(numpy.allclose(res2d.radial, res2d_dp.radial))
634+
self.assertTrue(numpy.allclose(res2d.intensity, res2d_dp.intensity))
635+
self.assertTrue(numpy.allclose(res2d.sum_signal, res2d_dp.sum_signal))
636+
self.assertTrue(numpy.allclose(res2d.sum_normalization, res2d_dp.sum_normalization))
637+
638+
ressp = ai.separate(img, 10, unit="r_mm")
639+
ressp_cp = copy.copy(ressp)
640+
self.assertTrue(numpy.allclose(ressp.bragg, ressp_cp.bragg))
641+
self.assertTrue(numpy.allclose(ressp.amorphous, ressp_cp.amorphous))
642+
self.assertTrue(numpy.allclose(ressp.sum_signal, ressp_cp.sum_signal))
643+
self.assertTrue(numpy.allclose(ressp.sum_normalization, ressp_cp.sum_normalization))
644+
645+
ressp_dp = copy.deepcopy(ressp)
646+
self.assertTrue(numpy.allclose(ressp.bragg, ressp_dp.bragg))
647+
self.assertTrue(numpy.allclose(ressp.amorphous, ressp_dp.amorphous))
648+
self.assertTrue(numpy.allclose(ressp.sum_signal, ressp_dp.sum_signal))
649+
self.assertTrue(numpy.allclose(ressp.sum_normalization, ressp_dp.sum_normalization))
650+
651+
from pyFAI.containers import SparseFrame
652+
sp = SparseFrame(numpy.arange(1,5),numpy.arange(4,9))
653+
sp._shape = (23,45)
654+
sp_cp = copy.copy(sp)
655+
self.assertTrue(numpy.allclose(sp.index, sp_cp.index))
656+
self.assertTrue(numpy.allclose(sp.intensity, sp_cp.intensity))
657+
self.assertEqual(sp.shape, sp_cp.shape)
658+
659+
sp_dp = copy.deepcopy(sp)
660+
self.assertTrue(numpy.allclose(sp.index, sp_dp.index))
661+
self.assertTrue(numpy.allclose(sp.intensity, sp_dp.intensity))
662+
self.assertEqual(sp.shape, sp_dp.shape)
663+
664+
665+
666+
667+
606668
class TestBug1703(unittest.TestCase):
607669
"""
608670
Check the normalization affect propely the propagated errors/intensity

0 commit comments

Comments
 (0)