Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/bones/spatial.py: 12%

160 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-09-03 13:41 +0000

1""" 

2`spatial` contains 

3- The `SpatialBone` to handle coordinates 

4- and `haversine` to calculate the distance between two points on earth using their latitude and longitude. 

5""" 

6 

7import logging 

8from copy import deepcopy 

9import typing as t 

10 

11import math 

12from math import floor 

13 

14from viur.core import db 

15from viur.core.bones.base import BaseBone, ReadFromClientError, ReadFromClientErrorSeverity 

16 

17 

18def haversine(lat1, lng1, lat2, lng2): 

19 """ 

20 Calculate the distance between two points on Earth's surface in meters. 

21 

22 This function uses the haversine formula to compute the great-circle distance between 

23 two points on Earth's surface, specified by their latitude and longitude coordinates. 

24 The haversine formula is particularly useful for small distances on the Earth's surface, 

25 as it provides accurate results with good performance. 

26 

27 For more details on the haversine formula, see 

28 `Haversine formula <https://en.wikipedia.org/wiki/Haversine_formula>`_. 

29 

30 :param float lat1: Latitude of the first point in decimal degrees. 

31 :param float lng1: Longitude of the first point in decimal degrees. 

32 :param float lat2: Latitude of the second point in decimal degrees. 

33 :param float lng2: Longitude of the second point in decimal degrees. 

34 :return: Distance between the two points in meters. 

35 :rtype: float 

36 """ 

37 lat1 = math.radians(lat1) 

38 lng1 = math.radians(lng1) 

39 lat2 = math.radians(lat2) 

40 lng2 = math.radians(lng2) 

41 distLat = lat2 - lat1 

42 distlng = lng2 - lng1 

43 d = math.sin(distLat / 2.0) ** 2.0 + math.cos(lat1) * math.cos(lat2) * math.sin(distlng / 2.0) ** 2.0 

44 return math.atan2(math.sqrt(d), math.sqrt(1 - d)) * 12742000 # 12742000 = Avg. Earth size (6371km) in meters*2 

45 

46 

47class SpatialBone(BaseBone): 

48 r""" 

49 The "SpatialBone" is a specific type of data structure designed to handle spatial data, such as geographical 

50 coordinates or geometries. This bone would typically be used for representing and storing location-based data, 

51 like the coordinates of a point of interest on a map or the boundaries of a geographic region. 

52 This feature allows querying elements near a specific location. Before using, designate the map region for 

53 which the index should be constructed. To ensure the best accuracy, minimize the region size; using the entire 

54 world is not feasible since boundary wraps are not executed. GridDimensions indicates the number of sub-regions 

55 the map will be partitioned into. Results beyond the size of these sub-regions will not be considered during 

56 searches by this algorithm. 

57 

58 .. note:: Example: 

59 When using this feature to find the nearest pubs, the algorithm could be set to consider 

60 results within 100km but not those 500km away. Setting the sub-region size to roughly 

61 100km in width and height allows the algorithm to exclude results further than 200km away 

62 at the database-query-level, significantly enhancing performance and reducing query costs. 

63 

64 Example region: Germany: ```boundsLat=(46.988, 55.022), boundsLng=(4.997, 15.148)``` 

65 

66 :param Tuple[float, float] boundsLat: The outer bounds (Latitude) of the region we will search in 

67 :param Tuple[float, float] boundsLng: The outer bounds (Longitude) of the region we will search in 

68 :param gridDimensions: (Tuple[int, int]) The number of sub-regions the map will be divided in 

69 """ 

70 

71 type = "spatial" 

72 

73 def __init__(self, *, boundsLat: tuple[float, float], boundsLng: tuple[float, float], 

74 gridDimensions: tuple[int, int], **kwargs): 

75 """ 

76 Initializes a new SpatialBone. 

77 

78 :param boundsLat: Outer bounds (Latitude) of the region we will search in. 

79 :param boundsLng: Outer bounds (Longitude) of the region we will search in. 

80 :param gridDimensions: Number of sub-regions the map will be divided in 

81 """ 

