Skip to content

Commit

Permalink
Merge pull request #576 from 3dgeo-heidelberg/physical-units
Browse files Browse the repository at this point in the history
Add physical units to the validation layer
  • Loading branch information
dokempf authored Mar 3, 2025
2 parents bb85bdd + 11da68c commit e54e712
Show file tree
Hide file tree
Showing 7 changed files with 143 additions and 23 deletions.
1 change: 1 addition & 0 deletions environment-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ dependencies:
- libgdal
- matplotlib-base
- numpydantic
- pint
- pip
- pybind11
- pydantic
Expand Down
1 change: 1 addition & 0 deletions python/helios/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@
)
from helios.survey import Survey
from helios.util import add_asset_directory, combine_parameters
from helios.validation import units
36 changes: 21 additions & 15 deletions python/helios/scanner.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
from helios.util import get_asset_directories
from helios.validation import (
Angle,
AngleVelocity,
AssetPath,
Frequency,
Length,
Model,
units,
UpdateableMixin,
TimeInterval,
validate_xml_file,
)
from pydantic import validate_call
Expand All @@ -17,26 +23,26 @@ class ScannerSettingsBase(Model, UpdateableMixin, cpp_class=_helios.ScannerSetti

class ScannerSettings(ScannerSettingsBase):
is_active: bool = True
head_rotation: float = 0
rotation_start_angle: float = 0
rotation_stop_angle: float = 0
pulse_frequency: int = 300000
scan_angle: float = 0.349066
min_vertical_angle: float = np.nan
max_vertical_angle: float = np.nan
scan_frequency: float = 200
beam_divergence_angle: float = 0.0003
trajectory_time_interval: float = 0.01
vertical_resolution: float = 0
horizontal_resolution: float = 0
head_rotation: AngleVelocity = 0
rotation_start_angle: Angle = 0
rotation_stop_angle: Angle = 0
pulse_frequency: Frequency = 300000
scan_angle: Angle = 0.349066
min_vertical_angle: Angle = np.nan
max_vertical_angle: Angle = np.nan
scan_frequency: Frequency = 200
beam_divergence_angle: Angle = 0.0003 * units.deg
trajectory_time_interval: TimeInterval = 0.01
vertical_resolution: Length = 0
horizontal_resolution: Length = 0


# TODO: Requires expert input
class RotatingOpticsScannerSettings(ScannerSettingsBase):
is_active: bool = True
head_rotation: float = 0
rotation_start_angle: float = 0
rotation_stop_angle: float = 0
head_rotation: AngleVelocity = 0
rotation_start_angle: Angle = 0
rotation_stop_angle: Angle = 0


# TODO: Requires expert input
Expand Down
3 changes: 2 additions & 1 deletion python/helios/settings.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from helios.validation import (
CreatedDirectory,
Length,
Model,
ThreadCount,
UpdateableMixin,
Expand Down Expand Up @@ -71,7 +72,7 @@ class OutputSettings(Model, UpdateableMixin):
output_dir: CreatedDirectory = "output"
write_waveform: bool = False
write_pulse: bool = False
las_scale: PositiveFloat = 0.0001
las_scale: Length = 0.0001


# Storage for global settings
Expand Down
28 changes: 27 additions & 1 deletion python/helios/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,44 @@

from collections.abc import Iterable
from pathlib import Path
from pint import UnitRegistry
from pydantic import validate_call, GetCoreSchemaHandler
from pydantic.functional_validators import AfterValidator
from pydantic.functional_validators import AfterValidator, BeforeValidator
from pydantic_core import core_schema
from typing import Any, Optional, Type, Union, get_origin, get_args
from typing_extensions import Annotated, dataclass_transform

import annotated_types
import inspect
import multiprocessing
import os
import xmlschema


# The global registry of physical units used in Helios++. Currently there
# are no custom units, so we go with Pint's default registry.
units = UnitRegistry()


def _unit_validator(default_unit):
"""A validator function that converts to a unit if necessary"""

def _validator(v):
quantity = units.Quantity(v)
if quantity.unitless:
return v
return quantity.to(default_unit).magnitude

return BeforeValidator(_validator)


Angle = Annotated[float, _unit_validator(units.rad)]
AngleVelocity = Annotated[float, _unit_validator(units.rad / units.s)]
Frequency = Annotated[int, _unit_validator(units.Hz), annotated_types.Ge(0)]
Length = Annotated[float, _unit_validator(units.m), annotated_types.Ge(0)]
TimeInterval = Annotated[float, _unit_validator(units.s), annotated_types.Ge(0)]


def validate_xml_file(file_path: Path, schema_path: Path):
"""Validate an XML file against an XML schema"""

Expand Down
9 changes: 3 additions & 6 deletions tests/python/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,18 +90,15 @@ def scene(box):

@pytest.fixture
def tls_survey(tls_scanner, tripod, scene):
def torad(x):
return (x / 180.0) * math.pi

survey = Survey(scanner=tls_scanner, platform=tripod, scene=scene)
survey.add_leg(
x=0,
y=0,
z=0,
pulse_frequency=2000,
head_rotation=torad(10),
rotation_start_angle=torad(0),
rotation_stop_angle=torad(10),
head_rotation="10 deg/s",
rotation_start_angle="0 deg",
rotation_stop_angle="10 deg",
)
return survey

Expand Down
88 changes: 88 additions & 0 deletions tests/python/test_validation.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from helios.validation import *

import copy
import math
import numpy as np
import pytest


Expand Down Expand Up @@ -406,3 +408,89 @@ def foo(path: CreatedDirectory):

foo(tmp_path / "nonexistent")
assert (tmp_path / "nonexistent").exists()


def test_angle_annotation():
class Obj(Model):
angle: Angle

# Testing all sorts of valid angle values
assert Obj(angle=1.0).angle == 1.0
assert Obj(angle=1 * units.rad).angle == 1.0
assert Obj(angle="1 rad").angle == 1.0
assert np.isclose(Obj(angle="180 deg").angle, math.pi)
assert np.isclose(Obj(angle=180 * units.deg).angle, math.pi)


def test_anglevelocity_annotation():
class Obj(Model):
anglevel: Optional[AngleVelocity] = None

# Testing all sorts of valid angle velocity values
assert Obj(anglevel=1.0).anglevel == 1.0
assert Obj(anglevel=1 * units.rad / units.s).anglevel == 1.0
assert Obj(anglevel="1 rad/s").anglevel == 1.0
assert np.isclose(Obj(anglevel="180 deg/s").anglevel, math.pi)
assert np.isclose(Obj(anglevel=180 * units.deg / units.s).anglevel, math.pi)
assert np.isclose(Obj(anglevel=360 * units.deg / (2.0 * units.s)).anglevel, math.pi)


def test_frequency_annotation():
class Obj(Model):
frequency: Frequency

# Testing all sorts of frequency values
assert Obj(frequency=1.0).frequency == 1.0
assert Obj(frequency=1 * units.Hz).frequency == 1.0
assert Obj(frequency="1 Hz").frequency == 1.0
assert Obj(frequency="1 kHz").frequency == 1000.0
assert Obj(frequency=1000 * units.mHz).frequency == 1.0
assert Obj(frequency=1 * units.kHz).frequency == 1000.0
assert Obj(frequency=1 * units.MHz).frequency == 1_000_000.0
assert Obj(frequency=1 * units.GHz).frequency == 1_000_000_000.0

with pytest.raises(ValueError):
Obj(frequency=-1.0)

with pytest.raises(ValueError):
Obj(frequency="-1 Hz")


def test_length_annotation():
class Obj(Model):
length: Length

# Testing all sorts of valid length values
assert Obj(length=1.0).length == 1.0
assert Obj(length=1 * units.m).length == 1.0
assert Obj(length="1 m").length == 1.0
assert Obj(length="1 km").length == 1000.0
assert Obj(length=1000 * units.mm).length == 1.0
assert Obj(length=1 * units.cm).length == 0.01
assert Obj(length=1 * units.km).length == 1000.0

with pytest.raises(ValueError):
Obj(length=-1.0)

with pytest.raises(ValueError):
Obj(length="-1 m")


def test_timeinterval_annotation():
class Obj(Model):
timeinterval: TimeInterval

# Testing all sorts of time interval values
assert Obj(timeinterval=1.0).timeinterval == 1.0
assert Obj(timeinterval=1 * units.s).timeinterval == 1.0
assert Obj(timeinterval="1 s").timeinterval == 1.0
assert Obj(timeinterval="1 ms").timeinterval == 0.001
assert Obj(timeinterval=1000 * units.us).timeinterval == 0.001
assert Obj(timeinterval=1 * units.ms).timeinterval == 0.001
assert Obj(timeinterval=1 * units.min).timeinterval == 60.0

with pytest.raises(ValueError):
Obj(timeinterval=-1.0)

with pytest.raises(ValueError):
Obj(timeinterval="-1 s")

0 comments on commit e54e712

Please sign in to comment.