Skip to content
Merged
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
34 changes: 30 additions & 4 deletions soil_id/global_soil.py
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,27 @@ def convert_to_serializable(obj):
)


def _slice_gower_distance(slice_mat):
"""Gower distance matrix for a single depth slice.

A slice can have zero feature columns when a depth has no usable measurements
— e.g. the user recorded a depth interval but left texture/rock-fragment/color
blank, or (after depth-aligning) the horizon data simply does not reach this
depth. ``gower_distances`` would feed a shape ``(n, 0)`` array into its mean
imputer and raise "Found array with 0 feature(s)…", failing the whole ranking.

For such a slice, return an all-NaN ``(n, n)`` matrix instead. Callers treat NaN
distances as "no information" (the masked average and NaN-infill steps), so the
slice is ignored and components rank on the depths that do have data. Returning a
matrix (rather than skipping) keeps the per-slice list aligned with soil_matrix
rows.
"""
if slice_mat.shape[1] == 0:
n = slice_mat.shape[0]
return np.full((n, n), np.nan)
return gower_distances(slice_mat)


##############################################################################################
# rankPredictionGlobal #
##############################################################################################
Expand Down Expand Up @@ -828,8 +849,11 @@ def rank_soils_global(
else:
slice_mat = slice_df.drop("compname", axis=1)

# Compute the Gower distance on the prepared slice matrix.
D = gower_distances(slice_mat)
# Compute the Gower distance on the prepared slice matrix. A slice with
# zero usable feature columns is handled inside the helper (see its
# docstring) so the per-depth mean imputer can't crash on a 0-feature
# array; it returns an all-NaN matrix that the steps below then ignore.
D = _slice_gower_distance(slice_mat)

dis_mat_list.append(D)

Expand All @@ -840,8 +864,10 @@ def rank_soils_global(
"Not Ranked" if np.ma.is_masked(x) else "Ranked" for x in D_check[0][1:]
]

# Calculate max dissimilarity per depth slice
dis_max = max(map(np.nanmax, dis_mat_list))
# Calculate max dissimilarity across all depth slices. Use a single
# NaN-aware reduction over the stack so an all-NaN slice (a depth with no
# usable data) can't make the result NaN via max()'s ordering.
dis_max = np.nanmax(dis_mat_list)

# Apply depth weight
depth_weight = np.concatenate([np.repeat(0.2, 20), np.repeat(1.0, 180)])
Expand Down
64 changes: 64 additions & 0 deletions soil_id/tests/test_global_rank_empty_slice.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Copyright © 2026 Technology Matters
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see https://www.gnu.org/licenses/.

"""Regression tests for the empty-feature depth-slice guard in global rank.

rank_soils_global builds a per-depth-slice feature matrix and feeds it to
gower_distances. When a depth slice has zero usable feature columns (e.g. a gap in
the user's horizons, or horizon data that doesn't reach the slice depth), the raw
gower path passes a shape ``(n, 0)`` array into SimpleImputer and raises
"Found array with 0 feature(s)…", failing the whole ranking. ``_slice_gower_distance``
guards that case. These tests cover the guard directly so they need no database or
network — unlike the live ``test_global_integration`` path.
"""

import numpy as np
import pandas as pd
import pytest

from soil_id.global_soil import _slice_gower_distance
from soil_id.utils import gower_distances


def test_zero_feature_slice_returns_all_nan_matrix():
# 11 components, but no feature columns — the exact shape from the Sentry crash.
slice_mat = pd.DataFrame(index=range(11))
assert slice_mat.shape == (11, 0)

result = _slice_gower_distance(slice_mat)

# An (n, n) all-NaN matrix: same shape gower would have returned, and the
# downstream masked-average/NaN-infill steps treat it as "no information".
assert result.shape == (11, 11)
assert np.isnan(result).all()


def test_zero_feature_slice_would_otherwise_crash_gower():
# Characterization: the raw gower path crashes on a 0-feature array. This is
# exactly what the guard above prevents; if gower ever stops raising here, the
# guard's rationale should be revisited.
slice_mat = pd.DataFrame(index=range(11))
with pytest.raises(ValueError):
gower_distances(slice_mat)


def test_nonempty_slice_passes_through_to_gower():
# With at least one feature column, the helper must be a transparent passthrough.
slice_mat = pd.DataFrame({"sand": [10.0, 20.0, 30.0], "clay": [5.0, 15.0, 25.0]})

result = _slice_gower_distance(slice_mat)
expected = gower_distances(slice_mat)

assert np.array_equal(result, expected, equal_nan=True)
Loading