82 super().__init__(**kwargs) 

83 assert isinstance(boundsLat, tuple) and len(boundsLat) == 2, "boundsLat must be a tuple of (float, float)" 

84 assert isinstance(boundsLng, tuple) and len(boundsLng) == 2, "boundsLng must be a tuple of (float, float)" 

85 assert isinstance(gridDimensions, tuple) and len( 

86 gridDimensions) == 2, "gridDimensions must be a tuple of (int, int)" 

87 # Checks if boundsLat and boundsLng have possible values 

88 # See https://docs.mapbox.com/help/glossary/lat-lon/ 

89 if not -90 <= boundsLat[0] <= 90: 

90 raise ValueError(f"boundsLat[0] must be between -90 and 90. Got {boundsLat[0]!r}") 

91 if not -90 <= boundsLat[1] <= 90: 

92 raise ValueError(f"boundsLat[1] must be between -90 and 90. Got {boundsLat[1]!r}") 

93 if not -180 <= boundsLng[0] <= 180: 

94 raise ValueError(f"boundsLng[0] must be between -180 and 180. Got {boundsLng[0]!r}") 

95 if not -180 <= boundsLng[1] <= 180: 

96 raise ValueError(f"boundsLng[1] must be between -180 and 180. Got {boundsLng[1]!r}") 

97 assert not (self.indexed and self.multiple), "Spatial-Bone cannot be indexed when multiple" 

98 self.boundsLat = boundsLat 

99 self.boundsLng = boundsLng 

100 self.gridDimensions = gridDimensions 

101 

102 def getGridSize(self): 

103 """ 

104 Calculate and return the size of the sub-regions in terms of fractions of latitude and longitude. 

105 

106 :return: A tuple containing the size of the sub-regions as (fractions-of-latitude, fractions-of-longitude) 

107 :rtype: (float, float) 

108 """ 

109 latDelta = float(self.boundsLat[1] - self.boundsLat[0]) 

110 lngDelta = float(self.boundsLng[1] - self.boundsLng[0]) 

111 return latDelta / float(self.gridDimensions[0]), lngDelta / float(self.gridDimensions[1]) 

112 

113 def isInvalid(self, value: tuple[float, float]) -> str | bool: 

114 """ 

115 Validate if the given point (latitude, longitude) falls within the specified boundaries. 

116 Rejects all values outside the defined region. 

117 

118 :param value: A tuple containing the location of the entry as (latitude, longitude) 

119 :return: An error description if the value is invalid or False if the value is valid 

120 :rtype: str | bool 

121 """ 

122 try: 

123 lat, lng = value 

124 except: 

125 return "Invalid value entered" 

126 if lat < self.boundsLat[0] or lat > self.boundsLat[1]: 

127 return "Latitude out of range" 

128 elif lng < self.boundsLng[0] or lng > self.boundsLng[1]: 

129 return "Longitude out of range" 

130 else: 

131 return False 

132 

133 def singleValueSerialize(self, value, skel: 'SkeletonInstance', name: str, parentIndexed: bool): 

134 """ 

135 Serialize a single value (latitude, longitude) for storage. If the bone is indexed, calculate 

136 and add tile information for efficient querying. 

137 

138 :param value: A tuple containing the location of the entry as (latitude, longitude) 

139 :param SkeletonInstance skel: The instance of the Skeleton this bone is attached to 

140 :param str name: The name of this bone 

141 :param bool parentIndexed: A boolean indicating if the parent bone is indexed 

142 :return: A dictionary containing the serialized data, including coordinates and tile information (if indexed) 

143 :rtype: dict | None 

144 """ 

145 if not value: 

146 return None 

147 lat, lng = value 

148 res = { 

149 "coordinates": { 

150 "lat": lat, 

151 "lng": lng, 

152 } 

153 } 

154 indexed = self.indexed and parentIndexed 

155 if indexed: 

156 gridSizeLat, gridSizeLng = self.getGridSize() 

157 tileLat = int(floor((lat - self.boundsLat[0]) / gridSizeLat)) 

