Repository: PMEAL/porespy

Issue:
Update `satn_profile` to accept an already thresholded image

Discussion:

Patch:
diff --git a/porespy/metrics/_funcs.py b/porespy/metrics/_funcs.py
index d1001a351..e44651570 100644
--- a/porespy/metrics/_funcs.py
+++ b/porespy/metrics/_funcs.py
@@ -1104,7 +1104,7 @@ def pc_curve(im, sizes=None, pc=None, seq=None,
     return pc_curve
 
 
-def satn_profile(satn, s, axis=0, span=10, mode='tile'):
+def satn_profile(satn, s=None, im=None, axis=0, span=10, mode='tile'):
     r"""
     Computes a saturation profile from an image of fluid invasion
 
@@ -1115,7 +1115,12 @@ def satn_profile(satn, s, axis=0, span=10, mode='tile'):
         invasion.  0's are treated as solid and -1's are treated as uninvaded
         void space.
     s : scalar
-        The global saturation value for which the profile is desired
+        The global saturation value for which the profile is desired. If `satn` is
+        a pre-thresholded boolean image then this is ignored, `im` is required.
+    im : ndarray
+        A boolean image with `True` values indicating the void phase. This is used
+        to compute the void volume if `satn` is given as a pre-thresholded boolean
+        mask.
     axis : int
         The axis along which to profile should be measured
     span : int
@@ -1153,46 +1158,41 @@ def satn_profile(satn, s, axis=0, span=10, mode='tile'):
     <https://porespy.org/examples/metrics/reference/satn_profile.html>`_
     to view online example.
     """
-    # @numba.njit()
-    def func(satn, s, axis, span, mode):
-        span = max(1, span)
-        satn = np.swapaxes(satn, 0, axis)
-        if mode == 'tile':
-            y = np.zeros(int(satn.shape[0]/span))
-            z = np.zeros_like(y)
-            for i in range(int(satn.shape[0]/span)):
-                void = satn[i*span:(i+1)*span, ...] != 0
-                nwp = (satn[i*span:(i+1)*span, ...] < s) \
-                    *(satn[i*span:(i+1)*span, ...] > 0)
-                y[i] = nwp.sum(dtype=np.int64)/void.sum(dtype=np.int64)
-                z[i] = i*span + (span-1)/2
-        if mode == 'slide':
-            y = np.zeros(int(satn.shape[0]-span))
-            z = np.zeros_like(y)
-            for i in range(int(satn.shape[0]-span)):
-                void = satn[i:i+span, ...] != 0
-                nwp = (satn[i:i+span, ...] < s)*(satn[i:i+span, ...] > 0)
-                y[i] = nwp.sum(dtype=np.int64)/void.sum(dtype=np.int64)
-                z[i] = i + (span-1)/2
-        return z, y
-
-    z, y = func(satn=satn, s=s, axis=axis, span=span, mode=mode)
-
-    class results(Results):
-        r"""
-
-        Attributes
-        ----------
-        position : ndarray
-            The position along the given axis at which saturation values are
-            computed.  The units are in voxels.
-        saturation : ndarray
-            The computed saturation value at each position
-
-        """
-        position = z
-        saturation = y
-
+    span = max(1, span)
+    if s is None:
+        if satn.dtype != bool:
+            msg = 'Must specify a target saturation if saturation map is provided'
+            raise Exception(msg)
+        s = 2  # Will find ALL voxels, then > 0 will limit to only True ones
+        satn = satn.astype(int)
+        satn[satn == 0] = -1
+        satn[~im] = 0
+    else:
+        msg = 'The maximum saturation in the image is less than the given threshold'
+        if satn.max() < s:
+            raise Exception(msg)
+
+    satn = np.swapaxes(satn, 0, axis)
+    if mode == 'tile':
+        y = np.zeros(int(satn.shape[0]/span))
+        z = np.zeros_like(y)
+        for i in range(int(satn.shape[0]/span)):
+            void = satn[i*span:(i+1)*span, ...] != 0
+            nwp = (satn[i*span:(i+1)*span, ...] <= s) \
+                *(satn[i*span:(i+1)*span, ...] > 0)
+            y[i] = nwp.sum(dtype=np.int64)/void.sum(dtype=np.int64)
+            z[i] = i*span + (span-1)/2
+    if mode == 'slide':
+        y = np.zeros(int(satn.shape[0]-span))
+        z = np.zeros_like(y)
+        for i in range(int(satn.shape[0]-span)):
+            void = satn[i:i+span, ...] != 0
+            nwp = (satn[i:i+span, ...] <= s)*(satn[i:i+span, ...] > 0)
+            y[i] = nwp.sum(dtype=np.int64)/void.sum(dtype=np.int64)
+            z[i] = i + (span-1)/2
+    results = Results()
+    results.position = z
+    results.saturation = y
     return results
 
 


Test patch:
diff --git a/test/unit/test_metrics.py b/test/unit/test_metrics.py
index 993b3fd81..8879d3846 100644
--- a/test/unit/test_metrics.py
+++ b/test/unit/test_metrics.py
@@ -256,6 +256,61 @@ class MetricsTest():
         assert hasattr(pc, 'pc')
         assert hasattr(pc, 'snwp')
 
+    def test_satn_profile_axis(self):
+        satn = np.tile(np.atleast_2d(np.linspace(1, 0.01, 100)), (100, 1))
+        satn[:25, :] = 0
+        satn[-25:, :] = -1
+        prof1 = ps.metrics.satn_profile(satn=satn, s=0.5, axis=1, span=1, mode='tile')
+        assert len(prof1.saturation) == 100
+        assert prof1.saturation[0] == 0
+        assert prof1.saturation[-1] == 2/3
+        assert prof1.saturation[49] == 0
+        assert prof1.saturation[50] == 2/3
+        prof1 = ps.metrics.satn_profile(satn=satn, s=0.5, axis=0, span=1, mode='tile')
+        assert len(prof1.saturation) == 100
+        assert np.isnan(prof1.saturation[0])
+        assert prof1.saturation[-1] == 0
+        assert prof1.saturation[50] == 0.5
+
+    def test_satn_profile_span(self):
+        satn = np.tile(np.atleast_2d(np.linspace(1, 0.01, 100)), (100, 1))
+        satn[:25, :] = 0
+        satn[-25:, :] = -1
+        prof1 = ps.metrics.satn_profile(satn=satn, s=0.5, axis=1, span=20, mode='tile')
+        assert len(prof1.saturation) == 5
+        assert prof1.saturation[0] == 0
+        assert prof1.saturation[-1] == 2/3
+        assert prof1.saturation[2] == 1/3
+        prof1 = ps.metrics.satn_profile(satn=satn, s=0.5, axis=1, span=20, mode='slide')
+        assert len(prof1.saturation) == 80
+        assert prof1.saturation[31] == 1/30
+        assert prof1.saturation[48] == 0.6
+
+    def test_satn_profile_threshold(self):
+        satn = np.tile(np.atleast_2d(np.linspace(1, 0.01, 100)), (100, 1))
+        satn[:25, :] = 0
+        satn[-25:, :] = -1
+        prof1 = ps.metrics.satn_profile(satn=satn, s=0.5, axis=1, span=1, mode='tile')
+        t = (satn <= 0.5)*(satn > 0)
+        im = satn != 0
+        prof2 = ps.metrics.satn_profile(satn=t, im=im, axis=1, span=1, mode='tile')
+        assert len(prof1.saturation) == 100
+        assert len(prof2.saturation) == 100
+        assert np.all(prof1.saturation == prof2.saturation)
+        prof1 = ps.metrics.satn_profile(satn=satn, s=0.5, axis=1, span=10, mode='tile')
+        prof2 = ps.metrics.satn_profile(satn=t, im=im, axis=1, span=10, mode='tile')
+        assert np.all(prof1.saturation == prof2.saturation)
+        prof1 = ps.metrics.satn_profile(satn=satn, s=0.5, axis=1, span=20, mode='slide')
+        prof2 = ps.metrics.satn_profile(satn=t, im=im, axis=1, span=20, mode='slide')
+        assert np.all(prof1.saturation == prof2.saturation)
+
+    def test_satn_profile_exception(self):
+        satn = np.tile(np.atleast_2d(np.linspace(0.4, 0.01, 100)), (100, 1))
+        satn[:25, :] = 0
+        satn[-25:, :] = -1
+        with pytest.raises(Exception):
+            prof1 = ps.metrics.satn_profile(satn=satn, s=0.5)
+
 
 if __name__ == '__main__':
     t = MetricsTest()