Skip to content
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
4 changes: 2 additions & 2 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@

def test_from_docstring(capsys: pytest.CaptureFixture[str]):
"""Test the from_docstring decorator applies help texts correctly."""
pytest.skip("Broken")

app = typer.Typer()

@cli.from_docstring(app)
Expand Down Expand Up @@ -145,7 +145,7 @@ def implicit_command(

def test_from_docstring_kwargs(capsys: pytest.CaptureFixture[str]) -> None:
"""Test the from_docstring decorator passes kwargs correctly."""
pytest.skip("Broken")

app = typer.Typer()

@cli.from_docstring(app, name="command_1")
Expand Down
142 changes: 142 additions & 0 deletions tests/test_coordinates.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,114 @@
import re

import numpy as np
import pyproj
import pytest
from hypothesis import given
from hypothesis import strategies as st

from qcore import coordinates
from qcore.coordinates import R_EARTH, SphericalProjection


@pytest.mark.parametrize(
"coords,expected",
[
(np.array([-43.5320, 172.6366]), np.array([5180040.61473068, 1570636.6812821])),
(
np.array([-43.5320, 172.6366, 1]),
np.array([5180040.61473068, 1570636.6812821, 1]),
),
(
np.array([[-36.8509, 174.7645, 100], [-41.2924, 174.7787, 0]]),
np.array(
[
[5.92021456e06, 1.75731133e06, 1.00000000e02],
[5.42725716e06, 1.74893148e06, 0.00000000e00],
]
),
),
],
)
def test_wgs_depth_to_nztm_nominal(coords: np.ndarray, expected: np.ndarray) -> None:
result = coordinates.wgs_depth_to_nztm(coords)
assert np.allclose(result, expected, rtol=1e-6)
assert result.shape == expected.shape
assert result.dtype == np.float64


@pytest.mark.parametrize(
"coords,expected",
[
(np.array([5180040.61473068, 1570636.6812821]), np.array([-43.5320, 172.6366])),
(
np.array([5180040.61473068, 1570636.6812821, 0]),
np.array([-43.5320, 172.6366, 0]),
),
(
np.array(
[
[5.92021456e06, 1.75731133e06, 100],
[5.42725716e06, 1.74893148e06, 100],
]
),
np.array([[-36.8509, 174.7645, 100], [-41.2924, 174.7787, 100]]),
),
],
)
def test_nztm_to_wgs_depth_nominal(coords: np.ndarray, expected: np.ndarray) -> None:
result = coordinates.nztm_to_wgs_depth(coords)
assert np.allclose(result, expected, rtol=1e-6)
assert result.shape == expected.shape
assert result.dtype == np.float64


def test_distance_between_wgs_depth_coordinates_single_point() -> None:
# Two points in lat/lon
point_a = np.array([-43.5320, 172.6366])
point_b = np.array([-43.5310, 172.6376])

dist = coordinates.distance_between_wgs_depth_coordinates(point_a, point_b)
assert isinstance(dist, float)
assert dist > 0 # distance should be positive


def test_distance_between_wgs_depth_coordinates_with_depth() -> None:
point_a = np.array([-43.5320, 172.6366, 10])
point_b = np.array([-43.5325, 172.6370, 20])

dist = coordinates.distance_between_wgs_depth_coordinates(point_a, point_b)
assert isinstance(dist, float)
assert dist > 0


def test_distance_between_wgs_depth_coordinates_multiple_points() -> None:
points_a = np.array([[-43.5320, 172.6366], [-41.2924, 174.7787]])
points_b = np.array([[-43.5310, 172.6376], [-41.2920, 174.7790]])

dist = coordinates.distance_between_wgs_depth_coordinates(points_a, points_b)
assert isinstance(dist, np.ndarray)
assert dist.shape == (2,)
assert np.all(dist > 0) # type: ignore[unsupported-operator]


def test_nztm_to_gc_bearing_inverse() -> None:
origin = np.array([-43.5, 172.6])
distance = 10.0 # km
nztm_bearing = 45.0 # degrees

gc_bearing = coordinates.nztm_bearing_to_great_circle_bearing(
origin, distance, nztm_bearing
)
assert isinstance(gc_bearing, float)
recovered_nztm = coordinates.great_circle_bearing_to_nztm_bearing(
origin, distance, gc_bearing
)
assert isinstance(recovered_nztm, float)

# The recovered NZTM bearing should be very close to the original
assert np.isclose(recovered_nztm, nztm_bearing, atol=1e-6)


def latitude(
min_value: float = -90.0, max_value: float = 90.0, **kwargs
) -> st.SearchStrategy:
Expand All @@ -31,6 +133,41 @@ def longitude(
)


def test_wgs_depth_to_nztm_invalid_coordinates() -> None:
with pytest.raises(
ValueError,
match=re.escape(
"Latitude and longitude coordinates given are invalid (did you input lon, lat instead of lat, lon?)"
),
):
coordinates.wgs_depth_to_nztm(np.array([-180.0, 0.0]))

with pytest.raises(
ValueError,
match=re.escape(
"Latitude and longitude coordinates given are invalid (did you input lon, lat instead of lat, lon?)"
),
):
coordinates.wgs_depth_to_nztm(np.array([np.nan, np.nan]))
Comment on lines +136 to +151
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The two test cases for invalid coordinates can be combined into a single parametrized test for better conciseness and maintainability. This avoids repeating the pytest.raises context manager.

Suggested change
def test_wgs_depth_to_nztm_invalid_coordinates() -> None:
with pytest.raises(
ValueError,
match=re.escape(
"Latitude and longitude coordinates given are invalid (did you input lon, lat instead of lat, lon?)"
),
):
coordinates.wgs_depth_to_nztm(np.array([-180.0, 0.0]))
with pytest.raises(
ValueError,
match=re.escape(
"Latitude and longitude coordinates given are invalid (did you input lon, lat instead of lat, lon?)"
),
):
coordinates.wgs_depth_to_nztm(np.array([np.nan, np.nan]))
@pytest.mark.parametrize(
"invalid_coords",
[
np.array([-180.0, 0.0]),
np.array([np.nan, np.nan]),
],
)
def test_wgs_depth_to_nztm_invalid_coordinates(invalid_coords: np.ndarray) -> None:
with pytest.raises(
ValueError,
match=re.escape(
"Latitude and longitude coordinates given are invalid (did you input lon, lat instead of lat, lon?)"
),
):
coordinates.wgs_depth_to_nztm(invalid_coords)



def test_nztm_wgs_depth_invalid_coordinates() -> None:
with pytest.raises(
ValueError,
match=re.escape(
"NZTM coordinates given are invalid (did you input x, y instead of y, x?)"
),
):
coordinates.nztm_to_wgs_depth(np.array([1e10, 1e10]))
with pytest.raises(
ValueError,
match=re.escape(
"NZTM coordinates given are invalid (did you input x, y instead of y, x?)"
),
):
coordinates.nztm_to_wgs_depth(np.array([np.nan, np.nan]))
Comment on lines +154 to +168
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

Similar to the previous test, these two test cases for invalid NZTM coordinates can be combined into a single parametrized test to improve code conciseness and reduce duplication.

Suggested change
def test_nztm_wgs_depth_invalid_coordinates() -> None:
with pytest.raises(
ValueError,
match=re.escape(
"NZTM coordinates given are invalid (did you input x, y instead of y, x?)"
),
):
coordinates.nztm_to_wgs_depth(np.array([1e10, 1e10]))
with pytest.raises(
ValueError,
match=re.escape(
"NZTM coordinates given are invalid (did you input x, y instead of y, x?)"
),
):
coordinates.nztm_to_wgs_depth(np.array([np.nan, np.nan]))
@pytest.mark.parametrize(
"invalid_coords",
[
np.array([1e10, 1e10]),
np.array([np.nan, np.nan]),
],
)
def test_nztm_wgs_depth_invalid_coordinates(invalid_coords: np.ndarray) -> None:
with pytest.raises(
ValueError,
match=re.escape(
"NZTM coordinates given are invalid (did you input x, y instead of y, x?)"
),
):
coordinates.nztm_to_wgs_depth(invalid_coords)



GEOD = pyproj.Geod(ellps="sphere", a=R_EARTH * 1000.0, b=R_EARTH * 1000.0)
HALF_SPHERE = R_EARTH * np.pi / 2.0 # Half-circumference of a hemisphere

Expand Down Expand Up @@ -64,6 +201,11 @@ def test_projection_inverse_is_identity(
assert pytest.approx(np.array([lat, lon]), rel=1e-4, abs=1e-4) == back


def test_projection_repr() -> None:
proj = SphericalProjection(172.0, -43.0, 0.0)
assert repr(proj) == "SphericalProjection(mlon=172.0, mlat=-43.0, mrot=0.0)"


# For this test we must exclude the poles because they don't behave
# like the other points wrt. the longitude. Namely the longitude is
# always 0 at the poles (because it is undefined), so shifting in
Expand Down
85 changes: 85 additions & 0 deletions tests/test_distributions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import hypothesis.strategies as st
import numpy as np
from hypothesis import given, settings

from qcore.uncertainties.distributions import (
rand_shyp,
truncated_log_normal,
truncated_normal,
truncated_weibull,
truncated_weibull_expected_value,
)


def test_truncated_normal_with_size_1() -> None:
mean = 0
std_dev = 1.0
limit = 5.0
samples = truncated_normal(mean, std_dev, limit, size=1, seed=0)
assert isinstance(samples, float)


def test_truncated_weibull_with_size_1() -> None:
upper = 1
samples = truncated_weibull(upper, size=1, seed=0)
assert isinstance(samples, float)


def test_truncated_log_normal_with_size_1() -> None:
mean = 1
std_dev = 0.1
samples = truncated_log_normal(mean, std_dev, size=1, seed=0)
assert isinstance(samples, float)


@given(mean=st.floats(-1e3, 1e3), std_dev=st.floats(0.1, 1e2), limit=st.floats(1, 10))
@settings(max_examples=20)
def test_truncated_normal_vectorized(mean: float, std_dev: float, limit: float) -> None:
samples = truncated_normal(mean, std_dev, limit, size=200, seed=0)
assert np.all(np.isfinite(samples))
assert np.all(samples >= mean - limit * std_dev)
assert np.all(samples <= mean + limit * std_dev)


@given(upper=st.floats(0.1, 1e3), seed=st.integers(0, 1_000_000))
@settings(max_examples=20)
def test_truncated_weibull_vectorized(upper: float, seed: int) -> None:
samples = truncated_weibull(upper, size=200, seed=seed)
assert np.all(np.isfinite(samples))
assert np.all(samples >= 0)
assert np.all(samples <= upper)
# reproducibility check
val1 = truncated_weibull(upper, seed=seed)
val2 = truncated_weibull(upper, seed=seed)
assert val1 == val2


@given(upper=st.floats(0.1, 1e3))
def test_truncated_weibull_expected_value_bounds(upper: float) -> None:
val = truncated_weibull_expected_value(upper)
assert 0 <= val <= upper


@given(
mean=st.floats(1e-3, 1e3),
std_dev=st.floats(1e-3, 1e2),
seed=st.integers(0, 1_000_000),
)
@settings(max_examples=20)
def test_truncated_log_normal_vectorized(mean: float, std_dev: float, seed: int):
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

For consistency with other test functions in this file and for better type safety, please add the -> None return type hint to this function signature.

Suggested change
def test_truncated_log_normal_vectorized(mean: float, std_dev: float, seed: int):
def test_truncated_log_normal_vectorized(mean: float, std_dev: float, seed: int) -> None:

samples = truncated_log_normal(mean, std_dev, size=200, seed=seed)
assert np.all(np.isfinite(samples))
log_mean = np.log(mean)
assert np.all(np.log(samples) >= log_mean - 2 * std_dev)
assert np.all(np.log(samples) <= log_mean + 2 * std_dev)
# reproducibility
val1 = truncated_log_normal(mean, std_dev, seed=seed)
val2 = truncated_log_normal(mean, std_dev, seed=seed)
assert val1 == val2


def test_rand_shyp_vectorized() -> None:
samples = rand_shyp(size=200, seed=0)
assert np.all(np.isfinite(samples))
assert np.all(samples >= -0.5)
assert np.all(samples <= 0.5)
Loading