158 tileLng = int(floor((lng - self.boundsLng[0]) / gridSizeLng)) 

159 res["tiles"] = { 

160 "lat": [tileLat - 1, tileLat, tileLat + 1], 

161 "lng": [tileLng - 1, tileLng, tileLng + 1], 

162 } 

163 return res 

164 

165 def singleValueUnserialize(self, val): 

166 """ 

167 Deserialize a single value (latitude, longitude) from the stored data. 

168 

169 :param val: A dictionary containing the serialized data, including coordinates 

170 :return: A tuple containing the location of the entry as (latitude, longitude) 

171 :rtype: Tuple[float, float] | None 

172 """ 

173 if not val: 

174 return None 

175 return val["coordinates"]["lat"], val["coordinates"]["lng"] 

176 

177 def parseSubfieldsFromClient(self): 

178 """ 

179 Determines if subfields (latitude and longitude) should be parsed from the client. 

180 

181 :return: Always returns True, as latitude and longitude are required 

182 :rtype: bool 

183 """ 

184 return True # We'll always get .lat and .lng 

185 

186 def isEmpty(self, value: t.Any): 

187 """ 

188 Check if the given raw value is considered empty (either not present or equal to the empty value). 

189 

190 :param value: The raw value to be checked 

191 :return: True if the raw value is considered empty, False otherwise 

192 :rtype: bool 

193 """ 

194 if not value: 

195 return True 

196 if isinstance(value, dict): 

197 try: 

198 rawLat = float(value["lat"]) 

199 rawLng = float(value["lng"]) 

200 return (rawLat, rawLng) == self.getEmptyValue() 

201 except: 

202 return True 

203 return value == self.getEmptyValue() 

204 

205 def getEmptyValue(self) -> tuple[float, float]: 

206 """ 

207 Returns an empty value for the bone, which represents an invalid position. Use 91.0, 181.0 as a special 

208 marker for empty, as they are both out of range for Latitude (-90, +90) and Longitude (-180, 180), but will 

209 be accepted by Vi and Admin. 

210 

211 :return: A tuple representing an empty value for this bone (91.0, 181.0) 

212 :rtype: Tuple[float, float] 

213 """ 

214 return 0.0, 0.0 

215 

216 def singleValueFromClient(self, value, skel, bone_name, client_data): 

217 rawLat = value.get("lat", None) 

218 rawLng = value.get("lng", None) 

219 if rawLat is None and rawLng is None: 

220 return self.getEmptyValue(), [ 

221 ReadFromClientError(ReadFromClientErrorSeverity.NotSet, "Field not submitted")] 

222 elif rawLat is None or rawLng is None: 

223 return self.getEmptyValue(), [ReadFromClientError(ReadFromClientErrorSeverity.Empty, "No value submitted")] 

224 try: 

225 rawLat = float(rawLat) 

226 rawLng = float(rawLng) 

227 # Check for NaNs 

228 assert rawLat == rawLat 

229 assert rawLng == rawLng 

230 except: 

231 return self.getEmptyValue(), [ 

232 ReadFromClientError(ReadFromClientErrorSeverity.Invalid, "Invalid value entered")] 

233 err = self.isInvalid((rawLat, rawLng)) 

234 if err: 

235 return self.getEmptyValue(), [ReadFromClientError(ReadFromClientErrorSeverity.Invalid, err)] 

236 return (rawLat, rawLng), None 

237 

238 def buildDBFilter( 

239 self, 

240 name: str, 

241 skel: 'viur.core.skeleton.SkeletonInstance', 

242 dbFilter: db.Query, 

243 rawFilter: dict, 

244 prefix: t.Optional[str] = None 

245 ) -> db.Query: 

