diff --git a/.github/workflows/test_plugin.yaml b/.github/workflows/test_plugin.yaml index 1b79350..5a4b24a 100644 --- a/.github/workflows/test_plugin.yaml +++ b/.github/workflows/test_plugin.yaml @@ -11,26 +11,48 @@ jobs: Tests-plugin-LoS-Tools: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - - name: GIS Sources + - name: Install run: | - sudo add-apt-repository ppa:ubuntugis/ubuntugis-unstable + sudo apt-get install python3-pytest python3-pytest-cov - - name: Install QGIS + - name: Prepare QGIS run: | - sudo wget -qO /etc/apt/keyrings/qgis-archive-keyring.gpg https://download.qgis.org/downloads/qgis-archive-keyring.gpg - sudo sh -c 'echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/qgis-archive-keyring.gpg] https://qgis.org/ubuntugis `lsb_release -c -s` main" > /etc/apt/sources.list.d/qgis.list' - sudo apt-get update - sudo apt-get install -y qgis + sudo gpg -k && \ + KEYRING=/usr/share/keyrings/qgis-archive-keyring.gpg && \ + wget -O $KEYRING https://download.qgis.org/downloads/qgis-archive-keyring.gpg && \ + sudo touch /etc/apt/sources.list.d/qgis.sources && \ + echo 'Types: deb deb-src' | sudo tee -a /etc/apt/sources.list.d/qgis.sources && \ + echo 'URIs: https://qgis.org/ubuntugis' | sudo tee -a /etc/apt/sources.list.d/qgis.sources && \ + echo 'Suites: '$(lsb_release -c -s) | sudo tee -a /etc/apt/sources.list.d/qgis.sources && \ + echo 'Architectures: '$(dpkg --print-architecture) | sudo tee -a /etc/apt/sources.list.d/qgis.sources && \ + echo 'Components: main' | sudo tee -a /etc/apt/sources.list.d/qgis.sources && \ + echo 'Signed-By: '$KEYRING | sudo tee -a /etc/apt/sources.list.d/qgis.sources && \ + LASTSUPPORTED=focal && \ + KEYRING=/usr/share/keyrings/ubuntugis-archive-keyring.gpg && \ + sudo gpg --no-default-keyring --keyring $KEYRING --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys 6B827C12C2D425E227EDCA75089EBE08314DF160 && \ + sudo touch /etc/apt/sources.list.d/ubuntugis-unstable.sources && \ + echo 'Types: deb deb-src' | sudo tee -a /etc/apt/sources.list.d/ubuntugis-unstable.sources && \ + echo 'URIs:https://ppa.launchpadcontent.net/ubuntugis/ubuntugis-unstable/ubuntu' | sudo tee -a /etc/apt/sources.list.d/ubuntugis-unstable.sources && \ + echo 'Suites: '$(lsb_release -c -s)| sudo tee -a /etc/apt/sources.list.d/ubuntugis-unstable.sources && \ + echo 'Architectures: '$(dpkg --print-architecture) | sudo tee -a /etc/apt/sources.list.d/ubuntugis-unstable.sources && \ + echo 'Components: main' | sudo tee -a /etc/apt/sources.list.d/ubuntugis-unstable.sources && \ + echo 'Signed-By: '$KEYRING | sudo tee -a /etc/apt/sources.list.d/ubuntugis-unstable.sources + + - name: Install + run: | + sudo apt-get update && \ + sudo apt-get -y -q install --no-install-recommends wget software-properties-common build-essential ca-certificates python3-pip dialog apt-utils && \ + sudo apt -y -q install qgis qgis-dev qgis-plugin-grass - name: QGIS Version run: qgis --version - name: Install Python packages - run: pip install pytest pytest-qgis pytest-cov + run: pip install pytest-qgis --break-system-packages - name: Checkout uses: actions/checkout@v4 diff --git a/los_tools/classes/classes_los.py b/los_tools/classes/classes_los.py index 257a0a5..0ab7003 100644 --- a/los_tools/classes/classes_los.py +++ b/los_tools/classes/classes_los.py @@ -1,6 +1,8 @@ from __future__ import annotations +import copy import math +import typing from typing import List, Optional, Union from qgis.core import QgsFeature, QgsGeometry, QgsPoint @@ -156,6 +158,8 @@ def is_visible_at_index(self, index: int, return_integer: bool = False) -> Union return int(self.visible[index]) if return_integer else self.visible[index] def get_geom_at_index(self, index: int) -> QgsPoint: + if index == -1: + index = 0 point = QgsPoint( self.points[index][self.X], self.points[index][self.Y], @@ -505,6 +509,38 @@ def from_feature( sampling_distance=sampling_distance, ) + @classmethod + def from_another( + cls, + other: LoSWithoutTarget, + distance_limit: typing.Optional[float] = None, + ) -> LoSWithoutTarget: + obj = LoSWithoutTarget.__new__(LoSWithoutTarget) + obj.observer_offset = other.observer_offset + obj.use_curvature_corrections = other.use_curvature_corrections + obj.refraction_coefficient = other.refraction_coefficient + obj.is_global = False + obj.is_without_target = True + obj.target_offset = 0 + obj.target_x = None + obj.target_y = None + obj.target_index = None + obj.global_horizon_index = None + + if distance_limit is None: + obj.points = copy.deepcopy(other.points) + obj.previous_max_angle = copy.deepcopy(other.previous_max_angle) + obj.visible = copy.deepcopy(other.visible) + obj.horizon = copy.deepcopy(other.horizon) + else: + index_limit = other._get_distance_limit_index(distance_limit) + obj.points = copy.deepcopy(other.points[:index_limit]) + obj.previous_max_angle = copy.deepcopy(other.previous_max_angle[:index_limit]) + obj.visible = copy.deepcopy(other.visible[:index_limit]) + obj.horizon = copy.deepcopy(other.horizon[:index_limit]) + + return obj + def get_horizontal_angle(self) -> float: azimuth = QgsPoint(self.points[0][self.X], self.points[0][self.Y]).azimuth( QgsPoint(self.points[-1][self.X], self.points[-1][self.Y]) @@ -579,3 +615,13 @@ def get_global_horizon_elevation_difference(self): else: return None + + def _get_distance_limit_index(self, distance: float) -> int: + index = len(self.points) - 1 + + for i in range(1, len(self.points)): + if self.points[i][self.DISTANCE] > distance: + index = i + break + + return index diff --git a/los_tools/constants/field_names.py b/los_tools/constants/field_names.py index 005d02a..976cb23 100644 --- a/los_tools/constants/field_names.py +++ b/los_tools/constants/field_names.py @@ -69,3 +69,5 @@ class FieldNames: ANGLE_STEP = "angle_step_between_los" ANGLE_STEP_POINTS = "angle_step_between_points" + + HORIZON_DISTANCE = "horizon_distance" diff --git a/los_tools/gui/create_los_tool/create_los_tool.py b/los_tools/gui/create_los_tool/create_los_tool.py index 673f151..c5f834c 100755 --- a/los_tools/gui/create_los_tool/create_los_tool.py +++ b/los_tools/gui/create_los_tool/create_los_tool.py @@ -91,12 +91,7 @@ def canvasReleaseEvent(self, e: QgsMapMouseEvent) -> None: self._start_point = None def canvasMoveEvent(self, event: QgsMapMouseEvent) -> None: - result = self._snapper.snapToMap(event.pos()) - self.snap_marker.setMatch(result) - if result.type() == QgsPointLocator.Vertex: - self._snap_point = result.point() - else: - self._snap_point = event.mapPoint() + self._set_snap_point(event) if self._start_point is not None: self.draw_los(self._snap_point) @@ -216,6 +211,7 @@ def add_los_to_layer(self) -> None: task.taskFinishedTime.connect(self.task_finished_message) self.task_manager.addTask(task) + self.clean() def task_finished(self) -> None: self.featuresAdded.emit() diff --git a/los_tools/gui/create_los_tool/los_digitizing_tool_with_widget.py b/los_tools/gui/create_los_tool/los_digitizing_tool_with_widget.py index 8aa4141..41ea55e 100644 --- a/los_tools/gui/create_los_tool/los_digitizing_tool_with_widget.py +++ b/los_tools/gui/create_los_tool/los_digitizing_tool_with_widget.py @@ -1,4 +1,6 @@ -from qgis.core import Qgis, QgsPointLocator +import typing + +from qgis.core import Qgis, QgsPointLocator, QgsPointXY from qgis.gui import QgisInterface, QgsMapMouseEvent, QgsMapToolAdvancedDigitizing, QgsSnapIndicator from qgis.PyQt.QtCore import Qt from qgis.PyQt.QtGui import QKeyEvent @@ -20,6 +22,8 @@ def __init__(self, iface: QgisInterface) -> None: self._los_rubber_band = self.createRubberBand(Qgis.GeometryType.Line) + self._snap_point: typing.Optional[QgsPointXY] = None + def activate(self) -> None: super().activate() @@ -70,7 +74,7 @@ def keyPressEvent(self, e: QKeyEvent) -> None: self._iface.mapCanvas().unsetMapTool(self) return super().keyPressEvent(e) - def canvasMoveEvent(self, event: QgsMapMouseEvent) -> None: + def _set_snap_point(self, event: QgsMapMouseEvent) -> None: result = self._snapper.snapToMap(event.pos()) self.snap_marker.setMatch(result) if result.type() == QgsPointLocator.Vertex: diff --git a/los_tools/gui/dialog_los_settings.py b/los_tools/gui/dialog_los_settings.py index aa64173..b77f60c 100755 --- a/los_tools/gui/dialog_los_settings.py +++ b/los_tools/gui/dialog_los_settings.py @@ -106,8 +106,9 @@ def init_gui(self) -> None: self.maximal_los_length.setValue(100, QgsUnitTypes.DistanceKilometers) self.maximal_los_length.valueChanged.connect(self.fill_distances) self.maximal_los_length.setDisabled(True) + self.use_maximal_los_length = QCheckBox() - self.use_maximal_los_length.stateChanged.connect(self.fill_distances) + self.use_maximal_los_length.toggled.connect(self.fill_distances) layout_group_box_los.addRow("Default Sampling Size", self.default_sampling_size) layout_group_box_los.addRow("Use Maximal LoS Length", self.use_maximal_los_length) @@ -209,7 +210,7 @@ def remove_distance(self) -> None: self.fill_distances() def fill_distances(self) -> None: - self.maximal_los_length.setEnabled(self.use_maximal_los_length.isChecked()) + self.maximal_los_length.setDisabled(not self.use_maximal_los_length.isChecked()) self.treeView.clear() @@ -246,7 +247,7 @@ def fill_distances(self) -> None: item = QTreeWidgetItem() item.setText( 0, - f"Over {self._distances[-1]} to {self.maximal_los_length.distance().inUnits(result_unit)}", + f"Over {self._distances[-1]} to {self.maximal_los_length.distance()}", ) item.setText(1, str(size)) diff --git a/los_tools/gui/dialog_raster_validations.py b/los_tools/gui/dialog_raster_validations.py index 3748c84..3ba4694 100755 --- a/los_tools/gui/dialog_raster_validations.py +++ b/los_tools/gui/dialog_raster_validations.py @@ -34,6 +34,8 @@ def __init__(self, iface=None) -> None: self.init_gui() + self._prepare() + def init_gui(self): self.setMinimumWidth(600) self.setWindowTitle("Rasters Validation and Sampling") @@ -152,16 +154,17 @@ def _default_tools(self) -> None: self._prev_map_tool = self._canvas.mapTool() self._prev_cursor = self._canvas.cursor() - def open(self) -> None: + def _prepare(self) -> None: self._default_tools() self._populate_raster_view() self.validate() + + def open(self) -> None: + self._prepare() super().open() def exec(self) -> int: - self._default_tools() - self._populate_raster_view() - self.validate() + self._prepare() return super().exec() def select_sample_point(self) -> None: diff --git a/los_tools/gui/los_without_target_visualization/los_without_target.py b/los_tools/gui/los_without_target_visualization/los_without_target.py index 87cf3f2..05f5128 100644 --- a/los_tools/gui/los_without_target_visualization/los_without_target.py +++ b/los_tools/gui/los_without_target_visualization/los_without_target.py @@ -29,6 +29,7 @@ def canvasReleaseEvent(self, e: QgsMapMouseEvent) -> None: if e.button() == Qt.RightButton: self.clean() if e.button() == Qt.LeftButton: + self._set_snap_point(e) self.draw_los() def draw_los(self): diff --git a/los_tools/metadata.txt b/los_tools/metadata.txt index 715e0fd..a9d3b2a 100755 --- a/los_tools/metadata.txt +++ b/los_tools/metadata.txt @@ -6,7 +6,7 @@ name=LoS Tools qgisMinimumVersion=3.30 description=This plugin creates and analyzes lines-of-sight (LoS) and also provides supporting tools. -version=1.1.2 +version=1.2.0 author=Jan Caha email=jan.caha@outlook.com @@ -20,7 +20,9 @@ repository=https://github.com/JanCaha/qgis_los_tools hasProcessingProvider=yes # Uncomment the following line and add your changelog: -changelog=1.1.2 +changelog=1.2.0 + - new tool to extract horizon lines at specific distance +

