Repository: martinfleis/momepy
Pull request #20 (2019-07-09 11:51:59)

ENH: Resilience characters
Added and refactored characters focusing on resilience of urban form.

enhanced `street_profile()` and refactored its return as dict (which should be standard for multi-value returns)
enhanced `reached` to allow sum, std, mean for any column


codecov[bot]: # [Codecov](https://codecov.io/gh/martinfleis/momepy/pull/20?src=pr&el=h1) Report
> Merging [#20](https://codecov.io/gh/martinfleis/momepy/pull/20?src=pr&el=desc) into [master](https://codecov.io/gh/martinfleis/momepy/commit/a9c8cd94d031b1f226b9826135f1b8abb09a9da1?src=pr&el=desc) will **decrease** coverage by `0.77%`.
> The diff coverage is `76.47%`.

[![Impacted file tree graph](https://codecov.io/gh/martinfleis/momepy/pull/20/graphs/tree.svg?width=650&token=i04999UxkK&height=150&src=pr)](https://codecov.io/gh/martinfleis/momepy/pull/20?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master      #20      +/-   ##
==========================================
- Coverage   88.78%   88.01%   -0.78%     
==========================================
  Files           9        9              
  Lines        2291     2327      +36     
==========================================
+ Hits         2034     2048      +14     
- Misses        257      279      +22
```


| [Impacted Files](https://codecov.io/gh/martinfleis/momepy/pull/20?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [momepy/intensity.py](https://codecov.io/gh/martinfleis/momepy/pull/20/diff?src=pr&el=tree#diff-bW9tZXB5L2ludGVuc2l0eS5weQ==) | `78.16% <30.76%> (-4.16%)` | :arrow_down: |
| [momepy/dimension.py](https://codecov.io/gh/martinfleis/momepy/pull/20/diff?src=pr&el=tree#diff-bW9tZXB5L2RpbWVuc2lvbi5weQ==) | `94.42% <92.1%> (-3.37%)` | :arrow_down: |
| [momepy/elements.py](https://codecov.io/gh/martinfleis/momepy/pull/20/diff?src=pr&el=tree#diff-bW9tZXB5L2VsZW1lbnRzLnB5) | `82.25% <0%> (-0.12%)` | :arrow_down: |
| [momepy/diversity.py](https://codecov.io/gh/martinfleis/momepy/pull/20/diff?src=pr&el=tree#diff-bW9tZXB5L2RpdmVyc2l0eS5weQ==) | `84.31% <0%> (ø)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/martinfleis/momepy/pull/20?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/martinfleis/momepy/pull/20?src=pr&el=footer). Last update [a9c8cd9...86336b8](https://codecov.io/gh/martinfleis/momepy/pull/20?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/momepy/dimension.py b/momepy/dimension.py
index 7112822..63d0d49 100644
--- a/momepy/dimension.py
+++ b/momepy/dimension.py
@@ -401,7 +401,7 @@ def mean_character(objects, spatial_weights=None, values=None, order=3, rng=None
 
 def street_profile(streets, buildings, heights=None, distance=10, tick_length=50):
     """
-    Calculates the street profile widths, heights, and ratio height/width
+    Calculates the street profile widths, standard deviation of width, heights, and ratio height/width
 
     .. math::
         \\
@@ -422,32 +422,37 @@ def street_profile(streets, buildings, heights=None, distance=10, tick_length=50
 
     Returns
     -------
-    widths, (heights, profile_ratio) : tuple
+    street_profile : dictionary
 
-    widths : Series
+    'widths' : Series
         Series containing street profile width values.
-    heights : Series, optional
+    'width_deviations' : Series
+        Series containing street profile standard deviation values.
+    'openness' : Series
+        Series containing street profile openness values.
+    'heights' : Series, optional
         Series containing street profile heights values. Returned only when heights is set.
-    profile_ratio : Series, optional
+    'heights_deviations' : Series, optional
+        Series containing street profile heights standard deviation values. Returned only when heights is set.
+    'profile' : Series, optional
         Series containing street profile height/width ratio values. Returned only when heights is set.
 
     References
     ----------
     Oliveira V (2013) Morpho: a methodology for assessing urban form. Urban Morphology 17(1): 21–33.
+    Araldi and Fusco...
 
     Examples
     --------
-    >>> widths, heights, profile = momepy.street_profile(streets_df, buildings_df, heights='height')
+    >>> street_profile = momepy.street_profile(streets_df, buildings_df, heights='height')
     Calculating street profile...
     100%|██████████| 33/33 [00:02<00:00, 15.66it/s]
     Street profile calculated.
-    >>> streets_df['width'] = widths
-    >>> streets_df['height'] = heights
-    >>> streets_df['profile'] = profile
+    >>> streets_df['width'] = street_profile['widths']
+    >>> streets_df['deviations'] = street_profile['width_deviations']
 
     Notes
     -----
-    Add condition for segments without buildings around
     Add explanation of algorithm
 
     """
@@ -481,7 +486,10 @@ def street_profile(streets, buildings, heights=None, distance=10, tick_length=50
     sindex = buildings.sindex
 
     results_list = []
+    deviations_list = []
     heights_list = []
+    heights_deviations_list = []
+    openness_list = []
 
     if heights is not None:
         if not isinstance(heights, str):
@@ -489,6 +497,8 @@ def street_profile(streets, buildings, heights=None, distance=10, tick_length=50
             heights = 'mm_h'
 
     for idx, row in tqdm(streets.iterrows(), total=streets.shape[0]):
+        # if idx == 2:
+        #     ee
         # list to hold all the point coords
         list_points = []
         # set the current distance to place the point
@@ -539,11 +549,13 @@ def street_profile(streets, buildings, heights=None, distance=10, tick_length=50
                 tick1 = LineString([(line_end_1.x, line_end_1.y), (pt.x, pt.y)])
                 tick2 = LineString([(line_end_2.x, line_end_2.y), (pt.x, pt.y)])
                 ticks.append([tick1, tick2])
-        widths = []
+        # widths = []
         m_heights = []
+        left = []
+        right = []
         for duo in ticks:
-            width = []
-            for tick in duo:
+
+            for ix, tick in enumerate(duo):
                 possible_intersections_index = list(sindex.intersection(tick.bounds))
                 possible_intersections = buildings.iloc[possible_intersections_index]
                 real_intersections = possible_intersections.intersects(tick)
@@ -567,9 +579,15 @@ def street_profile(streets, buildings, heights=None, distance=10, tick_length=50
                             distances.append(distance)
                             ix = ix + 1
                         minimal = min(distances)
-                        width.append(minimal)
+                        if ix == 0:
+                            left.append(minimal)
+                        else:
+                            right.append(minimal)
                     else:
-                        width.append(true_int[0].distance(Point(tick.coords[-1])))
+                        if ix == 0:
+                            left.append(true_int[0].distance(Point(tick.coords[-1])))
+                        else:
+                            right.append(true_int[0].distance(Point(tick.coords[-1])))
                     if heights is not None:
                         indices = {}
                         for idx, row in get_height.iterrows():
@@ -577,25 +595,45 @@ def street_profile(streets, buildings, heights=None, distance=10, tick_length=50
                             indices[idx] = dist
                         minim = min(indices, key=indices.get)
                         m_heights.append(buildings.iloc[minim][heights])
-                else:
-                    width.append(np.nan)
-            widths.append(width[0] + width[1])
 
-        results_list.append(np.nanmean(widths))
+        openness = (len(left) + len(right)) / len(ticks * 2)
+        openness_list.append(1 - openness)
+        if right and left:
+            results_list.append(2 * np.mean(left + right))
+            deviations_list.append(np.std(left + right))
+        elif not left and right:
+            results_list.append(2 * np.mean([np.mean(right), tick_length / 2]))
+            deviations_list.append(np.std(right))
+        elif not right and left:
+            results_list.append(2 * np.mean([np.mean(left), tick_length / 2]))
+            deviations_list.append(np.std(left))
+        else:
+            results_list.append(tick_length)
+            deviations_list.append(0)
+
         if heights is not None:
-            heights_list.append(np.mean(m_heights))
+            if m_heights:
+                heights_list.append(np.mean(m_heights))
+                heights_deviations_list.append(np.std(m_heights))
+            else:
+                heights_list.append(0)
+                heights_deviations_list.append(0)
+
+    street_profile = {}
+    street_profile['widths'] = pd.Series(results_list)
+    street_profile['width_deviations'] = pd.Series(deviations_list)
+    street_profile['openness'] = pd.Series(openness_list)
 
-    widths_series = pd.Series(results_list)
     if heights is not None:
-        heights_series = pd.Series(heights_list)
-        profile_ratio = heights_series / widths_series
+        street_profile['heights'] = pd.Series(heights_list)
+        street_profile['heights_deviations'] = pd.Series(heights_deviations_list)
+        street_profile['profile'] = street_profile['heights'] / street_profile['widths']
         if 'mm_h' in buildings.columns:
             buildings.drop(columns=['mm_h'], inplace=True)
         print('Street profile calculated.')
-        return widths_series, heights_series, profile_ratio
 
     print('Street profile calculated.')
-    return widths_series
+    return street_profile
 
 
 def weighted_character(objects, tessellation, characters, unique_id, spatial_weights=None, areas=None, order=3):
diff --git a/momepy/elements.py b/momepy/elements.py
index 8e54a6e..b160749 100644
--- a/momepy/elements.py
+++ b/momepy/elements.py
@@ -891,7 +891,8 @@ def get_node_id(objects, nodes, edges, node_id, edge_id):
 
     results_list = []
     for index, row in tqdm(objects.iterrows(), total=objects.shape[0]):
-        if np.isnan(row.nID):
+        if np.isnan(row[edge_id]):
+
             results_list.append(np.nan)
         else:
             centroid = row.geometry.centroid
diff --git a/momepy/intensity.py b/momepy/intensity.py
index e329a2a..ab0ccaf 100644
--- a/momepy/intensity.py
+++ b/momepy/intensity.py
@@ -381,14 +381,14 @@ def blocks_count(tessellation, block_id, spatial_weights=None, order=5):
     return series
 
 
-def reached(streets, elements, unique_id, spatial_weights=None, mode='count'):
+def reached(streets, elements, unique_id, spatial_weights=None, mode='count', values=None):
     """
     Calculates the number of elements reached within topological steps
 
     Number of elements within topological steps defined in spatial_weights. If
     spatial_weights are None, it will assume topological distance 0 (element itself).
     If mode='area', returns sum of areas of reached elements. Requires unique_id
-    of streets assigned beforehand (e.g. using :py:func:`momepy.elements.get_network_id`).
+    of streets assigned beforehand (e.g. using :py:func:`momepy.get_network_id`).
 
     .. math::
 
@@ -405,9 +405,13 @@ def reached(streets, elements, unique_id, spatial_weights=None, mode='count'):
     spatial_weights : libpysal.weights (default None)
         spatial weights matrix
     mode : str (default 'count')
-        mode of calculation. If 'count' function will return the count of reached elements.
-        If 'area', it will return sum of areas of reached elements
-
+        mode of calculation. If `'count'` function will return the count of reached elements.
+        If `'sum'`, it will return sum of `'values'`. If `'mean'` it will return mean value
+        of `'values'`. If `'std'` it will return standard deviation
+        of `'values'`. If `'values'` not set it will use of areas
+        of reached elements.
+    values : str (default None)
+        the name of the objects dataframe column with values used for calculations
 
     Returns
     -------
@@ -446,8 +450,21 @@ def reached(streets, elements, unique_id, spatial_weights=None, mode='count'):
             for nid in ids:
                 counts.append(count[nid])
             results_list.append(sum(counts))
-        elif mode == 'area':
-            results_list.append(sum(elements.loc[elements[unique_id].isin(ids)].geometry.area))
+        elif mode == 'sum':
+            if values:
+                results_list.append(sum(elements.loc[elements[unique_id].isin(ids)][values]))
+            else:
+                results_list.append(sum(elements.loc[elements[unique_id].isin(ids)].geometry.area))
+        elif mode == 'mean':
+            if values:
+                results_list.append(np.nanmean(elements.loc[elements[unique_id].isin(ids)][values]))
+            else:
+                results_list.append(np.nanmean(elements.loc[elements[unique_id].isin(ids)].geometry.area))
+        elif mode == 'std':
+            if values:
+                results_list.append(np.nanstd(elements.loc[elements[unique_id].isin(ids)][values]))
+            else:
+                results_list.append(np.nanstd(elements.loc[elements[unique_id].isin(ids)].geometry.area))
 
     series = pd.Series(results_list)
     if 'mm_id' in elements.columns:


Test patch:
diff --git a/tests/test_dimension.py b/tests/test_dimension.py
index 5a2103a..c119024 100644
--- a/tests/test_dimension.py
+++ b/tests/test_dimension.py
@@ -122,21 +122,23 @@ class TestDimensions:
             self.df_tessellation['meshE'] = mm.mean_character(gdf)
 
     def test_street_profile(self):
-        widths, heights, profile = mm.street_profile(self.df_streets, self.df_buildings, heights='height')
-        assert widths[0] == 48.12133795186165
-        assert heights[0] == 19.454545454545457
-        assert profile[0] == 0.4042810587271468
+        results = mm.street_profile(self.df_streets, self.df_buildings, heights='height')
+        assert results['widths'][0] == 46.7290758769204
+        assert results['width_deviations'][0] == 0.5690458650581023
+        assert results['heights'][0] == 19.454545454545457
+        assert results['profile'][0] == 0.4163263469148574
+        assert results['openness'][0] == 0.9107142857142857
+        assert results['heights_deviations'][0] == 7.720786443287433
 
     def test_street_profile_array(self):
         height = np.linspace(10., 30., 144)
-        widths, heights, profile = mm.street_profile(self.df_streets, self.df_buildings, heights=height, tick_length=100)
-        assert widths[0] == 59.119197738278075
-        assert heights[0] == 23.756643356643362
-        assert profile[0] == 0.40184312821385904
-
-    def test_street_profile_wrongtuple(self):
-        with pytest.raises(ValueError):
-            widths, heights, profile = mm.street_profile(self.df_streets, self.df_buildings)
+        results = mm.street_profile(self.df_streets, self.df_buildings, heights=height, tick_length=100)
+        assert results['widths'][0] == 67.563796073073
+        assert results['width_deviations'][0] == 8.791875291865827
+        assert results['heights'][0] == 23.756643356643362
+        assert results['profile'][0] == 0.3516179483306353
+        assert results['openness'][0] == 0.5535714285714286
+        assert results['heights_deviations'][0] == 5.526848034418866
 
     def test_weighted_character(self):
         weighted = mm.weighted_character(self.df_buildings, self.df_tessellation, 'height', 'uID')
diff --git a/tests/test_intensity.py b/tests/test_intensity.py
index b12769a..06cc154 100644
--- a/tests/test_intensity.py
+++ b/tests/test_intensity.py
@@ -66,9 +66,19 @@ class TestIntensity:
 
     def test_reached(self):
         count = mm.reached(self.df_streets, self.df_buildings, 'nID')
-        area = mm.reached(self.df_streets, self.df_buildings, 'nID', mode='area')
+        area = mm.reached(self.df_streets, self.df_buildings, 'nID', mode='sum')
+        mean = mm.reached(self.df_streets, self.df_buildings, 'nID', mode='mean')
+        std = mm.reached(self.df_streets, self.df_buildings, 'nID', mode='std')
+        area_v = mm.reached(self.df_streets, self.df_buildings, 'nID', mode='sum', values='fl_area')
+        mean_v = mm.reached(self.df_streets, self.df_buildings, 'nID', mode='mean', values='fl_area')
+        std_v = mm.reached(self.df_streets, self.df_buildings, 'nID', mode='std', values='fl_area')
         sw = mm.Queen_higher(k=2, geodataframe=self.df_streets)
         count_sw = mm.reached(self.df_streets, self.df_buildings, 'nID', sw)
         assert max(count) == 18
         assert max(area) == 18085.45897711331
         assert max(count_sw) == 138
+        assert max(mean) == 1808.5458977113315
+        assert max(std) == 3153.7019229524785
+        assert max(area_v) == 79169.31385861784
+        assert max(mean_v) == 7916.931385861784
+        assert max(std_v) == 8995.18003493457