246 """ 

247 Parses the client's search filter specified in their request and converts it into a format understood by the 

248 datastore. 

249 - Ignore filters that do not target this bone. 

250 - Safely handle malformed data in rawFilter (this parameter is directly controlled by the client). 

251 

252 For detailed information on how this geo-spatial search works, see the ViUR documentation. 

253 

254 :param str name: The property name this bone has in its Skeleton (not the description!) 

255 :param SkeletonInstance skel: The skeleton this bone is part of 

256 :param db.Query dbFilter: The current `viur.core.db.Query` instance to which the filters should be applied 

257 :param dict rawFilter: The dictionary of filters the client wants to have applied 

258 :param prefix: Optional string, specifying a prefix for the bone's name (default is None) 

259 :return: The modified `viur.core.db.Query` instance 

260 :rtype: db.Query 

261 """ 

262 assert prefix is None, "You cannot use spatial data in a relation for now" 

263 if name + ".lat" in rawFilter and name + ".lng" in rawFilter: 

264 try: 

265 lat = float(rawFilter[name + ".lat"]) 

266 lng = float(rawFilter[name + ".lng"]) 

267 except: 

268 logging.debug(f"Received invalid values for lat/lng in {name}") 

269 dbFilter.datastoreQuery = None 

270 return 

271 if self.isInvalid((lat, lng)): 

272 logging.debug(f"Values out of range in {name}") 

273 dbFilter.datastoreQuery = None 

274 return 

275 gridSizeLat, gridSizeLng = self.getGridSize() 

276 tileLat = int(floor((lat - self.boundsLat[0]) / gridSizeLat)) 

277 tileLng = int(floor((lng - self.boundsLng[0]) / gridSizeLng)) 

278 assert isinstance(dbFilter.queries, db.QueryDefinition) # Not supported on multi-queries 

279 origQuery = dbFilter.queries 

280 # Lat - Right Side 

281 q1 = deepcopy(origQuery) 

282 q1.filters[name + ".coordinates.lat >="] = lat 

283 q1.filters[name + ".tiles.lat ="] = tileLat 

284 q1.orders = [(name + ".coordinates.lat", db.SortOrder.Ascending)] 

285 # Lat - Left Side 

286 q2 = deepcopy(origQuery) 

287 q2.filters[name + ".coordinates.lat <"] = lat 

288 q2.filters[name + ".tiles.lat ="] = tileLat 

289 q2.orders = [(name + ".coordinates.lat", db.SortOrder.Descending)] 

290 # Lng - Down 

291 q3 = deepcopy(origQuery) 

292 q3.filters[name + ".coordinates.lng >="] = lng 

293 q3.filters[name + ".tiles.lng ="] = tileLng 

294 q3.orders = [(name + ".coordinates.lng", db.SortOrder.Ascending)] 

295 # Lng - Top 

296 q4 = deepcopy(origQuery) 

297 q4.filters[name + ".coordinates.lng <"] = lng 

298 q4.filters[name + ".tiles.lng ="] = tileLng 

299 q4.orders = [(name + ".coordinates.lng", db.SortOrder.Descending)] 

300 dbFilter.queries = [q1, q2, q3, q4] 

301 dbFilter._customMultiQueryMerge = lambda *args, **kwargs: self.customMultiQueryMerge(name, lat, lng, *args, 

302 **kwargs) 

303 dbFilter._calculateInternalMultiQueryLimit = self.calculateInternalMultiQueryLimit 

304 

305 def calculateInternalMultiQueryLimit(self, dbQuery: db.Query, targetAmount: int): 

306 """ 

307 Provides guidance to viur.core.db.Query on the number of entries that should be fetched in each subquery. 

308 

309 :param dbQuery: The `viur.core.db.Query` instance 

310 :param targetAmount: The desired number of entries to be returned from the db.Query 

311 :return: The number of elements db.Query should fetch for each subquery 

312 :rtype: int 

313 """ 

314 return targetAmount * 2 

315 

316 def customMultiQueryMerge(self, name, lat, lng, dbFilter: db.Query, 

317 result: list[db.Entity], targetAmount: int 

318 ) -> list[db.Entity]: 

319 """ 

320 Randomly returns 'targetAmount' elements from 'result'. 

321 

322 :param str name: The property-name this bone has in its Skeleton (not the description!) 

323 :param lat: Latitude of the reference point 

324 :param lng: Longitude of the reference point 

325 :param dbFilter: The db.Query instance calling this function 

326 :param result: The list of results for each subquery that was executed 

327 :param int targetAmount: The desired number of results to be returned from db.Query 

328 :return: List of elements to be returned from db.Query 

329 :rtype: List[db.Entity] 

330 """ 

