Skip to content

add sensorthings support #34

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions controller.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
from typing import Iterable, Optional, List

from qgis.PyQt.QtCore import pyqtSignal, QObject
from qgis.core import Qgis, QgsProject, QgsMapLayer, QgsMapLayerType, QgsWkbTypes, QgsGeometry
from qgis.core import QgsProject, QgsMapLayer, QgsMapLayerType, QgsWkbTypes, QgsGeometry
from qgis.gui import QgsRubberBand
from qgis.utils import iface

from .maptool import PolygonTool
from .filters import FilterDefinition, Predicate
from .helpers import getSupportedLayers, removeFilterFromLayer, addFilterToLayer, refreshLayerTree, hasLayerException, \
warnAboutCurveGeoms
from .settings import FILTER_COMMENT_START, LOCALIZED_PLUGIN_NAME
warnAboutCurveGeoms, getFilterStartStopString
from .settings import LOCALIZED_PLUGIN_NAME


class FilterController(QObject):
Expand Down Expand Up @@ -51,7 +51,8 @@ def onLayersAdded(self, layers: Iterable[QgsMapLayer]):
else:
# Look for saved filters to use with the plugin (possible when project was loaded)
for layer in getSupportedLayers(layers):
if FILTER_COMMENT_START in layer.subsetString():
FILTER_START_STRING, _ = getFilterStartStopString(layer)
if FILTER_START_STRING in layer.subsetString():
self.setFilterFromLayer(layer)
return

Expand All @@ -63,7 +64,7 @@ def onProjectCleared(self):
self.removeFilter()

def setFilterFromLayer(self, layer):
filterDefinition = FilterDefinition.fromFilterString(layer.subsetString())
filterDefinition = FilterDefinition.fromFilterString(layer)
self.currentFilter = filterDefinition
self.refreshFilter()

Expand Down
92 changes: 72 additions & 20 deletions filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,43 @@
from typing import List

from qgis.PyQt.QtWidgets import QMessageBox
from qgis.core import QgsVectorLayer, QgsGeometry, QgsCoordinateReferenceSystem

from qgis.core import (
QgsVectorLayer,
QgsGeometry,
QgsCoordinateReferenceSystem,
Qgis
)

if Qgis.QGIS_VERSION_INT > 33600:
from qgis.core import QgsSensorThingsUtils

from qgis.utils import iface

from .settings import FILTER_COMMENT_START, FILTER_COMMENT_STOP, LOCALIZED_PLUGIN_NAME
from .helpers import tr, saveSettingsValue, readSettingsValue, allSettingsValues, removeSettingsValue, \
getLayerGeomName, matchFormatString
from .settings import (
LOCALIZED_PLUGIN_NAME,
SENSORTHINGS_STORAGE_TYPE
)

from .helpers import (
tr,
saveSettingsValue,
readSettingsValue,
allSettingsValues,
removeSettingsValue,
getLayerGeomName,
matchFormatString,
reproject_geometry,
getFilterStartStopString
)


FILTERSTRING_TEMPLATE = "{spatial_predicate}({geom_name}, ST_TRANSFORM(ST_GeomFromText('{wkt}', {srid}), {layer_srid}))"

# sensorthings filter does not support reprojection (st_transform)
# reprojection happens in helpers.py -> addFilterToLayer
FILTERSTRING_TEMPLATE_SENSORTHINGS = "{spatial_predicate}({geom_name}, geography'{wkt}')"


