From 8be4968c74dd2a833c987c43547ffc95d7575b44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edouard=20Choini=C3=A8re?= <27212526+echoix@users.noreply.github.com> Date: Thu, 3 Sep 2026 22:32:57 +0000 Subject: [PATCH 1/4] CI: Add opt-in LLVM source-based C/C++ coverage to pytest workflow Instrument the Ubuntu build with clang under GRASS_LLVM_COVERAGE, enabled only on the Python 3.13 matrix cell so the 3.11 cell keeps the default gcc build. LLVM_PROFILE_FILE encodes pid and a hash per binary so xdist workers and C-tool subprocesses never collide on one file, and -fprofile-update=atomic makes counter increments atomic in OpenMP-parallel code (r.univar). Both were needed to make merged coverage deterministic between runs. utils/llvm_coverage_export.sh merges .profraw into .profdata and exports lcov for Codecov. It passes the file list to llvm-profdata via -f instead of argv, since a full test run's per-process .profraw count overflows the shell argument-list limit. utils/ coverage_disk_usage.sh logs .profraw count/size and disk usage before the merge for observability. Also fixes coverage_mapper.py running before coverage combine, which left it unable to read a .coverage file that parallel mode had not created yet. This is the Linux/pytest slice of #674, which also instruments macOS; macOS is out of scope here. AI assistance (Claude Code) helped draft the workflow and script changes. --- .github/workflows/build_ubuntu-24.04.sh | 46 ++++++++++++- .github/workflows/pytest.yml | 91 ++++++++++++++++++------- .gitignore | 5 ++ utils/coverage_disk_usage.sh | 29 ++++++++ utils/llvm_coverage_export.sh | 69 +++++++++++++++++++ 5 files changed, 214 insertions(+), 26 deletions(-) create mode 100755 utils/coverage_disk_usage.sh create mode 100755 utils/llvm_coverage_export.sh diff --git a/.github/workflows/build_ubuntu-24.04.sh b/.github/workflows/build_ubuntu-24.04.sh index 677c46d5f33..308049bbdcc 100755 --- a/.github/workflows/build_ubuntu-24.04.sh +++ b/.github/workflows/build_ubuntu-24.04.sh @@ -30,6 +30,49 @@ set -u export INSTALL_PREFIX=$1 +# Extra configure args added under coverage; keep declared under `set -u`. +OPENMP_ARGS=() + +# LLVM source-based coverage instrumentation, opt-in via env var. Requires +# clang (installed only when opted in, see pytest.yml). Kept out of the +# default O2 path so non-coverage runs are unaffected. +if [ "${GRASS_LLVM_COVERAGE:-0}" = "1" ]; then + export CC="${CC:-clang}" + export CXX="${CXX:-clang++}" + # LLVM source-based coverage generates its region mapping before the + # optimizer runs, so optimization does not affect coverage quality + # (clang doc: SourceBasedCodeCoverage, "Impact of llvm optimizations + # on coverage reports"). Keep the project's normal -O2 to hold the + # runtime impact small. -fprofile-update=atomic makes counter + # increments atomic; required for GRASS's OpenMP-parallel regions + # (e.g. r.univar) so threads racing on shared counters do not lose + # updates. + # + # Setting CFLAGS explicitly at configure time suppresses autoconf's + # default "-g -O2", so pass -O2 here. + COV_FLAGS="-fprofile-instr-generate -fcoverage-mapping -fprofile-update=atomic" + export CFLAGS="${CFLAGS:--O2} ${COV_FLAGS}" + export CXXFLAGS="${CXXFLAGS:--O2} ${COV_FLAGS}" + export LDFLAGS="${LDFLAGS:-} -fprofile-instr-generate -fcoverage-mapping" + # Clang ships omp.h in its resource-dir instead of /usr/include, so + # configure's plain omp.h check misses it. Point configure at both + # the include and (from apt libomp-dev) the runtime lib. + CLANG_RES_DIR="$("${CC}" -print-resource-dir 2>/dev/null || true)" + if [ -n "${CLANG_RES_DIR}" ] && [ -d "${CLANG_RES_DIR}/include" ]; then + OPENMP_ARGS+=("--with-openmp-includes=${CLANG_RES_DIR}/include") + # Debian's libomp-dev installs libomp.so under /usr/lib/llvm-/lib, + # not the linker's default search path, so configure's link probe for + # omp_get_num_threads fails and it falls back to -lgomp - which clang's + # OpenMP-instrumented code (using __kmpc_*) then can't link against. + # The resource-dir sits at /clang/, so its + # grandparent is , where libomp.so lives. + LLVM_LIB_DIR="$(cd "${CLANG_RES_DIR}/../.." && pwd)" + if [ -e "${LLVM_LIB_DIR}/libomp.so" ]; then + OPENMP_ARGS+=("--with-openmp-libs=${LLVM_LIB_DIR}") + fi + fi +fi + ./configure \ --enable-largefile \ --prefix="$INSTALL_PREFIX/" \ @@ -50,7 +93,8 @@ export INSTALL_PREFIX=$1 --with-readline \ --with-sqlite \ --with-tiff \ - --with-zstd + --with-zstd \ + ${OPENMP_ARGS[@]+"${OPENMP_ARGS[@]}"} eval $makecmd make install diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index bfd8d77b693..cbd1262ce97 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -32,6 +32,9 @@ jobs: FORCE_COLOR: 1 # for software including pip: https://force-color.org/ CLICOLOR_FORCE: 1 # for other software including ninja: https://bixense.com/clicolors/ PYTHONWARNINGS: always + # Only exercise LLVM C coverage on the latest Python; the older one + # runs the default gcc/no-instrumentation build for speed. + GRASS_LLVM_COVERAGE: ${{ matrix.python-version == '3.13' && '1' || '0' }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -54,6 +57,12 @@ jobs: xargs -a <(awk '! /^ *(#|$)/' ".github/workflows/apt.txt") -r -- \ sudo apt-get install -y --no-install-recommends --no-install-suggests + - name: Install LLVM coverage toolchain + if: env.GRASS_LLVM_COVERAGE == '1' + run: | + sudo apt-get install -y --no-install-recommends --no-install-suggests \ + clang llvm libomp-dev + - uses: rui314/setup-mold@7e4f20ad28a2e8ca6fd0892ccf72e2abb706b9c3 # v1 - name: Install Python dependencies @@ -90,16 +99,29 @@ jobs: - name: Test executing of the grass command run: .github/workflows/test_simple.sh + - name: Prepare coverage directories + run: | + mkdir -p "${PWD}/llvm-cov-raw" + # Wipe stray default.profraw the build step left behind (Makefiles + # run instrumented tools to dump interface XML), so their build- + # time data can't contaminate the test-run coverage. + find . -name 'default.profraw' -type f -delete + # %p (process id) separates every subprocess; %9m caps the per-binary + # pool at 512 files so long suites don't create millions of tiny + # .profraw. Together they eliminate the write-collision that made + # earlier runs non-deterministic. + { + echo "LLVM_PROFILE_FILE=${PWD}/llvm-cov-raw/grass-%p-%9m.profraw" + echo "INITIAL_GISBASE=$(grass --config path)" + echo "INITIAL_PWD=${PWD}" + } >> "${GITHUB_ENV}" + - name: Run pytest with multiple workers in parallel run: | PYTHONPATH="$(grass --config python_path):${PYTHONPATH}" LD_LIBRARY_PATH="$(grass --config path)/lib:${LD_LIBRARY_PATH}" - INITIAL_GISBASE="$(grass --config path)" - INITIAL_PWD="${PWD}" export PYTHONPATH export LD_LIBRARY_PATH - export INITIAL_GISBASE - export INITIAL_PWD pytest \ @.github/workflows/pytest_args_ci.txt \ @.github/workflows/pytest_args_cov.txt \ @@ -113,12 +135,8 @@ jobs: run: | PYTHONPATH="$(grass --config python_path):${PYTHONPATH}" LD_LIBRARY_PATH="$(grass --config path)/lib:${LD_LIBRARY_PATH}" - INITIAL_GISBASE="$(grass --config path)" - INITIAL_PWD="${PWD}" export PYTHONPATH export LD_LIBRARY_PATH - export INITIAL_GISBASE - export INITIAL_PWD pytest \ @.github/workflows/pytest_args_ci.txt \ @.github/workflows/pytest_args_cov.txt \ @@ -129,29 +147,40 @@ jobs: run: | PYTHONPATH="$(grass --config python_path):${PYTHONPATH}" LD_LIBRARY_PATH="$(grass --config path)/lib:${LD_LIBRARY_PATH}" - INITIAL_GISBASE="$(grass --config path)" - INITIAL_PWD="${PWD}" export PYTHONPATH export LD_LIBRARY_PATH - export INITIAL_GISBASE - export INITIAL_PWD pytest \ @.github/workflows/pytest_args_cov.txt \ @.github/workflows/pytest_args_gunittest.txt \ --junitxml=pytest.gunittest.junit.xml - - name: Fix non-standard installed script paths in coverage data + + - name: Coverage disk usage (before combine) + if: ${{ !cancelled() }} + run: utils/coverage_disk_usage.sh + + - name: Combine Python coverage and export XML + if: ${{ !cancelled() }} run: | - PYTHONPATH="$(grass --config python_path):${PYTHONPATH}" - LD_LIBRARY_PATH="$(grass --config path)/lib:${LD_LIBRARY_PATH}" - INITIAL_GISBASE="$(grass --config path)" - INITIAL_PWD="${PWD}" - export PYTHONPATH - export LD_LIBRARY_PATH - export INITIAL_GISBASE - export INITIAL_PWD - python utils/coverage_mapper.py - coverage combine - coverage html + # coverage combine must run before coverage_mapper.py: parallel + # mode leaves .coverage... partials and no + # .coverage file for the mapper to read. Tolerate "no data" so + # a build failure earlier in the job does not mask itself as a + # later failure here. + coverage combine || true + if [ -f .coverage ]; then + python utils/coverage_mapper.py + if [ -f .coverage.fixed_scripts ]; then + mv .coverage.fixed_scripts .coverage + fi + coverage html + coverage xml -o coverage.xml + else + echo "No .coverage data collected; skipping Python coverage export." + fi + + - name: Merge and export LLVM C/C++ coverage + if: ${{ !cancelled() && env.GRASS_LLVM_COVERAGE == '1' }} + run: utils/llvm_coverage_export.sh - name: Upload test results to Codecov if: ${{ !cancelled() }} @@ -162,13 +191,25 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} report_type: test_results - - name: Upload coverage reports to Codecov + - name: Upload Python coverage to Codecov uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: verbose: true + files: coverage.xml flags: pytest-python-${{ matrix.python-version }} name: pytest-python-${{ matrix.python-version }} token: ${{ secrets.CODECOV_TOKEN }} + + - name: Upload LLVM C/C++ coverage to Codecov + if: ${{ !cancelled() && env.GRASS_LLVM_COVERAGE == '1' }} + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + with: + verbose: true + files: coverage-llvm.lcov + flags: ubuntu-llvm-c + name: ubuntu-llvm-c-${{ matrix.python-version }} + token: ${{ secrets.CODECOV_TOKEN }} + - name: Make python-only code coverage test report available if: ${{ !cancelled() }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/.gitignore b/.gitignore index fd8c521b7c6..b4bcef7f870 100644 --- a/.gitignore +++ b/.gitignore @@ -87,6 +87,11 @@ lib/*/latex/ .coverage .coverage.* coverage.xml +*.profraw +*.profdata +coverage-llvm.lcov +llvm-cov-raw/ +coverage_html_report/ # Created by https://www.toptal.com/developers/gitignore/api/windows,linux,macos # Edit at https://www.toptal.com/developers/gitignore?templates=windows,linux,macos diff --git a/utils/coverage_disk_usage.sh b/utils/coverage_disk_usage.sh new file mode 100755 index 00000000000..f12a5c59c85 --- /dev/null +++ b/utils/coverage_disk_usage.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash + +# Print disk usage of raw coverage artifacts before combining. Emitted to +# the CI log so we can watch it approach the runner storage budget. + +set -u + +: "${INITIAL_PWD:=$(pwd)}" +: "${RAW_DIR:=${INITIAL_PWD}/llvm-cov-raw}" + +echo "=== LLVM .profraw files ===" +if [ -d "${RAW_DIR}" ]; then + count=$(find "${RAW_DIR}" -name '*.profraw' -type f | wc -l | tr -d ' ') + echo "count: ${count}" + du -sh "${RAW_DIR}" 2>/dev/null || true +else + echo "no ${RAW_DIR} (build was not instrumented, or no tests ran)" +fi + +echo "=== Python coverage.py partial files ===" +py_count=$(find "${INITIAL_PWD}" -maxdepth 2 -name '.coverage.*' -type f 2>/dev/null \ + | wc -l | tr -d ' ') +echo "count: ${py_count}" +if [ "${py_count}" -gt 0 ]; then + du -sch "${INITIAL_PWD}"/.coverage.* 2>/dev/null | tail -1 +fi + +echo "=== Filesystem ===" +df -h "${INITIAL_PWD}" 2>/dev/null || df -h diff --git a/utils/llvm_coverage_export.sh b/utils/llvm_coverage_export.sh new file mode 100755 index 00000000000..6e2e6ff47a2 --- /dev/null +++ b/utils/llvm_coverage_export.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash + +# Merge LLVM source-based coverage .profraw files into a single .profdata, +# then export lcov for Codecov. Invoked from CI after all test steps. +# +# Inputs (env): +# INITIAL_PWD - repository working directory (must match .coveragerc) +# RAW_DIR - directory holding *.profraw (default: $INITIAL_PWD/llvm-cov-raw) +# OUT_PROFDATA - merged profdata output (default: $INITIAL_PWD/grass.profdata) +# OUT_LCOV - lcov output for Codecov (default: $INITIAL_PWD/coverage-llvm.lcov) +# GISBASE - installed GRASS prefix; falls back to `grass --config path` +# +# Requires llvm-profdata and llvm-cov on PATH matching the clang used to build. + +set -euo pipefail + +: "${INITIAL_PWD:=$(pwd)}" +: "${RAW_DIR:=${INITIAL_PWD}/llvm-cov-raw}" +: "${OUT_PROFDATA:=${INITIAL_PWD}/grass.profdata}" +: "${OUT_LCOV:=${INITIAL_PWD}/coverage-llvm.lcov}" +: "${GISBASE:=$(grass --config path)}" + +if ! compgen -G "${RAW_DIR}/*.profraw" > /dev/null; then + echo "llvm_coverage_export: no .profraw files under ${RAW_DIR}; nothing to export." + : > "${OUT_LCOV}" + exit 0 +fi + +# -sparse drops zero-count regions so merges are smaller and deterministic +# for a given input set. The per-process "%p-%9m" naming can produce +# hundreds of thousands of .profraw files in a full test run, so pass them +# via -f (a newline-separated list) rather than shell-globbing them onto +# argv, which overflows the OS argument-list limit ("Argument list too +# long"). +PROFRAW_LIST="$(mktemp)" +trap 'rm -f "${PROFRAW_LIST}"' EXIT +find "${RAW_DIR}" -name '*.profraw' -print > "${PROFRAW_LIST}" +llvm-profdata merge -sparse -f "${PROFRAW_LIST}" -o "${OUT_PROFDATA}" + +# Enumerate every instrumented ELF binary and shared library the install +# produced. find -perm -u+x also sweeps in executable Python and shell +# scripts, so filter to the ELF magic bytes before passing to llvm-cov. +OBJ_ARGS=() +while IFS= read -r -d '' obj; do + magic=$(head -c4 "${obj}" 2>/dev/null | od -An -tx1 | tr -d ' \n') + if [ "${magic}" = "7f454c46" ]; then + OBJ_ARGS+=(-object "${obj}") + fi +done < <( + find "${GISBASE}" \ + \( -type f -perm -u+x -o -name '*.so' -o -name '*.so.*' \) \ + -print0 +) + +if [ "${#OBJ_ARGS[@]}" -eq 0 ]; then + echo "llvm_coverage_export: no instrumented objects found under ${GISBASE}." >&2 + exit 1 +fi + +# Drop system, conda, and build-tree paths so Codecov only sees repo files. +llvm-cov export \ + -format=lcov \ + -instr-profile="${OUT_PROFDATA}" \ + -ignore-filename-regex='(^/opt/|^/usr/|/\.cache/|/bin\.[^/]+/|/dist\.[^/]+/|/OBJ\.[^/]+/)' \ + "${OBJ_ARGS[@]}" \ + > "${OUT_LCOV}" + +echo "llvm_coverage_export: wrote ${OUT_LCOV}" +du -sh "${OUT_PROFDATA}" "${OUT_LCOV}" From 1f32b4e54da2af54fda7afa61d1de1a6f0d86807 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edouard=20Choini=C3=A8re?= <27212526+echoix@users.noreply.github.com> Date: Thu, 3 Sep 2026 22:44:52 +0000 Subject: [PATCH 2/4] CI: Document local usage of llvm_coverage_export.sh The script's env-var defaults already made it runnable outside CI; spell that out so it doesn't read as CI-only. --- utils/llvm_coverage_export.sh | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/utils/llvm_coverage_export.sh b/utils/llvm_coverage_export.sh index 6e2e6ff47a2..c2a9d860fc7 100755 --- a/utils/llvm_coverage_export.sh +++ b/utils/llvm_coverage_export.sh @@ -1,10 +1,15 @@ #!/usr/bin/env bash # Merge LLVM source-based coverage .profraw files into a single .profdata, -# then export lcov for Codecov. Invoked from CI after all test steps. +# then export lcov for Codecov. Run from CI after all test steps, but +# generic enough to invoke by hand too: build with GRASS_LLVM_COVERAGE=1 +# (see build_ubuntu-24.04.sh), run tests with LLVM_PROFILE_FILE pointed at +# a raw dir (see pytest.yml's "Prepare coverage directories" step), then +# run this script from the repository root with a `grass` matching that +# build on PATH. # -# Inputs (env): -# INITIAL_PWD - repository working directory (must match .coveragerc) +# Inputs (env), all optional: +# INITIAL_PWD - repository working directory (default: pwd) # RAW_DIR - directory holding *.profraw (default: $INITIAL_PWD/llvm-cov-raw) # OUT_PROFDATA - merged profdata output (default: $INITIAL_PWD/grass.profdata) # OUT_LCOV - lcov output for Codecov (default: $INITIAL_PWD/coverage-llvm.lcov) From c840379a7af497793a4f9a0d4c8ac1a0a08ed189 Mon Sep 17 00:00:00 2001 From: Jal Patel <102137104+jalpatel11@users.noreply.github.com> Date: Sat, 12 Sep 2026 23:38:07 -0700 Subject: [PATCH 3/4] lib/cluster: Add pytest ctypes test suite --- lib/cluster/tests/conftest.py | 32 +++ .../lib_cluster_algorithm_ctypes_test.py | 261 ++++++++++++++++++ .../tests/lib_cluster_exec_ctypes_test.py | 139 ++++++++++ .../tests/lib_cluster_points_ctypes_test.py | 110 ++++++++ 4 files changed, 542 insertions(+) create mode 100644 lib/cluster/tests/conftest.py create mode 100644 lib/cluster/tests/lib_cluster_algorithm_ctypes_test.py create mode 100644 lib/cluster/tests/lib_cluster_exec_ctypes_test.py create mode 100644 lib/cluster/tests/lib_cluster_points_ctypes_test.py diff --git a/lib/cluster/tests/conftest.py b/lib/cluster/tests/conftest.py new file mode 100644 index 00000000000..0626a9d0f0f --- /dev/null +++ b/lib/cluster/tests/conftest.py @@ -0,0 +1,32 @@ +import pytest + +import grass.lib.gis as libgis + + +@pytest.fixture +def libgis_without_session(): + """Let the clustering functions run without a GRASS session + + Most of the I_cluster_*() functions call G_debug(), which reads the + DEBUG variable out of the session's GISRC file. With no session there + is no file to read, so the lookup ends in G_fatal_error() and exits + the process, taking the pytest run down with it. Clustering itself + never touches a project or mapset, so instead of setting up a session + for its own sake, GISRC is switched to memory mode: libgis then keeps + those variables in memory and never looks for the file. Tools that + legitimately run outside a mapset do the same, see + general/g.message/main.c and general/g.dirseps/main.c. + + Giving these tests a real session is not an equivalent fix. Reading a + GISRC file makes libgis cache that session's project for the rest of + the process, so later tests sharing the process would go looking for + their own maps in a project that no longer exists. + + The mode is process-global in the C library, so it is restored here. + """ + previous_mode = libgis.G_get_gisrc_mode() + libgis.G_set_gisrc_mode(libgis.G_GISRC_MODE_MEMORY) + try: + yield + finally: + libgis.G_set_gisrc_mode(previous_mode) diff --git a/lib/cluster/tests/lib_cluster_algorithm_ctypes_test.py b/lib/cluster/tests/lib_cluster_algorithm_ctypes_test.py new file mode 100644 index 00000000000..99e890347bc --- /dev/null +++ b/lib/cluster/tests/lib_cluster_algorithm_ctypes_test.py @@ -0,0 +1,261 @@ +"""Tests for the clustering algorithm step ctypes bindings + +Most of these functions call G_debug() internally, which needs an +answer for the session's DEBUG variable even though none of them read or +write a project or mapset; see conftest.py's libgis_without_session +fixture for how that is arranged. + +The sample data is deliberately two-band rather than one-band: every one +of these functions loops over bands, and with a single band a loop that +only ever handled band 0 would still produce correct results. +""" + +from ctypes import byref, c_double, c_int + +import pytest + +import grass.lib.cluster as libcluster + +# Five points whose second band is ten times the first, so that values +# for the two bands stay easy to check by hand but remain distinguishable +# from each other. +SAMPLE_POINTS = [(1.0, 10.0), (2.0, 20.0), (3.0, 30.0), (4.0, 40.0), (5.0, 50.0)] + + +def cluster_with_points(points, nclasses, nbands=2): + """A Cluster struct loaded with points and allocated for nclasses + + Mirrors the first few steps of I_cluster_exec(): collect the points, + then allocate the per-class arrays that the rest of the algorithm's + steps read and write. + """ + cluster = libcluster.Cluster() + libcluster.I_cluster_begin(byref(cluster), nbands) + for values in points: + assert ( + libcluster.I_cluster_point(byref(cluster), (c_double * nbands)(*values)) + == 0 + ) + cluster.nclasses = nclasses + assert libcluster.I_cluster_exec_allocate(byref(cluster)) == 1 + return cluster + + +def classify(cluster): + """Run I_cluster_means() then I_cluster_assign(), as I_cluster_exec() + does for its first pass, and return the resulting per-point classes""" + libcluster.I_cluster_means(byref(cluster)) + interrupted = c_int(0) + libcluster.I_cluster_assign(byref(cluster), byref(interrupted)) + class_field = getattr(cluster, "class") + return [class_field[p] for p in range(cluster.npoints)] + + +def per_class(array, band, nclasses): + """Read one band's row out of a band-by-class array""" + return [array[band][c] for c in range(nclasses)] + + +def test_means_spreads_initial_class_means_by_standard_deviation( + libgis_without_session, +) -> None: + """I_cluster_means() has no classified points to average yet, so it + seeds each class mean at an even spread around the overall mean of + each band, scaled by that band's standard deviation""" + cluster = cluster_with_points(SAMPLE_POINTS, 3) + + libcluster.I_cluster_means(byref(cluster)) + + assert per_class(cluster.mean, 0, 3) == pytest.approx( + [1.4188611699158102, 3.0, 4.58113883008419] + ) + assert per_class(cluster.mean, 1, 3) == pytest.approx( + [14.188611699158104, 30.0, 45.811388300841898] + ) + + +def test_assign_puts_each_point_in_its_nearest_class(libgis_without_session) -> None: + cluster = cluster_with_points(SAMPLE_POINTS, 3) + + classes = classify(cluster) + + assert classes == [0, 0, 1, 2, 2] + assert [cluster.count[c] for c in range(3)] == [2, 1, 2] + assert per_class(cluster.sum, 0, 3) == [3.0, 3.0, 9.0] + assert per_class(cluster.sum, 1, 3) == [30.0, 30.0, 90.0] + + +def test_reassign_leaves_already_nearest_points_alone(libgis_without_session) -> None: + """I_cluster_reassign() is the iterated form of I_cluster_assign(): + it compares each point against the class sums rather than the seeded + means, and reports how many points changed class. Points that are + already in their nearest class stay put and are not counted. + """ + cluster = cluster_with_points(SAMPLE_POINTS, 3) + classify(cluster) + interrupted = c_int(0) + + changes = libcluster.I_cluster_reassign(byref(cluster), byref(interrupted)) + + assert changes == 0 + class_field = getattr(cluster, "class") + assert [class_field[p] for p in range(cluster.npoints)] == [0, 0, 1, 2, 2] + assert [cluster.count[c] for c in range(3)] == [2, 1, 2] + + +def test_reassign_moves_a_point_to_a_nearer_class(libgis_without_session) -> None: + """A point is moved when another class centroid is closer across all + bands together, and the counts and sums of both classes are updated + + The single point (1, 9) sits nearer to class 0 on band 0 alone + (distance 1 against 4), but nearer to class 1 once band 1 is included + (5 against 82), so this also pins down that the comparison really + spans every band rather than just the first. + """ + cluster = cluster_with_points([(1.0, 9.0)], 2) + # Hand-set the two class centroids: class 0 at (0, 0) and class 1 at + # (3, 10), each standing in for 2 points, since I_cluster_reassign() + # reads the class sums and counts rather than the means. + cluster.count[0] = 2 + cluster.sum[0][0] = 0.0 + cluster.sum[1][0] = 0.0 + cluster.count[1] = 2 + cluster.sum[0][1] = 6.0 + cluster.sum[1][1] = 20.0 + class_field = getattr(cluster, "class") + class_field[0] = 0 + interrupted = c_int(0) + + changes = libcluster.I_cluster_reassign(byref(cluster), byref(interrupted)) + + assert changes == 1 + assert class_field[0] == 1 + assert [cluster.count[c] for c in range(2)] == [1, 3] + assert per_class(cluster.sum, 0, 2) == [-1.0, 7.0] + assert per_class(cluster.sum, 1, 2) == [-9.0, 29.0] + + +def test_sum2_accumulates_the_sum_of_squares_per_class(libgis_without_session) -> None: + """Unlike GASTATS's variance-style statistics, I_cluster_sum2() stores + the plain sum of squares per class; I_cluster_separation() derives + variance from it itself""" + cluster = cluster_with_points(SAMPLE_POINTS, 3) + classify(cluster) + + libcluster.I_cluster_sum2(byref(cluster)) + + assert per_class(cluster.sum2, 0, 3) == [5.0, 9.0, 41.0] + assert per_class(cluster.sum2, 1, 3) == [500.0, 900.0, 4100.0] + + +def test_separation_returns_the_far_sentinel_for_a_tiny_class( + libgis_without_session, +) -> None: + """A class with fewer than 2 points has no variance to compare, so + I_cluster_separation() reports it as maximally separated (-1.0) + rather than dividing by zero""" + cluster = cluster_with_points(SAMPLE_POINTS, 3) + classify(cluster) + libcluster.I_cluster_sum2(byref(cluster)) + + # Class 1 holds only the single point (3, 30), see the assign test. + assert libcluster.I_cluster_separation(byref(cluster), 0, 1) == -1.0 + + +def test_separation_measures_how_distinguishable_two_classes_are( + libgis_without_session, +) -> None: + cluster = cluster_with_points(SAMPLE_POINTS, 3) + classify(cluster) + libcluster.I_cluster_sum2(byref(cluster)) + + # Classes 0 and 2 hold two points each, and the separation sums the + # contribution of both bands. + separation = libcluster.I_cluster_separation(byref(cluster), 0, 2) + + assert separation == pytest.approx(1.2247448713915889) + + +def test_distinct_finds_the_closest_pair_below_the_separation_threshold( + libgis_without_session, +) -> None: + cluster = cluster_with_points(SAMPLE_POINTS, 3) + classify(cluster) + libcluster.I_cluster_sum2(byref(cluster)) + + # A generous threshold finds classes 0 and 2 as the closest pair with + # at least 2 points each (class 1 is skipped: it only has one point). + assert libcluster.I_cluster_distinct(byref(cluster), 100.0) == 0 + assert (cluster.merge1, cluster.merge2) == (0, 2) + + # A threshold tighter than any actual separation finds nothing to merge. + assert libcluster.I_cluster_distinct(byref(cluster), 0.0001) == 1 + + +def test_nclasses_counts_classes_at_or_above_a_minimum_size( + libgis_without_session, +) -> None: + cluster = cluster_with_points(SAMPLE_POINTS, 3) + classify(cluster) + + # Classes 0 and 2 have 2 points each; class 1 has only 1. + assert libcluster.I_cluster_nclasses(byref(cluster), 2) == 2 + + +def test_merge_combines_two_classes(libgis_without_session) -> None: + cluster = cluster_with_points(SAMPLE_POINTS, 3) + classify(cluster) + libcluster.I_cluster_sum2(byref(cluster)) + libcluster.I_cluster_distinct(byref(cluster), 100.0) # sets merge1/merge2 to 0, 2 + + libcluster.I_cluster_merge(byref(cluster)) + + class_field = getattr(cluster, "class") + # Every point that was in class 2 now reports class 0; class 2's count + # and per-band sums move into class 0 and are zeroed at their old slot. + assert [class_field[p] for p in range(cluster.npoints)] == [0, 0, 1, 0, 0] + assert [cluster.count[c] for c in range(3)] == [4, 1, 0] + assert per_class(cluster.sum, 0, 3) == [12.0, 3.0, 0.0] + assert per_class(cluster.sum, 1, 3) == [120.0, 30.0, 0.0] + + +def test_reclass_compacts_out_a_class_below_the_minimum_size( + libgis_without_session, +) -> None: + """I_cluster_reclass() removes classes below minsize and shifts the + remaining ones down to fill the resulting gaps, remapping every + point's class along the way""" + cluster = cluster_with_points([(0.0, 0.0)] * 3, 3) + # Hand-set an empty class 1 between two populated ones, rather than + # reaching this state through classification, to isolate reclass() + # from the rest of the algorithm. + for class_index, (count, band0, band1) in enumerate( + [(5, 50.0, 500.0), (0, 0.0, 0.0), (3, 30.0, 300.0)] + ): + cluster.count[class_index] = count + cluster.sum[0][class_index] = band0 + cluster.sum[1][class_index] = band1 + class_field = getattr(cluster, "class") + class_field[0] = 0 + class_field[1] = 0 + class_field[2] = 2 + + ret = libcluster.I_cluster_reclass(byref(cluster), 1) + + assert ret == 0 + assert cluster.nclasses == 2 + assert [class_field[p] for p in range(3)] == [0, 0, 1] + assert [cluster.count[c] for c in range(2)] == [5, 3] + assert per_class(cluster.sum, 0, 2) == [50.0, 30.0] + assert per_class(cluster.sum, 1, 2) == [500.0, 300.0] + + +def test_reclass_returns_1_when_every_class_already_meets_the_minimum( + libgis_without_session, +) -> None: + cluster = cluster_with_points([(0.0, 0.0)], 2) + cluster.count[0] = 5 + cluster.count[1] = 3 + + assert libcluster.I_cluster_reclass(byref(cluster), 1) == 1 + assert cluster.nclasses == 2 diff --git a/lib/cluster/tests/lib_cluster_exec_ctypes_test.py b/lib/cluster/tests/lib_cluster_exec_ctypes_test.py new file mode 100644 index 00000000000..bdbf79ed125 --- /dev/null +++ b/lib/cluster/tests/lib_cluster_exec_ctypes_test.py @@ -0,0 +1,139 @@ +"""Tests for the full clustering workflow ctypes binding + +I_cluster_exec() drives the whole algorithm end to end (initial means, +assignment, iterative re-assignment, merging non-distinct classes, and +finally building the output signatures). It calls G_debug() internally, +so it needs the same libgis_without_session fixture as +lib_cluster_algorithm_ctypes_test.py; see conftest.py for why. + +The sample data is two-band for the reason given in +lib_cluster_algorithm_ctypes_test.py: a single band cannot tell a +correct per-band loop apart from one that only ever handles band 0. +""" + +from contextlib import contextmanager +from ctypes import CFUNCTYPE, byref, c_double, c_int + +import pytest + +import grass.lib.cluster as libcluster +import grass.lib.gis as libgis + +# Two groups far enough apart that the clustering result is unambiguous, +# with the second band on a different scale from the first. +GROUP_A = [(0.0, 0.0), (1.0, 1.0), (2.0, 2.0), (-1.0, -1.0), (0.5, 0.5)] +GROUP_B = [ + (100.0, 200.0), + (101.0, 201.0), + (99.0, 199.0), + (100.5, 200.5), + (99.5, 199.5), +] + +# The default handler for G_warning() looks up GISBASE to decide where to +# log the message, which is a fatal error when it is not set, and on some +# platforms (observed: glibc, not musl) that kills the process silently +# rather than just losing the message. Routing messages to a handler of +# our own avoids that lookup entirely, following the same +# G_set_error_routine() pattern used in gui/wxpython/vdigit/wxdisplay.py +# and gui/wxpython/nviz/wxnviz.py, and in lib/arraystats' tests. +ERROR_ROUTINE = CFUNCTYPE(libgis.UNCHECKED(c_int), libgis.String, c_int) + + +@contextmanager +def swallow_grass_messages(): + @ERROR_ROUTINE + def handler(message, is_fatal): + return 1 # non-zero: caller (this test) has handled it + + libgis.G_set_error_routine(handler) + try: + yield + finally: + libgis.G_unset_error_routine() + + +def cluster_with_points(points, nbands=2): + cluster = libcluster.Cluster() + libcluster.I_cluster_begin(byref(cluster), nbands) + for values in points: + assert ( + libcluster.I_cluster_point(byref(cluster), (c_double * nbands)(*values)) + == 0 + ) + return cluster + + +def band_mean(points, band): + return sum(point[band] for point in points) / len(points) + + +def test_exec_separates_two_well_separated_groups(libgis_without_session) -> None: + """A checkpoint callback lets a caller (e.g. i.cluster's progress + reporting) observe each phase of the algorithm as it runs; it is + exercised here as well as the final result""" + cluster = cluster_with_points(GROUP_A + GROUP_B) + + phases_seen = [] + checkpoint_type = libcluster.I_cluster_exec.argtypes[6] + + @checkpoint_type + def checkpoint(_cluster_ptr, phase): + phases_seen.append(phase) + return 0 + + interrupted = c_int(0) + ret = libcluster.I_cluster_exec( + byref(cluster), 2, 20, 98.0, 0.5, 2, checkpoint, byref(interrupted) + ) + + assert ret == 0 + assert cluster.nclasses == 2 + class_field = getattr(cluster, "class") + classes = [class_field[p] for p in range(cluster.npoints)] + # The two groups are far enough apart that every point in GROUP_A must + # land in one class and every point in GROUP_B in the other, whichever + # of the two class numbers the algorithm happens to assign to each. + assert len(set(classes[: len(GROUP_A)])) == 1 + assert len(set(classes[len(GROUP_A) :])) == 1 + assert classes[0] != classes[-1] + # Phase 1: initial means. Phase 2: initial assignment. Phase 3: each + # re-assignment pass. Phase 4 (a merge) never runs here, since the two + # groups are already distinct on the first pass. + assert phases_seen == [1, 2, 3] + + # The signature of each class is the per-band mean of its own group. + assert cluster.S.nsigs == 2 + signatures = sorted( + (cluster.S.sig[c].mean[0], cluster.S.sig[c].mean[1]) for c in range(2) + ) + assert signatures == pytest.approx( + [ + (band_mean(GROUP_A, 0), band_mean(GROUP_A, 1)), + (band_mean(GROUP_B, 0), band_mean(GROUP_B, 1)), + ] + ) + for c in range(2): + assert cluster.S.sig[c].npoints == len(GROUP_A) + assert cluster.S.sig[c].status == 1 + + +def test_exec_rejects_too_few_points(libgis_without_session) -> None: + """I_cluster_exec() checks the point count before doing anything else, + so a null checkpoint callback is safe here: it is never reached + + This is the one case in this file that reaches I_cluster_exec()'s + G_warning() call, so it needs swallow_grass_messages(); see that + function's comment for why. + """ + cluster = cluster_with_points([(1.0, 1.0)]) + interrupted = c_int(0) + checkpoint_type = libcluster.I_cluster_exec.argtypes[6] + no_checkpoint = checkpoint_type() # a null function pointer + + with swallow_grass_messages(): + ret = libcluster.I_cluster_exec( + byref(cluster), 2, 20, 98.0, 0.5, 2, no_checkpoint, byref(interrupted) + ) + + assert ret == 1 diff --git a/lib/cluster/tests/lib_cluster_points_ctypes_test.py b/lib/cluster/tests/lib_cluster_points_ctypes_test.py new file mode 100644 index 00000000000..2b5e0dea22a --- /dev/null +++ b/lib/cluster/tests/lib_cluster_points_ctypes_test.py @@ -0,0 +1,110 @@ +"""Tests for the point-collection ctypes bindings + +I_cluster_begin(), I_cluster_point() and the point-set variants only +allocate and fill the Cluster struct's own arrays; none of them call +G_debug(), so unlike most of the clustering algorithm itself they need +nothing from libgis' session environment and no fixture (see +conftest.py and lib_cluster_algorithm_ctypes_test.py). +""" + +from ctypes import byref, c_double + +import grass.lib.cluster as libcluster +import grass.lib.raster as libraster + + +def new_cluster(nbands): + """A freshly initialized Cluster struct for the given band count""" + cluster = libcluster.Cluster() + assert libcluster.I_cluster_begin(byref(cluster), nbands) == 0 + return cluster + + +def point(*values): + """Build a ctypes double array from per-band values""" + return (c_double * len(values))(*values) + + +def null_point(nbands): + """A point whose bands are all set to the DCELL NULL value""" + values = point(*([0.0] * nbands)) + libraster.Rast_set_d_null_value(values, nbands) + return values + + +def test_begin_rejects_a_non_positive_band_count() -> None: + cluster = libcluster.Cluster() + assert libcluster.I_cluster_begin(byref(cluster), 0) == 1 + + +def test_point_accumulates_values_and_running_sums() -> None: + cluster = new_cluster(2) + + assert libcluster.I_cluster_point(byref(cluster), point(1.0, 2.0)) == 0 + assert libcluster.I_cluster_point(byref(cluster), point(3.0, 4.0)) == 0 + + assert cluster.npoints == 2 + assert [cluster.points[0][p] for p in range(2)] == [1.0, 3.0] + assert [cluster.points[1][p] for p in range(2)] == [2.0, 4.0] + # band_sum/band_sum2 are running totals kept for I_cluster_means(), not + # recomputed from the points array, so they are checked here too. + assert [cluster.band_sum[b] for b in range(2)] == [4.0, 6.0] + assert [cluster.band_sum2[b] for b in range(2)] == [10.0, 20.0] + + +def test_point_rejects_a_point_with_a_null_band() -> None: + """A point with any band set to NULL is rejected outright, not just + skipped in that one band, since a partial pixel cannot be clustered""" + cluster = new_cluster(2) + + assert libcluster.I_cluster_point(byref(cluster), null_point(2)) == 1 + assert cluster.npoints == 0 + + +def test_point_set_batch_workflow_drops_all_zero_points() -> None: + """I_cluster_begin_point_set()/I_cluster_point_part()/ + I_cluster_end_point_set() is the batch equivalent of repeated + I_cluster_point() calls, used when bands are filled one at a time + rather than one point at a time. A point whose every band is exactly + zero (as opposed to NULL) is silently dropped, matching the "point + contains no data" convention used elsewhere in this library. + """ + cluster = new_cluster(2) + values = [(1.0, 10.0), (0.0, 0.0), (2.0, 20.0)] + + assert libcluster.I_cluster_begin_point_set(byref(cluster), len(values)) == 0 + for n, bands in enumerate(values): + for band, value in enumerate(bands): + assert ( + libcluster.I_cluster_point_part( + byref(cluster), c_double(value), band, n + ) + == 0 + ) + kept = libcluster.I_cluster_end_point_set(byref(cluster), len(values)) + + assert kept == 2 + assert cluster.npoints == 2 + assert [cluster.points[0][p] for p in range(2)] == [1.0, 2.0] + assert [cluster.points[1][p] for p in range(2)] == [10.0, 20.0] + + +def test_point_part_rejects_a_null_value() -> None: + cluster = new_cluster(1) + libcluster.I_cluster_begin_point_set(byref(cluster), 1) + + null_value = c_double() + libraster.Rast_set_d_null_value(byref(null_value), 1) + assert libcluster.I_cluster_point_part(byref(cluster), null_value, 0, 0) == 1 + + +def test_clear_resets_the_structure() -> None: + cluster = new_cluster(2) + libcluster.I_cluster_point(byref(cluster), point(1.0, 2.0)) + + assert libcluster.I_cluster_clear(byref(cluster)) == 0 + + assert cluster.nbands == 0 + assert not cluster.points + assert not cluster.band_sum + assert not cluster.band_sum2 From 6f43a4398862bcf6a358fd00832b72e2609a4ec2 Mon Sep 17 00:00:00 2001 From: Jal Patel <102137104+jalpatel11@users.noreply.github.com> Date: Thu, 17 Sep 2026 10:26:45 -0700 Subject: [PATCH 4/4] lib/cluster: Cover I_cluster_exec argument defaults and interrupts --- .../tests/lib_cluster_exec_ctypes_test.py | 286 ++++++++++++++++-- 1 file changed, 258 insertions(+), 28 deletions(-) diff --git a/lib/cluster/tests/lib_cluster_exec_ctypes_test.py b/lib/cluster/tests/lib_cluster_exec_ctypes_test.py index bdbf79ed125..9de83ae3f4b 100644 --- a/lib/cluster/tests/lib_cluster_exec_ctypes_test.py +++ b/lib/cluster/tests/lib_cluster_exec_ctypes_test.py @@ -1,10 +1,16 @@ """Tests for the full clustering workflow ctypes binding -I_cluster_exec() drives the whole algorithm end to end (initial means, -assignment, iterative re-assignment, merging non-distinct classes, and -finally building the output signatures). It calls G_debug() internally, -so it needs the same libgis_without_session fixture as -lib_cluster_algorithm_ctypes_test.py; see conftest.py for why. +I_cluster_exec() drives the whole algorithm end to end: initial means, +assignment, iterative re-assignment, merging classes that are not +distinct enough, and finally building the output signatures. It calls +G_debug() internally, so it needs the same libgis_without_session +fixture as lib_cluster_algorithm_ctypes_test.py; see conftest.py for why. + +Before any of that, the function quietly replaces out-of-range arguments +with defaults of its own, and most of the tests below pin those down. +None of the corrected values can be read back off the struct, so each is +checked by the effect it has on the result, alongside a contrasting +in-range value that produces a different one. The sample data is two-band for the reason given in lib_cluster_algorithm_ctypes_test.py: a single band cannot tell a @@ -30,6 +36,43 @@ (99.5, 199.5), ] +# One tight group plus a single distant point. The outlier ends up alone +# in a class of its own, which is what the final minimum-size pass then +# has to decide about. +GROUP_WITH_OUTLIER = [ + (0.0, 0.0), + (1.0, 1.0), + (2.0, 2.0), + (3.0, 3.0), + (4.0, 4.0), + (100.0, 100.0), +] + +# A continuum rather than distinct groups. Where the class boundaries +# fall is arbitrary enough that the first re-assignment pass still moves +# a point, leaving the run short of a 98% stable result on that pass. +EVENLY_SPACED = [(float(i), float(i) * 2.0) for i in range(12)] + +# Eight of the nine points of a 3x3 grid. Classes carved out of it +# overlap heavily, so the separation between them comes to about 0.35: +# below the 0.5 default, but above a smaller threshold passed explicitly. +UNIT_GRID = [ + (0.0, 0.0), + (1.0, 0.0), + (2.0, 0.0), + (0.0, 1.0), + (1.0, 1.0), + (2.0, 1.0), + (0.0, 2.0), + (1.0, 2.0), +] + +# I_cluster_exec() reports progress through this callback, which is also +# the only way to reach into the middle of a run. Phase 1 is the initial +# means, phase 2 the initial assignment, phase 3 each re-assignment pass, +# and phase 4 a merge of two classes that were not distinct enough. +CHECKPOINT = libcluster.I_cluster_exec.argtypes[6] + # The default handler for G_warning() looks up GISBASE to decide where to # log the message, which is a fatal error when it is not set, and on some # platforms (observed: glibc, not musl) that kills the process silently @@ -64,43 +107,80 @@ def cluster_with_points(points, nbands=2): return cluster -def band_mean(points, band): - return sum(point[band] for point in points) / len(points) +def exec_with( + points, + maxclass=2, + iterations=20, + convergence=98.0, + separation=0.5, + min_class_size=2, + interrupt_at_phase=None, +): + """Cluster points with I_cluster_exec(), recording the phases it reports + Every parameter defaults to an in-range value, so a test only has to + name the one it is about. Passing interrupt_at_phase raises the + interrupt flag from inside the checkpoint callback, which is the only + point at which a caller could realistically raise it mid-run. -def test_exec_separates_two_well_separated_groups(libgis_without_session) -> None: - """A checkpoint callback lets a caller (e.g. i.cluster's progress - reporting) observe each phase of the algorithm as it runs; it is - exercised here as well as the final result""" - cluster = cluster_with_points(GROUP_A + GROUP_B) - - phases_seen = [] - checkpoint_type = libcluster.I_cluster_exec.argtypes[6] + Returns the return value, the Cluster struct and the phases seen. + """ + cluster = cluster_with_points(points) + phases = [] + interrupted = c_int(0) - @checkpoint_type - def checkpoint(_cluster_ptr, phase): - phases_seen.append(phase) + @CHECKPOINT + def checkpoint(_cluster, phase): + phases.append(phase) + if phase == interrupt_at_phase: + interrupted.value = 1 return 0 - interrupted = c_int(0) ret = libcluster.I_cluster_exec( - byref(cluster), 2, 20, 98.0, 0.5, 2, checkpoint, byref(interrupted) + byref(cluster), + maxclass, + iterations, + convergence, + separation, + min_class_size, + checkpoint, + byref(interrupted), ) + return ret, cluster, phases + + +def classes_of(cluster): + """Each point's class, with -1 for points whose class was eliminated""" + class_field = getattr(cluster, "class") + return [class_field[p] for p in range(cluster.npoints)] + + +def counts_of(cluster): + return [cluster.count[c] for c in range(cluster.nclasses)] + + +def band_mean(points, band): + return sum(point[band] for point in points) / len(points) + + +def test_exec_separates_two_well_separated_groups(libgis_without_session) -> None: + """The checkpoint callback lets a caller (e.g. i.cluster's progress + reporting) observe each phase as it runs; it is exercised here as + well as the final result""" + ret, cluster, phases = exec_with(GROUP_A + GROUP_B) assert ret == 0 assert cluster.nclasses == 2 - class_field = getattr(cluster, "class") - classes = [class_field[p] for p in range(cluster.npoints)] + classes = classes_of(cluster) # The two groups are far enough apart that every point in GROUP_A must # land in one class and every point in GROUP_B in the other, whichever # of the two class numbers the algorithm happens to assign to each. assert len(set(classes[: len(GROUP_A)])) == 1 assert len(set(classes[len(GROUP_A) :])) == 1 assert classes[0] != classes[-1] - # Phase 1: initial means. Phase 2: initial assignment. Phase 3: each - # re-assignment pass. Phase 4 (a merge) never runs here, since the two - # groups are already distinct on the first pass. - assert phases_seen == [1, 2, 3] + # No phase 4, since the two groups are already distinct on the first + # pass and so nothing is ever merged. + assert phases == [1, 2, 3] # The signature of each class is the per-band mean of its own group. assert cluster.S.nsigs == 2 @@ -128,8 +208,7 @@ def test_exec_rejects_too_few_points(libgis_without_session) -> None: """ cluster = cluster_with_points([(1.0, 1.0)]) interrupted = c_int(0) - checkpoint_type = libcluster.I_cluster_exec.argtypes[6] - no_checkpoint = checkpoint_type() # a null function pointer + no_checkpoint = CHECKPOINT() # a null function pointer with swallow_grass_messages(): ret = libcluster.I_cluster_exec( @@ -137,3 +216,154 @@ def test_exec_rejects_too_few_points(libgis_without_session) -> None: ) assert ret == 1 + + +def test_exec_turns_a_negative_maxclass_into_a_single_class( + libgis_without_session, +) -> None: + """A negative class count becomes 1 rather than being rejected, so + the two groups come back merged into one class""" + ret, cluster, _ = exec_with(GROUP_A + GROUP_B, maxclass=-1) + + assert ret == 0 + assert cluster.nclasses == 1 + assert classes_of(cluster) == [0] * len(GROUP_A + GROUP_B) + assert counts_of(cluster) == [len(GROUP_A + GROUP_B)] + assert cluster.S.nsigs == 1 + + +def test_exec_raises_a_non_positive_min_class_size_to_seventeen( + libgis_without_session, +) -> None: + """A minimum class size of 0 is replaced by 17, not treated as "no + minimum" + + Both groups here hold five points, so the substituted minimum wipes + out every class and leaves no signatures at all, while a minimum of 5 + keeps both. That difference is the only way to see which value was + actually used, since the corrected one is never stored. + """ + ret, cluster, _ = exec_with(GROUP_A + GROUP_B, min_class_size=0) + + assert ret == 0 + assert cluster.nclasses == 0 + assert classes_of(cluster) == [-1] * len(GROUP_A + GROUP_B) + assert cluster.S.nsigs == 0 + + _, kept, _ = exec_with(GROUP_A + GROUP_B, min_class_size=5) + assert kept.nclasses == 2 + assert counts_of(kept) == [5, 5] + + +def test_exec_raises_a_min_class_size_of_one_to_two(libgis_without_session) -> None: + """A minimum class size of 1 is raised to 2, so a class holding a + single point is still discarded + + Taken literally, a minimum of 1 would keep the outlier's one-point + class; it is dropped instead, which is what shows the value was + raised. + """ + ret, cluster, _ = exec_with(GROUP_WITH_OUTLIER, min_class_size=1) + + assert ret == 0 + assert cluster.nclasses == 1 + assert counts_of(cluster) == [5] + assert classes_of(cluster) == [0, 0, 0, 0, 0, -1] + + +def test_exec_replaces_non_positive_iterations_with_twenty( + libgis_without_session, +) -> None: + """Asking for no iterations runs twenty of them rather than none + + The convergence threshold is set beyond 100% so that it can never be + met, which leaves the iteration limit as the only thing that can end + the loop and makes the substituted value countable. + """ + ret, cluster, phases = exec_with(GROUP_A + GROUP_B, iterations=0, convergence=200.0) + + assert ret == 0 + assert phases.count(3) == 20 + assert cluster.iteration == 20 + + _, capped, capped_phases = exec_with( + GROUP_A + GROUP_B, iterations=3, convergence=200.0 + ) + assert capped_phases.count(3) == 3 + assert capped.iteration == 3 + + +def test_exec_replaces_a_non_positive_convergence_with_ninety_eight( + libgis_without_session, +) -> None: + """A convergence of 0 becomes 98%, so the run keeps iterating rather + than stopping as soon as any point at all is stable + + The first re-assignment pass over this data leaves it short of 98% + stable, so the substituted threshold forces a second pass, while a + threshold the first pass already clears stops after one. + """ + ret, cluster, phases = exec_with(EVENLY_SPACED, maxclass=4, convergence=0.0) + + assert ret == 0 + assert phases.count(3) == 2 + assert cluster.iteration == 2 + + _, explicit, explicit_phases = exec_with( + EVENLY_SPACED, maxclass=4, convergence=98.0 + ) + assert explicit_phases.count(3) == 2 + assert explicit.iteration == 2 + + _, lenient, lenient_phases = exec_with(EVENLY_SPACED, maxclass=4, convergence=50.0) + assert lenient_phases.count(3) == 1 + assert lenient.iteration == 1 + + +def test_exec_replaces_a_negative_separation_with_half( + libgis_without_session, +) -> None: + """A negative separation becomes 0.5, which is enough to merge the + two classes this grid is split into + + The merge is also the only case in this file that reaches phase 4. + The two classes are about 0.35 apart, so the substituted 0.5 merges + them exactly as an explicit 0.5 does, while a smaller threshold + passed on purpose leaves them alone. + """ + ret, cluster, phases = exec_with(UNIT_GRID, separation=-1.0) + + assert ret == 0 + assert phases == [1, 2, 3, 4, 3] + assert cluster.nclasses == 1 + + _, explicit, explicit_phases = exec_with(UNIT_GRID, separation=0.5) + assert explicit_phases == [1, 2, 3, 4, 3] + assert explicit.nclasses == 1 + + _, distinct, distinct_phases = exec_with(UNIT_GRID, separation=0.1) + assert distinct_phases == [1, 2, 3] + assert distinct.nclasses == 2 + + +def test_exec_reports_an_interrupt_during_the_initial_assignment( + libgis_without_session, +) -> None: + """Raising the flag from the phase 1 checkpoint stops the run inside + I_cluster_assign(), before any point has been assigned""" + ret, _, phases = exec_with(GROUP_A + GROUP_B, interrupt_at_phase=1) + + assert ret == -2 + assert phases == [1] + + +def test_exec_reports_an_interrupt_before_the_reassignment_loop( + libgis_without_session, +) -> None: + """Raising the flag from the phase 2 checkpoint stops the run at the + top of the re-assignment loop, which is a separate check from the one + the initial assignment goes through""" + ret, _, phases = exec_with(GROUP_A + GROUP_B, interrupt_at_phase=2) + + assert ret == -2 + assert phases == [1, 2]