From 3106614f7b7653dd96006631a4f61e6a2bc36193 Mon Sep 17 00:00:00 2001 From: Jake Faulkner Date: Thu, 8 Jan 2026 13:42:54 +1300 Subject: [PATCH] test: expand test suite across geo, coordinates, and distributions Changes: - tests/test_cli.py: Re-enabled previously skipped docstring tests. - tests/test_coordinates.py: Added validation for NZTM/WGS conversions, distance calculations, and bearing transformations. - tests/test_distributions.py: New test file covering truncated normal, log-normal, and Weibull distributions using Hypothesis. - tests/test_geo.py: Expanded coverage for transformation matrices (gen_mat), coordinate rotations, and cross/along-track distances. - tests/test_grid.py: Added error handling for missing resolution parameters in patchgrids. - tests/test_xyts.py: Added tests for meta-only access restrictions in XYTSFile. --- tests/test_cli.py | 4 +- tests/test_coordinates.py | 142 ++++++++++++++++++++++++++++ tests/test_distributions.py | 85 +++++++++++++++++ tests/test_geo.py | 181 ++++++++++++++++++++++++++++++++++++ tests/test_grid.py | 12 +++ tests/test_xyts.py | 29 ++++++ 6 files changed, 451 insertions(+), 2 deletions(-) create mode 100644 tests/test_distributions.py diff --git a/tests/test_cli.py b/tests/test_cli.py index 8d9efbb7..dd6914ad 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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) @@ -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") diff --git a/tests/test_coordinates.py b/tests/test_coordinates.py index 16d62b33..238ae628 100644 --- a/tests/test_coordinates.py +++ b/tests/test_coordinates.py @@ -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: @@ -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])) + + +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])) + + 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 @@ -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 diff --git a/tests/test_distributions.py b/tests/test_distributions.py new file mode 100644 index 00000000..6fd1c36a --- /dev/null +++ b/tests/test_distributions.py @@ -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): + 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) diff --git a/tests/test_geo.py b/tests/test_geo.py index efdff5f9..52a2c727 100644 --- a/tests/test_geo.py +++ b/tests/test_geo.py @@ -1,3 +1,5 @@ +from pathlib import Path + import numpy as np import pytest from hypothesis import given @@ -171,3 +173,182 @@ def test_point_to_segement_degenerate() -> None: """Test the failure case of a degenerate line.""" with pytest.raises(ValueError): geo.point_to_segment_distance([1, 1], [0, 0], [0, 0]) + + +def test_get_distances_single_reference() -> None: + """Test get_distances with a single reference point.""" + locations = np.array([[174.7645, -36.8509], [174.7787, -41.2924]]) # lon, lat + ref_lon, ref_lat = 174.7645, -36.8509 + + distances = geo.get_distances(locations, ref_lon, ref_lat) + + assert distances.shape == (2,) + assert distances[0] == pytest.approx(0.0, abs=1e-6) # Same location + assert distances[1] > 0 # Different location + + +def test_get_distances_multiple_references() -> None: + """Test get_distances with multiple reference points.""" + locations = np.array([[174.7645, -36.8509], [174.7787, -41.2924]]) + ref_lons = np.array([174.7645, 174.7787]) + ref_lats = np.array([-36.8509, -41.2924]) + + distances = geo.get_distances(locations, ref_lons, ref_lats) + + assert distances.shape == (2, 2) + assert distances[0, 0] == pytest.approx(0.0, abs=1e-6) + assert distances[1, 1] == pytest.approx(0.0, abs=1e-6) + + +def test_closest_location() -> None: + """Test closest_location function.""" + locations = np.array( + [[174.7645, -36.8509], [174.7787, -41.2924], [172.6366, -43.5320]] + ) + ref_lon, ref_lat = 174.7650, -36.8510 + + idx, distance = geo.closest_location(locations, ref_lon, ref_lat) + + assert idx == 0 # First location should be closest + assert isinstance(idx, int) + assert isinstance(distance, float) + assert distance < 1.0 # Should be very close (less than 1 km) + + +def test_gen_mat() -> None: + """Test gen_mat transformation matrix generation.""" + mrot, mlon, mlat = 45.0, 174.0, -43.0 + + amat, ainv = geo.gen_mat(mrot, mlon, mlat) + + assert amat.shape == (9,) # Flattened 3x3 matrix + assert ainv.shape == (9,) + # Test that the matrices are valid (not all zeros) + assert np.any(amat != 0) + assert np.any(ainv != 0) + # Test basic properties of transformation matrices + amat_2d = amat.reshape(3, 3) + ainv_2d = ainv.reshape(3, 3) + # The determinant should be close to 1 + assert np.linalg.det(amat_2d) == pytest.approx(1) + assert amat_2d @ ainv_2d == pytest.approx(np.eye(3, dtype=amat_2d.dtype), abs=1e-6) + + +def test_xy2ll_and_ll2xy_roundtrip() -> None: + """Test that xy2ll and ll2xy are inverses of each other.""" + mrot, mlon, mlat = 0.0, 174.0, -43.0 + amat, ainv = geo.gen_mat(mrot, mlon, mlat) + + # Test with some XY offsets + xy_km = np.array([[10.0, 20.0], [5.0, -15.0], [0.0, 0.0]]) + + # Convert XY to lat/lon + ll = geo.xy2ll(xy_km, amat) + + # Convert back to XY + xy_recovered = geo.ll2xy(ll, ainv) + + assert xy_recovered == pytest.approx(xy_km, abs=1e-3) + + +def test_gp2xy() -> None: + """Test gp2xy grid point to XY conversion.""" + gp = np.array([[0, 0], [1, 0], [0, 1], [2, 2]]) + nx, ny = 3, 3 + hh = 1.0 # 1 km spacing + + xy = geo.gp2xy(gp, nx, ny, hh) + + assert xy.shape == (4, 2) + # Check that origin is at center + # For nx=3, ny=3, center should be at index (1, 1) + # gp[0,0] should be offset by -1.0 in both X and Y + assert xy[0, 0] == pytest.approx(-1.0, abs=1e-6) + assert xy[0, 1] == pytest.approx(-1.0, abs=1e-6) + + +def test_rotation_matrix() -> None: + """Test rotation_matrix function.""" + angle = np.pi / 4 # 45 degrees + + rot = geo.rotation_matrix(angle) + + assert rot.shape == (2, 2) + # Test that it's a proper rotation matrix (determinant = 1) + assert np.linalg.det(rot) == pytest.approx(1.0, abs=1e-6) + # Test that it rotates a vector correctly + vec = np.array([1.0, 0.0]) + rotated = rot @ vec + expected = np.array([np.cos(angle), np.sin(angle)]) + assert rotated == pytest.approx(expected, abs=1e-6) + + +def test_path_from_corners_return() -> None: + """Test path_from_corners when returning points.""" + corners = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)] + + path = geo.path_from_corners(corners, output=None, min_edge_points=5, close=True) + + assert path is not None + assert len(path) >= 20 # At least 5 points per edge * 4 edges + assert path[0] == path[-1] # Should be closed + + +def test_path_from_corners_output_file(tmp_path: Path) -> None: + """Test path_from_corners when writing to a file.""" + corners = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)] + output_file = tmp_path / "test_path.txt" + + result = geo.path_from_corners( + corners, output=str(output_file), min_edge_points=5, close=True + ) + + assert result is None + assert output_file.exists() + with open(output_file, "r") as f: + lines = f.readlines() + assert len(lines) >= 20 + + +def test_ll_cross_along_track_dist() -> None: + """Test ll_cross_along_track_dist function.""" + # Define a great circle path from point 1 to point 2 + lon1, lat1 = 0.0, 0.0 + lon2, lat2 = 10.0, 0.0 # Path along equator + lon3, lat3 = 5.0, 1.0 # Point slightly north of the path + + cross_track, along_track = geo.ll_cross_along_track_dist( + lon1, lat1, lon2, lat2, lon3, lat3 + ) + + # The function returns values, test that they are computed + assert isinstance(cross_track, (float, np.floating)) + assert isinstance(along_track, (float, np.floating)) + # The cross track distance should have absolute value around 111 km (1 degree latitude) + assert abs(cross_track) > 100 + assert abs(cross_track) < 120 + + +def test_ll_cross_along_track_dist_with_precomputed() -> None: + """Test ll_cross_along_track_dist with precomputed bearings and distance.""" + lon1, lat1 = 0.0, 0.0 + lon2, lat2 = 10.0, 0.0 + lon3, lat3 = 5.0, 1.0 + + # Precompute values + a12 = np.radians(geo.ll_bearing(lon1, lat1, lon2, lat2)) + a13 = np.radians(geo.ll_bearing(lon1, lat1, lon3, lat3)) + d13 = geo.ll_dist(lon1, lat1, lon3, lat3) + + cross_track_precomputed, along_track_precomputed = geo.ll_cross_along_track_dist( + lon1, lat1, lon2, lat2, lon3, lat3, a12=a12, a13=a13, d13=d13 + ) + + # Compute without precomputed values + cross_track, along_track = geo.ll_cross_along_track_dist( + lon1, lat1, lon2, lat2, lon3, lat3 + ) + + # Results should be the same whether precomputed or not + assert cross_track_precomputed == pytest.approx(cross_track, abs=1e-6) + assert along_track_precomputed == pytest.approx(along_track, abs=1e-6) diff --git a/tests/test_grid.py b/tests/test_grid.py index 1e75e802..51198518 100644 --- a/tests/test_grid.py +++ b/tests/test_grid.py @@ -139,3 +139,15 @@ def test_gridpoint_count_in_length( ) -> None: """Test gridpoint_count_in_length function""" assert grid.gridpoint_count_in_length(length, resolution) == expected + + +def test_coordinate_patchgrid_missing_resolution_and_nx_ny() -> None: + """Test that ValueError is raised when neither resolution nor nx/ny are provided""" + origin = np.array([-43.5, 172.5, 5000]) + x_upper = np.array([-43.45498004, 172.50037108, 5000.0]) + y_bottom = np.array([-43.50025372, 172.56184531, 5000.0]) + + with pytest.raises( + ValueError, match="If resolution is not provided, nx and ny must be." + ): + grid.coordinate_patchgrid(origin, x_upper, y_bottom) diff --git a/tests/test_xyts.py b/tests/test_xyts.py index 18d45d92..2194cd1b 100644 --- a/tests/test_xyts.py +++ b/tests/test_xyts.py @@ -204,5 +204,34 @@ def test_xyts_invalid_file(tmp_path: Path) -> None: # Create a file with invalid header with open(invalid_file, "wb") as f: f.write(b"\x00" * 100) + with pytest.raises(ValueError, match="File is not an XY timeslice file"): xyts.XYTSFile(str(invalid_file)) + + +def test_tslice_get_meta_only() -> None: + """Test that AttributeError is raised when tslice_get is called on meta-only instance.""" + test_file = Path(__file__).parent / "sample1" / "xyts.e3d" + if not test_file.exists(): + pytest.skip("Test file not available") + + xyts_file = xyts.XYTSFile(str(test_file), meta_only=True) + + with pytest.raises( + AttributeError, match="The data attribute must be set to use `tslice_get`" + ): + xyts_file.tslice_get(10, comp=xyts.Component.MAGNITUDE) + + +def test_pgv_meta_only() -> None: + """Test that AttributeError is raised when pgv is called on meta-only instance.""" + test_file = Path(__file__).parent / "sample1" / "xyts.e3d" + if not test_file.exists(): + pytest.skip("Test file not available") + + xyts_file = xyts.XYTSFile(str(test_file), meta_only=True) + + with pytest.raises( + AttributeError, match="The data and ll_map attributes must be set to use `pgv`" + ): + xyts_file.pgv()