Skip to content

Commit 3fa91ce

Browse files
authored
Merge pull request #38 from iosefa/bug/zero-cover
calculate: handle empty integration range in canopy cover calculation
2 parents e838643 + 45c0c42 commit 3fa91ce

2 files changed

Lines changed: 27 additions & 7 deletions

File tree

pyforestscan/calculate.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,9 @@ def calculate_canopy_cover(pad: np.ndarray,
192192
193193
Returns:
194194
np.ndarray: 2D array (X, Y) of canopy cover values in [0, 1], with NaN where
195-
PAD is entirely missing for the integration range.
195+
PAD is entirely missing for the integration range. If the requested
196+
integration range is empty (e.g., min_height >= available max height),
197+
returns a zeros array (no canopy above the threshold).
196198
197199
Raises:
198200
ValueError: If parameters are invalid (e.g., non-positive voxel_height, k < 0,
@@ -203,16 +205,18 @@ def calculate_canopy_cover(pad: np.ndarray,
203205
if k < 0:
204206
raise ValueError(f"k must be >= 0 (got {k})")
205207

206-
# Compute PAI integrated from min_height up to max_height/top
208+
# Determine effective max height and handle empty integration range
209+
effective_max_height = max_height if max_height is not None else pad.shape[2] * voxel_height
210+
if min_height >= effective_max_height:
211+
# No foliage above threshold: cover is zero everywhere
212+
return np.zeros((pad.shape[0], pad.shape[1]), dtype=float)
213+
214+
# Compute PAI integrated from min_height up to effective_max_height/top
207215
pai_above = calculate_pai(pad, voxel_height, min_height=min_height, max_height=max_height)
208216

209217
# Identify columns that are entirely NaN within the integration range
210-
if max_height is None:
211-
max_height = pad.shape[2] * voxel_height
212-
if min_height >= max_height:
213-
raise ValueError("Minimum height index must be less than maximum height index.")
214218
start_idx = int(np.ceil(min_height / voxel_height))
215-
end_idx = int(np.floor(max_height / voxel_height))
219+
end_idx = int(np.floor(effective_max_height / voxel_height))
216220
range_slice = pad[:, :, start_idx:end_idx]
217221
all_nan_mask = np.all(np.isnan(range_slice), axis=2)
218222

tests/test_calculate.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -646,3 +646,19 @@ def test_calculate_canopy_cover_monotonic_with_height():
646646
cov5 = calculate_canopy_cover(pad, voxel_height=1.0, min_height=5.0, k=0.5)
647647
assert np.all(cov0 >= cov2)
648648
assert np.all(cov2 >= cov5)
649+
650+
651+
def test_calculate_canopy_cover_empty_range_above_top_returns_zero():
652+
# Z extent is 0..1 m (2 layers with dz=1); threshold at 2 m leaves no range
653+
pad = np.random.rand(4, 4, 2)
654+
cov = calculate_canopy_cover(pad, voxel_height=1.0, min_height=2.0, k=0.5)
655+
assert cov.shape == (4, 4)
656+
assert np.allclose(cov, 0.0)
657+
658+
659+
def test_calculate_canopy_cover_min_ge_max_returns_zero():
660+
pad = np.random.rand(3, 3, 5)
661+
# Explicitly set max_height below min_height so integration is empty
662+
cov = calculate_canopy_cover(pad, voxel_height=1.0, min_height=3.0, max_height=2.0, k=0.5)
663+
assert cov.shape == (3, 3)
664+
assert np.allclose(cov, 0.0)

0 commit comments

Comments
 (0)