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