331 assert len(result) == 4 # There should be exactly one result for each direction 

332 result = [list(x) for x in result] # Remove the iterators 

333 latRight, latLeft, lngBottom, lngTop = result 

334 gridSizeLat, gridSizeLng = self.getGridSize() 

335 # Calculate the outer bounds we've reached - used to tell to which distance we can 

336 # prove the result to be correct. 

337 # If a result further away than this distance there might be missing results before that result 

338 # If there are no results in a give lane (f.e. because we are close the border and there is no point 

339 # in between) we choose a arbitrary large value for that lower bound 

340 expectedAmount = self.calculateInternalMultiQueryLimit(dbFilter, 

341 targetAmount) # How many items we expect in each direction 

342 limits = [ 

343 haversine(latRight[-1][name]["coordinates"]["lat"], lng, lat, lng) if latRight and len( 

344 latRight) == expectedAmount else 2 ** 31, # Lat - Right Side 

345 haversine(latLeft[-1][name]["coordinates"]["lat"], lng, lat, lng) if latLeft and len( 

346 latLeft) == expectedAmount else 2 ** 31, # Lat - Left Side 

347 haversine(lat, lngBottom[-1][name]["coordinates"]["lng"], lat, lng) if lngBottom and len( 

348 lngBottom) == expectedAmount else 2 ** 31, # Lng - Bottom 

349 haversine(lat, lngTop[-1][name]["coordinates"]["lng"], lat, lng) if lngTop and len( 

350 lngTop) == expectedAmount else 2 ** 31, # Lng - Top 

351 haversine(lat + gridSizeLat, lng, lat, lng), 

352 haversine(lat, lng + gridSizeLng, lat, lng) 

353 ] 

354 dbFilter.customQueryInfo["spatialGuaranteedCorrectness"] = min(limits) 

355 logging.debug(f"""SpatialGuaranteedCorrectness: { dbFilter.customQueryInfo["spatialGuaranteedCorrectness"]}""") 

356 # Filter duplicates 

357 tmpDict = {} 

358 for item in (latRight + latLeft + lngBottom + lngTop): 

359 tmpDict[str(item.key)] = item 

360 # Build up the final results 

361 tmpList = [(haversine(x[name]["coordinates"]["lat"], x[name]["coordinates"]["lng"], lat, lng), x) for x in 

362 tmpDict.values()] 

363 tmpList.sort(key=lambda x: x[0]) 

364 return [x[1] for x in tmpList[:targetAmount]] 

365 

366 def setBoneValue( 

367 self, 

368 skel: 'SkeletonInstance', 

369 boneName: str, 

370 value: t.Any, 

371 append: bool, 

372 language: None | str = None 

373 ) -> bool: 

374 """ 

375 Sets the value of the bone to the provided 'value'. 

376 Sanity checks are performed; if the value is invalid, the bone value will revert to its original 

377 (default) value and the function will return False. 

378 

379 :param skel: Dictionary with the current values from the skeleton the bone belongs to 

380 :param boneName: The name of the bone that should be modified 

381 :param value: The value that should be assigned. Its type depends on the type of the bone 

382 :param append: If True, the given value will be appended to the existing bone values instead of 

383 replacing them. Only supported on bones with multiple=True 

384 :param language: Optional, the language of the value if the bone is language-aware 

385 :return: A boolean indicating whether the operation succeeded or not 

386 :rtype: bool 

387 """ 

388 if append: 

389 raise ValueError(f"append is not possible on {self.type} bones") 

390 assert isinstance(value, tuple) and len(value) == 2, "Value must be a tuple of (lat, lng)" 

391 skel[boneName] = value 

392 

393 def structure(self) -> dict: 

394 return super().structure() | { 

395 "boundslat": self.boundsLat, 

396 "boundslng": self.boundsLng, 

397 }