Repository: thinkingmachines/geomancer

Issue:
Add LengthOf function
The `LengthOf` function would compute the length of all line features inside a certain circular `radius` centered at a location with a certain `lat` and `lon`.

Comments:

Patch:
diff --git a/geomancer/spells/__init__.py b/geomancer/spells/__init__.py
index 92e6755..501b5d4 100644
--- a/geomancer/spells/__init__.py
+++ b/geomancer/spells/__init__.py
@@ -1,7 +1,7 @@
 # -*- coding: utf-8 -*-
 
 from .distance_to_nearest import DistanceToNearest
+from .length_of import LengthOf
 from .number_of import NumberOf
 
-
-__all__ = ["DistanceToNearest", "NumberOf"]
+__all__ = ["DistanceToNearest", "NumberOf", "LengthOf"]
diff --git a/geomancer/spells/length_of.py b/geomancer/spells/length_of.py
new file mode 100644
index 0000000..2bb308f
--- /dev/null
+++ b/geomancer/spells/length_of.py
@@ -0,0 +1,116 @@
+# -*- coding: utf-8 -*-
+
+# Import modules
+from sqlalchemy import func
+from sqlalchemy.sql import select
+
+from .base import Spell
+from ..backend.cores.bq import BigQueryCore
+
+from loguru import logger
+
+
+class LengthOf(Spell):
+    """Obtain the length of all Lines-of-Interest within a certain radius"""
+
+    def __init__(self, on, within=10 * 1000, **kwargs):
+        """Spell constructor
+
+        Parameters
+        ----------
+        on : str
+            Feature class to compare upon
+        within : float, optional
+            Look for values within a particular range. Its value is in meters,
+            the default is :code:`10,000` meters.
+        source_table : str
+            Table URI to run queries against.
+        feature_name : str
+            Column name for the output feature.
+        column : str, optional
+            Column to look the geometries from. The default is :code:`WKT`
+        options : geomancer.Config
+            Specify configuration for interacting with the database backend.
+            Default is a BigQuery Configuration
+        """
+        super(LengthOf, self).__init__(**kwargs)
+        logger.warning(
+            "ST_Buffer is not yet implemented so BigQueryCore won't work: groups.google.com/d/msg/bq-gis-feedback/Yq4Ku6u2A80/ceVXU01RCgAJ"
+        )
+        self.source_column, self.source_filter = self.extract_columns(on)
+        self.within = within
+
+    def query(self, source, target, core, column):
+        # ST_Buffer is not yet implemented so BigQueryCore won't work
+        # (groups.google.com/d/msg/bq-gis-feedback/Yq4Ku6u2A80/ceVXU01RCgAJ)
+        if isinstance(core, BigQueryCore):
+            raise ValueError(
+                "The LengthOf feature is currently incompatible with \
+                BigQueryCore because ST_Buffer is not yet implemented"
+            )
+
+        # Get all lines-of-interests (LOIs) of fclass `on`
+        lois = select(
+            [source.c[self.source_id], source.c.WKT],
+            source.c[self.source_column] == self.source_filter,
+        ).cte("lois")
+
+        # Create a buffer `within` a distance/radius around each centroid.
+        # The point has to be converted to EPSG:3857 so that meters can be
+        # used instead of decimal degrees for EPSG:4326.
+        buff = select(
+            [
+                target,
+                func.ST_Buffer(
+                    core.ST_GeoFromText(target.c[column]), self.within
+                ).label("__buffer__"),
+            ]
+        ).cte("buff")
+
+        # Clip the LOIs with the buffers then calculate the length of all
+        # LOIs inside each buffer.
+        clip = select(
+            [
+                buff,
+                func.ST_Intersection(
+                    core.ST_GeoFromText(lois.c.WKT),
+                    func.ST_Transform(buff.c["__buffer__"], 4326),
+                ).label("__geom__"),
+                func.ST_Length(
+                    func.ST_Intersection(
+                        func.ST_Transform(
+                            core.ST_GeoFromText(lois.c.WKT), 3857
+                        ),
+                        buff.c["__buffer__"],
+                    )
+                ).label("__len__"),
+            ],
+            func.ST_Intersects(
+                core.ST_GeoFromText(lois.c.WKT),
+                func.ST_Transform(buff.c["__buffer__"], 4326),
+            ),
+        ).cte("clip")
+
+        # Sum the length of all LOIs inside each buffer
+        sum_length = (
+            select(
+                [
+                    clip.c["__index_level_0__"],
+                    func.sum(clip.c["__len__"]).label(self.feature_name),
+                ]
+            )
+            .select_from(clip)
+            .group_by(clip.c["__index_level_0__"])
+            .cte("sum_length")
+        )
+
+        # Join the sum of the length of all LOIs inside each buffer
+        query = select(
+            [
+                col
+                for col in sum_length.columns
+                if col.key not in ("__len__", "__geom__", "__buffer__")
+            ],
+            sum_length.c["__index_level_0__"] == buff.c["__index_level_0__"],
+        )
+        return query


Test patch:
diff --git a/tests/spells/test_length_of.py b/tests/spells/test_length_of.py
new file mode 100644
index 0000000..4fd5d72
--- /dev/null
+++ b/tests/spells/test_length_of.py
@@ -0,0 +1,30 @@
+# -*- coding: utf-8 -*-
+
+# Import modules
+import pytest
+from google.cloud import bigquery
+from tests.spells.base_test_spell import BaseTestSpell, SpellDB
+
+# Import from package
+from geomancer.backend.settings import SQLiteConfig
+from geomancer.spells import LengthOf
+
+params = [
+    SpellDB(
+        spell=LengthOf(
+            on="residential",
+            within=50,
+            source_table="gis_osm_roads_free_1",
+            feature_name="len_residential",
+            options=SQLiteConfig(),
+        ),
+        dburl="sqlite:///tests/data/source.sqlite",
+    )
+]
+
+
+@pytest.mark.slow
+class TestLengthOf(BaseTestSpell):
+    @pytest.fixture(params=params, ids=["roads-sqlite"])
+    def spelldb(self, request):
+        return request.param