class Predicate(IntEnum):
INTERSECTS = 1
Expand Down Expand Up @@ -51,35 +78,60 @@ def filterString(self, layer: QgsVectorLayer) -> str:
Returns:
str: A layer filter string
"""
# ST_DISJOINT does not use spatial indexes, but we can use its opposite "NOT ST_INTERSECTS" which does
spatial_predicate = f"ST_{Predicate(self.predicate).name}"
if self.predicate == Predicate.DISJOINT:
spatial_predicate = "NOT ST_INTERSECTS"
wkt = self.wkt if not self.bbox else self.boxGeometry.asWkt()
srid=self.crs.postgisSrid()
layer_srid=layer.crs().postgisSrid()
geom_name = getLayerGeomName(layer)

wkt = self.wkt
if self.bbox:
wkt = self.boxGeometry.asWkt()
if layer.storageType() == SENSORTHINGS_STORAGE_TYPE:
# SensorThings only supports single geometry types
single_geometry = QgsGeometry.fromWkt(wkt)
single_geometry.convertToSingleType()

geom_name = getLayerGeomName(layer)
# SensorThings filter does not support reprojection (st_transform)
# thats why the reprojection must be executed on client-side.
reprojected_geometry = reproject_geometry(single_geometry, srid, layer_srid)

spatial_predicate = spatial_predicate.lower() # sensorthings specification uses lower case

return FILTERSTRING_TEMPLATE_SENSORTHINGS.format(
spatial_predicate=spatial_predicate,
geom_name=geom_name,
wkt=reprojected_geometry.asWkt()
)
# ST_DISJOINT does not use spatial indexes, but we can use its opposite "NOT ST_INTERSECTS" which does
if self.predicate == Predicate.DISJOINT:
spatial_predicate = "NOT ST_INTERSECTS"

return FILTERSTRING_TEMPLATE.format(
spatial_predicate=spatial_predicate,
geom_name=geom_name,
wkt=wkt,
srid=self.crs.postgisSrid(),
layer_srid=layer.crs().postgisSrid()
srid=srid,
layer_srid=layer_srid
)


@staticmethod
def fromFilterString(subsetString: str) -> 'FilterDefinition':
start_index = subsetString.find(FILTER_COMMENT_START) + len(FILTER_COMMENT_START)
stop_index = subsetString.find(FILTER_COMMENT_STOP)
def fromFilterString(layer: QgsVectorLayer) -> 'FilterDefinition':
subsetString = layer.subsetString()
FILTER_START_STRING, FILTER_STOP_STRING = getFilterStartStopString(layer)
start_index = subsetString.find(FILTER_START_STRING) + len(FILTER_START_STRING)
stop_index = subsetString.find(FILTER_STOP_STRING)
filterString = subsetString[start_index: stop_index]
filterString = filterString.replace(' AND ', '')
params = matchFormatString(FILTERSTRING_TEMPLATE, filterString)
predicateName = params['spatial_predicate'][len('ST_'):]
if filterString.startswith('NOT ST_INTERSECTS'):
predicateName = 'DISJOINT'

if layer.storageType() == SENSORTHINGS_STORAGE_TYPE:
params = matchFormatString(FILTERSTRING_TEMPLATE, filterString)
predicateName = params['spatial_predicate'][len('st_'):]

else:
params = matchFormatString(FILTERSTRING_TEMPLATE, filterString)
predicateName = params['spatial_predicate'][len('ST_'):]
if filterString.startswith('NOT ST_INTERSECTS'):
predicateName = 'DISJOINT'#

predicate = Predicate[predicateName]
filterDefinition = FilterDefinition(
name=tr('Unknown filter'),
Expand Down
92 changes: 83 additions & 9 deletions helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,41 @@

from osgeo import ogr
from qgis.PyQt.QtCore import QCoreApplication
from qgis.core import Qgis, QgsExpressionContextUtils, QgsSettings, QgsMapLayer, QgsMapLayerType, QgsVectorLayer,\
QgsWkbTypes
from qgis.core import (
Qgis,
QgsExpressionContextUtils,
QgsSettings,
QgsMapLayer,
QgsMapLayerType,
QgsVectorLayer,
QgsWkbTypes,
QgsGeometry,
QgsCoordinateReferenceSystem,
QgsCoordinateTransform,
QgsProject
)

if Qgis.QGIS_VERSION_INT > 33600:
from qgis.core import QgsSensorThingsUtils

from qgis.utils import iface

from .settings import SUPPORTED_STORAGE_TYPES, GROUP, FILTER_COMMENT_START, FILTER_COMMENT_STOP, \
LAYER_EXCEPTION_VARIABLE, LOCALIZED_PLUGIN_NAME
from .settings import (
SUPPORTED_STORAGE_TYPES,
GROUP,
FILTER_COMMENT_START,
FILTER_COMMENT_STOP,
FILTER_COMMENT_START_SENSORTHINGS,
FILTER_COMMENT_STOP_SENSORTHINGS,
LAYER_EXCEPTION_VARIABLE,
LOCALIZED_PLUGIN_NAME,
SENSORTHINGS_STORAGE_TYPE
)

from typing import TYPE_CHECKING

if TYPE_CHECKING:
from .filters import FilterDefinition

def tr(message):
return QCoreApplication.translate('@default', message)
Expand Down Expand Up @@ -72,27 +100,73 @@ def isLayerSupported(layer: QgsMapLayer):
return True


def getFilterStartStopString(layer: QgsVectorLayer) -> tuple[str, str]:
if layer.storageType() == SENSORTHINGS_STORAGE_TYPE:
return FILTER_COMMENT_START_SENSORTHINGS, FILTER_COMMENT_STOP_SENSORTHINGS
else:
return FILTER_COMMENT_START, FILTER_COMMENT_STOP

def removeFilterFromLayer(layer: QgsVectorLayer):
# sensorthings filter does not support inline comments (FILTER_COMMENT_START)
# The workaround for this is a string that always evals to true and is only used with this plugin
FILTER_START_STRING, FILTER_STOP_STRING = getFilterStartStopString(layer)
currentFilter = layer.subsetString()
if FILTER_COMMENT_START not in currentFilter:
if FILTER_START_STRING not in currentFilter:
return
start_index = currentFilter.find(FILTER_COMMENT_START)
stop_index = currentFilter.find(FILTER_COMMENT_STOP) + len(FILTER_COMMENT_STOP)
start_index = currentFilter.find(FILTER_START_STRING)
stop_index = currentFilter.find(FILTER_STOP_STRING) + len(FILTER_STOP_STRING)
newFilter = currentFilter[:start_index] + currentFilter[stop_index:]
newFilter = newFilter.rstrip(' and ')
layer.setSubsetString(newFilter)


def addFilterToLayer(layer: QgsVectorLayer, filterDef: 'FilterDefinition'):
currentFilter = layer.subsetString()
if FILTER_COMMENT_START in currentFilter:
FILTER_START_STRING, FILTER_STOP_STRING = getFilterStartStopString(layer)
if FILTER_START_STRING in currentFilter:
removeFilterFromLayer(layer)

currentFilter = layer.subsetString()

connect = " AND " if currentFilter else ""
newFilter = f'{currentFilter}{FILTER_COMMENT_START}{connect}{filterDef.filterString(layer)}{FILTER_COMMENT_STOP}'
if layer.storageType() == SENSORTHINGS_STORAGE_TYPE:
connect = connect.lower() # SensorThings only supports lowercase 'and'
newFilter = f'{currentFilter}{connect}{FILTER_START_STRING}{filterDef.filterString(layer)}{FILTER_STOP_STRING}'
else:
newFilter = f'{currentFilter}{FILTER_START_STRING}{connect}{filterDef.filterString(layer)}{FILTER_STOP_STRING}'
layer.setSubsetString(newFilter)




def reproject_geometry(geometry: QgsGeometry, source_crs_epsg: int, target_crs_epsg: int) -> QgsGeometry:
"""
Reproject a QgsGeometry from a source CRS to a target CRS.

