Skip to content

Commit

Permalink
add tool
Browse files Browse the repository at this point in the history
  • Loading branch information
JanCaha committed Nov 12, 2024
1 parent aad63a3 commit e1755d5
Show file tree
Hide file tree
Showing 6 changed files with 373 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -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."
}
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 2 additions & 0 deletions los_tools/processing/los_tools_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -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]<br/><br/>Default: `True` | Should the curvarture and refraction corrections be used? |
| Refraction coefficient value | `RefractionCoefficient` | [number] <br/><br/> Default: <br/> `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)

0 comments on commit e1755d5

Please sign in to comment.