File indexing completed on 2024-05-05 03:55:01
0001 # SPDX-FileCopyrightText: 2021 Volker Krause <vkrause@kde.org> 0002 # SPDX-License-Identifier: LGPL-2.0-or-later 0003 0004 from config import * 0005 import datetime 0006 import functools 0007 import os 0008 import pytz 0009 import time 0010 from qgis import * 0011 from qgis.core import * 0012 0013 xStep = xRange / (1 << zDepth) 0014 yStep = yRange / (1 << zDepth) 0015 0016 def z2x(z): 0017 x = 0 0018 for i in range(0, zDepth): 0019 x += (z & (1 << i * 2)) >> i 0020 return x 0021 0022 def z2y(z): 0023 y = 0 0024 for i in range(0, zDepth): 0025 y += (z & (1 << (1 + i * 2))) >> (i + 1) 0026 return y 0027 0028 def rectForZ(z, depth): 0029 mask = (1 << (2*(zDepth - depth))) - 1 0030 x = z2x(z & ~mask) * xStep + xStart 0031 y = z2y(z & ~mask) * yStep + yStart 0032 xSize = xRange / (1 << depth) 0033 ySize = yRange / (1 << depth) 0034 return QgsRectangle(x, y, x + xSize, y + ySize) 0035 0036 def tzIdToEnum(tzId): 0037 if not tzId: 0038 return 'Undefined' 0039 return tzId.replace('/', '_').replace('-', '_') 0040 0041 # Layer specific configuration for things considered in the spatial index 0042 class LayerConfig: 0043 def readFeatureValue(self, feature): 0044 return feature[self.featureKey] 0045 0046 def isValidFeature(self, feature): 0047 return feature != None 0048 0049 def normalizeAndFilter(self, features): 0050 for key in features: 0051 if not self.isValidFeature(key): 0052 del(features[key]) 0053 return features 0054 0055 def resolveConflicts(self, features): 0056 out = [] 0057 for key in features: 0058 out.append((key, features[key])); 0059 n = functools.reduce(lambda n, f: n + f[1], out, 0) 0060 if n > 0: 0061 out = [(k, v/n) for (k, v) in out] 0062 out = list(filter(lambda x: x[1] > featureAreaRatioThreshold, out)) 0063 out.sort(key = lambda x: x[1], reverse = True) 0064 return out 0065 0066 class TzLayerConfig(LayerConfig): 0067 def __init__(self): 0068 self.layer = 'tzLayer' 0069 self.featureKey = 'tzid' 0070 0071 def readFeatureValue(self, feature): 0072 tzId = feature[self.featureKey] 0073 if tzId in TZID_MAP: 0074 return TZID_MAP[tzId] 0075 return tzId 0076 0077 def resolveConflicts(self, features): 0078 if len(features) > 1: 0079 tzs = list(features.keys()) 0080 # check for conflicting timezones 0081 tz = pytz.timezone(tzs[0]) 0082 if not all(self.isSameTimezone(tz, pytz.timezone(x)) for x in tzs[1:]): 0083 return None 0084 out = super().resolveConflicts(features) 0085 return [out[0]] 0086 return super().resolveConflicts(features) 0087 0088 def isSameTimezone(self, lhs, rhs): 0089 try: 0090 # hacky tz comparison, lacking access to the rules for comparing actual DST transition times 0091 # TODO stabilize results and ensure we capture differences due to different week boundaries 0092 dt = datetime.datetime.today().toordinal() 0093 return all(lhs.utcoffset(datetime.datetime.fromordinal(dt + 30*x)) == rhs.utcoffset(datetime.datetime.fromordinal(dt + 30*x)) 0094 and lhs.tzname(datetime.datetime.fromordinal(dt + 30*x)) == rhs.tzname(datetime.datetime.fromordinal(dt + 30*x)) for x in range(0, 11)) 0095 except: 0096 return False 0097 0098 class CountryLayerConfig(LayerConfig): 0099 def __init__(self): 0100 self.layer = 'countryLayer' 0101 self.featureKey = 'ISO3166-1' 0102 0103 def readFeatureValue(self, feature): 0104 code = feature[self.featureKey] 0105 if code in ISO3166_1_MAP: 0106 return ISO3166_1_MAP[code] 0107 return code 0108 0109 def normalizeAndFilter(self, features): 0110 if len(features) > 1: 0111 # apply country disambiguation 0112 for d in ISO3166_1_DISAMBIGUATION_MAP: 0113 if d[0] in features and d[1] in features: 0114 del(features[d[1]]) 0115 return super().normalizeAndFilter(features) 0116 0117 def resolveConflicts(self, features): 0118 out = super().resolveConflicts(features) 0119 if len(out) > 1: 0120 return None 0121 return out 0122 0123 class SubdivLayerConfig(LayerConfig): 0124 def __init__(self): 0125 self.layer = 'subdivLayer' 0126 self.featureKey = 'ISO3166-2' 0127 0128 def resolveConflicts(self, features): 0129 out = super().resolveConflicts(features) 0130 if len(out) > 1: 0131 return None 0132 return out 0133 # 0134 # Parallelized spatial index computation of a single sub-tile 0135 # 0136 class SpatialIndexerSubTask(QgsTask): 0137 def __init__(self, context, zStart, zStartDepth): 0138 super().__init__('Compute spatial index sub-tile ' + str(zStart), QgsTask.CanCancel) 0139 self.context = context 0140 self.zStart = zStart 0141 self.zStartDepth = zStartDepth 0142 self.lastFeature = [] 0143 self.exception = None 0144 self.result = [] 0145 self.layerConfig = [TzLayerConfig(), CountryLayerConfig(), SubdivLayerConfig()] 0146 0147 def run(self): 0148 try: 0149 self.computeTile(self.zStart, self.zStartDepth) 0150 except Exception as e: 0151 self.exception = e 0152 QgsMessageLog.logMessage('Exception in task "{}"'.format(self.exception), LOG_CATEGORY, Qgis.Info) 0153 return False 0154 return True 0155 0156 def computeTile(self, zStart, depth): 0157 if self.isCanceled() or depth < 1: 0158 return 0159 z = zStart 0160 d = depth - 1 0161 zIncrement = 1 << (2*d) 0162 for i in range(0, 4): 0163 # find features in the input vector layer inside our current tile 0164 # we cannot take shortcuts here and avoid the expensive area computation 0165 # as we do get spurious 0-area results for some large and disjoint polygons (FR, US, NZ, etc) 0166 feature = [] 0167 featureCount = 0 0168 for layerConfig in self.layerConfig: 0169 l = {} 0170 rect = rectForZ(z, zDepth - d) 0171 rectGeo = QgsGeometry.fromRect(rect) 0172 for f in self.context[layerConfig.layer].getFeatures(rect): 0173 featureArea = f.geometry().intersection(rectGeo).area() 0174 if featureArea > 0.0: 0175 key = layerConfig.readFeatureValue(f) 0176 if key in l: 0177 l[key] += featureArea / rectGeo.area() 0178 else: 0179 l[key] = featureArea / rectGeo.area() 0180 feature.append(layerConfig.normalizeAndFilter(l)) 0181 featureCount = max(featureCount, len(l)) 0182 0183 # recurse on conflicts 0184 if depth > 1 and featureCount > 1: 0185 self.computeTile(z, d) 0186 # leaf tile, process the result 0187 else: 0188 # try to clean up any remaining conflicts 0189 for i in range(len(feature)): 0190 feature[i] = self.layerConfig[i].resolveConflicts(feature[i]) 0191 self.resolveConflicts(feature) 0192 # if there's a change to the previous value, propagate to the result output 0193 if self.lastFeature != feature and feature != []: 0194 self.result.append((z, feature)) 0195 self.lastFeature = feature 0196 0197 z += zIncrement 0198 0199 def resolveConflicts(self, feature): 0200 # if we have a unique subdivision but no country, use the subdivision's country 0201 # this happens in coastal regions when territorial waters overlap in the tile, but 0202 # actual land boundaries don't. As we primarily care about correct result on land, 0203 # we can ignore the overlap on water 0204 if (not feature[1] or len(feature[1]) == 0) and feature[2] and len(feature[2]) == 1: 0205 feature[1] = [(feature[2][0][0][:2], 1)] 0206 0207 # if subdivision country and country code conflict, discard the subdivision 0208 # this is mainly a problem for French overseas territories, and there the country 0209 # code is actually more useful 0210 if feature[1] and len(feature[1]) == 1 and feature[2] and len(feature[2]) == 1 and feature[1][0][0] != feature[2][0][0][:2]: 0211 feature[2] = None 0212 0213 def finished(self, result): 0214 if not result and self.exception != None: 0215 QgsMessageLog.logMessage('Task "{name}" Exception: {exception}'.format(name=self.description(), exception=self.exception), LOG_CATEGORY, Qgis.Critical) 0216 raise self.exception 0217 0218 # 0219 # Task for spawning the sub-tasks doing the actual work, and accumulating the result 0220 # 0221 class SpatialIndexerTask(QgsTask): 0222 def __init__(self, context, loadLayersTask): 0223 super().__init__('Compute spatial index', QgsTask.CanCancel) 0224 self.context = context 0225 self.tasks = [] 0226 self.conflictTiles = [0, 0, 0] 0227 self.startTime = time.time() 0228 self.propertyTable = [] 0229 0230 startDepth = 4 0231 startIncrement = 1 << (2 * (zDepth - startDepth)) 0232 for i in range(0, (1 << (2 * startDepth))): 0233 task = SpatialIndexerSubTask(context, i * startIncrement, zDepth - startDepth) 0234 self.addSubTask(task, [loadLayersTask], QgsTask.ParentDependsOnSubTask) 0235 self.tasks.append(task) 0236 0237 def run(self): 0238 try: 0239 QgsMessageLog.logMessage('Aggregating results...', LOG_CATEGORY, Qgis.Info) 0240 0241 # compact the spatial index and prepare the property map 0242 spatialIndex = [] 0243 propertyMap = {} 0244 prevProperty = (None, None, None) 0245 propertyMap[prevProperty] = 0 0246 for task in self.tasks: 0247 for (z,res) in task.result: 0248 res = self.normalize(res); 0249 tmp = [] 0250 for i in range(len(res)): 0251 if res[i] == None or len(res[i]) == 0: 0252 tmp.append(None) 0253 else: 0254 tmp.append(res[i][0][0]) 0255 if res[i] == None: 0256 self.conflictTiles[i] += 1 0257 prop = (tmp[0], tmp[1], tmp[2]) 0258 0259 if prevProperty == prop: 0260 continue 0261 prevProperty = prop 0262 spatialIndex.append((z, prop)) 0263 propertyMap[prop] = 0 0264 0265 for prop in propertyMap: 0266 self.propertyTable.append(prop) 0267 self.propertyTable.sort(key = lambda x: (x[0] if x[0] else "", x[1] if x[1] else "", x[2] if x[2] else "")) 0268 for i in range(len(self.propertyTable)): 0269 propertyMap[self.propertyTable[i]] = i 0270 0271 # write spatial index 0272 out = open('../../data/spatial_index_data.cpp', 'w') 0273 out.write("""/* 0274 * SPDX-License-Identifier: ODbL-1.0 0275 * SPDX-FileCopyrightText: OpenStreetMap contributors 0276 * 0277 * Autogenerated spatial index generated using QGIS. 0278 */ 0279 0280 #include "spatial_index_entry_p.h" 0281 0282 static constexpr const SpatialIndexEntry spatial_index[] = { 0283 // clang-format off 0284 """) 0285 prevProperties = (None, None, None) 0286 for (z, prop) in spatialIndex: 0287 out.write(f" {{{z}, {propertyMap[prop]}}},\n") 0288 out.write(" // clang-format on\n};\n") 0289 out.close() 0290 0291 # write property table 0292 out = open('../../data/spatial_index_properties.cpp', 'w') 0293 out.write("""/* 0294 * SPDX-License-Identifier: ODbL-1.0 0295 * SPDX-FileCopyrightText: OpenStreetMap contributors 0296 * 0297 * Autogenerated spatial index generated using QGIS. 0298 */ 0299 0300 #include "spatial_index_property_p.h" 0301 #include "timezone_names_p.h" 0302 0303 static constexpr const SpatialIndexProperty spatial_index_properties[] = { 0304 """) 0305 for p in self.propertyTable: 0306 if not p[1] and not p[2]: 0307 out.write(f" {{Tz::{tzIdToEnum(p[0])}}},\n") 0308 elif not p[2]: 0309 out.write(f" {{Tz::{tzIdToEnum(p[0])}, \"{p[1]}\"}},\n") 0310 else: 0311 out.write(f" {{Tz::{tzIdToEnum(p[0])}, \"{p[2]}\"}},\n") 0312 out.write("};\n") 0313 out.close() 0314 0315 # write zindex parameters 0316 out = open('../../data/spatial_index_parameters_p.h', 'w') 0317 out.write("""/* 0318 * SPDX-License-Identifier: CC0-1.0 0319 * SPDX-FileCopyrightText: none 0320 * 0321 * Autogenerated spatial index generated using QGIS. 0322 */ 0323 0324 #include <cstdint> 0325 0326 """) 0327 out.write(f"constexpr const float XStart = {xStart};\n") 0328 out.write(f"constexpr const float XRange = {xRange};\n") 0329 out.write(f"constexpr const float YStart = {yStart};\n") 0330 out.write(f"constexpr const float YRange = {yRange};\n") 0331 out.write(f"constexpr const uint8_t ZDepth = {zDepth};\n") 0332 out.close() 0333 0334 return True 0335 0336 except Exception as e: 0337 self.exception = e 0338 QgsMessageLog.logMessage('Exception in task "{}"'.format(self.exception), LOG_CATEGORY, Qgis.Info) 0339 return False 0340 0341 def normalize(self, feature): 0342 # normalization we couldn't do in the sub tasks as they rely on results not available yet at that point 0343 # fill in missing timezones from country/subdiv to tz maps 0344 # this is needed for the same reason we do the subdivision to country back filling above, conflicts in 0345 # territorial waters but a unique result on land 0346 tz = None 0347 if feature[2] and len(feature[2]) == 1: 0348 subdivCode = feature[2][0][0] 0349 subdivToTz = self.context['subdivisionToTimezoneMap'] 0350 if subdivCode in subdivToTz and len(subdivToTz[subdivCode]) == 1: 0351 tz = list(subdivToTz[subdivCode])[0] 0352 if not tz and feature[1] and len(feature[1]) == 1: 0353 countryCode = feature[1][0][0] 0354 countryToTz = self.context['countryToTimezoneMap'] 0355 if countryCode in countryToTz and len(countryToTz[countryCode]) == 1: 0356 tz = list(countryToTz[countryCode])[0] 0357 if tz: 0358 feature[0] = [(tz, 1)] 0359 0360 return feature 0361 0362 def finished(self, result): 0363 QgsMessageLog.logMessage('Finished task "{}"'.format(self.description()), LOG_CATEGORY, Qgis.Info) 0364 tileCount = 1 << (2 * zDepth) 0365 for i in range(len(self.conflictTiles)): 0366 QgsMessageLog.logMessage(f" {self.conflictTiles[i] / tileCount} of feature {i} area is conflicted", LOG_CATEGORY, Qgis.Info) 0367 QgsMessageLog.logMessage(f" collected {len(self.propertyTable)} distinct features", LOG_CATEGORY, Qgis.Info) 0368 QgsMessageLog.logMessage(f" computation took {time.time() - self.startTime} seconds", LOG_CATEGORY, Qgis.Info) 0369 if not result and self.exception != None: 0370 QgsMessageLog.logMessage('Task "{name}" Exception: {exception}'.format(name=self.description(), exception=self.exception), LOG_CATEGORY, Qgis.Critical) 0371 raise self.exception 0372