Skip to content

Commit a73e084

Browse files
committed
Add comprehensive tests for various modules in the voxcity package
- Introduced tests for the raytracing and geometry modules, validating transmittance calculations and ray direction generation. - Added tests for voxel and land cover class definitions, ensuring correct mappings and descriptions. - Extended tests for the land cover utilities, including RGB distance calculations and land cover conversions. - Implemented tests for the logging utilities, verifying logger configurations and level resolutions. - Created tests for the material utilities, checking material dictionaries and building material assignments. - Added orientation tests to ensure correct handling of grid orientations. - Implemented shape utility tests for padding and cropping functions in both 2D and 3D contexts.
1 parent 3c34216 commit a73e084

16 files changed

Lines changed: 1866 additions & 1 deletion

projectdatabase.edb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<Header>
33
<filetype>DATA</filetype>
44
<version>1</version>
5-
<revisiondate>02/05/2026 05:29:58 PM</revisiondate>
5+
<revisiondate>02/05/2026 09:41:45 PM</revisiondate>
66
<remark>Envi-Data</remark>
77
<checksum>0</checksum>
88
<encryptionlevel>1699612</encryptionlevel>

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,9 @@ testpaths = ["tests"]
106106
[tool.coverage.run]
107107
branch = true
108108
source = ["src/voxcity"]
109+
# Note: When running coverage with integration tests, use:
110+
# pytest tests/ --cov=src/voxcity --ignore=tests/test_integration_pipeline.py
111+
# This avoids GPU/Taichi resource contention issues with coverage instrumentation
109112
omit = [
110113
# Visualization and I/O heavy modules (non-deterministic / GUI)
111114
"src/voxcity/utils/visualization.py",

tests/conftest.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,25 @@
66
# Add the src directory to the Python path
77
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../src')))
88

9+
10+
def pytest_collection_modifyitems(config, items):
11+
"""Reorder tests to run integration tests last.
12+
13+
This helps avoid Taichi/GPU state interference between tests.
14+
Integration tests that use GPU rendering should run after all other tests.
15+
"""
16+
integration_tests = []
17+
other_tests = []
18+
19+
for item in items:
20+
if 'integration' in item.keywords or 'test_integration' in item.nodeid:
21+
integration_tests.append(item)
22+
else:
23+
other_tests.append(item)
24+
25+
# Run other tests first, then integration tests
26+
items[:] = other_tests + integration_tests
27+
928
@pytest.fixture
1029
def sample_rectangle_vertices():
1130
"""Sample rectangle vertices for testing"""

tests/test_errors.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""Tests for voxcity.errors module - exception classes."""
2+
import pytest
3+
4+
from voxcity.errors import (
5+
VoxCityError,
6+
ConfigurationError,
7+
DownloaderError,
8+
ProcessingError,
9+
VisualizationError,
10+
)
11+
12+
13+
class TestVoxCityError:
14+
def test_is_exception(self):
15+
assert issubclass(VoxCityError, Exception)
16+
17+
def test_can_raise_and_catch(self):
18+
with pytest.raises(VoxCityError):
19+
raise VoxCityError("Base error")
20+
21+
def test_message(self):
22+
err = VoxCityError("Test message")
23+
assert str(err) == "Test message"
24+
25+
26+
class TestConfigurationError:
27+
def test_is_voxcity_error(self):
28+
assert issubclass(ConfigurationError, VoxCityError)
29+
30+
def test_can_raise_and_catch(self):
31+
with pytest.raises(ConfigurationError):
32+
raise ConfigurationError("Invalid config")
33+
34+
def test_catch_as_base_class(self):
35+
with pytest.raises(VoxCityError):
36+
raise ConfigurationError("Config issue")
37+
38+
39+
class TestDownloaderError:
40+
def test_is_voxcity_error(self):
41+
assert issubclass(DownloaderError, VoxCityError)
42+
43+
def test_can_raise_and_catch(self):
44+
with pytest.raises(DownloaderError):
45+
raise DownloaderError("Download failed")
46+
47+
48+
class TestProcessingError:
49+
def test_is_voxcity_error(self):
50+
assert issubclass(ProcessingError, VoxCityError)
51+
52+
def test_can_raise_and_catch(self):
53+
with pytest.raises(ProcessingError):
54+
raise ProcessingError("Processing failed")
55+
56+
57+
class TestVisualizationError:
58+
def test_is_voxcity_error(self):
59+
assert issubclass(VisualizationError, VoxCityError)
60+
61+
def test_can_raise_and_catch(self):
62+
with pytest.raises(VisualizationError):
63+
raise VisualizationError("Visualization failed")
64+
65+
66+
class TestExceptionHierarchy:
67+
"""Test that all exceptions can be caught by VoxCityError."""
68+
69+
@pytest.mark.parametrize("exc_class", [
70+
ConfigurationError,
71+
DownloaderError,
72+
ProcessingError,
73+
VisualizationError,
74+
])
75+
def test_catchable_by_base(self, exc_class):
76+
with pytest.raises(VoxCityError):
77+
raise exc_class("test")
78+
79+
@pytest.mark.parametrize("exc_class", [
80+
VoxCityError,
81+
ConfigurationError,
82+
DownloaderError,
83+
ProcessingError,
84+
VisualizationError,
85+
])
86+
def test_catchable_by_exception(self, exc_class):
87+
with pytest.raises(Exception):
88+
raise exc_class("test")

tests/test_generator_voxelizer.py

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
"""Tests for voxcity.generator.voxelizer module."""
2+
import pytest
3+
import numpy as np
4+
5+
from voxcity.generator.voxelizer import (
6+
Voxelizer,
7+
_flatten_building_segments,
8+
GROUND_CODE,
9+
TREE_CODE,
10+
BUILDING_CODE,
11+
)
12+
13+
14+
class TestVoxelCodes:
15+
def test_ground_code_value(self):
16+
assert GROUND_CODE == -1
17+
18+
def test_tree_code_value(self):
19+
assert TREE_CODE == -2
20+
21+
def test_building_code_value(self):
22+
assert BUILDING_CODE == -3
23+
24+
def test_codes_are_negative(self):
25+
"""Voxel codes should be negative to distinguish from land cover."""
26+
assert GROUND_CODE < 0
27+
assert TREE_CODE < 0
28+
assert BUILDING_CODE < 0
29+
30+
def test_codes_are_unique(self):
31+
codes = [GROUND_CODE, TREE_CODE, BUILDING_CODE]
32+
assert len(codes) == len(set(codes))
33+
34+
35+
class TestFlattenBuildingSegments:
36+
def test_empty_grid(self):
37+
"""Test with no buildings."""
38+
# Must be 2D array with object dtype containing lists
39+
grid = np.empty((2, 2), dtype=object)
40+
for i in range(2):
41+
for j in range(2):
42+
grid[i, j] = []
43+
starts, ends, offsets, counts = _flatten_building_segments(grid, 1.0)
44+
45+
assert len(starts) == 0
46+
assert len(ends) == 0
47+
assert counts.sum() == 0
48+
49+
def test_single_building(self):
50+
"""Test with one building segment."""
51+
grid = np.array([
52+
[[(0, 10)], []],
53+
[[], []]
54+
], dtype=object)
55+
56+
starts, ends, offsets, counts = _flatten_building_segments(grid, 1.0)
57+
58+
assert counts[0, 0] == 1
59+
assert counts[0, 1] == 0
60+
assert starts[0] == 0 # min_height/voxel_size
61+
assert ends[0] == 10 # max_height/voxel_size
62+
63+
def test_multiple_segments_same_cell(self):
64+
"""Test with multiple building segments in one cell (stacked buildings)."""
65+
grid = np.array([
66+
[[(0, 5), (10, 15)], []],
67+
[[], []]
68+
], dtype=object)
69+
70+
starts, ends, offsets, counts = _flatten_building_segments(grid, 1.0)
71+
72+
assert counts[0, 0] == 2
73+
assert starts[0] == 0
74+
assert ends[0] == 5
75+
assert starts[1] == 10
76+
assert ends[1] == 15
77+
78+
def test_voxel_size_scaling(self):
79+
"""Test that voxel size correctly scales segment heights."""
80+
grid = np.empty((1, 1), dtype=object)
81+
grid[0, 0] = [(0, 10)]
82+
83+
starts, ends, offsets, counts = _flatten_building_segments(grid, 2.0)
84+
85+
# With voxel_size=2, heights should be halved
86+
assert starts[0] == 0
87+
assert ends[0] == 5 # 10/2
88+
89+
90+
class TestVoxelizer:
91+
@pytest.fixture
92+
def voxelizer(self):
93+
return Voxelizer(voxel_size=1.0, land_cover_source="Urbanwatch")
94+
95+
def test_initialization(self, voxelizer):
96+
assert voxelizer.voxel_size == 1.0
97+
assert voxelizer.land_cover_source == "Urbanwatch"
98+
99+
def test_default_trunk_height_ratio(self, voxelizer):
100+
# Default ratio is 11.76/19.98
101+
expected = 11.76 / 19.98
102+
assert voxelizer.trunk_height_ratio == pytest.approx(expected)
103+
104+
def test_custom_trunk_height_ratio(self):
105+
voxelizer = Voxelizer(
106+
voxel_size=1.0,
107+
land_cover_source="OpenStreetMap",
108+
trunk_height_ratio=0.5
109+
)
110+
assert voxelizer.trunk_height_ratio == 0.5
111+
112+
def test_voxel_dtype(self):
113+
voxelizer = Voxelizer(
114+
voxel_size=1.0,
115+
land_cover_source="Urbanwatch",
116+
voxel_dtype=np.int16
117+
)
118+
assert voxelizer.voxel_dtype == np.int16
119+
120+
def test_estimate_and_allocate(self, voxelizer):
121+
grid = voxelizer._estimate_and_allocate(10, 10, 20)
122+
assert grid.shape == (10, 10, 20)
123+
assert grid.dtype == np.int8
124+
125+
def test_convert_land_cover_osm(self):
126+
"""OpenStreetMap should just add 1 to shift to 1-based indices."""
127+
voxelizer = Voxelizer(voxel_size=1.0, land_cover_source="OpenStreetMap")
128+
arr = np.array([[0, 1, 2]])
129+
result = voxelizer._convert_land_cover(arr)
130+
assert result.tolist() == [[1, 2, 3]]
131+
132+
def test_convert_land_cover_urbanwatch(self, voxelizer):
133+
"""Urbanwatch should use the convert_land_cover function."""
134+
arr = np.array([[0, 1, 2]], dtype=np.uint8)
135+
result = voxelizer._convert_land_cover(arr)
136+
# Should be mapped: 0->13, 1->12, 2->11
137+
assert result.tolist() == [[13, 12, 11]]
138+
139+
140+
class TestVoxelizerGenerateCombined:
141+
@pytest.fixture
142+
def simple_inputs(self):
143+
"""Create minimal input grids for testing."""
144+
shape = (3, 3)
145+
146+
# Building heights (10m building at center)
147+
building_heights = np.zeros(shape)
148+
building_heights[1, 1] = 10.0
149+
150+
# Building min heights (simple list structure)
151+
building_min_heights = np.empty(shape, dtype=object)
152+
for i in range(shape[0]):
153+
for j in range(shape[1]):
154+
building_min_heights[i, j] = []
155+
building_min_heights[1, 1] = [(0, 10)]
156+
157+
# Building IDs
158+
building_ids = np.zeros(shape, dtype=int)
159+
building_ids[1, 1] = 1
160+
161+
# Land cover (all grass = 2)
162+
land_cover = np.full(shape, 2, dtype=np.uint8)
163+
164+
# DEM (flat terrain)
165+
dem = np.zeros(shape)
166+
167+
# Tree heights (5m tree at corner)
168+
tree_heights = np.zeros(shape)
169+
tree_heights[0, 0] = 5.0
170+
171+
return {
172+
"building_height_grid_ori": building_heights,
173+
"building_min_height_grid_ori": building_min_heights,
174+
"building_id_grid_ori": building_ids,
175+
"land_cover_grid_ori": land_cover,
176+
"dem_grid_ori": dem,
177+
"tree_grid_ori": tree_heights,
178+
}
179+
180+
def test_generate_combined_shape(self, simple_inputs):
181+
voxelizer = Voxelizer(voxel_size=1.0, land_cover_source="Urbanwatch")
182+
result = voxelizer.generate_combined(**simple_inputs, print_class_info=False)
183+
184+
# Should have correct x,y dimensions
185+
assert result.shape[0] == 3
186+
assert result.shape[1] == 3
187+
# Z dimension should be > 0
188+
assert result.shape[2] > 0
189+
190+
def test_generate_combined_has_building(self, simple_inputs):
191+
voxelizer = Voxelizer(voxel_size=1.0, land_cover_source="Urbanwatch")
192+
result = voxelizer.generate_combined(**simple_inputs, print_class_info=False)
193+
194+
# Should have building code somewhere
195+
assert BUILDING_CODE in result
196+
197+
def test_generate_combined_has_tree(self, simple_inputs):
198+
voxelizer = Voxelizer(voxel_size=1.0, land_cover_source="Urbanwatch")
199+
result = voxelizer.generate_combined(**simple_inputs, print_class_info=False)
200+
201+
# Should have tree code somewhere
202+
assert TREE_CODE in result
203+
204+
def test_generate_combined_has_ground(self, simple_inputs):
205+
voxelizer = Voxelizer(voxel_size=1.0, land_cover_source="Urbanwatch")
206+
result = voxelizer.generate_combined(**simple_inputs, print_class_info=False)
207+
208+
# The voxelizer puts land cover class (positive int) at z=0 layer
209+
# Land cover values are positive (e.g., 11 for developed space from Urbanwatch)
210+
# Check that there are positive values at z=0 (land cover layer)
211+
assert np.any(result[:, :, 0] > 0)

0 commit comments

Comments
 (0)