/
spatial_properties.py
327 lines (279 loc) · 14.3 KB
/
spatial_properties.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
"""
Data types and validators (PointProperty) for working with neo4j's spatial data types through neomodel.
`spatial_properties` offers two classes: NeomodelPoint, PointProperty that marshal data to and from a neo4j dbms,
with the added capability of being Shapely objects. Therefore, points retrieved with Neomodel can readily be used
in further geometric (via Shapely) or geospatial (via PySAL) operations.
* More information on Neo4J's spatial data types:
* https://neo4j.com/docs/developer-manual/3.4/cypher/syntax/spatial/
* More information on the Python driver's data types:
* https://neo4j.com/docs/api/python-driver/1.7-preview/types.html#spatial-types
* More information about Shapely's spatial data types:
* http://toblerity.org/shapely/manual.html#geometric-objects
* More information about PySAL through Shapely:
* https://pysal.readthedocs.io/en/latest/users/tutorials/shapely.html
"""
__author__ = "Athanasios Anastasiou"
import neo4j.v1
# If shapely is not installed, its import will fail and the spatial properties will not be available
try:
from shapely.geometry import Point as ShapelyPoint
except ImportError:
raise ImportError('NEOMODEL ERROR: Shapely not found. If required, you can install Shapely via '
'`pip install shapely`.')
from neomodel.properties import Property, validator
# Note: Depending on how Neo4J decides to handle the resolution of geographical points, these two
# private attributes might have to be updated in the future or removed altogether.
# They help in trying to resolve which class needs to be instantiated based on the data received from the dbms
# and doing it in a concise way that minimises errors.
#
# Acceptable Coordinate Reference Systems
ACCEPTABLE_CRS = ['cartesian', 'cartesian-3d', 'wgs-84', 'wgs-84-3d']
# A simple CRS to SRID mapping
SRID_TO_CRS = {7203: 'cartesian', 9157: 'cartesian-3d', 4326: 'wgs-84', 4979: 'wgs-84-3d'}
class NeomodelPoint(ShapelyPoint):
"""
Abstracts the Point spatial data type of Neo4j.
Note:
At the time of writing, Neo4j supports 2 main variants of Point:
1. A generic point defined over a Cartesian plane
* The minimum data to define a point is x, y [,z] when crs is either "cartesian" or "cartesian-3d"
2. A generic point defined over the WGS84 ellipsoid
* The minimum data to define a point is longitude, latitude [,Height] and the crs is then assumed
to be "wgs-84".
"""
# def __init__(self, *args, crs=None, x=None, y=None, z=None, latitude=None, longitude=None, height=None, **kwargs):
def __init__(self, *args, **kwargs):
"""
Creates a NeomodelPoint.
:param args: Positional arguments to emulate the behaviour of Shapely's Point (and specifically the copy
constructor)
:type args: list
:param crs: Coordinate Reference System, must be one of ['cartesian', 'cartesian-3d', 'wgs-84', 'wgs-84-3d']
:type crs: str
:param x: x coordinate of point
:type x: float
:param y: y coordinate of point
:type y: float
:param z: z coordinate of point if the crs is cartesian-3d
:type z: float
:param latitude: Latitude of point
:type latitude: float
:param longitude: Longitude of point
:type longitude: float
:param height: Height of point if the crs is wgs-84-3d
:type height: float
:param kwargs: Dictionary of keyword arguments
:type kwargs: dict
"""
# Python2.7 Workaround for the order that the arguments get passed to the functions
crs = kwargs.pop('crs', None)
x = kwargs.pop('x', None)
y = kwargs.pop('y', None)
z = kwargs.pop('z', None)
longitude = kwargs.pop('longitude', None)
latitude = kwargs.pop('latitude', None)
height = kwargs.pop('height', None)
_x, _y, _z = None, None, None
# CRS validity check is common to both types of constructors that follow
if crs is not None and crs not in ACCEPTABLE_CRS:
raise ValueError('Invalid CRS({}). Expected one of {}'.format(crs, ','.join(ACCEPTABLE_CRS)))
self._crs = crs
# If positional arguments have been supplied, then this is a possible call to the copy constructor or
# initialisation by a coordinate iterable as per ShapelyPoint constructor.
if len(args):
# If a coordinate iterable was passed, emulate a call with x,y[,z] parameters
if isinstance(args[0],(tuple, list)):
# Check dimensionality of tuple
if len(args[0]) < 2 or len(args[0]) > 3:
raise ValueError('Invalid vector dimensions. Expected 2 or 3, received {}'.format(len(args[0])))
x = args[0][0]
y = args[0][1]
if len(args[0]) == 3:
z = args[0][2]
# If another "Point" was passed, then this is a call to the copy constructor
elif isinstance(args[0], ShapelyPoint):
super(NeomodelPoint, self).__init__(args[0])
# If the other Point was a NeomodelPoint then it bears the CRS that is used to
# interpret the points and this has to be carried over.
if isinstance(args[0], NeomodelPoint):
self._crs = args[0]._crs
else:
# This allows NeomodelPoint((0,0),crs="wgs-84") which will interpret the tuple as
# (longitude,latitude) even though it was not specified as such with the named arguments.
#
# NOTE: Notice the indexing on the coordinates `args[0].coords[0]`. Coordinates are always an
# iterable which for Points has length of 1 but for other geometrical objects (e.g. boundaries)
# might be longer. This will come back to bite you, if you assume that `coords` is what it pretends
# to be (i.e. coords gives access to the actual coordinates NOT an iterable).
#
if len(args[0].coords[0]) == 2:
if crs is None:
self._crs = 'cartesian'
elif len(args[0].coords[0]) == 3:
if crs is None:
self._crs = 'cartesian-3d'
else:
raise ValueError('Invalid vector dimensions. '
'Expected 2 or 3, received {}'.format(len(args[0].coords[0])))
return
else:
raise TypeError('Invalid object passed to copy constructor. '
'Expected NeomodelPoint or shapely Point, received {}'.format(type(args[0])))
# Initialisation is either via x,y[,z] XOR longitude,latitude[,height]. Specifying both leads to an error.
if (x is not None or y is not None or z is not None) and \
(latitude is not None or longitude is not None or height is not None):
raise ValueError('Invalid instantiation via arguments. '
'A Point can be defined either by x,y,z coordinates OR latitude,longitude,height but not '
'a combination of these terms')
# Specifying no initialisation argument at this point in the constructor is flagged as an error
if x is None and y is None and z is None and longitude is None and latitude is None and height is None:
raise ValueError('Invalid instantiation via no arguments. '
'A Point needs default values either in x,y,z or longitude, latitude, height coordinates')
# Geographical Point Initialisation
if latitude is not None and longitude is not None:
if height is not None:
if self._crs is None:
self._crs = 'wgs-84-3d'
_z = height
else:
if self._crs is None:
self._crs = 'wgs-84'
_x = longitude
_y = latitude
# Geometrical Point Initialisation
if x is not None and y is not None:
if z is not None:
if self._crs is None:
self._crs = 'cartesian-3d'
_z = z
else:
if self._crs is None:
self._crs = 'cartesian'
_x = x
_y = y
if _z is None:
if '-3d' not in self._crs:
super(NeomodelPoint, self).__init__((float(_x),float(_y)), **kwargs)
else:
raise ValueError("Invalid vector dimensions(2) for given CRS({}).".format(self._crs))
else:
if '-3d' in self._crs:
super(NeomodelPoint, self).__init__((float(_x), float(_y), float(_z)), **kwargs)
else:
raise ValueError("Invalid vector dimensions(3) for given CRS({}).".format(self._crs))
@property
def crs(self):
return self._crs
@property
def x(self):
if not self._crs.startswith('cartesian'):
raise AttributeError('Invalid coordinate ("x") for points defined over {}'.format(self.crs))
return super(NeomodelPoint, self).x
@property
def y(self):
if not self._crs.startswith('cartesian'):
raise AttributeError('Invalid coordinate ("y") for points defined over {}'.format(self.crs))
return super(NeomodelPoint, self).y
@property
def z(self):
if not self._crs == 'cartesian-3d':
raise AttributeError('Invalid coordinate ("z") for points defined over {}'.format(self.crs))
return super(NeomodelPoint, self).z
@property
def latitude(self):
if not self._crs.startswith('wgs-84'):
raise AttributeError('Invalid coordinate ("latitude") for points defined over {}'.format(self.crs))
return super(NeomodelPoint, self).y
@property
def longitude(self):
if not self._crs.startswith('wgs-84'):
raise AttributeError('Invalid coordinate ("longitude") for points defined over {}'.format(self.crs))
return super(NeomodelPoint, self).x
@property
def height(self):
if not self._crs == 'wgs-84-3d':
raise AttributeError('Invalid coordinate ("height") for points defined over {}'.format(self.crs))
return super(NeomodelPoint, self).z
class PointProperty(Property):
"""
Validates points which can participate in spatial queries.
"""
form_field_class = 'PointField'
# The CRS that this property is expected to be expressed in.
_crs = None
def __init__(self, *args, **kwargs):
"""
A Point property that requires at least its CRS to be known to offer proper validation.
:param crs: Coordinate Reference System
:type crs: str
:param kwargs: Dictionary of arguments
:type kwargs: dict
"""
if 'crs' in kwargs:
crs = kwargs['crs']
del(kwargs['crs'])
else:
crs = None
if crs is None or (crs not in ACCEPTABLE_CRS):
raise ValueError('Invalid CRS({}). '
'Point properties require CRS to be one of {}'.format(crs, ','.join(ACCEPTABLE_CRS)))
# If a default value is passed and it is not a callable, then make sure it is in the right type
if 'default' in kwargs:
if not hasattr(kwargs['default'], "__call__"):
if not isinstance(kwargs['default'], NeomodelPoint):
raise TypeError('Invalid default value. '
'Expected NeomodelPoint, received {}'.format(type(kwargs['default'])))
super(PointProperty, self).__init__(*args, **kwargs)
self._crs = crs
@validator
def inflate(self, value):
"""
Handles the marshalling from Neo4J POINT to NeomodelPoint
:param value: Value returned from the database
:type value: Neo4J POINT
:return: NeomodelPoint
"""
if not isinstance(value,neo4j.v1.spatial.Point):
raise TypeError('Invalid datatype to inflate. Expected POINT datatype, received {}'.format(type(value)))
try:
value_point_crs = SRID_TO_CRS[value.srid]
except KeyError:
raise ValueError('Invalid SRID to inflate. '
'Expected one of {}, received {}'.format(SRID_TO_CRS.keys(), value.srid))
if self._crs != value_point_crs:
raise ValueError('Invalid CRS. '
'Expected POINT defined over {}, received {}'.format(self._crs, value_point_crs))
# cartesian
if value.srid == 7203:
return NeomodelPoint(x=value.x, y=value.y)
# cartesian-3d
elif value.srid == 9157:
return NeomodelPoint(x=value.x, y=value.y, z=value.z)
# wgs-84
elif value.srid == 4326:
return NeomodelPoint(longitude=value.longitude, latitude=value.latitude)
# wgs-83-3d
elif value.srid == 4979:
return NeomodelPoint(longitude=value.longitude, latitude=value.latitude, height=value.height)
@validator
def deflate(self, value):
"""
Handles the marshalling from NeomodelPoint to Neo4J POINT
:param value: The point that was assigned as value to a property in the model
:type value: NeomodelPoint
:return: Neo4J POINT
"""
if not isinstance(value, NeomodelPoint):
raise TypeError('Invalid datatype to deflate. Expected NeomodelPoint, received {}'.format(type(value)))
if not value.crs == self._crs:
raise ValueError('Invalid CRS. '
'Expected NeomodelPoint defined over {}, '
'received NeomodelPoint defined over {}'.format(self._crs, value.crs))
if value.crs == 'cartesian-3d':
return neo4j.v1.spatial.CartesianPoint((value.x, value.y, value.z))
elif value.crs == 'cartesian':
return neo4j.v1.spatial.CartesianPoint((value.x,value.y))
elif value.crs == 'wgs-84':
return neo4j.v1.spatial.WGS84Point((value.longitude, value.latitude))
elif value.crs == 'wgs-84-3d':
return neo4j.v1.spatial.WGS84Point((value.longitude, value.latitude, value.height))