Skip to content

Commit f9a8467

Browse files
asinghvi17rafaqz
andauthored
Add support for tables to apply and applyreduce (#91)
* Add support for tables to `apply` * Fix the `reproject` implementation to use constructed traits * Fix error when testing `threaded` * Comment, fix indentation in apply * Implement applyreduce * document in readme and docs that tables are supported * Add DataFrames and Shapefile to project and docs/project * Remove unused function Co-authored-by: Rafael Schouten <[email protected]> * Add some truly wacko tests * Fix test * `_to_table` -> `_table` * Annotate that `_is3d` only returns bools Apparently this was not obvious to the compiler * Don't be lazy when getting names this promotes type stability and I guess we don't care about allocs anyway. --------- Co-authored-by: Rafael Schouten <[email protected]>
1 parent 2eb7328 commit f9a8467

File tree

9 files changed

+130
-24
lines changed

9 files changed

+130
-24
lines changed

Project.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ GeoInterface = "cf35fbd7-0cd7-5166-be24-54bfbe79505f"
99
GeometryBasics = "5c1252a2-5f33-56bf-86c9-59e7332b4326"
1010
LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e"
1111
Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2"
12+
Tables = "bd369af6-aec1-5ad0-b16a-f7cc5008161c"
1213

1314
[weakdeps]
1415
Proj = "c94c279d-25a6-4763-9509-64d165bea63e"
@@ -23,19 +24,22 @@ GeometryBasics = "0.4.7"
2324
LinearAlgebra = "1"
2425
Proj = "1"
2526
Statistics = "1"
27+
Tables = "1"
2628
julia = "1.9"
2729

2830
[extras]
2931
ArchGDAL = "c9ce4bd3-c3d5-55b8-8973-c0e20141b8c3"
3032
CoordinateTransformations = "150eb455-5306-5404-9cee-2592286d6298"
33+
DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0"
3134
Distributions = "31c24e10-a181-5473-b8eb-7969acd0382f"
3235
GeoFormatTypes = "68eda718-8dee-11e9-39e7-89f7f65f511f"
3336
GeoJSON = "61d90e0f-e114-555e-ac52-39dfb47a3ef9"
3437
JLD2 = "033835bb-8acc-5ee8-8aae-3f567f8a3819"
3538
LibGEOS = "a90b1aa1-3769-5649-ba7e-abc5a9d163eb"
3639
Proj = "c94c279d-25a6-4763-9509-64d165bea63e"
3740
Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
41+
Shapefile = "8e980c4a-a4fe-5da2-b3a7-4b4b0353a2f4"
3842
Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
3943

4044
[targets]
41-
test = ["ArchGDAL", "CoordinateTransformations", "Distributions", "GeoFormatTypes", "GeoJSON", "Proj", "JLD2", "LibGEOS", "Random", "Test"]
45+
test = ["ArchGDAL", "CoordinateTransformations", "DataFrames", "Distributions", "GeoFormatTypes", "GeoJSON", "Proj", "JLD2", "LibGEOS", "Random", "Shapefile", "Test"]

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,18 +24,19 @@ GeometryOps tries to offer most of the basic geometry operations you'd need, imp
2424

2525
- General geometry methods (OGC methods): `equals`, `extent`, `distance`, `crosses`, `contains`, `intersects`, etc
2626
- Targeted function application over large nested geometries (`apply`) and reduction over geometries (`applyreduce`)
27+
- Both `apply` and `applyreduce` consume arbitrary tables as well, like DataFrames!
2728
- `signed_area`, `centroid`, `distance`, etc for valid geometries
2829
- Line and polygon simplification (`simplify`)
2930
- Polygon clipping, `intersection`, `difference` and `union`
3031
- Generalized barycentric coordinates in polygons (`barycentric_coordinates`)
3132
- Projection of geometries between coordinate reference systems using [Proj.jl](https://github.com/JuliaGeo/Proj.jl)
3233
- Polygonization of raster images by contour detection (`polygonize`)
34+
- Segmentization/densification of geometry, both linearly and by geodesic paths (`segmentize`)
3335

3436
See the "API" page in the docs for a more complete list!
3537

3638
### Planned additions
3739

38-
- Arclength interpolation (absolute and relative)
3940
- Buffering, hulls (convex and otherwise)
4041
- Checks for valid geometries (empty linestrings, null points, etc) ([#14](https://github.com/asinghvi17/GeometryOps.jl/issues/14))
4142
- Operations on spherical (non-Euclidean) geometry ([#17](https://github.com/asinghvi17/GeometryOps.jl/issues/17))

docs/Project.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ BenchmarkTools = "6e4b80f9-dd63-53aa-95a3-0cdb28fa8baf"
44
CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0"
55
Chairmarks = "0ca39b1e-fe0b-4e98-acfc-b1656634c4de"
66
CoordinateTransformations = "150eb455-5306-5404-9cee-2592286d6298"
7+
DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0"
78
DataInterpolations = "82cc6244-b520-54b8-b5a6-8a565e85f1d0"
89
DataStructures = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8"
910
Distributions = "31c24e10-a181-5473-b8eb-7969acd0382f"
@@ -17,8 +18,9 @@ GeometryBasics = "5c1252a2-5f33-56bf-86c9-59e7332b4326"
1718
GeometryOps = "3251bfac-6a57-4b6d-aa61-ac1fef2975ab"
1819
LibGEOS = "a90b1aa1-3769-5649-ba7e-abc5a9d163eb"
1920
Literate = "98b081ad-f1c9-55d3-8b20-4c87d4299306"
20-
Proj = "c94c279d-25a6-4763-9509-64d165bea63e"
2121
Makie = "ee78f7c6-11fb-53f2-987a-cfe4a2b5a57a"
2222
MakieThemes = "e296ed71-da82-5faf-88ab-0034a9761098"
2323
Printf = "de0858da-6303-5e67-8744-51eddeeeb8d7"
24+
Proj = "c94c279d-25a6-4763-9509-64d165bea63e"
2425
Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
26+
Shapefile = "8e980c4a-a4fe-5da2-b3a7-4b4b0353a2f4"

docs/src/introduction.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ The `apply` function allows you to decompose a given collection of geometries do
2020

2121
Functionally, it's similar to `map` in the way you apply it to geometries.
2222

23+
`apply` and `applyreduce` take any geometry, vector of geometries, collection of geometries, or table (like `Shapefile.Table`, `DataFrame`, or `GeoTable`)!
24+
2325
### What's this `GeoInterface.Wrapper` thing?
2426

2527
Write a comment about GeoInterface.Wrapper and why it helps in type stability to guarantee a particular return type.

ext/GeometryOpsProjExt/reproject.jl

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,11 @@ function reproject(geom, source_crs, target_crs;
3939
end
4040
function reproject(geom, transform::Proj.Transformation; time=Inf, target_crs=nothing, kw...)
4141
if _is3d(geom)
42-
return apply(GI.PointTrait, geom; crs=target_crs, kw...) do p
42+
return apply(GI.PointTrait(), geom; crs=target_crs, kw...) do p
4343
transform(GI.x(p), GI.y(p), GI.z(p))
4444
end
4545
else
46-
return apply(GI.PointTrait, geom; crs=target_crs, kw...) do p
46+
return apply(GI.PointTrait(), geom; crs=target_crs, kw...) do p
4747
transform(GI.x(p), GI.y(p))
4848
end
4949
end

src/GeometryOps.jl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ module GeometryOps
44

55
using GeoInterface
66
using GeometryBasics
7+
import Tables
78
using LinearAlgebra, Statistics
89
import GeometryBasics.StaticArrays
910
import Base.@kwdef

src/primitives.jl

Lines changed: 68 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -212,17 +212,51 @@ end
212212
# There is no trait and this is not an AbstractArray.
213213
# Try to call _apply over it. We can't use threading
214214
# as we don't know if we can can index into it. So just `map`.
215-
@inline function _apply(f::F, target, ::Nothing, iterable; threaded, kw...) where F
216-
if threaded
217-
# `collect` first so we can use threads
218-
_apply(f, target, collect(iterable); threaded, kw...)
219-
else
220-
apply_to_iterable(x) = _apply(f, target, x; kw...)
221-
map(apply_to_iterable, iterable)
215+
@inline function _apply(f::F, target, ::Nothing, iterable::IterableType; threaded, kw...) where {F, IterableType}
216+
# Try the Tables.jl interface first
217+
if Tables.istable(iterable)
218+
_apply_table(f, target, iterable; threaded, kw...)
219+
else # this is probably some form of iterable...
220+
if threaded isa _True
221+
# `collect` first so we can use threads
222+
_apply(f, target, collect(iterable); threaded, kw...)
223+
else
224+
apply_to_iterable(x) = _apply(f, target, x; kw...)
225+
map(apply_to_iterable, iterable)
226+
end
222227
end
223228
end
229+
#=
230+
Doing this inline in `_apply` is _heavily_ type unstable, so it's best to separate this
231+
by a function barrier.
232+
233+
This function operates `apply` on the `geometry` column of the table, and returns a new table
234+
with the same schema, but with the new geometry column.
235+
236+
This new table may be of the same type as the old one iff `Tables.materializer` is defined for
237+
that table. If not, then a `NamedTuple` is returned.
238+
=#
239+
function _apply_table(f::F, target, iterable::IterableType; threaded, kw...) where {F, IterableType}
240+
_get_col_pair(colname) = colname => Tables.getcolumn(iterable, colname)
241+
# We extract the geometry column and run `apply` on it.
242+
geometry_column = first(GI.geometrycolumns(iterable))
243+
new_geometry = _apply(f, target, Tables.getcolumn(iterable, geometry_column); threaded, kw...)
244+
# Then, we obtain the schema of the table,
245+
old_schema = Tables.schema(iterable)
246+
# filter the geometry column out,
247+
new_names = filter(Base.Fix1(!==, geometry_column), old_schema.names)
248+
# and try to rebuild the same table as the best type - either the original type of `iterable`,
249+
# or a named tuple which is the default fallback.
250+
return Tables.materializer(iterable)(
251+
merge(
252+
NamedTuple{(geometry_column,), Base.Tuple{typeof(new_geometry)}}((new_geometry,)),
253+
NamedTuple(Iterators.map(_get_col_pair, new_names))
254+
)
255+
)
256+
end
257+
224258
# Rewrap all FeatureCollectionTrait feature collections as GI.FeatureCollection
225-
# Maybe use threads to call _apply on componenet features
259+
# Maybe use threads to call _apply on component features
226260
@inline function _apply(f::F, target, ::GI.FeatureCollectionTrait, fc;
227261
crs=GI.crs(fc), calc_extent=_False(), threaded
228262
) where F
@@ -325,14 +359,34 @@ end
325359
_mapreducetasks(applyreduce_array, op, eachindex(A), threaded; init)
326360
end
327361
# Try to applyreduce over iterables
328-
@inline function _applyreduce(f::F, op::O, target, ::Nothing, iterable; threaded, init) where {F, O}
329-
applyreduce_iterable(i) = _applyreduce(f, op, target, x; threaded=_False(), init)
330-
if threaded # Try to `collect` and reduce over the vector with threads
331-
_applyreduce(f, op, target, collect(iterable); threaded, init)
362+
@inline function _applyreduce(f::F, op::O, target, ::Nothing, iterable::IterableType; threaded, init) where {F, O, IterableType}
363+
if Tables.istable(iterable)
364+
_applyreduce_table(f, op, target, iterable; threaded, init)
332365
else
333-
# Try to `mapreduce` the iterable as-is
334-
mapreduce(applyreduce_iterable, op, iterable; init)
366+
applyreduce_iterable(i) = _applyreduce(f, op, target, x; threaded=_False(), init)
367+
if threaded isa _True # Try to `collect` and reduce over the vector with threads
368+
_applyreduce(f, op, target, collect(iterable); threaded, init)
369+
else
370+
# Try to `mapreduce` the iterable as-is
371+
mapreduce(applyreduce_iterable, op, iterable; init)
372+
end
373+
end
374+
end
375+
# In this case, we don't reconstruct the table, but only operate on the geometry column.
376+
function _applyreduce_table(f::F, op::O, target, iterable::IterableType; threaded, init) where {F, O, IterableType}
377+
# We extract the geometry column and run `applyreduce` on it.
378+
geometry_column = first(GI.geometrycolumns(iterable))
379+
return _applyreduce(f, op, target, Tables.getcolumn(iterable, geometry_column); threaded, init)
380+
end
381+
# If `applyreduce` wants features, then applyreduce over the rows as `GI.Feature`s.
382+
function _applyreduce_table(f::F, op::O, target::GI.FeatureTrait, iterable::IterableType; threaded, init) where {F, O, IterableType}
383+
# We extract the geometry column and run `apply` on it.
384+
geometry_column = first(GI.geometrycolumns(iterable))
385+
property_names = Iterators.filter(!=(geometry_column), Tables.schema(iterable).names)
386+
features = map(Tables.rows(iterable)) do row
387+
GI.Feature(Tables.getcolumn(row, geometry_column), properties=NamedTuple(Iterators.map(Base.Fix1(_get_col_pair, row), property_names)))
335388
end
389+
return _applyreduce(f, op, target, features; threaded, init)
336390
end
337391
# Maybe use threads reducing over features of feature collections
338392
@inline function _applyreduce(f::F, op::O, target, ::GI.FeatureCollectionTrait, fc; threaded, init) where {F, O}

src/utils.jl

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
# # Utility functions
22

3-
_is3d(geom) = _is3d(GI.trait(geom), geom)
4-
_is3d(::GI.AbstractGeometryTrait, geom) = GI.is3d(geom)
5-
_is3d(::GI.FeatureTrait, feature) = _is3d(GI.geometry(feature))
6-
_is3d(::GI.FeatureCollectionTrait, fc) = _is3d(GI.getfeature(fc, 1))
7-
_is3d(::Nothing, geom) = _is3d(first(geom)) # Otherwise step into an itererable
3+
_is3d(geom)::Bool = _is3d(GI.trait(geom), geom)
4+
_is3d(::GI.AbstractGeometryTrait, geom)::Bool = GI.is3d(geom)
5+
_is3d(::GI.FeatureTrait, feature)::Bool = _is3d(GI.geometry(feature))
6+
_is3d(::GI.FeatureCollectionTrait, fc)::Bool = _is3d(GI.getfeature(fc, 1))
7+
_is3d(::Nothing, geom)::Bool = _is3d(first(geom)) # Otherwise step into an itererable
88

99
_npoint(x) = _npoint(trait(x), x)
1010
_npoint(::Nothing, xs::AbstractArray) = sum(_npoint, xs)

test/primitives.jl

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import GeoInterface as GI
44
import GeometryOps as GO
55
import GeometryBasics as GB
66
import Proj
7+
import Shapefile, DataFrames
78

89
pv1 = [(1, 2), (3, 4), (5, 6), (1, 2)]
910
pv2 = [(3, 4), (5, 6), (6, 7), (3, 4)]
@@ -19,8 +20,49 @@ poly = GI.Polygon([lr1, lr2])
1920

2021
@test flipped_poly == GI.Polygon([GI.LinearRing([(2, 1), (4, 3), (6, 5), (2, 1)]),
2122
GI.LinearRing([(4, 3), (6, 5), (7, 6), (4, 3)])])
23+
24+
@testset "Tables.jl support" begin
25+
mktempdir() do dir
26+
cd(dir) do
27+
28+
download("https://rawcdn.githack.com/nvkelso/natural-earth-vector/v5.1.2/110m_cultural/ne_110m_admin_0_countries.shp", "countries.shp")
29+
download("https://rawcdn.githack.com/nvkelso/natural-earth-vector/v5.1.2/110m_cultural/ne_110m_admin_0_countries.shx", "countries.shx")
30+
download("https://rawcdn.githack.com/nvkelso/natural-earth-vector/v5.1.2/110m_cultural/ne_110m_admin_0_countries.dbf", "countries.dbf")
31+
download("https://rawcdn.githack.com/nvkelso/natural-earth-vector/v5.1.2/110m_cultural/ne_110m_admin_0_countries.prj", "countries.prj")
32+
countries_table = Shapefile.Table("countries.shp")
33+
34+
@testset "Shapefile" begin
35+
centroid_table = GO.apply(GO.centroid, GO.TraitTarget(GI.PolygonTrait(), GI.MultiPolygonTrait()), countries_table);
36+
centroid_geometry = centroid_table.geometry
37+
# Test that the centroids are correct
38+
@test all(centroid_geometry .== GO.centroid.(countries_table.geometry))
39+
@testset "Columns are preserved" begin
40+
for column in Iterators.filter(!=(:geometry), GO.Tables.columnnames(countries_table))
41+
@test all(GO.Tables.getcolumn(centroid_table, column) .== GO.Tables.getcolumn(countries_table, column))
42+
end
43+
end
44+
end
45+
46+
@testset "DataFrames" begin
47+
countries_df = DataFrames.DataFrame(countries_table)
48+
centroid_df = GO.apply(GO.centroid, GO.TraitTarget(GI.PolygonTrait(), GI.MultiPolygonTrait()), countries_df);
49+
@test centroid_df isa DataFrames.DataFrame
50+
centroid_geometry = centroid_df.geometry
51+
# Test that the centroids are correct
52+
@test all(centroid_geometry .== GO.centroid.(countries_df.geometry))
53+
@testset "Columns are preserved" begin
54+
for column in Iterators.filter(!=(:geometry), GO.Tables.columnnames(countries_df))
55+
@test all(centroid_df[!, column] .== countries_df[!, column])
56+
end
57+
end
58+
end
59+
end
60+
end
61+
end
2262
end
2363

64+
65+
2466
@testset "unwrap" begin
2567
flipped_vectors = GO.unwrap(GI.PointTrait, poly) do p
2668
(GI.y(p), GI.x(p))

0 commit comments

Comments
 (0)