1.1.2 - fix issue with interactive tools not opening properly after closing

1.1.1 - simplify inner working of GUI tools diff --git a/los_tools/processing/doc/tool_extract_horizon_lines_by_distances.help b/los_tools/processing/doc/tool_extract_horizon_lines_by_distances.help new file mode 100644 index 0000000..38607c6 --- /dev/null +++ b/los_tools/processing/doc/tool_extract_horizon_lines_by_distances.help @@ -0,0 +1,9 @@ +{ + "ALG_DESC": "This tool extracts horizon lines from LoS without a target. For other types of LoS, this operation is not applicable. Maximal horizon till specified distance is created. So horizon lines for several distances can be created at once.", + "ALG_CREATOR": "Jan Caha", + "LoSLayer": "LoS layer to analyze.", + "Distances": "Table of distance limits for which horizon lines should be extracted.", + "CurvatureCorrections": "Should curvature and refraction corrections be applied?", + "RefractionCoefficient": "Value of the refraction coefficient. Default value: 0.13.", + "OutputLayer": "Output layer containing the extracted horizon lines." +} \ No newline at end of file diff --git a/los_tools/processing/horizons/tool_extract_horizon_lines_by_distances.py b/los_tools/processing/horizons/tool_extract_horizon_lines_by_distances.py new file mode 100644 index 0000000..78278ef --- /dev/null +++ b/los_tools/processing/horizons/tool_extract_horizon_lines_by_distances.py @@ -0,0 +1,238 @@ +import typing + +from qgis.core import ( + QgsFeature, + QgsFeatureRequest, + QgsField, + QgsFields, + QgsLineString, + QgsPoint, + QgsProcessing, + QgsProcessingAlgorithm, + QgsProcessingException, + QgsProcessingFeedback, + QgsProcessingParameterBoolean, + QgsProcessingParameterFeatureSink, + QgsProcessingParameterFeatureSource, + QgsProcessingParameterMatrix, + QgsProcessingParameterNumber, + QgsProcessingUtils, + QgsVectorLayer, + QgsWkbTypes, +) + +from los_tools.classes.classes_los import LoSWithoutTarget +from los_tools.constants.field_names import FieldNames +from los_tools.constants.names_constants import NamesConstants +from los_tools.processing.tools.util_functions import get_los_type +from los_tools.utils import COLUMN_TYPE, COLUMN_TYPE_STRING, get_doc_file + + +class ExtractHorizonLinesByDistanceAlgorithm(QgsProcessingAlgorithm): + LOS_LAYER = "LoSLayer" + OUTPUT_LAYER = "OutputLayer" + CURVATURE_CORRECTIONS = "CurvatureCorrections" + REFRACTION_COEFFICIENT = "RefractionCoefficient" + DISTANCES = "Distances" + + horizons_types = [NamesConstants.HORIZON_MAX_LOCAL, NamesConstants.HORIZON_GLOBAL] + + def initAlgorithm(self, config=None): + self.addParameter( + QgsProcessingParameterFeatureSource(self.LOS_LAYER, "LoS layer", [QgsProcessing.TypeVectorLine]) + ) + + self.addParameter( + QgsProcessingParameterMatrix( + self.DISTANCES, + "Distance limits for horizon lines", + numberRows=1, + headers=["Distance"], + ) + ) + + self.addParameter(QgsProcessingParameterFeatureSink(self.OUTPUT_LAYER, "Output layer")) + + self.addParameter( + QgsProcessingParameterBoolean( + self.CURVATURE_CORRECTIONS, + "Use curvature corrections?", + defaultValue=True, + ) + ) + + self.addParameter( + QgsProcessingParameterNumber( + self.REFRACTION_COEFFICIENT, + "Refraction coefficient value", + type=QgsProcessingParameterNumber.Double, + defaultValue=0.13, + ) + ) + + def checkParameterValues(self, parameters, context): + los_layer = self.parameterAsVectorLayer(parameters, self.LOS_LAYER, context) + + field_names = los_layer.fields().names() + + if FieldNames.LOS_TYPE not in field_names: + msg = ( + "Fields specific for LoS not found in current layer ({0}). " + "Cannot extract horizon lines from this layer.".format(FieldNames.LOS_TYPE) + ) + + return False, msg + + los_type = get_los_type(los_layer, field_names) + + if los_type != NamesConstants.LOS_NO_TARGET: + msg = "LoS must be of type `{0}` to extract horizon lines but type `{1}` found.".format( + NamesConstants.LOS_NO_TARGET, los_type + ) + + return False, msg + + distances = self.parameterAsMatrix(parameters, self.DISTANCES, context) + + if len(distances) < 1: + msg = f"Length of distances must be at least 1. It is {len(distances)}." + + return False, msg + + return super().checkParameterValues(parameters, context) + + def processAlgorithm(self, parameters, context, feedback: QgsProcessingFeedback): + los_layer: QgsVectorLayer = self.parameterAsVectorLayer(parameters, self.LOS_LAYER, context) + + if los_layer is None: + raise QgsProcessingException(self.invalidSourceError(parameters, self.LOS_LAYER)) + + distances_matrix = self.parameterAsMatrix(parameters, self.DISTANCES, context) + + distances: typing.List[float] = [] + + for distance in distances_matrix: + try: + distances.append(float(distance)) + except ValueError: + raise QgsProcessingException(f"Cannot convert value `{distance}` to float number.") + + curvature_corrections = self.parameterAsBool(parameters, self.CURVATURE_CORRECTIONS, context) + ref_coeff = self.parameterAsDouble(parameters, self.REFRACTION_COEFFICIENT, context) + + fields = QgsFields() + fields.append(QgsField(FieldNames.HORIZON_DISTANCE, COLUMN_TYPE.Double)) + fields.append(QgsField(FieldNames.ID_OBSERVER, COLUMN_TYPE.Int)) + fields.append(QgsField(FieldNames.OBSERVER_X, COLUMN_TYPE.Double)) + fields.append(QgsField(FieldNames.OBSERVER_Y, COLUMN_TYPE.Double)) + + horizon_distance_field_index = fields.indexFromName(FieldNames.HORIZON_DISTANCE) + id_observer_field_index = fields.indexFromName(FieldNames.ID_OBSERVER) + observer_x_field_index = fields.indexFromName(FieldNames.OBSERVER_X) + observer_y_field_index = fields.indexFromName(FieldNames.OBSERVER_Y) + + sink, dest_id = self.parameterAsSink( + parameters, + self.OUTPUT_LAYER, + context, + fields, + QgsWkbTypes.LineStringZM, + los_layer.sourceCrs(), + ) + + if sink is None: + raise QgsProcessingException(self.invalidSinkError(parameters, self.OUTPUT_LAYER)) + + id_values = list(los_layer.uniqueValues(los_layer.fields().indexFromName(FieldNames.ID_OBSERVER))) + + total = 100 / (los_layer.featureCount()) if los_layer.featureCount() else 0 + + i = 0 + + for id_value in id_values: + request = QgsFeatureRequest() + request.setFilterExpression("{} = '{}'".format(FieldNames.ID_OBSERVER, id_value)) + order_by_clause = QgsFeatureRequest.OrderByClause(FieldNames.AZIMUTH, ascending=True) + request.setOrderBy(QgsFeatureRequest.OrderBy([order_by_clause])) + + features = los_layer.getFeatures(request) + + line_points: typing.Dict[float, typing.List[QgsPoint]] = {} + values: typing.Dict[float, typing.List[float]] = {} + + for distance in distances: + line_points[distance] = [] + values[distance] = [] + + for los_feature in features: + if feedback.isCanceled(): + break + + full_los = LoSWithoutTarget( + los_feature.geometry(), + observer_offset=los_feature.attribute(FieldNames.OBSERVER_OFFSET), + use_curvature_corrections=curvature_corrections, + refraction_coefficient=ref_coeff, + ) + + for distance in distances: + los_limited = LoSWithoutTarget.from_another(full_los, distance_limit=distance) + + line_points[distance].append(los_limited.get_global_horizon()) + values[distance].append(los_limited.get_global_horizon_angle()) + + i += 1 + + feedback.setProgress(int(i * total)) + + for distance in distances: + + points = line_points[distance] + m_values = values[distance] + + if 1 < len(points): + line = QgsLineString(points) + line.addMValue() + + for i in range(0, line.numPoints()): + line.setMAt(i, m_values[i]) + + f = QgsFeature(fields) + f.setGeometry(line) + f.setAttribute(id_observer_field_index, id_value) + f.setAttribute( + observer_x_field_index, + los_feature.attribute(FieldNames.OBSERVER_X), + ) + f.setAttribute( + observer_y_field_index, + los_feature.attribute(FieldNames.OBSERVER_Y), + ) + f.setAttribute(horizon_distance_field_index, distance) + + sink.addFeature(f) + + return {self.OUTPUT_LAYER: dest_id} + + def name(self): + return "extracthorizonlinesbydistace" + + def displayName(self): + return "Extract Horizon Lines By Distance" + + def group(self): + return "Horizons" + + def groupId(self): + return "horizons" + + def createInstance(self): + return ExtractHorizonLinesByDistanceAlgorithm() + + def helpUrl(self): + # TODO FIXME + return "https://jancaha.github.io/qgis_los_tools/tools/Horizons/tool_extract_horizon_lines/" + + # def shortHelpString(self): + # # TODO FIXME + # return QgsProcessingUtils.formatHelpMapAsHtml(get_doc_file(__file__), self) diff --git a/los_tools/processing/los_tools_provider.py b/los_tools/processing/los_tools_provider.py index 1db70b5..d295722 100755 --- a/los_tools/processing/los_tools_provider.py +++ b/los_tools/processing/los_tools_provider.py @@ -18,6 +18,7 @@ from los_tools.processing.create_points.tool_points_by_azimuths import CreatePointsInAzimuthsAlgorithm from los_tools.processing.create_points.tool_points_in_direction import CreatePointsInDirectionAlgorithm from los_tools.processing.horizons.tool_extract_horizon_lines import ExtractHorizonLinesAlgorithm +from los_tools.processing.horizons.tool_extract_horizon_lines_by_distances import ExtractHorizonLinesByDistanceAlgorithm from los_tools.processing.horizons.tool_extract_horizons import ExtractHorizonsAlgorithm from los_tools.processing.parameter_settings.tool_angle_at_distance_for_size import ObjectDetectionAngleAlgorithm from los_tools.processing.parameter_settings.tool_distances_for_sizes import ObjectDistancesAlgorithm @@ -77,6 +78,7 @@ def loadAlgorithms(self): self.addAlgorithm(ExtractLoSVisibilityPolygonsAlgorithm()) self.addAlgorithm(ObjectDetectionAngleAlgorithm()) self.addAlgorithm(CreatePointsInAzimuthsAlgorithm()) + self.addAlgorithm(ExtractHorizonLinesByDistanceAlgorithm()) def id(self): return PluginConstants.provider_id diff --git a/los_tools/processing/parameter_settings/tool_sizes_at_distances.py b/los_tools/processing/parameter_settings/tool_sizes_at_distances.py index 47b7167..4a5fe16 100755 --- a/los_tools/processing/parameter_settings/tool_sizes_at_distances.py +++ b/los_tools/processing/parameter_settings/tool_sizes_at_distances.py @@ -73,7 +73,7 @@ def checkParameterValues(self, parameters, context): distances = self.parameterAsMatrix(parameters, self.DISTANCES, context) if len(distances) < 1: - msg = f"Legth of distances must be at least 1. It is {len(distances)}." + msg = f"Length of distances must be at least 1. It is {len(distances)}." return False, msg diff --git a/los_tools/processing/tools/util_functions.py b/los_tools/processing/tools/util_functions.py index 05c4a57..1accb07 100644 --- a/los_tools/processing/tools/util_functions.py +++ b/los_tools/processing/tools/util_functions.py @@ -92,7 +92,7 @@ def check_existence_los_fields(field_names: List[str]) -> None: def wkt_to_array_points(wkt: str) -> List[List[float]]: - reg = re.compile("(LINESTRING |LineStringZ |MULTILINESTRING |MultiLineStringZ )") + reg = re.compile(r"(LineString\s?Z |LINESTRING |MULTILINESTRING |MultiLineString\s?Z )", re.IGNORECASE) wkt = reg.sub("", wkt) diff --git a/tests/processing/horizons/tool_extract_horizon_lines_by_distances.py b/tests/processing/horizons/tool_extract_horizon_lines_by_distances.py new file mode 100644 index 0000000..7436969 --- /dev/null +++ b/tests/processing/horizons/tool_extract_horizon_lines_by_distances.py @@ -0,0 +1,92 @@ +import typing + +import pytest +from qgis.core import Qgis, QgsVectorLayer + +from los_tools.constants.field_names import FieldNames +from los_tools.processing.horizons.tool_extract_horizon_lines_by_distances import ExtractHorizonLinesByDistanceAlgorithm +from tests.custom_assertions import ( + assert_algorithm, + assert_check_parameter_values, + assert_field_names_exist, + assert_layer, + assert_parameter, + assert_run, +) +from tests.utils import result_filename + + +def test_parameters() -> None: + alg = ExtractHorizonLinesByDistanceAlgorithm() + alg.initAlgorithm() + + assert_parameter(alg.parameterDefinition("LoSLayer"), parameter_type="source") + assert_parameter(alg.parameterDefinition("OutputLayer"), parameter_type="sink") + assert_parameter(alg.parameterDefinition("CurvatureCorrections"), parameter_type="boolean", default_value=True) + assert_parameter(alg.parameterDefinition("RefractionCoefficient"), parameter_type="number", default_value=0.13) + assert_parameter(alg.parameterDefinition("Distances"), parameter_type="matrix") + + +def test_alg_settings() -> None: + alg = ExtractHorizonLinesByDistanceAlgorithm() + alg.initAlgorithm() + + assert_algorithm(alg) + + +def test_wrong_params( + los_no_target_wrong: QgsVectorLayer, los_local: QgsVectorLayer, los_global: QgsVectorLayer +) -> None: + alg = ExtractHorizonLinesByDistanceAlgorithm() + alg.initAlgorithm() + + # use layer that is not corrently constructed LoS layer + params = {"LoSLayer": los_no_target_wrong} + + with pytest.raises(AssertionError, match="Fields specific for LoS not found in current layer"): + assert_check_parameter_values(alg, params) + + params = {"LoSLayer": los_local, "HorizonType": 0} + + with pytest.raises( + AssertionError, match="LoS must be of type `without target` to extract horizon lines but type `local` found" + ): + assert_check_parameter_values(alg, params) + + params = {"LoSLayer": los_global, "HorizonType": 0} + + with pytest.raises( + AssertionError, match="LoS must be of type `without target` to extract horizon lines but type `global` found" + ): + assert_check_parameter_values(alg, params) + + +def test_run_alg(los_no_target: QgsVectorLayer) -> None: + alg = ExtractHorizonLinesByDistanceAlgorithm() + alg.initAlgorithm() + + output_path = result_filename("horizon_lines.gpkg") + + distances = [10, 30, 50, 100, 200] + params = { + "LoSLayer": los_no_target, + "Distances": distances, + "OutputLayer": output_path, + "CurvatureCorrections": True, + "RefractionCoefficient": 0.13, + } + + assert_run(alg, params) + + horizon_lines_layer = QgsVectorLayer(output_path) + + assert_layer(horizon_lines_layer, geom_type=Qgis.WkbType.LineStringZM, crs=los_no_target.sourceCrs()) + + assert_field_names_exist( + [FieldNames.HORIZON_DISTANCE, FieldNames.ID_OBSERVER, FieldNames.OBSERVER_X, FieldNames.OBSERVER_Y], + horizon_lines_layer, + ) + + unique_ids = list(los_no_target.uniqueValues(los_no_target.fields().lookupField(FieldNames.ID_OBSERVER))) + + assert horizon_lines_layer.featureCount() == len(unique_ids) * len(distances) diff --git a/website/pages/images/tool_extract_horizon_lines_by_distances.png b/website/pages/images/tool_extract_horizon_lines_by_distances.png new file mode 100644 index 0000000..4e2b91e Binary files /dev/null and b/website/pages/images/tool_extract_horizon_lines_by_distances.png differ diff --git a/website/pages/tools/Horizons/tool_extract_horizon_lines_by_distances.md b/website/pages/tools/Horizons/tool_extract_horizon_lines_by_distances.md new file mode 100644 index 0000000..9baeb29 --- /dev/null +++ b/website/pages/tools/Horizons/tool_extract_horizon_lines_by_distances.md @@ -0,0 +1,32 @@ +# Extract Horizon Lines + +Tool that extracts horizon lines from LoS without target (created using tool [Create no target LoS](../LoS Creation/tool_create_notarget_los.md)). For other types of LoS this operation does not make sense. + +The horizon lines are extracted for a set of distances from the observer. The distances are provided in the table. The horizon line is extracted for each observer and distance separately, only horizons that are closer to the observer than the given distance are consider for each horizon line. + +## Parameters + +| Label | Name | Type | Description | +| --------------------------------- | ----------------------- | ----------------------------------------- | --------------------------------------------------------------------- | +| LoS layer | `LoSLayer` | [vector: line] | LoS layer to analyse. | +| Distance limits for horizon lines | `Distances` | [matrix] | Table of distance limits for which horizon lines should be extracted. | +| Output layer | `OutputLayer` | [vector: line] | Output layer horizon lines. | +| Use curvature corrections? | `CurvatureCorrections` | [boolean]

Default: `True` | Should the curvarture and refraction corrections be used? | +| Refraction coefficient value | `RefractionCoefficient` | [number]

Default:
`0.13` | Value of refraction coefficient. | + +## Outputs + +| Label | Name | Type | Description | +| ------------ | ------------- | -------------- | --------------------------- | +| Output layer | `OutputLayer` | [vector: line] | Output layer horizon lines. | + +### Fields in the output layer + +* __horizon_distance__ - double - maximal distance of the horizon line from observer +* __id_observer__ - integer - value from expected field (`id_observer`) in `LoSLayer` +* __observer_x__ - double - X coordinate of observer point, to be used later in analyses +* __observer_y__ - double - Y coordinate of observer point, to be used later in analyses + +## Tool screenshot + +![Extract Horizon Lines](../../images/tool_extract_horizon_lines_by_distances.png)