From 77ebe8772a3501055df66dd3f957bc31c15c4759 Mon Sep 17 00:00:00 2001 From: Johannes Mueller Date: Wed, 9 Oct 2024 17:51:03 +0200 Subject: [PATCH 1/3] Update changelog Signed-off-by: Johannes Mueller --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 774246d7..3e17c0e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ In this file noteworthy changes of new releases of pyLife are documented since ### Improvements * Sanitize checks for Wöhler analysis (#108) +* Error messages when odbclient gets unsupported element types (#64) ### Bugfixes From 50db0fc81e8f03056cc676275379c731facca7f7 Mon Sep 17 00:00:00 2001 From: Johannes Mueller Date: Thu, 10 Oct 2024 13:43:54 +0200 Subject: [PATCH 2/3] Load collective to histogram WIP Signed-off-by: Johannes Mueller --- CHANGELOG.md | 5 + .../stress/collective/load_collective.py | 191 ++++++++++++++++- .../stress/collective/test_load_collective.py | 195 ++++++++++++++++-- 3 files changed, 368 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e17c0e5..70834f5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,15 @@ In this file noteworthy changes of new releases of pyLife are documented since ## unreleased +### New features + +* New method `LoadCollective.histogram()` (#107) + ### Improvements * Sanitize checks for Wöhler analysis (#108) * Error messages when odbclient gets unsupported element types (#64) +* Improved documentation ### Bugfixes diff --git a/src/pylife/stress/collective/load_collective.py b/src/pylife/stress/collective/load_collective.py index ade4c6d5..6c9111f2 100644 --- a/src/pylife/stress/collective/load_collective.py +++ b/src/pylife/stress/collective/load_collective.py @@ -169,34 +169,209 @@ def shift(self, diffs): return obj.load_collective def range_histogram(self, bins, axis=None): - """Calculate the histogram of range values along a given axis. + """Calculate the histogram of cycles for range intervals along a given axis. Parameters ---------- bins : int, sequence of scalars or pd.IntervalIndex The bins of the histogram to be calculated + axis : str, optional + The index axis along which the histogram is calculated. If missing + the histogram is calculated over the whole collective. + + Returns ------- range histogram : :class:`~pylife.pylife.stress.LoadHistogram` - axis : str, optional - The index axis along which the histogram is calculated. If missing - the histogram is calculated over the whole collective. + + Note + ---- + This resulting histogram does not contain any information on the mean + stress. Neither does it perform any kind of mean stress transformation + + See also + -------- + histogram + + Examples + -------- + Calculate a range histogram of a simple load collective + + >>> df = pd.DataFrame( + ... {'range': [1.0, 2.0, 1.0, 2.0, 1.0], 'mean': [0, 0, 0, 0, 0]}, + ... columns=['range', 'mean'], + ... ) + >>> df.load_collective.range_histogram([0, 1, 2, 3]).to_pandas() + range + (0, 1] 0 + (1, 2] 3 + (2, 3] 2 + Name: cycles, dtype: int64 + + Calculate a range histogram of a load collective collection for + multiple nodes. The axis along which to aggregate the histogram is + given as ``cycle_number``. + + >>> element_idx = pd.Index([10, 20, 30], name='element_id') + >>> cycle_idx = pd.Index([0, 1, 2], name='cycle_number') + >>> index = pd.MultiIndex.from_product((element_idx, cycle_idx)) + + >>> df = pd.DataFrame({ + ... 'range': [1., 2., 2., 0., 1., 2., 1., 1., 2.], + ... 'mean': [0, 0, 0, 0, 0, 0, 0, 0, 0] + ... }, columns=['range', 'mean'], index=index) + + >>> h = df.load_collective.range_histogram([0, 1, 2, 3], 'cycle_number') + >>> h.to_pandas() + element_id range + 10 (0, 1] 0 + (1, 2] 1 + (2, 3] 2 + 20 (0, 1] 1 + (1, 2] 1 + (2, 3] 1 + 30 (0, 1] 0 + (1, 2] 2 + (2, 3] 1 + Name: cycles, dtype: int64 + """ def make_histogram(group): cycles, intervals = np.histogram(group * 2., bins) idx = pd.IntervalIndex.from_breaks(intervals, name='range') return pd.Series(cycles, index=idx, name='cycles') - if isinstance(bins, pd.IntervalIndex): + if isinstance(bins, pd.IntervalIndex) or isinstance(bins, pd.arrays.IntervalArray): bins = np.append(bins.left[0], bins.right) if axis is None: return LoadHistogram(make_histogram(self.amplitude)) - result = pd.Series(self.amplitude - .groupby(self._obj.index.droplevel(axis).names) - .apply(make_histogram), name='cycles') + result = pd.Series( + self.amplitude.groupby(self._levels_from_axis(axis)).apply( + make_histogram + ), + name='cycles', + ) return LoadHistogram(result) + + def histogram(self, bins, axis=None): + """Calculate the histogram of cycles along a given axis. + + Parameters + ---------- + bins : int, sequence of scalars or pd.IntervalIndex + The bins of the histogram to be calculated + + axis : str, optional + The index axis along which the histogram is calculated. If missing + the histogram is calculated over the whole collective. + + Returns + ------- + range histogram : :class:`~pylife.pylife.stress.LoadHistogram` + + See also + -------- + range_histogram + + Examples + -------- + Calculate a range histogram of a simple load collective + + >>> df = pd.DataFrame( + ... {'range': [1.0, 2.0, 1.0, 2.0, 1.0], 'mean': [0.5, 1.5, 1.0, 1.5, 0.5]}, + ... columns=['range', 'mean'], + ... ) + >>> df.load_collective.histogram([0, 1, 2, 3]).to_pandas() + range mean + (0, 1] (0, 1] 0.0 + (1, 2] 0.0 + (2, 3] 0.0 + (1, 2] (0, 1] 2.0 + (1, 2] 1.0 + (2, 3] 0.0 + (2, 3] (0, 1] 0.0 + (1, 2] 2.0 + (2, 3] 0.0 + Name: cycles, dtype: float64 + + Calculate a range histogram of a load collective collection for + multiple nodes. The axis along which to aggregate the histogram is + given as ``cycle_number``. + + >>> element_idx = pd.Index([10, 20], name='element_id') + >>> cycle_idx = pd.Index([0, 1, 2], name='cycle_number') + >>> index = pd.MultiIndex.from_product((element_idx, cycle_idx)) + + >>> df = pd.DataFrame({ + ... 'range': [1., 2., 2., 0., 1., 2.], + ... 'mean': [0.5, 1.0, 1.0, 0.0, 1.0, 1.5] + ... }, columns=['range', 'mean'], index=index) + + >>> h = df.load_collective.histogram([0, 1, 2, 3], 'cycle_number') + >>> h.to_pandas() + element_id range mean + 10 (0, 1] (0, 1] 0.0 + (1, 2] 0.0 + (2, 3] 0.0 + (1, 2] (0, 1] 1.0 + (1, 2] 0.0 + (2, 3] 0.0 + (2, 3] (0, 1] 0.0 + (1, 2] 2.0 + (2, 3] 0.0 + 20 (0, 1] (0, 1] 1.0 + (1, 2] 0.0 + (2, 3] 0.0 + (1, 2] (0, 1] 0.0 + (1, 2] 1.0 + (2, 3] 0.0 + (2, 3] (0, 1] 0.0 + (1, 2] 1.0 + (2, 3] 0.0 + Name: cycles, dtype: float64 + + """ + def make_histogram(group): + cycles, range_bins, mean_bins = np.histogram2d( + group["range"], group["meanstress"], bins + ) + + return pd.Series( + cycles.ravel(), + name="cycles", + index=pd.MultiIndex.from_product( + [ + pd.IntervalIndex.from_breaks(range_bins), + pd.IntervalIndex.from_breaks(mean_bins), + ], + names=["range", "mean"], + ), + ) + + range_mean = pd.DataFrame( + {'range': self.amplitude * 2, 'meanstress': self.meanstress}, + index=self._obj.index, + ) + + if isinstance(bins, pd.IntervalIndex) or isinstance(bins, pd.arrays.IntervalArray): + bins = np.append(bins.left[0], bins.right) + + if axis is None: + return LoadHistogram(make_histogram(range_mean)) + + result = pd.Series( + range_mean.groupby(self._levels_from_axis(axis)) + .apply(make_histogram) + .stack(['range', 'mean'], future_stack=True), + name="cycles", + ) + + return LoadHistogram(result) + + def _levels_from_axis(self, axis): + return [lv for lv in self._obj.index.names if lv not in [axis] and lv is not None] diff --git a/tests/stress/collective/test_load_collective.py b/tests/stress/collective/test_load_collective.py index 917e4ebe..1bc7fcf4 100644 --- a/tests/stress/collective/test_load_collective.py +++ b/tests/stress/collective/test_load_collective.py @@ -382,13 +382,15 @@ def test_load_collective_mean_range_shift_scalar(df, expected_amplitude, expecte ]) def test_load_collective_range_histogram_alter_bins(bins, expected_index_tuples, expected_data): df = pd.DataFrame({ - 'range': [1., 2., 1.], - 'mean': [0, 0, 0] + 'range': [1.0, 2.0, 1.0], + 'mean': [0.0, 0.0, 0.0] }, columns=['range', 'mean']) - expected = pd.Series(expected_data, - name='cycles', - index=pd.IntervalIndex.from_tuples(expected_index_tuples, name='range')) + expected = pd.Series( + expected_data, + name='cycles', + index=pd.IntervalIndex.from_tuples(expected_index_tuples, name='range'), + ) result = df.load_collective.range_histogram(bins) @@ -396,10 +398,10 @@ def test_load_collective_range_histogram_alter_bins(bins, expected_index_tuples, def test_load_collective_range_histogram_alter_ranges(): - df = pd.DataFrame({ - 'range': [1., 2., 1., 2., 1], - 'mean': [0, 0, 0, 0, 0] - }, columns=['range', 'mean']) + df = pd.DataFrame( + {'range': [1.0, 2.0, 1.0, 2.0, 1], 'mean': [0.0, 0.0, 0.0, 0.0, 0.0]}, + columns=['range', 'mean'], + ) expected_index = pd.IntervalIndex.from_tuples([(0, 1), (1, 2), (2, 3)], name='range') expected = pd.Series([0, 3, 2], name='cycles', index=expected_index) @@ -411,8 +413,8 @@ def test_load_collective_range_histogram_alter_ranges(): def test_load_collective_range_histogram_interval_index(): df = pd.DataFrame({ - 'range': [1., 2., 1., 2., 1], - 'mean': [0, 0, 0, 0, 0] + 'range': [1.0, 2.0, 1.0, 2.0, 1.0], + 'mean': [0.0, 0.0, 0.0, 0.0, 0.0] }, columns=['range', 'mean']) expected_index = pd.IntervalIndex.from_tuples([(0, 1), (1, 2), (2, 3)], name='range') @@ -423,19 +425,34 @@ def test_load_collective_range_histogram_interval_index(): pd.testing.assert_series_equal(result.to_pandas(), expected) +def test_load_collective_range_histogram_interval_arrays(): + df = pd.DataFrame({ + 'range': [1.0, 2.0, 1.0, 2.0, 1.0], + 'mean': [0.0, 0.0, 0.0, 0.0, 0.0] + }, columns=['range', 'mean']) + + expected_index = pd.IntervalIndex.from_tuples([(0, 1), (1, 2), (2, 3)], name='range') + expected = pd.Series([0, 3, 2], name='cycles', index=expected_index) + + intervals = pd.arrays.IntervalArray.from_tuples([(0, 1), (1, 2), (2, 3)]) + result = df.load_collective.range_histogram(intervals) + + pd.testing.assert_series_equal(result.to_pandas(), expected) + + def test_load_collective_range_histogram_unnested_grouped(): element_idx = pd.Index([10, 20, 30], name='element_id') cycle_idx = pd.Index([0, 1, 2], name='cycle_number') idx = pd.MultiIndex.from_product((element_idx, cycle_idx)) df = pd.DataFrame({ - 'range': [1., 2., 1., 2., 1., 2., 1., 1., 1], + 'range': [0., 1., 2., 0., 1., 2., 0., 1., 2.], 'mean': [0, 0, 0, 0, 0, 0, 0, 0, 0] }, columns=['range', 'mean'], index=idx) expected_intervals = pd.IntervalIndex.from_tuples([(0, 1), (1, 2), (2, 3)], name='range') expected_index = pd.MultiIndex.from_product([element_idx, expected_intervals]) - expected = pd.Series([0, 2, 1, 0, 1, 2, 0, 3, 0], name='cycles', index=expected_index) + expected = pd.Series(1, name='cycles', index=expected_index) result = df.load_collective.range_histogram([0, 1, 2, 3], 'cycle_number') @@ -449,8 +466,8 @@ def test_load_collective_range_histogram_nested_grouped(): idx = pd.MultiIndex.from_product((element_idx, node_idx, cycle_idx)) df = pd.DataFrame({ - 'range': [1., 2., 1., 2., 1., 2., 1., 1.], - 'mean': [0, 0, 0, 0, 0, 0, 0, 0] + 'range': [1.0, 2.0, 1.0, 2.0, 1.0, 2.0, 1.0, 1.0], + 'mean': [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] }, columns=['range', 'mean'], index=idx) expected_intervals = pd.IntervalIndex.from_tuples([(0, 1), (1, 2), (2, 3)], name='range') @@ -483,3 +500,151 @@ def test_load_collective_strange_shift(): }, index=expected_index) pd.testing.assert_frame_equal(result, expected) + + +# GH-107 +@pytest.mark.parametrize('bins, expected_index_tuples, expected_data', [ + ([0, 1, 2, 3], [(0, 1), (1, 2), (2, 3)], [0, 0, 0, 2, 0, 0, 0, 1, 0]), + ([0, 2, 4], [(0, 2), (2, 4)], [2, 0, 1, 0]) +]) +def test_load_collective_histogram_alter_bins(bins, expected_index_tuples, expected_data): + df = pd.DataFrame( + {'range': [1.5, 2.5, 1.5], 'mean': [0.75, 1.25, 0.75]}, columns=['range', 'mean'] + ) + + expected_intervals = pd.IntervalIndex.from_tuples(expected_index_tuples) + expected = pd.Series( + expected_data, + name='cycles', + index=pd.MultiIndex.from_product( + [expected_intervals, expected_intervals], names=['range', 'mean'] + ), + dtype=np.float64 + ) + + result = df.load_collective.histogram(bins) + + pd.testing.assert_series_equal(result.to_pandas(), expected) + + +# GH-107 +def test_load_collective_histogram_alter_ranges(): + df = pd.DataFrame({ + 'range': [1., 2., 1., 2., 1], + 'mean': [0.5, 1.0, 0.5, 1.0, 0.5] + }, columns=['range', 'mean']) + + expected_intervals = pd.IntervalIndex.from_tuples([(0, 1), (1, 2), (2, 3)]) + expected = pd.Series( + [0.0, 0.0, 0.0, 3.0, 0.0, 0.0, 0.0, 2.0, 0.0], + name='cycles', + index=pd.MultiIndex.from_product( + [expected_intervals, expected_intervals], names=['range', 'mean'] + ), + ) + + result = df.load_collective.histogram([0, 1, 2, 3]) + + pd.testing.assert_series_equal(result.to_pandas(), expected) + + +# GH-107 +def test_load_collective_histogram_interval_index(): + df = pd.DataFrame({ + 'range': [1., 2., 1., 2., 1], + 'mean': [0.5, 1.0, 0.5, 1.0, 0.5] + }, columns=['range', 'mean']) + + expected_intervals = pd.IntervalIndex.from_tuples([(0, 1), (1, 2), (2, 3)]) + expected = pd.Series( + [0.0, 0.0, 0.0, 3.0, 0.0, 0.0, 0.0, 2.0, 0.0], + name='cycles', + index=pd.MultiIndex.from_product( + [expected_intervals, expected_intervals], names=['range', 'mean'] + ), + ) + + result = df.load_collective.histogram(expected_intervals) + + pd.testing.assert_series_equal(result.to_pandas(), expected) + + +# GH-107 +def test_load_collective_histogram_interval_array(): + df = pd.DataFrame({ + 'range': [1., 2., 1., 2., 1], + 'mean': [0.5, 1.0, 0.5, 1.0, 0.5] + }, columns=['range', 'mean']) + + expected_intervals = pd.IntervalIndex.from_tuples([(0, 1), (1, 2), (2, 3)]) + expected = pd.Series( + [0.0, 0.0, 0.0, 3.0, 0.0, 0.0, 0.0, 2.0, 0.0], + name='cycles', + index=pd.MultiIndex.from_product( + [expected_intervals, expected_intervals], names=['range', 'mean'] + ), + ) + + intervals = pd.arrays.IntervalArray.from_tuples([(0, 1), (1, 2), (2, 3)]) + result = df.load_collective.histogram(intervals) + + pd.testing.assert_series_equal(result.to_pandas(), expected) + + +# GH-107 +def test_load_collective_histogram_unnested_grouped(): + element_idx = pd.Index([10, 20, 30], name='element_id') + cycle_idx = pd.Index([0, 1, 2], name='cycle_number') + idx = pd.MultiIndex.from_product((element_idx, cycle_idx)) + + df = pd.DataFrame({ + 'range': [1., 2., 1., 2., 1., 2., 1., 1., 1], + 'mean': [0, 0, 0, 0, 0, 0, 0, 0, 0] + }, columns=['range', 'mean'], index=idx) + + expected_intervals = pd.IntervalIndex.from_tuples([(0, 1), (1, 2), (2, 3)]) + expected_ranges = expected_intervals.set_names(['range']) + expected_means = expected_intervals.set_names(['mean']) + + expected_index = pd.MultiIndex.from_product( + [element_idx, expected_ranges, expected_means] + ) + expected = pd.Series( + [0, 0, 0, 2, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 2, 0, 0, 0, 0, 0, 3, 0, 0, 0, 0, 0], + name='cycles', + index=expected_index, + dtype=np.float64, + ) + + result = df.load_collective.histogram([0, 1, 2, 3], 'cycle_number') + pd.testing.assert_series_equal(result.to_pandas(), expected) + + +# GH-107 +def test_load_collective_histogram_nested_grouped(): + element_idx = pd.Index([10, 20], name='element_id') + node_idx = pd.Index([100, 101], name='node_id') + cycle_idx = pd.Index([0, 1], name='cycle_number') + idx = pd.MultiIndex.from_product((element_idx, node_idx, cycle_idx)) + + df = pd.DataFrame({ + 'range': [1., 2., 1., 2., 1., 2., 1., 2.], + 'mean': [0, 0, 0, 0, 0, 0, 0, 0] + }, columns=['range', 'mean'], index=idx) + + expected_intervals = pd.IntervalIndex.from_tuples([(0, 1), (1, 2), (2, 3)]) + expected_ranges = expected_intervals.set_names(['range']) + expected_means = expected_intervals.set_names(['mean']) + expected_index = pd.MultiIndex.from_product( + [element_idx, node_idx, expected_ranges, expected_means] + ) + expected = pd.Series( + [0, 0, 0, 1, 0, 0, 1, 0, 0] * 4, + name='cycles', + index=expected_index, + dtype=np.float64, + ) + + result = df.load_collective.histogram([0, 1, 2, 3], 'cycle_number') + + pd.testing.assert_series_equal(result.to_pandas(), expected) From de1d2ee9d2439d2c0b72927603ae0acd65866a49 Mon Sep 17 00:00:00 2001 From: Johannes Mueller Date: Thu, 10 Oct 2024 15:49:38 +0200 Subject: [PATCH 3/3] Filter FutureWarning to accommodate python-3.8 Signed-off-by: Johannes Mueller --- .../stress/collective/load_collective.py | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/pylife/stress/collective/load_collective.py b/src/pylife/stress/collective/load_collective.py index 6c9111f2..30afcb9d 100644 --- a/src/pylife/stress/collective/load_collective.py +++ b/src/pylife/stress/collective/load_collective.py @@ -17,14 +17,17 @@ __author__ = "Johannes Mueller" __maintainer__ = __author__ -from .abstract_load_collective import AbstractLoadCollective -from .load_histogram import LoadHistogram +import warnings import pandas as pd import numpy as np from pylife import PylifeSignal +from .abstract_load_collective import AbstractLoadCollective +from .load_histogram import LoadHistogram + + @pd.api.extensions.register_dataframe_accessor('load_collective') class LoadCollective(PylifeSignal, AbstractLoadCollective): """A Load collective. @@ -364,12 +367,15 @@ def make_histogram(group): if axis is None: return LoadHistogram(make_histogram(range_mean)) - result = pd.Series( - range_mean.groupby(self._levels_from_axis(axis)) - .apply(make_histogram) - .stack(['range', 'mean'], future_stack=True), - name="cycles", - ) + # TODO: Warning filter can be dropped as soon as python-3.8 support is dropped + with warnings.catch_warnings(): + warnings.simplefilter("ignore", category=FutureWarning) + result = pd.Series( + range_mean.groupby(self._levels_from_axis(axis)) + .apply(make_histogram) + .stack(['range', 'mean']), + name="cycles", + ) return LoadHistogram(result)