Tutorial
Point inclusion algorithm
This package was born from the need of ensuring a simple way of optimizing the performance of checking whether a point on the Earth's surface is located within a given region (e.g. a country, a satellite beam, or any other area that can be represented by a polygon in latitude/longitude coordinates).
We need to define multiple types of such regions in our downstream packages (e.g. CountriesBorders.jl) and we want to minimize code duplication as much as possible.
The traditional p in region
is well defined in Meshes.jl
but is suboptimal in terms of performance when one wants to check a lot of points (e.g. thousands) over a domain spanning multiple polygons (e.g. the domain of all polygons associated to all countries). The complexity of checking for point inclusion in a polygon increases with the number of points in the polygon, and when you have lots of points and lots of polygons, most of your point/polygon
pair will return false.
The approach to speed up computations taken in this package is to create custom geometries that store not only the polyareas but also the bounding boxes (i.e. a Box
from Meshes.jl
) associated to each polyarea. The modified inclusion algorithm then simply prefilters point/polygon
pairs by checking first inclusion in the bounding box (which is extremely fast) and falling back to checkin inclusion in the polygon only if p in bbox
is true
. This has shown to provide speed ups of 20x-40x in tests when implemented for the CountriesBorders.jl downstream package (see this PR)
FastInGeometry Interface
To simplify exploiting this fast algorithm for custom geometries defined in downstream packages, this package defines an interface that is adhered to when a type satisfies the three conditions below:
- It subtypes the
FastInGeometry
abstract type defined and exported by this package - It has a valid method for the interface function
polyareas
returning an iterable ofPolyArea
s. - It has a valid method for the interface function
bboxes
returning an iterable ofBox
s
In reality, the 2nd and 3rd conditions can also be met by simply containing a field which subtypes GeoBorders
(more details in the next section) or defining a custom method for the interface function geoborders
that returns an instance of type GeoBorders
.
As an example, see the following code snippet defining a simple geometry that satisfies the FastInGeometry
interface by resorting to the simplest approach of having a field subtyping GeoBorders
using GeoBasics
# Define a custom type which subtypes
struct SimpleGeometry <: FastInGeometry{Float64}
name::String
borders::GeoBorders{Float64}
end
The custom geometries adhering to the FastInGeometry
interface are expected to store the relevant bboxes
and polyareas
both in the LatLon
and Cartesian2D
CRS. This is because many of Meshes advanced methods are better defined for Cartesian2D
and so we want to have an easier access to the geometries also in this CRS.
The conversion between LatLon
and Cartesian2D
corresponds to an Equirectangular projection
where 1° of latitude/longitude is mapped to 1m in the projected space. This is also equivalent to the transformation achived by calling the Meshes.flat
function to a point in LatLon
CRS.
GeoBorders
This package defines a single concrete subtype satisfying the FastInGeometry
interface with the GeoBorders
type. This is intended to represent the borders of arbitrary regions, and provides an easy way for custom regions to adhere to the FastInGeometry
interface (as explained above).
Additionally, when provided with geometries that are plain Multi
, PolyArea
or Box
from Meshes (and not other geometries satysfiying the FastInGeometry
interface), the constructor for GeoBorders
automatically tries fixing issues with polygons crossing the antimeridian line. It does so by implementing the algorithm of the antimeridian python library that also provides a simplified explanation of the algorithm in its documentation.
To show the problem of with polygons crossing the antimeridian, consider the following complex polygon that contains holes and crosses the antimeridian multiple times
using GeoBasics
using GeoBasics.Meshes
using GeoBasics.GeoPlottingHelpers
using PlotlyBase
using PlotlyDocumenter
complex_s_poly = let
f = to_cartesian_point
outer = map(f, [ # Exterior part of the polygon, crossing the antimeridian multiple times
(160,30),
(-160,30),
(-160,0),
(170, 0),
(170, -10),
(-160, -10),
(-160, -20),
(160, -20),
(160, 10),
(-170, 10),
(-170, 20),
(160, 20),
]) |> Ring
inner_west = map(f, [ # Hole in the western hemisphere, in the lower right of the polygon
(-162, -12),
(-162, -18),
(-168, -18),
(-168, -12)
]) |> Ring
inner_east = map(f, [ # Hole in the eastern hemisphere, in the upper left of the polygon
(162, 22),
(162, 28),
(168, 28),
(168, 22)
]) |> Ring
inner_both = map(f, [ # Hole crossing the antimeridian, in the middle of the polygon
(170, 2),
(170, 8),
(-170, 8),
(-170, 2)
]) |> Ring
PolyArea([outer, inner_west, inner_east, inner_both])
end
plt = with_settings(:OVERSAMPLE_LINES => :SHORT) do # This makes sure that the plot uses the shortest line between points
data = geo_plotly_trace(complex_s_poly)
Plot(data, Layout(;
geo = attr(;
center_lon = 180,
lonaxis_range = [0, 360]
)
))
end
In reality, the polygon looks fine because we forced the plot to use shortest line between points for plotting (thus crossing the antimeridian). The actual polygon defined with those coordinates represents instead the following degenerate region:
plt = with_settings(:OVERSAMPLE_LINES => :NORMAL) do # Actually draw lines from endpoints without using shorted line
data = geo_plotly_trace(complex_s_poly)
Plot(data)
end
which can also be verified by checking point inclusion of one point that shouldn't be inside but is, and of one that should be in but isn't:
# We use cartesian point as point inclusion in LatLon is not always defined in Meshes
should_not = to_cartesian_point(LatLon(25, 0)) # This is in africa and shouldn't be in the intended polygon
should_be = to_cartesian_point(LatLon(25, 180)) # This is in the ocean inside the polygon and outside it's holes
map(in(complex_s_poly), (;should_be, should_not))
(should_be = false, should_not = true)
By feeding this polygon into the constructor of GeoBorders
, the antimeridian crossin is handled by splitting the input polygon into 4 subpolygons whenever a crossing of the antimeridian is encountered:
gb = GeoBorders(complex_s_poly)
plt = with_settings(:OVERSAMPLE_LINES => :NORMAL) do # Actually draw lines from endpoints without using shorted line, showing that GeoBorders handle this correctly
data = geo_plotly_trace(gb)
Plot(data, Layout(;
geo = attr(;
center_lon = 180,
lonaxis_range = [0, 360]
)
))
end
And checking again for point inclusion we find the expected behavior (including a point in the hole not being considered inside).
inside_hole = LatLon(25, 165) # Notice that this does not need to be a Point
map(in(gb), (; should_be, should_not, inside_hole))
(should_be = true, should_not = false, inside_hole = false)
As seen from the last example, there is one additional advantage to satisfying the FastInGeometry
interface. Inclusion in a FastInRegion
does not need to use points which are of Point
type with the exact same CRS
as the geometry (as in plain Meshes) but can be:
- a
Point
with eitherLatLon{WGS84Latest}
orCartesian2D{WGS84Latest}
CRS, - a plain
LatLon{WGS84Latest}
coordinate.
Override antimeridian fix
The procedure in the GeoBorders
constructor that fixes the antimeridian crossing decides if there is one by simply checking if any segment of a polygon spans more than 180° degrees of longitude.
In some cases, this has the unintended consequence of modifying the intended polygon. Consider for example a rectangular polygon going from -100 to +100 longitude and from -20 to + 20 latitude. Creating a GeoBorders
with this input polygon will uncorrectly split it:
large_rectangle = let
f = to_latlon_point(Float64)
PolyArea(map(f, [
LatLon(-20, -100),
LatLon(-20, 100),
LatLon(20, 100),
LatLon(20, -100),
]))
end
plt = with_settings(:OVERSAMPLE_LINES => :NORMAL) do # Actually draw lines from endpoints without using shorted line
data = geo_plotly_trace(GeoBorders(large_rectangle))
Plot(data)
end
In these cases, it is possible to override this behavior by using the fix_antimeridian_crossing
keyword argument:
plt = with_settings(:OVERSAMPLE_LINES => :NORMAL) do # Actually draw lines from endpoints without using shorted line
data = geo_plotly_trace(GeoBorders(large_rectangle; fix_antimeridian_crossing = false))
Plot(data)
end
The fix_antimeridian_crossing
keyword argument is only respected for input geometries which are not already implementing the FastInGeometry
interface. As mentioned in the polyareas
docstrings, the polygons of FastInGeometry
are already assumed to be fixed and so will always be kept as defined within the geometry.
FastInDomain Interface
As alternative to subtyping FastInGeometry
, custom types can also participate to the fast inclusion algorithms if they subtype FastInDomain
.
In this case, the custom subtype must represent a domain containing geometries implementing the FastInGeometry
interface and must implement have valid methods for the following two functions from Meshes.jl
:
Meshes.nelements(custom_domain)
: This should return the number of geometries within the domainMeshes.element(custom_domain, ind)
: This should return theind
-th geometry from the domain
See an example implementation of a custom domain with elements of a custom geometry satisfying the interface
struct SimpleGeometry <: FastInGeometry{Float64}
name::String
borders::GeoBorders{Float64}
end
function SimpleGeometry(geoms; name = "SimpleGeometry")
borders = GeoBorders{Float64}(geoms)
return SimpleGeometry(name, borders)
end
struct SimpleDomain <: FastInDomain{Float64}
name::String
geoms::Vector{SimpleGeometry}
end
Meshes.nelements(sd::SimpleDomain) = length(sd.geoms)
Meshes.element(sd::SimpleDomain, ind::Int) = sd.geoms[ind]