Args:
geometry (QgsGeometry): The QgsGeometry to reproject.
source_crs_epsg (int): The EPSG code of the source CRS.
target_crs_epsg (int): The EPSG code of the target CRS.

Returns:
str: The reprojected geometry.
"""
source_crs = QgsCoordinateReferenceSystem(source_crs_epsg)
target_crs = QgsCoordinateReferenceSystem(target_crs_epsg)
Comment on lines +154 to +155
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe have a quick if source_crs == target_crs: return wkt here. Probably not really that much of a performance improvement overall, but wouldn't hurt :D

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, this is more elegant 👍

if source_crs == target_crs:
return geometry
transform = QgsCoordinateTransform(source_crs, target_crs, QgsProject.instance())
geometry.transform(transform)
return geometry



def getLayerGeomName(layer: QgsVectorLayer):
if layer.storageType() == SENSORTHINGS_STORAGE_TYPE:
entity_str = layer.dataProvider().uri().param('entity')
entity_type = QgsSensorThingsUtils.stringToEntity(entity_str)
geom_field = QgsSensorThingsUtils.geometryFieldForEntityType(entity_type)
return geom_field
return layer.dataProvider().uri().geometryColumn() or getLayerGeomNameOgr(layer)


Expand Down
9 changes: 9 additions & 0 deletions settings.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from qgis.PyQt.QtCore import QCoreApplication
from qgis.core import Qgis

def tr(message):
return QCoreApplication.translate('@default', message)
Expand All @@ -17,5 +18,13 @@ def tr(message):
FILTER_COMMENT_START = '/* SpatialFilter Plugin Start */'
FILTER_COMMENT_STOP = '/* SpatialFilter Plugin Stop */'

FILTER_COMMENT_START_SENSORTHINGS = "'SpatialFilter Plugin Start' eq 'SpatialFilter Plugin Start' and "
FILTER_COMMENT_STOP_SENSORTHINGS = " and 'SpatialFilter Plugin Stop' eq 'SpatialFilter Plugin Stop'"

# The QGIS Storage Types that can be filtered by the plugin
SUPPORTED_STORAGE_TYPES = ['POSTGRESQL DATABASE WITH POSTGIS EXTENSION', 'GPKG', 'SQLITE']

SENSORTHINGS_STORAGE_TYPE = 'OGC SensorThings API'

if Qgis.QGIS_VERSION_INT > 33600:
SUPPORTED_STORAGE_TYPES.append(SENSORTHINGS_STORAGE_TYPE.upper())